Files
WhispAssist/src/lib/api.ts
T
iamdoubz 11b175dafa feat(llm): add hosted_ai_acknowledged to AppSettings (T10.3/M3.3)
Frontend counterpart of the new Settings field: the one-time "data
leaves your device" acknowledgment for hosted (non-local) AI providers.
2026-07-07 07:52:11 -05:00

555 lines
20 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;
}
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;
}
export type MeetingStatus = "recording" | "transcribing" | "ready" | "recovering" | "error";
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;
language: string | null;
backend_used: string | null;
model_used: string | null;
segments: TranscriptSegment[];
speakers: SpeakerInfo[];
notes_markdown: string;
summary: SummaryFile | null;
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 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;
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;
retention_max_age_days: number | null;
retention_max_size_gb: number | null;
pst_last_path: string | null;
pst_auto_sync: boolean;
audio_output_device: string | null;
microphone_enabled: boolean;
audio_input_device: string | null;
}
// 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;
}
// ---- Commands ----
export const api = {
// `record` controls audio RETENTION (default false / off — ADR-0009).
startRecording: (
meetingTitle?: string,
calendarEventId?: string,
record = false,
templateId?: string,
) =>
invoke<MeetingId>("start_recording", {
args: { meetingTitle, calendarEventId, record, templateId },
}),
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 }),
setRecordingRetention: (meetingId: MeetingId, record: boolean) =>
invoke<void>("set_recording_retention", { meetingId, record }),
acknowledgeRecordingConsent: () => invoke<void>("acknowledge_recording_consent"),
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 } }),
downloadNpuPackage: () => invoke<void>("download_npu_package"),
downloadDirectmlPackage: () => invoke<void>("download_directml_package"),
listModels: () => invoke<ModelInfo[]>("list_models"),
downloadModel: (id: string, kind: "whisper" = "whisper") =>
invoke<void>("download_model", { args: { kind, id } }),
removeModel: (id: string) => invoke<void>("remove_model", { id }),
reprocessTranscript: (meetingId: MeetingId, model: string) =>
invoke<void>("reprocess_transcript", { meetingId, 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 }),
// dest is a file path for md/pdf/docx, a folder for bundle.
exportMeeting: (meetingId: MeetingId, dest: string, format: "md" | "pdf" | "docx" | "bundle") =>
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,
}),
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 }),
importPst: (path: string, password?: string) => invoke<number>("import_pst", { path, password }),
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("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 }) => 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)),
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)),
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)),
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)),
onMcpAccess: (cb: (p: McpAccessEntry & { 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)),
};