feat(settings): model management UI — pull, delete, VRAM status

Backend:
- Enrich GET /api/chat/models to also hit /api/ps and return loaded:bool
  and modified_at alongside name/size, using parallel gather

Frontend (Settings → General):
- Model list: each row shows name (monospace), size (GB/MB), 'in VRAM' badge
  if currently loaded, 'default' badge if it's the configured default
- Delete button per row; disabled while deletion in progress
- Pull form: text input (Enter submits) + Pull button
- Suggestion chips for qwen3:7b/14b/4b, llama3.1:8b, nomic-embed-text;
  disabled if already installed
- Progress display during pull: status text + determinate bar when
  Ollama reports total/completed, indeterminate animation otherwise
- Refresh button reloads the list; list auto-refreshes after pull/delete
- Link to ollama.com/library for model discovery

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 02:52:31 -04:00
parent 9cd0de3883
commit 6564a03c0e
2 changed files with 241 additions and 8 deletions
+218
View File
@@ -16,6 +16,13 @@ const assistantName = ref("");
const defaultModel = ref("");
const installedModels = ref<string[]>([]);
const defaultChatModel = ref("");
interface OllamaModel { name: string; size: number; loaded: boolean; modified_at: string; }
const ollamaModels = ref<OllamaModel[]>([]);
const pullModelName = ref("");
const pullProgress = ref<{ status: string; pct: number | null } | null>(null);
const pulling = ref(false);
const deletingModel = ref<string | null>(null);
const newEmail = ref("");
const emailPassword = ref("");
const changingEmail = ref(false);
@@ -415,6 +422,7 @@ onMounted(async () => {
} catch {
// Ollama unreachable — dropdowns will be empty
}
await loadOllamaModels();
// Load notification preferences from user settings
const allSettings = await apiGet<Record<string, string>>("/api/settings");
@@ -527,6 +535,89 @@ async function changePassword() {
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "—";
const gb = bytes / 1e9;
if (gb >= 1) return `${gb.toFixed(1)} GB`;
return `${(bytes / 1e6).toFixed(0)} MB`;
}
async function loadOllamaModels() {
try {
const data = await apiGet<{ models: OllamaModel[] }>("/api/chat/models");
ollamaModels.value = data.models ?? [];
installedModels.value = ollamaModels.value.map((m) => m.name);
} catch {
// Ollama unreachable
}
}
async function pullModel() {
const name = pullModelName.value.trim();
if (!name || pulling.value) return;
pulling.value = true;
pullProgress.value = { status: "Connecting…", pct: null };
try {
const resp = await fetch("/api/chat/models/pull", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: name }),
credentials: "include",
});
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.replace(/^data: /, "").trim();
if (!trimmed) continue;
try {
const msg = JSON.parse(trimmed);
if (msg.error) throw new Error(msg.error);
if (msg.status === "success") {
pullProgress.value = { status: "Complete", pct: 100 };
pullModelName.value = "";
} else if (msg.total && msg.completed) {
const pct = Math.round((msg.completed / msg.total) * 100);
pullProgress.value = { status: msg.status || "Downloading…", pct };
} else {
pullProgress.value = { status: msg.status || "Working…", pct: null };
}
} catch (parseErr: any) {
if (parseErr.message !== "JSON parse error") throw parseErr;
}
}
}
await loadOllamaModels();
toastStore.show(`Model ${name} ready`);
} catch (err: any) {
toastStore.show(`Pull failed: ${err.message}`, "error");
} finally {
pulling.value = false;
setTimeout(() => { pullProgress.value = null; }, 3000);
}
}
async function deleteModel(name: string) {
deletingModel.value = name;
try {
await apiPost("/api/chat/models/delete", { model: name });
await loadOllamaModels();
if (defaultModel.value === name) defaultModel.value = "";
toastStore.show(`Deleted ${name}`);
} catch {
toastStore.show(`Failed to delete ${name}`, "error");
} finally {
deletingModel.value = null;
}
}
async function saveAssistant() {
saving.value = true;
saved.value = false;
@@ -1050,6 +1141,69 @@ function formatUserDate(iso: string): string {
<span v-if="saved" class="saved-msg">Saved!</span>
</div>
</section>
<!-- Model Management -->
<section class="settings-section full-width">
<div class="model-mgmt-header">
<div>
<h2>Model Management</h2>
<p class="section-desc">Install and remove Ollama models without leaving the app. Search <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a> for available models.</p>
</div>
<button class="btn-secondary" @click="loadOllamaModels" title="Refresh list"> Refresh</button>
</div>
<!-- Installed models -->
<div v-if="ollamaModels.length" class="model-list">
<div v-for="m in ollamaModels" :key="m.name" class="model-row">
<div class="model-row-info">
<span class="model-name">{{ m.name }}</span>
<span v-if="m.loaded" class="model-badge model-badge--loaded">in VRAM</span>
<span v-if="m.name === (defaultModel || defaultChatModel)" class="model-badge model-badge--default">default</span>
</div>
<div class="model-row-right">
<span class="model-size">{{ formatBytes(m.size) }}</span>
<button
class="model-delete-btn"
:disabled="deletingModel === m.name"
@click="deleteModel(m.name)"
:title="`Remove ${m.name}`"
>{{ deletingModel === m.name ? '…' : '✕' }}</button>
</div>
</div>
</div>
<p v-else class="field-hint">No models installed, or Ollama is unreachable.</p>
<!-- Pull a model -->
<div class="model-pull-form">
<input
v-model="pullModelName"
class="input"
placeholder="e.g. qwen3:7b"
:disabled="pulling"
@keydown.enter="pullModel"
/>
<button class="btn-secondary" @click="pullModel" :disabled="pulling || !pullModelName.trim()">
{{ pulling ? 'Pulling…' : 'Pull' }}
</button>
</div>
<div class="model-suggestions">
<span class="suggestions-label">Suggestions:</span>
<button v-for="s in ['qwen3:7b','qwen3:14b','qwen3:4b','llama3.1:8b','nomic-embed-text']"
:key="s" class="suggestion-chip"
:disabled="pulling || ollamaModels.some(m => m.name === s)"
@click="pullModelName = s"
>{{ s }}</button>
</div>
<!-- Pull progress -->
<div v-if="pullProgress" class="model-pull-progress">
<div class="pull-status">{{ pullProgress.status }}</div>
<div class="pull-bar-track" v-if="pullProgress.pct !== null">
<div class="pull-bar-fill" :style="{ width: pullProgress.pct + '%' }"></div>
</div>
<div v-else class="pull-bar-indeterminate"></div>
</div>
</section>
</div>
<!-- Account -->
@@ -2136,6 +2290,70 @@ function formatUserDate(iso: string): string {
line-height: 1.5;
}
/* ── Model Management ───────────────────────────────────────── */
.model-mgmt-header {
display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.75rem;
}
.model-mgmt-header h2 { margin: 0; }
.model-mgmt-header .section-desc { margin: 0.25rem 0 0; }
.model-list { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.75rem; }
.model-row {
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
padding: 0.45rem 0.6rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 6px;
}
.model-row-info { display: flex; align-items: center; gap: 0.4rem; min-width: 0; flex: 1; }
.model-name { font-size: 0.88rem; font-weight: 500; font-family: monospace; }
.model-badge {
font-size: 0.68rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-weight: 600; white-space: nowrap;
}
.model-badge--loaded { background: color-mix(in srgb, #22c55e 18%, transparent); color: #22c55e; }
.model-badge--default { background: color-mix(in srgb, var(--color-primary) 18%, transparent); color: var(--color-primary); }
.model-row-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.model-size { font-size: 0.78rem; color: var(--color-text-muted); }
.model-delete-btn {
background: none; border: none; cursor: pointer; color: var(--color-text-muted);
font-size: 0.8rem; padding: 0.2rem 0.35rem; border-radius: 3px; line-height: 1;
transition: color 0.15s, background 0.15s;
}
.model-delete-btn:hover:not(:disabled) { color: var(--color-danger, #ef4444); background: color-mix(in srgb, var(--color-danger, #ef4444) 10%, transparent); }
.model-delete-btn:disabled { opacity: 0.4; cursor: default; }
.model-pull-form { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
.model-pull-form .input { flex: 1; }
.model-suggestions { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.4rem; }
.suggestions-label { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; }
.suggestion-chip {
font-size: 0.72rem; padding: 0.15rem 0.5rem; border-radius: 4px;
border: 1px solid var(--color-border); background: var(--color-bg-card);
cursor: pointer; font-family: monospace; color: var(--color-text);
transition: border-color 0.12s, background 0.12s;
}
.suggestion-chip:hover:not(:disabled) { border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card)); }
.suggestion-chip:disabled { opacity: 0.4; cursor: default; }
.model-pull-progress { margin-top: 0.6rem; }
.pull-status { font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.25rem; }
.pull-bar-track {
height: 4px; background: var(--color-border); border-radius: 2px; overflow: hidden;
}
.pull-bar-fill {
height: 100%; background: var(--color-primary); border-radius: 2px;
transition: width 0.3s ease;
}
.pull-bar-indeterminate {
height: 4px; background: var(--color-border); border-radius: 2px;
position: relative; overflow: hidden;
}
.pull-bar-indeterminate::after {
content: ""; position: absolute; top: 0; left: -40%;
width: 40%; height: 100%; background: var(--color-primary); border-radius: 2px;
animation: indeterminate 1.2s ease-in-out infinite;
}
@keyframes indeterminate {
0% { left: -40%; } 100% { left: 100%; }
}
/* Assistant — 2-col internal grid */
.assistant-grid {
display: grid;
+23 -8
View File
@@ -405,14 +405,29 @@ async def chat_status_route():
async def list_models_route():
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
resp.raise_for_status()
data = resp.json()
models = [
{"name": m["name"], "size": m.get("size", 0)}
for m in data.get("models", [])
]
return jsonify({"models": models})
tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags"))
ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps"))
tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True)
loaded_names: set[str] = set()
if not isinstance(ps_resp, Exception):
try:
ps_resp.raise_for_status()
loaded_names = {m["name"] for m in ps_resp.json().get("models", [])}
except Exception:
pass
models = []
if not isinstance(tags_resp, Exception):
tags_resp.raise_for_status()
for m in tags_resp.json().get("models", []):
models.append({
"name": m["name"],
"size": m.get("size", 0),
"modified_at": m.get("modified_at", ""),
"loaded": m["name"] in loaded_names,
})
return jsonify({"models": models})
except Exception as e:
logger.warning("Failed to list Ollama models: %s", e)
return jsonify({"models": [], "error": str(e)}), 200