Add event bus for live UI updates
Introduces a system for broadcasting real-time events to the web UI. This enables features like automatic page reloads when case data changes in the background, improving the user experience by keeping the UI synchronized with the backend. Key components: - `events` module: Contains the `CaseEventKind` enum, `CaseEvent` struct, and `EventSender` type for managing the broadcast channel. - SSE endpoint (`/web/events`): Streams events to connected browsers. - Client-side JavaScript: Listens for events and triggers debounced page reloads. - Integration points: Workers and route handlers now emit events when relevant state changes occur.
This commit is contained in:
@@ -6,6 +6,7 @@ use tracing::{error, info, warn};
|
||||
|
||||
use super::{TranscribeReceiver, ffmpeg, ollama, whisper};
|
||||
use crate::config::{Config, WhisperUserSettings};
|
||||
use crate::events::{self, CaseEventKind, EventSender};
|
||||
use crate::gazetteer::Gazetteer;
|
||||
use crate::{BusyGuard, WorkerBusy};
|
||||
|
||||
@@ -19,6 +20,7 @@ pub async fn run(
|
||||
client: reqwest::Client,
|
||||
worker_busy: WorkerBusy,
|
||||
vocab: Arc<Gazetteer>,
|
||||
events_tx: EventSender,
|
||||
) {
|
||||
info!("Transcription worker started");
|
||||
let timeout = Duration::from_secs(config.whisper_timeout_seconds);
|
||||
@@ -48,7 +50,7 @@ pub async fn run(
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
error!(audio = %audio_path.display(), error = %e, "ffmpeg remux failed");
|
||||
mark_failed(&audio_path).await;
|
||||
mark_failed(&audio_path, &events_tx, &job.user_slug).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -71,7 +73,7 @@ pub async fn run(
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!(audio = %audio_path.display(), error = %e, "whisper call failed");
|
||||
mark_failed(&audio_path).await;
|
||||
mark_failed(&audio_path, &events_tx, &job.user_slug).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -92,6 +94,17 @@ pub async fn run(
|
||||
"Transcript written"
|
||||
);
|
||||
|
||||
// Notify the live-update bus so subscribed browsers can refresh the
|
||||
// recordings view. case_id = parent dir of the audio file.
|
||||
if let Some(case_dir) = audio_path.parent() {
|
||||
events::emit(
|
||||
&events_tx,
|
||||
&job.user_slug,
|
||||
events::case_id_of(case_dir),
|
||||
CaseEventKind::TranscriptReady,
|
||||
);
|
||||
}
|
||||
|
||||
// Regenerate the oneliner, but only after the *last* pending
|
||||
// recording in the batch — otherwise we'd burn LLM calls on
|
||||
// incomplete inputs that the next job would redo. Later
|
||||
@@ -102,7 +115,15 @@ pub async fn run(
|
||||
if let Some(case_dir) = audio_path.parent()
|
||||
&& !has_pending_recordings(case_dir).await
|
||||
{
|
||||
update_oneliner(case_dir, &job.user_slug, &client, &config, &vocab).await;
|
||||
update_oneliner(
|
||||
case_dir,
|
||||
&job.user_slug,
|
||||
&client,
|
||||
&config,
|
||||
&vocab,
|
||||
&events_tx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +134,7 @@ pub async fn run(
|
||||
/// so the recovery scan no longer picks it up. The UI still surfaces these
|
||||
/// files (with a "failed" flag) so the user can listen to the audio and
|
||||
/// manually decide whether to retry (by renaming back) or to delete.
|
||||
async fn mark_failed(audio_path: &Path) {
|
||||
async fn mark_failed(audio_path: &Path, events_tx: &EventSender, user_slug: &str) {
|
||||
let failed_path = audio_path.with_extension("m4a.failed");
|
||||
if let Err(e) = tokio::fs::rename(audio_path, &failed_path).await {
|
||||
// Nothing to roll back — if rename fails, the worst case is that we
|
||||
@@ -129,6 +150,14 @@ async fn mark_failed(audio_path: &Path) {
|
||||
failed = %failed_path.display(),
|
||||
"Recording marked as failed",
|
||||
);
|
||||
if let Some(case_dir) = audio_path.parent() {
|
||||
events::emit(
|
||||
events_tx,
|
||||
user_slug,
|
||||
events::case_id_of(case_dir),
|
||||
CaseEventKind::TranscriptFailed,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +191,7 @@ pub(crate) async fn update_oneliner(
|
||||
client: &reqwest::Client,
|
||||
config: &Config,
|
||||
vocab: &Gazetteer,
|
||||
events_tx: &EventSender,
|
||||
) {
|
||||
let path = case_dir.join("oneliner.txt");
|
||||
|
||||
@@ -191,6 +221,12 @@ pub(crate) async fn update_oneliner(
|
||||
chars = line.chars().count(),
|
||||
"Oneliner updated"
|
||||
);
|
||||
events::emit(
|
||||
events_tx,
|
||||
user_slug,
|
||||
events::case_id_of(case_dir),
|
||||
CaseEventKind::OnelinerUpdated,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
Reference in New Issue
Block a user