Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ Every item below is one more layer sedimented into a default — a capability yo
- **Toggle and push-to-talk** recording modes, plus a **MediaPlayPause trigger** so wired-earbud inline controls can start and stop recording. `Esc` cancels at any phase, including polish and insertion.
- **Cloud ASR**: Volcengine streaming ASR (bigasr), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS).
- **Local ASR**: bundled Qwen3-ASR (0.6B / 1.7B) via vendored `Open-Less/qwen-asr` (macOS); Windows Foundry Local Whisper and sherpa-onnx (experimental) variants.
- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, and StepFun — plus any OpenAI-compatible endpoint you bring.
- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, StepFun, and OpenCode Zen — plus any OpenAI-compatible endpoint you bring.
- **Four output modes**: raw, light polish, structured (**AI-prompt mode**), and formal. Plus a **translation hotkey** that converts speech directly into the configured target language ([#43](../../issues/43)).
- **Selection-ask QA panel** — a separate hotkey opens a floating panel that runs voice Q&A against the highlighted text in any app ([#118](../../issues/118)).
- **Main window**: Overview / History / Vocab / Style / Marketplace / Settings. Persistent tray icon, plus a mini status capsule that floats on screen and follows the display you are typing on (multi-monitor).
Expand Down
2 changes: 1 addition & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提
- **切换式与按住说话(push-to-talk)** 两种录音模式,外加 **MediaPlayPause 触发**,让有线耳机的线控也能开始 / 停止录音。`Esc` 可在任意阶段取消,包括润色与插入。
- **云端 ASR**:Volcengine 流式 ASR(bigasr)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。
- **本地 ASR**:通过 vendored 的 `Open-Less/qwen-asr` 内置 Qwen3-ASR(0.6B / 1.7B)(macOS);Windows 上的 Foundry Local Whisper 与 sherpa-onnx(实验性)变体。
- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun,以及你自带的任意 OpenAI 兼容端点。
- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。
- **四种输出模式**:原文、轻度润色、结构化(**AI 提示词模式**)、正式。另有一个**翻译快捷键**,将语音直接转换为所配置的目标语言([#43](../../issues/43))。
- **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。
- **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。
Expand Down
233 changes: 224 additions & 9 deletions openless-all/app/crates/openless-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3255,8 +3255,10 @@ impl OpenLessBackend {
};
let preferences = self.get_preferences();
let mode = preferences.hotkey.mode;
let modifier_only =
crate::shortcut_types::legacy_modifier_trigger(&preferences.dictation_hotkey).is_some();
let modifier_only = crate::hotkey_interpreter::modifier_arbitration_required(
crate::shortcut_types::legacy_modifier_trigger(&preferences.dictation_hotkey),
mode,
);
let (intent, reservation) = {
let mut hotkey = self
.hotkey
Expand Down Expand Up @@ -3436,7 +3438,40 @@ impl OpenLessBackend {
key: CredentialKey,
value: SecretValue,
) -> Result<CredentialsStatus, BackendError> {
if key.namespace == crate::CredentialNamespace::Llm
&& crate::llm_protocol::CONFIG_ACCOUNTS.contains(&key.account.as_str())
{
crate::llm_protocol::LlmProtocolConfig::default()
.apply(&key.account, value.expose_secret())?;
}
let invalidate = if key.namespace == crate::CredentialNamespace::Llm {
let id = match &key.provider_id {
Some(id) => id.clone(),
None => {
self.deps
.credential_store
.active_provider(crate::ProviderSlot::Llm)
.await?
}
};
self.list_channels(ChannelKind::Llm)
.await?
.into_iter()
.find(|channel| channel.id == id)
.map(|_| id)
} else {
None
};
self.deps.credential_store.write(key, value).await?;
if let Some(id) = invalidate {
self.deps
.credential_store
.mutate_channel(ChannelMutation::InvalidateTest {
kind: ChannelKind::Llm,
id,
})
.await?;
}
self.refresh_and_publish_credentials().await
}

Expand Down Expand Up @@ -3483,13 +3518,51 @@ impl OpenLessBackend {
id: String,
provider_type: String,
) -> Result<(), BackendError> {
self.apply_channel_mutation(ChannelMutation::SetProviderType {
kind,
id,
provider_type,
})
.await
.map(|_| ())
let provider_type = provider_type.trim().to_string();
if provider_type.trim().is_empty() {
return Err(BackendError::new(
BackendErrorCode::InvalidArgument,
"provider type must not be blank",
));
}
let previous = self
.list_channels(kind)
.await?
.into_iter()
.find(|channel| channel.id == id)
.ok_or_else(|| {
BackendError::new(BackendErrorCode::InvalidArgument, "unknown channel")
})?;
let key = CredentialKey::new(
crate::CredentialNamespace::Llm,
Some(id.clone()),
crate::llm_protocol::REQUEST_FORMAT_ACCOUNT,
)?;
let reset = kind == ChannelKind::Llm && previous.provider_type != provider_type;
let old_format = if reset {
let value = self.deps.credential_store.read(key.clone()).await?;
self.deps.credential_store.remove(key.clone()).await?;
value
} else {
None
};
let result = self
.deps
.credential_store
.mutate_channel(ChannelMutation::SetProviderType {
kind,
id,
provider_type,
})
.await
.map(|_| ());
if result.is_err() {
if let Some(value) = old_format {
self.deps.credential_store.write(key, value).await?;
}
}
result?;
self.refresh_and_publish_credentials().await.map(|_| ())
}

pub async fn delete_channel_if_blank(
Expand Down Expand Up @@ -3571,6 +3644,12 @@ impl OpenLessBackend {
.map(|_| ())
}

pub async fn invalidate_channel_tests(&self, kind: ChannelKind) -> Result<(), BackendError> {
self.apply_channel_mutation(ChannelMutation::InvalidateTests { kind })
.await
.map(|_| ())
}

pub async fn active_provider(&self, slot: ProviderSlot) -> Result<String, BackendError> {
self.deps.credential_store.active_provider(slot).await
}
Expand Down Expand Up @@ -8250,6 +8329,142 @@ mod tests {
));
}

#[tokio::test]
async fn invalidating_llm_tests_preserves_asr_test_results() {
let (backend, _) = backend();
for (kind, provider, name) in [
(ChannelKind::Llm, "custom", "first"),
(ChannelKind::Llm, "custom_messages", "second"),
(ChannelKind::Asr, "openai-compatible", "asr"),
] {
let id = backend
.create_channel(kind, provider.into(), name.into())
.await
.unwrap();
backend
.record_channel_test(kind, id, true, Some(1), None)
.await
.unwrap();
}

backend
.invalidate_channel_tests(ChannelKind::Llm)
.await
.unwrap();

assert!(backend
.list_channels(ChannelKind::Llm)
.await
.unwrap()
.iter()
.all(|channel| channel.last_test.is_none()));
assert!(backend.list_channels(ChannelKind::Asr).await.unwrap()[0]
.last_test
.is_some());
}

#[tokio::test]
async fn llm_protocol_mutations_reset_only_the_format_and_invalidate_tests() {
use crate::credentials::{CredentialNamespace, InMemoryCredentialStore, SecretValue};
use crate::llm_protocol::*;
let backend = OpenLessBackend::new(
BackendConfig {
data_dir: std::env::temp_dir()
.join(format!("openless-protocol-{}", uuid::Uuid::new_v4())),
..BackendConfig::default()
},
BackendDependencies {
host_actions: Arc::new(FakeHost::default()),
text_inserter: Arc::new(FakeInserter),
dictation_engine: Arc::new(FakeEngine),
task_spawner: Arc::new(TokioTaskSpawner),
credential_store: Arc::new(InMemoryCredentialStore::default()),
services: crate::domains::BackendServices::unsupported(),
local_asr_runtime: None,
marketplace_config: None,
selection_runtime: None,
selection_polisher: None,
qa_runtime: None,
},
)
.unwrap();
let id = backend
.create_channel(ChannelKind::Llm, "custom".into(), "test".into())
.await
.unwrap();
let key = |account: &str| {
CredentialKey::new(CredentialNamespace::Llm, Some(id.clone()), account).unwrap()
};
backend
.set_credential(key(REQUEST_FORMAT_ACCOUNT), SecretValue::new("messages"))
.await
.unwrap();
backend
.set_credential(
key(crate::credentials::LLM_API_KEY_ACCOUNT),
SecretValue::new("fixture-key"),
)
.await
.unwrap();
backend
.record_channel_test(ChannelKind::Llm, id.clone(), true, Some(1), None)
.await
.unwrap();
assert!(backend.list_channels(ChannelKind::Llm).await.unwrap()[0]
.last_test
.is_some());
backend
.set_credential(
key(crate::credentials::LLM_MODEL_ACCOUNT),
SecretValue::new("new-model"),
)
.await
.unwrap();
assert!(backend.list_channels(ChannelKind::Llm).await.unwrap()[0]
.last_test
.is_none());
assert_eq!(
backend
.read_credential(key(REQUEST_FORMAT_ACCOUNT))
.await
.unwrap()
.unwrap()
.expose_secret(),
"messages"
);
assert!(backend
.set_credential(key(REQUEST_FORMAT_ACCOUNT), SecretValue::new("invalid"))
.await
.is_err());
backend
.set_channel_provider_type(ChannelKind::Llm, id.clone(), "custom_responses".into())
.await
.unwrap();
assert!(backend
.read_credential(key(REQUEST_FORMAT_ACCOUNT))
.await
.unwrap()
.is_none());
assert_eq!(
backend
.read_credential(key(crate::credentials::LLM_API_KEY_ACCOUNT))
.await
.unwrap()
.unwrap()
.expose_secret(),
"fixture-key"
);
assert_eq!(
backend
.read_credential(key(crate::credentials::LLM_MODEL_ACCOUNT))
.await
.unwrap()
.unwrap()
.expose_secret(),
"new-model"
);
}

#[tokio::test]
async fn lifecycle_is_idempotent_and_emits_started_once_per_transition() {
let (backend, _) = backend();
Expand Down
Loading
Loading