[feat] call AssetBrowserModal for whitelisted widgets

This commit is contained in:
Arjan Singh
2025-09-17 15:06:16 -07:00
parent 4820d87d86
commit b67e4977d7
13 changed files with 504 additions and 54 deletions

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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
},

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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