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:
@@ -114,6 +114,64 @@ pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
|||||||
Ok(())
|
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 ---
|
// --- Task CRUD ---
|
||||||
|
|
||||||
pub async fn insert_task(
|
pub async fn insert_task(
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ pub mod file_storage;
|
|||||||
pub mod migrations;
|
pub mod migrations;
|
||||||
|
|
||||||
pub use database::{
|
pub use database::{
|
||||||
clear_timer_state, complete_task, delete_task, delete_transcript, get_setting,
|
clear_timer_state, complete_task, delete_task, delete_transcript, get_segments,
|
||||||
get_timer_state, get_transcript, init, insert_task, insert_task_v2, insert_transcript,
|
get_setting, get_timer_state, get_transcript, init, insert_segments, insert_task,
|
||||||
list_tasks, list_tasks_by_status, list_transcripts, log_error, reorder_tasks,
|
insert_task_v2, insert_transcript, list_tasks, list_tasks_by_status, list_transcripts,
|
||||||
save_timer_state, search_transcripts, set_setting, update_task_v2,
|
log_error, reorder_tasks, save_timer_state, search_transcripts, set_setting,
|
||||||
InsertTranscriptParams, TaskRow, TimerStateRow, TranscriptRow,
|
update_task_v2, InsertTranscriptParams, SegmentRow, TaskRow, TimerStateRow, TranscriptRow,
|
||||||
};
|
};
|
||||||
pub use file_storage::{app_data_dir, database_path, recordings_dir};
|
pub use file_storage::{app_data_dir, database_path, recordings_dir};
|
||||||
|
|||||||
@@ -36,3 +36,4 @@ tokio = { version = "1", features = ["rt", "sync"] }
|
|||||||
arboard = "3.6.1"
|
arboard = "3.6.1"
|
||||||
tauri-plugin-mcp = "0.7.1"
|
tauri-plugin-mcp = "0.7.1"
|
||||||
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] }
|
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|||||||
226
src-tauri/src/commands/history.rs
Normal file
226
src-tauri/src/commands/history.rs
Normal 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())
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
pub mod hardware;
|
pub mod hardware;
|
||||||
|
pub mod history;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod transcription;
|
pub mod transcription;
|
||||||
pub mod windows;
|
pub mod windows;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use tauri::Emitter;
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
|
use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions};
|
||||||
use kon_core::types::{Segment, TranscriptionOptions};
|
use kon_core::types::{Segment, TranscriptionOptions};
|
||||||
|
use kon_storage::InsertTranscriptParams;
|
||||||
|
|
||||||
/// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event.
|
/// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -82,8 +83,9 @@ pub async fn transcribe_file(
|
|||||||
initial_prompt: Some(initial_prompt),
|
initial_prompt: Some(initial_prompt),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let path_for_blocking = path.clone();
|
||||||
let timed = tokio::task::spawn_blocking(move || {
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let resampled =
|
let resampled =
|
||||||
kon_audio::resample_to_16khz(&audio).map_err(|e| e.to_string())?;
|
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!({
|
Ok(serde_json::json!({
|
||||||
|
"id": transcript_id,
|
||||||
"segments": segments,
|
"segments": segments,
|
||||||
"language": timed.transcript.language(),
|
"language": timed.transcript.language(),
|
||||||
"duration": timed.transcript.duration(),
|
"duration": timed.transcript.duration(),
|
||||||
|
|||||||
@@ -138,6 +138,14 @@ pub fn run() {
|
|||||||
commands::models::list_parakeet_models,
|
commands::models::list_parakeet_models,
|
||||||
commands::models::load_parakeet_model,
|
commands::models::load_parakeet_model,
|
||||||
commands::models::check_parakeet_engine,
|
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
|
// Transcription
|
||||||
commands::transcription::transcribe_pcm,
|
commands::transcription::transcribe_pcm,
|
||||||
commands::transcription::transcribe_file,
|
commands::transcription::transcribe_file,
|
||||||
|
|||||||
Reference in New Issue
Block a user