mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-13 09:00:16 +00:00
## Summary Add Sentry breadcrumbs to subgraph proxy widget operations for better observability of widget state changes. ## Changes - **What**: Add `Sentry.addBreadcrumb()` calls with category `'subgraph'` to `promoteWidget`, `demoteWidget`, and `pruneDisconnected` in `proxyWidgetUtils.ts` ## Review Focus Breadcrumbs are info-level and don't affect control flow. They log widget name/node ID for promote/demote and removed count for prune. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8996-chore-add-Sentry-breadcrumbs-to-subgraph-proxy-widget-operations-30d6d73d365081a5abbccabd39fc7129) by [Unito](https://www.unito.io)
324 lines
9.8 KiB
TypeScript
324 lines
9.8 KiB
TypeScript
import * as Sentry from '@sentry/vue'
|
|
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
|
import { t } from '@/i18n'
|
|
import type {
|
|
IContextMenuValue,
|
|
LGraphNode
|
|
} from '@/lib/litegraph/src/litegraph'
|
|
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
|
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
|
import {
|
|
CANVAS_IMAGE_PREVIEW_WIDGET,
|
|
supportsVirtualCanvasImagePreview
|
|
} from '@/composables/node/useNodeCanvasImagePreview'
|
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
|
import { useLitegraphService } from '@/services/litegraphService'
|
|
import { usePromotionStore } from '@/stores/promotionStore'
|
|
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
|
|
|
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
|
|
|
export type WidgetItem = [PartialNode, IBaseWidget]
|
|
export { CANVAS_IMAGE_PREVIEW_WIDGET }
|
|
|
|
export function getWidgetName(w: IBaseWidget): string {
|
|
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
|
|
}
|
|
|
|
/** Known non-$$ preview widget types added by core or popular extensions. */
|
|
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
|
|
|
|
/**
|
|
* Returns true for pseudo-widgets that display media previews and should
|
|
* be auto-promoted when their node is inside a subgraph.
|
|
* Matches the core `$$` convention as well as custom-node patterns
|
|
* (e.g. VHS `videopreview` with type `"preview"`).
|
|
*/
|
|
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
|
|
if (widget.name.startsWith('$$')) return true
|
|
// Custom nodes may set serialize on the widget or in options
|
|
if (widget.serialize !== false && widget.options?.serialize !== false)
|
|
return false
|
|
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
|
|
return true
|
|
return false
|
|
}
|
|
|
|
export function promoteWidget(
|
|
node: PartialNode,
|
|
widget: IBaseWidget,
|
|
parents: SubgraphNode[]
|
|
) {
|
|
const store = usePromotionStore()
|
|
const nodeId = String(
|
|
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
|
)
|
|
const widgetName = getWidgetName(widget)
|
|
for (const parent of parents) {
|
|
store.promote(parent.rootGraph.id, parent.id, nodeId, widgetName)
|
|
}
|
|
Sentry.addBreadcrumb({
|
|
category: 'subgraph',
|
|
message: `Promoted widget "${widgetName}" on node ${node.id}`,
|
|
level: 'info'
|
|
})
|
|
}
|
|
|
|
export function demoteWidget(
|
|
node: PartialNode,
|
|
widget: IBaseWidget,
|
|
parents: SubgraphNode[]
|
|
) {
|
|
const store = usePromotionStore()
|
|
const nodeId = String(
|
|
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
|
)
|
|
const widgetName = getWidgetName(widget)
|
|
for (const parent of parents) {
|
|
store.demote(parent.rootGraph.id, parent.id, nodeId, widgetName)
|
|
}
|
|
Sentry.addBreadcrumb({
|
|
category: 'subgraph',
|
|
message: `Demoted widget "${widgetName}" on node ${node.id}`,
|
|
level: 'info'
|
|
})
|
|
}
|
|
|
|
function getParentNodes(): SubgraphNode[] {
|
|
const { navigationStack } = useSubgraphNavigationStore()
|
|
const subgraph = navigationStack.at(-1)
|
|
if (!subgraph) {
|
|
useToastStore().add({
|
|
severity: 'error',
|
|
summary: t('g.error'),
|
|
detail: t('subgraphStore.promoteOutsideSubgraph')
|
|
})
|
|
return []
|
|
}
|
|
const parentGraph = navigationStack.at(-2) ?? subgraph.rootGraph
|
|
return parentGraph.nodes.filter(
|
|
(node): node is SubgraphNode =>
|
|
node.type === subgraph.id && node.isSubgraphNode()
|
|
)
|
|
}
|
|
|
|
export function addWidgetPromotionOptions(
|
|
options: (IContextMenuValue<unknown> | null)[],
|
|
widget: IBaseWidget,
|
|
node: LGraphNode
|
|
) {
|
|
const store = usePromotionStore()
|
|
const parents = getParentNodes()
|
|
const nodeId = String(node.id)
|
|
const widgetName = getWidgetName(widget)
|
|
const promotableParents = parents.filter(
|
|
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
|
|
)
|
|
if (promotableParents.length > 0)
|
|
options.unshift({
|
|
content: t('subgraphStore.promoteWidget', {
|
|
name: widget.label ?? widget.name
|
|
}),
|
|
callback: () => {
|
|
promoteWidget(node, widget, promotableParents)
|
|
widget.callback?.(widget.value)
|
|
}
|
|
})
|
|
else {
|
|
options.unshift({
|
|
content: t('subgraphStore.unpromoteWidget', {
|
|
name: widget.label ?? widget.name
|
|
}),
|
|
callback: () => {
|
|
demoteWidget(node, widget, parents)
|
|
widget.callback?.(widget.value)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
export function tryToggleWidgetPromotion() {
|
|
const canvas = useCanvasStore().getCanvas()
|
|
const [x, y] = canvas.graph_mouse
|
|
const node = canvas.graph?.getNodeOnPos(x, y, canvas.visible_nodes)
|
|
if (!node) return
|
|
const widget = node.getWidgetOnPos(x, y, true)
|
|
const parents = getParentNodes()
|
|
if (!parents.length || !widget) return
|
|
const store = usePromotionStore()
|
|
const nodeId = String(node.id)
|
|
const widgetName = getWidgetName(widget)
|
|
const promotableParents = parents.filter(
|
|
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
|
|
)
|
|
if (promotableParents.length > 0)
|
|
promoteWidget(node, widget, promotableParents)
|
|
else demoteWidget(node, widget, parents)
|
|
}
|
|
|
|
const recommendedNodes = [
|
|
'CLIPTextEncode',
|
|
'LoadImage',
|
|
'SaveImage',
|
|
'PreviewImage'
|
|
]
|
|
const recommendedWidgetNames = ['seed']
|
|
export function isRecommendedWidget([node, widget]: WidgetItem) {
|
|
return (
|
|
!widget.computedDisabled &&
|
|
(recommendedNodes.includes(node.type) ||
|
|
recommendedWidgetNames.includes(widget.name))
|
|
)
|
|
}
|
|
|
|
function supportsVirtualPreviewWidget(node: LGraphNode): boolean {
|
|
return supportsVirtualCanvasImagePreview(node)
|
|
}
|
|
|
|
function createVirtualCanvasImagePreviewWidget(): IBaseWidget {
|
|
return {
|
|
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
|
type: 'IMAGE_PREVIEW',
|
|
options: { serialize: false },
|
|
serialize: false,
|
|
y: 0,
|
|
computedDisabled: false
|
|
}
|
|
}
|
|
|
|
export function getPromotableWidgets(node: LGraphNode): IBaseWidget[] {
|
|
const widgets = [...(node.widgets ?? [])]
|
|
|
|
const hasCanvasPreviewWidget = widgets.some(
|
|
(widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET
|
|
)
|
|
const supportsVirtualPreview = supportsVirtualPreviewWidget(node)
|
|
if (!hasCanvasPreviewWidget && supportsVirtualPreview) {
|
|
widgets.push(createVirtualCanvasImagePreviewWidget())
|
|
}
|
|
|
|
return widgets
|
|
}
|
|
|
|
function nodeWidgets(n: LGraphNode): WidgetItem[] {
|
|
return getPromotableWidgets(n).map((w: IBaseWidget) => [n, w])
|
|
}
|
|
|
|
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
|
const store = usePromotionStore()
|
|
const { updatePreviews } = useLitegraphService()
|
|
const interiorNodes = subgraphNode.subgraph.nodes
|
|
for (const node of interiorNodes) {
|
|
node.updateComputedDisabled()
|
|
|
|
const hasPreviewWidget = () =>
|
|
node.widgets?.some(isPreviewPseudoWidget) ?? false
|
|
|
|
function promotePreviewWidget() {
|
|
const widget = node.widgets?.find(isPreviewPseudoWidget)
|
|
if (!widget) return
|
|
if (
|
|
store.isPromoted(
|
|
subgraphNode.rootGraph.id,
|
|
subgraphNode.id,
|
|
String(node.id),
|
|
widget.name
|
|
)
|
|
)
|
|
return
|
|
promoteWidget(node, widget, [subgraphNode])
|
|
}
|
|
// Promote preview widgets that already exist (e.g. custom node DOM widgets
|
|
// like VHS videopreview that are created in onNodeCreated).
|
|
promotePreviewWidget()
|
|
|
|
// If a preview widget already exists in this frame, there's nothing to
|
|
// defer. Core $$ preview widgets are the lazy path that needs updatePreviews.
|
|
if (hasPreviewWidget()) continue
|
|
|
|
// Nodes in CANVAS_IMAGE_PREVIEW_NODE_TYPES support a virtual $$
|
|
// preview widget. Eagerly promote it so getPseudoWidgetPreviewTargets
|
|
// includes this node and onDrawBackground can call updatePreviews on it
|
|
// once execution outputs arrive.
|
|
if (supportsVirtualCanvasImagePreview(node)) {
|
|
if (
|
|
!store.isPromoted(
|
|
subgraphNode.rootGraph.id,
|
|
subgraphNode.id,
|
|
String(node.id),
|
|
CANVAS_IMAGE_PREVIEW_WIDGET
|
|
)
|
|
) {
|
|
store.promote(
|
|
subgraphNode.rootGraph.id,
|
|
subgraphNode.id,
|
|
String(node.id),
|
|
CANVAS_IMAGE_PREVIEW_WIDGET
|
|
)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Also schedule a deferred check: core $$ widgets are created lazily by
|
|
// updatePreviews when node outputs are first loaded.
|
|
requestAnimationFrame(() => updatePreviews(node, promotePreviewWidget))
|
|
}
|
|
const filteredWidgets: WidgetItem[] = interiorNodes
|
|
.flatMap(nodeWidgets)
|
|
.filter(isRecommendedWidget)
|
|
for (const [n, w] of filteredWidgets) {
|
|
store.promote(
|
|
subgraphNode.rootGraph.id,
|
|
subgraphNode.id,
|
|
String(n.id),
|
|
getWidgetName(w)
|
|
)
|
|
}
|
|
subgraphNode.computeSize(subgraphNode.size)
|
|
}
|
|
|
|
export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
|
const store = usePromotionStore()
|
|
const subgraph = subgraphNode.subgraph
|
|
const entries = store.getPromotions(
|
|
subgraphNode.rootGraph.id,
|
|
subgraphNode.id
|
|
)
|
|
const removedEntries: Array<{ interiorNodeId: string; widgetName: string }> =
|
|
[]
|
|
|
|
const validEntries = entries.filter((entry) => {
|
|
const node = subgraph.getNodeById(entry.interiorNodeId)
|
|
if (!node) {
|
|
removedEntries.push(entry)
|
|
return false
|
|
}
|
|
const hasWidget = getPromotableWidgets(node).some(
|
|
(iw) => iw.name === entry.widgetName
|
|
)
|
|
if (!hasWidget) {
|
|
removedEntries.push(entry)
|
|
}
|
|
return hasWidget
|
|
})
|
|
|
|
if (removedEntries.length > 0 && import.meta.env.DEV) {
|
|
console.warn(
|
|
'[proxyWidgetUtils] Pruned disconnected promotions',
|
|
removedEntries,
|
|
{
|
|
graphId: subgraphNode.rootGraph.id,
|
|
subgraphNodeId: subgraphNode.id
|
|
}
|
|
)
|
|
}
|
|
|
|
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
|
|
Sentry.addBreadcrumb({
|
|
category: 'subgraph',
|
|
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
|
|
level: 'info'
|
|
})
|
|
}
|