Compare commits

..

11 Commits

Author SHA1 Message Date
Johnpaul
92fc6f35f3 fix: re-export VueWidgets types from public API 2026-01-13 20:54:18 +01:00
Johnpaul
394cefcc92 test: add widget re-registration overwrite test 2026-01-13 20:48:21 +01:00
Johnpaul
73b9079538 fix: remove redundant guard and export VueWidgets type
- Remove nested if check in getCustomVueWidgets registration by
  capturing method reference before async IIFE
- Export VueWidgets type for use by external extension code
2026-01-13 20:46:47 +01:00
Johnpaul Chiwetelu
a9503c5a2f Merge branch 'main' into feat/vue-widget-registration 2026-01-12 22:56:02 +01:00
GitHub Action
50db96a954 [automated] Apply ESLint and Prettier fixes 2025-12-24 02:59:32 +00:00
Johnpaul
81db82306e test: add unit tests for Vue widget registry 2025-12-24 02:16:55 +01:00
Johnpaul
be99596fd3 feat: expose Vue globally for external extension widgets 2025-12-24 02:16:23 +01:00
Johnpaul
b4a6b8b5ff feat: support custom display hints for Vue widget lookup 2025-12-24 02:15:29 +01:00
Johnpaul
187c80d213 feat: invoke getCustomVueWidgets hook when registering extensions 2025-12-24 02:14:58 +01:00
Johnpaul
bbc7671d31 feat: add registerVueWidgets API for extension widget registration 2025-12-24 02:14:24 +01:00
Johnpaul
9368b8c329 feat: add getCustomVueWidgets extension hook for Vue widget registration 2025-12-24 02:14:00 +01:00
72 changed files with 624 additions and 817 deletions

View File

@@ -133,11 +133,8 @@ test.describe('Menu', () => {
// Checkmark should be invisible again (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
// Click in top-right corner to close menu (avoid hamburger menu at top-left)
const viewport = comfyPage.page.viewportSize()!
await comfyPage.page
.locator('body')
.click({ position: { x: viewport.width - 10, y: 10 } })
// Click outside to close menu
await comfyPage.page.locator('body').click({ position: { x: 10, y: 10 } })
// Verify menu is now closed
await expect(menu).not.toBeVisible()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -194,10 +194,7 @@ test.describe('Image widget', () => {
const comboEntry = comfyPage.page.getByRole('menuitem', {
name: 'image32x32.webp'
})
await comboEntry.click()
// Stabilization for the image swap
await comfyPage.nextFrame()
await comboEntry.click({ noWaitAfter: true })
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.37.11",
"version": "1.37.10",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -1,6 +1,6 @@
<template>
<div
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
class="subgraph-breadcrumb w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
@@ -13,37 +13,17 @@
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
}"
>
<Button
class="context-menu-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
icon="pi pi-bars"
text
severity="secondary"
size="small"
@click="handleMenuClick"
/>
<Button
v-if="isInSubgraph"
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
text
severity="secondary"
size="small"
@click="handleBackClick"
>
<i class="icon-[lucide--undo-2]" />
</Button>
<Breadcrumb
ref="breadcrumbRef"
class="w-fit rounded-lg p-0"
:class="{ hidden: !isInSubgraph }"
:model="items"
:pt="{ item: { class: 'pointer-events-auto' } }"
:aria-label="$t('g.graphNavigation')"
>
<template #item="{ item }">
<SubgraphBreadcrumbItem
:ref="(el) => setItemRef(item, el)"
:item="item"
:is-active="item.key === activeItemKey"
:is-active="item === items.at(-1)"
/>
</template>
<template #separator
@@ -55,7 +35,6 @@
<script setup lang="ts">
import Breadcrumb from 'primevue/breadcrumb'
import Button from 'primevue/button'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onUpdated, ref, watch } from 'vue'
@@ -64,7 +43,6 @@ import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
@@ -77,12 +55,6 @@ const ICON_WIDTH = 20
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const rootItemRef = ref<InstanceType<typeof SubgraphBreadcrumbItem>>()
const setItemRef = (item: MenuItem, el: unknown) => {
if (item.key === 'root') {
rootItemRef.value = el as InstanceType<typeof SubgraphBreadcrumbItem>
}
}
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const isBlueprint = computed(() =>
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
@@ -90,28 +62,17 @@ const isBlueprint = computed(() =>
const collapseTabs = ref(false)
const overflowingTabs = ref(false)
const isInSubgraph = computed(() => navigationStore.navigationStack.length > 0)
const breadcrumbElement = computed(() => {
if (!breadcrumbRef.value) return null
const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
key: 'root',
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(canvas.graph.rootGraph)
}
}))
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
return list
})
const items = computed(() => {
const items = navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
label: subgraph.name,
key: `subgraph-${subgraph.id}`,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_item_selected'
@@ -134,26 +95,21 @@ const items = computed(() => {
return [home.value, ...items]
})
const activeItemKey = computed(() => items.value.at(-1)?.key)
const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
key: 'root',
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
const handleMenuClick = (event: MouseEvent) => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_menu_selected'
})
rootItemRef.value?.toggleMenu(event)
}
const handleBackClick = () => {
void useCommandStore().execute('Comfy.Graph.ExitSubgraph')
}
const breadcrumbElement = computed(() => {
if (!breadcrumbRef.value) return null
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
return list
})
canvas.setGraph(canvas.graph.rootGraph)
}
}))
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
@@ -233,18 +189,13 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item) {
@apply flex items-center overflow-hidden h-8;
@apply flex items-center overflow-hidden;
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
border: 1px solid transparent;
background-color: transparent;
transition: all 0.2s;
/* Collapse middle items first */
flex-shrink: 10000;
}
:deep(.p-breadcrumb-separator) {
border: 1px solid transparent;
background-color: transparent;
display: flex;
padding: 0 var(--p-breadcrumb-item-margin);
}
@@ -254,9 +205,11 @@ onUpdated(() => {
calc(var(--p-breadcrumb-item-margin) + var(--p-breadcrumb-item-padding));
}
:deep(.p-breadcrumb-item:hover) {
@apply rounded-lg;
border-color: var(--interface-stroke);
:deep(.p-breadcrumb-separator),
:deep(.p-breadcrumb-item) {
@apply h-12;
border-top: 1px solid var(--interface-stroke);
border-bottom: 1px solid var(--interface-stroke);
background-color: var(--comfy-menu-bg);
}
@@ -265,8 +218,10 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item:first-child) {
@apply rounded-l-lg;
/* Then collapse the root workflow */
flex-shrink: 5000;
border-left: 1px solid var(--interface-stroke);
.p-breadcrumb-item-link {
padding-left: var(--p-breadcrumb-item-padding);
@@ -274,10 +229,13 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item:last-child) {
@apply rounded-r-lg;
/* Then collapse the active item */
flex-shrink: 1;
border-right: 1px solid var(--interface-stroke);
}
:deep(.p-breadcrumb-item-link:hover),
:deep(.p-breadcrumb-item-link-menu-visible) {
background-color: color-mix(
in srgb,

View File

@@ -7,7 +7,7 @@
}"
draggable="false"
href="#"
class="p-breadcrumb-item-link h-8 cursor-pointer px-2"
class="p-breadcrumb-item-link h-12 cursor-pointer px-2"
:class="{
'flex items-center gap-1': isActive,
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
@@ -25,7 +25,7 @@
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
<Menu
v-if="isActive || isRoot"
v-if="isActive"
ref="menu"
:model="menuItems"
:popup="true"
@@ -59,7 +59,6 @@ import Tag from 'primevue/tag'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
ComfyWorkflow,
@@ -136,28 +135,79 @@ const tooltipText = computed(() => {
return props.item.label
})
const startRename = async () => {
// Check if element is hidden (collapsed breadcrumb)
// When collapsed, root item is hidden via CSS display:none, so use rename command
if (isRoot && wrapperRef.value?.offsetParent === null) {
await useCommandStore().execute('Comfy.RenameWorkflow')
return
}
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
const menuItems = computed<MenuItem[]>(() => {
return [
{
label: t('g.rename'),
icon: 'pi pi-pencil',
command: startRename
},
{
label: t('breadcrumbsMenu.duplicate'),
icon: 'pi pi-copy',
command: async () => {
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
},
visible: isRoot && !props.item.isBlueprint
},
{
separator: true,
visible: isRoot
},
{
label: t('menuLabels.Save'),
icon: 'pi pi-save',
command: async () => {
await useCommandStore().execute('Comfy.SaveWorkflow')
},
visible: isRoot
},
{
label: t('menuLabels.Save As'),
icon: 'pi pi-save',
command: async () => {
await useCommandStore().execute('Comfy.SaveWorkflowAs')
},
visible: isRoot
},
{
separator: true
},
{
label: t('breadcrumbsMenu.clearWorkflow'),
icon: 'pi pi-trash',
command: async () => {
await useCommandStore().execute('Comfy.ClearWorkflow')
}
},
{
separator: true,
visible: props.item.key === 'root' && props.item.isBlueprint
},
{
label: t('subgraphStore.publish'),
icon: 'pi pi-copy',
command: async () => {
await workflowService.saveWorkflowAs(workflowStore.activeWorkflow!)
},
visible: props.item.key === 'root' && props.item.isBlueprint
},
{
separator: true,
visible: isRoot
},
{
label: props.item.isBlueprint
? t('breadcrumbsMenu.deleteBlueprint')
: t('breadcrumbsMenu.deleteWorkflow'),
icon: 'pi pi-times',
command: async () => {
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
},
visible: isRoot
}
})
}
const { menuItems } = useWorkflowActionsMenu(startRename, { isRoot })
]
})
const handleClick = (event: MouseEvent) => {
if (isEditing.value) {
@@ -178,6 +228,20 @@ const handleClick = (event: MouseEvent) => {
}
}
const startRename = () => {
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
}
}
})
}
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
@@ -185,14 +249,6 @@ const inputBlur = async (doRename: boolean) => {
isEditing.value = false
}
const toggleMenu = (event: MouseEvent) => {
menu.value?.toggle(event)
}
defineExpose({
toggleMenu
})
</script>
<style scoped>

View File

@@ -1,65 +0,0 @@
<template>
<span class="relative inline-flex items-center justify-center size-[1em]">
<i :class="mainIcon" class="text-[1em]" />
<i
:class="
cn(
subIcon,
'absolute leading-none pointer-events-none',
positionX === 'left' ? 'left-0' : 'right-0',
positionY === 'top' ? 'top-0' : 'bottom-0'
)
"
:style="subIconStyle"
/>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
type Position = 'top' | 'bottom' | 'left' | 'right'
export interface OverlayIconProps {
mainIcon: string
subIcon: string
positionX?: Position
positionY?: Position
offsetX?: number
offsetY?: number
subIconScale?: number
}
const {
mainIcon,
subIcon,
positionX = 'right',
positionY = 'bottom',
offsetX = 0,
offsetY = 0,
subIconScale = 0.6
} = defineProps<OverlayIconProps>()
const textShadow = [
`-1px -1px 0 rgba(0, 0, 0, 0.7)`,
`1px -1px 0 rgba(0, 0, 0, 0.7)`,
`-1px 1px 0 rgba(0, 0, 0, 0.7)`,
`1px 1px 0 rgba(0, 0, 0, 0.7)`,
`-1px 0 0 rgba(0, 0, 0, 0.7)`,
`1px 0 0 rgba(0, 0, 0, 0.7)`,
`0 -1px 0 rgba(0, 0, 0, 0.7)`,
`0 1px 0 rgba(0, 0, 0, 0.7)`
].join(', ')
const subIconStyle = computed(() => ({
fontSize: `${subIconScale}em`,
textShadow,
...(offsetX !== 0 && {
[positionX === 'left' ? 'left' : 'right']: `${offsetX}px`
}),
...(offsetY !== 0 && {
[positionY === 'top' ? 'top' : 'bottom']: `${offsetY}px`
})
}))
</script>

View File

@@ -158,7 +158,6 @@ import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { useDialogService } from '@/services/dialogService'
@@ -176,7 +175,6 @@ const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { isSubscriptionEnabled } = useSubscription()
// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
@@ -254,11 +252,9 @@ async function handleBuy() {
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
await authActions.purchaseCredits(payAmount.value)
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
// Close top-up dialog (keep tracking) and open subscription panel to show updated credits
handleClose(false)
dialogService.showSettingsDialog(
isSubscriptionEnabled() ? 'subscription' : 'credits'
)
dialogService.showSettingsDialog('subscription')
} catch (error) {
console.error('Purchase failed:', error)

View File

@@ -7,15 +7,6 @@
@mouseleave="handleMouseLeave"
@click="handleClick"
>
<Button
v-if="isActiveTab"
class="context-menu-button -mx-1 w-auto px-1 py-0"
variant="muted-textonly"
size="icon-sm"
@click.stop="handleMenuClick"
>
<i class="pi pi-bars" />
</Button>
<span class="workflow-label inline-block max-w-[150px] truncate text-sm">
{{ workflowOption.workflow.filename }}
</span>
@@ -43,26 +34,9 @@
:thumbnail-url="thumbnailUrl"
:is-active-tab="isActiveTab"
/>
<Menu
v-if="isActiveTab"
ref="menu"
:model="menuItems"
:popup="true"
:pt="{
root: {
style: 'background-color: var(--comfy-menu-bg)'
},
itemLink: {
class: 'py-2'
}
}"
/>
</template>
<script setup lang="ts">
import type { MenuState } from 'primevue/menu'
import Menu from 'primevue/menu'
import { computed, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -71,14 +45,11 @@ import {
usePragmaticDraggable,
usePragmaticDroppable
} from '@/composables/usePragmaticDragAndDrop'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
@@ -143,12 +114,6 @@ const thumbnailUrl = computed(() => {
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
})
const menu = ref<InstanceType<typeof Menu> & MenuState>()
const { menuItems } = useWorkflowActionsMenu(() =>
useCommandStore().execute('Comfy.RenameWorkflow')
)
// Event handlers that delegate to the popover component
const handleMouseEnter = (event: Event) => {
popoverRef.value?.showPopover(event)
@@ -162,14 +127,6 @@ const handleClick = (event: Event) => {
popoverRef.value?.togglePopover(event)
}
const handleMenuClick = (event: MouseEvent) => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'workflow_tab_menu_selected'
})
// Show breadcrumb menu instead of emitting context click
menu.value?.toggle(event)
}
const closeWorkflows = async (options: WorkflowOption[]) => {
for (const opt of options) {
if (

View File

@@ -80,12 +80,7 @@
/>
<LoginButton v-else-if="isDesktop" class="p-1" />
</div>
<ContextMenu ref="menu" :model="contextMenuItems">
<template #itemicon="{ item }">
<OverlayIcon v-if="item.overlayIcon" v-bind="item.overlayIcon" />
<i v-else-if="item.icon" :class="item.icon" />
</template>
</ContextMenu>
<ContextMenu ref="menu" :model="contextMenuItems" />
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
</div>
</template>
@@ -99,8 +94,6 @@ import { computed, nextTick, onUpdated, ref, watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { useI18n } from 'vue-i18n'
import OverlayIcon from '@/components/common/OverlayIcon.vue'
import type { OverlayIconProps } from '@/components/common/OverlayIcon.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
@@ -108,11 +101,13 @@ import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
@@ -133,8 +128,8 @@ const { t } = useI18n()
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowBookmarkStore = useWorkflowBookmarkStore()
const workflowService = useWorkflowService()
const commandStore = useCommandStore()
const { isLoggedIn } = useCurrentUser()
const isIntegratedTabBar = computed(
@@ -198,73 +193,54 @@ const showContextMenu = (event: MouseEvent, option: WorkflowOption) => {
rightClickedTab.value = option
menu.value.show(event)
}
const rightClickedWorkflow = computed(
() => rightClickedTab.value?.workflow ?? null
)
const { menuItems: baseMenuItems } = useWorkflowActionsMenu(
() => commandStore.execute('Comfy.RenameWorkflow'),
{
includeDelete: false,
workflow: rightClickedWorkflow
}
)
const contextMenuItems = computed(() => {
const tab = rightClickedTab.value
const tab = rightClickedTab.value as WorkflowOption
if (!tab) return []
const index = options.value.findIndex((v) => v.workflow === tab.workflow)
return [
...baseMenuItems.value,
{
label: t('tabMenu.duplicateTab'),
command: async () => {
await workflowService.duplicateWorkflow(tab.workflow)
}
},
{
separator: true
},
{
label: t('tabMenu.closeTab'),
icon: 'pi pi-times',
command: () => onCloseWorkflow(tab)
},
{
label: t('tabMenu.closeTabsToLeft'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrow-left',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
} as OverlayIconProps,
command: () => closeWorkflows(options.value.slice(0, index)),
disabled: index <= 0
},
{
label: t('tabMenu.closeTabsToRight'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrow-right',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
} as OverlayIconProps,
command: () => closeWorkflows(options.value.slice(index + 1)),
disabled: index === options.value.length - 1
},
{
label: t('tabMenu.closeOtherTabs'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrows-h',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
} as OverlayIconProps,
command: () =>
closeWorkflows([
...options.value.slice(index + 1),
...options.value.slice(0, index)
]),
disabled: options.value.length <= 1
},
{
label: workflowBookmarkStore.isBookmarked(tab.workflow.path)
? t('tabMenu.removeFromBookmarks')
: t('tabMenu.addToBookmarks'),
command: () => workflowBookmarkStore.toggleBookmarked(tab.workflow.path),
disabled: tab.workflow.isTemporary
}
]
})
const commandStore = useCommandStore()
// Horizontal scroll on wheel
const handleWheel = (event: WheelEvent) => {

View File

@@ -2,17 +2,6 @@ import { formatCreditsFromUsd } from '@/base/credits/comfyCredits'
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
/**
* Meshy credit pricing constant.
* 1 Meshy credit = $0.04 USD
* Change this value to update all Meshy node prices.
*/
const MESHY_CREDIT_PRICE_USD = 0.04
/** Convert Meshy credits to USD */
const meshyCreditsToUsd = (credits: number): number =>
credits * MESHY_CREDIT_PRICE_USD
const DEFAULT_NUMBER_OPTIONS: Intl.NumberFormatOptions = {
minimumFractionDigits: 0,
maximumFractionDigits: 0
@@ -220,24 +209,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
const generateAudioWidget = node.widgets?.find(
(w) => w.name === 'generate_audio'
) as IComboWidget | undefined
if (!modelWidget || !durationWidget || !resolutionWidget) return 'Token-based'
const model = String(modelWidget.value).toLowerCase()
const resolution = String(resolutionWidget.value).toLowerCase()
const seconds = parseFloat(String(durationWidget.value))
const generateAudio =
generateAudioWidget &&
String(generateAudioWidget.value).toLowerCase() === 'true'
const priceByModel: Record<string, Record<string, [number, number]>> = {
'seedance-1-5-pro': {
'480p': [0.12, 0.12],
'720p': [0.26, 0.26],
'1080p': [0.58, 0.59]
},
'seedance-1-0-pro': {
'480p': [0.23, 0.24],
'720p': [0.51, 0.56],
@@ -255,15 +233,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
}
}
const modelKey = model.includes('seedance-1-5-pro')
? 'seedance-1-5-pro'
: model.includes('seedance-1-0-pro-fast')
? 'seedance-1-0-pro-fast'
: model.includes('seedance-1-0-pro')
? 'seedance-1-0-pro'
: model.includes('seedance-1-0-lite')
? 'seedance-1-0-lite'
: ''
const modelKey = model.includes('seedance-1-0-pro-fast')
? 'seedance-1-0-pro-fast'
: model.includes('seedance-1-0-pro')
? 'seedance-1-0-pro'
: model.includes('seedance-1-0-lite')
? 'seedance-1-0-lite'
: ''
const resKey = resolution.includes('1080')
? '1080p'
@@ -279,10 +255,8 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
const [min10s, max10s] = baseRange
const scale = seconds / 10
const audioMultiplier =
modelKey === 'seedance-1-5-pro' && generateAudio ? 2 : 1
const minCost = min10s * scale * audioMultiplier
const maxCost = max10s * scale * audioMultiplier
const minCost = min10s * scale
const maxCost = max10s * scale
if (minCost === maxCost) return formatCreditsLabel(minCost)
return formatCreditsRangeLabel(minCost, maxCost)
@@ -551,54 +525,6 @@ const calculateTripo3DGenerationPrice = (
return formatCreditsLabel(dollars)
}
/**
* Meshy Image to 3D pricing calculator.
* Pricing based on should_texture widget:
* - Without texture: 20 credits
* - With texture: 30 credits
*/
const calculateMeshyImageToModelPrice = (node: LGraphNode): string => {
const shouldTextureWidget = node.widgets?.find(
(w) => w.name === 'should_texture'
) as IComboWidget
if (!shouldTextureWidget) {
return formatCreditsRangeLabel(
meshyCreditsToUsd(20),
meshyCreditsToUsd(30),
{ note: '(varies with texture)' }
)
}
const shouldTexture = String(shouldTextureWidget.value).toLowerCase()
const credits = shouldTexture === 'true' ? 30 : 20
return formatCreditsLabel(meshyCreditsToUsd(credits))
}
/**
* Meshy Multi-Image to 3D pricing calculator.
* Pricing based on should_texture widget:
* - Without texture: 5 credits
* - With texture: 15 credits
*/
const calculateMeshyMultiImageToModelPrice = (node: LGraphNode): string => {
const shouldTextureWidget = node.widgets?.find(
(w) => w.name === 'should_texture'
) as IComboWidget
if (!shouldTextureWidget) {
return formatCreditsRangeLabel(
meshyCreditsToUsd(5),
meshyCreditsToUsd(15),
{ note: '(varies with texture)' }
)
}
const shouldTexture = String(shouldTextureWidget.value).toLowerCase()
const credits = shouldTexture === 'true' ? 15 : 5
return formatCreditsLabel(meshyCreditsToUsd(credits))
}
/**
* Static pricing data for API nodes, now supporting both strings and functions
*/
@@ -1886,27 +1812,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
TripoRefineNode: {
displayPrice: formatCreditsLabel(0.3)
},
MeshyTextToModelNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(20))
},
MeshyRefineNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(10))
},
MeshyImageToModelNode: {
displayPrice: calculateMeshyImageToModelPrice
},
MeshyMultiImageToModelNode: {
displayPrice: calculateMeshyMultiImageToModelPrice
},
MeshyRigModelNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(5))
},
MeshyAnimateModelNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(3))
},
MeshyTextureNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(10))
},
// Google/Gemini nodes
GeminiNode: {
displayPrice: (node: LGraphNode): string => {
@@ -2622,9 +2527,6 @@ export const useNodePricing = () => {
'animate_in_place'
],
TripoTextureNode: ['texture_quality'],
// Meshy nodes
MeshyImageToModelNode: ['should_texture'],
MeshyMultiImageToModelNode: ['should_texture'],
// Google/Gemini nodes
GeminiNode: ['model'],
GeminiImage2Node: ['resolution'],
@@ -2638,24 +2540,9 @@ export const useNodePricing = () => {
'sequential_image_generation',
'max_images'
],
ByteDanceTextToVideoNode: [
'model',
'duration',
'resolution',
'generate_audio'
],
ByteDanceImageToVideoNode: [
'model',
'duration',
'resolution',
'generate_audio'
],
ByteDanceFirstLastFrameNode: [
'model',
'duration',
'resolution',
'generate_audio'
],
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
WanTextToVideoApi: ['duration', 'size'],
WanImageToVideoApi: ['duration', 'resolution'],

View File

@@ -188,26 +188,6 @@ export function useCoreCommands(): ComfyCommand[] {
await workflowService.saveWorkflowAs(workflow)
}
},
{
id: 'Comfy.RenameWorkflow',
icon: 'pi pi-pencil',
label: 'Rename Workflow',
menubarLabel: 'Rename',
function: async () => {
const workflow = workflowStore.activeWorkflow
if (!workflow || !workflow.isPersisted) return
const newName = await dialogService.prompt({
title: t('g.rename'),
message: t('workflowService.enterFilename') + ':',
defaultValue: workflow.filename
})
if (!newName || newName === workflow.filename) return
const newPath = workflow.directory + '/' + newName + '.json'
await workflowService.renameWorkflow(workflow, newPath)
}
},
{
id: 'Comfy.ExportWorkflow',
icon: 'pi pi-download',

View File

@@ -1,192 +0,0 @@
import type { MenuItem } from 'primevue/menuitem'
import type { ComputedRef, Ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
interface WorkflowActionsMenuOptions {
/** Whether this is the root workflow level. Defaults to true. */
isRoot?: boolean
/** Whether to include the delete workflow action. Defaults to true. */
includeDelete?: boolean
/** Override the workflow to operate on. If not provided, uses activeWorkflow. */
workflow?: Ref<ComfyWorkflow | null> | ComputedRef<ComfyWorkflow | null>
}
export function useWorkflowActionsMenu(
startRename: () => void,
options: WorkflowActionsMenuOptions = {}
) {
const { isRoot = true, includeDelete = true, workflow } = options
const { t } = useI18n()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const bookmarkStore = useWorkflowBookmarkStore()
const commandStore = useCommandStore()
const subgraphStore = useSubgraphStore()
const targetWorkflow = computed(
() => workflow?.value ?? workflowStore.activeWorkflow
)
/** Switch to the target workflow tab if it's not already active */
const ensureWorkflowActive = async (wf: ComfyWorkflow | null) => {
if (!wf || wf === workflowStore.activeWorkflow) return
await workflowService.openWorkflow(wf)
}
const menuItems = computed<MenuItem[]>(() => {
const workflow = targetWorkflow.value
const isBlueprint = workflow
? subgraphStore.isSubgraphBlueprint(workflow)
: false
const items: MenuItem[] = []
const addItem = (
label: string,
icon: string,
command: () => void,
visible = true,
disabled = false,
separator = false
) => {
if (!visible) return
if (separator) items.push({ separator: true })
items.push({ label, icon, command, disabled })
}
addItem(
t('g.rename'),
'pi pi-pencil',
async () => {
await ensureWorkflowActive(targetWorkflow.value)
startRename()
},
true,
isRoot && !workflow?.isPersisted
)
addItem(
t('breadcrumbsMenu.duplicate'),
'pi pi-copy',
async () => {
if (workflow) {
await workflowService.duplicateWorkflow(workflow)
}
},
isRoot && !isBlueprint
)
addItem(
t('menuLabels.Save'),
'pi pi-save',
async () => {
await ensureWorkflowActive(workflow)
await commandStore.execute('Comfy.SaveWorkflow')
},
isRoot,
false,
true
)
addItem(
t('menuLabels.Save As'),
'pi pi-save',
async () => {
await ensureWorkflowActive(workflow)
await commandStore.execute('Comfy.SaveWorkflowAs')
},
isRoot
)
addItem(
bookmarkStore.isBookmarked(workflow?.path ?? '')
? t('tabMenu.removeFromBookmarks')
: t('tabMenu.addToBookmarks'),
'pi pi-bookmark' +
(bookmarkStore.isBookmarked(workflow?.path ?? '') ? '-fill' : ''),
async () => {
if (workflow?.path) {
await bookmarkStore.toggleBookmarked(workflow.path)
}
},
isRoot,
workflow?.isTemporary ?? false
)
addItem(
t('menuLabels.Export'),
'pi pi-download',
async () => {
await ensureWorkflowActive(workflow)
await commandStore.execute('Comfy.ExportWorkflow')
},
isRoot
)
addItem(
t('menuLabels.Export (API)'),
'pi pi-download',
async () => {
await ensureWorkflowActive(workflow)
await commandStore.execute('Comfy.ExportWorkflowAPI')
},
isRoot
)
addItem(
t('breadcrumbsMenu.clearWorkflow'),
'pi pi-trash',
async () => {
await ensureWorkflowActive(workflow)
await commandStore.execute('Comfy.ClearWorkflow')
},
true,
false,
true
)
addItem(
t('subgraphStore.publish'),
'pi pi-upload',
async () => {
if (workflow) {
await workflowService.saveWorkflowAs(workflow)
}
},
isRoot && isBlueprint,
false,
true
)
addItem(
isBlueprint
? t('breadcrumbsMenu.deleteBlueprint')
: t('breadcrumbsMenu.deleteWorkflow'),
'pi pi-times',
async () => {
if (workflow) {
await workflowService.deleteWorkflow(workflow)
}
},
isRoot && includeDelete,
false,
true
)
return items
})
return {
menuItems
}
}

View File

@@ -8,7 +8,6 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
@@ -4042,8 +4041,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
layoutStore.batchUpdateNodeBounds(newPositions)
this.selectItems(created)
forEachNode(graph, (n) => n.onGraphConfigured?.())
forEachNode(graph, (n) => n.onAfterGraphConfigured?.())
graph.afterChange()
this.emitAfterChange()

View File

@@ -2311,7 +2311,6 @@
"filterBy": "Filter by",
"findInLibrary": "Find it in the {type} section of the models library.",
"finish": "Finish",
"importAnother": "Import Another",
"genericLinkPlaceholder": "Paste link here",
"jobId": "Job ID",
"loadingModels": "Loading {type}...",

View File

@@ -12,6 +12,10 @@ import { createApp } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import { getFirebaseConfig } from '@/config/firebase'
import { exposeVueApi } from '@/utils/vueExtensionApi'
// Expose Vue utilities for external extensions before they load
exposeVueApi()
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'

View File

@@ -57,7 +57,6 @@
:assets="filteredAssets"
:loading="isLoading"
@asset-select="handleAssetSelectAndEmit"
@asset-deleted="refreshAssets"
/>
</template>
</BaseModalLayout>

View File

@@ -1,5 +1,6 @@
<template>
<div
v-if="!deletedLocal"
data-component-id="AssetCard"
:data-asset-id="asset.id"
:aria-labelledby="titleId"
@@ -138,9 +139,8 @@ const { asset, interactive } = defineProps<{
interactive?: boolean
}>()
const emit = defineEmits<{
defineEmits<{
select: [asset: AssetDisplayItem]
deleted: [asset: AssetDisplayItem]
}>()
const { t } = useI18n()
@@ -158,6 +158,7 @@ const descId = useId()
const isEditing = ref(false)
const newNameRef = ref<string>()
const deletedLocal = ref(false)
const displayName = computed(() => newNameRef.value ?? asset.name)
@@ -210,7 +211,7 @@ function confirmDeletion() {
})
// Give a second for the completion message
await new Promise((resolve) => setTimeout(resolve, 1_000))
emit('deleted', asset)
deletedLocal.value = true
} catch (err: unknown) {
console.error(err)
promptText.value = t('assetBrowser.deletion.failed', {

View File

@@ -35,7 +35,6 @@
:asset="item"
:interactive="true"
@select="$emit('assetSelect', $event)"
@deleted="$emit('assetDeleted', $event)"
/>
</template>
</VirtualGrid>
@@ -57,7 +56,6 @@ const { assets } = defineProps<{
defineEmits<{
assetSelect: [asset: AssetDisplayItem]
assetDeleted: [asset: AssetDisplayItem]
}>()
const assetsWithKey = computed(() =>

View File

@@ -5,7 +5,7 @@
{{ $t('assetBrowser.modelAssociatedWithLink') }}
</p>
<div
class="flex items-center gap-3 rounded-lg bg-secondary-background px-4 py-2"
class="flex items-center gap-3 rounded-lg bg-secondary-background p-3"
>
<img
v-if="previewImage"
@@ -21,15 +21,9 @@
<!-- Model Type Selection -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label>
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
</label>
<i class="icon-[lucide--circle-question-mark] text-muted-foreground" />
<span class="text-muted-foreground">
{{ $t('assetBrowser.notSureLeaveAsIs') }}
</span>
</div>
<label class="">
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
</label>
<SingleSelect
v-model="modelValue"
:label="
@@ -41,6 +35,10 @@
:disabled="isLoading"
data-attr="upload-model-step2-type-selector"
/>
<div class="flex items-center gap-2">
<i class="icon-[lucide--circle-question-mark]" />
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
</div>
</div>
</div>
</template>

View File

@@ -48,7 +48,6 @@
@fetch-metadata="handleFetchMetadata"
@upload="handleUploadModel"
@close="handleClose"
@import-another="resetWizard"
/>
</div>
</template>
@@ -86,8 +85,7 @@ const {
canUploadModel,
fetchMetadata,
uploadModel,
goToPreviousStep,
resetWizard
goToPreviousStep
} = useUploadModelWizard(modelTypes)
async function handleFetchMetadata() {

View File

@@ -80,33 +80,21 @@
<i v-if="isUploading" class="icon-[lucide--loader-circle] animate-spin" />
<span>{{ $t('assetBrowser.upload') }}</span>
</Button>
<template
<Button
v-else-if="
currentStep === 3 &&
(uploadStatus === 'success' || uploadStatus === 'processing')
"
variant="secondary"
data-attr="upload-model-step3-finish-button"
@click="emit('close')"
>
<Button
variant="muted-textonly"
size="lg"
data-attr="upload-model-step3-import-another-button"
@click="emit('importAnother')"
>
{{ $t('assetBrowser.importAnother') }}
</Button>
<Button
variant="secondary"
size="lg"
data-attr="upload-model-step3-finish-button"
@click="emit('close')"
>
{{
uploadStatus === 'processing'
? $t('g.close')
: $t('assetBrowser.finish')
}}
</Button>
</template>
{{
uploadStatus === 'processing'
? $t('g.close')
: $t('assetBrowser.finish')
}}
</Button>
<VideoHelpDialog
v-model="showCivitaiHelp"
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
@@ -146,6 +134,5 @@ const emit = defineEmits<{
(e: 'fetchMetadata'): void
(e: 'upload'): void
(e: 'close'): void
(e: 'importAnother'): void
}>()
</script>

View File

@@ -20,7 +20,7 @@
:href="civitaiUrl"
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground underline"
class="text-muted underline"
>
{{ $t('assetBrowser.providerCivitai') }}</a
><span>,</span>
@@ -35,7 +35,7 @@
:href="huggingFaceUrl"
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground underline"
class="text-muted underline"
>
{{ $t('assetBrowser.providerHuggingFace') }}
</a>
@@ -58,7 +58,7 @@
class="icon-[lucide--circle-check-big] absolute top-1/2 right-3 size-5 -translate-y-1/2 text-green-500"
/>
</div>
<p v-if="error" class="text-sm text-error">
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<p v-else-if="!flags.asyncModelUploadEnabled" class="text-foreground">

View File

@@ -11,7 +11,7 @@
<a
href="https://civitai.com/models"
target="_blank"
class="text-muted-foreground underline"
class="text-muted-foreground"
>
{{ $t('assetBrowser.uploadModelDescription2Link') }}
</a>
@@ -51,14 +51,14 @@
class="icon-[lucide--circle-check-big] absolute top-1/2 right-3 size-5 -translate-y-1/2 text-green-500"
/>
</div>
<p v-if="error" class="text-sm text-error">
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<i18n-t
v-else
keypath="assetBrowser.civitaiLinkExample"
tag="p"
class="text-sm"
class="text-xs"
>
<template #example>
<strong>{{ $t('assetBrowser.civitaiLinkExampleStrong') }}</strong>
@@ -67,7 +67,7 @@
<a
href="https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295"
target="_blank"
class="text-muted-foreground underline"
class="text-muted-foreground"
>
{{ $t('assetBrowser.civitaiLinkExampleUrl') }}
</a>

View File

@@ -245,8 +245,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
if (selectedModelType.value) {
assetDownloadStore.trackDownload(
result.task.task_id,
selectedModelType.value,
filename
selectedModelType.value
)
}
uploadStatus.value = 'processing'
@@ -285,20 +284,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
}
function resetWizard() {
currentStep.value = 1
isFetchingMetadata.value = false
isUploading.value = false
uploadStatus.value = undefined
uploadError.value = ''
wizardData.value = {
url: '',
name: '',
tags: []
}
selectedModelType.value = undefined
}
return {
// State
currentStep,
@@ -317,7 +302,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
// Actions
fetchMetadata,
uploadModel,
goToPreviousStep,
resetWizard
goToPreviousStep
}
}

View File

@@ -128,6 +128,7 @@ import PricingTable from '@/platform/cloud/subscription/components/PricingTable.
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
@@ -139,8 +140,7 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { fetchStatus, isActiveSubscription, isSubscriptionEnabled } =
useSubscription()
const { fetchStatus, isActiveSubscription } = useSubscription()
// Legacy price for non-tier flow with locale-aware formatting
const formattedMonthlyPrice = new Intl.NumberFormat(
@@ -156,7 +156,9 @@ const commandStore = useCommandStore()
const telemetry = useTelemetry()
// Always show custom pricing table for cloud subscriptions
const showCustomPricingTable = computed(() => isSubscriptionEnabled())
const showCustomPricingTable = computed(
() => isCloud && window.__CONFIG__?.subscription_required
)
const POLL_INTERVAL_MS = 3000
const MAX_POLL_ATTEMPTS = 3

View File

@@ -121,11 +121,7 @@ function useSubscriptionInternal() {
void showSubscriptionRequiredDialog()
}
/**
* Whether cloud subscription mode is enabled (cloud distribution with subscription_required config).
* Use to determine which UI to show (SubscriptionPanel vs LegacyCreditsPanel).
*/
const isSubscriptionEnabled = (): boolean =>
const shouldWatchCancellation = (): boolean =>
Boolean(isCloud && window.__CONFIG__?.subscription_required)
const { startCancellationWatcher, stopCancellationWatcher } =
@@ -134,7 +130,7 @@ function useSubscriptionInternal() {
isActiveSubscription: isSubscribedOrIsNotCloud,
subscriptionStatus,
telemetry,
shouldWatchCancellation: isSubscriptionEnabled
shouldWatchCancellation
})
const manageSubscription = async () => {
@@ -253,9 +249,6 @@ function useSubscriptionInternal() {
subscriptionTierName,
subscriptionStatus,
// Utilities
isSubscriptionEnabled,
// Actions
subscribe,
fetchStatus,

View File

@@ -1,5 +1,5 @@
import { whenever } from '@vueuse/core'
import { computed, nextTick, onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToastStore } from './toastStore'
@@ -65,12 +65,9 @@ export function useFrontendVersionMismatchWarning(
versionCompatibilityStore.dismissWarning()
}
onMounted(async () => {
onMounted(() => {
// Only set up the watcher if immediate is true
if (immediate) {
// Wait for next tick to ensure reactive updates from settings load have propagated
await nextTick()
whenever(
() => versionCompatibilityStore.shouldShowWarning,
() => {

View File

@@ -88,16 +88,11 @@ export const useVersionCompatibilityStore = defineStore(
return Date.now() < dismissedUntil
})
const warningsDisabled = computed(() =>
settingStore.get('Comfy.VersionCompatibility.DisableWarnings')
)
const shouldShowWarning = computed(() => {
return (
hasVersionMismatch.value &&
!isDismissed.value &&
!warningsDisabled.value
const warningsDisabled = settingStore.get(
'Comfy.VersionCompatibility.DisableWarnings'
)
return hasVersionMismatch.value && !isDismissed.value && !warningsDisabled
})
const warningMessage = computed(() => {

View File

@@ -152,7 +152,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
if (!shouldRenderAsVue(widget)) continue
const vueComponent =
getComponent(widget.type, widget.name) ||
getComponent(widget.type, widget.name, widget.options?.display) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const { slotMetadata, options } = widget

View File

@@ -12,11 +12,11 @@
</div>
<div
v-if="isRecording || isPlaying || recordedURL"
class="flex h-14 w-full min-w-0 items-center gap-2 rounded-lg px-3 bg-node-component-surface text-text-secondary"
class="flex h-14 w-full items-center gap-4 rounded-lg px-4 bg-node-component-surface text-text-secondary"
>
<!-- Recording Status -->
<div class="flex shrink-0 items-center gap-1">
<span class="text-xs">
<div class="flex min-w-30 items-center gap-2">
<span class="min-w-20 text-xs">
{{
isRecording
? t('g.listening', 'Listening...')
@@ -27,11 +27,11 @@
: ''
}}
</span>
<span class="text-sm">{{ formatTime(timer) }}</span>
<span class="min-w-10 text-sm">{{ formatTime(timer) }}</span>
</div>
<!-- Waveform Visualization -->
<div class="flex h-8 min-w-0 flex-1 items-center gap-2 overflow-hidden">
<div class="flex h-8 flex-1 items-center gap-2 overflow-x-clip">
<div
v-for="(bar, index) in waveformBars"
:key="index"
@@ -45,7 +45,7 @@
<button
v-if="isRecording"
:title="t('g.stopRecording', 'Stop Recording')"
class="flex shrink-0 size-8 animate-pulse items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors"
class="flex size-8 animate-pulse items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors"
@click="handleStopRecording"
>
<div class="size-2.5 rounded-sm bg-danger-100" />
@@ -54,7 +54,7 @@
<button
v-else-if="!isRecording && recordedURL && !isPlaying"
:title="t('g.playRecording') || 'Play Recording'"
class="flex shrink-0 size-8 items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors"
class="flex size-8 items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors"
@click="handlePlayRecording"
>
<i class="text-text-secondary icon-[lucide--play] size-4" />
@@ -63,7 +63,7 @@
<button
v-else-if="isPlaying"
:title="t('g.stopPlayback') || 'Stop Playback'"
class="flex shrink-0 size-8 items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors"
class="flex size-8 items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors"
@click="handleStopPlayback"
>
<i class="text-text-secondary icon-[lucide--square] size-4" />

View File

@@ -72,7 +72,8 @@ export const useFloatWidget = () => {
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
step: step * 10.0,
step2: step,
precision
precision,
display: inputSpec.display
}
)

View File

@@ -63,7 +63,8 @@ export const useIntWidget = () => {
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
step: step * 10,
step2: step,
precision: 0
precision: 0,
display: inputSpec.display
}
)

View File

@@ -0,0 +1,230 @@
import type { Component } from 'vue'
import { afterEach, describe, expect, it } from 'vitest'
import {
clearExtensionWidgets,
getComponent,
isEssential,
registerVueWidgets,
shouldExpand,
shouldRenderAsVue
} from '../widgetRegistry'
// Mock Vue components for testing (using object literals to avoid Vue linter)
const MockComponent = { name: 'MockComponent' } as Component
const MockComponent2 = { name: 'MockComponent2' } as Component
describe('widgetRegistry', () => {
afterEach(() => {
clearExtensionWidgets()
})
describe('registerVueWidgets', () => {
it('should register a custom widget', () => {
registerVueWidgets({
myCustomWidget: {
component: MockComponent
}
})
const result = getComponent('myCustomWidget', 'test')
expect(result).toBe(MockComponent)
})
it('should register multiple widgets at once', () => {
registerVueWidgets({
widget1: { component: MockComponent },
widget2: { component: MockComponent2 }
})
expect(getComponent('widget1', 'test')).toBe(MockComponent)
expect(getComponent('widget2', 'test')).toBe(MockComponent2)
})
it('should register aliases for a widget', () => {
registerVueWidgets({
myWidget: {
component: MockComponent,
aliases: ['MY_WIDGET', 'MYWIDGET']
}
})
expect(getComponent('myWidget', 'test')).toBe(MockComponent)
expect(getComponent('MY_WIDGET', 'test')).toBe(MockComponent)
expect(getComponent('MYWIDGET', 'test')).toBe(MockComponent)
})
it('should overwrite previous entry when re-registering same widget key', () => {
registerVueWidgets({
myWidget: { component: MockComponent }
})
registerVueWidgets({
myWidget: { component: MockComponent2 }
})
expect(getComponent('myWidget', 'test')).toBe(MockComponent2)
})
})
describe('getComponent', () => {
it('should return null for unknown widget type', () => {
const result = getComponent('unknownType', 'test')
expect(result).toBeNull()
})
it('should return core widget component for known type', () => {
const result = getComponent('int', 'test')
expect(result).not.toBeNull()
})
it('should return core widget component for alias', () => {
const result = getComponent('INT', 'test')
expect(result).not.toBeNull()
})
it('extension widgets should take precedence over core widgets', () => {
registerVueWidgets({
int: { component: MockComponent }
})
const result = getComponent('int', 'test')
expect(result).toBe(MockComponent)
})
it('extension aliases should take precedence over core aliases', () => {
registerVueWidgets({
customInt: {
component: MockComponent,
aliases: ['INT']
}
})
const result = getComponent('INT', 'test')
expect(result).toBe(MockComponent)
})
it('should use displayHint to find extension widget', () => {
registerVueWidgets({
star_rating: { component: MockComponent }
})
// Even though type is 'int', the displayHint 'star_rating' should match
const result = getComponent('int', 'rating', 'star_rating')
expect(result).toBe(MockComponent)
})
it('displayHint alias should work', () => {
registerVueWidgets({
star_rating: {
component: MockComponent,
aliases: ['STAR_RATING']
}
})
const result = getComponent('int', 'rating', 'STAR_RATING')
expect(result).toBe(MockComponent)
})
it('should fall back to type when displayHint has no match', () => {
const result = getComponent('int', 'test', 'unknown_display')
// Should return core int widget, not null
expect(result).not.toBeNull()
})
})
describe('clearExtensionWidgets', () => {
it('should clear all registered extension widgets', () => {
registerVueWidgets({
myWidget: { component: MockComponent }
})
expect(getComponent('myWidget', 'test')).toBe(MockComponent)
clearExtensionWidgets()
expect(getComponent('myWidget', 'test')).toBeNull()
})
it('should not affect core widgets', () => {
registerVueWidgets({
myWidget: { component: MockComponent }
})
clearExtensionWidgets()
expect(getComponent('int', 'test')).not.toBeNull()
})
})
describe('isEssential', () => {
it('should return true for essential core widgets', () => {
expect(isEssential('int')).toBe(true)
expect(isEssential('float')).toBe(true)
expect(isEssential('combo')).toBe(true)
expect(isEssential('boolean')).toBe(true)
})
it('should return false for non-essential core widgets', () => {
expect(isEssential('button')).toBe(false)
expect(isEssential('color')).toBe(false)
})
it('should return false for unknown widget types', () => {
expect(isEssential('unknownType')).toBe(false)
})
it('should return false for extension widgets (not marked as essential)', () => {
registerVueWidgets({
myWidget: { component: MockComponent }
})
expect(isEssential('myWidget')).toBe(false)
})
})
describe('shouldRenderAsVue', () => {
it('should return true for widgets with a type', () => {
expect(shouldRenderAsVue({ type: 'int' })).toBe(true)
})
it('should return false for widgets without a type', () => {
expect(shouldRenderAsVue({})).toBe(false)
})
it('should return false for canvasOnly widgets', () => {
expect(
shouldRenderAsVue({ type: 'int', options: { canvasOnly: true } })
).toBe(false)
})
it('should return true for widgets with canvasOnly false', () => {
expect(
shouldRenderAsVue({ type: 'int', options: { canvasOnly: false } })
).toBe(true)
})
})
describe('shouldExpand', () => {
it('should return true for textarea', () => {
expect(shouldExpand('textarea')).toBe(true)
expect(shouldExpand('TEXTAREA')).toBe(true)
})
it('should return true for markdown', () => {
expect(shouldExpand('markdown')).toBe(true)
expect(shouldExpand('MARKDOWN')).toBe(true)
})
it('should return true for load3D', () => {
expect(shouldExpand('load3D')).toBe(true)
expect(shouldExpand('LOAD_3D')).toBe(true)
})
it('should return false for non-expanding types', () => {
expect(shouldExpand('int')).toBe(false)
expect(shouldExpand('string')).toBe(false)
expect(shouldExpand('combo')).toBe(false)
})
})
})

View File

@@ -5,6 +5,7 @@ import { defineAsyncComponent } from 'vue'
import type { Component } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import type { VueWidgetDefinition } from '@/types/comfy'
const WidgetButton = defineAsyncComponent(
() => import('../components/WidgetButton.vue')
@@ -164,34 +165,86 @@ const getComboWidgetAdditions = (): Map<string, Component> => {
return new Map([['audio', WidgetAudioUI]])
}
// Build lookup maps
const widgets = new Map<string, WidgetDefinition>()
const aliasMap = new Map<string, string>()
// Build lookup maps for core widgets
const coreWidgets = new Map<string, WidgetDefinition>()
const coreAliasMap = new Map<string, string>()
for (const [type, def] of coreWidgetDefinitions) {
widgets.set(type, def)
coreWidgets.set(type, def)
for (const alias of def.aliases) {
aliasMap.set(alias, type)
coreAliasMap.set(alias, type)
}
}
// Utility functions
const getCanonicalType = (type: string): string => aliasMap.get(type) || type
// Extension-registered widgets (mutable, takes precedence over core)
const extensionWidgets = new Map<string, VueWidgetDefinition>()
const extensionAliasMap = new Map<string, string>()
export const getComponent = (type: string, name: string): Component | null => {
if (type == 'combo') {
const comboAdditions = getComboWidgetAdditions()
if (comboAdditions.has(name)) {
return comboAdditions.get(name) || null
/**
* Register custom Vue widgets from extensions.
* Extension widgets take precedence over core widgets for type lookup.
*/
export function registerVueWidgets(
widgets: Record<string, VueWidgetDefinition>
): void {
for (const [type, def] of Object.entries(widgets)) {
extensionWidgets.set(type, def)
for (const alias of def.aliases ?? []) {
extensionAliasMap.set(alias, type)
}
}
}
/**
* Clear all extension-registered widgets. Useful for testing.
*/
export function clearExtensionWidgets(): void {
extensionWidgets.clear()
extensionAliasMap.clear()
}
// Utility functions - extension aliases take precedence
const getCanonicalType = (type: string): string =>
extensionAliasMap.get(type) ?? coreAliasMap.get(type) ?? type
export const getComponent = (
type: string,
name: string,
displayHint?: string
): Component | null => {
// Check display hint first for custom Vue widgets
// This allows extensions to override widget rendering via the "display" field
if (displayHint) {
const displayCanonical = getCanonicalType(displayHint)
const extDef = extensionWidgets.get(displayCanonical)
if (extDef) {
return extDef.component
}
}
// Handle combo additions (existing logic)
if (type === 'combo') {
const comboAdditions = getComboWidgetAdditions()
if (comboAdditions.has(name)) {
return comboAdditions.get(name) ?? null
}
}
const canonicalType = getCanonicalType(type)
return widgets.get(canonicalType)?.component || null
// Extension widgets take precedence over core widgets
const extDef = extensionWidgets.get(canonicalType)
if (extDef) {
return extDef.component
}
// Fall back to core widgets
return coreWidgets.get(canonicalType)?.component ?? null
}
export const isEssential = (type: string): boolean => {
const canonicalType = getCanonicalType(type)
return widgets.get(canonicalType)?.essential || false
return coreWidgets.get(canonicalType)?.essential ?? false
}
export const shouldRenderAsVue = (widget: Partial<SafeWidgetData>): boolean => {

View File

@@ -42,7 +42,12 @@ const zNumericInputOptions = zBaseInputOptions.extend({
step: z.number().optional(),
/** Note: Many node authors are using INT/FLOAT to pass list of INT/FLOAT. */
default: z.union([z.number(), z.array(z.number())]).optional(),
display: z.enum(['slider', 'number', 'knob']).optional()
/**
* Display hint for widget rendering.
* Built-in values: 'slider', 'number', 'knob'
* Extensions can register custom values via getCustomVueWidgets hook.
*/
display: z.string().optional()
})
export const zIntInputOptions = zNumericInputOptions.extend({

View File

@@ -139,9 +139,6 @@ export function addValueControlWidgets(
'Allows the linked widget to be changed automatically, for example randomizing the noise seed.'
valueControl[IS_CONTROL_WIDGET] = true
updateControlWidgetLabel(valueControl)
Object.defineProperty(valueControl, 'disabled', {
get: () => targetWidget.computedDisabled
})
const widgets: [IComboWidget, ...IStringWidget[]] = [valueControl]
const isCombo = isComboWidget(targetWidget)
@@ -163,9 +160,6 @@ export function addValueControlWidgets(
updateControlWidgetLabel(comboFilter)
comboFilter.tooltip =
"Allows for filtering the list of values when changing the value via the control generate mode. Allows for RegEx matches in the format /abc/ to only filter to values containing 'abc'."
Object.defineProperty(comboFilter, 'disabled', {
get: () => targetWidget.computedDisabled
})
widgets.push(comboFilter)
}

View File

@@ -2,6 +2,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import { useSettingStore } from '@/platform/settings/settingStore'
import { registerVueWidgets } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
@@ -80,6 +81,14 @@ export const useExtensionService = () => {
})()
}
if (extension.getCustomVueWidgets) {
const getVueWidgets = extension.getCustomVueWidgets
void (async () => {
const vueWidgets = await getVueWidgets(app)
registerVueWidgets(vueWidgets)
})()
}
if (extension.onAuthUserResolved) {
const { onUserResolved } = useCurrentUser()
const handleUserResolved = wrapWithErrorHandlingAsync(

View File

@@ -117,29 +117,15 @@ describe('useAssetDownloadStore', () => {
it('associates task with model type for completion tracking', () => {
const store = useAssetDownloadStore()
store.trackDownload('task-123', 'checkpoints', 'model.safetensors')
store.trackDownload('task-123', 'checkpoints')
dispatch(createDownloadMessage({ status: 'completed', progress: 100 }))
expect(store.lastCompletedDownload).toMatchObject({
expect(store.completedDownloads).toHaveLength(1)
expect(store.completedDownloads[0]).toMatchObject({
taskId: 'task-123',
modelType: 'checkpoints'
})
})
it('handles out-of-order messages where completed arrives before progress', () => {
const store = useAssetDownloadStore()
store.trackDownload('task-123', 'checkpoints', 'model.safetensors')
dispatch(createDownloadMessage({ status: 'completed', progress: 100 }))
dispatch(createDownloadMessage({ status: 'running', progress: 50 }))
expect(store.activeDownloads).toHaveLength(0)
expect(store.finishedDownloads).toHaveLength(1)
expect(store.finishedDownloads[0].status).toBe('completed')
expect(store.lastCompletedDownload?.modelType).toBe('checkpoints')
})
})
describe('stale download polling', () => {

View File

@@ -16,7 +16,6 @@ export interface AssetDownload {
lastUpdate: number
assetId?: string
error?: string
modelType?: string
}
interface CompletedDownload {
@@ -24,29 +23,15 @@ interface CompletedDownload {
modelType: string
timestamp: number
}
const MAX_COMPLETED_DOWNLOADS = 10
const STALE_THRESHOLD_MS = 10_000
const POLL_INTERVAL_MS = 10_000
function generateDownloadTrackingPlaceholder(
taskId: string,
modelType: string,
assetName: string
): AssetDownload {
return {
taskId,
modelType,
assetName,
bytesTotal: 0,
bytesDownloaded: 0,
progress: 0,
status: 'created',
lastUpdate: Date.now()
}
}
export const useAssetDownloadStore = defineStore('assetDownload', () => {
const downloads = ref<Map<string, AssetDownload>>(new Map())
const lastCompletedDownload = ref<CompletedDownload | null>(null)
const pendingModelTypes = new Map<string, string>()
const completedDownloads = ref<CompletedDownload[]>([])
const downloadList = computed(() => Array.from(downloads.value.values()))
const activeDownloads = computed(() =>
@@ -62,13 +47,8 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => {
const hasActiveDownloads = computed(() => activeDownloads.value.length > 0)
const hasDownloads = computed(() => downloads.value.size > 0)
function trackDownload(taskId: string, modelType: string, assetName: string) {
if (downloads.value.has(taskId)) return
downloads.value.set(
taskId,
generateDownloadTrackingPlaceholder(taskId, modelType, assetName)
)
function trackDownload(taskId: string, modelType: string) {
pendingModelTypes.set(taskId, modelType)
}
function handleAssetDownload(e: CustomEvent<AssetDownloadWsMessage>) {
@@ -89,18 +69,24 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => {
progress: data.progress,
status: data.status,
error: data.error,
lastUpdate: Date.now(),
modelType: existing?.modelType
lastUpdate: Date.now()
}
downloads.value.set(data.task_id, download)
if (data.status === 'completed' && download.modelType) {
lastCompletedDownload.value = {
taskId: data.task_id,
modelType: download.modelType,
timestamp: Date.now()
if (data.status === 'completed') {
const modelType = pendingModelTypes.get(data.task_id)
if (modelType) {
const updated = [
...completedDownloads.value,
{ taskId: data.task_id, modelType, timestamp: Date.now() }
]
if (updated.length > MAX_COMPLETED_DOWNLOADS) updated.shift()
completedDownloads.value = updated
pendingModelTypes.delete(data.task_id)
}
} else if (data.status === 'failed') {
pendingModelTypes.delete(data.task_id)
}
}
@@ -171,7 +157,7 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => {
hasActiveDownloads,
hasDownloads,
downloadList,
lastCompletedDownload,
completedDownloads,
trackDownload,
clearFinishedDownloads
}

View File

@@ -1,7 +1,7 @@
import { useAsyncState, whenever } from '@vueuse/core'
import { useAsyncState } from '@vueuse/core'
import { isEqual } from 'es-toolkit'
import { defineStore } from 'pinia'
import { computed, shallowReactive, ref } from 'vue'
import { computed, shallowReactive, ref, watch } from 'vue'
import {
mapInputFileToAssetItem,
mapTaskOutputToAssetItem
@@ -376,32 +376,24 @@ export const useAssetsStore = defineStore('assets', () => {
} = getModelState()
// Watch for completed downloads and refresh model caches
whenever(
() => assetDownloadStore.lastCompletedDownload,
watch(
() => assetDownloadStore.completedDownloads.at(-1),
async (latestDownload) => {
if (!latestDownload) return
const { modelType } = latestDownload
const providers = modelToNodeStore
.getAllNodeProviders(modelType)
.filter((provider) => provider.nodeDef?.name)
const nodeTypeUpdates = providers.map((provider) =>
updateModelsForNodeType(provider.nodeDef.name).then(
() => provider.nodeDef.name
const results = await Promise.allSettled(
providers.map((provider) =>
updateModelsForNodeType(provider.nodeDef.name).then(
() => provider.nodeDef.name
)
)
)
// Also update by tag in case modal was opened with assetType
const tagUpdates = [
updateModelsForTag(modelType),
updateModelsForTag('models')
]
const results = await Promise.allSettled([
...nodeTypeUpdates,
...tagUpdates
])
for (const result of results) {
if (result.status === 'rejected') {
console.error(

View File

@@ -299,10 +299,9 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
const nodeDefs = computed(() => {
const subgraphStore = useSubgraphStore()
// Blueprints first for discoverability in the node library sidebar
return [
...subgraphStore.subgraphBlueprints,
...Object.values(nodeDefsByName.value)
...Object.values(nodeDefsByName.value),
...subgraphStore.subgraphBlueprints
]
})
const nodeDataTypes = computed(() => {

View File

@@ -1,3 +1,5 @@
import type { Component } from 'vue'
import type {
IContextMenuValue,
Positionable
@@ -15,6 +17,22 @@ import type { BottomPanelExtension } from '@/types/extensionTypes'
type Widgets = Record<string, ComfyWidgetConstructor>
/**
* Definition for a Vue widget that can be registered by extensions.
*/
export interface VueWidgetDefinition {
/**
* The Vue component to render for this widget type
*/
component: Component
/**
* Alternative type names that should map to this widget
*/
aliases?: string[]
}
export type VueWidgets = Record<string, VueWidgetDefinition>
export interface AboutPageBadge {
label: string
url: string
@@ -160,6 +178,15 @@ export interface ComfyExtension {
*/
getCustomWidgets?(app: ComfyApp): Promise<Widgets> | Widgets
/**
* Allows the extension to add custom Vue widgets for the Vue node renderer.
* These widgets will be used when Vue nodes are enabled and take precedence
* over core widgets with the same type.
* @param app The ComfyUI app instance
* @returns An object mapping widget type names to Vue widget definitions
*/
getCustomVueWidgets?(app: ComfyApp): Promise<VueWidgets> | VueWidgets
/**
* Allows the extension to add additional commands to the selection toolbox
* @param selectedItem The selected item on the canvas

View File

@@ -24,7 +24,7 @@ import type {
ToastMessageOptions
} from './extensionTypes'
export type { ComfyExtension } from './comfy'
export type { ComfyExtension, VueWidgetDefinition, VueWidgets } from './comfy'
export type { ComfyApi } from '@/scripts/api'
export type { ComfyApp } from '@/scripts/app'
export type { ComfyNodeDef } from '@/schemas/nodeDefSchema'

View File

@@ -40,6 +40,12 @@ declare module '@/lib/litegraph/src/types/widgets' {
/** If the widget is hidden, this will be set to true. */
hidden?: boolean
/**
* Display hint from Python node definition for custom widget rendering.
* Extensions can use this to render widgets with custom Vue components.
*/
display?: string
}
interface IBaseWidget {

View File

@@ -0,0 +1,19 @@
/**
* Exposes Vue for external extensions to create Vue components.
*
* Usage in extensions:
* ```js
* const { h, defineComponent, ref, computed } = window.Vue
* ```
*/
import * as Vue from 'vue'
declare global {
interface Window {
Vue: typeof Vue
}
}
export function exposeVueApi(): void {
window.Vue = Vue
}