Merge remote-tracking branch 'origin/beta' into feat/fast-agent

This commit is contained in:
吕柏青 committed 2026-06-05 08:08:50 +08:00
commit d5756db8a6
23 files changed
+628 -167

No files matched your search

+10 -8
View File
@@ -27,6 +27,7 @@
<a href="https://github.com/appergb/openless/releases/latest"><img alt="Release" src="https://img.shields.io/github/v/release/appergb/openless?style=flat-square&color=2c5282" /></a>
<a href="https://github.com/appergb/openless/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/appergb/openless?style=flat-square&color=2f855a" /></a>
<a href="https://github.com/appergb/openless/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/appergb/openless?style=flat-square&color=805ad5" /></a>
<a href="https://discord.gg/vTZHTFGFm"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
</p>
<p align="center">
@@ -37,7 +38,7 @@
</p>
<p align="center">
💬 &nbsp;QQ Group&nbsp; <strong>1078960553</strong>
💬 &nbsp;<a href="https://discord.gg/vTZHTFGFm"><strong>Join our Discord</strong></a> &nbsp;·&nbsp; QQ Group&nbsp; <strong>1078960553</strong>
</p>
<br/>
@@ -122,20 +123,21 @@ Two features define how OpenLess feels in daily use:
Hold the hotkey and say:
> uh… so… I want ChatGPT to write me a SQL query, from the orders table get last month's orders, group by customer, sort by amount desc, top ten
> uh… so… I need to reply to the client about the proposal they sent last time, we talked it over internally and the overall direction is fine but a few details need changing, first the delivery timeline is too tight we'd like to push it back two weeks, second on budget their quote came in about twenty percent higher than we expected can we negotiate that, and overall we're positive about working together so reassure them, keep the tone polite not too blunt
Release the hotkey. A moment later, your input box reads:
```text
Please write a SQL query that:
Reply to the client about the proposal they sent. After internal discussion, the overall direction is fine, but a few details need adjusting:
- Pulls orders from last month from the `orders` table.
- Groups by customer.
- Sorts by total amount, descending.
- Returns the top 10 rows only.
1. Delivery timeline
(a) The current timeline is too tight; we'd like to extend it by two weeks.
2. Budget
(a) Their quote is about 20% higher than we expected — can we renegotiate?
Keep the overall message positive about the partnership, and keep the tone polite rather than blunt.
```
No edits are required — press Enter and send. That is the core idea: **compose prompts by speaking, faster and more cleanly than typing them.**
No edits are required — paste it into ChatGPT or Claude and let it draft the email. That is the core idea: **compose prompts by speaking, faster and more cleanly than typing them.**
## Why OpenLess is open source
+10 -8
View File
@@ -27,6 +27,7 @@
<a href="https://github.com/appergb/openless/releases/latest"><img alt="Release" src="https://img.shields.io/github/v/release/appergb/openless?style=flat-square&color=2c5282" /></a>
<a href="https://github.com/appergb/openless/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/appergb/openless?style=flat-square&color=2f855a" /></a>
<a href="https://github.com/appergb/openless/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/appergb/openless?style=flat-square&color=805ad5" /></a>
<a href="https://discord.gg/vTZHTFGFm"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
</p>
<p align="center">
@@ -37,7 +38,7 @@
</p>
<p align="center">
💬 &nbsp;QQ 群&nbsp; <strong>1078960553</strong>
💬 &nbsp;<a href="https://discord.gg/vTZHTFGFm"><strong>加入 Discord 社区</strong></a> &nbsp;·&nbsp; QQ 群&nbsp; <strong>1078960553</strong>
</p>
<br/>
@@ -122,20 +123,21 @@ OpenLess 是一款跨平台(macOS 与 Windows)语音输入应用,是 [Typeless](
按住快捷键,说:
> 嗯…那个…我想让 ChatGPT 帮我写个 SQL,从 orders 表里取上个月的订单,按客户分组,金额从大到小排,取前十条
> 嗯…那个,我要给客户回个邮件,就是上次他们提的那个方案嘛,我们内部讨论了一下觉得大方向没问题,但是有几个细节要改,第一个就是交付时间太紧了我们希望延两周,第二个是预算那块他们报的价格比我们预期高了大概百分之二十能不能谈一下,然后整体合作意向是积极的让他们放心,语气客气一点别太生硬
松开快捷键。片刻之后,你的输入框里出现:
```text
请写一条 SQL 查询,要求:
给客户回邮件,关于上次他们提的方案,我们内部讨论后认为大方向没问题,但有几个细节需要调整:
- 从 `orders` 表中取出上个月的订单。
- 按客户分组。
- 按总金额降序排列。
- 仅返回前 10 行。
1. 交付时间
(a) 目前交付时间太紧,希望延长两周。
2. 预算
(a) 他们的报价比我们预期高了大约 20%,能否再谈一下?
整体合作意向是积极的,语气上请客气一点,不要太生硬。
```
无需任何修改——按下回车发送即可。这就是核心理念:**用嘴写提示词,比打字更快、更干净。**
无需任何修改——直接粘贴给 ChatGPT 或 Claude,让它替你把邮件写完。这就是核心理念:**用嘴写提示词,比打字更快、更干净。**
## 为什么 OpenLess 选择开源
+1
View File
@@ -3680,6 +3680,7 @@ version = "1.3.6-2"
dependencies = [
"anyhow",
"arboard",
"base64 0.22.1",
"block2 0.5.1",
"bytes",
"bzip2 0.4.4",
+2
View File
@@ -26,6 +26,8 @@ tauri-plugin-autostart = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# OpenRouter ASR 把音频以标准 base64(带 padding)放进 JSON body(issue #582)。
base64 = "0.22"
sha2 = "0.10"
bzip2 = "0.4"
tar = "0.4"
+136 -33
View File
@@ -2,6 +2,7 @@
//! to any OpenAI-compatible `/audio/transcriptions` endpoint on session end.
use anyhow::{Context, Result};
use base64::Engine;
use parking_lot::Mutex;
use crate::asr::wav::encode_wav_16k_mono;
@@ -21,6 +22,19 @@ pub const PROMPT_CHAR_BUDGET: usize = 240;
/// 区切り文字(ASCII)。Whisper のトークナイザはどの言語でも安定して扱える。
const PROMPT_SEPARATOR: &str = ", ";
/// `/audio/transcriptions` 请求体编码方式。
///
/// OpenAI 官方及多数兼容厂商用 `multipart/form-data`(file + model)。
/// OpenRouter 虽路径相同、也走 Bearer,但请求体是 `application/json`:
/// `{model, input_audio:{data:<base64 wav>, format:"wav"}}`(issue #582)。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum AsrRequestFormat {
/// `multipart/form-data`(既有行为,默认)。
Multipart,
/// OpenRouter `application/json` + base64 音频。
OpenRouterJson,
}
pub struct WhisperBatchASR {
api_key: String,
base_url: String,
@@ -36,6 +50,8 @@ pub struct WhisperBatchASR {
/// (SiliconFlow)は response_format 自体が無いので false にして従来の
/// `json` のまま送る(壊さない)。
verbose_json: bool,
/// 请求体编码方式。默认 `Multipart`,OpenRouter 走 `OpenRouterJson`。
request_format: AsrRequestFormat,
buffer: Mutex<Vec<u8>>,
}
@@ -55,10 +71,18 @@ impl WhisperBatchASR {
prompt,
max_chunk_duration_ms,
verbose_json,
request_format: AsrRequestFormat::Multipart,
buffer: Mutex::new(Vec::new()),
}
}
/// 设置请求体编码方式(默认 `Multipart`)。OpenRouter 需 `OpenRouterJson`。
/// 用 builder 而非给 `new()` 加参数,避免改动既有 4 处构造点的签名。
pub fn with_request_format(mut self, request_format: AsrRequestFormat) -> Self {
self.request_format = request_format;
self
}
/// Stop collecting audio, encode the buffer as WAV, and POST to the
/// Whisper transcriptions endpoint.
///
@@ -110,40 +134,62 @@ impl WhisperBatchASR {
.collect();
let wav = encode_wav_16k_mono(&samples);
let url = transcription_url(&self.base_url)?;
let wav_part = reqwest::multipart::Part::bytes(wav)
.file_name("audio.wav")
.mime_str("audio/wav")
.context("set MIME type")?;
let mut form = reqwest::multipart::Form::new()
.part("file", wav_part)
.text("model", self.model.clone());
// verbose_json 対応プロバイダ(OpenAI / Groq)のときだけ、セグメント
// メタデータ付きの応答を要求し、temperature も 0 に固定する。非対応
// プロバイダ(SiliconFlow の SenseVoice / TeleSpeech 等)には送らず
// 従来どおりの応答にして、未知パラメータでの 4xx を避ける。
if self.verbose_json {
form = form
.text("response_format", "verbose_json")
.text("temperature", "0");
}
// `prompt` は空文字を送らない:OpenAI 互換実装によっては空文字でエラーに
// なるリスクがある(Groq は許容するが防御的にスキップ)。`trim()` で
// 空白のみのケースも除外。
if let Some(prompt) = self.prompt.as_ref() {
let trimmed = prompt.trim();
if !trimmed.is_empty() {
form = form.text("prompt", trimmed.to_string());
}
}
let client = reqwest::Client::new();
let resp = client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.multipart(form)
let request = match self.request_format {
AsrRequestFormat::Multipart => {
let wav_part = reqwest::multipart::Part::bytes(wav)
.file_name("audio.wav")
.mime_str("audio/wav")
.context("set MIME type")?;
let mut form = reqwest::multipart::Form::new()
.part("file", wav_part)
.text("model", self.model.clone());
// verbose_json 対応プロバイダ(OpenAI / Groq)のときだけ、セグメント
// メタデータ付きの応答を要求し、temperature も 0 に固定する。非対応
// プロバイダ(SiliconFlow の SenseVoice / TeleSpeech 等)には送らず
// 従来どおりの応答にして、未知パラメータでの 4xx を避ける。
if self.verbose_json {
form = form
.text("response_format", "verbose_json")
.text("temperature", "0");
}
// `prompt` は空文字を送らない:OpenAI 互換実装によっては空文字でエラーに
// なるリスクがある(Groq は許容するが防御的にスキップ)。`trim()` で
// 空白のみのケースも除外。
if let Some(prompt) = self.prompt.as_ref() {
let trimmed = prompt.trim();
if !trimmed.is_empty() {
form = form.text("prompt", trimmed.to_string());
}
}
client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.multipart(form)
}
AsrRequestFormat::OpenRouterJson => {
// OpenRouter /audio/transcriptions:application/json,音频走标准
// base64(带 padding)。不带 multipart 专属的 prompt/response_format
// 字段,避免未知字段导致 4xx;verbose_json 对该协议保持关闭。
let body = serde_json::json!({
"model": self.model,
"input_audio": {
"data": base64::engine::general_purpose::STANDARD.encode(&wav),
"format": "wav",
},
});
client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&body)
}
};
let resp = request
.send()
.await
.context("Whisper HTTP request failed")?;
@@ -725,6 +771,63 @@ mod tests {
server.join().unwrap();
}
#[tokio::test]
async fn openrouter_format_posts_json_with_base64_audio() {
// issue #582:OpenRouterJson 走 application/json + input_audio.data(base64),
// 而非 multipart;响应仍按 {text} 解析。
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let addr = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(5);
let mut stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
assert!(
Instant::now() < deadline,
"timed out waiting for ASR test request"
);
thread::sleep(Duration::from_millis(10));
}
Err(err) => panic!("accept ASR test request failed: {err}"),
}
};
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let request = read_http_request(&mut stream);
let request_text = String::from_utf8_lossy(&request);
let lower = request_text.to_ascii_lowercase();
assert!(request_text.starts_with("POST /audio/transcriptions HTTP/1.1"));
assert!(lower.contains("content-type: application/json"));
assert!(lower.contains("authorization: bearer key"));
// body 是 JSON:含 input_audio.data + format:"wav",且不是 multipart。
assert!(request_text.contains("input_audio"));
assert!(request_text.contains(r#""format":"wav""#));
assert!(!lower.contains("multipart/form-data"));
write_json_response(&mut stream, r#"{"text":"openrouter ok"}"#);
});
let base_url = format!("http://{}", addr);
let asr = WhisperBatchASR::new(
"key".to_string(),
base_url,
"openai/whisper-large-v3-turbo".to_string(),
None,
None,
false,
)
.with_request_format(AsrRequestFormat::OpenRouterJson);
let pcm = vec![0u8; 32_000 * 2];
asr.consume_pcm_chunk(&pcm);
let transcript = asr.transcribe().await.unwrap();
assert_eq!(transcript.text, "openrouter ok");
server.join().unwrap();
}
fn start_whisper_test_server(texts: Vec<&'static str>) -> (String, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
+91 -51
View File
@@ -783,10 +783,15 @@ pub fn set_credential(window: Window, account: String, value: String) -> Result<
ensure_main_window(&window)?;
let acc = parse_account(&account)?;
if value.is_empty() {
CredentialsVault::remove(acc).map_err(|e| e.to_string())
CredentialsVault::remove(acc).map_err(|e| e.to_string())?;
} else {
CredentialsVault::set(acc, &value).map_err(|e| e.to_string())
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]
@@ -1879,8 +1884,12 @@ pub fn set_qa_hotkey(
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)?;
reject_qa_switch_style_hotkey_overlap(binding, &prefs.switch_style_hotkey)?;
reject_qa_open_app_hotkey_overlap(binding, &prefs.open_app_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())?;
@@ -1920,8 +1929,12 @@ pub fn set_dictation_hotkey(
reject_dictation_qa_hotkey_overlap(&binding, qa_hotkey)?;
}
reject_dictation_translation_hotkey_overlap(&binding, &prefs.translation_hotkey)?;
reject_dictation_switch_style_hotkey_overlap(&binding, &prefs.switch_style_hotkey)?;
reject_dictation_open_app_hotkey_overlap(&binding, &prefs.open_app_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())?;
@@ -1941,8 +1954,12 @@ pub fn set_translation_hotkey(
if let Some(qa_hotkey) = previous.qa_hotkey.as_ref() {
reject_qa_translation_hotkey_overlap(qa_hotkey, &binding)?;
}
reject_translation_switch_style_hotkey_overlap(&binding, &previous.switch_style_hotkey)?;
reject_translation_open_app_hotkey_overlap(&binding, &previous.open_app_hotkey)?;
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())?;
@@ -1956,40 +1973,55 @@ pub fn set_translation_hotkey(
Ok(())
}
/// 设置「切换风格」全局快捷键。`binding == None`(前端传 null)= 停用:清空绑定并
/// 反注册全局键。镜像 `set_qa_hotkey` 的 `Option=None` 停用模式(issue #576)。
#[tauri::command]
pub fn set_switch_style_hotkey(
coord: CoordinatorState<'_>,
binding: ShortcutBinding,
binding: Option<ShortcutBinding>,
) -> Result<(), String> {
crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?;
reject_modifier_only_action_shortcut(&binding)?;
let mut prefs = coord.prefs().get();
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(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)?;
}
}
reject_switch_style_open_app_hotkey_overlap(&binding, &prefs.open_app_hotkey)?;
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: ShortcutBinding,
binding: Option<ShortcutBinding>,
) -> Result<(), String> {
crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?;
reject_modifier_only_action_shortcut(&binding)?;
let mut prefs = coord.prefs().get();
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(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)?;
}
}
reject_switch_style_open_app_hotkey_overlap(&prefs.switch_style_hotkey, &binding)?;
prefs.open_app_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
coord.update_open_app_hotkey_binding();
@@ -2030,8 +2062,12 @@ pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> R
reject_dictation_qa_hotkey_overlap(&shortcut, qa_hotkey)?;
}
reject_dictation_translation_hotkey_overlap(&shortcut, &prefs.translation_hotkey)?;
reject_dictation_switch_style_hotkey_overlap(&shortcut, &prefs.switch_style_hotkey)?;
reject_dictation_open_app_hotkey_overlap(&shortcut, &prefs.open_app_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);
@@ -2088,30 +2124,34 @@ fn reject_hotkey_overlap(
}
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();
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)?;
reject_qa_switch_style_hotkey_overlap(qa_hotkey, &prefs.switch_style_hotkey)?;
reject_qa_open_app_hotkey_overlap(qa_hotkey, &prefs.open_app_hotkey)?;
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,
)?;
reject_dictation_switch_style_hotkey_overlap(
&prefs.dictation_hotkey,
&prefs.switch_style_hotkey,
)?;
reject_dictation_open_app_hotkey_overlap(&prefs.dictation_hotkey, &prefs.open_app_hotkey)?;
reject_translation_switch_style_hotkey_overlap(
&prefs.translation_hotkey,
&prefs.switch_style_hotkey,
)?;
reject_translation_open_app_hotkey_overlap(&prefs.translation_hotkey, &prefs.open_app_hotkey)?;
reject_switch_style_open_app_hotkey_overlap(
&prefs.switch_style_hotkey,
&prefs.open_app_hotkey,
)?;
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(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(switch_style), Some(open_app)) = (switch_style, open_app) {
reject_switch_style_open_app_hotkey_overlap(switch_style, open_app)?;
}
Ok(())
}
@@ -4064,14 +4104,14 @@ mod tests {
primary: "T".to_string(),
modifiers: vec!["ctrl".to_string(), "alt".to_string()],
},
switch_style_hotkey: ShortcutBinding {
switch_style_hotkey: Some(ShortcutBinding {
primary: "S".to_string(),
modifiers: vec!["ctrl".to_string(), "alt".to_string()],
},
open_app_hotkey: ShortcutBinding {
}),
open_app_hotkey: Some(ShortcutBinding {
primary: "O".to_string(),
modifiers: vec!["ctrl".to_string(), "alt".to_string()],
},
}),
hotkey: HotkeyBinding {
trigger: HotkeyTrigger::Custom,
mode: HotkeyMode::Hold,
@@ -4488,7 +4528,7 @@ mod tests {
};
let prefs = UserPreferences {
translation_hotkey: binding.clone(),
switch_style_hotkey: binding,
switch_style_hotkey: Some(binding),
..Default::default()
};
@@ -4507,8 +4547,8 @@ mod tests {
modifiers: vec!["cmd".into(), "shift".into()],
};
let prefs = UserPreferences {
switch_style_hotkey: binding.clone(),
open_app_hotkey: binding,
switch_style_hotkey: Some(binding.clone()),
open_app_hotkey: Some(binding),
..Default::default()
};
+84 -21
View File
@@ -100,6 +100,8 @@ fn capsule_show_strategy_for_platform() -> CapsuleShowStrategy {
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
@@ -184,7 +186,9 @@ fn active_asr_provider_kind(id: &str) -> ActiveAsrProviderKind {
fn batch_asr_chunk_limit_ms(provider_id: &str) -> Option<u64> {
match provider_id {
"zhipu" => Some(30_000),
// OpenRouter 把音频 base64 进 JSON body,体积比二进制大 ~33%,长录音易撞
// body/时长上限,保守按 30s 切分(与 zhipu 同)。
"zhipu" | "openrouter" => Some(30_000),
_ => None,
}
}
@@ -728,7 +732,12 @@ impl Coordinator {
}
fn update_action_hotkey_binding(&self, kind: ActionHotkeyKind) {
let binding = action_hotkey_binding(&self.inner, kind);
// None = 用户主动停用:反注册全局键,立即生效。
let Some(binding) = action_hotkey_binding(&self.inner, kind) else {
take_action_hotkey_on_main_thread(&self.inner, kind);
log::info!("[coord] action hotkey {kind:?} 已停用(用户清空)");
return;
};
if is_modifier_only_shortcut(&binding) {
take_action_hotkey_on_main_thread(&self.inner, kind);
log::warn!("[coord] action hotkey {kind:?} 使用了不支持的 modifier-only 绑定,已关闭");
@@ -1517,7 +1526,12 @@ fn action_hotkey_supervisor_loop(inner: Arc<Inner>, kind: ActionHotkeyKind) {
if inner.shutdown.load(Ordering::SeqCst) {
return;
}
let binding = action_hotkey_binding(&inner, kind);
// 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));
@@ -1711,7 +1725,7 @@ fn action_hotkey_slot(
fn action_hotkey_binding(
inner: &Arc<Inner>,
kind: ActionHotkeyKind,
) -> crate::types::ShortcutBinding {
) -> Option<crate::types::ShortcutBinding> {
let prefs = inner.prefs.get();
match kind {
ActionHotkeyKind::SwitchStyle => prefs.switch_style_hotkey,
@@ -1868,17 +1882,21 @@ fn reset_shortcut_held_state(inner: &Arc<Inner>) {
}
}
}
if !is_modifier_only_shortcut(&prefs.switch_style_hotkey) {
if let Some(monitor) = inner.switch_style_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(prefs.switch_style_hotkey.clone()) {
log::warn!("[coord] reset switch-style 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 !is_modifier_only_shortcut(&prefs.open_app_hotkey) {
if let Some(monitor) = inner.open_app_hotkey.lock().as_ref() {
if let Err(e) = monitor.update_binding(prefs.open_app_hotkey.clone()) {
log::warn!("[coord] reset open-app 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}");
}
}
}
}
@@ -2489,7 +2507,16 @@ async fn build_local_qwen3(
/// (messages=[{content:[{audio:...}]}]) 协议,不是 Whisper multipart,需要
/// 单独 ASR 客户端,留给 V2。
fn is_whisper_compatible_provider(id: &str) -> bool {
matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq")
matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq" | "openrouter")
}
/// 该 provider 的请求体编码方式。OpenRouter 的 `/audio/transcriptions` 是
/// `application/json` + base64 音频(issue #582),其余兼容厂商沿用 multipart。
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`
@@ -2665,14 +2692,17 @@ async fn build_qa_asr_start(inner: &Arc<Inner>, active_asr: &str) -> Result<QaAs
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),
));
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 })
@@ -4057,6 +4087,27 @@ mod tests {
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!(
@@ -4989,9 +5040,13 @@ fn show_capsule_window_no_activate<R: tauri::Runtime>(
};
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 _);
@@ -5226,6 +5281,14 @@ fn emit_capsule(
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;
@@ -811,14 +811,17 @@ pub(super) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
// 互換プロバイダにも揃えるのが筋。
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),
));
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)),
);
store_asr_for_session(
inner,
current_session_id,
@@ -2166,6 +2169,7 @@ mod tests {
#[test]
fn batch_asr_chunk_limit_applies_only_to_zhipu() {
assert_eq!(batch_asr_chunk_limit_ms("zhipu"), Some(30_000));
assert_eq!(batch_asr_chunk_limit_ms("openrouter"), Some(30_000));
assert_eq!(batch_asr_chunk_limit_ms("whisper"), None);
assert_eq!(batch_asr_chunk_limit_ms("siliconflow"), None);
assert_eq!(batch_asr_chunk_limit_ms("groq"), None);
+51 -1
View File
@@ -48,6 +48,8 @@ use std::sync::Arc;
use std::time::Duration;
const LOG_ROTATE_LIMIT_BYTES: u64 = 10 * 1024 * 1024;
#[cfg(target_os = "macos")]
const OPENLESS_BUNDLE_ID: &str = "com.openless.app";
/// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition,
/// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。
@@ -825,9 +827,49 @@ fn restart_app(app: AppHandle) {
log::info!("[updater] stripped xattr on {:?} before restart", bundle);
}
}
#[cfg(target_os = "macos")]
reset_tcc_for_beta_restart();
app.restart();
}
#[cfg(target_os = "macos")]
fn reset_tcc_for_beta_restart() {
if !is_beta_build() {
log::info!("[updater] skipping TCC reset before stable restart");
return;
}
// Beta builds are currently ad-hoc signed. Their code hash changes across builds, so
// old TCC rows can leave System Settings checked while AXIsProcessTrusted() is false.
reset_tcc_service_for_beta_restart("Accessibility");
reset_tcc_service_for_beta_restart("Microphone");
}
#[cfg(target_os = "macos")]
fn is_beta_build() -> bool {
env!("CARGO_PKG_VERSION").contains('-')
}
#[cfg(target_os = "macos")]
fn reset_tcc_service_for_beta_restart(service: &str) {
match std::process::Command::new("/usr/bin/tccutil")
.args(["reset", service, OPENLESS_BUNDLE_ID])
.status()
{
Ok(status) if status.success() => {
log::info!("[updater] reset TCC {service} before beta restart");
}
Ok(status) => {
log::warn!(
"[updater] reset TCC {service} before beta restart exited with {status}"
);
}
Err(e) => {
log::warn!("[updater] reset TCC {service} before beta restart failed: {e}");
}
}
}
/// 把日志同时写到 stderr + ~/Library/Logs/OpenLess/openless.log(match Swift `Log.swift`)。
fn init_file_logger() {
use simplelog::{
@@ -1339,7 +1381,15 @@ pub(crate) fn position_capsule_bottom_center<R: tauri::Runtime>(
let offset_from_bottom =
(capsule_visual_height(translation_active) + 80.0 + bounds.bottom_inset) * scale;
let y = ((mon.bottom as f64) - offset_from_bottom).round() as i32;
window.set_position(PhysicalPosition::new(x, y.max(mon.top)))?;
let clamped_y = y.max(mon.top);
// #470 诊断 v2:当前只夹了上边(.max(mon.top)),未夹下/左/右。多显示器、
// 负坐标或异常 DPI 下胶囊可能被算到屏幕外却无任何观测。记录显示器几何与
// 最终落点,用于证伪/证实「胶囊定位到屏幕外」(C 子嫌疑)。
log::debug!(
"[capsule] win position: mon=({},{})..({},{}) scale={:.2} size=({}x{}) -> x={} y={} clamped_y={}",
mon.left, mon.top, mon.right, mon.bottom, scale, phys_w, phys_h, x, y, clamped_y
);
window.set_position(PhysicalPosition::new(x, clamped_y))?;
return Ok(());
}
// 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor 逻辑。
+66 -14
View File
@@ -604,10 +604,13 @@ pub struct UserPreferences {
pub custom_combo_hotkey: Option<ComboBinding>,
#[serde(default = "default_translation_hotkey")]
pub translation_hotkey: ShortcutBinding,
/// 「切换风格」全局快捷键。`None` = 停用(不注册全局键);`Some(...)` = 注册。
/// 默认 `Some(默认键)`,对老用户零行为变化,仅新增可清空(issue #576)。
#[serde(default = "default_switch_style_hotkey")]
pub switch_style_hotkey: ShortcutBinding,
pub switch_style_hotkey: Option<ShortcutBinding>,
/// 「唤起 App」全局快捷键。`None` = 停用;`Some(...)` = 注册。默认 `Some(默认键)`。
#[serde(default = "default_open_app_hotkey")]
pub open_app_hotkey: ShortcutBinding,
pub open_app_hotkey: Option<ShortcutBinding>,
/// 本地 Qwen3-ASR 当前激活的模型 id("qwen3-asr-0.6b" / "qwen3-asr-1.7b")。
/// 仅在 active_asr_provider == "local-qwen3" 时有意义。
#[serde(default = "default_local_asr_model")]
@@ -886,8 +889,9 @@ impl Default for UserPreferencesWire {
qa_save_history: prefs.qa_save_history,
custom_combo_hotkey: prefs.custom_combo_hotkey,
translation_hotkey: None,
switch_style_hotkey: None,
open_app_hotkey: None,
// 默认携带默认键(Some),保证缺字段时仍是启用状态;None 专表「用户主动停用」。
switch_style_hotkey: prefs.switch_style_hotkey,
open_app_hotkey: prefs.open_app_hotkey,
local_asr_active_model: prefs.local_asr_active_model,
local_asr_mirror: prefs.local_asr_mirror,
local_asr_keep_loaded_secs: prefs.local_asr_keep_loaded_secs,
@@ -968,10 +972,11 @@ impl<'de> Deserialize<'de> for UserPreferences {
translation_hotkey: wire
.translation_hotkey
.unwrap_or_else(default_translation_hotkey),
switch_style_hotkey: wire
.switch_style_hotkey
.unwrap_or_else(default_switch_style_hotkey),
open_app_hotkey: wire.open_app_hotkey.unwrap_or_else(default_open_app_hotkey),
// 直传 Option:None = 用户主动停用,不再用 unwrap_or_else 塌缩成默认键
// (那正是 #576「无法关闭」的根因)。缺字段时 wire 的 serde struct-default
// 会落到 Some(默认键),保证老用户/新用户仍是启用。
switch_style_hotkey: wire.switch_style_hotkey,
open_app_hotkey: wire.open_app_hotkey,
local_asr_active_model: wire.local_asr_active_model,
local_asr_mirror: wire.local_asr_mirror,
local_asr_keep_loaded_secs: wire.local_asr_keep_loaded_secs,
@@ -1014,18 +1019,18 @@ fn default_translation_hotkey() -> ShortcutBinding {
}
}
fn default_switch_style_hotkey() -> ShortcutBinding {
ShortcutBinding {
fn default_switch_style_hotkey() -> Option<ShortcutBinding> {
Some(ShortcutBinding {
primary: "S".into(),
modifiers: default_app_shortcut_modifiers(),
}
})
}
fn default_open_app_hotkey() -> ShortcutBinding {
ShortcutBinding {
fn default_open_app_hotkey() -> Option<ShortcutBinding> {
Some(ShortcutBinding {
primary: "O".into(),
modifiers: default_app_shortcut_modifiers(),
}
})
}
fn default_app_shortcut_modifiers() -> Vec<String> {
@@ -2320,6 +2325,53 @@ mod tests {
assert!(!restored.audio_cue_on_record);
}
#[test]
fn action_hotkeys_default_to_enabled() {
// issue #576:默认仍开启(Some 默认键),对老用户零行为变化。
let prefs = UserPreferences::default();
assert!(prefs.switch_style_hotkey.is_some());
assert!(prefs.open_app_hotkey.is_some());
}
#[test]
fn missing_action_hotkeys_default_to_enabled() {
// 老用户/缺字段:wire 的 struct-default 落到 Some(默认键),不应被当成停用。
let prefs: UserPreferences = serde_json::from_str("{}").unwrap();
assert!(prefs.switch_style_hotkey.is_some());
assert!(prefs.open_app_hotkey.is_some());
}
#[test]
fn disabled_action_hotkeys_round_trip_as_null() {
// issue #576:用户清空(None=停用)后存盘→读回必须仍是 None,
// 不能像旧逻辑那样被 unwrap_or_else 塌缩回默认键。
let disabled = UserPreferences {
switch_style_hotkey: None,
open_app_hotkey: None,
..Default::default()
};
let json = serde_json::to_string(&disabled).unwrap();
assert!(
json.contains("\"switchStyleHotkey\":null"),
"停用应序列化成 null,实际: {json}"
);
let restored: UserPreferences = serde_json::from_str(&json).unwrap();
assert!(restored.switch_style_hotkey.is_none());
assert!(restored.open_app_hotkey.is_none());
}
#[test]
fn explicit_action_hotkey_binding_round_trips() {
// 旧 preferences.json 里带实际绑定 → 读回应保留为 Some(启用)。
let prefs: UserPreferences = serde_json::from_str(
r#"{"switchStyleHotkey":{"primary":"S","modifiers":["cmd","shift"]}}"#,
)
.unwrap();
let binding = prefs.switch_style_hotkey.expect("应保留为 Some");
assert_eq!(binding.primary, "S");
assert_eq!(binding.modifiers, vec!["cmd".to_string(), "shift".to_string()]);
}
#[test]
fn missing_custom_style_prompts_defaults_to_empty() {
let prefs: UserPreferences = serde_json::from_str("{}").unwrap();
@@ -286,6 +286,9 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }:
// 动画结束就 unmount → 用户看到半截动画被截断。
// v1.3.1-6: 从 240ms 加到 360ms 让用户看清退出动画(240ms 太快感知不到)。
const EXIT_ANIM_MS = 360;
// #470 诊断 v2:模块级一次性门,只在 webview 收到第一个 capsule:state 事件时打 log。
let capsuleStateFirstLogged = false;
// 初始可见 state:Tauri 内运行从 idle 开始(等后端 capsule:state 事件),
// 浏览器 dev 模式从 recording 开始以便直接看到胶囊。
const INITIAL_VISIBLE_STATE: CapsuleState = isTauri ? 'idle' : 'recording';
@@ -321,6 +324,12 @@ export function Capsule() {
const { listen } = await import('@tauri-apps/api/event');
const handle = await listen<CapsulePayload>('capsule:state', event => {
const p = event.payload;
if (!capsuleStateFirstLogged) {
capsuleStateFirstLogged = true;
// #470 诊断 v2:确认 capsule webview 确实收到了后端事件 —— 区分「后端没
// emit」与「emit 了但窗口没显示/没渲染」。配合后端 [capsule] 日志定位根因。
console.info('[capsule] first capsule:state received in webview, state=', p.state);
}
setState(p.state);
setLevel(p.level ?? 0);
setMessage(p.message ?? undefined);
+3
View File
@@ -654,6 +654,7 @@ export const en: typeof zhCN = {
asrZhipu: 'Zhipu GLM-ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper (compatible)',
asrOpenrouter: 'OpenRouter Whisper',
asrSherpaOnnxLocal: 'Local sherpa-onnx (experimental)',
asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)',
asrLocalQwen3: 'Local Qwen3-ASR',
@@ -720,6 +721,8 @@ export const en: typeof zhCN = {
confirm: 'Confirm capsule insertion',
switchStyle: 'Switch to previous style',
openApp: 'Open OpenLess',
enable: 'Enable',
disable: 'Disable',
confirmHint: 'Click ✓ on the capsule',
notSupported: 'Not yet supported',
},
+3
View File
@@ -656,6 +656,7 @@ export const ja: typeof zhCN = {
asrZhipu: 'Zhipu GLM-ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(互換)',
asrOpenrouter: 'OpenRouter Whisper',
asrSherpaOnnxLocal: 'ローカル sherpa-onnx(実験的)',
asrFoundryLocalWhisper: 'ローカル Whisper(Foundry Local)',
asrLocalQwen3: 'ローカル Qwen3-ASR',
@@ -722,6 +723,8 @@ export const ja: typeof zhCN = {
confirm: 'カプセル入力を確定',
switchStyle: '前のスタイルに切り替え',
openApp: 'OpenLess を開く',
enable: '有効化',
disable: '無効化',
confirmHint: '右側の ✓ をクリック',
notSupported: '未対応',
},
+3
View File
@@ -656,6 +656,7 @@ export const ko: typeof zhCN = {
asrZhipu: 'Zhipu GLM-ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(호환)',
asrOpenrouter: 'OpenRouter Whisper',
asrSherpaOnnxLocal: '로컬 sherpa-onnx(실험적)',
asrFoundryLocalWhisper: '로컬 Whisper(Foundry Local)',
asrLocalQwen3: '로컬 Qwen3-ASR',
@@ -722,6 +723,8 @@ export const ko: typeof zhCN = {
confirm: '캡슐 입력 확정',
switchStyle: '이전 스타일로 전환',
openApp: 'OpenLess 열기',
enable: '활성화',
disable: '비활성화',
confirmHint: '오른쪽 ✓ 클릭',
notSupported: '지원되지 않음',
},
+3
View File
@@ -652,6 +652,7 @@ export const zhCN = {
asrZhipu: '智谱 GLM-ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(兼容)',
asrOpenrouter: 'OpenRouter Whisper',
asrSherpaOnnxLocal: '本地 sherpa-onnx(实验性)',
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
asrLocalQwen3: '本地 Qwen3-ASR',
@@ -718,6 +719,8 @@ export const zhCN = {
confirm: '胶囊确认插入',
switchStyle: '切换上一次风格',
openApp: '打开 OpenLess',
enable: '启用',
disable: '停用',
confirmHint: '点击右侧 ✓',
notSupported: '暂未支持',
},
+3
View File
@@ -654,6 +654,7 @@ export const zhTW: typeof zhCN = {
asrZhipu: '智譜 GLM-ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(兼容)',
asrOpenrouter: 'OpenRouter Whisper',
asrSherpaOnnxLocal: '本地 sherpa-onnx(實驗性)',
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
asrLocalQwen3: '本地 Qwen3-ASR',
@@ -720,6 +721,8 @@ export const zhTW: typeof zhCN = {
confirm: '膠囊確認插入',
switchStyle: '切換上一次風格',
openApp: '打開 OpenLess',
enable: '啟用',
disable: '停用',
confirmHint: '點擊右側 ✓',
notSupported: '暫未支持',
},
+10
View File
@@ -12,6 +12,16 @@ export function defaultAppShortcutModifiers(): string[] {
return currentPlatform().isMac ? ['cmd', 'shift'] : ['ctrl', 'shift'];
}
// 「停用」后重新「启用」时恢复的默认键,与后端 default_switch_style_hotkey /
// default_open_app_hotkey 保持一致(issue #576)。
export function defaultSwitchStyleShortcut(): ShortcutBinding {
return { primary: 'S', modifiers: defaultAppShortcutModifiers() };
}
export function defaultOpenAppShortcut(): ShortcutBinding {
return { primary: 'O', modifiers: defaultAppShortcutModifiers() };
}
export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined): string {
if (!trigger) return i18n.t('hotkey.fallback');
if (trigger === 'custom') return i18n.t('hotkey.triggers.custom');
+3 -2
View File
@@ -1100,11 +1100,12 @@ export function setTranslationHotkey(binding: ShortcutBinding): Promise<void> {
return invokeOrMock("set_translation_hotkey", { binding }, () => undefined)
}
export function setSwitchStyleHotkey(binding: ShortcutBinding): Promise<void> {
// binding = null 表示停用(清空全局键),与 set_qa_hotkey 一致(issue #576)。
export function setSwitchStyleHotkey(binding: ShortcutBinding | null): Promise<void> {
return invokeOrMock("set_switch_style_hotkey", { binding }, () => undefined)
}
export function setOpenAppHotkey(binding: ShortcutBinding): Promise<void> {
export function setOpenAppHotkey(binding: ShortcutBinding | null): Promise<void> {
return invokeOrMock("set_open_app_hotkey", { binding }, () => undefined)
}
+4 -4
View File
@@ -255,10 +255,10 @@ export interface UserPreferences {
customComboHotkey: ComboBinding | null;
/** 录音中触发翻译的全局快捷键。默认 Shift。 */
translationHotkey: ShortcutBinding;
/** 切换到上一个润色风格的全局快捷键。 */
switchStyleHotkey: ShortcutBinding;
/** 打开 OpenLess 主窗口的全局快捷键。 */
openAppHotkey: ShortcutBinding;
/** 切换到上一个润色风格的全局快捷键。null = 用户已停用(issue #576)。 */
switchStyleHotkey: ShortcutBinding | null;
/** 打开 OpenLess 主窗口的全局快捷键。null = 用户已停用(issue #576)。 */
openAppHotkey: ShortcutBinding | null;
/** 本地 Qwen3-ASR 当前激活的模型 id。仅在 activeAsrProvider === 'local-qwen3' 时有意义。 */
localAsrActiveModel: string;
/** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */
+29
View File
@@ -30,6 +30,7 @@ const ASR_NAME_KEY_BY_ID: Record<string, string> = {
zhipu: 'asrZhipu',
groq: 'asrGroq',
whisper: 'asrWhisper',
openrouter: 'asrOpenrouter',
'foundry-local-whisper': 'asrFoundryLocalWhisper',
'sherpa-onnx-local': 'asrSherpaOnnxLocal',
'local-qwen3': 'asrLocalQwen3',
@@ -101,6 +102,34 @@ export function Overview({ onOpenHistory }: OverviewProps) {
refreshCredentials();
}, [refreshCredentials, prefs?.activeAsrProvider, prefs?.activeLlmProvider]);
// 凭据被保存后重新拉取状态(issue #532 / #573:在 Settings 中填写/更新凭据
// 但不切换提供商时,上面的 useEffect 不会重跑,导致概览页的状态仍停留在「未配置」)。
// 复用 refreshCredentials() 以带上 credentialsRequestSeq 防竞态。
useEffect(() => {
let cancelled = false;
let unlisten: (() => void) | undefined;
(async () => {
try {
const { listen } = await import('@tauri-apps/api/event');
const handle = await listen('credentials:changed', () => {
if (cancelled) return;
refreshCredentials();
});
if (cancelled) {
handle();
} else {
unlisten = handle;
}
} catch {
// browser dev mock — 没有 Tauri event bridge
}
})();
return () => {
cancelled = true;
unlisten?.();
};
}, [refreshCredentials]);
const metrics = useMemo(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -142,6 +142,9 @@ const ASR_PRESETS: ReadonlyArray<{ id: AsrPresetId; nameKey: string; baseUrl: st
{ id: 'zhipu', nameKey: 'asrZhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-asr-2512' },
{ id: 'groq', nameKey: 'asrGroq', baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3-turbo' },
{ id: 'whisper', nameKey: 'asrWhisper', baseUrl: 'https://api.openai.com/v1', model: 'whisper-1' },
// OpenRouter 的 /audio/transcriptions 走 application/json + base64(issue #582),
// 后端 coordinator.rs::whisper_request_format 对该 id 切换到 OpenRouterJson 编码。
{ id: 'openrouter', nameKey: 'asrOpenrouter', baseUrl: 'https://openrouter.ai/api/v1', model: 'openai/whisper-large-v3-turbo' },
{ id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper', baseUrl: '', model: '' },
// 本地引擎(Foundry / sherpa-onnx / Qwen3):无 baseUrl/model 配置,
// 模型在「高级 → 本地模型」里下载与切换。
@@ -1,8 +1,9 @@
// 快捷键设置:开始/停止、翻译、问答、切风格、唤起 App、以及只读取消/确认提示。
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { ShortcutRecorder } from '../../components/ShortcutRecorder';
import { defaultQaShortcut } from '../../lib/hotkey';
import { defaultOpenAppShortcut, defaultQaShortcut, defaultSwitchStyleShortcut } from '../../lib/hotkey';
import {
setDictationHotkey,
setOpenAppHotkey,
@@ -15,6 +16,31 @@ import { Card } from '../_atoms';
import { SettingRow } from './shared';
import { detectOS } from '../../components/WindowChrome';
const enableBtnStyle: CSSProperties = {
alignSelf: 'flex-start',
fontSize: 12,
padding: '5px 14px',
background: 'var(--ol-blue)',
color: '#fff',
border: 0,
borderRadius: 6,
fontFamily: 'inherit',
fontWeight: 500,
cursor: 'pointer',
};
const disableBtnStyle: CSSProperties = {
alignSelf: 'flex-start',
fontSize: 11,
padding: '3px 10px',
background: 'transparent',
color: 'var(--ol-ink-4)',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 6,
fontFamily: 'inherit',
cursor: 'pointer',
};
export function ShortcutsSection() {
const { t } = useTranslation();
const os = detectOS();
@@ -84,24 +110,72 @@ export function ShortcutsSection() {
)}
</SettingRow>
<SettingRow label={t('settings.shortcuts.switchStyle')}>
<ShortcutRecorder
value={prefs.switchStyleHotkey}
alignRecordButton
onSave={async binding => {
await setSwitchStyleHotkey(binding);
await savePrefs({ ...prefs, switchStyleHotkey: binding });
}}
/>
{prefs.switchStyleHotkey ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
<ShortcutRecorder
value={prefs.switchStyleHotkey}
alignRecordButton
onSave={async binding => {
await setSwitchStyleHotkey(binding);
await savePrefs({ ...prefs, switchStyleHotkey: binding });
}}
/>
<button
onClick={async () => {
await setSwitchStyleHotkey(null);
await savePrefs({ ...prefs, switchStyleHotkey: null });
}}
style={disableBtnStyle}
>
{t('settings.shortcuts.disable', 'Disable')}
</button>
</div>
) : (
<button
onClick={async () => {
const binding = defaultSwitchStyleShortcut();
await setSwitchStyleHotkey(binding);
await savePrefs({ ...prefs, switchStyleHotkey: binding });
}}
style={enableBtnStyle}
>
{t('settings.shortcuts.enable', 'Enable')}
</button>
)}
</SettingRow>
<SettingRow label={t('settings.shortcuts.openApp')}>
<ShortcutRecorder
value={prefs.openAppHotkey}
alignRecordButton
onSave={async binding => {
await setOpenAppHotkey(binding);
await savePrefs({ ...prefs, openAppHotkey: binding });
}}
/>
{prefs.openAppHotkey ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
<ShortcutRecorder
value={prefs.openAppHotkey}
alignRecordButton
onSave={async binding => {
await setOpenAppHotkey(binding);
await savePrefs({ ...prefs, openAppHotkey: binding });
}}
/>
<button
onClick={async () => {
await setOpenAppHotkey(null);
await savePrefs({ ...prefs, openAppHotkey: null });
}}
style={disableBtnStyle}
>
{t('settings.shortcuts.disable', 'Disable')}
</button>
</div>
) : (
<button
onClick={async () => {
const binding = defaultOpenAppShortcut();
await setOpenAppHotkey(binding);
await savePrefs({ ...prefs, openAppHotkey: binding });
}}
style={enableBtnStyle}
>
{t('settings.shortcuts.enable', 'Enable')}
</button>
)}
</SettingRow>
{readonlyRows.map(([k, v]) => (
<SettingRow key={k} label={k}>
@@ -179,6 +179,7 @@ export type AsrPresetId =
| "zhipu"
| "groq"
| "whisper"
| "openrouter"
| "foundry-local-whisper"
| "sherpa-onnx-local"
| "local-qwen3"