Skip to content

Commit dcacdf6

Browse files
Michael Borckclaude
andcommitted
feat: one-click Ollama model download on first run
When the landing-page check finds Ollama running with zero models installed, a banner offers to pull the configured model (default llama3.1:8b) with live progress, streamed through a /api/pullModel NDJSON proxy. Closes the last first-run gap that required a terminal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a483936 commit dcacdf6

2 files changed

Lines changed: 167 additions & 2 deletions

File tree

app/api/pullModel/route.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { NextResponse } from "next/server";
2+
import { getProviderBaseUrl } from "@/utils/settings";
3+
4+
// Proxies an Ollama model pull, streaming its NDJSON progress events
5+
// straight back to the client.
6+
export async function POST(request: Request) {
7+
try {
8+
const { model, baseUrl } = await request.json();
9+
if (!model || typeof model !== "string") {
10+
return NextResponse.json({ error: "No model specified" }, { status: 400 });
11+
}
12+
13+
const base = baseUrl || getProviderBaseUrl("ollama");
14+
const upstream = await fetch(`${base}/api/pull`, {
15+
method: "POST",
16+
headers: { "Content-Type": "application/json" },
17+
body: JSON.stringify({ name: model, stream: true }),
18+
});
19+
20+
if (!upstream.ok || !upstream.body) {
21+
return NextResponse.json(
22+
{ error: `Ollama responded with status ${upstream.status}` },
23+
{ status: 502 },
24+
);
25+
}
26+
27+
return new Response(upstream.body, {
28+
headers: {
29+
"Content-Type": "application/x-ndjson",
30+
"Cache-Control": "no-cache",
31+
},
32+
});
33+
} catch (e) {
34+
console.error("Model pull failed:", e);
35+
return NextResponse.json(
36+
{ error: "Could not reach Ollama" },
37+
{ status: 502 },
38+
);
39+
}
40+
}

components/Hero.tsx

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ const Hero: FC<THeroProps> = ({
5656
type: "loading" | "error";
5757
message: string;
5858
} | null>(null);
59+
const [ollamaNoModels, setOllamaNoModels] = useState(false);
60+
const [recommendedModel, setRecommendedModel] = useState("llama3.1:8b");
61+
const [pullProgress, setPullProgress] = useState<{
62+
status: string;
63+
percent: number | null;
64+
} | null>(null);
65+
const [pullError, setPullError] = useState<string | null>(null);
5966
const [dragActive, setDragActive] = useState(false);
6067
const fileInputRef = useRef<HTMLInputElement>(null);
6168

@@ -87,9 +94,25 @@ const Hero: FC<THeroProps> = ({
8794
apiKey: settings.llmApiKey || undefined,
8895
}),
8996
});
90-
if (!cancelled) setProviderWarning(res.ok ? null : warning);
97+
if (cancelled) return;
98+
if (res.ok) {
99+
const data = await res.json().catch(() => ({ models: [] }));
100+
const models: string[] = data.models || [];
101+
setProviderWarning(null);
102+
// Ollama is up but has nothing to run — offer a download.
103+
setOllamaNoModels(
104+
settings.llmProvider === "ollama" && models.length === 0,
105+
);
106+
setRecommendedModel(settings.llmModel || "llama3.1:8b");
107+
} else {
108+
setProviderWarning(warning);
109+
setOllamaNoModels(false);
110+
}
91111
} catch {
92-
if (!cancelled) setProviderWarning(warning);
112+
if (!cancelled) {
113+
setProviderWarning(warning);
114+
setOllamaNoModels(false);
115+
}
93116
}
94117
};
95118

@@ -112,6 +135,67 @@ const Hero: FC<THeroProps> = ({
112135
setPromptValue(value);
113136
};
114137

138+
const startModelPull = async () => {
139+
const settings = loadClientSettings();
140+
const model = settings.llmModel || "llama3.1:8b";
141+
setPullError(null);
142+
setPullProgress({ status: "Starting download...", percent: null });
143+
try {
144+
const res = await fetch("/api/pullModel", {
145+
method: "POST",
146+
headers: { "Content-Type": "application/json" },
147+
body: JSON.stringify({
148+
model,
149+
baseUrl: settings.llmBaseUrl || undefined,
150+
}),
151+
});
152+
if (!res.ok || !res.body) {
153+
const data = await res.json().catch(() => ({}));
154+
throw new Error(data.error || "Download failed");
155+
}
156+
157+
const reader = res.body.getReader();
158+
const decoder = new TextDecoder();
159+
let buffered = "";
160+
let success = false;
161+
for (;;) {
162+
const { value, done } = await reader.read();
163+
if (done) break;
164+
buffered += decoder.decode(value, { stream: true });
165+
const lines = buffered.split("\n");
166+
buffered = lines.pop() || "";
167+
for (const line of lines) {
168+
if (!line.trim()) continue;
169+
let evt: { error?: string; status?: string; total?: number; completed?: number };
170+
try {
171+
evt = JSON.parse(line);
172+
} catch {
173+
continue;
174+
}
175+
if (evt.error) throw new Error(evt.error);
176+
if (evt.status === "success") success = true;
177+
setPullProgress({
178+
status: evt.status || "Downloading...",
179+
percent:
180+
evt.total && evt.completed != null
181+
? Math.round((evt.completed / evt.total) * 100)
182+
: null,
183+
});
184+
}
185+
}
186+
if (!success) {
187+
throw new Error("Download did not complete. Please try again.");
188+
}
189+
setPullProgress(null);
190+
setOllamaNoModels(false);
191+
} catch (e) {
192+
setPullProgress(null);
193+
setPullError(
194+
e instanceof Error ? e.message : "Download failed. Please try again.",
195+
);
196+
}
197+
};
198+
115199
const importFile = async (file: File) => {
116200
const ext = file.name.toLowerCase().split(".").pop();
117201
setImportStatus({ type: "loading", message: `Reading ${file.name}...` });
@@ -190,6 +274,47 @@ const Hero: FC<THeroProps> = ({
190274
</div>
191275
)}
192276

277+
{/* Ollama is reachable but has no models — offer a one-click download */}
278+
{!showFirstRun && !providerWarning && ollamaNoModels && (
279+
<div className="mb-4 w-full rounded-soft border border-hairline-strong bg-paper-warm px-4 py-3">
280+
{pullProgress ? (
281+
<div>
282+
<p className="text-sm text-ink-muted" style={{ lineHeight: 1.6 }}>
283+
Downloading <strong className="text-ink">{recommendedModel}</strong>
284+
{pullProgress.percent != null ? ` — ${pullProgress.percent}%` : ""}{" "}
285+
<span className="text-ink-quiet">({pullProgress.status})</span>
286+
</p>
287+
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-ink/10">
288+
<div
289+
className="h-full rounded-full bg-accent transition-all duration-normal"
290+
style={{ width: `${pullProgress.percent ?? 2}%` }}
291+
/>
292+
</div>
293+
<p className="mt-1 text-xs text-ink-quiet">
294+
This is a one-time download. You can keep this page open.
295+
</p>
296+
</div>
297+
) : (
298+
<div className="flex flex-wrap items-center justify-between gap-3">
299+
<p className="text-sm text-ink-muted" style={{ lineHeight: 1.6 }}>
300+
<strong className="text-ink">Almost there!</strong> Ollama is
301+
running, but no AI model is installed yet.
302+
{pullError && (
303+
<span className="block text-error">{pullError}</span>
304+
)}
305+
</p>
306+
<button
307+
type="button"
308+
onClick={startModelPull}
309+
className="shrink-0 rounded-soft bg-ink px-4 py-2 text-sm font-medium text-paper transition-colors duration-normal hover:bg-accent"
310+
>
311+
Download {recommendedModel} (a few GB)
312+
</button>
313+
</div>
314+
)}
315+
</div>
316+
)}
317+
193318
{/* Provider connectivity warning (hidden while the first-run banner covers setup) */}
194319
{!showFirstRun && providerWarning && (
195320
<div className="mb-4 w-full rounded-soft border border-hairline-strong bg-paper-warm px-4 py-3">

0 commit comments

Comments
 (0)