Stream model pull progress via SSE instead of fire-and-forget

Replace the asyncio.create_task() fire-and-forget pattern for model
downloads with SSE streaming that relays Ollama's pull progress in
real time. The frontend now shows live download percentage instead of
a static "Pulling..." label with blind polling.

- routes/chat.py: SSE streaming endpoint with 1800s timeout
- stores/settings.ts: pullModel uses apiStreamPost with onProgress callback
- SettingsView.vue: live "Downloading 42%" display, removed polling/setTimeout
- summary.md: updated to reflect SSE streaming for model pulls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 22:34:58 -05:00
parent de899ebc50
commit 39bcd7a8fa
4 changed files with 70 additions and 45 deletions
+8 -3
View File
@@ -1,6 +1,6 @@
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPut, apiPost } from "@/api/client";
import { apiGet, apiPut, apiPost, apiStreamPost } from "@/api/client";
import type { AppSettings } from "@/types/settings";
export const useSettingsStore = defineStore("settings", () => {
@@ -45,8 +45,13 @@ export const useSettingsStore = defineStore("settings", () => {
}
}
async function pullModel(model: string) {
await apiPost("/api/chat/models/pull", { model });
async function pullModel(
model: string,
onProgress?: (data: Record<string, unknown>) => void,
) {
await apiStreamPost("/api/chat/models/pull", { model }, (data) => {
if (onProgress) onProgress(data);
});
}
async function deleteModel(model: string) {
+24 -30
View File
@@ -7,7 +7,7 @@ const store = useSettingsStore();
const assistantName = ref("");
const saving = ref(false);
const saved = ref(false);
const pulling = ref<string | null>(null);
const pullProgress = ref<{ model: string; percent: number } | null>(null);
const deleting = ref<string | null>(null);
const confirmDelete = ref<string | null>(null);
@@ -34,13 +34,6 @@ const MODEL_CATALOG: ModelInfo[] = [
bestFor: "Fast responses, general tasks",
category: "General Purpose",
},
{
name: "gemma2",
description: "Google's Gemma 2 9B — well-rounded model strong in reasoning and conversation.",
size: "5.4 GB",
bestFor: "Conversation, reasoning, summarization",
category: "General Purpose",
},
{
name: "qwen2.5",
description: "Alibaba's Qwen 2.5 7B — multilingual model with strong coding and math skills.",
@@ -171,8 +164,9 @@ function modelsInCategory(category: string) {
function isActiveModel(name: string): boolean {
const current = selectedModel.value || store.defaultModel;
if (!current) return false;
const clean = current.replace(/:latest$/, "");
return clean === name || clean === name.replace(/:.*$/, "");
const cleanCurrent = current.replace(/:latest$/, "");
const cleanName = name.replace(/:latest$/, "");
return cleanCurrent === cleanName;
}
onMounted(async () => {
@@ -205,27 +199,27 @@ async function selectModel(name: string) {
}
async function pullModel(name: string) {
pulling.value = name;
pullProgress.value = { model: name, percent: 0 };
try {
await store.pullModel(name);
// Poll for completion
const poll = setInterval(async () => {
await store.fetchInstalledModels();
const installed = new Set(
store.installedModels.map((m) => m.replace(/:latest$/, ""))
);
if (installed.has(name) || installed.has(name.replace(/:.*$/, ""))) {
clearInterval(poll);
pulling.value = null;
await store.pullModel(name, (data) => {
if (data.error) {
pullProgress.value = null;
return;
}
}, 5000);
// Stop polling after 10 minutes max
setTimeout(() => {
clearInterval(poll);
if (pulling.value === name) pulling.value = null;
}, 600_000);
const completed = data.completed as number | undefined;
const total = data.total as number | undefined;
if (completed != null && total && total > 0) {
pullProgress.value = {
model: name,
percent: Math.round((completed / total) * 100),
};
}
});
await store.fetchInstalledModels();
} catch {
pulling.value = null;
// error already handled
} finally {
pullProgress.value = null;
}
}
@@ -342,9 +336,9 @@ function cancelDelete() {
v-else
class="btn-pull"
@click="pullModel(model.name)"
:disabled="pulling !== null"
:disabled="pullProgress !== null"
>
{{ pulling === model.name ? "Pulling..." : "Download" }}
{{ pullProgress?.model === model.name ? `Downloading ${pullProgress.percent}%` : "Download" }}
</button>
</div>
</div>