mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
initial download-folder-selector interface (#890)
* initial download-folder-selector interface * use primevue select * add a folder select visibility checkbox * slightly reduce indirection * fix up select box updating * revert bad upstream changes * cleanup * allow localhost sourced models in ui side (for testing purposes only basically, but does no harm in deployed envs) * add screenshot expectations to test * Update test expectations [skip ci] * add testing of folder select * fix test * don't exclude folder selector when there's only 1 since the checkbox covers that better anyway * oo - fix checkbox * Update test expectations [skip ci] * testing - don't expect screenshots :( * experimental new test code * toHaveClass is silly * add // comments documenting intent of allowedSources --------- Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
committed by
Chenlei Hu
parent
30469a6d88
commit
35a7c81fd8
@@ -45,38 +45,54 @@ test.describe('Execution error', () => {
|
||||
|
||||
test.describe('Missing models warning', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Workflow.ShowMissingModelsWarning', true)
|
||||
await comfyPage.page.evaluate((url: string) => {
|
||||
return fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
}, comfyPage.url)
|
||||
await comfyPage.setSetting('Comfy.Workflow.ModelDownload.AllowedSources', [
|
||||
'http://localhost:8188'
|
||||
])
|
||||
await comfyPage.setSetting('Comfy.Workflow.ModelDownload.AllowedSuffixes', [
|
||||
'.safetensors'
|
||||
])
|
||||
})
|
||||
|
||||
// Regressed by https://github.com/comfyanonymous/ComfyUI/pull/4981
|
||||
test.skip('Should display a warning when missing models are found', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.Workflow.ShowMissingModelsWarning', true)
|
||||
|
||||
// The fake_model.safetensors is served by
|
||||
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
|
||||
await comfyPage.loadWorkflow('missing_models')
|
||||
|
||||
// Wait for the element with the .comfy-missing-models selector to be visible
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
|
||||
// Click the download button
|
||||
const downloadButton = comfyPage.page.getByLabel('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
await downloadButton.click()
|
||||
|
||||
// Wait for the element with the .download-complete selector to be visible
|
||||
const downloadComplete = comfyPage.page.locator('.download-complete')
|
||||
await expect(downloadComplete).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can configure download folder', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('missing_models')
|
||||
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
|
||||
const folderSelectToggle = comfyPage.page.locator(
|
||||
'.model-path-select-checkbox'
|
||||
)
|
||||
const folderSelect = comfyPage.page.locator('.model-path-select')
|
||||
await expect(folderSelectToggle).toBeVisible()
|
||||
await expect(folderSelect).not.toBeVisible()
|
||||
|
||||
await folderSelectToggle.click() // show the selectors
|
||||
await expect(folderSelect).toBeVisible()
|
||||
|
||||
await folderSelect.click() // open dropdown
|
||||
await expect(folderSelect).toHaveClass(/p-select-open/)
|
||||
|
||||
await folderSelect.click() // close the dropdown
|
||||
await expect(folderSelect).not.toHaveClass(/p-select-open/)
|
||||
|
||||
await folderSelectToggle.click() // hide the selectors
|
||||
await expect(folderSelect).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
<p class="warning-description">
|
||||
When loading the graph, the following models were not found:
|
||||
</p>
|
||||
<p class="warning-options">
|
||||
<Checkbox
|
||||
class="model-path-select-checkbox"
|
||||
v-model="showFolderSelect"
|
||||
label="Show folder selector"
|
||||
:binary="true"
|
||||
/>
|
||||
Show folder selector
|
||||
</p>
|
||||
<ListBox
|
||||
:options="missingModels"
|
||||
optionLabel="label"
|
||||
@@ -29,6 +38,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="model-action">
|
||||
<Select
|
||||
class="model-path-select"
|
||||
v-if="
|
||||
slotProps.option.action &&
|
||||
!slotProps.option.downloading &&
|
||||
!slotProps.option.completed &&
|
||||
!slotProps.option.error &&
|
||||
showFolderSelect
|
||||
"
|
||||
v-model="slotProps.option.folderPath"
|
||||
:options="slotProps.option.paths"
|
||||
@change="updateFolderPath(slotProps.option, $event)"
|
||||
/>
|
||||
<Button
|
||||
v-if="
|
||||
slotProps.option.action &&
|
||||
@@ -60,19 +82,24 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import ListBox from 'primevue/listbox'
|
||||
import Select from 'primevue/select'
|
||||
import { SelectChangeEvent } from 'primevue/select'
|
||||
import Button from 'primevue/button'
|
||||
import { api } from '@/scripts/api'
|
||||
import { DownloadModelStatus } from '@/types/apiTypes'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const allowedSources = settingStore.get(
|
||||
'Comfy.Workflow.ModelDownload.AllowedSources'
|
||||
)
|
||||
const allowedSuffixes = settingStore.get(
|
||||
'Comfy.Workflow.ModelDownload.AllowedSuffixes'
|
||||
)
|
||||
const showFolderSelect = ref(false)
|
||||
|
||||
// TODO: Read this from server internal API rather than hardcoding here
|
||||
// as some installations may wish to use custom sources
|
||||
const allowedSources = [
|
||||
'https://civitai.com/',
|
||||
'https://huggingface.co/',
|
||||
'http://localhost:' // Included for testing usage only
|
||||
]
|
||||
const allowedSuffixes = ['.safetensors', '.sft']
|
||||
|
||||
interface ModelInfo {
|
||||
name: string
|
||||
@@ -83,19 +110,26 @@ interface ModelInfo {
|
||||
completed?: boolean
|
||||
progress?: number
|
||||
error?: string
|
||||
folder_path?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
missingModels: ModelInfo[]
|
||||
paths: Record<string, string[]>
|
||||
maximized: boolean
|
||||
}>()
|
||||
|
||||
const modelDownloads = ref<Record<string, ModelInfo>>({})
|
||||
let lastModel: string | null = null
|
||||
|
||||
const updateFolderPath = (model: any, event: SelectChangeEvent) => {
|
||||
const downloadInfo = modelDownloads.value[model.name]
|
||||
downloadInfo.folder_path = event.value
|
||||
return false
|
||||
}
|
||||
const handleDownloadProgress = (detail: DownloadModelStatus) => {
|
||||
if (detail.download_path) {
|
||||
lastModel = detail.download_path.split('/', 2)[1]
|
||||
lastModel = detail.download_path
|
||||
}
|
||||
if (!lastModel) return
|
||||
if (detail.status === 'in_progress') {
|
||||
@@ -134,7 +168,8 @@ const handleDownloadProgress = (detail: DownloadModelStatus) => {
|
||||
const triggerDownload = async (
|
||||
url: string,
|
||||
directory: string,
|
||||
filename: string
|
||||
filename: string,
|
||||
folder_path: string
|
||||
) => {
|
||||
modelDownloads.value[filename] = {
|
||||
name: filename,
|
||||
@@ -143,49 +178,75 @@ const triggerDownload = async (
|
||||
downloading: true,
|
||||
progress: 0
|
||||
}
|
||||
const download = await api.internalDownloadModel(url, directory, filename, 1)
|
||||
const download = await api.internalDownloadModel(
|
||||
url,
|
||||
directory,
|
||||
filename,
|
||||
1,
|
||||
folder_path
|
||||
)
|
||||
lastModel = filename
|
||||
handleDownloadProgress(download)
|
||||
}
|
||||
|
||||
api.addEventListener('download_progress', (event) => {
|
||||
api.addEventListener('download_progress', (event: CustomEvent) => {
|
||||
handleDownloadProgress(event.detail)
|
||||
})
|
||||
|
||||
const missingModels = computed(() => {
|
||||
return props.missingModels.map((model) => {
|
||||
const downloadInfo = modelDownloads.value[model.name]
|
||||
if (!allowedSources.some((source) => model.url.startsWith(source))) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
hint: model.url,
|
||||
error:
|
||||
'Download not allowed from this source: ' + allowedSources.join(', ')
|
||||
}
|
||||
}
|
||||
if (!allowedSuffixes.some((suffix) => model.name.endsWith(suffix))) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
hint: model.url,
|
||||
error: 'Only allowed suffixes are ' + allowedSuffixes.join(', ')
|
||||
}
|
||||
}
|
||||
if (model.directory_invalid) {
|
||||
const paths = props.paths[model.directory]
|
||||
if (model.directory_invalid || !paths) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
hint: model.url,
|
||||
error: 'Invalid directory specified (does this require custom nodes?)'
|
||||
}
|
||||
}
|
||||
const downloadInfo: ModelInfo = modelDownloads.value[model.name] ?? {
|
||||
downloading: false,
|
||||
completed: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
name: model.name,
|
||||
directory: model.directory,
|
||||
url: model.url,
|
||||
folder_path: paths[0]
|
||||
}
|
||||
modelDownloads.value[model.name] = downloadInfo
|
||||
if (!allowedSources.some((source) => model.url.startsWith(source))) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
hint: model.url,
|
||||
error: `Download not allowed from source '${model.url}', only allowed from '${allowedSources.join("', '")}'`
|
||||
}
|
||||
}
|
||||
if (!allowedSuffixes.some((suffix) => model.name.endsWith(suffix))) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
hint: model.url,
|
||||
error: `Only allowed suffixes are: '${allowedSuffixes.join("', '")}'`
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
hint: model.url,
|
||||
downloading: downloadInfo?.downloading ?? false,
|
||||
completed: downloadInfo?.completed ?? false,
|
||||
progress: downloadInfo?.progress ?? 0,
|
||||
error: downloadInfo?.error,
|
||||
downloading: downloadInfo.downloading,
|
||||
completed: downloadInfo.completed,
|
||||
progress: downloadInfo.progress,
|
||||
error: downloadInfo.error,
|
||||
name: model.name,
|
||||
paths: paths,
|
||||
folderPath: downloadInfo.folder_path,
|
||||
action: {
|
||||
text: 'Download',
|
||||
callback: () => triggerDownload(model.url, model.directory, model.name)
|
||||
callback: () =>
|
||||
triggerDownload(
|
||||
model.url,
|
||||
model.directory,
|
||||
model.name,
|
||||
downloadInfo.folder_path
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -218,6 +279,10 @@ const missingModels = computed(() => {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.warning-options {
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.missing-models-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -396,7 +396,8 @@ class ComfyApi extends EventTarget {
|
||||
url: string,
|
||||
model_directory: string,
|
||||
model_filename: string,
|
||||
progress_interval: number
|
||||
progress_interval: number,
|
||||
folder_path: string
|
||||
): Promise<DownloadModelStatus> {
|
||||
const res = await this.fetchApi('/internal/models/download', {
|
||||
method: 'POST',
|
||||
@@ -407,7 +408,8 @@ class ComfyApi extends EventTarget {
|
||||
url,
|
||||
model_directory,
|
||||
model_filename,
|
||||
progress_interval
|
||||
progress_interval,
|
||||
folder_path
|
||||
})
|
||||
})
|
||||
return await res.json()
|
||||
@@ -716,6 +718,10 @@ class ComfyApi extends EventTarget {
|
||||
async getLogs(): Promise<string> {
|
||||
return (await axios.get(this.internalURL('/logs'))).data
|
||||
}
|
||||
|
||||
async getFolderPaths(): Promise<Record<string, string[]>> {
|
||||
return (await axios.get(this.internalURL('/folder_paths'))).data
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ComfyApi()
|
||||
|
||||
@@ -2189,10 +2189,11 @@ export class ComfyApp {
|
||||
})
|
||||
}
|
||||
|
||||
showMissingModelsError(missingModels) {
|
||||
showMissingModelsError(missingModels, paths) {
|
||||
if (useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')) {
|
||||
showMissingModelsWarning({
|
||||
missingModels,
|
||||
paths,
|
||||
maximizable: true
|
||||
})
|
||||
}
|
||||
@@ -2414,7 +2415,8 @@ export class ComfyApp {
|
||||
this.showMissingNodesError(missingNodeTypes)
|
||||
}
|
||||
if (missingModels.length && showMissingModelsDialog) {
|
||||
this.showMissingModelsError(missingModels)
|
||||
const paths = await api.getFolderPaths()
|
||||
this.showMissingModelsError(missingModels, paths)
|
||||
}
|
||||
await this.#invokeExtensionsAsync('afterConfigureGraph', missingNodeTypes)
|
||||
requestAnimationFrame(() => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export function showLoadWorkflowWarning(props: {
|
||||
|
||||
export function showMissingModelsWarning(props: {
|
||||
missingModels: any[]
|
||||
paths: Record<string, string[]>
|
||||
[key: string]: any
|
||||
}) {
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
@@ -203,18 +203,6 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'hidden',
|
||||
defaultValue: 'cover'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Workflow.ModelDownload.AllowedSources',
|
||||
name: 'Allowed model download sources',
|
||||
type: 'hidden',
|
||||
defaultValue: ['https://huggingface.co/', 'https://civitai.com/']
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Workflow.ModelDownload.AllowedSuffixes',
|
||||
name: 'Allowed model download suffixes',
|
||||
type: 'hidden',
|
||||
defaultValue: ['.safetensors', '.sft']
|
||||
},
|
||||
{
|
||||
id: 'Comfy.GroupSelectedNodes.Padding',
|
||||
name: 'Group selected nodes padding',
|
||||
|
||||
@@ -498,8 +498,6 @@ const zSettings = z.record(z.any()).and(
|
||||
'Comfy.Validation.Workflows': z.boolean(),
|
||||
'Comfy.Workflow.SortNodeIdOnSave': z.boolean(),
|
||||
'Comfy.Queue.ImageFit': z.enum(['contain', 'cover']),
|
||||
'Comfy.Workflow.ModelDownload.AllowedSources': z.array(z.string()),
|
||||
'Comfy.Workflow.ModelDownload.AllowedSuffixes': z.array(z.string()),
|
||||
'Comfy.Workflow.WorkflowTabsPosition': z.enum(['Sidebar', 'Topbar']),
|
||||
'Comfy.Node.DoubleClickTitleToEdit': z.boolean(),
|
||||
'Comfy.Window.UnloadConfirmation': z.boolean(),
|
||||
|
||||
Reference in New Issue
Block a user