feat(ui): AI settings section — pick provider, set LAN/local endpoint + model, test (FR-LLM-1)

This commit is contained in:
iamdoubz committed 2026-07-03 09:49:35 -05:00
1 parent df7ea29c67
commit 4fc271dd1a
1 file changed
+108 -3
+108 -3
View File
@@ -20,13 +20,53 @@
CalendarDays,
UploadCloud,
ShieldCheck,
Sparkles,
} from "@lucide/svelte";
let { onClose }: { onClose: () => void } = $props();
let section = $state<"recording" | "hardware" | "storage" | "calendar" | "sync" | "privacy">(
"recording",
);
let section = $state<
"recording" | "hardware" | "storage" | "calendar" | "sync" | "ai" | "privacy"
>("recording");
// ---- AI summary provider (T5.2, FR-LLM-1) ----
let llmProvider = $state(settings.settings.llm_provider);
let llmEndpoint = $state(settings.settings.llm_endpoint);
let llmModel = $state(settings.settings.llm_model);
$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;
});
async function saveLlm() {
await settings.setLlmProvider({
provider: llmProvider,
endpoint: llmEndpoint || undefined,
model: llmModel || undefined,
});
}
/** 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;
}
}
// ---- Calendar / .pst import (T6.1/T6.2/T6.3, FR-CAL-1/2) ----
let pstPath = $state("");
@@ -246,6 +286,9 @@
<button class:active={section === "sync"} onclick={() => (section = "sync")}>
<UploadCloud size={14} aria-hidden="true" /> Sync
</button>
<button class:active={section === "ai"} onclick={() => (section = "ai")}>
<Sparkles size={14} aria-hidden="true" /> AI
</button>
<button class:active={section === "privacy"} onclick={() => (section = "privacy")}>
<ShieldCheck size={14} aria-hidden="true" /> Privacy
</button>
@@ -620,6 +663,68 @@
TLS is required for non-LAN targets.
</p>
</section>
{:else if section === "ai"}
<section>
<h3>AI summary provider</h3>
<p class="muted">
Summaries run on a local LLM. Point this at Ollama on this PC or another machine on your
LAN (e.g. <code>192.168.0.x</code>) — both count as local, so nothing leaves your network.
</p>
<label
>Provider
<select bind:value={llmProvider}>
<option value="off">Off</option>
<option value="ollama">Ollama (local / LAN)</option>
<option value="custom">Custom (OpenAI-compatible)</option>
</select>
</label>
{#if llmProvider !== "off"}
<div class="grid">
<label class="wide"
>Endpoint<input
bind:value={llmEndpoint}
placeholder="http://192.168.0.42:11434"
/></label
>
<label>Model<input bind:value={llmModel} placeholder="llama3.1" /></label>
</div>
{#if llmEndpoint && !endpointIsLocalOrLan(llmEndpoint)}
<div class="banner">
<AlertTriangle size={14} aria-hidden="true" />
This endpoint isn't on your machine or LAN — your transcript would leave your network.
</div>
{/if}
<div class="actions">
<button class="primary" onclick={saveLlm} disabled={settings.llmSaving}>
{settings.llmSaving ? "Saving…" : "Save"}
</button>
<button onclick={() => settings.loadLlmStatus()}>Test / refresh</button>
{#if settings.llmStatus}
<span
class="test"
class:ok={settings.llmStatus.reachable}
class:fail={!settings.llmStatus.reachable}
>
{#if settings.llmStatus.reachable}<Check size={14} aria-hidden="true" />{:else}<X
size={14}
aria-hidden="true"
/>{/if}
{settings.llmStatus.reachable ? "reachable" : "unreachable"}
{#if settings.llmStatus.reachable && !settings.llmStatus.isLocal}· leaves your
network{/if}
</span>
{/if}
</div>
{#if settings.llmStatus?.reachable && settings.llmStatus.models.length}
<p class="muted">Models: {settings.llmStatus.models.slice(0, 6).join(", ")}</p>
{/if}
{:else}
<div class="actions">
<button class="primary" onclick={saveLlm} disabled={settings.llmSaving}>Turn off</button
>
</div>
{/if}
</section>
{:else}
<section>
<h3>Privacy</h3>