Files
WhispAssist/src/lib/views/Settings.svelte
T

3133 lines
104 KiB
Svelte
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.
<script lang="ts">
// Settings modal (stub) — Recording retention (ADR-0009) + Sync targets (ADR-0010).
// Backend commands are still `todo!()`; the store falls back to local state so this
// is fully demonstrable. Replace nothing here when Phase 9 lands — it just starts persisting.
import { onMount } from "svelte";
import { settings } from "../stores/settings.svelte";
import { calendar } from "../stores/calendar.svelte";
import { meetings } from "../stores/meetings.svelte";
import { t, i18n, LOCALES } from "../i18n/index.svelte";
import ConsentNotice from "../components/ConsentNotice.svelte";
import HostedAiBanner from "../components/HostedAiBanner.svelte";
import LevelMeter from "../components/LevelMeter.svelte";
import { open } from "@tauri-apps/plugin-dialog";
import { api, errorMessage, events } from "../api";
import type {
BackendId,
StressTestResult,
SyncKind,
SyncTargetConfig,
SyncTargetInfo,
} from "../api";
import { trapFocus } from "../actions/trapFocus";
import {
X,
Check,
AlertTriangle,
Mic,
Cpu,
HardDrive,
CalendarDays,
UploadCloud,
ShieldCheck,
Lock,
LockOpen,
Sparkles,
Volume2,
Zap,
RefreshCw,
ChevronRight,
RotateCcw,
Info,
Bot,
Copy,
KeyRound,
Eye,
EyeOff,
Globe,
Download,
Trash2,
FolderInput,
FolderOutput,
} from "@lucide/svelte";
import {
OLLAMA_OPTIONS,
THINK_OPTIONS,
OLLAMA_GROUP_LABELS,
type OllamaOption,
type OllamaGroup,
} from "../llmParams";
let { onClose }: { onClose: () => void } = $props();
let section = $state<
| "recording"
| "hardware"
| "storage"
| "calendar"
| "sync"
| "ai"
| "mcp"
| "privacy"
| "language"
| "about"
>("recording");
// The currently active whisper model (T8.7, FR-TRX-4) — gates the
// language picker below (multilingual model required).
let activeModel = $derived(settings.models.find((m) => m.active));
// ---- About (version + build commit + source) ----
const SOURCE_URL = "https://git.dou.bet/iamdoubz/WhispAssist";
let appInfo = $state<{ version: string; commit: string } | null>(null);
// ---- AI summary provider (T5.2/T10.1/T10.2, FR-LLM-1, ADR-0011) ----
let llmProvider = $state(settings.settings.llm_provider);
let llmEndpoint = $state(settings.settings.llm_endpoint);
let llmModel = $state(settings.settings.llm_model);
// Hosted (Anthropic) API key — never read back from the backend (it's
// never returned by llm_status/get_settings, FR-SEC-1); this is a
// write-only field that's blank unless the user is actively (re)typing
// one, and is dropped from memory the moment saveLlm() sends it.
let llmApiKey = $state("");
let showApiKey = $state(false);
$effect(() => {
// Re-sync the form when settings (re)load or are saved elsewhere.
llmProvider = settings.settings.llm_provider;
llmEndpoint = settings.settings.llm_endpoint;
llmModel = settings.settings.llm_model;
});
const HOSTED_PROVIDER_LABELS: Record<string, string> = { anthropic: "Anthropic (Claude)" };
/** True when the *currently selected* provider is hosted (leaves the
* device): Anthropic always is; "custom" is hosted only once its endpoint
* resolves off-network — mirrors the live warning already shown below. */
function isHostedProviderSelection(): boolean {
if (llmProvider === "anthropic") return true;
if (llmProvider === "custom" && llmEndpoint) return !endpointIsLocalOrLan(llmEndpoint);
return false;
}
// ---- Hosted-AI "leaves your device" one-time banner (T10.3/M3.3) ----
let showHostedBanner = $state(false);
function onSaveLlmClick() {
if (isHostedProviderSelection() && !settings.settings.hosted_ai_acknowledged) {
showHostedBanner = true;
return;
}
saveLlm();
}
async function acceptHostedBannerAndSave() {
await settings.acknowledgeHostedAi();
showHostedBanner = false;
await saveLlm();
}
async function saveLlm() {
await settings.setLlmProvider({
provider: llmProvider,
endpoint: llmProvider === "anthropic" ? undefined : llmEndpoint || undefined,
model: llmModel || undefined,
apiKey: llmApiKey.trim() || undefined,
});
llmApiKey = ""; // never linger in memory once sent
}
/** Client-side mirror of the backend's loopback+private-LAN check, for a live
* "leaves your network" hint while typing. */
function endpointIsLocalOrLan(url: string): boolean {
try {
const h = new URL(url).hostname.replace(/^\[|\]$/g, "");
if (h === "localhost" || h.toLowerCase().endsWith(".local") || h === "::1") return true;
const m = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (!m) return false;
const a = Number(m[1]);
const b = Number(m[2]);
return (
a === 127 ||
a === 10 ||
(a === 192 && b === 168) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 169 && b === 254)
);
} catch {
return false;
}
}
// ---- MCP server (Phase 10b, ADR-0011) ----
let mcpTransport = $state<"http" | "stdio">(
settings.settings.mcp_transport === "stdio" ? "stdio" : "http",
);
let mcpPort = $state(settings.settings.mcp_port);
let mcpExpose = $state<"none" | "selected" | "all">(
(settings.settings.mcp_expose as "none" | "selected" | "all") ?? "none",
);
let mcpExposeRecordings = $state(settings.settings.mcp_expose_recordings);
let mcpCopied = $state<"endpoint" | "token" | null>(null);
$effect(() => {
// Re-sync the form when settings (re)load, same pattern as the LLM form above.
mcpTransport = settings.settings.mcp_transport === "stdio" ? "stdio" : "http";
mcpPort = settings.settings.mcp_port;
mcpExpose = (settings.settings.mcp_expose as "none" | "selected" | "all") ?? "none";
mcpExposeRecordings = settings.settings.mcp_expose_recordings;
});
async function toggleMcpEnabled(enabled: boolean) {
await settings.setMcpEnabled(enabled, mcpTransport, mcpPort);
}
async function saveMcpScope() {
await settings.setMcpScope(mcpExpose, mcpExposeRecordings);
}
async function copyToClipboard(text: string, what: "endpoint" | "token") {
try {
await navigator.clipboard.writeText(text);
mcpCopied = what;
setTimeout(() => {
if (mcpCopied === what) mcpCopied = null;
}, 2000);
} catch {
/* clipboard API unavailable — the value is still selectable/copyable by hand */
}
}
function relativeTime(atMs: number): string {
const diffSec = Math.round((Date.now() - atMs) / 1000);
if (diffSec < 5) return "just now";
if (diffSec < 60) return `${diffSec}s ago`;
if (diffSec < 3600) return `${Math.round(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.round(diffSec / 3600)}h ago`;
return new Date(atMs).toLocaleString();
}
// ---- Advanced Ollama configuration (sparse; only overrides are stored) ----
const OPTION_GROUPS: OllamaGroup[] = ["sampling", "repetition", "mirostat", "context"];
type OptVal = number | boolean | string[];
let advSystem = $state("");
let advThink = $state("off");
let advKeepAlive = $state("");
let advOptions = $state<Record<string, OptVal>>({});
let advSaving = $state(false);
let advSaved = $state(false);
$effect(() => {
// (Re)load the form whenever persisted settings change.
const a = settings.settings.llm_advanced ?? {};
advSystem = a.system ?? "";
advThink = a.think ?? "off";
advKeepAlive = a.keep_alive ?? "";
advOptions = { ...(a.options ?? {}) };
});
let advChangedCount = $derived(
Object.keys(advOptions).length +
(advSystem.trim() ? 1 : 0) +
(advThink !== "off" ? 1 : 0) +
(advKeepAlive.trim() ? 1 : 0),
);
function mirostatOff(): boolean {
const m = advOptions["mirostat"];
return (m === undefined ? 0 : (m as number)) === 0;
}
function optDisplay(opt: OllamaOption): string {
const v = advOptions[opt.key];
if (opt.kind === "tags") return Array.isArray(v) ? v.join(", ") : "";
if (v !== undefined) return String(v);
return opt.default !== undefined ? String(opt.default) : "";
}
function isOverridden(opt: OllamaOption): boolean {
return opt.key in advOptions;
}
function commit(key: string, value: OptVal | undefined) {
const next = { ...advOptions };
if (value === undefined) delete next[key];
else next[key] = value;
advOptions = next;
advSaved = false;
}
function setNumber(opt: OllamaOption, raw: string) {
const t = raw.trim();
if (t === "") return commit(opt.key, undefined);
const n = Number(t);
if (Number.isNaN(n)) return;
commit(opt.key, opt.default !== undefined && n === opt.default ? undefined : n);
}
function setEnum(opt: OllamaOption, raw: string) {
const n = Number(raw);
commit(opt.key, opt.default !== undefined && n === opt.default ? undefined : n);
}
function setBool(opt: OllamaOption, checked: boolean) {
commit(opt.key, opt.default !== undefined && checked === opt.default ? undefined : checked);
}
function setTags(opt: OllamaOption, raw: string) {
const arr = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
commit(opt.key, arr.length ? arr : undefined);
}
function resetAllAdvanced() {
advSystem = "";
advThink = "off";
advKeepAlive = "";
advOptions = {};
advSaved = false;
}
async function saveAdvanced() {
advSaving = true;
advSaved = false;
const adv: NonNullable<(typeof settings.settings)["llm_advanced"]> = {};
if (advSystem.trim()) adv.system = advSystem.trim();
if (advThink !== "off") adv.think = advThink;
if (advKeepAlive.trim()) adv.keep_alive = advKeepAlive.trim();
if (Object.keys(advOptions).length) adv.options = advOptions;
try {
await settings.patch({ llm_advanced: Object.keys(adv).length ? adv : null });
advSaved = true;
} finally {
advSaving = false;
}
}
// ---- Calendar / .pst import (T6.1/T6.2/T6.3, FR-CAL-1/2) ----
// .pst import shells out to readpst (libpst, ADR-0008) — external like
// ffmpeg/yt-dlp, so link the same kind of download button as the Import
// dialog's. ezwinports is the maintained Windows build of libpst.
const LIBPST_URL =
"https://sourceforge.net/projects/ezwinports/files/libpst-0.6.63-w32-bin.zip/download";
let pstPath = $state("");
let pstPassword = $state("");
let eventTitleFilter = $state("");
let eventDateFilter = $state("");
function eventLocalYmd(unixSecs: number | null): string {
if (!unixSecs) return "";
const d = new Date(unixSecs * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
let filteredEvents = $derived(
calendar.events.filter((ev) => {
if (eventDateFilter && eventLocalYmd(ev.starts_at) !== eventDateFilter) return false;
if (
eventTitleFilter &&
!(ev.subject ?? "").toLowerCase().includes(eventTitleFilter.toLowerCase())
)
return false;
return true;
}),
);
// Prefills the remembered path once settings load, without clobbering
// whatever the user is actively typing/browsing to.
$effect(() => {
if (!pstPath && settings.settings.pst_last_path) {
pstPath = settings.settings.pst_last_path;
}
});
async function pickPstFile() {
const path = await open({ filters: [{ name: "Outlook data file", extensions: ["pst"] }] });
if (typeof path === "string") pstPath = path;
}
// "all" imports the full mailbox history; otherwise days of history from
// now. Persisted immediately on change so pst_auto_sync's startup
// re-import (lib.rs) applies the same range as the last manual import.
let importRange = $state<string>(
settings.settings.pst_import_range_days == null
? "all"
: String(settings.settings.pst_import_range_days),
);
function onImportRangeChange(e: Event) {
importRange = (e.target as HTMLSelectElement).value;
settings.patch({
pst_import_range_days: importRange === "all" ? null : Number(importRange),
});
}
async function doImportPst() {
if (!pstPath) return;
const rangeDays = importRange === "all" ? undefined : Number(importRange);
await calendar.importPst(pstPath, pstPassword || undefined, rangeDays);
pstPassword = "";
if (!calendar.importError) await settings.patch({ pst_last_path: pstPath });
}
function onToggleAutoSync(e: Event) {
settings.patch({ pst_auto_sync: (e.target as HTMLInputElement).checked });
}
// ---- Calendar cleanup (dedup/bloat fix follow-up) ----
let cleanupThreshold = $state<string>("all");
let cleanupRunning = $state(false);
let cleanupResult = $state<{ deleted: number; protected: number } | null>(null);
let cleanupError = $state<string | null>(null);
async function runCleanup() {
const isDeleteAll = cleanupThreshold === "all";
const olderThanDays = isDeleteAll ? undefined : Number(cleanupThreshold);
const warning = isDeleteAll
? "Delete ALL imported calendar events? Events already attached to a recorded meeting are always kept."
: `Delete calendar events older than ${cleanupThreshold} days? Events already attached to a recorded meeting are always kept.`;
if (!confirm(warning)) return;
cleanupRunning = true;
cleanupError = null;
cleanupResult = null;
try {
cleanupResult = await api.cleanupCalendarEvents(olderThanDays);
await calendar.load();
} catch (e) {
cleanupError = errorMessage(e);
} finally {
cleanupRunning = false;
}
}
function formatEventDate(unixSecs: number | null): string {
if (!unixSecs) return "";
return new Date(unixSecs * 1000).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
// ---- Hardware + models (T3.6/T3.7/T3.9) ----
function fmtBytes(mb: number): string {
return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb} MB`;
}
function progressPct(id: string): number | null {
const p = settings.downloadProgress[id];
if (!p || !p.total) return null;
return Math.round((p.received / p.total) * 100);
}
let showConsent = $state(false);
let testResult = $state<{ ok: boolean; message: string } | null>(null);
// ---- Audio device test (live level meter) ----
let monitorKind = $state<"input" | "loopback" | null>(null);
let monitorRms = $state(0);
let monitorPeak = $state(0);
let monitorUnlisten: (() => void) | null = null;
function stopMonitor() {
monitorUnlisten?.();
monitorUnlisten = null;
monitorKind = null;
monitorRms = 0;
monitorPeak = 0;
}
async function testDevice(kind: "input" | "loopback") {
if (monitorKind) return;
monitorKind = kind;
monitorRms = 0;
monitorPeak = 0;
monitorUnlisten = await events.onDeviceLevel((p) => {
if (p.kind !== kind) return;
if (p.done) {
stopMonitor();
return;
}
monitorRms = p.rms ?? 0;
monitorPeak = p.peak ?? 0;
});
const deviceId =
kind === "input"
? (settings.settings.audio_input_device ?? null)
: (settings.settings.audio_output_device ?? null);
try {
await api.monitorAudioLevel(kind, deviceId, 6000);
} catch (e) {
testResult = { ok: false, message: errorMessage(e) };
} finally {
stopMonitor();
}
}
// A 440Hz beep to the default output so the user can confirm speakers work.
function playTone() {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = 440;
gain.gain.value = 0.15;
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.5);
osc.onended = () => ctx.close();
} catch {
/* no Web Audio available */
}
}
// ---- Quick hardware stress test ----
let stressRunning = $state(false);
let stressProgress = $state<string | null>(null);
let stressResult = $state<StressTestResult | null>(null);
let stressError = $state<string | null>(null);
async function runStressTest() {
if (stressRunning) return;
stressRunning = true;
stressError = null;
stressResult = null;
const un = await events.onStressProgress((p) => {
stressProgress = `${p.backend} · ${p.model}`;
});
try {
stressResult = await api.stressTestHardware();
} catch (e) {
stressError = errorMessage(e);
} finally {
un();
stressProgress = null;
stressRunning = false;
}
}
async function applyRecommendation() {
const r = stressResult?.recommended;
if (!r) return;
await settings.setPreferredBackend(r.backend as BackendId | "auto");
await settings.patch({ whisper_model: r.model });
}
// ---- At-rest encryption vault (T8.8, FR-SEC-3) ----
let vault = $state<{ enabled: boolean; unlocked: boolean } | null>(null);
let vaultPw = $state("");
let vaultPw2 = $state("");
let vaultMsg = $state<string | null>(null);
let vaultMsgError = $state(false);
let showVaultPw = $state(false);
async function loadVault() {
try {
vault = await api.vaultStatus();
} catch {
vault = null;
}
}
onMount(loadVault);
function setVaultMsg(msg: string | null, isError = false) {
vaultMsg = msg;
vaultMsgError = isError;
}
async function enableVault() {
setVaultMsg(null);
try {
await api.enableVault(vaultPw);
vaultPw = "";
setVaultMsg("Vault enabled and unlocked.");
await loadVault();
} catch (e) {
setVaultMsg(errorMessage(e), true);
}
}
async function unlockVault() {
setVaultMsg(null);
try {
await api.unlockVault(vaultPw);
vaultPw = "";
setVaultMsg("Unlocked.");
await loadVault();
} catch (e) {
setVaultMsg(errorMessage(e), true);
}
}
async function lockVault() {
await api.lockVault();
setVaultMsg("Locked.");
await loadVault();
}
async function changeVaultPassword() {
setVaultMsg(null);
try {
await api.changeVaultPassword(vaultPw, vaultPw2);
vaultPw = "";
vaultPw2 = "";
setVaultMsg("Password changed.");
} catch (e) {
setVaultMsg(errorMessage(e), true);
}
}
// ---- NPU package download (T3.4) ----
let npuBusy = $state(false);
let npuMsg = $state<string | null>(null);
async function downloadNpuPackage() {
npuBusy = true;
npuMsg = "Starting…";
try {
await api.downloadNpuPackage();
await settings.loadHardware();
npuMsg = "NPU package ready.";
} catch (e) {
npuMsg = `Failed: ${errorMessage(e)}`;
} finally {
npuBusy = false;
}
}
// ---- DirectML package download (P2) ----
// ponytail: the await resolves when model+runtime are both staged, so the
// busy flag is the whole progress story here — no % listener like NPU has.
let dmlBusy = $state(false);
let dmlMsg = $state<string | null>(null);
async function downloadDirectmlPackage() {
dmlBusy = true;
dmlMsg = "Downloading…";
try {
await api.downloadDirectmlPackage();
await settings.loadHardware();
dmlMsg = "DirectML package ready.";
} catch (e) {
dmlMsg = `Failed: ${errorMessage(e)}`;
} finally {
dmlBusy = false;
}
}
onMount(() => {
settings.load();
calendar.load();
api
.appInfo()
.then((i) => (appInfo = i))
.catch(() => (appInfo = null));
const un = events.onNpuDownload((p) => {
if (p.stage === "model")
npuMsg = p.total ? `Model ${Math.round((100 * (p.received ?? 0)) / p.total)}%` : "Model…";
else if (p.stage === "runtime") npuMsg = `Runtime (${p.copied ?? 0} files)…`;
else if (p.stage === "done") npuMsg = p.ready ? "NPU package ready." : "Model ready.";
});
return () => {
void un.then((f) => f());
};
});
// ---- Recording ----
function onToggleDefaultRecord(e: Event) {
const on = (e.target as HTMLInputElement).checked;
if (on && !settings.settings.consent_acknowledged) {
showConsent = true; // gate first opt-in behind the consent notice (FR-REC-2)
(e.target as HTMLInputElement).checked = false;
return;
}
settings.setDefaultRecord(on);
}
async function acceptConsent() {
await settings.acknowledgeConsent();
await settings.setDefaultRecord(true);
showConsent = false;
}
// ---- Storage retention (FR-STORE-2) ----
let maxAgeInput = $state("");
let maxSizeInput = $state("");
$effect(() => {
maxAgeInput = settings.settings.retention_max_age_days?.toString() ?? "";
maxSizeInput = settings.settings.retention_max_size_gb?.toString() ?? "";
});
function saveRetention() {
const age = maxAgeInput.trim() === "" ? null : Math.max(1, parseInt(maxAgeInput, 10) || 0);
const size = maxSizeInput.trim() === "" ? null : Math.max(1, parseInt(maxSizeInput, 10) || 0);
settings.setRetentionPolicy(age, size);
}
// ---- Export / Import (FR-STORE-4) — move recordings between computers as
// portable "bundle" folders (audio + transcript + notes + summary +
// meeting.json manifest). Drop a bundle on any synced/removable drive, or a
// sync target's local mount, to carry it across. ----
let exportBusy = $state(false);
let importBusy = $state(false);
let ioStatus = $state<string | null>(null);
let ioError = $state(false);
async function exportAllMeetings() {
const dir = await open({ directory: true, title: t("settings.storage.export_dialog_title") });
if (typeof dir !== "string") return;
exportBusy = true;
ioStatus = null;
ioError = false;
try {
const n = await api.bulkExportMeetings(dir, "bundle");
ioStatus =
n === 1
? t("settings.storage.exported_one", { n })
: t("settings.storage.exported_many", { n });
} catch (e) {
ioStatus = errorMessage(e);
ioError = true;
} finally {
exportBusy = false;
}
}
async function importMeetings() {
const dir = await open({ directory: true, title: t("settings.storage.import_dialog_title") });
if (typeof dir !== "string") return;
importBusy = true;
ioStatus = null;
ioError = false;
try {
const n = await api.importMeetingBundle(dir);
await meetings.load();
ioStatus =
n === 1
? t("settings.storage.imported_one", { n })
: t("settings.storage.imported_many", { n });
} catch (e) {
ioStatus = errorMessage(e);
ioError = true;
} finally {
importBusy = false;
}
}
// ---- Add-target form ----
const PROVIDERS = ["nextcloud", "owncloud", "cloudreve", "seafile", "synology", "generic"];
let form = $state<SyncTargetConfig>(blankForm("webdav"));
// When set, the form is editing an existing target rather than adding one.
let editingId = $state<string | null>(null);
// Nextcloud/ownCloud only need the server URL — the app builds the DAV path.
let davAutoPath = $derived(
form.provider_hint === "nextcloud" || form.provider_hint === "owncloud",
);
function blankForm(kind: SyncKind): SyncTargetConfig {
return {
name: "",
kind,
provider_hint: kind === "webdav" ? "nextcloud" : undefined,
base_url: "",
remote_base_path: "/WhispAssist",
username: "",
secret: "",
enabled: true,
upload_transcript: true,
upload_notes: true,
upload_summary: true,
upload_recording: false,
trigger_on_finalize: true,
allow_plaintext_lan: false,
encrypt_before_upload: false,
};
}
function setKind(k: SyncKind) {
form = blankForm(k);
testResult = null;
}
async function testConnection() {
// In edit mode, pass the id so the backend reuses the stored password
// (unless a new one was typed) while still testing the edited URL/fields.
const r = await settings.test(editingId ? { ...form, id: editingId } : form);
testResult = { ok: r.ok, message: r.message };
}
// Load an existing target into the form for editing. The secret is never
// returned to the UI (FR-SYNC-6), so it stays blank — left blank on save it
// keeps the stored password; typed, it rotates. No `id` on the form itself, so
// "Test connection" checks the typed values (needs the password re-entered).
function startEdit(t: SyncTargetInfo) {
editingId = t.id;
form = {
name: t.name,
kind: t.kind,
provider_hint: t.provider_hint ?? undefined,
base_url: t.base_url ?? "",
remote_base_path: t.remote_base_path,
username: t.username ?? "",
secret: "",
enabled: t.enabled,
upload_transcript: t.upload_transcript,
upload_notes: t.upload_notes,
upload_summary: t.upload_summary,
upload_recording: t.upload_recording,
trigger_on_finalize: t.trigger_on_finalize,
allow_plaintext_lan: t.allow_plaintext_lan,
encrypt_before_upload: t.encrypt_before_upload,
};
testResult = null;
}
function cancelEdit() {
editingId = null;
form = blankForm("webdav");
testResult = null;
}
async function saveTarget() {
if (editingId) {
await settings.updateTarget({ ...form, id: editingId });
cancelEdit();
} else {
await settings.addTarget(form);
form = blankForm(form.kind);
testResult = null;
}
}
function linkOAuth() {
// begin_oauth_link → loopback PKCE flow; the target is created in the
// background on success (sync://linked), which the store handles.
if (form.kind === "webdav") return;
settings.linkOauth(form.kind);
}
</script>
<div
class="overlay"
role="dialog"
aria-modal="true"
aria-label={t("settings.dialog_title")}
tabindex="-1"
use:trapFocus
>
<div class="panel">
<header>
<strong>{t("settings.dialog_title")}</strong>
<nav>
<button class:active={section === "recording"} onclick={() => (section = "recording")}>
<Mic size={14} aria-hidden="true" />
{t("nav.recording")}
</button>
<button class:active={section === "hardware"} onclick={() => (section = "hardware")}>
<Cpu size={14} aria-hidden="true" />
{t("nav.hardware")}
</button>
<button class:active={section === "storage"} onclick={() => (section = "storage")}>
<HardDrive size={14} aria-hidden="true" />
{t("nav.storage")}
</button>
<button class:active={section === "calendar"} onclick={() => (section = "calendar")}>
<CalendarDays size={14} aria-hidden="true" />
{t("nav.calendar")}
</button>
<button class:active={section === "sync"} onclick={() => (section = "sync")}>
<UploadCloud size={14} aria-hidden="true" />
{t("nav.sync")}
</button>
<button class:active={section === "ai"} onclick={() => (section = "ai")}>
<Sparkles size={14} aria-hidden="true" />
{t("nav.ai")}
</button>
<button class:active={section === "mcp"} onclick={() => (section = "mcp")}>
<Bot size={14} aria-hidden="true" />
{t("nav.mcp")}
</button>
<button class:active={section === "privacy"} onclick={() => (section = "privacy")}>
<ShieldCheck size={14} aria-hidden="true" />
{t("nav.privacy")}
</button>
<button class:active={section === "language"} onclick={() => (section = "language")}>
<Globe size={14} aria-hidden="true" />
{t("nav.language")}
</button>
<button class:active={section === "about"} onclick={() => (section = "about")}>
<Info size={14} aria-hidden="true" />
{t("nav.about")}
</button>
</nav>
<button
class="close"
onclick={onClose}
aria-label={t("settings.close")}
title={t("settings.close_title")}
>
<X size={18} aria-hidden="true" />
</button>
</header>
{#if settings.backendStub && section === "sync"}
<p class="stub">{t("settings.sync.stub")}</p>
{/if}
{#if section === "recording"}
<section>
<h3>{t("settings.recording.title")}</h3>
<label class="row">
<input
type="checkbox"
checked={settings.settings.default_record}
onchange={onToggleDefaultRecord}
/>
<span
>{t("settings.recording.record_default")}
<em>({t("settings.recording.save_audio_as")} <code>.wav</code>)</em></span
>
</label>
<p class="muted">{t("settings.recording.default_hint")}</p>
<p class="muted">
{t("settings.recording.consent_label", {
status: settings.settings.consent_acknowledged
? t("settings.recording.consent_ack")
: t("settings.recording.consent_not"),
})}
</p>
<label class="row">
<input
type="checkbox"
checked={settings.settings.auto_record_calendar}
onchange={(e) =>
settings.patch({ auto_record_calendar: (e.target as HTMLInputElement).checked })}
/>
<span>{t("settings.recording.auto_label")}</span>
</label>
<p class="muted">{t("settings.recording.auto_hint")}</p>
<label class="row">
<input
type="checkbox"
checked={settings.settings.auto_start}
onchange={(e) => settings.setAutoStart((e.target as HTMLInputElement).checked)}
/>
<span>{t("settings.recording.autostart_label")}</span>
</label>
<p class="muted">{t("settings.recording.autostart_hint")}</p>
<label class="row">
<input
type="checkbox"
checked={settings.settings.close_to_tray}
onchange={(e) =>
settings.patch({ close_to_tray: (e.target as HTMLInputElement).checked })}
/>
<span>{t("settings.recording.close_tray_label")}</span>
</label>
<p class="muted">{t("settings.recording.close_tray_hint")}</p>
{#if showConsent}
<ConsentNotice onAccept={acceptConsent} onCancel={() => (showConsent = false)} />
{/if}
</section>
{:else if section === "hardware"}
<section>
<div class="actions">
<h3>{t("settings.hardware.title")}</h3>
<button class="ghost" onclick={() => settings.loadHardware()}>
<RefreshCw size={14} aria-hidden="true" />
{t("settings.hardware.refresh")}
</button>
</div>
{#if settings.hardware}
<div class="row">
{t("settings.hardware.active_backend")}<code>{settings.hardware.active}</code>
<span class="muted"
>{t("settings.hardware.model_meta", {
size: settings.hardware.modelSize,
})}</span
>
</div>
<label
>{t("settings.hardware.preferred_backend")}
<select
value={settings.settings.preferred_backend}
onchange={(e) =>
settings.setPreferredBackend(
(e.target as HTMLSelectElement).value as BackendId | "auto",
)}
>
<option value="auto">{t("settings.hardware.auto_backend")}</option>
{#each settings.hardware.backends as b (b.id)}
<option value={b.id} disabled={!b.available}>
{b.name} ({b.id}){b.available ? "" : ` — ${t("settings.hardware.not_available")}`}
</option>
{/each}
</select>
</label>
<p class="muted">{t("settings.hardware.fallback_hint")}</p>
<h4>{t("settings.hardware.audio_devices")}</h4>
<label
>{t("settings.hardware.recording_device")}
<select
value={settings.settings.audio_output_device ?? ""}
onchange={(e) =>
settings.setAudioOutputDevice((e.target as HTMLSelectElement).value || null)}
>
<option value="">{t("settings.hardware.default_system_audio")}</option>
{#each settings.audioDevices as d (d.id)}
<option value={d.id}>{d.name}</option>
{/each}
</select>
</label>
<p class="muted">{t("settings.hardware.recording_device_hint")}</p>
<div class="device-test">
<button onclick={() => testDevice("loopback")} disabled={monitorKind !== null}>
<Volume2 size={13} aria-hidden="true" />
{monitorKind === "loopback"
? t("settings.hardware.testing")
: t("settings.hardware.test_output")}
</button>
<button onclick={playTone} disabled={monitorKind !== null}>
{t("settings.hardware.play_tone")}
</button>
{#if monitorKind === "loopback"}
<LevelMeter rms={monitorRms} peak={monitorPeak} />
{/if}
</div>
<label
>{t("settings.hardware.microphone")}
<select
value={!settings.settings.microphone_enabled
? "off"
: (settings.settings.audio_input_device ?? "")}
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
if (v === "off") settings.setMicrophone(false, null);
else settings.setMicrophone(true, v || null);
}}
>
<option value="off">{t("settings.hardware.mic_off")}</option>
<option value="">{t("settings.hardware.default_mic")}</option>
{#each settings.inputDevices as d (d.id)}
<option value={d.id}>{d.name}</option>
{/each}
</select>
</label>
<p class="muted">{t("settings.hardware.mic_hint")}</p>
<div class="device-test">
<button
onclick={() => testDevice("input")}
disabled={monitorKind !== null || !settings.settings.microphone_enabled}
>
<Mic size={13} aria-hidden="true" />
{monitorKind === "input"
? t("settings.hardware.testing")
: t("settings.hardware.test_mic")}
</button>
{#if monitorKind === "input"}
<LevelMeter rms={monitorRms} peak={monitorPeak} />
{/if}
</div>
<h4>{t("settings.hardware.stress_title")}</h4>
<p class="muted">{t("settings.hardware.stress_hint")}</p>
<button onclick={runStressTest} disabled={stressRunning}>
<Zap size={13} aria-hidden="true" />
{stressRunning
? t("settings.hardware.stress_running")
: t("settings.hardware.stress_run")}
</button>
{#if stressProgress}
<p class="muted">{t("settings.hardware.stress_progress", { pair: stressProgress })}</p>
{/if}
{#if stressError}<p class="muted err">{stressError}</p>{/if}
{#if stressResult}
{#if stressResult.recommended}
<div class="stress-rec">
<ShieldCheck size={14} aria-hidden="true" />
<span
>{t("settings.hardware.stress_recommend", {
backend: stressResult.recommended.backend,
model: stressResult.recommended.model,
})}</span
>
<button class="primary" onclick={applyRecommendation}
>{t("settings.hardware.stress_apply")}</button
>
</div>
{:else}
<p class="muted">{t("settings.hardware.stress_none")}</p>
{/if}
<table class="stress-table">
<thead>
<tr>
<th>{t("settings.hardware.stress_backend")}</th>
<th>{t("settings.hardware.stress_model")}</th>
<th>{t("settings.hardware.stress_rtf")}</th>
<th>{t("settings.hardware.stress_realtime")}</th>
</tr>
</thead>
<tbody>
{#each stressResult.results as r (r.backend + r.model)}
<tr>
<td>{r.backend}</td>
<td>{r.model}</td>
<td class="num">{r.rtf.toFixed(2)}×</td>
<td>
{#if r.realtime}
<Check size={13} aria-hidden="true" /> {t("settings.hardware.stress_yes")}
{:else}
{t("settings.hardware.stress_no")}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{#if settings.hardware.npu?.present}
{@const npu = settings.hardware.npu}
{@const ready = npu.runtimeReady && npu.modelInstalled}
<div class="npu-package">
<div class="row">
<Cpu size={14} aria-hidden="true" />
<strong>{t("settings.hardware.npu_detected")}</strong>
{#if ready}
<span class="badge ok"
><Check size={12} aria-hidden="true" /> {t("settings.hardware.ready")}</span
>
{:else if npuBusy}
<span class="badge busy">{t("settings.hardware.downloading")}</span>
{:else}
<span class="badge">{t("settings.hardware.package_needed")}</span>
{/if}
</div>
{#if !ready}
<p class="muted">
{t("settings.hardware.npu_hint", {
extra: npu.runtimeReady ? "" : t("settings.hardware.plus_openvino"),
})}
</p>
<button onclick={downloadNpuPackage} disabled={npuBusy}>
{npuBusy
? t("settings.hardware.downloading")
: t("settings.hardware.download_npu")}
</button>
{/if}
{#if npuMsg}<p class="muted npu-status">{npuMsg}</p>{/if}
</div>
{/if}
{#if settings.hardware.directml?.applicable}
{@const dml = settings.hardware.directml}
{@const dmlReady = dml.runtimeReady && dml.modelInstalled}
<!-- ponytail: reuses the .npu-package layout styles (shared accel card). -->
<div class="npu-package">
<div class="row">
<Cpu size={14} aria-hidden="true" />
<strong>{t("settings.hardware.dml_title")}</strong>
{#if dmlReady}
<span class="badge ok"
><Check size={12} aria-hidden="true" /> {t("settings.hardware.ready")}</span
>
{:else if dmlBusy}
<span class="badge busy">{t("settings.hardware.downloading")}</span>
{:else}
<span class="badge">{t("settings.hardware.package_needed")}</span>
{/if}
</div>
{#if !dmlReady}
<p class="muted">
{t("settings.hardware.dml_hint", {
extra: dml.runtimeReady ? "" : t("settings.hardware.plus_directml"),
})}
</p>
<button onclick={downloadDirectmlPackage} disabled={dmlBusy}>
{dmlBusy
? t("settings.hardware.downloading")
: t("settings.hardware.download_dml")}
</button>
{/if}
{#if dmlMsg}<p class="muted npu-status">{dmlMsg}</p>{/if}
</div>
{/if}
{:else}
<p class="muted">{t("settings.hardware.detection_unavailable")}</p>
{/if}
<label class="row">
<input
type="checkbox"
checked={settings.settings.low_overhead}
onchange={(e) => settings.setLowOverhead((e.target as HTMLInputElement).checked)}
/>
<span
>{t("settings.hardware.low_overhead")}
<em>({t("settings.hardware.low_overhead_note")})</em></span
>
</label>
<h4>{t("settings.hardware.models")}</h4>
<ul class="models">
{#each settings.models as m (m.id)}
{@const downloading = m.id in settings.downloadProgress}
{@const pct = progressPct(m.id)}
<li class="model" class:active={m.active}>
<div class="model-info">
<span class="name">{m.label}</span>
<span class="meta">
{fmtBytes(m.size_mb)}
{#if m.active}
<span class="badge ok"
><Check size={11} aria-hidden="true" />
{t("settings.hardware.active_badge")}</span
>
{:else if m.installed}
<span class="badge">{t("settings.hardware.installed")}</span>
{/if}
</span>
{#if downloading}
<div
class="progress"
role="progressbar"
aria-label={t("settings.hardware.downloading_model", { name: m.label })}
aria-valuenow={pct ?? undefined}
aria-valuemin={0}
aria-valuemax={100}
>
<div
class="progress-fill"
class:indeterminate={pct === null}
style={pct !== null ? `width: ${pct}%` : ""}
></div>
</div>
<span class="muted progress-label"
>{t("settings.hardware.downloading")} {pct !== null ? `${pct}%` : ""}</span
>
{/if}
</div>
<div class="model-actions">
{#if downloading}
<!-- no actions while a download is in flight -->
{:else if m.installed}
<button
class="icon-btn"
disabled={m.active}
onclick={() => settings.setActiveModel(m.id)}
>
<Check size={13} aria-hidden="true" />
{t("settings.hardware.use")}
</button>
<button
class="icon-btn danger"
disabled={m.active}
title={m.active
? t("settings.hardware.cant_remove_active")
: t("settings.hardware.remove")}
onclick={() => settings.removeModel(m.id)}
>
<Trash2 size={13} aria-hidden="true" />
{t("settings.hardware.remove")}
</button>
{:else}
<button class="icon-btn" onclick={() => settings.downloadModel(m.id)}>
<Download size={13} aria-hidden="true" />
{t("settings.hardware.download")}
</button>
{/if}
</div>
</li>
{/each}
</ul>
<h4>{t("settings.hardware.diarization")}</h4>
<p class="muted">{t("settings.hardware.diarization_hint")}</p>
<ul class="models">
{#each settings.diarizationModels as m (m.id)}
{@const downloading = m.id in settings.downloadProgress}
{@const pct = progressPct(m.id)}
<li class="model" class:active={m.installed}>
<div class="model-info">
<span class="name">{m.label}</span>
<span class="meta">
{fmtBytes(m.size_mb)}
{#if m.installed}
<span class="badge ok"
><Check size={11} aria-hidden="true" />
{t("settings.hardware.installed")}</span
>
{/if}
</span>
{#if downloading}
<div
class="progress"
role="progressbar"
aria-label={t("settings.hardware.downloading_model", { name: m.label })}
aria-valuenow={pct ?? undefined}
aria-valuemin={0}
aria-valuemax={100}
>
<div
class="progress-fill"
class:indeterminate={pct === null}
style={pct !== null ? `width: ${pct}%` : ""}
></div>
</div>
<span class="muted progress-label"
>{t("settings.hardware.downloading")} {pct !== null ? `${pct}%` : ""}</span
>
{/if}
</div>
<div class="model-actions">
{#if downloading}
<!-- no actions while a download is in flight -->
{:else if m.installed}
<button
class="icon-btn danger"
title={t("settings.hardware.remove")}
onclick={() => settings.removeDiarizationModel(m.id)}
>
<Trash2 size={13} aria-hidden="true" />
{t("settings.hardware.remove")}
</button>
{:else}
<button class="icon-btn" onclick={() => settings.downloadDiarizationModel(m.id)}>
<Download size={13} aria-hidden="true" />
{t("settings.hardware.download")}
</button>
{/if}
</div>
</li>
{/each}
</ul>
<h4>{t("settings.transcription.title")}</h4>
{#if activeModel?.multilingual}
<label
>{t("settings.transcription.label")}
<select
value={settings.settings.whisper_language ?? ""}
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
settings.setWhisperLanguage(v || null);
}}
>
<option value="">{t("settings.transcription.auto")}</option>
{#each settings.languages as l (l.code)}
<option value={l.code}>{l.label}</option>
{/each}
</select>
</label>
<p class="muted">{t("settings.transcription.applies_hint")}</p>
{:else}
<label
>{t("settings.transcription.label")}
<select disabled aria-describedby="language-disabled-hint">
<option>{t("settings.transcription.english_only")}</option>
</select>
</label>
<p class="muted" id="language-disabled-hint">
{activeModel
? t("settings.transcription.model_english_only", { model: activeModel.label })
: t("settings.transcription.no_model")}
{t("settings.transcription.switch_multilingual")}
</p>
{/if}
</section>
{:else if section === "storage"}
<section>
<h3>{t("settings.storage.title")}</h3>
<div class="row">
{t("settings.storage.location_label")}<code>{settings.settings.storage_root}</code>
</div>
<p class="muted">{t("settings.storage.location_hint")}</p>
<h4>{t("settings.storage.retention_title")}</h4>
<p class="muted">{t("settings.storage.retention_hint")}</p>
<div class="grid">
<label
>{t("settings.storage.max_age")}<input
type="number"
min="1"
placeholder={t("settings.storage.no_limit")}
bind:value={maxAgeInput}
onchange={saveRetention}
/></label
>
<label
>{t("settings.storage.max_size")}<input
type="number"
min="1"
placeholder={t("settings.storage.no_limit")}
bind:value={maxSizeInput}
onchange={saveRetention}
/></label
>
</div>
<h4>{t("settings.storage.export_import_title")}</h4>
<p class="muted">
{t("settings.storage.export_hint_1")}
<code>meeting.json</code>
{t("settings.storage.export_hint_2")}
</p>
<div class="row btns">
<button onclick={exportAllMeetings} disabled={exportBusy}>
<FolderOutput size={14} aria-hidden="true" />
{exportBusy ? t("settings.storage.exporting") : t("settings.storage.export_all")}
</button>
<button onclick={importMeetings} disabled={importBusy}>
<FolderInput size={14} aria-hidden="true" />
{importBusy ? t("settings.storage.importing") : t("settings.storage.import_all")}
</button>
</div>
{#if ioStatus}
<p class={ioError ? "error" : "muted"}>{ioStatus}</p>
{/if}
</section>
{:else if section === "calendar"}
<section>
<h3>{t("settings.calendar.title")}</h3>
<p class="muted">
{t("settings.calendar.intro_1")} <code>.pst</code>
{t("settings.calendar.intro_2")}
</p>
<div class="tools">
<span class="muted small">{t("settings.calendar.requires")}</span>
<button class="tool" type="button" onclick={() => api.openUrl(LIBPST_URL)}>
<Download size={12} aria-hidden="true" /> libpst (readpst)
</button>
</div>
<div class="grid">
<label class="wide"
>{t("settings.calendar.pst_file")}
<div class="row">
<input
class="grow"
bind:value={pstPath}
placeholder={t("settings.calendar.no_file")}
readonly
/>
<button onclick={pickPstFile}>{t("settings.calendar.browse")}</button>
</div>
</label>
<label
>{t("settings.calendar.password")} <em>({t("settings.calendar.password_note")})</em>
<input
type="password"
bind:value={pstPassword}
placeholder={t("settings.calendar.optional")}
/>
</label>
<label
>{t("settings.calendar.import_range")}
<select value={importRange} onchange={onImportRangeChange}>
<option value="30">{t("settings.calendar.range_30")}</option>
<option value="90">{t("settings.calendar.range_90")}</option>
<option value="180">{t("settings.calendar.range_180")}</option>
<option value="365">{t("settings.calendar.range_365")}</option>
<option value="all">{t("settings.calendar.range_all")}</option>
</select>
</label>
</div>
<p class="muted small">{t("settings.calendar.range_hint")}</p>
<div class="actions">
<button class="primary" onclick={doImportPst} disabled={!pstPath || calendar.importing}>
{calendar.importing ? t("settings.calendar.importing") : t("settings.calendar.import")}
</button>
{#if calendar.importProgress}
<span class="muted"
>{t("settings.calendar.import_progress", {
processed: calendar.importProgress.processed,
total: calendar.importProgress.total,
})}</span
>
{/if}
</div>
<label class="row">
<input
type="checkbox"
checked={settings.settings.pst_auto_sync}
onchange={onToggleAutoSync}
/>
<span>{t("settings.calendar.auto_reimport")}</span>
</label>
<p class="muted small">{t("settings.calendar.auto_reimport_hint")}</p>
{#if calendar.importError}
<p class="error">
{t("settings.calendar.import_failed", { error: calendar.importError })}
</p>
{/if}
<h4>{t("settings.calendar.cleanup_title")}</h4>
<p class="muted small">{t("settings.calendar.cleanup_hint")}</p>
<div class="actions">
<select bind:value={cleanupThreshold} disabled={cleanupRunning}>
<option value="30">{t("settings.calendar.cleanup_30")}</option>
<option value="90">{t("settings.calendar.cleanup_90")}</option>
<option value="180">{t("settings.calendar.cleanup_180")}</option>
<option value="365">{t("settings.calendar.cleanup_365")}</option>
<option value="all">{t("settings.calendar.cleanup_all")}</option>
</select>
<button
class="icon-btn danger"
onclick={runCleanup}
disabled={cleanupRunning || calendar.events.length === 0}
>
<Trash2 size={13} aria-hidden="true" />
{cleanupRunning ? t("settings.calendar.cleaning") : t("settings.calendar.cleanup_btn")}
</button>
</div>
{#if cleanupResult}
<p class="muted small">
{t("settings.calendar.cleanup_result", {
deleted: cleanupResult.deleted,
extra: cleanupResult.protected
? t("settings.calendar.cleanup_kept", { n: cleanupResult.protected })
: "",
})}
</p>
{/if}
{#if cleanupError}
<p class="error">{t("settings.calendar.cleanup_failed", { error: cleanupError })}</p>
{/if}
<h4>{t("settings.calendar.imported_events")}</h4>
{#if calendar.events.length === 0}
<p class="muted">{t("settings.calendar.no_events")}</p>
{:else}
<div class="grid">
<label class="wide"
>{t("settings.calendar.search_title")}
<input
type="text"
bind:value={eventTitleFilter}
placeholder={t("settings.calendar.search_placeholder")}
/>
</label>
<label
>{t("settings.calendar.date_label")}
<input type="date" bind:value={eventDateFilter} />
</label>
</div>
<p class="muted">
{t("settings.calendar.events_count", {
shown: filteredEvents.length,
total: calendar.events.length,
})}
</p>
<ul class="events">
{#each filteredEvents as ev (ev.id)}
<li>
<span class="name">{ev.subject ?? t("settings.calendar.untitled")}</span>
<span class="muted">{formatEventDate(ev.starts_at)}</span>
{#if ev.organizer}<span class="muted">· {ev.organizer}</span>{/if}
</li>
{/each}
</ul>
{/if}
</section>
{:else if section === "sync"}
<section>
<h3>{t("settings.sync.title")}</h3>
<label class="row">
<input
type="checkbox"
checked={settings.settings.sync_enabled}
onchange={(e) => settings.setSyncEnabled((e.target as HTMLInputElement).checked)}
/>
<span>{t("settings.sync.enable_label")}</span>
</label>
<p class="muted">{t("settings.sync.enable_hint")}</p>
<h4>{t("settings.sync.targets_title")}</h4>
{#if settings.targets.length === 0}
<p class="muted">{t("settings.sync.no_targets")}</p>
{:else}
<ul class="targets">
{#each settings.targets as target (target.id)}
<li>
<label class="tg">
<input
type="checkbox"
checked={target.enabled}
onchange={() => settings.toggleTargetEnabled(target)}
/>
<span class="name">{target.name || target.kind}</span>
</label>
<span class="badge" class:third={target.third_party}
>{target.third_party
? t("settings.sync.third_party")
: t("settings.sync.your_server")}</span
>
<span class="host">{target.host ?? target.kind}</span>
{#if !target.third_party}
<button class="link" onclick={() => startEdit(target)}
>{t("settings.sync.edit")}</button
>
{/if}
<button class="link danger" onclick={() => settings.removeTarget(target.id)}
>{t("settings.sync.remove")}</button
>
</li>
{/each}
</ul>
{/if}
<h4>{editingId ? t("settings.sync.edit_target") : t("settings.sync.add_target")}</h4>
{#if !editingId}
<div class="kinds">
{#each ["webdav", "onedrive", "dropbox", "box"] as k (k)}
<button class:active={form.kind === k} onclick={() => setKind(k as SyncKind)}
>{k}</button
>
{/each}
</div>
{/if}
{#if form.kind === "webdav"}
<p class="muted">{t("settings.sync.webdav_hint")}</p>
<div class="grid">
<label
>{t("settings.sync.name")}<input
bind:value={form.name}
placeholder={t("settings.sync.name_placeholder")}
/></label
>
<label
>{t("settings.sync.provider")}
<select bind:value={form.provider_hint}>
{#each PROVIDERS as p (p)}<option value={p}>{p}</option>{/each}
</select>
</label>
<label class="wide"
>{t("settings.sync.server_url")}<input
bind:value={form.base_url}
placeholder={davAutoPath
? "https://cloud.example.com"
: "https://host/remote.php/dav/… (or /seafdav, /dav)"}
/>
{#if davAutoPath}
<small class="muted"
>{t("settings.sync.server_url_note_1")}
<code>/remote.php/dav/files/&lt;username&gt;/</code>
{t("settings.sync.server_url_note_2")}</small
>
{/if}
</label>
<label
>{t("settings.sync.remote_folder")}<input bind:value={form.remote_base_path} /></label
>
<label>{t("settings.sync.username")}<input bind:value={form.username} /></label>
<label
>{t("settings.sync.app_password")}<input
type="password"
bind:value={form.secret}
placeholder={editingId ? t("settings.sync.pw_keep") : t("settings.sync.pw_store")}
/></label
>
</div>
<fieldset class="artifacts">
<legend>{t("settings.sync.upload_legend")}</legend>
<label
><input type="checkbox" bind:checked={form.upload_transcript} />
{t("settings.sync.artifact_transcript")}</label
>
<label
><input type="checkbox" bind:checked={form.upload_notes} />
{t("settings.sync.artifact_notes")}</label
>
<label
><input type="checkbox" bind:checked={form.upload_summary} />
{t("settings.sync.artifact_summary")}</label
>
<label
><input type="checkbox" bind:checked={form.upload_recording} />
{t("settings.sync.artifact_recording")}</label
>
</fieldset>
<label class="row small"
><input type="checkbox" bind:checked={form.allow_plaintext_lan} />
{t("settings.sync.allow_plaintext")}</label
>
<label class="row small">
<input
type="checkbox"
bind:checked={form.encrypt_before_upload}
disabled={!vault?.unlocked}
/>
{t("settings.sync.encrypt_before")}
{#if !vault?.unlocked}<span class="muted">{t("settings.sync.requires_vault")}</span
>{/if}
</label>
<div class="actions">
<button onclick={testConnection}>{t("settings.sync.test_connection")}</button>
<button class="primary" onclick={saveTarget} disabled={!form.name || !form.base_url}
>{editingId
? t("settings.sync.save_changes")
: t("settings.sync.add_target_btn")}</button
>
{#if editingId}
<button class="link" onclick={cancelEdit}>{t("settings.sync.cancel")}</button>
{/if}
{#if testResult}
<span class="test" class:ok={testResult.ok} class:fail={!testResult.ok}>
{#if testResult.ok}<Check size={14} aria-hidden="true" />{:else}<X
size={14}
aria-hidden="true"
/>{/if}
{testResult.message}
</span>
{/if}
</div>
{:else}
<div class="banner">
<AlertTriangle size={14} aria-hidden="true" />
{t("settings.sync.third_party_banner", { kind: form.kind })}
</div>
<div class="grid">
<label class="wide"
>{t("settings.sync.name")}<input
bind:value={form.name}
placeholder={form.kind}
/></label
>
</div>
<div class="actions">
<button class="primary" onclick={linkOAuth}
>{t("settings.sync.link_account", { kind: form.kind })}</button
>
<span class="muted">{t("settings.sync.oauth_hint")}</span>
</div>
{#if settings.linkMessage}
<p class="muted">{settings.linkMessage}</p>
{/if}
{/if}
<p class="muted footnote">{t("settings.sync.footnote")}</p>
</section>
{:else if section === "ai"}
<section>
<h3>{t("settings.ai.title")}</h3>
<p class="muted">
{t("settings.ai.intro_1")} <code>192.168.0.x</code>) {t("settings.ai.intro_2")}
</p>
<label
>{t("settings.ai.provider")}
<select bind:value={llmProvider}>
<option value="off">{t("settings.ai.off")}</option>
<option value="ollama">{t("settings.ai.provider_ollama")}</option>
<option value="custom">{t("settings.ai.provider_custom")}</option>
<option value="anthropic">{t("settings.ai.provider_anthropic")}</option>
</select>
</label>
{#if llmProvider !== "off"}
<div class="grid">
{#if llmProvider !== "anthropic"}
<label class="wide"
>{t("settings.ai.endpoint")}<input
bind:value={llmEndpoint}
placeholder="http://192.168.0.42:11434"
/></label
>
<label
>{t("settings.ai.model")}<input
bind:value={llmModel}
placeholder="llama3.1"
/></label
>
{:else}
<label class="wide"
>{t("settings.ai.model")}<input
bind:value={llmModel}
placeholder="claude-3-5-sonnet-latest"
/></label
>
<label class="wide"
>{t("settings.ai.api_key")}
<div class="key-input">
<input
type={showApiKey ? "text" : "password"}
autocomplete="off"
bind:value={llmApiKey}
placeholder={settings.llmStatus?.provider === "anthropic"
? t("settings.ai.api_key_set_placeholder")
: "sk-ant-…"}
/>
<button
type="button"
class="icon-toggle"
onclick={() => (showApiKey = !showApiKey)}
aria-label={showApiKey
? t("settings.ai.hide_api_key")
: t("settings.ai.show_api_key")}
>
{#if showApiKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
size={14}
aria-hidden="true"
/>{/if}
</button>
</div>
</label>
{/if}
</div>
{#if llmProvider === "anthropic"}
<div class="banner hosted">
<Globe size={14} aria-hidden="true" />
{t("settings.ai.anthropic_banner")}
</div>
{:else if llmEndpoint && !endpointIsLocalOrLan(llmEndpoint)}
<div class="banner">
<AlertTriangle size={14} aria-hidden="true" />
{t("settings.ai.endpoint_banner")}
</div>
{/if}
<div class="actions">
<button class="primary" onclick={onSaveLlmClick} disabled={settings.llmSaving}>
{settings.llmSaving ? t("settings.ai.saving") : t("settings.ai.save")}
</button>
<button class="ghost" onclick={() => settings.loadLlmStatus()}>
<RefreshCw size={14} aria-hidden="true" />
{t("settings.ai.test_connection")}
</button>
</div>
{#if showHostedBanner}
<HostedAiBanner
providerLabel={HOSTED_PROVIDER_LABELS[llmProvider] ??
t("settings.ai.this_hosted_provider")}
onAccept={acceptHostedBannerAndSave}
onCancel={() => (showHostedBanner = false)}
/>
{/if}
{#if settings.llmStatus}
{@const s = settings.llmStatus}
<div class="status-card">
<span class="status-dot" class:ok={s.reachable} class:bad={!s.reachable}></span>
<span class="status-text"
>{s.reachable ? t("settings.ai.reachable") : t("settings.ai.unreachable")}</span
>
{#if s.reachable}
<span class="muted"
>{s.models.length === 1
? t("settings.ai.model_count_one", { n: s.models.length })
: t("settings.ai.model_count_many", { n: s.models.length })}</span
>
<span class="muted" class:warn={!s.isLocal}
>{s.isLocal ? t("settings.ai.on_lan") : t("settings.ai.leaves_network")}</span
>
{/if}
</div>
{#if s.reachable && s.models.length}
<p class="muted small">
{s.models.slice(0, 6).join(", ")}{s.models.length > 6 ? " …" : ""}
</p>
{/if}
{/if}
{#if llmProvider === "ollama"}
{#snippet paramRow(opt: OllamaOption)}
{@const disabled = opt.needsMirostat && mirostatOff()}
<div class="param" class:param-disabled={disabled}>
<div class="param-label">
<span class="param-name">{opt.label}</span>
<span class="param-help"
>{opt.help}{#if opt.default !== undefined}
{t("settings.ai.default_value", { value: String(opt.default) })}{/if}</span
>
</div>
<div class="param-control">
{#if opt.kind === "bool"}
<input
type="checkbox"
checked={(advOptions[opt.key] ?? opt.default) === true}
{disabled}
onchange={(e) => setBool(opt, e.currentTarget.checked)}
/>
{:else if opt.kind === "enum"}
<select
value={optDisplay(opt)}
onchange={(e) => setEnum(opt, e.currentTarget.value)}
>
{#each opt.enumOptions ?? [] as o (o.value)}
<option value={String(o.value)}>{o.label}</option>
{/each}
</select>
{:else if opt.kind === "tags"}
<input
value={optDisplay(opt)}
placeholder="e.g. User:, \n"
onchange={(e) => setTags(opt, e.currentTarget.value)}
/>
{:else}
<input
type="number"
value={optDisplay(opt)}
min={opt.min}
max={opt.max}
step={opt.step}
{disabled}
onchange={(e) => setNumber(opt, e.currentTarget.value)}
/>
{/if}
<button
class="reset-field"
class:hidden={!isOverridden(opt)}
title={t("settings.ai.reset_default")}
aria-label={t("settings.ai.reset_field", { name: opt.label })}
onclick={() => commit(opt.key, undefined)}
>
<RotateCcw size={13} aria-hidden="true" />
</button>
</div>
</div>
{/snippet}
<details class="advanced">
<summary>
<ChevronRight size={15} aria-hidden="true" class="chevron" />
<span>{t("settings.ai.advanced_title")}</span>
{#if advChangedCount}<span class="changed-badge"
>{t("settings.ai.changed", { n: advChangedCount })}</span
>{/if}
</summary>
<div class="adv-body">
{#if advChangedCount}
<button class="link reset-all" onclick={resetAllAdvanced}
>{t("settings.ai.reset_all")}</button
>
{/if}
<label class="wide adv-system"
>{t("settings.ai.system_prompt")}
<textarea
rows="3"
bind:value={advSystem}
placeholder={t("settings.ai.system_prompt_placeholder")}
></textarea>
</label>
<p class="muted small">{t("settings.ai.system_prompt_hint")}</p>
{#each OPTION_GROUPS as g (g)}
<fieldset class="adv-group">
<legend>{OLLAMA_GROUP_LABELS[g]}</legend>
{#if g === "context"}
<div class="param">
<div class="param-label">
<span class="param-name">{t("settings.ai.think")}</span>
<span class="param-help">{t("settings.ai.think_help")}</span>
</div>
<div class="param-control">
<select bind:value={advThink}>
{#each THINK_OPTIONS as th (th)}<option value={th}>{th}</option>{/each}
</select>
</div>
</div>
<div class="param">
<div class="param-label">
<span class="param-name">{t("settings.ai.keep_alive")}</span>
<span class="param-help">{t("settings.ai.keep_alive_help")}</span>
</div>
<div class="param-control">
<input bind:value={advKeepAlive} placeholder="5m" />
</div>
</div>
{/if}
{#each OLLAMA_OPTIONS.filter((o) => o.group === g) as opt (opt.key)}
{@render paramRow(opt)}
{/each}
</fieldset>
{/each}
<details class="adv-nested">
<summary>
<ChevronRight size={14} aria-hidden="true" class="chevron" />
<span>{t("settings.ai.runtime_hardware")}</span>
<span class="muted small">{t("settings.ai.rarely_needed")}</span>
</summary>
<fieldset class="adv-group">
{#each OLLAMA_OPTIONS.filter((o) => o.group === "hardware") as opt (opt.key)}
{@render paramRow(opt)}
{/each}
</fieldset>
</details>
<div class="actions">
<button class="primary" onclick={saveAdvanced} disabled={advSaving}>
{advSaving ? t("settings.ai.saving") : t("settings.ai.save_advanced")}
</button>
{#if advSaved}<span class="test ok"
><Check size={14} aria-hidden="true" /> {t("settings.ai.saved")}</span
>{/if}
</div>
</div>
</details>
{/if}
{:else}
<div class="actions">
<button class="primary" onclick={saveLlm} disabled={settings.llmSaving}
>{t("settings.ai.turn_off")}</button
>
</div>
{/if}
</section>
{:else if section === "mcp"}
<section>
<h3>{t("settings.mcp.title")}</h3>
<div class="banner">
<AlertTriangle size={14} aria-hidden="true" />
<span>
{t("settings.mcp.banner_1")} <strong>{t("settings.mcp.banner_agent")}</strong>
{t("settings.mcp.banner_2")}<code>127.0.0.1</code>{t("settings.mcp.banner_3")}
</span>
</div>
<label class="row">
<input
type="checkbox"
checked={settings.mcpStatus?.enabled ?? false}
disabled={settings.mcpSaving}
onchange={(e) => toggleMcpEnabled((e.target as HTMLInputElement).checked)}
/>
<span>{t("settings.mcp.enable")}</span>
</label>
<p class="muted">{t("settings.mcp.enable_hint")}</p>
<div class="grid">
<label
>{t("settings.mcp.transport")}
<select bind:value={mcpTransport} disabled={settings.mcpStatus?.enabled}>
<option value="http">{t("settings.mcp.transport_http")}</option>
<option value="stdio">{t("settings.mcp.transport_stdio")}</option>
</select>
</label>
{#if mcpTransport === "http"}
<label
>{t("settings.mcp.port")}<input
type="number"
min="1"
max="65535"
bind:value={mcpPort}
disabled={settings.mcpStatus?.enabled}
/></label
>
{/if}
</div>
{#if settings.mcpStatus?.enabled}
<p class="muted small">{t("settings.mcp.transport_change_hint")}</p>
{/if}
<h4>{t("settings.mcp.scope_title")}</h4>
<div class="grid">
<label
>{t("settings.mcp.expose")}
<select bind:value={mcpExpose} onchange={saveMcpScope}>
<option value="none">{t("settings.mcp.scope_none")}</option>
<option value="selected">{t("settings.mcp.scope_selected")}</option>
<option value="all">{t("settings.mcp.scope_all")}</option>
</select>
</label>
</div>
<label class="row small">
<input
type="checkbox"
bind:checked={mcpExposeRecordings}
disabled={mcpExpose === "none"}
onchange={saveMcpScope}
/>
<span>{t("settings.mcp.expose_recordings")}</span>
</label>
{#if settings.mcpLastToken}
<div class="mcp-token" aria-live="polite">
<div class="row">
<KeyRound size={14} aria-hidden="true" />
<strong>{t("settings.mcp.token_title")}</strong>
</div>
<p class="muted small">{t("settings.mcp.token_hint")}</p>
<div class="row">
<code>{settings.mcpLastToken}</code>
<button
class="link"
onclick={() => copyToClipboard(settings.mcpLastToken ?? "", "token")}
>
<Copy size={13} aria-hidden="true" />
{mcpCopied === "token" ? t("settings.mcp.copied") : t("settings.mcp.copy")}
</button>
</div>
</div>
{/if}
{#if settings.mcpStatus?.enabled}
<div class="row">
{t("settings.mcp.endpoint")}<code>{settings.mcpStatus.endpoint}</code>
<button
class="link"
onclick={() => copyToClipboard(settings.mcpStatus?.endpoint ?? "", "endpoint")}
>
<Copy size={13} aria-hidden="true" />
{mcpCopied === "endpoint" ? t("settings.mcp.copied") : t("settings.mcp.copy")}
</button>
</div>
<p class="muted small">
{t("settings.mcp.endpoint_hint", {
kind:
settings.mcpStatus.transport === "stdio"
? t("settings.mcp.kind_command")
: t("settings.mcp.kind_url"),
})}
</p>
{/if}
<h4>{t("settings.mcp.access_log")}</h4>
<p class="muted">{t("settings.mcp.access_log_hint")}</p>
{#if settings.mcpAccessLog.length === 0}
<p class="muted">{t("settings.mcp.no_reads")}</p>
{:else}
<ul class="events">
{#each settings.mcpAccessLog as entry, i (entry.at + "-" + i)}
<li>
<code>{entry.tool}</code>
{#if entry.meeting_id}<span class="host"
>{t("settings.mcp.log_meeting", { id: entry.meeting_id.slice(0, 8) })}</span
>{/if}
{#if entry.client}<span class="badge">{entry.client}</span>{/if}
<span class="host">{relativeTime(entry.at)}</span>
</li>
{/each}
</ul>
<button class="link" onclick={() => settings.loadMcpAccessLog()}
>{t("settings.mcp.refresh")}</button
>
{/if}
</section>
{:else if section === "privacy"}
<section>
<h3>{t("settings.privacy.title")}</h3>
<p class="muted">{t("settings.privacy.intro")}</p>
{#if settings.privacy}
<div class="row">
{t("settings.privacy.llm_endpoint")}<code
>{settings.privacy.llmEndpoint || t("settings.privacy.off")}</code
>
<span class="badge" class:third={!settings.privacy.llmIsLocal}>
{settings.privacy.llmIsLocal
? t("settings.privacy.local_only")
: t("settings.privacy.leaves_device")}
</span>
</div>
<div class="row">
{t("settings.privacy.sync_label")}<span
class="badge"
class:third={settings.privacy.syncEnabled}
>
{settings.privacy.syncEnabled
? t("settings.privacy.enabled")
: t("settings.privacy.off")}
</span>
</div>
<div class="row">
{t("settings.privacy.mcp_label")}<span
class="badge"
class:busy={settings.mcpStatus?.enabled}
>
{settings.mcpStatus?.enabled
? t("settings.privacy.mcp_on", { scope: settings.mcpStatus.exposeScope })
: t("settings.privacy.off")}
</span>
</div>
{#if settings.mcpStatus?.enabled}
<p class="muted confirm">
<Check size={14} aria-hidden="true" />
{t("settings.privacy.mcp_loopback_note")}
</p>
{/if}
<h4>{t("settings.privacy.egress_title")}</h4>
{#if settings.privacy.allowlistedHosts.length === 0}
<p class="muted confirm">
<Check size={14} aria-hidden="true" />
{t("settings.privacy.no_egress")}
</p>
{:else}
<ul class="hosts">
{#each settings.privacy.allowlistedHosts as host (host)}
<li><code>{host}</code></li>
{/each}
</ul>
{/if}
{#if settings.privacy.syncTargets.length > 0}
<h4>{t("settings.privacy.sync_targets_title")}</h4>
<ul class="targets">
{#each settings.privacy.syncTargets as target (target.name)}
<li>
<span class="name">{target.name}</span>
<span class="badge" class:third={target.thirdParty}
>{target.thirdParty
? t("settings.privacy.third_party")
: t("settings.privacy.your_server")}</span
>
<span class="host">{target.host}</span>
<span class="badge" class:third={!target.tls}
>{target.tls ? t("settings.privacy.tls") : t("settings.privacy.no_tls")}</span
>
</li>
{/each}
</ul>
{/if}
<button class="link" onclick={() => settings.loadPrivacy()}
>{t("settings.privacy.refresh")}</button
>
{:else}
<p class="muted">{t("settings.privacy.unavailable")}</p>
{/if}
{#if vault}
<div
class="vault-card"
class:locked={vault.enabled && !vault.unlocked}
class:unlocked={vault.enabled && vault.unlocked}
>
<div class="vault-head">
{#if !vault.enabled}
<ShieldCheck size={18} aria-hidden="true" />
{:else if !vault.unlocked}
<Lock size={18} aria-hidden="true" />
{:else}
<LockOpen size={18} aria-hidden="true" />
{/if}
<h4>{t("settings.privacy.vault_title")}</h4>
{#if vault.enabled}
<span class="badge" class:busy={!vault.unlocked}>
{vault.unlocked
? t("settings.privacy.unlocked_word")
: t("settings.privacy.locked_word")}
</span>
{/if}
</div>
{#if !vault.enabled}
<p class="muted">{t("settings.privacy.vault_intro")}</p>
<div class="pw-row">
<input
type={showVaultPw ? "text" : "password"}
bind:value={vaultPw}
placeholder={t("settings.privacy.vault_password")}
aria-label={t("settings.privacy.vault_password")}
/>
<button
type="button"
class="icon pw-toggle"
onclick={() => (showVaultPw = !showVaultPw)}
aria-label={t("settings.privacy.show_password")}
title={t("settings.privacy.show_password")}
>
{#if showVaultPw}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
size={14}
aria-hidden="true"
/>{/if}
</button>
</div>
<button class="primary" onclick={enableVault} disabled={vaultPw.length < 8}
>{t("settings.privacy.enable_vault")}</button
>
<p class="muted hint">{t("settings.privacy.vault_pw_hint")}</p>
{:else if !vault.unlocked}
<p class="muted">
{t("settings.privacy.vault_locked_1")}<strong
>{t("settings.privacy.locked_word")}</strong
>{t("settings.privacy.vault_locked_2")}
</p>
<div class="pw-row">
<input
type={showVaultPw ? "text" : "password"}
bind:value={vaultPw}
placeholder={t("settings.privacy.password")}
aria-label={t("settings.privacy.password")}
onkeydown={(e) => e.key === "Enter" && vaultPw && unlockVault()}
/>
<button
type="button"
class="icon pw-toggle"
onclick={() => (showVaultPw = !showVaultPw)}
aria-label={t("settings.privacy.show_password")}
title={t("settings.privacy.show_password")}
>
{#if showVaultPw}<EyeOff size={14} aria-hidden="true" />{:else}<Eye
size={14}
aria-hidden="true"
/>{/if}
</button>
</div>
<button class="primary" onclick={unlockVault} disabled={!vaultPw}
>{t("settings.privacy.unlock")}</button
>
{:else}
<p class="muted">
{t("settings.privacy.vault_unlocked_1")}<strong
>{t("settings.privacy.unlocked_word")}</strong
>{t("settings.privacy.vault_unlocked_2")}
</p>
<button onclick={lockVault}>
<Lock size={14} aria-hidden="true" />
{t("settings.privacy.lock_now")}
</button>
<details>
<summary>{t("settings.privacy.change_password")}</summary>
<div class="grid">
<label class="wide"
>{t("settings.privacy.current_password")}<input
type="password"
bind:value={vaultPw}
/></label
>
<label class="wide"
>{t("settings.privacy.new_password")}<input
type="password"
bind:value={vaultPw2}
/></label
>
</div>
<button onclick={changeVaultPassword} disabled={!vaultPw || vaultPw2.length < 8}
>{t("settings.privacy.change_password")}</button
>
</details>
{/if}
{#if vaultMsg}<p class="vault-msg" class:error={vaultMsgError}>{vaultMsg}</p>{/if}
</div>
{/if}
</section>
{:else if section === "language"}
<section>
<h3>{t("settings.language.title")}</h3>
<label
>{t("settings.language.display")}
<select
value={i18n.locale}
onchange={(e) => i18n.setLocale((e.target as HTMLSelectElement).value)}
>
{#each LOCALES as l (l.code)}
<option value={l.code}>{l.label}</option>
{/each}
</select>
</label>
<p class="muted">{t("settings.language.display_hint")}</p>
</section>
{:else if section === "about"}
<section>
<h3>{t("settings.about.title")}</h3>
<p class="about-name">
WhispAssist <span class="about-ver">v{appInfo?.version ?? "…"}</span>
</p>
<p class="muted">{t("settings.about.tagline")}</p>
<div class="row">
{t("settings.about.build_commit")}<code>{appInfo?.commit ?? "…"}</code>
</div>
<p>
<button class="link source" onclick={() => api.openUrl(SOURCE_URL)}>
{SOURCE_URL} ↗
</button>
</p>
</section>
{/if}
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.5);
display: grid;
place-items: center;
z-index: 50;
}
.panel {
background: var(--bg-elevated);
color: var(--fg);
width: min(720px, 92vw);
max-height: 88vh;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 0 1.25rem 1.25rem;
}
header {
position: sticky;
top: 0;
background: var(--bg-elevated);
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 0.9rem 0;
border-bottom: 1px solid var(--border);
}
/* nav takes the middle and wraps its tabs onto a second row instead of
overflowing the fixed-width panel — that overflow was pushing the close
button off the panel edge and adding a horizontal scrollbar. */
nav {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-wrap: wrap;
gap: 0.25rem;
}
nav button,
.kinds button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: transparent;
border: 1px solid var(--border);
color: var(--fg);
padding: 0.35rem 0.7rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
cursor: pointer;
transition: background-color 120ms ease-out;
}
nav button:hover,
.kinds button:hover {
background: var(--bg-hover);
}
nav button.active,
.kinds button.active {
background: var(--accent);
color: var(--accent-fg);
border-color: transparent;
}
.close {
display: grid;
place-items: center;
flex: 0 0 auto;
margin-left: auto;
width: 30px;
height: 30px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--muted);
cursor: pointer;
transition: background-color 120ms ease-out;
}
.close:hover {
background: var(--bg-hover);
color: var(--fg);
}
h3 {
margin: 1rem 0 0.5rem;
}
h4 {
margin: 1.25rem 0 0.4rem;
font-size: 0.9rem;
}
.row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.4rem 0;
}
.row.small {
font-size: 0.85rem;
color: var(--muted);
}
.muted {
color: var(--muted);
font-size: 0.85rem;
line-height: 1.5;
}
/* External-tool download chip — identical to the Import dialog's
ffmpeg/yt-dlp buttons (ImportMeeting.svelte). */
.tools {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.9rem;
}
.tool {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--bg-hover, transparent);
color: var(--accent);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.2rem 0.5rem;
font-size: 0.78rem;
cursor: pointer;
}
.tool:hover {
border-color: var(--accent);
}
.tools .small {
font-size: 0.8rem;
}
.footnote {
margin-top: 1rem;
border-top: 1px solid var(--border);
padding-top: 0.6rem;
}
em {
color: var(--muted);
font-style: normal;
}
code {
background: var(--border);
padding: 0 0.25rem;
border-radius: 3px;
}
.stub {
background: color-mix(in srgb, orange 18%, var(--bg));
border: 1px solid var(--border);
padding: 0.5rem 0.7rem;
border-radius: 6px;
font-size: 0.82rem;
}
.actions {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
margin-top: 0.6rem;
}
button.primary {
background: #2563eb;
color: #fff;
border: none;
padding: 0.4rem 0.8rem;
border-radius: 6px;
cursor: pointer;
}
button.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.kinds {
display: flex;
gap: 0.4rem;
margin: 0.5rem 0;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
margin: 0.5rem 0;
}
.grid label {
display: flex;
flex-direction: column;
font-size: 0.8rem;
gap: 0.2rem;
}
.grid label.wide {
grid-column: 1 / -1;
}
input,
select,
textarea {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 5px;
padding: 0.35rem;
}
input:disabled,
select:disabled,
textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.artifacts {
border: 1px solid var(--border);
border-radius: 6px;
display: flex;
gap: 1rem;
flex-wrap: wrap;
font-size: 0.85rem;
}
.artifacts label {
display: flex;
align-items: center;
gap: 0.3rem;
}
.banner {
display: flex;
align-items: flex-start;
gap: 0.4rem;
background: color-mix(in srgb, var(--warning) 14%, var(--bg));
border: 1px solid var(--border);
padding: 0.5rem 0.7rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
margin: 0.5rem 0;
}
.banner :global(svg) {
flex-shrink: 0;
margin-top: 0.15rem;
color: var(--warning);
}
/* Informational (not a warning-severity) variant for "this is hosted, by
your own choice" notices — Anthropic's fixed endpoint (ADR-0011). */
.banner.hosted {
background: color-mix(in srgb, var(--accent) 12%, var(--bg));
}
.banner.hosted :global(svg) {
color: var(--accent);
}
.key-input {
display: flex;
align-items: center;
gap: 0.3rem;
}
.key-input input {
flex: 1;
min-width: 0;
}
.icon-toggle {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--border);
border-radius: 5px;
padding: 0.35rem;
color: var(--muted);
cursor: pointer;
}
.icon-toggle:hover {
background: var(--bg-hover);
color: var(--fg);
}
ul.targets,
ul.models,
ul.events {
list-style: none;
padding: 0;
margin: 0.3rem 0;
}
ul.events li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
ul.events .name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.error {
color: var(--danger);
font-size: 0.85rem;
margin: 0.4rem 0;
}
input.grow {
flex: 1;
}
/* Model download list (T3.7, FR-MODEL-1) — a card row per model, not a
bare text row: clearer active/installed state, and a real progress bar
for in-flight downloads instead of a lone percentage number. */
ul.models li.model {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.55rem 0.7rem;
margin-bottom: 0.4rem;
border: 1px solid var(--border);
border-left: 2px solid transparent;
border-radius: var(--radius-md, 8px);
font-size: 0.85rem;
}
ul.models li.model.active {
background: var(--accent-soft);
border-left-color: var(--accent);
}
.model-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
flex: 1;
}
.model-info .name {
font-weight: 600;
}
.model-info .meta {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--muted);
font-size: 0.78rem;
}
.model-actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex: none;
}
.icon-btn {
display: flex;
align-items: center;
gap: 0.3rem;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm, 5px);
padding: 0.3rem 0.55rem;
font-size: 0.78rem;
color: var(--fg);
cursor: pointer;
transition: background-color 120ms ease-out;
}
.icon-btn:hover:not(:disabled) {
background: var(--bg-hover);
}
.icon-btn:disabled {
opacity: 0.5;
cursor: default;
}
.icon-btn.danger {
color: var(--danger);
border-color: var(--danger);
}
.icon-btn.danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--danger) 12%, transparent);
}
/* Real progress bar — a lone "NN%" string doesn't read as motion/progress
at a glance the way a filling bar does (ux: loading-states). */
.progress {
height: 6px;
border-radius: var(--radius-full, 999px);
background: var(--border);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: inherit;
transition: width 200ms ease-out;
}
/* Unknown total (no content-length yet) — an animated indeterminate sweep
instead of a bar stuck at 0%, so it still reads as "in progress". */
.progress-fill.indeterminate {
width: 40%;
animation: progress-sweep 1.1s ease-in-out infinite;
}
@keyframes progress-sweep {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(250%);
}
}
@media (prefers-reduced-motion: reduce) {
.progress-fill.indeterminate {
animation: none;
width: 100%;
opacity: 0.5;
}
}
.progress-label {
font-size: 0.75rem;
}
ul.targets li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
}
.tg {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1;
}
.badge {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border: 1px solid var(--border);
border-radius: 10px;
color: var(--muted);
}
.badge.third {
color: var(--danger);
}
.badge.ok {
color: var(--success, #16a34a);
border-color: currentColor;
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.badge.busy {
color: var(--accent, #2563eb);
border-color: currentColor;
}
.vault-card {
margin-top: 0.6rem;
padding: 0.85rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius-md, 8px);
background: var(--bg-elevated);
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.vault-card.locked {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.vault-card.unlocked {
border-color: color-mix(in srgb, var(--success, #16a34a) 45%, var(--border));
}
.vault-head {
display: flex;
align-items: center;
gap: 0.5rem;
}
.vault-head h4 {
margin: 0;
}
.vault-head :global(svg) {
color: var(--muted);
}
.vault-card.locked .vault-head :global(svg) {
color: var(--accent);
}
.vault-card.unlocked .vault-head :global(svg) {
color: var(--success, #16a34a);
}
.vault-head .badge {
margin-left: auto;
}
.pw-row {
display: flex;
align-items: center;
gap: 0.4rem;
max-width: 22rem;
}
.pw-row input {
flex: 1;
}
.pw-toggle {
flex: none;
}
.vault-card .hint {
margin: 0;
}
.vault-msg {
margin: 0;
font-size: 0.85rem;
color: var(--success, #16a34a);
}
.vault-msg.error {
color: var(--danger);
}
.device-test {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0.35rem 0 0.6rem;
}
.device-test :global(.meter) {
flex: 1;
min-width: 8rem;
}
.stress-rec {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.6rem 0;
padding: 0.55rem 0.75rem;
border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border));
border-radius: var(--radius-md, 8px);
background: color-mix(in srgb, var(--accent) 10%, var(--bg));
}
.stress-rec :global(svg) {
color: var(--accent);
}
.stress-rec span {
flex: 1;
}
.stress-table {
width: 100%;
border-collapse: collapse;
margin-top: 0.5rem;
font-size: 0.82rem;
}
.stress-table th,
.stress-table td {
text-align: left;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--border);
}
.stress-table th {
color: var(--muted);
font-weight: 600;
}
.stress-table .num {
font-variant-numeric: tabular-nums;
text-align: right;
}
.err {
color: var(--danger);
}
.npu-package {
margin-top: 0.6rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.npu-package .row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.npu-status {
font-variant-numeric: tabular-nums;
}
.host {
font-size: 0.75rem;
color: var(--muted);
}
.link {
background: none;
border: none;
cursor: pointer;
color: var(--muted);
}
.link.danger {
color: var(--danger);
}
.about-name {
font-size: 1.05rem;
font-weight: 600;
margin: 0.5rem 0 0.25rem;
}
.about-ver {
color: var(--muted);
font-weight: 500;
}
/* The source link reads as a link (accent + underline), unlike the muted
.link buttons used for secondary actions elsewhere. */
.link.source {
color: var(--accent);
text-decoration: underline;
padding: 0;
font: inherit;
}
.test {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.82rem;
color: var(--muted);
}
.test.ok {
color: var(--success);
}
.test.fail {
color: var(--danger);
}
.confirm {
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--success);
}
ul.hosts {
list-style: none;
padding: 0;
margin: 0.3rem 0;
font-size: 0.85rem;
}
ul.hosts li {
padding: 0.2rem 0;
}
.mcp-token {
margin: 0.6rem 0;
padding: 0.6rem 0.75rem;
border: 1px solid var(--warning, #d97706);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.mcp-token code {
word-break: break-all;
}
/* ---- AI provider: status + advanced config ---- */
.ghost {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: transparent;
border: 1px solid var(--border);
color: var(--fg, inherit);
}
.ghost:hover {
background: var(--hover, rgba(127, 127, 127, 0.08));
}
.status-card {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.6rem;
padding: 0.5rem 0.7rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.85rem;
}
.status-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--muted);
flex: none;
}
.status-dot.ok {
background: var(--success, #16a34a);
}
.status-dot.bad {
background: var(--danger, #dc2626);
}
.status-text {
font-weight: 600;
}
.muted.warn {
color: var(--danger, #dc2626);
}
.advanced,
.adv-nested {
margin-top: 0.9rem;
border: 1px solid var(--border);
border-radius: 8px;
}
.adv-nested {
margin-top: 0.6rem;
}
.advanced > summary,
.adv-nested > summary {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.6rem 0.75rem;
cursor: pointer;
list-style: none;
font-weight: 600;
user-select: none;
}
.advanced > summary::-webkit-details-marker,
.adv-nested > summary::-webkit-details-marker {
display: none;
}
.advanced :global(.chevron),
.adv-nested :global(.chevron) {
transition: transform 0.18s ease;
flex: none;
color: var(--muted);
}
.advanced[open] > summary :global(.chevron),
.adv-nested[open] > summary :global(.chevron) {
transform: rotate(90deg);
}
@media (prefers-reduced-motion: reduce) {
.advanced :global(.chevron),
.adv-nested :global(.chevron) {
transition: none;
}
}
.changed-badge {
margin-left: auto;
font-size: 0.7rem;
font-weight: 500;
padding: 0.1rem 0.45rem;
border-radius: 10px;
background: var(--accent, #2563eb);
color: var(--accent-fg, #fff);
}
.adv-body {
padding: 0 0.75rem 0.85rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.reset-all {
align-self: flex-end;
font-size: 0.78rem;
}
.adv-system textarea {
width: 100%;
resize: vertical;
font-family: inherit;
font-size: 0.85rem;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border-strong, var(--border));
border-radius: 6px;
}
.adv-group {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.4rem 0.75rem 0.6rem;
margin: 0;
display: flex;
flex-direction: column;
}
.adv-group legend {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
padding: 0 0.35rem;
}
.param {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--border);
}
.param:last-child {
border-bottom: none;
}
.param-label {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
flex: 1;
}
.param-name {
font-size: 0.85rem;
font-weight: 500;
}
.param-help {
font-size: 0.74rem;
color: var(--muted);
line-height: 1.35;
}
.param-control {
display: flex;
align-items: center;
gap: 0.3rem;
flex: none;
}
.param-control input[type="number"],
.param-control select {
width: 6.5rem;
text-align: right;
font-variant-numeric: tabular-nums;
padding: 0.3rem 0.4rem;
border: 1px solid var(--border-strong, var(--border));
border-radius: 6px;
}
.param-control select {
text-align: left;
}
.param-control input:not([type="number"]):not([type="checkbox"]) {
width: 9rem;
padding: 0.3rem 0.4rem;
border: 1px solid var(--border-strong, var(--border));
border-radius: 6px;
}
.param-disabled {
opacity: 0.45;
}
.reset-field {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
padding: 0;
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
border-radius: 5px;
}
.reset-field:hover {
background: var(--hover, rgba(127, 127, 127, 0.1));
color: var(--fg, inherit);
}
.reset-field.hidden {
visibility: hidden;
}
</style>