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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user