feat(history): add Tauri commands for transcript persistence and FTS5 search

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-21 12:47:28 +00:00
parent d79ad40b33
commit 13456e1fb6
7 changed files with 341 additions and 6 deletions

View File

@@ -114,6 +114,64 @@ pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
Ok(())
}
// --- Segment CRUD ---
/// Batch insert segments for a transcript.
pub async fn insert_segments(
pool: &SqlitePool,
transcript_id: &str,
segments: &[(f64, f64, &str)],
) -> Result<()> {
for (start, end, text) in segments {
sqlx::query(
"INSERT INTO segments (transcript_id, start_time, end_time, text) VALUES (?, ?, ?, ?)",
)
.bind(transcript_id)
.bind(start)
.bind(end)
.bind(text)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Insert segment failed: {e}")))?;
}
Ok(())
}
/// Row type for segment query results.
#[derive(Debug, Clone)]
pub struct SegmentRow {
pub id: i64,
pub transcript_id: String,
pub start_time: f64,
pub end_time: f64,
pub text: String,
}
/// Retrieve all segments for a transcript, ordered by start time.
pub async fn get_segments(
pool: &SqlitePool,
transcript_id: &str,
) -> Result<Vec<SegmentRow>> {
let rows = sqlx::query(
"SELECT id, transcript_id, start_time, end_time, text FROM segments WHERE transcript_id = ? ORDER BY start_time ASC",
)
.bind(transcript_id)
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("Get segments failed: {e}")))?;
Ok(rows
.into_iter()
.map(|r| SegmentRow {
id: r.get("id"),
transcript_id: r.get("transcript_id"),
start_time: r.get("start_time"),
end_time: r.get("end_time"),
text: r.get("text"),
})
.collect())
}
// --- Task CRUD ---
pub async fn insert_task(

View File

@@ -3,10 +3,10 @@ pub mod file_storage;
pub mod migrations;
pub use database::{
clear_timer_state, complete_task, delete_task, delete_transcript, get_setting,
get_timer_state, get_transcript, init, insert_task, insert_task_v2, insert_transcript,
list_tasks, list_tasks_by_status, list_transcripts, log_error, reorder_tasks,
save_timer_state, search_transcripts, set_setting, update_task_v2,
InsertTranscriptParams, TaskRow, TimerStateRow, TranscriptRow,
clear_timer_state, complete_task, delete_task, delete_transcript, get_segments,
get_setting, get_timer_state, get_transcript, init, insert_segments, insert_task,
insert_task_v2, insert_transcript, list_tasks, list_tasks_by_status, list_transcripts,
log_error, reorder_tasks, save_timer_state, search_transcripts, set_setting,
update_task_v2, InsertTranscriptParams, SegmentRow, TaskRow, TimerStateRow, TranscriptRow,
};
pub use file_storage::{app_data_dir, database_path, recordings_dir};

View File

@@ -36,3 +36,4 @@ tokio = { version = "1", features = ["rt", "sync"] }
arboard = "3.6.1"
tauri-plugin-mcp = "0.7.1"
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] }
uuid = { version = "1", features = ["v4"] }

View File

@@ -0,0 +1,226 @@
use serde::Serialize;
use crate::AppState;
use kon_storage::{
delete_transcript, get_segments, get_transcript, insert_segments, insert_transcript,
list_transcripts, search_transcripts, InsertTranscriptParams,
};
/// Serialisable transcript response for the frontend.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TranscriptResponse {
pub id: String,
pub text: String,
pub source: String,
pub title: Option<String>,
pub audio_path: Option<String>,
pub duration: f64,
pub engine: Option<String>,
pub model_id: Option<String>,
pub inference_ms: Option<i64>,
pub sample_rate: Option<i32>,
pub audio_channels: Option<i32>,
pub format_mode: Option<String>,
pub remove_fillers: bool,
pub british_english: bool,
pub anti_hallucination: bool,
pub created_at: String,
}
/// Serialisable segment response.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SegmentResponse {
pub id: i64,
pub transcript_id: String,
pub start_time: f64,
pub end_time: f64,
pub text: String,
}
/// Persist a completed transcription to SQLite.
#[tauri::command]
pub async fn save_transcript(
state: tauri::State<'_, AppState>,
id: String,
text: String,
source: String,
title: Option<String>,
audio_path: Option<String>,
duration: f64,
engine: Option<String>,
model_id: Option<String>,
inference_ms: Option<i64>,
sample_rate: Option<i32>,
audio_channels: Option<i32>,
format_mode: Option<String>,
remove_fillers: bool,
british_english: bool,
anti_hallucination: bool,
) -> Result<(), String> {
insert_transcript(
&state.db,
&InsertTranscriptParams {
id: &id,
text: &text,
source: &source,
title: title.as_deref(),
audio_path: audio_path.as_deref(),
duration,
engine: engine.as_deref(),
model_id: model_id.as_deref(),
inference_ms,
sample_rate,
audio_channels,
format_mode: format_mode.as_deref(),
remove_fillers,
british_english,
anti_hallucination,
},
)
.await
.map_err(|e| e.to_string())
}
/// Fetch a single transcript by ID.
#[tauri::command]
pub async fn get_transcript_by_id(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<Option<TranscriptResponse>, String> {
let row = get_transcript(&state.db, &id)
.await
.map_err(|e| e.to_string())?;
Ok(row.map(|r| TranscriptResponse {
id: r.id,
text: r.text,
source: r.source,
title: r.title,
audio_path: r.audio_path,
duration: r.duration,
engine: r.engine,
model_id: r.model_id,
inference_ms: r.inference_ms,
sample_rate: r.sample_rate,
audio_channels: r.audio_channels,
format_mode: r.format_mode,
remove_fillers: r.remove_fillers,
british_english: r.british_english,
anti_hallucination: r.anti_hallucination,
created_at: r.created_at,
}))
}
/// List transcripts, newest first.
#[tauri::command]
pub async fn list_all_transcripts(
state: tauri::State<'_, AppState>,
limit: i64,
) -> Result<Vec<TranscriptResponse>, String> {
let rows = list_transcripts(&state.db, limit)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.into_iter()
.map(|r| TranscriptResponse {
id: r.id,
text: r.text,
source: r.source,
title: r.title,
audio_path: r.audio_path,
duration: r.duration,
engine: r.engine,
model_id: r.model_id,
inference_ms: r.inference_ms,
sample_rate: r.sample_rate,
audio_channels: r.audio_channels,
format_mode: r.format_mode,
remove_fillers: r.remove_fillers,
british_english: r.british_english,
anti_hallucination: r.anti_hallucination,
created_at: r.created_at,
})
.collect())
}
/// Delete a transcript by ID (cascades to segments).
#[tauri::command]
pub async fn delete_transcript_by_id(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<(), String> {
delete_transcript(&state.db, &id)
.await
.map_err(|e| e.to_string())
}
/// Full-text search across transcripts via FTS5.
#[tauri::command]
pub async fn search_all_transcripts(
state: tauri::State<'_, AppState>,
query: String,
limit: i64,
) -> Result<Vec<TranscriptResponse>, String> {
let rows = search_transcripts(&state.db, &query, limit)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.into_iter()
.map(|r| TranscriptResponse {
id: r.id,
text: r.text,
source: r.source,
title: r.title,
audio_path: r.audio_path,
duration: r.duration,
engine: r.engine,
model_id: r.model_id,
inference_ms: r.inference_ms,
sample_rate: r.sample_rate,
audio_channels: r.audio_channels,
format_mode: r.format_mode,
remove_fillers: r.remove_fillers,
british_english: r.british_english,
anti_hallucination: r.anti_hallucination,
created_at: r.created_at,
})
.collect())
}
/// Batch insert segments for a transcript.
#[tauri::command]
pub async fn save_segments(
state: tauri::State<'_, AppState>,
transcript_id: String,
segments: Vec<(f64, f64, String)>,
) -> Result<(), String> {
let refs: Vec<(f64, f64, &str)> = segments
.iter()
.map(|(s, e, t)| (*s, *e, t.as_str()))
.collect();
insert_segments(&state.db, &transcript_id, &refs)
.await
.map_err(|e| e.to_string())
}
/// Retrieve all segments for a transcript, ordered by start time.
#[tauri::command]
pub async fn get_transcript_segments(
state: tauri::State<'_, AppState>,
transcript_id: String,
) -> Result<Vec<SegmentResponse>, String> {
let rows = get_segments(&state.db, &transcript_id)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.into_iter()
.map(|r| SegmentResponse {
id: r.id,
transcript_id: r.transcript_id,
start_time: r.start_time,
end_time: r.end_time,
text: r.text,
})
.collect())
}

View File

@@ -1,6 +1,7 @@
pub mod audio;
pub mod clipboard;
pub mod hardware;
pub mod history;
pub mod models;
pub mod transcription;
pub mod windows;

View File

@@ -9,6 +9,7 @@ use tauri::Emitter;
use crate::AppState;
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
use kon_core::types::{Segment, TranscriptionOptions};
use kon_storage::InsertTranscriptParams;
/// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event.
#[tauri::command]
@@ -82,8 +83,9 @@ pub async fn transcribe_file(
initial_prompt: Some(initial_prompt),
};
let path_for_blocking = path.clone();
let timed = tokio::task::spawn_blocking(move || {
let audio = kon_audio::decode_audio_file(Path::new(&path))
let audio = kon_audio::decode_audio_file(Path::new(&path_for_blocking))
.map_err(|e| e.to_string())?;
let resampled =
kon_audio::resample_to_16khz(&audio).map_err(|e| e.to_string())?;
@@ -105,7 +107,46 @@ pub async fn transcribe_file(
},
);
// Auto-persist file transcription to SQLite
let transcript_id = uuid::Uuid::new_v4().to_string();
let full_text: String = segments.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join(" ");
let file_name = Path::new(&path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
// Best-effort persistence — don't fail the command if storage errors
let _ = kon_storage::insert_transcript(
&state.db,
&InsertTranscriptParams {
id: &transcript_id,
text: &full_text,
source: file_name,
title: Some(file_name),
audio_path: Some(&path),
duration: timed.transcript.duration(),
engine: Some("whisper"),
model_id: None,
inference_ms: Some(timed.inference_ms as i64),
sample_rate: Some(16000),
audio_channels: Some(1),
format_mode: Some(&format_mode),
remove_fillers,
british_english,
anti_hallucination,
},
)
.await;
// Persist segments
let seg_tuples: Vec<(f64, f64, &str)> = segments
.iter()
.map(|s| (s.start, s.end, s.text.as_str()))
.collect();
let _ = kon_storage::insert_segments(&state.db, &transcript_id, &seg_tuples).await;
Ok(serde_json::json!({
"id": transcript_id,
"segments": segments,
"language": timed.transcript.language(),
"duration": timed.transcript.duration(),

View File

@@ -138,6 +138,14 @@ pub fn run() {
commands::models::list_parakeet_models,
commands::models::load_parakeet_model,
commands::models::check_parakeet_engine,
// History / transcript persistence
commands::history::save_transcript,
commands::history::get_transcript_by_id,
commands::history::list_all_transcripts,
commands::history::delete_transcript_by_id,
commands::history::search_all_transcripts,
commands::history::save_segments,
commands::history::get_transcript_segments,
// Transcription
commands::transcription::transcribe_pcm,
commands::transcription::transcribe_file,