Skip to content

Commit c24ec68

Browse files
committed
feat: add gated Hugging Face model access hints
1 parent 3be998a commit c24ec68

14 files changed

Lines changed: 425 additions & 39 deletions

packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ export interface ComfyDesktop2TelemetryBridge {
7070

7171
export interface ComfyDesktop2Bridge {
7272
isRemote(): boolean
73+
/** Opens a model provider access page in the hosted frontend's browser session.
74+
* Resolves `true` when the host accepts the request. */
75+
openModelAccessPage?: (url: string) => Promise<boolean>
7376
downloadModel?: (
7477
url: string,
7578
filename: string,

packages/comfyui-desktop-bridge-types/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@comfyorg/comfyui-desktop-bridge-types",
3-
"version": "0.1.3",
3+
"version": "0.1.4",
44
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
55
"homepage": "https://comfy.org",
66
"license": "MIT",

src/locales/en/main.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3991,7 +3991,10 @@
39913991
"importNotSupported": "Import Not Supported",
39923992
"copyModelName": "Copy model name",
39933993
"copyUrl": "Copy URL",
3994+
"gatedModelsHint": "Some models are gated. To download them, sign in to Hugging Face and accept the model license agreement.",
3995+
"gatedModelTooltip": "This model is gated and requires you to be logged in to Hugging Face and to accept its license agreement.",
39943996
"locateNode": "Locate node on canvas",
3997+
"openHuggingFaceRepo": "Open Hugging Face repo",
39953998
"expandNodes": "Show referencing nodes",
39963999
"collapseNodes": "Hide referencing nodes",
39974000
"unknownCategory": "Unknown",

src/platform/missingModel/components/MissingModelCard.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,28 @@ import { render, screen, within } from '@testing-library/vue'
33
import userEvent from '@testing-library/user-event'
44
import PrimeVue from 'primevue/config'
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { nextTick } from 'vue'
67
import { createI18n } from 'vue-i18n'
78

89
import enMessages from '@/locales/en/main.json' with { type: 'json' }
910
import type {
1011
MissingModelGroup,
1112
MissingModelViewModel
1213
} from '@/platform/missingModel/types'
14+
import type * as MissingModelDownload from '@/platform/missingModel/missingModelDownload'
15+
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
16+
17+
const mockDownloadModel = vi.hoisted(() => vi.fn())
18+
19+
vi.mock('@/platform/missingModel/missingModelDownload', async () => {
20+
const actual = await vi.importActual<typeof MissingModelDownload>(
21+
'@/platform/missingModel/missingModelDownload'
22+
)
23+
return {
24+
...actual,
25+
downloadModel: mockDownloadModel
26+
}
27+
})
1328

1429
vi.mock('./MissingModelRow.vue', () => ({
1530
default: {
@@ -131,6 +146,7 @@ function getRowsIn(testId: string) {
131146

132147
describe('MissingModelCard', () => {
133148
beforeEach(() => {
149+
vi.clearAllMocks()
134150
mockIsCloud.value = true
135151
})
136152

@@ -241,6 +257,21 @@ describe('MissingModelCard', () => {
241257
screen.queryByTestId('missing-model-actions')
242258
).not.toBeInTheDocument()
243259
})
260+
261+
it('does not show gated model guidance in cloud', async () => {
262+
const group = makeGroup({ withDownloadUrls: true })
263+
const url =
264+
'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
265+
mountCard({ missingModelGroups: [group] })
266+
267+
useMissingModelStore().gatedRepoUrls[url] =
268+
'https://huggingface.co/comfy/test'
269+
await nextTick()
270+
271+
expect(
272+
screen.queryByTestId('missing-model-gated-hint')
273+
).not.toBeInTheDocument()
274+
})
244275
})
245276

246277
describe('Event Handling', () => {
@@ -256,6 +287,7 @@ describe('MissingModelCard', () => {
256287

257288
describe('MissingModelCard (OSS)', () => {
258289
beforeEach(() => {
290+
vi.clearAllMocks()
259291
mockIsCloud.value = false
260292
})
261293

@@ -293,6 +325,38 @@ describe('MissingModelCard (OSS)', () => {
293325
).toBeVisible()
294326
})
295327

328+
it('shows gated model guidance in OSS', async () => {
329+
const group = makeGroup({ withDownloadUrls: true })
330+
const url =
331+
'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
332+
mountCard({ missingModelGroups: [group] })
333+
334+
useMissingModelStore().gatedRepoUrls[url] =
335+
'https://huggingface.co/comfy/test'
336+
await nextTick()
337+
338+
expect(screen.getByTestId('missing-model-gated-hint')).toHaveTextContent(
339+
'Some models are gated. To download them, sign in to Hugging Face and accept the model license agreement.'
340+
)
341+
})
342+
343+
it('routes Download all through the shared missing-model download handler', async () => {
344+
mountCard({
345+
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
346+
})
347+
348+
await userEvent.click(screen.getByTestId('missing-model-download-all'))
349+
350+
expect(mockDownloadModel).toHaveBeenCalledWith(
351+
{
352+
name: 'model.safetensors',
353+
url: 'https://huggingface.co/comfy/test/resolve/main/model.safetensors',
354+
directory: 'checkpoints'
355+
},
356+
{}
357+
)
358+
})
359+
296360
it('hides Download all when no model is downloadable', () => {
297361
mountCard()
298362

src/platform/missingModel/components/MissingModelCard.vue

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
<template>
22
<div class="px-3">
3+
<div
4+
v-if="showGatedModelsHint"
5+
data-testid="missing-model-gated-hint"
6+
role="alert"
7+
class="mb-2 flex gap-2 rounded-md border border-warning-background/30 bg-warning-background/10 p-2.5"
8+
>
9+
<i
10+
aria-hidden="true"
11+
class="mt-0.5 icon-[lucide--lock] size-4 shrink-0 text-warning-background"
12+
/>
13+
<p class="m-0 text-xs/relaxed text-warning-background">
14+
{{ t('rightSidePanel.missingModels.gatedModelsHint') }}
15+
</p>
16+
</div>
17+
318
<div
419
v-if="importableModelRows.length > 0"
520
data-testid="missing-model-importable-rows"
@@ -68,7 +83,7 @@ import type { MissingModelGroup } from '@/platform/missingModel/types'
6883
import { isCloud } from '@/platform/distribution/types'
6984
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
7085
import Button from '@/components/ui/button/Button.vue'
71-
import { downloadModel } from '@/platform/missingModel/missingModelDownload'
86+
import { useMissingModelDownload } from '@/platform/missingModel/composables/useMissingModelDownload'
7287
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
7388
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
7489
import { formatSize } from '@/utils/formatUtil'
@@ -100,6 +115,7 @@ const emit = defineEmits<{
100115
101116
const { t } = useI18n()
102117
const missingModelStore = useMissingModelStore()
118+
const { downloadMissingModel } = useMissingModelDownload()
103119
104120
const sortedModelRows = computed(() =>
105121
missingModelGroups
@@ -122,6 +138,15 @@ const unsupportedModelRows = computed(() =>
122138
isCloud ? sortedModelRows.value.filter((row) => !canCloudImport(row)) : []
123139
)
124140
141+
const showGatedModelsHint = computed(
142+
() =>
143+
!isCloud &&
144+
sortedModelRows.value.some((row) => {
145+
const url = row.model.representative.url
146+
return !!url && !!missingModelStore.gatedRepoUrls[url]
147+
})
148+
)
149+
125150
const downloadableModels = computed(() => {
126151
if (isCloud) return []
127152
@@ -139,7 +164,7 @@ const downloadAllLabel = computed(() => {
139164
140165
function downloadAllModels() {
141166
for (const model of downloadableModels.value) {
142-
downloadModel(model, missingModelStore.folderPaths)
167+
downloadMissingModel(model)
143168
}
144169
}
145170

src/platform/missingModel/components/MissingModelRow.test.ts

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const mockIsCloud = vi.hoisted(() => ({ value: true }))
1919
const mockShowUploadDialog = vi.hoisted(() => vi.fn())
2020
const mockCopyToClipboard = vi.hoisted(() => vi.fn())
2121
const mockDownloadModel = vi.hoisted(() => vi.fn())
22+
const mockFetchModelMetadata = vi.hoisted(() => vi.fn())
23+
const mockOpenGatedRepoPage = vi.hoisted(() => vi.fn())
2224
const mockRootGraph = vi.hoisted<{
2325
value: Record<string, never> | null
2426
}>(() => ({ value: null }))
@@ -104,10 +106,8 @@ vi.mock('@/platform/missingModel/missingModelDownload', async () => {
104106
return {
105107
...actual,
106108
downloadModel: mockDownloadModel,
107-
fetchModelMetadata: vi.fn().mockResolvedValue({
108-
fileSize: null,
109-
gatedRepoUrl: null
110-
})
109+
fetchModelMetadata: mockFetchModelMetadata,
110+
openGatedRepoPage: mockOpenGatedRepoPage
111111
}
112112
})
113113

@@ -177,12 +177,17 @@ function renderRow(
177177
describe('MissingModelRow', () => {
178178
beforeEach(() => {
179179
vi.clearAllMocks()
180+
delete window.__comfyDesktop2
180181
mockIsCloud.value = true
181182
mockRootGraph.value = null
182183
mockApiListeners.clear()
183184
mockGetNodeByExecutionId.mockReset()
184185
mockUploadContext.resolver = undefined
185186
mockUploadCallbacks.onUploadSuccess = undefined
187+
mockFetchModelMetadata.mockResolvedValue({
188+
fileSize: null,
189+
gatedRepoUrl: null
190+
})
186191
})
187192

188193
it('opens the model import dialog from the cloud row', async () => {
@@ -401,6 +406,96 @@ describe('MissingModelRow', () => {
401406
)
402407
})
403408

409+
it('shows a gated HuggingFace access action without replacing download', async () => {
410+
mockIsCloud.value = false
411+
const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
412+
model.representative.url =
413+
'https://huggingface.co/bfl/FLUX.1/resolve/main/model.safetensors'
414+
mockFetchModelMetadata.mockResolvedValueOnce({
415+
fileSize: null,
416+
gatedRepoUrl: 'https://huggingface.co/bfl/FLUX.1'
417+
})
418+
419+
renderRow(model, vi.fn(), false)
420+
const store = useMissingModelStore()
421+
422+
await waitFor(() => {
423+
expect(store.gatedRepoUrls[model.representative.url!]).toBe(
424+
'https://huggingface.co/bfl/FLUX.1'
425+
)
426+
})
427+
428+
const gatedModelTooltip =
429+
'This model is gated and requires you to be logged in to Hugging Face and to accept its license agreement.'
430+
expect(screen.getByTestId('missing-model-gated-access')).toHaveAttribute(
431+
'title',
432+
gatedModelTooltip
433+
)
434+
expect(screen.getByTestId('missing-model-download')).toHaveAttribute(
435+
'title',
436+
gatedModelTooltip
437+
)
438+
})
439+
440+
it('opens gated repo action separately from the download action', async () => {
441+
mockIsCloud.value = false
442+
const user = userEvent.setup()
443+
const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
444+
model.representative.url =
445+
'https://huggingface.co/bfl/FLUX.1/resolve/main/model.safetensors'
446+
447+
renderRow(model, vi.fn(), false)
448+
const store = useMissingModelStore()
449+
store.setGatedRepoUrl(
450+
model.representative.url,
451+
'https://huggingface.co/bfl/FLUX.1'
452+
)
453+
await nextTick()
454+
455+
await user.click(screen.getByTestId('missing-model-gated-access'))
456+
expect(mockOpenGatedRepoPage).toHaveBeenCalledWith(
457+
'https://huggingface.co/bfl/FLUX.1'
458+
)
459+
460+
await user.click(screen.getByTestId('missing-model-download'))
461+
expect(mockDownloadModel).toHaveBeenCalledWith(
462+
{
463+
name: 'model.safetensors',
464+
url: 'https://huggingface.co/bfl/FLUX.1/resolve/main/model.safetensors',
465+
directory: 'checkpoints'
466+
},
467+
{}
468+
)
469+
})
470+
471+
it('delegates the gated repo action to the Desktop bridge when available', async () => {
472+
mockIsCloud.value = false
473+
const user = userEvent.setup()
474+
const openModelAccessPage = vi.fn().mockResolvedValue(true)
475+
window.__comfyDesktop2 = {
476+
isRemote: () => false,
477+
openModelAccessPage
478+
}
479+
const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
480+
model.representative.url =
481+
'https://huggingface.co/bfl/FLUX.1/resolve/main/model.safetensors'
482+
483+
renderRow(model, vi.fn(), false)
484+
const store = useMissingModelStore()
485+
store.setGatedRepoUrl(
486+
model.representative.url,
487+
'https://huggingface.co/bfl/FLUX.1'
488+
)
489+
await nextTick()
490+
491+
await user.click(screen.getByTestId('missing-model-gated-access'))
492+
493+
expect(openModelAccessPage).toHaveBeenCalledWith(
494+
'https://huggingface.co/bfl/FLUX.1'
495+
)
496+
expect(mockOpenGatedRepoPage).not.toHaveBeenCalled()
497+
})
498+
404499
it('shows unknown category metadata for models without a directory', () => {
405500
renderRow(
406501
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]),

0 commit comments

Comments
 (0)