2171 lines
76 KiB
Rust
2171 lines
76 KiB
Rust
//! Tauri command handlers — the frontend's only entry into the core.
|
|
//! Contract: `docs/04-api-contracts.md`. Commands return promptly; long work
|
|
//! is spawned and reported via events ("recording://*", "transcript://*", …).
|
|
//!
|
|
//! Phases 1-5 commands are real implementations; everything for a
|
|
//! not-yet-reached phase returns `Err(not_implemented(...))` rather than
|
|
//! panicking — `panic = "abort"` in the release profile means any panic
|
|
//! reachable from a command handler kills the whole app, not just that call.
|
|
|
|
use crate::audio::{AudioCapture, WasapiCapture};
|
|
use crate::calendar::{CalImport, CalendarSource, PstSource};
|
|
use crate::diarization::{Diarizer, SherpaDiarizer};
|
|
use crate::error::WaResult;
|
|
use crate::hardware::{HardwareDetector, WinHardwareDetector};
|
|
use crate::models::*;
|
|
use crate::notes::NotesRenderer;
|
|
use crate::paths::{
|
|
diarization_embedding_model_file, diarization_segmentation_model_file, meeting_dir,
|
|
settings_path, wa_root, whisper_model_file,
|
|
};
|
|
use crate::storage::{FinalizeMeeting, Meeting, NewMeeting, SummaryFile};
|
|
use crate::transcription::{
|
|
models as model_catalog, run_streaming_worker, Transcriber, WhisperTranscriber,
|
|
};
|
|
use crate::{error::WaError, AppState, RecordingSession};
|
|
use serde::Deserialize;
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex as StdMutex};
|
|
use tauri::{AppHandle, Emitter, Manager, State};
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct StartRecordingArgs {
|
|
pub meeting_title: Option<String>,
|
|
pub calendar_event_id: Option<String>,
|
|
/// Retain audio as .wav? Defaults to false (ADR-0009). Controls retention, not capture.
|
|
#[serde(default)]
|
|
pub record: bool,
|
|
/// Note-template id (Phase 8, T8.1, FR-NOTE-5) — see `notes::built_in_note_templates`.
|
|
pub template_id: Option<String>,
|
|
}
|
|
|
|
// ---- File-backed settings (consent/default-retention/storage policy) ----
|
|
// `settings.json` per `docs/03-data-model.md`; meeting rows/files go through
|
|
// `AppState.store` (Phase 2, `storage::SqliteStore`).
|
|
|
|
fn default_settings() -> Settings {
|
|
Settings {
|
|
theme: "system".into(),
|
|
storage_root: wa_root().display().to_string(),
|
|
llm_provider: "ollama".into(),
|
|
llm_endpoint: "http://localhost:11434".into(),
|
|
llm_model: "llama3".into(),
|
|
preferred_backend: "auto".into(),
|
|
whisper_model: crate::paths::DEFAULT_WHISPER_MODEL.to_string(),
|
|
low_overhead: false,
|
|
default_record: false,
|
|
consent_acknowledged: false,
|
|
sync_enabled: false,
|
|
retention_max_age_days: None,
|
|
retention_max_size_gb: None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn load_settings() -> Settings {
|
|
std::fs::read_to_string(settings_path())
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_else(default_settings)
|
|
}
|
|
|
|
fn save_settings(settings: &Settings) -> Result<(), WaError> {
|
|
let path = settings_path();
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|e| WaError::new("settings", e.to_string()))?;
|
|
}
|
|
let json = serde_json::to_string_pretty(settings)
|
|
.map_err(|e| WaError::new("settings", e.to_string()))?;
|
|
std::fs::write(path, json).map_err(|e| WaError::new("settings", e.to_string()))
|
|
}
|
|
|
|
/// Resolves which model file a recording should use: the "low overhead" preset
|
|
/// (T3.9) always takes the smallest catalog model over whatever is configured,
|
|
/// since it's optimizing for overhead, not accuracy.
|
|
fn model_id_for(settings: &Settings) -> String {
|
|
if settings.low_overhead {
|
|
model_catalog::smallest_id().to_string()
|
|
} else {
|
|
settings.whisper_model.clone()
|
|
}
|
|
}
|
|
|
|
/// Picks the backend to transcribe with: "low overhead" always forces CPU;
|
|
/// otherwise resolve the user's preferred backend (or auto-detect) against
|
|
/// what's actually available (T3.2, T3.5).
|
|
fn backend_for(settings: &Settings) -> BackendId {
|
|
if settings.low_overhead {
|
|
return BackendId::Cpu;
|
|
}
|
|
let preferred = match settings.preferred_backend.as_str() {
|
|
"npu" => Some(BackendId::Npu),
|
|
"nvidia" => Some(BackendId::Nvidia),
|
|
"amd" => Some(BackendId::Amd),
|
|
"intel" => Some(BackendId::Intel),
|
|
"cpu" => Some(BackendId::Cpu),
|
|
_ => None, // "auto"
|
|
};
|
|
WinHardwareDetector.best(preferred).id
|
|
}
|
|
|
|
/// Builds the transcriber for `backend`: the NPU backend goes to the
|
|
/// ONNX/OpenVINO engine (T3.4), everything else to whisper.cpp. Falls through to
|
|
/// whisper.cpp on CPU if the chosen engine can't load, and returns the backend
|
|
/// *actually* used so callers can update their "active backend" state (T3.5,
|
|
/// FR-HW-4). One place so streaming and batch dispatch identically.
|
|
#[cfg(feature = "cpu-transcription")]
|
|
fn load_transcriber(
|
|
backend: BackendId,
|
|
whisper_model: &Path,
|
|
) -> Result<(Box<dyn Transcriber>, BackendId), crate::transcription::TrxError> {
|
|
#[cfg(feature = "npu")]
|
|
if backend == BackendId::Npu {
|
|
use crate::transcription::{onnx_models, OnnxNpuTranscriber};
|
|
if onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL) {
|
|
let dir = onnx_models::model_dir(onnx_models::DEFAULT_ONNX_MODEL);
|
|
match OnnxNpuTranscriber::load(&dir, BackendId::Npu) {
|
|
Ok(t) => return Ok((Box::new(t), BackendId::Npu)),
|
|
Err(e) => tracing::warn!("NPU engine load failed ({e}); falling back to CPU"),
|
|
}
|
|
} else {
|
|
tracing::warn!(
|
|
"NPU selected but ONNX model not installed; falling back to whisper.cpp"
|
|
);
|
|
}
|
|
}
|
|
match WhisperTranscriber::load(whisper_model, backend) {
|
|
Ok(t) => Ok((Box::new(t), backend)),
|
|
Err(e) if backend != BackendId::Cpu => {
|
|
tracing::warn!("backend {backend:?} failed to load ({e}); falling back to CPU");
|
|
WhisperTranscriber::load(whisper_model, BackendId::Cpu)
|
|
.map(|t| (Box::new(t) as Box<dyn Transcriber>, BackendId::Cpu))
|
|
}
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
fn now_unix() -> i64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// A command whose feature isn't built yet (roadmap phase not reached). This
|
|
/// is deliberately a returned `Err`, never a `todo!()`/`unimplemented!()`:
|
|
/// the release profile sets `panic = "abort"`, so a panic anywhere a command
|
|
/// handler can reach would kill the whole app the instant any frontend code
|
|
/// path calls it — not just fail that one call (found the hard way: an
|
|
/// unconditional `listSyncTargets()` on startup was crashing every launch).
|
|
fn not_implemented(feature: &str) -> WaError {
|
|
WaError::new("not_implemented", format!("{feature} isn't built yet"))
|
|
}
|
|
|
|
/// Builds a `Diarizer` if both diarization models are installed — a fixed
|
|
/// pair of well-known filenames, downloadable/removable via
|
|
/// `diarization::models` and `list_diarization_models`/`download_model`/
|
|
/// `remove_model` (T4.7). Returns `None` rather than erring — diarization is
|
|
/// a provisional/refinement layer that a recording never depends on, same
|
|
/// treatment as a missing hardware backend.
|
|
fn diarizer_from_installed_models() -> Option<SherpaDiarizer> {
|
|
let seg_model = diarization_segmentation_model_file();
|
|
let emb_model = diarization_embedding_model_file();
|
|
if !seg_model.exists() || !emb_model.exists() {
|
|
return None;
|
|
}
|
|
match SherpaDiarizer::new(&seg_model, &emb_model) {
|
|
Ok(d) => Some(d),
|
|
Err(e) => {
|
|
tracing::warn!("diarization models present but failed to load: {e}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The distinct speakers seen in `segments` so far, in first-appearance order,
|
|
/// with any display names applied (T4.3/T4.4, FR-SPK-2/5). Falls back to the
|
|
/// single pre-diarization "S1" placeholder if no segments exist yet.
|
|
fn speaker_infos_from_segments(
|
|
segments: &[TranscriptSegment],
|
|
names: &HashMap<String, String>,
|
|
) -> Vec<SpeakerInfo> {
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut out: Vec<SpeakerInfo> = segments
|
|
.iter()
|
|
.filter(|s| seen.insert(s.speaker.clone()))
|
|
.map(|s| SpeakerInfo {
|
|
label: s.speaker.clone(),
|
|
display_name: names.get(&s.speaker).cloned(),
|
|
participant_id: None,
|
|
})
|
|
.collect();
|
|
if out.is_empty() {
|
|
out.push(SpeakerInfo {
|
|
label: "S1".to_string(),
|
|
display_name: names.get("S1").cloned(),
|
|
participant_id: None,
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
// ---- Recording lifecycle (Phase 1) ----
|
|
|
|
#[tauri::command]
|
|
pub async fn start_recording(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
args: StartRecordingArgs,
|
|
) -> WaResult<MeetingId> {
|
|
let mut guard = state.session.lock().await;
|
|
if guard.is_some() {
|
|
return Err(WaError::new(
|
|
"recording",
|
|
"a meeting is already in progress",
|
|
));
|
|
}
|
|
|
|
if args.record && !load_settings().consent_acknowledged {
|
|
return Err(WaError::new(
|
|
"consent",
|
|
"recording consent has not been acknowledged yet",
|
|
));
|
|
}
|
|
|
|
let settings = load_settings();
|
|
let model_id = model_id_for(&settings);
|
|
let backend = backend_for(&settings);
|
|
let model_path = whisper_model_file(&model_id);
|
|
if !model_path.exists() {
|
|
return Err(WaError::new(
|
|
"transcription",
|
|
format!(
|
|
"whisper model '{model_id}' not found at {}; download it from Settings first",
|
|
model_path.display()
|
|
),
|
|
));
|
|
}
|
|
|
|
let meeting_id = state
|
|
.store
|
|
.create_meeting(NewMeeting {
|
|
title: args
|
|
.meeting_title
|
|
.unwrap_or_else(|| "Untitled meeting".to_string()),
|
|
calendar_event_id: args.calendar_event_id,
|
|
template_id: args.template_id,
|
|
})
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
let wav_path = meeting_dir(&meeting_id).join("audio.wav");
|
|
|
|
// Bounded so a slow/stalled transcription worker can never back up the capture thread.
|
|
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
|
|
// Level updates + device-change notices (FR-CAP-5/6); low-volume, forwarded to events below.
|
|
let (event_tx, event_rx) = std::sync::mpsc::sync_channel::<crate::audio::CaptureEvent>(8);
|
|
let capture = WasapiCapture
|
|
.start(&wav_path, frame_tx, event_tx)
|
|
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
|
|
|
// Fire-and-forget: exits on its own once `event_tx` drops at capture stop;
|
|
// nothing downstream needs to join it.
|
|
let app_for_capture_events = app.clone();
|
|
let meeting_id_for_capture_events = meeting_id.clone();
|
|
std::thread::Builder::new()
|
|
.name("wa-capture-events".into())
|
|
.spawn(move || {
|
|
for event in event_rx {
|
|
match event {
|
|
crate::audio::CaptureEvent::Level(level) => {
|
|
let _ = app_for_capture_events.emit(
|
|
"recording://level",
|
|
serde_json::json!({
|
|
"meetingId": meeting_id_for_capture_events,
|
|
"rms": level.rms,
|
|
"peak": level.peak,
|
|
}),
|
|
);
|
|
}
|
|
crate::audio::CaptureEvent::DeviceChanged { recovered, message } => {
|
|
let _ = app_for_capture_events.emit(
|
|
"recording://device",
|
|
serde_json::json!({
|
|
"meetingId": meeting_id_for_capture_events,
|
|
"recovered": recovered,
|
|
"message": message,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
|
|
|
let segments: Arc<StdMutex<Vec<TranscriptSegment>>> = Arc::new(StdMutex::new(Vec::new()));
|
|
let segments_for_worker = segments.clone();
|
|
let active_backend: Arc<StdMutex<BackendId>> = Arc::new(StdMutex::new(backend));
|
|
let active_backend_for_worker = active_backend.clone();
|
|
let app_for_worker = app.clone();
|
|
let meeting_id_for_worker = meeting_id.clone();
|
|
let transcription_worker = std::thread::Builder::new()
|
|
.name("wa-transcription".into())
|
|
.spawn(move || {
|
|
// Dispatch to the NPU engine or whisper.cpp, with graceful CPU
|
|
// fall-through (T3.4/T3.5, FR-HW-4): a GPU/NPU load failure (driver
|
|
// issue, OOM, missing model) drops to CPU rather than losing the
|
|
// meeting's transcript entirely.
|
|
let (transcriber, used) = match load_transcriber(backend, &model_path) {
|
|
Ok(pair) => pair,
|
|
Err(e) => {
|
|
tracing::error!("failed to load any transcriber: {e}");
|
|
return;
|
|
}
|
|
};
|
|
if used != backend {
|
|
if let Ok(mut b) = active_backend_for_worker.lock() {
|
|
*b = used;
|
|
}
|
|
let _ = app_for_worker.emit(
|
|
"hardware://changed",
|
|
serde_json::json!({
|
|
"active": used.as_str(),
|
|
"reason": format!("{backend:?} unavailable; using {used:?}"),
|
|
}),
|
|
);
|
|
}
|
|
run_streaming_worker(transcriber.as_ref(), frame_rx, |segment| {
|
|
if let Ok(mut buf) = segments_for_worker.lock() {
|
|
buf.push(segment.clone());
|
|
}
|
|
let _ = app_for_worker.emit(
|
|
"transcript://segment",
|
|
serde_json::json!({ "meetingId": meeting_id_for_worker, "segment": segment }),
|
|
);
|
|
});
|
|
})
|
|
.map_err(|e| WaError::new("transcription", e.to_string()))?;
|
|
|
|
// T4.3: cheap/provisional live diarization, skipped entirely (None) when
|
|
// the diarization models aren't installed yet (T4.7) — never blocks
|
|
// recording, same graceful-degradation treatment as a missing backend.
|
|
// Loading the ONNX models is blocking I/O, so it runs off this async task.
|
|
let diarizer: Option<Arc<dyn Diarizer>> =
|
|
tauri::async_runtime::spawn_blocking(diarizer_from_installed_models)
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.map(|d| Arc::new(d) as Arc<dyn Diarizer>);
|
|
let speaker_names: Arc<StdMutex<HashMap<String, String>>> =
|
|
Arc::new(StdMutex::new(HashMap::new()));
|
|
|
|
if let Some(diarizer) = diarizer.clone() {
|
|
let app_for_diar = app.clone();
|
|
let meeting_id_for_diar = meeting_id.clone();
|
|
let wav_path_for_diar = wav_path.clone();
|
|
let segments_for_diar = segments.clone();
|
|
let names_for_diar = speaker_names.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
// ponytail: reprocesses the whole recording-so-far each tick
|
|
// rather than incremental/windowed segmentation — sherpa-onnx's
|
|
// offline Diarizer has no streaming primitive to build on, and
|
|
// this is provisional preview only (the accurate pass runs once
|
|
// at stop). Fine at meeting length and a 15s cadence; revisit
|
|
// with real streaming segmentation if long meetings make it heavy.
|
|
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(15));
|
|
ticker.tick().await; // interval's first tick fires immediately; skip it
|
|
loop {
|
|
ticker.tick().await;
|
|
let still_active = app_for_diar
|
|
.state::<AppState>()
|
|
.session
|
|
.lock()
|
|
.await
|
|
.as_ref()
|
|
.is_some_and(|s| s.meeting_id == meeting_id_for_diar);
|
|
if !still_active {
|
|
break; // recording stopped (or a new one started) — nothing left to do
|
|
}
|
|
|
|
let diarizer_for_pass = diarizer.clone();
|
|
let wav_path = wav_path_for_diar.clone();
|
|
let spans = tauri::async_runtime::spawn_blocking(move || {
|
|
diarizer_for_pass.diarize(&wav_path)
|
|
})
|
|
.await;
|
|
let spans = match spans {
|
|
Ok(Ok(spans)) => spans,
|
|
Ok(Err(e)) => {
|
|
tracing::warn!("live diarization pass failed: {e}");
|
|
continue;
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("live diarization task failed: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let speakers = {
|
|
let mut segs = segments_for_diar.lock().unwrap_or_else(|e| e.into_inner());
|
|
diarizer.assign(&mut segs, &spans);
|
|
let names = names_for_diar.lock().unwrap_or_else(|e| e.into_inner());
|
|
speaker_infos_from_segments(&segs, &names)
|
|
};
|
|
let _ = app_for_diar.emit(
|
|
"diarization://updated",
|
|
serde_json::json!({ "meetingId": meeting_id_for_diar, "speakers": speakers }),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
*guard = Some(RecordingSession {
|
|
meeting_id: meeting_id.clone(),
|
|
capture,
|
|
retention: args.record,
|
|
wav_path,
|
|
started_at: std::time::Instant::now(),
|
|
transcription_worker,
|
|
segments,
|
|
active_backend,
|
|
model_id,
|
|
diarizer,
|
|
speaker_names,
|
|
});
|
|
drop(guard);
|
|
|
|
crate::update_tray_tooltip(&app, "WhispAssist — recording");
|
|
let _ = app.emit(
|
|
"recording://state",
|
|
serde_json::json!({ "meetingId": meeting_id, "state": "recording", "elapsedMs": 0 }),
|
|
);
|
|
Ok(meeting_id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn stop_recording(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
) -> WaResult<()> {
|
|
let mut guard = state.session.lock().await;
|
|
let session = match guard.take() {
|
|
Some(s) if s.meeting_id == meeting_id => s,
|
|
Some(s) => {
|
|
*guard = Some(s);
|
|
return Err(WaError::new(
|
|
"recording",
|
|
"meeting_id does not match the active recording",
|
|
));
|
|
}
|
|
None => {
|
|
return Err(WaError::new(
|
|
"recording",
|
|
"no meeting is currently recording",
|
|
))
|
|
}
|
|
};
|
|
drop(guard);
|
|
|
|
let summary = WasapiCapture
|
|
.stop(session.capture)
|
|
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
|
// The capture thread has dropped its `FrameSink`; the transcription worker's
|
|
// `recv()` now returns `Err` and the worker exits on its own — join to
|
|
// guarantee it has fully drained the audio before we act on retention.
|
|
let _ = session.transcription_worker.join();
|
|
|
|
let mut segments = session
|
|
.segments
|
|
.lock()
|
|
.map(|g| g.clone())
|
|
.unwrap_or_default();
|
|
let segment_count = segments.len();
|
|
|
|
// T4.1/4.2: one authoritative diarization pass over the now-complete
|
|
// recording (ADR-0005's "post-stop pass"), refining whatever the live
|
|
// provisional passes (T4.3) produced. Skipped if diarization models
|
|
// aren't installed — `speaker_infos_from_segments` then falls back to
|
|
// the single pre-diarization "S1" placeholder, same as before Phase 4.
|
|
if let Some(diarizer) = session.diarizer.clone() {
|
|
let diarizer_for_task = diarizer.clone();
|
|
let wav_path = session.wav_path.clone();
|
|
match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wav_path))
|
|
.await
|
|
{
|
|
Ok(Ok(spans)) => diarizer.assign(&mut segments, &spans),
|
|
Ok(Err(e)) => tracing::warn!("final diarization pass failed: {e}"),
|
|
Err(e) => tracing::warn!("final diarization task failed: {e}"),
|
|
}
|
|
}
|
|
let speaker_names = session
|
|
.speaker_names
|
|
.lock()
|
|
.map(|g| g.clone())
|
|
.unwrap_or_default();
|
|
let speakers = speaker_infos_from_segments(&segments, &speaker_names);
|
|
let backend_used = session
|
|
.active_backend
|
|
.lock()
|
|
.map(|b| b.as_str().to_string())
|
|
.unwrap_or_else(|_| BackendId::Cpu.as_str().to_string());
|
|
|
|
// T2.10: persist transcript.json (via finalize_meeting) and notes.md
|
|
// *before* touching the working WAV, so a crash here still leaves a
|
|
// recoverable, regenerable meeting.
|
|
let template_id = state
|
|
.store
|
|
.finalize_meeting(
|
|
&meeting_id,
|
|
FinalizeMeeting {
|
|
segments: segments.clone(),
|
|
speakers: speakers.clone(),
|
|
duration_secs: (summary.duration_ms / 1000) as i64,
|
|
recorded: session.retention,
|
|
language: None,
|
|
backend_used: Some(backend_used),
|
|
model_used: Some(session.model_id.clone()),
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
|
|
let template = template_id
|
|
.as_deref()
|
|
.and_then(crate::notes::note_template_by_id);
|
|
let notes_md =
|
|
crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, None, template.as_ref());
|
|
let _ = state.store.update_notes(&meeting_id, ¬es_md).await;
|
|
|
|
let _ = app.emit(
|
|
"transcript://finalized",
|
|
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segment_count }),
|
|
);
|
|
|
|
// ADR-0009: delete the working WAV only after the transcript is finalized
|
|
// above, and only when retention is off.
|
|
if !session.retention {
|
|
let _ = std::fs::remove_file(&session.wav_path);
|
|
}
|
|
|
|
crate::update_tray_tooltip(&app, "WhispAssist — idle");
|
|
let _ = app.emit(
|
|
"recording://state",
|
|
serde_json::json!({ "meetingId": meeting_id, "state": "stopped", "elapsedMs": summary.duration_ms }),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn pause_recording(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
) -> WaResult<()> {
|
|
let guard = state.session.lock().await;
|
|
let session = guard
|
|
.as_ref()
|
|
.filter(|s| s.meeting_id == meeting_id)
|
|
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
|
|
WasapiCapture
|
|
.pause(&session.capture)
|
|
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
|
drop(guard);
|
|
let _ = app.emit(
|
|
"recording://state",
|
|
serde_json::json!({ "meetingId": meeting_id, "state": "paused", "elapsedMs": 0 }),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn resume_recording(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
) -> WaResult<()> {
|
|
let guard = state.session.lock().await;
|
|
let session = guard
|
|
.as_ref()
|
|
.filter(|s| s.meeting_id == meeting_id)
|
|
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
|
|
WasapiCapture
|
|
.resume(&session.capture)
|
|
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
|
drop(guard);
|
|
let _ = app.emit(
|
|
"recording://state",
|
|
serde_json::json!({ "meetingId": meeting_id, "state": "recording", "elapsedMs": 0 }),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Toggle audio retention mid-meeting (ADR-0009, FR-REC-1).
|
|
#[tauri::command]
|
|
pub async fn set_recording_retention(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
record: bool,
|
|
) -> WaResult<()> {
|
|
if record && !load_settings().consent_acknowledged {
|
|
return Err(WaError::new(
|
|
"consent",
|
|
"recording consent has not been acknowledged yet",
|
|
));
|
|
}
|
|
let mut guard = state.session.lock().await;
|
|
let session = guard
|
|
.as_mut()
|
|
.filter(|s| s.meeting_id == meeting_id)
|
|
.ok_or_else(|| WaError::new("recording", "no matching active recording"))?;
|
|
session.retention = record;
|
|
drop(guard);
|
|
let _ = app.emit(
|
|
"recording://retention",
|
|
serde_json::json!({ "meetingId": meeting_id, "record": record }),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Record the one-time recording-consent acknowledgment (FR-REC-2).
|
|
#[tauri::command]
|
|
pub async fn acknowledge_recording_consent() -> WaResult<()> {
|
|
let mut settings = load_settings();
|
|
settings.consent_acknowledged = true;
|
|
save_settings(&settings)
|
|
}
|
|
|
|
// ---- Speakers (Phase 4) ----
|
|
|
|
/// Re-renders and persists `notes.md` from a finalized meeting's current
|
|
/// (post-rename/post-merge) segments+speakers, and tells the frontend what
|
|
/// changed (T4.5/4.6, FR-SPK-3/5). This is what keeps `export_meeting` — which
|
|
/// just copies the already-rendered `notes.md` — in sync with naming changes
|
|
/// made after the meeting ends; `transcript.json` itself is untouched.
|
|
async fn refresh_notes_and_notify(
|
|
app: &AppHandle,
|
|
state: &State<'_, AppState>,
|
|
meeting_id: &MeetingId,
|
|
) -> WaResult<Vec<SpeakerInfo>> {
|
|
let meeting = state
|
|
.store
|
|
.get_meeting(meeting_id)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
let template = meeting
|
|
.template_id
|
|
.as_deref()
|
|
.and_then(crate::notes::note_template_by_id);
|
|
let notes_md = crate::notes::MarkdownNotes.to_markdown(
|
|
&meeting.segments,
|
|
&meeting.speakers,
|
|
None,
|
|
template.as_ref(),
|
|
);
|
|
let _ = state.store.update_notes(meeting_id, ¬es_md).await;
|
|
let _ = app.emit(
|
|
"diarization://updated",
|
|
serde_json::json!({ "meetingId": meeting_id, "speakers": meeting.speakers }),
|
|
);
|
|
Ok(meeting.speakers)
|
|
}
|
|
|
|
/// Name a speaker; applies to that speaker's past & future segments (T4.4,
|
|
/// FR-SPK-2). Segments only ever carry the internal label ("S1"…) — never
|
|
/// rewritten — so persisting the label→name mapping here is enough to cover
|
|
/// both past and future segments once names are resolved at render time
|
|
/// (FR-SPK-5). Works whether the meeting is still recording or already
|
|
/// finalized.
|
|
#[tauri::command]
|
|
pub async fn rename_speaker(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
label: String,
|
|
name: String,
|
|
) -> WaResult<()> {
|
|
state
|
|
.store
|
|
.rename_speaker(&meeting_id, &label, &name)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
|
|
let guard = state.session.lock().await;
|
|
match guard.as_ref().filter(|s| s.meeting_id == meeting_id) {
|
|
Some(session) => {
|
|
// Still recording: transcript.json/notes.md don't exist on disk
|
|
// yet, so there's nothing to re-render — just refresh the live
|
|
// in-memory view (finalize builds notes.md from this same map
|
|
// at stop, T4.3/4.4).
|
|
let names = {
|
|
let mut names = session
|
|
.speaker_names
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
names.insert(label, name);
|
|
names.clone()
|
|
};
|
|
let segments = session
|
|
.segments
|
|
.lock()
|
|
.map(|g| g.clone())
|
|
.unwrap_or_default();
|
|
drop(guard);
|
|
let speakers = speaker_infos_from_segments(&segments, &names);
|
|
let _ = app.emit(
|
|
"diarization://updated",
|
|
serde_json::json!({ "meetingId": meeting_id, "speakers": speakers }),
|
|
);
|
|
}
|
|
None => {
|
|
drop(guard);
|
|
refresh_notes_and_notify(&app, &state, &meeting_id).await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Fold over-split speakers into one canonical label (T4.5, FR-SPK-3) — e.g.
|
|
/// diarization split one person into "S2" and "S3"; merging them shows one
|
|
/// name and one grouped paragraph in notes/export. Post-meeting only: while
|
|
/// still recording, the live provisional pass (T4.3) re-clusters from scratch
|
|
/// every tick, so a label merged now could mean something else by the next
|
|
/// tick.
|
|
#[tauri::command]
|
|
pub async fn merge_speakers(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
from: Vec<String>,
|
|
into: String,
|
|
) -> WaResult<()> {
|
|
let guard = state.session.lock().await;
|
|
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
|
|
return Err(WaError::new(
|
|
"recording",
|
|
"cannot merge speakers while this meeting is still recording — wait until it's stopped",
|
|
));
|
|
}
|
|
drop(guard);
|
|
|
|
state
|
|
.store
|
|
.merge_speakers(&meeting_id, &from, &into)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
refresh_notes_and_notify(&app, &state, &meeting_id).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Name a speaker AND link them to a known `Participant` (T6.5/T6.6,
|
|
/// FR-SPK-4) — e.g. picked from the linked calendar event's attendee
|
|
/// dropdown rather than typed free-text. The shared `participant_id` is what
|
|
/// gives naming continuity across meetings the same person attends. Post-
|
|
/// meeting only, same reasoning as `merge_speakers`: the live provisional
|
|
/// diarization pass re-clusters from scratch every tick, so a label mapped
|
|
/// now could mean someone else by the next tick.
|
|
#[tauri::command]
|
|
pub async fn map_speaker_to_participant(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
label: String,
|
|
participant_id: String,
|
|
) -> WaResult<()> {
|
|
let guard = state.session.lock().await;
|
|
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
|
|
return Err(WaError::new(
|
|
"recording",
|
|
"cannot map a speaker to a participant while this meeting is still recording — wait until it's stopped",
|
|
));
|
|
}
|
|
drop(guard);
|
|
|
|
state
|
|
.store
|
|
.map_speaker_to_participant(&meeting_id, &label, &participant_id)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
refresh_notes_and_notify(&app, &state, &meeting_id).await?;
|
|
Ok(())
|
|
}
|
|
|
|
// ---- Hardware + models (Phase 3) ----
|
|
|
|
#[tauri::command]
|
|
pub async fn hardware_status() -> WaResult<serde_json::Value> {
|
|
let settings = load_settings();
|
|
let backends = WinHardwareDetector.detect();
|
|
let active = backend_for(&settings);
|
|
let model_id = model_id_for(&settings);
|
|
Ok(serde_json::json!({
|
|
"backends": backends,
|
|
"active": active,
|
|
"modelSize": model_id,
|
|
// ponytail: no real-time factor measurement harness yet (needs a
|
|
// timed sample transcription); 1.0 stands in until T3.6 wires one up.
|
|
"estRtf": 1.0,
|
|
// NPU package state (T3.4 step 2) — drives the Settings ▸ Hardware
|
|
// download indicator: chip detected, its runtime staged, model fetched.
|
|
"npu": {
|
|
"present": crate::hardware::npu_hardware_present(),
|
|
"runtimeReady": crate::paths::npu_runtime_ready(),
|
|
"modelInstalled": npu_model_installed(),
|
|
},
|
|
}))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SetPreferredBackendArgs {
|
|
pub backend: String, // "auto"|"npu"|"nvidia"|"amd"|"intel"|"cpu"
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn set_preferred_backend(args: SetPreferredBackendArgs) -> WaResult<()> {
|
|
let mut settings = load_settings();
|
|
settings.preferred_backend = args.backend;
|
|
save_settings(&settings)
|
|
}
|
|
|
|
/// True when the NPU ONNX Whisper model is downloaded (false on non-NPU builds).
|
|
fn npu_model_installed() -> bool {
|
|
#[cfg(feature = "npu")]
|
|
{
|
|
use crate::transcription::onnx_models;
|
|
onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL)
|
|
}
|
|
#[cfg(not(feature = "npu"))]
|
|
{
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Downloads the Whisper ONNX model for the NPU engine, emitting `npu://download`
|
|
/// progress. Idempotent — skips artifacts already on disk. Also used by the
|
|
/// startup background fetch (see `lib.rs`).
|
|
#[cfg(feature = "npu")]
|
|
pub async fn download_npu_model(app: AppHandle) -> WaResult<()> {
|
|
use crate::transcription::onnx_models;
|
|
let app2 = app.clone();
|
|
onnx_models::download(
|
|
onnx_models::DEFAULT_ONNX_MODEL,
|
|
move |idx, received, total| {
|
|
let _ = app2.emit(
|
|
"npu://download",
|
|
serde_json::json!({ "stage": "model", "file": idx, "received": received, "total": total }),
|
|
);
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| WaError::new("npu", e.to_string()))
|
|
}
|
|
|
|
/// Stages the ONNX Runtime + OpenVINO DLLs into the app's NPU runtime dir.
|
|
///
|
|
/// ponytail: sourced by copying DLLs from local dirs named in
|
|
/// `WA_NPU_RUNTIME_SRC` (';'-separated) — the same act a bundled installer step
|
|
/// would perform. Upgrade path: host a versioned runtime bundle and
|
|
/// download+unzip it here instead. Until a source is configured this is a clean
|
|
/// typed error, never a panic.
|
|
#[cfg(feature = "npu")]
|
|
async fn stage_npu_runtime(app: &AppHandle) -> WaResult<()> {
|
|
if crate::paths::npu_runtime_ready() {
|
|
return Ok(());
|
|
}
|
|
let dir = crate::paths::npu_runtime_dir();
|
|
std::fs::create_dir_all(&dir).map_err(|e| WaError::new("npu", e.to_string()))?;
|
|
let src = std::env::var_os("WA_NPU_RUNTIME_SRC").ok_or_else(|| {
|
|
WaError::new(
|
|
"npu",
|
|
"NPU runtime source not configured (set WA_NPU_RUNTIME_SRC to the ORT+OpenVINO DLL dir[s])",
|
|
)
|
|
})?;
|
|
let mut copied = 0u32;
|
|
for d in std::env::split_paths(&src) {
|
|
let Ok(entries) = std::fs::read_dir(&d) else {
|
|
continue;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let p = entry.path();
|
|
if p.extension().and_then(|s| s.to_str()) == Some("dll") {
|
|
if let Some(name) = p.file_name() {
|
|
if std::fs::copy(&p, dir.join(name)).is_ok() {
|
|
copied += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let _ = app.emit(
|
|
"npu://download",
|
|
serde_json::json!({ "stage": "runtime", "copied": copied }),
|
|
);
|
|
}
|
|
if !crate::paths::npu_runtime_ready() {
|
|
return Err(WaError::new(
|
|
"npu",
|
|
format!("staged {copied} dll(s) but onnxruntime.dll not among them"),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Downloads everything the NPU engine needs (ONNX model + OpenVINO runtime) for
|
|
/// the Settings ▸ Hardware "download NPU package" action (T3.4 step 2).
|
|
#[tauri::command]
|
|
pub async fn download_npu_package(app: AppHandle) -> WaResult<()> {
|
|
#[cfg(feature = "npu")]
|
|
{
|
|
download_npu_model(app.clone()).await?;
|
|
stage_npu_runtime(&app).await?;
|
|
let _ = app.emit(
|
|
"npu://download",
|
|
serde_json::json!({ "stage": "done", "ready": crate::paths::npu_runtime_ready() }),
|
|
);
|
|
Ok(())
|
|
}
|
|
#[cfg(not(feature = "npu"))]
|
|
{
|
|
let _ = app;
|
|
Err(WaError::new("npu", "this build has no NPU support"))
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_models() -> WaResult<Vec<ModelInfo>> {
|
|
let settings = load_settings();
|
|
Ok(model_catalog::list(&model_id_for(&settings)))
|
|
}
|
|
|
|
/// The fixed segmentation+embedding pair (T4.7, FR-MODEL-1) — a separate
|
|
/// command rather than folding into `list_models` because they're a fixed
|
|
/// installable pair, not an interchangeable-size catalog like whisper's.
|
|
#[tauri::command]
|
|
pub async fn list_diarization_models() -> WaResult<Vec<ModelInfo>> {
|
|
Ok(crate::diarization::models::list())
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct DownloadModelArgs {
|
|
pub kind: String, // "whisper" | "diar-seg" | "diar-emb"
|
|
pub id: String,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn download_model(app: AppHandle, args: DownloadModelArgs) -> WaResult<()> {
|
|
let id = args.id;
|
|
let id_for_progress = id.clone();
|
|
let on_progress = move |received: u64, total: Option<u64>| {
|
|
let _ = app.emit(
|
|
"model://progress",
|
|
serde_json::json!({ "id": id_for_progress, "receivedBytes": received, "totalBytes": total }),
|
|
);
|
|
};
|
|
match args.kind.as_str() {
|
|
"whisper" => model_catalog::download(&id, on_progress)
|
|
.await
|
|
.map_err(|e| WaError::new("model", e.to_string())),
|
|
"diar-seg" | "diar-emb" => crate::diarization::models::download(&id, on_progress)
|
|
.await
|
|
.map_err(|e| WaError::new("model", e.to_string())),
|
|
other => Err(WaError::new(
|
|
"model",
|
|
format!("unknown model kind '{other}'"),
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn remove_model(id: String) -> WaResult<()> {
|
|
// No `kind` in this command's contract — disambiguate by catalog
|
|
// membership instead; whisper/diarization ids never collide.
|
|
if crate::diarization::models::list()
|
|
.iter()
|
|
.any(|m| m.id == id)
|
|
{
|
|
return crate::diarization::models::remove(&id)
|
|
.map_err(|e| WaError::new("model", e.to_string()));
|
|
}
|
|
let settings = load_settings();
|
|
model_catalog::remove(&id, &model_id_for(&settings))
|
|
.map_err(|e| WaError::new("model", e.to_string()))
|
|
}
|
|
|
|
/// Batch re-transcribe a finished meeting with a different (typically larger)
|
|
/// model (T3.8, FR-TRX-3). Only works if the meeting's audio was retained.
|
|
#[tauri::command]
|
|
pub async fn reprocess_transcript(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
model: String,
|
|
) -> WaResult<()> {
|
|
let model_path = whisper_model_file(&model);
|
|
if !model_path.exists() {
|
|
return Err(WaError::new(
|
|
"model",
|
|
format!("model '{model}' is not installed"),
|
|
));
|
|
}
|
|
let wav_path = meeting_dir(&meeting_id).join("audio.wav");
|
|
if !wav_path.exists() {
|
|
return Err(WaError::new(
|
|
"transcription",
|
|
"no retained audio.wav to reprocess — enable recording retention for this meeting",
|
|
));
|
|
}
|
|
|
|
let settings = load_settings();
|
|
let backend = backend_for(&settings);
|
|
let segments = tauri::async_runtime::spawn_blocking({
|
|
let wav_path = wav_path.clone();
|
|
move || {
|
|
let (transcriber, _used) = load_transcriber(backend, &model_path)?;
|
|
transcriber.transcribe_file(&wav_path)
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|e| WaError::new("transcription", e.to_string()))?
|
|
.map_err(|e| WaError::new("transcription", e.to_string()))?;
|
|
|
|
let meeting = state
|
|
.store
|
|
.get_meeting(&meeting_id)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
let duration_secs = segments
|
|
.last()
|
|
.map(|s| (s.end_ms / 1000) as i64)
|
|
.unwrap_or(meeting.duration_secs.unwrap_or(0));
|
|
|
|
let template = meeting
|
|
.template_id
|
|
.as_deref()
|
|
.and_then(crate::notes::note_template_by_id);
|
|
state
|
|
.store
|
|
.finalize_meeting(
|
|
&meeting_id,
|
|
FinalizeMeeting {
|
|
segments: segments.clone(),
|
|
speakers: meeting.speakers.clone(),
|
|
duration_secs,
|
|
recorded: meeting.recorded,
|
|
language: meeting.language,
|
|
backend_used: Some(backend.as_str().to_string()),
|
|
model_used: Some(model),
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
|
|
let notes_md = crate::notes::MarkdownNotes.to_markdown(
|
|
&segments,
|
|
&meeting.speakers,
|
|
None,
|
|
template.as_ref(),
|
|
);
|
|
let _ = state.store.update_notes(&meeting_id, ¬es_md).await;
|
|
|
|
let _ = app.emit(
|
|
"transcript://finalized",
|
|
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Re-run transcription from a `recovering` meeting's working `audio.wav`
|
|
/// (T2.8, FR-REL-1). CPU-bound, so it runs on a blocking task rather than
|
|
/// tying up an async worker.
|
|
#[tauri::command]
|
|
pub async fn resume_transcription(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
) -> WaResult<()> {
|
|
let settings = load_settings();
|
|
let model_id = model_id_for(&settings);
|
|
let model_path = whisper_model_file(&model_id);
|
|
if !model_path.exists() {
|
|
return Err(WaError::new(
|
|
"transcription",
|
|
format!(
|
|
"whisper model '{model_id}' not found at {}; download it from Settings first",
|
|
model_path.display()
|
|
),
|
|
));
|
|
}
|
|
let wav_path = meeting_dir(&meeting_id).join("audio.wav");
|
|
if !wav_path.exists() {
|
|
return Err(WaError::new(
|
|
"recovery",
|
|
"no working audio.wav to recover from",
|
|
));
|
|
}
|
|
|
|
// Note: deliberately doesn't emit `recording://state` — that event drives
|
|
// the main header's single live-session Record/Stop toggle, and reusing
|
|
// it here would make the header show a phantom "Stop" button for a
|
|
// recovery run that isn't a `RecordingSession` at all. The "recovering"
|
|
// badge in the meetings list is this operation's own progress signal.
|
|
|
|
let segments = tauri::async_runtime::spawn_blocking(move || {
|
|
WhisperTranscriber::load(&model_path, BackendId::Cpu)
|
|
.and_then(|transcriber| transcriber.transcribe_file(&wav_path))
|
|
})
|
|
.await
|
|
.map_err(|e| WaError::new("transcription", e.to_string()))?
|
|
.map_err(|e| WaError::new("transcription", e.to_string()))?;
|
|
|
|
let speakers = vec![SpeakerInfo {
|
|
label: "S1".to_string(),
|
|
display_name: None,
|
|
participant_id: None,
|
|
}];
|
|
let duration_secs = segments
|
|
.last()
|
|
.map(|s| (s.end_ms / 1000) as i64)
|
|
.unwrap_or(0);
|
|
|
|
let template_id = state
|
|
.store
|
|
.finalize_meeting(
|
|
&meeting_id,
|
|
FinalizeMeeting {
|
|
segments: segments.clone(),
|
|
speakers: speakers.clone(),
|
|
duration_secs,
|
|
// The working WAV surviving a crash is the only signal we have
|
|
// left about intent; keep it rather than silently discard it.
|
|
recorded: true,
|
|
language: None,
|
|
backend_used: Some(BackendId::Cpu.as_str().to_string()),
|
|
model_used: Some(model_id),
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
|
|
let template = template_id
|
|
.as_deref()
|
|
.and_then(crate::notes::note_template_by_id);
|
|
let notes_md =
|
|
crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, None, template.as_ref());
|
|
let _ = state.store.update_notes(&meeting_id, ¬es_md).await;
|
|
|
|
let _ = app.emit(
|
|
"transcript://finalized",
|
|
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
// ---- Meetings / storage (Phase 2) ----
|
|
|
|
#[tauri::command]
|
|
pub async fn list_meetings(
|
|
state: State<'_, AppState>,
|
|
query: Option<String>,
|
|
tag: Option<String>,
|
|
participant_id: Option<String>,
|
|
from: Option<i64>,
|
|
to: Option<i64>,
|
|
) -> WaResult<Vec<MeetingListItem>> {
|
|
state
|
|
.store
|
|
.list_meetings(crate::storage::MeetingFilter {
|
|
query,
|
|
tag,
|
|
participant_id,
|
|
from,
|
|
to,
|
|
})
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
|
}
|
|
|
|
/// Replaces a meeting's complete tag set (Phase 8, FR-SEARCH-2).
|
|
#[tauri::command]
|
|
pub async fn set_tags(
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
tags: Vec<String>,
|
|
) -> WaResult<()> {
|
|
state
|
|
.store
|
|
.set_tags(&meeting_id, &tags)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
|
}
|
|
|
|
/// All known tag names, sorted, for filter/autocomplete UI (Phase 8, FR-SEARCH-2).
|
|
#[tauri::command]
|
|
pub async fn list_tags(state: State<'_, AppState>) -> WaResult<Vec<String>> {
|
|
state
|
|
.store
|
|
.list_tags()
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
|
}
|
|
|
|
/// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1) —
|
|
/// distinct from `list_meetings`' `query`, which only substring-matches the
|
|
/// title.
|
|
#[tauri::command]
|
|
pub async fn search(state: State<'_, AppState>, query: String) -> WaResult<Vec<SearchHit>> {
|
|
state
|
|
.store
|
|
.search(&query)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
|
}
|
|
|
|
/// Built-in note templates for a picker at recording-start time (Phase 8,
|
|
/// T8.1, FR-NOTE-5).
|
|
#[tauri::command]
|
|
pub async fn list_note_templates() -> WaResult<Vec<crate::notes::NoteTemplate>> {
|
|
Ok(crate::notes::built_in_note_templates())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult<Meeting> {
|
|
state
|
|
.store
|
|
.get_meeting(&meeting_id)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn delete_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult<()> {
|
|
let guard = state.session.lock().await;
|
|
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
|
|
return Err(WaError::new(
|
|
"recording",
|
|
"cannot delete a meeting that is currently recording",
|
|
));
|
|
}
|
|
drop(guard);
|
|
state
|
|
.store
|
|
.delete_meeting(&meeting_id)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn update_notes(
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
markdown: String,
|
|
) -> WaResult<()> {
|
|
state
|
|
.store
|
|
.update_notes(&meeting_id, &markdown)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
|
}
|
|
|
|
/// `format` ∈ `md | pdf | docx | bundle`. `dest` is a file path for
|
|
/// `md`/`pdf`/`docx`, a destination folder for `bundle` (the frontend gets
|
|
/// it from a native Save/choose-folder dialog).
|
|
#[tauri::command]
|
|
pub async fn export_meeting(
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
dest: String,
|
|
format: String,
|
|
) -> WaResult<String> {
|
|
let dest_path = PathBuf::from(&dest);
|
|
export_meeting_to(&state, &meeting_id, &dest_path, &format).await?;
|
|
Ok(dest)
|
|
}
|
|
|
|
/// Bulk export (Phase 8, T8.5, FR-STORE-4) — every meeting matching the
|
|
/// tag/date filter, one file (or bundle folder) per meeting under `dest_dir`.
|
|
/// Reuses the same filter shape as `list_meetings`; a meeting that fails to
|
|
/// export (e.g. a "recovering" meeting with no notes yet) is skipped rather
|
|
/// than aborting the whole batch. Returns the count actually exported.
|
|
#[tauri::command]
|
|
pub async fn bulk_export_meetings(
|
|
state: State<'_, AppState>,
|
|
dest_dir: String,
|
|
format: String,
|
|
tag: Option<String>,
|
|
from: Option<i64>,
|
|
to: Option<i64>,
|
|
) -> WaResult<u32> {
|
|
let items = state
|
|
.store
|
|
.list_meetings(crate::storage::MeetingFilter {
|
|
tag,
|
|
from,
|
|
to,
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
|
|
let dir = PathBuf::from(&dest_dir);
|
|
std::fs::create_dir_all(&dir).map_err(|e| WaError::new("export", e.to_string()))?;
|
|
|
|
let mut count = 0u32;
|
|
for item in items {
|
|
let stem = bulk_export_stem(&item);
|
|
let dest_path = if format == "bundle" {
|
|
dir.join(stem)
|
|
} else {
|
|
dir.join(format!("{stem}.{format}"))
|
|
};
|
|
match export_meeting_to(&state, &item.id, &dest_path, &format).await {
|
|
Ok(()) => count += 1,
|
|
Err(e) => tracing::warn!("bulk export skipped meeting {}: {}", item.id, e.message),
|
|
}
|
|
}
|
|
Ok(count)
|
|
}
|
|
|
|
/// A filesystem-safe, collision-resistant filename stem: sanitized title +
|
|
/// an id prefix, since meeting titles are very often duplicates ("Untitled
|
|
/// meeting") and would otherwise silently overwrite each other in a batch.
|
|
fn bulk_export_stem(item: &MeetingListItem) -> String {
|
|
let safe_title: String = item
|
|
.title
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_alphanumeric() || c == '-' {
|
|
c
|
|
} else {
|
|
'_'
|
|
}
|
|
})
|
|
.collect();
|
|
let safe_title = safe_title.trim_matches('_');
|
|
let id_prefix = &item.id[..item.id.len().min(8)];
|
|
format!("{safe_title}_{id_prefix}")
|
|
}
|
|
|
|
async fn export_meeting_to(
|
|
state: &State<'_, AppState>,
|
|
meeting_id: &MeetingId,
|
|
dest_path: &Path,
|
|
format: &str,
|
|
) -> WaResult<()> {
|
|
let meeting = state
|
|
.store
|
|
.get_meeting(meeting_id)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
match format {
|
|
"md" => {
|
|
crate::notes::MarkdownNotes
|
|
.export(
|
|
&meeting.notes_markdown,
|
|
dest_path,
|
|
crate::notes::ExportFormat::Md,
|
|
)
|
|
.map_err(|e| WaError::new("export", e.to_string()))?;
|
|
}
|
|
"pdf" => {
|
|
crate::notes::MarkdownNotes
|
|
.export(
|
|
&meeting.notes_markdown,
|
|
dest_path,
|
|
crate::notes::ExportFormat::Pdf,
|
|
)
|
|
.map_err(|e| WaError::new("export", e.to_string()))?;
|
|
}
|
|
"docx" => {
|
|
crate::notes::MarkdownNotes
|
|
.export(
|
|
&meeting.notes_markdown,
|
|
dest_path,
|
|
crate::notes::ExportFormat::Docx,
|
|
)
|
|
.map_err(|e| WaError::new("export", e.to_string()))?;
|
|
}
|
|
"bundle" => {
|
|
std::fs::create_dir_all(dest_path)
|
|
.map_err(|e| WaError::new("export", e.to_string()))?;
|
|
let source_dir = meeting_dir(meeting_id);
|
|
for name in ["audio.wav", "transcript.json"] {
|
|
let src = source_dir.join(name);
|
|
if src.exists() {
|
|
std::fs::copy(&src, dest_path.join(name))
|
|
.map_err(|e| WaError::new("export", e.to_string()))?;
|
|
}
|
|
}
|
|
std::fs::write(dest_path.join("notes.md"), &meeting.notes_markdown)
|
|
.map_err(|e| WaError::new("export", e.to_string()))?;
|
|
}
|
|
other => {
|
|
return Err(WaError::new(
|
|
"export",
|
|
format!("unsupported export format: {other}"),
|
|
))
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---- LLM (Phase 5) ----
|
|
|
|
/// Builds the configured `LlmProvider`, or `None` if LLM integration is off
|
|
/// (default — the zero-egress state, FR-LLM-1).
|
|
fn llm_provider_from_settings(settings: &Settings) -> Option<Box<dyn crate::llm::LlmProvider>> {
|
|
match settings.llm_provider.as_str() {
|
|
"ollama" => Some(Box::new(crate::llm::OllamaProvider {
|
|
endpoint: settings.llm_endpoint.clone(),
|
|
model: settings.llm_model.clone(),
|
|
})),
|
|
"custom" => Some(Box::new(crate::llm::OpenAiCompatProvider {
|
|
endpoint: settings.llm_endpoint.clone(),
|
|
model: settings.llm_model.clone(),
|
|
credential_ref: None, // local, unauthenticated custom endpoint (ADR-0007); Phase 10a sets this for hosted gateways
|
|
})),
|
|
_ => None, // "off"
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn llm_status() -> WaResult<serde_json::Value> {
|
|
let settings = load_settings();
|
|
let Some(provider) = llm_provider_from_settings(&settings) else {
|
|
return Ok(serde_json::json!({
|
|
"provider": "off", "reachable": false, "isLocal": true, "models": Vec::<String>::new(),
|
|
}));
|
|
};
|
|
let status = provider.status().await;
|
|
Ok(serde_json::json!({
|
|
"provider": status.provider,
|
|
"reachable": status.reachable,
|
|
"isLocal": status.is_local,
|
|
"models": status.models,
|
|
}))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SetLlmProviderArgs {
|
|
pub provider: String, // "ollama" | "custom" | "off" (Phase 10a adds "anthropic" | "openai")
|
|
pub endpoint: Option<String>,
|
|
pub model: Option<String>,
|
|
}
|
|
|
|
/// Select/configure the LLM provider (T5.2, FR-LLM-1). Hosted providers
|
|
/// (`apiKey`, stored only in the OS credential store — never settings/DB)
|
|
/// land in Phase 10a.
|
|
#[tauri::command]
|
|
pub async fn set_llm_provider(args: SetLlmProviderArgs) -> WaResult<()> {
|
|
if !matches!(args.provider.as_str(), "ollama" | "custom" | "off") {
|
|
return Err(WaError::new(
|
|
"llm",
|
|
format!(
|
|
"provider '{}' is not available yet — hosted providers land in Phase 10a",
|
|
args.provider
|
|
),
|
|
));
|
|
}
|
|
let mut settings = load_settings();
|
|
settings.llm_provider = args.provider;
|
|
if let Some(endpoint) = args.endpoint {
|
|
settings.llm_endpoint = endpoint;
|
|
}
|
|
if let Some(model) = args.model {
|
|
settings.llm_model = model;
|
|
}
|
|
save_settings(&settings)
|
|
}
|
|
|
|
/// Detect a missing/unreachable provider and offer a hardware-aware model
|
|
/// suggestion (T5.7, FR-LLM-5). Read-only and on-demand — no background
|
|
/// polling (NFR-RES-1); the frontend calls this when Settings' LLM section
|
|
/// is open or `llm_status` comes back unreachable.
|
|
#[tauri::command]
|
|
pub async fn llm_setup_suggestions() -> WaResult<serde_json::Value> {
|
|
let best_backend = WinHardwareDetector.best(None);
|
|
let suggestion = crate::llm::suggest_ollama_model(best_backend.vram_mb);
|
|
Ok(serde_json::json!({
|
|
"ollamaInstalled": crate::llm::ollama_installed(),
|
|
"installUrl": "https://ollama.com/download/windows",
|
|
"suggestedModel": {
|
|
"id": suggestion.id,
|
|
"label": suggestion.label,
|
|
"approxSizeGb": suggestion.approx_size_gb,
|
|
},
|
|
}))
|
|
}
|
|
|
|
/// Guided model download via Ollama's `/api/pull` (T5.7, ADR-0007); streams
|
|
/// progress as `model://progress`, the same shape the whisper/diarization
|
|
/// catalogs use.
|
|
#[tauri::command]
|
|
pub async fn pull_ollama_model(app: AppHandle, model: String) -> WaResult<()> {
|
|
let endpoint = load_settings().llm_endpoint;
|
|
let model_for_progress = model.clone();
|
|
crate::llm::pull_model(&endpoint, &model, move |received, total| {
|
|
let _ = app.emit(
|
|
"model://progress",
|
|
serde_json::json!({ "id": model_for_progress, "receivedBytes": received, "totalBytes": total }),
|
|
);
|
|
})
|
|
.await
|
|
.map_err(|e| WaError::new("llm", e.to_string()))
|
|
}
|
|
|
|
/// Built-in LLM-prompt biases for common meeting shapes (T5.4) — distinct
|
|
/// from `notes::NoteTemplate` (Phase 8, T8.1/FR-NOTE-5), which structures
|
|
/// notes.md itself. Both use the same meeting-type identifiers
|
|
/// ("standup"/"retro"/"one-on-one") since they describe the same kinds of
|
|
/// meetings, but are two independent lookups. An unrecognized id is ignored
|
|
/// rather than erroring, so a stale/removed template id never blocks a summary.
|
|
fn summary_prompt_bias(id: &str) -> Option<&'static str> {
|
|
match id {
|
|
"standup" => Some(
|
|
"This is a daily standup. Focus the summary on what each person did, what's \
|
|
blocking them, and what's planned next.",
|
|
),
|
|
"retro" => Some(
|
|
"This is a retrospective. Focus the summary on what went well, what didn't, and \
|
|
concrete process changes to try.",
|
|
),
|
|
"one-on-one" => Some(
|
|
"This is a 1:1. Focus the summary on the individual's updates, concerns, and \
|
|
agreed next steps.",
|
|
),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Assembles the transcript + metadata (+ optional template) prompt (T5.4,
|
|
/// FR-LLM-2). Reuses `notes_markdown` (already speaker-tagged, kept in sync
|
|
/// by every rename/merge) as the transcript rather than re-rendering it.
|
|
fn build_prompt(meeting: &Meeting, template_id: Option<&str>) -> crate::llm::Prompt {
|
|
let participants: Vec<String> = meeting
|
|
.speakers
|
|
.iter()
|
|
.map(|s| s.display_name.clone().unwrap_or_else(|| s.label.clone()))
|
|
.collect();
|
|
let metadata = format!(
|
|
"Meeting: {}\nDuration: {} min\nParticipants: {}",
|
|
meeting.title,
|
|
meeting.duration_secs.unwrap_or(0) / 60,
|
|
if participants.is_empty() {
|
|
"unknown".to_string()
|
|
} else {
|
|
participants.join(", ")
|
|
},
|
|
);
|
|
crate::llm::Prompt {
|
|
transcript: meeting.notes_markdown.clone(),
|
|
metadata,
|
|
template: template_id
|
|
.and_then(summary_prompt_bias)
|
|
.map(str::to_string),
|
|
}
|
|
}
|
|
|
|
/// Generate a summary/decisions/action items for a finished meeting
|
|
/// (T5.4-5.6, FR-LLM-2/3/4). Streams tokens live via `llm://token`, persists
|
|
/// `summary.json`, and emits `llm://done` with the full result. Drafted
|
|
/// action items are NOT written to the `action_items` table here — the user
|
|
/// reviews/edits them first; `confirm_action_items` is what actually creates
|
|
/// rows (FR-LLM-3).
|
|
#[tauri::command]
|
|
pub async fn generate_summary(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
template_id: Option<String>,
|
|
) -> WaResult<()> {
|
|
let guard = state.session.lock().await;
|
|
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
|
|
return Err(WaError::new(
|
|
"llm",
|
|
"cannot generate a summary while this meeting is still recording — wait until it's stopped",
|
|
));
|
|
}
|
|
drop(guard);
|
|
|
|
let settings = load_settings();
|
|
let provider = llm_provider_from_settings(&settings).ok_or_else(|| {
|
|
WaError::new(
|
|
"llm",
|
|
"no LLM provider is configured — enable one in Settings first",
|
|
)
|
|
})?;
|
|
|
|
let meeting = state
|
|
.store
|
|
.get_meeting(&meeting_id)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
let prompt = build_prompt(&meeting, template_id.as_deref());
|
|
|
|
// `LlmProvider::summarize` streams tokens over a std (blocking) mpsc
|
|
// channel; drain it on its own thread so tokens reach the frontend as
|
|
// they arrive rather than batched after the whole reply completes.
|
|
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
|
let app_for_tokens = app.clone();
|
|
let meeting_id_for_tokens = meeting_id.clone();
|
|
let forward = std::thread::spawn(move || {
|
|
while let Ok(text) = rx.recv() {
|
|
let _ = app_for_tokens.emit(
|
|
"llm://token",
|
|
serde_json::json!({ "meetingId": meeting_id_for_tokens, "text": text }),
|
|
);
|
|
}
|
|
});
|
|
let result = provider.summarize(prompt, tx).await;
|
|
let _ = forward.join(); // rx errs (and the thread exits) once summarize() drops its Sender
|
|
|
|
let summary = result.map_err(|e| WaError::new("llm", e.to_string()))?;
|
|
let summary_file = SummaryFile {
|
|
schema: 1,
|
|
generated_at: now_unix(),
|
|
provider: settings.llm_provider,
|
|
model: settings.llm_model,
|
|
summary_md: summary.summary_md,
|
|
decisions: summary.decisions,
|
|
action_items: summary.action_items,
|
|
};
|
|
let json = serde_json::to_string_pretty(&summary_file)
|
|
.map_err(|e| WaError::new("llm", e.to_string()))?;
|
|
let _ = std::fs::write(meeting_dir(&meeting_id).join("summary.json"), json);
|
|
|
|
let _ = app.emit(
|
|
"llm://done",
|
|
serde_json::json!({ "meetingId": meeting_id, "summary": summary_file }),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Persist reviewed/edited action items as confirmed tasks (T5.6, FR-LLM-3),
|
|
/// then schedule or cancel each one's local reminder to match (Phase 8,
|
|
/// T8.6, FR-CAL-5).
|
|
#[tauri::command]
|
|
pub async fn confirm_action_items(
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
items: Vec<ActionItem>,
|
|
) -> WaResult<()> {
|
|
let saved = state
|
|
.store
|
|
.save_action_items(&meeting_id, &items)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
for item in &saved {
|
|
let Some(id) = &item.id else { continue };
|
|
match (item.reminder_set, item.due_at) {
|
|
(true, Some(due_at)) => {
|
|
crate::reminders::schedule(id, &item.text, item.owner.as_deref(), due_at)
|
|
}
|
|
_ => crate::reminders::cancel(id),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---- Calendar / .pst (Phase 6) ----
|
|
|
|
/// Import events + attendees from a `.pst` (T6.1/T6.2, FR-CAL-1). The
|
|
/// `readpst` subprocess + file parsing is CPU/IO-bound and blocking, so it
|
|
/// runs off the async runtime thread. `readpst` doesn't expose granular
|
|
/// progress, so `pst://progress` is a start/done signal rather than
|
|
/// per-item — real per-item progress would mean reimplementing readpst's
|
|
/// internals, not worth it for a one-shot import.
|
|
#[tauri::command]
|
|
pub async fn import_pst(
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
path: String,
|
|
password: Option<String>,
|
|
) -> WaResult<u32> {
|
|
let events = tauri::async_runtime::spawn_blocking(move || {
|
|
PstSource.import(CalImport { path, password })
|
|
})
|
|
.await
|
|
.map_err(|e| WaError::new("calendar", e.to_string()))?
|
|
.map_err(|e| WaError::new("calendar", e.to_string()))?;
|
|
|
|
let total = events.len() as u32;
|
|
let _ = app.emit(
|
|
"pst://progress",
|
|
serde_json::json!({ "processed": 0, "total": total }),
|
|
);
|
|
|
|
let imported = state
|
|
.store
|
|
.import_calendar_events(events)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
|
|
|
let _ = app.emit(
|
|
"pst://progress",
|
|
serde_json::json!({ "processed": imported, "total": total }),
|
|
);
|
|
Ok(imported)
|
|
}
|
|
|
|
/// Browse imported calendar events (T6.3, FR-CAL-2).
|
|
#[tauri::command]
|
|
pub async fn list_calendar_events(
|
|
state: State<'_, AppState>,
|
|
from: Option<i64>,
|
|
to: Option<i64>,
|
|
) -> WaResult<Vec<CalendarEvent>> {
|
|
state
|
|
.store
|
|
.list_calendar_events(from, to)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
|
}
|
|
|
|
/// A single event with its attendees (T6.3/T6.4, FR-CAL-1/3) — backs the
|
|
/// pre-meeting context panel and the speaker-naming attendee dropdown (T6.5).
|
|
#[tauri::command]
|
|
pub async fn get_calendar_event(
|
|
state: State<'_, AppState>,
|
|
event_id: String,
|
|
) -> WaResult<crate::storage::CalendarEventDetail> {
|
|
state
|
|
.store
|
|
.get_calendar_event(&event_id)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
|
}
|
|
|
|
/// Link a meeting (current or historical) to a calendar event (T6.3/T6.6,
|
|
/// FR-CAL-2/4).
|
|
#[tauri::command]
|
|
pub async fn attach_meeting_to_event(
|
|
state: State<'_, AppState>,
|
|
meeting_id: MeetingId,
|
|
event_id: String,
|
|
) -> WaResult<()> {
|
|
state
|
|
.store
|
|
.attach_meeting_to_event(&meeting_id, &event_id)
|
|
.await
|
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
|
}
|
|
|
|
// ---- Sync / upload (Phase 9, ADR-0010) ----
|
|
|
|
#[tauri::command]
|
|
pub async fn list_sync_targets() -> WaResult<Vec<SyncTargetInfo>> {
|
|
// Never returns secrets (FR-SYNC-6).
|
|
Err(not_implemented("list_sync_targets"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn add_sync_target(_config: serde_json::Value) -> WaResult<SyncTargetInfo> {
|
|
// `config` includes a `secret` stored to the OS credential store, not the DB.
|
|
Err(not_implemented("add_sync_target"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn update_sync_target(_config: serde_json::Value) -> WaResult<SyncTargetInfo> {
|
|
// `config` carries an `id` and optional fields; an optional `secret` updates the credential store.
|
|
Err(not_implemented("update_sync_target"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn remove_sync_target(_id: String) -> WaResult<()> {
|
|
Err(not_implemented("remove_sync_target"))
|
|
}
|
|
|
|
/// Begin OAuth 2.0 PKCE linking for a secondary target (loopback redirect). FR-SYNC-9.
|
|
#[tauri::command]
|
|
pub async fn begin_oauth_link(_kind: String) -> WaResult<serde_json::Value> {
|
|
Err(not_implemented("begin_oauth_link"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn retry_sync_job(_job_id: String) -> WaResult<()> {
|
|
Err(not_implemented("retry_sync_job"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn test_sync_target(_config_or_id: serde_json::Value) -> WaResult<serde_json::Value> {
|
|
Err(not_implemented("test_sync_target"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn set_sync_enabled(_enabled: bool) -> WaResult<()> {
|
|
Err(not_implemented("set_sync_enabled"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn sync_meeting(_meeting_id: MeetingId, _target_id: Option<String>) -> WaResult<()> {
|
|
// Manual "Upload now"; streams progress via "sync://job" events.
|
|
Err(not_implemented("sync_meeting"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn sync_status(_meeting_id: Option<MeetingId>) -> WaResult<Vec<SyncJobInfo>> {
|
|
Err(not_implemented("sync_status"))
|
|
}
|
|
|
|
// ---- Feature briefs + MCP server (Phase 10b, ADR-0011) ----
|
|
|
|
#[tauri::command]
|
|
pub async fn create_feature_brief(
|
|
_meeting_id: MeetingId,
|
|
_target_repo: Option<String>,
|
|
) -> WaResult<FeatureBrief> {
|
|
// T10.6: distill transcript → structured brief via the configured LlmProvider.
|
|
Err(not_implemented("create_feature_brief"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_feature_briefs(
|
|
_meeting_id: Option<MeetingId>,
|
|
) -> WaResult<Vec<FeatureBriefInfo>> {
|
|
Err(not_implemented("list_feature_briefs"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_feature_brief(_id: String) -> WaResult<FeatureBrief> {
|
|
Err(not_implemented("get_feature_brief"))
|
|
}
|
|
|
|
/// Scope control: include/exclude a brief from the MCP server (FR-MCP-3).
|
|
#[tauri::command]
|
|
pub async fn set_brief_exposed(_id: String, _exposed: bool) -> WaResult<()> {
|
|
Err(not_implemented("set_brief_exposed"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn mcp_status() -> WaResult<serde_json::Value> {
|
|
Err(not_implemented("mcp_status"))
|
|
}
|
|
|
|
/// Enable/disable the loopback MCP server; returns endpoint + token on enable (FR-MCP-1/6).
|
|
#[tauri::command]
|
|
pub async fn set_mcp_enabled(
|
|
_enabled: bool,
|
|
_transport: Option<String>,
|
|
_port: Option<u16>,
|
|
) -> WaResult<serde_json::Value> {
|
|
Err(not_implemented("set_mcp_enabled"))
|
|
}
|
|
|
|
/// Set exposure scope (none|selected|all) and whether recordings may be served (FR-MCP-3).
|
|
#[tauri::command]
|
|
pub async fn set_mcp_scope(_expose: String, _expose_recordings: Option<bool>) -> WaResult<()> {
|
|
Err(not_implemented("set_mcp_scope"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn mcp_access_log(_limit: Option<u32>) -> WaResult<Vec<McpAccessEntry>> {
|
|
Err(not_implemented("mcp_access_log"))
|
|
}
|
|
|
|
// ---- Agent push / task-tracker handoff (Phase 10c, later) ----
|
|
|
|
#[tauri::command]
|
|
pub async fn run_agent(
|
|
_brief_id: String,
|
|
_tool: String,
|
|
_repo_path: String,
|
|
) -> WaResult<serde_json::Value> {
|
|
Err(not_implemented("run_agent"))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn create_issue_from_brief(
|
|
_brief_id: String,
|
|
_tracker: String,
|
|
_assign_copilot: Option<bool>,
|
|
) -> WaResult<serde_json::Value> {
|
|
Err(not_implemented("create_issue_from_brief"))
|
|
}
|
|
|
|
// ---- Settings + privacy (Phase 2/7) ----
|
|
|
|
#[tauri::command]
|
|
pub async fn get_settings() -> WaResult<Settings> {
|
|
Ok(load_settings())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn update_settings(patch: serde_json::Value) -> WaResult<Settings> {
|
|
let mut current = serde_json::to_value(load_settings())
|
|
.map_err(|e| WaError::new("settings", e.to_string()))?;
|
|
if let (Some(base), Some(patch)) = (current.as_object_mut(), patch.as_object()) {
|
|
for (key, value) in patch {
|
|
base.insert(key.clone(), value.clone());
|
|
}
|
|
}
|
|
let merged: Settings =
|
|
serde_json::from_value(current).map_err(|e| WaError::new("settings", e.to_string()))?;
|
|
save_settings(&merged)?;
|
|
Ok(merged)
|
|
}
|
|
|
|
/// Pure assembly of the `privacy_self_check` response (shape fixed by
|
|
/// docs/04-api-contracts.md) from already-loaded settings + sync targets, so
|
|
/// it's unit-testable without touching the settings file or a DB.
|
|
fn privacy_self_check_json(
|
|
settings: &Settings,
|
|
sync_targets: Vec<SyncTargetInfo>,
|
|
) -> serde_json::Value {
|
|
let provider = llm_provider_from_settings(settings);
|
|
let (llm_endpoint, llm_is_local) = match &provider {
|
|
Some(p) => (settings.llm_endpoint.clone(), p.is_local()),
|
|
None => (String::new(), true), // off: no endpoint, trivially no egress
|
|
};
|
|
|
|
let mut allowlisted_hosts: Vec<String> = Vec::new();
|
|
if !llm_is_local {
|
|
if let Some(host) = reqwest::Url::parse(&llm_endpoint)
|
|
.ok()
|
|
.and_then(|u| u.host_str().map(str::to_string))
|
|
{
|
|
allowlisted_hosts.push(host);
|
|
}
|
|
}
|
|
for target in sync_targets.iter().filter(|t| t.enabled) {
|
|
if let Some(host) = &target.host {
|
|
allowlisted_hosts.push(host.clone());
|
|
}
|
|
}
|
|
allowlisted_hosts.sort();
|
|
allowlisted_hosts.dedup();
|
|
|
|
let sync_targets_json: Vec<serde_json::Value> = sync_targets
|
|
.iter()
|
|
.map(|t| {
|
|
serde_json::json!({
|
|
"name": t.name,
|
|
"host": t.host.clone().unwrap_or_default(),
|
|
"thirdParty": t.third_party,
|
|
"tls": t.base_url.as_deref().is_some_and(|u| u.starts_with("https://")),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
serde_json::json!({
|
|
"llmEndpoint": llm_endpoint,
|
|
"llmIsLocal": llm_is_local,
|
|
"syncEnabled": settings.sync_enabled,
|
|
"syncTargets": sync_targets_json,
|
|
"allowlistedHosts": allowlisted_hosts,
|
|
})
|
|
}
|
|
|
|
/// Reports current egress + LLM endpoint so the UI can prove local-only handling (FR-SEC-2).
|
|
/// Sync targets/hosts are empty until Phase 9 lands `SyncManager`; `list_sync_targets` is still
|
|
/// `not_implemented` so its error is swallowed here rather than failing the whole self-check.
|
|
#[tauri::command]
|
|
pub async fn privacy_self_check() -> WaResult<serde_json::Value> {
|
|
let settings = load_settings();
|
|
let sync_targets: Vec<SyncTargetInfo> = if settings.sync_enabled {
|
|
list_sync_targets().await.unwrap_or_default()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
Ok(privacy_self_check_json(&settings, sync_targets))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn segment(speaker: &str) -> TranscriptSegment {
|
|
TranscriptSegment {
|
|
id: 0,
|
|
start_ms: 0,
|
|
end_ms: 1000,
|
|
speaker: speaker.to_string(),
|
|
text: String::new(),
|
|
confidence: None,
|
|
interim: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn speaker_infos_lists_distinct_speakers_in_first_appearance_order() {
|
|
let segments = vec![segment("S2"), segment("S1"), segment("S2")];
|
|
let infos = speaker_infos_from_segments(&segments, &HashMap::new());
|
|
let labels: Vec<&str> = infos.iter().map(|s| s.label.as_str()).collect();
|
|
assert_eq!(labels, vec!["S2", "S1"]);
|
|
}
|
|
|
|
#[test]
|
|
fn speaker_infos_applies_display_names_by_label() {
|
|
let segments = vec![segment("S1"), segment("S2")];
|
|
let mut names = HashMap::new();
|
|
names.insert("S1".to_string(), "Alice".to_string());
|
|
let infos = speaker_infos_from_segments(&segments, &names);
|
|
assert_eq!(infos[0].display_name.as_deref(), Some("Alice"));
|
|
assert_eq!(infos[1].display_name, None); // S2 was never named
|
|
}
|
|
|
|
#[test]
|
|
fn speaker_infos_falls_back_to_s1_placeholder_when_no_segments_yet() {
|
|
let infos = speaker_infos_from_segments(&[], &HashMap::new());
|
|
assert_eq!(infos.len(), 1);
|
|
assert_eq!(infos[0].label, "S1");
|
|
assert!(infos[0].display_name.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn diarizer_is_none_when_models_are_not_installed() {
|
|
// This test environment never has the fixed-path diarization models
|
|
// installed (T4.7 will add real download/selection) — confirms the
|
|
// graceful-degradation path a recording never blocks on (T4.3).
|
|
assert!(diarizer_from_installed_models().is_none());
|
|
}
|
|
|
|
fn meeting_fixture() -> Meeting {
|
|
Meeting {
|
|
id: "m1".to_string(),
|
|
title: "Sprint planning".to_string(),
|
|
started_at: 0,
|
|
ended_at: None,
|
|
duration_secs: Some(1830), // 30.5 min
|
|
status: MeetingStatus::Ready,
|
|
recorded: false,
|
|
language: None,
|
|
backend_used: None,
|
|
model_used: None,
|
|
segments: Vec::new(),
|
|
speakers: vec![
|
|
SpeakerInfo {
|
|
label: "S1".to_string(),
|
|
display_name: Some("Alice".to_string()),
|
|
participant_id: None,
|
|
},
|
|
SpeakerInfo {
|
|
label: "S2".to_string(),
|
|
display_name: None,
|
|
participant_id: None,
|
|
},
|
|
],
|
|
notes_markdown: "**Alice:** Let's plan the sprint.".to_string(),
|
|
summary: None,
|
|
calendar_event_id: None,
|
|
tags: Vec::new(),
|
|
template_id: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_prompt_includes_title_duration_and_named_or_labeled_speakers() {
|
|
let prompt = build_prompt(&meeting_fixture(), None);
|
|
assert!(prompt.metadata.contains("Sprint planning"));
|
|
assert!(prompt.metadata.contains("30 min"));
|
|
assert!(prompt.metadata.contains("Alice"));
|
|
assert!(prompt.metadata.contains("S2")); // unnamed speaker falls back to its label
|
|
assert_eq!(prompt.transcript, "**Alice:** Let's plan the sprint.");
|
|
assert!(prompt.template.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn build_prompt_resolves_a_known_template_id_and_ignores_an_unknown_one() {
|
|
let known = build_prompt(&meeting_fixture(), Some("standup"));
|
|
assert!(known.template.unwrap().to_lowercase().contains("standup"));
|
|
|
|
let unknown = build_prompt(&meeting_fixture(), Some("no-such-template"));
|
|
assert!(unknown.template.is_none());
|
|
}
|
|
|
|
fn sync_target_fixture(
|
|
name: &str,
|
|
enabled: bool,
|
|
host: &str,
|
|
third_party: bool,
|
|
tls: bool,
|
|
) -> SyncTargetInfo {
|
|
SyncTargetInfo {
|
|
id: name.to_string(),
|
|
name: name.to_string(),
|
|
kind: SyncKind::WebDav,
|
|
provider_hint: Some("nextcloud".to_string()),
|
|
base_url: Some(format!("{}://{}", if tls { "https" } else { "http" }, host)),
|
|
remote_base_path: "/WhispAssist".to_string(),
|
|
username: Some("alice".to_string()),
|
|
enabled,
|
|
third_party,
|
|
host: Some(host.to_string()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn privacy_self_check_default_settings_is_fully_local_with_empty_allowlist() {
|
|
let result = privacy_self_check_json(&default_settings(), Vec::new());
|
|
assert_eq!(result["llmIsLocal"], true);
|
|
assert_eq!(result["llmEndpoint"], "http://localhost:11434");
|
|
assert_eq!(result["syncEnabled"], false);
|
|
assert_eq!(result["syncTargets"], serde_json::json!([]));
|
|
assert_eq!(result["allowlistedHosts"], serde_json::json!([]));
|
|
}
|
|
|
|
#[test]
|
|
fn privacy_self_check_remote_llm_endpoint_is_not_local_and_joins_the_allowlist() {
|
|
let mut settings = default_settings();
|
|
settings.llm_provider = "custom".to_string();
|
|
settings.llm_endpoint = "https://api.example.com".to_string();
|
|
|
|
let result = privacy_self_check_json(&settings, Vec::new());
|
|
assert_eq!(result["llmIsLocal"], false);
|
|
assert_eq!(
|
|
result["allowlistedHosts"],
|
|
serde_json::json!(["api.example.com"])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn privacy_self_check_only_enabled_sync_targets_join_the_allowlist() {
|
|
let mut settings = default_settings();
|
|
settings.sync_enabled = true;
|
|
let targets = vec![
|
|
sync_target_fixture("Nextcloud", true, "cloud.example.org", false, true),
|
|
sync_target_fixture(
|
|
"Old Disabled Target",
|
|
false,
|
|
"stale.example.org",
|
|
false,
|
|
true,
|
|
),
|
|
];
|
|
|
|
let result = privacy_self_check_json(&settings, targets);
|
|
assert_eq!(
|
|
result["allowlistedHosts"],
|
|
serde_json::json!(["cloud.example.org"])
|
|
);
|
|
assert_eq!(result["syncTargets"].as_array().unwrap().len(), 2);
|
|
assert_eq!(result["syncTargets"][0]["thirdParty"], false);
|
|
assert_eq!(result["syncTargets"][0]["tls"], true);
|
|
}
|
|
|
|
fn list_item(id: &str, title: &str) -> MeetingListItem {
|
|
MeetingListItem {
|
|
id: id.to_string(),
|
|
title: title.to_string(),
|
|
started_at: 0,
|
|
duration_secs: None,
|
|
status: MeetingStatus::Ready,
|
|
tags: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bulk_export_stem_sanitizes_unsafe_filename_characters() {
|
|
let item = list_item("abcdef12-0000-0000-0000-000000000000", "Q3: Sales / Ops?");
|
|
// ':',' ' -> "__"; ' ','/',' ' -> "___"; trailing '?' trimmed with the
|
|
// underscore it became, since trim_matches only strips leading/trailing.
|
|
assert_eq!(bulk_export_stem(&item), "Q3__Sales___Ops_abcdef12");
|
|
}
|
|
|
|
#[test]
|
|
fn bulk_export_stem_disambiguates_duplicate_titles_by_id() {
|
|
// Meeting titles are very often duplicates ("Untitled meeting") — the
|
|
// id prefix is what keeps a bulk export from overwriting files.
|
|
let a = list_item("11111111-aaaa", "Untitled meeting");
|
|
let b = list_item("22222222-bbbb", "Untitled meeting");
|
|
assert_ne!(bulk_export_stem(&a), bulk_export_stem(&b));
|
|
}
|
|
}
|