Files
WhispAssist/src/lib/api.ts
T

714 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Typed Tauri client — the frontend's ONLY way to reach the core.
// Mirrors docs/04-api-contracts.md. Keep these signatures in sync with
// src-tauri/src/commands.rs (a contract test enforces this — see docs/06).
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export type MeetingId = string;
export type BackendId = "npu" | "nvidia" | "amd" | "intel" | "cpu";
// Every backend command rejects an `Err` as this shape, not a native Error
// (see src-tauri/src/error.rs) — `e instanceof Error` is always false for it.
export interface WaError {
kind: string;
message: string;
}
export function errorMessage(e: unknown): string {
if (typeof e === "string") return e;
if (e instanceof Error) return e.message;
if (e && typeof e === "object" && typeof (e as WaError).message === "string") {
return (e as WaError).message;
}
return String(e);
}
export interface BackendInfo {
id: BackendId;
name: string;
available: boolean;
rank: number;
vram_mb: number | null;
}
export interface AppInfo {
version: string;
commit: string;
}
export interface HardwareStatus {
backends: BackendInfo[];
active: BackendId;
modelSize: string;
estRtf: number;
/** NPU package state (T3.4): chip detected, runtime staged, model fetched. */
npu?: { present: boolean; runtimeReady: boolean; modelInstalled: boolean };
/** DirectML GPU package (P2): applicable = staging would help this build. */
directml?: { applicable: boolean; runtimeReady: boolean; modelInstalled: boolean };
}
// One enumerated audio device — a render (playback) device for the loopback
// picker (FR-CAP-1) or a capture (microphone) device for the mic picker
// (FR-CAP-7). `id` is the persisted `Device::get_id()`; `name` is display-only.
export interface AudioDeviceInfo {
id: string;
name: string;
}
// Quick hardware stress test (Settings ▸ Hardware): per-(backend, model)
// real-time factor, plus the recommended real-time-capable pairing.
export interface StressResult {
backend: string;
model: string;
rtf: number;
realtime: boolean;
}
export interface StressTestResult {
results: StressResult[];
recommended: { backend: string; model: string } | null;
}
export interface LlmStatus {
provider: string; // ollama|custom|anthropic|off (ADR-0011; "openai" not yet wired)
reachable: boolean;
isLocal: boolean;
models: string[];
}
export interface ModelInfo {
id: string;
label: string;
size_mb: number;
installed: boolean;
active: boolean;
/** `false` for `.en` (English-only) ggml variants; `true` for multilingual
* ones — gates the Settings language picker (T8.7, FR-TRX-4, M4.2). */
multilingual: boolean;
}
// One selectable transcription language (T8.7, FR-TRX-4) — ISO-639-1 code
// as accepted by whisper.cpp, plus a display label.
export interface LanguageOption {
code: string;
label: string;
}
export type MeetingStatus = "recording" | "transcribing" | "ready" | "recovering" | "error";
// The four ordered phases of a background media import (import://progress).
export type ImportPhase = "prepare" | "transcribe" | "diarize" | "finalize";
// One `import://progress` tick. `state` is active (running), done (finished,
// `elapsedMs` set) or error (`error` message set) for the given `phase`.
export interface ImportProgress {
meetingId: MeetingId;
phase: ImportPhase;
state: "active" | "done" | "error";
elapsedMs: number | null;
error: string | null;
}
export interface MeetingListItem {
id: MeetingId;
title: string;
started_at: number;
duration_secs: number | null;
status: MeetingStatus;
tags: string[];
}
// Full-text search hit (Phase 8, FR-SEARCH-1) — same shape as MeetingListItem
// plus a highlighted excerpt of what matched.
export interface SearchHit {
id: MeetingId;
title: string;
started_at: number;
duration_secs: number | null;
status: MeetingStatus;
tags: string[];
snippet: string;
}
// list_meetings filters (Phase 8, FR-SEARCH-2). All fields optional/ANDed.
export interface MeetingFilter {
query?: string;
tag?: string;
participantId?: string;
from?: number;
to?: number;
}
export interface TranscriptSegment {
id: number;
start_ms: number;
end_ms: number;
speaker: string;
text: string;
confidence: number | null;
interim: boolean;
}
export interface SpeakerInfo {
label: string;
display_name: string | null;
participant_id: string | null;
}
// Drafted action item from a summary (Phase 5, FR-LLM-3). `id` is only set
// once `confirmActionItems` has persisted it as a row.
export interface ActionItem {
id: string | null;
text: string;
owner: string | null;
due_at: number | null;
confirmed: boolean;
// Schedule a local OS reminder for due_at (Phase 8, T8.6, FR-CAL-5).
reminder_set: boolean;
}
// On-disk shape of summary.json (FR-LLM-2/4) — `null` until generate_summary
// has run at least once for a meeting.
export interface SummaryFile {
schema: number;
generated_at: number;
provider: string;
model: string;
summary_md: string;
decisions: string[];
action_items: ActionItem[];
}
// Full meeting detail (get_meeting) — DB row + transcript + speakers + notes.
export interface Meeting {
id: MeetingId;
title: string;
started_at: number;
ended_at: number | null;
duration_secs: number | null;
status: MeetingStatus;
recorded: boolean;
// Transcription language actually used/selected for this meeting (T8.7,
// FR-TRX-4) — an ISO-639-1 code, or null if never resolved (e.g. no audio
// was ever decoded).
language: string | null;
backend_used: string | null;
model_used: string | null;
segments: TranscriptSegment[];
speakers: SpeakerInfo[];
notes_markdown: string;
summary: SummaryFile | null;
// Confirmed/edited action items (table-backed source of truth) — falls back
// to summary.action_items drafts until anything is saved. Manage via
// confirmActionItems (add/edit/delete).
action_items: ActionItem[];
calendar_event_id: string | null;
tags: string[];
template_id: string | null;
}
// Note template (Phase 8, T8.1, FR-NOTE-5) — section headers applied to
// notes.md at recording-start time.
export interface NoteTemplate {
id: string;
name: string;
sections: string[];
}
// Calendar / .pst (Phase 6, FR-CAL-*).
export interface CalendarEvent {
id: string;
source: string; // pst|graph|ics
subject: string | null;
organizer: string | null;
starts_at: number | null;
ends_at: number | null;
description: string | null;
raw_uid: string | null;
}
export interface Participant {
id: string;
name: string;
email: string | null;
role: string | null; // organizer|required|optional
}
export interface CalendarEventDetail {
event: CalendarEvent;
participants: Participant[];
}
export interface CalendarCleanupResult {
deleted: number;
protected: number;
}
export type SyncKind = "webdav" | "onedrive" | "dropbox" | "box";
// What the UI sees about a target — NEVER includes the secret (FR-SYNC-6).
export interface SyncTargetInfo {
id: string;
name: string;
kind: SyncKind;
provider_hint: string | null; // nextcloud|owncloud|cloudreve|seafile|synology|generic
base_url: string | null;
remote_base_path: string;
username: string | null;
enabled: boolean;
third_party: boolean;
host: string | null;
upload_transcript: boolean;
upload_notes: boolean;
upload_summary: boolean;
upload_recording: boolean;
trigger_on_finalize: boolean;
allow_plaintext_lan: boolean;
encrypt_before_upload: boolean;
}
// privacy_self_check() response (FR-SEC-2) — proves local-only handling.
export interface PrivacySyncTarget {
name: string;
host: string;
thirdParty: boolean;
tls: boolean;
}
export interface PrivacySelfCheck {
llmEndpoint: string;
llmIsLocal: boolean;
syncEnabled: boolean;
syncTargets: PrivacySyncTarget[];
allowlistedHosts: string[];
}
// Payload for add/update. `secret` is write-only (stored in the OS credential store).
export interface SyncTargetConfig {
id?: string;
name: string;
kind: SyncKind;
provider_hint?: string;
base_url?: string;
remote_base_path?: string;
username?: string;
secret?: string;
enabled?: boolean;
upload_transcript?: boolean;
upload_notes?: boolean;
upload_summary?: boolean;
upload_recording?: boolean;
trigger_on_finalize?: boolean;
allow_plaintext_lan?: boolean;
encrypt_before_upload?: boolean;
}
// A durable upload job for one artifact × target (sync_status). Snake_case,
// matching the Rust SyncJobInfo; the sync://job *event* is camelCase (onSyncJob).
export interface SyncJobInfo {
id: string;
target_id: string;
meeting_id: MeetingId;
artifact: string;
status: string; // pending|uploading|done|failed|skipped
attempts: number;
bytes_sent: number;
bytes_total: number | null;
last_error: string | null;
}
export interface AppSettings {
theme: string;
storage_root: string;
llm_provider: string;
llm_endpoint: string;
llm_model: string;
/** Advanced Ollama tuning; sparse (only non-default overrides). */
llm_advanced?: {
system?: string;
think?: string;
keep_alive?: string;
options?: Record<string, number | boolean | string[]>;
} | null;
preferred_backend: string;
whisper_model: string;
/** Default transcription language (T8.7, FR-TRX-4): `null` = auto-detect,
* an ISO-639-1 code forces that language. Only takes effect with a
* multilingual model — see `ModelInfo.multilingual`. */
whisper_language: string | null;
low_overhead: boolean;
default_record: boolean;
consent_acknowledged: boolean;
/** One-time "data leaves your device" ack for hosted (non-local) AI
* providers — Anthropic today (ADR-0011, T10.3). Independent of
* consent_acknowledged (that one's about recording law). */
hosted_ai_acknowledged: boolean;
sync_enabled: boolean;
mcp_enabled: boolean;
mcp_transport: string; // http|stdio
mcp_port: number;
mcp_expose: string; // none|selected|all
mcp_expose_recordings: boolean;
retention_max_age_days: number | null;
retention_max_size_gb: number | null;
pst_last_path: string | null;
pst_auto_sync: boolean;
// Days of history to import (both a manual Import click and pst_auto_sync);
// null = full mailbox history.
pst_import_range_days: number | null;
/** Auto-start recording when a calendar event begins while the app is open
* (opt-in, off by default). Armed as a one-shot UI timer — nothing polls. */
auto_record_calendar: boolean;
audio_output_device: string | null;
microphone_enabled: boolean;
audio_input_device: string | null;
/** Launch WhispAssist at login (opt-in, off by default; NFR-RES-4). Toggled
* via setAutoStart, which writes a per-user Run entry (no admin). */
auto_start: boolean;
/** Closing the window hides to the tray (keep running in background) instead
* of quitting; on by default. Tray "Quit" is the real exit. */
close_to_tray: boolean;
}
// Feature brief — agent-ready spec distilled from a meeting (ADR-0011).
export interface FeatureBrief {
id: string;
meeting_id: MeetingId;
title: string;
problem: string;
desired_outcome: string;
acceptance_criteria: string[];
target_repo: string | null;
context_excerpts: { speaker: string; text: string }[];
}
export interface FeatureBriefInfo {
id: string;
meeting_id: MeetingId;
title: string;
target_repo: string | null;
exposed: boolean;
}
export interface McpAccessEntry {
at: number;
tool: string;
meeting_id: MeetingId | null;
client: string | null;
}
// mcp_status() response (FR-MCP-1/6). `endpoint` is empty while disabled.
export interface McpStatus {
enabled: boolean;
transport: "http" | "stdio";
endpoint: string;
tokenSet: boolean;
exposeScope: "none" | "selected" | "all";
}
// ---- Commands ----
export const api = {
// `record` controls audio RETENTION (default false / off — ADR-0009).
// `language` (T8.7, FR-TRX-4): omit/undefined falls back to
// Settings.whisper_language; "auto" or omitted both mean auto-detect.
startRecording: (
meetingTitle?: string,
calendarEventId?: string,
record = false,
templateId?: string,
language?: string,
) =>
invoke<MeetingId>("start_recording", {
args: { meetingTitle, calendarEventId, record, templateId, language },
}),
listNoteTemplates: () => invoke<NoteTemplate[]>("list_note_templates"),
stopRecording: (meetingId: MeetingId) => invoke<void>("stop_recording", { meetingId }),
// Abandon an accidental recording: stop + delete files + drop the DB row.
cancelRecording: (meetingId: MeetingId) => invoke<void>("cancel_recording", { meetingId }),
// Absolute path to a playable audio.wav (decrypted if sealed), for convertFileSrc.
recordingPlaybackPath: (meetingId: MeetingId) =>
invoke<string>("recording_playback_path", { meetingId }),
pauseRecording: (meetingId: MeetingId) => invoke<void>("pause_recording", { meetingId }),
resumeRecording: (meetingId: MeetingId) => invoke<void>("resume_recording", { meetingId }),
// Toggle mic mute for the active recording (FR-CAP-7); returns the new muted
// state. Errors if the meeting was started with the mic off.
toggleMicrophoneMute: (meetingId: MeetingId) =>
invoke<boolean>("toggle_microphone_mute", { meetingId }),
setRecordingRetention: (meetingId: MeetingId, record: boolean) =>
invoke<void>("set_recording_retention", { meetingId, record }),
acknowledgeRecordingConsent: () => invoke<void>("acknowledge_recording_consent"),
// Live notes (Granola-style redesign): both live-session only, err once
// the meeting is finalized — use updateNotes on the merged notes.md instead.
updateLiveNotes: (meetingId: MeetingId, markdown: string) =>
invoke<void>("update_live_notes", { meetingId, markdown }),
// anchorMs: the clicked segment's start_ms (not its id). text: "" clears it.
setSegmentNote: (meetingId: MeetingId, anchorMs: number, text: string) =>
invoke<void>("set_segment_note", { meetingId, anchorMs, text }),
appInfo: () => invoke<AppInfo>("app_info"),
openUrl: (url: string) => invoke<void>("open_url", { url }),
hardwareStatus: () => invoke<HardwareStatus>("hardware_status"),
listAudioDevices: () => invoke<AudioDeviceInfo[]>("list_audio_devices"),
listInputDevices: () => invoke<AudioDeviceInfo[]>("list_input_devices"),
setPreferredBackend: (backend: BackendId | "auto") =>
invoke<void>("set_preferred_backend", { args: { backend } }),
setAutoStart: (enabled: boolean) => invoke<void>("set_auto_start", { enabled }),
// Test a device: stream device://level for a few seconds. Resolves when done.
monitorAudioLevel: (kind: "input" | "loopback", deviceId: string | null, durationMs = 6000) =>
invoke<void>("monitor_audio_level", { kind, deviceId, durationMs }),
stressTestHardware: () => invoke<StressTestResult>("stress_test_hardware"),
downloadNpuPackage: () => invoke<void>("download_npu_package"),
downloadDirectmlPackage: () => invoke<void>("download_directml_package"),
listModels: () => invoke<ModelInfo[]>("list_models"),
// The fixed segmentation+embedding pair that speaker diarization needs
// installed before it can separate speakers (T4.7, FR-MODEL-1). Same
// ModelInfo shape as whisper models; download via `downloadModel` with the
// `diar-seg`/`diar-emb` kind, remove via the shared `removeModel`.
listDiarizationModels: () => invoke<ModelInfo[]>("list_diarization_models"),
// T8.7/FR-TRX-4: static catalog of whisper.cpp-recognized language codes
// for the Settings dropdown; "Auto-detect" is a frontend-only addition.
listWhisperLanguages: () => invoke<LanguageOption[]>("list_whisper_languages"),
downloadModel: (id: string, kind: "whisper" | "diar-seg" | "diar-emb" = "whisper") =>
invoke<void>("download_model", { args: { kind, id } }),
removeModel: (id: string) => invoke<void>("remove_model", { id }),
// `language` (T8.7): omit/undefined reuses whatever language the meeting
// already had rather than resetting it to auto.
reprocessTranscript: (meetingId: MeetingId, model: string, language?: string) =>
invoke<void>("reprocess_transcript", { meetingId, model, language }),
// Manually add a meeting from an existing recording — a local audio/video
// file path or a URL (YouTube/streaming page or direct media URL). Requires
// ffmpeg (and yt-dlp for URLs) on PATH; neither is bundled. `model` overrides
// the Settings whisper model for this one import. Returns the new meeting's id
// *immediately*; transcode/transcribe/diarize run in the background and stream
// `import://progress` ticks, finishing with `transcript://finalized`.
importMedia: (source: string, title?: string, model?: string) =>
invoke<MeetingId>("import_media", { source, title, model }),
resumeTranscription: (meetingId: MeetingId) =>
invoke<void>("resume_transcription", { meetingId }),
listMeetings: (filter?: MeetingFilter) =>
invoke<MeetingListItem[]>("list_meetings", {
query: filter?.query,
tag: filter?.tag,
participantId: filter?.participantId,
from: filter?.from,
to: filter?.to,
}),
// Full-text search across transcripts + notes — distinct from listMeetings'
// `query`, which only substring-matches the title.
search: (query: string) => invoke<SearchHit[]>("search", { query }),
setTags: (meetingId: MeetingId, tags: string[]) => invoke<void>("set_tags", { meetingId, tags }),
listTags: () => invoke<string[]>("list_tags"),
getMeeting: (meetingId: MeetingId) => invoke<Meeting>("get_meeting", { meetingId }),
deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }),
updateNotes: (meetingId: MeetingId, markdown: string) =>
invoke<void>("update_notes", { meetingId, markdown }),
// AI-enhance rough notes into structured Markdown grounded in the transcript
// (Granola-style). Returns the enhanced text; the caller decides to keep it.
enhanceNotes: (meetingId: MeetingId, notes: string) =>
invoke<string>("enhance_notes", { meetingId, notes }),
// dest is a file path for md/pdf/docx/obsidian, a folder for bundle.
// "obsidian" writes one self-contained vault note (no audio) — FR-STORE-4.
exportMeeting: (
meetingId: MeetingId,
dest: string,
format: "md" | "pdf" | "docx" | "bundle" | "obsidian",
) => invoke<string>("export_meeting", { meetingId, dest, format }),
// Every meeting matching tag/date filters, one file (or bundle folder) per
// meeting under destDir. Returns the count actually exported (T8.5, FR-STORE-4).
bulkExportMeetings: (
destDir: string,
format: "md" | "pdf" | "docx" | "bundle",
filter?: Pick<MeetingFilter, "tag" | "from" | "to">,
) =>
invoke<number>("bulk_export_meetings", {
destDir,
format,
tag: filter?.tag,
from: filter?.from,
to: filter?.to,
}),
// Import bundle(s) exported with format "bundle" — dir is a single bundle
// folder or a parent folder of them. Reconstructs each under a fresh id and
// returns the count imported (FR-STORE-4).
importMeetingBundle: (dir: string) => invoke<number>("import_meeting_bundle", { dir }),
llmStatus: () => invoke<LlmStatus>("llm_status"),
// provider ∈ ollama|custom|anthropic|openai|off; apiKey (hosted) → OS credential store (ADR-0011).
setLlmProvider: (config: {
provider: string;
endpoint?: string;
model?: string;
apiKey?: string;
}) => invoke<void>("set_llm_provider", { args: config }),
generateSummary: (meetingId: MeetingId, templateId?: string) =>
invoke<void>("generate_summary", { meetingId, templateId }),
confirmActionItems: (meetingId: MeetingId, items: ActionItem[]) =>
invoke<void>("confirm_action_items", { meetingId, items }),
generateTags: (meetingId: MeetingId) => invoke<string[]>("generate_tags", { meetingId }),
// rangeDays: only import events starting within the last N days; omitted/undefined imports
// the full mailbox history (a long-lived .pst otherwise re-imports years of recurring/holiday
// entries on every launch when pst_auto_sync is on).
importPst: (path: string, password?: string, rangeDays?: number) =>
invoke<number>("import_pst", { path, password, rangeDays }),
// olderThanDays: undefined deletes every unlinked event ("Delete all").
// An event attached to a recorded meeting is always kept either way.
cleanupCalendarEvents: (olderThanDays?: number) =>
invoke<CalendarCleanupResult>("cleanup_calendar_events", { olderThanDays }),
listCalendarEvents: (from?: number, to?: number) =>
invoke<CalendarEvent[]>("list_calendar_events", { from, to }),
getCalendarEvent: (eventId: string) =>
invoke<CalendarEventDetail>("get_calendar_event", { eventId }),
attachMeetingToEvent: (meetingId: MeetingId, eventId: string) =>
invoke<void>("attach_meeting_to_event", { meetingId, eventId }),
renameMeeting: (meetingId: MeetingId, title: string) =>
invoke<void>("rename_meeting", { meetingId, title }),
renameSpeaker: (meetingId: MeetingId, label: string, name: string) =>
invoke<void>("rename_speaker", { meetingId, label, name }),
mapSpeakerToParticipant: (meetingId: MeetingId, label: string, participantId: string) =>
invoke<void>("map_speaker_to_participant", { meetingId, label, participantId }),
// Sync (ADR-0010) — off by default; secrets never returned by listSyncTargets.
listSyncTargets: () => invoke<SyncTargetInfo[]>("list_sync_targets"),
addSyncTarget: (config: SyncTargetConfig) =>
invoke<SyncTargetInfo>("add_sync_target", { config }),
updateSyncTarget: (config: SyncTargetConfig & { id: string }) =>
invoke<SyncTargetInfo>("update_sync_target", { config }),
removeSyncTarget: (id: string) => invoke<void>("remove_sync_target", { id }),
testSyncTarget: (configOrId: SyncTargetConfig | { id: string }) =>
invoke<{ ok: boolean; message: string }>("test_sync_target", { config: configOrId }),
setSyncEnabled: (enabled: boolean) => invoke<void>("set_sync_enabled", { enabled }),
beginOauthLink: (kind: "onedrive" | "dropbox" | "box") =>
invoke<{ authUrl: string }>("begin_oauth_link", { kind }),
syncMeeting: (meetingId: MeetingId, targetId?: string) =>
invoke<void>("sync_meeting", { meetingId, targetId }),
syncStatus: (meetingId?: MeetingId) => invoke<SyncJobInfo[]>("sync_status", { meetingId }),
retrySyncJob: (jobId: string) => invoke<void>("retry_sync_job", { jobId }),
// Feature briefs + MCP server (ADR-0011). MCP server is loopback-only and off by default.
createFeatureBrief: (meetingId: MeetingId, targetRepo?: string) =>
invoke<FeatureBrief>("create_feature_brief", { meetingId, targetRepo }),
listFeatureBriefs: (meetingId?: MeetingId) =>
invoke<FeatureBriefInfo[]>("list_feature_briefs", { meetingId }),
getFeatureBrief: (id: string) => invoke<FeatureBrief>("get_feature_brief", { id }),
setBriefExposed: (id: string, exposed: boolean) =>
invoke<void>("set_brief_exposed", { id, exposed }),
mcpStatus: () => invoke<McpStatus>("mcp_status"),
setMcpEnabled: (enabled: boolean, transport?: "http" | "stdio", port?: number) =>
invoke<{ endpoint: string; token: string }>("set_mcp_enabled", { enabled, transport, port }),
setMcpScope: (expose: "none" | "selected" | "all", exposeRecordings?: boolean) =>
invoke<void>("set_mcp_scope", { expose, exposeRecordings }),
mcpAccessLog: (limit?: number) => invoke<McpAccessEntry[]>("mcp_access_log", { limit }),
// Layer 3 (later) push handoff.
runAgent: (
briefId: string,
tool: "claude" | "codex" | "opencode" | "copilot",
repoPath: string,
) => invoke<{ ok: boolean }>("run_agent", { briefId, tool, repoPath }),
createIssueFromBrief: (briefId: string, tracker: "github", assignCopilot?: boolean) =>
invoke<{ url: string }>("create_issue_from_brief", { briefId, tracker, assignCopilot }),
getSettings: () => invoke<AppSettings>("get_settings"),
updateSettings: (patch: Partial<AppSettings>) =>
invoke<AppSettings>("update_settings", { patch }),
privacySelfCheck: () => invoke<PrivacySelfCheck>("privacy_self_check"),
// At-rest encryption vault (Phase 8, T8.8, FR-SEC-3).
vaultStatus: () => invoke<{ enabled: boolean; unlocked: boolean }>("vault_status"),
enableVault: (password: string) => invoke<void>("enable_vault", { args: { password } }),
unlockVault: (password: string) => invoke<void>("unlock_vault", { args: { password } }),
lockVault: () => invoke<void>("lock_vault"),
changeVaultPassword: (oldPassword: string, newPassword: string) =>
invoke<void>("change_vault_password", { args: { oldPassword, newPassword } }),
};
// ---- Events (Rust → UI) ----
export const events = {
onRecordingState: (cb: (p: unknown) => void): Promise<UnlistenFn> =>
listen("recording://state", (e) => cb(e.payload)),
onRetention: (cb: (p: { meetingId: string; record: boolean }) => void): Promise<UnlistenFn> =>
listen("recording://retention", (e) => cb(e.payload as never)),
onLevel: (
cb: (p: { meetingId: string; rms: number; peak: number; mic: boolean }) => void,
): Promise<UnlistenFn> => listen("recording://level", (e) => cb(e.payload as never)),
onDeviceChanged: (
cb: (p: { meetingId: string; recovered: boolean; message: string }) => void,
): Promise<UnlistenFn> => listen("recording://device", (e) => cb(e.payload as never)),
// Mic mute toggled for the active recording (FR-CAP-7).
onMicMuted: (
cb: (p: { meetingId: string; muted: boolean }) => void,
): Promise<UnlistenFn> => listen("recording://mic", (e) => cb(e.payload as never)),
onSegment: (
cb: (p: { meetingId: string; segment: TranscriptSegment }) => void,
): Promise<UnlistenFn> => listen("transcript://segment", (e) => cb(e.payload as never)),
onFinalized: (
cb: (p: { meetingId: string; segmentCount: number }) => void,
): Promise<UnlistenFn> => listen("transcript://finalized", (e) => cb(e.payload as never)),
// Per-phase progress of a background media import (feeds the import tracker).
onImportProgress: (cb: (p: ImportProgress) => void): Promise<UnlistenFn> =>
listen("import://progress", (e) => cb(e.payload as never)),
// Live diarization refined the speaker list mid-recording (FR-SPK): updated
// labels/display names, including the mic speaker resolved to "You".
onDiarizationUpdated: (
cb: (p: { meetingId: string; speakers: SpeakerInfo[] }) => void,
): Promise<UnlistenFn> => listen("diarization://updated", (e) => cb(e.payload as never)),
onLlmToken: (cb: (p: { meetingId: string; text: string }) => void): Promise<UnlistenFn> =>
listen("llm://token", (e) => cb(e.payload as never)),
onLlmDone: (cb: (p: { meetingId: string; summary: SummaryFile }) => void): Promise<UnlistenFn> =>
listen("llm://done", (e) => cb(e.payload as never)),
onModelProgress: (
cb: (p: { id: string; receivedBytes: number; totalBytes: number | null }) => void,
): Promise<UnlistenFn> => listen("model://progress", (e) => cb(e.payload as never)),
onHardwareChanged: (
cb: (p: { active: BackendId; reason: string }) => void,
): Promise<UnlistenFn> => listen("hardware://changed", (e) => cb(e.payload as never)),
// Live level meter for a device test (Settings ▸ Hardware). `done` marks the
// end of the monitor window.
onDeviceLevel: (
cb: (p: { kind: string; rms?: number; peak?: number; done?: boolean }) => void,
): Promise<UnlistenFn> => listen("device://level", (e) => cb(e.payload as never)),
// Per-(backend, model) progress ticks during the quick stress test.
onStressProgress: (
cb: (p: { backend: string; model: string }) => void,
): Promise<UnlistenFn> => listen("stress://progress", (e) => cb(e.payload as never)),
onNpuDownload: (
cb: (p: {
stage: "model" | "runtime" | "done";
file?: number;
received?: number;
total?: number | null;
copied?: number;
ready?: boolean;
}) => void,
): Promise<UnlistenFn> => listen("npu://download", (e) => cb(e.payload as never)),
onSyncJob: (
cb: (p: {
jobId: string;
meetingId: string;
targetId: string;
artifact: string;
status: string;
bytesSent: number;
bytesTotal: number | null;
attempts?: number;
error?: string | null;
}) => void,
): Promise<UnlistenFn> => listen("sync://job", (e) => cb(e.payload as never)),
onSyncLinked: (
cb: (p: { ok: boolean; kind: string; error?: string }) => void,
): Promise<UnlistenFn> => listen("sync://linked", (e) => cb(e.payload as never)),
// Live tail of the FR-MCP-5 audit log (camelCase on the wire, unlike the
// snake_case McpAccessEntry rows `mcpAccessLog()` returns).
onMcpAccess: (
cb: (p: { at: number; tool: string; meetingId?: MeetingId; client?: string }) => void,
): Promise<UnlistenFn> => listen("mcp://access", (e) => cb(e.payload as never)),
onAgentProgress: (
cb: (p: { briefId: string; tool: string; line: string }) => void,
): Promise<UnlistenFn> => listen("agent://progress", (e) => cb(e.payload as never)),
onPstProgress: (cb: (p: { processed: number; total: number }) => void): Promise<UnlistenFn> =>
listen("pst://progress", (e) => cb(e.payload as never)),
onError: (cb: (p: { kind: string; message: string }) => void): Promise<UnlistenFn> =>
listen("error", (e) => cb(e.payload as never)),
};