508 lines
16 KiB
TypeScript
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();
|