@@ -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