Skip to content

Commit 6729194

Browse files
Michael Borckclaude
andcommitted
feat: guided Ollama setup card — install, verify, download, connect
When the provider is Ollama and unreachable, the landing page shows a setup card instead of a plain warning: 'Get Ollama (free)' opens the official download page in the system browser, 'I've installed it — check again' re-probes, and once detected the existing model-download banner takes over and auto-connects after the pull. No terminal, and no silent system installs. Also fixes external links generally: main.js used the legacy 'new-window' event (removed in modern Electron), so target=_blank links opened bare child windows — now routed to the system browser via setWindowOpenHandler + shell.openExternal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3a2ae71 commit 6729194

2 files changed

Lines changed: 101 additions & 58 deletions

File tree

components/Hero.tsx

Lines changed: 92 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Image from "next/image";
22
import Link from "next/link";
3-
import { FC, useState, useEffect, useRef } from "react";
3+
import { FC, useState, useEffect, useRef, useCallback } from "react";
44
import InitialInputArea from "./InitialInputArea";
55
import { suggestions } from "@/utils/utils";
66
import { strategies } from "@/utils/strategies";
@@ -56,6 +56,8 @@ const Hero: FC<THeroProps> = ({
5656
type: "loading" | "error";
5757
message: string;
5858
} | null>(null);
59+
const [ollamaDown, setOllamaDown] = useState(false);
60+
const [checking, setChecking] = useState(false);
5961
const [ollamaNoModels, setOllamaNoModels] = useState(false);
6062
const [recommendedModel, setRecommendedModel] = useState("llama3.1:8b");
6163
const [pullProgress, setPullProgress] = useState<{
@@ -74,57 +76,59 @@ const Hero: FC<THeroProps> = ({
7476
}, []);
7577

7678
// Check the AI provider is reachable before the user invests in a session.
77-
useEffect(() => {
78-
let cancelled = false;
79-
80-
const check = async () => {
81-
const settings = loadClientSettings();
82-
const label = PROVIDER_LABELS[settings.llmProvider] || settings.llmProvider;
83-
const warning =
84-
settings.llmProvider === "ollama"
85-
? "I can't reach Ollama right now. Make sure it's running on your computer, or pick another provider in Settings."
86-
: `I can't reach ${label} right now. Check your secret key and connection in Settings.`;
87-
try {
88-
const res = await fetch("/api/models", {
89-
method: "POST",
90-
headers: { "Content-Type": "application/json" },
91-
body: JSON.stringify({
92-
provider: settings.llmProvider,
93-
baseUrl: settings.llmBaseUrl || undefined,
94-
apiKey: settings.llmApiKey || undefined,
95-
}),
96-
});
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-
}
111-
} catch {
112-
if (!cancelled) {
113-
setProviderWarning(warning);
114-
setOllamaNoModels(false);
115-
}
79+
// For Ollama, an unreachable provider starts the guided setup journey
80+
// (install → check again → download a model) instead of a plain warning.
81+
const runProviderCheck = useCallback(async () => {
82+
setChecking(true);
83+
const settings = loadClientSettings();
84+
const isOllama = settings.llmProvider === "ollama";
85+
const label = PROVIDER_LABELS[settings.llmProvider] || settings.llmProvider;
86+
try {
87+
const res = await fetch("/api/models", {
88+
method: "POST",
89+
headers: { "Content-Type": "application/json" },
90+
body: JSON.stringify({
91+
provider: settings.llmProvider,
92+
baseUrl: settings.llmBaseUrl || undefined,
93+
apiKey: settings.llmApiKey || undefined,
94+
}),
95+
});
96+
if (res.ok) {
97+
const data = await res.json().catch(() => ({ models: [] }));
98+
const models: string[] = data.models || [];
99+
setProviderWarning(null);
100+
setOllamaDown(false);
101+
// Ollama is up but has nothing to run — offer a download.
102+
setOllamaNoModels(isOllama && models.length === 0);
103+
setRecommendedModel(settings.llmModel || "llama3.1:8b");
104+
} else {
105+
throw new Error(`Provider check failed: ${res.status}`);
116106
}
117-
};
107+
} catch {
108+
setOllamaNoModels(false);
109+
if (isOllama) {
110+
setOllamaDown(true);
111+
setProviderWarning(null);
112+
} else {
113+
setOllamaDown(false);
114+
setProviderWarning(
115+
`I can't reach ${label} right now. Check your secret key and connection in Settings.`,
116+
);
117+
}
118+
} finally {
119+
setChecking(false);
120+
}
121+
}, []);
118122

119-
check();
120-
window.addEventListener("settingsChanged", check);
121-
window.addEventListener("settingsLoaded", check);
123+
useEffect(() => {
124+
runProviderCheck();
125+
window.addEventListener("settingsChanged", runProviderCheck);
126+
window.addEventListener("settingsLoaded", runProviderCheck);
122127
return () => {
123-
cancelled = true;
124-
window.removeEventListener("settingsChanged", check);
125-
window.removeEventListener("settingsLoaded", check);
128+
window.removeEventListener("settingsChanged", runProviderCheck);
129+
window.removeEventListener("settingsLoaded", runProviderCheck);
126130
};
127-
}, []);
131+
}, [runProviderCheck]);
128132

129133
const dismissFirstRun = () => {
130134
setShowFirstRun(false);
@@ -187,7 +191,8 @@ const Hero: FC<THeroProps> = ({
187191
throw new Error("Download did not complete. Please try again.");
188192
}
189193
setPullProgress(null);
190-
setOllamaNoModels(false);
194+
// Re-probe so the banner clears only once the model is really there.
195+
await runProviderCheck();
191196
} catch (e) {
192197
setPullProgress(null);
193198
setPullError(
@@ -238,8 +243,8 @@ const Hero: FC<THeroProps> = ({
238243
<>
239244
<div className="mx-auto mt-6 flex max-w-3xl flex-col items-center justify-center sm:mt-12">
240245

241-
{/* First-run banner */}
242-
{showFirstRun && (
246+
{/* First-run banner (suppressed while the guided Ollama setup is showing) */}
247+
{showFirstRun && !ollamaDown && !ollamaNoModels && (
243248
<div className="mb-4 w-full rounded-soft border border-accent bg-accent-soft px-4 py-3">
244249
<div className="flex items-start justify-between gap-3">
245250
<p className="text-sm text-ink-muted" style={{ lineHeight: 1.6 }}>
@@ -274,8 +279,43 @@ const Hero: FC<THeroProps> = ({
274279
</div>
275280
)}
276281

282+
{/* Ollama isn't reachable — guided setup, no terminal needed */}
283+
{ollamaDown && (
284+
<div className="mb-4 w-full rounded-soft border border-hairline-strong bg-paper-warm px-4 py-4">
285+
<p className="text-sm text-ink-muted" style={{ lineHeight: 1.6 }}>
286+
<strong className="text-ink">Set up your AI tutor.</strong> Study
287+
Buddy works with Ollama — free, private AI that runs on your own
288+
computer. No account needed. Install it, then come back here.
289+
</p>
290+
<div className="mt-3 flex flex-wrap items-center gap-3">
291+
<a
292+
href="https://ollama.com/download"
293+
target="_blank"
294+
rel="noopener noreferrer"
295+
className="rounded-soft bg-ink px-4 py-2 text-sm font-medium text-paper transition-colors duration-normal hover:bg-accent"
296+
>
297+
Get Ollama (free)
298+
</a>
299+
<button
300+
type="button"
301+
onClick={runProviderCheck}
302+
disabled={checking}
303+
className="rounded-soft border border-hairline px-4 py-2 text-sm font-medium text-ink-muted transition-colors duration-normal hover:border-hairline-strong hover:text-accent disabled:cursor-not-allowed disabled:opacity-50"
304+
>
305+
{checking ? "Checking..." : "I've installed it — check again"}
306+
</button>
307+
<Link
308+
href="/settings"
309+
className="text-sm text-ink-muted underline transition-colors duration-normal hover:text-accent"
310+
>
311+
Use a cloud provider instead
312+
</Link>
313+
</div>
314+
</div>
315+
)}
316+
277317
{/* Ollama is reachable but has no models — offer a one-click download */}
278-
{!showFirstRun && !providerWarning && ollamaNoModels && (
318+
{!providerWarning && ollamaNoModels && (
279319
<div className="mb-4 w-full rounded-soft border border-hairline-strong bg-paper-warm px-4 py-3">
280320
{pullProgress ? (
281321
<div>

main.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
const isDev = process.env.NODE_ENV === 'development';
33

44
// Import electron
5-
const { app, BrowserWindow } = require('electron');
5+
const { app, BrowserWindow, shell } = require('electron');
66
const fs = require('fs');
77
const path = require('path');
88

@@ -174,12 +174,15 @@ app.on('activate', () => {
174174
}
175175
});
176176

177-
// Security: Prevent new window creation
177+
// Security: open external links in the system browser, never as new
178+
// Electron windows. (The legacy 'new-window' event no longer fires on
179+
// modern Electron, so target="_blank" links were opening child windows.)
178180
app.on('web-contents-created', (event, contents) => {
179-
contents.on('new-window', (event, navigationUrl) => {
180-
event.preventDefault();
181-
// Could open in external browser if needed
182-
// require('electron').shell.openExternal(navigationUrl);
181+
contents.setWindowOpenHandler(({ url }) => {
182+
if (url.startsWith('https://') || url.startsWith('http://')) {
183+
shell.openExternal(url);
184+
}
185+
return { action: 'deny' };
183186
});
184187
});
185188

0 commit comments

Comments
 (0)