From 958ad06990e53566432ee2f86f01068ae619bf71 Mon Sep 17 00:00:00 2001 From: Roushan Singh Date: Wed, 5 Nov 2025 16:06:54 +0530 Subject: [PATCH 1/3] 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 --- web-app/src/containers/DownloadButton.tsx | 71 +++++++++++++++++- web-app/src/containers/DownloadManegement.tsx | 44 ++++++++++- .../src/containers/ModelDownloadAction.tsx | 74 ++++++++++++++++++- web-app/src/routes/settings/general.tsx | 69 +++++++++++++++-- web-app/src/services/models/default.ts | 32 ++++++++ web-app/src/services/models/types.ts | 18 +++++ 6 files changed, 297 insertions(+), 11 deletions(-) diff --git a/web-app/src/containers/DownloadButton.tsx b/web-app/src/containers/DownloadButton.tsx index 9e1912f69f..50fb09d6f8 100644 --- a/web-app/src/containers/DownloadButton.tsx +++ b/web-app/src/containers/DownloadButton.tsx @@ -10,6 +10,9 @@ import { cn, sanitizeModelId } from '@/lib/utils' import { CatalogModel } from '@/services/models/types' import { DownloadEvent, DownloadState, events } from '@janhq/core' import { useCallback, useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' +import { route } from '@/constants/routes' +import { useNavigate } from '@tanstack/react-router' import { useShallow } from 'zustand/shallow' type ModelProps = { @@ -37,6 +40,7 @@ export function DownloadButtonPlaceholder({ const serviceHub = useServiceHub() const huggingfaceToken = useGeneralSetting((state) => state.huggingfaceToken) const [isDownloaded, setDownloaded] = useState(false) + const navigate = useNavigate() const quant = model.quants.find((e) => @@ -109,8 +113,71 @@ export function DownloadButtonPlaceholder({ const isRecommended = isRecommendedModel(model.model_name) - const handleDownload = () => { - // Immediately set local downloading state + const handleDownload = async () => { + // Preflight check for gated repos/artifacts + const preflight = await serviceHub + .models() + .preflightArtifactAccess(modelUrl, huggingfaceToken) + + if (!preflight.ok) { + const repoPage = `https://huggingface.co/${model.model_name}` + + if (preflight.reason === 'AUTH_REQUIRED') { + toast.error('Hugging Face token required', { + description: + 'This model requires a Hugging Face access token. Add your token in Settings and retry.', + action: { + label: 'Open Settings', + onClick: () => navigate({ to: route.settings.general }), + }, + }) + return + } + + if (preflight.reason === 'LICENSE_NOT_ACCEPTED') { + toast.error('Accept model license on Hugging Face', { + description: + 'You must accept the model’s license on its Hugging Face page before downloading.', + action: { + label: 'Open model page', + onClick: () => window.open(repoPage, '_blank'), + }, + }) + return + } + + if (preflight.reason === 'RATE_LIMITED') { + toast.error('Rate limited by Hugging Face', { + description: + 'You have been rate-limited. Adding a token can increase rate limits. Please try again later.', + action: { + label: 'Open Settings', + onClick: () => navigate({ to: route.settings.general }), + }, + }) + return + } + + if (preflight.reason === 'NOT_FOUND') { + toast.error('File not found', { + description: + 'The requested artifact was not found in the repository. Try another quant or check the model page.', + action: { + label: 'Open model page', + onClick: () => window.open(repoPage, '_blank'), + }, + }) + return + } + + toast.error('Model download error', { + description: + 'We could not start the download. Check your network or try again later.', + }) + return + } + + // Immediately set local downloading state and start download addLocalDownloadingModel(modelId) const mmprojPath = ( model.mmproj_models?.find( diff --git a/web-app/src/containers/DownloadManegement.tsx b/web-app/src/containers/DownloadManegement.tsx index e0d8297404..4b79ebd905 100644 --- a/web-app/src/containers/DownloadManegement.tsx +++ b/web-app/src/containers/DownloadManegement.tsx @@ -13,9 +13,12 @@ import { IconDownload, IconX } from '@tabler/icons-react' import { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { useTranslation } from '@/i18n/react-i18next-compat' +import { useNavigate } from '@tanstack/react-router' +import { route } from '@/constants/routes' export function DownloadManagement() { const { t } = useTranslation() + const navigate = useNavigate() const { open: isLeftPanelOpen } = useLeftPanel() const [isPopoverOpen, setIsPopoverOpen] = useState(false) const serviceHub = useServiceHub() @@ -159,6 +162,45 @@ export function DownloadManagement() { console.debug('onFileDownloadError', state) removeDownload(state.modelId) removeLocalDownloadingModel(state.modelId) + + const anyState = state as unknown as { error?: string } + const err = anyState?.error || '' + + if (err.includes('HTTP status 401')) { + toast.error('Hugging Face token required', { + id: 'download-failed', + description: + 'This model requires a Hugging Face access token. Add your token in Settings and retry.', + action: { + label: 'Open Settings', + onClick: () => navigate({ to: route.settings.general }), + }, + }) + return + } + + if (err.includes('HTTP status 403')) { + toast.error('Accept model license on Hugging Face', { + id: 'download-failed', + description: + 'You must accept the model’s license on its Hugging Face page before downloading.', + }) + return + } + + if (err.includes('HTTP status 429')) { + toast.error('Rate limited by Hugging Face', { + id: 'download-failed', + description: + 'You have been rate-limited. Adding a token can increase rate limits. Please try again later.', + action: { + label: 'Open Settings', + onClick: () => navigate({ to: route.settings.general }), + }, + }) + return + } + toast.error(t('common:toast.downloadFailed.title'), { id: 'download-failed', description: t('common:toast.downloadFailed.description', { @@ -166,7 +208,7 @@ export function DownloadManagement() { }), }) }, - [removeDownload, removeLocalDownloadingModel, t] + [removeDownload, removeLocalDownloadingModel, t, navigate] ) const onModelValidationStarted = useCallback( diff --git a/web-app/src/containers/ModelDownloadAction.tsx b/web-app/src/containers/ModelDownloadAction.tsx index f74bc4aa6b..87c5018673 100644 --- a/web-app/src/containers/ModelDownloadAction.tsx +++ b/web-app/src/containers/ModelDownloadAction.tsx @@ -10,6 +10,7 @@ import { CatalogModel } from '@/services/models/types' import { IconDownload } from '@tabler/icons-react' import { useNavigate } from '@tanstack/react-router' import { useCallback, useMemo } from 'react' +import { toast } from 'sonner' export const ModelDownloadAction = ({ variant, @@ -98,7 +99,78 @@ export const ModelDownloadAction = ({
{ + onClick={async () => { + const preflight = await serviceHub + .models() + .preflightArtifactAccess(variant.path, huggingfaceToken) + + const repoPage = `https://huggingface.co/${model.model_name}` + + if (!preflight.ok) { + if (preflight.reason === 'AUTH_REQUIRED') { + toast.error('Hugging Face token required', { + description: + 'This model requires a Hugging Face access token. Add your token in Settings and retry.', + action: { + label: 'Open Settings', + onClick: () => + navigate({ + to: route.settings.general, + params: {}, + }), + }, + }) + return + } + + if (preflight.reason === 'LICENSE_NOT_ACCEPTED') { + toast.error('Accept model license on Hugging Face', { + description: + 'You must accept the model’s license on its Hugging Face page before downloading.', + action: { + label: 'Open model page', + onClick: () => window.open(repoPage, '_blank'), + }, + }) + return + } + + if (preflight.reason === 'RATE_LIMITED') { + toast.error('Rate limited by Hugging Face', { + description: + 'You have been rate-limited. Adding a token can increase rate limits. Please try again later.', + action: { + label: 'Open Settings', + onClick: () => + navigate({ + to: route.settings.general, + params: {}, + }), + }, + }) + return + } + + if (preflight.reason === 'NOT_FOUND') { + toast.error('File not found', { + description: + 'The requested artifact was not found in the repository. Try another quant or check the model page.', + action: { + label: 'Open model page', + onClick: () => window.open(repoPage, '_blank'), + }, + }) + return + } + + + toast.error('Model download error', { + description: + 'We could not start the download. Check your network or try again later.', + }) + return + } + addLocalDownloadingModel(variant.model_id) serviceHub .models() diff --git a/web-app/src/routes/settings/general.tsx b/web-app/src/routes/settings/general.tsx index 6f30bf7f78..6013957a23 100644 --- a/web-app/src/routes/settings/general.tsx +++ b/web-app/src/routes/settings/general.tsx @@ -31,6 +31,7 @@ import LanguageSwitcher from '@/containers/LanguageSwitcher' import { PlatformFeatures } from '@/lib/platform/const' import { PlatformFeature } from '@/lib/platform/types' import { isRootDir } from '@/utils/path' +const TOKEN_VALIDATION_TIMEOUT_MS = 10_000 // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Route = createFileRoute(route.settings.general as any)({ @@ -63,6 +64,7 @@ function General() { const [selectedNewPath, setSelectedNewPath] = useState(null) const [isDialogOpen, setIsDialogOpen] = useState(false) const [isCheckingUpdate, setIsCheckingUpdate] = useState(false) + const [isValidatingToken, setIsValidatingToken] = useState(false) useEffect(() => { const fetchDataFolder = async () => { @@ -415,13 +417,66 @@ function General() { ns: 'settings', })} actions={ - setHuggingfaceToken(e.target.value)} - placeholder={'hf_xxx'} - required - /> +
+ setHuggingfaceToken(e.target.value)} + placeholder={'hf_xxx'} + required + /> + +
} /> )} diff --git a/web-app/src/services/models/default.ts b/web-app/src/services/models/default.ts index 9c99ddb374..031dc5789b 100644 --- a/web-app/src/services/models/default.ts +++ b/web-app/src/services/models/default.ts @@ -23,6 +23,7 @@ import type { ModelValidationResult, ModelPlan, } from './types' +import { PreflightResult } from './types' // TODO: Replace this with the actual provider later const defaultProvider = 'llamacpp' @@ -104,6 +105,37 @@ export class DefaultModelsService implements ModelsService { } } + async preflightArtifactAccess( + url: string, + hfToken?: string + ): Promise { + try { + const resp = await fetch(url, { + method: 'HEAD', + headers: hfToken + ? { + Authorization: `Bearer ${hfToken}`, + } + : {}, + }) + + if (resp.ok) { + return { ok: true, status: resp.status } + } + + const status = resp.status + if (status === 401) return { ok: false, status, reason: 'AUTH_REQUIRED' } + if (status === 403) + return { ok: false, status, reason: 'LICENSE_NOT_ACCEPTED' } + if (status === 404) return { ok: false, status, reason: 'NOT_FOUND' } + if (status === 429) return { ok: false, status, reason: 'RATE_LIMITED' } + return { ok: false, status, reason: 'UNKNOWN' } + } catch (e) { + console.warn('Preflight artifact access failed:', e) + return { ok: false, reason: 'NETWORK' } + } + } + convertHfRepoToCatalogModel(repo: HuggingFaceRepo): CatalogModel { // Format file size helper const formatFileSize = (size?: number) => { diff --git a/web-app/src/services/models/types.ts b/web-app/src/services/models/types.ts index 4f7ef638f2..bca21ebd16 100644 --- a/web-app/src/services/models/types.ts +++ b/web-app/src/services/models/types.ts @@ -90,6 +90,20 @@ export interface ModelPlan { mode: 'GPU' | 'Hybrid' | 'CPU' | 'Unsupported' } +export type PreflightReason = + | 'AUTH_REQUIRED' + | 'LICENSE_NOT_ACCEPTED' + | 'NOT_FOUND' + | 'RATE_LIMITED' + | 'NETWORK' + | 'UNKNOWN' + +export interface PreflightResult { + ok: boolean + status?: number + reason?: PreflightReason +} + export interface ModelsService { getModel(modelId: string): Promise fetchModels(): Promise @@ -99,6 +113,10 @@ export interface ModelsService { hfToken?: string ): Promise convertHfRepoToCatalogModel(repo: HuggingFaceRepo): CatalogModel + preflightArtifactAccess( + url: string, + hfToken?: string + ): Promise updateModel(modelId: string, model: Partial): Promise pullModel( id: string, From 4deaa6a2181507b80ebd40db86e7ac1ec2a028af Mon Sep 17 00:00:00 2001 From: Roushan Singh Date: Wed, 26 Nov 2025 17:51:40 +0530 Subject: [PATCH 2/3] refactor(web-app): extract download handler in ModelDownloadAction --- .../src/containers/ModelDownloadAction.tsx | 182 +++++++++--------- 1 file changed, 96 insertions(+), 86 deletions(-) diff --git a/web-app/src/containers/ModelDownloadAction.tsx b/web-app/src/containers/ModelDownloadAction.tsx index 87c5018673..e0515457af 100644 --- a/web-app/src/containers/ModelDownloadAction.tsx +++ b/web-app/src/containers/ModelDownloadAction.tsx @@ -55,6 +55,101 @@ export const ModelDownloadAction = ({ [navigate] ) + const handleDownloadModel = useCallback(async () => { + const preflight = await serviceHub + .models() + .preflightArtifactAccess(variant.path, huggingfaceToken) + + const repoPage = `https://huggingface.co/${model.model_name}` + + if (!preflight.ok) { + if (preflight.reason === 'AUTH_REQUIRED') { + toast.error('Hugging Face token required', { + description: + 'This model requires a Hugging Face access token. Add your token in Settings and retry.', + action: { + label: 'Open Settings', + onClick: () => + navigate({ + to: route.settings.general, + params: {}, + }), + }, + }) + return + } + + if (preflight.reason === 'LICENSE_NOT_ACCEPTED') { + toast.error('Accept model license on Hugging Face', { + description: + 'You must accept the model’s license on its Hugging Face page before downloading.', + action: { + label: 'Open model page', + onClick: () => window.open(repoPage, '_blank'), + }, + }) + return + } + + if (preflight.reason === 'RATE_LIMITED') { + toast.error('Rate limited by Hugging Face', { + description: + 'You have been rate-limited. Adding a token can increase rate limits. Please try again later.', + action: { + label: 'Open Settings', + onClick: () => + navigate({ + to: route.settings.general, + params: {}, + }), + }, + }) + return + } + + if (preflight.reason === 'NOT_FOUND') { + toast.error('File not found', { + description: + 'The requested artifact was not found in the repository. Try another quant or check the model page.', + action: { + label: 'Open model page', + onClick: () => window.open(repoPage, '_blank'), + }, + }) + return + } + + toast.error('Model download error', { + description: + 'We could not start the download. Check your network or try again later.', + }) + return + } + + addLocalDownloadingModel(variant.model_id) + serviceHub + .models() + .pullModelWithMetadata( + variant.model_id, + variant.path, + ( + model.mmproj_models?.find( + (e) => e.model_id.toLowerCase() === 'mmproj-f16' + ) || model.mmproj_models?.[0] + )?.path, + huggingfaceToken + ) + }, [ + serviceHub, + variant.path, + variant.model_id, + huggingfaceToken, + model.model_name, + model.mmproj_models, + navigate, + addLocalDownloadingModel, + ]) + const isDownloading = localDownloadingModels.has(variant.model_id) || downloadProcesses.some((e) => e.id === variant.model_id) @@ -99,92 +194,7 @@ export const ModelDownloadAction = ({
{ - const preflight = await serviceHub - .models() - .preflightArtifactAccess(variant.path, huggingfaceToken) - - const repoPage = `https://huggingface.co/${model.model_name}` - - if (!preflight.ok) { - if (preflight.reason === 'AUTH_REQUIRED') { - toast.error('Hugging Face token required', { - description: - 'This model requires a Hugging Face access token. Add your token in Settings and retry.', - action: { - label: 'Open Settings', - onClick: () => - navigate({ - to: route.settings.general, - params: {}, - }), - }, - }) - return - } - - if (preflight.reason === 'LICENSE_NOT_ACCEPTED') { - toast.error('Accept model license on Hugging Face', { - description: - 'You must accept the model’s license on its Hugging Face page before downloading.', - action: { - label: 'Open model page', - onClick: () => window.open(repoPage, '_blank'), - }, - }) - return - } - - if (preflight.reason === 'RATE_LIMITED') { - toast.error('Rate limited by Hugging Face', { - description: - 'You have been rate-limited. Adding a token can increase rate limits. Please try again later.', - action: { - label: 'Open Settings', - onClick: () => - navigate({ - to: route.settings.general, - params: {}, - }), - }, - }) - return - } - - if (preflight.reason === 'NOT_FOUND') { - toast.error('File not found', { - description: - 'The requested artifact was not found in the repository. Try another quant or check the model page.', - action: { - label: 'Open model page', - onClick: () => window.open(repoPage, '_blank'), - }, - }) - return - } - - - toast.error('Model download error', { - description: - 'We could not start the download. Check your network or try again later.', - }) - return - } - - addLocalDownloadingModel(variant.model_id) - serviceHub - .models() - .pullModelWithMetadata( - variant.model_id, - variant.path, - ( - model.mmproj_models?.find( - (e) => e.model_id.toLowerCase() === 'mmproj-f16' - ) || model.mmproj_models?.[0] - )?.path, - huggingfaceToken - ) - }} + onClick={handleDownloadModel} >
From 6d19e47b39e4b7793d7479ba357fe7cb4a90bf92 Mon Sep 17 00:00:00 2001 From: Roushan Singh Date: Thu, 11 Dec 2025 00:19:36 +0530 Subject: [PATCH 3/3] feat: improve error messages and validate button UX --- .../src/containers/ModelDownloadAction.tsx | 8 ++-- web-app/src/routes/settings/general.tsx | 47 ++++++++++++------- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/web-app/src/containers/ModelDownloadAction.tsx b/web-app/src/containers/ModelDownloadAction.tsx index e0515457af..963da1b694 100644 --- a/web-app/src/containers/ModelDownloadAction.tsx +++ b/web-app/src/containers/ModelDownloadAction.tsx @@ -108,9 +108,9 @@ export const ModelDownloadAction = ({ } if (preflight.reason === 'NOT_FOUND') { - toast.error('File not found', { + toast.error('Model file not found', { description: - 'The requested artifact was not found in the repository. Try another quant or check the model page.', + 'The requested model could not be found. Please verify the model URL', action: { label: 'Open model page', onClick: () => window.open(repoPage, '_blank'), @@ -119,9 +119,9 @@ export const ModelDownloadAction = ({ return } - toast.error('Model download error', { + toast.error('Unable to start download', { description: - 'We could not start the download. Check your network or try again later.', + 'Jan encountered an issue. Please check your connection and try again.', }) return } diff --git a/web-app/src/routes/settings/general.tsx b/web-app/src/routes/settings/general.tsx index 6013957a23..39b7b45518 100644 --- a/web-app/src/routes/settings/general.tsx +++ b/web-app/src/routes/settings/general.tsx @@ -426,16 +426,21 @@ function General() { required />
}