Merge Phase 2 integration (5 tasks for circular dependency reduction)

Amp-Thread-ID: https://ampcode.com/threads/T-019c0094-0176-767f-9b8f-8cae71452582
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-01-27 12:28:50 -08:00
33 changed files with 548 additions and 142 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -5,9 +5,10 @@
severity="info"
icon="pi pi-user"
pt:text="w-full"
data-testid="current-user-indicator"
>
<div class="flex items-center justify-between">
<div data-testid="current-user-indicator">
<div class="tabular-nums">
{{ $t('g.currentUser') }}: {{ userStore.currentUser?.username }}
</div>
<Button

View File

@@ -1,9 +1,12 @@
import { markRaw } from 'vue'
import { defineAsyncComponent } from 'vue'
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
import { useQueueStore } from '@/stores/queueStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
const AssetsSidebarTab = defineAsyncComponent(
() => import('@/components/sidebar/tabs/AssetsSidebarTab.vue')
)
export const useAssetsSidebarTab = (): SidebarTabExtension => {
return {
id: 'assets',
@@ -11,7 +14,7 @@ export const useAssetsSidebarTab = (): SidebarTabExtension => {
title: 'sideToolbar.assets',
tooltip: 'sideToolbar.assets',
label: 'sideToolbar.labels.assets',
component: markRaw(AssetsSidebarTab),
component: AssetsSidebarTab,
type: 'vue',
iconBadge: () => {
const queueStore = useQueueStore()

View File

@@ -1,10 +1,13 @@
import { markRaw } from 'vue'
import { defineAsyncComponent } from 'vue'
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
import { isElectron } from '@/utils/envUtil'
const ModelLibrarySidebarTab = defineAsyncComponent(
() => import('@/components/sidebar/tabs/ModelLibrarySidebarTab.vue')
)
export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
return {
id: 'model-library',
@@ -12,7 +15,7 @@ export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
title: 'sideToolbar.modelLibrary',
tooltip: 'sideToolbar.modelLibrary',
label: 'sideToolbar.labels.models',
component: markRaw(ModelLibrarySidebarTab),
component: ModelLibrarySidebarTab,
type: 'vue',
iconBadge: () => {
if (isElectron()) {

View File

@@ -1,8 +1,11 @@
import { markRaw } from 'vue'
import { defineAsyncComponent } from 'vue'
import NodeLibrarySidebarTab from '@/components/sidebar/tabs/NodeLibrarySidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
const NodeLibrarySidebarTab = defineAsyncComponent(
() => import('@/components/sidebar/tabs/NodeLibrarySidebarTab.vue')
)
export const useNodeLibrarySidebarTab = (): SidebarTabExtension => {
return {
id: 'node-library',
@@ -10,7 +13,7 @@ export const useNodeLibrarySidebarTab = (): SidebarTabExtension => {
title: 'sideToolbar.nodeLibrary',
tooltip: 'sideToolbar.nodeLibrary',
label: 'sideToolbar.labels.nodes',
component: markRaw(NodeLibrarySidebarTab),
component: NodeLibrarySidebarTab,
type: 'vue'
}
}

View File

@@ -20,7 +20,7 @@ import {
} from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
import type { IComfyApp } from '@/types/appInterface'
const INLINE_INPUTS = false
@@ -69,7 +69,7 @@ function dynamicComboWidget(
node: LGraphNode,
inputName: string,
untypedInputData: InputSpec,
appArg: ComfyApp,
appArg: IComfyApp,
widgetName?: string
) {
const { addNodeInput } = useLitegraphService()

View File

@@ -187,8 +187,9 @@ export class ClipspaceDialog extends ComfyDialog {
app.registerExtension({
name: 'Comfy.Clipspace',
init(app) {
app.openClipspace = function () {
init(appArg) {
const comfyApp = appArg as ComfyApp
comfyApp.openClipspace = function () {
if (!ClipspaceDialog.instance) {
ClipspaceDialog.instance = new ClipspaceDialog()
ComfyApp.clipspace_invalidate_handler = ClipspaceDialog.invalidate
@@ -196,7 +197,7 @@ app.registerExtension({
if (ComfyApp.clipspace) {
ClipspaceDialog.instance.show()
} else app.ui.dialog.show('Clipspace is Empty!')
} else comfyApp.ui.dialog.show('Clipspace is Empty!')
}
}
})

View File

@@ -18,7 +18,7 @@ import { LGraphButton } from './LGraphButton'
import type { LGraphButtonOptions } from './LGraphButton'
import { LGraphCanvas } from './LGraphCanvas'
import { LLink } from './LLink'
import type { Reroute, RerouteId } from './Reroute'
import type { Reroute } from './Reroute'
import { getNodeInputOnPos, getNodeOutputOnPos } from './canvas/measureSlots'
import type { IDrawBoundingOptions } from './draw'
import { NullGraphError } from './infrastructure/NullGraphError'
@@ -73,6 +73,7 @@ import {
RenderShape,
TitleMode
} from './types/globalEnums'
import type { NodeId, RerouteId } from './types/ids'
import type { ISerialisedNode, SubgraphIO } from './types/serialisation'
import type {
IBaseWidget,
@@ -88,9 +89,9 @@ import { BaseWidget } from './widgets/BaseWidget'
import { toConcreteWidget } from './widgets/widgetMap'
import type { WidgetTypeMap } from './widgets/widgetMap'
// #region Types
export type { NodeId } from './types/ids'
export type NodeId = number | string
// #region Types
export type NodeProperty = string | number | boolean | object

View File

@@ -7,8 +7,8 @@ import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { LGraphNode, NodeId } from './LGraphNode'
import type { Reroute, RerouteId } from './Reroute'
import type { LGraphNode } from './LGraphNode'
import type { Reroute } from './Reroute'
import type {
CanvasColour,
INodeInputSlot,
@@ -19,11 +19,12 @@ import type {
Point,
ReadonlyLinkNetwork
} from './interfaces'
import type { LinkId, NodeId, RerouteId } from './types/ids'
import type { Serialisable, SerialisableLLink } from './types/serialisation'
const layoutMutations = useLayoutMutations()
export type { LinkId } from './types/ids'
export type LinkId = number
const layoutMutations = useLayoutMutations()
export type SerialisedLLinkArray = [
id: LinkId,

View File

@@ -2,9 +2,8 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
import { LayoutSource } from '@/renderer/core/layout/types'
import { LGraphBadge } from './LGraphBadge'
import type { LGraphNode, NodeId } from './LGraphNode'
import type { LGraphNode } from './LGraphNode'
import { LLink } from './LLink'
import type { LinkId } from './LLink'
import type {
CanvasColour,
INodeInputSlot,
@@ -17,11 +16,12 @@ import type {
ReadonlyLinkNetwork
} from './interfaces'
import { distance, isPointInRect } from './measure'
import type { LinkId, NodeId, RerouteId } from './types/ids'
import type { Serialisable, SerialisableReroute } from './types/serialisation'
const layoutMutations = useLayoutMutations()
export type { RerouteId } from './types/ids'
export type RerouteId = number
const layoutMutations = useLayoutMutations()
/** The input or output slot that an incomplete reroute link is connected to. */
export interface FloatingRerouteSlot {

View File

@@ -9,66 +9,78 @@ import type { Reroute } from './Reroute'
import type { SubgraphInput } from './subgraph/SubgraphInput'
import type { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import type { SubgraphOutputNode } from './subgraph/SubgraphOutputNode'
import type { LinkDirection } from './types/globalEnums'
import type { LinkDirection, RenderShape } from './types/globalEnums'
import type {
CanvasColour,
LinkId,
NodeId,
Point,
ReadOnlyRect,
RerouteId,
Size
} from './types/index'
import type { IBaseWidget } from './types/widgets'
// Re-export pure types from the types directory for backwards compatibility
export type {
CanvasColour,
CompassCorners,
Direction,
LinkId,
NodeId,
Point,
ReadOnlyRect,
ReadOnlyTypedArray,
Rect,
RerouteId,
Size
} from '../types/geometry'
} from './types/index'
export type { LinkId, NodeId, RerouteId } from '../types/ids'
export type Dictionary<T> = { [key: string]: T }
export type {
HasBoundingRect,
INodeFlags,
INodeSlotBase,
ISlotType,
IWidgetLocator
} from '../types/slots'
export type {
Dictionary,
MethodNames,
NullableProperties,
OptionalProps,
RequiredProps,
SharedIntersection,
WhenNullish
} from '../types/utility'
// Import types we need locally
import type { CanvasColour, Point, Size } from '../types/geometry'
import type { LinkId, NodeId, RerouteId } from '../types/ids'
import type {
HasBoundingRect,
INodeInputSlotBase,
INodeOutputSlotBase,
INodeSlotBase,
ISlotType,
IWidgetLocator
} from '../types/slots'
export interface NewNodePosition {
node: LGraphNode
newPos: {
x: number
y: number
}
/** Allows all properties to be null. The same as `Partial<T>`, but adds null instead of undefined. */
export type NullableProperties<T> = {
[P in keyof T]: T[P] | null
}
export interface IBoundaryNodes {
top: LGraphNode
right: LGraphNode
bottom: LGraphNode
left: LGraphNode
/**
* If {@link T} is `null` or `undefined`, evaluates to {@link Result}. Otherwise, evaluates to {@link T}.
* Useful for functions that return e.g. `undefined` when a param is nullish.
*/
export type WhenNullish<T, Result> =
| (T & {})
| (T extends null ? Result : T extends undefined ? Result : T & {})
/** A type with each of the {@link Properties} made optional. */
export type OptionalProps<T, Properties extends keyof T> = Omit<
T,
Properties
> & { [K in Properties]?: T[K] }
/** A type with each of the {@link Properties} marked as required. */
export type RequiredProps<T, Properties extends keyof T> = Omit<
T,
Properties
> & { [K in Properties]-?: T[K] }
/** Bitwise AND intersection of two types; returns a new, non-union type that includes only properties that exist on both types. */
export type SharedIntersection<T1, T2> = {
[P in keyof T1 as P extends keyof T2 ? P : never]: T1[P]
} & {
[P in keyof T2 as P extends keyof T1 ? P : never]: T2[P]
}
/**
* Any object that has a {@link boundingRect}.
*/
export interface HasBoundingRect {
/**
* A rectangle that represents the outer edges of the item.
*
* Used for various calculations, such as overlap, selective rendering, and click checks.
* For most items, this is cached position & size as `x, y, width, height`.
* Some items (such as nodes and slots) may extend above and/or to the left of their {@link pos}.
* @readonly
* @see {@link move}
*/
readonly boundingRect: ReadOnlyRect
}
/** An object containing a set of child objects */
@@ -233,22 +245,109 @@ export interface IFoundSlot extends IInputOrOutput {
link_pos: Point
}
/** Union of property names that are of type Match */
type KeysOfType<T, Match> = Exclude<
{ [P in keyof T]: T[P] extends Match ? P : never }[keyof T],
undefined
>
/** The names of all (optional) methods and functions in T */
export type MethodNames<T> = KeysOfType<
T,
((...args: unknown[]) => unknown) | undefined
>
export interface NewNodePosition {
node: LGraphNode
newPos: {
x: number
y: number
}
}
export interface IBoundaryNodes {
top: LGraphNode
right: LGraphNode
bottom: LGraphNode
left: LGraphNode
}
export type Direction = 'top' | 'bottom' | 'left' | 'right'
/** Resize handle positions (compass points) */
export type CompassCorners = 'NE' | 'SE' | 'SW' | 'NW'
/**
* Full slot interface with runtime-dependent properties.
* Extends the base slot from types/slots.ts with LLink reference.
* A string that represents a specific data / slot type, e.g. `STRING`.
*
* Can be comma-delimited to specify multiple allowed types, e.g. `STRING,INT`.
*/
export interface INodeSlot extends INodeSlotBase {
export type ISlotType = number | string
export interface INodeSlot extends HasBoundingRect {
/**
* The name of the slot in English.
* Will be included in the serialized data.
*/
name: string
/**
* The localized name of the slot to display in the UI.
* Takes higher priority than {@link name} if set.
* Will be included in the serialized data.
*/
localized_name?: string
/**
* The name of the slot to display in the UI, modified by the user.
* Takes higher priority than {@link display_name} if set.
* Will be included in the serialized data.
*/
label?: string
type: ISlotType
dir?: LinkDirection
removable?: boolean
shape?: RenderShape
color_off?: CanvasColour
color_on?: CanvasColour
locked?: boolean
nameLocked?: boolean
pos?: Point
/** @remarks Automatically calculated; not included in serialisation. */
boundingRect: ReadOnlyRect
/**
* A list of floating link IDs that are connected to this slot.
* This is calculated at runtime; it is **not** serialized.
*/
_floatingLinks?: Set<LLink>
/**
* Whether the slot has errors. It is **not** serialized.
*/
hasErrors?: boolean
}
export interface INodeFlags {
skip_repeated_outputs?: boolean
allow_interaction?: boolean
pinned?: boolean
collapsed?: boolean
/** Configuration setting for {@link LGraphNode.connectInputToOutput} */
keepAllLinksOnBypass?: boolean
}
/**
* Full input slot interface with runtime-dependent properties.
* A widget that is linked to a slot.
*
* This is set by the ComfyUI_frontend logic. See
* https://github.com/Comfy-Org/ComfyUI_frontend/blob/b80e0e1a3c74040f328c4e344326c969c97f67e0/src/extensions/core/widgetInputs.ts#L659
*/
export interface INodeInputSlot extends INodeInputSlotBase, INodeSlot {
export interface IWidgetLocator {
name: string
type?: string
}
export interface INodeInputSlot extends INodeSlot {
link: LinkId | null
widget?: IWidgetLocator
alwaysVisible?: boolean
/**
* Internal use only; API is not finalised and may change at any time.
*/
@@ -259,10 +358,11 @@ export interface IWidgetInputSlot extends INodeInputSlot {
widget: IWidgetLocator
}
/**
* Full output slot interface with runtime-dependent properties.
*/
export interface INodeOutputSlot extends INodeOutputSlotBase, INodeSlot {}
export interface INodeOutputSlot extends INodeSlot {
links: LinkId[] | null
_data?: unknown
slot_index?: number
}
/** Links */
export interface ConnectingLink extends IInputOrOutput {

View File

@@ -0,0 +1,29 @@
/** A point represented as `[x, y]` co-ordinates */
export type Point = [x: number, y: number]
/** A size represented as `[width, height]` */
export type Size = [width: number, height: number]
/** A rectangle starting at top-left coordinates `[x, y, width, height]` */
export type Rect =
| [x: number, y: number, width: number, height: number]
| Float64Array
/** A rectangle starting at top-left coordinates `[x, y, width, height]` that will not be modified */
export type ReadOnlyRect =
| readonly [x: number, y: number, width: number, height: number]
| ReadOnlyTypedArray<Float64Array>
export type ReadOnlyTypedArray<T extends Float64Array> = Omit<
Readonly<T>,
'fill' | 'copyWithin' | 'reverse' | 'set' | 'sort' | 'subarray'
>
/** A 2D vector as `[x, y]` */
export type Vector2 = [x: number, y: number]
/** A 4D vector as `[x, y, z, w]` */
export type Vector4 = [x: number, y: number, z: number, w: number]
/** Margin values as `[top, right, bottom, left]` */
export type Margin = [top: number, right: number, bottom: number, left: number]

View File

@@ -0,0 +1,8 @@
/** Unique identifier for a node in the graph */
export type NodeId = number | string
/** Unique identifier for a link between nodes */
export type LinkId = number
/** Unique identifier for a reroute point on a link */
export type RerouteId = number

View File

@@ -0,0 +1,14 @@
export type {
Margin,
Point,
ReadOnlyRect,
ReadOnlyTypedArray,
Rect,
Size,
Vector2,
Vector4
} from './geometry'
export type { LinkId, NodeId, RerouteId } from './ids'
export type { CanvasColour, INodeSlotBase, ISlotType } from './slots'

View File

@@ -0,0 +1,37 @@
import type { LinkDirection, RenderShape } from './globalEnums'
import type { Point, ReadOnlyRect } from './geometry'
/** Union type for slot connection types - can be a string name or a numeric type code */
export type ISlotType = string | number
/** Colour type for canvas elements */
export type CanvasColour = string | CanvasGradient | CanvasPattern
/**
* Base interface for node slots (inputs and outputs).
* Contains common properties shared between input and output slots.
*/
export interface INodeSlotBase {
/** The unique name of the slot */
name: string
/** The type of the slot, used for connection compatibility */
type: ISlotType
/** Direction of the link connection */
dir?: LinkDirection
/** Whether the slot can be removed */
removable?: boolean
/** Visual shape of the slot */
shape?: RenderShape
/** Color when disconnected */
color_off?: CanvasColour
/** Color when connected */
color_on?: CanvasColour
/** Whether the slot is locked from modifications */
locked?: boolean
/** Whether the slot name is locked from changes */
nameLocked?: boolean
/** Position of the slot relative to the node */
pos?: Point
/** Bounding rectangle of the slot for hit detection */
boundingRect: ReadOnlyRect
}

View File

@@ -0,0 +1,26 @@
import { authEventHook, userResolvedHook } from '@/stores/authEventBus'
import type { TelemetryProvider } from './types'
export function initAuthTracking(
getTelemetry: () => TelemetryProvider | null
): void {
authEventHook.on((event) => {
const telemetry = getTelemetry()
if (!telemetry) return
if (event.type === 'login' || event.type === 'register') {
telemetry.trackAuth({
method: event.method,
is_new_user: event.is_new_user
})
}
})
userResolvedHook.on((event) => {
const telemetry = getTelemetry()
if (!telemetry) return
telemetry.identify?.(event.userId)
})
}

View File

@@ -16,12 +16,13 @@
*/
import { isCloud } from '@/platform/distribution/types'
import { initAuthTracking } from './authTracking'
import { MixpanelTelemetryProvider } from './providers/cloud/MixpanelTelemetryProvider'
import type { TelemetryProvider } from './types'
import { authEventHook } from './userIdentityBus'
// Singleton instance
let _telemetryProvider: TelemetryProvider | null = null
let _authTrackingInitialized = false
/**
* Telemetry factory - conditionally creates provider based on distribution
@@ -36,12 +37,10 @@ export function useTelemetry(): TelemetryProvider | null {
if (isCloud) {
_telemetryProvider = new MixpanelTelemetryProvider()
authEventHook.on(({ method, isNewUser }) => {
_telemetryProvider?.trackAuth({
method,
is_new_user: isNewUser
})
})
if (!_authTrackingInitialized) {
initAuthTracking(() => _telemetryProvider)
_authTrackingInitialized = true
}
}
// For OSS builds, _telemetryProvider stays null
}

View File

@@ -45,7 +45,6 @@ import type {
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { TelemetryEvents } from '../../types'
import { userIdentityHook } from '../../userIdentityBus'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
@@ -119,12 +118,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
persistence: 'cookie',
loaded: () => {
this.isInitialized = true
this.flushEventQueue()
userIdentityHook.on(({ userId }) => {
if (this.mixpanel && userId) {
this.mixpanel.identify(userId)
}
})
this.flushEventQueue() // flush events that were queued while initializing
}
})
})
@@ -205,6 +199,12 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
)
}
identify(userId: string): void {
if (this.mixpanel) {
this.mixpanel.identify(userId)
}
}
trackSignupOpened(): void {
this.trackEvent(TelemetryEvents.USER_SIGN_UP_OPENED)
}

View File

@@ -272,6 +272,9 @@ export interface WorkflowCreatedMetadata {
* Core telemetry provider interface
*/
export interface TelemetryProvider {
// User identification (called by auth event hooks)
identify?(userId: string): void
// Authentication flow events
trackSignupOpened(): void
trackAuth(metadata: AuthMetadata): void

View File

@@ -11,11 +11,15 @@ vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
vi.mock('@/platform/updates/common/releaseService')
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/systemStatsStore')
vi.mock('@vueuse/core', () => ({
until: vi.fn(() => Promise.resolve()),
useStorage: vi.fn(() => ({ value: {} })),
createSharedComposable: vi.fn((fn) => fn)
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
until: vi.fn(() => Promise.resolve()),
useStorage: vi.fn(() => ({ value: {} })),
createSharedComposable: vi.fn((fn) => fn)
}
})
describe('useReleaseStore', () => {
let store: ReturnType<typeof useReleaseStore>

View File

@@ -1,10 +1,13 @@
import { markRaw } from 'vue'
import { defineAsyncComponent } from 'vue'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
const WorkflowsSidebarTab = defineAsyncComponent(
() => import('@/components/sidebar/tabs/WorkflowsSidebarTab.vue')
)
export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
@@ -23,7 +26,7 @@ export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
title: 'sideToolbar.workflows',
tooltip: 'sideToolbar.workflows',
label: 'sideToolbar.labels.workflows',
component: markRaw(WorkflowsSidebarTab),
component: WorkflowsSidebarTab,
type: 'vue'
}
}

View File

@@ -65,6 +65,7 @@ import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { IComfyApp } from '@/types/appInterface'
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import { type ExtensionManager } from '@/types/extensionTypes'
import type { NodeExecutionId } from '@/types/nodeIdentification'
@@ -127,7 +128,7 @@ type Clipspace = {
combinedIndex: number
}
export class ComfyApp {
export class ComfyApp implements IComfyApp {
/**
* List of entries to queue
*/

View File

@@ -1,4 +1,4 @@
import type { ComfyApp } from '@/scripts/app'
import type { IComfyApp } from '@/types/appInterface'
import { $el } from '../../ui'
import { ComfyButtonGroup } from '../components/buttonGroup'
@@ -13,13 +13,13 @@ export { DraggableList } from '@/scripts/ui/draggableList'
export { applyTextReplacements, addStylesheet } from '@/scripts/utils'
export class ComfyAppMenu {
app: ComfyApp
app: IComfyApp
actionsGroup: ComfyButtonGroup
settingsGroup: ComfyButtonGroup
viewGroup: ComfyButtonGroup
element: HTMLElement
constructor(app: ComfyApp) {
constructor(app: IComfyApp) {
this.app = app
// Keep the group as there are custom scripts attaching extra

View File

@@ -3,14 +3,14 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyApp } from '@/scripts/app'
import type { IComfyApp } from '@/types/appInterface'
import { ComfyDialog } from './dialog'
export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
app: ComfyApp
app: IComfyApp
constructor(app: ComfyApp) {
constructor(app: IComfyApp) {
super()
this.app = app
}

View File

@@ -24,7 +24,7 @@ import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { ComfyApp } from './app'
import type { IComfyApp } from '@/types/appInterface'
import './domWidget'
import './errorNodeWidgets'
@@ -37,7 +37,7 @@ export type ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: InputSpec,
app: ComfyApp,
app: IComfyApp,
widgetName?: string
) => { widget: IBaseWidget; minWidth?: number; minHeight?: number }

View File

@@ -1,6 +1,7 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import { isCloud, isNightly } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -13,6 +14,77 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { ComfyExtension } from '@/types/comfy'
import type { AuthUserInfo } from '@/types/authTypes'
type ExtensionModule = { default?: ComfyExtension; extension?: ComfyExtension }
const coreExtensionModules = import.meta.glob<ExtensionModule>(
'../extensions/core/*.ts',
{ eager: false }
)
async function loadCoreExtensions(
registerExtension: (ext: ComfyExtension) => void
) {
const loadPromises = Object.entries(coreExtensionModules)
.filter(([path]) => !path.endsWith('/index.ts'))
.map(async ([path, loader]) => {
try {
const mod = await loader()
const extension = mod.default ?? mod.extension
if (extension && typeof extension === 'object' && 'name' in extension) {
registerExtension(extension)
}
} catch (e) {
console.error(`Failed to load extension from ${path}:`, e)
}
})
await Promise.all(loadPromises)
if (isCloud) {
try {
await import('../extensions/core/cloudRemoteConfig')
} catch (e) {
console.error('Failed to load cloudRemoteConfig:', e)
}
try {
await import('../extensions/core/cloudBadges')
} catch (e) {
console.error('Failed to load cloudBadges:', e)
}
try {
await import('../extensions/core/cloudSessionCookie')
} catch (e) {
console.error('Failed to load cloudSessionCookie:', e)
}
if (window.__CONFIG__?.subscription_required) {
try {
await import('../extensions/core/cloudSubscription')
} catch (e) {
console.error('Failed to load cloudSubscription:', e)
}
}
}
if (isCloud || isNightly) {
try {
await import('../extensions/core/cloudFeedbackTopbarButton')
} catch (e) {
console.error('Failed to load cloudFeedbackTopbarButton:', e)
}
}
if (isNightly && !isCloud) {
try {
await import('../extensions/core/nightlyBadges')
} catch (e) {
console.error('Failed to load nightlyBadges:', e)
}
}
}
export const useExtensionService = () => {
const extensionStore = useExtensionStore()
const settingStore = useSettingStore()
@@ -33,9 +105,7 @@ export const useExtensionService = () => {
const extensions = await api.getExtensions()
// Need to load core extensions first as some custom extensions
// may depend on them.
await import('../extensions/core/index')
await loadCoreExtensions(registerExtension)
extensionStore.captureCoreExtensions()
await Promise.all(
extensions

View File

@@ -0,0 +1,16 @@
import { createEventHook } from '@vueuse/core'
import type { AuthMetadata } from '@/platform/telemetry/types'
export interface AuthEvent extends AuthMetadata {
type: 'login' | 'register' | 'logout' | 'password_reset'
}
export interface UserResolvedEvent {
userId: string
email?: string | null
displayName?: string | null
}
export const authEventHook = createEventHook<AuthEvent>()
export const userResolvedHook = createEventHook<UserResolvedEvent>()

View File

@@ -25,8 +25,8 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
import { isCloud } from '@/platform/distribution/types'
import { authEventHook } from '@/platform/telemetry/userIdentityBus'
import { useDialogService } from '@/services/dialogService'
import { authEventHook, userResolvedHook } from '@/stores/authEventBus'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import type { AuthHeader } from '@/types/authTypes'
import type { operations } from '@/types/comfyRegistryTypes'
@@ -154,7 +154,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
return
}
void useDialogService().showErrorDialog(error, {
useDialogService().showErrorDialog(error, {
title: t('errorDialog.defaultTitle'),
reportType: 'authenticationError'
})
@@ -324,9 +324,14 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
if (isCloud) {
void authEventHook.trigger({
event: 'login',
type: 'login',
method: 'email',
isNewUser: false
is_new_user: false
})
void userResolvedHook.trigger({
userId: result.user.uid,
email: result.user.email,
displayName: result.user.displayName
})
}
@@ -345,9 +350,14 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
if (isCloud) {
void authEventHook.trigger({
event: 'register',
type: 'register',
method: 'email',
isNewUser: true
is_new_user: true
})
void userResolvedHook.trigger({
userId: result.user.uid,
email: result.user.email,
displayName: result.user.displayName
})
}
@@ -364,9 +374,14 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const additionalUserInfo = getAdditionalUserInfo(result)
const isNewUser = additionalUserInfo?.isNewUser ?? false
void authEventHook.trigger({
event: isNewUser ? 'register' : 'login',
type: isNewUser ? 'register' : 'login',
method: 'google',
isNewUser
is_new_user: isNewUser
})
void userResolvedHook.trigger({
userId: result.user.uid,
email: result.user.email,
displayName: result.user.displayName
})
}
@@ -383,9 +398,14 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const additionalUserInfo = getAdditionalUserInfo(result)
const isNewUser = additionalUserInfo?.isNewUser ?? false
void authEventHook.trigger({
event: isNewUser ? 'register' : 'login',
type: isNewUser ? 'register' : 'login',
method: 'github',
isNewUser
is_new_user: isNewUser
})
void userResolvedHook.trigger({
userId: result.user.uid,
email: result.user.email,
displayName: result.user.displayName
})
}

View File

@@ -15,7 +15,7 @@ import type {
TaskOutput
} from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import type { ComfyApp } from '@/scripts/app'
import type { IComfyApp } from '@/types/appInterface'
import { useExtensionService } from '@/services/extensionService'
import { getJobDetail } from '@/services/jobOutputCache'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
@@ -408,7 +408,7 @@ export class TaskItemImpl {
return new TaskItemImpl(this.job, jobDetail.outputs)
}
public async loadWorkflow(app: ComfyApp) {
public async loadWorkflow(app: IComfyApp) {
if (!this.isHistory) {
return
}

60
src/types/appInterface.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyExtension } from '@/types/comfy'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
export interface IComfyApp {
vueAppReady: boolean
rootGraph: LGraph
canvas: LGraphCanvas
configuringGraph: boolean
nodeOutputs: Record<string, NodeExecutionOutput>
/** @deprecated storageLocation is always 'server' */
readonly storageLocation: string
/** @deprecated storage migration is no longer needed */
readonly isNewUserSession: boolean
/** @deprecated Use useExecutionStore().lastExecutionError instead */
readonly lastExecutionError: unknown
/** @deprecated Use useWidgetStore().widgets instead */
readonly widgets: Record<string, ComfyWidgetConstructor>
getPreviewFormatParam(): string
loadGraphData(
graphData?: ComfyWorkflowJSON,
clean?: boolean,
restore_view?: boolean,
workflow?: string | null | ComfyWorkflow,
options?: {
showMissingNodesDialog?: boolean
showMissingModelsDialog?: boolean
checkForRerouteMigration?: boolean
openSource?: WorkflowOpenSource
}
): Promise<void>
graphToPrompt(graph?: LGraph): Promise<{
workflow: ComfyWorkflowJSON
output: Record<string, unknown>
}>
queuePrompt(
number: number,
batchCount?: number,
queueNodeIds?: string[]
): Promise<boolean>
clean(): void
handleFile(file: File, openSource?: WorkflowOpenSource): Promise<void>
registerExtension(extension: ComfyExtension): void
registerNodeDef(nodeId: string, nodeDef: ComfyNodeDef): Promise<void>
}

View File

@@ -7,7 +7,7 @@ import type { SettingParams } from '@/platform/settings/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Keybinding } from '@/schemas/keyBindingSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyApp } from '@/scripts/app'
import type { IComfyApp } from './appInterface'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import type { ComfyCommand } from '@/stores/commandStore'
import type { AuthUserInfo } from '@/types/authTypes'
@@ -136,12 +136,12 @@ export interface ComfyExtension {
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
* @param app The ComfyUI app instance
*/
init?(app: ComfyApp): Promise<void> | void
init?(app: IComfyApp): Promise<void> | void
/**
* Allows any additional setup, called after the application is fully set up and running
* @param app The ComfyUI app instance
*/
setup?(app: ComfyApp): Promise<void> | void
setup?(app: IComfyApp): Promise<void> | void
/**
* Called before nodes are registered with the graph
* @param defs The collection of node definitions, add custom ones or edit existing ones
@@ -149,7 +149,7 @@ export interface ComfyExtension {
*/
addCustomNodeDefs?(
defs: Record<string, ComfyNodeDef>,
app: ComfyApp
app: IComfyApp
): Promise<void> | void
// TODO(huchenlei): We should deprecate the async return value of
// getCustomWidgets.
@@ -158,7 +158,7 @@ export interface ComfyExtension {
* @param app The ComfyUI app instance
* @returns An array of {[widget name]: widget data}
*/
getCustomWidgets?(app: ComfyApp): Promise<Widgets> | Widgets
getCustomWidgets?(app: IComfyApp): Promise<Widgets> | Widgets
/**
* Allows the extension to add additional commands to the selection toolbox
@@ -190,7 +190,7 @@ export interface ComfyExtension {
beforeRegisterNodeDef?(
nodeType: typeof LGraphNode,
nodeData: ComfyNodeDef,
app: ComfyApp
app: IComfyApp
): Promise<void> | void
/**
@@ -200,7 +200,7 @@ export interface ComfyExtension {
* @param defs The node definitions
* @param app The ComfyUI app instance
*/
beforeRegisterVueAppNodeDefs?(defs: ComfyNodeDef[], app: ComfyApp): void
beforeRegisterVueAppNodeDefs?(defs: ComfyNodeDef[], app: IComfyApp): void
/**
* Allows the extension to register additional nodes with LGraph after standard nodes are added.
@@ -208,7 +208,7 @@ export interface ComfyExtension {
*
* @param app The ComfyUI app instance
*/
registerCustomNodes?(app: ComfyApp): Promise<void> | void
registerCustomNodes?(app: IComfyApp): Promise<void> | void
/**
* Allows the extension to modify a node that has been reloaded onto the graph.
* If you break something in the backend and want to patch workflows in the frontend
@@ -216,13 +216,13 @@ export interface ComfyExtension {
* @param node The node that has been loaded
* @param app The ComfyUI app instance
*/
loadedGraphNode?(node: LGraphNode, app: ComfyApp): void
loadedGraphNode?(node: LGraphNode, app: IComfyApp): void
/**
* Allows the extension to run code after the constructor of the node
* @param node The node that has been created
* @param app The ComfyUI app instance
*/
nodeCreated?(node: LGraphNode, app: ComfyApp): void
nodeCreated?(node: LGraphNode, app: IComfyApp): void
/**
* Allows the extension to modify the graph data before it is configured.
@@ -247,7 +247,7 @@ export interface ComfyExtension {
* Extensions can register at any time and will receive the latest value immediately.
* This is an experimental API and may be changed or removed in the future.
*/
onAuthUserResolved?(user: AuthUserInfo, app: ComfyApp): Promise<void> | void
onAuthUserResolved?(user: AuthUserInfo, app: IComfyApp): Promise<void> | void
/**
* Fired whenever the auth token is refreshed.

View File

@@ -13,7 +13,7 @@ import type {
UserData,
UserDataFullInfo
} from '@/schemas/apiSchema'
import type { ComfyApp } from '@/scripts/app'
import type { IComfyApp } from './appInterface'
import type {
BottomPanelExtension,
@@ -27,6 +27,7 @@ import type {
export type { ComfyExtension } from './comfy'
export type { ComfyApi } from '@/scripts/api'
export type { ComfyApp } from '@/scripts/app'
export type { IComfyApp } from './appInterface'
export type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
export type { InputSpec } from '@/schemas/nodeDefSchema'
export type {
@@ -78,7 +79,7 @@ interface AppReadiness {
declare global {
interface Window {
/** For use by extensions and in the browser console. Where possible, import `app` from '@/scripts/app' instead. */
app?: ComfyApp
app?: IComfyApp
/** For use by extensions and in the browser console. Where possible, import `app` and access via `app.graph` instead. */
graph?: unknown

View File

@@ -46,9 +46,11 @@ vi.mock('@/stores/workspace/colorPaletteStore', () => ({
}))
}))
vi.mock('@vueuse/core', async () => {
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal()
const { ref } = await import('vue')
return {
...(actual as object),
whenever: vi.fn(),
useStorage: vi.fn((_key, defaultValue) => {
return ref(defaultValue)