Skip to content

Commit dc630fa

Browse files
feat: Improve toast messages and errors
- Add preflight option to get metadata and actionable errors - Add Token Validation Timeout - Improve Various Toasts for download action - Improve validate button ux
1 parent cb67a0d commit dc630fa

File tree

6 files changed

+297
-11
lines changed

6 files changed

+297
-11
lines changed

web-app/src/containers/DownloadButton.tsx

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { cn, sanitizeModelId } from '@/lib/utils'
1010
import { CatalogModel } from '@/services/models/types'
1111
import { DownloadEvent, DownloadState, events } from '@janhq/core'
1212
import { useCallback, useEffect, useMemo, useState } from 'react'
13+
import { toast } from 'sonner'
14+
import { route } from '@/constants/routes'
15+
import { useNavigate } from '@tanstack/react-router'
1316
import { useShallow } from 'zustand/shallow'
1417

1518
type ModelProps = {
@@ -37,6 +40,7 @@ export function DownloadButtonPlaceholder({
3740
const serviceHub = useServiceHub()
3841
const huggingfaceToken = useGeneralSetting((state) => state.huggingfaceToken)
3942
const [isDownloaded, setDownloaded] = useState<boolean>(false)
43+
const navigate = useNavigate()
4044

4145
const quant =
4246
model.quants.find((e) =>
@@ -109,8 +113,71 @@ export function DownloadButtonPlaceholder({
109113

110114
const isRecommended = isRecommendedModel(model.model_name)
111115

112-
const handleDownload = () => {
113-
// Immediately set local downloading state
116+
const handleDownload = async () => {
117+
// Preflight check for gated repos/artifacts
118+
const preflight = await serviceHub
119+
.models()
120+
.preflightArtifactAccess(modelUrl, huggingfaceToken)
121+
122+
if (!preflight.ok) {
123+
const repoPage = `https://huggingface.co/${model.model_name}`
124+
125+
if (preflight.reason === 'AUTH_REQUIRED') {
126+
toast.error('Hugging Face token required', {
127+
description:
128+
'This model requires a Hugging Face access token. Add your token in Settings and retry.',
129+
action: {
130+
label: 'Open Settings',
131+
onClick: () => navigate({ to: route.settings.general }),
132+
},
133+
})
134+
return
135+
}
136+
137+
if (preflight.reason === 'LICENSE_NOT_ACCEPTED') {
138+
toast.error('Accept model license on Hugging Face', {
139+
description:
140+
'You must accept the model’s license on its Hugging Face page before downloading.',
141+
action: {
142+
label: 'Open model page',
143+
onClick: () => window.open(repoPage, '_blank'),
144+
},
145+
})
146+
return
147+
}
148+
149+
if (preflight.reason === 'RATE_LIMITED') {
150+
toast.error('Rate limited by Hugging Face', {
151+
description:
152+
'You have been rate-limited. Adding a token can increase rate limits. Please try again later.',
153+
action: {
154+
label: 'Open Settings',
155+
onClick: () => navigate({ to: route.settings.general }),
156+
},
157+
})
158+
return
159+
}
160+
161+
if (preflight.reason === 'NOT_FOUND') {
162+
toast.error('File not found', {
163+
description:
164+
'The requested artifact was not found in the repository. Try another quant or check the model page.',
165+
action: {
166+
label: 'Open model page',
167+
onClick: () => window.open(repoPage, '_blank'),
168+
},
169+
})
170+
return
171+
}
172+
173+
toast.error('Model download error', {
174+
description:
175+
'We could not start the download. Check your network or try again later.',
176+
})
177+
return
178+
}
179+
180+
// Immediately set local downloading state and start download
114181
addLocalDownloadingModel(modelId)
115182
const mmprojPath = (
116183
model.mmproj_models?.find(

web-app/src/containers/DownloadManegement.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ import { IconDownload, IconX } from '@tabler/icons-react'
1313
import { useCallback, useEffect, useMemo, useState } from 'react'
1414
import { toast } from 'sonner'
1515
import { useTranslation } from '@/i18n/react-i18next-compat'
16+
import { useNavigate } from '@tanstack/react-router'
17+
import { route } from '@/constants/routes'
1618

1719
export function DownloadManagement() {
1820
const { t } = useTranslation()
21+
const navigate = useNavigate()
1922
const { open: isLeftPanelOpen } = useLeftPanel()
2023
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
2124
const serviceHub = useServiceHub()
@@ -159,14 +162,53 @@ export function DownloadManagement() {
159162
console.debug('onFileDownloadError', state)
160163
removeDownload(state.modelId)
161164
removeLocalDownloadingModel(state.modelId)
165+
166+
const anyState = state as unknown as { error?: string }
167+
const err = anyState?.error || ''
168+
169+
if (err.includes('HTTP status 401')) {
170+
toast.error('Hugging Face token required', {
171+
id: 'download-failed',
172+
description:
173+
'This model requires a Hugging Face access token. Add your token in Settings and retry.',
174+
action: {
175+
label: 'Open Settings',
176+
onClick: () => navigate({ to: route.settings.general }),
177+
},
178+
})
179+
return
180+
}
181+
182+
if (err.includes('HTTP status 403')) {
183+
toast.error('Accept model license on Hugging Face', {
184+
id: 'download-failed',
185+
description:
186+
'You must accept the model’s license on its Hugging Face page before downloading.',
187+
})
188+
return
189+
}
190+
191+
if (err.includes('HTTP status 429')) {
192+
toast.error('Rate limited by Hugging Face', {
193+
id: 'download-failed',
194+
description:
195+
'You have been rate-limited. Adding a token can increase rate limits. Please try again later.',
196+
action: {
197+
label: 'Open Settings',
198+
onClick: () => navigate({ to: route.settings.general }),
199+
},
200+
})
201+
return
202+
}
203+
162204
toast.error(t('common:toast.downloadFailed.title'), {
163205
id: 'download-failed',
164206
description: t('common:toast.downloadFailed.description', {
165207
item: state.modelId,
166208
}),
167209
})
168210
},
169-
[removeDownload, removeLocalDownloadingModel, t]
211+
[removeDownload, removeLocalDownloadingModel, t, navigate]
170212
)
171213

172214
const onModelValidationStarted = useCallback(

web-app/src/containers/ModelDownloadAction.tsx

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CatalogModel } from '@/services/models/types'
1010
import { IconDownload } from '@tabler/icons-react'
1111
import { useNavigate } from '@tanstack/react-router'
1212
import { useCallback, useMemo } from 'react'
13+
import { toast } from 'sonner'
1314

1415
export const ModelDownloadAction = ({
1516
variant,
@@ -98,7 +99,78 @@ export const ModelDownloadAction = ({
9899
<div
99100
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
100101
title={t('hub:downloadModel')}
101-
onClick={() => {
102+
onClick={async () => {
103+
const preflight = await serviceHub
104+
.models()
105+
.preflightArtifactAccess(variant.path, huggingfaceToken)
106+
107+
const repoPage = `https://huggingface.co/${model.model_name}`
108+
109+
if (!preflight.ok) {
110+
if (preflight.reason === 'AUTH_REQUIRED') {
111+
toast.error('Hugging Face token required', {
112+
description:
113+
'This model requires a Hugging Face access token. Add your token in Settings and retry.',
114+
action: {
115+
label: 'Open Settings',
116+
onClick: () =>
117+
navigate({
118+
to: route.settings.general,
119+
params: {},
120+
}),
121+
},
122+
})
123+
return
124+
}
125+
126+
if (preflight.reason === 'LICENSE_NOT_ACCEPTED') {
127+
toast.error('Accept model license on Hugging Face', {
128+
description:
129+
'You must accept the model’s license on its Hugging Face page before downloading.',
130+
action: {
131+
label: 'Open model page',
132+
onClick: () => window.open(repoPage, '_blank'),
133+
},
134+
})
135+
return
136+
}
137+
138+
if (preflight.reason === 'RATE_LIMITED') {
139+
toast.error('Rate limited by Hugging Face', {
140+
description:
141+
'You have been rate-limited. Adding a token can increase rate limits. Please try again later.',
142+
action: {
143+
label: 'Open Settings',
144+
onClick: () =>
145+
navigate({
146+
to: route.settings.general,
147+
params: {},
148+
}),
149+
},
150+
})
151+
return
152+
}
153+
154+
if (preflight.reason === 'NOT_FOUND') {
155+
toast.error('File not found', {
156+
description:
157+
'The requested artifact was not found in the repository. Try another quant or check the model page.',
158+
action: {
159+
label: 'Open model page',
160+
onClick: () => window.open(repoPage, '_blank'),
161+
},
162+
})
163+
return
164+
}
165+
166+
167+
toast.error('Model download error', {
168+
description:
169+
'We could not start the download. Check your network or try again later.',
170+
})
171+
return
172+
}
173+
102174
addLocalDownloadingModel(variant.model_id)
103175
serviceHub
104176
.models()

web-app/src/routes/settings/general.tsx

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import LanguageSwitcher from '@/containers/LanguageSwitcher'
3131
import { PlatformFeatures } from '@/lib/platform/const'
3232
import { PlatformFeature } from '@/lib/platform/types'
3333
import { isRootDir } from '@/utils/path'
34+
const TOKEN_VALIDATION_TIMEOUT_MS = 10_000
3435

3536
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3637
export const Route = createFileRoute(route.settings.general as any)({
@@ -63,6 +64,7 @@ function General() {
6364
const [selectedNewPath, setSelectedNewPath] = useState<string | null>(null)
6465
const [isDialogOpen, setIsDialogOpen] = useState(false)
6566
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
67+
const [isValidatingToken, setIsValidatingToken] = useState(false)
6668

6769
useEffect(() => {
6870
const fetchDataFolder = async () => {
@@ -415,13 +417,66 @@ function General() {
415417
ns: 'settings',
416418
})}
417419
actions={
418-
<Input
419-
id="hf-token"
420-
value={huggingfaceToken || ''}
421-
onChange={(e) => setHuggingfaceToken(e.target.value)}
422-
placeholder={'hf_xxx'}
423-
required
424-
/>
420+
<div className="flex items-center gap-2">
421+
<Input
422+
id="hf-token"
423+
value={huggingfaceToken || ''}
424+
onChange={(e) => setHuggingfaceToken(e.target.value)}
425+
placeholder={'hf_xxx'}
426+
required
427+
/>
428+
<Button
429+
size="sm"
430+
variant={(huggingfaceToken || '').trim() ? 'default' : 'link'}
431+
className={(huggingfaceToken || '').trim()
432+
? 'bg-green-600 text-white hover:bg-green-700'
433+
: ''}
434+
disabled={isValidatingToken}
435+
onClick={async () => {
436+
const token = (huggingfaceToken || '').trim()
437+
if (!token) {
438+
toast.error('Enter a Hugging Face token to validate')
439+
return
440+
}
441+
setIsValidatingToken(true)
442+
const controller = new AbortController()
443+
const timeoutId = setTimeout(
444+
() => controller.abort(),
445+
TOKEN_VALIDATION_TIMEOUT_MS
446+
)
447+
try {
448+
const resp = await fetch('https://huggingface.co/api/whoami-v2', {
449+
headers: { Authorization: `Bearer ${token}` },
450+
signal: controller.signal,
451+
})
452+
if (resp.ok) {
453+
const data = await resp.json()
454+
toast.success('Token valid', {
455+
description: data?.name
456+
? `Signed in as ${data.name}`
457+
: 'Your token is valid.',
458+
})
459+
} else {
460+
toast.error('Token invalid', {
461+
description: `HTTP ${resp.status}`,
462+
})
463+
}
464+
} catch (e) {
465+
const name = (e as { name?: string })?.name
466+
if (name === 'AbortError') {
467+
toast.error('Validation timed out')
468+
} else {
469+
toast.error('Network error while validating token')
470+
}
471+
} finally {
472+
clearTimeout(timeoutId)
473+
setIsValidatingToken(false)
474+
}
475+
}}
476+
>
477+
Validate
478+
</Button>
479+
</div>
425480
}
426481
/>
427482
)}

web-app/src/services/models/default.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
ModelValidationResult,
2424
ModelPlan,
2525
} from './types'
26+
import { PreflightResult } from './types'
2627

2728
// TODO: Replace this with the actual provider later
2829
const defaultProvider = 'llamacpp'
@@ -104,6 +105,37 @@ export class DefaultModelsService implements ModelsService {
104105
}
105106
}
106107

108+
async preflightArtifactAccess(
109+
url: string,
110+
hfToken?: string
111+
): Promise<PreflightResult> {
112+
try {
113+
const resp = await fetch(url, {
114+
method: 'HEAD',
115+
headers: hfToken
116+
? {
117+
Authorization: `Bearer ${hfToken}`,
118+
}
119+
: {},
120+
})
121+
122+
if (resp.ok) {
123+
return { ok: true, status: resp.status }
124+
}
125+
126+
const status = resp.status
127+
if (status === 401) return { ok: false, status, reason: 'AUTH_REQUIRED' }
128+
if (status === 403)
129+
return { ok: false, status, reason: 'LICENSE_NOT_ACCEPTED' }
130+
if (status === 404) return { ok: false, status, reason: 'NOT_FOUND' }
131+
if (status === 429) return { ok: false, status, reason: 'RATE_LIMITED' }
132+
return { ok: false, status, reason: 'UNKNOWN' }
133+
} catch (e) {
134+
console.warn('Preflight artifact access failed:', e)
135+
return { ok: false, reason: 'NETWORK' }
136+
}
137+
}
138+
107139
convertHfRepoToCatalogModel(repo: HuggingFaceRepo): CatalogModel {
108140
// Format file size helper
109141
const formatFileSize = (size?: number) => {

0 commit comments

Comments
 (0)