improve history performance (#1107)
* paginate history * fixes * fix * format * remove unecessary key
This commit is contained in:
parent
8836d45532
commit
e35f0a714e
5 changed files with 355 additions and 225 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::managers::history::{HistoryEntry, HistoryManager};
|
use crate::managers::history::{HistoryManager, PaginatedHistory};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
|
|
@ -7,9 +7,11 @@ use tauri::{AppHandle, State};
|
||||||
pub async fn get_history_entries(
|
pub async fn get_history_entries(
|
||||||
_app: AppHandle,
|
_app: AppHandle,
|
||||||
history_manager: State<'_, Arc<HistoryManager>>,
|
history_manager: State<'_, Arc<HistoryManager>>,
|
||||||
) -> Result<Vec<HistoryEntry>, String> {
|
cursor: Option<i64>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<PaginatedHistory, String> {
|
||||||
history_manager
|
history_manager
|
||||||
.get_history_entries()
|
.get_history_entries(cursor, limit)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ mod utils;
|
||||||
pub use cli::CliArgs;
|
pub use cli::CliArgs;
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
use specta_typescript::{BigIntExportBehavior, Typescript};
|
use specta_typescript::{BigIntExportBehavior, Typescript};
|
||||||
use tauri_specta::{collect_commands, Builder};
|
use tauri_specta::{collect_commands, collect_events, Builder};
|
||||||
|
|
||||||
use env_filter::Builder as EnvFilterBuilder;
|
use env_filter::Builder as EnvFilterBuilder;
|
||||||
use managers::audio::AudioRecordingManager;
|
use managers::audio::AudioRecordingManager;
|
||||||
|
|
@ -321,106 +321,108 @@ pub fn run(cli_args: CliArgs) {
|
||||||
// when the variable is unset
|
// when the variable is unset
|
||||||
let console_filter = build_console_filter();
|
let console_filter = build_console_filter();
|
||||||
|
|
||||||
let specta_builder = Builder::<tauri::Wry>::new().commands(collect_commands![
|
let specta_builder = Builder::<tauri::Wry>::new()
|
||||||
shortcut::change_binding,
|
.commands(collect_commands![
|
||||||
shortcut::reset_binding,
|
shortcut::change_binding,
|
||||||
shortcut::change_ptt_setting,
|
shortcut::reset_binding,
|
||||||
shortcut::change_audio_feedback_setting,
|
shortcut::change_ptt_setting,
|
||||||
shortcut::change_audio_feedback_volume_setting,
|
shortcut::change_audio_feedback_setting,
|
||||||
shortcut::change_sound_theme_setting,
|
shortcut::change_audio_feedback_volume_setting,
|
||||||
shortcut::change_start_hidden_setting,
|
shortcut::change_sound_theme_setting,
|
||||||
shortcut::change_autostart_setting,
|
shortcut::change_start_hidden_setting,
|
||||||
shortcut::change_translate_to_english_setting,
|
shortcut::change_autostart_setting,
|
||||||
shortcut::change_selected_language_setting,
|
shortcut::change_translate_to_english_setting,
|
||||||
shortcut::change_overlay_position_setting,
|
shortcut::change_selected_language_setting,
|
||||||
shortcut::change_debug_mode_setting,
|
shortcut::change_overlay_position_setting,
|
||||||
shortcut::change_word_correction_threshold_setting,
|
shortcut::change_debug_mode_setting,
|
||||||
shortcut::change_extra_recording_buffer_setting,
|
shortcut::change_word_correction_threshold_setting,
|
||||||
shortcut::change_paste_method_setting,
|
shortcut::change_extra_recording_buffer_setting,
|
||||||
shortcut::get_available_typing_tools,
|
shortcut::change_paste_method_setting,
|
||||||
shortcut::change_typing_tool_setting,
|
shortcut::get_available_typing_tools,
|
||||||
shortcut::change_external_script_path_setting,
|
shortcut::change_typing_tool_setting,
|
||||||
shortcut::change_clipboard_handling_setting,
|
shortcut::change_external_script_path_setting,
|
||||||
shortcut::change_auto_submit_setting,
|
shortcut::change_clipboard_handling_setting,
|
||||||
shortcut::change_auto_submit_key_setting,
|
shortcut::change_auto_submit_setting,
|
||||||
shortcut::change_post_process_enabled_setting,
|
shortcut::change_auto_submit_key_setting,
|
||||||
shortcut::change_experimental_enabled_setting,
|
shortcut::change_post_process_enabled_setting,
|
||||||
shortcut::change_post_process_base_url_setting,
|
shortcut::change_experimental_enabled_setting,
|
||||||
shortcut::change_post_process_api_key_setting,
|
shortcut::change_post_process_base_url_setting,
|
||||||
shortcut::change_post_process_model_setting,
|
shortcut::change_post_process_api_key_setting,
|
||||||
shortcut::set_post_process_provider,
|
shortcut::change_post_process_model_setting,
|
||||||
shortcut::fetch_post_process_models,
|
shortcut::set_post_process_provider,
|
||||||
shortcut::add_post_process_prompt,
|
shortcut::fetch_post_process_models,
|
||||||
shortcut::update_post_process_prompt,
|
shortcut::add_post_process_prompt,
|
||||||
shortcut::delete_post_process_prompt,
|
shortcut::update_post_process_prompt,
|
||||||
shortcut::set_post_process_selected_prompt,
|
shortcut::delete_post_process_prompt,
|
||||||
shortcut::update_custom_words,
|
shortcut::set_post_process_selected_prompt,
|
||||||
shortcut::suspend_binding,
|
shortcut::update_custom_words,
|
||||||
shortcut::resume_binding,
|
shortcut::suspend_binding,
|
||||||
shortcut::change_mute_while_recording_setting,
|
shortcut::resume_binding,
|
||||||
shortcut::change_append_trailing_space_setting,
|
shortcut::change_mute_while_recording_setting,
|
||||||
shortcut::change_lazy_stream_close_setting,
|
shortcut::change_append_trailing_space_setting,
|
||||||
shortcut::change_app_language_setting,
|
shortcut::change_lazy_stream_close_setting,
|
||||||
shortcut::change_update_checks_setting,
|
shortcut::change_app_language_setting,
|
||||||
shortcut::change_keyboard_implementation_setting,
|
shortcut::change_update_checks_setting,
|
||||||
shortcut::get_keyboard_implementation,
|
shortcut::change_keyboard_implementation_setting,
|
||||||
shortcut::change_show_tray_icon_setting,
|
shortcut::get_keyboard_implementation,
|
||||||
shortcut::change_whisper_accelerator_setting,
|
shortcut::change_show_tray_icon_setting,
|
||||||
shortcut::change_ort_accelerator_setting,
|
shortcut::change_whisper_accelerator_setting,
|
||||||
shortcut::get_available_accelerators,
|
shortcut::change_ort_accelerator_setting,
|
||||||
shortcut::handy_keys::start_handy_keys_recording,
|
shortcut::get_available_accelerators,
|
||||||
shortcut::handy_keys::stop_handy_keys_recording,
|
shortcut::handy_keys::start_handy_keys_recording,
|
||||||
trigger_update_check,
|
shortcut::handy_keys::stop_handy_keys_recording,
|
||||||
show_main_window_command,
|
trigger_update_check,
|
||||||
commands::cancel_operation,
|
show_main_window_command,
|
||||||
commands::get_app_dir_path,
|
commands::cancel_operation,
|
||||||
commands::get_app_settings,
|
commands::get_app_dir_path,
|
||||||
commands::get_default_settings,
|
commands::get_app_settings,
|
||||||
commands::get_log_dir_path,
|
commands::get_default_settings,
|
||||||
commands::set_log_level,
|
commands::get_log_dir_path,
|
||||||
commands::open_recordings_folder,
|
commands::set_log_level,
|
||||||
commands::open_log_dir,
|
commands::open_recordings_folder,
|
||||||
commands::open_app_data_dir,
|
commands::open_log_dir,
|
||||||
commands::check_apple_intelligence_available,
|
commands::open_app_data_dir,
|
||||||
commands::initialize_enigo,
|
commands::check_apple_intelligence_available,
|
||||||
commands::initialize_shortcuts,
|
commands::initialize_enigo,
|
||||||
commands::models::get_available_models,
|
commands::initialize_shortcuts,
|
||||||
commands::models::get_model_info,
|
commands::models::get_available_models,
|
||||||
commands::models::download_model,
|
commands::models::get_model_info,
|
||||||
commands::models::delete_model,
|
commands::models::download_model,
|
||||||
commands::models::cancel_download,
|
commands::models::delete_model,
|
||||||
commands::models::set_active_model,
|
commands::models::cancel_download,
|
||||||
commands::models::get_current_model,
|
commands::models::set_active_model,
|
||||||
commands::models::get_transcription_model_status,
|
commands::models::get_current_model,
|
||||||
commands::models::is_model_loading,
|
commands::models::get_transcription_model_status,
|
||||||
commands::models::has_any_models_available,
|
commands::models::is_model_loading,
|
||||||
commands::models::has_any_models_or_downloads,
|
commands::models::has_any_models_available,
|
||||||
commands::audio::update_microphone_mode,
|
commands::models::has_any_models_or_downloads,
|
||||||
commands::audio::get_microphone_mode,
|
commands::audio::update_microphone_mode,
|
||||||
commands::audio::get_windows_microphone_permission_status,
|
commands::audio::get_microphone_mode,
|
||||||
commands::audio::open_microphone_privacy_settings,
|
commands::audio::get_windows_microphone_permission_status,
|
||||||
commands::audio::get_available_microphones,
|
commands::audio::open_microphone_privacy_settings,
|
||||||
commands::audio::set_selected_microphone,
|
commands::audio::get_available_microphones,
|
||||||
commands::audio::get_selected_microphone,
|
commands::audio::set_selected_microphone,
|
||||||
commands::audio::get_available_output_devices,
|
commands::audio::get_selected_microphone,
|
||||||
commands::audio::set_selected_output_device,
|
commands::audio::get_available_output_devices,
|
||||||
commands::audio::get_selected_output_device,
|
commands::audio::set_selected_output_device,
|
||||||
commands::audio::play_test_sound,
|
commands::audio::get_selected_output_device,
|
||||||
commands::audio::check_custom_sounds,
|
commands::audio::play_test_sound,
|
||||||
commands::audio::set_clamshell_microphone,
|
commands::audio::check_custom_sounds,
|
||||||
commands::audio::get_clamshell_microphone,
|
commands::audio::set_clamshell_microphone,
|
||||||
commands::audio::is_recording,
|
commands::audio::get_clamshell_microphone,
|
||||||
commands::transcription::set_model_unload_timeout,
|
commands::audio::is_recording,
|
||||||
commands::transcription::get_model_load_status,
|
commands::transcription::set_model_unload_timeout,
|
||||||
commands::transcription::unload_model_manually,
|
commands::transcription::get_model_load_status,
|
||||||
commands::history::get_history_entries,
|
commands::transcription::unload_model_manually,
|
||||||
commands::history::toggle_history_entry_saved,
|
commands::history::get_history_entries,
|
||||||
commands::history::get_audio_file_path,
|
commands::history::toggle_history_entry_saved,
|
||||||
commands::history::delete_history_entry,
|
commands::history::get_audio_file_path,
|
||||||
commands::history::update_history_limit,
|
commands::history::delete_history_entry,
|
||||||
commands::history::update_recording_retention_period,
|
commands::history::update_history_limit,
|
||||||
helpers::clamshell::is_laptop,
|
commands::history::update_recording_retention_period,
|
||||||
]);
|
helpers::clamshell::is_laptop,
|
||||||
|
])
|
||||||
|
.events(collect_events![managers::history::HistoryUpdatePayload,]);
|
||||||
|
|
||||||
#[cfg(debug_assertions)] // <- Only export on non-release builds
|
#[cfg(debug_assertions)] // <- Only export on non-release builds
|
||||||
specta_builder
|
specta_builder
|
||||||
|
|
@ -430,6 +432,8 @@ pub fn run(cli_args: CliArgs) {
|
||||||
)
|
)
|
||||||
.expect("Failed to export typescript bindings");
|
.expect("Failed to export typescript bindings");
|
||||||
|
|
||||||
|
let invoke_handler = specta_builder.invoke_handler();
|
||||||
|
|
||||||
#[allow(unused_mut)]
|
#[allow(unused_mut)]
|
||||||
let mut builder = tauri::Builder::default()
|
let mut builder = tauri::Builder::default()
|
||||||
.device_event_filter(tauri::DeviceEventFilter::Always)
|
.device_event_filter(tauri::DeviceEventFilter::Always)
|
||||||
|
|
@ -497,6 +501,8 @@ pub fn run(cli_args: CliArgs) {
|
||||||
))
|
))
|
||||||
.manage(cli_args.clone())
|
.manage(cli_args.clone())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
|
specta_builder.mount_events(app);
|
||||||
|
|
||||||
// Create main window programmatically so we can set data_directory
|
// Create main window programmatically so we can set data_directory
|
||||||
// for portable mode (redirects WebView2 cache to portable Data dir)
|
// for portable mode (redirects WebView2 cache to portable Data dir)
|
||||||
let mut win_builder =
|
let mut win_builder =
|
||||||
|
|
@ -580,7 +586,7 @@ pub fn run(cli_args: CliArgs) {
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
})
|
})
|
||||||
.invoke_handler(specta_builder.invoke_handler())
|
.invoke_handler(invoke_handler)
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
.run(|app, event| {
|
.run(|app, event| {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::AppHandle;
|
||||||
|
use tauri_specta::Event;
|
||||||
|
|
||||||
use crate::audio_toolkit::save_wav_file;
|
use crate::audio_toolkit::save_wav_file;
|
||||||
|
|
||||||
|
|
@ -33,6 +34,23 @@ static MIGRATIONS: &[M] = &[
|
||||||
M::up("ALTER TABLE transcription_history ADD COLUMN post_process_prompt TEXT;"),
|
M::up("ALTER TABLE transcription_history ADD COLUMN post_process_prompt TEXT;"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||||
|
pub struct PaginatedHistory {
|
||||||
|
pub entries: Vec<HistoryEntry>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, Type, tauri_specta::Event)]
|
||||||
|
#[serde(tag = "action")]
|
||||||
|
pub enum HistoryUpdatePayload {
|
||||||
|
#[serde(rename = "added")]
|
||||||
|
Added { entry: HistoryEntry },
|
||||||
|
#[serde(rename = "deleted")]
|
||||||
|
Deleted { id: i64 },
|
||||||
|
#[serde(rename = "toggled")]
|
||||||
|
Toggled { id: i64 },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||||
pub struct HistoryEntry {
|
pub struct HistoryEntry {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
|
|
@ -193,20 +211,30 @@ impl HistoryManager {
|
||||||
save_wav_file(file_path, &audio_samples).await?;
|
save_wav_file(file_path, &audio_samples).await?;
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
self.save_to_database(
|
let id = self.save_to_database(
|
||||||
file_name,
|
file_name.clone(),
|
||||||
timestamp,
|
timestamp,
|
||||||
title,
|
title.clone(),
|
||||||
transcription_text,
|
transcription_text.clone(),
|
||||||
post_processed_text,
|
post_processed_text.clone(),
|
||||||
post_process_prompt,
|
post_process_prompt.clone(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Clean up old entries
|
// Clean up old entries
|
||||||
self.cleanup_old_entries()?;
|
self.cleanup_old_entries()?;
|
||||||
|
|
||||||
// Emit history updated event
|
// Emit history updated event with the full entry
|
||||||
if let Err(e) = self.app_handle.emit("history-updated", ()) {
|
let entry = HistoryEntry {
|
||||||
|
id,
|
||||||
|
file_name,
|
||||||
|
timestamp,
|
||||||
|
saved: false,
|
||||||
|
title,
|
||||||
|
transcription_text,
|
||||||
|
post_processed_text,
|
||||||
|
post_process_prompt,
|
||||||
|
};
|
||||||
|
if let Err(e) = (HistoryUpdatePayload::Added { entry }).emit(&self.app_handle) {
|
||||||
error!("Failed to emit history-updated event: {}", e);
|
error!("Failed to emit history-updated event: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,15 +249,16 @@ impl HistoryManager {
|
||||||
transcription_text: String,
|
transcription_text: String,
|
||||||
post_processed_text: Option<String>,
|
post_processed_text: Option<String>,
|
||||||
post_process_prompt: Option<String>,
|
post_process_prompt: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<i64> {
|
||||||
let conn = self.get_connection()?;
|
let conn = self.get_connection()?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
"INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
params![file_name, timestamp, false, title, transcription_text, post_processed_text, post_process_prompt],
|
params![file_name, timestamp, false, title, transcription_text, post_processed_text, post_process_prompt],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
debug!("Saved transcription to database");
|
let id = conn.last_insert_rowid();
|
||||||
Ok(())
|
debug!("Saved transcription to database with id {}", id);
|
||||||
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cleanup_old_entries(&self) -> Result<()> {
|
pub fn cleanup_old_entries(&self) -> Result<()> {
|
||||||
|
|
@ -352,13 +381,15 @@ impl HistoryManager {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_history_entries(&self) -> Result<Vec<HistoryEntry>> {
|
pub async fn get_history_entries(
|
||||||
|
&self,
|
||||||
|
cursor: Option<i64>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<PaginatedHistory> {
|
||||||
let conn = self.get_connection()?;
|
let conn = self.get_connection()?;
|
||||||
let mut stmt = conn.prepare(
|
let limit = limit.map(|l| l.min(100));
|
||||||
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt FROM transcription_history ORDER BY timestamp DESC"
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let rows = stmt.query_map([], |row| {
|
let row_mapper = |row: &rusqlite::Row| -> rusqlite::Result<HistoryEntry> {
|
||||||
Ok(HistoryEntry {
|
Ok(HistoryEntry {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
file_name: row.get("file_name")?,
|
file_name: row.get("file_name")?,
|
||||||
|
|
@ -369,14 +400,55 @@ impl HistoryManager {
|
||||||
post_processed_text: row.get("post_processed_text")?,
|
post_processed_text: row.get("post_processed_text")?,
|
||||||
post_process_prompt: row.get("post_process_prompt")?,
|
post_process_prompt: row.get("post_process_prompt")?,
|
||||||
})
|
})
|
||||||
})?;
|
};
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
let mut entries: Vec<HistoryEntry> = match (cursor, limit) {
|
||||||
for row in rows {
|
(Some(cursor_id), Some(lim)) => {
|
||||||
entries.push(row?);
|
let fetch_count = (lim + 1) as i64;
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt
|
||||||
|
FROM transcription_history
|
||||||
|
WHERE id < ?1
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?2",
|
||||||
|
)?;
|
||||||
|
let result = stmt
|
||||||
|
.query_map(params![cursor_id, fetch_count], row_mapper)?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
(None, Some(lim)) => {
|
||||||
|
let fetch_count = (lim + 1) as i64;
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt
|
||||||
|
FROM transcription_history
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?1",
|
||||||
|
)?;
|
||||||
|
let result = stmt
|
||||||
|
.query_map(params![fetch_count], row_mapper)?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
(_, None) => {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt
|
||||||
|
FROM transcription_history
|
||||||
|
ORDER BY id DESC",
|
||||||
|
)?;
|
||||||
|
let result = stmt
|
||||||
|
.query_map([], row_mapper)?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_more = limit.is_some_and(|lim| entries.len() > lim);
|
||||||
|
if has_more {
|
||||||
|
entries.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(entries)
|
Ok(PaginatedHistory { entries, has_more })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_latest_entry(&self) -> Result<Option<HistoryEntry>> {
|
pub fn get_latest_entry(&self) -> Result<Option<HistoryEntry>> {
|
||||||
|
|
@ -430,7 +502,7 @@ impl HistoryManager {
|
||||||
debug!("Toggled saved status for entry {}: {}", id, new_saved);
|
debug!("Toggled saved status for entry {}: {}", id, new_saved);
|
||||||
|
|
||||||
// Emit history updated event
|
// Emit history updated event
|
||||||
if let Err(e) = self.app_handle.emit("history-updated", ()) {
|
if let Err(e) = (HistoryUpdatePayload::Toggled { id }).emit(&self.app_handle) {
|
||||||
error!("Failed to emit history-updated event: {}", e);
|
error!("Failed to emit history-updated event: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -490,7 +562,7 @@ impl HistoryManager {
|
||||||
debug!("Deleted history entry with id: {}", id);
|
debug!("Deleted history entry with id: {}", id);
|
||||||
|
|
||||||
// Emit history updated event
|
// Emit history updated event
|
||||||
if let Err(e) = self.app_handle.emit("history-updated", ()) {
|
if let Err(e) = (HistoryUpdatePayload::Deleted { id }).emit(&self.app_handle) {
|
||||||
error!("Failed to emit history-updated event: {}", e);
|
error!("Failed to emit history-updated event: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -721,9 +721,9 @@ async unloadModelManually() : Promise<Result<null, string>> {
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getHistoryEntries() : Promise<Result<HistoryEntry[], string>> {
|
async getHistoryEntries(cursor: number | null, limit: number | null) : Promise<Result<PaginatedHistory, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_history_entries") };
|
return { status: "ok", data: await TAURI_INVOKE("get_history_entries", { cursor, limit }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
|
|
@ -788,6 +788,11 @@ async isLaptop() : Promise<Result<boolean, string>> {
|
||||||
/** user-defined events **/
|
/** user-defined events **/
|
||||||
|
|
||||||
|
|
||||||
|
export const events = __makeEvents__<{
|
||||||
|
historyUpdatePayload: HistoryUpdatePayload
|
||||||
|
}>({
|
||||||
|
historyUpdatePayload: "history-update-payload"
|
||||||
|
})
|
||||||
|
|
||||||
/** user-defined constants **/
|
/** user-defined constants **/
|
||||||
|
|
||||||
|
|
@ -804,6 +809,7 @@ export type ClipboardHandling = "dont_modify" | "copy_to_clipboard"
|
||||||
export type CustomSounds = { start: boolean; stop: boolean }
|
export type CustomSounds = { start: boolean; stop: boolean }
|
||||||
export type EngineType = "Whisper" | "Parakeet" | "Moonshine" | "MoonshineStreaming" | "SenseVoice" | "GigaAM" | "Canary"
|
export type EngineType = "Whisper" | "Parakeet" | "Moonshine" | "MoonshineStreaming" | "SenseVoice" | "GigaAM" | "Canary"
|
||||||
export type HistoryEntry = { id: number; file_name: string; timestamp: number; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null }
|
export type HistoryEntry = { id: number; file_name: string; timestamp: number; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null }
|
||||||
|
export type HistoryUpdatePayload = { action: "added"; entry: HistoryEntry } | { action: "deleted"; id: number } | { action: "toggled"; id: number }
|
||||||
/**
|
/**
|
||||||
* Result of changing keyboard implementation
|
* Result of changing keyboard implementation
|
||||||
*/
|
*/
|
||||||
|
|
@ -820,6 +826,7 @@ export type ModelLoadStatus = { is_loaded: boolean; current_model: string | null
|
||||||
export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_15"
|
export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_15"
|
||||||
export type OrtAcceleratorSetting = "auto" | "cpu" | "cuda" | "directml" | "rocm"
|
export type OrtAcceleratorSetting = "auto" | "cpu" | "cuda" | "directml" | "rocm"
|
||||||
export type OverlayPosition = "none" | "top" | "bottom"
|
export type OverlayPosition = "none" | "top" | "bottom"
|
||||||
|
export type PaginatedHistory = { entries: HistoryEntry[]; has_more: boolean }
|
||||||
export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" | "external_script"
|
export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" | "external_script"
|
||||||
export type PermissionAccess = "allowed" | "denied" | "unknown"
|
export type PermissionAccess = "allowed" | "denied" | "unknown"
|
||||||
export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean }
|
export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean }
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
|
import { readFile } from "@tauri-apps/plugin-fs";
|
||||||
|
import { Check, Copy, FolderOpen, Star, Trash2 } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
commands,
|
||||||
|
events,
|
||||||
|
type HistoryEntry,
|
||||||
|
type HistoryUpdatePayload,
|
||||||
|
} from "@/bindings";
|
||||||
|
import { useOsType } from "@/hooks/useOsType";
|
||||||
|
import { formatDateTime } from "@/utils/dateFormat";
|
||||||
import { AudioPlayer } from "../../ui/AudioPlayer";
|
import { AudioPlayer } from "../../ui/AudioPlayer";
|
||||||
import { Button } from "../../ui/Button";
|
import { Button } from "../../ui/Button";
|
||||||
import { Copy, Star, Check, Trash2, FolderOpen } from "lucide-react";
|
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
const PAGE_SIZE = 30;
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { readFile } from "@tauri-apps/plugin-fs";
|
|
||||||
import { commands, type HistoryEntry } from "@/bindings";
|
|
||||||
import { formatDateTime } from "@/utils/dateFormat";
|
|
||||||
import { useOsType } from "@/hooks/useOsType";
|
|
||||||
|
|
||||||
interface OpenRecordingsButtonProps {
|
interface OpenRecordingsButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
|
@ -34,53 +40,109 @@ const OpenRecordingsButton: React.FC<OpenRecordingsButtonProps> = ({
|
||||||
export const HistorySettings: React.FC = () => {
|
export const HistorySettings: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const osType = useOsType();
|
const osType = useOsType();
|
||||||
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
const [entries, setEntries] = useState<HistoryEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const entriesRef = useRef<HistoryEntry[]>([]);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
|
// Keep ref in sync for use in IntersectionObserver callback
|
||||||
|
useEffect(() => {
|
||||||
|
entriesRef.current = entries;
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
const loadPage = useCallback(async (cursor?: number) => {
|
||||||
|
const isFirstPage = cursor === undefined;
|
||||||
|
if (!isFirstPage && loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
|
|
||||||
|
if (isFirstPage) setLoading(true);
|
||||||
|
|
||||||
const loadHistoryEntries = useCallback(async () => {
|
|
||||||
try {
|
try {
|
||||||
const result = await commands.getHistoryEntries();
|
const result = await commands.getHistoryEntries(
|
||||||
|
cursor ?? null,
|
||||||
|
PAGE_SIZE,
|
||||||
|
);
|
||||||
if (result.status === "ok") {
|
if (result.status === "ok") {
|
||||||
setHistoryEntries(result.data);
|
const { entries: newEntries, has_more } = result.data;
|
||||||
|
setEntries((prev) =>
|
||||||
|
isFirstPage ? newEntries : [...prev, ...newEntries],
|
||||||
|
);
|
||||||
|
setHasMore(has_more);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load history entries:", error);
|
console.error("Failed to load history entries:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
loadingRef.current = false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadHistoryEntries();
|
loadPage();
|
||||||
|
}, [loadPage]);
|
||||||
|
|
||||||
// Listen for history update events
|
// Infinite scroll via IntersectionObserver
|
||||||
const setupListener = async () => {
|
useEffect(() => {
|
||||||
const unlisten = await listen("history-updated", () => {
|
if (loading) return;
|
||||||
console.log("History updated, reloading entries...");
|
|
||||||
loadHistoryEntries();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return cleanup function
|
const sentinel = sentinelRef.current;
|
||||||
return unlisten;
|
if (!sentinel || !hasMore) return;
|
||||||
};
|
|
||||||
|
|
||||||
let unlistenPromise = setupListener();
|
const observer = new IntersectionObserver(
|
||||||
|
(observerEntries) => {
|
||||||
|
const first = observerEntries[0];
|
||||||
|
if (first.isIntersecting) {
|
||||||
|
const lastEntry = entriesRef.current[entriesRef.current.length - 1];
|
||||||
|
if (lastEntry) {
|
||||||
|
loadPage(lastEntry.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [loading, hasMore, loadPage]);
|
||||||
|
|
||||||
|
// Listen for new entries added from the transcription pipeline
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = events.historyUpdatePayload.listen((event) => {
|
||||||
|
const payload: HistoryUpdatePayload = event.payload;
|
||||||
|
if (payload.action === "added") {
|
||||||
|
setEntries((prev) => [payload.entry, ...prev]);
|
||||||
|
}
|
||||||
|
// "deleted" and "toggled" are handled by optimistic updates only,
|
||||||
|
// so we intentionally ignore them here to avoid double-mutation.
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlistenPromise.then((unlisten) => {
|
unlisten.then((fn) => fn());
|
||||||
if (unlisten) {
|
|
||||||
unlisten();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
}, [loadHistoryEntries]);
|
}, []);
|
||||||
|
|
||||||
const toggleSaved = async (id: number) => {
|
const toggleSaved = async (id: number) => {
|
||||||
|
// Optimistic update
|
||||||
|
setEntries((prev) =>
|
||||||
|
prev.map((e) => (e.id === id ? { ...e, saved: !e.saved } : e)),
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await commands.toggleHistoryEntrySaved(id);
|
const result = await commands.toggleHistoryEntrySaved(id);
|
||||||
// No need to reload here - the event listener will handle it
|
if (result.status !== "ok") {
|
||||||
|
// Revert on failure
|
||||||
|
setEntries((prev) =>
|
||||||
|
prev.map((e) => (e.id === id ? { ...e, saved: !e.saved } : e)),
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle saved status:", error);
|
console.error("Failed to toggle saved status:", error);
|
||||||
|
// Revert on failure
|
||||||
|
setEntries((prev) =>
|
||||||
|
prev.map((e) => (e.id === id ? { ...e, saved: !e.saved } : e)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -100,10 +162,8 @@ export const HistorySettings: React.FC = () => {
|
||||||
if (osType === "linux") {
|
if (osType === "linux") {
|
||||||
const fileData = await readFile(result.data);
|
const fileData = await readFile(result.data);
|
||||||
const blob = new Blob([fileData], { type: "audio/wav" });
|
const blob = new Blob([fileData], { type: "audio/wav" });
|
||||||
|
|
||||||
return URL.createObjectURL(blob);
|
return URL.createObjectURL(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
return convertFileSrc(result.data, "asset");
|
return convertFileSrc(result.data, "asset");
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -116,70 +176,64 @@ export const HistorySettings: React.FC = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteAudioEntry = async (id: number) => {
|
const deleteAudioEntry = async (id: number) => {
|
||||||
|
// Optimistically remove
|
||||||
|
setEntries((prev) => prev.filter((e) => e.id !== id));
|
||||||
try {
|
try {
|
||||||
await commands.deleteHistoryEntry(id);
|
const result = await commands.deleteHistoryEntry(id);
|
||||||
|
if (result.status !== "ok") {
|
||||||
|
// Reload on failure
|
||||||
|
loadPage();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete audio entry:", error);
|
console.error("Failed to delete entry:", error);
|
||||||
throw error;
|
loadPage();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openRecordingsFolder = async () => {
|
const openRecordingsFolder = async () => {
|
||||||
try {
|
try {
|
||||||
await commands.openRecordingsFolder();
|
const result = await commands.openRecordingsFolder();
|
||||||
|
if (result.status !== "ok") {
|
||||||
|
throw new Error(String(result.error));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to open recordings folder:", error);
|
console.error("Failed to open recordings folder:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let content: React.ReactNode;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
content = (
|
||||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
<div className="px-4 py-3 text-center text-text/60">
|
||||||
<div className="space-y-2">
|
{t("settings.history.loading")}
|
||||||
<div className="px-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
|
|
||||||
{t("settings.history.title")}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<OpenRecordingsButton
|
|
||||||
onClick={openRecordingsFolder}
|
|
||||||
label={t("settings.history.openFolder")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
|
|
||||||
<div className="px-4 py-3 text-center text-text/60">
|
|
||||||
{t("settings.history.loading")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
} else if (entries.length === 0) {
|
||||||
|
content = (
|
||||||
if (historyEntries.length === 0) {
|
<div className="px-4 py-3 text-center text-text/60">
|
||||||
return (
|
{t("settings.history.empty")}
|
||||||
<div className="max-w-3xl w-full mx-auto space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="px-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
|
|
||||||
{t("settings.history.title")}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<OpenRecordingsButton
|
|
||||||
onClick={openRecordingsFolder}
|
|
||||||
label={t("settings.history.openFolder")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
|
|
||||||
<div className="px-4 py-3 text-center text-text/60">
|
|
||||||
{t("settings.history.empty")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
<div className="divide-y divide-mid-gray/20">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<HistoryEntryComponent
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
onToggleSaved={() => toggleSaved(entry.id)}
|
||||||
|
onCopyText={() => copyToClipboard(entry.transcription_text)}
|
||||||
|
getAudioUrl={getAudioUrl}
|
||||||
|
deleteAudio={deleteAudioEntry}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Sentinel for infinite scroll */}
|
||||||
|
<div ref={sentinelRef} className="h-1" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -197,18 +251,7 @@ export const HistorySettings: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
|
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
|
||||||
<div className="divide-y divide-mid-gray/20">
|
{content}
|
||||||
{historyEntries.map((entry) => (
|
|
||||||
<HistoryEntryComponent
|
|
||||||
key={entry.id}
|
|
||||||
entry={entry}
|
|
||||||
onToggleSaved={() => toggleSaved(entry.id)}
|
|
||||||
onCopyText={() => copyToClipboard(entry.transcription_text)}
|
|
||||||
getAudioUrl={getAudioUrl}
|
|
||||||
deleteAudio={deleteAudioEntry}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -249,7 +292,7 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
||||||
await deleteAudio(entry.id);
|
await deleteAudio(entry.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete entry:", error);
|
console.error("Failed to delete entry:", error);
|
||||||
alert("Failed to delete entry. Please try again.");
|
alert(t("settings.history.deleteError"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -262,7 +305,7 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={handleCopyText}
|
onClick={handleCopyText}
|
||||||
className="text-text/50 hover:text-logo-primary hover:border-logo-primary transition-colors cursor-pointer"
|
className="text-text/50 hover:text-logo-primary hover:border-logo-primary transition-colors cursor-pointer"
|
||||||
title={t("settings.history.copyToClipboard")}
|
title={t("settings.history.copyToClipboard")}
|
||||||
>
|
>
|
||||||
{showCopied ? (
|
{showCopied ? (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue