Compare commits

..

1 Commits

Author SHA1 Message Date
CodeRabbit Fixer
0f763b523d fix: refactor: Rearchitect workflow/graph data sync to prevent desynced state during loading (#9533)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:41:39 +01:00
34 changed files with 257 additions and 828 deletions

View File

@@ -14,7 +14,7 @@ on:
- 'cloud/*'
- 'main'
pull_request:
types: [labeled, synchronize]
types: [labeled]
workflow_dispatch:
permissions: {}
@@ -26,18 +26,11 @@ concurrency:
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo.
# For pull_request events, only dispatch for preview labels.
# - labeled: fires when a label is added; check the added label name.
# - synchronize: fires on push; check existing labels on the PR.
# For pull_request events, only dispatch when the 'preview' label is added.
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
github.event.label.name == 'preview')
runs-on: ubuntu-latest
steps:
- name: Build client payload

View File

@@ -1,62 +0,0 @@
# Release Process
## Bump Types
All releases use `release-version-bump.yaml`. Effects differ by bump type:
| Bump | Target | Creates branches? | GitHub release |
| ---------- | ---------- | ------------------------------------- | ---------------------------- |
| Minor | `main` | `core/` + `cloud/` for previous minor | Published, "latest" |
| Patch | `main` | No | Published, "latest" |
| Patch | `core/X.Y` | No | **Draft** (uncheck "latest") |
| Prerelease | any | No | Draft + prerelease |
**Minor bump** (e.g. 1.41→1.42): freezes the previous minor into `core/1.41`
and `cloud/1.41`, branched from the commit _before_ the bump. Nightly patch
bumps on `main` are convenience snapshots — no branches created.
**Patch on `core/X.Y`**: publishes a hotfix draft release. Must not be marked
"latest" so `main` stays current.
### Dual-homed commits
When a minor bump happens, unreleased commits appear in both places:
```
v1.40.1 ── A ── B ── C ── [bump to 1.41.0]
└── core/1.40
```
A, B, C become v1.41.0 on `main` AND sit on `core/1.40` (where they could
later ship as v1.40.2). Same commits, no divergence — the branch just prevents
1.41+ features from mixing in so ComfyUI can stay on 1.40.x.
## Backporting
1. Add `needs-backport` + version label to the merged PR
2. `pr-backport.yaml` cherry-picks and creates a backport PR
3. Conflicts produce a comment with details and an agent prompt
## Publishing
Merged PRs with the `Release` label trigger `release-draft-create.yaml`,
publishing to GitHub Releases (`dist.zip`), PyPI (`comfyui-frontend-package`),
and npm (`@comfyorg/comfyui-frontend-types`).
## Bi-weekly ComfyUI Integration
`release-biweekly-comfyui.yaml` runs every other Monday — if the next `core/`
branch has unreleased commits, it triggers a patch bump and drafts a PR to
`Comfy-Org/ComfyUI` updating `requirements.txt`.
## Workflows
| Workflow | Purpose |
| ------------------------------- | ------------------------------------------------ |
| `release-version-bump.yaml` | Bump version, create Release PR |
| `release-draft-create.yaml` | Build + publish to GitHub/PyPI/npm |
| `release-branch-create.yaml` | Create `core/` + `cloud/` branches (minor/major) |
| `release-biweekly-comfyui.yaml` | Auto-patch + ComfyUI requirements PR |
| `pr-backport.yaml` | Cherry-pick fixes to stable branches |
| `cloud-backport-tag.yaml` | Tag cloud branch merges |

View File

@@ -34,7 +34,17 @@
</Button>
</div>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
<div
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
)
"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
@@ -45,7 +55,6 @@
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
:has-any-error="hasAnyError"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
@@ -61,7 +70,7 @@
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[comfy--send] size-4" />
<i class="icon-[lucide--share-2] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
@@ -114,7 +123,7 @@
</template>
<script setup lang="ts">
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -136,7 +145,6 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -160,7 +168,6 @@ const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const executionErrorStore = useExecutionErrorStore()
const actionBarButtonStore = useActionBarButtonStore()
const queueUIStore = useQueueUIStore()
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
@@ -175,43 +182,6 @@ const isActionbarEnabled = computed(
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
/**
* Whether the actionbar container has any visible docked buttons
* (excluding ComfyActionbar, which uses position:fixed when floating
* and does not contribute to the container's visual layout).
*/
const hasDockedButtons = computed(() => {
if (actionBarButtonStore.buttons.length > 0) return true
if (hasLegacyContent.value) return true
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
if (isDesktop && !isIntegratedTabBar.value) return true
if (isCloud && flags.workflowSharingEnabled) return true
if (!isRightSidePanelOpen.value) return true
return false
})
const isActionbarContainerEmpty = computed(
() => isActionbarFloating.value && !hasDockedButtons.value
)
const actionbarContainerClass = computed(() => {
const base =
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
if (isActionbarContainerEmpty.value) {
return cn(
base,
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
}
const borderClass =
!isActionbarFloating.value && hasAnyError.value
? 'border-destructive-background-hover'
: 'border-interface-stroke'
return cn(base, 'px-2', borderClass)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
@@ -263,25 +233,6 @@ const rightSidePanelTooltipConfig = computed(() =>
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)
function checkLegacyContent() {
const el = legacyCommandsContainerRef.value
if (!el) {
hasLegacyContent.value = false
return
}
// Mirror the CSS: [&:not(:has(*>*:not(:empty)))]:hidden
hasLegacyContent.value =
el.querySelector(':scope > * > *:not(:empty)') !== null
}
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
childList: true,
subtree: true,
characterData: true
})
onMounted(() => {
if (legacyCommandsContainerRef.value) {
app.menu.element.style.width = 'fit-content'

View File

@@ -119,14 +119,9 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const {
topMenuContainer,
queueOverlayExpanded = false,
hasAnyError = false
} = defineProps<{
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
hasAnyError?: boolean
}>()
const emit = defineEmits<{
@@ -440,12 +435,7 @@ const panelClass = computed(() =>
isDragging.value && 'pointer-events-none select-none',
isDocked.value
? 'static border-none bg-transparent p-0'
: [
'fixed shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
]
: 'fixed shadow-interface'
)
)
</script>

View File

@@ -8,7 +8,6 @@ import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -28,7 +27,7 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
import { promptRenameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -53,15 +52,18 @@ workflowStore.activeWorkflow?.changeTracker?.reset()
const arrangeInputs = computed(() =>
appModeStore.selectedInputs
.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return node ? { nodeId, widgetName, node, widget } : null
const node = resolveNode(nodeId)
if (!node) return null
const widget = node.widgets?.find((w) => w.name === widgetName)
return { nodeId, widgetName, node, widget }
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
const node = resolveNode(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) {
return {
nodeId,
@@ -106,7 +108,7 @@ function getHovered(
function getBounding(nodeId: NodeId, widgetName?: string) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
const node = app.rootGraph.getNodeById(nodeId)
if (!node) return
const titleOffset =
@@ -119,6 +121,7 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) return
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
@@ -157,16 +160,12 @@ function handleClick(e: MouseEvent) {
else appModeStore.selectedOutputs.splice(index, 1)
return
}
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
if (!isSelectInputsMode.value) return
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
else appModeStore.selectedInputs.splice(index, 1)
}

View File

@@ -1,51 +0,0 @@
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
DialogTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
defineProps<{ title?: string; to?: string | HTMLElement }>()
const { t } = useI18n()
</script>
<template>
<DialogRoot v-slot="{ close }">
<DialogTrigger as-child>
<slot name="button" />
</DialogTrigger>
<DialogPortal :to>
<DialogOverlay
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-black/70"
/>
<DialogContent
v-bind="$attrs"
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
>
<div
v-if="title"
class="flex w-full items-center justify-between border-b border-border-subtle px-4"
>
<DialogTitle class="text-sm">{{ title }}</DialogTitle>
<DialogClose as-child>
<Button
:aria-label="t('g.close')"
size="icon"
variant="muted-textonly"
>
<i class="icon-[lucide--x]" />
</Button>
</DialogClose>
</div>
<slot :close />
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -50,9 +50,7 @@
{{ t('g.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="seeErrors">
{{
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
}}
{{ t('errorOverlay.seeErrors') }}
</Button>
</div>
</div>
@@ -71,8 +69,6 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
@@ -98,7 +94,6 @@ function dismiss() {
}
function seeErrors() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()

View File

@@ -1,5 +1,5 @@
import { computed, reactive, ref, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
@@ -227,7 +227,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
}
export function useErrorGroups(
searchQuery: MaybeRefOrGetter<string>,
searchQuery: Ref<string>,
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
@@ -584,7 +584,7 @@ export function useErrorGroups(
})
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = toValue(searchQuery).trim()
const query = searchQuery.value.trim()
return searchErrorGroups(tabErrorGroups.value, query)
})

View File

@@ -30,7 +30,7 @@
<UserAvatar
v-else
:photo-url="photoURL"
:class="compact && 'size-full'"
:class="compact && 'h-full w-auto'"
/>
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-4 px-1" />

View File

@@ -24,7 +24,7 @@ function handleWheel(e: WheelEvent) {
let dragging = false
function handleDown(e: PointerEvent) {
if (e.button !== 0 && e.button !== 1) return
if (e.button !== 0) return
const zoomPaneEl = zoomPane.value
if (!zoomPaneEl) return

View File

@@ -1,5 +1,6 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
@@ -28,6 +29,7 @@ useExtensionService().registerExtension({
const toUrl = (record: Record<string, string>) => {
const params = new URLSearchParams(record)
appendCloudResParam(params, record.filename)
return api.apiURL(`/view?${params}${rand}`)
}

View File

@@ -3180,7 +3180,6 @@
"cancelThisRun": "Cancel this run",
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"viewGraph": "View node graph",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",
@@ -3225,19 +3224,6 @@
"outputPlaceholder": "Output nodes will show up here",
"outputRequiredPlaceholder": "At least one node is required"
},
"error": {
"header": "This app encountered an error",
"log": "Error Logs",
"mobileFixable": "Check {0} for errors",
"requiresGraph": "Something went wrong during generation. This could be due to invalid hidden inputs, missing resources, or workflow configuration issues.",
"promptVisitGraph": "View the node graph to see the full error.",
"getHelp": "For help, view our {0}, {1}, or {2} with the copied error.",
"goto": "Show errors in graph",
"github": "submit a GitHub issue",
"guide": "troubleshooting guide",
"support": "contact our support",
"promptShow": "Show error report"
},
"queue": {
"clickToClear": "Click to clear queue",
"clear": "Clear queue"

View File

@@ -236,7 +236,7 @@ const adaptedAsset = computed(() => {
name: asset.name,
display_name: asset.display_name,
kind: fileKind.value,
src: asset.thumbnail_url || asset.preview_url || '',
src: asset.preview_url || '',
size: asset.size,
tags: asset.tags || [],
created_at: asset.created_at,

View File

@@ -45,8 +45,7 @@ export function mapTaskOutputToAssetItem(
? new Date(taskItem.executionStartTimestamp).toISOString()
: new Date().toISOString(),
tags: ['output'],
thumbnail_url: output.previewUrl,
preview_url: output.url,
preview_url: output.previewUrl,
user_metadata: metadata
}
}
@@ -64,7 +63,6 @@ export function mapInputFileToAssetItem(
directory: 'input' | 'output' = 'input'
): AssetItem {
const params = new URLSearchParams({ filename, type: directory })
const preview_url = api.apiURL(`/view?${params}`)
appendCloudResParam(params, filename)
return {
@@ -73,7 +71,6 @@ export function mapInputFileToAssetItem(
size: 0,
created_at: new Date().toISOString(),
tags: [directory],
thumbnail_url: api.apiURL(`/view?${params}`),
preview_url
preview_url: api.apiURL(`/view?${params}`)
}
}

View File

@@ -11,7 +11,6 @@ const zAsset = z.object({
preview_id: z.string().nullable().optional(),
display_name: z.string().optional(),
preview_url: z.string().optional(),
thumbnail_url: z.string().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
is_immutable: z.boolean().optional(),

View File

@@ -73,8 +73,7 @@ function mapOutputsToAssetItems({
size: 0,
created_at: createdAtValue,
tags: ['output'],
thumbnail_url: output.previewUrl,
preview_url: output.url,
preview_url: output.previewUrl,
user_metadata: {
jobId,
nodeId: output.nodeId,

View File

@@ -379,6 +379,13 @@ export const useWorkflowService = () => {
void workflowThumbnail.storeThumbnail(activeWorkflow)
domWidgetStore.clear()
}
// Deactivate the current workflow before the graph is reconfigured.
// This ensures there is never a window where activeWorkflow references
// the OLD workflow while rootGraph already contains NEW data — any
// checkState or data-sync path that reads activeWorkflow will see null
// and naturally skip, without needing a guard flag.
workflowStore.activeWorkflow = null
}
/**

View File

@@ -46,10 +46,7 @@
onThumbnailError($event.name, $event.previewUrl)
"
/>
<span
v-tooltip="buildTooltipConfig(item.name)"
class="truncate text-xs text-base-foreground"
>
<span class="truncate text-xs text-base-foreground">
{{ item.name }}
</span>
<span
@@ -77,7 +74,6 @@ import ShareAssetThumbnail from '@/platform/workflow/sharing/components/ShareAss
import { useAssetSections } from '@/platform/workflow/sharing/composables/useAssetSections'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
const { items } = defineProps<{
items: AssetInfo[]

View File

@@ -10,7 +10,6 @@ import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useBillingContext } from '@/composables/billing/useBillingContext'
@@ -30,7 +29,7 @@ import { useQueueSettingsStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { resolveNode } from '@/utils/litegraphUtil'
const { t } = useI18n()
const commandStore = useCommandStore()
const executionErrorStore = useExecutionErrorStore()
@@ -64,41 +63,21 @@ useEventListener(
)
const mappedSelections = computed(() => {
let unprocessedInputs = appModeStore.selectedInputs.flatMap(
([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return widget ? ([[node, widget]] as const) : []
}
)
let unprocessedInputs = [...appModeStore.selectedInputs]
//FIXME strict typing here
const processedInputs: ReturnType<typeof nodeToNodeData>[] = []
while (unprocessedInputs.length) {
const [node] = unprocessedInputs[0]
const inputGroup = takeWhile(unprocessedInputs, ([n]) => n === node).map(
([, widget]) => widget
)
const nodeId = unprocessedInputs[0][0]
const inputGroup = takeWhile(
unprocessedInputs,
([id]) => id === nodeId
).map(([, widgetName]) => widgetName)
unprocessedInputs = unprocessedInputs.slice(inputGroup.length)
//FIXME: hide widget if owning node bypassed
const node = resolveNode(nodeId)
if (node?.mode !== LGraphEventMode.ALWAYS) continue
const nodeData = nodeToNodeData(node)
remove(nodeData.widgets ?? [], (vueWidget) => {
if (vueWidget.slotMetadata?.linked) return true
if (!node.isSubgraphNode())
return !inputGroup.some((w) => w.name === vueWidget.name)
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return !inputGroup.some(
(subWidget) =>
isPromotedWidgetView(subWidget) &&
subWidget.sourceNodeId == storeNodeId &&
subWidget.sourceWidgetName === vueWidget.storeName
)
})
for (const widget of nodeData.widgets ?? []) {
widget.slotMetadata = undefined
widget.nodeId = String(node.id)
}
remove(nodeData.widgets ?? [], (w) => !inputGroup.includes(w.name))
processedInputs.push(nodeData)
}
return processedInputs
@@ -128,6 +107,8 @@ function getDropIndicator(node: LGraphNode) {
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
remove(nodeData.widgets ?? [], (w) => w.slotMetadata?.linked ?? false)
for (const widget of nodeData.widgets ?? []) widget.slotMetadata = undefined
return {
...nodeData,

View File

@@ -15,9 +15,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import MobileError from '@/renderer/extensions/linearMode/MobileError.vue'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueStore } from '@/stores/queueStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
@@ -33,7 +31,6 @@ const canvasStore = useCanvasStore()
const colorPaletteService = useColorPaletteService()
const colorPaletteStore = useColorPaletteStore()
const { isLoggedIn } = useCurrentUser()
const executionErrorStore = useExecutionErrorStore()
const { t } = useI18n()
const { commandIdToMenuItem } = useMenuItemStore()
const queueStore = useQueueStore()
@@ -43,7 +40,7 @@ const { toggle: toggleFullscreen } = useFullscreen(undefined, {
autoExit: true
})
const activeIndex = ref(1)
const activeIndex = ref(2)
const sliderPaneRef = useTemplateRef('sliderPaneRef')
const sliderWidth = computed(() => sliderPaneRef.value?.offsetWidth)
@@ -195,11 +192,7 @@ const menuEntries = computed<MenuItem[]>(() => [
<div
class="absolute top-0 left-[100vw] flex h-full w-screen flex-col bg-base-background"
>
<MobileError
v-if="executionErrorStore.isErrorOverlayOpen"
@navigate-controls="activeIndex = 0"
/>
<LinearPreview v-else mobile @navigate-controls="activeIndex = 0" />
<LinearPreview mobile />
</div>
<AssetsSidebarTab
class="absolute top-0 left-[200vw] h-full w-screen bg-base-background"
@@ -220,11 +213,7 @@ const menuEntries = computed<MenuItem[]>(() => [
<div class="relative size-4">
<i :class="cn('size-4', icon)" />
<div
v-if="index === 1 && executionErrorStore.isErrorOverlayOpen"
class="absolute -top-1 -right-1 size-2 rounded-full bg-error"
/>
<div
v-else-if="
v-if="
index === 1 &&
(queueStore.runningTasks.length > 0 ||
queueStore.pendingTasks.length > 0)

View File

@@ -1,174 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Dialogue from '@/components/common/Dialogue.vue'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useExternalLink } from '@/composables/useExternalLink'
import { buildSupportUrl } from '@/platform/support/config'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
defineEmits<{ navigateControls: [] }>()
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const executionErrorStore = useExecutionErrorStore()
const { buildDocsUrl, staticUrls } = useExternalLink()
const { allErrorGroups } = useErrorGroups('', t)
const { copyToClipboard } = useCopyToClipboard()
const guideUrl = buildDocsUrl('troubleshooting/overview', {
includeLocale: true
})
const supportUrl = buildSupportUrl()
const inputNodeIds = computed(() => {
const ids = new Set()
for (const [id] of appModeStore.selectedInputs) ids.add(String(id))
return ids
})
const accessibleNodeErrors = computed(() =>
Object.keys(executionErrorStore.lastNodeErrors ?? {}).filter((k) =>
inputNodeIds.value.has(k)
)
)
const accessibleErrors = computed(() =>
accessibleNodeErrors.value.flatMap((k) =>
executionErrorStore.lastNodeErrors![k].errors.flatMap((error) => {
const { extra_info } = error
if (!extra_info) return []
const selectedInput = appModeStore.selectedInputs.find(
([id, name]) => id == k && extra_info.input_name === name
)
if (!selectedInput) return []
return [`${selectedInput[1]}: ${error.message}`]
})
)
)
const allErrors = computed(() =>
allErrorGroups.value.flatMap((group) => {
if (group.type !== 'execution') return [group.title]
return group.cards.flatMap((c) =>
c.errors.map((e) =>
e.details
? `${c.title} (${e.details}): ${e.message}`
: `${c.title}: ${e.message}`
)
)
})
)
function copy(obj: unknown) {
copyToClipboard(JSON.stringify(obj))
}
</script>
<template>
<section class="flex h-full flex-col items-center justify-center gap-2 px-4">
<i class="icon-[lucide--circle-alert] size-6 bg-error" />
{{ t('linearMode.error.header') }}
<div class="p-1 text-muted-foreground">
<i18n-t
v-if="accessibleErrors.length"
keypath="linearMode.error.mobileFixable"
>
<Button @click="$emit('navigateControls')">
{{ t('linearMode.mobileControls') }}
</Button>
</i18n-t>
<div v-else class="text-center">
<p v-text="t('linearMode.error.requiresGraph')" />
<p v-text="t('linearMode.error.promptVisitGraph')" />
<p class="*:text-muted-foreground">
<i18n-t keypath="linearMode.error.getHelp">
<a
:href="guideUrl"
target="_blank"
v-text="t('linearMode.error.guide')"
/>
<a
:href="staticUrls.githubIssues"
target="_blank"
v-text="t('linearMode.error.github')"
/>
<a
:href="supportUrl"
target="_blank"
v-text="t('linearMode.error.support')"
/>
</i18n-t>
</p>
<Dialogue :title="t('linearMode.error.log')">
<template #button>
<Button variant="textonly">
{{ t('linearMode.error.promptShow') }}
<i class="icon-[lucide--chevron-right] size-5" />
</Button>
</template>
<template #default="{ close }">
<article class="flex flex-col gap-2 p-4">
<section class="flex max-h-[60vh] flex-col gap-2 overflow-y-auto">
<div
v-for="error in allErrors"
:key="error"
class="w-full rounded-lg bg-secondary-background p-2 text-muted-foreground"
v-text="error"
/>
</section>
<div class="flex items-center justify-end gap-4">
<Button variant="muted-textonly" size="lg" @click="close">
{{ t('g.close') }}
</Button>
<Button size="lg" @click="copy(allErrors)">
{{ t('importFailed.copyError') }}
<i class="icon-[lucide--copy]" />
</Button>
</div>
</article>
</template>
</Dialogue>
</div>
</div>
<div
v-if="accessibleErrors.length"
class="my-8 w-full rounded-lg bg-secondary-background text-muted-foreground"
>
<ul>
<li
v-for="error in accessibleErrors"
:key="error"
class="before:content"
v-text="error"
/>
</ul>
</div>
<div class="flex gap-2">
<Button
variant="textonly"
size="lg"
@click="executionErrorStore.dismissErrorOverlay()"
>
{{ t('g.dismiss') }}
</Button>
<Button variant="textonly" size="lg" @click="setMode('graph')">
{{ t('linearMode.viewGraph') }}
</Button>
<Button
v-if="accessibleErrors.length"
size="lg"
@click="copy(accessibleErrors)"
>
{{ t('importFailed.copyError') }}
<i class="icon-[lucide--copy]" />
</Button>
</div>
</section>
</template>

View File

@@ -1,115 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import FormDropdown from './FormDropdown.vue'
import type { FormDropdownItem } from './types'
function createItem(id: string, name: string): FormDropdownItem {
return { id, preview_url: '', name, label: name }
}
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
addAlert: vi.fn()
})
}))
const MockFormDropdownMenu = defineComponent({
name: 'FormDropdownMenu',
props: {
items: { type: Array as () => FormDropdownItem[], default: () => [] },
isSelected: { type: Function, default: undefined },
filterOptions: { type: Array, default: () => [] },
sortOptions: { type: Array, default: () => [] },
maxSelectable: { type: Number, default: 1 },
disabled: { type: Boolean, default: false },
showOwnershipFilter: { type: Boolean, default: false },
ownershipOptions: { type: Array, default: () => [] },
showBaseModelFilter: { type: Boolean, default: false },
baseModelOptions: { type: Array, default: () => [] }
},
setup() {
return () => h('div', { class: 'mock-menu' })
}
})
function mountDropdown(items: FormDropdownItem[]) {
return mount(FormDropdown, {
props: { items },
global: {
plugins: [PrimeVue, i18n],
stubs: {
FormDropdownInput: true,
Popover: { template: '<div><slot /></div>' },
FormDropdownMenu: MockFormDropdownMenu
}
}
})
}
function getMenuItems(
wrapper: ReturnType<typeof mountDropdown>
): FormDropdownItem[] {
return wrapper
.findComponent(MockFormDropdownMenu)
.props('items') as FormDropdownItem[]
}
describe('FormDropdown', () => {
describe('filteredItems updates when items prop changes', () => {
it('updates displayed items when items prop changes', async () => {
const wrapper = mountDropdown([
createItem('input-0', 'video1.mp4'),
createItem('input-1', 'video2.mp4')
])
await flushPromises()
expect(getMenuItems(wrapper)).toHaveLength(2)
await wrapper.setProps({
items: [
createItem('output-0', 'rendered1.mp4'),
createItem('output-1', 'rendered2.mp4')
]
})
await flushPromises()
const menuItems = getMenuItems(wrapper)
expect(menuItems).toHaveLength(2)
expect(menuItems[0].name).toBe('rendered1.mp4')
})
it('updates when items change but IDs stay the same', async () => {
const wrapper = mountDropdown([createItem('1', 'alpha')])
await flushPromises()
await wrapper.setProps({ items: [createItem('1', 'beta')] })
await flushPromises()
expect(getMenuItems(wrapper)[0].name).toBe('beta')
})
it('updates when switching between empty and non-empty items', async () => {
const wrapper = mountDropdown([])
await flushPromises()
expect(getMenuItems(wrapper)).toHaveLength(0)
await wrapper.setProps({ items: [createItem('1', 'video.mp4')] })
await flushPromises()
expect(getMenuItems(wrapper)).toHaveLength(1)
expect(getMenuItems(wrapper)[0].name).toBe('video.mp4')
await wrapper.setProps({ items: [] })
await flushPromises()
expect(getMenuItems(wrapper)).toHaveLength(0)
})
})
})

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { computedAsync, refDebounced } from '@vueuse/core'
import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -102,16 +101,9 @@ const maxSelectable = computed(() => {
return 1
})
const debouncedSearchQuery = refDebounced(searchQuery, 250, { maxWait: 1000 })
const itemsKey = computed(() => items.map((item) => item.id).join('|'))
const filteredItems = computedAsync(async (onCancel) => {
let cleanupFn: (() => void) | undefined
onCancel(() => cleanupFn?.())
const result = await searcher(debouncedSearchQuery.value, items, (cb) => {
cleanupFn = cb
})
return result
}, [])
const filteredItems = ref<FormDropdownItem[]>([])
const defaultSorter = computed<SortOption['sorter']>(() => {
const sorter = sortOptions.find((option) => option.id === 'default')?.sorter
@@ -179,6 +171,21 @@ function handleSelection(item: FormDropdownItem, index: number) {
closeDropdown()
}
}
async function customSearcher(
query: string,
onCleanup: (cleanupFn: () => void) => void
) {
let isCleanup = false
let cleanupFn: undefined | (() => void)
onCleanup(() => {
isCleanup = true
cleanupFn?.()
})
await searcher(query, items, (cb) => (cleanupFn = cb)).then((results) => {
if (!isCleanup) filteredItems.value = results
})
}
</script>
<template>
@@ -226,9 +233,11 @@ function handleSelection(item: FormDropdownItem, index: number) {
:show-base-model-filter
:base-model-options
:disabled
:searcher="customSearcher"
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable
:update-key="itemsKey"
@close="closeDropdown"
@item-click="handleSelection"
/>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import type { CSSProperties, MaybeRefOrGetter } from 'vue'
import { computed } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
@@ -20,6 +20,11 @@ interface Props {
isSelected: (item: FormDropdownItem, index: number) => boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
searcher?: (
query: string,
onCleanup: (cleanupFn: () => void) => void
) => Promise<void>
updateKey?: MaybeRefOrGetter<unknown>
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -31,6 +36,8 @@ const {
isSelected,
filterOptions,
sortOptions,
searcher,
updateKey,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -111,6 +118,8 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:sort-options
:searcher
:update-key
:show-ownership-filter
:ownership-options
:show-base-model-filter

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { MaybeRefOrGetter } from 'vue'
import Popover from 'primevue/popover'
import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -17,7 +19,12 @@ import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
defineProps<{
searcher?: (
query: string,
onCleanup: (cleanupFn: () => void) => void
) => Promise<void>
sortOptions: SortOption[]
updateKey?: MaybeRefOrGetter<unknown>
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -101,6 +108,8 @@ function toggleBaseModelSelection(item: FilterOption) {
<div class="text-secondary flex gap-2 px-4">
<FormSearchInput
v-model="searchQuery"
:searcher
:update-key
:class="
cn(
actionButtonStyle,

View File

@@ -109,15 +109,8 @@ function createMockNode(comfyClass = 'TestNode'): LGraphNode {
// Spy on the addWidget method
vi.spyOn(node, 'addWidget').mockImplementation(
(type, name, value, callback, options = {}) => {
const normalizedOptions =
typeof options === 'string' ? { property: options } : options
const widget = createMockWidget({
type,
name,
value,
options: normalizedOptions
})
(type, name, value, callback) => {
const widget = createMockWidget({ type, name, value })
// Store the callback function on the widget for testing
if (typeof callback === 'function') {
widget.callback = callback
@@ -327,7 +320,7 @@ describe('useComboWidget', () => {
HASH_FILENAME,
expect.any(Function),
expect.objectContaining({
values: [], // Empty initially, populated via dynamic getter
values: [], // Empty initially, populated dynamically by Proxy
getOptionLabel: expect.any(Function)
})
)
@@ -335,23 +328,6 @@ describe('useComboWidget', () => {
}
)
it('should keep the original options object for cloud input mappings', () => {
mockDistributionState.isCloud = true
const constructor = useComboWidget()
const mockNode = createMockNode('LoadImage')
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
})
const widget = constructor(mockNode, inputSpec)
const addWidgetCall = vi.mocked(mockNode.addWidget).mock.calls[0]
const options = addWidgetCall[4]
expect(widget.options).toBe(options)
})
it("should format option labels using store's getInputName function", () => {
mockDistributionState.isCloud = true
mockGetInputName.mockReturnValue('Beautiful Sunset.png')

View File

@@ -44,29 +44,6 @@ const NODE_PLACEHOLDER_MAP: Record<string, string> = {
LoadAudio: 'widgets.uploadSelect.placeholderAudio'
}
const bindDynamicValuesOption = (
widget: IBaseWidget,
getValues: () => unknown
) => {
const options = widget.options
let fallbackValues = Array.isArray(options.values)
? options.values
: ([] as unknown[])
Object.defineProperty(options, 'values', {
configurable: true,
enumerable: true,
get: () => {
const values = getValues()
if (values === undefined || values === null) return fallbackValues
return values
},
set: (values: unknown[]) => {
fallbackValues = Array.isArray(values) ? values : fallbackValues
}
})
}
const addMultiSelectWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
@@ -156,16 +133,22 @@ const createInputMappingWidget = (
})
}
bindDynamicValuesOption(widget, () =>
assetsStore.inputAssets
.filter(
(asset) =>
getMediaTypeFromFilename(asset.name) ===
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
)
.map((asset) => asset.asset_hash)
.filter((hash): hash is string => !!hash)
)
const origOptions = widget.options
widget.options = new Proxy(origOptions, {
get(target, prop) {
if (prop !== 'values') {
return target[prop as keyof typeof target]
}
return assetsStore.inputAssets
.filter(
(asset) =>
getMediaTypeFromFilename(asset.name) ===
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
)
.map((asset) => asset.asset_hash)
.filter((hash): hash is string => !!hash)
}
})
if (inputSpec.control_after_generate) {
if (!isComboWidget(widget)) {
@@ -227,7 +210,15 @@ const addComboWidget = (
})
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
bindDynamicValuesOption(widget, () => remoteWidget.getValue())
const origOptions = widget.options
widget.options = new Proxy(origOptions, {
get(target, prop) {
// Assertion: Proxy handler passthrough
return prop !== 'values'
? target[prop as keyof typeof target]
: remoteWidget.getValue()
}
})
}
if (inputSpec.control_after_generate) {

View File

@@ -9,7 +9,6 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { st, t } from '@/i18n'
import { ChangeTracker } from '@/scripts/changeTracker'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import {
LGraph,
@@ -208,11 +207,6 @@ export class ComfyApp {
return this.rootGraphInternal!
}
/** Whether the root graph has been initialized. Safe to check without triggering error logs. */
get isGraphReady(): boolean {
return !!this.rootGraphInternal
}
canvas!: LGraphCanvas
dragOverNode: LGraphNode | null = null
readonly canvasElRef = shallowRef<HTMLCanvasElement>()
@@ -1312,143 +1306,136 @@ export class ComfyApp {
}
}
ChangeTracker.isLoadingGraph = true
try {
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.rootGraph.configure(graphData)
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.rootGraph.configure(graphData)
// Save original renderer version before scaling (it gets modified during scaling)
const originalMainGraphRenderer =
this.rootGraph.extra.workflowRendererVersion
// Save original renderer version before scaling (it gets modified during scaling)
const originalMainGraphRenderer =
this.rootGraph.extra.workflowRendererVersion
// Scale main graph
ensureCorrectLayoutScale(originalMainGraphRenderer)
// Scale main graph
ensureCorrectLayoutScale(originalMainGraphRenderer)
// Scale all subgraphs that were loaded with the workflow
// Use original main graph renderer as fallback (not the modified one)
for (const subgraph of this.rootGraph.subgraphs.values()) {
ensureCorrectLayoutScale(
subgraph.extra.workflowRendererVersion || originalMainGraphRenderer,
subgraph
)
}
if (canvasVisible) fitView()
} catch (error) {
useDialogService().showErrorDialog(error, {
title: t('errorDialog.loadWorkflowTitle'),
reportType: 'loadWorkflowError'
})
console.error(error)
return
// Scale all subgraphs that were loaded with the workflow
// Use original main graph renderer as fallback (not the modified one)
for (const subgraph of this.rootGraph.subgraphs.values()) {
ensureCorrectLayoutScale(
subgraph.extra.workflowRendererVersion || originalMainGraphRenderer,
subgraph
)
}
forEachNode(this.rootGraph, (node) => {
const size = node.computeSize()
size[0] = Math.max(node.size[0], size[0])
size[1] = Math.max(node.size[1], size[1])
node.setSize(size)
if (node.widgets) {
// If you break something in the backend and want to patch workflows in the frontend
// This is the place to do this
for (let widget of node.widgets) {
if (node.type == 'KSampler' || node.type == 'KSamplerAdvanced') {
if (widget.name == 'sampler_name') {
if (
typeof widget.value === 'string' &&
widget.value.startsWith('sample_')
) {
widget.value = widget.value.slice(7)
}
}
}
if (
node.type == 'KSampler' ||
node.type == 'KSamplerAdvanced' ||
node.type == 'PrimitiveNode'
) {
if (widget.name == 'control_after_generate') {
if (widget.value === true) {
widget.value = 'randomize'
} else if (widget.value === false) {
widget.value = 'fixed'
}
}
}
if (widget.type == 'combo') {
const values = widget.options.values as
| (string | number | boolean)[]
| undefined
if (canvasVisible) fitView()
} catch (error) {
useDialogService().showErrorDialog(error, {
title: t('errorDialog.loadWorkflowTitle'),
reportType: 'loadWorkflowError'
})
console.error(error)
return
}
forEachNode(this.rootGraph, (node) => {
const size = node.computeSize()
size[0] = Math.max(node.size[0], size[0])
size[1] = Math.max(node.size[1], size[1])
node.setSize(size)
if (node.widgets) {
// If you break something in the backend and want to patch workflows in the frontend
// This is the place to do this
for (let widget of node.widgets) {
if (node.type == 'KSampler' || node.type == 'KSamplerAdvanced') {
if (widget.name == 'sampler_name') {
if (
values &&
values.length > 0 &&
(widget.value == null ||
(reset_invalid_values &&
!values.includes(
widget.value as string | number | boolean
)))
typeof widget.value === 'string' &&
widget.value.startsWith('sample_')
) {
widget.value = values[0]
widget.value = widget.value.slice(7)
}
}
}
}
useExtensionService().invokeExtensions('loadedGraphNode', node)
})
await useExtensionService().invokeExtensionsAsync(
'afterConfigureGraph',
missingNodeTypes
)
const telemetryPayload = {
missing_node_count: missingNodeTypes.length,
missing_node_types: missingNodeTypes.map((node) =>
typeof node === 'string' ? node : node.type
),
open_source: openSource ?? 'unknown'
}
useTelemetry()?.trackWorkflowOpened(telemetryPayload)
useTelemetry()?.trackWorkflowImported(telemetryPayload)
await useWorkflowService().afterLoadNewGraph(
workflow,
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON
)
// If the canvas was not visible and we're a fresh load, resize the canvas and fit the view
// This fixes switching from app mode to a new graph mode workflow (e.g. load template)
if (!canvasVisible && (!workflow || typeof workflow === 'string')) {
this.canvas.resize()
requestAnimationFrame(() => fitView())
}
// Store pending warnings on the workflow for deferred display
const activeWf = useWorkspaceStore().workflow.activeWorkflow
if (activeWf) {
const warnings: PendingWarnings = {}
if (missingNodeTypes.length && showMissingNodesDialog) {
warnings.missingNodeTypes = missingNodeTypes
}
if (missingModels.length && showMissingModelsDialog) {
const paths = await api.getFolderPaths()
warnings.missingModels = { missingModels: missingModels, paths }
}
if (warnings.missingNodeTypes || warnings.missingModels) {
activeWf.pendingWarnings = warnings
if (
node.type == 'KSampler' ||
node.type == 'KSamplerAdvanced' ||
node.type == 'PrimitiveNode'
) {
if (widget.name == 'control_after_generate') {
if (widget.value === true) {
widget.value = 'randomize'
} else if (widget.value === false) {
widget.value = 'fixed'
}
}
}
if (widget.type == 'combo') {
const values = widget.options.values as
| (string | number | boolean)[]
| undefined
if (
values &&
values.length > 0 &&
(widget.value == null ||
(reset_invalid_values &&
!values.includes(widget.value as string | number | boolean)))
) {
widget.value = values[0]
}
}
}
}
if (!deferWarnings) {
useWorkflowService().showPendingWarnings()
}
useExtensionService().invokeExtensions('loadedGraphNode', node)
})
requestAnimationFrame(() => {
this.canvas.setDirty(true, true)
})
} finally {
ChangeTracker.isLoadingGraph = false
await useExtensionService().invokeExtensionsAsync(
'afterConfigureGraph',
missingNodeTypes
)
const telemetryPayload = {
missing_node_count: missingNodeTypes.length,
missing_node_types: missingNodeTypes.map((node) =>
typeof node === 'string' ? node : node.type
),
open_source: openSource ?? 'unknown'
}
useTelemetry()?.trackWorkflowOpened(telemetryPayload)
useTelemetry()?.trackWorkflowImported(telemetryPayload)
await useWorkflowService().afterLoadNewGraph(
workflow,
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON
)
// If the canvas was not visible and we're a fresh load, resize the canvas and fit the view
// This fixes switching from app mode to a new graph mode workflow (e.g. load template)
if (!canvasVisible && (!workflow || typeof workflow === 'string')) {
this.canvas.resize()
requestAnimationFrame(() => fitView())
}
// Store pending warnings on the workflow for deferred display
const activeWf = useWorkspaceStore().workflow.activeWorkflow
if (activeWf) {
const warnings: PendingWarnings = {}
if (missingNodeTypes.length && showMissingNodesDialog) {
warnings.missingNodeTypes = missingNodeTypes
}
if (missingModels.length && showMissingModelsDialog) {
const paths = await api.getFolderPaths()
warnings.missingModels = { missingModels: missingModels, paths }
}
if (warnings.missingNodeTypes || warnings.missingModels) {
activeWf.pendingWarnings = warnings
}
}
if (!deferWarnings) {
useWorkflowService().showPendingWarnings()
}
requestAnimationFrame(() => {
this.canvas.setDirty(true, true)
})
}
async graphToPrompt(graph = this.rootGraph) {

View File

@@ -28,14 +28,6 @@ logger.setLevel('info')
export class ChangeTracker {
static MAX_HISTORY = 50
/**
* Guard flag to prevent checkState from running during loadGraphData.
* Between rootGraph.configure() and afterLoadNewGraph(), the rootGraph
* contains the NEW workflow's data while activeWorkflow still points to
* the OLD workflow. Any checkState call in that window would serialize
* the wrong graph into the old workflow's activeState, corrupting it.
*/
static isLoadingGraph = false
/**
* The active state of the workflow.
*/
@@ -139,7 +131,7 @@ export class ChangeTracker {
}
checkState() {
if (!app.graph || this.changeCount || ChangeTracker.isLoadingGraph) return
if (!app.graph || this.changeCount) return
const currentState = clone(app.rootGraph.serialize()) as ComfyWorkflowJSON
if (!this.activeState) {
this.activeState = currentState

View File

@@ -9,7 +9,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { resolveNode } from '@/utils/litegraphUtil'
export function nodeTypeValidForApp(type: string) {
@@ -82,7 +81,7 @@ export const useAppModeStore = defineStore('appMode', () => {
? { inputs: selectedInputs, outputs: selectedOutputs }
: null,
(data) => {
if (!data || ChangeTracker.isLoadingGraph) return
if (!data || !workflowStore.activeWorkflow) return
const graph = app.rootGraph
if (!graph) return
const extra = (graph.extra ??= {})

View File

@@ -238,7 +238,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** Graph node IDs (as strings) that have errors in the current graph scope. */
const activeGraphErrorNodeIds = computed<Set<string>>(() => {
const ids = new Set<string>()
if (!app.isGraphReady) return ids
if (!app.rootGraph) return ids
// Fall back to rootGraph when currentGraph hasn't been initialized yet
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
@@ -287,7 +287,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
const ids = new Set<string>()
if (!app.isGraphReady) return ids
if (!app.rootGraph) return ids
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
@@ -357,7 +357,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** True if the node has errors inside it at any nesting depth. */
function isContainerWithInternalError(node: LGraphNode): boolean {
if (!app.isGraphReady) return false
if (!app.rootGraph) return false
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return false
return errorAncestorExecutionIds.value.has(execId)
@@ -365,15 +365,15 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** True if the node has a missing node inside it at any nesting depth. */
function isContainerWithMissingNode(node: LGraphNode): boolean {
if (!app.isGraphReady) return false
if (!app.rootGraph) return false
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return false
return missingAncestorExecutionIds.value.has(execId)
}
watch(lastNodeErrors, () => {
if (!app.isGraphReady) return
const rootGraph = app.rootGraph
if (!rootGraph) return
clearAllNodeErrorFlags(rootGraph)

View File

@@ -10,6 +10,7 @@ import type {
ResultItem,
ResultItemType
} from '@/schemas/apiSchema'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { clone } from '@/scripts/utils'
@@ -119,9 +120,11 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
const rand = app.getRandParam()
const previewParam = getPreviewParam(node, outputs)
const isImage = isImageOutputs(node, outputs)
return outputs.images.map((image) => {
const params = new URLSearchParams(image)
if (isImage) appendCloudResParam(params, image.filename)
return api.apiURL(`/view?${params}${previewParam}${rand}`)
})
}

View File

@@ -1,6 +1,5 @@
import _ from 'es-toolkit/compat'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { ColorOption, LGraph } from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import {
@@ -320,31 +319,6 @@ export function resolveNode(
}
return undefined
}
export function resolveNodeWidget(
nodeId: NodeId,
widgetName?: string,
graph: LGraph = app.rootGraph
): [LGraphNode, IBaseWidget] | [LGraphNode] | [] {
const node = graph.getNodeById(nodeId)
if (!widgetName) return node ? [node] : []
if (node) {
const widget = node.widgets?.find((w) => w.name === widgetName)
return widget ? [node, widget] : []
}
for (const node of graph.nodes) {
if (!node.isSubgraphNode()) continue
const widget = node.widgets?.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceWidgetName === widgetName &&
w.sourceNodeId === nodeId
)
if (widget) return [node, widget]
}
return []
}
export function isLoad3dNode(node: LGraphNode) {
return (

View File

@@ -9,7 +9,6 @@ import { computed, useTemplateRef } from 'vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
@@ -157,7 +156,6 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
</div>
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"