diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index d11b35f..5fb108b 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -1,16 +1,21 @@ use anyhow::Result; use tokio::io::AsyncBufReadExt; -use typex_config::AppConfig; +use typex_config::{AppConfig, LogLevel}; use typex_core::{TypeXBuildOptions, build_typex_from_config}; #[tokio::main] async fn main() -> Result<()> { - tracing_subscriber::fmt() - .with_env_filter("typex=debug") - .init(); + let config_path = std::path::Path::new("config.toml"); + let config_exists = config_path.exists(); + let config = load_config(config_path)?; + init_tracing(config.logging.level); + if config_exists { + tracing::info!("loaded config from {}", config_path.display()); + } else { + tracing::info!("using default config"); + } - let config = load_config()?; let input_file = parse_input_arg()?; let options = if input_file.is_some() { TypeXBuildOptions::file() @@ -79,6 +84,19 @@ async fn main() -> Result<()> { Ok(()) } +fn init_tracing(level: LogLevel) { + let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(typex_log_filter(level))); + tracing_subscriber::fmt().with_env_filter(env_filter).init(); +} + +fn typex_log_filter(level: LogLevel) -> String { + let level = level.as_str(); + format!( + "typex={level},typex_cli={level},typex_core={level},typex_pipeline={level},typex_asr={level},typex_llm={level},typex_plugin={level},typex_audio={level},typex_injector={level}" + ) +} + fn parse_input_arg() -> Result> { let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { @@ -92,14 +110,10 @@ fn parse_input_arg() -> Result> { Ok(None) } -fn load_config() -> Result { - let config_path = std::path::Path::new("config.toml"); +fn load_config(config_path: &std::path::Path) -> Result { if config_path.exists() { - let config = AppConfig::load(config_path)?; - tracing::info!("loaded config from {}", config_path.display()); - Ok(config) + AppConfig::load(config_path) } else { - tracing::info!("using default config"); Ok(AppConfig::default()) } } diff --git a/apps/desktop/frontend/index.html b/apps/desktop/frontend/index.html index 62474f9..295a38a 100644 --- a/apps/desktop/frontend/index.html +++ b/apps/desktop/frontend/index.html @@ -1405,6 +1405,21 @@

+ +
+
+
+ + +
+
+
@@ -1923,6 +1938,7 @@

document.getElementById('injector-method').value = currentConfig.injector.method || 'clipboard'; document.getElementById('pipeline-performance').value = currentConfig.pipeline.performance || 'balanced'; + document.getElementById('logging-level').value = currentConfig.logging?.level || 'info'; document.getElementById('history-log-limit').value = currentConfig.history.log_limit; @@ -1982,6 +1998,8 @@

if (document.getElementById('plugin-cleaner').checked) plugins.push('text_cleaner'); currentConfig.pipeline.plugins = plugins; currentConfig.pipeline.performance = document.getElementById('pipeline-performance').value || 'balanced'; + currentConfig.logging = currentConfig.logging || {}; + currentConfig.logging.level = document.getElementById('logging-level').value || 'info'; currentConfig.injector.method = document.getElementById('injector-method').value || 'clipboard'; currentConfig.audio.device = document.getElementById('audio-device').value || null; diff --git a/apps/desktop/frontend/locales/en.json b/apps/desktop/frontend/locales/en.json index a8d7352..ca92a43 100644 --- a/apps/desktop/frontend/locales/en.json +++ b/apps/desktop/frontend/locales/en.json @@ -88,6 +88,14 @@ "settings.new_llm_connection": "New LLM Connection", "settings.advanced": "Advanced", "settings.advanced_desc": "System prompt, plugin pipeline, and experimental performance options.", + "settings.logging": "Logging", + "settings.logging_level": "Log level", + "settings.logging_help": "Controls TypeX diagnostic logs. Use Debug to inspect ASR, plugin, and LLM text transformations. Debug logs may include transcribed text.", + "settings.log_level_error": "Error", + "settings.log_level_warn": "Warn", + "settings.log_level_info": "Info", + "settings.log_level_debug": "Debug", + "settings.log_level_trace": "Trace", "settings.llm_provider_settings": "AI Provider Settings", "settings.llm_disabled_advanced": "Enable AI Polish above to configure provider, endpoint, model, and prompt.", "settings.test_connection": "Test", diff --git a/apps/desktop/frontend/locales/zh-CN.json b/apps/desktop/frontend/locales/zh-CN.json index 3c35ca2..d6418e3 100644 --- a/apps/desktop/frontend/locales/zh-CN.json +++ b/apps/desktop/frontend/locales/zh-CN.json @@ -88,6 +88,14 @@ "settings.new_llm_connection": "新的 LLM 连接", "settings.advanced": "高级设置", "settings.advanced_desc": "系统 Prompt、插件流水线和实验性性能选项。", + "settings.logging": "日志", + "settings.logging_level": "日志等级", + "settings.logging_help": "控制 TypeX 诊断日志。排查 ASR、插件和 LLM 文本处理问题时可切换到 Debug;Debug 日志可能包含你的语音转写内容。", + "settings.log_level_error": "Error", + "settings.log_level_warn": "Warn", + "settings.log_level_info": "Info", + "settings.log_level_debug": "Debug", + "settings.log_level_trace": "Trace", "settings.llm_provider_settings": "AI Provider 设置", "settings.llm_disabled_advanced": "先在上方启用 AI 润色,再配置 provider、endpoint、model 和 prompt。", "settings.test_connection": "测试", diff --git a/apps/desktop/src/lib.rs b/apps/desktop/src/lib.rs index 4e1a5bd..5c1d933 100644 --- a/apps/desktop/src/lib.rs +++ b/apps/desktop/src/lib.rs @@ -18,7 +18,8 @@ use tauri::{ tray::TrayIconBuilder, }; use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; -use typex_config::{AppConfig, AsrConnection, LlmConnection}; +use tracing_subscriber::{EnvFilter, Registry, prelude::*}; +use typex_config::{AppConfig, AsrConnection, LlmConnection, LogLevel}; use typex_core::typex_asr::AsrProvider; use typex_core::typex_llm::LlmProvider; use typex_core::{TypeX, TypeXBuildOptions, build_typex_from_config}; @@ -27,6 +28,7 @@ use typex_core::{TypeX, TypeXBuildOptions, build_typex_from_config}; /// Awaiting it yields the accumulator JoinHandle, which in turn yields the PCM data. type RecordingAccFuture = tokio::task::JoinHandle>>>>; +type LogReloadHandle = tracing_subscriber::reload::Handle; struct AppState { config: Mutex, @@ -46,6 +48,7 @@ struct AppState { overlay_save_token: AtomicU64, /// Prevents concurrent recording start attempts. recording_starting: AtomicBool, + log_filter: Option, /// Tokio runtime handle — needed because global shortcut callbacks run outside Tokio context. rt: tokio::runtime::Handle, } @@ -71,6 +74,26 @@ fn db_path(app: &tauri::AppHandle) -> std::path::PathBuf { .join("typex.db") } +fn typex_log_filter(level: LogLevel) -> String { + let level = level.as_str(); + format!( + "typex_desktop_lib={level},typex_desktop={level},typex_core={level},typex_pipeline={level},typex_asr={level},typex_llm={level},typex_plugin={level},typex_audio={level},typex_injector={level}" + ) +} + +fn env_filter_for_level(level: LogLevel) -> EnvFilter { + EnvFilter::new(typex_log_filter(level)) +} + +fn reload_log_filter(handle: &Option, level: LogLevel) { + let Some(handle) = handle else { + return; + }; + if let Err(e) = handle.reload(env_filter_for_level(level)) { + tracing::warn!("failed to reload log filter: {}", e); + } +} + fn init_db(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS history ( @@ -612,6 +635,7 @@ fn save_config(state: tauri::State, mut config: AppConfig) -> Result<( } // 3. Update in-memory config immediately + reload_log_filter(&state.log_filter, config.logging.level); *state.config.lock().unwrap() = config.clone(); // 4. Rebuild pipeline (non-fatal: keep old pipeline on failure) @@ -1100,11 +1124,22 @@ fn set_language(state: tauri::State, language: String) -> Result<(), S #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Initialize tracing subscriber so log messages are visible in console. - // Uses RUST_LOG env var for filtering (e.g. RUST_LOG=typex_audio=trace,typex_desktop=info). - // Falls back to "info" level if RUST_LOG is not set. - let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); - tracing_subscriber::fmt().with_env_filter(env_filter).init(); + // RUST_LOG takes precedence over the UI-configured log level. + let log_filter = match EnvFilter::try_from_default_env() { + Ok(env_filter) => { + tracing_subscriber::fmt().with_env_filter(env_filter).init(); + None + } + Err(_) => { + let (filter_layer, handle) = + tracing_subscriber::reload::Layer::new(env_filter_for_level(LogLevel::Info)); + tracing_subscriber::registry() + .with(filter_layer) + .with(tracing_subscriber::fmt::layer()) + .init(); + Some(handle) + } + }; // Create a dedicated Tokio runtime that lives for the entire application // lifetime (run() blocks until exit). Its handle is shared with global @@ -1149,6 +1184,7 @@ pub fn run() { } config }; + reload_log_filter(&log_filter, config.logging.level); let current_shortcut = config.shortcut.record.clone(); @@ -1189,6 +1225,7 @@ pub fn run() { overlay_error_token: AtomicU64::new(0), overlay_save_token: AtomicU64::new(0), recording_starting: AtomicBool::new(false), + log_filter, rt: rt.clone(), }); diff --git a/config.example.toml b/config.example.toml index 19cff20..b397e61 100644 --- a/config.example.toml +++ b/config.example.toml @@ -33,6 +33,9 @@ provider = "mock" # mock | openai-compatible performance = "balanced" # low | balanced | high plugins = ["filler_remover", "text_cleaner"] +[logging] +level = "info" # error | warn | info | debug | trace + [injector] method = "clipboard" # clipboard | platform diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index ab5c5a4..1f24271 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -29,6 +29,9 @@ pub struct AppConfig { #[serde(default)] pub ui: UiConfig, + + #[serde(default)] + pub logging: LoggingConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -371,6 +374,35 @@ pub struct UiConfig { pub theme: String, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LoggingConfig { + #[serde(default)] + pub level: LogLevel, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { + Error, + Warn, + #[default] + Info, + Debug, + Trace, +} + +impl LogLevel { + pub fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + Self::Debug => "debug", + Self::Trace => "trace", + } + } +} + fn default_language() -> String { "auto".into() } diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 6173685..0278517 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -65,6 +65,8 @@ fn test_llm_config_roundtrip_from_legacy_json() { Some("You are a helpful assistant.") ); + assert_eq!(config.logging.level, LogLevel::Info); + let toml_str = toml::to_string_pretty(&config).expect("AppConfig → TOML should work"); assert!(toml_str.contains("connections")); assert!(toml_str.contains("active_connection")); @@ -269,6 +271,17 @@ fn test_option_none_omitted_from_toml() { ); } +#[test] +fn test_logging_level_deserializes_from_config() { + let toml_str = r#" +[logging] +level = "debug" +"#; + + let config: AppConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.logging.level, LogLevel::Debug); +} + /// Verify that when the frontend sends `null` for an Option field, /// it correctly deserializes to None. #[test] diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index d83f721..fa41dc9 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -60,6 +60,7 @@ impl Pipeline { let plugins = plugins.clone(); async move { let asr_result = result?; + log_pipeline_text("asr", asr_result.is_final, &asr_result.text); let text = apply_plugins(&asr_result.text, &asr_result, &plugins).await?; @@ -76,35 +77,34 @@ impl Pipeline { plugin_stream.boxed() }; - if let Some(injector) = injector { - let accumulated = Arc::new(std::sync::Mutex::new(String::new())); - final_stream - .then(move |result| { - let injector = injector.clone(); - let accumulated = accumulated.clone(); - async move { - let output = result?; - let text_to_inject = { - let mut acc = accumulated.lock().unwrap(); - acc.push_str(&output.text); - if output.is_final { - let text = acc.clone(); - acc.clear(); - Some(text) - } else { - None - } - }; - if let Some(text) = text_to_inject { + let accumulated = Arc::new(std::sync::Mutex::new(String::new())); + final_stream + .then(move |result| { + let injector = injector.clone(); + let accumulated = accumulated.clone(); + async move { + let output = result?; + let text_to_finalize = { + let mut acc = accumulated.lock().unwrap(); + acc.push_str(&output.text); + if output.is_final { + let text = acc.clone(); + acc.clear(); + Some(text) + } else { + None + } + }; + if let Some(text) = text_to_finalize { + log_pipeline_text("final", true, &text); + if let Some(injector) = injector { Self::inject_text(injector, text).await; } - Ok(output) } - }) - .boxed() - } else { - final_stream - } + Ok(output) + } + }) + .boxed() } pub async fn run_session(&self, pcm_data: Vec) -> Result { @@ -131,27 +131,28 @@ impl Pipeline { } async fn process_text(&self, asr_result: AsrResult) -> Result { + log_pipeline_text("asr", asr_result.is_final, &asr_result.text); let text = apply_plugins(&asr_result.text, &asr_result, &self.plugins).await?; let final_text = match &self.llm { Some(llm) => { + log_pipeline_text("llm_input", true, &text); let input = futures::stream::once(async move { Ok(text) }).boxed(); let mut output = llm.optimize(input); let mut optimized = String::new(); while let Some(res) = output.next().await { - let chunk = res?.text.trim().to_string(); - if !chunk.is_empty() { - if !optimized.is_empty() { - optimized.push(' '); - } - optimized.push_str(&chunk); - } + let llm_result = res?; + log_pipeline_text("llm_output", llm_result.is_final, &llm_result.text); + optimized.push_str(&llm_result.text); } + let optimized = optimized.trim().to_string(); + log_pipeline_text("llm_output_final", true, &optimized); optimized } None => text, }; + log_pipeline_text("final", true, &final_text); if let Some(ref inj) = self.injector { Self::inject_text(inj.clone(), final_text.clone()).await; } @@ -172,6 +173,7 @@ impl Pipeline { let tx = tx.clone(); async move { if let Ok(output) = item { + log_pipeline_text("llm_input", output.is_final, &output.text); let _ = tx.send(output.text).await; } } @@ -185,6 +187,7 @@ impl Pipeline { let merged = llm_output.map(|r| { let lr = r?; + log_pipeline_text("llm_output", lr.is_final, &lr.text); Ok(PipelineOutput { text: lr.text, is_final: lr.is_final, @@ -208,6 +211,24 @@ async fn apply_plugins( }; for plugin in plugins { result = plugin.process(&result, &ctx).await?; + tracing::debug!( + target: "typex_pipeline", + stage = "plugin", + plugin = plugin.name(), + is_final = ctx.is_final, + text = %result, + "pipeline text" + ); } Ok(result) } + +fn log_pipeline_text(stage: &'static str, is_final: bool, text: &str) { + tracing::debug!( + target: "typex_pipeline", + stage, + is_final, + text = %text, + "pipeline text" + ); +}