3133 lines
104 KiB
Svelte
3133 lines
104 KiB
Svelte
<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/<username>/</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>
|