mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 11:11:53 +00:00
feat(cloud): add asset widget support for PrimitiveNode model selection (#8598)
Add cloud asset widget creation in `_createWidget()` using `isAssetBrowserEligible()` - Extract shared `createAssetWidget` factory to `src/platform/assets/utils/` - Refactor `useComboWidget.ts` to use the shared factory - Add `_finalizeWidget()` helper to DRY up widget sizing/callback setup - Pass target node's `comfyClass` and input name to Asset Browser for correct model filtering - Check `Comfy.Assets.UseAssetAPI` setting (matches `useComboWidget.ts` behavior) - Sync existing target widget value to asset widget - Add toast notifications for asset validation errors - Add i18n translations for invalidAsset and invalidFilename errors Supersedes #8461 (clean rebase, no merge commits) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8598-feat-cloud-add-asset-widget-support-for-PrimitiveNode-model-selection-2fd6d73d365081a8afa7c2e91762f11c) by [Unito](https://www.unito.io) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced asset widget integration for cloud-based model selection, enabling users to browse and select assets through an improved interface. * Added comprehensive asset validation with enhanced error messages for invalid assets and filenames. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: Subagent 5 <subagent@example.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: guill <jacob.e.segal@gmail.com> Co-authored-by: Jin Yi <jin12cc@gmail.com> Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: AustinMroz <austin@comfy.org> Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe> Co-authored-by: Kelly Yang <124ykl@gmail.com> Co-authored-by: sno <snomiao@gmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com> Co-authored-by: Terry Jia <terryjia88@gmail.com> Co-authored-by: Luke Mino-Altherr <luke@comfy.org> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -12,6 +12,10 @@ import type {
|
|||||||
IBaseWidget,
|
IBaseWidget,
|
||||||
TWidgetValue
|
TWidgetValue
|
||||||
} from '@/lib/litegraph/src/types/widgets'
|
} from '@/lib/litegraph/src/types/widgets'
|
||||||
|
import { assetService } from '@/platform/assets/services/assetService'
|
||||||
|
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import {
|
import {
|
||||||
@@ -19,10 +23,10 @@ import {
|
|||||||
addValueControlWidgets,
|
addValueControlWidgets,
|
||||||
isValidWidgetType
|
isValidWidgetType
|
||||||
} from '@/scripts/widgets'
|
} from '@/scripts/widgets'
|
||||||
|
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
|
||||||
import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
|
import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
|
||||||
import { mergeInputSpec } from '@/utils/nodeDefUtil'
|
import { mergeInputSpec } from '@/utils/nodeDefUtil'
|
||||||
import { applyTextReplacements } from '@/utils/searchAndReplace'
|
import { applyTextReplacements } from '@/utils/searchAndReplace'
|
||||||
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
|
|
||||||
|
|
||||||
const replacePropertyName = 'Run widget replace on values'
|
const replacePropertyName = 'Run widget replace on values'
|
||||||
export class PrimitiveNode extends LGraphNode {
|
export class PrimitiveNode extends LGraphNode {
|
||||||
@@ -228,6 +232,24 @@ export class PrimitiveNode extends LGraphNode {
|
|||||||
// Store current size as addWidget resizes the node
|
// Store current size as addWidget resizes the node
|
||||||
const [oldWidth, oldHeight] = this.size
|
const [oldWidth, oldHeight] = this.size
|
||||||
let widget: IBaseWidget
|
let widget: IBaseWidget
|
||||||
|
|
||||||
|
// Cloud: Use asset widget for model-eligible inputs when asset API is enabled
|
||||||
|
if (isCloud && type === 'COMBO') {
|
||||||
|
const settingStore = useSettingStore()
|
||||||
|
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||||
|
const isEligible = assetService.isAssetBrowserEligible(
|
||||||
|
node.comfyClass,
|
||||||
|
widgetName
|
||||||
|
)
|
||||||
|
if (isUsingAssetAPI && isEligible) {
|
||||||
|
widget = this._createAssetWidget(node, widgetName, inputData)
|
||||||
|
const theirWidget = node.widgets?.find((w) => w.name === widgetName)
|
||||||
|
if (theirWidget) widget.value = theirWidget.value
|
||||||
|
this._finalizeWidget(widget, oldWidth, oldHeight, recreating)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isValidWidgetType(type)) {
|
if (isValidWidgetType(type)) {
|
||||||
widget = (ComfyWidgets[type](this, 'value', inputData, app) || {}).widget
|
widget = (ComfyWidgets[type](this, 'value', inputData, app) || {}).widget
|
||||||
} else {
|
} else {
|
||||||
@@ -277,20 +299,50 @@ export class PrimitiveNode extends LGraphNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When our value changes, update other widgets to reflect our changes
|
this._finalizeWidget(widget, oldWidth, oldHeight, recreating)
|
||||||
// e.g. so LoadImage shows correct image
|
}
|
||||||
|
|
||||||
|
private _createAssetWidget(
|
||||||
|
targetNode: LGraphNode,
|
||||||
|
targetInputName: string,
|
||||||
|
inputData: InputSpec
|
||||||
|
): IBaseWidget {
|
||||||
|
const defaultValue = inputData[1]?.default as string | undefined
|
||||||
|
return createAssetWidget({
|
||||||
|
node: this,
|
||||||
|
widgetName: 'value',
|
||||||
|
nodeTypeForBrowser: targetNode.comfyClass ?? '',
|
||||||
|
inputNameForBrowser: targetInputName,
|
||||||
|
defaultValue,
|
||||||
|
onValueChange: (widget, newValue, oldValue) => {
|
||||||
|
widget.callback?.(
|
||||||
|
widget.value,
|
||||||
|
app.canvas,
|
||||||
|
this,
|
||||||
|
app.canvas.graph_mouse,
|
||||||
|
{} as CanvasPointerEvent
|
||||||
|
)
|
||||||
|
this.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private _finalizeWidget(
|
||||||
|
widget: IBaseWidget,
|
||||||
|
oldWidth: number,
|
||||||
|
oldHeight: number,
|
||||||
|
recreating: boolean
|
||||||
|
) {
|
||||||
widget.callback = useChainCallback(widget.callback, () => {
|
widget.callback = useChainCallback(widget.callback, () => {
|
||||||
this.applyToGraph()
|
this.applyToGraph()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use the biggest dimensions in case the widgets caused the node to grow
|
|
||||||
this.setSize([
|
this.setSize([
|
||||||
Math.max(this.size[0], oldWidth),
|
Math.max(this.size[0], oldWidth),
|
||||||
Math.max(this.size[1], oldHeight)
|
Math.max(this.size[1], oldHeight)
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!recreating) {
|
if (!recreating) {
|
||||||
// Grow our node more if required
|
|
||||||
const sz = this.computeSize()
|
const sz = this.computeSize()
|
||||||
if (this.size[0] < sz[0]) {
|
if (this.size[0] < sz[0]) {
|
||||||
this.size[0] = sz[0]
|
this.size[0] = sz[0]
|
||||||
|
|||||||
@@ -150,7 +150,9 @@ export { BaseWidget } from './widgets/BaseWidget'
|
|||||||
|
|
||||||
export { LegacyWidget } from './widgets/LegacyWidget'
|
export { LegacyWidget } from './widgets/LegacyWidget'
|
||||||
|
|
||||||
export { isComboWidget, isAssetWidget } from './widgets/widgetMap'
|
export { isComboWidget } from './widgets/widgetMap'
|
||||||
|
/** @knipIgnoreUnusedButUsedByCustomNodes */
|
||||||
|
export { isAssetWidget } from './widgets/widgetMap'
|
||||||
// Additional test-specific exports
|
// Additional test-specific exports
|
||||||
export { LGraphButton } from './LGraphButton'
|
export { LGraphButton } from './LGraphButton'
|
||||||
export { MovingOutputLink } from './canvas/MovingOutputLink'
|
export { MovingOutputLink } from './canvas/MovingOutputLink'
|
||||||
|
|||||||
@@ -141,7 +141,10 @@ export function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
|||||||
return widget.type === 'combo'
|
return widget.type === 'combo'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IAssetWidget}. */
|
/**
|
||||||
|
* Type guard: Narrow **from {@link IBaseWidget}** to {@link IAssetWidget}.
|
||||||
|
* @knipIgnoreUnusedButUsedByCustomNodes
|
||||||
|
*/
|
||||||
export function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
|
export function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
|
||||||
return widget.type === 'asset'
|
return widget.type === 'asset'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2633,6 +2633,10 @@
|
|||||||
"errorUploadFailed": "Failed to import asset. Please try again.",
|
"errorUploadFailed": "Failed to import asset. Please try again.",
|
||||||
"errorUserTokenAccessDenied": "Your API token does not have access to this resource. Please check your token permissions.",
|
"errorUserTokenAccessDenied": "Your API token does not have access to this resource. Please check your token permissions.",
|
||||||
"errorUserTokenInvalid": "Your stored API token is invalid or expired. Please update your token in settings.",
|
"errorUserTokenInvalid": "Your stored API token is invalid or expired. Please update your token in settings.",
|
||||||
|
"invalidAsset": "Invalid Asset",
|
||||||
|
"invalidAssetDetail": "The selected asset could not be validated. Please try again.",
|
||||||
|
"invalidFilename": "Invalid Filename",
|
||||||
|
"invalidFilenameDetail": "The asset filename could not be determined. Please try again.",
|
||||||
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
|
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
|
||||||
"fileFormats": "File formats",
|
"fileFormats": "File formats",
|
||||||
"fileName": "File Name",
|
"fileName": "File Name",
|
||||||
|
|||||||
111
src/platform/assets/utils/createAssetWidget.ts
Normal file
111
src/platform/assets/utils/createAssetWidget.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import type {
|
||||||
|
IBaseWidget,
|
||||||
|
IWidgetAssetOptions
|
||||||
|
} from '@/lib/litegraph/src/types/widgets'
|
||||||
|
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||||
|
import {
|
||||||
|
assetFilenameSchema,
|
||||||
|
assetItemSchema
|
||||||
|
} from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
|
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||||
|
|
||||||
|
interface CreateAssetWidgetParams {
|
||||||
|
/** The node to add the widget to */
|
||||||
|
node: LGraphNode
|
||||||
|
/** The widget name */
|
||||||
|
widgetName: string
|
||||||
|
/** The node type to show in asset browser (may differ from node.comfyClass for PrimitiveNode) */
|
||||||
|
nodeTypeForBrowser: string
|
||||||
|
/** Input name for asset browser filtering (defaults to widgetName if not provided) */
|
||||||
|
inputNameForBrowser?: string
|
||||||
|
/** Default value for the widget */
|
||||||
|
defaultValue?: string
|
||||||
|
/** Callback when widget value changes */
|
||||||
|
onValueChange?: (
|
||||||
|
widget: IBaseWidget,
|
||||||
|
newValue: string,
|
||||||
|
oldValue: unknown
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an asset widget that opens the Asset Browser dialog for model selection.
|
||||||
|
* Used by both regular nodes (via useComboWidget) and PrimitiveNode.
|
||||||
|
*
|
||||||
|
* @param params - Configuration for the asset widget
|
||||||
|
* @returns The created asset widget
|
||||||
|
*/
|
||||||
|
export function createAssetWidget(
|
||||||
|
params: CreateAssetWidgetParams
|
||||||
|
): IBaseWidget {
|
||||||
|
const {
|
||||||
|
node,
|
||||||
|
widgetName,
|
||||||
|
nodeTypeForBrowser,
|
||||||
|
inputNameForBrowser,
|
||||||
|
defaultValue,
|
||||||
|
onValueChange
|
||||||
|
} = params
|
||||||
|
|
||||||
|
const displayLabel = defaultValue ?? t('widgets.selectModel')
|
||||||
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
|
||||||
|
async function openModal(widget: IBaseWidget) {
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
|
||||||
|
await assetBrowserDialog.show({
|
||||||
|
nodeType: nodeTypeForBrowser,
|
||||||
|
inputName: inputNameForBrowser ?? widgetName,
|
||||||
|
currentValue: widget.value as string,
|
||||||
|
onAssetSelected: (asset) => {
|
||||||
|
const validatedAsset = assetItemSchema.safeParse(asset)
|
||||||
|
|
||||||
|
if (!validatedAsset.success) {
|
||||||
|
console.error(
|
||||||
|
'Invalid asset item:',
|
||||||
|
validatedAsset.error.errors,
|
||||||
|
'Received:',
|
||||||
|
asset
|
||||||
|
)
|
||||||
|
toastStore.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('assetBrowser.invalidAsset'),
|
||||||
|
detail: t('assetBrowser.invalidAssetDetail'),
|
||||||
|
life: 5000
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = getAssetFilename(validatedAsset.data)
|
||||||
|
const validatedFilename = assetFilenameSchema.safeParse(filename)
|
||||||
|
|
||||||
|
if (!validatedFilename.success) {
|
||||||
|
console.error(
|
||||||
|
'Invalid asset filename:',
|
||||||
|
validatedFilename.error.errors,
|
||||||
|
'for asset:',
|
||||||
|
validatedAsset.data.id
|
||||||
|
)
|
||||||
|
toastStore.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('assetBrowser.invalidFilename'),
|
||||||
|
detail: t('assetBrowser.invalidFilenameDetail'),
|
||||||
|
life: 5000
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldValue = widget.value
|
||||||
|
widget.value = validatedFilename.data
|
||||||
|
onValueChange?.(widget, validatedFilename.data, oldValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: IWidgetAssetOptions = { openModal }
|
||||||
|
|
||||||
|
return node.addWidget('asset', widgetName, displayLabel, () => {}, options)
|
||||||
|
}
|
||||||
@@ -3,18 +3,10 @@ import { ref } from 'vue'
|
|||||||
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
|
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
import { isAssetWidget, isComboWidget } from '@/lib/litegraph/src/litegraph'
|
import { isComboWidget } from '@/lib/litegraph/src/litegraph'
|
||||||
import type {
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
IBaseWidget,
|
|
||||||
IWidgetAssetOptions
|
|
||||||
} from '@/lib/litegraph/src/types/widgets'
|
|
||||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
|
||||||
import {
|
|
||||||
assetFilenameSchema,
|
|
||||||
assetItemSchema
|
|
||||||
} from '@/platform/assets/schemas/assetSchema'
|
|
||||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
|
||||||
import { assetService } from '@/platform/assets/services/assetService'
|
import { assetService } from '@/platform/assets/services/assetService'
|
||||||
|
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import type {
|
import type {
|
||||||
@@ -90,71 +82,20 @@ const addMultiSelectWidget = (
|
|||||||
return widget
|
return widget
|
||||||
}
|
}
|
||||||
|
|
||||||
const createAssetBrowserWidget = (
|
function createAssetBrowserWidget(
|
||||||
node: LGraphNode,
|
node: LGraphNode,
|
||||||
inputSpec: ComboInputSpec,
|
inputSpec: ComboInputSpec,
|
||||||
defaultValue: string | undefined
|
defaultValue: string | undefined
|
||||||
): IBaseWidget => {
|
): IBaseWidget {
|
||||||
const currentValue = defaultValue
|
return createAssetWidget({
|
||||||
const displayLabel = currentValue ?? t('widgets.selectModel')
|
node,
|
||||||
const assetBrowserDialog = useAssetBrowserDialog()
|
widgetName: inputSpec.name,
|
||||||
|
nodeTypeForBrowser: node.comfyClass ?? '',
|
||||||
async function openModal(widget: IBaseWidget) {
|
defaultValue,
|
||||||
if (!isAssetWidget(widget)) {
|
onValueChange: (widget, newValue, oldValue) => {
|
||||||
throw new Error(`Expected asset widget but received ${widget.type}`)
|
node.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
|
||||||
}
|
}
|
||||||
await assetBrowserDialog.show({
|
})
|
||||||
nodeType: node.comfyClass || '',
|
|
||||||
inputName: inputSpec.name,
|
|
||||||
currentValue: widget.value,
|
|
||||||
onAssetSelected: (asset) => {
|
|
||||||
const validatedAsset = assetItemSchema.safeParse(asset)
|
|
||||||
|
|
||||||
if (!validatedAsset.success) {
|
|
||||||
console.error(
|
|
||||||
'Invalid asset item:',
|
|
||||||
validatedAsset.error.errors,
|
|
||||||
'Received:',
|
|
||||||
asset
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = getAssetFilename(validatedAsset.data)
|
|
||||||
const validatedFilename = assetFilenameSchema.safeParse(filename)
|
|
||||||
|
|
||||||
if (!validatedFilename.success) {
|
|
||||||
console.error(
|
|
||||||
'Invalid asset filename:',
|
|
||||||
validatedFilename.error.errors,
|
|
||||||
'for asset:',
|
|
||||||
validatedAsset.data.id
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldValue = widget.value
|
|
||||||
widget.value = validatedFilename.data
|
|
||||||
node.onWidgetChanged?.(
|
|
||||||
widget.name,
|
|
||||||
validatedFilename.data,
|
|
||||||
oldValue,
|
|
||||||
widget
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const options: IWidgetAssetOptions = { openModal }
|
|
||||||
|
|
||||||
const widget = node.addWidget(
|
|
||||||
'asset',
|
|
||||||
inputSpec.name,
|
|
||||||
displayLabel,
|
|
||||||
() => undefined,
|
|
||||||
options
|
|
||||||
)
|
|
||||||
|
|
||||||
return widget
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const createInputMappingWidget = (
|
const createInputMappingWidget = (
|
||||||
|
|||||||
Reference in New Issue
Block a user