mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-09 07:00:06 +00:00
[feat] call AssetBrowserModal for whitelisted widgets
This commit is contained in:
@@ -69,7 +69,7 @@ const {
|
||||
availableCategories,
|
||||
contentTitle,
|
||||
filteredAssets,
|
||||
selectAsset
|
||||
selectAssetWithCallback
|
||||
} = useAssetBrowser(props.assets)
|
||||
|
||||
// Dialog controls panel visibility via prop
|
||||
@@ -84,13 +84,10 @@ const handleClose = () => {
|
||||
}
|
||||
|
||||
// Handle asset selection and emit to parent
|
||||
const handleAssetSelectAndEmit = (asset: AssetDisplayItem) => {
|
||||
selectAsset(asset) // This logs the selection for dev mode
|
||||
const handleAssetSelectAndEmit = async (asset: AssetDisplayItem) => {
|
||||
emit('asset-select', asset) // Emit the full asset object
|
||||
|
||||
// Call prop callback if provided
|
||||
if (props.onSelect) {
|
||||
props.onSelect(asset.name) // Use asset name as the asset path
|
||||
}
|
||||
// Use composable for detail fetching and callback execution
|
||||
await selectAssetWithCallback(asset.id, props.onSelect)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
||||
import { d, t } from '@/i18n'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
getAssetBaseModel,
|
||||
getAssetDescription
|
||||
@@ -162,12 +163,51 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
return filtered.map(transformAssetForDisplay)
|
||||
})
|
||||
|
||||
// Actions
|
||||
function selectAsset(asset: AssetDisplayItem): UUID {
|
||||
/**
|
||||
* Asset selection that fetches full details and executes callback with filename
|
||||
* @param assetId - The asset ID to select and fetch details for
|
||||
* @param onSelect - Optional callback to execute with the asset filename
|
||||
*/
|
||||
async function selectAssetWithCallback(
|
||||
assetId: string,
|
||||
onSelect?: (filename: string) => void
|
||||
): Promise<void> {
|
||||
// Always log selection for debugging
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('Asset selected:', asset.id, asset.name)
|
||||
console.log('Asset selected:', assetId)
|
||||
}
|
||||
|
||||
// If no callback provided, just return (no need to fetch details)
|
||||
if (!onSelect) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch complete asset details to get user_metadata
|
||||
const detailAsset = await assetService.getAssetDetails(assetId)
|
||||
|
||||
// Extract filename from user_metadata
|
||||
const filename = detailAsset.user_metadata?.filename
|
||||
|
||||
// Validate filename exists and is not empty
|
||||
if (!filename || typeof filename !== 'string' || filename.trim() === '') {
|
||||
console.error(
|
||||
'Invalid asset filename from user_metadata:',
|
||||
filename || null,
|
||||
'for asset:',
|
||||
assetId
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Execute callback with validated filename
|
||||
onSelect(filename)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to fetch asset details for ${assetId}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
return asset.id
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -182,7 +222,6 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
filteredAssets,
|
||||
|
||||
// Actions
|
||||
selectAsset,
|
||||
transformAssetForDisplay
|
||||
selectAssetWithCallback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
interface AssetBrowserDialogProps {
|
||||
@@ -8,8 +10,11 @@ interface AssetBrowserDialogProps {
|
||||
inputName: string
|
||||
/** Current selected asset value */
|
||||
currentValue?: string
|
||||
/** Callback for when an asset is selected */
|
||||
onAssetSelected?: (assetPath: string) => void
|
||||
/**
|
||||
* Callback for when an asset is selected
|
||||
* @param {string} filename - The validated filename from user_metadata.filename
|
||||
*/
|
||||
onAssetSelected?: (filename: string) => void
|
||||
}
|
||||
|
||||
export const useAssetBrowserDialog = () => {
|
||||
@@ -20,7 +25,7 @@ export const useAssetBrowserDialog = () => {
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
}
|
||||
|
||||
function show(props: AssetBrowserDialogProps) {
|
||||
async function show(props: AssetBrowserDialogProps) {
|
||||
const handleAssetSelected = (assetPath: string) => {
|
||||
props.onAssetSelected?.(assetPath)
|
||||
hide() // Auto-close on selection
|
||||
@@ -48,6 +53,14 @@ export const useAssetBrowserDialog = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch assets for the specific node type, fallback to empty array on error
|
||||
let assets: AssetItem[] = []
|
||||
try {
|
||||
assets = await assetService.getAssetsForNodeType(props.nodeType)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch assets for node type:', props.nodeType, error)
|
||||
}
|
||||
|
||||
dialogStore.showDialog({
|
||||
key: dialogKey,
|
||||
component: AssetBrowserModal,
|
||||
@@ -55,6 +68,7 @@ export const useAssetBrowserDialog = () => {
|
||||
nodeType: props.nodeType,
|
||||
inputName: props.inputName,
|
||||
currentValue: props.currentValue,
|
||||
assets,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: handleClose
|
||||
},
|
||||
|
||||
@@ -6,11 +6,11 @@ const zAsset = z.object({
|
||||
name: z.string(),
|
||||
asset_hash: z.string(),
|
||||
size: z.number(),
|
||||
mime_type: z.string(),
|
||||
mime_type: z.string().nullable(),
|
||||
tags: z.array(z.string()),
|
||||
preview_url: z.string().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
updated_at: z.string().optional(),
|
||||
last_access_time: z.string(),
|
||||
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
|
||||
preview_id: z.string().nullable().optional()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import {
|
||||
type AssetItem,
|
||||
type AssetResponse,
|
||||
type ModelFile,
|
||||
type ModelFolder,
|
||||
@@ -127,10 +128,74 @@ function createAssetService() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets assets for a specific node type by finding the matching category
|
||||
* and fetching all assets with that category tag
|
||||
*
|
||||
* @param nodeType - The ComfyUI node type (e.g., 'CheckpointLoaderSimple')
|
||||
* @returns Promise<AssetItem[]> - Full asset objects with preserved metadata
|
||||
*/
|
||||
async function getAssetsForNodeType(nodeType: string): Promise<AssetItem[]> {
|
||||
if (!nodeType || typeof nodeType !== 'string') {
|
||||
return []
|
||||
}
|
||||
|
||||
// Find the category for this node type by reverse lookup in modelToNodeMap
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const modelToNodeMap = modelToNodeStore.modelToNodeMap
|
||||
|
||||
const category = Object.keys(modelToNodeMap).find(categoryKey =>
|
||||
modelToNodeMap[categoryKey].some(provider => provider.nodeDef.name === nodeType)
|
||||
)
|
||||
|
||||
if (!category) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Fetch assets for this category using same API pattern as getAssetModels
|
||||
const data = await handleAssetRequest(
|
||||
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${category}`,
|
||||
`assets for ${nodeType}`
|
||||
)
|
||||
|
||||
// Return full AssetItem[] objects (don't strip like getAssetModels does)
|
||||
return data?.assets?.filter(asset =>
|
||||
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(category)
|
||||
) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets complete details for a specific asset by ID
|
||||
* Calls the detail endpoint which includes user_metadata and all fields
|
||||
*
|
||||
* @param id - The asset ID
|
||||
* @returns Promise<AssetItem> - Complete asset object with user_metadata
|
||||
*/
|
||||
async function getAssetDetails(id: string): Promise<AssetItem> {
|
||||
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Unable to load asset details for ${id}: Server returned ${res.status}. Please try again.`
|
||||
)
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
// Validate the single asset response against our schema
|
||||
const result = assetResponseSchema.safeParse({ assets: [data] })
|
||||
if (result.success && result.data.assets?.[0]) {
|
||||
return result.data.assets[0]
|
||||
}
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
throw new Error(`Invalid asset response against zod schema:\n${error}`)
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
isAssetBrowserEligible
|
||||
isAssetBrowserEligible,
|
||||
getAssetsForNodeType,
|
||||
getAssetDetails
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,3 +25,38 @@ export function getAssetBaseModel(asset: AssetItem): string | null {
|
||||
? asset.user_metadata.base_model
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts the ComfyUI-relative filename from user_metadata.
|
||||
* @param {import('../schemas/assetSchema').AssetItem} asset - The asset item containing user_metadata
|
||||
* @returns {string | null} ComfyUI-relative path or null if not available
|
||||
*/
|
||||
export function getAssetFilename(asset: AssetItem): string | null {
|
||||
const filename = asset.user_metadata?.filename
|
||||
|
||||
if (typeof filename !== 'string' || !filename.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return filename.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a filename path is safe for ComfyUI widget usage.
|
||||
* @param {string} filename - The filename to validate
|
||||
* @returns {boolean} True if filename is safe for widget usage
|
||||
*/
|
||||
export function validateAssetFilename(filename: string): boolean {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
|
||||
const trimmed = filename.trim()
|
||||
if (!trimmed) return false
|
||||
|
||||
// Reject dangerous patterns but allow forward slashes for subdirectories
|
||||
// e.g., reject "../../../etc/passwd" but allow "checkpoints/model.safetensors"
|
||||
if (trimmed.includes('..') || /[<>:"|?*]/.test(trimmed)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IAssetWidget,
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
@@ -73,10 +75,21 @@ const addComboWidget = (
|
||||
const currentValue = getDefaultValue(inputSpec)
|
||||
const displayLabel = currentValue ?? t('widgets.selectModel')
|
||||
|
||||
const widget = node.addWidget('asset', inputSpec.name, displayLabel, () => {
|
||||
console.log(
|
||||
`Asset Browser would open here for:\nNode: ${node.type}\nWidget: ${inputSpec.name}\nCurrent Value:${currentValue}`
|
||||
)
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
|
||||
const widget = node.addWidget('asset', inputSpec.name, displayLabel, async () => {
|
||||
const assetWidget = widget as IAssetWidget
|
||||
await assetBrowserDialog.show({
|
||||
nodeType: node.comfyClass || '',
|
||||
inputName: inputSpec.name,
|
||||
currentValue: assetWidget.value,
|
||||
onAssetSelected: (filename: string) => {
|
||||
assetWidget.value = filename
|
||||
// Must call widget.callback to notify litegraph of value changes
|
||||
// This ensures proper serialization and triggers any downstream effects
|
||||
assetWidget.callback?.(assetWidget.value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return widget
|
||||
|
||||
Reference in New Issue
Block a user