* 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:
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]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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`,其它原样保留。
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"],
|
||||
}))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user