diagnostics: panic hook + frontend error capture + Settings → About diagnostic report
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/<unix-ts>-<short-id>.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 <details> with the full text the user can inspect before deciding to share. - Save writes to ~/.kon/diagnostic-reports/kon-diagnostic-<ts>.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.
This commit is contained in:
@@ -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<String>,
|
||||
pub message: String,
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result<Vec<ErrorLogRow>> {
|
||||
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::*;
|
||||
|
||||
@@ -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: `<unix-ts>-<short-id>.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")
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
369
src-tauri/src/commands/diagnostics.rs
Normal file
369
src-tauri/src/commands/diagnostics.rs
Normal file
@@ -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("<unnamed>"),
|
||||
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<String>,
|
||||
) -> 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::<String>()
|
||||
});
|
||||
|
||||
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<String>,
|
||||
pub message: String,
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ErrorLogRow> 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<i64>,
|
||||
) -> Result<Vec<ErrorLogDto>, 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<Vec<CrashFile>, 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<CrashFile> = 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::<String>(),
|
||||
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<ReportOptions>,
|
||||
) -> Result<String, String> {
|
||||
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::<String>()
|
||||
.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<String> {
|
||||
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<ReportOptions>,
|
||||
) -> Result<String, String> {
|
||||
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())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod audio;
|
||||
pub mod clipboard;
|
||||
pub mod diagnostics;
|
||||
pub mod hardware;
|
||||
pub mod hotkey;
|
||||
pub mod live;
|
||||
|
||||
@@ -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<Arc<LocalEngine>, 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<LocalEngine>,
|
||||
) -> 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<kon_transcription::LoadedModel, String> {
|
||||
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<String>,
|
||||
pub engines: Vec<EngineRuntimeCapabilities>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EngineRuntimeCapabilities {
|
||||
pub id: String,
|
||||
pub default_model_id: String,
|
||||
pub loaded_model_id: Option<String>,
|
||||
pub supports_gpu: bool,
|
||||
pub models: Vec<ModelRuntimeCapabilities>,
|
||||
}
|
||||
|
||||
#[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<RuntimeCapabilities, String> {
|
||||
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<Vec<String>, 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<String, String> {
|
||||
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<Vec<String>, 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<String, String> {
|
||||
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<AppState>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
Ok(state.parakeet_engine.is_loaded())
|
||||
}
|
||||
|
||||
@@ -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<Arc<kon_transcription::LocalEngine>, 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<String>,
|
||||
model_id: Option<String>,
|
||||
language: String,
|
||||
initial_prompt: String,
|
||||
remove_fillers: bool,
|
||||
@@ -76,7 +91,12 @@ pub async fn transcribe_file(
|
||||
anti_hallucination: bool,
|
||||
format_mode: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-text-tertiary mt-5">Kon v1.0 · Powered by whisper.cpp · Built by CORBEL Ltd</p>
|
||||
|
||||
<!-- Diagnostic report (privacy posture: local only until you share it) -->
|
||||
<div class="mt-6 pt-5 border-t border-border-subtle">
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Diagnostics</p>
|
||||
<p class="text-[11px] text-text-tertiary mb-3">
|
||||
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.
|
||||
</p>
|
||||
<div class="flex gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={generateDiagnosticReport}
|
||||
disabled={diagnosticReportLoading}
|
||||
class="px-3 py-2 text-[12px] bg-accent text-bg rounded-lg disabled:opacity-50 hover:bg-accent-hover"
|
||||
>{diagnosticReportLoading ? "Generating…" : "Generate report"}</button>
|
||||
{#if diagnosticReport}
|
||||
<button
|
||||
type="button"
|
||||
onclick={copyDiagnosticReport}
|
||||
class="px-3 py-2 text-[12px] text-text border border-border rounded-lg hover:border-accent hover:text-accent"
|
||||
>Copy to clipboard</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveDiagnosticReport}
|
||||
class="px-3 py-2 text-[12px] text-text border border-border rounded-lg hover:border-accent hover:text-accent"
|
||||
>Save as file</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if diagnosticReportError}
|
||||
<p class="text-[11px] text-error mb-2">{diagnosticReportError}</p>
|
||||
{/if}
|
||||
{#if diagnosticReportSavedTo}
|
||||
<p class="text-[11px] text-success mb-2">{diagnosticReportSavedTo}</p>
|
||||
{/if}
|
||||
{#if diagnosticReport}
|
||||
<details class="text-[11px]">
|
||||
<summary class="cursor-pointer text-text-secondary hover:text-text">Preview report ({diagnosticReport.length} chars)</summary>
|
||||
<pre class="mt-2 p-3 bg-bg-input border border-border rounded-lg text-[10px] text-text-secondary overflow-auto max-h-[400px] whitespace-pre-wrap font-mono">{diagnosticReport}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user