feat(ui): redesign AI status/actions + collapsible advanced Ollama config (T5.2)
This commit is contained in:
+463
-18
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user