feat(ui): redesign AI status/actions + collapsible advanced Ollama config (T5.2)

This commit is contained in:
iamdoubz
2026-07-04 09:21:29 -05:00
parent a0ce2e2321
commit 8aa6ba59a7
+463 -18
View File
@@ -21,7 +21,17 @@
UploadCloud,
ShieldCheck,
Sparkles,
RefreshCw,
ChevronRight,
RotateCcw,
} from "@lucide/svelte";
import {
OLLAMA_OPTIONS,
THINK_OPTIONS,
OLLAMA_GROUP_LABELS,
type OllamaOption,
type OllamaGroup,
} from "../llmParams";
let { onClose }: { onClose: () => void } = $props();
@@ -68,6 +78,93 @@
}
}
// ---- 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) ----
let pstPath = $state("");
let pstPassword = $state("");
@@ -698,25 +795,172 @@
<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}
<button class="ghost" onclick={() => settings.loadLlmStatus()}>
<RefreshCw size={14} aria-hidden="true" /> Test connection
</button>
</div>
{#if settings.llmStatus?.reachable && settings.llmStatus.models.length}
<p class="muted">Models: {settings.llmStatus.models.slice(0, 6).join(", ")}</p>
{#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 ? "Reachable" : "Unreachable"}</span>
{#if s.reachable}
<span class="muted"
>· {s.models.length} model{s.models.length === 1 ? "" : "s"}</span
>
<span class="muted" class:warn={!s.isLocal}
>· {s.isLocal ? "on your machine / LAN" : "leaves your 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}
Default {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="Reset to default"
aria-label="Reset {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>Advanced Ollama configuration</span>
{#if advChangedCount}<span class="changed-badge">{advChangedCount} changed</span
>{/if}
</summary>
<div class="adv-body">
{#if advChangedCount}
<button class="link reset-all" onclick={resetAllAdvanced}
>Reset all to defaults</button
>
{/if}
<label class="wide adv-system"
>System prompt
<textarea
rows="3"
bind:value={advSystem}
placeholder="e.g. You are a concise meeting summarizer."
></textarea>
</label>
<p class="muted small">
Added before WhispAssist's required output format, so your instructions can't
break summary/action-item parsing.
</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">Think</span>
<span class="param-help">Reasoning effort (reasoning models only).</span>
</div>
<div class="param-control">
<select bind:value={advThink}>
{#each THINK_OPTIONS as t (t)}<option value={t}>{t}</option>{/each}
</select>
</div>
</div>
<div class="param">
<div class="param-label">
<span class="param-name">Keep alive</span>
<span class="param-help"
>How long the model stays in RAM. e.g. 5m, 1h, 0 (unload), -1 (forever).</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>Runtime &amp; hardware</span>
<span class="muted small">— 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 ? "Saving…" : "Save advanced"}
</button>
{#if advSaved}<span class="test ok"
><Check size={14} aria-hidden="true" /> Saved</span
>{/if}
</div>
</div>
</details>
{/if}
{:else}
<div class="actions">
@@ -1153,4 +1397,205 @@
ul.hosts li {
padding: 0.2rem 0;
}
/* ---- 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>