Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions web-app/src/containers/DownloadButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
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 = {
Expand Down Expand Up @@ -37,6 +40,7 @@
const serviceHub = useServiceHub()
const huggingfaceToken = useGeneralSetting((state) => state.huggingfaceToken)
const [isDownloaded, setDownloaded] = useState<boolean>(false)
const navigate = useNavigate()

const quant =
model.quants.find((e) =>
Expand Down Expand Up @@ -75,7 +79,7 @@
if (state.modelId === modelId) setDownloaded(true)
}
)
}, [])

Check warning on line 82 in web-app/src/containers/DownloadButton.tsx

View workflow job for this annotation

GitHub Actions / test-on-macos

React Hook useEffect has a missing dependency: 'modelId'. Either include it or remove the dependency array

Check warning on line 82 in web-app/src/containers/DownloadButton.tsx

View workflow job for this annotation

GitHub Actions / test-on-windows-pr

React Hook useEffect has a missing dependency: 'modelId'. Either include it or remove the dependency array

Check warning on line 82 in web-app/src/containers/DownloadButton.tsx

View workflow job for this annotation

GitHub Actions / test-on-ubuntu

React Hook useEffect has a missing dependency: 'modelId'. Either include it or remove the dependency array

const isRecommendedModel = useCallback((modelId: string) => {
return (extractModelName(modelId)?.toLowerCase() ===
Expand Down Expand Up @@ -107,8 +111,71 @@

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(
Expand Down
44 changes: 43 additions & 1 deletion web-app/src/containers/DownloadManegement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -159,14 +162,53 @@ 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', {
item: state.modelId,
}),
})
},
[removeDownload, removeLocalDownloadingModel, t]
[removeDownload, removeLocalDownloadingModel, t, navigate]
)

const onModelValidationStarted = useCallback(
Expand Down
74 changes: 73 additions & 1 deletion web-app/src/containers/ModelDownloadAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -98,7 +99,78 @@ export const ModelDownloadAction = ({
<div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
title={t('hub:downloadModel')}
onClick={() => {
onClick={async () => {
const preflight = await serviceHub
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can define a generic func for reusability.

.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()
Expand Down
69 changes: 62 additions & 7 deletions web-app/src/routes/settings/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)({
Expand Down Expand Up @@ -63,6 +64,7 @@ function General() {
const [selectedNewPath, setSelectedNewPath] = useState<string | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
const [isValidatingToken, setIsValidatingToken] = useState(false)

useEffect(() => {
const fetchDataFolder = async () => {
Expand Down Expand Up @@ -415,13 +417,66 @@ function General() {
ns: 'settings',
})}
actions={
<Input
id="hf-token"
value={huggingfaceToken || ''}
onChange={(e) => setHuggingfaceToken(e.target.value)}
placeholder={'hf_xxx'}
required
/>
<div className="flex items-center gap-2">
<Input
id="hf-token"
value={huggingfaceToken || ''}
onChange={(e) => setHuggingfaceToken(e.target.value)}
placeholder={'hf_xxx'}
required
/>
<Button
size="sm"
variant={(huggingfaceToken || '').trim() ? 'default' : 'link'}
className={(huggingfaceToken || '').trim()
? 'bg-green-600 text-white hover:bg-green-700'
: ''}
disabled={isValidatingToken}
onClick={async () => {
const token = (huggingfaceToken || '').trim()
if (!token) {
toast.error('Enter a Hugging Face token to validate')
return
}
setIsValidatingToken(true)
const controller = new AbortController()
const timeoutId = setTimeout(
() => controller.abort(),
TOKEN_VALIDATION_TIMEOUT_MS
)
try {
const resp = await fetch('https://huggingface.co/api/whoami-v2', {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
})
if (resp.ok) {
const data = await resp.json()
toast.success('Token valid', {
description: data?.name
? `Signed in as ${data.name}`
: 'Your token is valid.',
})
} else {
toast.error('Token invalid', {
description: `HTTP ${resp.status}`,
})
}
} catch (e) {
const name = (e as { name?: string })?.name
if (name === 'AbortError') {
toast.error('Validation timed out')
} else {
toast.error('Network error while validating token')
}
} finally {
clearTimeout(timeoutId)
setIsValidatingToken(false)
}
}}
>
Validate
</Button>
</div>
}
/>
)}
Expand Down
32 changes: 32 additions & 0 deletions web-app/src/services/models/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
ModelValidationResult,
ModelPlan,
} from './types'
import { PreflightResult } from './types'

// TODO: Replace this with the actual provider later
const defaultProvider = 'llamacpp'
Expand Down Expand Up @@ -105,6 +106,37 @@ export class DefaultModelsService implements ModelsService {
}
}

async preflightArtifactAccess(
url: string,
hfToken?: string
): Promise<PreflightResult> {
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) => {
Expand Down
Loading
Loading