[refactor] Redesign missing models dialog (#9014)

## Summary
Redesign the missing models warning dialog to match the MissingNodes
dialog pattern with header/content/footer separation, type badges, file
sizes, and context-sensitive actions.

## Changes
- **What**: Split `MissingModelsWarning.vue` into `MissingModelsHeader`,
`MissingModelsContent`, `MissingModelsFooter` components following the
established MissingNodes pattern. Added model type badges (VAE,
DIFFUSION, LORA, etc.), inline file sizes, total download size, custom
model warnings, and context-sensitive footer buttons (Download all /
Download available / Ok, got it). Extracted security validation into
shared `missingModelsUtils.ts`. Removed orphaned `FileDownload`,
`ElectronFileDownload`, `useDownload`, and `useCivitaiModel` files.
- **Breaking**: None

## Review Focus
- Badge styling and icon button variants for theme compatibility
- Security validation logic preserved correctly in extracted utility
- E2e test locator updates for the new dialog structure

<img width="641" height="478" alt="스크린샷 2026-02-20 오후 7 35 23"
src="https://github.com/user-attachments/assets/ded27dc7-04e6-431d-9b2e-a96ba61043a4"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9014-refactor-Redesign-missing-models-dialog-30d6d73d365081809cb0c555c2c28034)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Jin Yi
2026-02-25 10:51:18 +09:00
committed by GitHub
parent 9108b7535a
commit 164379bf4b
14 changed files with 417 additions and 589 deletions

View File

@@ -1,95 +0,0 @@
import { useAsyncState } from '@vueuse/core'
import { computed } from 'vue'
type ModelType =
| 'Checkpoint'
| 'TextualInversion'
| 'Hypernetwork'
| 'AestheticGradient'
| 'LORA'
| 'Controlnet'
| 'Poses'
interface CivitaiFileMetadata {
fp?: 'fp16' | 'fp32'
size?: 'full' | 'pruned'
format?: 'SafeTensor' | 'PickleTensor' | 'Other'
}
interface CivitaiModelFile {
name: string
id: number
sizeKB: number
type: string
downloadUrl: string
metadata: CivitaiFileMetadata
}
interface CivitaiModel {
name: string
type: ModelType
}
interface CivitaiModelVersionResponse {
id: number
name: string
model: CivitaiModel
modelId: number
files: CivitaiModelFile[]
[key: string]: unknown
}
/**
* Composable to manage Civitai model
* @param url - The URL of the Civitai model, where the model ID is the last part of the URL's pathname
* @see https://developer.civitai.com/docs/api/public-rest
* @example
* const { fileSize, isLoading, error, modelData } =
* useCivitaiModel('https://civitai.com/api/download/models/16576?type=Model&format=SafeTensor&size=full&fp=fp16')
*/
export function useCivitaiModel(url: string) {
const createModelVersionUrl = (modelId: string): string =>
`https://civitai.com/api/v1/model-versions/${modelId}`
const extractModelIdFromUrl = (): string | null => {
const urlObj = new URL(url)
return urlObj.pathname.split('/').pop() || null
}
const fetchModelData =
async (): Promise<CivitaiModelVersionResponse | null> => {
const modelId = extractModelIdFromUrl()
if (!modelId) return null
const apiUrl = createModelVersionUrl(modelId)
const res = await fetch(apiUrl)
return res.json()
}
const findMatchingFileSize = (): number | null => {
const matchingFile = modelData.value?.files?.find(
(file) => file.downloadUrl && url.startsWith(file.downloadUrl)
)
return matchingFile?.sizeKB ? matchingFile.sizeKB << 10 : null
}
const {
state: modelData,
isLoading,
error
} = useAsyncState(fetchModelData, null, {
immediate: true
})
const fileSize = computed(() =>
!isLoading.value ? findMatchingFileSize() : null
)
return {
fileSize,
isLoading,
error,
modelData
}
}

View File

@@ -1,67 +0,0 @@
import { whenever } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { useCivitaiModel } from '@/composables/useCivitaiModel'
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
export function useDownload(url: string, fileName?: string) {
const fileSize = ref<number | null>(null)
const error = ref<Error | null>(null)
const setFileSize = (size: number) => {
fileSize.value = size
}
const fetchFileSize = async () => {
try {
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) throw new Error('Failed to fetch file size')
const size = response.headers.get('content-length')
if (size) {
setFileSize(parseInt(size))
} else {
console.error('"content-length" header not found')
return null
}
} catch (e) {
console.error('Error fetching file size:', e)
error.value = e instanceof Error ? e : new Error(String(e))
return null
}
}
/**
* Trigger browser download
*/
const triggerBrowserDownload = () => {
const link = document.createElement('a')
if (url.includes('huggingface.co') && error.value) {
// If model is a gated HF model, send user to the repo page so they can sign in first
link.href = downloadUrlToHfRepoUrl(url)
} else {
link.href = url
link.download = fileName || url.split('/').pop() || 'download'
}
link.target = '_blank' // Opens in new tab if download attribute is not supported
link.rel = 'noopener noreferrer' // Security best practice for _blank links
link.click()
}
onMounted(() => {
if (isCivitaiModelUrl(url)) {
const { fileSize: civitaiSize, error: civitaiErr } = useCivitaiModel(url)
whenever(civitaiSize, setFileSize)
// Try falling back to normal fetch if using Civitai API fails
whenever(civitaiErr, fetchFileSize, { once: true })
} else {
// Fetch file size in the background
void fetchFileSize()
}
})
return {
triggerBrowserDownload,
fileSize
}
}

View File

@@ -1,6 +1,8 @@
import type { ComponentAttrs } from 'vue-component-type-helpers'
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
import MissingModelsContent from '@/components/dialog/content/MissingModelsContent.vue'
import MissingModelsFooter from '@/components/dialog/content/MissingModelsFooter.vue'
import MissingModelsHeader from '@/components/dialog/content/MissingModelsHeader.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -14,11 +16,14 @@ export function useMissingModelsDialog() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(props: ComponentAttrs<typeof MissingModelsWarning>) {
function show(props: ComponentAttrs<typeof MissingModelsContent>) {
showSmallLayoutDialog({
key: DIALOG_KEY,
component: MissingModelsWarning,
props
headerComponent: MissingModelsHeader,
footerComponent: MissingModelsFooter,
component: MissingModelsContent,
props,
footerProps: props
})
}