feat(pipeline): 多模态识别管线(实验性),传统/多模态模式与 omni 凭据隔离 (#902) (#924)

* feat(pipeline): 多模态识别管线(实验性),传统/多模态模式与 omni 凭据隔离 (#902)

新增实验性「多模态识别管线」:高级设置开启后,服务页出现传统/多模态模式切换。多模态模式用单个模型(OpenAI 兼容 input_audio 或 Gemini 原生 generateContent)一步完成「提示词 + 音频 → 最终文本」,覆盖主听写、划词问答(OpenAI 兼容流式 / Gemini 一次性)、选区润色(omni 当纯文本 LLM)、Less Computer 转写。两套配置在凭据库中完全隔离(新增 omni 命名空间),切换不删数据、不回退传统配置。

* fix(merge): 修正多模态路径对 beta API 的适配(translation_active/bump/front_app)与 ElevenLabs 重复分支
This commit is contained in:
Chris233 authored and GitHub committed 2026-08-06 19:15:54 +08:00
1 parent b62e2324c5
commit 7655dcc7fa
32 files changed
+3012 -876

No files matched your search

@@ -2,6 +2,8 @@ use super::*;
const LLM_EXTRA_HEADERS_ACCOUNT: &str = "ark.extra_headers";
const LLM_TEMPERATURE_ACCOUNT: &str = "ark.temperature";
const OMNI_EXTRA_HEADERS_ACCOUNT: &str = "omni.extra_headers";
const OMNI_TEMPERATURE_ACCOUNT: &str = "omni.temperature";
#[tauri::command]
pub async fn get_credentials() -> Result<CredentialsStatus, String> {
@@ -9,14 +11,20 @@ pub async fn get_credentials() -> Result<CredentialsStatus, String> {
let snap = CredentialsVault::snapshot();
let active_asr_provider = CredentialsVault::get_active_asr();
let active_llm_provider = CredentialsVault::get_active_llm();
let pipeline_mode = PreferencesStore::new()
.map(|store| store.get().pipeline_mode)
.unwrap_or(crate::types::PipelineMode::Traditional);
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);
let omni_configured = omni_configured_for_active_provider(&snap);
CredentialsStatus {
active_asr_provider,
active_llm_provider,
pipeline_mode,
asr_configured,
llm_configured,
omni_configured,
volcengine_configured,
ark_configured: llm_configured,
}
@@ -136,6 +144,18 @@ fn configured(field: &Option<String>) -> bool {
.unwrap_or(false)
}
/// 多模态(Omni)模型是否已配置:OpenAI 兼容通道要求 API Key + Base URL + Model;
/// Gemini 通道要求 API Key + Model(Base URL 为空时后端走官方默认)。
pub(crate) fn omni_configured_for_active_provider(snap: &CredentialsSnapshot) -> bool {
let provider = &snap.active_omni_provider;
let has_api_key = configured(&snap.omni_api_key);
let has_model = configured(&snap.omni_model);
if provider == "gemini" {
return has_api_key && has_model;
}
has_api_key && configured(&snap.omni_endpoint) && has_model
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg(not(mobile))]
pub(crate) struct LocalAsrReleasePlan {
@@ -189,7 +209,9 @@ pub async fn set_credential(
ensure_main_window(&window)?;
let extra_headers = account == LLM_EXTRA_HEADERS_ACCOUNT;
let temperature = account == LLM_TEMPERATURE_ACCOUNT;
let parsed = if extra_headers || temperature {
let omni_extra_headers = account == OMNI_EXTRA_HEADERS_ACCOUNT;
let omni_temperature = account == OMNI_TEMPERATURE_ACCOUNT;
let parsed = if extra_headers || temperature || omni_extra_headers || omni_temperature {
None
} else {
Some(parse_account(&account)?)
@@ -200,7 +222,14 @@ pub async fn set_credential(
.map_err(|e| e.to_string());
}
if temperature {
return CredentialsVault::set_active_llm_temperature(&value)
return CredentialsVault::set_active_llm_temperature(&value).map_err(|e| e.to_string());
}
if omni_extra_headers {
return CredentialsVault::set_active_omni_extra_headers_json(&value)
.map_err(|e| e.to_string());
}
if omni_temperature {
return CredentialsVault::set_active_omni_temperature(&value)
.map_err(|e| e.to_string());
}
let acc = parsed.expect("non-extra credential account must be parsed");
@@ -304,6 +333,11 @@ pub fn set_active_llm_provider(provider: String) -> Result<(), String> {
CredentialsVault::set_active_llm_provider(&provider).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_active_omni_provider(provider: String) -> Result<(), String> {
CredentialsVault::set_active_omni_provider(&provider).map_err(|e| e.to_string())
}
/// 读出某个账号的实际值(用于设置页预填表单)。
/// 凭据来自系统凭据库;只允许主设置窗口读取 raw secret,避免胶囊 / QA 等辅助窗口默认暴露。
#[tauri::command]
@@ -315,7 +349,9 @@ pub async fn read_credential(
ensure_main_window(&window)?;
let extra_headers = account == LLM_EXTRA_HEADERS_ACCOUNT;
let temperature = account == LLM_TEMPERATURE_ACCOUNT;
let parsed = if extra_headers || temperature {
let omni_extra_headers = account == OMNI_EXTRA_HEADERS_ACCOUNT;
let omni_temperature = account == OMNI_TEMPERATURE_ACCOUNT;
let parsed = if extra_headers || temperature || omni_extra_headers || omni_temperature {
None
} else {
Some(parse_account(&account)?)
@@ -328,6 +364,13 @@ pub async fn read_credential(
if temperature {
return Ok(CredentialsVault::get_active_llm_temperature_string());
}
if omni_extra_headers {
return CredentialsVault::get_active_omni_extra_headers_json()
.map_err(|e| e.to_string());
}
if omni_temperature {
return Ok(CredentialsVault::get_active_omni_temperature_string());
}
let acc = parsed.expect("non-extra credential account must be parsed");
if let Some(provider) = provider {
CredentialsVault::get_for_asr_provider(&provider, acc).map_err(|e| e.to_string())
@@ -364,6 +407,9 @@ fn parse_account(s: &str) -> Result<CredentialAccount, String> {
"asr.advanced_config" => Ok(CredentialAccount::AsrAdvancedConfig),
"xfyun.app_id" => Ok(CredentialAccount::XfyunAppId),
"xfyun.api_key" => Ok(CredentialAccount::XfyunApiKey),
"omni.api_key" => Ok(CredentialAccount::OmniApiKey),
"omni.endpoint" => Ok(CredentialAccount::OmniEndpoint),
"omni.model" => Ok(CredentialAccount::OmniModel),
_ => Err(format!("unknown account: {s}")),
}
}
@@ -66,10 +66,17 @@ pub async fn read_audio_recording(session_id: String) -> Result<String, String>
format!("read wav failed: {e}")
}
})?;
log::info!("[history] read_audio_recording id={session_id} bytes={} head={:?}", data.len(), &data.get(..16));
log::info!(
"[history] read_audio_recording id={session_id} bytes={} head={:?}",
data.len(),
&data.get(..16)
);
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);
let data_url = format!("data:audio/wav;base64,{b64}");
log::info!("[history] read_audio_recording data_url_len={}", data_url.len());
log::info!(
"[history] read_audio_recording data_url_len={}",
data_url.len()
);
Ok(data_url)
}
@@ -128,9 +135,7 @@ fn export_recording_to_destination(
}
}
let destination = file_path
.into_path()
.map_err(export_recording_failed)?;
let destination = file_path.into_path().map_err(export_recording_failed)?;
copy_recording_to_path(source, &destination)?;
Ok(destination.to_string_lossy().into_owned())
}
@@ -157,7 +162,8 @@ fn copy_recording_to_path(
destination: &std::path::Path,
) -> Result<(), String> {
let mut source_file = open_recording_source(source)?;
let mut destination_file = std::fs::File::create(destination).map_err(export_recording_failed)?;
let mut destination_file =
std::fs::File::create(destination).map_err(export_recording_failed)?;
std::io::copy(&mut source_file, &mut destination_file)
.map(|_| ())
.map_err(export_recording_failed)
@@ -178,7 +184,9 @@ fn copy_recording_to_mobile_url(
Ok(file) => file,
Err(error) => {
#[cfg(target_os = "ios")]
let _ = app.fs().stop_accessing_security_scoped_resource(destination.clone());
let _ = app
.fs()
.stop_accessing_security_scoped_resource(destination.clone());
return Err(export_recording_failed(error));
}
};
@@ -262,7 +270,6 @@ pub async fn retranscribe_recording(
Ok(entry)
}
/// 把一次重转录的结果落到既有历史条目上(纯函数,供单测覆盖契约):
/// - 只更新转写结果并清除失败标记。insert_status 保持原值——重新转录不向光标落字,
/// 没有可表达「已转写未落字」的状态,清掉 error_code 即足以标记不再是失败条目。
@@ -313,6 +320,7 @@ mod retranscribe_tests {
asr_model: Some("volc.seedasr.sauc.duration".into()),
llm_provider: Some("ark".into()),
llm_model: Some("deepseek-v3-2".into()),
pipeline_mode: None,
asr_ms: Some(15000),
polish_ms: Some(1200),
}
@@ -331,7 +339,10 @@ mod retranscribe_tests {
assert_eq!(entry.final_text, "重转出来的文本");
assert_eq!(entry.error_code, None, "重转成功应清除失败标记");
// ASR 归因换成本次重转的构建时快照。
assert_eq!(entry.asr_provider.as_deref(), Some("bailian-qwen3-realtime"));
assert_eq!(
entry.asr_provider.as_deref(),
Some("bailian-qwen3-realtime")
);
assert_eq!(entry.asr_model.as_deref(), Some("qwen3-asr-flash-realtime"));
assert_eq!(entry.asr_ms, Some(480));
// 重转没有润色环节:旧 LLM 元数据不得残留在新转写结果上。
@@ -22,6 +22,9 @@ pub async fn validate_provider_credentials(kind: String) -> Result<ProviderCheck
"asr" => validate_asr_provider()
.await
.map(|()| ProviderCheckResult { ok: true }),
"omni" => validate_omni_provider()
.await
.map(|()| ProviderCheckResult { ok: true }),
_ => Err(format!("unknown provider kind: {kind}")),
}
}
@@ -129,6 +132,12 @@ fn read_openai_provider_config(kind: &str) -> Result<ProviderConfig, String> {
CredentialsVault::get_active_asr()
!= crate::coordinator::OPENAI_COMPATIBLE_ASR_PROVIDER_ID,
),
// 多模态(Omni)模型:独立命名空间,OpenAI 兼容通道要求 API Key + Base URL。
"omni" => (
CredentialAccount::OmniApiKey,
CredentialAccount::OmniEndpoint,
true,
),
_ => return Err(format!("unknown provider kind: {kind}")),
};
let api_key = CredentialsVault::get(api_key_account)
@@ -146,6 +155,15 @@ fn read_openai_provider_config(kind: &str) -> Result<ProviderConfig, String> {
CredentialsVault::get_active_llm_temperature(),
),
)
} else if kind == "omni" {
let active_omni = CredentialsVault::get_active_omni();
(
CredentialsVault::get_active_omni_extra_headers(),
openai_compatible_temperature_for_provider(
&active_omni,
CredentialsVault::get_active_omni_temperature(),
),
)
} else {
(HashMap::new(), None)
};
@@ -246,6 +264,18 @@ fn provider_llm_error_message(error: LLMError) -> String {
}
}
/// 多模态(Omni)模型连通性验证:真发一次纯文本请求(无音频),走与运行期
/// 完全相同的 provider 构建与请求路径,避免「验证通过但真实调用失败」。
async fn validate_omni_provider() -> Result<(), String> {
let provider =
crate::coordinator::build_active_omni_provider(false).map_err(|e| e.to_string())?;
provider
.complete("验证连接", "ping", None)
.await
.map(|_| ())
.map_err(provider_llm_error_message)
}
async fn validate_asr_provider() -> Result<(), String> {
let active_asr = CredentialsVault::get_active_asr();
if active_asr_is_keyless_for_validation(&active_asr) {
@@ -746,8 +776,7 @@ async fn validate_asr_transcription(
request.json(&body)
}
};
match request.send().await
{
match request.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 => {
@@ -1241,9 +1270,12 @@ mod tests {
stream.write_all(response.as_bytes()).await.unwrap();
});
let target_server = tokio::spawn(async move {
tokio::time::timeout(std::time::Duration::from_millis(500), target_listener.accept())
.await
.is_ok()
tokio::time::timeout(
std::time::Duration::from_millis(500),
target_listener.accept(),
)
.await
.is_ok()
});
let error = send_dashscope_multimodal_validation(
@@ -1256,7 +1288,10 @@ mod tests {
redirect_server.await.unwrap();
assert_eq!(error, "providerHttpStatus:302");
assert!(!target_server.await.unwrap(), "validation followed redirect");
assert!(
!target_server.await.unwrap(),
"validation followed redirect"
);
}
#[test]
+195 -70
View File
@@ -72,9 +72,9 @@ mod polish_flow;
mod qa;
mod qa_session;
mod resources;
mod silence_auto_stop;
#[cfg(not(mobile))]
pub(crate) mod selection_polish;
mod silence_auto_stop;
use asr_wiring::*;
// providers.rs 的 ASR 验证路径按 provider 的真实请求格式发送探针(issue #837),
@@ -113,13 +113,7 @@ use qa::{
};
#[cfg(test)]
use resources::discard_startup_resources_for_session;
use resources::{
acquire_recording_mute, cancel_active_asr, cancel_qa_asr_for_session, release_recording_mute,
selected_microphone_device_name, stop_microphone_preview_monitor,
stop_qa_recorder_for_session, store_qa_asr_for_session, store_qa_recorder_for_session,
take_asr_for_session, take_qa_asr_for_session, take_recorder_for_session, SessionResource,
SharedRecordingMuteState,
};
use resources::{cancel_active_asr, SessionResource, SharedRecordingMuteState};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CapsuleShowStrategy {
@@ -292,17 +286,16 @@ impl ActiveAsrProviderKind {
match self {
ActiveAsrProviderKind::Bailian
| ActiveAsrProviderKind::Qwen3Realtime
| ActiveAsrProviderKind::ElevenLabs => {
AsrConfiguredFields::ApiKeyOnly
}
| ActiveAsrProviderKind::ElevenLabs => AsrConfiguredFields::ApiKeyOnly,
ActiveAsrProviderKind::Mimo | ActiveAsrProviderKind::DashScopeMultimodal => {
AsrConfiguredFields::ApiKeyEndpointModel
}
// StepfunRealtime 只经 `stepfun` 的模型路由可达(隐藏 effective id),
// 「已配置」判定看真实 active `stepfun` → WhisperCompatible;此处形态
// 与之对齐,保证直接停在该 id 上也语义一致。
ActiveAsrProviderKind::WhisperCompatible
| ActiveAsrProviderKind::StepfunRealtime => AsrConfiguredFields::EndpointModelOnly,
ActiveAsrProviderKind::WhisperCompatible | ActiveAsrProviderKind::StepfunRealtime => {
AsrConfiguredFields::EndpointModelOnly
}
ActiveAsrProviderKind::Volcengine => AsrConfiguredFields::VolcAppKey,
ActiveAsrProviderKind::Xfyun => AsrConfiguredFields::XfyunAppKey,
}
@@ -532,12 +525,10 @@ fn advanced_asr_config_for(provider_id: &str, raw: Option<&str>) -> AdvancedAsrC
/// 读取某 ASR provider 的高级配置。仅 `openai-compatible` / `zenmux` 读 vault;
/// 其余命名厂商走硬编码行为(这里返回默认值),避免破坏已测通的路径。
fn read_advanced_asr_config(provider_id: &str) -> AdvancedAsrConfig {
let raw = CredentialsVault::get_for_asr_provider(
provider_id,
CredentialAccount::AsrAdvancedConfig,
)
.ok()
.flatten();
let raw =
CredentialsVault::get_for_asr_provider(provider_id, CredentialAccount::AsrAdvancedConfig)
.ok()
.flatten();
advanced_asr_config_for(provider_id, raw.as_deref())
}
@@ -565,6 +556,10 @@ struct Inner {
/// store_asr_for_session 一并写入,end_session 取走落 history——比事后重读
/// 全局设置可靠:会话中途切 provider/model 不会污染归因(PR #826 review)。
asr_label: Mutex<Option<SessionResource<AsrCallLabel>>>,
/// 多模态(Omni)模式下的 dictation 录音 PCM 缓冲。只在
/// `multimodal_pipeline_enabled && pipeline_mode == multimodal` 时使用,
/// 与 asr 槽互斥——同一会话二者有且仅有一个。
omni_pcm: Mutex<Option<SessionResource<Arc<resources::PcmBufferConsumer>>>>,
/// 本地 Qwen3-ASR 引擎缓存。跨会话复用,避免每次重加载 1.2GB+ 模型。
/// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。
local_asr_cache: Arc<crate::asr::local::LocalAsrCache>,
@@ -670,6 +665,8 @@ struct Inner {
capsule_cursor_passthrough: AtomicBool,
/// QA 用的 ASR 句柄。必须跟 active_asr_provider 保持一致,避免浮窗走不同入口。
qa_asr: Mutex<Option<SessionResource<ActiveAsr>>>,
/// QA 用的多模态(Omni)录音 PCM 缓冲。与 qa_asr 互斥。
qa_omni_pcm: Mutex<Option<SessionResource<Arc<resources::PcmBufferConsumer>>>>,
/// QA 用的 Recorder 句柄。
qa_recorder: Mutex<Option<SessionResource<Recorder>>>,
/// QA SSE 流取消标志。begin_qa_session 重置为 false;cancel_qa_session 设 true;
@@ -830,6 +827,7 @@ impl Coordinator {
state: Mutex::new(SessionState::default()),
asr: Mutex::new(None),
asr_label: Mutex::new(None),
omni_pcm: Mutex::new(None),
recorder: Mutex::new(None),
audio_archive_active: AtomicBool::new(false),
recording_mute: Mutex::new(SharedRecordingMuteState::new()),
@@ -868,6 +866,7 @@ impl Coordinator {
capsule_style: AtomicU8::new(0),
capsule_cursor_passthrough: AtomicBool::new(true),
qa_asr: Mutex::new(None),
qa_omni_pcm: Mutex::new(None),
qa_recorder: Mutex::new(None),
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()),
@@ -948,6 +947,7 @@ impl Coordinator {
state: Mutex::new(SessionState::default()),
asr: Mutex::new(None),
asr_label: Mutex::new(None),
omni_pcm: Mutex::new(None),
recorder: Mutex::new(None),
audio_archive_active: AtomicBool::new(false),
recording_mute: Mutex::new(SharedRecordingMuteState::new()),
@@ -986,6 +986,7 @@ impl Coordinator {
capsule_style: AtomicU8::new(0),
capsule_cursor_passthrough: AtomicBool::new(true),
qa_asr: Mutex::new(None),
qa_omni_pcm: Mutex::new(None),
qa_recorder: Mutex::new(None),
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()),
@@ -1064,7 +1065,6 @@ impl Coordinator {
self.inner.local_asr_cache.loaded_model_id()
}
/// 主动把当前本地 ASR 引擎状态推给前端(keepLoadedSecs 变更等命令侧调用)。
pub fn emit_local_asr_engine_status(&self) {
emit_local_asr_engine_status(&self.inner);
@@ -1562,7 +1562,6 @@ impl Coordinator {
close_qa_panel(&self.inner);
}
/// 用户点 ✕ / 按 Esc 关 Less Computer 浮窗:隐藏窗口 + 结束连续对话
/// (下次说话开新会话,不再 --continue 续旧上下文)。
pub fn less_computer_window_dismiss(&self) {
@@ -1602,8 +1601,7 @@ impl Coordinator {
// callback (SIGABRT). Tauri's runtime handle is safe from either thread.
tauri::async_runtime::spawn(async move {
let session_id = crate::coordinator_state::new_session_id();
if let Err(e) =
dictation::run_voice_agent_transcript(&inner, session_id, text, 0).await
if let Err(e) = dictation::run_voice_agent_transcript(&inner, session_id, text, 0).await
{
log::warn!("[less-computer] text submit run failed: {e}");
}
@@ -1627,10 +1625,7 @@ impl Coordinator {
/// 执行——用户反馈「切换成默认风格后仍显示流光 Siri」。在保存路径直接同步后,
/// 任何平台的下一次录音从入场帧起就携带最新样式,不再依赖 emit 闭包的时序。
pub fn sync_capsule_style_from_preferences(&self) {
let classic = matches!(
self.inner.prefs.get().capsule_style,
CapsuleStyle::Classic
);
let classic = matches!(self.inner.prefs.get().capsule_style, CapsuleStyle::Classic);
self.inner
.capsule_style
.store(if classic { 1 } else { 0 }, Ordering::Relaxed);
@@ -2101,6 +2096,7 @@ impl Coordinator {
// repolish 不回写历史的模型/耗时字段,调用快照就地丢弃。
&mut None,
&mut None,
pipeline_multimodal_enabled(&self.inner.prefs.get()),
)
.await
.map_err(|e| e.to_string())
@@ -2108,10 +2104,7 @@ impl Coordinator {
/// 返回 (转写文本, 本次实际构建的 ASR (provider, model) 快照)。快照供命令层把
/// 「重转用了哪个模型」写回历史(构建时归因,PR #826 review)。
pub async fn retranscribe_pcm(
&self,
pcm: Vec<u8>,
) -> Result<(String, AsrCallLabel), String> {
pub async fn retranscribe_pcm(&self, pcm: Vec<u8>) -> Result<(String, AsrCallLabel), String> {
self.retranscribe_pcm_inner(pcm, false, None).await
}
@@ -2205,9 +2198,9 @@ impl Coordinator {
.map_err(|e| e.to_string())?,
ActiveAsr::DashScopeMultimodal(m) => {
tokio::time::timeout(m.transcribe_timeout(audio_secs), m.transcribe())
.await
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?
.await
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?
}
ActiveAsr::ElevenLabs(e) => {
tokio::time::timeout(elevenlabs_timeout, e.transcribe())
@@ -2478,9 +2471,11 @@ pub(super) fn insert_via_non_tsf_fallback(
let prefs = inner.prefs.get();
let sendinput_options = dictation::windows_sendinput_options_from_prefs(&prefs);
let status = finish_non_tsf_insertion_fallback(
|| inner
.inserter
.insert_via_unicode_keystrokes(polished, sendinput_options),
|| {
inner
.inserter
.insert_via_unicode_keystrokes(polished, sendinput_options)
},
|| inner.inserter.copy_fallback(polished),
);
@@ -2582,7 +2577,6 @@ mod non_tsf_fallback_tests {
// ─────────────────────────── helpers ───────────────────────────
fn read_whisper_credentials() -> (String, String, String) {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
@@ -2800,10 +2794,12 @@ fn read_volc_credentials() -> VolcengineCredentials {
// 密钥槽位随鉴权模式:AppIdToken 读旧版 Access Token,ApiKey 读独立的方舟 API Key,
// 两者互不污染,切换模式不会把旧模式的凭据带进新模式的握手。
let secret = match auth_mode {
VolcengineAuthMode::AppIdToken => CredentialsVault::get(CredentialAccount::VolcengineAccessKey)
.ok()
.flatten()
.unwrap_or_default(),
VolcengineAuthMode::AppIdToken => {
CredentialsVault::get(CredentialAccount::VolcengineAccessKey)
.ok()
.flatten()
.unwrap_or_default()
}
VolcengineAuthMode::ApiKey => CredentialsVault::get(CredentialAccount::VolcengineApiKey)
.ok()
.flatten()
@@ -2847,7 +2843,6 @@ fn enabled_hotwords(inner: &Arc<Inner>) -> Vec<DictionaryHotword> {
.collect()
}
/// 读 Gemini 凭据。所有 LLM provider 共用 ark.* 槽位(persistence 没做 per-provider
/// 隔离),所以这里也是从 `ArkApiKey` / `ArkModelId` / `ArkEndpoint` 三个槽读,
/// 但回退默认值改成谷歌的:base_url 默认 `https://generativelanguage.googleapis.com/v1beta`,
@@ -2936,6 +2931,86 @@ fn build_active_llm_provider(llm_thinking_enabled: bool) -> anyhow::Result<Activ
)))
}
/// 是否启用多模态识别管线:实验开关 + 模式切换都满足才生效。
pub(crate) fn pipeline_multimodal_enabled(prefs: &crate::types::UserPreferences) -> bool {
prefs.multimodal_pipeline_enabled
&& prefs.pipeline_mode == crate::types::PipelineMode::Multimodal
}
/// 多模态(Omni)模型通道的凭据预检(友好错误信息,供录音前拦截)。
pub(crate) fn ensure_omni_credentials() -> Result<(), String> {
let api_key = CredentialsVault::get(CredentialAccount::OmniApiKey)
.map_err(|e| e.to_string())?
.unwrap_or_default();
let model = CredentialsVault::get(CredentialAccount::OmniModel)
.map_err(|e| e.to_string())?
.unwrap_or_default();
let base_url = CredentialsVault::get(CredentialAccount::OmniEndpoint)
.map_err(|e| e.to_string())?
.unwrap_or_default();
if api_key.trim().is_empty() {
return Err("多模态模型 API Key 为空:请在 服务 → AI 提供商 → 多模态模型 中配置".into());
}
if model.trim().is_empty() {
return Err("多模态模型 id 为空:请在 服务 → AI 提供商 → 多模态模型 中配置".into());
}
let active = CredentialsVault::get_active_omni();
if active != crate::omni::OMNI_GEMINI_PROVIDER_ID && base_url.trim().is_empty() {
return Err("多模态模型 Base URL 为空:请在 服务 → AI 提供商 → 多模态模型 中配置".into());
}
Ok(())
}
fn omni_default_base_url(provider: &str) -> &'static str {
match provider {
"openai" => "https://api.openai.com/v1",
crate::omni::OMNI_GEMINI_PROVIDER_ID => "https://generativelanguage.googleapis.com/v1beta",
"dashscope-omni" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
_ => "",
}
}
/// 读取 omni 命名空间凭据并构建多模态模型通道(与 build_active_llm_provider
/// 平行的唯一构建点)。Gemini 按 provider id / base_url 路由到原生通道。
pub(crate) fn build_active_omni_provider(
thinking_enabled: bool,
) -> anyhow::Result<crate::omni::OmniProvider> {
let active = CredentialsVault::get_active_omni();
let api_key = CredentialsVault::get(CredentialAccount::OmniApiKey)?.unwrap_or_default();
let model = CredentialsVault::get(CredentialAccount::OmniModel)?.unwrap_or_default();
let base_url = CredentialsVault::get(CredentialAccount::OmniEndpoint)?.unwrap_or_default();
if api_key.trim().is_empty() {
anyhow::bail!("多模态模型 API Key 为空");
}
if model.trim().is_empty() {
anyhow::bail!("多模态模型 id 为空");
}
let base_url = if base_url.trim().is_empty() {
omni_default_base_url(&active).to_string()
} else {
base_url.trim().to_string()
};
if base_url.is_empty() {
anyhow::bail!("多模态模型 Base URL 为空");
}
// 与 LLM / ASR 通道一致:拒绝指向内网/回环/元数据服务的地址(SSRF 防线)。
crate::endpoint_security::validate_http_endpoint(&base_url)
.map_err(|_| anyhow::anyhow!("endpointInvalid"))?;
let config = crate::omni::OmniConfig {
provider_id: active.clone(),
base_url,
api_key,
model,
extra_headers: CredentialsVault::get_active_omni_extra_headers(),
temperature: crate::polish::openai_compatible_temperature_for_provider(
&active,
CredentialsVault::get_active_omni_temperature(),
),
thinking_enabled,
};
Ok(crate::omni::OmniProvider::new(config))
}
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)
@@ -2973,8 +3048,16 @@ mod tests {
// 非 volc. 命名空间 / 含异常字符 / 超长的值可能携带租户信息,一律不落历史。
assert_eq!(super::volc_resource_history_label(""), None);
assert_eq!(super::volc_resource_history_label("my-secret-tenant"), None);
assert_eq!(super::volc_resource_history_label("volc.a b"), None, "空格不在字符集");
assert_eq!(super::volc_resource_history_label("volc.引擎"), None, "非 ASCII 拒绝");
assert_eq!(
super::volc_resource_history_label("volc.a b"),
None,
"空格不在字符集"
);
assert_eq!(
super::volc_resource_history_label("volc.引擎"),
None,
"非 ASCII 拒绝"
);
let too_long = format!("volc.{}", "x".repeat(64));
assert_eq!(super::volc_resource_history_label(&too_long), None);
}
@@ -2991,6 +3074,24 @@ mod tests {
Uuid::from_u128(n)
}
#[test]
fn pipeline_multimodal_enabled_requires_both_flag_and_mode() {
let mut prefs = crate::types::UserPreferences::default();
assert!(!super::pipeline_multimodal_enabled(&prefs));
prefs.multimodal_pipeline_enabled = true;
assert!(
!super::pipeline_multimodal_enabled(&prefs),
"只开实验开关但模式还是 traditional 时不得启用"
);
prefs.pipeline_mode = crate::types::PipelineMode::Multimodal;
assert!(super::pipeline_multimodal_enabled(&prefs));
prefs.multimodal_pipeline_enabled = false;
assert!(
!super::pipeline_multimodal_enabled(&prefs),
"实验开关关闭时即使模式为 multimodal 也不得启用"
);
}
#[test]
fn failed_remote_pin_persistence_keeps_memory_and_server_state() {
let slot = Mutex::new(Some("123456".to_string()));
@@ -3263,7 +3364,9 @@ mod tests {
fn openai_compatible_preset_is_whisper_compatible_and_conservative_by_default() {
use crate::asr::whisper::AsrRequestFormat;
assert!(is_whisper_compatible_provider(OPENAI_COMPATIBLE_ASR_PROVIDER_ID));
assert!(is_whisper_compatible_provider(
OPENAI_COMPATIBLE_ASR_PROVIDER_ID
));
assert_eq!(
active_asr_provider_kind(OPENAI_COMPATIBLE_ASR_PROVIDER_ID),
ActiveAsrProviderKind::WhisperCompatible
@@ -3334,9 +3437,7 @@ mod tests {
AdvancedAsrConfig::default()
);
assert_eq!(
parse_advanced_asr_config(Some(
r#"{"verboseJson":false,"chunkDurationMs":30000}"#
)),
parse_advanced_asr_config(Some(r#"{"verboseJson":false,"chunkDurationMs":30000}"#)),
AdvancedAsrConfig {
verbose_json: false,
chunk_duration_ms: Some(30_000),
@@ -3510,8 +3611,8 @@ mod tests {
// 穷尽 match,这里逐 kind 钉死映射,防止未来悄悄改动某个 provider 的凭据形态。
#[test]
fn preflight_credential_maps_every_kind() {
use AsrPreflightCredential::*;
use ActiveAsrProviderKind::*;
use AsrPreflightCredential::*;
assert_eq!(Bailian.preflight_credential(), AsrApiKey);
assert_eq!(Qwen3Realtime.preflight_credential(), AsrApiKey);
assert_eq!(Mimo.preflight_credential(), AsrApiKey);
@@ -3535,8 +3636,7 @@ mod tests {
crate::asr::qwen_realtime::PROVIDER_ID
);
assert_eq!(
resolve_effective_asr_provider(bailian, "qwen3-asr-flash-realtime-2026-02-10")
.unwrap(),
resolve_effective_asr_provider(bailian, "qwen3-asr-flash-realtime-2026-02-10").unwrap(),
crate::asr::qwen_realtime::PROVIDER_ID
);
assert_eq!(
@@ -3602,22 +3702,20 @@ mod tests {
.unwrap_err();
assert!(error.contains("不支持的百炼 ASR 模型"));
// qwen3-asr-flash-filetrans 仅接受公网 URL,与本地录音链路不兼容,同样拒绝。
let error =
resolve_effective_asr_provider(crate::asr::bailian::PROVIDER_ID, "qwen3-asr-flash-filetrans")
.unwrap_err();
let error = resolve_effective_asr_provider(
crate::asr::bailian::PROVIDER_ID,
"qwen3-asr-flash-filetrans",
)
.unwrap_err();
assert!(error.contains("不支持的百炼 ASR 模型"));
}
#[test]
fn validates_only_supported_dashscope_multimodal_models() {
assert!(validate_dashscope_multimodal_model("").is_ok());
assert!(
validate_dashscope_multimodal_model("fun-asr-flash-2026-06-15").is_ok()
);
assert!(validate_dashscope_multimodal_model("fun-asr-flash-2026-06-15").is_ok());
assert!(validate_dashscope_multimodal_model("qwen-audio-3.0-asr-flash").is_ok());
assert!(
validate_dashscope_multimodal_model("qwen-audio-3.0-asr-flash-streaming").is_err()
);
assert!(validate_dashscope_multimodal_model("qwen-audio-3.0-asr-flash-streaming").is_err());
}
#[test]
@@ -3652,8 +3750,8 @@ mod tests {
#[test]
fn configured_fields_maps_every_kind() {
use AsrConfiguredFields::*;
use ActiveAsrProviderKind::*;
use AsrConfiguredFields::*;
assert_eq!(Bailian.configured_fields(), ApiKeyOnly);
assert_eq!(Qwen3Realtime.configured_fields(), ApiKeyOnly);
assert_eq!(Mimo.configured_fields(), ApiKeyEndpointModel);
@@ -3905,10 +4003,22 @@ mod tests {
// 旧 schedule 触发时若期间有更新的 emit,应跳过隐藏(voice agent 取消双 emit 竞争)。
emit_capsule(&coordinator.inner, CapsuleState::Done, 0.0, 0, None, None);
schedule_capsule_idle(&coordinator.inner, 30);
emit_capsule(&coordinator.inner, CapsuleState::Cancelled, 0.0, 0, None, None);
emit_capsule(
&coordinator.inner,
CapsuleState::Cancelled,
0.0,
0,
None,
None,
);
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
assert_eq!(
coordinator.inner.last_capsule_state.lock().as_ref().copied(),
coordinator
.inner
.last_capsule_state
.lock()
.as_ref()
.copied(),
Some(CapsuleState::Cancelled),
"旧 schedule 不应把更新的 Cancelled 状态提前隐藏"
);
@@ -3921,7 +4031,12 @@ mod tests {
schedule_capsule_idle(&coordinator.inner, 30);
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
assert_eq!(
coordinator.inner.last_capsule_state.lock().as_ref().copied(),
coordinator
.inner
.last_capsule_state
.lock()
.as_ref()
.copied(),
Some(CapsuleState::Idle),
"无新 emit 时 schedule 应隐藏胶囊"
);
@@ -4017,8 +4132,7 @@ mod tests {
let coordinator = Coordinator::new();
// Idle + 冷却未过期:模拟「识别中按下 → 会话收尾 → bridge 取出该 Pressed」的时刻。
*coordinator.inner.session_cooldown_until.lock() = Some(
std::time::Instant::now()
+ std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS),
std::time::Instant::now() + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS),
);
handle_pressed_edge(&coordinator.inner, std::time::Instant::now(), 1).await;
@@ -4086,7 +4200,11 @@ mod tests {
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
handle_released_edge(&coordinator.inner, pressed_at + std::time::Duration::from_millis(100)).await;
handle_released_edge(
&coordinator.inner,
pressed_at + std::time::Duration::from_millis(100),
)
.await;
// 短按松手不结束录音,等下一次按下再停。
assert_eq!(
@@ -4115,7 +4233,10 @@ mod tests {
)
.await;
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Listening);
assert_eq!(
coordinator.inner.state.lock().phase,
SessionPhase::Listening
);
assert!(coordinator.inner.hotkey_press_at.lock().is_none());
}
@@ -4133,7 +4254,11 @@ mod tests {
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
handle_released_edge(&coordinator.inner, pressed_at + std::time::Duration::from_millis(500)).await;
handle_released_edge(
&coordinator.inner,
pressed_at + std::time::Duration::from_millis(500),
)
.await;
// 无 recorder / ASR 的测试会话下,end_session 直接收尾到 Idle。
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
@@ -283,6 +283,7 @@ async fn run_streaming_polish(
prior_turns,
llm_call,
llm_elapsed_ms,
pipeline_multimodal_enabled(&inner.prefs.get()),
)
.await;
return (p, e, false);
@@ -315,6 +316,7 @@ async fn run_streaming_polish(
prior_turns,
llm_call,
llm_elapsed_ms,
pipeline_multimodal_enabled(&inner.prefs.get()),
)
.await;
return (p, err, false);
@@ -327,8 +329,7 @@ async fn run_streaming_polish(
// from what the user actually sees\"。
let (tx, rx) = std::sync::mpsc::channel::<String>();
#[cfg(target_os = "windows")]
let sendinput_options =
windows_sendinput_options_from_prefs(&inner.prefs.get());
let sendinput_options = windows_sendinput_options_from_prefs(&inner.prefs.get());
let typer_handle = tokio::task::spawn_blocking(move || {
#[cfg(target_os = "windows")]
{
@@ -473,6 +474,7 @@ async fn run_streaming_polish(
prior_turns,
llm_call,
llm_elapsed_ms,
pipeline_multimodal_enabled(&inner.prefs.get()),
)
.await;
(p, e, false)
@@ -735,9 +737,7 @@ pub(super) async fn handle_pressed_edge(
inner
.hotkey_press_generation
.store(press_id, Ordering::SeqCst);
inner
.hotkey_press_began_session
.store(0, Ordering::SeqCst);
inner.hotkey_press_began_session.store(0, Ordering::SeqCst);
// 防抖:相邻 < HOTKEY_DEBOUNCE 的边沿直接丢弃,记到 log 方便排查。
// 与 `hotkey_trigger_held` 互补:held 防 press-without-release,本检查防
@@ -1052,8 +1052,13 @@ pub(super) async fn handle_released(inner: &Arc<Inner>, released_at: std::time::
}
if mode == HotkeyMode::Auto {
// 使用物理按下/松开的事件时刻,避免 bridge 排队时把处理延迟误算为按住时长。
let held_long = inner.hotkey_press_at.lock().take()
.map(|pressed_at| released_at.saturating_duration_since(pressed_at) >= AUTO_HOLD_THRESHOLD)
let held_long = inner
.hotkey_press_at
.lock()
.take()
.map(|pressed_at| {
released_at.saturating_duration_since(pressed_at) >= AUTO_HOLD_THRESHOLD
})
.unwrap_or(false);
match phase {
// 长按松手 = 按住说话,松手即停;短按 = 切换式,锁存保持录音,下次按下再停。
@@ -1065,9 +1070,7 @@ pub(super) async fn handle_released(inner: &Arc<Inner>, released_at: std::time::
request_stop_during_starting(inner, "auto hold release edge");
}
SessionPhase::Listening | SessionPhase::Starting => {
log::info!(
"[coord] auto short-tap latched (toggle semantics); next press stops"
);
log::info!("[coord] auto short-tap latched (toggle semantics); next press stops");
}
_ => {}
}
@@ -1371,8 +1374,7 @@ async fn run_less_computer_once(
// OpenCode 无 `--settings`,护栏走 `permission` 配置经 OPENCODE_CONFIG_CONTENT 注入。
// build_opencode_guard_config 默认 bash deny 高风险前缀、webfetch deny,审批放行的
// 前缀显式 allow。fail-closed:序列化失败立即中止,绝不无护栏裸跑。
let guard =
crate::coding_agent::guard::build_opencode_guard_config(&approved_patterns);
let guard = crate::coding_agent::guard::build_opencode_guard_config(&approved_patterns);
let guard_str = match serde_json::to_string(&guard) {
Ok(s) => s,
Err(e) => {
@@ -1586,10 +1588,7 @@ pub(super) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
/// begin_session 的带参版本,voice_agent=true 时在 Starting 阶段就标记好,
/// 防止 finish_starting_session 处理 pending_stop 时丢失标志。
pub(super) async fn begin_session_as(
inner: &Arc<Inner>,
voice_agent: bool,
) -> Result<(), String> {
pub(super) async fn begin_session_as(inner: &Arc<Inner>, voice_agent: bool) -> Result<(), String> {
let current_session_id = {
let mut state = inner.state.lock();
let Some(session_id) =
@@ -1632,6 +1631,42 @@ pub(super) async fn begin_session_as(
inner.capsule_warming.store(true, Ordering::SeqCst);
emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None);
// 多模态(Omni)模式:不构建 ASR,录音 PCM 直接进缓冲器,松键后一步出文。
if pipeline_multimodal_enabled(&inner.prefs.get()) {
if let Err(message) = ensure_omni_credentials() {
log::warn!("[coord] omni credential gate failed: {message}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(message.clone()),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
return Err(message);
}
if let Err(message) = ensure_microphone_permission(inner) {
log::warn!("[coord] omni microphone permission gate failed: {message}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(message.clone()),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
return Err(message);
}
let consumer = PcmBufferConsumer::new();
store_omni_pcm_for_session(inner, current_session_id, Arc::clone(&consumer));
start_recorder_and_enter_listening(inner, current_session_id, "omni", consumer).await?;
return Ok(());
}
if let Err(message) = ensure_asr_credentials() {
log::warn!("[coord] ASR credential gate failed: {message}");
emit_capsule(
@@ -2436,7 +2471,9 @@ pub(super) async fn start_recorder_for_starting(
// 第一帧 PCM 真的流到 consumer 了(recorder.rs::process_callback 的顺序保证
// consume_pcm_chunk 先于 level_handler)——关掉预备态,让这一帧起 payload.warming
// 翻 false,前端把「待命」光条点亮成正式录音态。之后每帧都是 false(幂等)。
inner_for_level.capsule_warming.store(false, Ordering::SeqCst);
inner_for_level
.capsule_warming
.store(false, Ordering::SeqCst);
emit_capsule(
&inner_for_level,
CapsuleState::Recording,
@@ -2658,6 +2695,7 @@ fn build_transcribe_failed_session(
asr_model: None,
llm_provider: None,
llm_model: None,
pipeline_mode: None,
asr_ms: Some(asr_ms),
polish_ms: None,
}
@@ -2927,6 +2965,90 @@ async fn wait_for_processing_cancel(inner: &Arc<Inner>) {
}
}
/// 一次性(非流式)插入最终文本:平台分支与 `end_session` 原内联逻辑一致,
/// 供传统与多模态(Omni)两条收尾路径复用,避免插入策略漂移。
async fn insert_final_text(
inner: &Arc<Inner>,
current_session_id: SessionId,
text: &str,
prefs: &crate::types::UserPreferences,
focus_ready_for_paste: bool,
) -> InsertStatus {
let restore_clipboard = prefs.restore_clipboard_after_paste;
let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback;
let windows_insertion_mode = prefs.windows_insertion_mode;
let paste_shortcut = prefs.paste_shortcut;
#[cfg(target_os = "android")]
{
crate::android::android_insert_with_strategy(
&inner.inserter,
text,
inner.prefs.get().android_insert_strategy,
)
}
#[cfg(not(target_os = "android"))]
if focus_ready_for_paste {
#[cfg(target_os = "windows")]
{
match windows_insertion_mode {
crate::types::WindowsInsertionMode::SendInput => {
let sendinput_options = windows_sendinput_options_from_prefs(prefs);
if allow_non_tsf_insertion_fallback {
insert_via_non_tsf_fallback(inner, text, restore_clipboard, paste_shortcut)
} else {
inner
.inserter
.insert_via_unicode_keystrokes(text, sendinput_options)
}
}
crate::types::WindowsInsertionMode::Paste => {
inner
.inserter
.insert(text, restore_clipboard, paste_shortcut)
}
crate::types::WindowsInsertionMode::Tsf => {
let ime_target = capture_ime_submit_target();
insert_with_windows_ime_first(
inner,
current_session_id,
text,
restore_clipboard,
allow_non_tsf_insertion_fallback,
paste_shortcut,
ime_target,
)
.await
}
}
}
#[cfg(not(target_os = "windows"))]
{
inner
.inserter
.insert(text, restore_clipboard, paste_shortcut)
}
} else {
#[cfg(target_os = "linux")]
{
// Linux: fcitx5 commitString 无需窗口焦点,始终尝试插入。
inner
.inserter
.insert(text, restore_clipboard, paste_shortcut)
}
#[cfg(not(target_os = "linux"))]
{
log::warn!(
"[coord] original insertion target is not foreground; copied output without paste"
);
if allow_non_tsf_insertion_fallback {
inner.inserter.copy_fallback(text)
} else {
InsertStatus::Failed
}
}
}
}
pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let current_session_id = {
let mut state = inner.state.lock();
@@ -2944,6 +3066,12 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
release_recording_mute(inner, "dictation");
}
// 多模态(Omni)模式:不走 ASR 转写 + LLM 润色,录音 PCM 直接编码 WAV,
// 一次调用出最终文本(issue #902)。两套配置隔离,缺 omni 配置时明确报错。
if pipeline_multimodal_enabled(&inner.prefs.get()) {
return finish_dictation_multimodal(inner, current_session_id, elapsed).await;
}
let asr_opt = take_asr_for_session(inner, current_session_id);
// 构建时快照(begin_session 存入)。会话中途改设置不影响这份归因。
let mut asr_call_label = take_asr_label_for_session(inner, current_session_id);
@@ -3432,9 +3560,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
// 处理最后一次重试结果时也复查一次取消标志,覆盖「重试刚返回
// Exhausted 与用户同时按 Esc」的窄竞态,避免误走失败提示。
if inner.state.lock().cancelled {
log::info!(
"[coord] cancel after silent ASR retry — discarding transcript"
);
log::info!("[coord] cancel after silent ASR retry — discarding transcript");
restore_prepared_windows_ime_session(inner, current_session_id);
finish_cancelled_processing(inner, current_session_id);
return Ok(());
@@ -3505,6 +3631,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
asr_model: asr_model.clone(),
llm_provider: None,
llm_model: None,
pipeline_mode: None,
asr_ms: Some(asr_ms),
polish_ms: None,
};
@@ -3604,10 +3731,8 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let llm_thinking_enabled = prefs.llm_thinking_enabled;
// 风格包原有 Prompt 就是录音 / ASR 后处理的完整规则;不要在全局设置再叠一层,
// 否则会让同一个风格包的导出、复用和运行结果不一致。
let style_system_prompt = crate::types::style_pack_prompt(
&pack,
crate::types::StylePromptKind::DictationAsr,
);
let style_system_prompt =
crate::types::style_pack_prompt(&pack, crate::types::StylePromptKind::DictationAsr);
let raw_uses_llm = mode == PolishMode::Raw && super::raw_style_pack_uses_llm(&pack);
let translation_target = prefs.translation_target_language.trim().to_string();
let translation_active = crate::types::translation_effective(
@@ -3698,6 +3823,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
&prior_turns,
&mut llm_call,
&mut llm_elapsed_ms,
pipeline_multimodal_enabled(&inner.prefs.get()),
)
.await;
polish_source = src;
@@ -3733,6 +3859,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
&prior_turns,
&mut llm_call,
&mut llm_elapsed_ms,
pipeline_multimodal_enabled(&inner.prefs.get()),
)
.await;
(p, e, false)
@@ -3784,10 +3911,8 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let focus_target = inner.state.lock().focus_target;
let focus_ready_for_paste = restore_focus_target_if_possible(focus_target);
let prefs = inner.prefs.get();
let restore_clipboard = prefs.restore_clipboard_after_paste;
let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback;
let windows_insertion_mode = prefs.windows_insertion_mode;
let paste_shortcut = prefs.paste_shortcut;
// 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。
let status = if already_streamed {
log::info!(
@@ -3797,80 +3922,14 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
);
InsertStatus::Inserted
} else {
#[cfg(target_os = "android")]
{
crate::android::android_insert_with_strategy(
&inner.inserter,
&polished,
inner.prefs.get().android_insert_strategy,
)
}
#[cfg(not(target_os = "android"))]
if focus_ready_for_paste {
#[cfg(target_os = "windows")]
{
match windows_insertion_mode {
crate::types::WindowsInsertionMode::SendInput => {
let sendinput_options = windows_sendinput_options_from_prefs(&prefs);
if allow_non_tsf_insertion_fallback {
insert_via_non_tsf_fallback(
inner,
&polished,
restore_clipboard,
paste_shortcut,
)
} else {
inner
.inserter
.insert_via_unicode_keystrokes(&polished, sendinput_options)
}
}
crate::types::WindowsInsertionMode::Paste => inner.inserter.insert(
&polished,
restore_clipboard,
paste_shortcut,
),
crate::types::WindowsInsertionMode::Tsf => {
let ime_target = capture_ime_submit_target();
insert_with_windows_ime_first(
inner,
current_session_id,
&polished,
restore_clipboard,
allow_non_tsf_insertion_fallback,
paste_shortcut,
ime_target,
)
.await
}
}
}
#[cfg(not(target_os = "windows"))]
{
inner
.inserter
.insert(&polished, restore_clipboard, paste_shortcut)
}
} else {
#[cfg(target_os = "linux")]
{
// Linux: fcitx5 commitString 无需窗口焦点,始终尝试插入。
inner
.inserter
.insert(&polished, restore_clipboard, paste_shortcut)
}
#[cfg(not(target_os = "linux"))]
{
log::warn!(
"[coord] original insertion target is not foreground; copied output without paste"
);
if allow_non_tsf_insertion_fallback {
inner.inserter.copy_fallback(&polished)
} else {
InsertStatus::Failed
}
}
}
insert_final_text(
inner,
current_session_id,
&polished,
&prefs,
focus_ready_for_paste,
)
.await
};
restore_prepared_windows_ime_session(inner, current_session_id);
let inserted_chars = polished.chars().count() as u32;
@@ -3938,6 +3997,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
asr_model,
llm_provider,
llm_model,
pipeline_mode: None,
asr_ms: Some(asr_ms),
polish_ms,
};
@@ -4017,6 +4077,377 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
Ok(())
}
/// 多模态(Omni)听写收尾(issue #902):录音 PCM → WAV → omni 一次调用 →
/// 修正规则 → 一次性插入 → 历史。与两段式管线完全隔离:
/// 不复用 ASR 构建/静默重试/流式插入,缺 omni 配置时明确报错、不回退传统配置。
async fn finish_dictation_multimodal(
inner: &Arc<Inner>,
current_session_id: SessionId,
elapsed: u64,
) -> Result<(), String> {
let Some(pcm_consumer) = take_omni_pcm_for_session(inner, current_session_id) else {
restore_prepared_windows_ime_session(inner, current_session_id);
if !finish_cancelled_processing(inner, current_session_id) {
set_phase_idle_if_session_matches(inner, current_session_id);
}
return Ok(());
};
let duration_ms = pcm_consumer.duration_ms();
let wav = pcm_bytes_to_wav(&pcm_consumer.pcm());
// 录音后被取消 → 静默丢弃(与 ASR 完成后的 cancel 检查一致)。
if inner.state.lock().cancelled {
log::info!("[coord] cancel detected after recording (multimodal) — discarding");
restore_prepared_windows_ime_session(inner, current_session_id);
finish_cancelled_processing(inner, current_session_id);
return Ok(());
}
// 提示词装配:风格包提示词 + 词典热词 + 工作语言 + 翻译目标(同一次调用生效,
// 这正是多模态管线解决专有名词误识别的关键);Less Computer 用逐字转写指令。
let prefs = inner.prefs.get();
let pack = match inner
.style_packs
.get_or_default_active(&prefs.active_style_pack_id)
{
Ok(pack) => pack,
Err(error) => {
log::warn!(
"[coord] active style pack unavailable, falling back to builtin light: {error}"
);
crate::types::builtin_style_pack_for_mode(PolishMode::Light)
}
};
let mode = pack.base_mode;
let translation_target = prefs.translation_target_language.trim().to_string();
let translation_active = crate::types::translation_effective(
inner.translation_active.load(Ordering::SeqCst),
&translation_target,
&prefs.working_languages,
);
let voice_agent = inner.state.lock().voice_agent;
let system_prompt = if voice_agent {
"把用户的语音指令逐字转写为文本。不要改写、不要润色、不要补全,只输出转写文本本身。"
.to_string()
} else {
let base =
crate::types::style_pack_prompt(&pack, crate::types::StylePromptKind::DictationAsr);
let hotwords = enabled_phrases(inner);
let mut prompt = base;
if !prefs.working_languages.is_empty() {
prompt.push_str(&format!(
"\n\n# 工作语言\n用户主要在以下语言间工作:{}。",
prefs.working_languages.join("、")
));
}
if !hotwords.is_empty() {
prompt.push_str(&format!(
"\n\n# 词典/热词\n以下专有名词必须严格按给定写法准确识别,不得换成同音错词:{}。",
hotwords.join("、")
));
}
if translation_active {
prompt.push_str(&format!(
"\n\n用户按住了翻译键,需要把识别结果翻译成「{}」。直接输出译文,不要额外解释。",
translation_target
));
}
prompt
};
log::info!(
"[coord] multimodal dictation dispatch session_id={} mode={:?} translation={} voice_agent={} prompt_chars={} audio_ms={}",
current_session_id,
mode,
translation_active,
voice_agent,
system_prompt.chars().count(),
duration_ms
);
let provider = match build_active_omni_provider(prefs.llm_thinking_enabled) {
Ok(provider) => provider,
Err(error) => {
let reason = error.to_string();
let user_msg = format!("多模态模型配置不完整:{reason}");
return fail_dictation_multimodal(inner, current_session_id, elapsed, user_msg, reason);
}
};
let omni_label = provider.call_label();
let call_started = std::time::Instant::now();
let output = match provider.complete(&system_prompt, "", Some(&wav)).await {
Ok(text) => text,
Err(error) => {
let reason = error.to_string();
let user_msg = format!("多模态识别失败:{reason}");
return fail_dictation_multimodal(inner, current_session_id, elapsed, user_msg, reason);
}
};
let omni_ms = call_started.elapsed().as_millis() as u64;
let output = output.trim().to_string();
// 模型返回空 → emptyTranscript 失败历史 + 错误胶囊(保留录音供排查)。
if output.is_empty() {
let session = DictationSession {
id: current_session_id.to_string(),
created_at: Utc::now().to_rfc3339(),
source: crate::types::HistorySource::Voice,
raw_transcript: String::new(),
final_text: String::new(),
mode: prefs.default_mode,
style_pack_id: None,
translation_active: false,
polish_source: None,
app_bundle_id: None,
app_name: None,
insert_status: InsertStatus::Failed,
error_code: Some("emptyTranscript".to_string()),
duration_ms: Some(duration_ms),
dictionary_entry_count: Some(enabled_phrases(inner).len() as u32),
has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)),
asr_provider: None,
asr_model: None,
llm_provider: Some(omni_label.provider.clone()),
llm_model: Some(omni_label.model.clone()),
pipeline_mode: Some("multimodal".to_string()),
asr_ms: None,
polish_ms: Some(omni_ms),
};
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] history append failed: {e}");
}
emit_capsule(
inner,
CapsuleState::Error,
0.0,
elapsed,
Some("多模态模型返回空结果".to_string()),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
{
let now = std::time::Instant::now();
*inner.session_cooldown_until.lock() =
Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS));
}
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err("多模态模型返回空结果".to_string());
}
// Less Computer:转写文本交给 CLI agent,不走插入/历史(agent 流程自己收尾)。
if voice_agent {
return run_voice_agent_transcript(inner, current_session_id, output, elapsed).await;
}
let correction_rules = match inner.correction_rules.list() {
Ok(rules) => rules,
Err(e) => {
log::warn!("[coord] load correction rules failed: {e}; continue without correction");
Vec::new()
}
};
let polished = finalize_polished_text(
output,
translation_active,
false,
mode,
&None,
prefs.chinese_script_preference,
&correction_rules,
false,
);
// 原子化最后一次 cancel 检查 + 转 Inserting(与两段式路径同款 audit HIGH #2 修复)。
let proceed_to_insert = {
let mut state = inner.state.lock();
if state.cancelled {
false
} else {
state.phase = SessionPhase::Inserting;
true
}
};
if !proceed_to_insert {
log::info!(
"[coord] cancel detected before insert (multimodal) — discarding output (chars={})",
polished.chars().count()
);
restore_prepared_windows_ime_session(inner, current_session_id);
finish_cancelled_processing(inner, current_session_id);
return Ok(());
}
let focus_target = inner.state.lock().focus_target;
let focus_ready_for_paste = restore_focus_target_if_possible(focus_target);
let prefs = inner.prefs.get();
let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback;
let windows_insertion_mode = prefs.windows_insertion_mode;
let status = insert_final_text(
inner,
current_session_id,
&polished,
&prefs,
focus_ready_for_paste,
)
.await;
restore_prepared_windows_ime_session(inner, current_session_id);
let inserted_chars = polished.chars().count() as u32;
let total_hits: u64 = match inner.vocab.record_hits(&polished) {
Ok(n) => n,
Err(e) => {
log::error!("[coord] record_hits failed: {e}");
0
}
};
if total_hits > 0 {
if let Some(app) = inner.app.lock().clone() {
let _ = app.emit("vocab:updated", total_hits);
}
}
let error_code = dictation_error_code(
status,
false,
focus_ready_for_paste,
allow_non_tsf_insertion_fallback,
windows_insertion_mode,
)
.map(str::to_string);
let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired");
let prefs_snapshot = inner.prefs.get();
let session = DictationSession {
id: current_session_id.to_string(),
created_at: Utc::now().to_rfc3339(),
source: crate::types::HistorySource::Voice,
raw_transcript: polished.clone(),
final_text: polished.clone(),
mode,
style_pack_id: Some(pack.id.clone()),
translation_active,
polish_source: None,
app_bundle_id: None,
app_name: None,
insert_status: status,
error_code,
duration_ms: Some(duration_ms),
dictionary_entry_count: Some(total_hits.min(u32::MAX as u64) as u32),
has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)),
asr_provider: None,
asr_model: None,
llm_provider: Some(omni_label.provider.clone()),
llm_model: Some(omni_label.model.clone()),
pipeline_mode: Some("multimodal".to_string()),
asr_ms: None,
polish_ms: Some(omni_ms),
};
if let Err(e) = inner.history.append_with_retention(
session,
prefs_snapshot.history_retention_days,
prefs_snapshot.history_max_entries,
) {
log::error!("[coord] history append failed: {e}");
}
if let Err(e) = inner.activity.bump(
&chrono::Local::now().format("%Y-%m-%d").to_string(),
polished.chars().count() as u64,
duration_ms,
) {
log::warn!("[coord] activity bump failed: {e}");
}
if !polished.trim().is_empty() {
if let Some(app) = inner.app.lock().clone() {
let _ = app.emit("remote:result", polished.clone());
}
}
let done_message = if tsf_required_insert_failed {
Some("TSF 未上屏,已禁止非 TSF 兜底".to_string())
} else {
default_done_message(status, false)
};
let session_failed = tsf_required_insert_failed || status == InsertStatus::Failed;
let capsule_state = if session_failed {
CapsuleState::Error
} else {
CapsuleState::Done
};
emit_capsule(
inner,
capsule_state,
0.0,
elapsed,
done_message,
Some(inserted_chars),
);
{
let mut state = inner.state.lock();
state.phase = SessionPhase::Idle;
state.focus_target = None;
}
{
let now = std::time::Instant::now();
*inner.session_cooldown_until.lock() =
Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS));
}
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
Ok(())
}
/// 多模态听写失败收尾:落失败历史(pipeline_mode=multimodal,前端据此隐藏
/// 「重新转录」)→ 错误胶囊 → 恢复窗口/IME → 回 Idle + 冷却。永远返回 Err。
fn fail_dictation_multimodal(
inner: &Arc<Inner>,
session_id: SessionId,
elapsed: u64,
user_msg: String,
err: String,
) -> Result<(), String> {
let prefs = inner.prefs.get();
let front_app = inner.state.lock().front_app.clone();
let mut session = build_transcribe_failed_session(
session_id,
elapsed,
0,
prefs.default_mode,
inner.audio_archive_active.load(Ordering::Relaxed),
front_app.as_deref(),
);
session.pipeline_mode = Some("multimodal".to_string());
if let Err(e) = inner.history.append_with_retention(
session,
prefs.history_retention_days,
prefs.history_max_entries,
) {
log::error!("[coord] transcribeFailed history append failed: {e}");
}
emit_capsule(
inner,
CapsuleState::Error,
0.0,
elapsed,
Some(user_msg),
None,
);
restore_prepared_windows_ime_session(inner, session_id);
inner.state.lock().phase = SessionPhase::Idle;
{
let now = std::time::Instant::now();
*inner.session_cooldown_until.lock() =
Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS));
}
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
Err(err)
}
pub(super) fn dictation_error_code(
status: InsertStatus,
polish_failed: bool,
@@ -4322,6 +4753,7 @@ mod tests {
asr_model: None,
llm_provider: None,
llm_model: None,
pipeline_mode: None,
asr_ms: None,
polish_ms: None,
}
@@ -1,460 +1,494 @@
//! Polish / translate orchestration extracted from `coordinator.rs`
//! (behavior-preserving move).
//!
//! The streaming/one-shot polish entry points and the polish+translate combiner.
//! References parent items via `use super::*;`; `pub(super)` so the parent and
//! sibling submodules (e.g. `dictation`) reach them through `use polish_flow::*;`.
use super::*;
/// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。
/// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。
/// 流式润色的三态结果。让上层(dictation pipeline)能区分「已经流出去了」、
/// 「降级到一次性」和「真失败了走 raw 兜底」三种 case。
pub 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),
}
fn accumulate_llm_elapsed(total_ms: &mut Option<u64>, elapsed_ms: u64) {
*total_ms = Some(total_ms.unwrap_or(0).saturating_add(elapsed_ms));
}
fn record_llm_elapsed(total_ms: &mut Option<u64>, started: std::time::Instant) {
accumulate_llm_elapsed(total_ms, started.elapsed().as_millis() as u64);
}
/// 流式润色入口。在不支持流式的所有 case 都返回 `UnsupportedFallback`,让调用方
/// 透明降级。不修改任何持久化 / 焦点 / 光标状态。
///
/// `on_delta` 每收到一个 SSE chunk 就被调用一次(同步),调用方负责把 chunk 实际
/// 模拟键盘事件落到光标 —— 见 `coordinator/dictation.rs` 的流式分支。
/// `should_cancel` 用户取消时返回 true,立即 break SSE 读循环避免烧 quota。
pub 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)],
llm_call: &mut Option<crate::polish::LlmCallLabel>,
llm_elapsed_ms: &mut Option<u64>,
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;
}
// 过了所有 early-out、即将发起真实调用——此刻才记录调用快照。
*llm_call = Some(provider.call_label());
log::info!(
"[coord] streaming polish START: provider=openai-compatible mode={:?} raw_chars={} prior_turns={}",
mode,
raw.text.chars().count(),
prior_turns.len()
);
let call_started = std::time::Instant::now();
let result = 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;
record_llm_elapsed(llm_elapsed_ms, call_started);
match result {
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)
}
}
}
//! Polish / translate orchestration extracted from `coordinator.rs`
//! (behavior-preserving move).
//!
//! The streaming/one-shot polish entry points and the polish+translate combiner.
//! References parent items via `use super::*;`; `pub(super)` so the parent and
//! sibling submodules (e.g. `dictation`) reach them through `use polish_flow::*;`.
use super::*;
/// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。
/// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。
/// 流式润色的三态结果。让上层(dictation pipeline)能区分「已经流出去了」、
/// 「降级到一次性」和「真失败了走 raw 兜底」三种 case。
pub 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),
}
fn accumulate_llm_elapsed(total_ms: &mut Option<u64>, elapsed_ms: u64) {
*total_ms = Some(total_ms.unwrap_or(0).saturating_add(elapsed_ms));
}
fn record_llm_elapsed(total_ms: &mut Option<u64>, started: std::time::Instant) {
accumulate_llm_elapsed(total_ms, started.elapsed().as_millis() as u64);
}
/// 流式润色入口。在不支持流式的所有 case 都返回 `UnsupportedFallback`,让调用方
/// 透明降级。不修改任何持久化 / 焦点 / 光标状态。
///
/// `on_delta` 每收到一个 SSE chunk 就被调用一次(同步),调用方负责把 chunk 实际
/// 模拟键盘事件落到光标 —— 见 `coordinator/dictation.rs` 的流式分支。
/// `should_cancel` 用户取消时返回 true,立即 break SSE 读循环避免烧 quota。
pub 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)],
llm_call: &mut Option<crate::polish::LlmCallLabel>,
llm_elapsed_ms: &mut Option<u64>,
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;
}
// 过了所有 early-out、即将发起真实调用——此刻才记录调用快照。
*llm_call = Some(provider.call_label());
log::info!(
"[coord] streaming polish START: provider=openai-compatible mode={:?} raw_chars={} prior_turns={}",
mode,
raw.text.chars().count(),
prior_turns.len()
);
let call_started = std::time::Instant::now();
let result = 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;
record_llm_elapsed(llm_elapsed_ms, call_started);
match result {
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(super) 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)],
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)],
llm_call: &mut Option<crate::polish::LlmCallLabel>,
llm_elapsed_ms: &mut Option<u64>,
multimodal: bool,
) -> (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,
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,
llm_call,
llm_elapsed_ms,
multimodal,
)
.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))
}
}
}
.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(super) 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)],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
prior_turns: &[(String, String)],
llm_call: &mut Option<crate::polish::LlmCallLabel>,
llm_elapsed_ms: &mut Option<u64>,
multimodal: bool,
) -> anyhow::Result<String> {
// 多模态(Omni)模式:纯文本管线(选区润色 / 历史重润色)改用 omni 模型当
// 文本 LLM,读取 omni 命名空间凭据,与传统 LLM 配置隔离。
if multimodal {
let provider = super::build_active_omni_provider(llm_thinking_enabled)?;
let label = provider.call_label();
*llm_call = Some(crate::polish::LlmCallLabel {
provider: label.provider,
model: label.model,
});
let mut system_prompt = style_system_prompt.to_string();
if !hotwords.is_empty() {
system_prompt.push_str(&format!(
"\n\n# 词典/热词\n以下专有名词必须严格按给定写法准确识别:{}。",
hotwords.join("、")
));
}
if !working_languages.is_empty() {
system_prompt.push_str(&format!(
"\n\n# 工作语言\n用户主要在以下语言间工作:{}。",
working_languages.join("、")
));
}
let call_started = std::time::Instant::now();
let result = provider.complete(&system_prompt, raw, None).await;
record_llm_elapsed(llm_elapsed_ms, call_started);
return Ok(result?);
}
// 谷歌 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()?;
// 凭据读取成功、即将发起调用——记录构建时快照(preflight 失败走上面的 ? 提前返回,不会记)。
*llm_call = Some(crate::polish::LlmCallLabel {
provider: active_llm.clone(),
model: model.clone(),
});
let provider = GeminiProvider::new(
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
);
let call_started = std::time::Instant::now();
let result = provider
.polish(
raw,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
prior_turns,
)
.await;
record_llm_elapsed(llm_elapsed_ms, call_started);
return Ok(result?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
*llm_call = Some(provider.call_label());
let call_started = std::time::Instant::now();
let result = provider
.polish(
raw,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
prior_turns,
)
.await;
record_llm_elapsed(llm_elapsed_ms, call_started);
Ok(result?)
}
/// 专用翻译(仅翻译、不润色、单轮)。现作为"润色+翻译"合成调用解析失败时的兜底——
/// 模型没按两段格式输出时,退回这里拿一段干净译文,而不是把畸形输出当译文插入。
pub(super) 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>,
llm_call: &mut Option<crate::polish::LlmCallLabel>,
llm_elapsed_ms: &mut Option<u64>,
) -> 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()?;
*llm_call = Some(crate::polish::LlmCallLabel {
provider: active_llm.clone(),
model: model.clone(),
});
let provider = GeminiProvider::new(
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
);
let call_started = std::time::Instant::now();
let result = provider
.translate_to(
raw,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
)
.await;
record_llm_elapsed(llm_elapsed_ms, call_started);
return Ok(result?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
*llm_call = Some(provider.call_label());
let call_started = std::time::Instant::now();
let result = provider
.translate_to(
raw,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
)
.await;
record_llm_elapsed(llm_elapsed_ms, call_started);
Ok(result?)
}
/// "润色+翻译"单次调用的两段哨兵。模型按 `SRC\n源文\nTGT\n译文` 输出,解析器据此切分。
/// 这两个串必须与 build_polish_translate_system_prompt 写给模型的完全一致。
pub(super) const POLISH_TRANSLATE_SRC_MARKER: &str = "[[OPENLESS_POLISHED_SOURCE]]";
pub(super) const POLISH_TRANSLATE_TGT_MARKER: &str = "[[OPENLESS_TRANSLATION]]";
/// 合成"先润色源文、再翻译"的系统提示词:在原翻译 prompt 之上追加"额外输出润色后源文"
/// 与严格两段格式(覆盖原 prompt 末尾的"只输出译文")。译文仍是要插入用户光标的主产物,
/// 故完整保留原翻译规则;润色后的源文只作对话上下文用,轻量清理即可。
pub(super) 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(super) 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)]
// 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()?;
// 凭据读取成功、即将发起调用——记录构建时快照(preflight 失败走上面的 ? 提前返回,不会记)。
*llm_call = Some(crate::polish::LlmCallLabel {
provider: active_llm.clone(),
model: model.clone(),
});
let provider = GeminiProvider::new(
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
);
let call_started = std::time::Instant::now();
let result = provider
.polish(
raw,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
prior_turns,
)
.await;
record_llm_elapsed(llm_elapsed_ms, call_started);
return Ok(result?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
*llm_call = Some(provider.call_label());
let call_started = std::time::Instant::now();
let result = provider
.polish(
raw,
mode,
hotwords,
style_system_prompt,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
prior_turns,
)
.await;
record_llm_elapsed(llm_elapsed_ms, call_started);
Ok(result?)
}
/// 专用翻译(仅翻译、不润色、单轮)。现作为"润色+翻译"合成调用解析失败时的兜底——
/// 模型没按两段格式输出时,退回这里拿一段干净译文,而不是把畸形输出当译文插入。
pub(super) 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>,
llm_call: &mut Option<crate::polish::LlmCallLabel>,
llm_elapsed_ms: &mut Option<u64>,
) -> 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()?;
*llm_call = Some(crate::polish::LlmCallLabel {
provider: active_llm.clone(),
model: model.clone(),
});
let provider = GeminiProvider::new(
GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled),
);
let call_started = std::time::Instant::now();
let result = provider
.translate_to(
raw,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
)
.await;
record_llm_elapsed(llm_elapsed_ms, call_started);
return Ok(result?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
*llm_call = Some(provider.call_label());
let call_started = std::time::Instant::now();
let result = provider
.translate_to(
raw,
target_language,
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
)
.await;
record_llm_elapsed(llm_elapsed_ms, call_started);
Ok(result?)
}
/// "润色+翻译"单次调用的两段哨兵。模型按 `SRC\n源文\nTGT\n译文` 输出,解析器据此切分。
/// 这两个串必须与 build_polish_translate_system_prompt 写给模型的完全一致。
pub(super) const POLISH_TRANSLATE_SRC_MARKER: &str = "[[OPENLESS_POLISHED_SOURCE]]";
pub(super) const POLISH_TRANSLATE_TGT_MARKER: &str = "[[OPENLESS_TRANSLATION]]";
/// 合成"先润色源文、再翻译"的系统提示词:在原翻译 prompt 之上追加"额外输出润色后源文"
/// 与严格两段格式(覆盖原 prompt 末尾的"只输出译文")。译文仍是要插入用户光标的主产物,
/// 故完整保留原翻译规则;润色后的源文只作对话上下文用,轻量清理即可。
pub(super) 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(super) 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(super) 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)],
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)],
llm_call: &mut Option<crate::polish::LlmCallLabel>,
llm_elapsed_ms: &mut Option<u64>,
multimodal: bool,
) -> (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,
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,
llm_call,
llm_elapsed_ms,
multimodal,
)
.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,
llm_call,
llm_elapsed_ms,
)
.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))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// PR #826 review:llm_call 快照只在真的构建 provider / 发起调用时填充。
/// Raw 直通在读取任何凭据之前就 early-return,llm_call 必须保持 None——
/// 调用方据此不落 llm_* / polish_ms。
#[tokio::test]
async fn raw_passthrough_leaves_llm_call_snapshot_empty() {
let raw = RawTranscript {
text: "原样输出".to_string(),
duration_ms: 800,
};
let mut llm_call: Option<crate::polish::LlmCallLabel> = None;
let mut llm_elapsed_ms = None;
// 直通判定:style prompt 等于内置 raw 提示词 → raw_mode_uses_llm 为 false。
let builtin_raw_prompt = crate::types::StyleSystemPrompts::default().raw;
let (out, err) = polish_or_passthrough(
&raw,
PolishMode::Raw,
&[],
&builtin_raw_prompt,
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
false,
None,
&[],
.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,
llm_call,
llm_elapsed_ms,
)
.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))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// PR #826 review:llm_call 快照只在真的构建 provider / 发起调用时填充。
/// Raw 直通在读取任何凭据之前就 early-return,llm_call 必须保持 None——
/// 调用方据此不落 llm_* / polish_ms。
#[tokio::test]
async fn raw_passthrough_leaves_llm_call_snapshot_empty() {
let raw = RawTranscript {
text: "原样输出".to_string(),
duration_ms: 800,
};
let mut llm_call: Option<crate::polish::LlmCallLabel> = None;
let mut llm_elapsed_ms = None;
// 直通判定:style prompt 等于内置 raw 提示词 → raw_mode_uses_llm 为 false。
let builtin_raw_prompt = crate::types::StyleSystemPrompts::default().raw;
let (out, err) = polish_or_passthrough(
&raw,
PolishMode::Raw,
&[],
&builtin_raw_prompt,
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
false,
None,
&[],
&mut llm_call,
&mut llm_elapsed_ms,
false,
)
.await;
assert_eq!(out, "原样输出");
assert_eq!(err, None);
assert_eq!(llm_call, None, "Raw 直通不得产生 LLM 调用快照");
assert_eq!(llm_elapsed_ms, None, "Raw 直通不得产生 LLM 调用耗时");
}
#[test]
fn llm_elapsed_accumulates_only_provider_call_durations() {
let mut elapsed_ms = None;
accumulate_llm_elapsed(&mut elapsed_ms, 120);
accumulate_llm_elapsed(&mut elapsed_ms, 80);
assert_eq!(elapsed_ms, Some(200));
}
}
assert_eq!(out, "原样输出");
assert_eq!(err, None);
assert_eq!(llm_call, None, "Raw 直通不得产生 LLM 调用快照");
assert_eq!(llm_elapsed_ms, None, "Raw 直通不得产生 LLM 调用耗时");
}
#[test]
fn llm_elapsed_accumulates_only_provider_call_durations() {
let mut elapsed_ms = None;
accumulate_llm_elapsed(&mut elapsed_ms, 120);
accumulate_llm_elapsed(&mut elapsed_ms, 80);
assert_eq!(elapsed_ms, Some(200));
}
}
@@ -6,6 +6,7 @@
//! References parent items via `use super::*;`; `pub(super)` so the parent and
//! sibling submodules (e.g. `qa`) reach them through `use qa_session::*;`.
use super::resources::*;
use super::*;
fn compose_qa_user_content(selection_text: &str, question: &str) -> String {
@@ -206,6 +207,7 @@ pub(super) async fn finalize_dictation_as_qa_question(inner: &Arc<Inner>) -> Res
raw.text.trim().to_string(),
raw.duration_ms,
session_id,
None,
)
.await
}
@@ -268,7 +270,7 @@ pub(super) async fn submit_qa_text_question(
}
}
answer_qa_question_text(inner, question, 0, session_id).await
answer_qa_question_text(inner, question, 0, session_id, None).await
}
pub(super) async fn take_current_dictation_transcript_for_qa(
@@ -293,6 +295,26 @@ pub(super) async fn take_current_dictation_transcript_for_qa(
release_recording_mute(inner, "dictation");
}
// 多模态(Omni)模式:dictation 会话没有 ASR,录音 PCM 直接交给 QA 一步回答。
if pipeline_multimodal_enabled(&inner.prefs.get()) {
let Some(pcm_consumer) = take_omni_pcm_for_session(inner, current_session_id) else {
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(None);
};
let duration_ms = pcm_consumer.duration_ms();
let wav = pcm_bytes_to_wav(&pcm_consumer.pcm());
restore_prepared_windows_ime_session(inner, current_session_id);
{
let mut state = inner.state.lock();
state.phase = SessionPhase::Idle;
state.focus_target = None;
}
answer_qa_question_text(inner, String::new(), duration_ms, qa_session_id, Some(wav))
.await?;
return Ok(None);
}
let Some(asr) = take_asr_for_session(inner, current_session_id) else {
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
@@ -605,6 +627,7 @@ pub(super) async fn answer_qa_question_text(
question: String,
duration_ms: u64,
session_id: SessionId,
audio_wav: Option<Vec<u8>>,
) -> Result<(), String> {
{
let state = inner.qa_state.lock();
@@ -613,20 +636,27 @@ pub(super) async fn answer_qa_question_text(
return Ok(());
}
}
if question.trim().is_empty() {
if question.trim().is_empty() && audio_wav.is_none() {
if qa_turn_can_continue(&inner.qa_state.lock(), session_id) {
finish_qa_idle_silently_if_current(inner, session_id);
}
return Ok(());
}
// 多模态(Omni)模式:问题本体在音频里,文本槽位用占位符,便于模型理解
// 「这是语音提问」并让 history 的 raw_transcript 不为空。
let question_for_message = if audio_wav.is_some() {
"(语音问题)".to_string()
} else {
question.clone()
};
{
let mut state = inner.qa_state.lock();
if !qa_turn_can_continue(&state, session_id) {
log::info!("[coord] QA turn invalidated before answer dispatch");
return Ok(());
}
let user_message = qa_user_message_from_state(&state, &question);
let user_message = qa_user_message_from_state(&state, &question_for_message);
state.messages.push(user_message);
}
@@ -702,6 +732,8 @@ pub(super) async fn answer_qa_question_text(
output_language_preference,
llm_thinking_enabled,
front_app.as_deref(),
audio_wav,
pipeline_multimodal_enabled(&inner.prefs.get()),
on_delta,
should_cancel,
)
@@ -773,6 +805,7 @@ pub(super) async fn answer_qa_question_text(
asr_model: None,
llm_provider: None,
llm_model: None,
pipeline_mode: None,
asr_ms: None,
polish_ms: None,
};
@@ -844,12 +877,40 @@ pub(super) async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
// 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_if_current(inner, session_id, format!("缺少 ASR 凭据:{message}"));
return Err(message);
}
// 多模态(Omni)模式:不构建 ASR,录音 PCM 进缓冲器,松键后一步出答案。
let multimodal = pipeline_multimodal_enabled(&inner.prefs.get());
let qa_asr: Option<QaAsrStart> = if multimodal {
if let Err(message) = ensure_omni_credentials() {
log::warn!("[coord] QA: omni credential gate failed: {message}");
finish_qa_with_error_if_current(
inner,
session_id,
format!("缺少多模态模型凭据:{message}"),
);
return Err(message);
}
None
} else {
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_if_current(inner, session_id, format!("缺少 ASR 凭据:{message}"));
return Err(message);
}
// QA 历史暂不落模型归因字段,构建时快照就地丢弃(dictation / 重转录路径在用)。
match build_qa_asr_start(inner, &active_asr).await {
Ok((qa_asr, _asr_call_label)) => Some(qa_asr),
Err(message) => {
log::error!("[coord] QA active ASR init failed: {message}");
finish_qa_with_error_if_current(
inner,
session_id,
format!("ASR 初始化失败: {message}"),
);
return Err(message);
}
}
};
if let Err(message) = ensure_microphone_permission(inner) {
log::warn!("[coord] QA: microphone permission gate failed: {message}");
@@ -857,28 +918,24 @@ pub(super) async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
return Err(message);
}
// QA 历史暂不落模型归因字段,构建时快照就地丢弃(dictation / 重转录路径在用)。
let qa_asr = match build_qa_asr_start(inner, &active_asr).await {
Ok((qa_asr, _asr_call_label)) => qa_asr,
Err(message) => {
log::error!("[coord] QA active ASR init failed: {message}");
finish_qa_with_error_if_current(
inner,
session_id,
format!("ASR 初始化失败: {message}"),
);
return Err(message);
}
};
let consumer = {
let consumer: Arc<dyn crate::recorder::AudioConsumer> = {
let state = inner.qa_state.lock();
if !qa_recording_can_continue(&state, session_id) {
log::info!("[coord] QA recording invalidated during ASR initialization");
return Ok(());
}
let consumer = qa_asr.recorder_consumer();
store_qa_asr_for_session(inner, session_id, qa_asr.active_asr());
consumer
match &qa_asr {
Some(start) => {
let consumer = start.recorder_consumer();
store_qa_asr_for_session(inner, session_id, start.active_asr());
consumer
}
None => {
let consumer = PcmBufferConsumer::new();
store_qa_omni_pcm_for_session(inner, session_id, Arc::clone(&consumer));
consumer
}
}
};
// QA recorder 不需要 RMS 节流到胶囊;前端 QA 浮窗有自己的电平视图,
@@ -963,18 +1020,20 @@ pub(super) async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
}
}
if let Err(e) = qa_asr.open_streaming_session().await {
if !qa_recording_can_continue(&inner.qa_state.lock(), session_id) {
log::info!("[coord] discarded ASR error from invalidated QA session");
if let Some(start) = &qa_asr {
if let Err(e) = start.open_streaming_session().await {
if !qa_recording_can_continue(&inner.qa_state.lock(), session_id) {
log::info!("[coord] discarded ASR error from invalidated QA session");
stop_qa_recorder_for_session(inner, session_id);
cancel_qa_asr_for_session(inner, session_id);
return Ok(());
}
log::error!("[coord] QA: open ASR session failed: {e}");
stop_qa_recorder_for_session(inner, session_id);
cancel_qa_asr_for_session(inner, session_id);
return Ok(());
finish_qa_with_error_if_current(inner, session_id, format!("ASR 连接失败: {e}"));
return Err(e);
}
log::error!("[coord] QA: open ASR session failed: {e}");
stop_qa_recorder_for_session(inner, session_id);
cancel_qa_asr_for_session(inner, session_id);
finish_qa_with_error_if_current(inner, session_id, format!("ASR 连接失败: {e}"));
return Err(e);
}
// cancel race:在 await 期间用户可能 dismiss 了浮窗。
@@ -1019,6 +1078,18 @@ pub(super) async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
stop_qa_recorder_for_session(inner, session_id);
// 多模态(Omni)模式:不走 ASR 转写,录音 PCM 直接编码 WAV,一步出答案。
if pipeline_multimodal_enabled(&inner.prefs.get()) {
let Some(pcm_consumer) = take_qa_omni_pcm_for_session(inner, session_id) else {
reset_qa_processing_if_current(&mut inner.qa_state.lock(), session_id);
return Ok(());
};
let duration_ms = pcm_consumer.duration_ms();
let wav = pcm_bytes_to_wav(&pcm_consumer.pcm());
return answer_qa_question_text(inner, String::new(), duration_ms, session_id, Some(wav))
.await;
}
let asr = match take_qa_asr_for_session(inner, session_id) {
Some(a) => a,
None => {
@@ -1396,7 +1467,7 @@ pub(super) async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
return Ok(());
}
answer_qa_question_text(inner, question, raw.duration_ms, session_id).await
answer_qa_question_text(inner, question, raw.duration_ms, session_id, None).await
}
/// 静默收尾:发 idle 事件给前端,phase 复位。**不关浮窗**(v2:浮窗只在用户
@@ -1473,6 +1544,8 @@ pub(super) async fn answer_chat_dispatch<F, C>(
output_language_preference: OutputLanguagePreference,
llm_thinking_enabled: bool,
front_app: Option<&str>,
audio_wav: Option<Vec<u8>>,
multimodal: bool,
on_delta: F,
should_cancel: C,
) -> anyhow::Result<String>
@@ -1480,6 +1553,50 @@ where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
// 多模态(Omni)模式:音频 + 选区/历史上下文一次调用出答案。
// OpenAI 兼容通道逐字流式(answer_delta);Gemini 通道一次性返回。
if let Some(wav) = audio_wav {
let provider = build_active_omni_provider(llm_thinking_enabled)?;
let system_prompt = crate::polish::compose_qa_system_prompt(
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
);
let user_text = messages
.iter()
.map(|message| format!("{}: {}", message.role, message.content))
.collect::<Vec<_>>()
.join("\n\n");
return Ok(provider
.complete_streaming(
&system_prompt,
&user_text,
Some(&wav),
on_delta,
should_cancel,
)
.await?);
}
// 多模态模式下键盘输入的纯文本问题:omni 模型当文本 LLM 用(无音频 part)。
if multimodal {
let provider = build_active_omni_provider(llm_thinking_enabled)?;
let system_prompt = crate::polish::compose_qa_system_prompt(
working_languages,
chinese_script_preference,
output_language_preference,
front_app,
);
let user_text = messages
.iter()
.map(|message| format!("{}: {}", message.role, message.content))
.collect::<Vec<_>>()
.join("\n\n");
return Ok(provider
.complete_streaming(&system_prompt, &user_text, None, on_delta, should_cancel)
.await?);
}
// 见 polish_text 顶部注释——同样的 Gemini / OpenAI-compatible 路由逻辑,
// QA 流式回答走 Gemini 原生 :streamGenerateContent?alt=sse。
let active_llm = CredentialsVault::get_active_llm();
@@ -67,6 +67,74 @@ pub(super) fn store_asr_for_session(
*inner.asr_label.lock() = Some(SessionResource::new(session_id, label));
}
/// 多模态模式下替代 ASR 消费录音 PCM 的简单缓冲器:录音期间把 16k/mono/i16 PCM
/// 原样攒进 Vec,松键后由 omni 通道编码成 WAV 一次调用。与 ActiveAsr 完全解耦,
/// 不会误触发任何 ASR 协议/凭据逻辑。
#[derive(Default)]
pub(super) struct PcmBufferConsumer {
buffer: parking_lot::Mutex<Vec<u8>>,
}
impl PcmBufferConsumer {
pub(super) fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub(super) fn pcm(&self) -> Vec<u8> {
self.buffer.lock().clone()
}
pub(super) fn duration_ms(&self) -> u64 {
crate::asr::pcm::pcm_duration_ms(&self.buffer.lock())
}
}
impl crate::recorder::AudioConsumer for PcmBufferConsumer {
fn consume_pcm_chunk(&self, pcm: &[u8]) {
self.buffer.lock().extend_from_slice(pcm);
}
}
/// 把 16k/mono/i16 原始 PCM 字节编码成 WAV 文件字节(omni 通道统一入口)。
/// 与各 ASR provider 内联的 `chunks_exact(2)` 转换等价,收口成共享实现。
pub(super) fn pcm_bytes_to_wav(pcm: &[u8]) -> Vec<u8> {
let samples: Vec<i16> = pcm
.chunks_exact(2)
.map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
.collect();
crate::asr::wav::encode_wav_16k_mono(&samples)
}
pub(super) fn store_omni_pcm_for_session(
inner: &Arc<Inner>,
session_id: SessionId,
consumer: Arc<PcmBufferConsumer>,
) {
*inner.omni_pcm.lock() = Some(SessionResource::new(session_id, consumer));
}
pub(super) fn take_omni_pcm_for_session(
inner: &Arc<Inner>,
session_id: SessionId,
) -> Option<Arc<PcmBufferConsumer>> {
take_session_resource(&mut inner.omni_pcm.lock(), session_id)
}
pub(super) fn store_qa_omni_pcm_for_session(
inner: &Arc<Inner>,
session_id: SessionId,
consumer: Arc<PcmBufferConsumer>,
) {
*inner.qa_omni_pcm.lock() = Some(SessionResource::new(session_id, consumer));
}
pub(super) fn take_qa_omni_pcm_for_session(
inner: &Arc<Inner>,
session_id: SessionId,
) -> Option<Arc<PcmBufferConsumer>> {
take_session_resource(&mut inner.qa_omni_pcm.lock(), session_id)
}
pub(super) fn take_asr_for_session(inner: &Arc<Inner>, session_id: SessionId) -> Option<ActiveAsr> {
let mut slot = inner.asr.lock();
take_session_resource(&mut slot, session_id)
@@ -10,8 +10,9 @@ use std::sync::{
};
use super::{
emit_selection_polish_capsule, enabled_phrases, polish_text, raw_style_pack_uses_llm,
schedule_selection_polish_capsule_idle, Coordinator, Inner, CAPSULE_AUTO_HIDE_DELAY_MS,
emit_selection_polish_capsule, enabled_phrases, pipeline_multimodal_enabled, polish_text,
raw_style_pack_uses_llm, schedule_selection_polish_capsule_idle, Coordinator, Inner,
CAPSULE_AUTO_HIDE_DELAY_MS,
};
use chrono::Utc;
use serde::Serialize;
@@ -198,10 +199,8 @@ pub(super) async fn run_selection_polish(inner: &Arc<Inner>) -> Result<(), Strin
// 与 `repolish` 同样读取当前 style pack、词表和语言偏好;但前台上下文必须
// 来自选区捕获时的源应用,避免在 provider 等待期间重新读取/校验目标窗口。
// 选区润色只读取风格包的书面文本 Prompt;旧包缺少该字段时回退为安全默认。
let selection_style_prompt = crate::types::style_pack_prompt(
&pack,
crate::types::StylePromptKind::Selection,
);
let selection_style_prompt =
crate::types::style_pack_prompt(&pack, crate::types::StylePromptKind::Selection);
log::info!(
"[style-pack] runtime dispatch scope=selection pack={} kind={:?} mode={:?} prompt_chars={}",
pack.id,
@@ -225,6 +224,7 @@ pub(super) async fn run_selection_polish(inner: &Arc<Inner>) -> Result<(), Strin
&[],
&mut llm_call,
&mut polish_ms,
pipeline_multimodal_enabled(&inner.prefs.get()),
)
.await
.map_err(|error| error.to_string())
@@ -296,7 +296,10 @@ pub(super) async fn run_selection_polish(inner: &Arc<Inner>) -> Result<(), Strin
finish_selection_polish_capsule(
inner,
CapsuleState::Done,
selection_polish_success_message(InsertStatus::Inserted, prefs.selection_polish_output_mode),
selection_polish_success_message(
InsertStatus::Inserted,
prefs.selection_polish_output_mode,
),
);
return Ok(());
}
@@ -341,6 +344,7 @@ pub(super) async fn run_selection_polish(inner: &Arc<Inner>) -> Result<(), Strin
asr_model: None,
llm_provider,
llm_model,
pipeline_mode: None,
asr_ms: None,
polish_ms,
};
@@ -464,6 +468,7 @@ impl Coordinator {
asr_model: None,
llm_provider: preview.llm_provider,
llm_model: preview.llm_model,
pipeline_mode: None,
asr_ms: None,
polish_ms: preview.polish_ms,
};
+33 -36
View File
@@ -49,6 +49,7 @@ mod llm_gemini;
#[cfg(mobile)]
mod mobile_runtime;
mod net;
mod omni;
mod permissions;
mod persistence;
mod polish;
@@ -68,14 +69,14 @@ mod selection;
mod selection;
#[cfg(not(mobile))]
mod shortcut_binding;
#[cfg(mobile)]
#[path = "mobile_stubs/shortcut_binding.rs"]
mod shortcut_binding;
#[cfg(not(mobile))]
mod side_aware_combo;
#[cfg(mobile)]
#[path = "mobile_stubs/side_aware_combo.rs"]
mod side_aware_combo;
#[cfg(mobile)]
#[path = "mobile_stubs/shortcut_binding.rs"]
mod shortcut_binding;
mod types;
#[cfg(not(mobile))]
mod unicode_keystroke;
@@ -247,6 +248,7 @@ macro_rules! app_invoke_handler_desktop {
commands::read_credential,
commands::set_active_asr_provider,
commands::set_active_llm_provider,
commands::set_active_omni_provider,
commands::get_qa_hotkey_label,
commands::set_qa_hotkey,
commands::set_selection_polish_hotkey,
@@ -357,6 +359,7 @@ macro_rules! app_invoke_handler_mobile {
$crate::commands::read_credential,
$crate::commands::set_active_asr_provider,
$crate::commands::set_active_llm_provider,
$crate::commands::set_active_omni_provider,
$crate::commands::validate_provider_credentials,
$crate::commands::list_provider_models,
$crate::commands::list_history,
@@ -917,11 +920,7 @@ fn build_microphone_tray_menu<M: Manager<tauri::Wry>>(
// CoreAudio device enumeration can block inside AudioUnitSetProperty while AppKit is
// finishing launch. Tray menus must be built on the main thread, so only consume the
// cache here; the watcher below owns every potentially blocking enumeration.
let devices = app
.state::<TrayMicrophoneDeviceCache>()
.0
.lock()
.clone();
let devices = app.state::<TrayMicrophoneDeviceCache>().0.lock().clone();
let selected_available =
selected.trim().is_empty() || devices.iter().any(|device| device.name == selected);
@@ -1066,8 +1065,10 @@ fn start_tray_microphone_watcher(app: AppHandle) {
// Linux 无原生路径,返回 false,纯靠下面的慢速兜底。
// 注册失败(OSStatus≠0 / RegisterEndpoint Err)只 warn,不 panic——兜底轮询保证
// 三平台都「永远能检测到设备」。
let native_registered =
device_watch::spawn_native_watcher(app.clone(), make_microphone_change_handler(app.clone()));
let native_registered = device_watch::spawn_native_watcher(
app.clone(),
make_microphone_change_handler(app.clone()),
);
if native_registered {
log::info!("[tray] OS native microphone device watcher registered");
} else {
@@ -1185,12 +1186,7 @@ fn apply_windows_caption_theme<R: Runtime>(window: &tauri::WebviewWindow<R>, dar
&immersive_dark,
"immersive dark mode",
);
set_dwm_window_attribute(
hwnd,
DWMWA_CAPTION_COLOR,
&caption_color,
"caption color",
);
set_dwm_window_attribute(hwnd, DWMWA_CAPTION_COLOR, &caption_color, "caption color");
set_dwm_window_attribute(hwnd, DWMWA_TEXT_COLOR, &text_color, "text color");
set_dwm_window_attribute(hwnd, DWMWA_BORDER_COLOR, &border_color, "border color");
}
@@ -1676,10 +1672,7 @@ fn bottom_visual_position(
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn frame_contains_point(frame: LogicalMonitorFrame, x: f64, y: f64) -> bool {
x >= frame.x
&& x < frame.x + frame.width
&& y >= frame.y
&& y < frame.y + frame.height
x >= frame.x && x < frame.x + frame.width && y >= frame.y && y < frame.y + frame.height
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
@@ -1888,11 +1881,8 @@ mod macos_capsule_ax {
unsafe fn cfstring_from_static(bytes_with_nul: &[u8]) -> Option<CFStringRef> {
let cstr = CStr::from_bytes_with_nul(bytes_with_nul).ok()?;
let s = CFStringCreateWithCString(
std::ptr::null(),
cstr.as_ptr(),
K_CF_STRING_ENCODING_UTF8,
);
let s =
CFStringCreateWithCString(std::ptr::null(), cstr.as_ptr(), K_CF_STRING_ENCODING_UTF8);
if s.is_null() {
None
} else {
@@ -2209,7 +2199,10 @@ fn make_chat_window_panel_macos<R: tauri::Runtime>(window: &tauri::WebviewWindow
/// 解法是把 NSWindow 的 `movableByWindowBackground` 打开——这条路径不依赖窗口是否成为
/// key window,跟 Spotlight / Raycast 的浮窗是同一手法。设一次就够,整个生命周期保持。
#[cfg(target_os = "macos")]
fn make_chat_window_draggable_macos<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>, tag: &str) {
fn make_chat_window_draggable_macos<R: tauri::Runtime>(
window: &tauri::WebviewWindow<R>,
tag: &str,
) {
use objc2::msg_send;
use objc2::runtime::{AnyObject, Bool};
let Ok(handle) = window.ns_window() else {
@@ -2250,8 +2243,9 @@ fn ensure_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) -> Option<tauri::Webv
if let Some(w) = app.get_webview_window("qa") {
return Some(w);
}
let built = WebviewWindowBuilder::new(app, "qa", WebviewUrl::App("index.html?window=qa".into()))
.title("OpenLess QA")
let built =
WebviewWindowBuilder::new(app, "qa", WebviewUrl::App("index.html?window=qa".into()))
.title("OpenLess QA")
.inner_size(QA_WINDOW_WIDTH, QA_WINDOW_HEIGHT)
.decorations(false)
.transparent(true)
@@ -2295,7 +2289,9 @@ fn ensure_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) -> Option<tauri::Webv
/// 懒创建 Less Computer 浮窗(macOS only)。配置与原 tauri.conf 的 less-computer 块一致。
#[cfg(target_os = "macos")]
fn ensure_less_computer_window<R: tauri::Runtime>(app: &AppHandle<R>) -> Option<tauri::WebviewWindow<R>> {
fn ensure_less_computer_window<R: tauri::Runtime>(
app: &AppHandle<R>,
) -> Option<tauri::WebviewWindow<R>> {
if let Some(w) = app.get_webview_window("less-computer") {
return Some(w);
}
@@ -2446,7 +2442,11 @@ pub(crate) fn show_selection_polish_preview<R: tauri::Runtime>(app: &AppHandle<R
if let Err(error) = window.set_focus() {
log::warn!("[selection-polish] focus preview failed: {error}");
}
let _ = app.emit_to("selection-polish-preview", "selection-polish-preview:shown", ());
let _ = app.emit_to(
"selection-polish-preview",
"selection-polish-preview:shown",
(),
);
}
#[cfg(any(target_os = "android", target_os = "ios"))]
@@ -2882,8 +2882,8 @@ fn capsule_height_for_qa() -> f64 {
mod tests {
use super::{
bottom_center_position, bottom_visual_position, capsule_height_for_qa,
capsule_visual_height, capsule_window_bounds, clamp_to_monitor, logical_monitor_frame,
frame_contains_point, frame_distance_to_point_squared, parse_tray_polish_mode_id,
capsule_visual_height, capsule_window_bounds, clamp_to_monitor, frame_contains_point,
frame_distance_to_point_squared, logical_monitor_frame, parse_tray_polish_mode_id,
rotate_log_if_too_large, tray_polish_mode_menu_entries, tray_style_menu_enabled,
LogicalMonitorFrame, LOG_ROTATE_LIMIT_BYTES,
};
@@ -3007,10 +3007,7 @@ mod tests {
assert_eq!(frame_distance_to_point_squared(frame, 100.0, -100.0), 0.0);
assert_eq!(frame_distance_to_point_squared(frame, 100.0, 20.0), 400.0);
assert_eq!(
frame_distance_to_point_squared(frame, -10.0, -910.0),
200.0
);
assert_eq!(frame_distance_to_point_squared(frame, -10.0, -910.0), 200.0);
}
#[test]
@@ -15,6 +15,7 @@
use std::time::Duration;
use base64::Engine;
use serde_json::{json, Value};
use crate::polish::{
@@ -158,6 +159,33 @@ impl GeminiProvider {
Ok(clean_polish_output(&raw))
}
/// 多模态(Omni)识别管线(issue #902)的 Gemini 通道:音频 + 提示词一次调用。
/// `wav_bytes` 为 `Some` 时以 `inlineData(audio/wav)` 追加到 user parts(已是
/// 编码好的 WAV 文件字节,PCM→WAV 的转换由 omni 层统一完成);
/// `None` 时退化为纯文本调用(选区润色 / 历史重润色等文本管线复用同一通道,
/// 读取的是 omni 命名空间的凭据,与传统 LLM 配置隔离)。
pub(crate) async fn complete_omni(
&self,
system_prompt: &str,
user_text: &str,
wav_bytes: Option<&[u8]>,
) -> Result<String, LLMError> {
let contents = omni_gemini_contents(user_text, wav_bytes);
let body = self.build_generate_body(system_prompt, contents);
let url = generate_content_url(&self.config.base_url, &self.config.model);
log::info!(
"[omni] POST {} provider=gemini model={} audio={}",
crate::net::sanitized_url_for_logs(&url),
self.config.model,
wav_bytes.is_some()
);
let body_text = self.send_unary(&url, &body).await?;
let raw = extract_assistant_content(&body_text)?;
Ok(clean_polish_output(&raw))
}
/// 划词语音问答的流式回答。Gemini 原生 SSE: `:streamGenerateContent?alt=sse`,
/// 每个 `data: {...}` 帧里 `candidates[0].content.parts[0].text` 是 delta;
/// 流结束没有 `[DONE]` sentinel,stream 自然终止。
@@ -429,6 +457,22 @@ fn build_polish_history_contents(
contents
}
/// Gemini 多模态调用的一轮 user contents:文本 part 恒在首位,音频 part 可选。
/// `wav_bytes` 是编码好的 WAV 文件字节,base64 后经 `inlineData(audio/wav)` 下发。
fn omni_gemini_contents(user_text: &str, wav_bytes: Option<&[u8]>) -> Vec<Value> {
let mut parts = vec![json!({ "text": user_text })];
if let Some(wav) = wav_bytes {
let data = base64::engine::general_purpose::STANDARD.encode(wav);
parts.push(json!({
"inlineData": {
"mimeType": "audio/wav",
"data": data,
}
}));
}
vec![json!({ "role": "user", "parts": parts })]
}
/// QA chat messages → Gemini contents:assistant role 重命名为 model。
/// QaChatMessage.role 在 polish.rs OpenAI 路径里是 `"user" | "assistant"`;
/// 这里把 `assistant` 翻成 Gemini 的 `model`,其它原样保留。
+481
View File
@@ -0,0 +1,481 @@
//! 多模态(Omni)识别管线(issue #902)的模型通道。
//!
//! 与 `polish.rs` 的 LLM 客户端不同:这里接收「系统提示词 + 用户文本 + 可选音频」,
//! 让模型一步基于音频与词典/提示词直接输出最终文本,替代「ASR 转写 + LLM 润色」
//! 两段式管线。凭据读取独立 `omni` 命名空间,与 asr/llm 配置完全隔离。
//!
//! 通道:
//! - OpenAI 兼容 chat completions:user content 的 `input_audio` part 携带 base64 WAV;
//! - Gemini 原生 generateContent:`inlineData(audio/wav)` part(复用 `llm_gemini.rs`)。
use std::collections::HashMap;
use base64::Engine;
use serde_json::{json, Value};
use crate::polish::{
append_utf8_sse_chunk, apply_openai_compatible_thinking_control, chat_completions_url,
extract_assistant_content, finish_utf8_sse_chunks, http_client_builder,
openai_model_is_gpt5_family, safe_str_slice, send_with_transient_retry, LLMError,
};
pub const OMNI_GEMINI_PROVIDER_ID: &str = "gemini";
/// Omni 请求默认超时(秒)。比普通文本润色长:base64 WAV 上传 + 音频模型生成。
const OMNI_DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 90;
const BODY_PREVIEW_LIMIT: usize = 200;
#[derive(Clone, Debug)]
pub struct OmniConfig {
pub provider_id: String,
pub base_url: String,
pub api_key: String,
pub model: String,
pub extra_headers: HashMap<String, String>,
pub temperature: Option<f32>,
pub thinking_enabled: bool,
}
impl OmniConfig {
pub fn is_gemini(&self) -> bool {
self.provider_id.trim() == OMNI_GEMINI_PROVIDER_ID
|| self.base_url.contains("generativelanguage.googleapis.com")
}
}
/// 一次 Omni 调用的构建时快照(provider id + model),落历史归因用。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OmniCallLabel {
pub provider: String,
pub model: String,
}
/// OpenAI 兼容 chat completions 通道(`input_audio` 音频 part)。
pub struct OpenAICompatibleOmni {
config: OmniConfig,
client: reqwest::Client,
}
impl OpenAICompatibleOmni {
pub fn new(config: OmniConfig) -> Self {
// 与 OpenAICompatibleLLMProvider 同款:按 (超时, 是否绕过代理) 缓存连接池,
// 跨句子复用 TLS 握手。代理开关切换时 net 缓存会清空重建。
let timeout = OMNI_DEFAULT_REQUEST_TIMEOUT_SECS;
let no_proxy =
crate::net::should_bypass_proxy(&config.base_url, crate::net::use_system_proxy());
let base_url = config.base_url.clone();
let client = crate::net::cached_client((timeout, no_proxy), || {
http_client_builder(&base_url, timeout)
.build()
.unwrap_or_else(|_| reqwest::Client::new())
});
Self { config, client }
}
fn omni_body(&self, stream: bool, messages: Vec<Value>) -> Value {
let mut body = json!({
"model": self.config.model,
"stream": stream,
"messages": messages,
});
if let Some(temperature) = self.config.temperature {
// OpenAI 官方 gpt-5 系列只接受默认 temperature=1(issue #857),同润色路径。
if !(self.config.provider_id.trim() == "openai"
&& openai_model_is_gpt5_family(&self.config.model))
{
body["temperature"] = json!(temperature);
}
}
apply_openai_compatible_thinking_control(
&mut body,
&self.config.provider_id,
&self.config.base_url,
&self.config.model,
self.config.thinking_enabled,
);
body
}
fn build_messages(
&self,
system_prompt: &str,
user_text: &str,
wav_bytes: Option<&[u8]>,
) -> Vec<Value> {
let user_content = match wav_bytes {
Some(wav) => {
let data = base64::engine::general_purpose::STANDARD.encode(wav);
let mut parts = vec![json!({
"type": "input_audio",
"input_audio": { "data": data, "format": "wav" },
})];
if !user_text.trim().is_empty() {
parts.push(json!({ "type": "text", "text": user_text }));
}
Value::Array(parts)
}
None => json!(user_text),
};
vec![
json!({ "role": "system", "content": system_prompt }),
json!({ "role": "user", "content": user_content }),
]
}
async fn send_unary(&self, url: &str, body: &Value) -> Result<String, LLMError> {
let mut request = self
.client
.post(url)
.header("Content-Type", "application/json");
if !self.config.api_key.trim().is_empty() {
request = request.header("Authorization", format!("Bearer {}", self.config.api_key));
}
for (key, value) in &self.config.extra_headers {
request = request.header(key.as_str(), value.as_str());
}
let request = request.json(body);
let response = send_with_transient_retry(request).await?;
let status = response.status();
let body_text = response
.text()
.await
.map_err(crate::polish::llm_error_from_reqwest)?;
let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len());
let preview = safe_str_slice(&body_text, preview_end);
log::info!("[omni] HTTP {} body={}", status.as_u16(), preview);
if !status.is_success() {
return Err(LLMError::InvalidResponse {
status: status.as_u16(),
body: preview.to_string(),
});
}
extract_assistant_content(&body_text)
}
async fn send_streaming<F, C>(
&self,
url: &str,
body: &Value,
on_delta: F,
should_cancel: C,
) -> Result<String, LLMError>
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
let mut request = self
.client
.post(url)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream");
if !self.config.api_key.trim().is_empty() {
request = request.header("Authorization", format!("Bearer {}", self.config.api_key));
}
for (key, value) in &self.config.extra_headers {
request = request.header(key.as_str(), value.as_str());
}
let request = request.json(body);
let response = send_with_transient_retry(request).await?;
let status = response.status();
if !status.is_success() {
let body_text = response
.text()
.await
.map_err(crate::polish::llm_error_from_reqwest)?;
let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len());
let preview = safe_str_slice(&body_text, preview_end);
log::error!("[omni] streaming HTTP {} body={}", status.as_u16(), preview);
return Err(LLMError::InvalidResponse {
status: status.as_u16(),
body: preview.to_string(),
});
}
// SSE 流解析与 polish 路径同款:一帧 = 若干行,`\n\n` 分隔,
// 每行 `data: {...}` / `data: [DONE]`。
let mut response = response;
let mut buffer = String::new();
let mut utf8_pending: Vec<u8> = Vec::new();
let mut full_text = String::new();
let mut cancelled = false;
loop {
if should_cancel() {
log::info!("[omni] stream cancelled by caller; breaking SSE loop");
cancelled = true;
break;
}
let chunk_opt = response
.chunk()
.await
.map_err(crate::polish::llm_error_from_reqwest)?;
let Some(chunk) = chunk_opt else { break };
append_utf8_sse_chunk(&mut buffer, &mut utf8_pending, &chunk)?;
while let Some(idx) = buffer.find("\n\n") {
let event = buffer[..idx].to_string();
buffer.drain(..idx + 2);
for line in event.lines() {
let Some(payload) = line
.strip_prefix("data: ")
.or_else(|| line.strip_prefix("data:"))
else {
continue;
};
let payload = payload.trim();
if payload.is_empty() || payload == "[DONE]" {
continue;
}
let value: Value = match serde_json::from_str(payload) {
Ok(value) => value,
Err(error) => {
log::warn!(
"[omni] SSE parse skip: {error}; payload preview: {}",
safe_str_slice(payload, 80)
);
continue;
}
};
if let Some(delta) = value["choices"][0]["delta"]["content"].as_str() {
if !delta.is_empty() {
full_text.push_str(delta);
on_delta(delta);
}
}
}
}
}
if !cancelled {
finish_utf8_sse_chunks(&mut buffer, &mut utf8_pending)?;
}
log::info!(
"[omni] stream done; total chars={}",
full_text.chars().count()
);
if full_text.is_empty() {
return Err(LLMError::InvalidResponse {
status: 200,
body: "empty omni stream".to_string(),
});
}
Ok(full_text)
}
pub(crate) async fn complete(
&self,
system_prompt: &str,
user_text: &str,
wav_bytes: Option<&[u8]>,
) -> Result<String, LLMError> {
let messages = self.build_messages(system_prompt, user_text, wav_bytes);
let body = self.omni_body(false, messages);
let url = chat_completions_url(&self.config.base_url);
log::info!(
"[omni] POST {} provider={} model={} audio={}",
crate::net::sanitized_url_for_logs(&url),
self.config.provider_id,
self.config.model,
wav_bytes.is_some()
);
self.send_unary(&url, &body).await
}
pub(crate) async fn complete_streaming<F, C>(
&self,
system_prompt: &str,
user_text: &str,
wav_bytes: Option<&[u8]>,
on_delta: F,
should_cancel: C,
) -> Result<String, LLMError>
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
let messages = self.build_messages(system_prompt, user_text, wav_bytes);
let body = self.omni_body(true, messages);
let url = chat_completions_url(&self.config.base_url);
log::info!(
"[omni] POST {} provider={} model={} audio={} stream=true",
crate::net::sanitized_url_for_logs(&url),
self.config.provider_id,
self.config.model,
wav_bytes.is_some()
);
self.send_streaming(&url, &body, on_delta, should_cancel)
.await
}
}
/// 多模态通道统一入口:按配置路由到 Gemini 原生或 OpenAI 兼容客户端。
pub enum OmniProvider {
Gemini {
provider: crate::llm_gemini::GeminiProvider,
label: OmniCallLabel,
},
OpenAI(OpenAICompatibleOmni),
}
impl OmniProvider {
pub fn new(config: OmniConfig) -> Self {
if config.is_gemini() {
let label = OmniCallLabel {
provider: config.provider_id.clone(),
model: config.model.clone(),
};
let gemini_config = crate::llm_gemini::GeminiConfig::new(
config.api_key.clone(),
config.model.clone(),
config.base_url.clone(),
)
.with_thinking_enabled(config.thinking_enabled);
let mut gemini_config = gemini_config;
if let Some(temperature) = config.temperature {
gemini_config.temperature = temperature;
}
Self::Gemini {
provider: crate::llm_gemini::GeminiProvider::new(gemini_config),
label,
}
} else {
Self::OpenAI(OpenAICompatibleOmni::new(config))
}
}
pub fn call_label(&self) -> OmniCallLabel {
match self {
Self::Gemini { label, .. } => label.clone(),
Self::OpenAI(provider) => OmniCallLabel {
provider: provider.config.provider_id.clone(),
model: provider.config.model.clone(),
},
}
}
/// 一次性调用:音频 + 提示词一步输出最终文本;无音频时为纯文本(文本管线复用)。
pub async fn complete(
&self,
system_prompt: &str,
user_text: &str,
wav_bytes: Option<&[u8]>,
) -> Result<String, LLMError> {
match self {
Self::Gemini { provider, .. } => {
provider
.complete_omni(system_prompt, user_text, wav_bytes)
.await
}
Self::OpenAI(provider) => provider.complete(system_prompt, user_text, wav_bytes).await,
}
}
/// 流式输出。OpenAI 兼容通道按 SSE 逐字回调;Gemini 通道 v1 一次性返回后
/// 以单次 `on_delta` 回调完整文本(与批准方案的「Gemini 回退一次性」一致)。
pub async fn complete_streaming<F, C>(
&self,
system_prompt: &str,
user_text: &str,
wav_bytes: Option<&[u8]>,
on_delta: F,
should_cancel: C,
) -> Result<String, LLMError>
where
F: Fn(&str) + Send + Sync,
C: Fn() -> bool + Send + Sync,
{
match self {
Self::Gemini { provider, .. } => {
let text = provider
.complete_omni(system_prompt, user_text, wav_bytes)
.await?;
on_delta(&text);
Ok(text)
}
Self::OpenAI(provider) => {
provider
.complete_streaming(
system_prompt,
user_text,
wav_bytes,
on_delta,
should_cancel,
)
.await
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> OmniConfig {
OmniConfig {
provider_id: "openai".into(),
base_url: "https://api.openai.com/v1".into(),
api_key: "sk-test".into(),
model: "gpt-4o-audio-preview".into(),
extra_headers: HashMap::new(),
temperature: Some(0.3),
thinking_enabled: false,
}
}
#[test]
fn build_messages_embeds_wav_as_input_audio_part() {
let provider = OpenAICompatibleOmni::new(config());
let messages = provider.build_messages("system-prompt", "", Some(&[1u8, 2, 3, 4]));
assert_eq!(messages.len(), 2);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"], "system-prompt");
assert_eq!(messages[1]["role"], "user");
let parts = messages[1]["content"].as_array().expect("audio parts");
assert_eq!(parts[0]["type"], "input_audio");
assert_eq!(parts[0]["input_audio"]["format"], "wav");
let data = parts[0]["input_audio"]["data"]
.as_str()
.expect("base64 data");
let decoded = base64::engine::general_purpose::STANDARD
.decode(data)
.expect("valid base64");
assert_eq!(decoded, vec![1u8, 2, 3, 4]);
// 空 user_text 时不追加多余 text part。
assert_eq!(parts.len(), 1);
}
#[test]
fn build_messages_text_only_when_no_audio() {
let provider = OpenAICompatibleOmni::new(config());
let messages = provider.build_messages("system", "你好", None);
assert_eq!(messages[1]["content"], "你好");
}
#[test]
fn build_messages_appends_text_part_alongside_audio() {
let provider = OpenAICompatibleOmni::new(config());
let messages = provider.build_messages("system", "翻译成中文", Some(&[0u8; 8]));
let parts = messages[1]["content"].as_array().expect("audio parts");
assert_eq!(parts.len(), 2);
assert_eq!(parts[1]["type"], "text");
assert_eq!(parts[1]["text"], "翻译成中文");
}
#[test]
fn omni_body_has_stream_model_and_temperature() {
let provider = OpenAICompatibleOmni::new(config());
let body = provider.omni_body(true, vec![json!({"role": "user", "content": "x"})]);
assert_eq!(body["stream"], true);
assert_eq!(body["model"], "gpt-4o-audio-preview");
// temperature 以 f32 存(0.3f32 序列化后是 0.30000001192092896),用容差比较。
assert!((body["temperature"].as_f64().unwrap() - 0.3).abs() < 1e-6);
}
#[test]
fn omni_gemini_routing_uses_provider_id_or_base_url() {
assert!(config().is_gemini() == false);
let mut gemini = config();
gemini.provider_id = "gemini".into();
assert!(gemini.is_gemini());
let mut via_url = config();
via_url.base_url = "https://generativelanguage.googleapis.com/v1beta".into();
assert!(via_url.is_gemini());
}
}
@@ -127,6 +127,10 @@ struct CredsRoot {
active: CredsActive,
#[serde(default)]
providers: CredsProviders,
/// 多模态识别管线(issue #902)专用凭据命名空间,与 asr/llm 完全隔离:
/// 运行时只在 `pipeline_mode == multimodal` 时读取,切换模式不删除。
#[serde(default)]
omni: CredsOmni,
#[serde(default, skip_serializing_if = "CredsMarketplace::is_empty")]
marketplace: CredsMarketplace,
}
@@ -174,6 +178,50 @@ struct CredsProviders {
llm: HashMap<String, CredsLlmEntry>,
}
/// 多模态(Omni)模型配置:一个 active provider + 按 provider 隔离的 entry。
/// entry 字段形状与 LLM 对齐(API Key / Base URL / Model / 温度 / 额外请求头),
/// 但存放在独立命名空间,绝不与 `providers.llm` 共享槽位。
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
struct CredsOmni {
#[serde(default = "creds_default_omni")]
active: String,
#[serde(default)]
providers: HashMap<String, CredsOmniEntry>,
}
fn creds_default_omni() -> String {
"custom".into()
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[allow(non_snake_case)]
struct CredsOmniEntry {
#[serde(skip_serializing_if = "Option::is_none")]
apiKey: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
baseURL: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
extraHeaders: Option<HashMap<String, String>>,
}
impl CredsOmniEntry {
fn is_empty(&self) -> bool {
self.apiKey.as_deref().unwrap_or("").is_empty()
&& self.baseURL.as_deref().unwrap_or("").is_empty()
&& self.model.as_deref().unwrap_or("").is_empty()
&& self.temperature.is_none()
&& self
.extraHeaders
.as_ref()
.map(|h| h.is_empty())
.unwrap_or(true)
}
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[allow(non_snake_case)]
struct CredsMarketplace {
@@ -290,6 +338,14 @@ fn active_llm_extra_headers(root: &CredsRoot) -> HashMap<String, String> {
.unwrap_or_default()
}
fn active_omni_extra_headers(root: &CredsRoot) -> HashMap<String, String> {
root.omni
.providers
.get(&root.omni.active)
.and_then(|entry| entry.extraHeaders.clone())
.unwrap_or_default()
}
fn is_valid_llm_temperature(temperature: f64) -> bool {
temperature.is_finite() && (0.0..=2.0).contains(&temperature)
}
@@ -321,6 +377,33 @@ fn active_llm_extra_headers_json(root: &CredsRoot) -> Result<Option<String>> {
.context("encode LLM extra headers")
}
fn active_omni_extra_headers_json(root: &CredsRoot) -> Result<Option<String>> {
let headers = active_omni_extra_headers(root);
if headers.is_empty() {
return Ok(None);
}
let ordered = headers.into_iter().collect::<BTreeMap<_, _>>();
serde_json::to_string(&ordered)
.map(Some)
.context("encode omni extra headers")
}
fn active_omni_temperature_value(root: &CredsRoot) -> Option<f64> {
root.omni
.providers
.get(&root.omni.active)
.and_then(|entry| entry.temperature)
.filter(|temperature| is_valid_llm_temperature(*temperature))
}
fn active_omni_temperature(root: &CredsRoot) -> Option<f32> {
active_omni_temperature_value(root).map(|temperature| temperature as f32)
}
fn active_omni_temperature_string(root: &CredsRoot) -> Option<String> {
active_omni_temperature_value(root).map(|temperature| temperature.to_string())
}
fn parse_extra_headers_json(value: &str) -> Result<HashMap<String, String>> {
let trimmed = value.trim();
if trimmed.is_empty() {
@@ -540,13 +623,13 @@ fn load_android_credentials_from_source_with_crypto(
ReadOutcome::Legacy(bytes) => (bytes, true),
ReadOutcome::Plaintext(bytes) => (bytes, false),
};
let root = serde_json::from_slice::<CredsRoot>(&bytes)
.context("parse Android credential payload")?;
let root =
serde_json::from_slice::<CredsRoot>(&bytes).context("parse Android credential payload")?;
let cleaned = android_persistable_credentials(&root);
let contained_marketplace_token = lookup_marketplace_github_token(&root).is_some();
if needs_rewrite && contained_marketplace_token {
let sanitized = serde_json::to_vec(&cleaned)
.context("encode bearer-free Android legacy payload")?;
let sanitized =
serde_json::to_vec(&cleaned).context("encode bearer-free Android legacy payload")?;
super::android_credentials::rewrite_legacy_without_bearer(source_path, &sanitized)
.map_err(anyhow::Error::new)
.context("scrub Marketplace bearer before Android Keystore migration")?;
@@ -636,6 +719,7 @@ fn clean_credentials(root: &CredsRoot) -> CredsRoot {
let mut cleaned = root.clone();
cleaned.providers.asr.retain(|_, v| !v.is_empty());
cleaned.providers.llm.retain(|_, v| !v.is_empty());
cleaned.omni.providers.retain(|_, v| !v.is_empty());
cleaned
}
@@ -1124,6 +1208,7 @@ fn save_credentials(root: &CredsRoot) -> Result<()> {
fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option<String> {
let asr = root.providers.asr.get(&root.active.asr);
let llm = root.providers.llm.get(&root.active.llm);
let omni = root.omni.providers.get(&root.omni.active);
let pick = |s: &Option<String>| s.as_ref().filter(|v| !v.is_empty()).cloned();
match account {
CredentialAccount::VolcengineAppKey => {
@@ -1143,12 +1228,16 @@ fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option<String
CredentialAccount::AsrAdvancedConfig => asr.and_then(|e| pick(&e.advancedConfig)),
CredentialAccount::XfyunAppId => asr.and_then(|e| pick(&e.xfyunAppId)),
CredentialAccount::XfyunApiKey => asr.and_then(|e| pick(&e.xfyunApiKey)),
CredentialAccount::OmniApiKey => omni.and_then(|e| pick(&e.apiKey)),
CredentialAccount::OmniEndpoint => omni.and_then(|e| pick(&e.baseURL)),
CredentialAccount::OmniModel => omni.and_then(|e| pick(&e.model)),
}
}
fn write_account(root: &mut CredsRoot, account: CredentialAccount, value: Option<String>) {
let asr_id = root.active.asr.clone();
let llm_id = root.active.llm.clone();
let omni_id = root.omni.active.clone();
let normalized = value.and_then(|v| if v.is_empty() { None } else { Some(v) });
match account {
CredentialAccount::VolcengineAppKey => {
@@ -1211,6 +1300,18 @@ fn write_account(root: &mut CredsRoot, account: CredentialAccount, value: Option
let entry = root.providers.asr.entry(asr_id).or_default();
entry.xfyunApiKey = normalized;
}
CredentialAccount::OmniApiKey => {
let entry = root.omni.providers.entry(omni_id).or_default();
entry.apiKey = normalized;
}
CredentialAccount::OmniEndpoint => {
let entry = root.omni.providers.entry(omni_id).or_default();
entry.baseURL = normalized;
}
CredentialAccount::OmniModel => {
let entry = root.omni.providers.entry(omni_id).or_default();
entry.model = normalized;
}
}
}
@@ -1239,6 +1340,12 @@ pub enum CredentialAccount {
XfyunAppId,
/// 讯飞实时语音转写 APIKey。
XfyunApiKey,
/// 多模态(Omni)模型的 API Key。仅多模态管线读取。
OmniApiKey,
/// 多模态(Omni)模型的 Base URL。
OmniEndpoint,
/// 多模态(Omni)模型的 model id。
OmniModel,
}
impl CredentialAccount {
@@ -1262,6 +1369,9 @@ impl CredentialAccount {
CredentialAccount::AsrAdvancedConfig => "asr.advanced_config",
CredentialAccount::XfyunAppId => "xfyun.app_id",
CredentialAccount::XfyunApiKey => "xfyun.api_key",
CredentialAccount::OmniApiKey => "omni.api_key",
CredentialAccount::OmniEndpoint => "omni.endpoint",
CredentialAccount::OmniModel => "omni.model",
}
}
@@ -1282,6 +1392,9 @@ impl CredentialAccount {
CredentialAccount::AsrAdvancedConfig,
CredentialAccount::XfyunAppId,
CredentialAccount::XfyunApiKey,
CredentialAccount::OmniApiKey,
CredentialAccount::OmniEndpoint,
CredentialAccount::OmniModel,
]
}
}
@@ -1302,6 +1415,10 @@ pub struct CredentialsSnapshot {
pub ark_api_key: Option<String>,
pub ark_model_id: Option<String>,
pub ark_endpoint: Option<String>,
pub active_omni_provider: String,
pub omni_api_key: Option<String>,
pub omni_endpoint: Option<String>,
pub omni_model: Option<String>,
}
/// 凭据存储——系统凭据库;旧 JSON 文件只作为迁移来源。
@@ -1465,6 +1582,68 @@ impl CredentialsVault {
load_credentials().active.llm
}
pub fn get_active_omni() -> String {
let _guard = credentials_lock().lock();
load_credentials().omni.active
}
pub fn set_active_omni_provider(id: &str) -> Result<()> {
let _guard = credentials_lock().lock();
let mut root = load_credentials_for_update()?;
root.omni.active = id.to_string();
save_credentials(&root)
}
pub fn get_active_omni_extra_headers() -> HashMap<String, String> {
let _guard = credentials_lock().lock();
active_omni_extra_headers(&load_credentials())
}
pub fn get_active_omni_extra_headers_json() -> Result<Option<String>> {
let _guard = credentials_lock().lock();
active_omni_extra_headers_json(&load_credentials())
}
pub fn get_active_omni_temperature() -> Option<f32> {
let _guard = credentials_lock().lock();
active_omni_temperature(&load_credentials())
}
pub fn get_active_omni_temperature_string() -> Option<String> {
let _guard = credentials_lock().lock();
active_omni_temperature_string(&load_credentials())
}
pub fn set_active_omni_temperature(value: &str) -> Result<()> {
let _guard = credentials_lock().lock();
let temperature = parse_llm_temperature(value)?;
let mut root = load_credentials_for_update()?;
let entry = root
.omni
.providers
.entry(root.omni.active.clone())
.or_default();
entry.temperature = temperature;
save_credentials(&root)
}
pub fn set_active_omni_extra_headers_json(value: &str) -> Result<()> {
let _guard = credentials_lock().lock();
let headers = parse_extra_headers_json(value)?;
let mut root = load_credentials_for_update()?;
let entry = root
.omni
.providers
.entry(root.omni.active.clone())
.or_default();
entry.extraHeaders = if headers.is_empty() {
None
} else {
Some(headers)
};
save_credentials(&root)
}
pub fn get_active_llm_extra_headers() -> HashMap<String, String> {
let _guard = credentials_lock().lock();
active_llm_extra_headers(&load_credentials())
@@ -1489,7 +1668,11 @@ impl CredentialsVault {
let _guard = credentials_lock().lock();
let temperature = parse_llm_temperature(value)?;
let mut root = load_credentials_for_update()?;
let entry = root.providers.llm.entry(root.active.llm.clone()).or_default();
let entry = root
.providers
.llm
.entry(root.active.llm.clone())
.or_default();
entry.temperature = temperature;
save_credentials(&root)
}
@@ -1498,7 +1681,11 @@ impl CredentialsVault {
let _guard = credentials_lock().lock();
let headers = parse_extra_headers_json(value)?;
let mut root = load_credentials_for_update()?;
let entry = root.providers.llm.entry(root.active.llm.clone()).or_default();
let entry = root
.providers
.llm
.entry(root.active.llm.clone())
.or_default();
entry.extraHeaders = if headers.is_empty() {
None
} else {
@@ -1524,12 +1711,18 @@ impl CredentialsVault {
ark_api_key: lookup_account(&root, CredentialAccount::ArkApiKey),
ark_model_id: lookup_account(&root, CredentialAccount::ArkModelId),
ark_endpoint: lookup_account(&root, CredentialAccount::ArkEndpoint),
active_omni_provider: root.omni.active.clone(),
omni_api_key: lookup_account(&root, CredentialAccount::OmniApiKey),
omni_endpoint: lookup_account(&root, CredentialAccount::OmniEndpoint),
omni_model: lookup_account(&root, CredentialAccount::OmniModel),
}
}
}
#[cfg(test)]
mod tests {
#[cfg(not(windows))]
use super::load_android_credentials_from_source_with_crypto;
use super::{
android_persistable_credentials, chunk_json_payload, credentials_cache,
get_android_marketplace_token_at, load_android_credentials_from_path,
@@ -1539,8 +1732,6 @@ mod tests {
write_marketplace_github_token, CredentialAccount, CredsAsrEntry, CredsRoot,
MarketplaceGithubToken, KEYRING_CHUNK_MAX_UTF16_UNITS,
};
#[cfg(not(windows))]
use super::load_android_credentials_from_source_with_crypto;
use anyhow::anyhow;
use parking_lot::Mutex;
@@ -1560,6 +1751,50 @@ mod tests {
.all(|chunk| chunk.encode_utf16().count() <= KEYRING_CHUNK_MAX_UTF16_UNITS));
}
#[test]
fn omni_accounts_route_to_omni_namespace_only() {
// 多模态(Omni)凭据必须与 LLM/ASR 命名空间完全隔离(issue #902):
// 写 omni 槽位不影响 ark 槽位;切换 omni active provider 后读到的是
// 该 provider 自己的 entry,而不是别的 provider 的残留值。
let mut root = CredsRoot::default();
root.active.llm = "ark".into();
root.active.asr = "volcengine".into();
root.omni.active = "openai".into();
write_account(
&mut root,
CredentialAccount::OmniApiKey,
Some("omni-key".into()),
);
write_account(
&mut root,
CredentialAccount::OmniEndpoint,
Some("https://api.openai.com/v1".into()),
);
write_account(
&mut root,
CredentialAccount::OmniModel,
Some("gpt-4o-audio-preview".into()),
);
assert_eq!(
lookup_account(&root, CredentialAccount::OmniApiKey).as_deref(),
Some("omni-key")
);
// 传统 LLM / ASR 槽位必须保持为空。
assert_eq!(lookup_account(&root, CredentialAccount::ArkApiKey), None);
assert_eq!(lookup_account(&root, CredentialAccount::AsrApiKey), None);
// 切到另一个 omni provider:读不到 openai 的 entry(per-provider 隔离)。
root.omni.active = "custom".into();
assert_eq!(lookup_account(&root, CredentialAccount::OmniApiKey), None);
root.omni.active = "openai".into();
assert_eq!(
lookup_account(&root, CredentialAccount::OmniModel).as_deref(),
Some("gpt-4o-audio-preview")
);
}
#[test]
fn parse_extra_headers_json_rejects_reserved_header_names() {
for name in [
@@ -1609,8 +1844,13 @@ mod tests {
// 清空即移除该字段,且只影响对应 provider 的 entry。
write_account(&mut root, CredentialAccount::AsrAdvancedConfig, None);
assert_eq!(lookup_account(&root, CredentialAccount::AsrAdvancedConfig), None);
assert!(root.providers.asr["openai-compatible"].advancedConfig.is_none());
assert_eq!(
lookup_account(&root, CredentialAccount::AsrAdvancedConfig),
None
);
assert!(root.providers.asr["openai-compatible"]
.advancedConfig
.is_none());
// 旧条目(无 advancedConfig 字段)反序列化为 None,不破坏既有数据。
let legacy: CredsAsrEntry = serde_json::from_str(r#"{"apiKey":"k"}"#).unwrap();
@@ -1736,9 +1976,11 @@ mod tests {
assert!(std::fs::read_to_string(&destination_path)
.unwrap()
.contains("openless-android-credentials"));
assert!(load_android_credentials_from_path_with_crypto(&destination_path, &mut crypto)
.unwrap()
.is_some());
assert!(
load_android_credentials_from_path_with_crypto(&destination_path, &mut crypto)
.unwrap()
.is_some()
);
std::fs::remove_dir_all(root_dir).unwrap();
}
@@ -1800,9 +2042,8 @@ mod tests {
)
.unwrap();
let mut crypto = super::super::android_credentials::TestCrypto::default();
crypto.fail_next_seal = Some(
super::super::android_credentials::CryptoErrorKind::TemporarilyUnavailable,
);
crypto.fail_next_seal =
Some(super::super::android_credentials::CryptoErrorKind::TemporarilyUnavailable);
assert!(load_android_credentials_from_path_with_crypto(&path, &mut crypto).is_err());
let sanitized = std::fs::read(&path).unwrap();
+48 -26
View File
@@ -587,7 +587,13 @@ impl OpenAICompatibleLLMProvider {
body["temperature"] = json!(temperature);
}
}
apply_openai_compatible_thinking_control(&mut body, &self.config);
apply_openai_compatible_thinking_control(
&mut body,
&self.config.provider_id,
&self.config.base_url,
&self.config.model,
self.config.thinking_enabled,
);
body
}
@@ -1211,7 +1217,7 @@ impl CodexOAuthLLMProvider {
}
}
fn append_utf8_sse_chunk(
pub(crate) fn append_utf8_sse_chunk(
buffer: &mut String,
pending: &mut Vec<u8>,
chunk: &[u8],
@@ -1220,7 +1226,10 @@ fn append_utf8_sse_chunk(
drain_complete_utf8(buffer, pending)
}
fn finish_utf8_sse_chunks(buffer: &mut String, pending: &mut Vec<u8>) -> Result<(), LLMError> {
pub(crate) fn finish_utf8_sse_chunks(
buffer: &mut String,
pending: &mut Vec<u8>,
) -> Result<(), LLMError> {
drain_complete_utf8(buffer, pending)?;
if pending.is_empty() {
Ok(())
@@ -1297,7 +1306,7 @@ fn build_polish_history_messages(
messages
}
fn chat_completions_url(base_url: &str) -> String {
pub(crate) fn chat_completions_url(base_url: &str) -> String {
let trimmed = base_url.trim();
let Ok(mut url) = reqwest::Url::parse(trimmed) else {
let fallback = trimmed.trim_end_matches('/');
@@ -1341,7 +1350,7 @@ fn should_retry_transient(is_connect: bool, is_request: bool, is_timeout: bool)
/// 对流式 SSE 路径 retry 是安全的:connect / request 类失败发生在 TCP 握手 / HTTP
/// 请求写出阶段,response 还没回 → on_delta 必然未被调用 → 不会有「已流式输出的字
/// 被重复」的问题。
async fn send_with_transient_retry(
pub(crate) async fn send_with_transient_retry(
request: reqwest::RequestBuilder,
) -> Result<reqwest::Response, LLMError> {
const RETRY_DELAY_MS: u64 = 500;
@@ -1578,41 +1587,43 @@ fn unix_now_secs() -> u64 {
.unwrap_or(0)
}
fn apply_openai_compatible_thinking_control(body: &mut Value, config: &OpenAICompatibleConfig) {
pub(crate) fn apply_openai_compatible_thinking_control(
body: &mut Value,
provider_id: &str,
base_url: &str,
model: &str,
thinking_enabled: bool,
) {
// 优先按 provider_id 预设分派;custom / 未声明 provider 时回退到 base_url 兜底,
// 让用户用"自定义"preset 接入 MiniMax 也能正确下发 thinking 控制参数。
let control = openai_compatible_thinking_control(&config.provider_id)
.or_else(|| openai_compatible_thinking_control_for_base_url(&config.base_url));
let control = openai_compatible_thinking_control(provider_id)
.or_else(|| openai_compatible_thinking_control_for_base_url(base_url));
match control {
Some(ThinkingControl::ReasoningEffort) => {
// OpenAI 官方 Chat Completions 只在推理模型族接受 reasoning_effort;
// 普通 chat 模型会直接 400。其它兼容渠道按渠道声明继续下发。
let effort = if config.provider_id.trim() == "openai" {
openai_chat_reasoning_effort(&config.model, config.thinking_enabled)
let effort = if provider_id.trim() == "openai" {
openai_chat_reasoning_effort(model, thinking_enabled)
} else {
Some(if config.thinking_enabled {
"medium"
} else {
"low"
})
Some(if thinking_enabled { "medium" } else { "low" })
};
if let Some(effort) = effort {
body["reasoning_effort"] = json!(effort);
}
}
Some(ThinkingControl::EnableThinking) => {
body["enable_thinking"] = json!(config.thinking_enabled);
body["enable_thinking"] = json!(thinking_enabled);
}
Some(ThinkingControl::OpenRouterReasoning) => {
body["reasoning"] = json!({
"effort": if config.thinking_enabled { "medium" } else { "none" },
"effort": if thinking_enabled { "medium" } else { "none" },
// OpenLess 的 QA/润色输出只展示最终答案;推理内容即使生成,也不应进 UI。
"exclude": true,
});
}
Some(ThinkingControl::DeepSeekThinking) => {
body["thinking"] = json!({
"type": if config.thinking_enabled { "enabled" } else { "disabled" },
"type": if thinking_enabled { "enabled" } else { "disabled" },
});
}
// MiniMax OpenAI 兼容 Chat Completions 接受官方 `thinking` 字段,关闭用
@@ -1623,7 +1634,7 @@ fn apply_openai_compatible_thinking_control(body: &mut Value, config: &OpenAICom
// 这与 OpenLess 渠道级"按官方参数声明下发"的策略一致,不维护单模型白名单。
Some(ThinkingControl::MiniMaxThinking) => {
body["thinking"] = json!({
"type": if config.thinking_enabled { "adaptive" } else { "disabled" },
"type": if thinking_enabled { "adaptive" } else { "disabled" },
});
}
None => {}
@@ -1631,7 +1642,7 @@ fn apply_openai_compatible_thinking_control(body: &mut Value, config: &OpenAICom
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ThinkingControl {
pub(crate) enum ThinkingControl {
ReasoningEffort,
EnableThinking,
OpenRouterReasoning,
@@ -1639,7 +1650,7 @@ enum ThinkingControl {
MiniMaxThinking,
}
fn openai_compatible_thinking_control(provider_id: &str) -> Option<ThinkingControl> {
pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option<ThinkingControl> {
match provider_id.trim() {
"deepseek" => Some(ThinkingControl::DeepSeekThinking),
// provider_id 预设(见 ProvidersSection.tsx::LLM_PRESETS)。
@@ -1660,7 +1671,9 @@ fn openai_compatible_thinking_control(provider_id: &str) -> Option<ThinkingContr
/// 识别,沿用原"不主动干预"行为。
///
/// 命中策略:base_url 主机名包含厂商关键字。
fn openai_compatible_thinking_control_for_base_url(base_url: &str) -> Option<ThinkingControl> {
pub(crate) fn openai_compatible_thinking_control_for_base_url(
base_url: &str,
) -> Option<ThinkingControl> {
// 抽 host(不区分大小写),允许带端口。`base_url` 末尾可能带 `/v1`、`/v1/`、
// 甚至 `/v1/chat/completions`——统一取第一个 `/` 段当 host。
let host = base_url
@@ -1693,7 +1706,7 @@ fn openai_compatible_thinking_control_for_base_url(base_url: &str) -> Option<Thi
/// OpenAI 官方 gpt-5 系列(gpt-5 / gpt-5-mini / gpt-5-nano / gpt-5.5 等)在
/// Chat Completions 中只接受默认 temperature=1,传其它值会返回 400(issue #857)。
/// 模型名归一化规则与 `openai_chat_reasoning_effort` 保持一致。
fn openai_model_is_gpt5_family(model: &str) -> bool {
pub(crate) fn openai_model_is_gpt5_family(model: &str) -> bool {
model
.trim()
.strip_prefix("openai/")
@@ -1724,7 +1737,7 @@ fn openai_chat_reasoning_effort(model: &str, thinking_enabled: bool) -> Option<&
}
}
fn extract_assistant_content(body: &str) -> Result<String, LLMError> {
pub(crate) fn extract_assistant_content(body: &str) -> Result<String, LLMError> {
let json: Value = serde_json::from_str(body)
.map_err(|e| LLMError::ParseError(format!("not valid JSON: {}", e)))?;
let choices = json
@@ -2603,7 +2616,13 @@ mod tests {
#[test]
fn chat_body_omits_temperature_for_openai_gpt5_family() {
for model in ["gpt-5", "gpt-5-mini", "gpt-5-nano", "gpt-5.5", "openai/gpt-5"] {
for model in [
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-5.5",
"openai/gpt-5",
] {
let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new(
"openai",
"OpenAI",
@@ -3242,7 +3261,10 @@ mod tests {
structured.contains("高置信度") && structured.contains("低置信度"),
"Structured prompt 缺少置信度分级"
);
assert!(structured.contains("根目录"), "Structured prompt 缺少根目录纠错示例");
assert!(
structured.contains("根目录"),
"Structured prompt 缺少根目录纠错示例"
);
}
#[test]
+77 -11
View File
@@ -30,6 +30,29 @@ pub enum PolishMode {
Formal,
}
/// 识别管线模式(issue #902):`traditional` = 两段式 ASR + LLM 润色;
/// `multimodal` = 单个多模态模型一步完成「音频 + 提示词 → 最终文本」。
/// 两套配置在凭据库中完全隔离,运行时只读当前模式,切换不删除另一套配置。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum PipelineMode {
#[default]
Traditional,
Multimodal,
}
fn default_pipeline_mode() -> PipelineMode {
PipelineMode::Traditional
}
fn default_multimodal_pipeline_enabled() -> bool {
false
}
fn default_active_omni_provider() -> String {
"custom".into()
}
/// 历史记录的产生来源。旧版 `history.json` 未写入该字段时,按既有听写记录处理。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
@@ -259,6 +282,11 @@ pub struct DictationSession {
/// 本次润色用的 LLM 模型 id。Raw 直通时 None。
#[serde(default)]
pub llm_model: Option<String>,
/// 本次会话走的识别管线模式("multimodal" / 缺失 = 传统两段式)。
/// 多模态会话 `asr_provider/asr_model` 为空,`llm_provider/llm_model`
/// 记实际调用的多模态模型,`polish_ms` 记该调用的耗时。
#[serde(default)]
pub pipeline_mode: Option<String>,
/// 松键后「等待转写结果」的实测耗时(毫秒)。流式 ASR 大部分识别在录音期间已完成,
/// 这里量的是用户感知的收尾延迟;批式 ASR 则是完整转写耗时。
#[serde(default)]
@@ -787,6 +815,17 @@ pub struct UserPreferences {
pub microphone_device_name: String,
pub active_asr_provider: String, // "volcengine" | "apple-speech" | ...
pub active_llm_provider: String, // "ark" | "openai" | ...
/// 识别管线模式(实验性,issue #902)。`multimodal` 时各语音管线改用
/// 单独隔离的多模态模型配置(`omni.*` 凭据命名空间),不再读 ASR/LLM 两套。
#[serde(default = "default_pipeline_mode")]
pub pipeline_mode: PipelineMode,
/// 「多模态识别管线」实验性功能总开关(高级设置)。关闭时一切行为与旧版一致。
#[serde(default = "default_multimodal_pipeline_enabled")]
pub multimodal_pipeline_enabled: bool,
/// 多模态(Omni)模型当前激活的 provider id(镜像凭据库 `omni.active`,
/// 供设置页初始化下拉;运行时权威仍在 CredentialsVault)。
#[serde(default = "default_active_omni_provider")]
pub active_omni_provider: String,
/// LLM 思考模式开关。默认 false 以保持既有「尽量关闭思考」行为;
/// Gemini 走原生 thinkingConfig,OpenAI-compatible 路径仅按 provider/channel
/// 下发官方渠道级字段;OpenAI 官方渠道会跳过普通 chat 模型不支持的字段。详见 issue #402。
@@ -826,10 +865,7 @@ pub struct UserPreferences {
pub windows_sendinput_insertion_only: bool,
/// Windows:SendInput 模式下是否在系统键盘列表(Win+Space)中显示 OpenLess TSF 输入法。
/// 默认 true 保持现有行为;关闭后用户级禁用语言配置文件,无需管理员权限。
#[serde(
default = "default_true",
rename = "windowsShowOpenlessInKeyboardList"
)]
#[serde(default = "default_true", rename = "windowsShowOpenlessInKeyboardList")]
pub windows_show_openless_in_keyboard_list: bool,
/// 用户的工作语言(多选,原生名)。会作为前提注入 LLM polish/translate 的 system prompt 头部,
/// 让模型知道该用户在哪些语言间工作。详见 issue #4。
@@ -1154,6 +1190,12 @@ struct UserPreferencesWire {
microphone_device_name: String,
active_asr_provider: String,
active_llm_provider: String,
#[serde(default = "default_pipeline_mode")]
pipeline_mode: PipelineMode,
#[serde(default = "default_multimodal_pipeline_enabled")]
multimodal_pipeline_enabled: bool,
#[serde(default = "default_active_omni_provider")]
active_omni_provider: String,
#[serde(default)]
llm_thinking_enabled: bool,
#[serde(default = "default_true")]
@@ -1322,6 +1364,9 @@ impl Default for UserPreferencesWire {
microphone_device_name: prefs.microphone_device_name,
active_asr_provider: prefs.active_asr_provider,
active_llm_provider: prefs.active_llm_provider,
pipeline_mode: prefs.pipeline_mode,
multimodal_pipeline_enabled: prefs.multimodal_pipeline_enabled,
active_omni_provider: prefs.active_omni_provider,
llm_thinking_enabled: prefs.llm_thinking_enabled,
use_system_proxy: prefs.use_system_proxy,
restore_clipboard_after_paste: prefs.restore_clipboard_after_paste,
@@ -1418,9 +1463,8 @@ impl<'de> Deserialize<'de> for UserPreferences {
// 设置保存都会被热键冲突校验整体拒绝,改动全部丢失(#904)。
let legacy_default_user = cfg!(target_os = "windows")
&& is_right_control_modifier_shortcut(&dictation_hotkey);
let default_taken_by_dictation = selection_polish_hotkey
.as_ref()
.is_some_and(|binding| {
let default_taken_by_dictation =
selection_polish_hotkey.as_ref().is_some_and(|binding| {
crate::shortcut_binding::bindings_overlap(binding, &dictation_hotkey)
});
if legacy_default_user || default_taken_by_dictation {
@@ -1457,6 +1501,9 @@ impl<'de> Deserialize<'de> for UserPreferences {
microphone_device_name: wire.microphone_device_name,
active_asr_provider: wire.active_asr_provider,
active_llm_provider: wire.active_llm_provider,
pipeline_mode: wire.pipeline_mode,
multimodal_pipeline_enabled: wire.multimodal_pipeline_enabled,
active_omni_provider: wire.active_omni_provider,
llm_thinking_enabled: wire.llm_thinking_enabled,
use_system_proxy: wire.use_system_proxy,
restore_clipboard_after_paste: wire.restore_clipboard_after_paste,
@@ -2280,6 +2327,9 @@ impl Default for UserPreferences {
microphone_device_name: String::new(),
active_asr_provider: default_active_asr_provider(),
active_llm_provider: "ark".into(),
pipeline_mode: PipelineMode::Traditional,
multimodal_pipeline_enabled: false,
active_omni_provider: "custom".into(),
llm_thinking_enabled: false,
use_system_proxy: true,
restore_clipboard_after_paste: true,
@@ -3041,8 +3091,13 @@ pub struct CapsulePayload {
pub struct CredentialsStatus {
pub active_asr_provider: String,
pub active_llm_provider: String,
/// 当前识别管线模式("traditional" | "multimodal"),前端据此决定
/// 配置页渲染哪套卡片、概览页按哪套判定「已配置」。
pub pipeline_mode: PipelineMode,
pub asr_configured: bool,
pub llm_configured: bool,
/// 多模态(omni)模型是否已配置。仅 `pipeline_mode == multimodal` 时有意义。
pub omni_configured: bool,
// 兼容旧前端字段(逐步迁移中)
pub volcengine_configured: bool,
pub ark_configured: bool,
@@ -3327,7 +3382,8 @@ mod tests {
#[cfg(target_os = "windows")]
#[test]
fn new_preferences_keep_the_existing_dictation_default_and_use_right_alt_for_selection_polish() {
fn new_preferences_keep_the_existing_dictation_default_and_use_right_alt_for_selection_polish()
{
let prefs = UserPreferences::default();
assert_eq!(prefs.dictation_hotkey.primary, "RightControl");
assert_eq!(
@@ -3355,7 +3411,10 @@ mod tests {
let prefs: UserPreferences =
serde_json::from_str(r#"{"windowsSendInputInsertionOnly": true}"#).unwrap();
assert!(prefs.windows_sendinput_insertion_only);
assert_eq!(prefs.windows_insertion_mode, WindowsInsertionMode::SendInput);
assert_eq!(
prefs.windows_insertion_mode,
WindowsInsertionMode::SendInput
);
}
#[test]
@@ -3363,7 +3422,10 @@ mod tests {
let prefs: UserPreferences =
serde_json::from_str(r#"{"windowsSendinputInsertionOnly": true}"#).unwrap();
assert!(prefs.windows_sendinput_insertion_only);
assert_eq!(prefs.windows_insertion_mode, WindowsInsertionMode::SendInput);
assert_eq!(
prefs.windows_insertion_mode,
WindowsInsertionMode::SendInput
);
}
#[test]
@@ -3429,7 +3491,10 @@ mod tests {
assert!(json.contains(r#""windowsInsertionMode":"sendInput""#));
let restored: UserPreferences = serde_json::from_str(&json).unwrap();
assert!(restored.windows_sendinput_insertion_only);
assert_eq!(restored.windows_insertion_mode, WindowsInsertionMode::SendInput);
assert_eq!(
restored.windows_insertion_mode,
WindowsInsertionMode::SendInput
);
}
#[test]
@@ -3876,6 +3941,7 @@ mod tests {
asr_model: Some("fun-asr-realtime".into()),
llm_provider: Some("ark".into()),
llm_model: Some("deepseek-v3-2".into()),
pipeline_mode: None,
asr_ms: Some(230),
polish_ms: Some(1450),
};
+15
View File
@@ -390,6 +390,7 @@ export const en: typeof zhCN = {
selectHint: 'Select an entry on the left to see details.',
recorded: 'Recorded {{duration}}',
stepAsr: 'Transcribe',
multimodalPipeline: 'Multimodal',
stepAsrHint: 'Time spent waiting for the transcript after key release. Streaming ASR transcribes while you speak, so this is usually much shorter than the recording.',
stepPolish: 'Polish',
stepInsert: 'Insert',
@@ -878,6 +879,13 @@ export const en: typeof zhCN = {
asrProviderDesc: 'Switching providers automatically loads the matching credentials.',
asrTitle: 'ASR (transcription)',
asrDesc: 'Used to turn recorded speech into text.',
omniTitle: 'Multimodal model',
omniDesc: 'One model that turns audio + prompt into the final text directly (experimental pipeline).',
pipelineModeLabel: 'Pipeline mode',
pipelineModeHint: 'Traditional = ASR + LLM two-stage. Multimodal = a single audio-capable model in one pass.',
pipelineModeTraditional: 'Traditional',
pipelineModeMultimodal: 'Multimodal',
pipelineIsolationNotice: 'The two modes keep fully separate credentials. Switching modes keeps the other set stored but unused; switching back restores it.',
presets: {
ark: 'ARK (Volcengine Ark)',
deepseek: 'DeepSeek',
@@ -913,6 +921,9 @@ export const en: typeof zhCN = {
asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)',
asrLocalQwen3: 'Local Qwen3-ASR',
asrAppleSpeech: 'Apple Speech (macOS)',
omniOpenai: 'OpenAI (audio-capable)',
omniGemini: 'Google Gemini',
omniDashscope: 'Alibaba DashScope Omni',
},
elevenLabsUploadNotice: 'ElevenLabs uploads recorded audio to the configured endpoint for batch transcription.',
zenmuxVocabularyNote: 'ZenMux uses a JSON transcription protocol and does not receive dictionary hotwords (prompt/hotwords); the dictionary still feeds the polish step but does not bias speech recognition.',
@@ -1115,6 +1126,10 @@ export const en: typeof zhCN = {
},
},
advanced: {
multimodalPipelineTitle: 'Multimodal recognition pipeline (experimental)',
multimodalPipelineTitleHint: 'One-pass audio recognition with a single multimodal model; traditional ASR + LLM configuration is fully isolated from it.',
multimodalPipelineLabel: 'Enable multimodal pipeline',
multimodalPipelineHint: 'Adds a Traditional / Multimodal switch on the AI providers page. Traditional = ASR + LLM; Multimodal = one audio-capable model. The two configurations are stored separately and never share credentials.',
streamingInsertTitle: 'Streaming insertion',
streamingInsertTitleLinux: 'Streaming insertion (experimental)',
streamingInsertDesc:
+15
View File
@@ -392,6 +392,7 @@ export const ja: typeof zhCN = {
selectHint: '左側から 1 件選択して詳細を表示。',
recorded: '録音 {{duration}}',
stepAsr: '認識',
multimodalPipeline: 'マルチモーダル',
stepAsrHint: 'キーを離してから認識結果を待った時間。ストリーミング認識は録音中に変換するため、通常は録音時間よりずっと短くなります。',
stepPolish: '推敲',
stepInsert: '挿入',
@@ -880,6 +881,13 @@ export const ja: typeof zhCN = {
asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。',
asrTitle: 'ASR 音声(転写)',
asrDesc: '録音した音声をテキストに文字起こしします。',
omniTitle: 'マルチモーダルモデル',
omniDesc: '1つのモデルが「プロンプト + 音声」から最終テキストを直接出力します(実験的パイプライン)。',
pipelineModeLabel: '認識パイプライン',
pipelineModeHint: '従来 = ASR 文字起こし + LLM 整形の2段式。マルチモーダル = 音声対応モデルが1回で完了。',
pipelineModeTraditional: '従来モード',
pipelineModeMultimodal: 'マルチモーダルモード',
pipelineIsolationNotice: '2つのモードは完全に独立した認証情報を使用します。切り替えてももう一方の設定は削除されず、切り戻せば復元されます。',
presets: {
ark: 'ARK(Volcengine Ark)',
deepseek: 'DeepSeek',
@@ -915,6 +923,9 @@ export const ja: typeof zhCN = {
asrFoundryLocalWhisper: 'ローカル Whisper(Foundry Local)',
asrLocalQwen3: 'ローカル Qwen3-ASR',
asrAppleSpeech: 'Apple 音声認識 (macOS)',
omniOpenai: 'OpenAI(音声対応)',
omniGemini: 'Google Gemini',
omniDashscope: 'Alibaba DashScope Omni',
},
elevenLabsUploadNotice: 'ElevenLabs は録音音声を設定済みのエンドポイントへアップロードしてバッチ文字起こしします。',
zenmuxVocabularyNote: 'ZenMux は JSON 文字起こしプロトコルを使用し、辞書ホットワード(prompt/hotwords)は送信されません。辞書は依然として潤色段階には渡りますが、音声認識のバイアスには使用されません。',
@@ -1083,6 +1094,10 @@ export const ja: typeof zhCN = {
},
},
advanced: {
multimodalPipelineTitle: 'マルチモーダル認識パイプライン(実験的)',
multimodalPipelineTitleHint: '1つのマルチモーダルモデルで音声認識を一括実行。従来の ASR + LLM 設定から完全に分離されます。',
multimodalPipelineLabel: 'マルチモーダルパイプラインを有効化',
multimodalPipelineHint: '有効にすると「サービス → AI プロバイダー」ページに従来 / マルチモーダルの切り替えが表示されます。従来 = ASR + LLM、マルチモーダル = 音声対応モデル1つ。設定は別々に保存され、認証情報を共有しません。',
streamingInsertTitle: 'ストリーミング入力',
streamingInsertTitleLinux: 'ストリーミング入力(実験的)',
streamingInsertDesc:
+15
View File
@@ -392,6 +392,7 @@ export const ko: typeof zhCN = {
selectHint: '왼쪽에서 하나를 선택하여 자세히 보기.',
recorded: '녹음 {{duration}}',
stepAsr: '인식',
multimodalPipeline: '멀티모달',
stepAsrHint: '키를 뗀 후 인식 결과를 기다린 시간. 스트리밍 인식은 녹음 중에 변환하므로 보통 녹음 시간보다 훨씬 짧습니다.',
stepPolish: '다듬기',
stepInsert: '삽입',
@@ -880,6 +881,13 @@ export const ko: typeof zhCN = {
asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.',
asrTitle: 'ASR 음성(전사)',
asrDesc: '녹음된 음성을 텍스트로 전사합니다.',
omniTitle: '멀티모달 모델',
omniDesc: '하나의 모델이 프롬프트 + 오디오를 받아 최종 텍스트를 한 번에 출력합니다(실험적 파이프라인).',
pipelineModeLabel: '인식 파이프라인',
pipelineModeHint: '전통 = ASR 전사 + LLM 다듬기 2단계. 멀티모달 = 오디오 지원 모델이 한 번에 처리.',
pipelineModeTraditional: '전통 모드',
pipelineModeMultimodal: '멀티모달 모드',
pipelineIsolationNotice: '두 모드는 완전히 분리된 자격 증명을 사용합니다. 전환해도 다른 쪽 설정은 삭제되지 않으며, 다시 전환하면 복원됩니다.',
presets: {
ark: 'ARK (Volcengine Ark)',
deepseek: 'DeepSeek',
@@ -915,6 +923,9 @@ export const ko: typeof zhCN = {
asrFoundryLocalWhisper: '로컬 Whisper(Foundry Local)',
asrLocalQwen3: '로컬 Qwen3-ASR',
asrAppleSpeech: 'Apple 음성 (macOS)',
omniOpenai: 'OpenAI (오디오 지원)',
omniGemini: 'Google Gemini',
omniDashscope: 'Alibaba DashScope Omni',
},
elevenLabsUploadNotice: 'ElevenLabs는 녹음 오디오를 설정된 엔드포인트에 업로드해 일괄 전사합니다.',
zenmuxVocabularyNote: 'ZenMux는 JSON 전사 프로토콜을 사용하며 사전 핫워드(prompt/hotwords)를 보내지 않습니다. 사전은 여전히 다듬기 단계에 전달되지만 음성 인식 편향에는 사용되지 않습니다.',
@@ -1083,6 +1094,10 @@ export const ko: typeof zhCN = {
},
},
advanced: {
multimodalPipelineTitle: '멀티모달 인식 파이프라인 (실험적)',
multimodalPipelineTitleHint: '단일 멀티모달 모델로 음성 인식을 한 번에 처리합니다. 기존 ASR + LLM 설정과 완전히 분리됩니다.',
multimodalPipelineLabel: '멀티모달 파이프라인 활성화',
multimodalPipelineHint: '활성화하면 「서비스 → AI 공급자」 페이지에 전통 / 멀티모달 전환이 나타납니다. 전통 = ASR + LLM, 멀티모달 = 오디오 지원 모델 1개. 두 설정은 별도로 저장되며 자격 증명을 공유하지 않습니다.',
streamingInsertTitle: '스트리밍 입력',
streamingInsertTitleLinux: '스트리밍 입력 (실험적)',
streamingInsertDesc:
+15
View File
@@ -388,6 +388,7 @@ export const zhCN = {
selectHint: '左侧选一条查看详情。',
recorded: '录音 {{duration}}',
stepAsr: '识别',
multimodalPipeline: '多模态',
stepAsrHint: '松键后等待识别结果的耗时。流式识别边录边转,此值通常远小于录音时长。',
stepPolish: '润色',
stepInsert: '插入',
@@ -876,6 +877,13 @@ export const zhCN = {
asrProviderDesc: '切换后将自动选用对应凭据。',
asrTitle: 'ASR 语音(转写)',
asrDesc: '用于将录制的语音转写为文本。',
omniTitle: '多模态模型',
omniDesc: '一个模型直接接收「提示词 + 音频」一步输出最终文本(实验性管线)。',
pipelineModeLabel: '识别管线',
pipelineModeHint: '传统 = ASR 转写 + LLM 润色两段式;多模态 = 单个多模态模型一次完成。',
pipelineModeTraditional: '传统模式',
pipelineModeMultimodal: '多模态模式',
pipelineIsolationNotice: '两种模式使用完全独立的凭据配置。切换模式不会删除另一套配置,只是暂时停用;切回即恢复。',
presets: {
ark: 'ARK(火山方舟)',
deepseek: 'DeepSeek',
@@ -911,6 +919,9 @@ export const zhCN = {
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
asrLocalQwen3: '本地 Qwen3-ASR',
asrAppleSpeech: 'Apple 语音(macOS)',
omniOpenai: 'OpenAI(支持音频)',
omniGemini: 'Google Gemini',
omniDashscope: '阿里云百炼 Omni',
},
elevenLabsUploadNotice: 'ElevenLabs 会将录音上传到所配置的端点进行批量转写。',
zenmuxVocabularyNote: 'ZenMux 走 JSON 转写协议,不发送词典热词(prompt/hotwords);词典仍会进入润色链路,但不会参与语音识别偏置。',
@@ -1113,6 +1124,10 @@ export const zhCN = {
},
},
advanced: {
multimodalPipelineTitle: '多模态识别管线(实验性)',
multimodalPipelineTitleHint: '用单个多模态模型一步完成语音识别;与传统 ASR + LLM 配置完全隔离。',
multimodalPipelineLabel: '启用多模态识别管线',
multimodalPipelineHint: '开启后,「服务 → AI 提供商」页出现「传统模式 / 多模态模式」切换。传统 = ASR + LLM;多模态 = 单个支持音频的模型。两套配置分开存储、绝不共享凭据。',
streamingInsertTitle: '流式输入',
streamingInsertTitleLinux: '流式输入(实验)',
streamingInsertDesc:
+15
View File
@@ -390,6 +390,7 @@ export const zhTW: typeof zhCN = {
selectHint: '左側選一條查看詳情。',
recorded: '錄音 {{duration}}',
stepAsr: '辨識',
multimodalPipeline: '多模態',
stepAsrHint: '放開按鍵後等待辨識結果的耗時。串流辨識邊錄邊轉,此值通常遠小於錄音時長。',
stepPolish: '潤飾',
stepInsert: '插入',
@@ -878,6 +879,13 @@ export const zhTW: typeof zhCN = {
asrProviderDesc: '切換後將自動選用對應憑據。',
asrTitle: 'ASR 語音(轉寫)',
asrDesc: '用於將錄製的語音轉寫為文字。',
omniTitle: '多模態模型',
omniDesc: '一個模型直接接收「提示詞 + 音訊」一步輸出最終文字(實驗性管線)。',
pipelineModeLabel: '識別管線',
pipelineModeHint: '傳統 = ASR 轉寫 + LLM 潤色兩段式;多模態 = 單一多模態模型一次完成。',
pipelineModeTraditional: '傳統模式',
pipelineModeMultimodal: '多模態模式',
pipelineIsolationNotice: '兩種模式使用完全獨立的憑證設定。切換模式不會刪除另一套設定,只是暫時停用;切回即恢復。',
presets: {
ark: 'ARK(火山方舟)',
deepseek: 'DeepSeek',
@@ -913,6 +921,9 @@ export const zhTW: typeof zhCN = {
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
asrLocalQwen3: '本地 Qwen3-ASR',
asrAppleSpeech: 'Apple 語音(macOS)',
omniOpenai: 'OpenAI(支援音訊)',
omniGemini: 'Google Gemini',
omniDashscope: '阿里雲百煉 Omni',
},
elevenLabsUploadNotice: 'ElevenLabs 會將錄音上傳至已設定的端點進行批次轉寫。',
zenmuxVocabularyNote: 'ZenMux 走 JSON 轉寫協定,不傳送詞典熱詞(prompt/hotwords);詞典仍會進入潤色鏈路,但不會參與語音辨識偏置。',
@@ -1081,6 +1092,10 @@ export const zhTW: typeof zhCN = {
},
},
advanced: {
multimodalPipelineTitle: '多模態辨識管線(實驗性)',
multimodalPipelineTitleHint: '用單一多模態模型一步完成語音辨識;與傳統 ASR + LLM 設定完全隔離。',
multimodalPipelineLabel: '啟用多模態辨識管線',
multimodalPipelineHint: '開啟後,「服務 → AI 提供者」頁出現「傳統模式 / 多模態模式」切換。傳統 = ASR + LLM;多模態 = 單一支援音訊的模型。兩套設定分開儲存、絕不共用憑證。',
streamingInsertTitle: '流式輸入',
streamingInsertTitleLinux: '流式輸入(實驗)',
streamingInsertDesc:
+59 -51
View File
@@ -1,35 +1,35 @@
import type { CredentialsStatus } from "../types"
import { invokeOrMock } from "./shared"
import { mockCredentialsStatus } from "./mock-data"
export interface ProviderCheckResult {
ok: boolean
}
export interface ProviderModelsResult {
models: string[]
}
export function getCredentials(): Promise<CredentialsStatus> {
return invokeOrMock(
"get_credentials",
undefined,
() => mockCredentialsStatus,
)
}
export function setCredential(account: string, value: string, provider?: string): Promise<void> {
return invokeOrMock("set_credential", { account, value, provider }, () => undefined)
}
export function setActiveAsrProvider(provider: string): Promise<void> {
return invokeOrMock(
"set_active_asr_provider",
{ provider },
() => undefined,
)
}
import type { CredentialsStatus } from "../types"
import { invokeOrMock } from "./shared"
import { mockCredentialsStatus } from "./mock-data"
export interface ProviderCheckResult {
ok: boolean
}
export interface ProviderModelsResult {
models: string[]
}
export function getCredentials(): Promise<CredentialsStatus> {
return invokeOrMock(
"get_credentials",
undefined,
() => mockCredentialsStatus,
)
}
export function setCredential(account: string, value: string, provider?: string): Promise<void> {
return invokeOrMock("set_credential", { account, value, provider }, () => undefined)
}
export function setActiveAsrProvider(provider: string): Promise<void> {
return invokeOrMock(
"set_active_asr_provider",
{ provider },
() => undefined,
)
}
export function setActiveLlmProvider(provider: string): Promise<void> {
return invokeOrMock(
"set_active_llm_provider",
@@ -38,29 +38,37 @@ export function setActiveLlmProvider(provider: string): Promise<void> {
)
}
export function readCredential(account: string, provider?: string): Promise<string | null> {
return invokeOrMock<string | null>(
"read_credential",
{ account, provider },
() => null,
export function setActiveOmniProvider(provider: string): Promise<void> {
return invokeOrMock(
"set_active_omni_provider",
{ provider },
() => undefined,
)
}
export function readCredential(account: string, provider?: string): Promise<string | null> {
return invokeOrMock<string | null>(
"read_credential",
{ account, provider },
() => null,
)
}
export function validateProviderCredentials(
kind: "llm" | "asr",
kind: "llm" | "asr" | "omni",
): Promise<ProviderCheckResult> {
return invokeOrMock("validate_provider_credentials", { kind }, () => ({
ok: true,
}))
}
return invokeOrMock("validate_provider_credentials", { kind }, () => ({
ok: true,
}))
}
export function listProviderModels(
kind: "llm" | "asr",
kind: "llm" | "asr" | "omni",
): Promise<ProviderModelsResult> {
return invokeOrMock("list_provider_models", { kind }, () => ({
models:
kind === "llm"
? ["gpt-4o", "deepseek-v4-flash", "deepseek-v4-pro"]
: ["whisper-1"],
}))
}
return invokeOrMock("list_provider_models", { kind }, () => ({
models:
kind === "llm"
? ["gpt-4o", "deepseek-v4-flash", "deepseek-v4-pro"]
: ["whisper-1"],
}))
}
+1
View File
@@ -27,6 +27,7 @@ export {
setCredential,
setActiveAsrProvider,
setActiveLlmProvider,
setActiveOmniProvider,
readCredential,
validateProviderCredentials,
listProviderModels,
@@ -50,6 +50,9 @@ export let mockSettings: UserPreferences = {
microphoneDeviceName: "",
activeAsrProvider: "foundry-local-whisper",
activeLlmProvider: "ark",
pipelineMode: "traditional",
multimodalPipelineEnabled: false,
activeOmniProvider: "custom",
llmThinkingEnabled: false,
useSystemProxy: true,
restoreClipboardAfterPaste: true,
@@ -534,8 +537,10 @@ export const mockHotkeyCapability: HotkeyCapability = {
export const mockCredentialsStatus: CredentialsStatus = {
activeAsrProvider: "foundry-local-whisper",
activeLlmProvider: "ark",
pipelineMode: "traditional",
asrConfigured: true,
llmConfigured: true,
omniConfigured: false,
volcengineConfigured: true,
arkConfigured: true,
}
+106 -62
View File
@@ -1,110 +1,154 @@
import {
areProvidersConfigured,
shouldShowProviderSetupPrompt,
} from './providerSetup';
function assertEqual(actual: boolean, expected: boolean, name: string) {
if (actual !== expected) {
throw new Error(`${name}: expected ${expected}, got ${actual}`);
}
}
assertEqual(
import {
areProvidersConfigured,
shouldShowProviderSetupPrompt,
} from './providerSetup';
function assertEqual(actual: boolean, expected: boolean, name: string) {
if (actual !== expected) {
throw new Error(`${name}: expected ${expected}, got ${actual}`);
}
}
assertEqual(
areProvidersConfigured({
activeAsrProvider: 'volcengine',
activeLlmProvider: 'ark',
pipelineMode: 'traditional',
asrConfigured: true,
llmConfigured: true,
omniConfigured: false,
volcengineConfigured: true,
arkConfigured: true,
}),
true,
'configured when ASR and LLM are both ready',
);
assertEqual(
}),
true,
'configured when ASR and LLM are both ready',
);
assertEqual(
areProvidersConfigured({
activeAsrProvider: 'volcengine',
activeLlmProvider: 'ark',
pipelineMode: 'traditional',
asrConfigured: false,
llmConfigured: true,
omniConfigured: false,
volcengineConfigured: false,
arkConfigured: true,
}),
false,
'not configured when ASR provider is missing',
);
assertEqual(
}),
false,
'not configured when ASR provider is missing',
);
assertEqual(
areProvidersConfigured({
activeAsrProvider: 'volcengine',
activeLlmProvider: 'ark',
pipelineMode: 'traditional',
asrConfigured: true,
llmConfigured: false,
omniConfigured: false,
volcengineConfigured: true,
arkConfigured: false,
}),
false,
'not configured when LLM provider is missing',
);
assertEqual(
}),
false,
'not configured when LLM provider is missing',
);
assertEqual(
areProvidersConfigured({
activeAsrProvider: 'whisper',
activeLlmProvider: 'ark',
pipelineMode: 'traditional',
asrConfigured: true,
llmConfigured: true,
omniConfigured: false,
volcengineConfigured: false,
arkConfigured: true,
}),
true,
'configured when active ASR is non-volcengine but already ready',
);
assertEqual(
shouldShowProviderSetupPrompt(
{
}),
true,
'configured when active ASR is non-volcengine but already ready',
);
assertEqual(
shouldShowProviderSetupPrompt(
{
activeAsrProvider: 'whisper',
activeLlmProvider: 'ark',
pipelineMode: 'traditional',
asrConfigured: false,
llmConfigured: false,
omniConfigured: false,
volcengineConfigured: false,
arkConfigured: false,
},
null,
),
true,
'show first-run prompt when providers are missing and no prompt was seen',
);
assertEqual(
shouldShowProviderSetupPrompt(
{
},
null,
),
true,
'show first-run prompt when providers are missing and no prompt was seen',
);
assertEqual(
shouldShowProviderSetupPrompt(
{
activeAsrProvider: 'whisper',
activeLlmProvider: 'ark',
pipelineMode: 'traditional',
asrConfigured: false,
llmConfigured: false,
omniConfigured: false,
volcengineConfigured: false,
arkConfigured: false,
},
'1',
),
false,
'do not repeat first-run prompt after the user has deferred it in this session',
);
assertEqual(
shouldShowProviderSetupPrompt(
{
},
'1',
),
false,
'do not repeat first-run prompt after the user has deferred it in this session',
);
assertEqual(
shouldShowProviderSetupPrompt(
{
activeAsrProvider: 'whisper',
activeLlmProvider: 'ark',
pipelineMode: 'traditional',
asrConfigured: true,
llmConfigured: true,
omniConfigured: false,
volcengineConfigured: false,
arkConfigured: true,
},
null,
),
false,
},
null,
),
false,
'do not show prompt when providers are already configured',
);
assertEqual(
areProvidersConfigured({
activeAsrProvider: 'volcengine',
activeLlmProvider: 'ark',
pipelineMode: 'multimodal',
asrConfigured: false,
llmConfigured: false,
omniConfigured: true,
volcengineConfigured: false,
arkConfigured: false,
}),
true,
'multimodal mode only requires the omni model',
);
assertEqual(
areProvidersConfigured({
activeAsrProvider: 'volcengine',
activeLlmProvider: 'ark',
pipelineMode: 'multimodal',
asrConfigured: true,
llmConfigured: true,
omniConfigured: false,
volcengineConfigured: true,
arkConfigured: true,
}),
false,
'multimodal mode ignores traditional ASR/LLM readiness',
);
+15 -11
View File
@@ -1,16 +1,20 @@
import type { CredentialsStatus } from './types';
export const PROVIDER_SETUP_PROMPT_DEFERRED_KEY = 'ol.providerSetupPromptDeferredThisSession';
import type { CredentialsStatus } from './types';
export const PROVIDER_SETUP_PROMPT_DEFERRED_KEY = 'ol.providerSetupPromptDeferredThisSession';
export function areProvidersConfigured(credentials: CredentialsStatus): boolean {
// 多模态(Omni)模式:只要求多模态模型已配置;传统 ASR/LLM 两套在该模式下不参与。
if (credentials.pipelineMode === 'multimodal') {
return credentials.omniConfigured === true;
}
const asrConfigured = credentials.asrConfigured ?? credentials.volcengineConfigured;
const llmConfigured = credentials.llmConfigured ?? credentials.arkConfigured;
return asrConfigured && llmConfigured;
}
export function shouldShowProviderSetupPrompt(
credentials: CredentialsStatus,
promptDeferredValue: string | null,
): boolean {
return !areProvidersConfigured(credentials) && promptDeferredValue !== '1';
}
export function shouldShowProviderSetupPrompt(
credentials: CredentialsStatus,
promptDeferredValue: string | null,
): boolean {
return !areProvidersConfigured(credentials) && promptDeferredValue !== '1';
}
@@ -18,6 +18,9 @@ function assert(condition: boolean, message: string) {
const previousPrefs: UserPreferences = {
hotkey: { trigger: 'rightOption', mode: 'toggle' },
dictationHotkey: { primary: 'RightOption', modifiers: [] },
pipelineMode: 'traditional',
multimodalPipelineEnabled: false,
activeOmniProvider: 'custom',
selectionPolishHotkey: { primary: 'RightControl', modifiers: [] },
selectionPolishStylePackId: 'builtin.light',
selectionPolishOutputMode: 'directReplace',
+17
View File
@@ -24,6 +24,11 @@ export type {
export type PolishMode = 'raw' | 'light' | 'structured' | 'formal';
/** 识别管线模式(issue #902):traditional = ASR + LLM 两段式;
* multimodal = 单个多模态模型一步完成「音频 + 提示词 → 最终文本」。
* 两套配置在凭据库中完全隔离,运行时只读当前模式。 */
export type PipelineMode = 'traditional' | 'multimodal';
export type InsertStatus = 'inserted' | 'pasteSent' | 'copiedFallback' | 'failed';
/** 概览页年度活动热力图的单日计数(date = 本地日期 YYYY-MM-DD)。 */
@@ -62,6 +67,8 @@ export interface DictationSession {
llmProvider: string | null;
/** 本次润色用的 LLM 模型 id。Raw 直通时为 null。 */
llmModel: string | null;
/** 本次会话走的识别管线模式("multimodal" / 缺失 = 传统两段式)。 */
pipelineMode?: string | null;
/** 松键后等待转写结果的实测耗时(毫秒)。流式 ASR 是收尾延迟,批式是完整转写耗时。 */
asrMs: number | null;
/** LLM 润色/翻译调用的实测耗时(毫秒)。未调用 LLM 时为 null。 */
@@ -301,6 +308,12 @@ export interface UserPreferences {
microphoneDeviceName: string;
activeAsrProvider: string;
activeLlmProvider: string;
/** 识别管线模式(实验性,issue #902)。multimodal 时各语音管线改用 omni 配置。 */
pipelineMode: PipelineMode;
/** 「多模态识别管线」实验性功能总开关(高级设置)。默认 false。 */
multimodalPipelineEnabled: boolean;
/** 多模态(Omni)模型当前激活的 provider id,镜像凭据库 omni.active。 */
activeOmniProvider: string;
/** LLM 思考模式开关。默认关闭;OpenAI 普通 chat 模型会跳过不支持的字段。详见 issue #402。 */
llmThinkingEnabled: boolean;
/** 是否使用系统代理(issue #869)。默认开启;关闭后所有请求直连,境外服务(GitHub 登录/更新等)可能连不上。 */
@@ -608,8 +621,12 @@ export interface CapsulePayload {
export interface CredentialsStatus {
activeAsrProvider: string;
activeLlmProvider: string;
/** 当前识别管线模式,前端据此渲染配置页与概览「已配置」判定。 */
pipelineMode: PipelineMode;
asrConfigured: boolean;
llmConfigured: boolean;
/** 多模态(omni)模型是否已配置。仅 multimodal 模式有意义。 */
omniConfigured: boolean;
/** 兼容旧字段(过渡期保留)。 */
volcengineConfigured: boolean;
arkConfigured: boolean;
+4
View File
@@ -447,6 +447,9 @@ export function History() {
<span style={{ display: 'flex', minWidth: 0 }} title={styleLabel(item)}>
<Pill size="sm" tone="default" style={TRUNCATED_PILL_STYLE}>{styleLabel(item)}</Pill>
</span>
{item.pipelineMode === 'multimodal' && (
<Pill size="sm" tone="blue">{t('history.multimodalPipeline')}</Pill>
)}
{/* 「录音」前缀:与下方识别/润色耗时区分——录音时长发生在松键前,
不该与流水线各步耗时加总(用户反馈"时间对不上")。 */}
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>{t('history.recorded', { duration: formatDuration(item.durationMs, t) })}</span>
@@ -457,6 +460,7 @@ export function History() {
)}
{item.hasAudioRecording
&& !audioMissingIds.has(item.id)
&& item.pipelineMode !== 'multimodal'
&& (item.errorCode === 'transcribeFailed' || item.errorCode === 'emptyTranscript') && (
<Btn icon="refresh" variant="ghost" size="sm" disabled={retranscribing} onClick={() => void onRetranscribe()}>
{retranscribing ? t('history.retranscribing') : t('history.retranscribe')}
+2
View File
@@ -65,8 +65,10 @@ export function Overview({ onOpenHistory }: OverviewProps) {
const [creds, setCreds] = useState<CredentialsStatus>({
activeAsrProvider: 'volcengine',
activeLlmProvider: 'ark',
pipelineMode: 'traditional',
asrConfigured: false,
llmConfigured: false,
omniConfigured: false,
volcengineConfigured: false,
arkConfigured: false,
});
@@ -0,0 +1,42 @@
// 高级 → 实验性:多模态识别管线(issue #902)总开关。
// 开启后在 服务 → AI 提供商 顶部出现「传统模式 / 多模态模式」切换:
// 多模态模式用一个模型一步完成「提示词 + 音频 → 最终文本」,
// 凭据走独立 omni 命名空间,与传统 ASR/LLM 两套配置完全隔离、并存但停用。
import { useTranslation } from 'react-i18next';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { Card } from '../_atoms';
import { SettingRow, SectionTitle, Toggle } from './shared';
export function MultimodalPipelineSection() {
const { t } = useTranslation();
const { prefs, updatePrefs } = useHotkeySettings();
if (!prefs) {
return (
<Card>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
</Card>
);
}
const onToggle = (multimodalPipelineEnabled: boolean) => {
void updatePrefs({ ...prefs, multimodalPipelineEnabled }).catch(error => {
console.error('[settings] failed to update multimodal pipeline flag', error);
});
};
return (
<Card>
<SectionTitle hint={t('settings.advanced.multimodalPipelineTitleHint')}>
{t('settings.advanced.multimodalPipelineTitle')}
</SectionTitle>
<SettingRow
label={t('settings.advanced.multimodalPipelineLabel')}
desc={t('settings.advanced.multimodalPipelineHint')}
>
<Toggle on={prefs.multimodalPipelineEnabled} onToggle={onToggle} />
</SettingRow>
</Card>
);
}
@@ -10,6 +10,7 @@ import {
readCredential,
setActiveAsrProvider,
setActiveLlmProvider,
setActiveOmniProvider,
setCredential,
validateProviderCredentials,
} from '../../lib/ipc';
@@ -18,7 +19,15 @@ import { useMobileLayout } from '../../lib/useMobileLayout';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { SelectLite, type SelectOption } from '../../components/ui/SelectLite';
import { Card } from '../_atoms';
import { SettingRow, SectionTitle, Toggle, inputStyle, ASR_PRESETS, type AsrPresetId } from './shared';
import {
SettingRow,
SectionTitle,
Toggle,
inputStyle,
segmentedTrackStyle,
ASR_PRESETS,
type AsrPresetId,
} from './shared';
import {
parseAdvancedAsrConfig,
serializeAdvancedAsrConfig,
@@ -186,6 +195,41 @@ export const LLM_PRESETS = [
type LlmPresetId = typeof LLM_PRESETS[number]['id'];
// 多模态(Omni)模型预设(issue #902):一个模型同时接收「提示词 + 音频」一步输出
// 最终文本。凭据走独立 `omni.*` 命名空间,与上方 LLM/ASR 两套配置完全隔离。
// - openai : OpenAI 官方(gpt-4o-audio-preview 等,input_audio part)
// - gemini : Gemini 原生 generateContent(inlineData audio/wav)
// - dashscope-omni: 阿里云百炼 OpenAI 兼容通道(qwen3-omni-flash 等)
// - custom : 任意 OpenAI 兼容多模态网关
export const OMNI_PRESETS = [
{
id: 'openai',
nameKey: 'omniOpenai',
baseUrl: 'https://api.openai.com/v1',
modelPlaceholder: 'gpt-4o-audio-preview',
},
{
id: 'gemini',
nameKey: 'omniGemini',
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
modelPlaceholder: 'gemini-2.5-flash',
},
{
id: 'dashscope-omni',
nameKey: 'omniDashscope',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
modelPlaceholder: 'qwen3-omni-flash',
},
{
id: 'custom',
nameKey: 'custom',
baseUrl: '',
modelPlaceholder: '',
},
] as const;
type OmniPresetId = typeof OMNI_PRESETS[number]['id'];
const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration';
// ASR_PRESETS 已上移到 settings/shared.tsx 作为单一来源(AsrPresetId 由其派生,
@@ -243,12 +287,16 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
// 会覆盖后发的 commit。
const [llmProvider, setLlmProvider] = useState<LlmPresetId>('ark');
const [asrProvider, setAsrProvider] = useState<AsrPresetId>('volcengine');
const [omniProvider, setOmniProvider] = useState<OmniPresetId>('custom');
const [committedLlmProvider, setCommittedLlmProvider] = useState<LlmPresetId>('ark');
const [committedAsrProvider, setCommittedAsrProvider] = useState<AsrPresetId>('volcengine');
const [committedOmniProvider, setCommittedOmniProvider] = useState<OmniPresetId>('custom');
const llmSwitchSeqRef = useRef(0);
const asrSwitchSeqRef = useRef(0);
const omniSwitchSeqRef = useRef(0);
const [llmModelRevision, setLlmModelRevision] = useState(0);
const [asrModelRevision, setAsrModelRevision] = useState(0);
const [omniModelRevision, setOmniModelRevision] = useState(0);
const os = detectOS();
const unifiedBailian = committedAsrProvider === 'bailian';
const [bailianModel, setBailianModel] = useState('');
@@ -371,6 +419,10 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
const asrId = knownAsr ? knownAsr.id : 'volcengine';
setAsrProvider(asrId);
setCommittedAsrProvider(asrId);
const knownOmni = OMNI_PRESETS.find(x => x.id === prefs.activeOmniProvider);
const omniId = knownOmni ? knownOmni.id : 'custom';
setOmniProvider(omniId);
setCommittedOmniProvider(omniId);
}, [prefs, os]);
// issue #219 / #220 P2:
@@ -528,14 +580,69 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
}
};
// 多模态(Omni)模型切换:语义与 LLM 卡完全一致(受控下拉立即反馈 + committed
// 控制 CredentialField remount + seq 守卫防 stale 覆盖),只是凭据落到 omni.* 槽。
const onOmniProviderChange = async (id: OmniPresetId) => {
setOmniProvider(id);
const seq = ++omniSwitchSeqRef.current;
emitSaved('saving', t('common.saving'));
let backendSwitched = false;
try {
await setActiveOmniProvider(id);
backendSwitched = true;
if (seq !== omniSwitchSeqRef.current) return;
if (prefs) {
const next = { ...prefs, activeOmniProvider: id };
await updatePrefs(next);
if (seq !== omniSwitchSeqRef.current) return;
}
const preset = OMNI_PRESETS.find(p => p.id === id);
// 切到非 custom 预设强制覆盖 endpoint/model 默认值(与 LLM 卡同语义),
// 保证「切换」真切到位,不残留旧厂商的槽值。
if (preset && preset.id !== 'custom') {
if (preset.baseUrl) {
await setCredential('omni.endpoint', preset.baseUrl);
if (seq !== omniSwitchSeqRef.current) return;
}
if (preset.modelPlaceholder) {
await setCredential('omni.model', preset.modelPlaceholder);
if (seq !== omniSwitchSeqRef.current) return;
}
}
setCommittedOmniProvider(id);
emitSaved('saved', t('common.saved'));
} catch (err) {
if (seq === omniSwitchSeqRef.current) {
emitSaved('failed', t('common.operationFailed'));
if (!backendSwitched) {
setOmniProvider(committedOmniProvider);
}
}
console.error('[settings] switch omni provider failed', err);
}
};
// 识别管线模式(issue #902):两套配置并存但停用——切换只改偏好,
// 不删除另一套凭据,切回即恢复;运行时只读当前模式。
const onPipelineModeChange = (mode: 'traditional' | 'multimodal') => {
if (!prefs) return;
void updatePrefs(current => ({ ...current, pipelineMode: mode })).catch(error => {
console.error('[settings] failed to update pipeline mode', error);
emitSaved('failed', t('common.operationFailed'));
});
};
// preset 决定 placeholder 与 default —— 必须跟着 committed*Provider 走,
// 否则受控 <select> 立刻切到新厂商,但凭据字段还在显示旧 entry,placeholder
// 会先于实际数据切换、视觉上对不上。
const preset = LLM_PRESETS.find(p => p.id === committedLlmProvider) ?? LLM_PRESETS[LLM_PRESETS.length - 1];
const codexOAuthSelected = committedLlmProvider === 'codex_oauth';
const asrPreset = visibleAsrPresets.find(p => p.id === committedAsrProvider);
const omniPreset = OMNI_PRESETS.find(p => p.id === committedOmniProvider);
const showLlm = kind === 'all' || kind === 'llm';
const showAsr = kind === 'all' || kind === 'asr';
const multimodalEnabled = prefs?.multimodalPipelineEnabled === true;
const multimodalMode = multimodalEnabled && prefs?.pipelineMode === 'multimodal';
return (
<>
{kind === 'all' && (
@@ -543,7 +650,41 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
{t('settings.providers.credentialStorageNotice')}
</div>
)}
{showLlm && (
{kind === 'all' && multimodalEnabled && (
<div style={{ marginBottom: 12 }}>
<SettingRow
label={t('settings.providers.pipelineModeLabel')}
desc={t('settings.providers.pipelineModeHint')}
>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: mobile ? 'wrap' : 'nowrap' }}>
<div style={segmentedTrackStyle}>
{(['traditional', 'multimodal'] as const).map(mode => (
<button
key={mode}
onClick={() => onPipelineModeChange(mode)}
style={{
padding: '5px 12px', fontSize: 12, fontWeight: 500, border: 0, borderRadius: 6,
fontFamily: 'inherit',
background: prefs?.pipelineMode === mode ? 'var(--ol-segmented-active-bg)' : 'transparent',
color: prefs?.pipelineMode === mode ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
boxShadow: prefs?.pipelineMode === mode ? 'var(--ol-segmented-active-shadow)' : 'none',
cursor: 'default',
}}
>
{mode === 'traditional'
? t('settings.providers.pipelineModeTraditional')
: t('settings.providers.pipelineModeMultimodal')}
</button>
))}
</div>
</div>
</SettingRow>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', lineHeight: 1.5, paddingLeft: 2 }}>
{t('settings.providers.pipelineIsolationNotice')}
</div>
</div>
)}
{showLlm && !multimodalMode && (
<Card>
<div style={{ marginBottom: 10 }}>
<SectionTitle>{t('settings.providers.llmTitle')}</SectionTitle>
@@ -605,7 +746,7 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
</Card>
)}
{showAsr && (
{showAsr && !multimodalMode && (
<Card>
<div style={{ marginBottom: 10 }}>
<SectionTitle>{t('settings.providers.asrTitle')}</SectionTitle>
@@ -884,6 +1025,70 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
</div>
</Card>
)}
{showLlm && multimodalMode && (
<Card>
<div style={{ marginBottom: 10 }}>
<SectionTitle>{t('settings.providers.omniTitle')}</SectionTitle>
</div>
<SettingRow label={t('settings.providers.providerLabel')}>
<SelectLite
value={omniProvider}
onChange={next => onOmniProviderChange(next as OmniPresetId)}
options={OMNI_PRESETS.map(p => ({
value: p.id,
label: t(`settings.providers.presets.${p.nameKey}`),
}))}
ariaLabel={t('settings.providers.providerLabel')}
style={{ ...inputStyle, width: '100%', maxWidth: mobile ? '100%' : 200 }}
/>
</SettingRow>
<CredentialField
key={`${committedOmniProvider}:api_key`}
label={t('settings.providers.apiKeyLabel')}
account="omni.api_key"
mono
mask
/>
<CredentialField
key={`${committedOmniProvider}:endpoint`}
label={t('settings.providers.baseUrlLabel')}
account="omni.endpoint"
placeholder={omniPreset?.baseUrl || 'https://your-endpoint/v1'}
/>
{committedOmniProvider === 'custom' && (
<>
<CredentialField
key="omni:temperature"
label={t('settings.providers.temperatureLabel')}
account="omni.temperature"
placeholder={t('settings.providers.temperaturePlaceholder')}
mono
/>
<CredentialField
key="omni:extra_headers"
label={t('settings.providers.extraHeadersLabel')}
account="omni.extra_headers"
placeholder={t('settings.providers.extraHeadersPlaceholder')}
mono
mask
/>
</>
)}
<CredentialField
key={`${committedOmniProvider}:model:${omniModelRevision}`}
label={t('settings.providers.modelLabel')}
account="omni.model"
placeholder={omniPreset?.modelPlaceholder || 'model-name'}
mono
/>
<ProviderTools
key={`omni:${committedOmniProvider}`}
kind="omni"
modelAccount="omni.model"
onModelSelected={() => setOmniModelRevision(v => v + 1)}
/>
</Card>
)}
</>
);
}
@@ -1074,7 +1279,7 @@ function BailianProtocolHint({ currentModel }: { currentModel: string }) {
type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error';
function ProviderTools({ kind, modelAccount, provider, onModelSelected, showFetchModels = true }: { kind: 'llm' | 'asr'; modelAccount: string; provider?: string; onModelSelected: () => void; showFetchModels?: boolean }) {
function ProviderTools({ kind, modelAccount, provider, onModelSelected, showFetchModels = true }: { kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; showFetchModels?: boolean }) {
const { t } = useTranslation();
const mobile = useMobileLayout();
const [models, setModels] = useState<string[]>([]);
@@ -15,6 +15,7 @@ import { PermissionsSection } from './PermissionsSection';
import { DataStorageSection } from './DataStorageSection';
import { LocalModelSection } from './LocalModelSection';
import { DebugToolsSection } from './DebugToolsSection';
import { MultimodalPipelineSection } from './MultimodalPipelineSection';
import { CodingAgentSection } from './CodingAgentSection';
import { ClaudeConsoleSection } from './ClaudeConsoleSection';
import { BetaChannelSection } from './BetaChannelSection';
@@ -116,6 +117,7 @@ export function AdvancedTab() {
<>
{showDesktopAdvanced && os !== 'win' && <CodingAgentSection />}
{showDesktopAdvanced && os !== 'win' && <ClaudeConsoleSection />}
<MultimodalPipelineSection />
{showDebugTools && <DebugToolsSection />}
</>
);