feat(kon): wire Tauri shell — thin commands, ProviderRegistry, tray

- lib.rs rewritten from scaffold to full app setup (~70 lines)
- AppState holds Arc<LocalEngine> for Whisper and Parakeet
- commands/models.rs: download, check, list, load for both engines
  Maps legacy size strings (Tiny/Base/Small/Medium) to ModelId
- commands/transcription.rs: transcribe_pcm, transcribe_file, transcribe_pcm_parakeet
  Delegates to LocalEngine.transcribe_sync() + post_process_segments()
- commands/audio.rs: save_audio via kon_audio::write_wav
- commands/windows.rs: open_task_window, open_viewer_window
- commands/llm.rs: 8 stub commands preserved for frontend compatibility
- tray.rs: extracted system tray setup (show, status, quit, click-to-show)
- Close-to-tray behaviour preserved
- All 25 v0.2 command names preserved for Svelte frontend compatibility
- clippy clean

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-16 21:15:16 +00:00
parent 0646c75de7
commit 29ff91d3f6
9 changed files with 638 additions and 0 deletions

View File

@@ -30,3 +30,6 @@ tauri-plugin-global-shortcut = "2"
# Serialisation
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Async runtime (spawn_blocking for inference)
tokio = { version = "1", features = ["rt", "sync"] }

View File

@@ -0,0 +1,49 @@
use std::path::PathBuf;
use tauri::Manager;
use kon_core::types::AudioSamples;
/// Save PCM f32 samples as a WAV file. Returns the file path.
#[tauri::command]
pub async fn save_audio(
app: tauri::AppHandle,
samples: Vec<f32>,
output_folder: Option<String>,
) -> Result<String, String> {
let recordings_dir = if let Some(ref folder) = output_folder {
if !folder.is_empty() {
PathBuf::from(folder)
} else {
app.path()
.app_local_data_dir()
.map_err(|e: tauri::Error| e.to_string())?
.join("recordings")
}
} else {
app.path()
.app_local_data_dir()
.map_err(|e: tauri::Error| e.to_string())?
.join("recordings")
};
std::fs::create_dir_all(&recordings_dir)
.map_err(|e| format!("Failed to create recordings dir: {e}"))?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let filename = format!("kon-{timestamp}.wav");
let path = recordings_dir.join(&filename);
let path_clone = path.clone();
tokio::task::spawn_blocking(move || {
let audio = AudioSamples::mono_16khz(samples);
kon_audio::write_wav(&path_clone, &audio).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())??;
Ok(path.to_string_lossy().to_string())
}

View File

@@ -0,0 +1,53 @@
// LLM stubs — ONNX-based local LLM coming post-revenue.
#[tauri::command]
pub fn check_llm_engine() -> Result<bool, String> {
Ok(false)
}
#[tauri::command]
pub fn check_llm_model(_size: String) -> Result<bool, String> {
Ok(false)
}
#[tauri::command]
pub fn list_llm_models() -> Result<Vec<String>, String> {
Ok(vec![])
}
#[tauri::command]
pub async fn download_llm_model(
_app: tauri::AppHandle,
_size: String,
) -> Result<String, String> {
Err("LLM not available yet — coming soon".into())
}
#[tauri::command]
pub async fn load_llm_model(
_size: String,
) -> Result<String, String> {
Err("LLM not available yet — coming soon".into())
}
#[tauri::command]
pub async fn llm_generate(
_prompt: String,
_max_tokens: Option<u32>,
) -> Result<String, String> {
Err("LLM not available yet — coming soon".into())
}
#[tauri::command]
pub async fn llm_extract_tasks(
_transcript: String,
) -> Result<Vec<String>, String> {
Err("LLM not available yet — coming soon".into())
}
#[tauri::command]
pub async fn llm_format_transcript(
_transcript: String,
) -> Result<String, String> {
Err("LLM not available yet — coming soon".into())
}

View File

@@ -0,0 +1,5 @@
pub mod audio;
pub mod llm;
pub mod models;
pub mod transcription;
pub mod windows;

View File

@@ -0,0 +1,172 @@
use tauri::Emitter;
use crate::AppState;
use kon_core::types::ModelId;
use kon_transcription::model_manager;
/// Map legacy size strings to ModelId.
fn whisper_model_id(size: &str) -> ModelId {
match size.to_lowercase().as_str() {
"tiny" => ModelId::new("whisper-tiny-en"),
"base" => ModelId::new("whisper-base-en"),
"small" => ModelId::new("whisper-small-en"),
"medium" => ModelId::new("whisper-medium-en"),
other => ModelId::new(other),
}
}
fn parakeet_model_id(name: &str) -> ModelId {
match name {
"ctc-int8" => ModelId::new("parakeet-ctc-0.6b-int8"),
other => ModelId::new(other),
}
}
// --- Whisper model commands ---
#[tauri::command]
pub async fn download_model(
app: tauri::AppHandle,
size: String,
) -> Result<String, String> {
let id = whisper_model_id(&size);
let app_clone = app.clone();
model_manager::download(&id, move |progress| {
let _ = app_clone.emit("model-download-progress", &progress);
})
.await
.map_err(|e| e.to_string())?;
Ok(format!("Model {} downloaded", size))
}
#[tauri::command]
pub fn check_model(size: String) -> Result<bool, String> {
let id = whisper_model_id(&size);
Ok(model_manager::is_downloaded(&id))
}
#[tauri::command]
pub fn list_models() -> Result<Vec<String>, String> {
let ids = model_manager::list_downloaded();
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(),
}
})
.collect())
}
#[tauri::command]
pub async fn load_model(
state: tauri::State<'_, AppState>,
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();
tokio::task::spawn_blocking(move || {
let model = kon_transcription::load_whisper(&model_file)
.map_err(|e| e.to_string())?;
engine.load(model);
Ok::<_, String>(())
})
.await
.map_err(|e| e.to_string())??;
Ok(format!("Model {} loaded", size))
}
#[tauri::command]
pub fn check_engine(state: tauri::State<AppState>) -> Result<bool, String> {
Ok(state.whisper_engine.is_loaded())
}
// --- Parakeet model commands ---
#[tauri::command]
pub async fn download_parakeet_model(
app: tauri::AppHandle,
name: String,
) -> Result<String, String> {
let id = parakeet_model_id(&name);
let app_clone = app.clone();
model_manager::download(&id, move |progress| {
let _ = app_clone.emit("parakeet-download-progress", &progress);
})
.await
.map_err(|e| e.to_string())?;
Ok(format!("Parakeet model {} downloaded", name))
}
#[tauri::command]
pub fn check_parakeet_model(name: String) -> Result<bool, String> {
let id = parakeet_model_id(&name);
Ok(model_manager::is_downloaded(&id))
}
#[tauri::command]
pub fn list_parakeet_models() -> Result<Vec<String>, String> {
let ids = model_manager::list_downloaded();
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(),
}
})
.collect())
}
#[tauri::command]
pub async fn load_parakeet_model(
state: tauri::State<'_, AppState>,
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();
tokio::task::spawn_blocking(move || {
let model = kon_transcription::load_parakeet(&dir)
.map_err(|e| e.to_string())?;
engine.load(model);
Ok::<_, String>(())
})
.await
.map_err(|e| e.to_string())??;
Ok(format!("Parakeet model {} loaded", name))
}
#[tauri::command]
pub fn check_parakeet_engine(
state: tauri::State<AppState>,
) -> Result<bool, String> {
Ok(state.parakeet_engine.is_loaded())
}

View File

@@ -0,0 +1,165 @@
// Tauri command handlers must match the frontend's invoke() parameter lists,
// so the argument counts are dictated by the Svelte code.
#![allow(clippy::too_many_arguments)]
use std::path::Path;
use tauri::Emitter;
use crate::AppState;
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
use kon_core::types::{Segment, TranscriptionOptions};
/// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event.
#[tauri::command]
pub async fn transcribe_pcm(
state: tauri::State<'_, AppState>,
app: tauri::AppHandle,
samples: Vec<f32>,
chunk_id: u32,
language: String,
initial_prompt: String,
remove_fillers: bool,
british_english: bool,
anti_hallucination: bool,
format_mode: String,
) -> Result<(), String> {
let engine = state.whisper_engine.clone();
let audio = kon_core::AudioSamples::mono_16khz(samples);
let options = TranscriptionOptions {
language: Some(language),
initial_prompt: Some(initial_prompt),
};
let result = tokio::task::spawn_blocking(move || {
let transcript = engine.transcribe_sync(&audio, &options)
.map_err(|e| e.to_string())?;
Ok::<_, String>(transcript)
})
.await
.map_err(|e| e.to_string())??;
let mut segments: Vec<Segment> = result.segments().to_vec();
post_process_segments(
&mut segments,
&PostProcessOptions {
remove_fillers,
british_english,
anti_hallucination,
format_mode: FormatMode::parse(&format_mode),
},
);
app.emit(
"transcription-result",
serde_json::json!({
"status": "transcription",
"segments": segments,
"language": result.language(),
"duration": result.duration(),
"chunk_id": chunk_id,
}),
)
.map_err(|e| format!("Failed to emit result: {e}"))?;
Ok(())
}
/// Transcribe an audio file by path. Decodes, resamples to 16kHz, runs Whisper.
#[tauri::command]
pub async fn transcribe_file(
state: tauri::State<'_, AppState>,
path: String,
language: String,
initial_prompt: String,
remove_fillers: bool,
british_english: bool,
anti_hallucination: bool,
format_mode: String,
) -> Result<serde_json::Value, String> {
let engine = state.whisper_engine.clone();
let options = TranscriptionOptions {
language: Some(language),
initial_prompt: Some(initial_prompt),
};
let result = tokio::task::spawn_blocking(move || {
let audio = kon_audio::decode_audio_file(Path::new(&path))
.map_err(|e| e.to_string())?;
let resampled = kon_audio::resample_to_16khz(&audio)
.map_err(|e| e.to_string())?;
let transcript = engine
.transcribe_sync(&resampled, &options)
.map_err(|e| e.to_string())?;
Ok::<_, String>(transcript)
})
.await
.map_err(|e| e.to_string())??;
let mut segments: Vec<Segment> = result.segments().to_vec();
post_process_segments(
&mut segments,
&PostProcessOptions {
remove_fillers,
british_english,
anti_hallucination,
format_mode: FormatMode::parse(&format_mode),
},
);
Ok(serde_json::json!({
"segments": segments,
"language": result.language(),
"duration": result.duration(),
}))
}
/// Transcribe raw PCM f32 samples (Parakeet). Emits "transcription-result" event.
#[tauri::command]
pub async fn transcribe_pcm_parakeet(
state: tauri::State<'_, AppState>,
app: tauri::AppHandle,
samples: Vec<f32>,
chunk_id: u32,
remove_fillers: bool,
british_english: bool,
anti_hallucination: bool,
) -> Result<(), String> {
let engine = state.parakeet_engine.clone();
let audio = kon_core::AudioSamples::mono_16khz(samples);
let options = TranscriptionOptions::default();
let result = tokio::task::spawn_blocking(move || {
let transcript = engine
.transcribe_sync(&audio, &options)
.map_err(|e| e.to_string())?;
Ok::<_, String>(transcript)
})
.await
.map_err(|e| e.to_string())??;
let mut segments: Vec<Segment> = result.segments().to_vec();
post_process_segments(
&mut segments,
&PostProcessOptions {
remove_fillers,
british_english,
anti_hallucination,
format_mode: FormatMode::parse("Clean"),
},
);
app.emit(
"transcription-result",
serde_json::json!({
"status": "transcription",
"segments": segments,
"language": result.language(),
"duration": result.duration(),
"chunk_id": chunk_id,
}),
)
.map_err(|e| format!("Failed to emit result: {e}"))?;
Ok(())
}

View File

@@ -0,0 +1,58 @@
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
/// Open a floating always-on-top task window.
#[tauri::command]
pub async fn open_task_window(
app: tauri::AppHandle,
) -> Result<(), String> {
if let Some(window) = app.get_webview_window("tasks-float") {
window.show().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| e.to_string())?;
app.emit("task-window-focus", ())
.map_err(|e| e.to_string())?;
return Ok(());
}
WebviewWindowBuilder::new(
&app,
"tasks-float",
WebviewUrl::App("/float".into()),
)
.title("Kon Tasks")
.inner_size(480.0, 520.0)
.min_inner_size(400.0, 400.0)
.always_on_top(true)
.decorations(false)
.resizable(true)
.build()
.map_err(|e| e.to_string())?;
Ok(())
}
/// Open the transcript viewer window.
#[tauri::command]
pub async fn open_viewer_window(
app: tauri::AppHandle,
) -> Result<(), String> {
if let Some(window) = app.get_webview_window("transcript-viewer") {
window.show().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
WebviewWindowBuilder::new(
&app,
"transcript-viewer",
WebviewUrl::App("/viewer".into()),
)
.title("Kon - Viewer")
.inner_size(600.0, 700.0)
.min_inner_size(450.0, 500.0)
.decorations(false)
.resizable(true)
.build()
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -1,9 +1,83 @@
mod commands;
mod tray;
use std::sync::Arc;
use tauri::Manager;
use kon_core::types::EngineName;
use kon_transcription::LocalEngine;
/// Shared app state holding the transcription engines.
pub struct AppState {
pub whisper_engine: Arc<LocalEngine>,
pub parakeet_engine: Arc<LocalEngine>,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.manage(AppState {
whisper_engine: Arc::new(LocalEngine::new(
EngineName::new("whisper"),
)),
parakeet_engine: Arc::new(LocalEngine::new(
EngineName::new("parakeet"),
)),
})
.setup(|app| {
if let Err(e) = tray::setup(app) {
eprintln!("Failed to setup tray: {e}");
}
// Close-to-tray: hide window instead of exiting
let main_window = app.get_webview_window("main").unwrap();
let win = main_window.clone();
main_window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event
{
api.prevent_close();
let _ = win.hide();
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
// Whisper model management
commands::models::download_model,
commands::models::check_model,
commands::models::list_models,
commands::models::load_model,
commands::models::check_engine,
// Parakeet model management
commands::models::download_parakeet_model,
commands::models::check_parakeet_model,
commands::models::list_parakeet_models,
commands::models::load_parakeet_model,
commands::models::check_parakeet_engine,
// Transcription
commands::transcription::transcribe_pcm,
commands::transcription::transcribe_file,
commands::transcription::transcribe_pcm_parakeet,
// Audio
commands::audio::save_audio,
// Windows
commands::windows::open_task_window,
commands::windows::open_viewer_window,
// LLM stubs
commands::llm::download_llm_model,
commands::llm::check_llm_model,
commands::llm::list_llm_models,
commands::llm::load_llm_model,
commands::llm::check_llm_engine,
commands::llm::llm_generate,
commands::llm::llm_extract_tasks,
commands::llm::llm_format_transcript,
])
.run(tauri::generate_context!())
.expect("error while running Kon");
}

59
src-tauri/src/tray.rs Normal file
View File

@@ -0,0 +1,59 @@
use tauri::image::Image;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
use tauri::Manager;
pub fn setup(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let show = MenuItemBuilder::with_id("show", "Show Kon").build(app)?;
let status = MenuItemBuilder::with_id("status", "Ready")
.enabled(false)
.build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
let menu = MenuBuilder::new(app)
.item(&show)
.separator()
.item(&status)
.separator()
.item(&quit)
.build()?;
let icon = app
.default_window_icon()
.cloned()
.unwrap_or_else(|| Image::new(&[0u8; 4], 1, 1));
let _tray = TrayIconBuilder::new()
.icon(icon)
.tooltip("Kon — Ready")
.menu(&menu)
.on_menu_event(move |app, event| match event.id().as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let tauri::tray::TrayIconEvent::Click {
button: tauri::tray::MouseButton::Left,
..
} = event
{
if let Some(window) =
tray.app_handle().get_webview_window("main")
{
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;
Ok(())
}