Merge pull request #615 from Open-Less/refactor/srp-split-dedup

refactor: coordinator.rs/commands.rs 单一职责拆分 + 重复函数合并(机械式·零回归)
This commit is contained in:
TRIP authored and GitHub committed 2026-06-08 13:26:09 +08:00
commit 5da5445a10
42 files changed
+10008 -9767

No files matched your search

@@ -6,11 +6,13 @@ function assertMatch(source, pattern, name) {
}
}
// show_capsule_window_no_activate 已随 capsule/IME 窗口插入管线拆到
// coordinator/ime_insertion.rs(SRP 拆分),契约函数本身未改。
const coordinatorRs = (
await readFile(new URL('../src-tauri/src/coordinator.rs', import.meta.url), 'utf-8')
await readFile(new URL('../src-tauri/src/coordinator/ime_insertion.rs', import.meta.url), 'utf-8')
).replace(/\r\n/g, '\n');
const functionMatch = coordinatorRs.match(
/#\[cfg\(target_os = "macos"\)\]\s*fn show_capsule_window_no_activate[\s\S]*?\n}\n\n#\[cfg\(target_os = "linux"\)\]/,
/#\[cfg\(target_os = "macos"\)\]\s*(?:pub\(crate\) )?fn show_capsule_window_no_activate[\s\S]*?\n}\n\n#\[cfg\(target_os = "linux"\)\]/,
);
if (!functionMatch) {
@@ -148,7 +148,7 @@ impl crate::recorder::AudioConsumer for FoundryLocalWhisperAsr {
}
fn pcm_duration_ms(pcm: &[u8]) -> u64 {
(pcm.len() as u64 / 2) * 1000 / 16_000
crate::asr::pcm::pcm_duration_ms(pcm)
}
fn pcm_to_wav(pcm: &[u8]) -> Vec<u8> {
@@ -222,11 +222,11 @@ impl crate::recorder::AudioConsumer for SherpaOnnxAsr {
}
fn pcm_duration_ms(pcm: &[u8]) -> u64 {
pcm_duration_ms_from_bytes(pcm.len() as u64)
crate::asr::pcm::pcm_duration_ms(pcm)
}
fn pcm_duration_ms_from_bytes(bytes: u64) -> u64 {
(bytes / 2) * 1000 / 16_000
crate::asr::pcm::pcm_duration_ms_from_bytes(bytes)
}
fn trim_transcript_text(text: &str) -> String {
@@ -485,7 +485,7 @@ fn accumulate_dir_size(dir: &Path, bytes: &mut u64) {
}
fn pcm_duration_ms(pcm: &[u8]) -> u64 {
(pcm.len() as u64 / 2) * 1000 / 16_000
crate::asr::pcm::pcm_duration_ms(pcm)
}
enum LoadedModel {
+1 -1
View File
@@ -178,7 +178,7 @@ pub fn extract_mimo_text(json: &Value) -> String {
}
fn pcm_duration_ms(pcm: &[u8]) -> u64 {
(pcm.len() as u64 / PCM_BYTES_PER_SAMPLE as u64) * 1000 / PCM_SAMPLE_RATE_HZ
super::pcm::pcm_duration_ms(pcm)
}
fn split_pcm_by_duration(pcm: &[u8], max_chunk_duration_ms: u64) -> Vec<&[u8]> {
@@ -9,6 +9,7 @@ pub mod bailian;
mod frame;
pub mod local;
pub mod mimo;
pub mod pcm;
pub mod volcengine;
pub mod wav;
pub mod whisper;
+41
View File
@@ -0,0 +1,41 @@
//! 共享 PCM 时长计算。
//!
//! 录音统一是 16 kHz / 单声道 / 16-bit 小端 PCM,时长换算 `(字节数 / 2) * 1000 / 16000`
//! 原本散落在各 ASR provider 里重复(foundry / sherpa / whisper / mimo),这里收口成
//! 唯一实现,各处改为薄封装调用(参考 `wav::encode_wav_16k_mono` 的共享先例)。
/// 每个采样的字节数(16-bit → 2 字节)。
const PCM_BYTES_PER_SAMPLE: u64 = 2;
/// 采样率(16 kHz)。
const PCM_SAMPLE_RATE_HZ: u64 = 16_000;
/// 由原始字节数计算 16 kHz / 单声道 / 16-bit PCM 的时长(毫秒)。
pub fn pcm_duration_ms_from_bytes(bytes: u64) -> u64 {
(bytes / PCM_BYTES_PER_SAMPLE) * 1000 / PCM_SAMPLE_RATE_HZ
}
/// 由 PCM 字节切片计算 16 kHz / 单声道 / 16-bit PCM 的时长(毫秒)。
pub fn pcm_duration_ms(pcm: &[u8]) -> u64 {
pcm_duration_ms_from_bytes(pcm.len() as u64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_second_of_16k_i16_pcm_is_1000ms() {
// 16000 采样 × 2 字节 = 32000 字节 = 1 秒
assert_eq!(pcm_duration_ms(&vec![0u8; 32_000]), 1000);
assert_eq!(pcm_duration_ms_from_bytes(32_000), 1000);
}
#[test]
fn odd_trailing_byte_is_floored() {
// 末尾半个采样向下取整,与历史行为一致
assert_eq!(
pcm_duration_ms(&vec![0u8; 33]),
pcm_duration_ms(&vec![0u8; 32])
);
}
}
@@ -285,7 +285,7 @@ fn extract_confident_text(json: &serde_json::Value) -> String {
}
fn pcm_duration_ms(pcm: &[u8]) -> u64 {
(pcm.len() as u64 / PCM_BYTES_PER_SAMPLE as u64) * 1000 / PCM_SAMPLE_RATE_HZ
super::pcm::pcm_duration_ms(pcm)
}
fn split_pcm_by_duration(pcm: &[u8], max_chunk_duration_ms: Option<u64>) -> Vec<&[u8]> {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,227 @@
use super::*;
#[tauri::command]
pub fn get_credentials() -> CredentialsStatus {
let snap = CredentialsVault::snapshot();
let active_asr_provider = CredentialsVault::get_active_asr();
let active_llm_provider = CredentialsVault::get_active_llm();
let volcengine_configured = volcengine_configured(&snap);
let asr_configured = asr_configured_for_provider(&active_asr_provider, &snap);
let llm_configured = llm_configured_for_provider(&active_llm_provider, &snap);
CredentialsStatus {
active_asr_provider,
active_llm_provider,
asr_configured,
llm_configured,
volcengine_configured,
ark_configured: llm_configured,
}
}
fn volcengine_configured(snap: &CredentialsSnapshot) -> bool {
configured(&snap.volcengine_app_key)
&& configured(&snap.volcengine_access_key)
&& configured(&snap.volcengine_resource_id)
}
pub(crate) fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bool {
if provider == "volcengine" {
return volcengine_configured(snap);
}
if provider == crate::asr::local::PROVIDER_ID
|| active_foundry_asr_is_supported(provider)
|| active_sherpa_asr_is_supported(provider)
{
// 本地 ASR 不依赖云端凭据。
return true;
}
if provider == crate::asr::bailian::PROVIDER_ID {
return configured(&snap.asr_api_key);
}
if provider == crate::asr::mimo::PROVIDER_ID {
return configured(&snap.asr_api_key)
&& configured(&snap.asr_endpoint)
&& configured(&snap.asr_model);
}
configured(&snap.asr_endpoint) && configured(&snap.asr_model)
}
pub(crate) fn llm_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bool {
if provider == CODEX_OAUTH_PROVIDER_ID {
return CodexOAuthCredentials::load_default().is_ok();
}
let endpoint = snap.ark_endpoint.as_deref().unwrap_or_default();
let endpoint_and_model = configured(&snap.ark_endpoint) && configured(&snap.ark_model_id);
if endpoint_and_model
&& llm_provider_default_endpoint(provider)
.map(|default| same_llm_endpoint(endpoint, default))
.unwrap_or(false)
{
return configured(&snap.ark_api_key);
}
endpoint_and_model
}
fn llm_provider_default_endpoint(provider: &str) -> Option<&'static str> {
match provider {
"ark" => Some("https://ark.cn-beijing.volces.com/api/v3"),
"deepseek" => Some("https://api.deepseek.com/v1"),
"siliconflow" => Some("https://api.siliconflow.cn/v1"),
"openai" => Some("https://api.openai.com/v1"),
// 谷歌 Gemini 原生 API(v1beta)。后端 llm_gemini.rs 会拼成
// `{baseUrl}/models/{model}:generateContent`,认证用 x-goog-api-key 头。
"gemini" => Some("https://generativelanguage.googleapis.com/v1beta"),
"mimo" => Some("https://api.xiaomimimo.com/v1"),
"cometapi" => Some("https://api.cometapi.com/v1"),
"openrouterFree" => Some("https://openrouter.ai/api/v1"),
"alibabaCoding" => Some("https://coding-intl.dashscope.aliyuncs.com/v1"),
"codingPlanX" => Some("https://api.codingplanx.ai/v1"),
_ => None,
}
}
fn same_llm_endpoint(a: &str, b: &str) -> bool {
fn normalize(value: &str) -> &str {
value
.trim()
.trim_end_matches('/')
.trim_end_matches("/chat/completions")
.trim_end_matches('/')
}
normalize(a).eq_ignore_ascii_case(normalize(b))
}
fn configured(field: &Option<String>) -> bool {
field
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct LocalAsrReleasePlan {
pub(crate) qwen: bool,
pub(crate) foundry: bool,
pub(crate) sherpa: bool,
}
pub(crate) fn local_asr_release_plan_for_provider(provider: &str) -> LocalAsrReleasePlan {
LocalAsrReleasePlan {
qwen: provider != crate::asr::local::PROVIDER_ID,
foundry: provider != FOUNDRY_LOCAL_PROVIDER_ID,
sherpa: provider != crate::asr::local::sherpa::PROVIDER_ID,
}
}
pub(crate) async fn release_foundry_runtime_if_inactive(
runtime: &Arc<FoundryLocalRuntime>,
release_foundry: bool,
) {
if release_foundry {
runtime.request_cancel_prepare();
if let Err(error) = runtime.release_now().await {
log::warn!("[foundry-asr] release inactive runtime failed: {error:#}");
}
}
}
pub(crate) async fn release_sherpa_runtime_if_inactive(
runtime: &Arc<SherpaOnnxRuntime>,
release_sherpa: bool,
) {
if release_sherpa {
runtime.request_cancel_prepare();
if let Err(error) = runtime.release_now().await {
log::warn!("[sherpa-asr] release inactive runtime failed: {error:#}");
}
}
}
#[tauri::command]
pub fn set_credential(window: Window, account: String, value: String) -> Result<(), String> {
ensure_main_window(&window)?;
let acc = parse_account(&account)?;
if value.is_empty() {
CredentialsVault::remove(acc).map_err(|e| e.to_string())?;
} else {
CredentialsVault::set(acc, &value).map_err(|e| e.to_string())?;
}
// 通知前端凭据已变更(如 Overview 页需要刷新 asrConfigured 状态)。
// issue #532 / #573:在 Settings 填写凭据但不切换提供商时,Overview 不会重拉状态,
// 仍显示「未配置」。该修复曾随 #538 合入 main,但被 beta→main 合并覆盖,beta 上缺失。
let _ = window.emit("credentials:changed", ());
Ok(())
}
#[tauri::command]
pub async fn set_active_asr_provider(
coord: CoordinatorState<'_>,
runtime: State<'_, Arc<FoundryLocalRuntime>>,
sherpa_runtime: State<'_, Arc<SherpaOnnxRuntime>>,
provider: String,
) -> Result<(), String> {
if provider == FOUNDRY_LOCAL_PROVIDER_ID && !active_foundry_asr_is_supported(&provider) {
return Err("Foundry Local Whisper is only available on Windows".to_string());
}
if provider == crate::asr::local::sherpa::PROVIDER_ID
&& !active_sherpa_asr_is_supported(&provider)
{
return Err("sherpa-onnx local ASR is only available on Windows".to_string());
}
if CredentialsVault::get_active_asr() == provider {
return Ok(());
}
CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())?;
let release_plan = local_asr_release_plan_for_provider(&provider);
if provider == crate::asr::local::PROVIDER_ID {
// 切到本地 ASR → 后台预加载模型,下次按 hotkey 时不必等数秒。
coord.preload_local_asr_in_background();
}
if release_plan.qwen {
// 切回云端 → 用户已不需要本地引擎,立刻释放 1.2GB+ RAM;不释放的话只会等到
// schedule_local_asr_release 的下一次 dictation 才触发,而切回云端后根本不会
// 再走 local 路径,引擎会驻留到进程退出。
coord.release_local_asr_engine();
}
release_foundry_runtime_if_inactive(runtime.inner(), release_plan.foundry).await;
release_sherpa_runtime_if_inactive(sherpa_runtime.inner(), release_plan.sherpa).await;
Ok(())
}
#[tauri::command]
pub fn set_active_llm_provider(provider: String) -> Result<(), String> {
CredentialsVault::set_active_llm_provider(&provider).map_err(|e| e.to_string())
}
/// 读出某个账号的实际值(用于设置页预填表单)。
/// 凭据来自系统凭据库;只允许主设置窗口读取 raw secret,避免胶囊 / QA 等辅助窗口默认暴露。
#[tauri::command]
pub fn read_credential(window: Window, account: String) -> Result<Option<String>, String> {
ensure_main_window(&window)?;
let acc = parse_account(&account)?;
CredentialsVault::get(acc).map_err(|e| e.to_string())
}
fn ensure_main_window(window: &Window) -> Result<(), String> {
if window.label() == "main" {
Ok(())
} else {
Err("credential access is only allowed from the main window".to_string())
}
}
fn parse_account(s: &str) -> Result<CredentialAccount, String> {
match s {
"volcengine.app_key" => Ok(CredentialAccount::VolcengineAppKey),
"volcengine.access_key" => Ok(CredentialAccount::VolcengineAccessKey),
"volcengine.resource_id" => Ok(CredentialAccount::VolcengineResourceId),
"ark.api_key" => Ok(CredentialAccount::ArkApiKey),
"ark.model_id" => Ok(CredentialAccount::ArkModelId),
"ark.endpoint" => Ok(CredentialAccount::ArkEndpoint),
"asr.api_key" => Ok(CredentialAccount::AsrApiKey),
"asr.endpoint" => Ok(CredentialAccount::AsrEndpoint),
"asr.model" => Ok(CredentialAccount::AsrModel),
"asr.vocabulary_id" => Ok(CredentialAccount::AsrVocabularyId),
_ => Err(format!("unknown account: {s}")),
}
}
@@ -0,0 +1,49 @@
use super::*;
#[tauri::command]
pub async fn start_dictation(coord: CoordinatorState<'_>) -> Result<(), String> {
coord.start_dictation().await
}
#[tauri::command]
pub async fn stop_dictation(coord: CoordinatorState<'_>) -> Result<(), String> {
coord.stop_dictation().await
}
#[tauri::command]
pub fn cancel_dictation(coord: CoordinatorState<'_>) {
coord.cancel_dictation();
}
#[tauri::command]
pub async fn handle_window_hotkey_event(
coord: CoordinatorState<'_>,
event_type: String,
key: String,
code: String,
repeat: bool,
) -> Result<(), String> {
coord
.handle_window_hotkey_event(event_type, key, code, repeat)
.await
}
#[cfg(debug_assertions)]
#[tauri::command]
pub async fn inject_hotkey_click_for_dev(coord: CoordinatorState<'_>) -> Result<(), String> {
coord.inject_hotkey_click_for_dev().await
}
#[tauri::command]
pub async fn repolish(
coord: CoordinatorState<'_>,
raw_text: String,
mode: PolishMode,
) -> Result<String, String> {
log::info!(
"[style-pack] command repolish requested legacy_mode={:?} raw_chars={}",
mode,
raw_text.chars().count()
);
coord.repolish(raw_text, mode).await
}
@@ -0,0 +1,79 @@
use super::*;
#[tauri::command]
pub fn list_vocab(coord: CoordinatorState<'_>) -> Result<Vec<DictionaryEntry>, String> {
coord.vocab().list().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn add_vocab(
coord: CoordinatorState<'_>,
phrase: String,
note: Option<String>,
) -> Result<DictionaryEntry, String> {
coord.vocab().add(phrase, note).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn remove_vocab(coord: CoordinatorState<'_>, id: String) -> Result<(), String> {
coord.vocab().remove(&id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_vocab_enabled(
coord: CoordinatorState<'_>,
id: String,
enabled: bool,
) -> Result<(), String> {
coord
.vocab()
.set_enabled(&id, enabled)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_correction_rules(coord: CoordinatorState<'_>) -> Result<Vec<CorrectionRule>, String> {
coord.correction_rules().list().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn add_correction_rule(
coord: CoordinatorState<'_>,
pattern: String,
replacement: String,
) -> Result<CorrectionRule, String> {
coord
.correction_rules()
.add(pattern, replacement)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn remove_correction_rule(coord: CoordinatorState<'_>, id: String) -> Result<(), String> {
coord
.correction_rules()
.remove(&id)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_correction_rule_enabled(
coord: CoordinatorState<'_>,
id: String,
enabled: bool,
) -> Result<(), String> {
coord
.correction_rules()
.set_enabled(&id, enabled)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_vocab_presets() -> Result<VocabPresetStore, String> {
crate::persistence::list_vocab_presets().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn save_vocab_presets(store: VocabPresetStore) -> Result<(), String> {
crate::persistence::save_vocab_presets(&store).map_err(|e| e.to_string())
}
@@ -0,0 +1,190 @@
use super::*;
pub(crate) fn active_foundry_model_from_prefs(prefs: &UserPreferences) -> String {
if model_alias_is_known(&prefs.foundry_local_asr_model) {
prefs.foundry_local_asr_model.clone()
} else {
DEFAULT_MODEL_ALIAS.to_string()
}
}
pub(crate) fn validate_foundry_model_alias(model_alias: &str) -> Result<(), String> {
if model_alias_is_known(model_alias) {
Ok(())
} else {
Err(format!(
"unknown Foundry Whisper model alias: {model_alias}"
))
}
}
pub(crate) fn normalize_foundry_language_hint(language_hint: &str) -> Result<String, String> {
let normalized = language_hint.trim().to_string();
if normalized.is_empty()
|| (normalized.len() == 2 && normalized.bytes().all(|b| b.is_ascii_lowercase()))
{
Ok(normalized)
} else {
Err("language hint must be empty or ISO 639-1 lowercase code".to_string())
}
}
fn normalize_foundry_runtime_source(source: &str) -> String {
crate::asr::local::foundry_native::normalize_runtime_source_str(source)
}
#[tauri::command]
pub async fn foundry_local_asr_status(
coord: CoordinatorState<'_>,
runtime: State<'_, Arc<FoundryLocalRuntime>>,
) -> Result<FoundryRuntimeStatus, String> {
let prefs = coord.prefs().get();
let active_model = active_foundry_model_from_prefs(&prefs);
Ok(runtime
.status_snapshot(&active_model, &prefs.foundry_local_runtime_source)
.await)
}
#[tauri::command]
pub async fn foundry_local_asr_catalog(
runtime: State<'_, Arc<FoundryLocalRuntime>>,
) -> Result<Vec<FoundryCatalogModel>, String> {
runtime
.catalog_snapshot()
.await
.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub fn foundry_local_asr_set_model(
coord: CoordinatorState<'_>,
model_alias: String,
) -> Result<(), String> {
validate_foundry_model_alias(&model_alias)?;
let mut prefs = coord.prefs().get();
if prefs.foundry_local_asr_model == model_alias {
return Ok(());
}
prefs.foundry_local_asr_model = model_alias;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn foundry_local_asr_set_language_hint(
coord: CoordinatorState<'_>,
language_hint: String,
) -> Result<(), String> {
let normalized = normalize_foundry_language_hint(&language_hint)?;
let mut prefs = coord.prefs().get();
if prefs.foundry_local_asr_language_hint == normalized {
return Ok(());
}
prefs.foundry_local_asr_language_hint = normalized;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn foundry_local_asr_set_runtime_source(
coord: CoordinatorState<'_>,
source: String,
) -> Result<(), String> {
let mut prefs = coord.prefs().get();
let normalized = normalize_foundry_runtime_source(&source);
if prefs.foundry_local_runtime_source == normalized {
return Ok(());
}
prefs.foundry_local_runtime_source = normalized;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn foundry_local_asr_prepare(
app: AppHandle,
coord: CoordinatorState<'_>,
runtime: State<'_, Arc<FoundryLocalRuntime>>,
model_alias: String,
) -> Result<String, String> {
validate_foundry_model_alias(&model_alias)?;
let prefs = coord.prefs().get();
let runtime_source = prefs.foundry_local_runtime_source.clone();
let progress_app = app.clone();
let result = runtime
.ensure_loaded_with_progress(&model_alias, &runtime_source, move |payload| {
emit_foundry_prepare_progress(&progress_app, payload);
})
.await;
match result {
Ok(model_id) => Ok(model_id),
Err(error) => {
let message = format!("{error:#}");
emit_foundry_prepare_progress(
&app,
FoundryPrepareProgressPayload::failed(
model_alias,
"Foundry Local Whisper prepare failed",
message.clone(),
),
);
Err(message)
}
}
}
#[tauri::command]
pub fn foundry_local_asr_cancel_prepare(
runtime: State<'_, Arc<FoundryLocalRuntime>>,
) -> Result<(), String> {
runtime.request_cancel_prepare();
Ok(())
}
#[tauri::command]
pub async fn foundry_local_asr_release(
runtime: State<'_, Arc<FoundryLocalRuntime>>,
) -> Result<(), String> {
runtime.release_now().await.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub async fn foundry_local_asr_model_dir(
runtime: State<'_, Arc<FoundryLocalRuntime>>,
model_alias: String,
) -> Result<String, String> {
validate_foundry_model_alias(&model_alias)?;
runtime
.model_dir_for_alias(&model_alias)
.await
.map(|path| path.display().to_string())
.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub async fn foundry_local_asr_delete_model(
runtime: State<'_, Arc<FoundryLocalRuntime>>,
model_alias: String,
) -> Result<(), String> {
validate_foundry_model_alias(&model_alias)?;
runtime
.delete_model(&model_alias)
.await
.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub async fn foundry_local_asr_reveal_model_dir(
runtime: State<'_, Arc<FoundryLocalRuntime>>,
model_alias: String,
) -> Result<(), String> {
validate_foundry_model_alias(&model_alias)?;
let dir = runtime
.model_dir_for_alias(&model_alias)
.await
.map_err(|e| format!("{e:#}"))?;
open_path_in_file_manager(&dir)
}
fn emit_foundry_prepare_progress(app: &AppHandle, payload: FoundryPrepareProgressPayload) {
if let Err(error) = app.emit("foundry-local-asr-prepare-progress", payload) {
log::warn!("[foundry-asr] emit prepare progress failed: {error}");
}
}
@@ -0,0 +1,154 @@
use super::*;
// ─────────────────────── GitHub OAuth Device Flow (Phase 1) ───────────────────────
//
// 客户端直连 GitHub 拿 access_token + login,前端自动把 login 写进
// prefs.marketplaceDevLogin。marketplace backend 完全不动(依然 X-Dev-User)。
// Phase 2 才会让 backend 验证 GitHub identity(JWT 签发 + 防伪造)。
//
// 配置 client_id 的两种方式(OAuth App client_id 非敏感,可硬编码):
// 1. 在下方 GITHUB_OAUTH_CLIENT_ID 常量填值(生产推荐 — 直接 bake 进二进制)
// 2. 启动前设置环境变量 GITHUB_OAUTH_CLIENT_ID=<your_client_id>(dev 方便)
//
// 注册 OAuth App:
// https://github.com/settings/applications/new
// - Application name: OpenLess (or your fork)
// - Homepage URL: https://openless.top (or任意)
// - Authorization callback URL: https://openless.top (Device Flow 不真用,但表单要求填)
// - 创建后在 General 页面勾选 "Enable Device Flow"
// - 抄 client_id 填到本常量
const GITHUB_OAUTH_CLIENT_ID: &str = "Ov23liyv3nEucG7oMHNE";
fn get_github_oauth_client_id() -> Result<String, String> {
if let Ok(env_id) = std::env::var("GITHUB_OAUTH_CLIENT_ID") {
let trimmed = env_id.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
}
if !GITHUB_OAUTH_CLIENT_ID.is_empty() {
return Ok(GITHUB_OAUTH_CLIENT_ID.to_string());
}
Err(
"GitHub OAuth 未配置。请去 https://github.com/settings/applications/new 注册一个 OAuth App\
(必须勾 Enable Device Flow),把 client_id 填到 \
openless-all/app/src-tauri/src/commands.rs 的 GITHUB_OAUTH_CLIENT_ID 常量,\
或在启动前设置环境变量 GITHUB_OAUTH_CLIENT_ID=<your_client_id>。"
.to_string(),
)
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GithubDeviceStartResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub interval: u32,
pub expires_in: u32,
}
#[tauri::command]
pub async fn github_device_flow_start() -> Result<GithubDeviceStartResponse, String> {
let client_id = get_github_oauth_client_id()?;
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()
.await
.map_err(|e| format!("解析 device/code 响应失败:{e}"))?;
if !status.is_success() {
let err = body["error"].as_str().unwrap_or("unknown_error");
let desc = body["error_description"].as_str().unwrap_or("");
return Err(format!("GitHub device/code {status} {err}: {desc}"));
}
Ok(GithubDeviceStartResponse {
device_code: body["device_code"].as_str().unwrap_or("").to_string(),
user_code: body["user_code"].as_str().unwrap_or("").to_string(),
verification_uri: body["verification_uri"]
.as_str()
.unwrap_or("https://github.com/login/device")
.to_string(),
interval: body["interval"].as_u64().unwrap_or(5) as u32,
expires_in: body["expires_in"].as_u64().unwrap_or(900) as u32,
})
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum GithubDevicePollResult {
Authorized { login: String },
Pending,
SlowDown,
Error { message: String },
}
#[tauri::command]
pub async fn github_device_flow_poll(
device_code: String,
) -> Result<GithubDevicePollResult, String> {
let client_id = get_github_oauth_client_id()?;
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 = 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
.map_err(|e| format!("解析 /user 响应失败:{e}"))?;
let login = user_body["login"].as_str().unwrap_or("").to_string();
if login.is_empty() {
return Ok(GithubDevicePollResult::Error {
message: "GitHub /user 返回空 login".to_string(),
});
}
return Ok(GithubDevicePollResult::Authorized { login });
}
let err = body["error"].as_str().unwrap_or("");
let msg = match err {
"authorization_pending" => return Ok(GithubDevicePollResult::Pending),
"slow_down" => return Ok(GithubDevicePollResult::SlowDown),
"expired_token" => "OAuth 设备码已过期,请重新发起登录".to_string(),
"access_denied" => "你在 GitHub 上拒绝了授权".to_string(),
other if !other.is_empty() => format!("OAuth 错误:{other}"),
_ => "未知 OAuth 错误(access_token 缺失)".to_string(),
};
Ok(GithubDevicePollResult::Error { message: msg })
}
@@ -0,0 +1,50 @@
use super::*;
#[tauri::command]
pub fn list_history(coord: CoordinatorState<'_>) -> Result<Vec<DictationSession>, String> {
coord.history().list().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_history_entry(coord: CoordinatorState<'_>, id: String) -> Result<(), String> {
coord.history().delete(&id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn clear_history(coord: CoordinatorState<'_>) -> Result<(), String> {
coord.history().clear().map_err(|e| e.to_string())
}
/// 读取某次会话的原始麦克风 wav 字节流。仅当用户开过
/// `prefs.record_audio_for_debug` 并且这条 session 是开关打开后录的,才会有文件。
/// 文件名规约:`<data_dir>/recordings/<session_id>.wav`,与 DictationSession.id 同名。
///
/// 路径校验:session_id **必须**严格匹配 UUID-v4 字面(36 字符 = 8-4-4-4-12 + 4 个 `-`,
/// 内容仅 ASCII 十六进制 + `-`)。白名单胜过黑名单——绝对路径前缀、Windows ADS、
/// 百分号编码、NUL 字节都不在合法字符集里,挡掉所有 Path::join 越界的可能。
/// session_id 在仓库内由 `Uuid::new_v4()` 生成 (`dictation.rs:1531`),前端只会回传
/// 自己列出的合法 id,但 IPC = boundary,按 boundary 规则严格校验。
///
/// async fs:单条 5 分钟 wav 约 9.6MB,同步 `std::fs::read` 会阻塞 Tauri IPC 主循环。
/// 改 `tokio::fs::read` 后让出线程给其它 IPC。
#[tauri::command]
pub async fn read_audio_recording(session_id: String) -> Result<Vec<u8>, String> {
if !is_valid_session_id(&session_id) {
return Err("invalid session id".into());
}
let path =
crate::persistence::recording_path_for_session(&session_id).map_err(|e| e.to_string())?;
if !path.exists() {
return Err("recording not found".into());
}
// TOCTOU 兜底:exists() 通过到 read 之间文件可能被 prune(条数 cap / retention
// 清理 / 用户手动删)。把 NotFound 标准化成跟 exists() 失败同样的错误字符串,
// 前端单条 'recording not found' catch 就能稳定隐藏按钮,不依赖本地化 OS 错误。
tokio::fs::read(&path).await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"recording not found".into()
} else {
format!("read wav failed: {e}")
}
})
}
@@ -0,0 +1,408 @@
use super::*;
#[tauri::command]
pub fn validate_shortcut_binding(binding: ShortcutBinding) -> Result<(), String> {
crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_dictation_hotkey(
coord: CoordinatorState<'_>,
binding: ShortcutBinding,
) -> Result<(), String> {
crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?;
reject_bare_shift_dictation_shortcut(&binding)?;
let mut prefs = coord.prefs().get();
if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() {
reject_dictation_qa_hotkey_overlap(&binding, qa_hotkey)?;
}
reject_dictation_translation_hotkey_overlap(&binding, &prefs.translation_hotkey)?;
if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() {
reject_dictation_switch_style_hotkey_overlap(&binding, switch_style)?;
}
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
reject_dictation_open_app_hotkey_overlap(&binding, open_app)?;
}
prefs.dictation_hotkey = binding;
sync_dictation_hotkey_legacy_fields(&mut prefs);
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
coord.update_hotkey_binding();
coord.update_combo_hotkey_binding();
Ok(())
}
#[tauri::command]
pub fn set_translation_hotkey(
coord: CoordinatorState<'_>,
binding: ShortcutBinding,
) -> Result<(), String> {
crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?;
let previous = coord.prefs().get();
reject_dictation_translation_hotkey_overlap(&previous.dictation_hotkey, &binding)?;
if let Some(qa_hotkey) = previous.qa_hotkey.as_ref() {
reject_qa_translation_hotkey_overlap(qa_hotkey, &binding)?;
}
if let Some(switch_style) = previous.switch_style_hotkey.as_ref() {
reject_translation_switch_style_hotkey_overlap(&binding, switch_style)?;
}
if let Some(open_app) = previous.open_app_hotkey.as_ref() {
reject_translation_open_app_hotkey_overlap(&binding, open_app)?;
}
let mut prefs = previous.clone();
prefs.translation_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
if let Err(e) = coord.try_update_translation_hotkey_binding() {
if let Err(rollback_err) = coord.prefs().set(previous) {
log::warn!("[commands] 回滚翻译快捷键失败: {rollback_err}");
}
coord.update_translation_hotkey_binding();
return Err(e);
}
Ok(())
}
/// 设置「切换风格」全局快捷键。`binding == None`(前端传 null)= 停用:清空绑定并
/// 反注册全局键。镜像 `set_qa_hotkey` 的 `Option=None` 停用模式(issue #576)。
#[tauri::command]
pub fn set_switch_style_hotkey(
coord: CoordinatorState<'_>,
binding: Option<ShortcutBinding>,
) -> Result<(), String> {
if let Some(binding) = binding.as_ref() {
crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?;
reject_modifier_only_action_shortcut(binding)?;
}
let mut prefs = coord.prefs().get();
if let Some(binding) = binding.as_ref() {
reject_dictation_switch_style_hotkey_overlap(&prefs.dictation_hotkey, binding)?;
reject_translation_switch_style_hotkey_overlap(&prefs.translation_hotkey, binding)?;
if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() {
reject_qa_switch_style_hotkey_overlap(qa_hotkey, binding)?;
}
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
reject_switch_style_open_app_hotkey_overlap(binding, open_app)?;
}
}
prefs.switch_style_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
coord.update_switch_style_hotkey_binding();
Ok(())
}
/// 设置「唤起 App」全局快捷键。`binding == None`(前端传 null)= 停用(同上)。
#[tauri::command]
pub fn set_open_app_hotkey(
coord: CoordinatorState<'_>,
binding: Option<ShortcutBinding>,
) -> Result<(), String> {
if let Some(binding) = binding.as_ref() {
crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?;
reject_modifier_only_action_shortcut(binding)?;
}
let mut prefs = coord.prefs().get();
if let Some(binding) = binding.as_ref() {
reject_dictation_open_app_hotkey_overlap(&prefs.dictation_hotkey, binding)?;
reject_translation_open_app_hotkey_overlap(&prefs.translation_hotkey, binding)?;
if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() {
reject_qa_open_app_hotkey_overlap(qa_hotkey, binding)?;
}
if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() {
reject_switch_style_open_app_hotkey_overlap(switch_style, binding)?;
}
}
prefs.open_app_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
coord.update_open_app_hotkey_binding();
Ok(())
}
fn reject_modifier_only_action_shortcut(binding: &ShortcutBinding) -> Result<(), String> {
if binding.modifiers.is_empty()
&& (binding.primary.eq_ignore_ascii_case("shift")
|| crate::shortcut_binding::legacy_modifier_trigger(binding).is_some())
{
return Err("该快捷键需要使用组合键或非修饰主键".into());
}
Ok(())
}
#[tauri::command]
pub fn validate_combo_hotkey(binding: ComboBinding) -> Result<(), String> {
let shortcut = ShortcutBinding {
primary: binding.primary,
modifiers: binding.modifiers,
};
reject_bare_shift_dictation_shortcut(&shortcut)?;
crate::combo_hotkey::validate_binding(&shortcut).map_err(|e| e.to_string())
}
/// 设置自定义录音组合键并热更新 monitor。
#[tauri::command]
pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> Result<(), String> {
let mut prefs = coord.prefs().get();
let shortcut = ShortcutBinding {
primary: binding.primary.clone(),
modifiers: binding.modifiers.clone(),
};
reject_bare_shift_dictation_shortcut(&shortcut)?;
crate::combo_hotkey::validate_binding(&shortcut).map_err(|e| e.to_string())?;
if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() {
reject_dictation_qa_hotkey_overlap(&shortcut, qa_hotkey)?;
}
reject_dictation_translation_hotkey_overlap(&shortcut, &prefs.translation_hotkey)?;
if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() {
reject_dictation_switch_style_hotkey_overlap(&shortcut, switch_style)?;
}
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
reject_dictation_open_app_hotkey_overlap(&shortcut, open_app)?;
}
prefs.custom_combo_hotkey = Some(binding);
prefs.dictation_hotkey = shortcut;
sync_dictation_hotkey_legacy_fields(&mut prefs);
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
coord.update_hotkey_binding();
coord.update_combo_hotkey_binding();
Ok(())
}
pub(crate) fn reject_bare_shift_dictation_shortcut(
binding: &ShortcutBinding,
) -> Result<(), String> {
if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") {
return Err("Shift 单键目前只能用于翻译快捷键".into());
}
Ok(())
}
pub(crate) fn sync_dictation_hotkey_legacy_fields(prefs: &mut UserPreferences) {
if let Some(trigger) = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey)
{
prefs.hotkey.trigger = trigger;
prefs.custom_combo_hotkey = None;
return;
}
prefs.hotkey.trigger = crate::types::HotkeyTrigger::Custom;
prefs.custom_combo_hotkey = if prefs.dictation_hotkey.primary.trim().is_empty() {
None
} else {
Some(ComboBinding {
primary: prefs.dictation_hotkey.primary.clone(),
modifiers: prefs.dictation_hotkey.modifiers.clone(),
})
};
}
pub(crate) fn reject_dictation_qa_hotkey_overlap(
dictation: &ShortcutBinding,
qa: &ShortcutBinding,
) -> Result<(), String> {
if shortcut_bindings_overlap(dictation, qa) {
return Err("QA 快捷键不能和听写快捷键相同".into());
}
Ok(())
}
fn reject_hotkey_overlap(
left: &ShortcutBinding,
right: &ShortcutBinding,
message: &'static str,
) -> Result<(), String> {
if shortcut_bindings_overlap(left, right) {
return Err(message.into());
}
Ok(())
}
pub(crate) fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), String> {
// 停用(None)的 action 快捷键不参与任何冲突检测。
let switch_style = prefs.switch_style_hotkey.as_ref();
let open_app = prefs.open_app_hotkey.as_ref();
let less_computer = prefs.coding_agent_voice_hotkey.as_ref();
if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() {
reject_dictation_qa_hotkey_overlap(&prefs.dictation_hotkey, qa_hotkey)?;
reject_qa_translation_hotkey_overlap(qa_hotkey, &prefs.translation_hotkey)?;
if let Some(less_computer) = less_computer {
reject_qa_less_computer_hotkey_overlap(qa_hotkey, less_computer)?;
}
if let Some(switch_style) = switch_style {
reject_qa_switch_style_hotkey_overlap(qa_hotkey, switch_style)?;
}
if let Some(open_app) = open_app {
reject_qa_open_app_hotkey_overlap(qa_hotkey, open_app)?;
}
}
reject_dictation_translation_hotkey_overlap(
&prefs.dictation_hotkey,
&prefs.translation_hotkey,
)?;
if let Some(less_computer) = less_computer {
reject_dictation_less_computer_hotkey_overlap(&prefs.dictation_hotkey, less_computer)?;
reject_translation_less_computer_hotkey_overlap(&prefs.translation_hotkey, less_computer)?;
}
if let Some(switch_style) = switch_style {
reject_dictation_switch_style_hotkey_overlap(&prefs.dictation_hotkey, switch_style)?;
reject_translation_switch_style_hotkey_overlap(&prefs.translation_hotkey, switch_style)?;
if let Some(less_computer) = less_computer {
reject_less_computer_switch_style_hotkey_overlap(less_computer, switch_style)?;
}
}
if let Some(open_app) = open_app {
reject_dictation_open_app_hotkey_overlap(&prefs.dictation_hotkey, open_app)?;
reject_translation_open_app_hotkey_overlap(&prefs.translation_hotkey, open_app)?;
if let Some(less_computer) = less_computer {
reject_less_computer_open_app_hotkey_overlap(less_computer, open_app)?;
}
}
if let (Some(switch_style), Some(open_app)) = (switch_style, open_app) {
reject_switch_style_open_app_hotkey_overlap(switch_style, open_app)?;
}
Ok(())
}
pub(crate) fn reject_dictation_translation_hotkey_overlap(
dictation: &ShortcutBinding,
translation: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(dictation, translation, "翻译快捷键不能和听写快捷键相同")
}
fn reject_dictation_switch_style_hotkey_overlap(
dictation: &ShortcutBinding,
switch_style: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(
dictation,
switch_style,
"切换风格快捷键不能和听写快捷键相同",
)
}
fn reject_dictation_open_app_hotkey_overlap(
dictation: &ShortcutBinding,
open_app: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(dictation, open_app, "打开应用快捷键不能和听写快捷键相同")
}
fn reject_dictation_less_computer_hotkey_overlap(
dictation: &ShortcutBinding,
less_computer: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(
dictation,
less_computer,
"Less Computer 快捷键不能和听写快捷键相同",
)
}
pub(crate) fn reject_qa_translation_hotkey_overlap(
qa: &ShortcutBinding,
translation: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(qa, translation, "翻译快捷键不能和 QA 快捷键相同")
}
pub(crate) fn reject_qa_switch_style_hotkey_overlap(
qa: &ShortcutBinding,
switch_style: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(qa, switch_style, "切换风格快捷键不能和 QA 快捷键相同")
}
pub(crate) fn reject_qa_open_app_hotkey_overlap(
qa: &ShortcutBinding,
open_app: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(qa, open_app, "打开应用快捷键不能和 QA 快捷键相同")
}
fn reject_qa_less_computer_hotkey_overlap(
qa: &ShortcutBinding,
less_computer: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(
qa,
less_computer,
"Less Computer 快捷键不能和 QA 快捷键相同",
)
}
fn reject_translation_switch_style_hotkey_overlap(
translation: &ShortcutBinding,
switch_style: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(
translation,
switch_style,
"切换风格快捷键不能和翻译快捷键相同",
)
}
fn reject_translation_open_app_hotkey_overlap(
translation: &ShortcutBinding,
open_app: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(translation, open_app, "打开应用快捷键不能和翻译快捷键相同")
}
fn reject_translation_less_computer_hotkey_overlap(
translation: &ShortcutBinding,
less_computer: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(
translation,
less_computer,
"Less Computer 快捷键不能和翻译快捷键相同",
)
}
fn reject_switch_style_open_app_hotkey_overlap(
switch_style: &ShortcutBinding,
open_app: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(
switch_style,
open_app,
"打开应用快捷键不能和切换风格快捷键相同",
)
}
fn reject_less_computer_switch_style_hotkey_overlap(
less_computer: &ShortcutBinding,
switch_style: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(
less_computer,
switch_style,
"Less Computer 快捷键不能和切换风格快捷键相同",
)
}
fn reject_less_computer_open_app_hotkey_overlap(
less_computer: &ShortcutBinding,
open_app: &ShortcutBinding,
) -> Result<(), String> {
reject_hotkey_overlap(
less_computer,
open_app,
"Less Computer 快捷键不能和打开应用快捷键相同",
)
}
fn shortcut_bindings_overlap(left: &ShortcutBinding, right: &ShortcutBinding) -> bool {
let left_legacy = crate::shortcut_binding::legacy_modifier_trigger(left);
let right_legacy = crate::shortcut_binding::legacy_modifier_trigger(right);
match (left_legacy, right_legacy) {
(Some(left), Some(right)) => left == right,
(Some(_), None) | (None, Some(_)) => false,
(None, None) => {
let Ok(left) = crate::shortcut_binding::parse_global_hotkey(left) else {
return false;
};
let Ok(right) = crate::shortcut_binding::parse_global_hotkey(right) else {
return false;
};
left == right
}
}
}
@@ -0,0 +1,309 @@
use super::*;
use crate::asr::local::{
download::{fetch_remote_info, RemoteInfo},
DownloadManager, ModelId, ModelStatus, PROVIDER_ID as LOCAL_PROVIDER_ID,
};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalAsrSettings {
pub provider_id: String,
pub active_model: String,
pub mirror: String,
pub models_base_dir: Option<String>,
pub models_root_dir: String,
/// macOS 才编入引擎;Windows 端 UI 需要据此把"开始下载"按钮灰掉。
pub engine_available: bool,
}
#[tauri::command]
pub fn local_asr_get_settings(coord: CoordinatorState<'_>) -> LocalAsrSettings {
let prefs = coord.prefs().get();
let models_base_dir = non_empty_string(prefs.local_asr_models_base_dir.clone());
let models_root_dir = crate::persistence::models_root_for_base_dir(models_base_dir.as_deref())
.map(|path| path.display().to_string())
.unwrap_or_default();
LocalAsrSettings {
provider_id: LOCAL_PROVIDER_ID.into(),
active_model: prefs.local_asr_active_model,
mirror: prefs.local_asr_mirror,
models_base_dir,
models_root_dir,
engine_available: cfg!(target_os = "macos"),
}
}
fn non_empty_string(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalAsrStorageSettings {
pub models_base_dir: Option<String>,
pub models_root_dir: String,
pub is_default: bool,
}
#[tauri::command]
pub fn local_asr_storage_settings(
coord: CoordinatorState<'_>,
) -> Result<LocalAsrStorageSettings, String> {
let prefs = coord.prefs().get();
let models_base_dir = non_empty_string(prefs.local_asr_models_base_dir);
let models_root_dir = crate::persistence::models_root_for_base_dir(models_base_dir.as_deref())
.map_err(|e| format!("{e:#}"))?
.display()
.to_string();
Ok(LocalAsrStorageSettings {
is_default: models_base_dir.is_none(),
models_base_dir,
models_root_dir,
})
}
#[tauri::command]
pub async fn local_asr_set_models_base_dir(
coord: CoordinatorState<'_>,
qwen_manager: State<'_, Arc<DownloadManager>>,
foundry_runtime: State<'_, Arc<FoundryLocalRuntime>>,
sherpa_manager: State<'_, Arc<SherpaDownloadManager>>,
sherpa_runtime: State<'_, Arc<SherpaOnnxRuntime>>,
models_base_dir: Option<String>,
) -> Result<LocalAsrStorageSettings, String> {
let next_base_dir = models_base_dir
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let new_root = crate::persistence::validate_models_base_dir(next_base_dir.as_deref())
.map_err(|e| format!("{e:#}"))?;
let prefs = coord.prefs().get();
let current_base_dir = non_empty_string(prefs.local_asr_models_base_dir.clone());
let old_root = crate::persistence::models_root_for_base_dir(current_base_dir.as_deref())
.map_err(|e| format!("{e:#}"))?;
let same_root = same_path_for_command(&old_root, &new_root);
if same_root && current_base_dir == next_base_dir {
return local_asr_storage_settings(coord);
}
if !same_root && foundry_runtime.storage_configuration_locked() {
return Err(
"Foundry Local has already been initialized in this app session; restart OpenLess before changing the model storage location"
.to_string(),
);
}
quiesce_local_asr_storage_users(
coord.inner(),
qwen_manager.inner(),
foundry_runtime.inner(),
sherpa_manager.inner(),
sherpa_runtime.inner(),
)
.await?;
crate::persistence::migrate_models_root(&old_root, &new_root).map_err(|e| format!("{e:#}"))?;
let mut prefs = prefs;
prefs.local_asr_models_base_dir = next_base_dir.clone().unwrap_or_default();
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
local_asr_storage_settings(coord)
}
fn same_path_for_command(left: &std::path::Path, right: &std::path::Path) -> bool {
match (left.canonicalize(), right.canonicalize()) {
(Ok(left), Ok(right)) => left == right,
_ => false,
}
}
async fn quiesce_local_asr_storage_users(
coord: &Arc<Coordinator>,
qwen_manager: &Arc<DownloadManager>,
foundry_runtime: &Arc<FoundryLocalRuntime>,
sherpa_manager: &Arc<SherpaDownloadManager>,
sherpa_runtime: &Arc<SherpaOnnxRuntime>,
) -> Result<(), String> {
for model_id in ModelId::all() {
qwen_manager.cancel(*model_id);
}
for model in crate::asr::local::sherpa::MODELS {
sherpa_manager.cancel(model.alias);
}
foundry_runtime.request_cancel_prepare();
sherpa_runtime.request_cancel_prepare();
coord.release_local_asr_engine();
foundry_runtime
.release_now()
.await
.map_err(|e| format!("{e:#}"))?;
sherpa_runtime
.release_now()
.await
.map_err(|e| format!("{e:#}"))?;
for _ in 0..50 {
let qwen_active = ModelId::all()
.iter()
.any(|model_id| qwen_manager.is_active(*model_id));
let sherpa_active = crate::asr::local::sherpa::MODELS
.iter()
.any(|model| sherpa_manager.is_active(model.alias));
if !qwen_active && !sherpa_active {
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
Err("local ASR downloads are still stopping; retry after cancellation finishes".to_string())
}
#[tauri::command]
pub fn local_asr_set_active_model(
coord: CoordinatorState<'_>,
model_id: String,
) -> Result<(), String> {
if ModelId::from_str(&model_id).is_none() {
return Err(format!("unknown model id: {model_id}"));
}
let mut prefs = coord.prefs().get();
prefs.local_asr_active_model = model_id;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn local_asr_set_mirror(coord: CoordinatorState<'_>, mirror: String) -> Result<(), String> {
let _normalized = Mirror::from_str(&mirror);
let mut prefs = coord.prefs().get();
prefs.local_asr_mirror = mirror;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn local_asr_list_models() -> Vec<ModelStatus> {
crate::asr::local::models::list_status()
}
/// 实时去 HuggingFace API 拉某个模型的真实文件清单 + 总尺寸;
/// 前端在显示模型卡时调一次,避免硬编码尺寸过期。
#[tauri::command]
pub async fn local_asr_fetch_remote_info(
model_id: String,
mirror: Option<String>,
) -> Result<RemoteInfo, String> {
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
let m = mirror.as_deref().map(Mirror::from_str).unwrap_or_default();
fetch_remote_info(id, m).await.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub fn local_asr_download_model(
app: AppHandle,
manager: State<'_, Arc<DownloadManager>>,
model_id: String,
mirror: Option<String>,
) -> Result<(), String> {
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
let m = mirror.as_deref().map(Mirror::from_str).unwrap_or_default();
manager.start(app, id, m);
Ok(())
}
#[tauri::command]
pub fn local_asr_cancel_download(
manager: State<'_, Arc<DownloadManager>>,
model_id: String,
) -> Result<(), String> {
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
manager.cancel(id);
Ok(())
}
#[tauri::command]
pub fn local_asr_delete_model(coord: CoordinatorState<'_>, model_id: String) -> Result<(), String> {
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
// 如果内存里加载的就是要删的这个模型,先释放:否则 mmap 残留指向已 unlink 的文件,
// 且 RAM 直到下次切模型 / 用户手动按"释放"才回收。
if coord.local_asr_loaded_model().as_deref() == Some(id.as_str()) {
coord.release_local_asr_engine();
}
crate::asr::local::models::delete_model(id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn local_asr_model_dir(model_id: String) -> Result<String, String> {
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
crate::asr::local::models::model_dir(id)
.map(|path| path.display().to_string())
.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub fn local_asr_reveal_model_dir(model_id: String) -> Result<(), String> {
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
let dir = crate::asr::local::models::model_dir(id).map_err(|e| format!("{e:#}"))?;
std::fs::create_dir_all(&dir).map_err(|e| format!("create {} failed: {e}", dir.display()))?;
open_path_in_file_manager(&dir)
}
#[tauri::command]
pub fn local_asr_reveal_models_root(coord: CoordinatorState<'_>) -> Result<(), String> {
let prefs = coord.prefs().get();
let base_dir = non_empty_string(prefs.local_asr_models_base_dir);
let dir = crate::persistence::models_root_for_base_dir(base_dir.as_deref())
.map_err(|e| format!("{e:#}"))?;
open_path_in_file_manager(&dir)
}
#[tauri::command]
pub async fn local_asr_test_model(
model_id: String,
) -> Result<crate::asr::local::test_run::TestResult, String> {
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
crate::asr::local::test_run::run_test(id)
.await
.map_err(|e| format!("{e:#}"))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalAsrEngineStatus {
pub loaded: bool,
pub model_id: Option<String>,
pub keep_loaded_secs: u32,
}
#[tauri::command]
pub fn local_asr_engine_status(coord: CoordinatorState<'_>) -> LocalAsrEngineStatus {
let prefs = coord.prefs().get();
LocalAsrEngineStatus {
loaded: coord.local_asr_loaded_model().is_some(),
model_id: coord.local_asr_loaded_model(),
keep_loaded_secs: prefs.local_asr_keep_loaded_secs,
}
}
#[tauri::command]
pub fn local_asr_release_engine(coord: CoordinatorState<'_>) {
coord.release_local_asr_engine();
}
#[tauri::command]
pub fn local_asr_preload(coord: tauri::State<'_, std::sync::Arc<crate::coordinator::Coordinator>>) {
coord.preload_local_asr_in_background();
}
#[tauri::command]
pub fn local_asr_set_keep_loaded_secs(
coord: CoordinatorState<'_>,
seconds: u32,
) -> Result<(), String> {
let mut prefs = coord.prefs().get();
prefs.local_asr_keep_loaded_secs = seconds;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
@@ -0,0 +1,404 @@
use super::*;
// ─────────────────────────── marketplace (Phase A) ───────────────────────────
//
// 客户端跟 marketplace backend 的 HTTP 客户端封装。Backend URL 走 prefs
// `marketplace_base_url`(默认 http://127.0.0.1:8090 开发;生产用户填 https://api.<domain>)。
// dev-mode auth:用户在 Settings 填 `marketplace_dev_login`(GitHub 风格 username),
// 后续 OAuth 接入时换成 token 字段。
//
// 5 个 IPC:
// - marketplace_list 列表 + 搜索 + 排序
// - marketplace_detail 详情(含完整 prompt)
// - marketplace_install 下载 ZIP + 直接调 import_from_zip 装到本地
// - marketplace_upload 把本地某个 style pack export ZIP → multipart 上传
// - marketplace_like 点赞
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct MarketplaceListItem {
pub id: String,
pub slug: String,
pub name: String,
pub description: String,
#[serde(default)]
pub author_login: String,
pub version: String,
pub base_mode: String,
#[serde(default)]
pub tags: Vec<String>,
pub like_count: i64,
pub download_count: i64,
pub published_at: String,
pub updated_at: String,
pub origin_pack_id: Option<String>,
pub origin_author_login: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct MarketplaceDetail {
#[serde(flatten)]
pub summary: MarketplaceListItem,
pub prompt: String,
pub state: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct MarketplaceMyPackItem {
#[serde(flatten)]
pub summary: MarketplaceListItem,
pub state: String,
}
/// 风格市场 backend URL —— 硬编码到生产云端,不再读 prefs。
///
/// 历史上这里读 `prefs.marketplace_base_url`(dev 本地可填 127.0.0.1:8090),
/// 现在风格市场已经稳定部署在 apic.openless.top,把 URL 锁死避免用户误改 / 写错。
/// 参数 `_prefs` 保留是为不动调用点签名;将来需要白名单 / 多 endpoint 时再开口。
pub(crate) const MARKETPLACE_BASE_URL: &str = "https://apic.openless.top";
fn marketplace_url_from_prefs(_prefs: &UserPreferences) -> String {
MARKETPLACE_BASE_URL.to_string()
}
fn marketplace_dev_user(prefs: &UserPreferences) -> String {
prefs.marketplace_dev_login.trim().to_string()
}
#[tauri::command]
pub async fn marketplace_list(
coord: CoordinatorState<'_>,
query: Option<String>,
sort: Option<String>,
limit: Option<u32>,
) -> Result<Vec<MarketplaceListItem>, String> {
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let mut url = reqwest::Url::parse(&format!("{base}/packs"))
.map_err(|e| format!("invalid marketplace url: {e}"))?;
if let Some(q) = query.as_deref() {
if !q.trim().is_empty() {
url.query_pairs_mut().append_pair("q", q.trim());
}
}
if let Some(s) = sort.as_deref() {
if !s.trim().is_empty() {
url.query_pairs_mut().append_pair("sort", s.trim());
}
}
if let Some(n) = limit {
url.query_pairs_mut().append_pair("limit", &n.to_string());
}
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();
return Err(format!("marketplace HTTP {status}: {body}"));
}
let items: Vec<MarketplaceListItem> = resp
.json()
.await
.map_err(|e| format!("parse failed: {e}"))?;
Ok(items)
}
#[tauri::command]
pub async fn marketplace_detail(
coord: CoordinatorState<'_>,
pack_id: String,
) -> Result<MarketplaceDetail, String> {
if !is_valid_session_id(&pack_id) {
return Err("invalid pack id".into());
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
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}"));
}
resp.json::<MarketplaceDetail>()
.await
.map_err(|e| format!("parse failed: {e}"))
}
#[tauri::command]
pub async fn marketplace_install(
coord: CoordinatorState<'_>,
pack_id: String,
) -> Result<StylePack, String> {
// 安全校验:pack_id 来自远端 backend,可能含路径遍历 segment。
// 用跟 read_audio_recording 同样的 UUID-v4 白名单挡住 ../ / 绝对路径等。
// backend 当前用 Uuid::new_v4 生成所有 id,合法 id 必然匹配。
if !is_valid_session_id(&pack_id) {
return Err("invalid pack id".into());
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
// 先拉 detail 拿 authorLogin —— 装好后本地写 originAuthorLogin,
// 后续编辑+发布时 backend 据此判 supersede(原作者)vs derivative(他人 fork)。
let detail_url = format!("{base}/packs/{pack_id}");
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 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"));
std::fs::write(&tmp, &bytes).map_err(|e| format!("write tmp zip: {e}"))?;
let imported_result = coord
.style_packs()
.import_from_zip(&tmp)
.map_err(|e| e.to_string());
let _ = std::fs::remove_file(&tmp);
let imported = imported_result?;
// 绑定 origin —— 后续编辑+发布走 derivative / supersede 分支。
coord
.style_packs()
.set_origin(&imported.id, Some(pack_id), origin_author_login)
.map_err(|e| format!("set origin failed: {e}"))
}
#[tauri::command]
pub async fn marketplace_upload(
coord: CoordinatorState<'_>,
pack_id: String,
origin_pack_id: Option<String>,
) -> Result<serde_json::Value, String> {
// 本地 pack id 形态:`builtin.light` / 用户 slug / Uuid。用 local 白名单挡 `..` / `/` / `\`。
if !is_valid_local_pack_id(&pack_id) {
return Err("invalid pack id".into());
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
if dev_user.is_empty() {
return Err("未登录:先在 Settings 填发布者名字".into());
}
// 拉本地 pack 拿 origin_pack_id —— 装过的 pack 这里有值,
// backend 据此判同作者就 supersede 原行(新版本),他人就 derivative(独立新 row)。
let local_pack = coord
.style_packs()
.get(&pack_id)
.map_err(|e| format!("local pack not found: {e}"))?;
let origin_pack_id = origin_pack_id
.filter(|id| is_valid_session_id(id))
.or_else(|| local_pack.origin_pack_id.clone());
// 先 export 本地 pack → 临时 ZIP
let tmp = std::env::temp_dir().join(format!("openless-marketplace-upload-{pack_id}.zip"));
coord
.style_packs()
.export_to_zip(&pack_id, &tmp)
.map_err(|e| format!("export local pack failed: {e}"))?;
let bytes = std::fs::read(&tmp).map_err(|e| format!("read exported zip: {e}"))?;
let _ = std::fs::remove_file(&tmp);
// multipart 上传:表单是流式 body,不走 send_with_retry 的闭包重试;改用共享
// 客户端 —— 之前 list/detail 命令若已打开过连接,这里直接复用连接池里的连接。
let part = reqwest::multipart::Part::bytes(bytes)
.file_name(format!("{pack_id}.zip"))
.mime_str("application/zip")
.map_err(|e| format!("multipart build failed: {e}"))?;
let mut form = reqwest::multipart::Form::new().part("file", part);
if let Some(ref oid) = origin_pack_id {
form = form.text("origin_pack_id", oid.clone());
}
let resp = net::http()
.post(format!("{base}/packs"))
.header("X-Dev-User", dev_user)
.timeout(std::time::Duration::from_secs(30))
.multipart(form)
.send()
.await
.map_err(|e| format!("upload request failed: {e}"))?;
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("read body failed: {e}"))
.clone();
if !status.is_success() {
return Err(format!("upload HTTP {status}: {body}"));
}
let parsed = serde_json::from_str::<serde_json::Value>(&body)
.map_err(|e| format!("parse upload response failed: {e}"))?;
// 本地从未绑定 origin(首次上传一个本地原创 pack)→ 把 backend 分配的 pack id 写回本地,
// 让用户在同设备上后续编辑能继续走「同作者 supersede」分支,更新自己原创的包。
if origin_pack_id.is_none() {
if let Some(remote_id) = parsed.get("id").and_then(|v| v.as_str()) {
let prefs2 = coord.prefs().get();
let dev_user2 = marketplace_dev_user(&prefs2);
let _ = coord.style_packs().set_origin(
&pack_id,
Some(remote_id.to_string()),
Some(dev_user2),
);
}
}
Ok(parsed)
}
#[tauri::command]
pub async fn marketplace_like(
coord: CoordinatorState<'_>,
pack_id: String,
) -> Result<serde_json::Value, String> {
if !is_valid_session_id(&pack_id) {
return Err("invalid pack id".into());
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
if dev_user.is_empty() {
return Err("未登录:先在 Settings 填发布者名字".into());
}
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()));
}
resp.json::<serde_json::Value>()
.await
.map_err(|e| format!("parse failed: {e}"))
}
/// 撤回自己发布的 pack(后端软删 state='withdrawn',前端列表不再可见)。
/// pack_id 来自远端,必须是 UUID-v4。
#[tauri::command]
pub async fn marketplace_delete(
coord: CoordinatorState<'_>,
pack_id: String,
) -> Result<(), String> {
if !is_valid_session_id(&pack_id) {
return Err("invalid pack id".into());
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
if dev_user.is_empty() {
return Err("未登录:先在 Settings 填发布者名字".into());
}
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();
return Err(format!("delete HTTP {status}: {body}"));
}
Ok(())
}
/// 拉当前用户赞过的所有 pack id,用于客户端市场页面渲染红心 + 「我赞过的」过滤。
#[tauri::command]
pub async fn marketplace_my_likes(coord: CoordinatorState<'_>) -> Result<Vec<String>, String> {
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
if dev_user.is_empty() {
return Ok(Vec::new()); // 未登录就空集合,UI 渲染无红心
}
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()));
}
resp.json::<Vec<String>>()
.await
.map_err(|e| format!("parse my-likes failed: {e}"))
}
/// 拉当前用户发布过的 pack(含审核中/已通过/已拒绝/已撤回),用于「我的发布」页面。
#[tauri::command]
pub async fn marketplace_my_packs(
coord: CoordinatorState<'_>,
) -> Result<Vec<MarketplaceMyPackItem>, String> {
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
if dev_user.is_empty() {
return Ok(Vec::new());
}
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()));
}
resp.json::<Vec<MarketplaceMyPackItem>>()
.await
.map_err(|e| format!("parse my-packs failed: {e}"))
}
@@ -0,0 +1,123 @@
use super::*;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkCheckResult {
pub online: bool,
pub latency_ms: Option<u64>,
}
#[tauri::command]
pub async fn check_network() -> NetworkCheckResult {
// 探一个真实存在的接口。旧逻辑探 `/health` —— 实测返回 404,链路正常也永远判
// 离线;且用 HEAD(后端只挂 GET)。改成 GET `/packs`,拿到任意 HTTP 响应即算通。
//
// 单发、不走 send_with_retry:这是每 30s 跑一次的状态探针,要的是「快」。10 次
// 退避重试会让被过滤 / 黑洞的网络下探测拖到近一分钟、状态灯像卡死。偶发的瞬时
// 误判由下一个 30s 周期自动纠正。仍用 net::http() 共享连接池。
let url = format!("{MARKETPLACE_BASE_URL}/packs?limit=1");
let start = std::time::Instant::now();
match net::http()
.get(&url)
.timeout(std::time::Duration::from_secs(8))
.send()
.await
{
Ok(_) => NetworkCheckResult {
online: true,
latency_ms: Some(start.elapsed().as_millis() as u64),
},
Err(_) => NetworkCheckResult {
online: false,
latency_ms: None,
},
}
}
#[tauri::command]
pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus {
coord.hotkey_status()
}
#[tauri::command]
pub fn get_hotkey_capability(coord: CoordinatorState<'_>) -> HotkeyCapability {
coord.hotkey_capability()
}
#[tauri::command]
pub fn set_shortcut_recording_active(coord: CoordinatorState<'_>, active: bool) {
coord.set_shortcut_recording_active(active);
}
#[tauri::command]
pub fn get_windows_ime_status() -> WindowsImeStatus {
crate::windows_ime_profile::get_windows_ime_status()
}
#[tauri::command]
pub fn list_microphone_devices() -> Result<Vec<crate::recorder::MicrophoneDevice>, String> {
crate::recorder::list_input_devices().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn start_microphone_level_monitor(
app: AppHandle,
device_name: String,
) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let state = app.state::<MicrophoneMonitorState>();
if let Some(existing) = state.lock().take() {
existing.stop();
}
let selected = device_name.trim().to_string();
let microphone_device_name = if selected.is_empty() {
None
} else {
Some(selected)
};
let consumer: Arc<dyn AudioConsumer> = Arc::new(LevelProbeConsumer);
let level_app = app.clone();
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = Arc::new(move |level| {
let _ = level_app.emit("microphone:level", serde_json::json!({ "level": level }));
});
let (recorder, _runtime_errors, _archive_active) =
Recorder::start(microphone_device_name, consumer, level_handler, None)
.map_err(|e| e.to_string())?;
*state.lock() = Some(recorder);
Ok(())
})
.await
.map_err(|e| format!("start microphone monitor task failed: {e}"))?
}
#[tauri::command]
pub async fn stop_microphone_level_monitor(app: AppHandle) {
let _ = tauri::async_runtime::spawn_blocking(move || {
let state = app.state::<MicrophoneMonitorState>();
let recorder = state.lock().take();
if let Some(recorder) = recorder {
recorder.stop();
}
})
.await;
}
/// 把当前会话的 openless.log 复制到用户选择的位置(前端用 plugin-dialog 拿 target_path)。
/// 路径来自 lib::log_dir_path() —— mac: ~/Library/Logs/OpenLess/openless.log,
/// windows: %LOCALAPPDATA%\OpenLess\Logs\openless.log。
#[tauri::command]
pub fn export_error_log(target_path: String) -> Result<(), String> {
let src = crate::log_dir_path().join("openless.log");
if !src.exists() {
return Err(format!("日志文件不存在:{}", src.display()));
}
std::fs::copy(&src, std::path::Path::new(&target_path))
.map(|_| ())
.map_err(|e| format!("复制日志失败:{}", e))
}
// ─────────────────────────── unused but exported (silences dead_code) ───────────────────────────
#[allow(dead_code)]
fn _ensure_snapshot_used(_: CredentialsSnapshot) {}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,100 @@
use super::*;
#[tauri::command]
pub fn check_accessibility_permission() -> PermissionStatus {
permissions::check_accessibility()
}
#[tauri::command]
pub fn request_accessibility_permission() -> PermissionStatus {
permissions::request_accessibility()
}
#[tauri::command]
pub fn check_microphone_permission() -> PermissionStatus {
permissions::check_microphone()
}
#[tauri::command]
pub fn request_microphone_permission(app: AppHandle) -> PermissionStatus {
crate::request_microphone_from_foreground(&app)
}
/// 跳到 macOS 系统设置的指定隐私面板。pane: "accessibility" | "microphone".
#[tauri::command]
pub fn open_system_settings(pane: String) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let url = match pane.as_str() {
"accessibility" => {
"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
}
"microphone" => {
"x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
}
_ => "x-apple.systempreferences:com.apple.preference.security?Privacy",
};
std::process::Command::new("open")
.arg(url)
.spawn()
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(target_os = "windows")]
{
use windows::core::PCWSTR;
use windows::Win32::UI::Shell::ShellExecuteW;
use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
fn wide_null(value: &str) -> Vec<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
}
let uri = match pane.as_str() {
"microphone" => "ms-settings:privacy-microphone",
"sound" => "ms-settings:sound",
"accessibility" => "ms-settings:easeofaccess",
_ => "ms-settings:",
};
let operation = wide_null("open");
let target = wide_null(uri);
let result = unsafe {
ShellExecuteW(
None,
PCWSTR(operation.as_ptr()),
PCWSTR(target.as_ptr()),
PCWSTR::null(),
PCWSTR::null(),
SW_SHOWNORMAL,
)
};
if result.0 as isize <= 32 {
Err(format!("ShellExecuteW failed: {}", result.0 as isize))
} else {
Ok(())
}
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
{
let _ = pane;
Err("open_system_settings is only supported on macOS and Windows".to_string())
}
}
/// 触发 macOS 系统弹"是否允许 OpenLess 访问麦克风"对话框。
/// 与 Swift `MicrophonePermission.request()` 同语义:只信系统权限回调,
/// 不用 cpal stream 成功与否伪造授权状态。
#[tauri::command]
pub fn trigger_microphone_prompt(app: AppHandle) -> Result<(), String> {
let status = crate::request_microphone_from_foreground(&app);
if matches!(
status,
PermissionStatus::Granted | PermissionStatus::NotApplicable
) {
Ok(())
} else {
Err(format!("microphone permission is {status:?}"))
}
}
@@ -0,0 +1,496 @@
use super::*;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderCheckResult {
ok: bool,
}
#[derive(Serialize)]
pub struct ProviderModelsResult {
models: Vec<String>,
}
#[tauri::command]
pub async fn validate_provider_credentials(kind: String) -> Result<ProviderCheckResult, String> {
match kind.as_str() {
"llm" => validate_llm_provider()
.await
.map(|()| ProviderCheckResult { ok: true }),
"asr" => validate_asr_provider()
.await
.map(|()| ProviderCheckResult { ok: true }),
_ => Err(format!("unknown provider kind: {kind}")),
}
}
#[tauri::command]
pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult, String> {
if kind == "asr" && CredentialsVault::get_active_asr() == crate::asr::bailian::PROVIDER_ID {
return Ok(ProviderModelsResult {
models: vec![crate::asr::bailian::DEFAULT_MODEL.to_string()],
});
}
if kind == "asr" && CredentialsVault::get_active_asr() == crate::asr::mimo::PROVIDER_ID {
return Ok(ProviderModelsResult {
models: vec![crate::asr::mimo::DEFAULT_MODEL.to_string()],
});
}
if kind == "llm" && CredentialsVault::get_active_llm() == CODEX_OAUTH_PROVIDER_ID {
return Ok(ProviderModelsResult {
models: vec![
CODEX_DEFAULT_MODEL.to_string(),
"gpt-5.3-codex".to_string(),
"gpt-5.4".to_string(),
"gpt-5.5".to_string(),
],
});
}
let config = read_openai_provider_config(&kind)?;
fetch_provider_models(&config)
.await
.map(|models| ProviderModelsResult { models })
}
pub(crate) struct ProviderConfig {
pub(crate) base_url: String,
pub(crate) api_key: String,
}
fn read_openai_provider_config(kind: &str) -> Result<ProviderConfig, String> {
let (api_key_account, endpoint_account, api_key_required) = match kind {
"llm" => (
CredentialAccount::ArkApiKey,
CredentialAccount::ArkEndpoint,
false,
),
"asr" => (
CredentialAccount::AsrApiKey,
CredentialAccount::AsrEndpoint,
true,
),
_ => return Err(format!("unknown provider kind: {kind}")),
};
let api_key = CredentialsVault::get(api_key_account)
.map_err(|e| e.to_string())?
.unwrap_or_default();
let base_url = CredentialsVault::get(endpoint_account)
.map_err(|e| e.to_string())?
.unwrap_or_default();
if api_key_required && api_key.trim().is_empty() {
return Err("API Key 为空".to_string());
}
if base_url.trim().is_empty() {
return Err("Endpoint 为空".to_string());
}
Ok(ProviderConfig { base_url, api_key })
}
async fn validate_llm_provider() -> Result<(), String> {
let llm_thinking_enabled = PreferencesStore::new()
.map_err(|e| e.to_string())?
.get()
.llm_thinking_enabled;
if CredentialsVault::get_active_llm() == CODEX_OAUTH_PROVIDER_ID {
let model = CredentialsVault::get(CredentialAccount::ArkModelId)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| CODEX_DEFAULT_MODEL.to_string());
let provider = CodexOAuthLLMProvider::new(
CodexOAuthConfig::new(model).with_thinking_enabled(llm_thinking_enabled),
);
return provider
.polish(
"验证连接",
PolishMode::Raw,
&[],
"",
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
None,
&[],
)
.await
.map(|_| ())
.map_err(|e| match e {
LLMError::InvalidResponse { status, .. } => {
format!("providerHttpStatus:{status}")
}
other => other.to_string(),
});
}
let config = read_openai_provider_config("llm")?;
let active_llm = CredentialsVault::get_active_llm();
let model = CredentialsVault::get(CredentialAccount::ArkModelId)
.map_err(|e| e.to_string())?
.filter(|s| !s.is_empty())
.ok_or_else(|| "llmModelMissing".to_string())?;
let provider = OpenAICompatibleLLMProvider::new(
OpenAICompatibleConfig::new(
active_llm.clone(),
active_llm,
config.base_url,
config.api_key,
model,
)
.with_thinking_enabled(llm_thinking_enabled),
);
provider
.polish(
"验证连接",
PolishMode::Raw,
&[],
"",
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
None,
&[],
)
.await
.map(|_| ())
.map_err(|e| match e {
LLMError::InvalidResponse { status, .. } => {
format!("providerHttpStatus:{status}")
}
other => other.to_string(),
})
}
async fn validate_asr_provider() -> Result<(), String> {
let active_asr = CredentialsVault::get_active_asr();
if active_asr_is_keyless_for_validation(&active_asr) {
return Ok(());
}
if active_asr == crate::asr::bailian::PROVIDER_ID {
return validate_bailian_asr_provider().await;
}
if active_asr == crate::asr::mimo::PROVIDER_ID {
return validate_mimo_asr_provider().await;
}
let config = read_openai_provider_config("asr")?;
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| "asrModelMissing".to_string())?;
validate_asr_transcription(&config, model.trim()).await
}
async fn validate_mimo_asr_provider() -> Result<(), String> {
let config = read_openai_provider_config("asr")?;
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::mimo::DEFAULT_MODEL.to_string());
let asr = crate::asr::MimoBatchASR::new(config.api_key, config.base_url, model);
crate::recorder::AudioConsumer::consume_pcm_chunk(
&asr,
&encode_wav_16k_mono_silence(250)[44..],
);
asr.transcribe()
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
async fn validate_bailian_asr_provider() -> Result<(), String> {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.map_err(|e| e.to_string())?
.unwrap_or_default();
if api_key.trim().is_empty() {
return Err("API Key 为空".to_string());
}
let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_ENDPOINT.to_string());
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_MODEL.to_string());
let vocabulary_id = CredentialsVault::get(CredentialAccount::AsrVocabularyId)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty());
let asr = std::sync::Arc::new(crate::asr::BailianRealtimeASR::new(
crate::asr::BailianCredentials {
api_key,
endpoint,
model,
vocabulary_id,
},
));
asr.open_session().await.map_err(|e| e.to_string())?;
crate::asr::AudioConsumer::consume_pcm_chunk(
&*asr,
&vec![0u8; crate::asr::bailian::TARGET_AUDIO_CHUNK_BYTES],
);
asr.send_last_frame().await.map_err(|e| e.to_string())?;
asr.await_final_result()
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
pub(crate) fn active_asr_is_keyless_for_validation(provider: &str) -> bool {
provider == crate::asr::local::PROVIDER_ID
|| active_foundry_asr_is_supported(provider)
|| active_sherpa_asr_is_supported(provider)
}
pub(crate) fn active_foundry_asr_is_supported(provider: &str) -> bool {
#[cfg(target_os = "windows")]
{
provider == FOUNDRY_LOCAL_PROVIDER_ID
}
#[cfg(not(target_os = "windows"))]
{
let _ = provider;
false
}
}
pub(crate) fn active_sherpa_asr_is_supported(provider: &str) -> bool {
#[cfg(target_os = "windows")]
{
provider == crate::asr::local::sherpa::PROVIDER_ID
}
#[cfg(not(target_os = "windows"))]
{
let _ = provider;
false
}
}
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 client = http_client_builder(&url, 20)
.build()
.map_err(|_| "providerClientInitFailed".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()));
}
if let Some(len) = response.content_length() {
if len as usize > MAX_ASR_VALIDATE_BODY_BYTES {
return Err("providerResponseTooLarge".to_string());
}
}
use futures_util::StreamExt;
let mut body = Vec::<u8>::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| "providerReadResponseFailed".to_string())?;
if body.len().saturating_add(chunk.len()) > MAX_ASR_VALIDATE_BODY_BYTES {
return Err("providerResponseTooLarge".to_string());
}
body.extend_from_slice(&chunk);
}
let json: Value = serde_json::from_slice(&body).map_err(|_| "asrInvalidJson".to_string())?;
if !json.is_object() || json.get("text").is_none() {
return Err("asrMissingTextField".to_string());
}
Ok(())
}
pub(crate) fn asr_transcriptions_url(base_url: &str) -> Result<String, String> {
let parsed = reqwest::Url::parse(base_url.trim()).map_err(|_| "endpointInvalid".to_string())?;
// Work on the URL path only so we don't corrupt query parameters.
let mut url = parsed.clone();
let path = parsed.path().trim_end_matches('/');
let next_path = if path.ends_with("/audio/transcriptions") {
path.to_string()
} else if path.ends_with("/audio") {
format!("{path}/transcriptions")
} else if let Some(prefix) = path.strip_suffix("/chat/completions") {
format!("{prefix}/audio/transcriptions")
} else {
format!("{path}/audio/transcriptions")
};
url.set_path(&next_path);
Ok(url.to_string())
}
fn encode_wav_16k_mono_silence(duration_ms: u32) -> Vec<u8> {
let sample_rate: u32 = 16_000;
let num_channels: u16 = 1;
let bits_per_sample: u16 = 16;
let bytes_per_sample = (bits_per_sample / 8) as usize;
let samples = (sample_rate as usize * duration_ms as usize) / 1000;
let pcm_len = samples * bytes_per_sample;
let data_size = pcm_len as u32;
let byte_rate = sample_rate * num_channels as u32 * bits_per_sample as u32 / 8;
let block_align = num_channels * bits_per_sample / 8;
let chunk_size = 36 + data_size;
let mut wav = Vec::with_capacity(44 + pcm_len);
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&chunk_size.to_le_bytes());
wav.extend_from_slice(b"WAVE");
wav.extend_from_slice(b"fmt ");
wav.extend_from_slice(&16u32.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&num_channels.to_le_bytes());
wav.extend_from_slice(&sample_rate.to_le_bytes());
wav.extend_from_slice(&byte_rate.to_le_bytes());
wav.extend_from_slice(&block_align.to_le_bytes());
wav.extend_from_slice(&bits_per_sample.to_le_bytes());
wav.extend_from_slice(b"data");
wav.extend_from_slice(&data_size.to_le_bytes());
wav.resize(44 + pcm_len, 0);
wav
}
pub(crate) async fn fetch_provider_models(config: &ProviderConfig) -> Result<Vec<String>, String> {
let url = models_url(&config.base_url);
let is_gemini = is_gemini_base_url(&config.base_url);
log::info!("[provider-check] GET {url} (gemini={is_gemini})");
let client = http_client_builder(&config.base_url, 15)
.build()
.map_err(|e| format!("HTTP client 初始化失败: {e}"))?;
let mut request = client.get(&url);
if !config.api_key.trim().is_empty() {
// 谷歌原生 generativelanguage.googleapis.com 不识别 Bearer Authorization,
// 必须用 x-goog-api-key 头。其它 OpenAI 兼容 provider 仍走 Bearer。
if is_gemini {
request = request.header("x-goog-api-key", config.api_key.as_str());
} else {
request = request.header("Authorization", format!("Bearer {}", config.api_key));
}
}
let response = request.send().await.map_err(|e| {
if e.is_timeout() {
"请求超时".to_string()
} else {
format!("网络错误: {e}")
}
})?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| format!("读取响应失败: {e}"))?;
if !status.is_success() {
return Err(format!("providerHttpStatus:{}", status.as_u16()));
}
if is_gemini {
parse_gemini_model_ids(&body)
} else {
parse_model_ids(&body)
}
}
pub(crate) fn is_gemini_base_url(base_url: &str) -> bool {
base_url.contains("generativelanguage.googleapis.com")
}
pub(crate) fn models_url(base_url: &str) -> String {
let trimmed = base_url.trim().trim_end_matches('/');
if trimmed.ends_with("/models") {
return trimmed.to_string();
}
if let Some(prefix) = trimmed.strip_suffix("/chat/completions") {
return format!("{prefix}/models");
}
format!("{trimmed}/models")
}
pub(crate) fn parse_model_ids(body: &str) -> Result<Vec<String>, String> {
let json: Value =
serde_json::from_str(body).map_err(|e| format!("模型列表不是有效 JSON: {e}"))?;
let data = json
.get("data")
.and_then(|v| v.as_array())
.ok_or_else(|| "模型列表缺少 data 数组".to_string())?;
let mut models = data
.iter()
.filter_map(|item| item.get("id").and_then(|id| id.as_str()))
.map(str::trim)
.filter(|id| !id.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
models.sort();
models.dedup();
Ok(models)
}
/// 谷歌 v1beta/models 响应形状:`{models: [{name: "models/gemini-2.5-flash",
/// supportedGenerationMethods: ["generateContent", ...], ...}, ...]}`。
/// 与 OpenAI `{data: [{id: "..."}]}` 不兼容,所以单独解析;name 字段去掉
/// "models/" 前缀后即是 ProviderTools「拉取模型」按钮可直接写入 ark.model_id
/// 的字符串。
///
/// 过滤:只保留声明支持 `generateContent` 的模型——Google 的 model list 同时
/// 暴露 embedding (`gemini-embedding-2`)、TTS、image 等不支持
/// generateContent 的家族;用户选中那种 ID 后 polish 必失败(PR #398 pr_agent
/// 漏洞反馈)。`supportedGenerationMethods` 字段缺失时保守保留——某些 preview
/// 模型可能未暴露这个字段,宁误显示也不要把新模型挡在外面。
pub(crate) fn parse_gemini_model_ids(body: &str) -> Result<Vec<String>, String> {
let json: Value =
serde_json::from_str(body).map_err(|e| format!("模型列表不是有效 JSON: {e}"))?;
let models = json
.get("models")
.and_then(|v| v.as_array())
.ok_or_else(|| "Gemini 模型列表缺少 models 数组".to_string())?;
let mut ids = models
.iter()
.filter(|item| {
match item
.get("supportedGenerationMethods")
.and_then(|v| v.as_array())
{
Some(methods) => methods
.iter()
.any(|m| m.as_str() == Some("generateContent")),
None => true, // 字段缺失:保守包含
}
})
.filter_map(|item| item.get("name").and_then(|n| n.as_str()))
.map(|name| {
name.strip_prefix("models/")
.unwrap_or(name)
.trim()
.to_string()
})
.filter(|id| !id.is_empty())
.collect::<Vec<_>>();
ids.sort();
ids.dedup();
Ok(ids)
}
@@ -0,0 +1,67 @@
use super::*;
#[tauri::command]
pub fn get_qa_hotkey_label(coord: CoordinatorState<'_>) -> String {
coord.qa_hotkey_label()
}
/// 设置 QA 快捷键并热更新 monitor。
/// 传入 `None` 形式的字段不在这里支持——前端用 `binding == null` 时调下面的
/// "disable" 写法(写 prefs.qa_hotkey = None)即可。
#[tauri::command]
pub fn set_qa_hotkey(
coord: CoordinatorState<'_>,
binding: Option<ShortcutBinding>,
) -> Result<(), String> {
if let Some(binding) = binding.as_ref() {
crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?;
if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") {
return Err("Shift 单键目前只能用于翻译快捷键".into());
}
}
let mut prefs = coord.prefs().get();
if let Some(binding) = binding.as_ref() {
reject_dictation_qa_hotkey_overlap(&prefs.dictation_hotkey, binding)?;
reject_qa_translation_hotkey_overlap(binding, &prefs.translation_hotkey)?;
if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() {
reject_qa_switch_style_hotkey_overlap(binding, switch_style)?;
}
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
reject_qa_open_app_hotkey_overlap(binding, open_app)?;
}
}
prefs.qa_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
coord.update_qa_hotkey_binding();
Ok(())
}
/// 用户点 ✕ / 按 Esc 关 QA 浮窗。
#[tauri::command]
pub fn qa_window_dismiss(coord: CoordinatorState<'_>) {
coord.qa_window_dismiss();
}
/// 用户点 📌 / 取消 📌。pinned=true 时浮窗不会自动隐藏。
#[tauri::command]
pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) {
coord.qa_window_pin(pinned);
}
/// 用户点 ✕ / 按 Esc 关 Less Computer 浮窗。
#[tauri::command]
pub fn less_computer_window_dismiss(coord: CoordinatorState<'_>) {
coord.less_computer_window_dismiss();
}
/// 前端按内容测高后回传高度,后端 clamp + bottom-anchored 重新摆放浮窗。
#[tauri::command]
pub fn less_computer_window_resize(coord: CoordinatorState<'_>, height: f64) {
coord.less_computer_window_resize(height);
}
/// 内联审批卡的 Approve / Deny 回执。token 关联到等待中的拦截动作。
#[tauri::command]
pub fn less_computer_approve(coord: CoordinatorState<'_>, token: String, approved: bool) {
coord.less_computer_approve(&token, approved);
}
@@ -0,0 +1,412 @@
use super::*;
#[tauri::command]
pub fn get_settings(coord: CoordinatorState<'_>) -> UserPreferences {
coord.prefs().get()
}
#[tauri::command]
pub fn get_default_style_system_prompts() -> StyleSystemPrompts {
StyleSystemPrompts::default()
}
pub(crate) trait SettingsWriter {
fn read_settings(&self) -> UserPreferences;
fn write_settings(&self, prefs: UserPreferences) -> Result<(), String>;
fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String>;
fn refresh_dictation_hotkey(&self);
fn refresh_qa_hotkey(&self);
fn refresh_combo_hotkey(&self);
fn refresh_translation_hotkey(&self);
fn refresh_switch_style_hotkey(&self);
fn refresh_open_app_hotkey(&self);
fn refresh_coding_agent_hotkey(&self);
}
impl SettingsWriter for Coordinator {
fn read_settings(&self) -> UserPreferences {
self.prefs().get()
}
fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> {
self.prefs().set(prefs).map_err(|e| e.to_string())
}
fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String> {
self.sync_active_asr_provider_to_vault(provider)
}
fn refresh_dictation_hotkey(&self) {
self.update_hotkey_binding();
}
fn refresh_qa_hotkey(&self) {
self.update_qa_hotkey_binding();
}
fn refresh_combo_hotkey(&self) {
self.update_combo_hotkey_binding();
}
fn refresh_translation_hotkey(&self) {
self.update_translation_hotkey_binding();
}
fn refresh_switch_style_hotkey(&self) {
self.update_switch_style_hotkey_binding();
}
fn refresh_open_app_hotkey(&self) {
self.update_open_app_hotkey_binding();
}
fn refresh_coding_agent_hotkey(&self) {
self.update_coding_agent_hotkey_binding();
}
}
impl<T: SettingsWriter + ?Sized> SettingsWriter for Arc<T> {
fn read_settings(&self) -> UserPreferences {
(**self).read_settings()
}
fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> {
(**self).write_settings(prefs)
}
fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String> {
(**self).sync_active_asr_provider(provider)
}
fn refresh_dictation_hotkey(&self) {
(**self).refresh_dictation_hotkey();
}
fn refresh_qa_hotkey(&self) {
(**self).refresh_qa_hotkey();
}
fn refresh_combo_hotkey(&self) {
(**self).refresh_combo_hotkey();
}
fn refresh_translation_hotkey(&self) {
(**self).refresh_translation_hotkey();
}
fn refresh_switch_style_hotkey(&self) {
(**self).refresh_switch_style_hotkey();
}
fn refresh_open_app_hotkey(&self) {
(**self).refresh_open_app_hotkey();
}
fn refresh_coding_agent_hotkey(&self) {
(**self).refresh_coding_agent_hotkey();
}
}
pub(crate) fn persist_settings<T: SettingsWriter>(
coord: &T,
mut prefs: UserPreferences,
) -> Result<(), String> {
let mut previous = coord.read_settings();
sync_dictation_hotkey_legacy_fields(&mut previous);
sync_dictation_hotkey_legacy_fields(&mut prefs);
reject_hotkey_collisions(&prefs)?;
let dictation_shortcut_changed = previous.dictation_hotkey != prefs.dictation_hotkey;
let dictation_mode_changed = previous.hotkey.mode != prefs.hotkey.mode;
let qa_changed = previous.qa_hotkey != prefs.qa_hotkey;
let translation_changed = previous.translation_hotkey != prefs.translation_hotkey;
let switch_style_changed = previous.switch_style_hotkey != prefs.switch_style_hotkey;
let open_app_changed = previous.open_app_hotkey != prefs.open_app_hotkey;
let coding_agent_changed = previous.coding_agent_enabled != prefs.coding_agent_enabled
|| previous.coding_agent_voice_hotkey != prefs.coding_agent_voice_hotkey;
let active_asr_provider_changed = previous.active_asr_provider != prefs.active_asr_provider;
let active_asr_provider = prefs.active_asr_provider.clone();
if active_asr_provider_changed {
coord.sync_active_asr_provider(&active_asr_provider)?;
}
if let Err(error) = coord.write_settings(prefs.clone()) {
if active_asr_provider_changed {
if let Err(rollback_error) =
coord.sync_active_asr_provider(&previous.active_asr_provider)
{
coord.write_settings(prefs).map_err(|roll_forward_error| {
format!(
"{error}; additionally failed to restore active ASR provider: {rollback_error}; additionally failed to preserve active ASR provider consistency: {roll_forward_error}"
)
})?;
} else {
return Err(error);
}
} else {
return Err(error);
}
}
if dictation_shortcut_changed || dictation_mode_changed {
coord.refresh_dictation_hotkey();
}
if dictation_shortcut_changed {
coord.refresh_combo_hotkey();
}
if qa_changed {
coord.refresh_qa_hotkey();
}
if translation_changed {
coord.refresh_translation_hotkey();
}
if switch_style_changed {
coord.refresh_switch_style_hotkey();
}
if open_app_changed {
coord.refresh_open_app_hotkey();
}
if coding_agent_changed {
coord.refresh_coding_agent_hotkey();
}
Ok(())
}
#[tauri::command]
pub fn set_settings(
coord: CoordinatorState<'_>,
app: AppHandle,
tray_microphones: State<'_, TrayMicrophoneMenuState>,
mut prefs: UserPreferences,
) -> Result<(), String> {
let packs = coord.style_packs().list().map_err(|e| e.to_string())?;
sync_style_pack_preferences(&mut prefs, &packs);
// 广播给所有 webview。issue #205:QaPanel 跑在独立 webview,
// 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
persist_settings(&*coord, prefs.clone())?;
// refresh_tray_microphone_menu 内部会调用 NSStatusItem.set_menu,必须在主线程上跑。
// set_settings 本身是同步 Tauri command,在 IPC handler 线程上执行;从这里直接调
// 会触发 macOS 主线程断言或在 dispatch 队列上死锁,导致整个 UI 无响应(用户改
// 偏好后所有按键都没反应即此根因)。dispatch 到主线程后立即返回,IPC 线程不阻塞。
let app_for_main = app.clone();
let prefs_for_main = prefs.clone();
let _ = app.run_on_main_thread(move || {
if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) {
log::warn!("[tray] refresh microphone menu after settings save failed: {err}");
let tray_state = app_for_main.state::<TrayMicrophoneMenuState>();
sync_tray_microphone_selection(
&tray_state.lock(),
&prefs_for_main.microphone_device_name,
);
}
});
// 抑制 unused 警告:tray_microphones 现在改在闭包里通过 app.state 取,
// 但函数签名保留 State 入参,以便 Tauri 在调用前注入。
let _ = tray_microphones;
let _ = app.emit("prefs:changed", &prefs);
Ok(())
}
// ─────────────────────────── release channel (Beta opt-in) ───────────────────────────
//
// 渠道偏好的写入路径跟 set_settings 复用 persist_settings:保持热键兜底归一化
// 跟其他 prefs 写入一致,且写完后 emit "prefs:changed",让前端跨 webview 同步。
//
// 更新:plugin-updater 2.10.1 的 Builder 现在暴露 .endpoints() runtime API(CLAUDE.md
// 当年记的"不支持"已不成立)。本节配合 `app_check_update_with_channel` 命令实现
// Beta auto-update:Stable 渠道 → 走 tauri.conf 的默认 endpoints;Beta 渠道 →
// fetch_latest_beta_release 拿最新 prerelease tag → 拼成 -beta manifest URL →
// builder.endpoints(vec![url]).build().check()。Stable 用户绝对不会撞到 Beta 包
// (Beta tag 的 manifest 文件名带 `-beta` 后缀,跟 Stable manifest 在 GitHub
// Release assets 里物理分离)。
#[tauri::command]
pub fn get_update_channel(coord: CoordinatorState<'_>) -> UpdateChannel {
coord.prefs().get().update_channel
}
#[tauri::command]
pub fn set_update_channel(
coord: CoordinatorState<'_>,
app: AppHandle,
channel: UpdateChannel,
) -> Result<(), String> {
let mut prefs = coord.prefs().get();
if prefs.update_channel == channel {
return Ok(());
}
prefs.update_channel = channel;
persist_settings(&*coord, prefs.clone())?;
let _ = app.emit("prefs:changed", &prefs);
Ok(())
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LatestBetaRelease {
pub tag_name: String,
pub html_url: String,
pub published_at: String,
}
/// 拉 GitHub Releases atom feed 找最新 Beta release(tag 以 `-beta-tauri` 结尾)。
///
/// 历史:之前用 `api.github.com/repos/.../releases` REST 端点,**未认证 60 req/h/IP**,
/// 多人多次切 Beta toggle 很容易撞 403 rate limit(用户报"获取 Beta 版本信息失败"
/// 即是这个)。换成 `releases.atom` 后是公开页面 + CDN cache,没有同等 rate 限制。
/// Atom feed 不显式标 prerelease,但项目约定 tag 后缀 `-beta-tauri` 必为 Beta,
/// 所以只用 tag 后缀过滤就够了。
///
/// 返回 `Ok(None)` = 当前没发过 Beta 版;`Err(String)` = 网络/解析故障。
#[tauri::command]
pub async fn fetch_latest_beta_release() -> Result<Option<LatestBetaRelease>, String> {
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()));
}
let body = resp
.text()
.await
.map_err(|e| format!("read atom body: {e}"))?;
Ok(parse_latest_beta_from_atom(&body))
}
/// 简单字符串解析 atom feed,避免引 XML 库。每个 `<entry>...</entry>` 内含一行
/// `<link rel="alternate" type="text/html" href=".../releases/tag/<tag>"/>`,
/// 用 `/releases/tag/` 这个唯一锚点抓 tag。
pub(crate) fn parse_latest_beta_from_atom(body: &str) -> Option<LatestBetaRelease> {
for entry in body.split("<entry>").skip(1) {
let entry_body = entry
.split_once("</entry>")
.map(|(b, _)| b)
.unwrap_or(entry);
let needle = "/releases/tag/";
let tag_start = match entry_body.find(needle) {
Some(i) => i + needle.len(),
None => continue,
};
let tag_after = &entry_body[tag_start..];
let tag_end = tag_after
.find(|c: char| c == '"' || c == '<' || c == ' ' || c == '/')
.unwrap_or(tag_after.len());
let tag_name = tag_after[..tag_end].to_string();
if !tag_name.ends_with("-beta-tauri") {
continue;
}
let html_url = format!("https://github.com/appergb/openless/releases/tag/{tag_name}");
let published_at =
extract_between(entry_body, "<updated>", "</updated>").unwrap_or_default();
return Some(LatestBetaRelease {
tag_name,
html_url,
published_at,
});
}
None
}
fn extract_between(haystack: &str, open: &str, close: &str) -> Option<String> {
let start = haystack.find(open)? + open.len();
let end = haystack[start..].find(close)?;
Some(haystack[start..start + end].to_string())
}
// ─────────────────────── Channel-aware updater check ────────────────────────
//
// 替换前端原来直接 import('@tauri-apps/plugin-updater').check() 的路径:
// - Stable 渠道:builder 不动 endpoints,沿用 tauri.conf 配的 stable manifest URL。
// - Beta 渠道:先 fetch_latest_beta_release 拿最新 prerelease tag,拼成 -beta manifest
// URL(同时给一对 mirror + direct),再 builder.endpoints(vec![url])?.build()?.check()。
//
// 返回的 Metadata 形状与 plugin-updater 的 JS UpdateMetadata 完全一致(rid +
// currentVersion 等驼峰字段),前端可以直接 `new Update(metadata)` 复用 plugin
// 的 download / install / close 实现,无需我们自己写下载和签名校验。
//
// 物理隔离:Beta tag 推出来的 manifest 文件名带 `-beta` 后缀(参见 release-tauri.yml
// 第 382 行注释),跟 Stable 的 `latest-{tgt}-{arch}.json` 在 GitHub Release assets
// 里是分开的两份文件 —— 即使代码逻辑写错把 Beta URL 传给 Stable 用户,HTTP 也是
// 直接 404,绝不会拿到错档。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppUpdateMetadata {
pub rid: tauri::ResourceId,
pub current_version: String,
pub version: String,
pub date: Option<String>,
pub body: Option<String>,
/// 原始 manifest JSON——`new Update(metadata)` 在 JS 那边会校验它存在;
/// 我们透传 plugin 自己 check 时拿到的字段。
pub raw_json: serde_json::Value,
}
/// 决定 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 = 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));
}
if matches!(channel, UpdateChannel::Beta) {
let urls = resolve_beta_manifest_endpoints().await?;
builder = builder
.endpoints(urls)
.map_err(|e| format!("set beta endpoints: {e}"))?;
}
let updater = builder.build().map_err(|e| format!("build updater: {e}"))?;
let update = updater
.check()
.await
.map_err(|e| format!("check update failed: {e}"))?;
let Some(update) = update else {
return Ok(None);
};
// date 字段透传需要引 time crate;前端 AutoUpdate.tsx 实际并不用 date,所以这里
// 直接置 None,避免拉一个新 dep 进 src-tauri/Cargo.toml。
let metadata = AppUpdateMetadata {
current_version: update.current_version.clone(),
version: update.version.clone(),
date: None,
body: update.body.clone(),
raw_json: update.raw_json.clone(),
rid: webview.resources_table().add(update),
};
Ok(Some(metadata))
}
/// 把 fetch_latest_beta_release 找到的最新 prerelease tag 拼成 -beta manifest URL 对。
/// 顺序:先镜像(fastgit.cc 代理 GitHub),后直连 —— 跟 tauri.conf 现有 Stable
/// endpoints 一致,让国内访问优先打到 CDN。
async fn resolve_beta_manifest_endpoints() -> Result<Vec<url::Url>, String> {
let Some(latest) = fetch_latest_beta_release().await? else {
return Err("尚未发布过 Beta 版本".to_string());
};
let tag = latest.tag_name;
// {{target}} / {{arch}} 占位符由 plugin 在 check 时替换。Rust raw string 用 r#""#
// 不需要转义双花括号,比 format! 干净。
let mirror = format!(
"https://fastgit.cc/https://github.com/appergb/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta-mirror.json"
);
let direct = format!(
"https://github.com/appergb/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta.json"
);
let mirror_url = url::Url::parse(&mirror).map_err(|e| format!("parse beta mirror url: {e}"))?;
let direct_url = url::Url::parse(&direct).map_err(|e| format!("parse beta direct url: {e}"))?;
Ok(vec![mirror_url, direct_url])
}
@@ -0,0 +1,192 @@
use super::*;
pub(crate) fn active_sherpa_model_from_prefs(prefs: &UserPreferences) -> String {
if sherpa_model_alias_is_known(&prefs.sherpa_onnx_model) {
prefs.sherpa_onnx_model.clone()
} else {
SHERPA_DEFAULT_MODEL_ALIAS.to_string()
}
}
pub(crate) fn validate_sherpa_model_alias(model_alias: &str) -> Result<(), String> {
if sherpa_model_alias_is_known(model_alias) {
Ok(())
} else {
Err(format!("unknown sherpa-onnx model alias: {model_alias}"))
}
}
pub(crate) fn normalize_sherpa_language_hint(language_hint: &str) -> Result<String, String> {
let normalized = language_hint.trim().to_lowercase();
if normalized.is_empty()
|| normalized
.chars()
.all(|c| c.is_ascii_lowercase() || c == '-')
{
Ok(normalized)
} else {
Err("language hint must be empty or BCP-47 lowercase code".to_string())
}
}
#[tauri::command]
pub async fn sherpa_onnx_asr_status(
coord: CoordinatorState<'_>,
runtime: State<'_, Arc<SherpaOnnxRuntime>>,
) -> Result<SherpaRuntimeStatus, String> {
let prefs = coord.prefs().get();
let active_model = active_sherpa_model_from_prefs(&prefs);
Ok(runtime.status_snapshot(&active_model).await)
}
#[tauri::command]
pub async fn sherpa_onnx_asr_catalog(
runtime: State<'_, Arc<SherpaOnnxRuntime>>,
) -> Result<Vec<SherpaCatalogModel>, String> {
runtime
.catalog_snapshot()
.await
.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub async fn sherpa_onnx_asr_fetch_remote_info(
model_alias: String,
mirror: Option<String>,
) -> Result<SherpaRemoteInfo, String> {
validate_sherpa_model_alias(&model_alias)?;
let mirror = mirror.as_deref().map(Mirror::from_str).unwrap_or_default();
fetch_sherpa_remote_info(&model_alias, mirror)
.await
.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub fn sherpa_onnx_asr_download_model(
app: AppHandle,
manager: State<'_, Arc<SherpaDownloadManager>>,
model_alias: String,
mirror: Option<String>,
) -> Result<(), String> {
validate_sherpa_model_alias(&model_alias)?;
let mirror = mirror.as_deref().map(Mirror::from_str).unwrap_or_default();
manager.start(app, model_alias, mirror);
Ok(())
}
#[tauri::command]
pub fn sherpa_onnx_asr_cancel_download(
manager: State<'_, Arc<SherpaDownloadManager>>,
model_alias: String,
) -> Result<(), String> {
validate_sherpa_model_alias(&model_alias)?;
manager.cancel(&model_alias);
Ok(())
}
#[tauri::command]
pub fn sherpa_onnx_asr_set_model(
coord: CoordinatorState<'_>,
model_alias: String,
) -> Result<(), String> {
validate_sherpa_model_alias(&model_alias)?;
let mut prefs = coord.prefs().get();
if prefs.sherpa_onnx_model == model_alias {
return Ok(());
}
prefs.sherpa_onnx_model = model_alias;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn sherpa_onnx_asr_set_language_hint(
coord: CoordinatorState<'_>,
language_hint: String,
) -> Result<(), String> {
let normalized = normalize_sherpa_language_hint(&language_hint)?;
let mut prefs = coord.prefs().get();
if prefs.sherpa_onnx_language_hint == normalized {
return Ok(());
}
prefs.sherpa_onnx_language_hint = normalized;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn sherpa_onnx_asr_prepare(
app: AppHandle,
runtime: State<'_, Arc<SherpaOnnxRuntime>>,
model_alias: String,
) -> Result<String, String> {
validate_sherpa_model_alias(&model_alias)?;
let progress_app = app.clone();
let result = runtime
.ensure_loaded_with_progress(&model_alias, move |payload| {
emit_sherpa_prepare_progress(&progress_app, payload);
})
.await;
match result {
Ok(loaded) => Ok(loaded),
Err(error) => {
let message = format!("{error:#}");
emit_sherpa_prepare_progress(
&app,
SherpaPrepareProgressPayload::failed(
model_alias,
"sherpa-onnx prepare failed",
message.clone(),
),
);
Err(message)
}
}
}
#[tauri::command]
pub fn sherpa_onnx_asr_cancel_prepare(
runtime: State<'_, Arc<SherpaOnnxRuntime>>,
) -> Result<(), String> {
runtime.request_cancel_prepare();
Ok(())
}
#[tauri::command]
pub async fn sherpa_onnx_asr_release(
runtime: State<'_, Arc<SherpaOnnxRuntime>>,
) -> Result<(), String> {
runtime.release_now().await.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub fn sherpa_onnx_asr_model_dir(model_alias: String) -> Result<String, String> {
validate_sherpa_model_alias(&model_alias)?;
SherpaOnnxRuntime::model_dir_for_alias(&model_alias)
.map(|path| path.display().to_string())
.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub async fn sherpa_onnx_asr_delete_model(
runtime: State<'_, Arc<SherpaOnnxRuntime>>,
model_alias: String,
) -> Result<(), String> {
validate_sherpa_model_alias(&model_alias)?;
runtime
.delete_model(&model_alias)
.await
.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub fn sherpa_onnx_asr_reveal_model_dir(model_alias: String) -> Result<(), String> {
validate_sherpa_model_alias(&model_alias)?;
let dir = SherpaOnnxRuntime::model_dir_for_alias(&model_alias).map_err(|e| format!("{e:#}"))?;
std::fs::create_dir_all(&dir).map_err(|e| format!("create {} failed: {e}", dir.display()))?;
open_path_in_file_manager(&dir)
}
fn emit_sherpa_prepare_progress(app: &AppHandle, payload: SherpaPrepareProgressPayload) {
if let Err(error) = app.emit("sherpa-onnx-asr-prepare-progress", payload) {
log::warn!("[sherpa-asr] emit prepare progress failed: {error}");
}
}
@@ -0,0 +1,287 @@
use super::*;
fn refresh_tray_menu_async(app: &AppHandle) {
let app_for_main = app.clone();
let _ = app.run_on_main_thread(move || {
if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) {
log::warn!("[tray] refresh after style change failed: {err}");
}
});
}
fn emit_prefs_changed(app: &AppHandle, prefs: &UserPreferences) {
let _ = app.emit("prefs:changed", prefs);
let _ = app.emit_to("main", "prefs:changed", prefs);
}
pub(crate) fn sync_style_pack_prefs_and_persist(
coord: &Coordinator,
app: &AppHandle,
mut prefs: UserPreferences,
) -> Result<UserPreferences, String> {
let packs = coord.style_packs().list().map_err(|e| e.to_string())?;
sync_style_pack_preferences(&mut prefs, &packs);
coord
.prefs()
.set(prefs.clone())
.map_err(|e| e.to_string())?;
emit_prefs_changed(app, &prefs);
refresh_tray_menu_async(app);
Ok(prefs)
}
pub(crate) fn activate_style_pack_by_id(
coord: &Coordinator,
app: &AppHandle,
id: &str,
) -> Result<StylePack, String> {
let mut prefs = coord.prefs().get();
let pack = coord.style_packs().get(id).map_err(|e| e.to_string())?;
log::info!(
"[style-pack] activate helper requested id={} kind={:?} base_mode={:?} enabled={}",
pack.id,
pack.kind,
pack.base_mode,
pack.enabled
);
if !pack.enabled {
coord
.style_packs()
.set_enabled(id, true)
.map_err(|e| e.to_string())?;
}
prefs.active_style_pack_id = id.to_string();
sync_style_pack_prefs_and_persist(coord, app, prefs)?;
log::info!("[style-pack] activate helper applied id={id}");
coord
.style_packs()
.get(id)
.map(|mut pack| {
pack.active = true;
pack
})
.map_err(|e| e.to_string())
}
pub(crate) fn activate_builtin_style_mode(
coord: &Coordinator,
app: &AppHandle,
mode: PolishMode,
) -> Result<(), String> {
let pack_id = builtin_style_pack_id(mode).to_string();
log::info!(
"[style-pack] activate builtin mode helper mode={:?} pack_id={}",
mode,
pack_id
);
let _ = activate_style_pack_by_id(coord, app, &pack_id)?;
Ok(())
}
// ─────────────────────────── style packs ───────────────────────────
#[tauri::command]
pub fn list_style_packs(coord: CoordinatorState<'_>) -> Result<Vec<StylePack>, String> {
let prefs = coord.prefs().get();
coord
.style_packs()
.list_with_active(&prefs.active_style_pack_id)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn create_style_pack_from_template(
coord: CoordinatorState<'_>,
app: AppHandle,
template: StylePack,
) -> Result<StylePack, String> {
log::info!(
"[style-pack] command create_from_template name={} base_mode={:?}",
template.name,
template.base_mode
);
let created = coord
.style_packs()
.create_from_template(template)
.map_err(|e| e.to_string())?;
let prefs = coord.prefs().get();
let _ = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?;
Ok(created)
}
#[tauri::command]
pub fn save_style_pack(
coord: CoordinatorState<'_>,
app: AppHandle,
style_pack: StylePack,
) -> Result<StylePack, String> {
log::info!(
"[style-pack] command save id={} kind={:?} base_mode={:?}",
style_pack.id,
style_pack.kind,
style_pack.base_mode
);
let saved = coord
.style_packs()
.upsert(style_pack)
.map_err(|e| e.to_string())?;
if saved.kind == StylePackKind::Builtin {
let prefs = coord.prefs().get();
let _ = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?;
}
Ok(saved)
}
#[tauri::command]
pub fn preview_style_pack_runtime(
coord: CoordinatorState<'_>,
style_pack: StylePack,
) -> Result<StylePackRuntimeDiagnostics, String> {
log::info!(
"[style-pack] command preview_runtime id={} base_mode={:?} prompt_chars={}",
style_pack.id,
style_pack.base_mode,
style_pack.prompt.chars().count()
);
Ok(coord.preview_style_pack_runtime(&style_pack))
}
#[tauri::command]
pub fn set_active_style_pack(
coord: CoordinatorState<'_>,
app: AppHandle,
id: String,
) -> Result<StylePack, String> {
activate_style_pack_by_id(&coord, &app, &id)
}
#[tauri::command]
pub fn set_style_pack_enabled(
coord: CoordinatorState<'_>,
app: AppHandle,
id: String,
enabled: bool,
) -> Result<Vec<StylePack>, String> {
log::info!(
"[style-pack] command set_enabled requested id={} enabled={}",
id,
enabled
);
coord
.style_packs()
.set_enabled(&id, enabled)
.map_err(|e| e.to_string())?;
let mut prefs = coord.prefs().get();
if !enabled && prefs.active_style_pack_id == id {
prefs.active_style_pack_id = default_active_style_pack_id();
}
let prefs = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?;
coord
.style_packs()
.list_with_active(&prefs.active_style_pack_id)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn reset_builtin_style_pack(
coord: CoordinatorState<'_>,
app: AppHandle,
id: String,
) -> Result<StylePack, String> {
log::info!("[style-pack] command reset_builtin requested id={id}");
let saved = coord
.style_packs()
.reset_builtin(&id)
.map_err(|e| e.to_string())?;
let prefs = coord.prefs().get();
let _ = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?;
Ok(saved)
}
#[tauri::command]
pub fn delete_style_pack(
coord: CoordinatorState<'_>,
app: AppHandle,
id: String,
) -> Result<(), String> {
let mut prefs = coord.prefs().get();
log::info!("[style-pack] command delete requested id={id}");
coord
.style_packs()
.remove_imported(&id)
.map_err(|e| e.to_string())?;
if prefs.active_style_pack_id == id {
prefs.active_style_pack_id = default_active_style_pack_id();
let _ = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?;
} else {
refresh_tray_menu_async(&app);
}
Ok(())
}
#[tauri::command]
pub fn import_style_pack_from_zip(
coord: CoordinatorState<'_>,
zip_path: String,
) -> Result<StylePack, String> {
log::info!("[style-pack] command import requested zip_path={zip_path}");
coord
.style_packs()
.import_from_zip(std::path::Path::new(&zip_path))
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn export_style_pack_to_zip(
coord: CoordinatorState<'_>,
id: String,
target_path: String,
) -> Result<String, String> {
log::info!(
"[style-pack] command export requested id={} target_path={}",
id,
target_path
);
coord
.style_packs()
.export_to_zip(&id, std::path::Path::new(&target_path))
.map_err(|e| e.to_string())?;
Ok(target_path)
}
// ─────────────────────────── style toggles (compat) ───────────────────────────
#[tauri::command]
pub fn set_default_polish_mode(
coord: CoordinatorState<'_>,
app: AppHandle,
mode: PolishMode,
) -> Result<(), String> {
activate_builtin_style_mode(&coord, &app, mode)
}
#[tauri::command]
pub fn set_style_enabled(
coord: CoordinatorState<'_>,
app: AppHandle,
mode: PolishMode,
enabled: bool,
) -> Result<(), String> {
let pack_id = builtin_style_pack_id(mode).to_string();
log::info!(
"[style-pack] compat set_style_enabled mode={:?} pack_id={} enabled={}",
mode,
pack_id,
enabled
);
coord
.style_packs()
.set_enabled(&pack_id, enabled)
.map_err(|e| e.to_string())?;
let mut prefs = coord.prefs().get();
if !enabled && prefs.active_style_pack_id == pack_id {
prefs.active_style_pack_id = default_active_style_pack_id();
}
let _ = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?;
Ok(())
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,506 @@
//! ASR 引擎构建与生命周期:麦克风/凭证前置校验、本地模型释放调度(Foundry/sherpa/
//! local-qwen3)、QA-ASR provider 构建(QaAsrStart)、全局超时常量与各 provider 超时,
//! 以及音频线程驱动的 DeferredAsrBridge。
//!
//! 从 `coordinator.rs` 机械拆出(行为保持,仅可见性提升)。schedule_*_release 的
//! session-staleness 守卫与 DeferredAsrBridge 的 attach/flush 交错是并发敏感路径,只搬不改。
//! ActiveAsr/ActiveAsrProviderKind 及其分类器留在父模块(Inner 字段类型)。
use super::*;
pub(crate) fn ensure_microphone_permission(_inner: &Arc<Inner>) -> Result<(), String> {
use crate::permissions::{self, PermissionStatus};
#[cfg(target_os = "windows")]
{
if permissions::windows_microphone_access_explicitly_denied() {
return Err("需要麦克风权限,当前状态: Denied".to_string());
}
return Ok(());
}
let status = permissions::check_microphone();
if matches!(
status,
PermissionStatus::Granted | PermissionStatus::NotApplicable
) {
return Ok(());
}
// 听写路径不抢前台焦点:缺 mic 权限时直接请求系统授权,不再先 show_main_window。
// 用户在设置页手动点“请求权限”仍走 request_microphone_from_foreground,那是显式操作。
// 这里若系统不弹框,后续会通过 capsule error 引导用户主动去权限页处理。详见 #166。
let requested = permissions::request_microphone();
if matches!(
requested,
PermissionStatus::Granted | PermissionStatus::NotApplicable
) {
Ok(())
} else {
Err(format!("需要麦克风权限,当前状态: {requested:?}"))
}
}
pub(crate) fn ensure_asr_credentials() -> Result<(), String> {
let active_asr = CredentialsVault::get_active_asr();
// 本地 Qwen3-ASR 没有"凭据"概念,但需要:(a) macOS 平台 (b) 模型已下载。
if crate::asr::local::is_local_qwen3(&active_asr) {
#[cfg(not(target_os = "macos"))]
{
return Err("本地 ASR 当前仅支持 macOS(Windows 见 issue #256)".to_string());
}
#[cfg(target_os = "macos")]
{
return ensure_local_qwen3_model_ready();
}
}
if crate::asr::local::foundry::is_foundry_local_whisper(&active_asr) {
#[cfg(not(target_os = "windows"))]
{
return Err("Foundry Local Whisper 当前仅支持 Windows".to_string());
}
#[cfg(target_os = "windows")]
{
return Ok(());
}
}
if crate::asr::local::sherpa::is_sherpa_onnx_local(&active_asr) {
#[cfg(not(target_os = "windows"))]
{
return Err("sherpa-onnx local ASR 当前仅支持 Windows".to_string());
}
#[cfg(target_os = "windows")]
{
return Ok(());
}
}
if is_whisper_compatible_provider(&active_asr) || is_bailian_provider(&active_asr) {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
.unwrap_or_default();
if api_key.trim().is_empty() {
return Err("请先在设置中填写 ASR 服务商 API Key".to_string());
}
return Ok(());
}
let creds = read_volc_credentials();
if creds.app_id.trim().is_empty() || creds.access_token.trim().is_empty() {
Err("请先在设置中填写火山引擎 ASR App Key 和 Access Key".to_string())
} else {
Ok(())
}
}
#[cfg(test)]
pub(crate) fn is_keyless_local_asr_provider(id: &str) -> bool {
if crate::asr::local::is_local_qwen3(id) {
return true;
}
#[cfg(target_os = "windows")]
{
crate::asr::local::foundry::is_foundry_local_whisper(id)
|| crate::asr::local::sherpa::is_sherpa_onnx_local(id)
}
#[cfg(not(target_os = "windows"))]
{
let _ = id;
false
}
}
#[cfg(target_os = "macos")]
pub(crate) fn ensure_local_qwen3_model_ready() -> Result<(), String> {
let prefs = || -> Result<crate::types::UserPreferences, String> {
// 这里没法拿到 inner,直接读 preferences.json 即可(Coordinator 写盘后总是同步的)。
crate::persistence::PreferencesStore::new()
.map_err(|e| e.to_string())
.map(|s| s.get())
}()?;
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
.ok_or_else(|| format!("未知的本地模型 id: {}", prefs.local_asr_active_model))?;
if !crate::asr::local::models::is_downloaded(model_id) {
return Err(format!(
"本地模型 {} 未下载完整,请到 设置 → 模型设置 中下载",
model_id.as_str()
));
}
Ok(())
}
/// 一次 dictation 结束后,按 prefs.local_asr_keep_loaded_secs 决定何时释放
/// 内存里的 Qwen3-ASR 引擎。0 = 立即释放;其它值 = sleep N 秒后看 last_used。
/// 多次会话叠加多个 sleep 任务,每个独立 check:只要中间又被使用过就跳过释放。
pub(crate) fn schedule_local_asr_release(inner: &Arc<Inner>) {
let keep_secs = inner.prefs.get().local_asr_keep_loaded_secs;
let cache = Arc::clone(&inner.local_asr_cache);
if keep_secs == 0 {
cache.release_now();
return;
}
let dur = std::time::Duration::from_secs(keep_secs as u64);
tauri::async_runtime::spawn(async move {
tokio::time::sleep(dur).await;
cache.release_if_idle(dur);
});
}
#[cfg(target_os = "windows")]
pub(crate) fn foundry_local_asr_release_keep_secs(inner: &Arc<Inner>) -> u32 {
inner.prefs.get().foundry_local_asr_keep_loaded_secs
}
#[cfg(target_os = "windows")]
#[derive(Clone, Copy)]
pub(crate) enum AsrReleaseSession {
Dictation(SessionId),
Qa(SessionId),
}
#[cfg(target_os = "windows")]
pub(crate) fn asr_release_session_is_current(
inner: &Arc<Inner>,
session: AsrReleaseSession,
) -> bool {
match session {
AsrReleaseSession::Dictation(session_id) => inner.state.lock().session_id == session_id,
AsrReleaseSession::Qa(session_id) => inner.qa_state.lock().session_id == session_id,
}
}
#[cfg(target_os = "windows")]
pub(crate) fn schedule_foundry_local_asr_release(inner: &Arc<Inner>, session: AsrReleaseSession) {
let keep_secs = foundry_local_asr_release_keep_secs(inner);
let runtime = Arc::clone(&inner.foundry_local_runtime);
let inner = Arc::clone(inner);
tauri::async_runtime::spawn(async move {
if keep_secs > 0 {
tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await;
}
if !asr_release_session_is_current(&inner, session) {
return;
}
if let Err(error) = runtime.release_now().await {
log::warn!("[foundry-asr] scheduled release failed: {error:#}");
}
});
}
#[cfg(target_os = "windows")]
pub(crate) fn sherpa_onnx_release_keep_secs(inner: &Arc<Inner>) -> u32 {
inner.prefs.get().sherpa_onnx_keep_loaded_secs
}
/// 与 `schedule_foundry_local_asr_release` 同形:session_id 老旧则不释放,
/// 避免下一轮 session 立即重加载同一个 offline batch 模型。
#[cfg(target_os = "windows")]
pub(crate) fn schedule_sherpa_onnx_release(inner: &Arc<Inner>, session: AsrReleaseSession) {
let keep_secs = sherpa_onnx_release_keep_secs(inner);
let runtime = Arc::clone(&inner.sherpa_onnx_runtime);
let inner = Arc::clone(inner);
tauri::async_runtime::spawn(async move {
if keep_secs > 0 {
tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await;
}
if !asr_release_session_is_current(&inner, session) {
return;
}
if let Err(error) = runtime.release_now().await {
log::warn!("[sherpa-asr] scheduled release failed: {error:#}");
}
});
}
#[cfg(target_os = "macos")]
pub(crate) async fn build_local_qwen3(
inner: &Arc<Inner>,
) -> anyhow::Result<Arc<crate::asr::local::LocalQwenAsr>> {
let prefs = inner.prefs.get();
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
.ok_or_else(|| anyhow::anyhow!("未知本地模型 id: {}", prefs.local_asr_active_model))?;
let dir = crate::asr::local::models::model_dir(model_id)?;
let app = inner
.app
.lock()
.clone()
.ok_or_else(|| anyhow::anyhow!("AppHandle 未绑定"))?;
// 走缓存:如果已有同 id 的引擎在内存里就直接复用,避免每次会话都重加载
// 1.2GB+ 模型。第一次加载阻塞数秒,spawn_blocking 不卡 tokio runtime。
let cache = Arc::clone(&inner.local_asr_cache);
let mid = model_id.as_str().to_string();
let engine = tauri::async_runtime::spawn_blocking(move || cache.get_or_load(&mid, &dir))
.await
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
Ok(Arc::new(crate::asr::local::LocalQwenAsr::new(app, engine)))
}
pub(crate) enum QaAsrStart {
Volcengine {
asr: Arc<VolcengineStreamingASR>,
bridge: Arc<DeferredAsrBridge>,
},
Bailian {
asr: Arc<BailianRealtimeASR>,
bridge: Arc<DeferredAsrBridge>,
},
Ready {
active: ActiveAsr,
consumer: Arc<dyn crate::recorder::AudioConsumer>,
},
}
impl QaAsrStart {
pub(crate) fn active_asr(&self) -> ActiveAsr {
match self {
QaAsrStart::Volcengine { asr, .. } => ActiveAsr::Volcengine(Arc::clone(asr)),
QaAsrStart::Bailian { asr, .. } => ActiveAsr::Bailian(Arc::clone(asr)),
QaAsrStart::Ready { active, .. } => active.clone(),
}
}
pub(crate) fn recorder_consumer(&self) -> Arc<dyn crate::recorder::AudioConsumer> {
match self {
QaAsrStart::Volcengine { bridge, .. } => Arc::clone(bridge) as _,
QaAsrStart::Bailian { bridge, .. } => Arc::clone(bridge) as _,
QaAsrStart::Ready { consumer, .. } => Arc::clone(consumer),
}
}
pub(crate) async fn open_streaming_session(&self) -> Result<(), String> {
match self {
QaAsrStart::Volcengine { asr, bridge } => {
asr.open_session().await.map_err(|e| e.to_string())?;
let target: Arc<dyn crate::asr::AudioConsumer> = Arc::clone(asr) as _;
let flushed = bridge.attach(target);
log::info!("[coord] QA ASR connected; flushed {flushed} deferred audio bytes");
Ok(())
}
QaAsrStart::Bailian { asr, bridge } => {
asr.open_session().await.map_err(|e| e.to_string())?;
let target: Arc<dyn crate::asr::AudioConsumer> = Arc::clone(asr) as _;
let flushed = bridge.attach(target);
log::info!(
"[coord] QA Bailian ASR connected; flushed {flushed} deferred audio bytes"
);
Ok(())
}
QaAsrStart::Ready { .. } => Ok(()),
}
}
}
pub(crate) async fn build_qa_asr_start(
inner: &Arc<Inner>,
active_asr: &str,
) -> Result<QaAsrStart, String> {
#[cfg(target_os = "windows")]
if foundry::is_foundry_local_whisper(active_asr) {
let prefs = inner.prefs.get();
let model_alias = if foundry::model_alias_is_known(&prefs.foundry_local_asr_model) {
prefs.foundry_local_asr_model.clone()
} else {
foundry::DEFAULT_MODEL_ALIAS.to_string()
};
let language_hint = prefs.foundry_local_asr_language_hint.trim().to_string();
let language_hint = if language_hint.is_empty() {
None
} else {
Some(language_hint)
};
let local = Arc::new(FoundryLocalWhisperAsr::new(
Arc::clone(&inner.foundry_local_runtime),
model_alias,
prefs.foundry_local_runtime_source.clone(),
language_hint,
));
let active = ActiveAsr::FoundryLocalWhisper(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
return Ok(QaAsrStart::Ready { active, consumer });
}
#[cfg(target_os = "windows")]
if sherpa::is_sherpa_onnx_local(active_asr) {
let prefs = inner.prefs.get();
let model_alias = if sherpa::model_alias_is_known(&prefs.sherpa_onnx_model) {
prefs.sherpa_onnx_model.clone()
} else {
sherpa::DEFAULT_MODEL_ALIAS.to_string()
};
let language_hint = prefs.sherpa_onnx_language_hint.trim().to_string();
let language_hint = if language_hint.is_empty() {
None
} else {
Some(language_hint)
};
let token_handler = inner.app.lock().clone().map(|app| {
Arc::new(move |piece: String| {
if let Err(error) = app.emit("local-asr-token", piece) {
log::warn!("[sherpa-asr] emit token failed: {error}");
}
}) as crate::asr::local::sherpa_provider::SherpaTokenHandler
});
let local = SherpaOnnxAsr::new_for_model(
Arc::clone(&inner.sherpa_onnx_runtime),
model_alias,
language_hint,
token_handler,
)
.await
.map_err(|e| format!("sherpa-onnx init failed: {e}"))?;
let local = Arc::new(local);
let active = ActiveAsr::SherpaOnnxLocal(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
return Ok(QaAsrStart::Ready { active, consumer });
}
#[cfg(target_os = "macos")]
if crate::asr::local::is_local_qwen3(active_asr) {
let local = build_local_qwen3(inner)
.await
.map_err(|e| format!("local ASR init failed: {e}"))?;
let active = ActiveAsr::Local(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
return Ok(QaAsrStart::Ready { active, consumer });
}
match active_asr_provider_kind(active_asr) {
ActiveAsrProviderKind::Bailian => Ok(QaAsrStart::Bailian {
asr: Arc::new(BailianRealtimeASR::new(read_bailian_credentials())),
bridge: Arc::new(DeferredAsrBridge::new()),
}),
ActiveAsrProviderKind::Mimo => {
let (api_key, base_url, model) = read_mimo_credentials();
let mimo = Arc::new(MimoBatchASR::new(api_key, base_url, model));
let active = ActiveAsr::Mimo(Arc::clone(&mimo));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = mimo;
Ok(QaAsrStart::Ready { active, consumer })
}
ActiveAsrProviderKind::WhisperCompatible => {
let (api_key, base_url, model) = read_whisper_credentials();
let whisper_prompt =
crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner));
let whisper = Arc::new(
WhisperBatchASR::new(
api_key,
base_url,
model,
whisper_prompt,
batch_asr_chunk_limit_ms(active_asr),
whisper_supports_verbose_json(active_asr),
)
.with_request_format(whisper_request_format(active_asr)),
);
let active = ActiveAsr::Whisper(Arc::clone(&whisper));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = whisper;
Ok(QaAsrStart::Ready { active, consumer })
}
ActiveAsrProviderKind::Volcengine => Ok(QaAsrStart::Volcengine {
asr: Arc::new(VolcengineStreamingASR::new(
read_volc_credentials(),
enabled_hotwords(inner),
)),
bridge: Arc::new(DeferredAsrBridge::new()),
}),
}
}
/// Coordinator 全局超时保护:防止 ASR await_final_result() 永远挂起。
/// 设置为 15 秒(比 ASR 的 12 秒 FINAL_RESULT_TIMEOUT 稍长),
/// 只在 ASR 超时机制失效时作为最后的防线触发。
pub(crate) const COORDINATOR_GLOBAL_TIMEOUT_SECS: u64 = 15;
#[cfg(target_os = "windows")]
pub(crate) fn foundry_audio_transcribe_timeout_duration() -> std::time::Duration {
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
}
/// 本地 Qwen3-ASR 的动态转写超时。固定 15 秒在长录音(≥ 30s)+ 慢机器
/// (RTF ≈ 0.3–0.5)上必然超时把整段内容丢掉。改用 max(15, ceil(audio_s
/// × 0.6) + 10):基础保留 15s 兜住短录音;长录音按音频长度的 0.6 倍 +
/// 10s 余量,覆盖 RTF ≤ 0.5 的机器。
pub(crate) fn local_qwen_transcribe_timeout(audio_secs: f64) -> std::time::Duration {
let secs = ((audio_secs * 0.6).ceil() as u64)
.saturating_add(10)
.max(COORDINATOR_GLOBAL_TIMEOUT_SECS);
std::time::Duration::from_secs(secs)
}
/// sherpa-onnx offline batch 暂与 Foundry 同档;后续按 Windows 真机 CPU/模型
/// 实测结果再调整。
#[cfg(target_os = "windows")]
pub(crate) fn sherpa_audio_transcribe_timeout_duration() -> std::time::Duration {
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
}
// ─────────────────────────── audio bridge ───────────────────────────
pub(crate) struct DeferredAsrBridge {
state: Mutex<DeferredAsrState>,
}
pub(crate) struct DeferredAsrState {
target: Option<Arc<dyn crate::asr::AudioConsumer>>,
pending_audio: Vec<u8>,
attaching: bool,
}
impl DeferredAsrBridge {
pub(crate) fn new() -> Self {
Self {
state: Mutex::new(DeferredAsrState {
target: None,
pending_audio: Vec::new(),
attaching: false,
}),
}
}
pub(crate) fn attach(&self, target: Arc<dyn crate::asr::AudioConsumer>) -> usize {
let mut flushed_bytes = 0;
{
let mut state = self.state.lock();
state.attaching = true;
}
loop {
let pending = {
let mut state = self.state.lock();
if state.pending_audio.is_empty() {
state.target = Some(Arc::clone(&target));
state.attaching = false;
return flushed_bytes;
}
std::mem::take(&mut state.pending_audio)
};
flushed_bytes += pending.len();
target.consume_pcm_chunk(&pending);
}
}
}
impl crate::recorder::AudioConsumer for DeferredAsrBridge {
fn consume_pcm_chunk(&self, pcm: &[u8]) {
let target = {
let mut state = self.state.lock();
if state.attaching {
state.pending_audio.extend_from_slice(pcm);
return;
}
if let Some(target) = state.target.as_ref() {
Some(Arc::clone(target))
} else {
state.pending_audio.extend_from_slice(pcm);
None
}
};
if let Some(target) = target {
target.consume_pcm_chunk(pcm);
}
}
}
@@ -0,0 +1,358 @@
//! Capsule HUD 呈现层:显示策略(no-activate / fallback)、emit_capsule 状态广播、
//! 布局快照与底部居中定位、自动回 Idle 调度。
//!
//! 从 `coordinator.rs` 机械拆出(行为保持,仅可见性提升)。emit_capsule 由 cpal 音频
//! 回调线程 ~30Hz 调用,run_on_main_thread marshaling 原样保留(SIGTRAP 规避)。
use super::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CapsuleShowStrategy {
NoActivate,
FallbackShow,
}
pub(crate) fn capsule_show_strategy_for_platform() -> CapsuleShowStrategy {
// ⚠️ 如果改下面的 cfg 列表,**必须**同步更新单元测试
// `capsule_show_strategy_matches_platform_activation_contract` 的两组 cfg —
// 否则 Linux CI 直接红(PR #451 即是这种漏改)。
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
CapsuleShowStrategy::NoActivate
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
CapsuleShowStrategy::FallbackShow
}
}
static CAPSULE_NO_ACTIVATE_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false);
static CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED: AtomicBool = AtomicBool::new(false);
static CAPSULE_FIRST_SHOW_LOGGED: AtomicBool = AtomicBool::new(false);
// #470 诊断 v2:capsule webview 句柄取不到时的一次性门,区分「窗口压根没创建」(A0)。
static CAPSULE_WINDOW_MISSING_LOGGED: AtomicBool = AtomicBool::new(false);
/// 给 #470 诊断日志用的 capsule 状态短名。显式枚举每个变体到 &'static str,
/// 不走 `Debug` —— 哪天 CapsuleState 加了 `String` 字段,`:?` 会把 ASR / polish
/// 内容意外灌进日志(pr_agent 提的 forward-looking 隐患);这里只输出状态名。
pub(crate) fn capsule_state_log_name(state: CapsuleState) -> &'static str {
match state {
CapsuleState::Idle => "idle",
CapsuleState::Recording => "recording",
CapsuleState::Transcribing => "transcribing",
CapsuleState::Polishing => "polishing",
CapsuleState::Done => "done",
CapsuleState::Cancelled => "cancelled",
CapsuleState::Error => "error",
}
}
pub(crate) fn show_capsule_window_for_recording<R: tauri::Runtime>(
app: &AppHandle<R>,
window: &tauri::WebviewWindow<R>,
) {
let mut needs_fallback = true;
if capsule_show_strategy_for_platform() == CapsuleShowStrategy::NoActivate {
needs_fallback = !show_capsule_window_no_activate(app, window);
if needs_fallback && !CAPSULE_NO_ACTIVATE_FALLBACK_WARNED.swap(true, Ordering::SeqCst) {
// 产品取舍:no-activate 是 macOS/AeroSpace 的主路径;但如果 ns_window
// 暂不可用,仍优先保住录音反馈,不让用户以为听写没启动。fallback 可能
// 重新触发 workspace 跳转,只在 no-activate 失败时作为降级路径。
log::warn!("[capsule] no-activate show failed; falling back to window.show()");
}
}
if needs_fallback {
if let Err(e) = window.show() {
log::warn!("[capsule] show fallback failed: {e}");
}
}
}
/// 终止态(Done / Cancelled / Error)后延迟 N ms 把胶囊改回 Idle,让浮窗自动消失。
/// 用户点 ✕ / ✓ / 中途出错 / 按 Esc 都走这里,统一 2 秒。
pub(crate) const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000;
/// Toggle 模式下,end_session 将 phase 设为 Idle 后在此时间内禁止新的 begin_session。
/// 避免用户三连按时第 3 次按下误激活新听写(此时胶囊仍在离场动画周期内)。
/// 值取 capsule EXIT_ANIM_MS (360ms) + 余量 ≈ 600ms。
pub(crate) const POST_SESSION_COOLDOWN_MS: u64 = 600;
pub(crate) fn schedule_capsule_idle(inner: &Arc<Inner>, delay_ms: u64) {
let inner_clone = Arc::clone(inner);
async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
// 必须 dictation **和** QA 同时空闲才能隐藏胶囊。否则旧 dictation Done timer
// 的尾巴会在新 QA 录音/思考中把胶囊意外收掉(issue #118 v2 复现)。
let dictation_idle = inner_clone.state.lock().phase == SessionPhase::Idle;
let qa_idle = inner_clone.qa_state.lock().phase == QaPhase::Idle;
if dictation_idle && qa_idle {
emit_capsule(&inner_clone, CapsuleState::Idle, 0.0, 0, None, None);
}
});
}
pub(crate) fn emit_capsule(
inner: &Arc<Inner>,
state: CapsuleState,
level: f32,
elapsed_ms: u64,
message: Option<String>,
inserted_chars: Option<u32>,
) {
// 在 app 句柄校验之前记录,便于无 GUI 的测试断言「按下热键 → 弹了哪种胶囊」。
*inner.last_capsule_state.lock() = Some(state);
let app_opt = inner.app.lock().clone();
let Some(app) = app_opt else { return };
let translation = inner.translation_modifier_seen.load(Ordering::SeqCst);
let operating = inner.state.lock().voice_agent;
let payload = CapsulePayload {
state,
level,
elapsed_ms,
message,
inserted_chars,
translation,
operating,
};
// visible / translation 是「这一帧 capsule:state event 的 payload」内容 ——
// 必须在 call-site(即音频线程触发 emit_capsule 时)就算定,否则 main thread
// 闭包里读到的将是「下一帧」的 state,跟实际下发给 JS 的 payload 不一致。
let visible = !matches!(state, CapsuleState::Idle);
// Linux: 通过 fcitx5 插件在候选词列表下方显示听写状态,不干扰输入法预编辑。
// 只在文本变化时调用 DBus,避免录音中 ~30Hz 的音频电平回调重复调用。
#[cfg(target_os = "linux")]
{
use std::sync::Mutex;
static LAST_AUX: Mutex<Option<String>> = Mutex::new(None);
let aux = match state {
CapsuleState::Idle => None,
CapsuleState::Recording => Some("🎤 收音中..."),
CapsuleState::Transcribing => Some("🔄 识别中..."),
CapsuleState::Polishing => Some("✨ 润色中..."),
CapsuleState::Done => Some("✅ 已插入"),
CapsuleState::Cancelled => Some("— 已取消"),
CapsuleState::Error => Some("❌ 出错"),
};
let mut last = LAST_AUX.lock().unwrap();
if aux != last.as_deref() {
*last = aux.map(String::from);
// 代数计数器:每次状态变化 +1,retry 线程只在自己代数仍为最新时生效。
// 避免 Recording→Idle→Recording 快速切换时多个 retry 重复触发。
static RETRY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
// fetch_add 返回旧值,所以 latest_gen > gen+1 才表示"在我之后又发生了变更"。
let gen = RETRY_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
match aux {
Some(t) => {
log::info!("[capsule] set_aux_down: {t} gen={gen}");
let text = t.to_string();
std::thread::spawn(move || {
let current = LAST_AUX.lock().unwrap().clone();
if current.as_deref() != Some(&text) {
log::info!(
"[capsule] set_aux_down skipped: state changed to {current:?}"
);
return;
}
if let Err(e) = crate::linux_fcitx::set_aux_down(&text) {
log::warn!("[capsule] set_aux_down failed: {e}");
}
});
// 终态(Done/Cancelled/Error)3 秒后自动清除,避免一直跟随焦点。
if matches!(
state,
CapsuleState::Done | CapsuleState::Cancelled | CapsuleState::Error
) {
let text = t.to_string();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(3));
let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst);
if latest_gen > gen + 1 {
return;
}
let current = LAST_AUX.lock().unwrap().clone();
if current.as_deref() != Some(&text) {
return;
}
log::info!("[capsule] auto-clear terminal state: {text}");
let _ = crate::linux_fcitx::set_aux_down("");
*LAST_AUX.lock().unwrap() = None;
});
}
}
None => {
log::info!("[capsule] clear_aux_down gen={gen}");
std::thread::spawn(move || {
let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst);
if latest_gen > gen + 1 {
log::info!(
"[capsule] clear_aux_down skipped: gen {gen}, latest {latest_gen}"
);
return;
}
let current = LAST_AUX.lock().unwrap().clone();
if current.is_some() {
log::info!(
"[capsule] clear_aux_down skipped: state changed to {current:?}"
);
return;
}
if let Err(e) = crate::linux_fcitx::clear_aux_down() {
log::warn!("[capsule] clear_aux_down failed: {e}");
}
});
}
}
}
}
// emit_capsule 会被 cpal process_callback(音频回调线程)调用 ~30 Hz —— 在该
// 线程上调用 NSWindow / HWND API 会撞 macOS dispatch_assert_queue_fail SIGTRAP
// 或者 Win32 SendMessage 死锁。把 window.show/hide + 位置调整 marshal 到主线程;
// app.emit_to 走 Tauri 内部事件总线,本身线程安全,保留同步调用。详见 audit 3.2.2。
//
// show_capsule(用户偏好)在主线程执行时再读 —— 用户可以在录音过程中改设置,
// 闭包入队到真正跑之间窗口上限是一两帧(~16-33ms),用最新值消除 stale-pref
// 闪烁。pr_agent 关注点 — 见 audit follow-up。
let inner_for_main = Arc::clone(inner);
let app_for_main = app.clone();
let _ = app.run_on_main_thread(move || {
let Some(window) = app_for_main.get_webview_window("capsule") else {
// #470 诊断 v2:比 A/B/C 更靠前的暗点 A0 —— capsule webview 句柄取不到
// (窗口未创建/已销毁)。此前静默 return,无法观测。一次性 warn。
if !CAPSULE_WINDOW_MISSING_LOGGED.swap(true, Ordering::SeqCst) {
log::warn!(
"[capsule] capsule webview window not found — emit_capsule show path skipped (state={})",
capsule_state_log_name(state)
);
}
return;
};
let show_capsule = inner_for_main.prefs.get().show_capsule;
// Linux: 不操作胶囊窗口(不 show/hide,不 reposition)。
// 文字通过 fcitx5 插件直接 commit,用户始终在目标 app 中。
#[cfg(target_os = "linux")]
{
return;
}
#[cfg(not(target_os = "linux"))]
{
// 三平台统一:Done / Cancelled / Error 状态保留 ~1.5s toast
// (schedule_capsule_idle 之后会回 Idle 隐藏)。
// Windows 上 linger 的真实问题(截图选中 / 死区 / 拖拽卡顿)由 #140 加的
// `hide_capsule_window_if_present()` Win32 hard-hide 在 visible=false 分支
// 处理,不依赖把 Done/Cancelled/Error 打成 invisible。详见 PR #140 评论。
maybe_position_capsule_bottom_center(&inner_for_main, &window, translation);
if show_capsule && visible {
// 用户报"看不到胶囊"时第一时间能在 log 里确认:胶囊路径有跑、show_capsule
// 开关是 true、当前进入 visible 帧 —— 排除 prefs 没存住 / emit_capsule 没触
// 发 / state 一直 Idle 这几类常见 root cause。issue #470。
if !CAPSULE_FIRST_SHOW_LOGGED.swap(true, Ordering::SeqCst) {
log::info!(
"[capsule] first show this session: show_capsule=true visible=true state={}",
capsule_state_log_name(state)
);
}
show_capsule_window_for_recording(&app_for_main, &window);
// macOS/Windows 优先走 no-activate show,避免录音胶囊抢走当前工作 app 焦点。
// 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。
#[cfg(target_os = "macos")]
crate::restore_main_window_key_if_active(&app_for_main);
} else {
// show_capsule 开关被用户关掉但本次确实想显示(visible=true)的情况:
// 一次性 info log,让用户报"胶囊没显示"时能在日志里一眼看到根因 —— 维护者
// 不必再让用户"去打开设置确认"。issue #470。
if !show_capsule
&& visible
&& !CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED.swap(true, Ordering::SeqCst)
{
log::info!(
"[capsule] suppressed by user toggle: show_capsule=false visible=true state={}",
capsule_state_log_name(state)
);
}
hide_capsule_window_if_present();
let _ = window.hide();
}
}
});
let _ = app.emit_to("capsule", "capsule:state", &payload);
// 主窗口也需要 capsule:state 事件:AudioCueListener 用它触发录音提示音。
// Linux 上胶囊隐藏时提示音仍应工作,所以同时发给 main 窗口。
let _ = app.emit_to("main", "capsule:state", &payload);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CapsuleLayoutState {
translation_active: bool,
monitor_x: i32,
monitor_y: i32,
monitor_width: u32,
monitor_height: u32,
scale_bits: u64,
}
/// 返回胶囊「应该摆放到的显示器」的标识信息。
///
/// 它看的显示器必须和 `position_capsule_bottom_center` 实际定位用的一致:
/// Windows 看「正在输入的 App 所在显示器」,其它平台看胶囊自己的显示器。
/// 这是「是否需要重新定位」去重缓存(`maybe_position_capsule_bottom_center`)
/// 的 key,如果这里看错了显示器,就会出现「输入焦点移到另一块屏、胶囊却没
/// 跟过去」的 bug。
pub(crate) fn capsule_layout_snapshot<R: tauri::Runtime>(
window: &tauri::WebviewWindow<R>,
translation_active: bool,
) -> Option<CapsuleLayoutState> {
// Windows:以「正在输入的 App 所在显示器」为基准。若用胶囊自己的
// current_monitor,输入焦点切到另一块屏时胶囊仍在原屏 → 误判「没变化」
// → 跳过重新定位。
#[cfg(target_os = "windows")]
{
if let Some(mon) = crate::foreground_window_monitor() {
return Some(CapsuleLayoutState {
translation_active,
monitor_x: mon.left,
monitor_y: mon.top,
monitor_width: (mon.right - mon.left).max(0) as u32,
monitor_height: (mon.bottom - mon.top).max(0) as u32,
scale_bits: mon.scale.to_bits(),
});
}
// 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor。
}
let monitor = window.current_monitor().ok().flatten()?;
Some(CapsuleLayoutState {
translation_active,
monitor_x: monitor.position().x,
monitor_y: monitor.position().y,
monitor_width: monitor.size().width,
monitor_height: monitor.size().height,
scale_bits: monitor.scale_factor().to_bits(),
})
}
pub(crate) fn maybe_position_capsule_bottom_center<R: tauri::Runtime>(
inner: &Arc<Inner>,
window: &tauri::WebviewWindow<R>,
translation_active: bool,
) {
let Some(next) = capsule_layout_snapshot(window, translation_active) else {
return;
};
{
let last = inner.capsule_layout.lock();
if last.as_ref() == Some(&next) {
return;
}
}
if crate::position_capsule_bottom_center(window, translation_active).is_ok() {
let mut last = inner.capsule_layout.lock();
*last = Some(next);
}
}
@@ -0,0 +1,925 @@
//! 听写 / QA / combo / 翻译 / action / window 热键的 supervisor / bridge / 事件处理循环。
//!
//! 从 `coordinator.rs` 机械拆出(行为保持,仅可见性提升)。所有自由函数/类型
//! 仍通过 `use super::*` 看到父模块的 `Inner` / `ActiveAsr` / 兄弟 helper。
use super::*;
pub(crate) fn hotkey_supervisor_loop(inner: Arc<Inner>) {
let mut attempts: u32 = 0;
let capability = HotkeyMonitor::capability();
loop {
if inner.shutdown.load(Ordering::SeqCst) {
return;
}
let prefs = inner.prefs.get();
if inner.hotkey.lock().is_some() {
return;
}
// Linux: 启动前检查 fcitx5 插件是否可用
#[cfg(target_os = "linux")]
if !crate::linux_fcitx::available() {
*inner.hotkey_status.lock() = HotkeyStatus {
adapter: capability.adapter,
state: HotkeyStatusState::Failed,
message: Some("fcitx5 插件不可用 — 请确保 fcitx5 已安装且在运行".into()),
last_error: Some(crate::types::HotkeyInstallError {
code: "fcitx5_unavailable".into(),
message: "fcitx5 插件 DBus 接口无响应".into(),
}),
};
log::warn!("[hotkey-supervisor] fcitx5 plugin unavailable, retrying...");
attempts += 1;
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
*inner.hotkey_status.lock() = HotkeyStatus {
adapter: capability.adapter,
state: HotkeyStatusState::Starting,
message: Some(format!("正在安装全局快捷键监听(第 {} 次)", attempts + 1)),
last_error: None,
};
let trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey)
.unwrap_or(crate::types::HotkeyTrigger::Custom);
let binding = crate::types::HotkeyBinding {
trigger,
mode: prefs.hotkey.mode,
keys: None,
};
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
#[cfg(target_os = "linux")]
let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone());
match HotkeyMonitor::start(binding, tx) {
Ok(monitor) => {
let adapter = monitor.kind();
*inner.hotkey.lock() = Some(monitor);
if let Some(monitor) = inner.hotkey.lock().as_ref() {
let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner);
monitor.update_modifier_shortcuts(qa_trigger, translation_trigger);
}
*inner.hotkey_status.lock() = HotkeyStatus {
adapter,
state: HotkeyStatusState::Installed,
message: Some(format!("{} 已安装", adapter.display_name())),
last_error: None,
};
log::info!(
"[coord] hotkey listener installed (after {} attempt(s))",
attempts + 1
);
let inner_clone = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-hotkey-bridge".into())
.spawn(move || hotkey_bridge_loop(inner_clone, rx))
.ok();
// Linux: 启动 fcitx5 插件信号监听作为热键源。
#[cfg(target_os = "linux")]
{
let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner);
let custom_key = custom_dictation_key_string(&inner);
crate::linux_fcitx::start_dictation_signal_listener(
fcitx_tx,
fcitx_binding.clone(),
qa_trigger,
translation_trigger,
custom_key,
);
if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom {
sync_custom_dictation_to_plugin(&inner);
} else {
crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding);
}
}
return;
}
Err(e) => {
attempts += 1;
let error_message = e.message.clone();
*inner.hotkey_status.lock() = HotkeyStatus {
adapter: capability.adapter,
state: HotkeyStatusState::Failed,
message: Some(error_message.clone()),
last_error: Some(e),
};
if attempts <= 3 || attempts % 10 == 0 {
log::warn!(
"[coord] hotkey listener attempt #{attempts} failed: {}; retrying in 3s",
error_message
);
}
std::thread::sleep(std::time::Duration::from_secs(3));
}
}
}
}
// ─────────────────────────── QA hotkey supervisor ───────────────────────────
pub(crate) fn qa_hotkey_supervisor_loop(inner: Arc<Inner>) {
let mut attempts: u32 = 0;
loop {
if inner.shutdown.load(Ordering::SeqCst) {
return;
}
// 用户已经把 QA 关掉就睡着等 prefs 改动;改动通过 update_qa_hotkey_binding 唤醒。
let binding = match inner.prefs.get().qa_hotkey.clone() {
Some(b) => b,
None => {
inner.qa_hotkey.lock().take();
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
};
if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() {
inner.qa_hotkey.lock().take();
if let Some(monitor) = inner.hotkey.lock().as_ref() {
let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner);
monitor.update_modifier_shortcuts(qa_trigger, translation_trigger);
}
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
if inner.qa_hotkey.lock().is_some() {
// 已注册成功 → 不重复装;睡 5s 复查( binding 变化由 update 路径手动触发 )。
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
// global-hotkey crate 在 macOS 走 Carbon RegisterEventHotKey,要求 manager
// 在主线程构造,否则 register() 看起来 Ok 但事件根本不会派发——这是 issue #118
// PR #119 第一版漏掉的关键步骤,导致用户按了 hotkey 完全无反应。这里通过
// run_on_main_thread 把 QaHotkeyMonitor::start 跳到主线程跑,结果再回 channel。
let app = inner.app.lock().clone();
let app = match app {
Some(a) => a,
None => {
// 启动期 AppHandle 还没 bind,再等。
std::thread::sleep(std::time::Duration::from_secs(1));
continue;
}
};
let (tx, rx) = mpsc::channel::<QaHotkeyEvent>();
let (init_tx, init_rx) = mpsc::sync_channel::<Result<QaHotkeyMonitor, QaHotkeyError>>(1);
let binding_for_main = binding.clone();
let _ = app.run_on_main_thread(move || {
let result = QaHotkeyMonitor::start(binding_for_main, tx);
let _ = init_tx.send(result);
});
// run_on_main_thread 是 fire-and-forget;等主线程跑完结果回来。给 5s 上限避免
// 主线程繁忙时 supervisor 永久阻塞。
let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(r) => r,
Err(_) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!(
"[coord] QA hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试"
);
}
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
};
match init_result {
Ok(monitor) => {
*inner.qa_hotkey.lock() = Some(monitor);
log::info!(
"[coord] QA hotkey listener installed on main thread (after {} attempt(s))",
attempts + 1
);
let inner_clone = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-qa-hotkey-bridge".into())
.spawn(move || qa_hotkey_bridge_loop(inner_clone, rx))
.ok();
attempts = 0;
}
Err(e) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!("[coord] QA hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试");
}
std::thread::sleep(std::time::Duration::from_secs(3));
}
}
}
}
pub(crate) fn qa_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<QaHotkeyEvent>) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
let inner_cloned = Arc::clone(&inner);
match evt {
QaHotkeyEvent::Pressed => {
async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await });
}
}
}
}
// ─────────────────────────── combo hotkey supervisor ───────────────────────────
pub(crate) fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
let mut attempts: u32 = 0;
loop {
if inner.shutdown.load(Ordering::SeqCst) {
return;
}
// 读当前 prefs
let prefs = inner.prefs.get();
if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() {
// 不是 Custom → 睡着等 prefs 改动
take_combo_hotkey_on_main_thread(&inner);
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
let binding = prefs.dictation_hotkey.clone();
if is_unconfigured_shortcut(&binding) {
take_combo_hotkey_on_main_thread(&inner);
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
if inner.combo_hotkey.lock().is_some() {
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
let app = inner.app.lock().clone();
let app = match app {
Some(a) => a,
None => {
std::thread::sleep(std::time::Duration::from_secs(1));
continue;
}
};
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
let (init_tx, init_rx) =
mpsc::sync_channel::<Result<ComboHotkeyMonitor, ComboHotkeyError>>(1);
let binding_for_main = binding.clone();
let _ = app.run_on_main_thread(move || {
let result = ComboHotkeyMonitor::start(binding_for_main, tx);
let _ = init_tx.send(result);
});
let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(r) => r,
Err(_) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!(
"[coord] combo hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试"
);
}
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
};
match init_result {
Ok(monitor) => {
*inner.combo_hotkey.lock() = Some(monitor);
log::info!(
"[coord] combo hotkey listener installed on main thread (after {} attempt(s))",
attempts + 1
);
let inner_clone = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-combo-hotkey-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(inner_clone, rx))
.ok();
attempts = 0;
}
Err(e) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!("[coord] combo hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试");
}
std::thread::sleep(std::time::Duration::from_secs(3));
}
}
}
}
pub(crate) fn combo_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<ComboHotkeyEvent>) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
let inner_cloned = Arc::clone(&inner);
match evt {
// P0 #468/#475: 同 hotkey_bridge_loop —— Pressed/Released 必须串行 await,
// 否则 latch 竞态导致 combo 快捷键二次按键失效。
ComboHotkeyEvent::Pressed => {
async_runtime::block_on(async {
handle_pressed_edge(&inner_cloned).await;
});
}
ComboHotkeyEvent::Released => {
async_runtime::block_on(async {
handle_released_edge(&inner_cloned).await;
});
}
}
}
}
pub(crate) fn translation_hotkey_supervisor_loop(inner: Arc<Inner>) {
let mut attempts: u32 = 0;
loop {
if inner.shutdown.load(Ordering::SeqCst) {
return;
}
let binding = inner.prefs.get().translation_hotkey;
if is_builtin_translation_shift(&binding)
|| crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some()
{
take_translation_hotkey_on_main_thread(&inner);
if let Some(monitor) = inner.hotkey.lock().as_ref() {
let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner);
monitor.update_modifier_shortcuts(qa_trigger, translation_trigger);
}
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
if inner.translation_hotkey.lock().is_some() {
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
let app = match inner.app.lock().clone() {
Some(a) => a,
None => {
std::thread::sleep(std::time::Duration::from_secs(1));
continue;
}
};
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
let (init_tx, init_rx) =
mpsc::sync_channel::<Result<ComboHotkeyMonitor, ComboHotkeyError>>(1);
let binding_for_main = binding.clone();
let _ = app.run_on_main_thread(move || {
let result = ComboHotkeyMonitor::start(binding_for_main, tx);
let _ = init_tx.send(result);
});
let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(r) => r,
Err(_) => {
attempts += 1;
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
};
match init_result {
Ok(monitor) => {
*inner.translation_hotkey.lock() = Some(monitor);
let inner_clone = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-translation-hotkey-bridge".into())
.spawn(move || translation_hotkey_bridge_loop(inner_clone, rx))
.ok();
attempts = 0;
}
Err(e) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!(
"[coord] translation hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"
);
}
std::thread::sleep(std::time::Duration::from_secs(3));
}
}
}
}
pub(crate) fn update_translation_hotkey_on_main_thread(
inner: Arc<Inner>,
binding: crate::types::ShortcutBinding,
) -> Result<(), ComboHotkeyError> {
if let Some(monitor) = inner.translation_hotkey.lock().as_ref() {
return monitor.update_binding(binding);
}
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
let monitor = ComboHotkeyMonitor::start(binding, tx)?;
*inner.translation_hotkey.lock() = Some(monitor);
let bridge_inner = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-translation-hotkey-bridge".into())
.spawn(move || translation_hotkey_bridge_loop(bridge_inner, rx))
.map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn bridge thread: {e}")))?;
Ok(())
}
pub(crate) fn translation_hotkey_bridge_loop(
inner: Arc<Inner>,
rx: mpsc::Receiver<ComboHotkeyEvent>,
) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
if matches!(evt, ComboHotkeyEvent::Pressed) {
mark_translation_modifier_seen(&inner);
}
}
}
pub(crate) fn action_hotkey_supervisor_loop(inner: Arc<Inner>, kind: ActionHotkeyKind) {
let mut attempts: u32 = 0;
loop {
if inner.shutdown.load(Ordering::SeqCst) {
return;
}
// None = 用户主动停用:反注册并睡着等 prefs 改动(由 update 路径唤醒)。
let Some(binding) = action_hotkey_binding(&inner, kind) else {
take_action_hotkey_on_main_thread(&inner, kind);
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
};
if is_modifier_only_shortcut(&binding) {
take_action_hotkey_on_main_thread(&inner, kind);
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
if action_hotkey_slot(&inner, kind).lock().is_some() {
std::thread::sleep(std::time::Duration::from_secs(5));
continue;
}
let app = match inner.app.lock().clone() {
Some(a) => a,
None => {
std::thread::sleep(std::time::Duration::from_secs(1));
continue;
}
};
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
let (init_tx, init_rx) =
mpsc::sync_channel::<Result<ComboHotkeyMonitor, ComboHotkeyError>>(1);
let binding_for_main = binding.clone();
let _ = app.run_on_main_thread(move || {
let result = ComboHotkeyMonitor::start(binding_for_main, tx);
let _ = init_tx.send(result);
});
let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(r) => r,
Err(_) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!(
"[coord] action hotkey {kind:?} 第 {attempts} 次注册超时;3s 后重试"
);
}
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
};
match init_result {
Ok(monitor) => {
*action_hotkey_slot(&inner, kind).lock() = Some(monitor);
log::info!(
"[coord] action hotkey {kind:?} listener installed after {} attempt(s)",
attempts + 1
);
let inner_clone = Arc::clone(&inner);
std::thread::Builder::new()
.name(action_hotkey_bridge_thread_name(kind).into())
.spawn(move || action_hotkey_bridge_loop(inner_clone, rx, kind))
.ok();
attempts = 0;
}
Err(e) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!(
"[coord] action hotkey {kind:?} 第 {attempts} 次注册失败: {e}; 3s 后重试"
);
}
std::thread::sleep(std::time::Duration::from_secs(3));
}
}
}
}
pub(crate) fn action_hotkey_bridge_loop(
inner: Arc<Inner>,
rx: mpsc::Receiver<ComboHotkeyEvent>,
kind: ActionHotkeyKind,
) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
if matches!(evt, ComboHotkeyEvent::Pressed) {
handle_action_hotkey_pressed(&inner, kind);
}
}
}
pub(crate) fn handle_action_hotkey_pressed(inner: &Arc<Inner>, kind: ActionHotkeyKind) {
match kind {
ActionHotkeyKind::SwitchStyle => switch_to_previous_style(inner),
ActionHotkeyKind::OpenApp => {
if let Some(app) = inner.app.lock().clone() {
let app_for_main = app.clone();
let _ = app.run_on_main_thread(move || {
crate::show_main_window(&app_for_main);
});
}
}
}
}
pub(crate) fn switch_to_previous_style(inner: &Arc<Inner>) {
let mut prefs = inner.prefs.get();
let packs = match inner.style_packs.list() {
Ok(packs) => packs,
Err(error) => {
log::warn!("[coord] switch style hotkey failed to load style packs: {error}");
return;
}
};
let enabled: Vec<crate::types::StylePack> =
packs.into_iter().filter(|pack| pack.enabled).collect();
if enabled.len() <= 1 {
log::info!("[coord] switch style hotkey ignored: enabled style count <= 1");
return;
}
let current_index = enabled
.iter()
.position(|pack| pack.id == prefs.active_style_pack_id)
.unwrap_or(0);
let next_index = if current_index == 0 {
enabled.len() - 1
} else {
current_index - 1
};
prefs.active_style_pack_id = enabled[next_index].id.clone();
sync_style_pack_preferences(&mut prefs, &enabled);
if let Err(e) = inner.prefs.set(prefs.clone()) {
log::warn!("[coord] switch style hotkey 保存失败: {e}");
} else {
log::info!(
"[coord] switch style hotkey changed active style pack to {}",
prefs.active_style_pack_id
);
if let Some(app) = inner.app.lock().clone() {
let _ = app.emit("prefs:changed", &prefs);
let _ = app.emit_to("main", "prefs:changed", &prefs);
let app_for_main = app.clone();
let _ = app.run_on_main_thread(move || {
if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) {
log::warn!("[tray] refresh style menu after switch style hotkey failed: {err}");
}
});
}
}
}
/// 在主线程上 `.take()` 一个 combo 监听槽。
///
/// 这三类 combo 热键(combo / translation / coding-agent)的「拿 app → 有则
/// `run_on_main_thread` 里 take,无则直接 take」marshaling 语义逐字相同,只是 take
/// 的字段不同(issue #118:监听器必须在主线程释放)。`slot` 闭包按 `&Arc<Inner>`
/// 取回对应字段,消除三处复制。`action_hotkey` 类已用 `action_hotkey_slot` 单独泛化。
pub(crate) fn take_combo_monitor_on_main_thread(
inner: &Arc<Inner>,
slot: impl Fn(&Arc<Inner>) -> &Mutex<Option<ComboHotkeyMonitor>> + Send + 'static,
) {
let app = inner.app.lock().clone();
if let Some(app) = app {
let inner = Arc::clone(inner);
let _ = app.run_on_main_thread(move || {
slot(&inner).lock().take();
});
} else {
slot(inner).lock().take();
}
}
pub(crate) fn take_combo_hotkey_on_main_thread(inner: &Arc<Inner>) {
take_combo_monitor_on_main_thread(inner, |inner| &inner.combo_hotkey);
}
pub(crate) fn take_translation_hotkey_on_main_thread(inner: &Arc<Inner>) {
take_combo_monitor_on_main_thread(inner, |inner| &inner.translation_hotkey);
}
pub(crate) fn take_action_hotkey_on_main_thread(inner: &Arc<Inner>, kind: ActionHotkeyKind) {
let app = inner.app.lock().clone();
if let Some(app) = app {
let inner = Arc::clone(inner);
let _ = app.run_on_main_thread(move || {
action_hotkey_slot(&inner, kind).lock().take();
});
} else {
action_hotkey_slot(inner, kind).lock().take();
}
}
pub(crate) fn action_hotkey_slot(
inner: &Arc<Inner>,
kind: ActionHotkeyKind,
) -> &Mutex<Option<ComboHotkeyMonitor>> {
match kind {
ActionHotkeyKind::SwitchStyle => &inner.switch_style_hotkey,
ActionHotkeyKind::OpenApp => &inner.open_app_hotkey,
}
}
pub(crate) fn action_hotkey_binding(
inner: &Arc<Inner>,
kind: ActionHotkeyKind,
) -> Option<crate::types::ShortcutBinding> {
let prefs = inner.prefs.get();
match kind {
ActionHotkeyKind::SwitchStyle => prefs.switch_style_hotkey,
ActionHotkeyKind::OpenApp => prefs.open_app_hotkey,
}
}
pub(crate) fn is_modifier_only_shortcut(binding: &crate::types::ShortcutBinding) -> bool {
binding.modifiers.is_empty()
&& (binding.primary.eq_ignore_ascii_case("shift")
|| crate::shortcut_binding::legacy_modifier_trigger(binding).is_some())
}
pub(crate) fn is_unconfigured_shortcut(binding: &crate::types::ShortcutBinding) -> bool {
binding.primary.trim().is_empty()
}
pub(crate) fn action_hotkey_bridge_thread_name(kind: ActionHotkeyKind) -> &'static str {
match kind {
ActionHotkeyKind::SwitchStyle => "openless-switch-style-hotkey-bridge",
ActionHotkeyKind::OpenApp => "openless-open-app-hotkey-bridge",
}
}
pub(crate) fn is_builtin_translation_shift(binding: &crate::types::ShortcutBinding) -> bool {
binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift")
}
/// Linux: 从 prefs 读取自定义组合键,同步到 fcitx5 插件。
#[cfg(target_os = "linux")]
pub(crate) fn custom_dictation_key_string(inner: &Arc<Inner>) -> Option<String> {
let prefs = inner.prefs.get();
let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(&prefs.dictation_hotkey);
if key_string.is_empty() {
None
} else {
Some(key_string)
}
}
#[cfg(target_os = "linux")]
pub(crate) fn sync_custom_dictation_to_plugin(inner: &Arc<Inner>) {
let prefs = inner.prefs.get();
let dictation = &prefs.dictation_hotkey;
let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(dictation);
if key_string.is_empty() {
return;
}
match crate::linux_fcitx::set_custom_dictation_trigger(&key_string) {
Ok(()) => log::info!(
"[fcitx] Synced custom dictation trigger '{}' to plugin",
key_string
),
Err(e) => log::warn!("[fcitx] Failed to sync custom dictation trigger: {e}"),
}
}
pub(crate) fn modifier_shortcut_triggers(
inner: &Arc<Inner>,
) -> (
Option<crate::types::HotkeyTrigger>,
Option<crate::types::HotkeyTrigger>,
) {
let prefs = inner.prefs.get();
let qa_trigger = prefs
.qa_hotkey
.as_ref()
.and_then(crate::shortcut_binding::legacy_modifier_trigger);
let translation_trigger = if is_builtin_translation_shift(&prefs.translation_hotkey) {
None
} else {
crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey)
};
(qa_trigger, translation_trigger)
}
pub(crate) fn mark_translation_modifier_seen(inner: &Arc<Inner>) {
let phase = inner.state.lock().phase;
if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) {
inner
.translation_modifier_seen
.store(true, Ordering::SeqCst);
log::info!("[coord] translation modifier seen during {phase:?}");
}
}
pub(crate) fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
let inner_cloned = Arc::clone(&inner);
match evt {
// P0 #468/#475: Pressed/Released 必须串行处理,否则在 Windows 上 WH_KEYBOARD_LL
// 边沿间隔微秒级 → 两个独立 spawn 的 task 被 work-stealing 调度器并行执行 →
// `hotkey_trigger_held` latch 翻转顺序错乱 → 下次按键被静默吞掉
// (UI 关不掉 / 录音停不下来)。改为 bridge 线程内 block_on 顺序 await,
// recv 的 FIFO 顺序就是 handler 执行顺序。
// 注意:handle_pressed_edge / handle_released_edge 内部走 .await(含网络
// 握手),会暂时阻塞本 bridge 线程;Hold 模式短按时 Released 会排队在 channel
// 里直到 begin_session 完成,但 SessionPhase::Starting 已经有
// request_stop_during_starting 兜底,begin_session 完成进 Listening 后
// bridge 立刻 recv Released → end_session,行为正确,仅有短暂 stop 延迟。
HotkeyEvent::Pressed => {
async_runtime::block_on(async {
handle_pressed_edge(&inner_cloned).await;
});
}
HotkeyEvent::Released => {
async_runtime::block_on(async {
handle_released_edge(&inner_cloned).await;
});
}
HotkeyEvent::Cancelled => {
cancel_session(&inner_cloned);
}
HotkeyEvent::TranslationModifierPressed => {
let translation_hotkey = inner_cloned.prefs.get().translation_hotkey;
if is_builtin_translation_shift(&translation_hotkey)
|| crate::shortcut_binding::legacy_modifier_trigger(&translation_hotkey)
.is_some()
{
mark_translation_modifier_seen(&inner_cloned);
}
}
HotkeyEvent::QaShortcutPressed => {
async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await });
}
}
}
}
pub(crate) fn reset_shortcut_held_state(inner: &Arc<Inner>) {
inner.hotkey_trigger_held.store(false, Ordering::SeqCst);
if let Some(monitor) = inner.hotkey.lock().as_ref() {
monitor.reset_held_state();
}
let prefs = inner.prefs.get();
if let Some(binding) = prefs.qa_hotkey.as_ref() {
if crate::shortcut_binding::legacy_modifier_trigger(binding).is_none() {
if let Some(monitor) = inner.qa_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(binding.clone()) {
log::warn!("[coord] reset QA hotkey latch failed: {e}");
}
}
}
}
if !is_builtin_translation_shift(&prefs.translation_hotkey)
&& crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey).is_none()
{
if let Some(monitor) = inner.translation_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(prefs.translation_hotkey.clone()) {
log::warn!("[coord] reset translation hotkey latch failed: {e}");
}
}
}
if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() {
if !is_modifier_only_shortcut(switch_style) {
if let Some(monitor) = inner.switch_style_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(switch_style.clone()) {
log::warn!("[coord] reset switch-style hotkey latch failed: {e}");
}
}
}
}
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
if !is_modifier_only_shortcut(open_app) {
if let Some(monitor) = inner.open_app_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(open_app.clone()) {
log::warn!("[coord] reset open-app hotkey latch failed: {e}");
}
}
}
}
}
pub(crate) async fn handle_window_hotkey_event(
inner: &Arc<Inner>,
event_type: String,
key: String,
code: String,
repeat: bool,
) -> Result<(), String> {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
return Ok(());
}
if event_type == "keydown" && key == "Escape" {
// Esc 路由(issue #161):QA 浮窗可见时优先取消 QA(不动 dictation);
// 否则走 dictation 取消通路。之前无条件 cancel_session 导致 QA 浮窗
// 按 Esc 杀的是 dictation 而 QA 流还在烧 token。
let qa_active = {
let st = inner.qa_state.lock();
st.panel_visible || st.phase != QaPhase::Idle
};
if qa_active {
close_qa_panel(inner);
} else {
cancel_session(inner);
}
return Ok(());
}
#[cfg(not(target_os = "windows"))]
{
let _ = (inner, event_type, key, code, repeat);
Ok(())
}
#[cfg(target_os = "windows")]
{
if !window_hotkey_fallback_enabled() {
if event_type == "keydown" && !repeat {
log::info!(
"[window-hotkey] ignored because Windows lifecycle owner is the low-level hook"
);
}
return Ok(());
}
let Some(trigger) =
crate::shortcut_binding::legacy_modifier_trigger(&inner.prefs.get().dictation_hotkey)
else {
return Ok(());
};
if !window_key_matches_trigger(trigger, &key, &code) {
return Ok(());
}
match event_type.as_str() {
"keydown" => {
if repeat {
return Ok(());
}
log::info!(
"[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}"
);
handle_pressed_edge(inner).await;
}
"keyup" => {
log::info!("[window-hotkey] released trigger={trigger:?} code={code}");
handle_released_edge(inner).await;
}
_ => {}
}
Ok(())
}
}
pub(crate) fn window_hotkey_fallback_enabled() -> bool {
crate::types::HotkeyCapability::current().explicit_fallback_available
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn window_key_matches_trigger(
trigger: crate::types::HotkeyTrigger,
key: &str,
code: &str,
) -> bool {
use crate::types::HotkeyTrigger;
match trigger {
HotkeyTrigger::RightControl => key == "Control" && code == "ControlRight",
HotkeyTrigger::LeftControl => key == "Control" && code == "ControlLeft",
HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => {
(key == "Alt" || key == "AltGraph") && code == "AltRight"
}
HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltLeft",
HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight",
HotkeyTrigger::Fn => key == "Control" && code == "ControlRight",
// MediaPlayPause 走 WH_KEYBOARD_LL,不走 window hotkey fallback
HotkeyTrigger::MediaPlayPause => false,
// Custom 走 global-hotkey crate,不走 window hotkey fallback
HotkeyTrigger::Custom => false,
}
}
@@ -0,0 +1,598 @@
//! OS 级窗口 & 插入管线:Windows IME 会话(store/take/restore)、TSF 优先插入、
//! 非 TSF 回退,以及焦点目标抓取/恢复、前台 App 抓取、capsule 窗口 no-activate 显示/隐藏。
//!
//! 从 `coordinator.rs` 机械拆出(行为保持,仅可见性提升)。大量 unsafe + Win32/objc2 FFI,
//! 所有 cfg 分支与 SAFETY/issue 注释原样跟随。
use super::*;
#[cfg(target_os = "windows")]
pub(crate) fn store_prepared_windows_ime_session(
slots: &mut Vec<PreparedWindowsImeSessionSlot>,
session_id: SessionId,
prepared: PreparedWindowsImeSession,
) {
slots.retain(|slot| slot.session_id != session_id);
slots.push(PreparedWindowsImeSessionSlot {
session_id,
prepared,
});
}
#[cfg(target_os = "windows")]
pub(crate) fn take_matching_prepared_windows_ime_session(
slots: &mut Vec<PreparedWindowsImeSessionSlot>,
session_id: SessionId,
) -> Option<PreparedWindowsImeSession> {
let index = slots
.iter()
.position(|slot| slot.session_id == session_id)?;
Some(slots.remove(index).prepared)
}
#[cfg(target_os = "windows")]
pub(crate) fn take_current_prepared_windows_ime_session_for_restore(
slots: &mut Vec<PreparedWindowsImeSessionSlot>,
session_id: SessionId,
current_session_id: SessionId,
) -> Option<PreparedWindowsImeSession> {
let prepared = take_matching_prepared_windows_ime_session(slots, session_id)?;
if current_session_id == session_id {
Some(prepared)
} else {
None
}
}
#[cfg(target_os = "windows")]
pub(crate) fn restore_prepared_windows_ime_session(inner: &Arc<Inner>, session_id: SessionId) {
let state = inner.state.lock();
let prepared = {
let mut slot = inner.prepared_windows_ime_session.lock();
take_current_prepared_windows_ime_session_for_restore(
&mut slot,
session_id,
state.session_id,
)
};
if let Some(prepared) = prepared {
inner.windows_ime.restore_session(prepared);
}
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn restore_prepared_windows_ime_session(_inner: &Arc<Inner>, _session_id: SessionId) {}
#[cfg(target_os = "windows")]
pub(crate) async fn insert_with_windows_ime_first(
inner: &Arc<Inner>,
session_id: SessionId,
polished: &str,
restore_clipboard: bool,
allow_non_tsf_insertion_fallback: bool,
paste_shortcut: PasteShortcut,
ime_target: Option<ImeSubmitTarget>,
) -> InsertStatus {
let prepared = {
let mut slot = inner.prepared_windows_ime_session.lock();
take_matching_prepared_windows_ime_session(&mut slot, session_id)
};
let Some(prepared) = prepared else {
log::warn!("[windows-ime] no prepared TSF session for this dictation");
if should_try_non_tsf_insertion_fallback(
allow_non_tsf_insertion_fallback,
InsertStatus::Failed,
) {
return insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut);
}
log::warn!("[windows-ime] non-TSF insertion fallback is disabled; failing insert");
return InsertStatus::Failed;
};
let request = crate::windows_ime_ipc::ImeSubmitRequest {
session_id: Uuid::new_v4().to_string(),
text: polished.to_string(),
created_at: Utc::now().to_rfc3339(),
target: ime_target,
};
let ime_status = match inner.windows_ime.submit_prepared(&prepared, request).await {
Ok(status) => status,
Err(error) => {
log::warn!("[windows-ime] TSF submit failed: {error}");
InsertStatus::Failed
}
};
inner.windows_ime.restore_session(prepared);
if ime_status == InsertStatus::Inserted {
ime_status
} else if should_try_non_tsf_insertion_fallback(allow_non_tsf_insertion_fallback, ime_status) {
insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut)
} else {
log::warn!("[windows-ime] TSF did not insert; non-TSF insertion fallback is disabled");
InsertStatus::Failed
}
}
#[cfg(target_os = "windows")]
pub(crate) fn should_try_non_tsf_insertion_fallback(
allow_non_tsf_insertion_fallback: bool,
ime_status: InsertStatus,
) -> bool {
allow_non_tsf_insertion_fallback && ime_status != InsertStatus::Inserted
}
#[cfg(target_os = "windows")]
pub(crate) fn insert_via_non_tsf_fallback(
inner: &Arc<Inner>,
polished: &str,
_restore_clipboard: bool,
_paste_shortcut: PasteShortcut,
) -> InsertStatus {
let status = finish_non_tsf_insertion_fallback(
|| inner.inserter.insert_via_unicode_keystrokes(polished),
|| inner.inserter.copy_fallback(polished),
);
match status {
InsertStatus::Inserted => {
log::warn!(
"[windows-ime] TSF unavailable; inserted via paced Unicode SendInput fallback"
);
}
InsertStatus::CopiedFallback => {
log::warn!(
"[windows-ime] TSF unavailable; Unicode SendInput failed, left text on clipboard"
);
}
InsertStatus::PasteSent | InsertStatus::Failed => {
log::warn!(
"[windows-ime] TSF unavailable; Unicode SendInput fallback failed and copy fallback failed"
);
}
}
status
}
#[cfg(any(target_os = "windows", test))]
pub(crate) fn finish_non_tsf_insertion_fallback<U, C>(
mut unicode_fallback: U,
mut copy_only_fallback: C,
) -> InsertStatus
where
U: FnMut() -> InsertStatus,
C: FnMut() -> InsertStatus,
{
match unicode_fallback() {
InsertStatus::Inserted => InsertStatus::Inserted,
InsertStatus::PasteSent | InsertStatus::CopiedFallback | InsertStatus::Failed => {
match copy_only_fallback() {
InsertStatus::CopiedFallback => InsertStatus::CopiedFallback,
// TextInserter::copy_fallback is copy-only: success is CopiedFallback.
// Treat any other status as failure so this helper never invents an insert.
InsertStatus::Inserted | InsertStatus::PasteSent | InsertStatus::Failed => {
InsertStatus::Failed
}
}
}
}
}
#[cfg(test)]
mod non_tsf_fallback_tests {
use super::finish_non_tsf_insertion_fallback;
use crate::types::InsertStatus;
#[test]
fn unicode_fallback_runs_before_copy_fallback() {
let mut copy_called = false;
let status = finish_non_tsf_insertion_fallback(
|| InsertStatus::Inserted,
|| {
copy_called = true;
InsertStatus::CopiedFallback
},
);
assert_eq!(status, InsertStatus::Inserted);
assert!(!copy_called);
}
#[test]
fn copy_fallback_runs_after_unicode_failure() {
let mut copy_called = false;
let status = finish_non_tsf_insertion_fallback(
|| InsertStatus::Failed,
|| {
copy_called = true;
InsertStatus::CopiedFallback
},
);
assert_eq!(status, InsertStatus::CopiedFallback);
assert!(copy_called);
}
#[test]
fn double_failure_does_not_pretend_text_was_copied() {
let mut copy_called = false;
let status = finish_non_tsf_insertion_fallback(
|| InsertStatus::Failed,
|| {
copy_called = true;
InsertStatus::Failed
},
);
assert_eq!(status, InsertStatus::Failed);
assert!(copy_called);
}
}
/// 与 capture_focus_target 类似,但前台窗口属于本进程(即用户停在 QA / capsule / main
/// 等自家窗口)时返回 None,让 caller 区分"用户没切到别处" vs "用户切到了另一个真正的
/// 外部 app"。issue #466 多轮场景下用来刷新 qa_focus_target。
#[cfg(target_os = "windows")]
pub(crate) fn capture_external_focus_target() -> Option<usize> {
use windows::Win32::System::Threading::GetCurrentProcessId;
use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId};
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.0.is_null() {
return None;
}
let mut pid: u32 = 0;
GetWindowThreadProcessId(hwnd, Some(&mut pid));
if pid == GetCurrentProcessId() {
return None;
}
Some(hwnd.0 as usize)
}
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn capture_external_focus_target() -> Option<usize> {
None
}
#[cfg(target_os = "windows")]
pub(crate) fn capture_focus_target() -> Option<usize> {
use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
let foreground = unsafe { GetForegroundWindow() };
if foreground.0.is_null() {
None
} else {
Some(foreground.0 as usize)
}
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn capture_focus_target() -> Option<usize> {
None
}
/// 捕获用户开始 dictation 时的前台 app 标签("localizedName (bundle.id)"),用作 LLM
/// polish/translate 的上下文前提,让模型按 app 调风格。详见 issue #116。
///
/// macOS 走 NSWorkspace.frontmostApplication(公开 API,无需额外权限);
/// Windows 复用前台 HWND 拿窗口标题;Linux/其他平台返回 None。
#[cfg(target_os = "macos")]
pub(crate) fn capture_frontmost_app() -> Option<String> {
use objc2::msg_send;
use objc2::runtime::{AnyClass, AnyObject};
unsafe {
let cls = AnyClass::get("NSWorkspace")?;
let workspace: *mut AnyObject = msg_send![cls, sharedWorkspace];
if workspace.is_null() {
return None;
}
let app: *mut AnyObject = msg_send![workspace, frontmostApplication];
if app.is_null() {
return None;
}
let name_obj: *mut AnyObject = msg_send![app, localizedName];
let bundle_obj: *mut AnyObject = msg_send![app, bundleIdentifier];
let name = nsstring_to_string(name_obj);
let bundle = nsstring_to_string(bundle_obj);
match (name, bundle) {
(Some(n), Some(b)) => Some(format!("{n} ({b})")),
(Some(n), None) => Some(n),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
}
#[cfg(target_os = "macos")]
pub(crate) unsafe fn nsstring_to_string(
ns_string: *mut objc2::runtime::AnyObject,
) -> Option<String> {
use objc2::msg_send;
if ns_string.is_null() {
return None;
}
let utf8: *const std::os::raw::c_char = unsafe { msg_send![ns_string, UTF8String] };
if utf8.is_null() {
return None;
}
let cstr = unsafe { std::ffi::CStr::from_ptr(utf8) };
let s = cstr.to_string_lossy().into_owned();
if s.is_empty() {
None
} else {
Some(s)
}
}
#[cfg(target_os = "windows")]
pub(crate) fn capture_frontmost_app() -> Option<String> {
use windows::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW,
};
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.0.is_null() {
return None;
}
let len = GetWindowTextLengthW(hwnd);
if len <= 0 {
return None;
}
let mut buf = vec![0u16; (len + 1) as usize];
let copied = GetWindowTextW(hwnd, &mut buf);
if copied <= 0 {
return None;
}
let title = String::from_utf16_lossy(&buf[..copied as usize]);
if title.is_empty() {
None
} else {
Some(title)
}
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub(crate) fn capture_frontmost_app() -> Option<String> {
None
}
#[cfg(target_os = "windows")]
pub(crate) fn restore_focus_target_if_possible(target: Option<usize>) -> bool {
use std::ffi::c_void;
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, IsIconic, IsWindow, SetForegroundWindow, ShowWindow, SW_RESTORE,
};
let Some(raw_target) = target else {
log::warn!("[coord] no original Windows insertion target captured");
return false;
};
let hwnd = HWND(raw_target as *mut c_void);
if hwnd.0.is_null() {
return false;
}
if !unsafe { IsWindow(hwnd).as_bool() } {
log::warn!("[coord] original Windows insertion target is no longer a valid window");
return false;
}
let foreground = unsafe { GetForegroundWindow() };
if foreground == hwnd {
return true;
}
if unsafe { IsIconic(hwnd).as_bool() } {
let _ = unsafe { ShowWindow(hwnd, SW_RESTORE) };
}
let _ = unsafe { SetForegroundWindow(hwnd) };
std::thread::sleep(std::time::Duration::from_millis(60));
let foreground = unsafe { GetForegroundWindow() };
if foreground != hwnd {
log::warn!("[coord] failed to restore original Windows insertion target before paste");
return false;
}
true
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn restore_focus_target_if_possible(_target: Option<usize>) -> bool {
true
}
#[cfg(target_os = "windows")]
pub(crate) fn windows_hwnd_is_present(hwnd: windows::Win32::Foundation::HWND) -> bool {
hwnd != windows::Win32::Foundation::HWND::default()
}
#[cfg(target_os = "windows")]
pub(crate) fn capture_ime_submit_target() -> Option<ImeSubmitTarget> {
use windows::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetGUIThreadInfo, GetWindowThreadProcessId, GUITHREADINFO,
};
let foreground = unsafe { GetForegroundWindow() };
if !windows_hwnd_is_present(foreground) {
return None;
}
let mut foreground_process_id = 0;
let foreground_thread_id =
unsafe { GetWindowThreadProcessId(foreground, Some(&mut foreground_process_id)) };
if foreground_thread_id == 0 {
return None;
}
let mut gui_info = GUITHREADINFO {
cbSize: std::mem::size_of::<GUITHREADINFO>() as u32,
..Default::default()
};
let target_window = if unsafe { GetGUIThreadInfo(foreground_thread_id, &mut gui_info).is_ok() }
&& windows_hwnd_is_present(gui_info.hwndFocus)
{
gui_info.hwndFocus
} else {
foreground
};
let mut process_id = 0;
let thread_id = unsafe { GetWindowThreadProcessId(target_window, Some(&mut process_id)) };
if process_id == 0 || thread_id == 0 {
return None;
}
Some(ImeSubmitTarget {
process_id,
thread_id,
})
}
// Windows topmost overlay 的已知 OS 级限制(issue #457):
// `SetWindowPos(HWND_TOPMOST)` 让 capsule 在普通桌面合成、最大化窗口、borderless
// windowed fullscreen 上正常叠加;但**对独占全屏(exclusive fullscreen)DirectX /
// OpenGL 应用无效** —— 那条路径绕过桌面合成器,标准 topmost 窗口不参与合成 →
// 用户看不见 capsule。这是 OS 层面的限制,用户空间无法绕过(除非接入 DirectX
// overlay,工程量与风险都不在 surgical 修复范围内)。
//
// 用户侧 workaround:把游戏切到 borderless windowed fullscreen(Minecraft Java 默认
// 即是;F11 在不同版本表现不一致,按设置里的「全屏」选项决定)。
//
// 相关 UIPI 限制:若游戏以管理员身份运行而 OpenLess 不是,`WH_KEYBOARD_LL` 收不到
// 游戏的按键 → hotkey 完全不触发。这里跟 SetWindowPos 路径无关,但同源不可绕过。
#[cfg(target_os = "windows")]
pub(crate) fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
window: &tauri::WebviewWindow<R>,
) -> bool {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::WindowsAndMessaging::{
SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE,
SWP_SHOWWINDOW, SW_SHOWNOACTIVATE,
};
let Ok(handle) = window.window_handle() else {
// #470 诊断 v2:Win32 show 路径最可能的暗点之一。此前静默 return,
// 无法观测「胶囊完全不显示」是否卡在这里。
log::warn!(
"[capsule] no_activate failed: window_handle() unavailable — Win32 show skipped"
);
return false;
};
let RawWindowHandle::Win32(raw) = handle.as_raw() else {
log::warn!("[capsule] no_activate failed: non-Win32 RawWindowHandle — Win32 show skipped");
return false;
};
let hwnd = HWND(raw.hwnd.get() as *mut _);
let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) };
let _ = unsafe {
SetWindowPos(
hwnd,
HWND_TOPMOST,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW,
)
};
true
}
#[cfg(target_os = "macos")]
pub(crate) fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
window: &tauri::WebviewWindow<R>,
) -> bool {
use objc2::msg_send;
use objc2::runtime::AnyObject;
let Ok(handle) = window.ns_window() else {
return false;
};
let ns_window = handle as *mut AnyObject;
if ns_window.is_null() {
return false;
}
// emit_capsule 已经把窗口操作 marshal 到 Tauri 主线程;这里不能再调用
// window.show()/set_focus()/NSApp.activate,否则 AeroSpace 会把 workspace 切回
// OpenLess 主窗口所在空间。先让胶囊加入所有 Spaces,再用
// orderFrontRegardless 做无激活展示。
if let Err(e) = window.set_visible_on_all_workspaces(true) {
log::warn!("[capsule] set visible on all macOS Spaces failed: {e}");
}
unsafe {
const NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_SPACES: usize = 1 << 0;
const NS_WINDOW_COLLECTION_BEHAVIOR_FULL_SCREEN_AUXILIARY: usize = 1 << 8;
let behavior: usize = msg_send![ns_window, collectionBehavior];
let behavior = behavior
| NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_SPACES
| NS_WINDOW_COLLECTION_BEHAVIOR_FULL_SCREEN_AUXILIARY;
let _: () = msg_send![ns_window, setCollectionBehavior: behavior];
let _: () = msg_send![ns_window, orderFrontRegardless];
}
true
}
#[cfg(target_os = "linux")]
pub(crate) fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
_window: &tauri::WebviewWindow<R>,
) -> bool {
true
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
pub(crate) fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
_window: &tauri::WebviewWindow<R>,
) -> bool {
false
}
#[cfg(target_os = "windows")]
pub(crate) fn hide_capsule_window_if_present() {
use std::iter::once;
use windows::core::PCWSTR;
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::WindowsAndMessaging::{
FindWindowW, SetWindowPos, ShowWindow, HWND_NOTOPMOST, SWP_HIDEWINDOW, SWP_NOACTIVATE,
SWP_NOMOVE, SWP_NOSIZE, SW_HIDE,
};
let title: Vec<u16> = "OpenLess Capsule".encode_utf16().chain(once(0)).collect();
let hwnd = match unsafe { FindWindowW(PCWSTR::null(), PCWSTR(title.as_ptr())) } {
Ok(hwnd) => hwnd,
Err(_) => return,
};
if hwnd == HWND::default() || hwnd.0.is_null() {
return;
}
let _ = unsafe { ShowWindow(hwnd, SW_HIDE) };
let _ = unsafe {
SetWindowPos(
hwnd,
HWND_NOTOPMOST,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_HIDEWINDOW,
)
};
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn hide_capsule_window_if_present() {}
@@ -0,0 +1,660 @@
//! LLM 润色 / 翻译 / provider 构建 / 凭证读取 管线。
//!
//! 从 `coordinator.rs` 机械拆出(行为保持,仅可见性提升):流式/一次性润色、
//! 润色+翻译合并、各 ASR/LLM provider 凭证读取与 ActiveLLMProvider 构建、
//! provider 分类器与中文字形偏好、词库 phrases/hotwords 读取。
//! polish-translate marker 常量原样保留(父模块 tests 经 super:: 引用)。
use super::*;
pub(crate) fn raw_style_pack_uses_llm(pack: &crate::types::StylePack) -> bool {
!(pack.kind == crate::types::StylePackKind::Builtin
&& pack.id == crate::types::BUILTIN_STYLE_PACK_RAW_ID
&& pack.prompt == crate::types::StyleSystemPrompts::default().raw)
}
pub(crate) fn raw_mode_uses_llm(style_system_prompt: &str) -> bool {
style_system_prompt != crate::types::StyleSystemPrompts::default().raw
}
/// `whisper` 是 OpenAI 原生;`siliconflow` / `zhipu` / `groq` 都暴露
/// OpenAI 兼容的 `/audio/transcriptions`,统一走 `WhisperBatchASR`。
/// 新增 OpenAI 兼容 ASR 时只需在这里加一项。
///
/// 注:DashScope 的 Qwen3-ASR-Flash 不在此列——它用 MultiModalConversation
/// (messages=[{content:[{audio:...}]}]) 协议,不是 Whisper multipart,需要
/// 单独 ASR 客户端,留给 V2。
pub(crate) fn is_whisper_compatible_provider(id: &str) -> bool {
matches!(
id,
"whisper" | "siliconflow" | "zhipu" | "groq" | "openrouter"
)
}
/// 该 provider 的请求体编码方式。OpenRouter 的 `/audio/transcriptions` 是
/// `application/json` + base64 音频(issue #582),其余兼容厂商沿用 multipart。
pub(crate) fn whisper_request_format(provider_id: &str) -> crate::asr::whisper::AsrRequestFormat {
match provider_id {
"openrouter" => crate::asr::whisper::AsrRequestFormat::OpenRouterJson,
_ => crate::asr::whisper::AsrRequestFormat::Multipart,
}
}
/// 该 provider 的 `/audio/transcriptions` 是否支持 `response_format=verbose_json`
/// 并返回带 `no_speech_prob` / `avg_logprob` / `compression_ratio` 的 segments,
/// 用于幻听过滤。
///
/// - `whisper`(OpenAI)/ `groq`:原生 Whisper,完整支持,过滤有效。
/// - `siliconflow`:模型是 SenseVoice / TeleSpeech,文档无 `response_format`,
/// 发送 verbose_json 可能被拒,**保持关闭**走旧的 `json`。
/// - `zhipu`(GLM-ASR):虽接受 verbose_json,但不产出上述指标,过滤是空转;
/// 为最小化行为变更,这里也**保持关闭**,仅对确证有收益的 whisper/groq 开启。
pub(crate) fn whisper_supports_verbose_json(provider_id: &str) -> bool {
matches!(provider_id, "whisper" | "groq")
}
pub(crate) fn is_bailian_provider(id: &str) -> bool {
id == crate::asr::bailian::PROVIDER_ID
}
pub(crate) fn is_mimo_provider(id: &str) -> bool {
id == crate::asr::mimo::PROVIDER_ID
}
pub(crate) fn apply_chinese_script_preference(text: &str, pref: ChineseScriptPreference) -> String {
if text.is_empty() {
return String::new();
}
let config = match pref {
ChineseScriptPreference::Simplified => Some(BuiltinConfig::T2s),
ChineseScriptPreference::Traditional => Some(BuiltinConfig::S2t),
ChineseScriptPreference::Auto => None,
};
let Some(config) = config else {
return text.to_string();
};
match OpenCC::from_config(config) {
Ok(converter) => converter.convert(text),
Err(err) => {
log::warn!("[coord] OpenCC init failed, skip script conversion: {err}");
text.to_string()
}
}
}
/// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。
/// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。
/// 流式润色的三态结果。让上层(dictation pipeline)能区分「已经流出去了」、
/// 「降级到一次性」和「真失败了走 raw 兜底」三种 case。
pub(crate) enum StreamingPolishOutcome {
/// 流式润色成功,`String` 是已经一边流一边交给 `on_delta` 的全部文本(用于写
/// history、做词条命中统计)。调用方不应再 `inserter.insert(&text)`,因为字符
/// 已经通过键盘事件落到光标处。
Streamed(String),
/// 当前配置不支持流式:用户没开 streaming_insert / Gemini provider / Codex
/// provider / Raw 模式 / 翻译模式 / 不是 macOS。调用方应回到现有的
/// `polish_or_passthrough` 一次性路径,跟历史行为完全一致。
UnsupportedFallback,
/// 流式过程中失败(HTTP / 解析 / 空流等)。`String` 是失败原因,调用方应当
/// 走 raw 兜底(同 `polish_or_passthrough` 失败分支的语义)。
Failed(String),
}
/// 流式润色入口。在不支持流式的所有 case 都返回 `UnsupportedFallback`,让调用方
/// 透明降级。不修改任何持久化 / 焦点 / 光标状态。
///
/// `on_delta` 每收到一个 SSE chunk 就被调用一次(同步),调用方负责把 chunk 实际
/// 模拟键盘事件落到光标 —— 见 `coordinator/dictation.rs` 的流式分支。
/// `should_cancel` 用户取消时返回 true,立即 break SSE 读循环避免烧 quota。
pub(crate) async fn polish_or_passthrough_streaming<F, C>(
raw: &RawTranscript,
mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
on_delta: F,
should_cancel: C,
) -> StreamingPolishOutcome
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
if mode == PolishMode::Raw && !raw_mode_uses_llm(style_system_prompt) {
log::info!("[coord] streaming polish skipped: mode=Raw, fall back to one-shot");
return StreamingPolishOutcome::UnsupportedFallback;
}
let active_llm = CredentialsVault::get_active_llm();
if active_llm == "gemini" {
log::info!(
"[coord] streaming polish skipped: active LLM provider=gemini (v1 not implemented), fall back to one-shot"
);
return StreamingPolishOutcome::UnsupportedFallback;
}
let provider = match build_active_llm_provider(llm_thinking_enabled) {
Ok(p) => p,
Err(e) => {
log::error!("[coord] streaming polish: build provider failed: {e}");
return StreamingPolishOutcome::Failed(e.to_string());
}
};
if !provider.supports_streaming_polish() {
log::info!(
"[coord] streaming polish skipped: provider does not support streaming (likely codex OAuth), fall back to one-shot"
);
return StreamingPolishOutcome::UnsupportedFallback;
}
log::info!(
"[coord] streaming polish START: provider=openai-compatible mode={:?} raw_chars={} prior_turns={}",
mode,
raw.text.chars().count(),
prior_turns.len()
);
match provider
.polish_streaming(
&raw.text,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
prior_turns,
on_delta,
should_cancel,
)
.await
{
Ok(text) => {
log::info!(
"[coord] streaming polish OK: final_chars={}",
text.chars().count()
);
StreamingPolishOutcome::Streamed(text)
}
Err(e) => {
let reason = e.to_string();
log::error!("[coord] streaming polish FAILED: {reason}");
StreamingPolishOutcome::Failed(reason)
}
}
}
pub(crate) async fn polish_or_passthrough(
raw: &RawTranscript,
mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
) -> (String, Option<String>) {
if mode == PolishMode::Raw && !raw_mode_uses_llm(style_system_prompt) {
return (raw.text.clone(), None);
}
match polish_text(
&raw.text,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
prior_turns,
)
.await
{
Ok(s) => (s, None),
Err(e) => {
let reason = e.to_string();
log::error!("[coord] polish failed, falling back to raw: {reason}");
(raw.text.clone(), Some(reason))
}
}
}
pub(crate) async fn polish_text(
raw: &str,
mode: PolishMode,
hotwords: &[String],
style_system_prompt: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
) -> anyhow::Result<String> {
// 谷歌 Gemini 分支:所有 LLM provider 共用 ark.* 凭据槽,唯独 Gemini 走原生
// generateContent / 自带 thinkingConfig 控制;其余 provider 走 OpenAI
// 兼容协议,并在该路径里按 provider/channel 下发对应的思考开关。
let active_llm = CredentialsVault::get_active_llm();
if active_llm == "gemini" {
let (api_key, model, base_url) = read_gemini_credentials()?;
let provider = GeminiProvider::new(
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
);
return Ok(provider
.polish(
raw,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
prior_turns,
)
.await?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
Ok(provider
.polish(
raw,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
prior_turns,
)
.await?)
}
/// 专用翻译(仅翻译、不润色、单轮)。现作为"润色+翻译"合成调用解析失败时的兜底——
/// 模型没按两段格式输出时,退回这里拿一段干净译文,而不是把畸形输出当译文插入。
pub(crate) async fn translate_text(
raw: &str,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
) -> anyhow::Result<String> {
// 见 polish_text 顶部注释——同样的 Gemini / OpenAI-compatible 路由逻辑。
let active_llm = CredentialsVault::get_active_llm();
if active_llm == "gemini" {
let (api_key, model, base_url) = read_gemini_credentials()?;
let provider = GeminiProvider::new(
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
);
return Ok(provider
.translate_to(
raw,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
)
.await?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
Ok(provider
.translate_to(
raw,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
)
.await?)
}
/// "润色+翻译"单次调用的两段哨兵。模型按 `SRC\n源文\nTGT\n译文` 输出,解析器据此切分。
/// 这两个串必须与 build_polish_translate_system_prompt 写给模型的完全一致。
pub(crate) const POLISH_TRANSLATE_SRC_MARKER: &str = "[[OPENLESS_POLISHED_SOURCE]]";
pub(crate) const POLISH_TRANSLATE_TGT_MARKER: &str = "[[OPENLESS_TRANSLATION]]";
/// 合成"先润色源文、再翻译"的系统提示词:在原翻译 prompt 之上追加"额外输出润色后源文"
/// 与严格两段格式(覆盖原 prompt 末尾的"只输出译文")。译文仍是要插入用户光标的主产物,
/// 故完整保留原翻译规则;润色后的源文只作对话上下文用,轻量清理即可。
pub(crate) fn build_polish_translate_system_prompt(target_language: &str) -> String {
let base = crate::polish::prompts::translate_system_prompt(target_language);
format!(
"{base}\n\n\
# 额外输出:润色后的源文(仅用于对话上下文,不展示给用户)\n\
在译文之前,先把上面的原始转写**按它本来的语言**润色一遍:去掉口癖(嗯 / 那个 / um)、\
补必要标点、纠正明显的识别错误,但**不翻译、不改写风格、不增删意思**。\n\n\
# 输出格式(覆盖上面\u{201C}只输出译文\u{201D}的说明,严格遵守)\n\
严格按下面两段输出,两个标记必须原样出现、各占一行,标记之外不要有任何多余文字:\n\
{src}\n\
(这里放润色后的源文,保持原语言)\n\
{tgt}\n\
(这里放翻译成\u{300C}{lang}\u{300D}的译文)",
base = base,
src = POLISH_TRANSLATE_SRC_MARKER,
tgt = POLISH_TRANSLATE_TGT_MARKER,
lang = target_language,
)
}
/// 解析"润色+翻译"单次调用输出 → Some((润色后源文, 译文))。
/// 找到译文标记且译文非空 → Some((源文, 译文)):源文标记缺失 / 源文段为空时源文为 None,
/// 译文取标记之后的干净正文。**没有译文标记、或译文段为空(模型截断 / 只吐了标记)→ None**,
/// 表示没拿到可信译文,交由调用方退回专用翻译——避免把空串当"成功译文"插进光标而丢字。
pub(crate) fn split_polish_translate_output(raw: &str) -> Option<(Option<String>, String)> {
let tgt_idx = raw.find(POLISH_TRANSLATE_TGT_MARKER)?;
let translation = raw[tgt_idx + POLISH_TRANSLATE_TGT_MARKER.len()..]
.trim()
.to_string();
if translation.is_empty() {
return None;
}
let before_tgt = &raw[..tgt_idx];
let source = before_tgt
.find(POLISH_TRANSLATE_SRC_MARKER)
.map(|i| {
before_tgt[i + POLISH_TRANSLATE_SRC_MARKER.len()..]
.trim()
.to_string()
})
.filter(|s| !s.is_empty());
Some((source, translation))
}
/// 翻译路径——单次 LLM 调用同时润色源文 + 翻译。和 polish 一样失败时返回原文 + 失败原因,
/// 避免"不丢字"约定被违反(CLAUDE.md)。返回 (要插入的译文, 润色后源文供上下文用, 失败原因)。
#[allow(clippy::too_many_arguments)]
pub(crate) async fn polish_and_translate_or_passthrough(
raw: &RawTranscript,
target_language: &str,
mode: PolishMode,
hotwords: &[String],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
) -> (String, Option<String>, Option<String>) {
let system_prompt = build_polish_translate_system_prompt(target_language);
match polish_text(
&raw.text,
mode,
hotwords,
&system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
prior_turns,
)
.await
{
Ok(out) => match split_polish_translate_output(&out) {
Some((source, translation)) => (translation, source, None),
None => {
// 模型没按两段格式输出:退回专用翻译拿一段干净译文,避免把畸形输出插进光标。
// 此时无可信源文,这条翻译历史不参与后续普通润色上下文。
log::warn!(
"[coord] polish+translate output missing markers; falling back to plain translate"
);
match translate_text(
&raw.text,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app,
)
.await
{
Ok(translation) => (translation, None, None),
Err(e) => {
let reason = e.to_string();
log::error!("[coord] fallback translate failed, using raw: {reason}");
(raw.text.clone(), None, Some(reason))
}
}
}
},
Err(e) => {
let reason = e.to_string();
log::error!("[coord] polish+translate failed, falling back to raw: {reason}");
(raw.text.clone(), None, Some(reason))
}
}
}
pub(crate) fn read_whisper_credentials() -> (String, String, String) {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
.unwrap_or_default();
let base_url = CredentialsVault::get(CredentialAccount::AsrEndpoint)
.ok()
.flatten()
.unwrap_or_default();
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.ok()
.flatten()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "whisper-1".to_string());
(api_key, base_url, model)
}
pub(crate) fn read_mimo_credentials() -> (String, String, String) {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
.unwrap_or_default();
let base_url = CredentialsVault::get(CredentialAccount::AsrEndpoint)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::mimo::DEFAULT_ENDPOINT.to_string());
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::mimo::DEFAULT_MODEL.to_string());
(api_key, base_url, model)
}
pub(crate) fn read_bailian_credentials() -> BailianCredentials {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
.flatten()
.unwrap_or_default();
let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_ENDPOINT.to_string());
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::bailian::DEFAULT_MODEL.to_string());
let vocabulary_id = CredentialsVault::get(CredentialAccount::AsrVocabularyId)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty());
BailianCredentials {
api_key,
endpoint,
model,
vocabulary_id,
}
}
pub(crate) fn read_volc_credentials() -> VolcengineCredentials {
let app_id = CredentialsVault::get(CredentialAccount::VolcengineAppKey)
.ok()
.flatten()
.unwrap_or_default();
let access_token = CredentialsVault::get(CredentialAccount::VolcengineAccessKey)
.ok()
.flatten()
.unwrap_or_default();
let resource_id = CredentialsVault::get(CredentialAccount::VolcengineResourceId)
.ok()
.flatten()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| VolcengineCredentials::default_resource_id().to_string());
VolcengineCredentials {
app_id,
access_token,
resource_id,
}
}
pub(crate) fn enabled_hotwords(inner: &Arc<Inner>) -> Vec<DictionaryHotword> {
inner
.vocab
.list()
.unwrap_or_default()
.into_iter()
.map(|e| DictionaryHotword {
phrase: e.phrase,
enabled: e.enabled,
})
.collect()
}
pub(crate) async fn answer_chat_dispatch<F, C>(
messages: &[crate::types::QaChatMessage],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
on_delta: F,
should_cancel: C,
) -> anyhow::Result<String>
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
// 见 polish_text 顶部注释——同样的 Gemini / OpenAI-compatible 路由逻辑,
// QA 流式回答走 Gemini 原生 :streamGenerateContent?alt=sse。
let active_llm = CredentialsVault::get_active_llm();
if active_llm == "gemini" {
let (api_key, model, base_url) = read_gemini_credentials()?;
let provider = GeminiProvider::new(
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
);
return Ok(provider
.answer_chat_streaming(
messages,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
on_delta,
should_cancel,
)
.await?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
Ok(provider
.answer_chat_streaming(
messages,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
on_delta,
should_cancel,
)
.await?)
}
/// 读 Gemini 凭据。所有 LLM provider 共用 ark.* 槽位(persistence 没做 per-provider
/// 隔离),所以这里也是从 `ArkApiKey` / `ArkModelId` / `ArkEndpoint` 三个槽读,
/// 但回退默认值改成谷歌的:base_url 默认 `https://generativelanguage.googleapis.com/v1beta`,
/// 模型默认 `gemini-2.5-flash`。Settings.tsx::onLlmProviderChange 在用户切到 gemini
/// 时会强制把 endpoint/model 覆盖为这两个默认值,所以 99% 情况下槽里读出来就是
/// 这两个;这里的 `unwrap_or_else` 是给极端情况兜底(如旧版本切换 bug 留下的脏数据)。
///
/// base_url 末尾去掉 `/`,让 `llm_gemini::generate_content_url` 拼接稳定。
/// 不去 `/chat/completions` 后缀——OpenAI 兼容路径才会有那个后缀,原生 Gemini 不会。
pub(crate) fn read_gemini_credentials() -> anyhow::Result<(String, String, String)> {
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "gemini-2.5-flash".to_string());
let base_url = CredentialsVault::get(CredentialAccount::ArkEndpoint)?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string());
if api_key.trim().is_empty() {
anyhow::bail!("API Key 为空");
}
let base_url = base_url.trim_end_matches('/').to_string();
Ok((api_key, model, base_url))
}
pub(crate) fn build_active_llm_provider(
llm_thinking_enabled: bool,
) -> anyhow::Result<ActiveLLMProvider> {
let active = CredentialsVault::get_active_llm();
let model =
CredentialsVault::get(CredentialAccount::ArkModelId)?.filter(|s| !s.trim().is_empty());
if active == CODEX_OAUTH_PROVIDER_ID {
let config =
CodexOAuthConfig::new(model.unwrap_or_else(|| CODEX_DEFAULT_MODEL.to_string()))
.with_thinking_enabled(llm_thinking_enabled);
return Ok(ActiveLLMProvider::Codex(CodexOAuthLLMProvider::new(config)));
}
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
let model = model.unwrap_or_else(|| "deepseek-v3-2".to_string());
let endpoint = resolve_ark_endpoint(&api_key)?;
let base_url = endpoint
.trim_end_matches("/chat/completions")
.trim_end_matches('/')
.to_string();
let config = OpenAICompatibleConfig::new(active, "OpenLess LLM", base_url, api_key, model)
.with_thinking_enabled(llm_thinking_enabled);
Ok(ActiveLLMProvider::OpenAI(OpenAICompatibleLLMProvider::new(
config,
)))
}
pub(crate) fn resolve_ark_endpoint(api_key: &str) -> anyhow::Result<String> {
let endpoint = CredentialsVault::get(CredentialAccount::ArkEndpoint)?.filter(|s| !s.is_empty());
resolve_ark_endpoint_with_policy(api_key, endpoint)
}
pub(crate) fn resolve_ark_endpoint_with_policy(
api_key: &str,
endpoint: Option<String>,
) -> anyhow::Result<String> {
if api_key.trim().is_empty() && endpoint.is_none() {
anyhow::bail!("API Key 为空");
}
Ok(endpoint
.unwrap_or_else(|| "https://ark.cn-beijing.volces.com/api/v3/chat/completions".to_string()))
}
pub(crate) fn enabled_phrases(inner: &Arc<Inner>) -> Vec<String> {
inner
.vocab
.list()
.unwrap_or_default()
.into_iter()
.filter(|e| e.enabled)
.map(|e| e.phrase)
.collect()
}
@@ -0,0 +1,690 @@
//! 划词语音问答(QA)会话生命周期:录音 error 监听、begin/end、错误/静默/取消收尾。
//!
//! 从 `coordinator.rs` 机械拆出(行为保持,仅可见性提升)。与 `qa.rs`(热键/面板状态)
//! 互补:本文件持 QA 流程主体。end_qa_session 跨 await 持/放 qa_state 锁、驱动 SSE
//! 流与窗口焦点 marshaling,是全文件最并发敏感的函数,只搬不改。
use super::*;
/// QA 录音 runtime error 监听器。镜像 `spawn_recorder_error_monitor` 的语义但走 QA
/// 收尾路径(`finish_qa_with_error` 替代 `abort_recording_with_error`)。
/// 用 qa_state.session_id 守卫 stale 事件。详见 issue #168。
pub(crate) fn spawn_qa_recorder_error_monitor(
inner: &Arc<Inner>,
rx: mpsc::Receiver<RecorderError>,
) {
let captured_session_id = inner.qa_state.lock().session_id;
let inner = Arc::clone(inner);
std::thread::Builder::new()
.name("openless-qa-recorder-error-monitor".into())
.spawn(move || {
if let Ok(err) = rx.recv() {
let current_session_id = inner.qa_state.lock().session_id;
if captured_session_id != current_session_id {
log::warn!(
"[coord] QA recorder error from stale session {} dropped (current={}, err={})",
captured_session_id,
current_session_id,
err
);
return;
}
log::error!("[coord] QA recorder runtime error: {err}");
finish_qa_with_error(&inner, format!("录音设备异常: {err}"));
}
})
.ok();
}
// ─────────────────────────── QA session lifecycle ───────────────────────────
/// 划词语音问答会话(issue #118)。
///
/// 与 dictation 完全分离:
/// - 不进 SessionPhase(互不抢锁)
/// - 不写 history.json(除非 prefs.qa_save_history=true 才旁路写一条 placeholder)
/// - 用独立的 qa_recorder + qa_asr,复用现有 Volcengine ASR 通路
pub(crate) async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
{
let mut state = inner.qa_state.lock();
if !state.panel_visible {
// 防御:浮窗没开就被叫到这里说明路由错了,直接退出。
return Ok(());
}
if state.phase != QaPhase::Idle {
return Ok(());
}
state.phase = QaPhase::Recording;
state.cancelled = false;
state.session_id = new_session_id();
state.front_app = capture_frontmost_app();
state.selection = None;
}
// 重置 SSE 取消标志:上一轮可能 set 过的 true 留着会让本轮流式立即 break。
inner.qa_stream_cancelled.store(false, Ordering::SeqCst);
// 抓选区。每轮按 Option 都重新抓一次:用户多轮提问中可以重新选别处文字。
//
// - macOS:浮窗走 orderFrontRegardless,不成为 key window,原 app 仍是 frontmost,
// AX/Cmd+C fallback 都能拿到。
// - Windows:#466 修复后 show_qa_window_no_activate 主动抓焦点,QA 此刻已是前台,
// simulate_copy 会跑在 QA 自己 webview 上 → 抓不到。focus-dance 上半场:把焦点临时
// 还给"用户原 app 的 HWND"。
//
// 多轮场景的目标刷新:用户开 QA 后可能 Alt+Tab 切到别的 app 选新文字。如果还死认
// open_qa_panel 时记下的初始 HWND,会把焦点抢回错的 app(pr_agent stale-focus 关注点)。
// 策略:每轮先看当前前台是不是本进程的窗口(QA / capsule / main)—— 是 → 用户没切
// 走,沿用 saved;不是 → 用户切到了真正的外部 app,刷新 saved 为当前 HWND。
// 抓完选区后下半场再把焦点交还 QA,让 ESC/X 继续可用。
#[cfg(target_os = "windows")]
{
// 合并两次 lock:原来分 lock #1 写 + lock #2 读,两者之间 close_qa_panel 在别的
// 线程把 qa_focus_target 清成 None 会被覆盖回旧 HWND。Cloud 评审指出的 TOCTOU。
// 单次加锁里既写最新外部前台、再读出来交给后面的 restore_focus_target_if_possible
// —— capture_external_focus_target() 内部只调 GetForegroundWindow / pid 查询,
// 不会反向取 qa_state 锁,持锁期间调用安全。
let saved_target = {
let mut state = inner.qa_state.lock();
if let Some(current_external) = capture_external_focus_target() {
state.qa_focus_target = Some(current_external);
}
state.qa_focus_target
};
let _ = restore_focus_target_if_possible(saved_target);
}
let selection = capture_selection();
#[cfg(target_os = "windows")]
if let Some(app) = inner.app.lock().clone() {
crate::refocus_qa_window(&app);
}
let selection_preview_text = selection.as_ref().map(|s| s.text.clone());
inner.qa_state.lock().selection = selection.clone();
if let Some(app) = inner.app.lock().clone() {
let messages = inner.qa_state.lock().messages.clone();
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "recording",
"selection_preview": selection_preview_text,
"messages": messages,
}),
);
}
// 2. QA 与 dictation 使用同一个 active ASR 入口。不要回退火山,否则用户配置
// 百炼 / Whisper / 本地 ASR 后,浮窗仍会偷偷走另一套凭据。
let active_asr = CredentialsVault::get_active_asr();
if let Err(message) = ensure_asr_credentials() {
log::warn!("[coord] QA: active ASR credentials missing: {message}");
finish_qa_with_error(inner, format!("缺少 ASR 凭据:{message}"));
return Err(message);
}
if let Err(message) = ensure_microphone_permission(inner) {
log::warn!("[coord] QA: microphone permission gate failed: {message}");
finish_qa_with_error(inner, message.clone());
return Err(message);
}
let qa_asr = match build_qa_asr_start(inner, &active_asr).await {
Ok(qa_asr) => qa_asr,
Err(message) => {
log::error!("[coord] QA active ASR init failed: {message}");
finish_qa_with_error(inner, format!("ASR 初始化失败: {message}"));
return Err(message);
}
};
let consumer = qa_asr.recorder_consumer();
*inner.qa_asr.lock() = Some(qa_asr.active_asr());
// QA recorder 不需要 RMS 节流到胶囊;前端 QA 浮窗有自己的电平视图,
// 这里发一份事件给 "qa" label 用就够了。
let inner_for_level = Arc::clone(inner);
let last_emit_at = Arc::new(Mutex::new(None::<Instant>));
const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33;
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = Arc::new(move |level| {
let phase = inner_for_level.qa_state.lock().phase;
if phase != QaPhase::Recording {
return;
}
let now = Instant::now();
{
let mut last = last_emit_at.lock();
if let Some(prev) = *last {
if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 {
return;
}
}
*last = Some(now);
}
if let Some(app) = inner_for_level.app.lock().clone() {
let _ = app.emit_to("qa", "qa:level", serde_json::json!({ "level": level }));
}
// 同步把电平推给底部胶囊,让 QA 录音也有跟主听写一致的可视反馈。
emit_capsule(
&inner_for_level,
CapsuleState::Recording,
level,
0,
None,
None,
);
});
let microphone_device_name = selected_microphone_device_name(inner);
stop_microphone_preview_monitor(inner, "QA recorder");
acquire_recording_mute(inner, "qa").await;
// QA 默认不留痕(qa_save_history 默认 false),录音文件归档也跟着不开。
// 调试 QA 麦克风请用主听写路径。
match Recorder::start(microphone_device_name, consumer, level_handler, None) {
Ok((rec, runtime_errors, archive_active)) => {
// QA 路径不写 dictation 的 history,但仍把 archive 状态归零,避免 dictation
// 接力时读到上一个 QA session 的过期值。
inner
.audio_archive_active
.store(archive_active, std::sync::atomic::Ordering::Relaxed);
*inner.qa_recorder.lock() = Some(rec);
// QA 也跟主听写一样监听 cpal runtime error。设备中途消失 / panic 时
// 不能让 QA 永远卡在 Recording 没反馈。详见 issue #168。
spawn_qa_recorder_error_monitor(inner, runtime_errors);
}
Err(e) => {
log::error!("[coord] QA recorder start failed: {e}");
if let Some(asr) = inner.qa_asr.lock().take() {
cancel_active_asr(asr);
}
release_recording_mute(inner, "qa");
finish_qa_with_error(inner, format!("录音启动失败: {e}"));
return Err(e.to_string());
}
}
if let Err(e) = qa_asr.open_streaming_session().await {
log::error!("[coord] QA: open ASR session failed: {e}");
stop_qa_recorder(inner);
if let Some(asr) = inner.qa_asr.lock().take() {
cancel_active_asr(asr);
}
finish_qa_with_error(inner, format!("ASR 连接失败: {e}"));
return Err(e);
}
// cancel race:在 await 期间用户可能 dismiss 了浮窗。
if inner.qa_state.lock().cancelled {
log::info!("[coord] QA cancel raced during open_session — aborting begin");
if let Some(asr) = inner.qa_asr.lock().take() {
cancel_active_asr(asr);
}
stop_qa_recorder(inner);
inner.qa_state.lock().phase = QaPhase::Idle;
return Ok(());
}
// 显式弹胶囊到 Recording。level_handler 后续会持续推电平,胶囊里"录音中…"
// 的视觉反馈跟主听写完全一致。
emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None);
Ok(())
}
pub(crate) async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
{
let mut state = inner.qa_state.lock();
if state.phase != QaPhase::Recording {
return Ok(());
}
state.phase = QaPhase::Processing;
}
// 胶囊进入 Transcribing:用户视觉上看到"识别中"。
emit_capsule(inner, CapsuleState::Transcribing, 0.0, 0, None, None);
if let Some(app) = inner.app.lock().clone() {
let _ = app.emit_to("qa", "qa:state", serde_json::json!({ "kind": "loading" }));
}
stop_qa_recorder(inner);
let asr = match inner.qa_asr.lock().take() {
Some(a) => a,
None => {
inner.qa_state.lock().phase = QaPhase::Idle;
return Ok(());
}
};
#[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
let qa_session_id = inner.qa_state.lock().session_id;
let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr);
let raw = match asr {
ActiveAsr::Volcengine(asr) => {
debug_assert!(uses_global_timeout);
if let Err(e) = asr.send_last_frame().await {
log::error!("[coord] QA: send last frame failed: {e}");
}
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA: await final failed: {e}");
finish_qa_with_error(inner, format!("识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA: 全局超时 {} 秒 - 强制恢复",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
asr.cancel();
finish_qa_with_error(inner, "识别超时".to_string());
return Err("global timeout".to_string());
}
}
}
ActiveAsr::Bailian(asr) => {
debug_assert!(uses_global_timeout);
if let Err(e) = asr.send_last_frame().await {
log::error!("[coord] QA: Bailian send last frame failed: {e}");
}
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, asr.await_final_result()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA: Bailian await final failed: {e}");
finish_qa_with_error(inner, format!("识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA: Bailian 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
asr.cancel();
finish_qa_with_error(inner, "识别超时".to_string());
return Err("bailian global timeout".to_string());
}
}
}
ActiveAsr::Whisper(w) => {
debug_assert!(uses_global_timeout);
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, w.transcribe()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA: whisper transcribe failed: {e}");
finish_qa_with_error(inner, format!("识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA: whisper 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
finish_qa_with_error(inner, "识别超时".to_string());
return Err("whisper global timeout".to_string());
}
}
}
ActiveAsr::Mimo(m) => {
debug_assert!(uses_global_timeout);
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
match tokio::time::timeout(timeout_duration, m.transcribe()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA: MiMo ASR transcribe failed: {e}");
finish_qa_with_error(inner, format!("识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA: MiMo ASR 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
finish_qa_with_error(inner, "识别超时".to_string());
return Err("mimo global timeout".to_string());
}
}
}
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(local) => {
debug_assert!(!uses_global_timeout);
match local
.transcribe(foundry_audio_transcribe_timeout_duration())
.await
{
Ok(r) => {
schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id));
r
}
Err(e) => {
schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id));
if inner.qa_state.lock().cancelled {
log::info!(
"[coord] QA Foundry Local Whisper transcribe cancelled — discarding transcript"
);
finish_qa_idle_silently(inner);
return Ok(());
}
log::error!("[coord] QA Foundry Local Whisper transcribe failed: {e:#}");
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
return Err(e.to_string());
}
}
}
#[cfg(target_os = "windows")]
ActiveAsr::SherpaOnnxLocal(local) => {
debug_assert!(!uses_global_timeout);
match local
.transcribe(sherpa_audio_transcribe_timeout_duration())
.await
{
Ok(r) => {
schedule_sherpa_onnx_release(inner, AsrReleaseSession::Qa(qa_session_id));
r
}
Err(e) => {
schedule_sherpa_onnx_release(inner, AsrReleaseSession::Qa(qa_session_id));
if inner.qa_state.lock().cancelled {
log::info!(
"[coord] QA sherpa-onnx transcribe cancelled — discarding transcript"
);
finish_qa_idle_silently(inner);
return Ok(());
}
log::error!("[coord] QA sherpa-onnx transcribe failed: {e:#}");
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
return Err(e.to_string());
}
}
}
#[cfg(target_os = "macos")]
ActiveAsr::Local(local) => {
debug_assert!(uses_global_timeout);
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
let timeout_duration = local_qwen_transcribe_timeout(audio_secs);
log::info!(
"[coord] QA local Qwen3-ASR transcribe: audio={:.2}s timeout={}s",
audio_secs,
timeout_duration.as_secs()
);
let result = tokio::time::timeout(timeout_duration, local.transcribe()).await;
inner.local_asr_cache.touch();
schedule_local_asr_release(inner);
match result {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA local Qwen3-ASR transcribe failed: {e:#}");
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA local Qwen3-ASR transcribe timeout after {}s",
timeout_duration.as_secs()
);
finish_qa_with_error(inner, "本地识别超时".to_string());
return Err("local qwen transcribe timeout".to_string());
}
}
}
};
// cancel race:用户在 transcribe 中按 Esc / dismiss → 静默退出。
if inner.qa_state.lock().cancelled {
log::info!("[coord] QA cancel detected after ASR — discarding transcript");
finish_qa_idle_silently(inner);
return Ok(());
}
let question = raw.text.trim().to_string();
if question.is_empty() {
// 静默录音:不调 LLM,不弹错误,直接关浮窗。
log::info!("[coord] QA: empty transcript → silent dismiss");
finish_qa_idle_silently(inner);
return Ok(());
}
// 拼这一轮的 user 消息:第一轮(messages 还空)把选区原文嵌进去;
// 之后的轮次只送提问,让 LLM 顺着上下文回答。详见 issue #118 v2。
let user_content = {
let st = inner.qa_state.lock();
let is_first_turn = st.messages.is_empty();
let sel_text = st
.selection
.as_ref()
.map(|s| s.text.clone())
.unwrap_or_default();
if is_first_turn && !sel_text.trim().is_empty() {
format!(
"# 选区原文\n{}\n\n# 我的问题\n{}",
sel_text.trim(),
question
)
} else {
question.clone()
}
};
inner
.qa_state
.lock()
.messages
.push(crate::types::QaChatMessage {
role: "user".to_string(),
content: user_content,
});
if let Some(app) = inner.app.lock().clone() {
let messages = inner.qa_state.lock().messages.clone();
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "thinking",
"messages": messages,
}),
);
}
// 胶囊:思考阶段(复用 dictation 的 Polishing 状态——视觉上是"润色中",QA 借用一下)。
emit_capsule(inner, CapsuleState::Polishing, 0.0, 0, None, None);
let prefs = inner.prefs.get();
let working_languages = prefs.working_languages.clone();
let chinese_script_preference = prefs.chinese_script_preference;
let output_language_preference = prefs.output_language_preference;
let llm_thinking_enabled = prefs.llm_thinking_enabled;
let (messages_for_llm, front_app) = {
let st = inner.qa_state.lock();
(st.messages.clone(), st.front_app.clone())
};
// 流式回调:每个 SSE delta 立刻推一帧 qa:state{kind:"answer_delta"} 给前端,
// 浮窗里气泡边收边长。最终的 messages 由 answer 事件统一下发(保证一致性)。
//
// session_id 守卫(issue #161):闭包捕获本会话 id;用户取消 → 关浮窗 → 开新浮窗
// 开新一轮时,旧的 in-flight LLM 流仍可能 emit chunk,必须在 emit 前比对当前
// qa_state.session_id == 捕获 id,否则跳过——避免旧会话的字漏进新气泡。
let captured_session_id = inner.qa_state.lock().session_id;
let inner_for_delta = Arc::clone(inner);
let on_delta = move |chunk: &str| {
let cur_id = inner_for_delta.qa_state.lock().session_id;
if cur_id != captured_session_id {
return; // 旧 session 漏来的 chunk,丢弃
}
if let Some(app) = inner_for_delta.app.lock().clone() {
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "answer_delta",
"chunk": chunk,
}),
);
}
};
// SSE 流取消旗标:cancel_qa_session / close_qa_panel 会 set true,
// polish 的 SSE loop 每帧检查 → break,释放 HTTP body。详见 issue #161。
let cancel_flag = Arc::clone(&inner.qa_stream_cancelled);
let should_cancel = move || cancel_flag.load(Ordering::Relaxed);
let answer = match answer_chat_dispatch(
&messages_for_llm,
&working_languages,
chinese_script_preference,
output_language_preference,
llm_thinking_enabled,
front_app.as_deref(),
on_delta,
should_cancel,
)
.await
{
Ok(s) => s,
Err(e) => {
log::error!("[coord] QA: LLM answer failed: {e}");
// 把刚 push 的 user 消息回滚,避免 retry 重复
inner.qa_state.lock().messages.pop();
finish_qa_with_error(inner, format!("回答失败: {e}"));
return Err(e.to_string());
}
};
if inner.qa_state.lock().cancelled {
log::info!("[coord] QA cancel detected before answer — discarding");
// 同样回滚未配对的 user 消息
inner.qa_state.lock().messages.pop();
finish_qa_idle_silently(inner);
return Ok(());
}
inner
.qa_state
.lock()
.messages
.push(crate::types::QaChatMessage {
role: "assistant".to_string(),
content: answer.clone(),
});
if let Some(app) = inner.app.lock().clone() {
let messages = inner.qa_state.lock().messages.clone();
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "answer",
"messages": messages,
}),
);
}
// 胶囊直接收掉。QA 不走 insertion,没"已粘贴 N 字"语义;浮窗里答案就是用户的反馈。
// (之前用 Done 状态会被 capsule UI 错误地渲染上一次 dictation 残留的 message/insertedChars。)
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
// 可选:写一条 history(QA 类型)。当前 DictationSession schema 不能直接表达
// "QuestionAnswer" 类型,因此简单做法:勾选 qa_save_history 时写一条
// mode=Raw、error_code=Some("qaSession") 的 placeholder,避免污染 schema 同时
// 让用户能在历史里翻到这次问答的字面值。详见 issue #118。
if prefs.qa_save_history {
let session = DictationSession {
id: Uuid::new_v4().to_string(),
created_at: Utc::now().to_rfc3339(),
raw_transcript: question.clone(),
final_text: answer.clone(),
mode: PolishMode::Raw,
style_pack_id: None,
translation_active: false,
polish_source: None,
app_bundle_id: None,
app_name: front_app.clone(),
insert_status: InsertStatus::CopiedFallback,
error_code: Some("qaSession".to_string()),
duration_ms: Some(raw.duration_ms),
dictionary_entry_count: None,
has_audio_recording: None,
};
let prefs_snapshot = inner.prefs.get();
if let Err(e) = inner.history.append_with_retention(
session,
prefs_snapshot.history_retention_days,
prefs_snapshot.history_max_entries,
) {
log::error!("[coord] QA history append failed: {e}");
}
}
inner.qa_state.lock().phase = QaPhase::Idle;
Ok(())
}
/// 把出错状态送到前端浮窗 + 胶囊错误闪一下 + 复位 phase。
/// 浮窗保持可见(v2:错误后用户可以再按 Option 重试);messages 一并送过去
/// 让前端继续渲染历史对话。
pub(crate) fn finish_qa_with_error(inner: &Arc<Inner>, message: String) {
stop_qa_recorder(inner);
if let Some(app) = inner.app.lock().clone() {
let messages = inner.qa_state.lock().messages.clone();
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "error",
"error": message,
"messages": messages,
}),
);
}
emit_capsule(inner, CapsuleState::Error, 0.0, 0, Some(message), None);
schedule_capsule_idle(inner, 1500);
let mut state = inner.qa_state.lock();
state.phase = QaPhase::Idle;
state.cancelled = false;
}
/// 静默收尾:发 idle 事件给前端,phase 复位。**不关浮窗**(v2:浮窗只在用户
/// Esc/X 或再按 QA hotkey 时才关);多轮对话历史保留。胶囊也即刻收掉。
pub(crate) fn finish_qa_idle_silently(inner: &Arc<Inner>) {
if let Some(app) = inner.app.lock().clone() {
let messages = inner.qa_state.lock().messages.clone();
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "idle",
"messages": messages,
}),
);
}
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
let mut state = inner.qa_state.lock();
state.phase = QaPhase::Idle;
state.cancelled = false;
state.selection = None;
}
pub(crate) fn cancel_qa_session(inner: &Arc<Inner>) {
let phase = inner.qa_state.lock().phase;
if phase == QaPhase::Idle {
return;
}
inner.qa_state.lock().cancelled = true;
// SSE 流取消旗标——polish::chat_completion_history_streaming 的 loop 每帧检查
// 这个 flag,true 时立即 break 不再 drain HTTP body,避免取消后 LLM 仍烧 token。
// 详见 issue #161。
inner.qa_stream_cancelled.store(true, Ordering::SeqCst);
stop_qa_recorder(inner);
if let Some(asr) = inner.qa_asr.lock().take() {
cancel_active_asr(asr);
}
// Processing 阶段保持 phase 让 end_qa_session 自然走完 cancel 检查;
// 否则直接复位。
if phase != QaPhase::Processing {
inner.qa_state.lock().phase = QaPhase::Idle;
}
log::info!("[coord] QA session cancelled (was {phase:?})");
}
@@ -0,0 +1,846 @@
use super::dictation::abort_recording_with_error;
use super::*;
use crate::types::{HotkeyMode, HotkeyTrigger};
use once_cell::sync::Lazy;
static ENV_LOCK: Lazy<tokio::sync::Mutex<()>> = Lazy::new(|| tokio::sync::Mutex::new(()));
fn session_id(n: u128) -> SessionId {
Uuid::from_u128(n)
}
#[test]
fn split_polish_translate_parses_both_sections() {
let out = format!(
"{POLISH_TRANSLATE_SRC_MARKER}\n你好,世界。\n{POLISH_TRANSLATE_TGT_MARKER}\nHello, world."
);
let (source, translation) = split_polish_translate_output(&out).expect("both markers");
assert_eq!(source.as_deref(), Some("你好,世界。"));
assert_eq!(translation, "Hello, world.");
}
#[test]
fn split_polish_translate_no_translation_marker_returns_none_for_fallback() {
// 完全没有译文标记 → None,调用方据此退回专用翻译拿干净译文。
assert_eq!(split_polish_translate_output(" Hello, world. "), None);
}
#[test]
fn split_polish_translate_empty_translation_returns_none_for_fallback() {
// 有译文标记但内容为空(截断 / 只吐标记)→ None,避免空串当成功译文插入光标。
let out = format!("{POLISH_TRANSLATE_SRC_MARKER}\n你好。\n{POLISH_TRANSLATE_TGT_MARKER}\n ");
assert_eq!(split_polish_translate_output(&out), None);
}
#[test]
fn split_polish_translate_only_translation_marker_keeps_clean_translation() {
let out = format!("noise{POLISH_TRANSLATE_TGT_MARKER}\nHola");
let (source, translation) = split_polish_translate_output(&out).expect("tgt marker");
assert_eq!(source, None);
assert_eq!(translation, "Hola");
}
#[test]
fn split_polish_translate_empty_source_section_is_none() {
let out = format!("{POLISH_TRANSLATE_SRC_MARKER}\n \n{POLISH_TRANSLATE_TGT_MARKER}\nHi");
let (source, translation) = split_polish_translate_output(&out).expect("tgt marker");
assert_eq!(source, None);
assert_eq!(translation, "Hi");
}
#[test]
fn build_polish_translate_prompt_contains_markers_and_target() {
let p = build_polish_translate_system_prompt("日本語");
assert!(p.contains(POLISH_TRANSLATE_SRC_MARKER));
assert!(p.contains(POLISH_TRANSLATE_TGT_MARKER));
assert!(p.contains("日本語"));
}
#[tokio::test]
async fn hotkey_injection_gate_logs_pressed_and_cancels() {
let _ = env_logger::builder()
.filter_level(log::LevelFilter::Info)
.is_test(false)
.try_init();
let _guard = ENV_LOCK.lock().await;
std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1");
let coordinator = Coordinator::new();
coordinator.inject_hotkey_click_for_dev().await.unwrap();
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN");
}
/// 复现并验证目标 2(a):按下 Less Computer 键必须弹出可见胶囊。
/// 这里直接驱动 bridge 会调用的 handler,断言 begin_session 确实下发了可见胶囊。
#[tokio::test]
async fn less_computer_press_emits_visible_capsule() {
let _guard = ENV_LOCK.lock().await;
std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1");
let coordinator = Coordinator::new();
{
let mut prefs = coordinator.inner.prefs.get();
prefs.coding_agent_enabled = true;
coordinator.inner.prefs.set(prefs).unwrap();
}
// 前置:还没弹过任何胶囊。
assert!(coordinator.inner.last_capsule_state.lock().is_none());
// 等价于「按下 Less Computer 键」:bridge_loop 收到 Pressed 后就是调这个 handler。
super::handle_less_computer_pressed(&coordinator.inner).await;
assert_eq!(
*coordinator.inner.last_capsule_state.lock(),
Some(CapsuleState::Recording),
"按下 Less Computer 键必须进入录音并弹出可见胶囊"
);
std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN");
}
#[tokio::test]
async fn begin_session_dry_run_enters_listening_and_clears_stale_edges() {
let _guard = ENV_LOCK.lock().await;
std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1");
let coordinator = Coordinator::new();
let old_session_id = coordinator.inner.state.lock().session_id;
{
let mut state = coordinator.inner.state.lock();
state.pending_stop = true;
state.cancelled = true;
}
coordinator.start_dictation().await.unwrap();
let state = coordinator.inner.state.lock();
assert_eq!(state.phase, SessionPhase::Listening);
assert!(!state.pending_stop);
assert!(!state.cancelled);
assert_ne!(state.session_id, old_session_id);
std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN");
}
#[tokio::test]
async fn begin_session_ignores_non_idle_phase() {
let _guard = ENV_LOCK.lock().await;
std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1");
let coordinator = Coordinator::new();
let old_session_id = {
let mut state = coordinator.inner.state.lock();
state.phase = SessionPhase::Processing;
state.session_id = session_id(99);
state.session_id
};
coordinator.start_dictation().await.unwrap();
let state = coordinator.inner.state.lock();
assert_eq!(state.phase, SessionPhase::Processing);
assert_eq!(state.session_id, old_session_id);
std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN");
}
#[test]
fn window_key_matcher_mirrors_windows_trigger_aliases() {
let cases = [
(HotkeyTrigger::RightControl, "Control", "ControlRight"),
(HotkeyTrigger::LeftControl, "Control", "ControlLeft"),
(HotkeyTrigger::RightOption, "Alt", "AltRight"),
(HotkeyTrigger::RightAlt, "AltGraph", "AltRight"),
(HotkeyTrigger::RightCommand, "Meta", "MetaRight"),
(HotkeyTrigger::LeftOption, "Alt", "AltLeft"),
// Mirrors Windows trigger_to_vk_code aliases.
(HotkeyTrigger::Fn, "Control", "ControlRight"),
];
for (trigger, key, code) in cases {
assert!(
window_key_matches_trigger(trigger, key, code),
"{trigger:?} should match {key}/{code}"
);
}
assert!(!window_key_matches_trigger(
HotkeyTrigger::RightControl,
"Control",
"ControlLeft"
));
assert!(!window_key_matches_trigger(
HotkeyTrigger::LeftOption,
"Alt",
"AltRight"
));
assert!(!window_key_matches_trigger(HotkeyTrigger::Fn, "Fn", "Fn"));
}
#[test]
fn windows_local_providers_are_keyless_and_not_whisper_compatible() {
#[cfg(target_os = "windows")]
assert!(is_keyless_local_asr_provider(
crate::asr::local::foundry::PROVIDER_ID
));
#[cfg(target_os = "windows")]
assert!(is_keyless_local_asr_provider(
crate::asr::local::sherpa::PROVIDER_ID
));
#[cfg(not(target_os = "windows"))]
assert!(!is_keyless_local_asr_provider(
crate::asr::local::foundry::PROVIDER_ID
));
#[cfg(not(target_os = "windows"))]
assert!(!is_keyless_local_asr_provider(
crate::asr::local::sherpa::PROVIDER_ID
));
assert!(!is_whisper_compatible_provider(
crate::asr::local::foundry::PROVIDER_ID
));
assert!(!is_whisper_compatible_provider(
crate::asr::local::sherpa::PROVIDER_ID
));
assert!(!is_whisper_compatible_provider(
crate::asr::mimo::PROVIDER_ID
));
}
#[test]
fn verbose_json_enabled_only_for_whisper_family() {
// verbose_json + 幻听过滤只对返回完整 Whisper 指标的 provider 开启。
assert!(whisper_supports_verbose_json("whisper"));
assert!(whisper_supports_verbose_json("groq"));
// SiliconFlow(SenseVoice/TeleSpeech) / Zhipu(GLM-ASR) 保持旧的 json 行为。
assert!(!whisper_supports_verbose_json("siliconflow"));
assert!(!whisper_supports_verbose_json("zhipu"));
}
#[test]
fn openrouter_is_whisper_compatible_json_provider() {
use crate::asr::whisper::AsrRequestFormat;
// issue #582:OpenRouter 走 whisper 兼容路由,但请求体是 JSON+base64。
assert!(is_whisper_compatible_provider("openrouter"));
assert_eq!(
whisper_request_format("openrouter"),
AsrRequestFormat::OpenRouterJson
);
// 其余兼容厂商保持 multipart。
assert_eq!(
whisper_request_format("whisper"),
AsrRequestFormat::Multipart
);
assert_eq!(whisper_request_format("groq"), AsrRequestFormat::Multipart);
// OpenRouter 的 JSON 协议不吃 response_format,verbose_json 保持关闭。
assert!(!whisper_supports_verbose_json("openrouter"));
// base64 膨胀,长录音保守按 30s 切分。
assert_eq!(batch_asr_chunk_limit_ms("openrouter"), Some(30_000));
}
#[test]
fn qa_asr_provider_kind_tracks_active_provider() {
assert_eq!(
active_asr_provider_kind(crate::asr::bailian::PROVIDER_ID),
ActiveAsrProviderKind::Bailian
);
assert_eq!(
active_asr_provider_kind("whisper"),
ActiveAsrProviderKind::WhisperCompatible
);
assert_eq!(
active_asr_provider_kind(crate::asr::mimo::PROVIDER_ID),
ActiveAsrProviderKind::Mimo
);
assert_eq!(
active_asr_provider_kind("volcengine"),
ActiveAsrProviderKind::Volcengine
);
}
#[cfg(target_os = "windows")]
#[test]
fn coordinator_shares_app_foundry_runtime() {
let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new());
let coordinator = Coordinator::new_with_foundry_runtime(Arc::clone(&runtime));
assert!(Arc::ptr_eq(
&runtime,
&coordinator.inner.foundry_local_runtime
));
}
#[cfg(target_os = "windows")]
#[test]
fn foundry_transcribe_skips_global_timeout_for_first_run_provisioning() {
let provider = Arc::new(crate::asr::local::FoundryLocalWhisperAsr::new(
Arc::new(crate::asr::local::FoundryLocalRuntime::new()),
crate::asr::local::foundry::DEFAULT_MODEL_ALIAS.to_string(),
"auto".to_string(),
None,
));
let active_asr = ActiveAsr::FoundryLocalWhisper(provider);
assert!(!asr_transcribe_uses_global_timeout(&active_asr));
}
#[cfg(target_os = "windows")]
#[test]
fn foundry_audio_transcribe_timeout_is_separate_from_prepare() {
let timeout = foundry_audio_transcribe_timeout_duration();
assert_eq!(
timeout,
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
);
}
#[test]
fn local_qwen_timeout_floors_at_global_timeout_for_short_audio() {
// 5s 录音:5 × 0.6 = 3, +10 = 13, max(15) = 15。短录音保留 15s 兜底。
assert_eq!(
local_qwen_transcribe_timeout(5.0),
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
);
}
#[test]
fn local_qwen_timeout_scales_with_audio_duration() {
// 60s 录音:60 × 0.6 = 36, +10 = 46s。覆盖 RTF ≈ 0.5 的边界。
assert_eq!(
local_qwen_transcribe_timeout(60.0),
std::time::Duration::from_secs(46)
);
}
#[test]
fn local_qwen_timeout_ceils_partial_seconds() {
// 10.1s 录音:10.1 × 0.6 = 6.06, ceil = 7, +10 = 17, max(15) = 17。
assert_eq!(
local_qwen_transcribe_timeout(10.1),
std::time::Duration::from_secs(17)
);
}
#[test]
fn local_qwen_timeout_handles_zero_duration() {
// 0 时长(空 buffer 边界):0 × 0.6 = 0, +10 = 10, max(15) = 15。
assert_eq!(
local_qwen_transcribe_timeout(0.0),
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
);
}
#[cfg(target_os = "windows")]
#[test]
fn foundry_release_uses_foundry_keep_loaded_preference() {
let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new());
let coordinator = Coordinator::new_with_foundry_runtime(runtime);
let mut prefs = coordinator.inner.prefs.get();
prefs.local_asr_keep_loaded_secs = 3;
prefs.foundry_local_asr_keep_loaded_secs = 7;
coordinator.inner.prefs.set(prefs).unwrap();
assert_eq!(foundry_local_asr_release_keep_secs(&coordinator.inner), 7);
}
#[cfg(target_os = "windows")]
#[test]
fn foundry_release_guard_rejects_stale_dictation_session() {
let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new());
let coordinator = Coordinator::new_with_foundry_runtime(runtime);
let old_session_id = coordinator.inner.state.lock().session_id;
assert!(asr_release_session_is_current(
&coordinator.inner,
AsrReleaseSession::Dictation(old_session_id)
));
coordinator.inner.state.lock().session_id = new_session_id();
assert!(!asr_release_session_is_current(
&coordinator.inner,
AsrReleaseSession::Dictation(old_session_id)
));
}
#[cfg(target_os = "windows")]
#[test]
fn local_asr_release_guard_rejects_stale_qa_session() {
let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new());
let coordinator = Coordinator::new_with_foundry_runtime(runtime);
let old_session_id = coordinator.inner.qa_state.lock().session_id;
assert!(asr_release_session_is_current(
&coordinator.inner,
AsrReleaseSession::Qa(old_session_id)
));
coordinator.inner.qa_state.lock().session_id = new_session_id();
assert!(!asr_release_session_is_current(
&coordinator.inner,
AsrReleaseSession::Qa(old_session_id)
));
}
#[test]
fn resolve_ark_endpoint_rejects_blank_key_without_custom_endpoint() {
assert_eq!(
resolve_ark_endpoint_with_policy("", None)
.unwrap_err()
.to_string(),
"API Key 为空"
);
}
#[test]
fn resolve_ark_endpoint_allows_blank_key_with_custom_endpoint() {
let endpoint = resolve_ark_endpoint_with_policy(
"",
Some("https://example.com/v1/chat/completions".to_string()),
)
.unwrap();
assert_eq!(endpoint, "https://example.com/v1/chat/completions");
}
#[test]
fn deferred_asr_bridge_flushes_startup_audio_before_live_chunks() {
#[derive(Default)]
struct RecordingConsumer {
bytes: Mutex<Vec<u8>>,
}
impl crate::asr::AudioConsumer for RecordingConsumer {
fn consume_pcm_chunk(&self, pcm: &[u8]) {
self.bytes.lock().extend_from_slice(pcm);
}
}
let bridge = DeferredAsrBridge::new();
crate::recorder::AudioConsumer::consume_pcm_chunk(&bridge, &[1, 2]);
crate::recorder::AudioConsumer::consume_pcm_chunk(&bridge, &[3, 4]);
let target = Arc::new(RecordingConsumer::default());
let target_for_attach: Arc<dyn crate::asr::AudioConsumer> = target.clone();
assert_eq!(bridge.attach(target_for_attach), 4);
crate::recorder::AudioConsumer::consume_pcm_chunk(&bridge, &[5, 6]);
assert_eq!(&*target.bytes.lock(), &[1, 2, 3, 4, 5, 6]);
}
#[tokio::test]
async fn manual_stop_during_starting_is_queued() {
let coordinator = Coordinator::new();
{
let mut state = coordinator.inner.state.lock();
state.phase = SessionPhase::Starting;
state.pending_stop = false;
}
coordinator.stop_dictation().await.unwrap();
let state = coordinator.inner.state.lock();
assert_eq!(state.phase, SessionPhase::Starting);
assert!(state.pending_stop);
}
#[tokio::test]
async fn stop_dictation_from_listening_without_asr_returns_idle() {
let coordinator = Coordinator::new();
{
let mut state = coordinator.inner.state.lock();
state.phase = SessionPhase::Listening;
state.session_id = session_id(123);
}
coordinator.stop_dictation().await.unwrap();
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
}
#[test]
fn cancel_session_state_machine_is_table_driven() {
let cases = [
(SessionPhase::Idle, SessionPhase::Idle, false),
(SessionPhase::Starting, SessionPhase::Idle, true),
(SessionPhase::Listening, SessionPhase::Idle, true),
(SessionPhase::Processing, SessionPhase::Processing, true),
(SessionPhase::Inserting, SessionPhase::Inserting, false),
];
for (initial, expected_phase, expected_cancelled) in cases {
let coordinator = Coordinator::new();
{
let mut state = coordinator.inner.state.lock();
state.phase = initial;
state.cancelled = false;
state.focus_target = Some(1);
}
coordinator.cancel_dictation();
let state = coordinator.inner.state.lock();
assert_eq!(state.phase, expected_phase, "initial={initial:?}");
assert_eq!(state.cancelled, expected_cancelled, "initial={initial:?}");
if matches!(initial, SessionPhase::Starting | SessionPhase::Listening) {
assert!(state.focus_target.is_none(), "initial={initial:?}");
}
}
}
#[test]
fn recorder_runtime_error_aborts_active_session() {
let coordinator = Coordinator::new();
{
let mut state = coordinator.inner.state.lock();
state.phase = SessionPhase::Listening;
state.cancelled = false;
}
abort_recording_with_error(&coordinator.inner, "录音中断: stream failed".to_string());
let state = coordinator.inner.state.lock();
assert_eq!(state.phase, SessionPhase::Idle);
assert!(state.cancelled);
assert!(coordinator.inner.recorder.lock().is_none());
assert!(coordinator.inner.asr.lock().is_none());
}
#[test]
fn abort_recording_keeps_session_non_idle_until_restore_can_run() {
let mut state = SessionState::default();
state.phase = SessionPhase::Listening;
state.cancelled = false;
state.session_id = session_id(7);
let abort = begin_recording_abort_before_restore(&mut state).unwrap();
assert_eq!(abort.session_id, session_id(7));
assert!(state.cancelled);
assert_eq!(state.phase, SessionPhase::Listening);
publish_abort_idle_after_restore(&mut state, abort.session_id);
assert_eq!(state.phase, SessionPhase::Idle);
}
#[tokio::test]
async fn pressed_edge_during_inserting_does_not_start_new_session() {
let coordinator = Coordinator::new();
{
let mut state = coordinator.inner.state.lock();
state.phase = SessionPhase::Inserting;
state.session_id = session_id(41);
}
handle_pressed_edge(&coordinator.inner).await;
let state = coordinator.inner.state.lock();
assert_eq!(state.phase, SessionPhase::Inserting);
assert_eq!(state.session_id, session_id(41));
}
#[tokio::test]
async fn repeated_pressed_edge_during_hold_session_does_not_restart() {
let coordinator = Coordinator::new();
coordinator
.inner
.prefs
.set(crate::types::UserPreferences {
hotkey: crate::types::HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
mode: HotkeyMode::Hold,
keys: None,
},
..Default::default()
})
.unwrap();
coordinator.inner.state.lock().phase = SessionPhase::Listening;
coordinator
.inner
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
handle_pressed_edge(&coordinator.inner).await;
assert_eq!(
coordinator.inner.state.lock().phase,
SessionPhase::Listening
);
assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
}
#[test]
fn enabling_shortcut_recording_clears_dictation_hold_latch() {
let coordinator = Coordinator::new();
coordinator
.inner
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
coordinator.set_shortcut_recording_active(true);
assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
}
#[test]
fn window_hotkey_fallback_is_disabled_when_no_explicit_fallback_is_advertised() {
assert_eq!(
window_hotkey_fallback_enabled(),
crate::types::HotkeyCapability::current().explicit_fallback_available
);
}
#[test]
fn capsule_show_strategy_matches_platform_activation_contract() {
// 平台列表必须与 capsule_show_strategy_for_platform 的 cfg 完全一致:
// 改实现里的 #[cfg] 时,一并改这两个 #[cfg],否则 Linux CI 直接红
// (fcitx5 PR #451 把 Linux 加进 NoActivate 但漏改本测试,CI 失败)。
#[cfg(any(target_os = "macos", target_os = "windows"))]
assert_eq!(
capsule_show_strategy_for_platform(),
CapsuleShowStrategy::NoActivate
);
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
assert_eq!(
capsule_show_strategy_for_platform(),
CapsuleShowStrategy::FallbackShow
);
}
#[test]
#[cfg(target_os = "windows")]
fn prepared_windows_ime_slot_is_taken_only_for_matching_session() {
let mut slots = vec![PreparedWindowsImeSessionSlot {
session_id: session_id(2),
prepared: PreparedWindowsImeSession::unavailable(),
}];
assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(1)).is_none());
assert_eq!(
slots.iter().map(|slot| slot.session_id).collect::<Vec<_>>(),
vec![session_id(2)]
);
assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(2)).is_some());
assert!(slots.is_empty());
}
#[test]
#[cfg(target_os = "windows")]
fn prepared_windows_ime_sessions_keep_overlapping_snapshots() {
let mut slots = Vec::new();
store_prepared_windows_ime_session(
&mut slots,
session_id(1),
PreparedWindowsImeSession::unavailable(),
);
store_prepared_windows_ime_session(
&mut slots,
session_id(2),
PreparedWindowsImeSession::unavailable(),
);
assert_eq!(
slots.iter().map(|slot| slot.session_id).collect::<Vec<_>>(),
vec![session_id(1), session_id(2)]
);
assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(1)).is_some());
assert_eq!(
slots.iter().map(|slot| slot.session_id).collect::<Vec<_>>(),
vec![session_id(2)]
);
}
#[test]
#[cfg(target_os = "windows")]
fn stale_prepared_windows_ime_restore_discards_old_snapshot_without_restoring() {
let mut slots = Vec::new();
store_prepared_windows_ime_session(
&mut slots,
session_id(1),
PreparedWindowsImeSession::unavailable(),
);
store_prepared_windows_ime_session(
&mut slots,
session_id(2),
PreparedWindowsImeSession::unavailable(),
);
assert!(take_current_prepared_windows_ime_session_for_restore(
&mut slots,
session_id(1),
session_id(2)
)
.is_none());
assert_eq!(
slots.iter().map(|slot| slot.session_id).collect::<Vec<_>>(),
vec![session_id(2)]
);
}
#[test]
#[cfg(target_os = "windows")]
fn non_tsf_insertion_fallback_gate_blocks_only_when_disabled() {
assert!(should_try_non_tsf_insertion_fallback(
true,
InsertStatus::CopiedFallback
));
assert!(should_try_non_tsf_insertion_fallback(
true,
InsertStatus::Failed
));
assert!(!should_try_non_tsf_insertion_fallback(
true,
InsertStatus::Inserted
));
assert!(!should_try_non_tsf_insertion_fallback(
false,
InsertStatus::CopiedFallback
));
assert!(!should_try_non_tsf_insertion_fallback(
false,
InsertStatus::Failed
));
}
#[test]
fn focus_restore_failure_uses_specific_error_code_when_insert_fails() {
assert_eq!(
dictation_error_code(InsertStatus::Failed, false, false, false),
Some("focusRestoreFailed")
);
}
#[test]
#[cfg(target_os = "windows")]
fn missing_windows_hwnd_is_not_present() {
use windows::Win32::Foundation::HWND;
assert!(!windows_hwnd_is_present(HWND::default()));
}
#[test]
#[cfg(target_os = "windows")]
fn tsf_required_failure_keeps_tsf_error_when_focus_was_ready() {
assert_eq!(
dictation_error_code(InsertStatus::Failed, false, true, false),
Some("windowsImeTsfRequired")
);
}
#[test]
fn startup_race_check_treats_newer_session_as_stale() {
let mut state = SessionState::default();
state.phase = SessionPhase::Starting;
state.cancelled = false;
state.session_id = session_id(2);
assert_eq!(
startup_race_status(&state, session_id(1)),
StartupRaceStatus::StaleContinuation
);
}
#[test]
fn startup_race_check_is_table_driven_for_begin_session_edges() {
let cases = [
(
SessionPhase::Starting,
false,
session_id(7),
StartupRaceStatus::ActiveStarting,
),
(
SessionPhase::Starting,
true,
session_id(7),
StartupRaceStatus::CancelRaced,
),
(
SessionPhase::Idle,
false,
session_id(7),
StartupRaceStatus::CancelRaced,
),
(
SessionPhase::Listening,
false,
session_id(7),
StartupRaceStatus::CancelRaced,
),
(
SessionPhase::Starting,
false,
session_id(8),
StartupRaceStatus::StaleContinuation,
),
];
for (phase, cancelled, actual_session_id, expected) in cases {
let mut state = SessionState::default();
state.phase = phase;
state.cancelled = cancelled;
state.session_id = actual_session_id;
assert_eq!(
startup_race_status(&state, session_id(7)),
expected,
"phase={phase:?} cancelled={cancelled} actual_session={actual_session_id}"
);
}
}
#[test]
fn begin_recording_abort_is_noop_after_prior_cancel_or_idle() {
let cases = [
(SessionPhase::Idle, false),
(SessionPhase::Processing, false),
(SessionPhase::Listening, true),
];
for (phase, cancelled) in cases {
let mut state = SessionState::default();
state.phase = phase;
state.cancelled = cancelled;
assert!(begin_recording_abort_before_restore(&mut state).is_none());
assert_eq!(state.phase, phase);
assert_eq!(state.cancelled, cancelled);
}
}
#[test]
fn stale_startup_cleanup_keeps_newer_asr_resource() {
let coordinator = Coordinator::new();
let newer_asr = Arc::new(WhisperBatchASR::new(
"key".to_string(),
"http://localhost".to_string(),
"model".to_string(),
None,
None,
false,
));
*coordinator.inner.asr.lock() = Some(SessionResource::new(
session_id(2),
ActiveAsr::Whisper(Arc::clone(&newer_asr)),
));
discard_startup_resources_for_session(&coordinator.inner, session_id(1));
assert_eq!(
coordinator
.inner
.asr
.lock()
.as_ref()
.map(|resource| resource.session_id),
Some(session_id(2))
);
discard_startup_resources_for_session(&coordinator.inner, session_id(2));
assert!(coordinator.inner.asr.lock().is_none());
}
@@ -0,0 +1,356 @@
//! Less Computer 语音 Agent 热键(coding_agent / less_computer)的 supervisor /
//! bridge / 事件处理。从 `coordinator.rs` 机械拆出(行为保持,仅可见性提升)。
use super::*;
// ─────────────────────── coding agent hotkey supervisor ───────────────────────
pub(crate) fn coding_agent_hotkey_supervisor_loop(inner: Arc<Inner>) {
loop {
if inner.shutdown.load(Ordering::SeqCst) {
return;
}
update_coding_agent_hotkey_binding_now(&inner);
std::thread::sleep(std::time::Duration::from_secs(5));
}
}
pub(crate) fn update_coding_agent_hotkey_binding_now(inner: &Arc<Inner>) {
#[cfg(not(target_os = "macos"))]
{
// Less Computer is intentionally macOS-only for now; keep Windows/Linux hidden and inert.
take_coding_agent_hotkeys_on_main_thread(inner);
return;
}
#[cfg(target_os = "macos")]
{
let prefs = inner.prefs.get();
let Some(binding) = prefs.coding_agent_voice_hotkey.clone() else {
take_coding_agent_hotkeys_on_main_thread(inner);
log::info!("[less-computer] hotkey disabled");
return;
};
if !prefs.coding_agent_enabled || is_unconfigured_shortcut(&binding) {
take_coding_agent_hotkeys_on_main_thread(inner);
return;
}
if let Some(modifier_binding) = less_computer_modifier_binding(&binding) {
take_coding_agent_combo_hotkey_on_main_thread(inner);
if let Some(monitor) = inner.coding_agent_modifier_hotkey.lock().as_ref() {
monitor.update_binding(modifier_binding);
return;
}
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
match HotkeyMonitor::start(modifier_binding, tx) {
Ok(monitor) => {
*inner.coding_agent_modifier_hotkey.lock() = Some(monitor);
log::info!(
"[less-computer] modifier hotkey installed ({})",
binding.display_label()
);
let bridge_inner = Arc::clone(inner);
std::thread::Builder::new()
.name("openless-less-computer-modifier-bridge".into())
.spawn(move || less_computer_modifier_bridge_loop(bridge_inner, rx))
.ok();
}
Err(e) => log::warn!("[less-computer] modifier hotkey install failed: {e}"),
}
return;
}
inner.coding_agent_modifier_hotkey.lock().take();
let app = match inner.app.lock().clone() {
Some(app) => app,
None => {
log::warn!("[less-computer] AppHandle 未 bind,跳过组合键注册");
return;
}
};
let inner_clone = Arc::clone(inner);
let binding_for_main = binding.clone();
let _ = app.run_on_main_thread(move || {
if let Some(monitor) = inner_clone.coding_agent_combo_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(binding_for_main.clone()) {
log::warn!("[less-computer] combo hotkey update failed: {e}");
}
return;
}
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
match ComboHotkeyMonitor::start(binding_for_main.clone(), tx) {
Ok(monitor) => {
*inner_clone.coding_agent_combo_hotkey.lock() = Some(monitor);
log::info!(
"[less-computer] combo hotkey installed ({})",
binding_for_main.display_label()
);
let bridge_inner = Arc::clone(&inner_clone);
std::thread::Builder::new()
.name("openless-less-computer-combo-bridge".into())
.spawn(move || less_computer_combo_bridge_loop(bridge_inner, rx))
.ok();
}
Err(e) => log::warn!("[less-computer] combo hotkey install failed: {e}"),
}
});
}
}
#[cfg(target_os = "macos")]
pub(crate) fn less_computer_modifier_binding(
binding: &crate::types::ShortcutBinding,
) -> Option<crate::types::HotkeyBinding> {
let trigger = crate::shortcut_binding::legacy_modifier_trigger(binding)?;
Some(crate::types::HotkeyBinding {
trigger,
mode: crate::types::HotkeyMode::Hold,
keys: None,
})
}
/// Less Computer bridge 循环把不同来源的事件枚举(修饰键 `HotkeyEvent` /
/// combo `ComboHotkeyEvent`)归一成的语义边沿。
enum LessComputerEdge {
Pressed,
Released,
Cancelled,
/// 与 Less Computer 无关的事件(如 translation/qa),忽略。
Ignore,
}
/// 修饰键与 combo 两个 bridge 循环体逐字相同,只是事件枚举类型不同。抽出泛型
/// 循环,调用方传入把各自事件映射到 `LessComputerEdge` 的闭包。`block_on(handle_*)`
/// 调用语义保持不变。
fn less_computer_bridge_loop<E>(
inner: Arc<Inner>,
rx: mpsc::Receiver<E>,
to_edge: impl Fn(&E) -> LessComputerEdge,
) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
let inner_cloned = Arc::clone(&inner);
match to_edge(&evt) {
LessComputerEdge::Pressed => {
async_runtime::block_on(async {
handle_less_computer_pressed(&inner_cloned).await
});
}
LessComputerEdge::Released => {
async_runtime::block_on(async {
handle_less_computer_released(&inner_cloned).await
});
}
LessComputerEdge::Cancelled => cancel_session(&inner_cloned),
LessComputerEdge::Ignore => {}
}
}
}
pub(crate) fn less_computer_modifier_bridge_loop(
inner: Arc<Inner>,
rx: mpsc::Receiver<HotkeyEvent>,
) {
less_computer_bridge_loop(inner, rx, |evt| match evt {
HotkeyEvent::Pressed => LessComputerEdge::Pressed,
HotkeyEvent::Released => LessComputerEdge::Released,
HotkeyEvent::Cancelled => LessComputerEdge::Cancelled,
HotkeyEvent::TranslationModifierPressed | HotkeyEvent::QaShortcutPressed => {
LessComputerEdge::Ignore
}
});
}
pub(crate) fn less_computer_combo_bridge_loop(
inner: Arc<Inner>,
rx: mpsc::Receiver<ComboHotkeyEvent>,
) {
less_computer_bridge_loop(inner, rx, |evt| match evt {
ComboHotkeyEvent::Pressed => LessComputerEdge::Pressed,
ComboHotkeyEvent::Released => LessComputerEdge::Released,
});
}
pub(crate) async fn handle_less_computer_pressed(inner: &Arc<Inner>) {
let prefs = inner.prefs.get();
if !prefs.coding_agent_enabled {
return;
}
if !matches!(inner.state.lock().phase, SessionPhase::Idle) {
log::info!("[less-computer] press ignored: dictation session already active");
return;
}
if !matches!(inner.qa_state.lock().phase, QaPhase::Idle) {
log::info!("[less-computer] press ignored: QA session active");
return;
}
if begin_session(inner).await.is_err() {
return;
}
let started = {
let mut state = inner.state.lock();
if matches!(
state.phase,
SessionPhase::Starting | SessionPhase::Listening
) {
state.voice_agent = true;
log::info!(
"[less-computer] voice session started (session={:?})",
state.session_id
);
true
} else {
false
}
};
// 一按下键(开始录音)就点亮整屏彩虹描边,贯穿 录音 → 处理 → 出结果,完成/关闭才熄灭。
if started {
if let Some(app) = inner.app.lock().clone() {
crate::show_less_computer_glow(&app);
}
}
}
pub(crate) async fn handle_less_computer_released(inner: &Arc<Inner>) {
let (phase, voice_agent) = {
let state = inner.state.lock();
(state.phase, state.voice_agent)
};
if !voice_agent {
return;
}
match phase {
SessionPhase::Listening => {
let _ = end_session(inner).await;
// 收尾后熄灭整屏描边。正常路径 run_voice_agent_transcript 已熄过、这里兜底;
// 空转写/出错路径不进 run_voice_agent_transcript,全靠这里熄,否则描边卡住不灭。
if let Some(app) = inner.app.lock().clone() {
crate::hide_less_computer_glow(&app);
}
}
SessionPhase::Starting => {
// 握手中松手:排队;正常路径真正收尾在 begin 续流的 end_session → run_voice_agent_transcript 熄灭。
request_stop_during_starting(inner, "less-computer release edge");
// 但若初始化失败永远到不了 Listening(不会进 run_voice_agent_transcript),
// 描边会永久卡屏 → 这里兜底熄灭。Listening 分支已有熄灭逻辑,故只在 Starting 加。
if let Some(app) = inner.app.lock().clone() {
crate::hide_less_computer_glow(&app);
}
}
_ => {}
}
}
pub(crate) fn take_coding_agent_hotkeys_on_main_thread(inner: &Arc<Inner>) {
inner.coding_agent_modifier_hotkey.lock().take();
take_coding_agent_combo_hotkey_on_main_thread(inner);
}
pub(crate) fn take_coding_agent_combo_hotkey_on_main_thread(inner: &Arc<Inner>) {
take_combo_monitor_on_main_thread(inner, |inner| &inner.coding_agent_combo_hotkey);
}
/// 快取用:抓当前选中文本 → Claude 润色 → 回插(替换选区)。全程胶囊反馈。
pub(crate) async fn handle_coding_agent_quick(inner: &Arc<Inner>) {
let prefs = inner.prefs.get();
if !prefs.coding_agent_enabled {
return;
}
let selection = tauri::async_runtime::spawn_blocking(crate::selection::capture_selection)
.await
.ok()
.flatten();
let source_text = match selection {
Some(ctx) => ctx.text,
None => {
log::info!("[coding-agent] 快取用:没有选中文本");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some("请先选中文本,再按快捷键".to_string()),
None,
);
return;
}
};
log::info!(
"[coding-agent] 快取用:润色 {} 字",
source_text.chars().count()
);
emit_capsule(
inner,
CapsuleState::Polishing,
0.0,
0,
Some("Claude 润色中…".to_string()),
None,
);
let prompt = format!(
"请润色下面这段文字,使其更通顺自然、表达更清晰,保持原意、语言和事实不变。\
直接输出润色后的文本,不要加任何解释、前缀或引号:\n\n{source_text}"
);
// 纯文本润色:不需要任何工具 → plan 只读、无 guard、便宜快、最可靠。
let mut req = crate::coding_agent::CodingAgentRequest::new("quick-polish", prompt);
req.model = prefs
.coding_agent_model
.clone()
.filter(|m| !m.trim().is_empty())
.or_else(|| Some("sonnet".to_string()));
req.permission_mode = crate::coding_agent::CodingAgentPermissionMode::Plan;
req.allowed_tools = Vec::new();
req.max_budget_usd = Some(0.2);
req.timeout_secs = 60;
req.session_persistence = false;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false));
let run = async_runtime::spawn(async move {
crate::coding_agent::run_claude_agent("claude", req, tx, cancel).await
});
let mut final_text = String::new();
let mut error_msg: Option<String> = None;
while let Some(ev) = rx.recv().await {
match ev {
crate::coding_agent::CodingAgentEvent::Completed { text, .. } => final_text = text,
crate::coding_agent::CodingAgentEvent::Error { message, .. } => {
error_msg = Some(message)
}
_ => {}
}
}
let run_result = run.await;
let final_text = final_text.trim().to_string();
if final_text.is_empty() {
let msg = error_msg
.or_else(|| match run_result {
Ok(Err(e)) => Some(e.to_string()),
_ => None,
})
.unwrap_or_else(|| "Claude 无结果(确认已登录 claude 且额度充足)".to_string());
log::warn!("[coding-agent] 快取用失败: {msg}");
emit_capsule(inner, CapsuleState::Error, 0.0, 0, Some(msg), None);
return;
}
let inserted = final_text.chars().count() as u32;
let inner2 = Arc::clone(inner);
let restore = prefs.restore_clipboard_after_paste;
let paste_shortcut = prefs.paste_shortcut;
let _ = tauri::async_runtime::spawn_blocking(move || {
inner2.inserter.insert(&final_text, restore, paste_shortcut)
})
.await;
log::info!("[coding-agent] 快取用:已回插 {inserted} 字");
emit_capsule(inner, CapsuleState::Done, 0.0, 0, None, Some(inserted));
}
@@ -12,6 +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 { formatBytes as formatBytesShared } from '../lib/format';
import { isTauri, restartApp, type UpdateChannel } from '../lib/ipc';
import { Btn } from '../pages/_atoms';
@@ -240,9 +241,7 @@ export function UpdateDialog({
);
}
// 下载进度尺寸:封顶 MB、对非有限/<=0 值兜底成 0 B。
function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
return formatBytesShared(value, { maxUnit: 'MB', guard: true });
}
+71
View File
@@ -0,0 +1,71 @@
// 跨页面共享的格式化工具。把原本散落在 Overview/History/LocalAsr/AutoUpdate/
// settings 各处重复(且行为略有差异)的 clamp / formatTime / formatDuration /
// formatBytes 收口到一处,调用方用参数区分各自需要的分支,行为保持不变。
import type { useTranslation } from 'react-i18next';
type TFn = ReturnType<typeof useTranslation>['t'];
/** 把 n 夹到 [min, max]。 */
export function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n));
}
/**
* 把 ISO 时间格式化为简短显示。
* - 当天:`HH:MM`
* - 非当天:默认 `M/D`;`withTimeOnOtherDays` 为 true 时为 `M/D HH:MM`。
* 无效时间原样返回输入串。
*/
export function formatTime(iso: string, opts?: { withTimeOnOtherDays?: boolean }): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
const pad = (n: number) => String(n).padStart(2, '0');
if (sameDay) return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
if (opts?.withTimeOnOtherDays) {
return `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
return `${d.getMonth() + 1}/${d.getDate()}`;
}
/**
* 把毫秒时长格式化。<=0 / null 返回占位符 `—`。
* <60s 用 i18n `common.durationSeconds`;>=60s 默认用 i18n `common.durationMinutes`,
* `minutesAsClock` 为 true 时改用 `M:SS` 时钟格式。
*/
export function formatDuration(
ms: number | null,
t: TFn,
opts?: { minutesAsClock?: boolean }
): string {
if (ms == null || ms <= 0) return '—';
const sec = ms / 1000;
if (sec < 60) return t('common.durationSeconds', { value: sec.toFixed(1) });
if (opts?.minutesAsClock) {
return `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`;
}
return t('common.durationMinutes', { value: (sec / 60).toFixed(1) });
}
/**
* 把字节数格式化为人类可读尺寸。
* - `maxUnit`:封顶单位(默认 GB);'MB' 时不再升到 GB。
* - `mbDigits`:MB 档小数位(AutoUpdate 用 1,LocalAsr 用 0)。
* - `guard`:非有限或 <=0 时返回 `0 B`(AutoUpdate 下载进度需要)。
*/
export function formatBytes(
n: number,
opts?: { maxUnit?: 'MB' | 'GB'; mbDigits?: number; guard?: boolean }
): string {
const maxUnit = opts?.maxUnit ?? 'GB';
const mbDigits = opts?.mbDigits ?? 1;
if (opts?.guard && (!Number.isFinite(n) || n <= 0)) return '0 B';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (maxUnit === 'MB' || n < 1024 * 1024 * 1024) {
return `${(n / 1024 / 1024).toFixed(mbDigits)} MB`;
}
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
+3 -14
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import { detectOS } from '../components/WindowChrome';
import { formatDuration, formatTime as formatTimeShared } from '../lib/format';
import { formatComboLabel } from '../lib/hotkey';
import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording } from '../lib/ipc';
import type { DictationSession, PolishMode } from '../lib/types';
@@ -399,19 +400,7 @@ function AudioRecordingPlayer({
);
}
// History 非当天显示带时分(与 Overview 只显示 M/D 不同)。
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
const pad = (n: number) => String(n).padStart(2, '0');
if (sameDay) return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
return `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function formatDuration(ms: number | null, t: ReturnType<typeof useTranslation>['t']): string {
if (ms == null || ms <= 0) return '—';
const sec = ms / 1000;
if (sec < 60) return t('common.durationSeconds', { value: sec.toFixed(1) });
return t('common.durationMinutes', { value: (sec / 60).toFixed(1) });
return formatTimeShared(iso, { withTimeOnOtherDays: true });
}
+3 -4
View File
@@ -78,6 +78,7 @@ import {
type SherpaOnnxModelAlias,
type SherpaPrepareProgress,
} from "../lib/localAsr"
import { formatBytes as formatBytesShared } from "../lib/format"
import { useHotkeySettings } from "../state/HotkeySettingsContext"
import { detectOS } from "../components/WindowChrome"
import { SelectLite } from "../components/ui/SelectLite"
@@ -3388,9 +3389,7 @@ function formatFoundrySizeMb(
return Math.round(fileSizeMb).toLocaleString()
}
// 模型尺寸:升到 GB、MB 档取整(与 AutoUpdate 的 1 位小数下载进度不同)。
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(0)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
return formatBytesShared(n, { maxUnit: "GB", mbDigits: 0 })
}
+3 -14
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import { formatDuration as formatDurationShared, formatTime } from '../lib/format';
import { formatComboLabel } from '../lib/hotkey';
import { getCredentials, listHistory } from '../lib/ipc';
import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types';
@@ -368,21 +369,9 @@ function RecentRow({ session, modeLabel }: { session: DictationSession; modeLabe
);
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
const pad = (n: number) => String(n).padStart(2, '0');
if (sameDay) return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
return `${d.getMonth() + 1}/${d.getDate()}`;
}
// Overview 用 M:SS 时钟格式展示时长(与 History 的「分钟」i18n 文案不同)。
function formatDuration(ms: number, t: ReturnType<typeof useTranslation>['t']): string {
if (ms <= 0) return '—';
const sec = ms / 1000;
if (sec < 60) return t('common.durationSeconds', { value: sec.toFixed(1) });
return `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`;
return formatDurationShared(ms, t, { minutesAsClock: true });
}
function weekDayLabels(names: string[]): string[] {
@@ -2,13 +2,11 @@
// 自 Settings.tsx 的 RecordingSection「历史与上下文」折叠组拆出,逻辑零改动。
import { useTranslation } from 'react-i18next';
import { clamp } from '../../lib/format';
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();
@@ -4,13 +4,12 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { clamp } from '../../lib/format';
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();