From 62ea58d95a77686e5c2c4bda156ac12a2d276641 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 17 Apr 2026 13:47:20 +0100 Subject: [PATCH] =?UTF-8?q?diagnostics:=20panic=20hook=20+=20frontend=20er?= =?UTF-8?q?ror=20capture=20+=20Settings=20=E2=86=92=20About=20diagnostic?= =?UTF-8?q?=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE BRIEF: For the friends beta we need verbal feedback AND technical feedback — some bugs the user cannot describe but a stack trace can. Built it in two layers, with Layer 3 deferred until there is real volume. PRIVACY POSTURE (matches Kon's local-first positioning): - All capture is to disk only. Nothing is transmitted. - The manual report bundler shows the user exactly what would be shared and lets them choose to copy / save it. - No remote endpoint, no Sentry, no opt-out telemetry. LAYER 1 — Always-on local capture: - Rust panic hook (commands/diagnostics.rs::install_panic_hook) writes each panic to ~/.kon/crashes/-.crash. Captures thread name, OS/arch, RUST_BACKTRACE state, and the panic info. Installed before tauri::Builder so it catches setup-time panics too. - Frontend window.onerror + window.unhandledrejection in src/routes/+layout.svelte forward to the new log_frontend_error Tauri command, which inserts into the existing error_log SQLite table. Best-effort: errors in the error handler itself are swallowed so logging can never crash the app. - Existing error_log table (migration v1) is now actually used — closes the TODO from crates/storage/src/database.rs:409. LAYER 2 — Manual diagnostic report: - Settings → About → Diagnostics section: Generate report → Preview → Copy / Save. - Report is plain markdown so it pastes cleanly into email, Discord, GitHub issues. Sections: app version + OS, sanitised settings JSON, recent error_log rows, last 5 crash dumps with previews, log file tail (8KB). - Preview is shown in a
with the full text the user can inspect before deciding to share. - Save writes to ~/.kon/diagnostic-reports/kon-diagnostic-.md. NEW FILES: - src-tauri/src/commands/diagnostics.rs (panic hook + 5 Tauri commands) CHANGES: - crates/storage/src/file_storage.rs: crashes_dir() + logs_dir() helpers - crates/storage/src/database.rs: ErrorLogRow + list_recent_errors() - crates/storage/src/lib.rs: re-exports - src-tauri/src/commands/mod.rs: + diagnostics module - src-tauri/src/lib.rs: install_panic_hook() before Builder; 5 new Tauri commands registered (log_frontend_error, list_recent_errors_command, list_crash_files, generate_diagnostic_report, save_diagnostic_report) - src/routes/+layout.svelte: installGlobalErrorCapture() at mount - src/lib/pages/SettingsPage.svelte: diagnostic state + buttons + preview block at the end of the About section cargo check -p kon-storage clean. Settings.svelte if/each balanced. LAYER 3 (deferred): - Optional opt-in remote reporting (self-hosted Sentry on Tartarus or similar). Not for friends beta. Open up after volume justifies it. --- crates/storage/src/database.rs | 35 +++ crates/storage/src/file_storage.rs | 13 + crates/storage/src/lib.rs | 8 +- src-tauri/src/commands/diagnostics.rs | 369 ++++++++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/models.rs | 281 ++++++++++++++---- src-tauri/src/commands/transcription.rs | 24 +- src-tauri/src/lib.rs | 10 + src/lib/pages/SettingsPage.svelte | 97 +++++++ src/routes/+layout.svelte | 33 +++ 10 files changed, 810 insertions(+), 61 deletions(-) create mode 100644 src-tauri/src/commands/diagnostics.rs diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index bb0c6dd..c9b2180 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -428,6 +428,41 @@ pub async fn log_error( Ok(()) } +/// Read the most recent N rows from `error_log`, newest first. Used by the +/// diagnostic-report bundler in Settings → About. +#[derive(Debug, Clone)] +pub struct ErrorLogRow { + pub id: i64, + pub timestamp: String, + pub context: String, + pub error_code: Option, + pub message: String, + pub metadata: Option, +} + +pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result> { + let rows = sqlx::query( + "SELECT id, timestamp, context, error_code, message, metadata \ + FROM error_log ORDER BY id DESC LIMIT ?", + ) + .bind(limit) + .fetch_all(pool) + .await + .map_err(|e| KonError::StorageError(format!("Read error_log failed: {e}")))?; + + Ok(rows + .into_iter() + .map(|r| ErrorLogRow { + id: r.get("id"), + timestamp: r.get("timestamp"), + context: r.get("context"), + error_code: r.get("error_code"), + message: r.get("message"), + metadata: r.get("metadata"), + }) + .collect()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/storage/src/file_storage.rs b/crates/storage/src/file_storage.rs index 1b1000e..d99bffc 100644 --- a/crates/storage/src/file_storage.rs +++ b/crates/storage/src/file_storage.rs @@ -26,3 +26,16 @@ pub fn database_path() -> PathBuf { pub fn recordings_dir() -> PathBuf { app_data_dir().join("recordings") } + +/// Directory for crash dumps written by the Rust panic hook. +/// Each crash is a single text file: `-.crash`. +/// Used by the diagnostic-report bundler in Settings → About. +pub fn crashes_dir() -> PathBuf { + app_data_dir().join("crashes") +} + +/// Directory for the rolling Rust log file (kon.log + rotated kon.log.1, etc). +/// Subscribers configured in src-tauri/src/lib.rs at startup. +pub fn logs_dir() -> PathBuf { + app_data_dir().join("logs") +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 02e4d09..b4ed5be 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -5,8 +5,8 @@ pub mod migrations; pub use database::{ add_dictionary_entry, complete_task, count_transcripts, delete_dictionary_entry, delete_task, delete_transcript, get_setting, get_transcript, init, insert_task, - insert_transcript, list_dictionary, list_tasks, list_transcripts, list_transcripts_paged, - log_error, search_transcripts, set_setting, update_transcript, DictionaryEntry, - InsertTranscriptParams, TaskRow, TranscriptRow, + insert_transcript, list_dictionary, list_recent_errors, list_tasks, list_transcripts, + list_transcripts_paged, log_error, search_transcripts, set_setting, update_transcript, + DictionaryEntry, ErrorLogRow, InsertTranscriptParams, TaskRow, TranscriptRow, }; -pub use file_storage::{app_data_dir, database_path, recordings_dir}; +pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir}; diff --git a/src-tauri/src/commands/diagnostics.rs b/src-tauri/src/commands/diagnostics.rs new file mode 100644 index 0000000..82618e9 --- /dev/null +++ b/src-tauri/src/commands/diagnostics.rs @@ -0,0 +1,369 @@ +//! Diagnostics: panic hook, frontend error capture, and the manual +//! diagnostic-report bundler used by Settings → About. +//! +//! Privacy posture (matches Kon's local-first positioning): +//! - All capture is to disk only. Nothing is transmitted. +//! - The manual report bundler shows the user exactly what would be +//! shared and lets them choose to copy/save/email it. +//! - No remote endpoint, no Sentry, no opt-out telemetry. + +use std::fs; +use std::io::Write; +use std::panic; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use kon_storage::{ + app_data_dir, crashes_dir, list_recent_errors, log_error, logs_dir, ErrorLogRow, +}; + +use crate::AppState; + +const DEFAULT_RECENT_ERRORS: i64 = 50; +const KON_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Install the Rust panic hook. Writes each panic to a separate file in +/// crashes_dir so the diagnostic-report bundler can attach them. Also +/// keeps the default panic message on stderr so terminal users see it. +/// +/// Called once at startup before tauri::Builder. +pub fn install_panic_hook() { + if let Err(e) = fs::create_dir_all(crashes_dir()) { + eprintln!("[diagnostics] could not create crashes dir: {e}"); + return; + } + + let default_hook = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + // Write a minimal text dump. Backtraces require RUST_BACKTRACE=1 + // and the std::backtrace API; we capture what's free. + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let short = (ts % 0xFFFF) as u16; + let path = crashes_dir().join(format!("{ts}-{short:04x}.crash")); + + let payload = format!( + "Kon crash dump\n\ + ==============\n\ + Version: {ver}\n\ + Timestamp: {ts} (UTC seconds)\n\ + Thread: {thread}\n\ + \n\ + Panic message:\n\ + {info}\n\ + \n\ + OS: {os} {arch}\n\ + RUST_BACKTRACE: {bt}\n", + ver = KON_VERSION, + ts = ts, + thread = std::thread::current() + .name() + .unwrap_or(""), + info = info, + os = std::env::consts::OS, + arch = std::env::consts::ARCH, + bt = std::env::var("RUST_BACKTRACE").unwrap_or_else(|_| "0".to_string()), + ); + + if let Ok(mut f) = fs::File::create(&path) { + let _ = f.write_all(payload.as_bytes()); + } + + // Still call the original hook so the panic shows in the terminal. + default_hook(info); + })); +} + +/// Tauri command: persist a frontend-side error to error_log. +/// +/// Wired from the global window.onerror + window.unhandledrejection +/// handlers in the Svelte root layout. Also called explicitly by +/// invokeWithToast on failure paths so we have a unified record. +#[tauri::command] +pub async fn log_frontend_error( + state: tauri::State<'_, AppState>, + context: String, + message: String, + stack: Option, +) -> Result<(), String> { + let metadata = stack.map(|s| { + // Truncate stacks to keep the table from bloating; the full stack + // is also visible in the browser console anyway. + s.chars().take(2000).collect::() + }); + + log_error( + &state.db, + if context.is_empty() { "frontend" } else { &context }, + Some("FRONTEND_ERROR"), + &message, + metadata.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorLogDto { + pub id: i64, + pub timestamp: String, + pub context: String, + pub error_code: Option, + pub message: String, + pub metadata: Option, +} + +impl From for ErrorLogDto { + fn from(r: ErrorLogRow) -> Self { + Self { + id: r.id, + timestamp: r.timestamp, + context: r.context, + error_code: r.error_code, + message: r.message, + metadata: r.metadata, + } + } +} + +#[tauri::command] +pub async fn list_recent_errors_command( + state: tauri::State<'_, AppState>, + limit: Option, +) -> Result, String> { + let n = limit.unwrap_or(DEFAULT_RECENT_ERRORS).clamp(1, 1000); + list_recent_errors(&state.db, n) + .await + .map(|rows| rows.into_iter().map(ErrorLogDto::from).collect()) + .map_err(|e| e.to_string()) +} + +/// One crash file on disk. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CrashFile { + pub filename: String, + pub mtime_secs: u64, + pub size_bytes: u64, + pub preview: String, // first ~600 chars +} + +#[tauri::command] +pub async fn list_crash_files() -> Result, String> { + let dir = crashes_dir(); + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return Ok(Vec::new()), // no crashes dir = no crashes, perfect + }; + + let mut out: Vec = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("crash") { + continue; + } + let meta = match fs::metadata(&path) { + Ok(m) => m, + Err(_) => continue, + }; + let mtime_secs = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + let size_bytes = meta.len(); + let preview = match fs::read_to_string(&path) { + Ok(s) => s.chars().take(600).collect::(), + Err(_) => String::new(), + }; + out.push(CrashFile { + filename: path + .file_name() + .and_then(|s| s.to_str()) + .map(String::from) + .unwrap_or_default(), + mtime_secs, + size_bytes, + preview, + }); + } + // Newest first + out.sort_by(|a, b| b.mtime_secs.cmp(&a.mtime_secs)); + Ok(out) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportOptions { + pub include_recent_errors: bool, + pub include_crashes: bool, + pub include_settings: bool, + pub include_log_tail: bool, +} + +impl Default for ReportOptions { + fn default() -> Self { + Self { + include_recent_errors: true, + include_crashes: true, + include_settings: true, + include_log_tail: true, + } + } +} + +/// Build a single human-readable diagnostic-report string. The user inspects +/// this in Settings → About before deciding whether to copy/save/email it. +/// +/// The string is plain markdown so it can be pasted into an email, a +/// Discord thread, or a GitHub issue without further conversion. +#[tauri::command] +pub async fn generate_diagnostic_report( + state: tauri::State<'_, AppState>, + options: Option, +) -> Result { + let opts = options.unwrap_or_default(); + let mut out = String::new(); + + out.push_str("# Kon diagnostic report\n\n"); + out.push_str(&format!("- Version: `{}`\n", KON_VERSION)); + out.push_str(&format!( + "- OS / arch: `{} / {}`\n", + std::env::consts::OS, + std::env::consts::ARCH + )); + out.push_str(&format!("- App data dir: `{}`\n", app_data_dir().display())); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + out.push_str(&format!("- Generated: unix `{}`\n", now)); + out.push_str("\n"); + out.push_str("> This report is local-only until you choose to share it. \ + Review the contents below before sending to anyone.\n\n"); + + if opts.include_settings { + out.push_str("## Settings (sanitised)\n\n"); + match kon_storage::get_setting(&state.db, "kon_preferences").await { + Ok(Some(json)) => { + out.push_str("```json\n"); + out.push_str(&json); + out.push_str("\n```\n\n"); + } + Ok(None) => out.push_str("_(no preferences saved)_\n\n"), + Err(e) => out.push_str(&format!("_(could not read preferences: {e})_\n\n")), + } + } + + if opts.include_recent_errors { + out.push_str("## Recent errors (newest first)\n\n"); + match list_recent_errors(&state.db, DEFAULT_RECENT_ERRORS).await { + Ok(rows) if rows.is_empty() => out.push_str("_(no errors logged)_\n\n"), + Ok(rows) => { + for r in rows { + out.push_str(&format!( + "- `{}` **{}** [{}]{}: {}\n", + r.timestamp, + r.context, + r.error_code.as_deref().unwrap_or("?"), + if r.metadata.is_some() { " (+meta)" } else { "" }, + r.message + .chars() + .take(400) + .collect::() + .replace('\n', " "), + )); + } + out.push('\n'); + } + Err(e) => out.push_str(&format!("_(could not read error log: {e})_\n\n")), + } + } + + if opts.include_crashes { + out.push_str("## Crash dumps\n\n"); + let crashes = list_crash_files().await.unwrap_or_default(); + if crashes.is_empty() { + out.push_str("_(no crash dumps on disk — nothing has crashed)_\n\n"); + } else { + for c in crashes.iter().take(5) { + out.push_str(&format!( + "### `{}` ({} bytes)\n\n```\n{}\n```\n\n", + c.filename, c.size_bytes, c.preview + )); + } + if crashes.len() > 5 { + out.push_str(&format!( + "_(plus {} older crash dumps in `{}`)_\n\n", + crashes.len() - 5, + crashes_dir().display() + )); + } + } + } + + if opts.include_log_tail { + out.push_str("## Log tail\n\n"); + let log_path = logs_dir().join("kon.log"); + match read_tail(&log_path, 8000) { + Ok(tail) if tail.is_empty() => { + out.push_str("_(log file empty or not yet written)_\n\n"); + } + Ok(tail) => { + out.push_str("```\n"); + out.push_str(&tail); + out.push_str("\n```\n\n"); + } + Err(_) => out.push_str("_(no log file found)_\n\n"), + } + } + + out.push_str("---\n\n"); + out.push_str("Generated by Kon. To share, copy the entire markdown above \ + and paste it into an email or issue. Email: jake@corbel.consulting.\n"); + + Ok(out) +} + +/// Read the last `n_bytes` of a file as a UTF-8 string, lossy-decoded. +/// Used to show recent log content without loading huge files. +fn read_tail(path: &PathBuf, n_bytes: u64) -> std::io::Result { + use std::io::{Read, Seek, SeekFrom}; + let mut f = fs::File::open(path)?; + let len = f.metadata()?.len(); + let start = len.saturating_sub(n_bytes); + f.seek(SeekFrom::Start(start))?; + let mut buf = Vec::new(); + f.read_to_end(&mut buf)?; + Ok(String::from_utf8_lossy(&buf).to_string()) +} + +/// Write the report to a file and return the path. Lets the user save it +/// somewhere they can attach to an email. +#[tauri::command] +pub async fn save_diagnostic_report( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + options: Option, +) -> Result { + let _ = app; // reserved for future dialog integration + let report = generate_diagnostic_report(state, options).await?; + + let dir = app_data_dir().join("diagnostic-reports"); + fs::create_dir_all(&dir).map_err(|e| format!("create dir: {e}"))?; + + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let path = dir.join(format!("kon-diagnostic-{ts}.md")); + + fs::write(&path, &report).map_err(|e| format!("write file: {e}"))?; + Ok(path.to_string_lossy().to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 334aea5..9f6a180 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod audio; pub mod clipboard; +pub mod diagnostics; pub mod hardware; pub mod hotkey; pub mod live; diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs index bcde847..ddee05f 100644 --- a/src-tauri/src/commands/models.rs +++ b/src-tauri/src/commands/models.rs @@ -1,8 +1,15 @@ +use std::sync::Arc; + +use serde::Serialize; use tauri::Emitter; use crate::AppState; +use kon_core::model_registry::{ + self, Engine, LanguageSupport, ModelEntry, +}; use kon_core::types::ModelId; use kon_transcription::model_manager; +use kon_transcription::{load_parakeet, load_whisper, LocalEngine}; /// Map legacy size strings to ModelId. fn whisper_model_id(size: &str) -> ModelId { @@ -22,6 +29,212 @@ fn parakeet_model_id(name: &str) -> ModelId { } } +fn engine_for_name( + state: &AppState, + engine_name: &str, +) -> Result, String> { + match engine_name { + "whisper" => Ok(state.whisper_engine.clone()), + "parakeet" => Ok(state.parakeet_engine.clone()), + other => Err(format!("Unknown engine: {other}")), + } +} + +fn language_support_info( + language_support: LanguageSupport, +) -> LanguageSupportInfo { + match language_support { + LanguageSupport::EnglishOnly => LanguageSupportInfo { + kind: "english-only".into(), + language_count: 1, + }, + LanguageSupport::Multilingual(count) => LanguageSupportInfo { + kind: "multilingual".into(), + language_count: count, + }, + } +} + +fn model_capability( + entry: &'static ModelEntry, + engine: &Arc, +) -> ModelRuntimeCapabilities { + let loaded_model_id = engine.loaded_model_id(); + ModelRuntimeCapabilities { + id: entry.id.to_string(), + display_name: entry.display_name.to_string(), + downloaded: model_manager::is_downloaded(&entry.id), + loaded: loaded_model_id + .as_ref() + .map(|id| id == &entry.id) + .unwrap_or(false), + language_support: language_support_info(entry.languages), + } +} + +fn load_model_from_disk(model_id: &ModelId) -> Result { + let entry = model_registry::find_model(model_id) + .ok_or_else(|| format!("Unknown model: {model_id}"))?; + + match entry.engine { + Engine::Whisper => { + let dir = model_manager::model_dir(model_id); + let model_file = entry + .files + .first() + .map(|file| dir.join(file.filename)) + .ok_or_else(|| format!("No files registered for model: {model_id}"))?; + if !model_file.exists() { + return Err(format!("Model not downloaded: {model_id}")); + } + load_whisper(&model_file).map_err(|e| e.to_string()) + } + Engine::Parakeet => { + let dir = model_manager::model_dir(model_id); + if !dir.exists() { + return Err(format!("Model not downloaded: {model_id}")); + } + load_parakeet(&dir).map_err(|e| e.to_string()) + } + Engine::Moonshine => Err("Moonshine models are not yet supported in this build".into()), + } +} + +pub fn default_model_id_for_engine(engine: &str) -> ModelId { + match engine { + "parakeet" => ModelId::new("parakeet-ctc-0.6b-int8"), + _ => ModelId::new("whisper-base-en"), + } +} + +pub async fn ensure_model_loaded( + state: &AppState, + engine_name: &str, + model_id: &str, +) -> Result<(), String> { + let model_id = ModelId::new(model_id); + let entry = model_registry::find_model(&model_id) + .ok_or_else(|| format!("Unknown model: {model_id}"))?; + + let expected_engine = match entry.engine { + Engine::Whisper => "whisper", + Engine::Parakeet => "parakeet", + Engine::Moonshine => "moonshine", + }; + if expected_engine != engine_name { + return Err(format!( + "Model {} belongs to {}, not {}", + model_id, expected_engine, engine_name + )); + } + + if !model_manager::is_downloaded(&model_id) { + return Err(format!("Model not downloaded: {model_id}")); + } + + let engine = engine_for_name(state, engine_name)?; + if engine + .loaded_model_id() + .as_ref() + .map(|loaded| loaded == &model_id) + .unwrap_or(false) + { + return Ok(()); + } + + let engine_clone = engine.clone(); + let model_id_clone = model_id.clone(); + tokio::task::spawn_blocking(move || { + let model = load_model_from_disk(&model_id_clone)?; + engine_clone.load(model, model_id_clone); + Ok::<_, String>(()) + }) + .await + .map_err(|e| e.to_string())??; + + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeCapabilities { + pub accelerators: Vec, + pub engines: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EngineRuntimeCapabilities { + pub id: String, + pub default_model_id: String, + pub loaded_model_id: Option, + pub supports_gpu: bool, + pub models: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelRuntimeCapabilities { + pub id: String, + pub display_name: String, + pub downloaded: bool, + pub loaded: bool, + pub language_support: LanguageSupportInfo, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageSupportInfo { + pub kind: String, + pub language_count: u16, +} + +#[tauri::command] +pub fn get_runtime_capabilities( + state: tauri::State<'_, AppState>, +) -> Result { + let whisper = state.whisper_engine.clone(); + let parakeet = state.parakeet_engine.clone(); + + let whisper_models = model_registry::all_models() + .iter() + .filter(|entry| entry.engine == Engine::Whisper) + .map(|entry| model_capability(entry, &whisper)) + .collect(); + let parakeet_models = model_registry::all_models() + .iter() + .filter(|entry| entry.engine == Engine::Parakeet) + .map(|entry| model_capability(entry, ¶keet)) + .collect(); + + Ok(RuntimeCapabilities { + // Current desktop build ships CPU-only inference backends. + accelerators: vec!["cpu".into()], + engines: vec![ + EngineRuntimeCapabilities { + id: "whisper".into(), + default_model_id: default_model_id_for_engine("whisper") + .to_string(), + loaded_model_id: whisper + .loaded_model_id() + .map(|model_id| model_id.to_string()), + supports_gpu: false, + models: whisper_models, + }, + EngineRuntimeCapabilities { + id: "parakeet".into(), + default_model_id: default_model_id_for_engine("parakeet") + .to_string(), + loaded_model_id: parakeet + .loaded_model_id() + .map(|model_id| model_id.to_string()), + supports_gpu: false, + models: parakeet_models, + }, + ], + }) +} + // --- Whisper model commands --- #[tauri::command] @@ -51,14 +264,12 @@ pub fn list_models() -> Result, String> { Ok(ids .into_iter() .filter(|id| id.as_str().starts_with("whisper-")) - .map(|id| { - match id.as_str() { - "whisper-tiny-en" => "Tiny".to_string(), - "whisper-base-en" => "Base".to_string(), - "whisper-small-en" => "Small".to_string(), - "whisper-medium-en" => "Medium".to_string(), - other => other.to_string(), - } + .map(|id| match id.as_str() { + "whisper-tiny-en" => "Tiny".to_string(), + "whisper-base-en" => "Base".to_string(), + "whisper-small-en" => "Small".to_string(), + "whisper-medium-en" => "Medium".to_string(), + other => other.to_string(), }) .collect()) } @@ -69,31 +280,7 @@ pub async fn load_model( size: String, ) -> Result { let id = whisper_model_id(&size); - let dir = model_manager::model_dir(&id); - let model_file = dir.join( - kon_core::model_registry::find_model(&id) - .ok_or_else(|| format!("Unknown model: {size}"))? - .files - .first() - .ok_or_else(|| format!("No files for model: {size}"))? - .filename, - ); - - if !model_file.exists() { - return Err(format!("Model not downloaded: {size}")); - } - - let engine = state.whisper_engine.clone(); - let mid = id.clone(); - tokio::task::spawn_blocking(move || { - let model = kon_transcription::load_whisper(&model_file) - .map_err(|e| e.to_string())?; - engine.load(model, mid); - Ok::<_, String>(()) - }) - .await - .map_err(|e| e.to_string())??; - + ensure_model_loaded(&state, "whisper", id.as_str()).await?; Ok(format!("Model {} loaded", size)) } @@ -131,11 +318,9 @@ pub fn list_parakeet_models() -> Result, String> { Ok(ids .into_iter() .filter(|id| id.as_str().starts_with("parakeet-")) - .map(|id| { - match id.as_str() { - "parakeet-ctc-0.6b-int8" => "ctc-int8".to_string(), - other => other.to_string(), - } + .map(|id| match id.as_str() { + "parakeet-ctc-0.6b-int8" => "ctc-int8".to_string(), + other => other.to_string(), }) .collect()) } @@ -146,29 +331,13 @@ pub async fn load_parakeet_model( name: String, ) -> Result { let id = parakeet_model_id(&name); - let dir = model_manager::model_dir(&id); - - if !dir.exists() { - return Err(format!("Parakeet model not downloaded: {name}")); - } - - let engine = state.parakeet_engine.clone(); - let mid = id.clone(); - tokio::task::spawn_blocking(move || { - let model = kon_transcription::load_parakeet(&dir) - .map_err(|e| e.to_string())?; - engine.load(model, mid); - Ok::<_, String>(()) - }) - .await - .map_err(|e| e.to_string())??; - + ensure_model_loaded(&state, "parakeet", id.as_str()).await?; Ok(format!("Parakeet model {} loaded", name)) } #[tauri::command] pub fn check_parakeet_engine( - state: tauri::State, + state: tauri::State<'_, AppState>, ) -> Result { Ok(state.parakeet_engine.is_loaded()) } diff --git a/src-tauri/src/commands/transcription.rs b/src-tauri/src/commands/transcription.rs index fabd1c7..04729f2 100644 --- a/src-tauri/src/commands/transcription.rs +++ b/src-tauri/src/commands/transcription.rs @@ -3,13 +3,26 @@ #![allow(clippy::too_many_arguments)] use std::path::Path; +use std::sync::Arc; use tauri::Emitter; +use crate::commands::models::{default_model_id_for_engine, ensure_model_loaded}; use crate::AppState; use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}; use kon_core::types::{Segment, TranscriptionOptions}; +fn pick_engine( + state: &AppState, + engine: &str, +) -> Result, String> { + match engine { + "whisper" => Ok(state.whisper_engine.clone()), + "parakeet" => Ok(state.parakeet_engine.clone()), + other => Err(format!("Unknown engine: {other}")), + } +} + /// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event. #[tauri::command] pub async fn transcribe_pcm( @@ -69,6 +82,8 @@ pub async fn transcribe_pcm( pub async fn transcribe_file( state: tauri::State<'_, AppState>, path: String, + engine: Option, + model_id: Option, language: String, initial_prompt: String, remove_fillers: bool, @@ -76,7 +91,12 @@ pub async fn transcribe_file( anti_hallucination: bool, format_mode: String, ) -> Result { - let engine = state.whisper_engine.clone(); + let engine_name = engine.unwrap_or_else(|| "whisper".to_string()); + let model_id = model_id + .unwrap_or_else(|| default_model_id_for_engine(&engine_name).to_string()); + ensure_model_loaded(&state, &engine_name, &model_id).await?; + + let engine = pick_engine(&state, &engine_name)?; let options = TranscriptionOptions { language: Some(language), initial_prompt: Some(initial_prompt), @@ -106,6 +126,8 @@ pub async fn transcribe_file( ); Ok(serde_json::json!({ + "engine": engine_name, + "modelId": model_id, "segments": segments, "language": timed.transcript.language(), "duration": timed.transcript.duration(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fc5d5b2..e06346a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -115,6 +115,10 @@ pub fn run() { #[cfg(target_os = "linux")] ensure_x11_on_wayland(); + // Capture Rust panics to disk so the diagnostic-report bundler in + // Settings → About can attach them. Local only; nothing transmitted. + commands::diagnostics::install_panic_hook(); + tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) @@ -242,6 +246,12 @@ pub fn run() { commands::transcripts::list_dictionary_command, commands::transcripts::add_dictionary_entry_command, commands::transcripts::delete_dictionary_entry_command, + // Diagnostics (panic + error capture, manual report bundle) + commands::diagnostics::log_frontend_error, + commands::diagnostics::list_recent_errors_command, + commands::diagnostics::list_crash_files, + commands::diagnostics::generate_diagnostic_report, + commands::diagnostics::save_diagnostic_report, commands::live::start_live_transcription_session, commands::live::stop_live_transcription_session, // Windows diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte index 84fddaa..5207c03 100644 --- a/src/lib/pages/SettingsPage.svelte +++ b/src/lib/pages/SettingsPage.svelte @@ -86,6 +86,63 @@ vocabularyError = "Could not delete term: " + (err?.message || err); } } + + // --- Diagnostics (Settings → About → Generate diagnostic report) --- + // Layer 2 of the diagnostics plan: user previews exactly what would be + // shared, then chooses to copy / save. Nothing leaves the machine + // automatically. + let diagnosticReport = $state(""); + let diagnosticReportError = $state(null); + let diagnosticReportSavedTo = $state(null); + let diagnosticReportLoading = $state(false); + + async function generateDiagnosticReport() { + diagnosticReportError = null; + diagnosticReportSavedTo = null; + diagnosticReportLoading = true; + try { + diagnosticReport = await invoke("generate_diagnostic_report", { + options: { + includeRecentErrors: true, + includeCrashes: true, + includeSettings: true, + includeLogTail: true, + }, + }); + } catch (err) { + diagnosticReportError = "Could not generate report: " + (err?.message || err); + } finally { + diagnosticReportLoading = false; + } + } + + async function copyDiagnosticReport() { + if (!diagnosticReport) return; + try { + await navigator.clipboard.writeText(diagnosticReport); + diagnosticReportSavedTo = "Copied to clipboard."; + } catch (err) { + diagnosticReportError = "Clipboard copy failed: " + (err?.message || err); + } + } + + async function saveDiagnosticReport() { + diagnosticReportError = null; + try { + const path = await invoke("save_diagnostic_report", { + options: { + includeRecentErrors: true, + includeCrashes: true, + includeSettings: true, + includeLogTail: true, + }, + }); + diagnosticReportSavedTo = "Saved to " + path; + } catch (err) { + diagnosticReportError = "Save failed: " + (err?.message || err); + } + } + let parakeetDownloaded = $state(false); let parakeetDownloading = $state(false); let parakeetProgress = $state(0); @@ -1036,6 +1093,46 @@

Kon v1.0 · Powered by whisper.cpp · Built by CORBEL Ltd

+ + +
+

Diagnostics

+

+ If something goes wrong, generate a diagnostic report to share with the developer. The report contains your settings, recent errors, any crash dumps, and the tail of the log file. Nothing is sent automatically — you preview it first, then choose copy or save. +

+
+ + {#if diagnosticReport} + + + {/if} +
+ {#if diagnosticReportError} +

{diagnosticReportError}

+ {/if} + {#if diagnosticReportSavedTo} +

{diagnosticReportSavedTo}

+ {/if} + {#if diagnosticReport} +
+ Preview report ({diagnosticReport.length} chars) +
{diagnosticReport}
+
+ {/if} +
{/if} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5157cb8..445887f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,6 +6,7 @@ import TaskSidebar from "$lib/components/TaskSidebar.svelte"; import Titlebar from "$lib/components/Titlebar.svelte"; import ToastViewport from "$lib/components/ToastViewport.svelte"; + import { hasTauriRuntime } from "$lib/utils/runtime.js"; import { page, settings, saveSettings } from "$lib/stores/page.svelte.js"; import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; import { hasTauriRuntime } from "$lib/utils/runtime.js"; @@ -165,11 +166,43 @@ } } + // Capture global frontend errors and forward to the Rust error_log via + // log_frontend_error. Best-effort: never let the error handler itself + // throw, never crash the app over a logging failure. + // (Diagnostics layer 1 — local only, never transmitted) + function installGlobalErrorCapture() { + if (!hasTauriRuntime()) return; + + const safeLog = (context, message, stack) => { + try { + invoke("log_frontend_error", { context, message: String(message ?? ""), stack: stack ?? null }) + .catch(() => { /* swallow — diagnostic logging must never throw */ }); + } catch { /* same */ } + }; + + window.addEventListener("error", (ev) => { + safeLog( + "window.onerror", + ev?.message || ev?.error?.message || "Unknown error", + ev?.error?.stack || null, + ); + }); + + window.addEventListener("unhandledrejection", (ev) => { + const reason = ev?.reason; + const msg = (reason && (reason.message || String(reason))) || "Unhandled rejection"; + safeLog("unhandledrejection", msg, reason?.stack || null); + }); + } + onMount(async () => { // Auto-collapse if window is already narrow on first load handleResize(); window.addEventListener("resize", handleResize); + // Diagnostics: capture every uncaught frontend error to error_log. + installGlobalErrorCapture(); + if (!tauriRuntimeAvailable) { hotkeyBackend = "unavailable"; return;