Files
WhispAssist/src/lib/stores/settings.svelte.ts
T

508 lines
16 KiB
TypeScript

// Settings + sync-target store (Svelte 5 runes).
// Wraps the typed api; tolerant of the current `todo!()` backend so the UI is
// demonstrable before Phase 9 lands. Once the commands are implemented, the
// optimistic fallbacks below simply stop being exercised.
import {
api,
errorMessage,
events,
type AppSettings,
type AudioDeviceInfo,
type SyncTargetInfo,
type SyncTargetConfig,
type HardwareStatus,
type LlmStatus,
type ModelInfo,
type LanguageOption,
type PrivacySelfCheck,
type McpStatus,
type McpAccessEntry,
} from "../api";
const DEFAULT_SETTINGS: AppSettings = {
theme: "system",
storage_root: "%LOCALAPPDATA%\\WhispAssist",
llm_provider: "ollama",
llm_endpoint: "http://localhost:11434",
llm_model: "llama3",
preferred_backend: "auto",
whisper_model: "base.en-q5_1",
whisper_language: null, // auto-detect by default (T8.7, FR-TRX-4)
low_overhead: false,
default_record: false, // recording OFF by default (ADR-0009)
consent_acknowledged: false,
hosted_ai_acknowledged: false, // hosted-AI "leaves your device" notice (ADR-0011)
sync_enabled: false, // sync OFF by default (ADR-0010)
mcp_enabled: false, // MCP server OFF by default (ADR-0011)
mcp_transport: "http",
mcp_port: 4849,
mcp_expose: "none", // scope OFF by default (FR-MCP-3)
mcp_expose_recordings: false,
retention_max_age_days: null, // no cap by default (FR-STORE-2)
retention_max_size_gb: null,
pst_last_path: null,
pst_auto_sync: false,
pst_import_range_days: null, // full mailbox history by default
auto_record_calendar: false, // don't auto-start on calendar events by default
audio_output_device: null, // system default render device (FR-CAP-1)
microphone_enabled: true, // capture the user's mic into the transcript (FR-CAP-7)
audio_input_device: null, // system default capture device
auto_start: false, // launch at login — opt-in, off by default (NFR-RES-4)
close_to_tray: true, // closing the window hides to tray; on by default
};
class SettingsStore {
settings = $state<AppSettings>({ ...DEFAULT_SETTINGS });
targets = $state<SyncTargetInfo[]>([]);
loaded = $state(false);
/** Set when the backend isn't wired yet, so the UI can show a "stub" hint. */
backendStub = $state(false);
/** Status line for the OAuth linking flow (Phase 9b). */
linkMessage = $state<string | null>(null);
// Hardware + model management (Phase 3, T3.6/T3.7).
hardware = $state<HardwareStatus | null>(null);
models = $state<ModelInfo[]>([]);
// Speaker-diarization models (segmentation + embedding). Both must be
// installed before recordings separate speakers instead of labelling
// everything "S1" (T4.7, FR-MODEL-1).
diarizationModels = $state<ModelInfo[]>([]);
// Transcription language catalog for the Settings dropdown (T8.7, FR-TRX-4).
languages = $state<LanguageOption[]>([]);
audioDevices = $state<AudioDeviceInfo[]>([]);
inputDevices = $state<AudioDeviceInfo[]>([]);
downloadProgress = $state<Record<string, { received: number; total: number | null }>>({});
// Privacy self-check (T7.6, FR-SEC-2).
privacy = $state<PrivacySelfCheck | null>(null);
// MCP server (Phase 10b, ADR-0011).
mcpStatus = $state<McpStatus | null>(null);
mcpAccessLog = $state<McpAccessEntry[]>([]);
mcpSaving = $state(false);
/** The freshly-minted token from the last `setMcpEnabled(true)` call —
* shown exactly once (it is never re-readable afterwards, same as any
* other newly-issued secret). Cleared on disable or when the panel closes. */
mcpLastToken = $state<string | null>(null);
// LLM provider status (T5.2, FR-LLM-1).
llmStatus = $state<LlmStatus | null>(null);
llmSaving = $state(false);
async load() {
try {
this.settings = await api.getSettings();
} catch {
// Backend command is still a todo!(); fall back to local defaults.
this.backendStub = true;
this.settings = { ...DEFAULT_SETTINGS };
}
try {
this.targets = await api.listSyncTargets();
} catch {
// Sync lands in Phase 9 — don't let its stub discard real settings above.
this.backendStub = true;
}
await this.loadHardware();
await this.loadAudioDevices();
await this.loadInputDevices();
await this.loadModels();
await this.loadDiarizationModels();
await this.loadLanguages();
await this.loadPrivacy();
await this.loadLlmStatus();
await this.loadMcpStatus();
await this.loadMcpAccessLog();
// Live tail of the FR-MCP-5 audit log — every tool read an agent makes
// while the panel is open shows up immediately, not just on refresh.
await events.onMcpAccess(({ at, tool, meetingId, client }) => {
this.mcpAccessLog = [
{ at, tool, meeting_id: meetingId ?? null, client: client ?? null },
...this.mcpAccessLog,
].slice(0, 50);
});
await events.onHardwareChanged(({ active }) => {
if (this.hardware) this.hardware.active = active;
});
await events.onModelProgress(({ id, receivedBytes, totalBytes }) => {
this.downloadProgress = {
...this.downloadProgress,
[id]: { received: receivedBytes, total: totalBytes },
};
});
// OAuth link completion (Phase 9b) — the flow finishes in the background.
await events.onSyncLinked(async ({ ok, kind, error }) => {
if (ok) {
this.linkMessage = `${kind} linked.`;
await this.reloadTargets();
await this.loadPrivacy();
} else {
this.linkMessage = `Link failed: ${error ?? "unknown error"}`;
}
});
this.loaded = true;
}
async reloadTargets() {
try {
this.targets = await api.listSyncTargets();
} catch {
/* keep the current list on a transient failure */
}
}
/** Start OAuth linking for a secondary provider: open the auth URL; the flow
* completes via the `sync://linked` event handled in load(). */
async linkOauth(kind: "onedrive" | "dropbox" | "box") {
this.linkMessage = "Opening browser…";
try {
const { authUrl } = await api.beginOauthLink(kind);
window.open(authUrl, "_blank");
this.linkMessage = "Finish sign-in in your browser…";
} catch (e) {
this.linkMessage = `Link failed: ${errorMessage(e)}`;
}
}
async loadHardware() {
try {
this.hardware = await api.hardwareStatus();
} catch {
this.hardware = null;
}
}
async loadAudioDevices() {
try {
this.audioDevices = await api.listAudioDevices();
} catch {
this.audioDevices = [];
}
}
async setAudioOutputDevice(deviceId: string | null) {
await this.patch({ audio_output_device: deviceId });
}
async loadInputDevices() {
try {
this.inputDevices = await api.listInputDevices();
} catch {
this.inputDevices = [];
}
}
/** Set the microphone selection in one patch (FR-CAP-7): `enabled=false`
* disables mic capture entirely; `deviceId=null` uses the system default. */
async setMicrophone(enabled: boolean, deviceId: string | null) {
await this.patch({ microphone_enabled: enabled, audio_input_device: deviceId });
}
async loadModels() {
try {
this.models = await api.listModels();
} catch {
this.models = [];
}
}
async loadDiarizationModels() {
try {
this.diarizationModels = await api.listDiarizationModels();
} catch {
this.diarizationModels = [];
}
}
/** Download one diarization model. The two known ids map to the backend's
* `diar-seg`/`diar-emb` kinds; `removeModel` needs no kind (it disambiguates
* by catalog membership). */
async downloadDiarizationModel(id: string) {
this.clearProgress(id);
await api.downloadModel(id, id.startsWith("seg") ? "diar-seg" : "diar-emb");
this.clearProgress(id);
await this.loadDiarizationModels();
}
async removeDiarizationModel(id: string) {
await api.removeModel(id);
await this.loadDiarizationModels();
}
async loadLanguages() {
try {
this.languages = await api.listWhisperLanguages();
} catch {
this.languages = [];
}
}
/** `null` = auto-detect (T8.7, FR-TRX-4). Only meaningful when the active
* model is multilingual — the Settings UI disables/hides this control
* otherwise, and the backend forces "en" regardless if it's set anyway. */
async setWhisperLanguage(code: string | null) {
await this.patch({ whisper_language: code });
}
async loadPrivacy() {
try {
this.privacy = await api.privacySelfCheck();
} catch {
this.privacy = null;
}
}
async loadMcpStatus() {
try {
this.mcpStatus = await api.mcpStatus();
} catch {
this.mcpStatus = null;
}
}
async loadMcpAccessLog(limit = 50) {
try {
this.mcpAccessLog = await api.mcpAccessLog(limit);
} catch {
this.mcpAccessLog = [];
}
}
/** Enable/disable the loopback MCP server (FR-MCP-1/6). On enable, the
* returned token is stashed in `mcpLastToken` for the one-time reveal. */
async setMcpEnabled(enabled: boolean, transport?: "http" | "stdio", port?: number) {
this.mcpSaving = true;
try {
const res = await api.setMcpEnabled(enabled, transport, port);
this.mcpLastToken = enabled ? res.token : null;
} catch {
this.backendStub = true;
} finally {
this.mcpSaving = false;
}
await this.loadMcpStatus();
await this.loadPrivacy();
}
/** Scope control (FR-MCP-3) — takes effect immediately, no restart needed. */
async setMcpScope(expose: "none" | "selected" | "all", exposeRecordings?: boolean) {
try {
await api.setMcpScope(expose, exposeRecordings);
} catch {
this.backendStub = true;
}
await this.loadMcpStatus();
await this.loadPrivacy();
}
async loadLlmStatus() {
try {
this.llmStatus = await api.llmStatus();
} catch {
this.llmStatus = null;
}
}
/** Persist the LLM provider/endpoint/model (+ hosted apiKey, ADR-0011) and
* refresh status (T5.2/T10.2). The key is only ever sent to the backend
* command (which stores it in the OS credential store) — never held here
* beyond this call, and never merged into `this.settings`. */
async setLlmProvider(config: {
provider: string;
endpoint?: string;
model?: string;
apiKey?: string;
}) {
this.llmSaving = true;
// Optimistic local update so the form reflects the change immediately.
this.settings = {
...this.settings,
llm_provider: config.provider,
llm_endpoint: config.endpoint ?? this.settings.llm_endpoint,
llm_model: config.model ?? this.settings.llm_model,
};
try {
await api.setLlmProvider(config);
await this.loadLlmStatus();
await this.loadPrivacy();
} finally {
this.llmSaving = false;
}
}
/** Persist the one-time hosted-AI "leaves your device" acknowledgment
* (ADR-0011, T10.3) — same generic patch() every other boolean setting
* here uses (see setDefaultRecord below). */
acknowledgeHostedAi() {
return this.patch({ hosted_ai_acknowledged: true });
}
async setPreferredBackend(backend: AppSettings["preferred_backend"]) {
await this.patch({ preferred_backend: backend });
await this.loadHardware();
}
async setLowOverhead(on: boolean) {
await this.patch({ low_overhead: on });
}
async downloadModel(id: string) {
this.clearProgress(id);
await api.downloadModel(id);
this.clearProgress(id);
await this.loadModels();
}
private clearProgress(id: string) {
const rest = { ...this.downloadProgress };
delete rest[id];
this.downloadProgress = rest;
}
async removeModel(id: string) {
await api.removeModel(id);
await this.loadModels();
}
async setActiveModel(id: string) {
await this.patch({ whisper_model: id });
await this.loadModels();
}
async patch(patch: Partial<AppSettings>) {
// Optimistic: reflect immediately, then persist (ignore stub errors).
this.settings = { ...this.settings, ...patch };
try {
this.settings = await api.updateSettings(patch);
} catch {
this.backendStub = true;
}
await this.loadPrivacy();
}
setDefaultRecord(on: boolean) {
return this.patch({ default_record: on });
}
/** Launch-at-login toggle (NFR-RES-4). Goes through its own command (not
* patch) since the backend also writes the per-user OS Run entry; that
* command persists auto_start itself, so we just mirror it locally. */
async setAutoStart(on: boolean) {
this.settings = { ...this.settings, auto_start: on };
try {
await api.setAutoStart(on);
} catch {
this.backendStub = true;
}
}
setRetentionPolicy(maxAgeDays: number | null, maxSizeGb: number | null) {
return this.patch({ retention_max_age_days: maxAgeDays, retention_max_size_gb: maxSizeGb });
}
async acknowledgeConsent() {
this.settings = { ...this.settings, consent_acknowledged: true };
try {
await api.acknowledgeRecordingConsent();
} catch {
this.backendStub = true;
}
}
async setSyncEnabled(on: boolean) {
this.settings = { ...this.settings, sync_enabled: on };
try {
await api.setSyncEnabled(on);
} catch {
this.backendStub = true;
}
await this.loadPrivacy();
}
async addTarget(config: SyncTargetConfig) {
try {
const created = await api.addSyncTarget(config);
this.targets = [...this.targets, created];
} catch {
// Stub: synthesize a local row so the list is demonstrable.
this.backendStub = true;
this.targets = [...this.targets, stubTarget(config)];
}
await this.loadPrivacy();
}
async updateTarget(config: SyncTargetConfig & { id: string }) {
try {
const updated = await api.updateSyncTarget(config);
this.targets = this.targets.map((t) => (t.id === config.id ? updated : t));
} catch {
this.backendStub = true;
this.targets = this.targets.map((t) =>
t.id === config.id ? { ...t, ...stubTarget(config), id: config.id } : t,
);
}
await this.loadPrivacy();
}
async removeTarget(id: string) {
this.targets = this.targets.filter((t) => t.id !== id);
try {
await api.removeSyncTarget(id);
} catch {
this.backendStub = true;
}
await this.loadPrivacy();
}
async toggleTargetEnabled(t: SyncTargetInfo) {
const enabled = !t.enabled;
this.targets = this.targets.map((x) => (x.id === t.id ? { ...x, enabled } : x));
try {
await api.updateSyncTarget({ id: t.id, name: t.name, kind: t.kind, enabled });
} catch {
this.backendStub = true;
}
await this.loadPrivacy();
}
async test(config: SyncTargetConfig): Promise<{ ok: boolean; message: string }> {
try {
return await api.testSyncTarget(config);
} catch {
this.backendStub = true;
return { ok: false, message: "Backend not implemented yet (Phase 9)." };
}
}
}
function stubTarget(c: SyncTargetConfig): SyncTargetInfo {
let host: string | null = null;
try {
host = c.base_url ? new URL(c.base_url).host : null;
} catch {
host = null;
}
return {
id: crypto.randomUUID(),
name: c.name,
kind: c.kind,
provider_hint: c.provider_hint ?? null,
base_url: c.base_url ?? null,
remote_base_path: c.remote_base_path ?? "/WhispAssist",
username: c.username ?? null,
enabled: c.enabled ?? false,
third_party: c.kind !== "webdav",
host,
upload_transcript: c.upload_transcript ?? true,
upload_notes: c.upload_notes ?? true,
upload_summary: c.upload_summary ?? true,
upload_recording: c.upload_recording ?? false,
trigger_on_finalize: c.trigger_on_finalize ?? true,
allow_plaintext_lan: c.allow_plaintext_lan ?? false,
encrypt_before_upload: c.encrypt_before_upload ?? false,
};
}
export const settings = new SettingsStore();