Compare commits

..

2 Commits

Author SHA1 Message Date
Benjamin Lu
fefbe7843c refactor(website): dedupe download label and add shared Platform type (#12776)
## Summary

Cleanup of the website download button: deduplicate the label lookup in
`DownloadLocalButton.vue`, and export a `Platform` domain type from
`useDownloadUrl` so the button spec, icon map, and analytics all consume
it instead of loose `string`s.

## Changes

- **What**:
- Hoist `t('download.hero.downloadLocal', locale)` into a single `label`
computed, reused by the fallback aria-labels and the button text
(previously evaluated in two places)
- Export `Platform` (`'windows' | 'mac'`) from `useDownloadUrl`;
`ButtonSpec.key`, the `ICONS` map (`Record<Platform, string>`), and
`captureDownloadClick` now consume it — adding a platform without an
icon becomes a compile error, and typo'd platform names can't reach
PostHog
- Drop the redundant `aria-hidden="true"` on the icon (`alt=""` already
marks it decorative)

## Review Focus

`ppformula-text-center` on the icon is intentionally kept — it is
positional (`position: relative; top: 0.19em`), not typographic, and
keeps the icon aligned with the optically-shifted PP Formula text.

No behavior change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-11 16:20:14 +00:00
imick-io
6445690ed3 fix(website): rebrand "Comfy Local" to "Comfy Desktop" (#12794)
## Summary

- Rebrand "Comfy Local" → "Comfy Desktop" across all user-facing copy on
`apps/website`. The nav already showed "Comfy Desktop"; this pulls the
rest of the site in line.
- Updates the homepage product card, the `/cloud` page FAQ + pricing
copy, the `/download` page SEO title/keywords, and matching Storybook
stories. Both `en` and `zh-CN`.
- Internal identifiers left untouched (i18n key names, the
`src/components/product/local/` folder, the `Product = 'local' | …` type
discriminator, and the `media.comfy.org/website/local/*` CDN paths).

## Test plan

- [ ] `pnpm --filter @comfyorg/website dev` and visually verify:
- [ ] `/` — product card section reads "Comfy Desktop" / "SEE DESKTOP
FEATURES"
- [ ] `/cloud` (en + zh-CN) — every FAQ "Local" reference now reads
"Comfy Desktop"
  - [ ] `/download` — page `<title>` and meta keywords show new values
- [ ] Nav + footer still read "Comfy Desktop" / "DOWNLOAD DESKTOP"
(unchanged)
- [ ] `pnpm --filter @comfyorg/website test:e2e -- navigation.spec.ts`
passes
- [ ] Storybook: `Website/Common/ProductCard` and
`Website/Common/FooterLinkColumn` render with new labels
- [ ] Regenerate any screenshot baselines that snapshot the homepage
product cards or the `/cloud` FAQ section

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-11 16:15:19 +00:00
38 changed files with 941 additions and 2181 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -21,7 +21,7 @@ export const Default: Story = {
args: {
title: 'Product',
links: [
{ label: 'Local', href: '/local' },
{ label: 'Desktop', href: '/download' },
{ label: 'Cloud', href: '/cloud' },
{ label: 'API', href: '/api' },
{ label: 'Enterprise', href: '/enterprise' }

View File

@@ -12,9 +12,9 @@ const meta: Meta<typeof ProductCard> = {
})
],
args: {
title: 'Comfy\nLocal',
title: 'Comfy\nDesktop',
description: 'Run ComfyUI on your own hardware.',
cta: 'SEE LOCAL FEATURES',
cta: 'SEE DESKTOP FEATURES',
href: '#',
bg: 'bg-primary-warm-gray'
}
@@ -31,9 +31,9 @@ export const AllCards: Story = {
template: `
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<ProductCard
title="Comfy\nLocal"
title="Comfy\nDesktop"
description="Run ComfyUI on your own hardware."
cta="SEE LOCAL FEATURES"
cta="SEE DESKTOP FEATURES"
href="#"
bg="bg-primary-warm-gray"
/>

View File

@@ -3,6 +3,7 @@ import type { Locale } from '../../../i18n/translations'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import type { Platform } from '../../../composables/useDownloadUrl'
import {
downloadUrls,
useDownloadUrl
@@ -18,13 +19,15 @@ const { locale = 'en', class: customClass = '' } = defineProps<{
const { downloadUrl, platform, showFallback } = useDownloadUrl()
const ICONS = {
const label = computed(() => t('download.hero.downloadLocal', locale))
const ICONS: Record<Platform, string> = {
windows: '/icons/os/windows.svg',
mac: '/icons/os/apple.svg'
} as const
}
interface ButtonSpec {
key: string
key: Platform
href: string
icon: string
ariaLabel?: string
@@ -41,19 +44,18 @@ const buttons = computed<ButtonSpec[]>(() => {
]
}
if (showFallback.value) {
const label = t('download.hero.downloadLocal', locale)
return [
{
key: 'windows',
href: downloadUrls.windows,
icon: ICONS.windows,
ariaLabel: `${label} — Windows`
ariaLabel: `${label.value} — Windows`
},
{
key: 'mac',
href: downloadUrls.macArm,
icon: ICONS.mac,
ariaLabel: `${label} — macOS`
ariaLabel: `${label.value} — macOS`
}
]
}
@@ -77,11 +79,8 @@ const buttons = computed<ButtonSpec[]>(() => {
:src="btn.icon"
alt=""
class="ppformula-text-center size-5 -translate-y-0.75"
aria-hidden="true"
/>
<span class="ppformula-text-center">{{
t('download.hero.downloadLocal', locale)
}}</span>
<span class="ppformula-text-center">{{ label }}</span>
</span>
</BrandButton>
</template>

View File

@@ -7,13 +7,13 @@ export const downloadUrls = {
macArm: 'https://download.comfy.org/mac/dmg/arm64'
} as const
type DetectedPlatform = 'windows' | 'mac' | null
export type Platform = 'windows' | 'mac'
function isMobile(ua: string): boolean {
return /iphone|ipad|ipod|android/.test(ua)
}
function detectPlatform(ua: string): DetectedPlatform {
function detectPlatform(ua: string): Platform | null {
if (isMobile(ua)) return null
if (ua.includes('win')) return 'windows'
if (ua.includes('macintosh') || ua.includes('mac os x')) return 'mac'
@@ -23,7 +23,7 @@ function detectPlatform(ua: string): DetectedPlatform {
// TODO: Only Windows x64 and macOS arm64 are available today.
// When Linux and/or macIntel builds are added, extend detection and URLs here.
export function useDownloadUrl() {
const platform = ref<DetectedPlatform>(null)
const platform = ref<Platform | null>(null)
const detected = ref(false)
const isMobileUa = ref(false)

View File

@@ -174,16 +174,16 @@ const translations = {
'zh-CN': '掌控每个模型、每个节点、每个步骤、每个输出。'
},
'products.local.title': {
en: 'Comfy\nLocal',
'zh-CN': 'Comfy\n本地版'
en: 'Comfy\nDesktop',
'zh-CN': 'Comfy\n桌面版'
},
'products.local.description': {
en: 'Run ComfyUI on your own hardware.',
'zh-CN': '在您自己的硬件上运行 ComfyUI。'
},
'products.local.cta': {
en: 'SEE LOCAL FEATURES',
'zh-CN': '查看本地版属性'
en: 'SEE DESKTOP FEATURES',
'zh-CN': '查看桌面版属性'
},
'products.cloud.title': {
en: 'Comfy\nCloud',
@@ -1057,18 +1057,18 @@ const translations = {
'zh-CN': 'Cloud 与本地运行 ComfyUI 有什么区别?'
},
'cloud.faq.2.a': {
en: 'Cloud runs on powerful remote GPUs and is accessible from any device. Local runs entirely on your computer, giving you full control and offline use.',
en: 'Cloud runs on powerful remote GPUs and is accessible from any device. Comfy Desktop runs entirely on your computer, giving you full control and offline use.',
'zh-CN':
'Cloud 在强大的远程 GPU 上运行,可从任何设备访问。本地版完全在您的电脑上运行,提供完全控制和离线使用。'
'Cloud 在强大的远程 GPU 上运行,可从任何设备访问。Comfy 桌面版完全在您的电脑上运行,提供完全控制和离线使用。'
},
'cloud.faq.3.q': {
en: 'Which version should I choose, Comfy Cloud or local ComfyUI (self-hosted)?',
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI自托管'
en: 'Which version should I choose, Comfy Cloud or Comfy Desktop?',
'zh-CN': '我应该选择 Comfy Cloud 还是 Comfy 桌面版'
},
'cloud.faq.3.a': {
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nComfy Desktop is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
'zh-CN':
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\nComfy 桌面版可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
},
'cloud.faq.4.q': {
en: 'Do I need a GPU or a strong computer to use Comfy Cloud?',
@@ -1091,9 +1091,9 @@ const translations = {
'zh-CN': '我可以在 Comfy Cloud 上使用现有的工作流吗?'
},
'cloud.faq.6.a': {
en: 'Yes, your workflows work across Local and Cloud. Just note that only the most popular custom nodes are supported for now, but more will be added soon.',
en: 'Yes, your workflows work across Desktop and Cloud. Just note that only the most popular custom nodes are supported for now, but more will be added soon.',
'zh-CN':
'可以,您的工作流在本地和云端都能使用。请注意,目前仅支持最热门的自定义节点,但很快会添加更多。'
'可以,您的工作流在桌面版和云端都能使用。请注意,目前仅支持最热门的自定义节点,但很快会添加更多。'
},
'cloud.faq.7.q': {
en: 'Are all ComfyUI extensions and custom nodes supported?',
@@ -1145,9 +1145,9 @@ const translations = {
'zh-CN': '合作伙伴节点积分和我的 Cloud 订阅有什么区别?'
},
'cloud.faq.12.a': {
en: 'Comfy Cloud has a credit system that is used for both Partner nodes (formerly API nodes) and running workflows on cloud.\n1. Partner Nodes (Pay-as-you-go): These nodes (formerly called API nodes) run third-party models via API calls and can be used on both Comfy Cloud and Local/Self-Hosted ComfyUI. Each node has its own usage cost, determined by the API provider, and we directly match their pricing.\n2. Running workflows on cloud: Exclusive to Comfy Cloud, you get a set amount of credits per month, with the amount differing based on your plan. More credits can be topped up anytime. Credits are only used up for GPU time while workflows are running — not while editing or building them. No idle costs, no setup, and no infrastructure to manage.',
en: 'Comfy Cloud has a credit system that is used for both Partner nodes (formerly API nodes) and running workflows on cloud.\n1. Partner Nodes (Pay-as-you-go): These nodes (formerly called API nodes) run third-party models via API calls and can be used on both Comfy Cloud and Comfy Desktop. Each node has its own usage cost, determined by the API provider, and we directly match their pricing.\n2. Running workflows on cloud: Exclusive to Comfy Cloud, you get a set amount of credits per month, with the amount differing based on your plan. More credits can be topped up anytime. Credits are only used up for GPU time while workflows are running — not while editing or building them. No idle costs, no setup, and no infrastructure to manage.',
'zh-CN':
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和本地/自托管 ComfyUI 上使用。每个节点有其自身的使用成本,由 API 提供商决定,我们直接匹配他们的定价。\n2. 在云端运行工作流Comfy Cloud 专属,您每月获得一定数量的积分,数量根据您的计划而不同。积分可随时充值。积分仅在工作流运行时用于 GPU 时间——编辑或构建时不消耗。无闲置成本,无需设置,无需管理基础设施。'
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和 Comfy 桌面版上使用。每个节点有其自身的使用成本,由 API 提供商决定,我们直接匹配他们的定价。\n2. 在云端运行工作流Comfy Cloud 专属,您每月获得一定数量的积分,数量根据您的计划而不同。积分可随时充值。积分仅在工作流运行时用于 GPU 时间——编辑或构建时不消耗。无闲置成本,无需设置,无需管理基础设施。'
},
'cloud.faq.13.q': {
en: 'Can I cancel my subscription?',
@@ -1411,9 +1411,9 @@ const translations = {
'zh-CN': '合作伙伴节点'
},
'pricing.included.feature8.description': {
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and Comfy Desktop. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
'zh-CN':
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置且与月度订阅积分通用。积分可在 Comfy Cloud 和 Comfy 桌面版间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
},
'pricing.included.feature9.title': {
en: 'Job queue',

View File

@@ -11,9 +11,9 @@ import { t } from '../i18n/translations'
---
<BaseLayout
title="Download Comfy — Run AI Locally"
title="Download Comfy Desktop — Run AI on Your Hardware"
description={t('download.hero.subtitle', 'en')}
keywords={['comfyui app', 'comfyui desktop app', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux', 'comfyui local']}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
>
<CloudBannerSection />
<HeroSection client:load />

View File

@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
---
<BaseLayout
title="下载 Comfy"
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
description={t('download.hero.subtitle', 'zh-CN')}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>

View File

@@ -2,6 +2,8 @@ import posthog from 'posthog-js'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
import type { Platform } from '@/composables/useDownloadUrl'
const POSTHOG_KEY =
import.meta.env.PUBLIC_POSTHOG_KEY ??
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
@@ -39,7 +41,7 @@ export function capturePageview() {
}
}
export function captureDownloadClick(platform: string) {
export function captureDownloadClick(platform: Platform) {
if (!initialized) return
try {
posthog.capture('website:download_button_clicked', { platform })

View File

@@ -1,60 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [100, 100],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["cloud_importable_model.safetensors"]
},
{
"id": 2,
"type": "LoadImage",
"pos": [560, 100],
"size": [400, 314],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": null },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["cloud_unknown_model.safetensors", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "cloud_importable_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -60,16 +60,14 @@ export const TestIds = {
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model',
missingModelExpand: 'missing-model-expand',
missingModelImport: 'missing-model-import',
missingModelImportableRows: 'missing-model-importable-rows',
missingModelLocate: 'missing-model-locate',
missingModelReferenceCount: 'missing-model-reference-count',
missingModelUnsupportedSection:
'missing-model-import-not-supported-section',
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelDownload: 'missing-model-download',
missingModelActions: 'missing-model-actions',
missingModelDownloadAll: 'missing-model-download-all',
missingModelRefresh: 'missing-model-header-refresh',
missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
swapNodeGroupCount: 'swap-node-group-count',

View File

@@ -5,16 +5,12 @@ import {
countAssetRequestsByTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
const WORKFLOW = 'missing/nested_subgraph_installed_model'
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
id: 'test-lotus-depth-d-v1-1',
@@ -38,10 +34,6 @@ test.describe(
{ tag: ['@cloud', '@vue-nodes'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setServerFlags({
model_upload_button_enabled: true,
private_models_enabled: true
})
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
@@ -96,113 +88,5 @@ test.describe(
await expect(errorsTab).toBeHidden()
})
test('separates importable cloud models from unsupported rows', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const importableRows = missingModelsGroup.getByTestId(
TestIds.dialogs.missingModelImportableRows
)
const unsupportedSection = missingModelsGroup.getByTestId(
TestIds.dialogs.missingModelUnsupportedSection
)
await expect(
importableRows.getByRole('button', {
name: CLOUD_IMPORTABLE_MODEL_NAME,
exact: true
})
).toBeVisible()
await expect(
importableRows.getByTestId(TestIds.dialogs.missingModelImport)
).toBeVisible()
await expect(unsupportedSection).toBeVisible()
await expect(
unsupportedSection.getByText('Import Not Supported')
).toBeVisible()
await expect(
unsupportedSection.getByText(
/Nodes that reference the models below do not support imported models/
)
).toBeVisible()
await expect(
unsupportedSection.getByText(CLOUD_UNKNOWN_MODEL_NAME)
).toBeVisible()
await expect(
unsupportedSection.getByText('Unknown', { exact: true })
).toBeVisible()
await expect(
unsupportedSection.getByRole('button', {
name: 'Load Image',
exact: true
})
).toBeVisible()
await expect(
unsupportedSection.getByTestId(TestIds.dialogs.missingModelImport)
).toHaveCount(0)
})
test('opens cloud import with missing-model replacement context', async ({
comfyPage
}) => {
await comfyPage.modelLibrary.mockModelFolders([
{ name: 'checkpoints', folders: [] }
])
await comfyPage.page.route('**/assets/remote-metadata?**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
content_length: 1024,
final_url:
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors',
content_type: 'application/octet-stream',
filename: 'replacement.safetensors',
tags: ['loras']
})
})
)
await loadWorkflowAndOpenErrorsTab(comfyPage, IMPORT_SECTIONS_WORKFLOW)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await missingModelsGroup
.getByTestId(TestIds.dialogs.missingModelImport)
.click()
const urlInput = comfyPage.page.locator(
'[data-attr="upload-model-step1-url-input"]'
)
await expect(urlInput).toBeVisible()
await urlInput.fill(
'https://huggingface.co/comfy/test/resolve/main/replacement.safetensors'
)
await comfyPage.page
.locator('[data-attr="upload-model-step1-continue-button"]')
.click()
const uploadDialog = comfyPage.page.getByRole('dialog', {
name: /Import a model/
})
await expect(
uploadDialog.getByText(
`This import will replace ${CLOUD_IMPORTABLE_MODEL_NAME} in:`
)
).toBeVisible()
await expect(uploadDialog.getByText('Load Checkpoint')).toBeVisible()
await expect(uploadDialog.getByText('- ckpt_name')).toBeVisible()
await expect(
uploadDialog.getByText(
/Locked to (Checkpoints|checkpoints) for this missing model/
)
).toBeVisible()
})
}
)

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -12,18 +11,6 @@ import {
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
).toHaveText(String(count))
}
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
@@ -47,14 +34,15 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
).toHaveText(/\S/)
})
test('Should display model name and metadata', async ({ comfyPage }) => {
test('Should display model name with referencing node count', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(getModelLabel(modelsGroup)).toBeVisible()
await expect(modelsGroup.getByText('checkpoints')).toBeVisible()
await expect(modelsGroup).toContainText(/fake_model\.safetensors\s*\(\d+\)/)
})
test('Should expand model row to show referencing nodes', async ({
@@ -65,33 +53,32 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
'missing/missing_models_with_nodes'
)
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelLocate
)
const expandButton = modelsGroup.getByTestId(
await expect(locateButton.first()).toBeHidden()
const expandButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelExpand
)
await expect(expandButton.first()).toBeVisible()
await expectReferenceBadge(modelsGroup, 2)
await expandButton.first().click()
await expect(
modelsGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(2)
await expect(locateButton.first()).toBeVisible()
})
test('Should copy model URL to clipboard', async ({ comfyPage }) => {
test('Should copy model name to clipboard', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await interceptClipboardWrite(comfyPage.page)
const copyButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
const copyButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyName
)
await expect(copyButton.first()).toBeVisible()
await copyButton.first().dispatchEvent('click')
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toContain('/api/devtools/')
expect(copiedText).toContain('fake_model.safetensors')
})
test.describe('OSS-specific', { tag: '@oss' }, () => {
@@ -100,9 +87,9 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const copyUrlButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
await expect(copyUrlButton.first()).toBeVisible()
})
@@ -115,7 +102,6 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelDownload
)
await expect(downloadButton.first()).toBeVisible()
await expect(downloadButton.first()).toHaveText('Download')
})
test('Should render Download all and Refresh actions for one downloadable model', async ({

View File

@@ -1,5 +1,4 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -9,18 +8,6 @@ import {
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
).toHaveText(String(count))
}
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -143,9 +130,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
'missing/missing_models_from_node_properties'
)
const copyUrlButton = comfyPage.page.getByRole('button', {
name: 'Copy URL'
})
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
)
await expect(copyUrlButton.first()).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
@@ -169,7 +156,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
@@ -179,7 +168,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await comfyPage.canvas.click()
await expectReferenceBadge(missingModelGroup, 2)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(2\)/
)
})
test('Pasting a bypassed node does not add a new error', async ({
@@ -261,17 +252,14 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expectReferenceBadge(missingModelGroup, 2)
await expect(missingModelGroup).toContainText(/\(2\)/)
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
await node1.click('title')
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(1)
await expect(missingModelGroup).toContainText(/\(1\)/)
await comfyPage.canvas.click()
await expectReferenceBadge(missingModelGroup, 2)
await expect(missingModelGroup).toContainText(/\(2\)/)
})
})
@@ -396,7 +384,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await comfyPage.page.evaluate((value) => {
const hostNode = window.app!.graph!.getNodeById(2)
@@ -449,7 +439,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle('Subgraph with Promoted Missing Model')

View File

@@ -539,7 +539,7 @@ describe('TabErrors.vue', () => {
).toBeInTheDocument()
})
it('renders missing model Refresh in the header and Download all in the card when models are downloadable', () => {
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
const missingModel = {
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
@@ -557,8 +557,11 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByTestId('missing-model-header-refresh')).toBeVisible()
expect(
screen.queryByTestId('missing-model-header-refresh')
).not.toBeInTheDocument()
expect(screen.getByTestId('missing-model-actions')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
})
})

View File

@@ -94,11 +94,9 @@
showMissingModelHeaderRefresh
"
data-testid="missing-model-header-refresh"
variant="muted-textonly"
size="icon"
class="mr-2 shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingModels.refresh')"
:disabled="missingModelStore.isRefreshingMissingModels"
variant="secondary"
size="sm"
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click.stop="handleMissingModelRefresh"
@@ -114,6 +112,7 @@
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
{{ t('rightSidePanel.missingModels.refresh') }}
</Button>
<span
v-if="
@@ -247,6 +246,7 @@
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateAssetNode"
/>
@@ -301,9 +301,11 @@ import { cn } from '@comfyorg/tailwind-utils'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
@@ -317,6 +319,7 @@ import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCar
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -344,6 +347,7 @@ const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
@@ -367,6 +371,12 @@ function getGroupSize(group: ErrorGroup) {
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
}
const showNodeIdBadge = computed(
() =>
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
NodeBadgeMode.None
)
function isExecutionItemListGroup(group: ErrorGroup) {
return (
group.type === 'execution' &&
@@ -453,13 +463,20 @@ const {
swapNodeGroups
} = useErrorGroups(searchQuery)
const missingModelDownloadableModels = computed(() => {
if (isCloud) return []
return getDownloadableModels(missingModelGroups.value)
})
const showMissingModelHeaderRefresh = computed(
() => !isCloud && missingModelGroups.value.length > 0
() =>
!isCloud &&
missingModelGroups.value.length > 0 &&
missingModelDownloadableModels.value.length === 0
)
function handleMissingModelRefresh() {
if (missingModelStore.isRefreshingMissingModels) return
void missingModelStore.refreshMissingModels()
}

View File

@@ -3089,13 +3089,6 @@
"loadingModels": "Loading {type}...",
"maxFileSize": "Max file size: {size}",
"maxFileSizeValue": "1 GB",
"missingModelImportTypeLocked": "Locked to {type} for this missing model",
"missingModelImportTypeMismatchAlreadyImported": "This file is already imported as {actual}.",
"missingModelImportTypeMismatchNextAction": "Try importing a different {required} model that this node can use.",
"missingModelImportTypeMismatchRequired": "This node requires {required}, so this import cannot resolve the missing model.",
"missingModelImportTypeMismatchTitle": "This model cannot resolve the missing model.",
"missingModelImportUnknownType": "another model type",
"missingModelImportWillReplace": "This import will replace {model} in:",
"modelAssociatedWithLink": "The model associated with the link you provided:",
"modelName": "Model Name",
"modelNamePlaceholder": "Enter a name for this model",
@@ -3649,7 +3642,9 @@
},
"missingModels": {
"urlPlaceholder": "Paste Model URL (Civitai or Hugging Face)",
"readyToApply": "Ready to apply",
"or": "OR",
"useFromLibrary": "Use from Library",
"usingFromLibrary": "Using from Library",
"unsupportedUrl": "Only Civitai and Hugging Face URLs are supported.",
"metadataFetchFailed": "Failed to retrieve metadata. Please check the link and try again.",
"import": "Import",
@@ -3659,7 +3654,7 @@
"typeMismatch": "This model seems to be a \"{detectedType}\". Are you sure?",
"importAnyway": "Import Anyway",
"alreadyExistsInCategory": "This model already exists in \"{category}\"",
"customNodeDownloadDisabled": "Nodes that reference the models below do not support imported models. Open the node to choose a supported built-in model, or replace it with a standard node that supports imported models.",
"customNodeDownloadDisabled": "Cloud environment does not support model imports for custom nodes in this section. Please use standard loader nodes or substitute with a model from the library below.",
"importNotSupported": "Import Not Supported",
"copyModelName": "Copy model name",
"copyUrl": "Copy URL",

View File

@@ -1,80 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
import UploadModelConfirmation from './UploadModelConfirmation.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false,
escapeParameter: true
})
const SingleSelectStub = {
name: 'SingleSelect',
props: {
disabled: Boolean,
modelValue: String
},
template:
'<button type="button" :disabled="disabled">{{ modelValue }}</button>'
}
describe('UploadModelConfirmation', () => {
it('shows missing-model replacement context and locks the model type', () => {
const uploadContext: UploadModelDialogContext = {
kind: 'missing-model-resolution',
missingModelName: 'segm/person_yolov8m-seg.pt',
requiredModelType: 'Ultralytics/bbox',
replacementTargets: [
{
nodeId: '1',
nodeLabel: 'Checkpoint Loader',
widgetName: 'ckpt_name'
}
]
}
render(UploadModelConfirmation, {
props: {
modelValue: 'Ultralytics/bbox',
metadata: {
content_length: 100,
final_url: 'https://civitai.com/models/123',
filename: 'replacement.safetensors'
},
uploadContext,
'onUpdate:modelValue': () => {}
},
global: {
plugins: [i18n],
stubs: {
SingleSelect: SingleSelectStub
}
}
})
expect(screen.getByText('segm/person_yolov8m-seg.pt')).toBeInTheDocument()
expect(screen.getByText('Checkpoint Loader')).toBeInTheDocument()
expect(screen.getByText('- ckpt_name')).toBeInTheDocument()
const modelTypeSelect = screen.getByRole('button', {
name: 'Ultralytics/bbox'
})
expect(modelTypeSelect).toBeDisabled()
expect(
screen.getByText((_content, element) => {
return (
element?.textContent ===
'Locked to Ultralytics/bbox for this missing model'
)
})
).toBeInTheDocument()
})
})

View File

@@ -22,50 +22,16 @@
</div>
</div>
<div
v-if="isMissingModelResolution"
class="flex flex-col gap-2 rounded-lg bg-secondary-background px-4 py-3"
>
<i18n-t
keypath="assetBrowser.missingModelImportWillReplace"
tag="p"
class="m-0 text-base-foreground"
>
<template #model>
<span>{{ missingModelName }}</span>
</template>
</i18n-t>
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="target in replacementTargets"
:key="`${target.nodeId}:${target.widgetName}`"
class="flex min-w-0 items-center gap-2"
>
<span class="min-w-0 truncate text-muted-foreground">
{{ target.nodeLabel }}
</span>
<span class="shrink-0 text-muted-foreground">
- {{ target.widgetName }}
</span>
</li>
</ul>
</div>
<!-- Model Type Selection -->
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<label>
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
</label>
<i
aria-hidden="true"
class="icon-[lucide--circle-question-mark] text-muted-foreground"
/>
<span v-if="!isMissingModelResolution" class="text-muted-foreground">
{{ $t('assetBrowser.notSureLeaveAsIs') }}
</span>
</div>
<div class="flex items-center gap-2">
<label>
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
</label>
<i class="icon-[lucide--circle-question-mark] text-muted-foreground" />
<span class="text-muted-foreground">
{{ $t('assetBrowser.notSureLeaveAsIs') }}
</span>
</div>
<SingleSelect
v-model="modelValue"
@@ -75,37 +41,23 @@
: $t('assetBrowser.modelTypeSelectorPlaceholder')
"
:options="modelTypes"
:disabled="isLoading || isMissingModelResolution"
:disabled="isLoading"
:content-style="selectContentStyle"
data-attr="upload-model-step2-type-selector"
/>
<i18n-t
v-if="isMissingModelResolution"
keypath="assetBrowser.missingModelImportTypeLocked"
tag="span"
class="text-muted-foreground"
>
<template #type>
<span>{{ selectedModelTypeLabel }}</span>
</template>
</i18n-t>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
const { uploadContext } = defineProps<{
defineProps<{
metadata?: AssetMetadata
previewImage?: string
uploadContext?: UploadModelDialogContext
}>()
const modelValue = defineModel<string | undefined>()
@@ -113,27 +65,4 @@ const modelValue = defineModel<string | undefined>()
const { modelTypes, isLoading } = useModelTypes()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
const isMissingModelResolution = computed(
() => uploadContext?.kind === 'missing-model-resolution'
)
const missingModelName = computed(() =>
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.missingModelName
: ''
)
const replacementTargets = computed(() =>
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.replacementTargets
: []
)
const selectedModelTypeLabel = computed(() => {
const value =
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.requiredModelType
: modelValue.value
return (
modelTypes.value.find((option) => option.value === value)?.name ?? value
)
})
</script>

View File

@@ -17,7 +17,6 @@
v-model="selectedModelType"
:metadata="wizardData.metadata"
:preview-image="wizardData.previewImage"
:upload-context="uploadContext"
/>
<!-- Step 3: Upload Progress -->
@@ -25,7 +24,6 @@
v-else-if="currentStep === 3 && uploadStatus != null"
:result="uploadStatus"
:error="uploadError"
:type-mismatch="uploadTypeMismatch"
:metadata="wizardData.metadata"
:model-type="selectedModelType"
:preview-image="wizardData.previewImage"
@@ -41,7 +39,6 @@
:can-fetch-metadata="canFetchMetadata"
:can-upload-model="canUploadModel"
:upload-status="uploadStatus"
:can-import-another="!isMissingModelResolution"
@back="goToPreviousStep"
@fetch-metadata="handleFetchMetadata"
@upload="handleUploadModel"
@@ -52,47 +49,29 @@
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { onMounted } from 'vue'
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type {
UploadModelDialogContext,
UploadModelSuccess
} from '@/platform/assets/composables/useUploadModelWizard'
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { modelTypes, fetchModelTypes } = useModelTypes()
const { uploadContext } = defineProps<{
uploadContext?: UploadModelDialogContext
}>()
const emit = defineEmits<{
'upload-success': [result: UploadModelSuccess]
'upload-success': []
}>()
const isMissingModelResolution = computed(
() => uploadContext?.kind === 'missing-model-resolution'
)
const requiredModelType = computed(() =>
uploadContext?.kind === 'missing-model-resolution'
? uploadContext.requiredModelType
: undefined
)
const {
currentStep,
isFetchingMetadata,
isUploading,
uploadStatus,
uploadError,
uploadTypeMismatch,
wizardData,
selectedModelType,
canFetchMetadata,
@@ -101,18 +80,16 @@ const {
uploadModel,
goToPreviousStep,
resetWizard
} = useUploadModelWizard(modelTypes, {
requiredModelType: requiredModelType.value
})
} = useUploadModelWizard(modelTypes)
async function handleFetchMetadata() {
await fetchMetadata()
}
async function handleUploadModel() {
const result = await uploadModel()
if (result) {
emit('upload-success', result)
const success = await uploadModel()
if (success) {
emit('upload-success')
}
}

View File

@@ -1,60 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import UploadModelFooter from './UploadModelFooter.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
function renderFooter(
props: Partial<InstanceType<typeof UploadModelFooter>['$props']> = {}
) {
render(UploadModelFooter, {
props: {
currentStep: 3,
isFetchingMetadata: false,
isUploading: false,
canFetchMetadata: true,
canUploadModel: true,
uploadStatus: 'success',
...props
},
global: {
plugins: [i18n],
stubs: {
VideoHelpDialog: true
}
}
})
}
describe('UploadModelFooter', () => {
it('allows importing another model by default', () => {
renderFooter()
expect(screen.getByRole('button', { name: 'Import Another' })).toBeEnabled()
})
it('disables importing another model when the upload resolves a missing model', () => {
renderFooter({ canImportAnother: false })
expect(
screen.getByRole('button', { name: 'Import Another' })
).toBeDisabled()
})
it('shows recovery actions for upload errors', () => {
renderFooter({ uploadStatus: 'error' })
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
})
})

View File

@@ -73,7 +73,6 @@
variant="muted-textonly"
size="lg"
data-attr="upload-model-step3-import-another-button"
:disabled="!canImportAnother"
@click="emit('importAnother')"
>
{{ $t('assetBrowser.importAnother') }}
@@ -91,24 +90,6 @@
}}
</Button>
</template>
<template v-else-if="currentStep === 3 && uploadStatus === 'error'">
<Button
variant="muted-textonly"
size="lg"
data-attr="upload-model-step3-back-button"
@click="emit('back')"
>
{{ $t('g.back') }}
</Button>
<Button
variant="secondary"
size="lg"
data-attr="upload-model-step3-close-button"
@click="emit('close')"
>
{{ $t('g.close') }}
</Button>
</template>
<VideoHelpDialog
v-model="showCivitaiHelp"
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
@@ -132,14 +113,13 @@ import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'
const showCivitaiHelp = ref(false)
const showHuggingFaceHelp = ref(false)
const { canImportAnother = true } = defineProps<{
defineProps<{
currentStep: number
isFetchingMetadata: boolean
isUploading: boolean
canFetchMetadata: boolean
canUploadModel: boolean
uploadStatus?: 'processing' | 'success' | 'error'
canImportAnother?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,74 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import UploadModelProgress from './UploadModelProgress.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false,
escapeParameter: true
})
describe('UploadModelProgress', () => {
it('renders missing-model type mismatch labels', () => {
render(UploadModelProgress, {
props: {
result: 'error',
typeMismatch: {
importedModelType: 'loras',
importedModelTypeLabel: 'LoRA/Custom',
requiredModelType: 'Ultralytics/bbox',
requiredModelTypeLabel: 'Ultralytics/bbox'
}
},
global: {
plugins: [i18n]
}
})
expect(
screen.getByText('This model cannot resolve the missing model.')
).toBeInTheDocument()
expect(screen.getByText('LoRA/Custom')).toBeInTheDocument()
expect(screen.getAllByText('Ultralytics/bbox').length).toBeGreaterThan(0)
expect(
screen.getByText((_content, element) => {
return (
element?.textContent ===
'Try importing a different Ultralytics/bbox model that this node can use.'
)
})
).toBeInTheDocument()
})
it('uses fallback copy when the imported model type label is unknown', () => {
render(UploadModelProgress, {
props: {
result: 'error',
typeMismatch: {
requiredModelType: 'checkpoints',
requiredModelTypeLabel: 'Checkpoint'
}
},
global: {
plugins: [i18n]
}
})
expect(screen.getByText('another model type')).toBeInTheDocument()
expect(
screen.getByText((_content, element) => {
return (
element?.textContent ===
'This file is already imported as another model type.'
)
})
).toBeInTheDocument()
})
})

View File

@@ -1,12 +1,5 @@
<template>
<div
:class="
cn(
'flex flex-1 flex-col gap-6 text-sm text-muted-foreground',
isTypeMismatchError && 'min-h-full justify-center'
)
"
>
<div class="flex flex-1 flex-col gap-6 text-sm text-muted-foreground">
<!-- Processing State (202 async download in progress) -->
<div v-if="result === 'processing'" class="flex flex-col gap-2">
<p class="m-0 font-bold">
@@ -74,51 +67,8 @@
v-else-if="result === 'error'"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<i
aria-hidden="true"
class="text-error"
:class="
typeMismatch
? 'icon-[lucide--circle-alert] size-12'
: 'icon-[lucide--x-circle] size-16'
"
/>
<div
v-if="typeMismatch"
class="flex max-w-2xl flex-col gap-3 text-center"
>
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.missingModelImportTypeMismatchTitle') }}
</p>
<i18n-t
keypath="assetBrowser.missingModelImportTypeMismatchAlreadyImported"
tag="p"
class="m-0 text-sm text-muted"
>
<template #actual>
<span>{{ actualModelTypeLabel }}</span>
</template>
</i18n-t>
<i18n-t
keypath="assetBrowser.missingModelImportTypeMismatchRequired"
tag="p"
class="m-0 text-sm text-muted"
>
<template #required>
<span>{{ typeMismatch.requiredModelTypeLabel }}</span>
</template>
</i18n-t>
<i18n-t
keypath="assetBrowser.missingModelImportTypeMismatchNextAction"
tag="p"
class="m-0 text-sm text-base-foreground"
>
<template #required>
<span>{{ typeMismatch.requiredModelTypeLabel }}</span>
</template>
</i18n-t>
</div>
<div v-else class="text-center">
<i class="icon-[lucide--x-circle] text-6xl text-error" />
<div class="text-center">
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.uploadFailed') }}
</p>
@@ -131,26 +81,13 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { UploadModelTypeMismatch } from '@/platform/assets/composables/useUploadModelWizard'
const { typeMismatch } = defineProps<{
defineProps<{
result: 'processing' | 'success' | 'error'
error?: string
metadata?: AssetMetadata
modelType?: string
previewImage?: string
typeMismatch?: UploadModelTypeMismatch | null
}>()
const { t } = useI18n()
const isTypeMismatchError = computed(() => typeMismatch != null)
const actualModelTypeLabel = computed(
() =>
typeMismatch?.importedModelTypeLabel ??
t('assetBrowser.missingModelImportUnknownType')
)
</script>

View File

@@ -3,28 +3,17 @@ import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
import type {
UploadModelDialogContext,
UploadModelSuccess
} from '@/platform/assets/composables/useUploadModelWizard'
import UploadModelUpgradeModal from '@/platform/assets/components/UploadModelUpgradeModal.vue'
import UploadModelUpgradeModalHeader from '@/platform/assets/components/UploadModelUpgradeModalHeader.vue'
import { useDialogStore } from '@/stores/dialogStore'
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
export function useModelUpload(
onUploadSuccess?: (result: UploadModelSuccess) => Promise<unknown> | void,
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
onUploadSuccess?: () => Promise<unknown> | void
) {
const dialogStore = useDialogStore()
const { flags } = useFeatureFlags()
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
function resolveUploadContext() {
return typeof uploadContext === 'function' ? uploadContext() : uploadContext
}
function showUploadDialog() {
if (!flags.privateModelsEnabled) {
dialogStore.showDialog({
@@ -44,9 +33,8 @@ export function useModelUpload(
headerComponent: UploadModelDialogHeader,
component: UploadModelDialog,
props: {
uploadContext: resolveUploadContext(),
onUploadSuccess: async (result: UploadModelSuccess) => {
await onUploadSuccess?.(result)
onUploadSuccess: async () => {
await onUploadSuccess?.()
}
},
dialogComponentProps: {

View File

@@ -1,18 +1,14 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, ref } from 'vue'
import type { App } from 'vue'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { AsyncUploadResponse } from '@/platform/assets/schemas/assetSchema'
import { useUploadModelWizard } from './useUploadModelWizard'
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetMetadata: vi.fn(),
uploadAssetAsync: vi.fn(),
uploadAssetPreviewImage: vi.fn()
}
@@ -49,52 +45,18 @@ vi.mock('@/i18n', () => ({
d: (date: Date) => date.toISOString()
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
describe('useUploadModelWizard', () => {
const modelTypes = ref([{ name: 'Checkpoint', value: 'checkpoints' }])
const mountedApps: App<Element>[] = []
function setupWithI18n<T>(factory: () => T): T {
let result: T | undefined
const host = document.createElement('div')
const app = createApp({
setup() {
result = factory()
return () => null
}
})
app.use(
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
)
app.mount(host)
mountedApps.push(app)
if (result === undefined) {
throw new Error('Composable setup did not run')
}
return result
}
function setupUploadModelWizard(
...args: Parameters<typeof useUploadModelWizard>
): ReturnType<typeof useUploadModelWizard> {
return setupWithI18n(() => useUploadModelWizard(...args))
}
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
for (const app of mountedApps.splice(0)) {
app.unmount()
}
})
it('updates uploadStatus to success when async download completes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
@@ -109,18 +71,11 @@ describe('useUploadModelWizard', () => {
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = setupUploadModelWizard(modelTypes)
const wizard = useUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toEqual({
filename: 'model',
modelType: 'checkpoints',
taskId: 'task-123',
status: 'processing'
})
await wizard.uploadModel()
expect(wizard.uploadStatus.value).toBe('processing')
@@ -163,7 +118,7 @@ describe('useUploadModelWizard', () => {
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = setupUploadModelWizard(modelTypes)
const wizard = useUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.com/models/99999'
wizard.selectedModelType.value = 'checkpoints'
@@ -214,7 +169,7 @@ describe('useUploadModelWizard', () => {
}
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse)
const wizard = setupUploadModelWizard(modelTypes)
const wizard = useUploadModelWizard(modelTypes)
wizard.wizardData.value.url = 'https://civitai.red/models/12345'
wizard.selectedModelType.value = 'checkpoints'
@@ -223,124 +178,4 @@ describe('useUploadModelWizard', () => {
expect(assetService.uploadAssetAsync).toHaveBeenCalled()
expect(wizard.uploadStatus.value).toBe('processing')
})
it('keeps a required model type when metadata suggests another type', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.getAssetMetadata).mockResolvedValue({
content_length: 100,
final_url: 'https://civitai.com/models/12345',
filename: 'lora.safetensors',
tags: ['loras']
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
]),
{ requiredModelType: 'checkpoints' }
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.fetchMetadata()
expect(wizard.selectedModelType.value).toBe('checkpoints')
})
it('uploads with the required model type even if selection changes', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
}
})
const wizard = setupUploadModelWizard(modelTypes, {
requiredModelType: 'checkpoints'
})
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'loras'
const result = await wizard.uploadModel()
expect(assetService.uploadAssetAsync).toHaveBeenCalledWith(
expect.objectContaining({
tags: ['models', 'checkpoints'],
user_metadata: expect.objectContaining({
model_type: 'checkpoints'
})
})
)
expect(result?.modelType).toBe('checkpoints')
})
it('blocks a missing-model import when an existing asset has the wrong model type', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-lora',
name: 'model.safetensors',
tags: ['models', 'loras']
}
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
]),
{ requiredModelType: 'checkpoints' }
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(wizard.uploadStatus.value).toBe('error')
expect(wizard.uploadTypeMismatch.value).toEqual({
importedModelType: 'loras',
importedModelTypeLabel: 'LoRA',
requiredModelType: 'checkpoints',
requiredModelTypeLabel: 'Checkpoint'
})
})
it('does not block sync imports as mismatches without a required model type', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-lora',
name: 'model.safetensors',
tags: ['models', 'loras']
}
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
])
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
wizard.selectedModelType.value = 'checkpoints'
const result = await wizard.uploadModel()
expect(result).toEqual({
filename: 'model',
modelType: 'checkpoints',
status: 'success'
})
expect(wizard.uploadStatus.value).toBe('success')
expect(wizard.uploadTypeMismatch.value).toBeNull()
})
})

View File

@@ -5,10 +5,7 @@ import { useI18n } from 'vue-i18n'
import { st } from '@/i18n'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
import type {
AssetItem,
AssetMetadata
} from '@/platform/assets/schemas/assetSchema'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import type { ImportSource } from '@/platform/assets/types/importSource'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
@@ -29,54 +26,16 @@ interface ModelTypeOption {
value: string
}
const MODEL_ROOT_TAG = 'models'
export interface UploadModelSuccess {
filename: string
modelType?: string
taskId?: string
status: 'processing' | 'success'
}
export interface UploadModelTypeMismatch {
importedModelType?: string
importedModelTypeLabel?: string
requiredModelType: string
requiredModelTypeLabel: string
}
interface MissingModelUploadContext {
kind: 'missing-model-resolution'
missingModelName: string
requiredModelType: string
replacementTargets: Array<{
nodeId: string
nodeLabel: string
widgetName: string
}>
}
export type UploadModelDialogContext = MissingModelUploadContext
interface UploadModelWizardOptions {
requiredModelType?: string
}
export function useUploadModelWizard(
modelTypes: Ref<ModelTypeOption[]>,
options: UploadModelWizardOptions = {}
) {
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const { t } = useI18n()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
const requiredModelType = options.requiredModelType
const currentStep = ref(1)
const isFetchingMetadata = ref(false)
const isUploading = ref(false)
const uploadStatus = ref<'processing' | 'success' | 'error'>()
const uploadError = ref('')
const uploadTypeMismatch = ref<UploadModelTypeMismatch | null>(null)
let stopAsyncWatch: (() => void) | undefined
const wizardData = ref<WizardData>({
@@ -85,10 +44,7 @@ export function useUploadModelWizard(
tags: []
})
const selectedModelType = ref<string | undefined>(requiredModelType)
const resolvedModelType = computed(
() => requiredModelType ?? selectedModelType.value
)
const selectedModelType = ref<string>()
const importSources: ImportSource[] = [
civitaiImportSource,
@@ -109,29 +65,16 @@ export function useUploadModelWizard(
() => wizardData.value.url,
() => {
uploadError.value = ''
uploadTypeMismatch.value = null
}
)
if (requiredModelType) {
watch(
selectedModelType,
(value) => {
if (value !== requiredModelType) {
selectedModelType.value = requiredModelType
}
},
{ immediate: true }
)
}
// Validation - only enable Continue when URL matches a supported source
const canFetchMetadata = computed(() => {
return detectedSource.value !== null
})
const canUploadModel = computed(() => {
return !!resolvedModelType.value
return !!selectedModelType.value
})
async function fetchMetadata() {
@@ -185,9 +128,7 @@ export function useUploadModelWizard(
wizardData.value.previewImage = metadata.preview_image
// Pre-fill model type from metadata tags if available
if (requiredModelType) {
selectedModelType.value = requiredModelType
} else if (metadata.tags && metadata.tags.length > 0) {
if (metadata.tags && metadata.tags.length > 0) {
wizardData.value.tags = metadata.tags
// Try to detect model type from tags
const typeTag = metadata.tags.find((tag) =>
@@ -242,10 +183,10 @@ export function useUploadModelWizard(
}
async function refreshModelCaches() {
if (!resolvedModelType.value) return
if (!selectedModelType.value) return
const providers = modelToNodeStore.getAllNodeProviders(
resolvedModelType.value
selectedModelType.value
)
const results = await Promise.allSettled(
providers.map((provider) =>
@@ -262,61 +203,24 @@ export function useUploadModelWizard(
})
}
function getModelTypeLabel(modelType: string): string {
return (
modelTypes.value.find((type) => type.value === modelType)?.name ??
modelType
)
}
function getImportedModelType(asset: AssetItem): string | undefined {
const knownType = asset.tags.find(
(tag) =>
tag !== MODEL_ROOT_TAG &&
modelTypes.value.some((type) => type.value === tag)
)
return knownType ?? asset.tags.find((tag) => tag !== MODEL_ROOT_TAG)
}
function blockMismatchedImportedModel(
asset: AssetItem,
requiredType: string
): boolean {
if (asset.tags.includes(requiredType)) return false
const importedType = getImportedModelType(asset)
uploadStatus.value = 'error'
uploadError.value = ''
uploadTypeMismatch.value = {
importedModelType: importedType,
importedModelTypeLabel: importedType
? getModelTypeLabel(importedType)
: undefined,
requiredModelType: requiredType,
requiredModelTypeLabel: getModelTypeLabel(requiredType)
}
return true
}
async function uploadModel(): Promise<UploadModelSuccess | null> {
if (isUploading.value) return null
async function uploadModel(): Promise<boolean> {
if (isUploading.value) return false
if (!canUploadModel.value) {
return null
return false
}
const source = detectedSource.value
if (!source) {
uploadError.value = t('assetBrowser.noValidSourceDetected')
return null
return false
}
isUploading.value = true
uploadTypeMismatch.value = null
let uploadSuccess: UploadModelSuccess | null = null
try {
const modelType = resolvedModelType.value
const tags = modelType ? ['models', modelType] : ['models']
const tags = selectedModelType.value
? ['models', selectedModelType.value]
: ['models']
const filename =
wizardData.value.metadata?.filename ||
wizardData.value.metadata?.name ||
@@ -326,7 +230,7 @@ export function useUploadModelWizard(
const userMetadata = {
source: source.type,
source_url: wizardData.value.url,
model_type: modelType
model_type: selectedModelType.value
}
const result = await assetService.uploadAssetAsync({
@@ -337,20 +241,14 @@ export function useUploadModelWizard(
})
if (result.type === 'async' && result.task.status !== 'completed') {
if (modelType) {
if (selectedModelType.value) {
assetDownloadStore.trackDownload(
result.task.task_id,
modelType,
selectedModelType.value,
filename
)
}
uploadStatus.value = 'processing'
uploadSuccess = {
filename,
modelType,
taskId: result.task.task_id,
status: 'processing'
}
stopAsyncWatch?.()
let resolved = false
@@ -390,23 +288,8 @@ export function useUploadModelWizard(
stopAsyncWatch = stop
}
} else {
if (
requiredModelType &&
result.type === 'sync' &&
modelType &&
blockMismatchedImportedModel(result.asset, modelType)
) {
currentStep.value = 3
return null
}
uploadStatus.value = 'success'
await refreshModelCaches()
uploadSuccess = {
filename,
modelType,
status: 'success'
}
}
currentStep.value = 3
} catch (error) {
@@ -418,7 +301,7 @@ export function useUploadModelWizard(
} finally {
isUploading.value = false
}
return uploadSuccess
return uploadStatus.value !== 'error'
}
function goToPreviousStep() {
@@ -435,13 +318,12 @@ export function useUploadModelWizard(
isUploading.value = false
uploadStatus.value = undefined
uploadError.value = ''
uploadTypeMismatch.value = null
wizardData.value = {
url: '',
name: '',
tags: []
}
selectedModelType.value = requiredModelType
selectedModelType.value = undefined
}
return {
@@ -451,7 +333,6 @@ export function useUploadModelWizard(
isUploading,
uploadStatus,
uploadError,
uploadTypeMismatch,
wizardData,
selectedModelType,

View File

@@ -1,37 +1,24 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen, within } from '@testing-library/vue'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type {
MissingModelGroup,
MissingModelViewModel
} from '@/platform/missingModel/types'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
vi.mock('./MissingModelRow.vue', () => ({
default: {
name: 'MissingModelRow',
template: `
<div
data-testid="model-row"
class="model-row"
:data-model-name="model.name"
:data-is-asset-supported="isAssetSupported"
:data-directory="directory"
:data-can-cloud-import="canCloudImport"
>
<button
class="locate-trigger"
@click="$emit('locate-model', model?.representative?.nodeId)"
>
Locate
</button>
</div>
`,
props: ['model', 'directory', 'isAssetSupported', 'canCloudImport'],
template:
'<div class="model-row" :data-show-node-id-badge="showNodeIdBadge" :data-is-asset-supported="isAssetSupported" :data-directory="directory"><button class="locate-trigger" @click="$emit(\'locate-model\', model?.representative?.nodeId)">Locate</button></div>',
props: ['model', 'directory', 'showNodeIdBadge', 'isAssetSupported'],
emits: ['locate-model']
}
}))
@@ -48,7 +35,21 @@ import MissingModelCard from './MissingModelCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
messages: {
en: {
rightSidePanel: {
missingModels: {
importNotSupported: 'Import Not Supported',
customNodeDownloadDisabled:
'Cloud environment does not support model imports for custom nodes.',
unknownCategory: 'Unknown Category',
downloadAll: 'Download all',
refresh: 'Refresh',
refreshing: 'Refreshing missing models.'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
@@ -105,6 +106,7 @@ function makeGroup(
function mountCard(
props: Partial<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}> = {},
onLocateModel?: (nodeId: string) => void
) {
@@ -112,6 +114,7 @@ function mountCard(
return render(MissingModelCard, {
props: {
missingModelGroups: [makeGroup()],
showNodeIdBadge: false,
...props,
...(onLocateModel ? { onLocateModel } : {})
},
@@ -121,115 +124,62 @@ function mountCard(
})
}
function getRows() {
return screen.queryAllByTestId('model-row')
}
function getRowsIn(testId: string) {
return within(screen.getByTestId(testId)).getAllByTestId('model-row')
}
describe('MissingModelCard', () => {
beforeEach(() => {
mockIsCloud.value = true
})
describe('Rendering & Props', () => {
it('passes the model directory to rows', () => {
mockIsCloud.value = false
mountCard({
it('renders directory name in category header', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ directory: 'loras' })]
})
expect(getRows()[0].getAttribute('data-directory')).toBe('loras')
expect(container.textContent).toContain('loras')
})
it('renders translated unknown category when directory is null', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ directory: null })]
})
expect(container.textContent).toContain('Unknown Category')
})
it('renders model count in category header', () => {
const { container } = mountCard({
missingModelGroups: [
makeGroup({ modelNames: ['a.safetensors', 'b.safetensors'] })
]
})
expect(container.textContent).toContain('(2)')
})
it('renders correct number of MissingModelRow components', () => {
mountCard({
const { container } = mountCard({
missingModelGroups: [
makeGroup({
modelNames: ['a.safetensors', 'b.safetensors', 'c.safetensors']
})
]
})
expect(getRows()).toHaveLength(3)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelectorAll('.model-row')).toHaveLength(3)
})
it('flattens multiple groups into rows', () => {
mockIsCloud.value = false
mountCard({
it('renders multiple groups', () => {
const { container } = mountCard({
missingModelGroups: [
makeGroup({ directory: 'checkpoints' }),
makeGroup({ directory: 'loras' })
]
})
expect(getRows()).toHaveLength(2)
})
it('sorts importable rows by model type order in cloud', () => {
mountCard({
missingModelGroups: [
makeGroup({ directory: null, modelNames: ['unknown.safetensors'] }),
makeGroup({ directory: 'loras', modelNames: ['lora.safetensors'] }),
makeGroup({
directory: 'checkpoints',
modelNames: ['checkpoint.safetensors']
})
]
})
expect(
getRowsIn('missing-model-importable-rows').map((row) =>
row.getAttribute('data-model-name')
)
).toEqual(['checkpoint.safetensors', 'lora.safetensors'])
})
it('moves cloud rows without import context into the unsupported section', () => {
mountCard({
missingModelGroups: [
makeGroup({
directory: 'checkpoints',
modelNames: ['importable.safetensors']
}),
makeGroup({
directory: null,
modelNames: ['unknown.safetensors']
}),
makeGroup({
directory: 'loras',
isAssetSupported: false,
modelNames: ['custom-node-model.safetensors']
})
]
})
expect(
getRowsIn('missing-model-importable-rows').map((row) =>
row.getAttribute('data-model-name')
)
).toEqual(['importable.safetensors'])
const unsupportedSection = screen.getByTestId(
'missing-model-import-not-supported-section'
)
expect(
within(unsupportedSection)
.getAllByTestId('model-row')
.map((row) => row.getAttribute('data-model-name'))
).toEqual(['custom-node-model.safetensors', 'unknown.safetensors'])
expect(
within(unsupportedSection).getByText('Import Not Supported')
).toBeInTheDocument()
expect(
within(unsupportedSection).getByText(
/Nodes that reference the models below do not support imported models/
)
).toBeInTheDocument()
expect(container.textContent).toContain('checkpoints')
expect(container.textContent).toContain('loras')
})
it('renders zero rows when missingModelGroups is empty', () => {
mountCard({ missingModelGroups: [] })
expect(getRows()).toHaveLength(0)
const { container } = mountCard({ missingModelGroups: [] })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelectorAll('.model-row')).toHaveLength(0)
})
it('hides bulk actions in cloud', () => {
@@ -241,6 +191,43 @@ describe('MissingModelCard', () => {
screen.queryByTestId('missing-model-actions')
).not.toBeInTheDocument()
})
it('passes props correctly to MissingModelRow children', () => {
const { container } = mountCard({ showNodeIdBadge: true })
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const row = container.querySelector('.model-row')
expect(row).not.toBeNull()
expect(row!.getAttribute('data-show-node-id-badge')).toBe('true')
expect(row!.getAttribute('data-is-asset-supported')).toBe('true')
expect(row!.getAttribute('data-directory')).toBe('checkpoints')
})
})
describe('Asset Unsupported Group', () => {
it('shows "Import Not Supported" header for unsupported groups', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(container.textContent).toContain('Import Not Supported')
})
it('shows info notice for unsupported groups', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(container.textContent).toContain(
'Cloud environment does not support model imports'
)
})
it('hides info notice for supported groups', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: true })]
})
expect(container.textContent).not.toContain(
'Cloud environment does not support model imports'
)
})
})
describe('Event Handling', () => {
@@ -264,43 +251,79 @@ describe('MissingModelCard (OSS)', () => {
})
it('shows directory name instead of "Import Not Supported" for unsupported groups', () => {
mountCard({
const { container } = mountCard({
missingModelGroups: [
makeGroup({ directory: 'checkpoints', isAssetSupported: false })
]
})
expect(getRows()[0].getAttribute('data-directory')).toBe('checkpoints')
expect(container.textContent).toContain('checkpoints')
expect(container.textContent).not.toContain('Import Not Supported')
})
it('passes null directory for unknown category rows in OSS', () => {
mountCard({
it('hides info notice for unsupported groups', () => {
const { container } = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(container.textContent).not.toContain(
'Cloud environment does not support model imports'
)
})
it('renders unknown category for null directory in OSS', () => {
const { container } = mountCard({
missingModelGroups: [
makeGroup({ directory: null, isAssetSupported: false })
]
})
expect(getRows()[0].hasAttribute('data-directory')).toBe(false)
expect(container.textContent).toContain('Unknown Category')
expect(container.textContent).not.toContain('Import Not Supported')
})
it('shows Download all at the bottom when one model is downloadable', () => {
it('shows bulk actions when one model is downloadable', () => {
mountCard({
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
})
const actions = screen.getByTestId('missing-model-actions')
expect(actions).toBeVisible()
expect(
within(actions).getByRole('button', { name: /Download all/ })
).toBeVisible()
expect(screen.getByRole('button', { name: /Download all/ })).toBeVisible()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible()
})
it('hides Download all when no model is downloadable', () => {
it('hides bulk actions when no model is downloadable', () => {
mountCard()
expect(
screen.queryByRole('button', { name: /Download all/ })
).not.toBeInTheDocument()
expect(
screen.queryByTestId('missing-model-actions')
screen.queryByRole('button', { name: 'Refresh' })
).not.toBeInTheDocument()
})
it('refreshes missing models from the action bar', async () => {
mountCard({
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
})
const store = useMissingModelStore()
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }))
expect(store.refreshMissingModels).toHaveBeenCalled()
})
it('keeps the Refresh button focusable and announces refresh progress', async () => {
mountCard({
missingModelGroups: [makeGroup({ withDownloadUrls: true })]
})
const store = useMissingModelStore()
store.isRefreshingMissingModels = true
await nextTick()
const refreshButton = screen.getByRole('button', { name: 'Refresh' })
expect(refreshButton).toHaveAttribute('aria-disabled', 'true')
expect(refreshButton).toHaveAttribute('aria-busy', 'true')
expect(screen.getByRole('status')).toHaveTextContent(
'Refreshing missing models.'
)
})
})

View File

@@ -1,49 +1,9 @@
<template>
<div class="px-4 pb-2">
<div
v-if="importableModelRows.length > 0"
data-testid="missing-model-importable-rows"
class="flex flex-col gap-1 overflow-hidden py-2"
>
<MissingModelRow
v-for="row in importableModelRows"
:key="row.key"
:model="row.model"
:directory="row.directory"
:is-asset-supported="row.isAssetSupported"
:can-cloud-import="true"
@locate-model="emit('locateModel', $event)"
/>
</div>
<div
v-if="unsupportedModelRows.length > 0"
data-testid="missing-model-import-not-supported-section"
class="flex flex-col gap-1 border-t border-interface-stroke pt-3"
>
<div class="mb-1">
<p class="m-0 text-sm font-semibold text-warning-background">
{{ t('rightSidePanel.missingModels.importNotSupported') }}
</p>
<p class="m-0 mt-1 text-xs/relaxed text-muted-foreground">
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
</p>
</div>
<MissingModelRow
v-for="row in unsupportedModelRows"
:key="row.key"
:model="row.model"
:directory="row.directory"
:is-asset-supported="row.isAssetSupported"
:can-cloud-import="false"
@locate-model="emit('locateModel', $event)"
/>
</div>
<div
v-if="downloadableModels.length > 0"
data-testid="missing-model-actions"
class="flex items-center pt-2"
class="flex items-center gap-2 border-b border-interface-stroke py-2"
>
<Button
data-testid="missing-model-download-all"
@@ -55,6 +15,100 @@
<i aria-hidden="true" class="icon-[lucide--download] size-4 shrink-0" />
<span class="truncate">{{ downloadAllLabel }}</span>
</Button>
<!-- Keep this focusable while refreshing so the live status remains discoverable. -->
<Button
data-testid="missing-model-refresh"
variant="secondary"
size="sm"
class="h-8 w-28 shrink-0 rounded-lg text-sm"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click="handleRefreshClick"
>
<DotSpinner
v-if="missingModelStore.isRefreshingMissingModels"
aria-hidden="true"
duration="1s"
:size="12"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
{{ t('rightSidePanel.missingModels.refresh') }}
</Button>
<span role="status" aria-live="polite" class="sr-only">
{{
missingModelStore.isRefreshingMissingModels
? t('rightSidePanel.missingModels.refreshing')
: ''
}}
</span>
</div>
<!-- Category groups (by directory) -->
<div
v-for="group in missingModelGroups"
:key="`${group.isAssetSupported ? 'supported' : 'unsupported'}::${group.directory ?? '__unknown__'}`"
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
>
<!-- Category header -->
<div class="flex h-8 w-full items-center">
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
(isCloud && !group.isAssetSupported) || group.directory === null
? 'text-warning-background'
: 'text-destructive-background-hover'
"
>
<span v-if="isCloud && !group.isAssetSupported">
{{ t('rightSidePanel.missingModels.importNotSupported') }}
({{ group.models.length }})
</span>
<span v-else>
<i
v-if="group.directory === null"
aria-hidden="true"
class="mr-1 icon-[lucide--triangle-alert] size-3.5 align-text-bottom"
/>
{{
group.directory ??
t('rightSidePanel.missingModels.unknownCategory')
}}
({{ group.models.length }})
</span>
</p>
</div>
<!-- Asset unsupported group notice -->
<div
v-if="isCloud && !group.isAssetSupported"
data-testid="missing-model-import-unsupported"
class="flex items-start gap-1.5 px-0.5 py-1 pl-2"
>
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--info] size-3.5 shrink-0 text-muted-foreground"
/>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
</span>
</div>
<!-- Model rows -->
<div class="flex flex-col gap-1 overflow-hidden pl-2">
<MissingModelRow
v-for="model in group.models"
:key="model.name"
:model="model"
:directory="group.directory"
:show-node-id-badge="showNodeIdBadge"
:is-asset-supported="group.isAssetSupported"
@locate-model="emit('locateModel', $event)"
/>
</div>
</div>
</div>
</template>
@@ -66,28 +120,15 @@ import type { MissingModelGroup } from '@/platform/missingModel/types'
import { isCloud } from '@/platform/distribution/types'
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { downloadModel } from '@/platform/missingModel/missingModelDownload'
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { formatSize } from '@/utils/formatUtil'
interface MissingModelRowEntry {
key: string
model: MissingModelGroup['models'][number]
directory: string | null
isAssetSupported: boolean
}
const MODEL_TYPE_SORT_ORDER = [
'checkpoints',
'loras',
'vae',
'text_encoders',
'diffusion_models'
] as const
const { missingModelGroups } = defineProps<{
const { missingModelGroups, showNodeIdBadge } = defineProps<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -97,27 +138,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const missingModelStore = useMissingModelStore()
const sortedModelRows = computed(() =>
missingModelGroups
.flatMap((group) =>
group.models.map((model, index) => ({
key: getModelRowKey(group, model, index),
model,
directory: group.directory,
isAssetSupported: group.isAssetSupported
}))
)
.sort((a, b) => compareModelRows(a, b))
)
const importableModelRows = computed(() =>
sortedModelRows.value.filter((row) => !isCloud || canCloudImport(row))
)
const unsupportedModelRows = computed(() =>
isCloud ? sortedModelRows.value.filter((row) => !canCloudImport(row)) : []
)
const downloadableModels = computed(() => {
if (isCloud) return []
@@ -139,37 +159,7 @@ function downloadAllModels() {
}
}
function getModelRowKey(
group: MissingModelGroup,
model: MissingModelGroup['models'][number],
index: number
) {
const supportKey = group.isAssetSupported ? 'supported' : 'unsupported'
return [
supportKey,
group.directory ?? '__unknown__',
model.name,
String(index)
].join('::')
}
function compareModelRows(a: MissingModelRowEntry, b: MissingModelRowEntry) {
return (
getModelTypeSortIndex(a.directory) - getModelTypeSortIndex(b.directory) ||
(a.directory ?? '').localeCompare(b.directory ?? '') ||
a.model.name.localeCompare(b.model.name)
)
}
function getModelTypeSortIndex(directory: string | null) {
if (directory === null) return Number.MAX_SAFE_INTEGER
const index = MODEL_TYPE_SORT_ORDER.indexOf(
directory as (typeof MODEL_TYPE_SORT_ORDER)[number]
)
return index === -1 ? MODEL_TYPE_SORT_ORDER.length : index
}
function canCloudImport(row: MissingModelRowEntry) {
return row.isAssetSupported && row.directory !== null
function handleRefreshClick() {
void missingModelStore.refreshMissingModels()
}
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div class="flex flex-col gap-2">
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
<span class="text-xs font-bold text-muted-foreground">
{{ t('rightSidePanel.missingModels.or') }}
</span>
</div>
<Select
:model-value="modelValue"
:disabled="options.length === 0"
@update:model-value="handleSelect"
>
<SelectTrigger
size="md"
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
>
<SelectValue
:placeholder="t('rightSidePanel.missingModels.useFromLibrary')"
/>
</SelectTrigger>
<SelectContent>
<template v-if="options.length > 4" #prepend>
<div class="px-1 pb-1.5">
<div
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
>
<i
aria-hidden="true"
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="filterQuery"
type="text"
:aria-label="t('g.searchPlaceholder', { subject: '' })"
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('g.searchPlaceholder', { subject: '' })"
@keydown.stop
/>
</div>
</div>
</template>
<SelectItem
v-for="option in filteredOptions"
:key="option.value"
:value="option.value"
class="text-xs"
>
{{ option.name }}
</SelectItem>
<div
v-if="filteredOptions.length === 0"
role="status"
class="px-3 py-2 text-xs text-muted-foreground"
>
{{ t('g.noResultsFound') }}
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFuse } from '@vueuse/integrations/useFuse'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
const { options, showDivider = false } = defineProps<{
modelValue: string | undefined
options: { name: string; value: string }[]
showDivider?: boolean
}>()
const emit = defineEmits<{
select: [value: string]
}>()
const { t } = useI18n()
const filterQuery = ref('')
watch(
() => options.length,
(len) => {
if (len <= 4) filterQuery.value = ''
}
)
const { results: fuseResults } = useFuse(filterQuery, () => options, {
fuseOptions: {
keys: ['name'],
threshold: 0.4,
ignoreLocation: true
},
matchAllWhenSearchEmpty: true
})
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
function handleSelect(value: unknown) {
if (typeof value === 'string') {
filterQuery.value = ''
emit('select', value)
}
}
</script>

View File

@@ -1,458 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type {
UploadModelDialogContext,
UploadModelSuccess
} from '@/platform/assets/composables/useUploadModelWizard'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
import type * as MissingModelDownload from '@/platform/missingModel/missingModelDownload'
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockShowUploadDialog = vi.hoisted(() => vi.fn())
const mockCopyToClipboard = vi.hoisted(() => vi.fn())
const mockDownloadModel = vi.hoisted(() => vi.fn())
const mockRootGraph = vi.hoisted<{
value: Record<string, never> | null
}>(() => ({ value: null }))
const mockGetNodeByExecutionId = vi.hoisted(() => vi.fn())
const mockApiListeners = vi.hoisted(
() => new Map<string, (event: CustomEvent) => void>()
)
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
const mockUploadContext = vi.hoisted(() => ({
resolver: undefined as UploadModelContextResolver | undefined
}))
const mockUploadCallbacks = vi.hoisted(() => ({
onUploadSuccess: undefined as
| ((result: UploadModelSuccess) => Promise<unknown> | unknown)
| undefined
}))
vi.mock('@/scripts/app', () => ({
app: {
get rootGraph() {
return mockRootGraph.value
}
}
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(
(event: string, handler: (event: CustomEvent) => void) => {
mockApiListeners.set(event, handler)
}
),
apiURL: vi.fn((path: string) => path),
fetchApi: vi.fn()
}
}))
vi.mock('@/utils/graphTraversalUtil', async () => {
const actual = await vi.importActual<typeof GraphTraversalUtil>(
'@/utils/graphTraversalUtil'
)
return {
...actual,
getActiveGraphNodeIds: vi.fn(() => new Set()),
getNodeByExecutionId: mockGetNodeByExecutionId
}
})
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
vi.mock('@/platform/assets/composables/useModelUpload', () => ({
useModelUpload: (
onUploadSuccess?: (
result: UploadModelSuccess
) => Promise<unknown> | unknown,
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
) => {
mockUploadCallbacks.onUploadSuccess = onUploadSuccess
mockUploadContext.resolver =
typeof uploadContext === 'function' ? uploadContext : () => uploadContext
return {
isUploadButtonEnabled: { value: true },
showUploadDialog: mockShowUploadDialog
}
}
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: mockCopyToClipboard
})
}))
vi.mock('@/platform/missingModel/missingModelDownload', async () => {
const actual = await vi.importActual<typeof MissingModelDownload>(
'@/platform/missingModel/missingModelDownload'
)
return {
...actual,
downloadModel: mockDownloadModel,
fetchModelMetadata: vi.fn().mockResolvedValue({
fileSize: null,
gatedRepoUrl: null
})
}
})
import MissingModelRow from './MissingModelRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages },
missingWarn: false,
fallbackWarn: false
})
const TransitionCollapseStub = {
name: 'TransitionCollapse',
template: '<div><slot /></div>'
}
function makeModel(
refs: MissingModelViewModel['referencingNodes']
): MissingModelViewModel {
return {
name: 'model.safetensors',
representative: {
nodeId: refs[0]?.nodeId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
name: 'model.safetensors',
directory: 'checkpoints',
url: 'https://example.com/model.safetensors',
isMissing: true
},
referencingNodes: refs
}
}
function renderRow(
model: MissingModelViewModel,
onLocateModel = vi.fn(),
isAssetSupported = true,
directory: string | null = 'checkpoints',
canCloudImport = true
) {
const pinia = createPinia()
setActivePinia(pinia)
render(MissingModelRow, {
props: {
model,
directory,
isAssetSupported,
canCloudImport,
onLocateModel
},
global: {
plugins: [pinia, i18n],
stubs: {
TransitionCollapse: TransitionCollapseStub
}
}
})
return { onLocateModel }
}
describe('MissingModelRow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockRootGraph.value = null
mockApiListeners.clear()
mockGetNodeByExecutionId.mockReset()
mockUploadContext.resolver = undefined
mockUploadCallbacks.onUploadSuccess = undefined
})
it('opens the model import dialog from the cloud row', async () => {
const user = userEvent.setup()
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
await user.click(screen.getByRole('button', { name: 'Import' }))
expect(mockShowUploadDialog).toHaveBeenCalledTimes(1)
expect(mockUploadContext.resolver?.()).toEqual({
kind: 'missing-model-resolution',
missingModelName: 'model.safetensors',
requiredModelType: 'checkpoints',
replacementTargets: [
{
nodeId: '1',
nodeLabel: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name'
}
]
})
})
it('keeps unsupported cloud rows as reference-only rows', () => {
renderRow(
makeModel([{ nodeId: '1', widgetName: 'model_name' }]),
vi.fn(),
true,
null,
false
)
expect(screen.getByText('model.safetensors')).toBeInTheDocument()
expect(screen.getByText('Unknown')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'CheckpointLoaderSimple' })
).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Import' })).toBeNull()
})
it('shows row progress as soon as the model import starts', async () => {
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
const store = useMissingModelStore()
await mockUploadCallbacks.onUploadSuccess?.({
filename: 'downloaded-model.safetensors',
modelType: 'checkpoints',
taskId: 'task-1',
status: 'processing'
})
await nextTick()
expect(
store.importTaskIds['supported::checkpoints::model.safetensors']
).toBe('task-1')
expect(
screen.getByRole('progressbar', { name: 'Importing...' })
).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Importing...')
expect(screen.getByText('downloaded-model.safetensors')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Import' })).toBeNull()
})
it('applies the completed imported model to every referencing node', async () => {
const graph = {}
const firstWidget = {
name: 'ckpt_name',
value: 'old-first.safetensors',
callback: vi.fn()
}
const secondWidget = {
name: 'ckpt_name',
value: 'old-second.safetensors',
callback: vi.fn()
}
const firstSetDirtyCanvas = vi.fn()
const secondSetDirtyCanvas = vi.fn()
mockRootGraph.value = graph
mockGetNodeByExecutionId.mockImplementation((_graph, nodeId) => {
if (nodeId === '1') {
return {
widgets: [firstWidget],
graph: { setDirtyCanvas: firstSetDirtyCanvas }
}
}
if (nodeId === '2') {
return {
widgets: [secondWidget],
graph: { setDirtyCanvas: secondSetDirtyCanvas }
}
}
return null
})
renderRow(
makeModel([
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
])
)
await mockUploadCallbacks.onUploadSuccess?.({
filename: 'client-name.safetensors',
modelType: 'checkpoints',
taskId: 'task-1',
status: 'processing'
})
await nextTick()
const handler = mockApiListeners.get('asset_download')
expect(handler).toBeDefined()
handler!(
new CustomEvent('asset_download', {
detail: {
task_id: 'task-1',
asset_name: 'server-name.safetensors',
bytes_total: 100,
bytes_downloaded: 100,
progress: 1,
status: 'completed'
}
})
)
await waitFor(() => {
expect(firstWidget.value).toBe('server-name.safetensors')
expect(secondWidget.value).toBe('server-name.safetensors')
})
expect(firstWidget.callback).toHaveBeenCalledWith('server-name.safetensors')
expect(secondWidget.callback).toHaveBeenCalledWith(
'server-name.safetensors'
)
expect(firstSetDirtyCanvas).toHaveBeenCalledWith(true, true)
expect(secondSetDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('locates the parent row directly when a cloud model has one reference', async () => {
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
)
await user.click(screen.getByRole('button', { name: 'model.safetensors' }))
expect(onLocateModel).toHaveBeenCalledWith('1')
})
it('moves locate actions to expanded child rows when a cloud model has multiple references', async () => {
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
])
)
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.queryAllByTestId('missing-model-locate')).toHaveLength(0)
await user.click(
screen.getByRole('button', { name: 'Show referencing nodes' })
)
const locateButtons = screen.getAllByTestId('missing-model-locate')
expect(locateButtons).toHaveLength(2)
await user.click(locateButtons[1])
expect(onLocateModel).toHaveBeenCalledWith('2')
})
it('locates the parent row directly when an OSS model has one reference', async () => {
mockIsCloud.value = false
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
)
await user.click(screen.getByRole('button', { name: 'model.safetensors' }))
expect(onLocateModel).toHaveBeenCalledWith('1')
})
it('does not show the library selector in OSS rows', () => {
mockIsCloud.value = false
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
expect(
screen.getByPlaceholderText('Paste Model URL (Civitai or Hugging Face)')
).toBeInTheDocument()
})
it('shows model type metadata below the model name', () => {
renderRow(makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]))
expect(screen.getByText('checkpoints')).toBeInTheDocument()
})
it('shows downloadable model size beside the model type metadata', async () => {
mockIsCloud.value = false
const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
model.representative.url =
'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
renderRow(model, vi.fn(), false)
const store = useMissingModelStore()
store.fileSizes[model.representative.url] = 14 * 1024 ** 3
await nextTick()
expect(screen.getByText('checkpoints · 14 GB')).toBeInTheDocument()
expect(screen.getByTestId('missing-model-download')).toHaveTextContent(
'Download'
)
})
it('shows unknown category metadata for models without a directory', () => {
renderRow(
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]),
vi.fn(),
true,
null
)
expect(screen.getByText('Unknown')).toBeInTheDocument()
})
it('moves locate actions to expanded child rows when an OSS model has multiple references', async () => {
mockIsCloud.value = false
const user = userEvent.setup()
const { onLocateModel } = renderRow(
makeModel([
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
])
)
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.queryAllByTestId('missing-model-locate')).toHaveLength(0)
await user.click(
screen.getByRole('button', { name: 'Show referencing nodes' })
)
const locateButtons = screen.getAllByTestId('missing-model-locate')
expect(locateButtons).toHaveLength(2)
await user.click(locateButtons[1])
expect(onLocateModel).toHaveBeenCalledWith('2')
})
it('shows the OSS download action in the row for downloadable models', async () => {
mockIsCloud.value = false
const user = userEvent.setup()
const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
model.representative.url =
'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
renderRow(model, vi.fn(), false)
await user.click(screen.getByTestId('missing-model-download'))
expect(mockDownloadModel).toHaveBeenCalledWith(
{
name: 'model.safetensors',
url: 'https://huggingface.co/comfy/test/resolve/main/model.safetensors',
directory: 'checkpoints'
},
{}
)
})
})

View File

@@ -1,11 +1,72 @@
<template>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<div class="flex w-full flex-col pb-3">
<!-- Model header -->
<div class="flex h-8 w-full items-center gap-2">
<i
aria-hidden="true"
class="text-foreground icon-[lucide--file-check] size-4 shrink-0"
/>
<div class="flex min-w-0 flex-1 items-center">
<p
class="text-foreground min-w-0 truncate text-sm font-medium"
:title="model.name"
>
{{ model.name }} ({{ model.referencingNodes.length }})
</p>
<Button
data-testid="missing-model-copy-name"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 hover:bg-transparent"
:aria-label="t('rightSidePanel.missingModels.copyModelName')"
:title="t('rightSidePanel.missingModels.copyModelName')"
@click="copyToClipboard(model.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--copy] size-3.5 text-muted-foreground"
/>
</Button>
</div>
<Button
v-if="hasMultipleReferences"
v-if="!isCloud && model.representative.url && !isAssetSupported"
data-testid="missing-model-copy-url"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="copyToClipboard(toBrowsableUrl(model.representative.url!))"
>
{{ t('rightSidePanel.missingModels.copyUrl') }}
</Button>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.confirmSelection')"
:disabled="!canConfirm"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
canConfirm ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="handleLibrarySelect"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="canConfirm ? 'text-primary' : 'text-foreground'"
/>
</Button>
<Button
v-if="model.referencingNodes.length > 0"
data-testid="missing-model-expand"
variant="textonly"
size="unset"
size="icon-sm"
:aria-label="
expanded
? t('rightSidePanel.missingModels.collapseNodes')
@@ -14,238 +75,116 @@
:aria-expanded="expanded"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-180'
)
"
@click="handleToggleExpand"
@click="toggleModelExpand(modelKey)"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
/>
</Button>
<span class="flex min-w-0 flex-1 flex-col gap-0">
<span class="block min-w-0 text-sm/tight">
<button
v-if="hasMultipleReferences"
ref="modelLabelControl"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
:title="displayModelName"
@click="handleToggleExpand"
>
{{ displayModelName }}
</button>
<button
v-else-if="!isUnknownCategory && primaryReference"
ref="modelLabelControl"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
:title="displayModelName"
@click="handleLocatePrimary"
>
{{ displayModelName }}
</button>
<span
v-else
class="font-normal wrap-break-word text-base-foreground"
:title="displayModelName"
>
{{ displayModelName }}
</span>
<span
v-if="hasMultipleReferences"
data-testid="missing-model-reference-count"
class="ml-2 inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected align-middle text-xs font-bold text-muted-foreground"
>
{{ model.referencingNodes.length }}
</span>
<Button
variant="textonly"
size="icon-sm"
class="ml-2 inline-flex size-7 shrink-0 align-middle text-muted-foreground hover:bg-transparent hover:text-base-foreground"
:aria-label="linkLabel"
:title="linkLabel"
@click="copyModelLink"
>
<i aria-hidden="true" class="icon-[lucide--link] size-4" />
</Button>
</span>
<span
v-if="modelMetadataLabel"
class="block text-2xs/tight"
:class="
isUnknownCategory
? 'text-warning-background'
: 'text-muted-foreground'
"
>
{{ modelMetadataLabel }}
</span>
</span>
<template v-if="isCloud && canCloudImport">
<Button
v-if="!isCloudImportDownloadActive"
data-testid="missing-model-import"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="showUploadDialog"
>
{{ t('g.import') }}
</Button>
<div
v-else
ref="cloudProgress"
role="progressbar"
:aria-label="t('rightSidePanel.missingModels.importing')"
:aria-valuenow="cloudImportProgressPercent"
aria-valuemin="0"
aria-valuemax="100"
tabindex="-1"
class="flex h-8 w-16 shrink-0 items-center"
>
<span
class="block h-1.5 w-full overflow-hidden rounded-full bg-secondary-background-selected"
>
<span
class="block h-full rounded-full bg-primary-background transition-all duration-200 ease-linear"
:style="{ width: `${cloudImportProgressPercent}%` }"
/>
</span>
</div>
<span
v-if="isCloudImportDownloadActive"
role="status"
aria-live="polite"
class="sr-only"
>
{{ t('rightSidePanel.missingModels.importing') }}
</span>
</template>
<template v-else>
<Button
v-if="showDownloadAction"
data-testid="missing-model-download"
variant="secondary"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
:aria-label="`${t('g.download')} ${model.name}`"
@click="handleDownload"
>
{{ t('g.download') }}
</Button>
<Button
v-else-if="showConfirmAction"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.confirmSelection')"
:disabled="!canConfirm"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
canConfirm ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="handleLibrarySelect"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="canConfirm ? 'text-primary' : 'text-foreground'"
/>
</Button>
</template>
<Button
v-if="!hasMultipleReferences && !isUnknownCategory && primaryReference"
data-testid="missing-model-locate"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="handleLocatePrimary"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<!-- Referencing nodes -->
<TransitionCollapse>
<ul
v-if="showReferenceList"
:class="
cn(
'm-0 list-none space-y-0.5 p-0',
(hasMultipleReferences || isUnknownCategory) && 'pl-5'
)
"
<div
v-if="expanded"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
>
<li
<div
v-for="ref in model.referencingNodes"
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
class="min-w-0"
class="flex h-7 items-center"
>
<div class="flex min-h-6 min-w-0 items-center gap-2">
<button
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/tight font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="emit('locateModel', String(ref.nodeId))"
>
{{
getNodeDisplayLabel(ref.nodeId, model.representative.nodeType)
}}
</button>
<Button
data-testid="missing-model-locate"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="ml-auto size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
<span
v-if="showNodeIdBadge"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ ref.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
</p>
<Button
data-testid="missing-model-locate"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<template v-if="!isCloud">
<TransitionCollapse>
<MissingModelStatusCard
v-if="selectedLibraryModel[modelKey]"
:model-name="selectedLibraryModel[modelKey]"
:is-download-active="isDownloadActive"
:download-status="downloadStatus"
:category-mismatch="importCategoryMismatch[modelKey]"
@cancel="cancelLibrarySelect(modelKey)"
/>
</TransitionCollapse>
<!-- Status card -->
<TransitionCollapse>
<MissingModelStatusCard
v-if="selectedLibraryModel[modelKey]"
:model-name="selectedLibraryModel[modelKey]"
:is-download-active="isDownloadActive"
:download-status="downloadStatus"
:category-mismatch="importCategoryMismatch[modelKey]"
@cancel="cancelLibrarySelect(modelKey)"
/>
</TransitionCollapse>
<TransitionCollapse>
<div
v-if="!selectedLibraryModel[modelKey]"
class="mt-1 flex flex-col gap-1"
>
<div v-if="isAssetSupported" class="flex w-full flex-col py-1">
<MissingModelUrlInput
:model-key="modelKey"
:directory="directory"
:type-mismatch="typeMismatch"
/>
</div>
<!-- Input area -->
<TransitionCollapse>
<div
v-if="!selectedLibraryModel[modelKey]"
class="mt-1 flex flex-col gap-1"
>
<div v-if="isAssetSupported" class="flex w-full flex-col py-1">
<MissingModelUrlInput
:model-key="modelKey"
:directory="directory"
:type-mismatch="typeMismatch"
/>
</div>
</TransitionCollapse>
</template>
<div
v-else-if="!isCloud && downloadable"
class="flex w-full items-start py-1"
>
<Button
data-testid="missing-model-download"
variant="secondary"
size="md"
class="flex w-full flex-1"
:aria-label="`${t('g.download')} ${model.name}`"
@click="handleDownload"
>
<i
aria-hidden="true"
class="text-foreground mr-1 icon-[lucide--download] size-4 shrink-0"
/>
<span class="text-foreground min-w-0 truncate text-sm">
{{ downloadLabel }}
</span>
</Button>
</div>
<TransitionCollapse>
<MissingModelLibrarySelect
v-if="!urlInputs[modelKey]"
:model-value="getComboValue(model.representative)"
:options="comboOptions"
:show-divider="isAssetSupported || downloadable"
@select="handleComboSelect(modelKey, $event)"
/>
</TransitionCollapse>
</div>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, useTemplateRef, watch } from 'vue'
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
@@ -253,14 +192,14 @@ import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import MissingModelStatusCard from '@/platform/missingModel/components/MissingModelStatusCard.vue'
import MissingModelUrlInput from '@/platform/missingModel/components/MissingModelUrlInput.vue'
import MissingModelLibrarySelect from '@/platform/missingModel/components/MissingModelLibrarySelect.vue'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
import type { UploadModelDialogContext } from '@/platform/assets/composables/useUploadModelWizard'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import {
useMissingModelInteractions,
getModelStateKey,
getNodeDisplayLabel
getNodeDisplayLabel,
getComboValue
} from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
@@ -273,16 +212,11 @@ import {
} from '@/platform/missingModel/missingModelDownload'
import { formatSize } from '@/utils/formatUtil'
const {
model,
directory,
isAssetSupported,
canCloudImport = true
} = defineProps<{
const { model, directory, isAssetSupported } = defineProps<{
model: MissingModelViewModel
directory: string | null
showNodeIdBadge: boolean
isAssetSupported: boolean
canCloudImport?: boolean
}>()
const emit = defineEmits<{
@@ -297,123 +231,21 @@ const modelKey = computed(() =>
)
const downloadStatus = computed(() => getDownloadStatus(modelKey.value))
const comboOptions = computed(() => getComboOptions(model.representative))
const canConfirm = computed(() => isSelectionConfirmable(modelKey.value))
const expanded = computed(() => isModelExpanded(modelKey.value))
const typeMismatch = computed(() => getTypeMismatch(modelKey.value, directory))
const isUnknownCategory = computed(() => directory === null)
const isDownloadActive = computed(
() =>
downloadStatus.value?.status === 'running' ||
downloadStatus.value?.status === 'created'
)
const isCloudImportDownloadActive = computed(
() => isCloud && canCloudImport && isDownloadActive.value
)
const cloudImportProgressPercent = computed(() =>
Math.round((downloadStatus.value?.progress ?? 0) * 100)
)
const hasMultipleReferences = computed(() => model.referencingNodes.length > 1)
const primaryReference = computed(() => model.referencingNodes[0])
const linkLabel = computed(() =>
model.representative.url
? t('rightSidePanel.missingModels.copyUrl')
: t('rightSidePanel.missingModels.copyModelName')
)
const store = useMissingModelStore()
const { selectedLibraryModel, importCategoryMismatch } = storeToRefs(store)
const cloudProgress = useTemplateRef<HTMLElement>('cloudProgress')
const modelLabelControl = useTemplateRef<HTMLButtonElement>('modelLabelControl')
const expanded = computed(
() =>
store.modelExpandState[modelKey.value] ??
(isUnknownCategory.value && hasMultipleReferences.value)
)
const showReferenceList = computed(
() =>
(isUnknownCategory.value && model.referencingNodes.length === 1) ||
(hasMultipleReferences.value && expanded.value)
)
const displayModelName = computed(() => {
if (!isCloudImportDownloadActive.value) return model.name
return (
downloadStatus.value?.assetName ??
selectedLibraryModel.value[modelKey.value] ??
model.name
)
})
const downloadable = computed(() => {
const rep = model.representative
return !!(
!isAssetSupported &&
rep.url &&
rep.directory &&
isModelDownloadable({
name: rep.name,
url: rep.url,
directory: rep.directory
})
)
})
const showDownloadAction = computed(
() =>
!isCloud &&
downloadable.value &&
!selectedLibraryModel.value[modelKey.value]
)
const showConfirmAction = computed(
() => !isCloud && !!selectedLibraryModel.value[modelKey.value]
)
const downloadSizeLabel = computed(() => {
if (!showDownloadAction.value) return undefined
const url = model.representative.url
const size = url ? store.fileSizes[url] : undefined
return size ? formatSize(size) : undefined
})
const modelTypeLabel = computed(
() => directory ?? t('rightSidePanel.missingModels.unknownCategory')
)
const modelMetadataLabel = computed(() =>
[modelTypeLabel.value, downloadSizeLabel.value].filter(Boolean).join(' · ')
)
const missingModelUploadContext = computed<
UploadModelDialogContext | undefined
>(() => {
if (!canCloudImport || !directory) return undefined
return {
kind: 'missing-model-resolution',
missingModelName: model.name,
requiredModelType: directory,
replacementTargets: model.referencingNodes.map((ref) => ({
nodeId: String(ref.nodeId),
nodeLabel: getNodeDisplayLabel(ref.nodeId, model.representative.nodeType),
widgetName: ref.widgetName
}))
}
})
const { showUploadDialog } = useModelUpload(
(result) => {
handleUploadedModelImport(modelKey.value, result)
if (result.status === 'success') {
handleLibrarySelect()
}
},
() => missingModelUploadContext.value
)
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
storeToRefs(store)
onMounted(() => {
if (isCloud) return
const url = model.representative.url
if (url && !store.fileSizes[url]) {
fetchModelMetadata(url)
@@ -431,6 +263,27 @@ onMounted(() => {
}
})
const downloadable = computed(() => {
const rep = model.representative
return !!(
!isAssetSupported &&
rep.url &&
rep.directory &&
isModelDownloadable({
name: rep.name,
url: rep.url,
directory: rep.directory
})
)
})
const downloadLabel = computed(() => {
const base = t('g.download')
const url = model.representative.url
const size = url ? store.fileSizes[url] : undefined
return size ? `${base} (${formatSize(size)})` : base
})
function handleDownload() {
const rep = model.representative
if (rep.url && rep.directory) {
@@ -443,51 +296,18 @@ function handleDownload() {
}
}
function handleLocatePrimary() {
const ref = primaryReference.value
if (ref) emit('locateModel', String(ref.nodeId))
}
function copyModelLink() {
const url = model.representative.url
copyToClipboard(url ? toBrowsableUrl(url) : model.name)
}
const {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
getTypeMismatch,
getDownloadStatus,
handleUploadedModelImport
getDownloadStatus
} = useMissingModelInteractions()
function handleToggleExpand() {
store.modelExpandState[modelKey.value] = !expanded.value
}
watch(
() => downloadStatus.value?.status,
(status) => {
if (!isCloud || status !== 'completed') return
const completedAssetName = downloadStatus.value?.assetName
if (completedAssetName) {
selectedLibraryModel.value[modelKey.value] = completedAssetName
}
handleLibrarySelect()
},
{ immediate: true }
)
watch(isCloudImportDownloadActive, async (isActive, wasActive) => {
await nextTick()
if (isActive) {
cloudProgress.value?.focus()
} else if (wasActive) {
modelLabelControl.value?.focus()
}
})
function handleLibrarySelect() {
confirmLibrarySelect(
modelKey.value,

View File

@@ -3,6 +3,7 @@
aria-live="polite"
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
>
<!-- Progress bar fill -->
<div
v-if="isDownloadActive"
class="absolute inset-y-0 left-0 bg-primary/10 transition-all duration-200 ease-linear"
@@ -64,7 +65,7 @@
}}
</template>
<template v-else>
{{ t('rightSidePanel.missingModels.readyToApply') }}
{{ t('rightSidePanel.missingModels.usingFromLibrary') }}
</template>
</span>
</div>

View File

@@ -1,10 +1,6 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import type { App } from 'vue'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { MissingModelCandidate } from '@/platform/missingModel/types'
const mockGetNodeByExecutionId = vi.fn()
@@ -14,16 +10,29 @@ const mockGetAssetMetadata = vi.fn()
const mockUploadAssetAsync = vi.fn()
const mockTrackDownload = vi.fn()
const mockInvalidateModelsForCategory = vi.fn()
const mockGetAssetDisplayName = vi.fn((a: { name: string }) => a.name)
const mockGetAssetFilename = vi.fn((a: { name: string }) => a.name)
const mockGetAssets = vi.fn()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetAllNodeProviders = vi.fn()
const mockDownloadList = vi.fn(
(): Array<{ taskId: string; status: string }> => []
)
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: null
@@ -46,6 +55,7 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
getAssets: mockGetAssets,
updateModelsForNodeType: mockUpdateModelsForNodeType,
invalidateModelsForCategory: mockInvalidateModelsForCategory,
updateModelsForTag: vi.fn()
@@ -74,6 +84,11 @@ vi.mock('@/platform/assets/services/assetService', () => ({
}
}))
vi.mock('@/platform/assets/utils/assetMetadataUtils', () => ({
getAssetDisplayName: (a: { name: string }) => mockGetAssetDisplayName(a),
getAssetFilename: (a: { name: string }) => mockGetAssetFilename(a)
}))
vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
civitaiImportSource: {
type: 'civitai',
@@ -97,6 +112,7 @@ vi.mock('@/platform/assets/utils/importSourceUtil', () => ({
import { app } from '@/scripts/app'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
getComboValue,
getModelStateKey,
getNodeDisplayLabel,
useMissingModelInteractions
@@ -117,54 +133,17 @@ function makeCandidate(
}
describe('useMissingModelInteractions', () => {
const mountedApps: App<Element>[] = []
function setupWithI18n<T>(factory: () => T): T {
let result: T | undefined
const host = document.createElement('div')
const app = createApp({
setup() {
result = factory()
return () => null
}
})
app.use(
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
)
app.mount(host)
mountedApps.push(app)
if (result === undefined) {
throw new Error('Composable setup did not run')
}
return result
}
function setupMissingModelInteractions(): ReturnType<
typeof useMissingModelInteractions
> {
return setupWithI18n(() => useMissingModelInteractions())
}
beforeEach(() => {
setActivePinia(createPinia())
vi.resetAllMocks()
mockGetAssetDisplayName.mockImplementation((a: { name: string }) => a.name)
mockGetAssetFilename.mockImplementation((a: { name: string }) => a.name)
mockDownloadList.mockImplementation(
(): Array<{ taskId: string; status: string }> => []
)
;(app as { rootGraph: unknown }).rootGraph = null
})
afterEach(() => {
for (const app of mountedApps.splice(0)) {
app.unmount()
}
})
describe('getModelStateKey', () => {
it('returns key with supported prefix when asset is supported', () => {
expect(getModelStateKey('model.safetensors', 'checkpoints', true)).toBe(
@@ -205,31 +184,101 @@ describe('useMissingModelInteractions', () => {
})
})
describe('getComboValue', () => {
it('returns undefined when node is not found', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue(null)
const result = getComboValue(makeCandidate())
expect(result).toBeUndefined()
})
it('returns undefined when widget is not found', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'other_widget', value: 'test' }]
})
const result = getComboValue(makeCandidate())
expect(result).toBeUndefined()
})
it('returns string value directly', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: 'v1-5.safetensors' }]
})
expect(getComboValue(makeCandidate())).toBe('v1-5.safetensors')
})
it('returns stringified number value', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: 42 }]
})
expect(getComboValue(makeCandidate())).toBe('42')
})
it('returns undefined for unexpected types', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: { complex: true } }]
})
expect(getComboValue(makeCandidate())).toBeUndefined()
})
it('returns undefined when nodeId is null', () => {
const result = getComboValue(makeCandidate({ nodeId: undefined }))
expect(result).toBeUndefined()
})
})
describe('toggleModelExpand / isModelExpanded', () => {
it('starts collapsed by default', () => {
const { isModelExpanded } = setupMissingModelInteractions()
const { isModelExpanded } = useMissingModelInteractions()
expect(isModelExpanded('key1')).toBe(false)
})
it('toggles to expanded', () => {
const { toggleModelExpand, isModelExpanded } =
setupMissingModelInteractions()
useMissingModelInteractions()
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(true)
})
it('toggles back to collapsed', () => {
const { toggleModelExpand, isModelExpanded } =
setupMissingModelInteractions()
useMissingModelInteractions()
toggleModelExpand('key1')
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(false)
})
})
describe('handleComboSelect', () => {
it('sets selectedLibraryModel in store', () => {
const store = useMissingModelStore()
const { handleComboSelect } = useMissingModelInteractions()
handleComboSelect('key1', 'model_v2.safetensors')
expect(store.selectedLibraryModel['key1']).toBe('model_v2.safetensors')
})
it('does not set value when undefined', () => {
const store = useMissingModelStore()
const { handleComboSelect } = useMissingModelInteractions()
handleComboSelect('key1', undefined)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
})
})
describe('isSelectionConfirmable', () => {
it('returns false when no selection exists', () => {
const { isSelectionConfirmable } = setupMissingModelInteractions()
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
@@ -241,7 +290,7 @@ describe('useMissingModelInteractions', () => {
{ taskId: 'task-123', status: 'running' }
])
const { isSelectionConfirmable } = setupMissingModelInteractions()
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
@@ -250,7 +299,7 @@ describe('useMissingModelInteractions', () => {
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
const { isSelectionConfirmable } = setupMissingModelInteractions()
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
@@ -259,7 +308,7 @@ describe('useMissingModelInteractions', () => {
store.selectedLibraryModel['key1'] = 'model.safetensors'
mockDownloadList.mockReturnValue([])
const { isSelectionConfirmable } = setupMissingModelInteractions()
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(true)
})
})
@@ -269,14 +318,12 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
store.importTaskIds['key1'] = 'task-123'
const { cancelLibrarySelect } = setupMissingModelInteractions()
const { cancelLibrarySelect } = useMissingModelInteractions()
cancelLibrarySelect('key1')
expect(store.selectedLibraryModel['key1']).toBeUndefined()
expect(store.importCategoryMismatch['key1']).toBeUndefined()
expect(store.importTaskIds['key1']).toBeUndefined()
})
})
@@ -300,7 +347,6 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new_model.safetensors'
store.importTaskIds['key1'] = 'task-123'
store.setMissingModels([
makeCandidate({ name: 'old_model.safetensors', nodeId: '10' }),
makeCandidate({ name: 'old_model.safetensors', nodeId: '20' })
@@ -308,7 +354,7 @@ describe('useMissingModelInteractions', () => {
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = setupMissingModelInteractions()
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect(
'key1',
'old_model.safetensors',
@@ -326,7 +372,6 @@ describe('useMissingModelInteractions', () => {
new Set(['10', '20'])
)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
expect(store.importTaskIds['key1']).toBeUndefined()
})
it('does nothing when no selection exists', () => {
@@ -334,7 +379,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = setupMissingModelInteractions()
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
@@ -346,7 +391,7 @@ describe('useMissingModelInteractions', () => {
store.selectedLibraryModel['key1'] = 'new.safetensors'
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = setupMissingModelInteractions()
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
@@ -362,7 +407,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new.safetensors'
const { confirmLibrarySelect } = setupMissingModelInteractions()
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], 'checkpoints')
expect(mockGetAllNodeProviders).toHaveBeenCalledWith('checkpoints')
@@ -376,7 +421,7 @@ describe('useMissingModelInteractions', () => {
store.urlErrors['key1'] = 'old error'
store.urlFetching['key1'] = true
const { handleUrlInput } = setupMissingModelInteractions()
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(store.urlInputs['key1']).toBe('https://civitai.com/models/123')
@@ -389,7 +434,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
const { handleUrlInput } = setupMissingModelInteractions()
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', ' ')
expect(setTimerSpy).not.toHaveBeenCalled()
@@ -399,7 +444,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
const { handleUrlInput } = setupMissingModelInteractions()
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(setTimerSpy).toHaveBeenCalledWith(
@@ -413,7 +458,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
const clearTimerSpy = vi.spyOn(store, 'clearDebounceTimer')
const { handleUrlInput } = setupMissingModelInteractions()
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(clearTimerSpy).toHaveBeenCalledWith('key1')
@@ -422,12 +467,12 @@ describe('useMissingModelInteractions', () => {
describe('getTypeMismatch', () => {
it('returns null when groupDirectory is null', () => {
const { getTypeMismatch } = setupMissingModelInteractions()
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', null)).toBeNull()
})
it('returns null when no metadata exists', () => {
const { getTypeMismatch } = setupMissingModelInteractions()
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
@@ -435,7 +480,7 @@ describe('useMissingModelInteractions', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = { name: 'model', tags: [] } as never
const { getTypeMismatch } = setupMissingModelInteractions()
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
@@ -446,7 +491,7 @@ describe('useMissingModelInteractions', () => {
tags: ['checkpoints']
} as never
const { getTypeMismatch } = setupMissingModelInteractions()
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
@@ -457,7 +502,7 @@ describe('useMissingModelInteractions', () => {
tags: ['loras']
} as never
const { getTypeMismatch } = setupMissingModelInteractions()
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBe('loras')
})
@@ -468,14 +513,63 @@ describe('useMissingModelInteractions', () => {
tags: ['other', 'random']
} as never
const { getTypeMismatch } = setupMissingModelInteractions()
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
})
describe('getComboOptions', () => {
it('returns assets from assetsStore when the model is asset-supported', () => {
mockGetAssets.mockReturnValueOnce([
{ name: 'modelA.safetensors' },
{ name: 'modelB.safetensors' }
])
const { getComboOptions } = useMissingModelInteractions()
const options = getComboOptions(makeCandidate({ isAssetSupported: true }))
expect(mockGetAssets).toHaveBeenCalledWith('CheckpointLoaderSimple')
expect(options).toEqual([
{ name: 'modelA.safetensors', value: 'modelA.safetensors' },
{ name: 'modelB.safetensors', value: 'modelB.safetensors' }
])
})
it('returns widget options when the model is not asset-supported', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [
{
name: 'ckpt_name',
value: '',
options: { values: ['v1.safetensors', 'v2.safetensors'] }
}
]
})
const { getComboOptions } = useMissingModelInteractions()
const options = getComboOptions(makeCandidate())
expect(options).toEqual([
{ name: 'v1.safetensors', value: 'v1.safetensors' },
{ name: 'v2.safetensors', value: 'v2.safetensors' }
])
})
it('returns an empty array when the widget has no options.values', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: '' }]
})
const { getComboOptions } = useMissingModelInteractions()
expect(getComboOptions(makeCandidate())).toEqual([])
})
})
describe('getDownloadStatus', () => {
it('returns null when no taskId is tracked for the key', () => {
const { getDownloadStatus } = setupMissingModelInteractions()
const { getDownloadStatus } = useMissingModelInteractions()
expect(getDownloadStatus('key1')).toBeNull()
})
@@ -487,7 +581,7 @@ describe('useMissingModelInteractions', () => {
{ taskId: 'task-42', status: 'created' }
])
const { getDownloadStatus } = setupMissingModelInteractions()
const { getDownloadStatus } = useMissingModelInteractions()
expect(getDownloadStatus('key1')).toEqual({
taskId: 'task-42',
status: 'created'
@@ -514,7 +608,7 @@ describe('useMissingModelInteractions', () => {
task: { task_id: 'task-99', status: 'created' }
})
const { handleImport } = setupMissingModelInteractions()
const { handleImport } = useMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(store.importTaskIds['key1']).toBe('task-99')
@@ -532,7 +626,7 @@ describe('useMissingModelInteractions', () => {
task: { task_id: 'task-100', status: 'completed' }
})
const { handleImport } = setupMissingModelInteractions()
const { handleImport } = useMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
@@ -547,7 +641,7 @@ describe('useMissingModelInteractions', () => {
asset: { tags: ['models', 'loras'] }
})
const { handleImport } = setupMissingModelInteractions()
const { handleImport } = useMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(store.importCategoryMismatch['key1']).toBe('loras')
@@ -557,7 +651,7 @@ describe('useMissingModelInteractions', () => {
const store = setupImportableState('key1')
mockUploadAssetAsync.mockRejectedValueOnce(new Error('Upload boom'))
const { handleImport } = setupMissingModelInteractions()
const { handleImport } = useMissingModelInteractions()
await handleImport('key1', 'checkpoints')
expect(store.urlErrors['key1']).toBe('Upload boom')

View File

@@ -3,9 +3,12 @@ import { useI18n } from 'vue-i18n'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import { assetService } from '@/platform/assets/services/assetService'
import {
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
import type { UploadModelSuccess } from '@/platform/assets/composables/useUploadModelWizard'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -13,7 +16,12 @@ import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { app } from '@/scripts/app'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
import type {
MissingModelCandidate,
MissingModelViewModel
} from '@/platform/missingModel/types'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
const importSources = [civitaiImportSource, huggingfaceImportSource]
@@ -50,6 +58,33 @@ export function getNodeDisplayLabel(
})
}
function getModelComboWidget(
model: MissingModelCandidate
): { node: LGraphNode; widget: IBaseWidget } | null {
if (model.nodeId == null) return null
const graph = app.rootGraph
if (!graph) return null
const node = getNodeByExecutionId(graph, String(model.nodeId))
if (!node) return null
const widget = node.widgets?.find((w) => w.name === model.widgetName)
if (!widget) return null
return { node, widget }
}
export function getComboValue(
model: MissingModelCandidate
): string | undefined {
const result = getModelComboWidget(model)
if (!result) return undefined
const val = result.widget.value
if (typeof val === 'string') return val
if (typeof val === 'number') return String(val)
return undefined
}
export function useMissingModelInteractions() {
const { t } = useI18n()
const store = useMissingModelStore()
@@ -67,6 +102,30 @@ export function useMissingModelInteractions() {
return store.modelExpandState[key] ?? false
}
function getComboOptions(
model: MissingModelCandidate
): { name: string; value: string }[] {
if (model.isAssetSupported && model.nodeType) {
const assets = assetsStore.getAssets(model.nodeType) ?? []
return assets.map((asset) => ({
name: getAssetDisplayName(asset),
value: getAssetFilename(asset)
}))
}
const result = getModelComboWidget(model)
if (!result) return []
const values = result.widget.options?.values
if (!Array.isArray(values)) return []
return values.map((v) => ({ name: String(v), value: String(v) }))
}
function handleComboSelect(key: string, value: string | undefined) {
if (value) {
store.selectedLibraryModel[key] = value
}
}
function isSelectionConfirmable(key: string): boolean {
if (!store.selectedLibraryModel[key]) return false
if (store.importCategoryMismatch[key]) return false
@@ -84,7 +143,6 @@ export function useMissingModelInteractions() {
function cancelLibrarySelect(key: string) {
delete store.selectedLibraryModel[key]
delete store.importCategoryMismatch[key]
delete store.importTaskIds[key]
}
/** Apply selected model to referencing nodes, removing only that model from the error list. */
@@ -131,7 +189,6 @@ export function useMissingModelInteractions() {
}
delete store.selectedLibraryModel[key]
delete store.importTaskIds[key]
const nodeIdSet = new Set(referencingNodes.map((ref) => String(ref.nodeId)))
store.removeMissingModelByNameOnNodes(modelName, nodeIdSet)
}
@@ -250,16 +307,6 @@ export function useMissingModelInteractions() {
}
}
function handleUploadedModelImport(key: string, result: UploadModelSuccess) {
if (result.taskId) {
handleAsyncPending(key, result.taskId, result.modelType, result.filename)
} else if (result.status === 'success') {
handleAsyncCompleted(result.modelType)
}
store.selectedLibraryModel[key] = result.filename
}
function handleSyncResult(
key: string,
tags: string[],
@@ -333,13 +380,14 @@ export function useMissingModelInteractions() {
return {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
handleUrlInput,
getTypeMismatch,
getDownloadStatus,
handleUploadedModelImport,
handleImport
}
}