Compare commits

..

17 Commits

Author SHA1 Message Date
Alexander Brown
338da7f72e Feat: Rename and Delete for imported Models ☁️ (#6969)
## Summary

Add Rename and Delete options for Personal Models.

Also updates and standardizes some styles for Cards and adds a simple
Confirmation dialog.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6969-WIP-Feat-Rename-and-Delete-for-custom-Models-2b86d73d36508140a687e929b0544ae6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-04 19:17:13 +00:00
Comfy Org PR Bot
6c3408592e [backport cloud/1.33] cloud: increase feature flag polling interval to 10min (from 30s) (#7111)
Backport of #7100 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7111-backport-cloud-1-33-cloud-increase-feature-flag-polling-interval-to-10min-from-30s-2be6d73d3650817ea746fa49eb896a2d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-02 19:28:06 -07:00
Comfy Org PR Bot
b6632443dc [backport cloud/1.33] fix: normalize path separators in comfyAPIPlugin for Windows compatibility (#7090)
Backport of #7087 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7090-backport-cloud-1-33-fix-normalize-path-separators-in-comfyAPIPlugin-for-Windows-compat-2bd6d73d365081228497fa23b58b8978)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-12-02 19:27:41 -07:00
Comfy Org PR Bot
c8a1df3a05 [backport cloud/1.33] feat(api-nodes-pricing): add prices for Kling O1 video model (#7079)
Backport of #7077 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7079-backport-cloud-1-33-feat-api-nodes-pricing-add-prices-for-Kling-O1-video-model-2bc6d73d365081c4ad83d1c5317a9135)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-12-01 14:18:13 -07:00
Comfy Org PR Bot
39204135ba [backport cloud/1.33] [fix] Prevent drag activation during Vue node resize (#7071)
Backport of #7064 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7071-backport-cloud-1-33-fix-Prevent-drag-activation-during-Vue-node-resize-2bc6d73d36508129b6d4ee43e79de500)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2025-11-30 20:44:35 -08:00
Christian Byrne
ffa55cb92b [backport cloud/1.33] Simplify Vue node resize to bottom-right corner only (#7063) (#7068)
## Summary
- Backport of #7063 to cloud/1.33
- Simplifies Vue node resize to bottom-right corner only

Cherry-picked from d76c59cb14

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7068-backport-cloud-1-33-Simplify-Vue-node-resize-to-bottom-right-corner-only-7063-2bc6d73d36508149bd85c3ae534387b6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-30 20:20:35 -08:00
Comfy Org PR Bot
3856e0deea [backport cloud/1.33] fix: loader node widget value shows placeholder instead of filename on cloud (#7046)
Backport of #7005 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7046-backport-cloud-1-33-fix-loader-node-widget-value-shows-placeholder-instead-of-filename-2bb6d73d365081bd9531c08bbaeb8634)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-29 19:14:34 -07:00
Comfy Org PR Bot
7b589b5502 [backport cloud/1.33] mark vue nodes menu toggle with beta tag (#7052)
Backport of #7047 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7052-backport-cloud-1-33-mark-vue-nodes-menu-toggle-with-beta-tag-2bb6d73d365081da9bb6cb1859c7bf5a)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-29 19:14:19 -07:00
Comfy Org PR Bot
72a2581068 [backport cloud/1.33] feat(api-nodes-pricing): add prices for ByteDance seedance-1-0-pro-fast model (#7030)
Backport of #7026 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7030-backport-cloud-1-33-feat-api-nodes-pricing-add-prices-for-ByteDance-seedance-1-0-pro--2b96d73d365081ff9e4feb7b1d147dc5)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-11-29 15:09:55 -07:00
Comfy Org PR Bot
22aea29a0d [backport cloud/1.33] [feat] Show "Finished in" duration for completed jobs in cloud (#7013)
Backport of #6895 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7013-backport-cloud-1-33-feat-Show-Finished-in-duration-for-completed-jobs-in-cloud-2b86d73d365081419f90ee82f5e45253)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-27 18:31:20 -07:00
Comfy Org PR Bot
637c1995b4 [backport cloud/1.33] [fix] Re-encode cloud-subscription video to VP9 for Safari compatibility (#7012)
Backport of #7006 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7012-backport-cloud-1-33-fix-Re-encode-cloud-subscription-video-to-VP9-for-Safari-compatib-2b86d73d365081a0be8ac57b8afce8a8)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-11-27 17:56:57 -07:00
Comfy Org PR Bot
550ca0c911 [backport cloud/1.33] Remove app.graph usage from widgetInput code (#7011)
Backport of #7008 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7011-backport-cloud-1-33-Remove-app-graph-usage-from-widgetInput-code-2b86d73d365081de9460daecfde4bb87)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-11-27 17:56:50 -07:00
Comfy Org PR Bot
896867b03c [backport cloud/1.33] fix: add filter for combo widgets (#7003)
Backport of #6999 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7003-backport-cloud-1-33-fix-add-filter-for-combo-widgets-2b86d73d3650818daa83c680abf3b5c4)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-11-27 14:20:42 -07:00
Christian Byrne
0cd0218946 [backport cloud/1.33] fix: Vue Node <-> Litegraph node height offset normalization (#6978)
## Summary
Backport of #6966 onto cloud/1.33.

- cherry-picked 29dbfa3f
- accepted upstream snapshot updates (only zoomed-in ctrl+shift PNG
conflicted)

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6978-backport-cloud-1-33-fix-Vue-Node-Litegraph-node-height-offset-normalization-2b86d73d365081a19a81f4fac0fd2e91)
by [Unito](https://www.unito.io)

Co-authored-by: github-actions <github-actions@github.com>
2025-11-26 22:38:13 -07:00
Christian Byrne
334404aa3b [backport cloud/1.33] fix: remove LOD from vue nodes (#6983)
## Summary
Backport of #6950 onto cloud/1.33 (clean cherry-pick of 4b87b1fdc).

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6983-backport-cloud-1-33-fix-remove-LOD-from-vue-nodes-2b86d73d36508119bafbc5a5a6a5ad42)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-26 21:40:50 -07:00
Christian Byrne
31d842217b [backport cloud/1.33] fix: don't use registry when only checking for presence of missing nodes (#6972)
## Summary
Backport of #6965 onto cloud/1.33 (clean cherry-pick of 83f04490b).

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6972-backport-cloud-1-33-fix-don-t-use-registry-when-only-checking-for-presence-of-missing--2b86d73d36508150af46da84e754df3a)
by [Unito](https://www.unito.io)
2025-11-26 17:50:40 -07:00
Comfy Org PR Bot
3dd3e26003 [backport cloud/1.33] feat: open template via URL in linear mode (#6968)
Backport of #6945 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6968-backport-cloud-1-33-feat-open-template-via-URL-in-linear-mode-2b76d73d365081c4b5d5c0727106fc29)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-26 17:32:19 -07:00
73 changed files with 1175 additions and 548 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 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: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -0,0 +1,54 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Node Resizing', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
// Get a Vue node fixture
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
const initialBox = await node.boundingBox()
if (!initialBox) throw new Error('Node bounding box not found')
// Select the node first (this was causing the bug)
await node.header.click()
await comfyPage.page.waitForTimeout(100) // Brief pause after selection
// Get position after selection
const selectedBox = await node.boundingBox()
if (!selectedBox)
throw new Error('Node bounding box not found after select')
// Verify position unchanged after selection
expect(selectedBox.x).toBeCloseTo(initialBox.x, 1)
expect(selectedBox.y).toBeCloseTo(initialBox.y, 1)
// Now resize from bottom-right corner
const resizeStartX = selectedBox.x + selectedBox.width - 5
const resizeStartY = selectedBox.y + selectedBox.height - 5
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
await comfyPage.page.mouse.up()
// Get final position and size
const finalBox = await node.boundingBox()
if (!finalBox) throw new Error('Node bounding box not found after resize')
// Position should NOT have changed (the bug was position drift)
expect(finalBox.x).toBeCloseTo(initialBox.x, 1)
expect(finalBox.y).toBeCloseTo(initialBox.y, 1)
// Size should have increased
expect(finalBox.width).toBeGreaterThan(initialBox.width)
expect(finalBox.height).toBeGreaterThan(initialBox.height)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -88,12 +88,14 @@ export function comfyAPIPlugin(isDev: boolean): Plugin {
if (result.exports.length > 0) {
const projectRoot = process.cwd()
const relativePath = path.relative(path.join(projectRoot, 'src'), id)
const relativePath = path
.relative(path.join(projectRoot, 'src'), id)
.replace(/\\/g, '/')
const shimFileName = relativePath.replace(/\.ts$/, '.js')
let shimContent = `// Shim for ${relativePath}\n`
const fileKey = relativePath.replace(/\.ts$/, '').replace(/\\/g, '/')
const fileKey = relativePath.replace(/\.ts$/, '')
const warningMessage = getWarningMessage(fileKey, shimFileName)
if (warningMessage) {

24
cloud-loader-dropdown.md Normal file
View File

@@ -0,0 +1,24 @@
Fixes loader dropdown placeholder
===============================
Cloud loader dropdowns hydrate via `useAssetWidgetData(nodeType)`, so `dropdownItems` stays empty until the Asset API returns friendly filenames. Meanwhile `modelValue` already holds the saved asset and the watcher at [WidgetSelectDropdown.vue#L215-L227](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue#L215-L227) only tracks `modelValue`. It runs before assets load, fails to find a match, clears `selectedSet`, and the placeholder persists.
```ts
watch(
modelValue,
(currentValue) => {
if (currentValue === undefined) {
selectedSet.value.clear()
return
}
const item = dropdownItems.value.find((item) => item.name === currentValue)
if (item) {
selectedSet.value.clear()
selectedSet.value.add(item.id)
}
},
{ immediate: true }
)
```
Once the API resolves, `dropdownItems` recomputes but nothing resyncs because the watcher never sees that change. Desktop doesnt hit this because it still reads from `widget.options.values` immediately.

View File

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

View File

@@ -44,17 +44,22 @@ import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import BatchCountEdit from '../BatchCountEdit.vue'
const workspaceStore = useWorkspaceStore()
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
const { hasMissingNodes } = useMissingNodes()
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
)
const { t } = useI18n()
const queueModeMenuItemLookup = computed(() => {

View File

@@ -64,11 +64,13 @@ import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { appendJsonExt } from '@/utils/formatUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
interface Props {
item: MenuItem
@@ -79,7 +81,10 @@ const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const { hasMissingNodes } = useMissingNodes()
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
)
const { t } = useI18n()
const menu = ref<InstanceType<typeof Menu> & MenuState>()

View File

@@ -1,17 +1,15 @@
<template>
<div :class="iconGroupClasses">
<div
:class="
cn(
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg bg-secondary-background shadow-sm transition-all duration-200 cursor-pointer'
)
"
>
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const iconGroupClasses = cn(
'flex justify-center items-center shrink-0',
'outline-hidden border-none p-0 rounded-lg',
'bg-secondary-background shadow-sm',
'transition-all duration-200',
'cursor-pointer'
)
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="relative inline-flex items-center">
<IconButton :size="size" :type="type" @click="toggle">
<IconButton :size="size" :type="type" @click="popover?.toggle">
<i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" />
<i v-else class="icon-[lucide--more-vertical] text-sm" />
</IconButton>
@@ -25,8 +25,18 @@
)
}
}"
@show="$emit('menuOpened')"
@hide="$emit('menuClosed')"
@show="
() => {
isOpen = true
$emit('menuOpened')
}
"
@hide="
() => {
isOpen = false
$emit('menuClosed')
}
"
>
<div class="flex min-w-40 flex-col gap-2 p-2">
<slot :close="hide" />
@@ -48,8 +58,6 @@ interface MoreButtonProps extends BaseButtonProps {
isVertical?: boolean
}
const popover = ref<InstanceType<typeof Popover>>()
const {
size = 'md',
type = 'secondary',
@@ -61,15 +69,15 @@ defineEmits<{
menuClosed: []
}>()
const toggle = (event: Event) => {
popover.value?.toggle(event)
}
const isOpen = ref(false)
const popover = ref<InstanceType<typeof Popover>>()
const hide = () => {
function hide() {
popover.value?.hide()
}
defineExpose({
hide
hide,
isOpen
})
</script>

View File

@@ -18,8 +18,8 @@
...inputAttrs
}
}"
@keyup.enter="blurInputElement"
@keyup.escape="cancelEditing"
@keyup.enter.capture.stop="blurInputElement"
@keyup.escape.stop="cancelEditing"
@click.stop
@pointerdown.stop.capture
@pointermove.stop.capture
@@ -38,7 +38,7 @@ const {
} = defineProps<{
modelValue: string
isEditing?: boolean
inputAttrs?: Record<string, any>
inputAttrs?: Record<string, string>
}>()
const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])

View File

@@ -14,6 +14,7 @@
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<h3 v-else :id="item.key">

View File

@@ -0,0 +1,19 @@
<template>
<div
class="flex flex-col px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
>
<p v-if="promptTextReal">
{{ promptTextReal }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
const { promptText } = defineProps<{
promptText?: MaybeRefOrGetter<string>
}>()
const promptTextReal = computed(() => toValue(promptText))
</script>

View File

@@ -0,0 +1,43 @@
<template>
<section class="w-full flex gap-2 justify-end px-2 pb-2">
<TextButton
:label="cancelTextX"
:disabled
type="transparent"
autofocus
@click="$emit('cancel')"
/>
<TextButton
:label="confirmTextX"
:disabled
type="transparent"
:class="confirmClass"
@click="$emit('confirm')"
/>
</section>
</template>
<script setup lang="ts">
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'
import TextButton from '@/components/button/TextButton.vue'
const { t } = useI18n()
const { cancelText, confirmText, confirmClass, optionsDisabled } = defineProps<{
cancelText?: string
confirmText?: string
confirmClass?: string
optionsDisabled?: MaybeRefOrGetter<boolean>
}>()
defineEmits<{
cancel: []
confirm: []
}>()
const confirmTextX = computed(() => confirmText || t('g.confirm'))
const cancelTextX = computed(() => cancelText || t('g.cancel'))
const disabled = computed(() => toValue(optionsDisabled))
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div
class="flex items-center gap-2 p-4 font-bold text-sm text-base-foreground font-inter"
>
<span v-if="title" class="flex-auto">{{ title }}</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
title?: string
}>()
</script>

View File

@@ -0,0 +1,31 @@
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
import { useDialogStore } from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
interface ConfirmDialogOptions {
headerProps?: ComponentAttrs<typeof ConfirmHeader>
props?: ComponentAttrs<typeof ConfirmBody>
footerProps?: ComponentAttrs<typeof ConfirmFooter>
}
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
const dialogStore = useDialogStore()
const { headerProps, props, footerProps } = options
return dialogStore.showDialog({
headerComponent: ConfirmHeader,
component: ConfirmBody,
footerComponent: ConfirmFooter,
headerProps,
props,
footerProps,
dialogComponentProps: {
pt: {
header: 'py-0! px-0!',
content: 'p-0!',
footer: 'p-0!'
}
}
})
}

View File

@@ -73,6 +73,7 @@
@click.stop="handleNodes2ToggleClick"
>
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<Tag severity="info" class="ml-2 text-xs">{{ $t('g.beta') }}</Tag>
<ToggleSwitch
v-model="nodes2Enabled"
class="ml-4"
@@ -101,6 +102,7 @@
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import Tag from 'primevue/tag'
import TieredMenu from 'primevue/tieredmenu'
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
import ToggleSwitch from 'primevue/toggleswitch'

View File

@@ -10,6 +10,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { app as comfyApp } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -38,7 +39,10 @@ function useVueNodeLifecycleIndividual() {
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
id: node.id.toString(),
pos: [node.pos[0], node.pos[1]] as [number, number],
size: [node.size[0], node.size[1]] as [number, number]
size: [node.size[0], removeNodeTitleHeight(node.size[1])] as [
number,
number
]
}))
layoutStore.initializeFromLiteGraph(nodes)

View File

@@ -49,6 +49,21 @@ const calculateRunwayDurationPrice = (node: LGraphNode): string => {
return `$${cost}/Run`
}
const makeOmniProDurationCalculator =
(pricePerSecond: number): PricingFunction =>
(node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return `$${pricePerSecond.toFixed(3)}/second`
const seconds = parseFloat(String(durationWidget.value))
if (!Number.isFinite(seconds)) return `$${pricePerSecond.toFixed(3)}/second`
const cost = pricePerSecond * seconds
return `$${cost.toFixed(2)}/Run`
}
const pixversePricingCalculator = (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration_seconds'
@@ -131,6 +146,11 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
'720p': [0.51, 0.56],
'1080p': [1.18, 1.22]
},
'seedance-1-0-pro-fast': {
'480p': [0.09, 0.1],
'720p': [0.21, 0.23],
'1080p': [0.47, 0.49]
},
'seedance-1-0-lite': {
'480p': [0.17, 0.18],
'720p': [0.37, 0.41],
@@ -138,11 +158,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
}
}
const modelKey = 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'
@@ -699,6 +721,21 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
KlingVirtualTryOnNode: {
displayPrice: '$0.07/Run'
},
KlingOmniProTextToVideoNode: {
displayPrice: makeOmniProDurationCalculator(0.112)
},
KlingOmniProFirstLastFrameNode: {
displayPrice: makeOmniProDurationCalculator(0.112)
},
KlingOmniProImageToVideoNode: {
displayPrice: makeOmniProDurationCalculator(0.112)
},
KlingOmniProVideoToVideoNode: {
displayPrice: makeOmniProDurationCalculator(0.168)
},
KlingOmniProEditVideoNode: {
displayPrice: '$0.168/second'
},
LumaImageToVideoNode: {
displayPrice: (node: LGraphNode): string => {
// Same pricing as LumaVideoNode per CSV
@@ -1873,6 +1910,10 @@ export const useNodePricing = () => {
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
KlingSingleImageVideoEffectNode: ['effect_scene'],
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
KlingOmniProTextToVideoNode: ['duration'],
KlingOmniProFirstLastFrameNode: ['duration'],
KlingOmniProImageToVideoNode: ['duration'],
KlingOmniProVideoToVideoNode: ['duration'],
MinimaxHailuoVideoNode: ['resolution', 'duration'],
OpenAIDalle3: ['size', 'quality'],
OpenAIDalle2: ['size', 'n'],

View File

@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
@@ -263,7 +264,8 @@ export function useJobList() {
totalPercent: isActive ? totalPercent.value : undefined,
currentNodePercent: isActive ? currentNodePercent.value : undefined,
currentNodeName: isActive ? currentNodeName.value : undefined,
showAddedHint
showAddedHint,
isCloud
})
return {

View File

@@ -9,7 +9,8 @@ export enum ServerFeatureFlag {
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
MAX_UPLOAD_SIZE = 'max_upload_size',
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled'
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled'
}
/**
@@ -31,6 +32,12 @@ export function useFeatureFlags() {
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
false
)
},
get assetUpdateOptionsEnabled() {
return api.getServerFeature(
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
false
)
}
})

View File

@@ -9,7 +9,7 @@ useExtensionService().registerExtension({
name: 'Comfy.Cloud.RemoteConfig',
setup: async () => {
// Poll for config updates every 30 seconds
setInterval(() => void loadRemoteConfig(), 30000)
// Poll for config updates every 10 minutes
setInterval(() => void loadRemoteConfig(), 600_000)
}
})

View File

@@ -7,9 +7,9 @@ import type {
INodeInputSlot,
INodeOutputSlot,
ISlotType,
LLink,
Point
LLink
} from '@/lib/litegraph/src/litegraph'
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDefSchema'
@@ -37,15 +37,15 @@ export class PrimitiveNode extends LGraphNode {
}
override applyToGraph(extraLinks: LLink[] = []) {
if (!this.outputs[0].links?.length) return
if (!this.outputs[0].links?.length || !this.graph) return
const links = [
...this.outputs[0].links.map((l) => app.graph.links[l]),
...this.outputs[0].links.map((l) => this.graph!.links[l]),
...extraLinks
]
let v = this.widgets?.[0].value
if (v && this.properties[replacePropertyName]) {
v = applyTextReplacements(app.graph, v as string)
v = applyTextReplacements(this.graph, v as string)
}
// For each output link copy our value over the original widget value
@@ -331,13 +331,13 @@ export class PrimitiveNode extends LGraphNode {
const config1 = (output.widget?.[GET_CONFIG] as () => InputSpec)?.()
if (!config1) return
const isNumber = config1[0] === 'INT' || config1[0] === 'FLOAT'
if (!isNumber) return
if (!isNumber || !this.graph) return
for (const linkId of links) {
const link = app.graph.links[linkId]
const link = this.graph.links[linkId]
if (!link) continue // Can be null when removing a node
const theirNode = app.graph.getNodeById(link.target_id)
const theirNode = this.graph.getNodeById(link.target_id)
if (!theirNode) continue
const theirInput = theirNode.inputs[link.target_slot]
@@ -441,10 +441,7 @@ function getWidgetType(config: InputSpec) {
return { type }
}
export function setWidgetConfig(
slot: INodeInputSlot | INodeOutputSlot,
config?: InputSpec
) {
export function setWidgetConfig(slot: INodeInputSlot, config?: InputSpec) {
if (!slot.widget) return
if (config) {
slot.widget[GET_CONFIG] = () => config
@@ -452,19 +449,18 @@ export function setWidgetConfig(
delete slot.widget
}
if ('link' in slot) {
const link = app.graph.links[slot.link ?? -1]
if (link) {
const originNode = app.graph.getNodeById(link.origin_id)
if (originNode && isPrimitiveNode(originNode)) {
if (config) {
originNode.recreateWidget()
} else if (!app.configuringGraph) {
originNode.disconnectOutput(0)
originNode.onLastDisconnect()
}
}
}
if (!(slot instanceof NodeSlot)) return
const graph = slot.node.graph
if (!graph) return
const link = graph.links[slot.link ?? -1]
if (!link) return
const originNode = graph.getNodeById(link.origin_id)
if (!originNode || !isPrimitiveNode(originNode)) return
if (config) {
originNode.recreateWidget()
} else if (!app.configuringGraph) {
originNode.disconnectOutput(0)
originNode.onLastDisconnect()
}
}
@@ -555,15 +551,6 @@ app.registerExtension({
}
)
function isNodeAtPos(pos: Point) {
for (const n of app.graph.nodes) {
if (n.pos[0] === pos[0] && n.pos[1] === pos[1]) {
return true
}
}
return false
}
// Double click a widget input to automatically attach a primitive
const origOnInputDblClick = nodeType.prototype.onInputDblClick
nodeType.prototype.onInputDblClick = function (
@@ -589,18 +576,18 @@ app.registerExtension({
// Create a primitive node
const node = LiteGraph.createNode('PrimitiveNode')
if (!node) return r
const graph = app.canvas.graph
if (!node || !graph) return r
this.graph?.add(node)
graph?.add(node)
// Calculate a position that won't directly overlap another node
const pos: [number, number] = [
this.pos[0] - node.size[0] - 30,
this.pos[1]
]
while (isNodeAtPos(pos)) {
while (graph.getNodeOnPos(pos[0], pos[1], graph.nodes))
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
}
node.pos = pos
node.connect(0, this, slot)

View File

@@ -159,6 +159,7 @@ export const i18n = createI18n({
legacy: false,
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
escapeParameter: true,
messages,
// Ignore warnings for locale options as each option is in its own language.
// e.g. "English", "中文", "Русский", "日本語", "한국어", "Français", "Español"

View File

@@ -7,6 +7,7 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
@@ -4043,16 +4044,25 @@ export class LGraphCanvas
// TODO: Report failures, i.e. `failedNodes`
const newPositions = created.map((node) => ({
nodeId: String(node.id),
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size?.[0] ?? 100,
height: node.size?.[1] ?? 200
}
}))
const newPositions = created
.filter((item): item is LGraphNode => item instanceof LGraphNode)
.map((node) => {
const fullHeight = node.size?.[1] ?? 200
const layoutHeight = LiteGraph.vueNodesMode
? removeNodeTitleHeight(fullHeight)
: fullHeight
return {
nodeId: String(node.id),
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size?.[0] ?? 100,
height: layoutHeight
}
}
})
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
layoutStore.batchUpdateNodeBounds(newPositions)
this.selectItems(created)

View File

@@ -124,6 +124,7 @@
"searchKeybindings": "Search Keybindings",
"searchExtensions": "Search Extensions",
"search": "Search",
"searchPlaceholder": "Search...",
"noResultsFound": "No Results Found",
"searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.",
"noTasksFound": "No Tasks Found",
@@ -984,6 +985,7 @@
"initializingAlmostReady": "Initializing - Almost ready",
"inQueue": "In queue...",
"jobAddedToQueue": "Job added to queue",
"completedIn": "Finished in {duration}",
"jobMenu": {
"openAsWorkflowNewTab": "Open as workflow in new tab",
"openWorkflowNewTab": "Open workflow in new tab",
@@ -2086,7 +2088,6 @@
"connectionError": "Please check your connection and try again",
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Type to search...",
"uploadModel": "Import model",
"uploadModelFromCivitai": "Import a model from Civitai",
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
@@ -2145,6 +2146,16 @@
"media": {
"threeDModelPlaceholder": "3D Model",
"audioPlaceholder": "Audio"
},
"deletion": {
"header": "Delete this model?",
"body": "This model will be permanently removed from your library.",
"inProgress": "Deleting {assetName}...",
"complete": "{assetName} has been deleted.",
"failed": "{assetName} could not be deleted."
},
"rename": {
"failed": "Could not rename asset."
}
},
"mediaAsset": {

View File

@@ -1,5 +1,5 @@
<template>
<div class="absolute right-2 bottom-2 flex flex-wrap justify-end gap-1">
<div class="absolute left-2 bottom-2 flex flex-wrap justify-start gap-1">
<span
v-for="badge in badges"
:key="badge.label"

View File

@@ -26,7 +26,7 @@
v-model="searchQuery"
:autofocus="true"
size="lg"
:placeholder="$t('assetBrowser.searchAssetsPlaceholder')"
:placeholder="$t('g.searchPlaceholder')"
class="max-w-96"
/>
<IconTextButton

View File

@@ -1,53 +1,106 @@
<template>
<component
:is="interactive ? 'button' : 'div'"
<div
v-if="!deletedLocal"
data-component-id="AssetCard"
:data-asset-id="asset.id"
v-bind="elementProps"
:class="cardClasses"
@click="interactive && $emit('select', asset)"
@keydown.enter="interactive && $emit('select', asset)"
:aria-labelledby="titleId"
:aria-describedby="descId"
:tabindex="interactive ? 0 : -1"
:class="
cn(
'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
interactive &&
'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4'
)
"
@keydown.enter.self="interactive && $emit('select', asset)"
>
<div class="relative aspect-square w-full overflow-hidden rounded-xl">
<img
v-if="shouldShowImage"
:src="asset.preview_url"
class="h-full w-full object-contain"
/>
<div
v-if="isLoading || error"
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400"
role="button"
@click.self="interactive && $emit('select', asset)"
/>
<img
v-else
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400"
></div>
:src="asset.preview_url"
:alt="displayName"
class="size-full object-contain cursor-pointer"
role="button"
@click.self="interactive && $emit('select', asset)"
/>
<AssetBadgeGroup :badges="asset.badges" />
<IconGroup
v-if="flags.assetUpdateOptionsEnabled"
:class="
cn(
'absolute top-2 right-2 invisible group-hover:visible',
dropdownMenuButton?.isOpen && 'visible'
)
"
>
<IconButton v-if="false" size="sm">
<i class="icon-[lucide--file-text]" />
</IconButton>
<MoreButton ref="dropdown-menu-button" size="sm">
<template #default>
<IconTextButton
:label="$t('g.rename')"
type="secondary"
size="md"
@click="startAssetRename"
>
<template #icon>
<i class="icon-[lucide--pencil]" />
</template>
</IconTextButton>
<IconTextButton
:label="$t('g.delete')"
type="secondary"
size="md"
@click="confirmDeletion"
>
<template #icon>
<i class="icon-[lucide--trash-2]" />
</template>
</IconTextButton>
</template>
</MoreButton>
</IconGroup>
</div>
<div :class="cn('p-4 h-32 flex flex-col justify-between')">
<div>
<h3
:id="titleId"
v-tooltip.top="{ value: asset.name, showDelay: tooltipDelay }"
:class="
cn(
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
'text-base-foreground'
)
"
>
{{ asset.name }}
</h3>
<p
:id="descId"
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
:class="
cn(
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
'text-muted-foreground'
)
"
>
{{ asset.description }}
</p>
</div>
<div :class="cn('flex gap-4 text-xs text-muted-foreground')">
<div class="max-h-32 flex flex-col gap-2 justify-between flex-auto">
<h3
:id="titleId"
v-tooltip.top="{ value: displayName, showDelay: tooltipDelay }"
:class="
cn(
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
'text-base-foreground'
)
"
>
<EditableText
:model-value="displayName"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'asset-name-input' }"
@edit="assetRename"
@cancel="assetRename()"
/>
</h3>
<p
:id="descId"
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
:class="
cn(
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
)
"
>
{{ asset.description }}
</p>
<div class="flex gap-4 text-xs text-muted-foreground mt-auto">
<span v-if="asset.stats.stars" class="flex items-center gap-1">
<i class="icon-[lucide--star] size-3" />
{{ asset.stats.stars }}
@@ -62,73 +115,141 @@
</span>
</div>
</div>
</component>
</div>
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import { computed, useId } from 'vue'
import { computed, ref, toValue, useId, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconGroup from '@/components/button/IconGroup.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import EditableText from '@/components/common/EditableText.vue'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
const { asset, interactive } = defineProps<{
asset: AssetDisplayItem
interactive?: boolean
}>()
defineEmits<{
select: [asset: AssetDisplayItem]
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const { closeDialog } = useDialogStore()
const { flags } = useFeatureFlags()
const toastStore = useToastStore()
const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
'dropdown-menu-button'
)
const titleId = useId()
const descId = useId()
const isEditing = ref(false)
const newNameRef = ref<string>()
const deletedLocal = ref(false)
const displayName = computed(() => newNameRef.value ?? asset.name)
const tooltipDelay = computed<number>(() =>
settingStore.get('LiteGraph.Node.TooltipDelay')
)
const { error } = useImage({
src: props.asset.preview_url ?? '',
alt: props.asset.name
const { isLoading, error } = useImage({
src: asset.preview_url ?? '',
alt: asset.name
})
const shouldShowImage = computed(() => props.asset.preview_url && !error.value)
function confirmDeletion() {
dropdownMenuButton.value?.hide()
const assetName = toValue(displayName)
const promptText = ref<string>(t('assetBrowser.deletion.body'))
const optionsDisabled = ref(false)
const confirmDialog = showConfirmDialog({
headerProps: {
title: t('assetBrowser.deletion.header')
},
props: {
promptText
},
footerProps: {
confirmText: t('g.delete'),
// TODO: These need to be put into the new Button Variants once we have them.
confirmClass: cn(
'bg-danger-200 text-base-foreground hover:bg-danger-200/80 focus:bg-danger-200/80 focus:ring ring-base-foreground'
),
optionsDisabled,
onCancel: () => {
closeDialog(confirmDialog)
},
onConfirm: async () => {
optionsDisabled.value = true
try {
promptText.value = t('assetBrowser.deletion.inProgress', {
assetName
})
await assetService.deleteAsset(asset.id)
promptText.value = t('assetBrowser.deletion.complete', {
assetName
})
// Give a second for the completion message
await new Promise((resolve) => setTimeout(resolve, 1_000))
deletedLocal.value = true
} catch (err: unknown) {
console.error(err)
promptText.value = t('assetBrowser.deletion.failed', {
assetName
})
// Give a second for the completion message
await new Promise((resolve) => setTimeout(resolve, 3_000))
} finally {
closeDialog(confirmDialog)
}
}
}
})
}
const cardClasses = computed(() => {
const base = cn(
'rounded-xl overflow-hidden transition-all duration-200 bg-modal-card-background'
)
function startAssetRename() {
dropdownMenuButton.value?.hide()
isEditing.value = true
}
if (!props.interactive) {
return base
async function assetRename(newName?: string) {
isEditing.value = false
if (newName) {
// Optimistic update
newNameRef.value = newName
try {
const result = await assetService.updateAsset(asset.id, {
name: newName
})
// Update with the actual name once the server responds
newNameRef.value = result.name
} catch (err: unknown) {
console.error(err)
toastStore.add({
severity: 'error',
summary: t('assetBrowser.rename.failed'),
life: 10_000
})
newNameRef.value = undefined
}
}
return cn(
base,
'group',
'appearance-none bg-transparent p-0 m-0',
'font-inherit text-inherit outline-none cursor-pointer text-left',
'hover:bg-secondary-background',
'border-none',
'focus:outline-solid outline-azure-600 outline-4'
)
})
const elementProps = computed(() =>
props.interactive
? {
type: 'button',
'aria-labelledby': titleId,
'aria-describedby': descId
}
: {
'aria-labelledby': titleId,
'aria-describedby': descId
}
)
defineEmits<{
select: [asset: AssetDisplayItem]
}>()
}
</script>

View File

@@ -1,7 +1,9 @@
<template>
<div
data-component-id="AssetGrid"
:style="gridStyle"
:class="
cn('grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-4 p-2')
"
role="grid"
:aria-label="$t('assetBrowser.assetCollection')"
:aria-rowcount="-1"
@@ -34,7 +36,6 @@
:key="asset.id"
:asset="asset"
:interactive="true"
role="gridcell"
@select="$emit('assetSelect', $event)"
/>
</template>
@@ -42,11 +43,9 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { createGridStyle } from '@/utils/gridUtil'
import { cn } from '@/utils/tailwindUtil'
defineProps<{
assets: AssetDisplayItem[]
@@ -56,7 +55,4 @@ defineProps<{
defineEmits<{
assetSelect: [asset: AssetDisplayItem]
}>()
// Use same grid style as BaseModalLayout
const gridStyle = computed(() => createGridStyle())
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6 border-t-[1px] border-border-default"
class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6 border-t border-border-default"
>
<!-- Step 1: Enter URL -->
<UploadModelUrlInput

View File

@@ -1,7 +1,10 @@
import { fromZodError } from 'zod-validation-error'
import { st } from '@/i18n'
import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema'
import {
assetItemSchema,
assetResponseSchema
} from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
AssetMetadata,
@@ -281,6 +284,43 @@ function createAssetService() {
}
}
/**
* Update metadata of an asset by ID
* Only available in cloud environment
*
* @param id - The asset ID (UUID)
* @param newData - The data to update
* @returns Promise<AssetItem>
* @throws Error if update fails
*/
async function updateAsset(
id: string,
newData: Partial<AssetMetadata>
): Promise<AssetItem> {
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newData)
})
if (!res.ok) {
throw new Error(
`Unable to update asset ${id}: Server returned ${res.status}`
)
}
const newAsset = assetItemSchema.safeParse(await res.json())
if (newAsset.success) {
return newAsset.data
}
throw new Error(
`Unable to update asset ${id}: Invalid response - ${newAsset.error}`
)
}
/**
* Retrieves metadata from a download URL without downloading the file
*
@@ -360,6 +400,7 @@ function createAssetService() {
getAssetDetails,
getAssetsByTag,
deleteAsset,
updateAsset,
getAssetMetadata,
uploadAssetFromUrl
}

View File

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useTemplateWorkflows } from './useTemplateWorkflows'
@@ -13,9 +14,10 @@ import { useTemplateWorkflows } from './useTemplateWorkflows'
* Supports URLs like:
* - /?template=flux_simple (loads with default source)
* - /?template=flux_simple&source=custom (loads from custom source)
* - /?template=flux_simple&mode=linear (loads template in linear mode)
*
* Input validation:
* - Template and source parameters must match: ^[a-zA-Z0-9_-]+$
* - Template, source, and mode parameters must match: ^[a-zA-Z0-9_-]+$
* - Invalid formats are rejected with console warnings
*/
export function useTemplateUrlLoader() {
@@ -24,7 +26,10 @@ export function useTemplateUrlLoader() {
const { t } = useI18n()
const toast = useToast()
const templateWorkflows = useTemplateWorkflows()
const canvasStore = useCanvasStore()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
const SUPPORTED_MODES = ['linear'] as const
type SupportedMode = (typeof SUPPORTED_MODES)[number]
/**
* Validates parameter format to prevent path traversal and injection attacks
@@ -34,12 +39,20 @@ export function useTemplateUrlLoader() {
}
/**
* Removes template and source parameters from URL
* Type guard to check if a value is a supported mode
*/
const isSupportedMode = (mode: string): mode is SupportedMode => {
return SUPPORTED_MODES.includes(mode as SupportedMode)
}
/**
* Removes template, source, and mode parameters from URL
*/
const cleanupUrlParams = () => {
const newQuery = { ...route.query }
delete newQuery.template
delete newQuery.source
delete newQuery.mode
void router.replace({ query: newQuery })
}
@@ -70,6 +83,24 @@ export function useTemplateUrlLoader() {
return
}
const modeParam = route.query.mode as string | undefined
if (
modeParam &&
(typeof modeParam !== 'string' || !isValidParameter(modeParam))
) {
console.warn(
`[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}`
)
return
}
if (modeParam && !isSupportedMode(modeParam)) {
console.warn(
`[useTemplateUrlLoader] Unsupported mode parameter: ${modeParam}. Supported modes: ${SUPPORTED_MODES.join(', ')}`
)
}
try {
await templateWorkflows.loadTemplates()
@@ -87,6 +118,9 @@ export function useTemplateUrlLoader() {
}),
life: 3000
})
} else if (modeParam === 'linear') {
// Set linear mode after successful template load
canvasStore.linearMode = true
}
} catch (error) {
console.error(

View File

@@ -9,6 +9,8 @@ import { computed, customRef, ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import * as Y from 'yjs'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
import { LayoutSource } from '@/renderer/core/layout/types'
import type {
@@ -136,6 +138,8 @@ class LayoutStoreImpl implements LayoutStore {
// Vue dragging state for selection toolbox (public ref for direct mutation)
public isDraggingVueNodes = ref(false)
// Vue resizing state to prevent drag from activating during resize
public isResizingVueNodes = ref(false)
constructor() {
// Initialize Yjs data structures
@@ -1414,8 +1418,8 @@ class LayoutStoreImpl implements LayoutStore {
batchUpdateNodeBounds(updates: NodeBoundsUpdate[]): void {
if (updates.length === 0) return
// Set source to Vue for these DOM-driven updates
const originalSource = this.currentSource
const shouldNormalizeHeights = originalSource === LayoutSource.DOM
this.currentSource = LayoutSource.Vue
const nodeIds: NodeId[] = []
@@ -1426,8 +1430,15 @@ class LayoutStoreImpl implements LayoutStore {
if (!ynode) continue
const currentLayout = yNodeToLayout(ynode)
const normalizedBounds = shouldNormalizeHeights
? {
...bounds,
height: removeNodeTitleHeight(bounds.height)
}
: bounds
boundsRecord[nodeId] = {
bounds,
bounds: normalizedBounds,
previousBounds: currentLayout.bounds
}
nodeIds.push(nodeId)

View File

@@ -8,6 +8,7 @@ import { onUnmounted, ref } from 'vue'
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { addNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
/**
* Composable for syncing LiteGraph with the Layout system
@@ -43,12 +44,13 @@ export function useLayoutSync() {
liteNode.pos[1] = layout.position.y
}
const targetHeight = addNodeTitleHeight(layout.size.height)
if (
liteNode.size[0] !== layout.size.width ||
liteNode.size[1] !== layout.size.height
liteNode.size[1] !== targetHeight
) {
// Use setSize() to trigger onResize callback
liteNode.setSize([layout.size.width, layout.size.height])
liteNode.setSize([layout.size.width, targetHeight])
}
}

View File

@@ -10,6 +10,7 @@ import type { ComputedRef, Ref } from 'vue'
export enum LayoutSource {
Canvas = 'canvas',
Vue = 'vue',
DOM = 'dom',
External = 'external'
}

View File

@@ -0,0 +1,7 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
export const removeNodeTitleHeight = (height: number) =>
Math.max(0, height - (LiteGraph.NODE_TITLE_HEIGHT || 0))
export const addNodeTitleHeight = (height: number) =>
height + LiteGraph.NODE_TITLE_HEIGHT

View File

@@ -117,17 +117,14 @@
</div>
</template>
<!-- Resize handles -->
<template v-if="!isCollapsed">
<div
v-for="handle in cornerResizeHandles"
:key="handle.id"
role="button"
:aria-label="handle.ariaLabel"
:class="cn(baseResizeHandleClasses, handle.classes)"
@pointerdown.stop="handleResizePointerDown(handle.direction)($event)"
/>
</template>
<!-- Resize handle (bottom-right only) -->
<div
v-if="!isCollapsed"
role="button"
:aria-label="t('g.resizeFromBottomRight')"
:class="cn(baseResizeHandleClasses, 'right-0 bottom-0 cursor-se-resize')"
@pointerdown.stop="handleResizePointerDown"
/>
</div>
</template>
@@ -171,7 +168,6 @@ import {
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import type { ResizeHandleDirection } from '../interactions/resize/resizeMath'
import { useNodeResize } from '../interactions/resize/useNodeResize'
import LivePreview from './LivePreview.vue'
import NodeContent from './NodeContent.vue'
@@ -263,7 +259,7 @@ onErrorCaptured((error) => {
return false // Prevent error propagation
})
const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id)
const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
const { startDrag } = useNodeDrag()
@@ -314,41 +310,6 @@ onMounted(() => {
const baseResizeHandleClasses =
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
const POSITION_EPSILON = 0.01
type CornerResizeHandle = {
id: string
direction: ResizeHandleDirection
classes: string
ariaLabel: string
}
const cornerResizeHandles: CornerResizeHandle[] = [
{
id: 'se',
direction: { horizontal: 'right', vertical: 'bottom' },
classes: 'right-0 bottom-0 cursor-se-resize',
ariaLabel: t('g.resizeFromBottomRight')
},
{
id: 'ne',
direction: { horizontal: 'right', vertical: 'top' },
classes: 'right-0 top-0 cursor-ne-resize',
ariaLabel: t('g.resizeFromTopRight')
},
{
id: 'sw',
direction: { horizontal: 'left', vertical: 'bottom' },
classes: 'left-0 bottom-0 cursor-sw-resize',
ariaLabel: t('g.resizeFromBottomLeft')
},
{
id: 'nw',
direction: { horizontal: 'left', vertical: 'top' },
classes: 'left-0 top-0 cursor-nw-resize',
ariaLabel: t('g.resizeFromTopLeft')
}
]
const MIN_NODE_WIDTH = 225
@@ -361,22 +322,11 @@ const { startResize } = useNodeResize((result, element) => {
// Apply size directly to DOM element - ResizeObserver will pick this up
element.style.setProperty('--node-width', `${clampedWidth}px`)
element.style.setProperty('--node-height', `${result.size.height}px`)
const currentPosition = position.value
const deltaX = Math.abs(result.position.x - currentPosition.x)
const deltaY = Math.abs(result.position.y - currentPosition.y)
if (deltaX > POSITION_EPSILON || deltaY > POSITION_EPSILON) {
moveNodeTo(result.position)
}
})
const handleResizePointerDown = (direction: ResizeHandleDirection) => {
return (event: PointerEvent) => {
if (nodeData.flags?.pinned) return
startResize(event, direction, { ...position.value })
}
const handleResizePointerDown = (event: PointerEvent) => {
if (nodeData.flags?.pinned) return
startResize(event)
}
watch(isCollapsed, (collapsed) => {

View File

@@ -92,12 +92,14 @@ const mockData = vi.hoisted(() => {
vi.mock('@/renderer/core/layout/store/layoutStore', () => {
const isDraggingVueNodes = ref(false)
const isResizingVueNodes = ref(false)
const fakeNodeLayoutRef = ref(mockData.fakeNodeLayout)
const getNodeLayoutRef = vi.fn(() => fakeNodeLayoutRef)
const setSource = vi.fn()
return {
layoutStore: {
isDraggingVueNodes,
isResizingVueNodes,
getNodeLayoutRef,
setSource
}

View File

@@ -63,6 +63,9 @@ export function useNodePointerInteractions(
function onPointermove(event: PointerEvent) {
if (forwardMiddlePointerIfNeeded(event)) return
// Don't activate drag while resizing
if (layoutStore.isResizingVueNodes.value) return
const nodeId = toValue(nodeIdRef)
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {

View File

@@ -107,7 +107,7 @@ const resizeObserver = new ResizeObserver((entries) => {
x: topLeftCanvas.x,
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
width: Math.max(0, width),
height: Math.max(0, height - LiteGraph.NODE_TITLE_HEIGHT)
height: Math.max(0, height)
}
let updates = updatesByType.get(elementType)
@@ -123,8 +123,7 @@ const resizeObserver = new ResizeObserver((entries) => {
}
}
// Set source to Vue before processing DOM-driven updates
layoutStore.setSource(LayoutSource.Vue)
layoutStore.setSource(LayoutSource.DOM)
// Flush per-type
for (const [type, updates] of updatesByType) {

View File

@@ -1,104 +0,0 @@
import type { Point, Size } from '@/renderer/core/layout/types'
export type ResizeHandleDirection = {
horizontal: 'left' | 'right'
vertical: 'top' | 'bottom'
}
function applyHandleDelta(
startSize: Size,
delta: Point,
handle: ResizeHandleDirection
): Size {
const horizontalMultiplier = handle.horizontal === 'right' ? 1 : -1
const verticalMultiplier = handle.vertical === 'bottom' ? 1 : -1
return {
width: startSize.width + delta.x * horizontalMultiplier,
height: startSize.height + delta.y * verticalMultiplier
}
}
function computeAdjustedPosition(
startPosition: Point,
startSize: Size,
nextSize: Size,
handle: ResizeHandleDirection
): Point {
const widthDelta = startSize.width - nextSize.width
const heightDelta = startSize.height - nextSize.height
return {
x:
handle.horizontal === 'left'
? startPosition.x + widthDelta
: startPosition.x,
y:
handle.vertical === 'top'
? startPosition.y + heightDelta
: startPosition.y
}
}
/**
* Computes the resulting size and position of a node given pointer movement
* and handle orientation.
*/
export function computeResizeOutcome({
startSize,
startPosition,
delta,
handle,
snapFn
}: {
startSize: Size
startPosition: Point
delta: Point
handle: ResizeHandleDirection
snapFn?: (size: Size) => Size
}): { size: Size; position: Point } {
const resized = applyHandleDelta(startSize, delta, handle)
const snapped = snapFn?.(resized) ?? resized
const position = computeAdjustedPosition(
startPosition,
startSize,
snapped,
handle
)
return {
size: snapped,
position
}
}
export function createResizeSession(config: {
startSize: Size
startPosition: Point
handle: ResizeHandleDirection
}) {
const startSize = { ...config.startSize }
const startPosition = { ...config.startPosition }
const handle = config.handle
return (delta: Point, snapFn?: (size: Size) => Size) =>
computeResizeOutcome({
startSize,
startPosition,
handle,
delta,
snapFn
})
}
export function toCanvasDelta(
startPointer: Point,
currentPointer: Point,
scale: number
): Point {
const safeScale = scale === 0 ? 1 : scale
return {
x: (currentPointer.x - startPointer.x) / safeScale,
y: (currentPointer.y - startPointer.y) / safeScale
}
}

View File

@@ -2,20 +2,17 @@ import { useEventListener } from '@vueuse/core'
import { ref } from 'vue'
import type { Point, Size } from '@/renderer/core/layout/types'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import type { ResizeHandleDirection } from './resizeMath'
import { createResizeSession, toCanvasDelta } from './resizeMath'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
interface ResizeCallbackPayload {
size: Size
position: Point
}
/**
* Composable for node resizing functionality
* Composable for node resizing functionality (bottom-right corner only)
*
* Provides resize handle interaction that integrates with the layout system.
* Handles pointer capture, coordinate calculations, and size constraints.
@@ -27,16 +24,7 @@ export function useNodeResize(
const isResizing = ref(false)
const resizeStartPointer = ref<Point | null>(null)
const resizeSession = ref<
| ((
delta: Point,
snapFn?: (size: Size) => Size
) => {
size: Size
position: Point
})
| null
>(null)
const resizeStartSize = ref<Size | null>(null)
// Snap-to-grid functionality
const { shouldSnap, applySnapToSize } = useNodeSnap()
@@ -44,11 +32,7 @@ export function useNodeResize(
// Shift key sync for LiteGraph canvas preview
const { trackShiftKey } = useShiftKeySync()
const startResize = (
event: PointerEvent,
handle: ResizeHandleDirection,
startPosition: Point
) => {
const startResize = (event: PointerEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -72,47 +56,49 @@ export function useNodeResize(
// Capture pointer to ensure we get all move/up events
target.setPointerCapture(event.pointerId)
// Mark as resizing to prevent drag from activating
layoutStore.isResizingVueNodes.value = true
isResizing.value = true
resizeStartPointer.value = { x: event.clientX, y: event.clientY }
resizeSession.value = createResizeSession({
startSize,
startPosition: { ...startPosition },
handle
})
resizeStartSize.value = startSize
const handlePointerMove = (moveEvent: PointerEvent) => {
if (
!isResizing.value ||
!resizeStartPointer.value ||
!resizeSession.value
)
!resizeStartSize.value
) {
return
}
const startPointer = resizeStartPointer.value
const session = resizeSession.value
const scale = transformState.camera.z
const deltaX =
(moveEvent.clientX - resizeStartPointer.value.x) / (scale || 1)
const deltaY =
(moveEvent.clientY - resizeStartPointer.value.y) / (scale || 1)
const delta = toCanvasDelta(
startPointer,
{ x: moveEvent.clientX, y: moveEvent.clientY },
transformState.camera.z
)
let newSize: Size = {
width: resizeStartSize.value.width + deltaX,
height: resizeStartSize.value.height + deltaY
}
// Apply snap if shift is held
if (shouldSnap(moveEvent)) {
newSize = applySnapToSize(newSize)
}
const nodeElement = target.closest('[data-node-id]')
if (nodeElement instanceof HTMLElement) {
const outcome = session(
delta,
shouldSnap(moveEvent) ? applySnapToSize : undefined
)
resizeCallback(outcome, nodeElement)
resizeCallback({ size: newSize }, nodeElement)
}
}
const handlePointerUp = (upEvent: PointerEvent) => {
if (isResizing.value) {
isResizing.value = false
layoutStore.isResizingVueNodes.value = false
resizeStartPointer.value = null
resizeSession.value = null
resizeStartSize.value = null
// Stop tracking shift key state
stopShiftSync()

View File

@@ -3,6 +3,7 @@
<Select
v-model="modelValue"
:invalid
:filter="selectOptions.length > 4"
:options="selectOptions"
v-bind="combinedProps"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"

View File

@@ -213,12 +213,13 @@ const acceptTypes = computed(() => {
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
watch(
modelValue,
(currentValue) => {
[modelValue, dropdownItems],
([currentValue, _dropdownItems]) => {
if (currentValue === undefined) {
selectedSet.value.clear()
return
}
const item = dropdownItems.value.find((item) => item.name === currentValue)
if (item) {
selectedSet.value.clear()

View File

@@ -80,7 +80,7 @@ const router = createRouter({
installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE,
keys: ['template', 'source']
keys: ['template', 'source', 'mode']
}
])

View File

@@ -34,7 +34,7 @@ import NodeConflictDialogContent from '@/workbench/extensions/manager/components
import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue'
import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import type { ComponentProps } from 'vue-component-type-helpers'
import type { ComponentAttrs } from 'vue-component-type-helpers'
export type ConfirmationDialogType =
| 'default'
@@ -48,7 +48,7 @@ export const useDialogService = () => {
const dialogStore = useDialogStore()
function showLoadWorkflowWarning(
props: ComponentProps<typeof MissingNodesContent>
props: ComponentAttrs<typeof MissingNodesContent>
) {
dialogStore.showDialog({
key: 'global-missing-nodes',
@@ -74,7 +74,7 @@ export const useDialogService = () => {
}
function showMissingModelsWarning(
props: InstanceType<typeof MissingModelsWarning>['$props']
props: ComponentAttrs<typeof MissingModelsWarning>
) {
dialogStore.showDialog({
key: 'global-missing-models-warning',
@@ -115,7 +115,7 @@ export const useDialogService = () => {
}
function showExecutionErrorDialog(executionError: ExecutionErrorWsMessage) {
const props: InstanceType<typeof ErrorDialogContent>['$props'] = {
const props: ComponentAttrs<typeof ErrorDialogContent> = {
error: {
exceptionType: executionError.exception_type,
exceptionMessage: executionError.exception_message,
@@ -141,7 +141,7 @@ export const useDialogService = () => {
}
function showManagerDialog(
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
props: ComponentAttrs<typeof ManagerDialogContent> = {}
) {
dialogStore.showDialog({
key: 'global-manager',
@@ -206,7 +206,7 @@ export const useDialogService = () => {
errorMessage: String(error)
}
const props: InstanceType<typeof ErrorDialogContent>['$props'] = {
const props: ComponentAttrs<typeof ErrorDialogContent> = {
error: {
exceptionType: options.title ?? 'Unknown Error',
exceptionMessage: errorProps.errorMessage,
@@ -430,7 +430,7 @@ export const useDialogService = () => {
}
function toggleManagerDialog(
props?: InstanceType<typeof ManagerDialogContent>['$props']
props?: ComponentAttrs<typeof ManagerDialogContent>
) {
if (dialogStore.isDialogOpen('global-manager')) {
dialogStore.closeDialog({ key: 'global-manager' })
@@ -440,7 +440,7 @@ export const useDialogService = () => {
}
function toggleManagerProgressDialog(
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
props?: ComponentAttrs<typeof ManagerProgressDialogContent>
) {
if (dialogStore.isDialogOpen('global-manager-progress-dialog')) {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })

View File

@@ -7,6 +7,7 @@ import { markRaw, ref } from 'vue'
import type { Component } from 'vue'
import type GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import type { ComponentAttrs } from 'vue-component-type-helpers'
type DialogPosition =
| 'center'
@@ -33,30 +34,40 @@ interface CustomDialogComponentProps {
headless?: boolean
}
export type DialogComponentProps = InstanceType<typeof GlobalDialog>['$props'] &
export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &
CustomDialogComponentProps
interface DialogInstance {
interface DialogInstance<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
> {
key: string
visible: boolean
title?: string
headerComponent?: Component
component: Component
contentProps: Record<string, any>
footerComponent?: Component
footerProps?: Record<string, any>
headerComponent?: H
headerProps?: ComponentAttrs<H>
component: B
contentProps: ComponentAttrs<B>
footerComponent?: F
footerProps?: ComponentAttrs<F>
dialogComponentProps: DialogComponentProps
priority: number
}
export interface ShowDialogOptions {
export interface ShowDialogOptions<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
> {
key?: string
title?: string
headerComponent?: Component
footerComponent?: Component
component: Component
props?: Record<string, any>
footerProps?: Record<string, any>
headerComponent?: H
footerComponent?: F
component: B
props?: ComponentAttrs<B>
headerProps?: ComponentAttrs<H>
footerProps?: ComponentAttrs<F>
dialogComponentProps?: DialogComponentProps
/**
* Optional priority for dialog stacking.
@@ -123,17 +134,11 @@ export const useDialogStore = defineStore('dialog', () => {
updateCloseOnEscapeStates()
}
function createDialog(options: {
key: string
title?: string
headerComponent?: Component
footerComponent?: Component
component: Component
props?: Record<string, any>
footerProps?: Record<string, any>
dialogComponentProps?: DialogComponentProps
priority?: number
}) {
function createDialog<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
>(options: ShowDialogOptions<H, B, F> & { key: string }) {
if (dialogStack.value.length >= 10) {
dialogStack.value.shift()
}
@@ -149,6 +154,7 @@ export const useDialogStore = defineStore('dialog', () => {
? markRaw(options.footerComponent)
: undefined,
component: markRaw(options.component),
headerProps: { ...options.headerProps },
contentProps: { ...options.props },
footerProps: { ...options.footerProps },
priority: options.priority ?? 1,
@@ -203,7 +209,11 @@ export const useDialogStore = defineStore('dialog', () => {
})
}
function showDialog(options: ShowDialogOptions) {
function showDialog<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
>(options: ShowDialogOptions<H, B, F>) {
const dialogKey = options.key || genDialogKey()
let dialog = dialogStack.value.find((d) => d.key === dialogKey)

View File

@@ -24,8 +24,7 @@ export const getButtonSizeClasses = (size: ButtonSize = 'md') => {
export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
const baseByType = {
primary:
'bg-neutral-900 border-none text-white dark-theme:bg-white dark-theme:text-neutral-900',
primary: 'bg-base-foreground border-none text-base-background',
secondary: cn(
'bg-secondary-background border-none text-base-foreground hover:bg-secondary-background-hover'
),
@@ -41,10 +40,8 @@ export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
const baseByType = {
primary:
'bg-neutral-900 text-white dark-theme:bg-white dark-theme:text-neutral-900',
secondary:
'bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white',
primary: 'bg-base-background text-base-foreground',
secondary: 'bg-secondary-background text-base-foreground',
transparent: cn(
'bg-transparent text-base-foreground hover:bg-secondary-background-hover'
),
@@ -53,10 +50,9 @@ export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
} as const
const borderByType = {
primary: 'border border-solid border-white dark-theme:border-neutral-900',
secondary: 'border border-solid border-neutral-950 dark-theme:border-white',
transparent:
'border border-solid border-neutral-950 dark-theme:border-white',
primary: 'border border-solid border-base-background',
secondary: 'border border-solid border-base-foreground',
transparent: 'border border-solid border-base-foreground',
accent: 'border border-solid border-primary-background'
} as const

View File

@@ -14,6 +14,8 @@ interface GridOptions {
}
/**
* @deprecated Just use tailwind utilities directly.
* TODO: Create a common grid layout component if needed.
* Creates CSS grid styles for responsive grid layouts
* @param options Grid configuration options
* @returns CSS properties object for grid styling

View File

@@ -1,5 +1,6 @@
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobState } from '@/types/queue'
import { formatDuration } from '@/utils/formatUtil'
import { clampPercentInt, formatPercent0 } from '@/utils/numberUtil'
type BuildJobDisplayCtx = {
@@ -11,6 +12,8 @@ type BuildJobDisplayCtx = {
currentNodePercent?: number
currentNodeName?: string
showAddedHint?: boolean
/** Whether the app is running in cloud distribution */
isCloud?: boolean
}
type JobDisplay = {
@@ -122,13 +125,20 @@ export const buildJobDisplay = (
const time = task.executionTimeInSeconds
const preview = task.previewOutput
const iconImageUrl = preview && preview.isImage ? preview.url : undefined
// Cloud shows "Completed in Xh Ym Zs", non-cloud shows filename
const primary = ctx.isCloud
? ctx.t('queue.completedIn', {
duration: formatDuration(task.executionTime ?? 0)
})
: preview?.filename && preview.filename.length
? preview.filename
: buildTitle(task, ctx.t)
return {
iconName: iconForJobState(state),
iconImageUrl,
primary:
preview?.filename && preview.filename.length
? preview.filename
: buildTitle(task, ctx.t),
primary,
secondary: time !== undefined ? `${time.toFixed(2)}s` : '',
showClear: false
}

View File

@@ -0,0 +1,37 @@
import { unref } from 'vue'
import type { MaybeRef } from 'vue'
import type {
LGraph,
LGraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
export type NodeDefLookup = Record<string, ComfyNodeDefImpl | undefined>
const isNodeMissingDefinition = (
node: LGraphNode,
nodeDefsByName: NodeDefLookup
) => {
const nodeName = node?.type
if (!nodeName) return false
return !nodeDefsByName[nodeName]
}
export const collectMissingNodes = (
graph: LGraph | Subgraph | null | undefined,
nodeDefsByName: MaybeRef<NodeDefLookup>
): LGraphNode[] => {
if (!graph) return []
const lookup = unref(nodeDefsByName)
return collectAllNodes(graph, (node) => isNodeMissingDefinition(node, lookup))
}
export const graphHasMissingNodes = (
graph: LGraph | Subgraph | null | undefined,
nodeDefsByName: MaybeRef<NodeDefLookup>
) => {
return collectMissingNodes(graph, nodeDefsByName).length > 0
}

View File

@@ -8,8 +8,9 @@ import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/
* Tests the behavior of loading templates via URL query parameters:
* - ?template=flux_simple loads the template
* - ?template=flux_simple&source=custom loads from custom source
* - ?template=flux_simple&mode=linear loads template in linear mode
* - Invalid template shows error toast
* - Input validation for template and source parameters
* - Input validation for template, source, and mode parameters
*/
const preservedQueryMocks = vi.hoisted(() => ({
@@ -70,10 +71,20 @@ vi.mock('vue-i18n', () => ({
})
}))
// Mock canvas store
const mockCanvasStore = {
linearMode: false
}
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
describe('useTemplateUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryParams = {}
mockCanvasStore.linearMode = false
})
it('does not load template when no query param present', () => {
@@ -236,6 +247,7 @@ describe('useTemplateUrlLoader', () => {
mockQueryParams = {
template: 'flux_simple',
source: 'custom',
mode: 'linear',
other: 'param'
}
@@ -270,4 +282,121 @@ describe('useTemplateUrlLoader', () => {
query: { other: 'param' }
})
})
it('sets linear mode when mode=linear and template loads successfully', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'linear' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(true)
})
it('does not set linear mode when template loading fails', async () => {
mockQueryParams = { template: 'invalid-template', mode: 'linear' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockCanvasStore.linearMode).toBe(false)
})
it('does not set linear mode when mode parameter is not linear', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'graph' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(false)
})
it('rejects invalid mode parameter with special characters', () => {
mockQueryParams = { template: 'flux_simple', mode: '../malicious' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
expect(mockLoadTemplates).not.toHaveBeenCalled()
})
it('handles array mode params correctly', () => {
// Vue Router can return string[] for duplicate params
mockQueryParams = {
template: 'flux_simple',
mode: ['linear', 'graph'] as any
}
const { loadTemplateFromUrl } = useTemplateUrlLoader()
void loadTemplateFromUrl()
// Should not load when mode param is an array
expect(mockLoadTemplates).not.toHaveBeenCalled()
})
it('warns about unsupported mode values but continues loading', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockQueryParams = { template: 'flux_simple', mode: 'unsupported' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(consoleSpy).toHaveBeenCalledWith(
'[useTemplateUrlLoader] Unsupported mode parameter: unsupported. Supported modes: linear'
)
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(false)
consoleSpy.mockRestore()
})
it('accepts supported mode parameter: linear', async () => {
mockQueryParams = { template: 'flux_simple', mode: 'linear' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(true)
})
it('accepts valid format but warns about unsupported modes', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const unsupportedModes = ['graph', 'mode123', 'my_mode-2']
for (const mode of unsupportedModes) {
vi.clearAllMocks()
consoleSpy.mockClear()
mockCanvasStore.linearMode = false
mockQueryParams = { template: 'flux_simple', mode }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(consoleSpy).toHaveBeenCalledWith(
`[useTemplateUrlLoader] Unsupported mode parameter: ${mode}. Supported modes: linear`
)
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith(
'flux_simple',
'default'
)
expect(mockCanvasStore.linearMode).toBe(false)
}
consoleSpy.mockRestore()
})
})

View File

@@ -1,11 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import {
type LayoutChange,
LayoutSource,
type NodeLayout
} from '@/renderer/core/layout/types'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { LayoutChange, NodeLayout } from '@/renderer/core/layout/types'
describe('layoutStore CRDT operations', () => {
beforeEach(() => {
@@ -304,4 +302,108 @@ describe('layoutStore CRDT operations', () => {
expect(recentOps.length).toBeGreaterThanOrEqual(1)
expect(recentOps[0].type).toBe('moveNode')
})
it('normalizes DOM-sourced heights before storing', () => {
const nodeId = 'dom-node'
const layout = createTestNode(nodeId)
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
layoutStore.setSource(LayoutSource.DOM)
layoutStore.batchUpdateNodeBounds([
{
nodeId,
bounds: {
x: layout.bounds.x,
y: layout.bounds.y,
width: layout.size.width,
height: layout.size.height + LiteGraph.NODE_TITLE_HEIGHT
}
}
])
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value?.size.height).toBe(layout.size.height)
expect(nodeRef.value?.size.width).toBe(layout.size.width)
expect(nodeRef.value?.position).toEqual(layout.position)
})
it('normalizes very small DOM-sourced heights safely', () => {
const nodeId = 'small-dom-node'
const layout = createTestNode(nodeId)
layout.size.height = 10
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
layoutStore.setSource(LayoutSource.DOM)
layoutStore.batchUpdateNodeBounds([
{
nodeId,
bounds: {
x: layout.bounds.x,
y: layout.bounds.y,
width: layout.size.width,
height: layout.size.height + LiteGraph.NODE_TITLE_HEIGHT
}
}
])
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value?.size.height).toBeGreaterThanOrEqual(0)
})
it('handles undefined NODE_TITLE_HEIGHT without NaN results', () => {
const nodeId = 'undefined-title-height'
const layout = createTestNode(nodeId)
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
const originalTitleHeight = LiteGraph.NODE_TITLE_HEIGHT
// @ts-expect-error intentionally simulate undefined runtime value
LiteGraph.NODE_TITLE_HEIGHT = undefined
try {
layoutStore.setSource(LayoutSource.DOM)
layoutStore.batchUpdateNodeBounds([
{
nodeId,
bounds: {
x: layout.bounds.x,
y: layout.bounds.y,
width: layout.size.width,
height: layout.size.height
}
}
])
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value?.size.height).toBe(layout.size.height)
} finally {
LiteGraph.NODE_TITLE_HEIGHT = originalTitleHeight
}
})
})

View File

@@ -5,7 +5,9 @@ import { LayoutSource } from '@/renderer/core/layout/types'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
// Mock the layout mutations module
vi.mock('@/renderer/core/layout/operations/layoutMutations')
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
useLayoutMutations: vi.fn()
}))
const mockedUseLayoutMutations = vi.mocked(useLayoutMutations)

View File

@@ -1,94 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
computeResizeOutcome,
createResizeSession,
toCanvasDelta
} from '@/renderer/extensions/vueNodes/interactions/resize/resizeMath'
describe('nodeResizeMath', () => {
const startSize = { width: 200, height: 120 }
const startPosition = { x: 80, y: 160 }
it('computes resize from bottom-right corner without moving position', () => {
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: 40, y: 20 },
handle: { horizontal: 'right', vertical: 'bottom' }
})
expect(outcome.size).toEqual({ width: 240, height: 140 })
expect(outcome.position).toEqual(startPosition)
})
it('computes resize from top-left corner adjusting position', () => {
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: -30, y: -20 },
handle: { horizontal: 'left', vertical: 'top' }
})
expect(outcome.size).toEqual({ width: 230, height: 140 })
expect(outcome.position).toEqual({ x: 50, y: 140 })
})
it('supports reusable resize sessions with snapping', () => {
const session = createResizeSession({
startSize,
startPosition,
handle: { horizontal: 'right', vertical: 'bottom' }
})
const snapFn = vi.fn((size: typeof startSize) => ({
width: Math.round(size.width / 25) * 25,
height: Math.round(size.height / 25) * 25
}))
const applied = session({ x: 13, y: 17 }, snapFn)
expect(applied.size).toEqual({ width: 225, height: 125 })
expect(applied.position).toEqual(startPosition)
expect(snapFn).toHaveBeenCalled()
})
it('converts screen delta to canvas delta using scale', () => {
const delta = toCanvasDelta({ x: 50, y: 75 }, { x: 150, y: 135 }, 2)
expect(delta).toEqual({ x: 50, y: 30 })
})
describe('edge cases', () => {
it('handles zero scale by using fallback scale of 1', () => {
const delta = toCanvasDelta({ x: 50, y: 75 }, { x: 150, y: 135 }, 0)
expect(delta).toEqual({ x: 100, y: 60 })
})
it('handles negative deltas when resizing from right/bottom', () => {
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: -50, y: -30 },
handle: { horizontal: 'right', vertical: 'bottom' }
})
expect(outcome.size).toEqual({ width: 150, height: 90 })
expect(outcome.position).toEqual(startPosition)
})
it('handles very large deltas without overflow', () => {
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: 10000, y: 10000 },
handle: { horizontal: 'right', vertical: 'bottom' }
})
expect(outcome.size.width).toBe(10200)
expect(outcome.size.height).toBe(10120)
expect(outcome.position).toEqual(startPosition)
})
})
})

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import type {
LGraph,
LGraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import {
collectMissingNodes,
graphHasMissingNodes
} from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import type { NodeDefLookup } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
type NodeDefs = NodeDefLookup
let nodeIdCounter = 0
const mockNodeDef = {} as ComfyNodeDefImpl
const createGraph = (nodes: LGraphNode[] = []): LGraph => {
return { nodes } as Partial<LGraph> as LGraph
}
const createSubgraph = (nodes: LGraphNode[]): Subgraph => {
return { nodes } as Partial<Subgraph> as Subgraph
}
const createNode = (
type?: string,
subgraphNodes?: LGraphNode[]
): LGraphNode => {
return {
id: nodeIdCounter++,
type,
isSubgraphNode: subgraphNodes ? () => true : undefined,
subgraph: subgraphNodes ? createSubgraph(subgraphNodes) : undefined
} as unknown as LGraphNode
}
describe('graphHasMissingNodes', () => {
it('returns false when graph is null', () => {
expect(graphHasMissingNodes(null, {})).toBe(false)
})
it('returns false when graph is undefined', () => {
expect(graphHasMissingNodes(undefined, {})).toBe(false)
})
it('returns false when graph has no nodes', () => {
expect(graphHasMissingNodes(createGraph(), {})).toBe(false)
})
it('returns false when every node has a definition', () => {
const graph = createGraph([createNode('FooNode'), createNode('BarNode')])
const nodeDefs: NodeDefs = {
FooNode: mockNodeDef,
BarNode: mockNodeDef
}
expect(graphHasMissingNodes(graph, nodeDefs)).toBe(false)
})
it('returns true when at least one node is missing', () => {
const graph = createGraph([
createNode('FooNode'),
createNode('MissingNode')
])
const nodeDefs: NodeDefs = {
FooNode: mockNodeDef
}
expect(graphHasMissingNodes(graph, nodeDefs)).toBe(true)
})
it('checks nodes nested in subgraphs', () => {
const graph = createGraph([
createNode('ContainerNode', [createNode('InnerMissing')])
])
const nodeDefs: NodeDefs = {
ContainerNode: mockNodeDef
}
const missingNodes = collectMissingNodes(graph, nodeDefs)
expect(missingNodes).toHaveLength(1)
expect(missingNodes[0]?.type).toBe('InnerMissing')
})
it('ignores nodes without a type', () => {
const graph = createGraph([
createNode(undefined),
createNode(null as unknown as string)
])
expect(graphHasMissingNodes(graph, {})).toBe(false)
})
it('traverses deeply nested subgraphs', () => {
const deepGraph = createGraph([
createNode('Layer1', [
createNode('Layer2', [
createNode('Layer3', [createNode('MissingDeep')])
])
])
])
const nodeDefs: NodeDefs = {
Layer1: mockNodeDef,
Layer2: mockNodeDef,
Layer3: mockNodeDef
}
const missingNodes = collectMissingNodes(deepGraph, nodeDefs)
expect(missingNodes).toHaveLength(1)
expect(missingNodes[0]?.type).toBe('MissingDeep')
})
})