mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-11 00:38:37 +00:00
Compare commits
1 Commits
fix/manage
...
uy/node-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e424d7a16 |
188
src/components/searchbox/LinkReleaseContextMenu.vue
Normal file
188
src/components/searchbox/LinkReleaseContextMenu.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<ContextMenu
|
||||
ref="menu"
|
||||
:model="items"
|
||||
class="max-h-[80vh] overflow-y-auto md:max-h-none md:overflow-y-visible"
|
||||
:pt="{
|
||||
item: ({ context }) =>
|
||||
context.item.isSearch || context.item.isGroupLabel
|
||||
? { class: searchItemSurfaceClass }
|
||||
: undefined,
|
||||
itemContent: ({ context }) =>
|
||||
context.item.isSearch || context.item.isGroupLabel
|
||||
? { class: searchItemSurfaceClass }
|
||||
: undefined
|
||||
}"
|
||||
@hide="onHide"
|
||||
>
|
||||
<template #item="{ item, props, hasSubmenu }">
|
||||
<span
|
||||
v-if="item.isHeader"
|
||||
class="block truncate px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.isGroupLabel"
|
||||
class="block truncate px-3 pt-1 pb-0.5 text-xs font-medium text-muted-foreground uppercase"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<div
|
||||
v-else-if="item.isSearch"
|
||||
class="px-1 py-1.5"
|
||||
@click.stop
|
||||
@keydown.capture="onSearchKeydown"
|
||||
>
|
||||
<SearchInput
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
size="md"
|
||||
:placeholder="t('contextMenu.Search')"
|
||||
:debounce-time="0"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
v-else
|
||||
v-bind="props.action"
|
||||
class="flex items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<i v-if="item.icon" :class="cn(item.icon, 'size-4')" />
|
||||
<span class="flex-1 truncate">{{ item.label }}</span>
|
||||
<i
|
||||
v-if="hasSubmenu"
|
||||
class="icon-[lucide--chevron-right] size-4 opacity-60"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import { buildLinkReleaseMenuItems } from './linkReleaseMenuModel'
|
||||
import type { LinkReleaseContext } from './linkReleaseMenuModel'
|
||||
|
||||
const { context } = defineProps<{ context: LinkReleaseContext | null }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectNode: [nodeDef: ComfyNodeDefImpl]
|
||||
addReroute: []
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const menu = ref<InstanceType<typeof ContextMenu>>()
|
||||
const searchInput = ref<InstanceType<typeof SearchInput>>()
|
||||
const query = ref('')
|
||||
let actionTaken = false
|
||||
|
||||
const searchItemSurfaceClass =
|
||||
'bg-interface-menu-surface hover:bg-interface-menu-surface focus:bg-interface-menu-surface data-[p-focused=true]:bg-interface-menu-surface'
|
||||
|
||||
const defaultNodeDefs = computed<ComfyNodeDefImpl[]>(() => {
|
||||
if (!context?.dataType) return []
|
||||
const table = context.isFromOutput
|
||||
? LiteGraph.slot_types_default_out
|
||||
: LiteGraph.slot_types_default_in
|
||||
const types = table?.[context.dataType] ?? []
|
||||
return types
|
||||
.map((type) => nodeDefStore.allNodeDefsByName[type])
|
||||
.filter((nodeDef): nodeDef is ComfyNodeDefImpl => Boolean(nodeDef))
|
||||
})
|
||||
|
||||
const typeFilter = computed(() => {
|
||||
if (!context) return null
|
||||
const svc = nodeDefStore.nodeSearchService
|
||||
return {
|
||||
filterDef: context.isFromOutput
|
||||
? svc.inputTypeFilter
|
||||
: svc.outputTypeFilter,
|
||||
value: context.dataType
|
||||
}
|
||||
})
|
||||
|
||||
const compatibleNodes = computed<ComfyNodeDefImpl[]>(() => {
|
||||
if (!typeFilter.value) return []
|
||||
return nodeDefStore.nodeSearchService.searchNode('', [typeFilter.value], {
|
||||
limit: 500
|
||||
})
|
||||
})
|
||||
|
||||
const searchResults = computed<ComfyNodeDefImpl[]>(() => {
|
||||
const q = query.value.trim()
|
||||
if (!q || !typeFilter.value) return []
|
||||
return nodeDefStore.nodeSearchService.searchNode(q, [typeFilter.value], {
|
||||
limit: 20
|
||||
})
|
||||
})
|
||||
|
||||
function selectNode(nodeDef: ComfyNodeDefImpl) {
|
||||
actionTaken = true
|
||||
emit('selectNode', nodeDef)
|
||||
hide()
|
||||
}
|
||||
|
||||
function addReroute() {
|
||||
actionTaken = true
|
||||
emit('addReroute')
|
||||
hide()
|
||||
}
|
||||
|
||||
const items = computed<MenuItem[]>(() =>
|
||||
context
|
||||
? buildLinkReleaseMenuItems({
|
||||
context,
|
||||
compatibleNodes: compatibleNodes.value,
|
||||
defaultNodeDefs: defaultNodeDefs.value,
|
||||
query: query.value,
|
||||
searchResults: searchResults.value,
|
||||
t,
|
||||
handlers: { selectNode, addReroute }
|
||||
})
|
||||
: []
|
||||
)
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Enter') {
|
||||
const first = searchResults.value[0]
|
||||
if (first) selectNode(first)
|
||||
} else if (event.key === 'Escape') {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
function show(event: MouseEvent) {
|
||||
actionTaken = false
|
||||
query.value = ''
|
||||
menu.value?.show(event)
|
||||
requestAnimationFrame(() => searchInput.value?.focus())
|
||||
}
|
||||
|
||||
function hide() {
|
||||
menu.value?.hide()
|
||||
}
|
||||
|
||||
function onHide() {
|
||||
if (actionTaken) {
|
||||
actionTaken = false
|
||||
return
|
||||
}
|
||||
emit('dismiss')
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -52,6 +52,13 @@
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
<LinkReleaseContextMenu
|
||||
ref="linkReleaseMenu"
|
||||
:context="linkReleaseContext"
|
||||
@select-node="connectNodeFromMenu"
|
||||
@add-reroute="addRerouteFromMenu"
|
||||
@dismiss="reset"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -63,7 +70,11 @@ import { computed, ref, toRaw, watch, watchEffect } from 'vue'
|
||||
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
isNodeSlot
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
@@ -81,11 +92,12 @@ import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
|
||||
import LinkReleaseContextMenu from './LinkReleaseContextMenu.vue'
|
||||
import type { LinkReleaseContext } from './linkReleaseMenuModel'
|
||||
import NodeSearchContent from './v2/NodeSearchContent.vue'
|
||||
import NodeSearchBox from './NodeSearchBox.vue'
|
||||
|
||||
let triggerEvent: CanvasPointerEvent | null = null
|
||||
let listenerController: AbortController | null = null
|
||||
let disconnectOnReset = false
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
@@ -108,6 +120,8 @@ const enableNodePreview = computed(
|
||||
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
|
||||
)
|
||||
const defaultRootFilter = ref<RootCategoryId | null>(null)
|
||||
const linkReleaseMenu = ref<InstanceType<typeof LinkReleaseContextMenu>>()
|
||||
const linkReleaseContext = ref<LinkReleaseContext | null>(null)
|
||||
watch(visible, (isVisible) => {
|
||||
if (!isVisible) return
|
||||
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
|
||||
@@ -139,13 +153,16 @@ function closeDialog() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
function connectNewNode(
|
||||
nodeDef: ComfyNodeDefImpl,
|
||||
options: { ghost?: boolean; dragEvent?: MouseEvent } = {}
|
||||
) {
|
||||
const { ghost = false, dragEvent } = options
|
||||
const node = withNodeAddSource('search_modal', () =>
|
||||
litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
{ ghost, dragEvent }
|
||||
)
|
||||
)
|
||||
if (!node) return
|
||||
@@ -160,6 +177,14 @@ function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
connectNewNode(nodeDef, {
|
||||
ghost: useSearchBoxV2.value && followCursor,
|
||||
dragEvent
|
||||
})
|
||||
window.requestAnimationFrame(closeDialog)
|
||||
}
|
||||
|
||||
@@ -212,62 +237,38 @@ function showContextMenu(e: CanvasPointerEvent) {
|
||||
const firstLink = getFirstLink()
|
||||
if (!firstLink) return
|
||||
|
||||
const { node, fromSlot, toType } = firstLink
|
||||
const commonOptions = {
|
||||
e,
|
||||
allow_searchbox: true,
|
||||
showSearchBox: () => {
|
||||
cancelResetOnContextClose()
|
||||
showSearchBox(e)
|
||||
}
|
||||
const { fromSlot, toType } = firstLink
|
||||
linkReleaseContext.value = {
|
||||
dataType: fromSlot.type?.toString() ?? '',
|
||||
slotName: fromSlot.name ?? '',
|
||||
isFromOutput: toType === 'input'
|
||||
}
|
||||
const afterRerouteId = firstLink.fromReroute?.id
|
||||
const connectionOptions =
|
||||
toType === 'input'
|
||||
? { nodeFrom: node, slotFrom: fromSlot, afterRerouteId }
|
||||
: { nodeTo: node, slotTo: fromSlot, afterRerouteId }
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const menu = canvas.showConnectionMenu({
|
||||
...connectionOptions,
|
||||
...commonOptions
|
||||
})
|
||||
|
||||
if (!menu) {
|
||||
console.warn('No menu was returned from showConnectionMenu')
|
||||
return
|
||||
}
|
||||
|
||||
triggerEvent = e
|
||||
listenerController = new AbortController()
|
||||
const { signal } = listenerController
|
||||
const options = { once: true, signal }
|
||||
linkReleaseMenu.value?.show(e)
|
||||
}
|
||||
|
||||
// Connect the node after it is created via context menu
|
||||
useEventListener(
|
||||
canvas.canvas,
|
||||
'connect-new-default-node',
|
||||
(createEvent) => {
|
||||
if (!(createEvent instanceof CustomEvent))
|
||||
throw new Error('Invalid event')
|
||||
function connectNodeFromMenu(nodeDef: ComfyNodeDefImpl) {
|
||||
connectNewNode(nodeDef)
|
||||
reset()
|
||||
}
|
||||
|
||||
const node: unknown = createEvent.detail?.node
|
||||
if (!(node instanceof LGraphNode)) throw new Error('Invalid node')
|
||||
|
||||
disconnectOnReset = false
|
||||
createEvent.preventDefault()
|
||||
canvas.linkConnector.connectToNode(node, e)
|
||||
},
|
||||
options
|
||||
)
|
||||
|
||||
// Reset when the context menu is closed
|
||||
const cancelResetOnContextClose = useEventListener(
|
||||
menu.controller.signal,
|
||||
'abort',
|
||||
reset,
|
||||
options
|
||||
)
|
||||
function addRerouteFromMenu() {
|
||||
const firstLink = getFirstLink()
|
||||
const node = firstLink?.node
|
||||
if (
|
||||
firstLink &&
|
||||
triggerEvent &&
|
||||
node instanceof LGraphNode &&
|
||||
isNodeSlot(firstLink.fromSlot)
|
||||
) {
|
||||
node.connectFloatingReroute(
|
||||
[triggerEvent.canvasX, triggerEvent.canvasY],
|
||||
firstLink.fromSlot,
|
||||
firstLink.fromReroute?.id
|
||||
)
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
reset()
|
||||
}
|
||||
|
||||
// Disable litegraph's default behavior of release link and search box.
|
||||
@@ -343,8 +344,6 @@ function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
|
||||
|
||||
// Resets litegraph state
|
||||
function reset() {
|
||||
listenerController?.abort()
|
||||
listenerController = null
|
||||
triggerEvent = null
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
|
||||
258
src/components/searchbox/linkReleaseMenuModel.test.ts
Normal file
258
src/components/searchbox/linkReleaseMenuModel.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
import {
|
||||
buildLinkReleaseMenuItems,
|
||||
getLinkReleaseHeaderLabel
|
||||
} from './linkReleaseMenuModel'
|
||||
import type {
|
||||
LinkReleaseContext,
|
||||
LinkReleaseMenuHandlers
|
||||
} from './linkReleaseMenuModel'
|
||||
|
||||
function coreNode(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return {
|
||||
name,
|
||||
display_name,
|
||||
nodeSource: { type: NodeSourceType.Core },
|
||||
api_node: false
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
function customNode(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return {
|
||||
name,
|
||||
display_name,
|
||||
nodeSource: { type: NodeSourceType.CustomNodes },
|
||||
api_node: false
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
function partnerNode(name: string, display_name = name): ComfyNodeDefImpl {
|
||||
return {
|
||||
name,
|
||||
display_name,
|
||||
nodeSource: { type: NodeSourceType.Core },
|
||||
api_node: true
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
const ksampler = coreNode('KSampler')
|
||||
const vaeDecode = coreNode('VAEDecode', 'VAE Decode')
|
||||
const rerouteNode = coreNode('Reroute')
|
||||
|
||||
function createContext(
|
||||
overrides: Partial<LinkReleaseContext> = {}
|
||||
): LinkReleaseContext {
|
||||
return {
|
||||
dataType: 'MODEL',
|
||||
slotName: 'model',
|
||||
isFromOutput: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createHandlers(): LinkReleaseMenuHandlers {
|
||||
return {
|
||||
selectNode: vi.fn(),
|
||||
addReroute: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const identityT = (key: string) => key
|
||||
|
||||
const labelOf = (item: MenuItem) => item.label
|
||||
|
||||
describe('getLinkReleaseHeaderLabel', () => {
|
||||
it('combines slot name and data type', () => {
|
||||
const label = getLinkReleaseHeaderLabel(
|
||||
createContext({ slotName: 'model', dataType: 'MODEL' })
|
||||
)
|
||||
expect(label).toBe('model | MODEL')
|
||||
})
|
||||
|
||||
it('falls back to whichever value is present', () => {
|
||||
const onlyType = getLinkReleaseHeaderLabel(
|
||||
createContext({ slotName: '', dataType: 'IMAGE' })
|
||||
)
|
||||
const onlyName = getLinkReleaseHeaderLabel(
|
||||
createContext({ slotName: 'clip', dataType: '' })
|
||||
)
|
||||
expect(onlyType).toBe('IMAGE')
|
||||
expect(onlyName).toBe('clip')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildLinkReleaseMenuItems', () => {
|
||||
function build(
|
||||
options: {
|
||||
context?: LinkReleaseContext
|
||||
compatibleNodes?: ComfyNodeDefImpl[]
|
||||
defaultNodeDefs?: ComfyNodeDefImpl[]
|
||||
query?: string
|
||||
searchResults?: ComfyNodeDefImpl[]
|
||||
handlers?: LinkReleaseMenuHandlers
|
||||
} = {}
|
||||
) {
|
||||
const handlers = options.handlers ?? createHandlers()
|
||||
const items = buildLinkReleaseMenuItems({
|
||||
context: options.context ?? createContext(),
|
||||
compatibleNodes: options.compatibleNodes ?? [],
|
||||
defaultNodeDefs: options.defaultNodeDefs ?? [],
|
||||
query: options.query ?? '',
|
||||
searchResults: options.searchResults ?? [],
|
||||
t: identityT,
|
||||
handlers
|
||||
})
|
||||
return { items, handlers }
|
||||
}
|
||||
|
||||
it('renders a disabled slot-type header as the first entry', () => {
|
||||
const { items } = build()
|
||||
expect(items[0]).toMatchObject({
|
||||
label: 'model | MODEL',
|
||||
isHeader: true,
|
||||
disabled: true
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a separator then search field after the header', () => {
|
||||
const { items } = build()
|
||||
expect(items[1]).toMatchObject({ separator: true })
|
||||
expect(items[2]).toMatchObject({ isSearch: true })
|
||||
expect(items[3]).toMatchObject({ separator: true })
|
||||
})
|
||||
|
||||
it('always has Add Reroute as the last item', () => {
|
||||
const { items } = build()
|
||||
expect(items.at(-1)?.label).toBe('contextMenu.Add Reroute')
|
||||
})
|
||||
|
||||
it('Add Reroute remains last when query is non-empty', () => {
|
||||
const { items } = build({ query: 'ksampler', searchResults: [ksampler] })
|
||||
expect(items.at(-1)?.label).toBe('contextMenu.Add Reroute')
|
||||
})
|
||||
|
||||
it('groups Reroute node def immediately before Add Reroute', () => {
|
||||
const { items, handlers } = build({
|
||||
defaultNodeDefs: [vaeDecode, rerouteNode]
|
||||
})
|
||||
expect(items.at(-1)?.label).toBe('contextMenu.Add Reroute')
|
||||
expect(items.at(-2)?.label).toBe('Reroute')
|
||||
expect(items.at(-3)?.separator).toBe(true)
|
||||
|
||||
items.at(-2)?.command?.({} as never)
|
||||
expect(handlers.selectNode).toHaveBeenCalledWith(rerouteNode)
|
||||
})
|
||||
|
||||
it('excludes Reroute node def from the suggestions section', () => {
|
||||
const { items } = build({ defaultNodeDefs: [rerouteNode, vaeDecode] })
|
||||
const addRerouteIdx = items.findIndex(
|
||||
(i) => i.label === 'contextMenu.Add Reroute'
|
||||
)
|
||||
const rerouteNodeIdx = items.findIndex((i) => i.label === 'Reroute')
|
||||
expect(rerouteNodeIdx).toBeGreaterThan(0)
|
||||
expect(rerouteNodeIdx).toBeLessThan(addRerouteIdx)
|
||||
expect(items.at(-2)?.label).toBe('Reroute')
|
||||
})
|
||||
|
||||
it('groups compatible nodes by source under Comfy Nodes, Extensions, Partner Nodes', () => {
|
||||
const ext = customNode('ExtNode', 'Ext Node')
|
||||
const partner = partnerNode('PartnerNode', 'Partner Node')
|
||||
|
||||
const { items, handlers } = build({
|
||||
compatibleNodes: [ksampler, ext, partner]
|
||||
})
|
||||
|
||||
const comfyGroup = items.find((i) => i.label === 'contextMenu.Comfy Nodes')
|
||||
const extGroup = items.find((i) => i.label === 'contextMenu.Extensions')
|
||||
const partnerGroup = items.find(
|
||||
(i) => i.label === 'contextMenu.Partner Nodes'
|
||||
)
|
||||
|
||||
expect(comfyGroup?.items?.map((i) => i.label)).toContain('KSampler')
|
||||
expect(extGroup?.items?.map((i) => i.label)).toContain('Ext Node')
|
||||
expect(partnerGroup?.items?.map((i) => i.label)).toContain('Partner Node')
|
||||
|
||||
comfyGroup?.items
|
||||
?.find((i) => i.label === 'KSampler')
|
||||
?.command?.({} as never)
|
||||
expect(handlers.selectNode).toHaveBeenCalledWith(ksampler)
|
||||
})
|
||||
|
||||
it('omits empty source groups', () => {
|
||||
const { items } = build({ compatibleNodes: [ksampler] })
|
||||
const labels = items.map(labelOf)
|
||||
expect(labels).toContain('contextMenu.Comfy Nodes')
|
||||
expect(labels).not.toContain('contextMenu.Extensions')
|
||||
expect(labels).not.toContain('contextMenu.Partner Nodes')
|
||||
})
|
||||
|
||||
it('sorts nodes alphabetically within each group', () => {
|
||||
const nodeB = coreNode('B')
|
||||
const nodeA = coreNode('A')
|
||||
const { items } = build({ compatibleNodes: [nodeB, nodeA] })
|
||||
const comfyGroup = items.find((i) => i.label === 'contextMenu.Comfy Nodes')
|
||||
expect(comfyGroup?.items?.[0]?.label).toBe('A')
|
||||
expect(comfyGroup?.items?.[1]?.label).toBe('B')
|
||||
})
|
||||
|
||||
it('wires Add Reroute to its handler', () => {
|
||||
const { items, handlers } = build()
|
||||
items
|
||||
.find((i) => i.label === 'contextMenu.Add Reroute')
|
||||
?.command?.({} as never)
|
||||
expect(handlers.addReroute).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lists suggestions before compatible node groups', () => {
|
||||
const { items, handlers } = build({
|
||||
defaultNodeDefs: [vaeDecode],
|
||||
compatibleNodes: [ksampler]
|
||||
})
|
||||
const suggestionIdx = items.findIndex((i) => i.label === 'VAE Decode')
|
||||
const comfyGroupIdx = items.findIndex(
|
||||
(i) => i.label === 'contextMenu.Comfy Nodes'
|
||||
)
|
||||
const rerouteIdx = items.findIndex(
|
||||
(i) => i.label === 'contextMenu.Add Reroute'
|
||||
)
|
||||
|
||||
expect(suggestionIdx).toBeGreaterThan(0)
|
||||
expect(suggestionIdx).toBeLessThan(comfyGroupIdx)
|
||||
expect(comfyGroupIdx).toBeLessThan(rerouteIdx)
|
||||
|
||||
items[suggestionIdx].command?.({} as never)
|
||||
expect(handlers.selectNode).toHaveBeenCalledWith(vaeDecode)
|
||||
})
|
||||
|
||||
it('has 3 separators with no compatible nodes, 4 with', () => {
|
||||
const { items: noCompat } = build({ compatibleNodes: [] })
|
||||
expect(noCompat.filter((i) => i.separator).length).toBe(3)
|
||||
|
||||
const { items: withCompat } = build({ compatibleNodes: [ksampler] })
|
||||
expect(withCompat.filter((i) => i.separator).length).toBe(4)
|
||||
})
|
||||
|
||||
it('shows search results when query is non-empty', () => {
|
||||
const { items, handlers } = build({
|
||||
query: 'ksampler',
|
||||
searchResults: [ksampler]
|
||||
})
|
||||
const labels = items.map(labelOf)
|
||||
expect(labels).not.toContain('contextMenu.Comfy Nodes')
|
||||
expect(labels).toContain('KSampler')
|
||||
|
||||
items.find((i) => i.label === 'KSampler')?.command?.({} as never)
|
||||
expect(handlers.selectNode).toHaveBeenCalledWith(ksampler)
|
||||
})
|
||||
|
||||
it('shows a disabled no-results row when query has no matches', () => {
|
||||
const { items } = build({ query: 'nonexistent', searchResults: [] })
|
||||
const noResults = items.find((i) => i.label === 'g.noResults')
|
||||
expect(noResults).toMatchObject({ disabled: true })
|
||||
})
|
||||
})
|
||||
208
src/components/searchbox/linkReleaseMenuModel.ts
Normal file
208
src/components/searchbox/linkReleaseMenuModel.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
export interface LinkReleaseContext {
|
||||
/** The data type of the slot the link was dragged from (e.g. "MODEL"). */
|
||||
dataType: string
|
||||
/** The name of the slot the link was dragged from (e.g. "model"). */
|
||||
slotName: string
|
||||
/**
|
||||
* Whether the released link originates from an output slot, meaning the new
|
||||
* node will be connected to via one of its inputs.
|
||||
*/
|
||||
isFromOutput: boolean
|
||||
}
|
||||
|
||||
declare module 'primevue/menuitem' {
|
||||
interface MenuItem {
|
||||
isHeader?: boolean
|
||||
isSearch?: boolean
|
||||
isGroupLabel?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface LinkReleaseMenuHandlers {
|
||||
selectNode: (nodeDef: ComfyNodeDefImpl) => void
|
||||
addReroute: () => void
|
||||
}
|
||||
|
||||
export interface LinkReleaseMenuModelOptions {
|
||||
context: LinkReleaseContext
|
||||
/** All nodes compatible with the slot type, for grouping into source buckets. */
|
||||
compatibleNodes: ComfyNodeDefImpl[]
|
||||
/** Quick-add node suggestions for the released slot type. */
|
||||
defaultNodeDefs: ComfyNodeDefImpl[]
|
||||
/** Current search field value. */
|
||||
query: string
|
||||
/** Slot-type-filtered search results when query is non-empty. */
|
||||
searchResults: ComfyNodeDefImpl[]
|
||||
t: (key: string) => string
|
||||
handlers: LinkReleaseMenuHandlers
|
||||
}
|
||||
|
||||
export function getLinkReleaseHeaderLabel(context: LinkReleaseContext): string {
|
||||
const { slotName, dataType } = context
|
||||
if (slotName && dataType) return `${slotName} | ${dataType}`
|
||||
return slotName || dataType
|
||||
}
|
||||
|
||||
function classifyNodes(nodes: ComfyNodeDefImpl[]): {
|
||||
comfy: ComfyNodeDefImpl[]
|
||||
extensions: ComfyNodeDefImpl[]
|
||||
partner: ComfyNodeDefImpl[]
|
||||
} {
|
||||
const comfy: ComfyNodeDefImpl[] = []
|
||||
const extensions: ComfyNodeDefImpl[] = []
|
||||
const partner: ComfyNodeDefImpl[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.api_node || node.category?.startsWith('api node')) {
|
||||
partner.push(node)
|
||||
} else if (
|
||||
node.nodeSource.type === NodeSourceType.Core ||
|
||||
node.nodeSource.type === NodeSourceType.Essentials
|
||||
) {
|
||||
comfy.push(node)
|
||||
} else {
|
||||
extensions.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
return { comfy, extensions, partner }
|
||||
}
|
||||
|
||||
function toNodeGroupItem(
|
||||
label: string,
|
||||
nodes: ComfyNodeDefImpl[],
|
||||
selectNode: (nodeDef: ComfyNodeDefImpl) => void
|
||||
): MenuItem | null {
|
||||
if (!nodes.length) return null
|
||||
const sorted = [...nodes].sort((a, b) =>
|
||||
a.display_name.localeCompare(b.display_name)
|
||||
)
|
||||
return {
|
||||
label,
|
||||
items: sorted.map((nodeDef) => ({
|
||||
label: nodeDef.display_name,
|
||||
command: () => selectNode(nodeDef)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function buildAddRerouteItem(
|
||||
t: (key: string) => string,
|
||||
handlers: LinkReleaseMenuHandlers
|
||||
): MenuItem {
|
||||
return {
|
||||
label: t('contextMenu.Add Reroute'),
|
||||
icon: 'icon-[lucide--git-fork]',
|
||||
command: handlers.addReroute
|
||||
}
|
||||
}
|
||||
|
||||
function buildDefaultMenuItems(
|
||||
suggestions: ComfyNodeDefImpl[],
|
||||
compatibleNodes: ComfyNodeDefImpl[],
|
||||
t: (key: string) => string,
|
||||
handlers: LinkReleaseMenuHandlers
|
||||
): MenuItem[] {
|
||||
const items: MenuItem[] = []
|
||||
|
||||
if (suggestions.length) {
|
||||
items.push({ label: t('contextMenu.Most Relevant'), isGroupLabel: true })
|
||||
for (const nodeDef of suggestions) {
|
||||
items.push({
|
||||
label: nodeDef.display_name,
|
||||
command: () => handlers.selectNode(nodeDef)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { comfy, extensions, partner } = classifyNodes(compatibleNodes)
|
||||
const groups = [
|
||||
toNodeGroupItem(t('contextMenu.Comfy Nodes'), comfy, handlers.selectNode),
|
||||
toNodeGroupItem(
|
||||
t('contextMenu.Extensions'),
|
||||
extensions,
|
||||
handlers.selectNode
|
||||
),
|
||||
toNodeGroupItem(
|
||||
t('contextMenu.Partner Nodes'),
|
||||
partner,
|
||||
handlers.selectNode
|
||||
)
|
||||
].filter((g): g is MenuItem => g !== null)
|
||||
|
||||
if (groups.length) {
|
||||
items.push({ separator: true }, ...groups)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function buildSearchResultItems(
|
||||
searchResults: ComfyNodeDefImpl[],
|
||||
noResultsLabel: string,
|
||||
selectNode: (nodeDef: ComfyNodeDefImpl) => void
|
||||
): MenuItem[] {
|
||||
if (!searchResults.length) {
|
||||
return [{ label: noResultsLabel, disabled: true }]
|
||||
}
|
||||
return searchResults.map((nodeDef) => ({
|
||||
label: nodeDef.display_name,
|
||||
command: () => selectNode(nodeDef)
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildLinkReleaseMenuItems({
|
||||
context,
|
||||
compatibleNodes,
|
||||
defaultNodeDefs,
|
||||
query,
|
||||
searchResults,
|
||||
t,
|
||||
handlers
|
||||
}: LinkReleaseMenuModelOptions): MenuItem[] {
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
const rerouteDef = defaultNodeDefs.find((d) => d.name === 'Reroute')
|
||||
const suggestions = defaultNodeDefs.filter((d) => d.name !== 'Reroute')
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: getLinkReleaseHeaderLabel(context),
|
||||
isHeader: true,
|
||||
disabled: true
|
||||
},
|
||||
{ separator: true },
|
||||
{ isSearch: true },
|
||||
{ separator: true }
|
||||
]
|
||||
|
||||
if (trimmedQuery) {
|
||||
items.push(
|
||||
...buildSearchResultItems(
|
||||
searchResults,
|
||||
t('g.noResults'),
|
||||
handlers.selectNode
|
||||
)
|
||||
)
|
||||
} else {
|
||||
items.push(
|
||||
...buildDefaultMenuItems(suggestions, compatibleNodes, t, handlers)
|
||||
)
|
||||
}
|
||||
|
||||
items.push({ separator: true })
|
||||
if (rerouteDef) {
|
||||
items.push({
|
||||
label: rerouteDef.display_name,
|
||||
command: () => handlers.selectNode(rerouteDef)
|
||||
})
|
||||
}
|
||||
items.push(buildAddRerouteItem(t, handlers))
|
||||
|
||||
return items
|
||||
}
|
||||
@@ -170,6 +170,7 @@ export type { TWidgetType, TWidgetValue, IWidgetOptions } from './types/widgets'
|
||||
export {
|
||||
findUsedSubgraphIds,
|
||||
getDirectSubgraphIds,
|
||||
isNodeSlot,
|
||||
isSubgraphInput,
|
||||
isSubgraphOutput
|
||||
} from './subgraph/subgraphUtils'
|
||||
|
||||
@@ -453,7 +453,6 @@
|
||||
"totalNodes": "Total Nodes",
|
||||
"discoverCommunityContent": "Discover community-made Node Packs, Extensions, and more...",
|
||||
"errorConnecting": "Error connecting to the Comfy Node Registry.",
|
||||
"retry": "Try Again",
|
||||
"noResultsFound": "No results found matching your search.",
|
||||
"tryDifferentSearch": "Please try a different search query.",
|
||||
"emptyState": {
|
||||
@@ -594,6 +593,11 @@
|
||||
"Bypass": "Bypass",
|
||||
"Copy (Clipspace)": "Copy (Clipspace)",
|
||||
"Add Node": "Add Node",
|
||||
"Add Reroute": "Add Reroute",
|
||||
"Most Relevant": "Most Relevant",
|
||||
"Comfy Nodes": "Comfy Nodes",
|
||||
"Extensions": "Extensions",
|
||||
"Partner Nodes": "Partner Nodes",
|
||||
"Add Group": "Add Group",
|
||||
"Manage Group Nodes": "Manage Group Nodes",
|
||||
"Add Group For Selected Nodes": "Add Group For Selected Nodes",
|
||||
|
||||
@@ -573,8 +573,7 @@ describe('useMediaAssetActions', () => {
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled()
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledWith({
|
||||
job_ids: ['job1'],
|
||||
naming_strategy: 'preserve',
|
||||
include_previews: true
|
||||
naming_strategy: 'preserve'
|
||||
})
|
||||
expect(mockTrackExport).toHaveBeenCalledWith('test-task-id')
|
||||
|
||||
|
||||
@@ -201,8 +201,7 @@ export function useMediaAssetActions() {
|
||||
...(Object.keys(jobAssetNameFilters).length > 0
|
||||
? { job_asset_name_filters: jobAssetNameFilters }
|
||||
: {}),
|
||||
naming_strategy: namingStrategy,
|
||||
include_previews: true
|
||||
naming_strategy: namingStrategy
|
||||
})
|
||||
|
||||
assetExportStore.trackExport(result.task_id)
|
||||
|
||||
@@ -50,7 +50,6 @@ interface AssetExportOptions {
|
||||
| 'preserve'
|
||||
| 'asset_id'
|
||||
job_asset_name_filters?: Record<string, string[]>
|
||||
include_previews?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
capturePreservedQuery,
|
||||
clearPreservedQuery,
|
||||
getPreservedQueryParam,
|
||||
hydratePreservedQuery,
|
||||
mergePreservedQueryIntoQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
@@ -30,22 +29,6 @@ describe('preservedQueryManager', () => {
|
||||
expect(sessionStorage.getItem('Comfy.PreservedQuery.template')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reads a preserved query param by key', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
|
||||
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBe('flux')
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'source')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hydrates from sessionStorage when reading a param', () => {
|
||||
sessionStorage.setItem(
|
||||
'Comfy.PreservedQuery.template',
|
||||
JSON.stringify({ template: 'flux' })
|
||||
)
|
||||
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBe('flux')
|
||||
})
|
||||
|
||||
it('hydrates cached payload from sessionStorage once', () => {
|
||||
sessionStorage.setItem(
|
||||
'Comfy.PreservedQuery.template',
|
||||
|
||||
@@ -87,14 +87,6 @@ export const capturePreservedQuery = (
|
||||
writeToStorage(namespace, payload)
|
||||
}
|
||||
|
||||
export function getPreservedQueryParam(
|
||||
namespace: string,
|
||||
key: string
|
||||
): string | undefined {
|
||||
hydratePreservedQuery(namespace)
|
||||
return preservedQueries.get(namespace)?.[key]
|
||||
}
|
||||
|
||||
export const mergePreservedQueryIntoQuery = (
|
||||
namespace: string,
|
||||
query?: LocationQueryRaw
|
||||
|
||||
@@ -2,7 +2,6 @@ export const PRESERVED_QUERY_NAMESPACES = {
|
||||
TEMPLATE: 'template',
|
||||
INVITE: 'invite',
|
||||
SHARE: 'share',
|
||||
SHARE_AUTH: 'share_auth',
|
||||
CREATE_WORKSPACE: 'create_workspace',
|
||||
OAUTH: 'oauth'
|
||||
} as const
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ShareLinkOpenedMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ExecutionTriggerSource,
|
||||
@@ -20,7 +19,6 @@ import type {
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
SettingChangedMetadata,
|
||||
SharedWorkflowRunMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
@@ -184,10 +182,6 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackShareFlow?.(metadata))
|
||||
}
|
||||
|
||||
trackShareLinkOpened(metadata: ShareLinkOpenedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackShareLinkOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.dispatch((provider) => provider.trackPageVisibilityChanged?.(metadata))
|
||||
}
|
||||
@@ -246,10 +240,6 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackExecutionSuccess?.(metadata))
|
||||
}
|
||||
|
||||
trackSharedWorkflowRun(metadata: SharedWorkflowRunMetadata): void {
|
||||
this.dispatch((provider) => provider.trackSharedWorkflowRun?.(metadata))
|
||||
}
|
||||
|
||||
trackSettingChanged(metadata: SettingChangedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackSettingChanged?.(metadata))
|
||||
}
|
||||
|
||||
@@ -322,31 +322,13 @@ describe('GtmTelemetryProvider', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackShareFlow({
|
||||
step: 'link_copied',
|
||||
source: 'app_mode',
|
||||
share_id: 'share-1'
|
||||
source: 'app_mode'
|
||||
})
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'share_flow',
|
||||
step: 'link_copied',
|
||||
source: 'app_mode'
|
||||
})
|
||||
expect(lastDataLayerEntry()).not.toHaveProperty('share_id')
|
||||
})
|
||||
|
||||
it('omits share_id from workflow import events', () => {
|
||||
const provider = createInitializedProvider()
|
||||
provider.trackWorkflowImported({
|
||||
missing_node_count: 0,
|
||||
missing_node_types: [],
|
||||
open_source: 'shared_url',
|
||||
share_id: 'share-1'
|
||||
})
|
||||
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'workflow_import',
|
||||
open_source: 'shared_url'
|
||||
})
|
||||
expect(lastDataLayerEntry()).not.toHaveProperty('share_id')
|
||||
})
|
||||
|
||||
it('pushes normalized email inside the auth event payload', () => {
|
||||
@@ -356,8 +338,7 @@ describe('GtmTelemetryProvider', () => {
|
||||
method: 'email',
|
||||
is_new_user: true,
|
||||
user_id: 'uid-123',
|
||||
email: ' Test@Example.com ',
|
||||
share_id: 'share-1'
|
||||
email: ' Test@Example.com '
|
||||
})
|
||||
|
||||
const dl = window.dataLayer as Record<string, unknown>[]
|
||||
@@ -370,7 +351,6 @@ describe('GtmTelemetryProvider', () => {
|
||||
email: 'test@example.com'
|
||||
}
|
||||
})
|
||||
expect(authEvent).not.toHaveProperty('share_id')
|
||||
expect(
|
||||
dl.some((entry) => 'user_data' in entry && !('event' in entry))
|
||||
).toBe(false)
|
||||
|
||||
@@ -408,45 +408,6 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('omits share_id from existing Mixpanel events', async () => {
|
||||
const provider = new MixpanelTelemetryProvider()
|
||||
await waitForMixpanelInit()
|
||||
mockMixpanel.track.mockClear()
|
||||
|
||||
provider.trackAuth({ method: 'google', share_id: 'share-1' })
|
||||
provider.trackWorkflowImported({
|
||||
missing_node_count: 0,
|
||||
missing_node_types: [],
|
||||
open_source: 'shared_url',
|
||||
share_id: 'share-1'
|
||||
})
|
||||
provider.trackShareFlow({
|
||||
step: 'link_copied',
|
||||
source: 'app_mode',
|
||||
share_id: 'share-1'
|
||||
})
|
||||
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
{ method: 'google' }
|
||||
)
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.WORKFLOW_IMPORTED,
|
||||
{
|
||||
missing_node_count: 0,
|
||||
missing_node_types: [],
|
||||
open_source: 'shared_url'
|
||||
}
|
||||
)
|
||||
expect(mockMixpanel.track).toHaveBeenCalledWith(
|
||||
TelemetryEvents.SHARE_FLOW,
|
||||
{
|
||||
step: 'link_copied',
|
||||
source: 'app_mode'
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MixpanelTelemetryProvider — topup delegation', () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||
import { omit } from 'es-toolkit'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -18,6 +17,7 @@ import type {
|
||||
CreditTopupMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionTriggerSource,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
@@ -27,7 +27,6 @@ import type {
|
||||
PageVisibilityMetadata,
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
ShareFlowMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
@@ -210,10 +209,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
this.trackEvent(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
omit(metadata, ['share_id'])
|
||||
)
|
||||
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
|
||||
}
|
||||
|
||||
trackUserLoggedIn(): void {
|
||||
@@ -360,17 +356,11 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
|
||||
this.trackEvent(
|
||||
TelemetryEvents.WORKFLOW_IMPORTED,
|
||||
omit(metadata, ['share_id'])
|
||||
)
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_IMPORTED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
|
||||
this.trackEvent(
|
||||
TelemetryEvents.WORKFLOW_OPENED,
|
||||
omit(metadata, ['share_id'])
|
||||
)
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
|
||||
@@ -386,7 +376,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
trackShareFlow(metadata: ShareFlowMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SHARE_FLOW, omit(metadata, ['share_id']))
|
||||
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
|
||||
@@ -270,35 +270,6 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('captures share attribution events', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackShareLinkOpened({
|
||||
share_id: 'share-1',
|
||||
is_authenticated: true
|
||||
})
|
||||
provider.trackSharedWorkflowRun({
|
||||
job_id: 'job-1',
|
||||
share_id: 'share-1'
|
||||
})
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.SHARE_LINK_OPENED,
|
||||
{
|
||||
share_id: 'share-1',
|
||||
is_authenticated: true
|
||||
}
|
||||
)
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.SHARED_WORKFLOW_RUN,
|
||||
{
|
||||
job_id: 'job-1',
|
||||
share_id: 'share-1'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('captures search queries with surface, query, length, and result count', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ShareLinkOpenedMetadata,
|
||||
ExecutionTriggerSource,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
@@ -27,7 +26,6 @@ import type {
|
||||
PageVisibilityMetadata,
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
SharedWorkflowRunMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
@@ -485,10 +483,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
|
||||
}
|
||||
|
||||
trackShareLinkOpened(metadata: ShareLinkOpenedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SHARE_LINK_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
|
||||
}
|
||||
@@ -533,10 +527,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
|
||||
}
|
||||
|
||||
trackSharedWorkflowRun(metadata: SharedWorkflowRunMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SHARED_WORKFLOW_RUN, metadata)
|
||||
}
|
||||
|
||||
trackSettingChanged(metadata: SettingChangedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SETTING_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ export interface AuthMetadata {
|
||||
is_new_user?: boolean
|
||||
user_id?: string
|
||||
email?: string
|
||||
share_id?: string
|
||||
referrer_url?: string
|
||||
utm_source?: string
|
||||
utm_medium?: string
|
||||
@@ -117,11 +116,6 @@ export interface ExecutionSuccessMetadata {
|
||||
jobId: string
|
||||
}
|
||||
|
||||
export interface SharedWorkflowRunMetadata {
|
||||
job_id: string
|
||||
share_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Template metadata for workflow tracking
|
||||
*/
|
||||
@@ -171,7 +165,6 @@ export interface WorkflowImportMetadata {
|
||||
| 'template'
|
||||
| 'shared_url'
|
||||
| 'unknown'
|
||||
share_id?: string
|
||||
}
|
||||
|
||||
export interface EnterLinearMetadata {
|
||||
@@ -196,12 +189,6 @@ type ShareFlowStep =
|
||||
export interface ShareFlowMetadata {
|
||||
step: ShareFlowStep
|
||||
source?: 'app_mode' | 'graph_mode'
|
||||
share_id?: string
|
||||
}
|
||||
|
||||
export interface ShareLinkOpenedMetadata {
|
||||
share_id: string
|
||||
is_authenticated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -490,7 +477,6 @@ export interface TelemetryProvider {
|
||||
trackDefaultViewSet?(metadata: DefaultViewSetMetadata): void
|
||||
trackEnterLinear?(metadata: EnterLinearMetadata): void
|
||||
trackShareFlow?(metadata: ShareFlowMetadata): void
|
||||
trackShareLinkOpened?(metadata: ShareLinkOpenedMetadata): void
|
||||
|
||||
// Page visibility events
|
||||
trackPageVisibilityChanged?(metadata: PageVisibilityMetadata): void
|
||||
@@ -523,7 +509,6 @@ export interface TelemetryProvider {
|
||||
trackWorkflowExecution?(): void
|
||||
trackExecutionError?(metadata: ExecutionErrorMetadata): void
|
||||
trackExecutionSuccess?(metadata: ExecutionSuccessMetadata): void
|
||||
trackSharedWorkflowRun?(metadata: SharedWorkflowRunMetadata): void
|
||||
|
||||
// Settings events
|
||||
trackSettingChanged?(metadata: SettingChangedMetadata): void
|
||||
@@ -585,7 +570,6 @@ export const TelemetryEvents = {
|
||||
WORKFLOW_OPENED: 'app:workflow_opened',
|
||||
ENTER_LINEAR_MODE: 'app:app_mode_opened',
|
||||
SHARE_FLOW: 'app:share_flow',
|
||||
SHARE_LINK_OPENED: 'app:share_link_opened',
|
||||
|
||||
// Page Visibility
|
||||
PAGE_VISIBILITY_CHANGED: 'app:page_visibility_changed',
|
||||
@@ -619,7 +603,6 @@ export const TelemetryEvents = {
|
||||
EXECUTION_START: 'execution_start',
|
||||
EXECUTION_ERROR: 'execution_error',
|
||||
EXECUTION_SUCCESS: 'execution_success',
|
||||
SHARED_WORKFLOW_RUN: 'app:shared_workflow_run',
|
||||
// Generic UI Button Click
|
||||
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
|
||||
|
||||
@@ -648,7 +631,6 @@ export type TelemetryEventProperties =
|
||||
| RunButtonProperties
|
||||
| ExecutionErrorMetadata
|
||||
| ExecutionSuccessMetadata
|
||||
| SharedWorkflowRunMetadata
|
||||
| CreditTopupMetadata
|
||||
| WorkflowImportMetadata
|
||||
| TemplateLibraryMetadata
|
||||
@@ -667,7 +649,6 @@ export type TelemetryEventProperties =
|
||||
| WorkflowCreatedMetadata
|
||||
| EnterLinearMetadata
|
||||
| ShareFlowMetadata
|
||||
| ShareLinkOpenedMetadata
|
||||
| WorkflowSavedMetadata
|
||||
| DefaultViewSetMetadata
|
||||
| SubscriptionMetadata
|
||||
|
||||
@@ -636,71 +636,6 @@ describe('useWorkflowService', () => {
|
||||
|
||||
expect(workflowStore.createNewTemporary).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stores share attribution on shared temporary workflows', async () => {
|
||||
vi.mocked(workflowStore.getWorkflowByPath).mockReturnValue(null)
|
||||
const tempWorkflow = createModeTestWorkflow({
|
||||
path: 'workflows/shared.json'
|
||||
})
|
||||
vi.mocked(workflowStore.createNewTemporary).mockReturnValue(tempWorkflow)
|
||||
vi.mocked(workflowStore.openWorkflow).mockResolvedValue(tempWorkflow)
|
||||
|
||||
await useWorkflowService().afterLoadNewGraph(
|
||||
'shared',
|
||||
{ nodes: [] } as never,
|
||||
'share-1'
|
||||
)
|
||||
|
||||
expect(tempWorkflow.shareId).toBe('share-1')
|
||||
})
|
||||
|
||||
it('preserves share attribution on repeated same-path loads', async () => {
|
||||
existingWorkflow.shareId = 'share-1'
|
||||
|
||||
await useWorkflowService().afterLoadNewGraph('repeat', {
|
||||
nodes: [{ id: 1, type: 'TestNode', pos: [0, 0], size: [100, 100] }]
|
||||
} as never)
|
||||
|
||||
expect(existingWorkflow.shareId).toBe('share-1')
|
||||
})
|
||||
|
||||
it('preserves share attribution on workflow object reloads', async () => {
|
||||
existingWorkflow.shareId = 'share-1'
|
||||
|
||||
await useWorkflowService().afterLoadNewGraph(existingWorkflow, {
|
||||
nodes: [{ id: 1, type: 'TestNode', pos: [0, 0], size: [100, 100] }]
|
||||
} as never)
|
||||
|
||||
expect(existingWorkflow.shareId).toBe('share-1')
|
||||
})
|
||||
|
||||
it('overwrites share attribution on repeated same-path loads with a new share id', async () => {
|
||||
existingWorkflow.shareId = 'share-1'
|
||||
|
||||
await useWorkflowService().afterLoadNewGraph(
|
||||
'repeat',
|
||||
{
|
||||
nodes: [{ id: 1, type: 'TestNode', pos: [0, 0], size: [100, 100] }]
|
||||
} as never,
|
||||
'share-2'
|
||||
)
|
||||
|
||||
expect(existingWorkflow.shareId).toBe('share-2')
|
||||
})
|
||||
|
||||
it('overwrites share attribution on workflow object reloads with a new share id', async () => {
|
||||
existingWorkflow.shareId = 'share-1'
|
||||
|
||||
await useWorkflowService().afterLoadNewGraph(
|
||||
existingWorkflow,
|
||||
{
|
||||
nodes: [{ id: 1, type: 'TestNode', pos: [0, 0], size: [100, 100] }]
|
||||
} as never,
|
||||
'share-2'
|
||||
)
|
||||
|
||||
expect(existingWorkflow.shareId).toBe('share-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-workflow mode switching', () => {
|
||||
|
||||
@@ -445,8 +445,7 @@ export const useWorkflowService = () => {
|
||||
*/
|
||||
const afterLoadNewGraph = async (
|
||||
value: string | ComfyWorkflow | null,
|
||||
workflowData: ComfyWorkflowJSON,
|
||||
shareId?: string
|
||||
workflowData: ComfyWorkflowJSON
|
||||
) => {
|
||||
const workflowStore = useWorkspaceStore().workflow
|
||||
const { isAppMode } = useAppMode()
|
||||
@@ -500,9 +499,6 @@ export const useWorkflowService = () => {
|
||||
) ?? freshLoadMode
|
||||
trackIfEnteringApp(loadedWorkflow)
|
||||
}
|
||||
if (shareId) {
|
||||
loadedWorkflow.shareId = shareId
|
||||
}
|
||||
loadedWorkflow.changeTracker.reset(workflowData)
|
||||
loadedWorkflow.changeTracker.restore()
|
||||
return
|
||||
@@ -514,18 +510,12 @@ export const useWorkflowService = () => {
|
||||
workflowData
|
||||
)
|
||||
tempWorkflow.initialMode = freshLoadMode
|
||||
if (shareId) {
|
||||
tempWorkflow.shareId = shareId
|
||||
}
|
||||
trackIfEnteringApp(tempWorkflow)
|
||||
await workflowStore.openWorkflow(tempWorkflow)
|
||||
return
|
||||
}
|
||||
|
||||
const loadedWorkflow = await workflowStore.openWorkflow(value)
|
||||
if (shareId) {
|
||||
loadedWorkflow.shareId = shareId
|
||||
}
|
||||
if (loadedWorkflow.initialMode === undefined) {
|
||||
loadedWorkflow.initialMode = freshLoadMode
|
||||
trackIfEnteringApp(loadedWorkflow)
|
||||
|
||||
@@ -55,7 +55,6 @@ export class ComfyWorkflow extends UserFile {
|
||||
* Takes precedence over initialMode when present.
|
||||
*/
|
||||
activeMode: AppMode | null = null
|
||||
shareId?: string
|
||||
/**
|
||||
* @param options The path, modified, and size of the workflow.
|
||||
* Note: path is the full path, including the 'workflows/' prefix.
|
||||
|
||||
@@ -30,9 +30,8 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
const { url, shareId } = defineProps<{
|
||||
const { url } = defineProps<{
|
||||
url: string
|
||||
shareId: string
|
||||
}>()
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
@@ -44,8 +43,7 @@ async function handleCopy() {
|
||||
copied.value = true
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'link_copied',
|
||||
source: isAppMode.value ? 'app_mode' : 'graph_mode',
|
||||
share_id: shareId
|
||||
source: isAppMode.value ? 'app_mode' : 'graph_mode'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -23,14 +23,6 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore
|
||||
}))
|
||||
|
||||
const mockTrackShareFlow = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackShareFlow: mockTrackShareFlow
|
||||
})
|
||||
}))
|
||||
|
||||
const mockToast = vi.hoisted(() => ({ add: vi.fn() }))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
@@ -120,10 +112,7 @@ const i18n = createI18n({
|
||||
saveButton: 'Save workflow',
|
||||
createLinkButton: 'Create link',
|
||||
creatingLink: 'Creating link...',
|
||||
copyLink: 'Copy link',
|
||||
linkCopied: 'Copied',
|
||||
checkingAssets: 'Checking assets...',
|
||||
shareUrlLabel: 'Share URL',
|
||||
successDescription: 'Anyone with this link...',
|
||||
hasChangesDescription: 'You have made changes...',
|
||||
updateLinkButton: 'Update link',
|
||||
@@ -384,31 +373,6 @@ describe('ShareWorkflowDialogContent', () => {
|
||||
'workflows/test.json',
|
||||
initialShareableAssets
|
||||
)
|
||||
expect(mockTrackShareFlow).toHaveBeenCalledWith({
|
||||
step: 'link_created',
|
||||
source: 'graph_mode',
|
||||
share_id: 'test-123'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks copied share link with share id', async () => {
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: true,
|
||||
shareId: 'copy-123',
|
||||
shareUrl: 'https://comfy.org/shared/copy-123',
|
||||
publishedAt: new Date('2026-01-15')
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
await flushPromises()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Copy link/i }))
|
||||
|
||||
expect(mockTrackShareFlow).toHaveBeenCalledWith({
|
||||
step: 'link_copied',
|
||||
source: 'graph_mode',
|
||||
share_id: 'copy-123'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows update button when workflow was saved after last publish', async () => {
|
||||
|
||||
@@ -112,10 +112,7 @@
|
||||
</template>
|
||||
|
||||
<template v-if="dialogState === 'shared' && publishResult">
|
||||
<ShareUrlCopyField
|
||||
:url="publishResult.shareUrl"
|
||||
:share-id="publishResult.shareId"
|
||||
/>
|
||||
<ShareUrlCopyField :url="publishResult.shareUrl" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p
|
||||
v-if="publishResult.publishedAt"
|
||||
@@ -440,8 +437,7 @@ const {
|
||||
acknowledged.value = false
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'link_created',
|
||||
source: getShareSource(),
|
||||
share_id: result.shareId
|
||||
source: getShareSource()
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composab
|
||||
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
|
||||
|
||||
const preservedQueryMocks = vi.hoisted(() => ({
|
||||
capturePreservedQuery: vi.fn(),
|
||||
clearPreservedQuery: vi.fn(),
|
||||
hydratePreservedQuery: vi.fn(),
|
||||
mergePreservedQueryIntoQuery: vi.fn()
|
||||
@@ -29,20 +28,6 @@ vi.mock('vue-router', () => ({
|
||||
}))
|
||||
|
||||
const mockImportPublishedAssets = vi.fn()
|
||||
const mockIsLoggedIn = vi.hoisted(() => ({ value: false }))
|
||||
const mockTrackShareLinkOpened = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
isLoggedIn: mockIsLoggedIn
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackShareLinkOpened: mockTrackShareLinkOpened
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
|
||||
SharedWorkflowLoadError: class extends Error {
|
||||
@@ -189,7 +174,6 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockQueryParams = {}
|
||||
mockIsLoggedIn.value = false
|
||||
mockDialogStack.length = 0
|
||||
mockShowLayoutDialog.mockImplementation(createDialogInstance)
|
||||
mockUpdateDialog.mockImplementation(
|
||||
@@ -229,7 +213,6 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
expect(loaded).toBe('not-present')
|
||||
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
|
||||
expect(mockLoadGraphData).not.toHaveBeenCalled()
|
||||
expect(mockTrackShareLinkOpened).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens dialog immediately with shareId and loads graph on confirm', async () => {
|
||||
@@ -251,16 +234,7 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
true,
|
||||
true,
|
||||
'Test Workflow',
|
||||
{ openSource: 'shared_url', shareId: 'share-id-1' }
|
||||
)
|
||||
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
|
||||
share_id: 'share-id-1',
|
||||
is_authenticated: false
|
||||
})
|
||||
expect(preservedQueryMocks.capturePreservedQuery).toHaveBeenCalledWith(
|
||||
'share_auth',
|
||||
{ share: 'share-id-1' },
|
||||
['share']
|
||||
{ openSource: 'shared_url' }
|
||||
)
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
@@ -268,24 +242,6 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not capture share auth attribution for authenticated users', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
mockIsLoggedIn.value = true
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithConfirm(makePayload())
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
const loaded = await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(loaded).toBe('loaded')
|
||||
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
|
||||
share_id: 'share-id-1',
|
||||
is_authenticated: true
|
||||
})
|
||||
expect(preservedQueryMocks.capturePreservedQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides template selector when user confirms opening shared workflow', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
@@ -345,9 +301,6 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'share'
|
||||
)
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'share_auth'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not hide template selector when user cancels shared workflow dialog', async () => {
|
||||
@@ -458,7 +411,7 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
true,
|
||||
true,
|
||||
'Test Workflow',
|
||||
{ openSource: 'shared_url', shareId: 'share-id-1' }
|
||||
{ openSource: 'shared_url' }
|
||||
)
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -588,7 +541,7 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
true,
|
||||
true,
|
||||
'Open shared workflow',
|
||||
{ openSource: 'shared_url', shareId: 'share-id-1' }
|
||||
{ openSource: 'shared_url' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,13 +2,10 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
|
||||
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import {
|
||||
capturePreservedQuery,
|
||||
clearPreservedQuery,
|
||||
hydratePreservedQuery,
|
||||
mergePreservedQueryIntoQuery
|
||||
@@ -44,7 +41,6 @@ export function useSharedWorkflowUrlLoader() {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
|
||||
|
||||
function isValidParameter(param: string): boolean {
|
||||
@@ -144,22 +140,9 @@ export function useSharedWorkflowUrlLoader() {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
useTelemetry()?.trackShareLinkOpened({
|
||||
share_id: shareParam,
|
||||
is_authenticated: isLoggedIn.value
|
||||
})
|
||||
if (!isLoggedIn.value) {
|
||||
capturePreservedQuery(
|
||||
PRESERVED_QUERY_NAMESPACES.SHARE_AUTH,
|
||||
{ share: shareParam },
|
||||
['share']
|
||||
)
|
||||
}
|
||||
|
||||
const result = await showOpenSharedWorkflowDialog(shareParam)
|
||||
|
||||
if (result.action === 'cancel') {
|
||||
clearPreservedQuery(PRESERVED_QUERY_NAMESPACES.SHARE_AUTH)
|
||||
clearShareIntent()
|
||||
return 'cancelled'
|
||||
}
|
||||
@@ -199,8 +182,7 @@ export function useSharedWorkflowUrlLoader() {
|
||||
true,
|
||||
workflowName,
|
||||
{
|
||||
openSource: 'shared_url',
|
||||
shareId: payload.shareId
|
||||
openSource: 'shared_url'
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
|
||||
@@ -1138,7 +1138,6 @@ export class ComfyApp {
|
||||
options: {
|
||||
checkForRerouteMigration?: boolean
|
||||
openSource?: WorkflowOpenSource
|
||||
shareId?: string
|
||||
deferWarnings?: boolean
|
||||
skipAssetScans?: boolean
|
||||
silentAssetErrors?: boolean
|
||||
@@ -1147,7 +1146,6 @@ export class ComfyApp {
|
||||
const {
|
||||
checkForRerouteMigration = false,
|
||||
openSource,
|
||||
shareId,
|
||||
deferWarnings = false,
|
||||
skipAssetScans = false,
|
||||
silentAssetErrors = false
|
||||
@@ -1425,24 +1423,19 @@ export class ComfyApp {
|
||||
missingNodeTypes
|
||||
)
|
||||
|
||||
const effectiveShareId =
|
||||
shareId ??
|
||||
(workflow instanceof ComfyWorkflow ? workflow.shareId : undefined)
|
||||
const telemetryPayload = {
|
||||
missing_node_count: missingNodeTypes.length,
|
||||
missing_node_types: missingNodeTypes.map((node) =>
|
||||
typeof node === 'string' ? node : node.type
|
||||
),
|
||||
missing_node_packs: groupMissingNodesByPack(missingNodeTypes),
|
||||
open_source: openSource ?? 'unknown',
|
||||
...(effectiveShareId ? { share_id: effectiveShareId } : {})
|
||||
open_source: openSource ?? 'unknown'
|
||||
}
|
||||
useTelemetry()?.trackWorkflowOpened(telemetryPayload)
|
||||
useTelemetry()?.trackWorkflowImported(telemetryPayload)
|
||||
await useWorkflowService().afterLoadNewGraph(
|
||||
workflow,
|
||||
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON,
|
||||
effectiveShareId
|
||||
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
|
||||
|
||||
@@ -7,13 +7,8 @@ import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
|
||||
const API_BASE_URL = 'https://api.comfy.org'
|
||||
|
||||
// Without a timeout a hung socket (e.g. no internet, captive portal) never
|
||||
// rejects, leaving callers stuck in their loading state indefinitely.
|
||||
const REQUEST_TIMEOUT_MS = 10_000
|
||||
|
||||
const registryApiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SubgraphNode,
|
||||
createBounds
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { overlapBounding } from '@/lib/litegraph/src/measure'
|
||||
import type {
|
||||
CreateNodeOptions,
|
||||
GraphAddOptions,
|
||||
@@ -944,9 +945,39 @@ export const useLitegraphService = () => {
|
||||
if (!graph || !node) return null
|
||||
|
||||
graph.add(node, addOptions)
|
||||
if (!addOptions?.ghost) {
|
||||
resolveOverlap(node, graph)
|
||||
centerOnNewNode(node)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
const OVERLAP_GAP = 20
|
||||
const OVERLAP_MAX_ITER = 100
|
||||
|
||||
function resolveOverlap(
|
||||
node: LGraphNode,
|
||||
graph: { nodes: LGraphNode[] }
|
||||
): void {
|
||||
node.updateArea()
|
||||
let iter = 0
|
||||
while (
|
||||
iter++ < OVERLAP_MAX_ITER &&
|
||||
graph.nodes.some(
|
||||
(n) =>
|
||||
n.id !== node.id && overlapBounding(node.boundingRect, n.boundingRect)
|
||||
)
|
||||
) {
|
||||
node.pos[1] += node.size[1] + OVERLAP_GAP
|
||||
node.updateArea()
|
||||
}
|
||||
}
|
||||
|
||||
function centerOnNewNode(node: LGraphNode): void {
|
||||
node.updateArea()
|
||||
app.canvas?.animateToBounds(node.boundingRect, { zoom: 0 })
|
||||
}
|
||||
|
||||
function getCanvasCenter(): Point {
|
||||
const dpi = Math.max(window.devicePixelRatio ?? 1, 1)
|
||||
const visibleArea = app.canvas?.ds?.visible_area
|
||||
|
||||
@@ -6,8 +6,6 @@ import type { Mock } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as vuefire from 'vuefire'
|
||||
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
@@ -141,8 +139,6 @@ describe('useAuthStore', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
sessionStorage.clear()
|
||||
clearPreservedQuery(PRESERVED_QUERY_NAMESPACES.SHARE_AUTH)
|
||||
|
||||
// Setup dialog service mock
|
||||
vi.mocked(useDialogService, { partial: true }).mockReturnValue({
|
||||
@@ -660,30 +656,6 @@ describe('useAuthStore', () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('includes preserved share id on new-user social auth', async () => {
|
||||
sessionStorage.setItem(
|
||||
'Comfy.PreservedQuery.share_auth',
|
||||
JSON.stringify({ share: 'share-1' })
|
||||
)
|
||||
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
|
||||
isNewUser: true,
|
||||
providerId: 'google.com',
|
||||
profile: null
|
||||
})
|
||||
|
||||
await store.loginWithGoogle()
|
||||
|
||||
expect(mockTrackAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
is_new_user: true,
|
||||
share_id: 'share-1'
|
||||
})
|
||||
)
|
||||
expect(
|
||||
sessionStorage.getItem('Comfy.PreservedQuery.share_auth')
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -23,11 +23,6 @@ import { useFirebaseAuth } from 'vuefire'
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
clearPreservedQuery,
|
||||
getPreservedQueryParam
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
@@ -102,15 +97,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const userEmail = computed(() => currentUser.value?.email)
|
||||
const userId = computed(() => currentUser.value?.uid)
|
||||
|
||||
function getShareAuthMetadata() {
|
||||
const shareId = getPreservedQueryParam(
|
||||
PRESERVED_QUERY_NAMESPACES.SHARE_AUTH,
|
||||
'share'
|
||||
)
|
||||
if (shareId) clearPreservedQuery(PRESERVED_QUERY_NAMESPACES.SHARE_AUTH)
|
||||
return shareId ? { share_id: shareId } : {}
|
||||
}
|
||||
|
||||
// Get auth from VueFire and listen for auth state changes
|
||||
// From useFirebaseAuth docs:
|
||||
// Retrieves the Firebase Auth instance. Returns `null` on the server.
|
||||
@@ -347,8 +333,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
method: 'email',
|
||||
is_new_user: false,
|
||||
user_id: result.user.uid,
|
||||
email: result.user.email ?? undefined,
|
||||
...getShareAuthMetadata()
|
||||
email: result.user.email ?? undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -370,8 +355,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
method: 'email',
|
||||
is_new_user: true,
|
||||
user_id: result.user.uid,
|
||||
email: result.user.email ?? undefined,
|
||||
...getShareAuthMetadata()
|
||||
email: result.user.email ?? undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -393,8 +377,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
is_new_user:
|
||||
options?.isNewUser || additionalUserInfo?.isNewUser || false,
|
||||
user_id: result.user.uid,
|
||||
email: result.user.email ?? undefined,
|
||||
...getShareAuthMetadata()
|
||||
email: result.user.email ?? undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -416,8 +399,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
is_new_user:
|
||||
options?.isNewUser || additionalUserInfo?.isNewUser || false,
|
||||
user_id: result.user.uid,
|
||||
email: result.user.email ?? undefined,
|
||||
...getShareAuthMetadata()
|
||||
email: result.user.email ?? undefined
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import type * as DistributionTypes from '@/platform/distribution/types'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
@@ -16,18 +15,12 @@ const {
|
||||
mockNodeExecutionIdToNodeLocatorId,
|
||||
mockNodeIdToNodeLocatorId,
|
||||
mockNodeLocatorIdToNodeExecutionId,
|
||||
mockShowTextPreview,
|
||||
mockTrackExecutionError,
|
||||
mockTrackExecutionSuccess,
|
||||
mockTrackSharedWorkflowRun
|
||||
mockShowTextPreview
|
||||
} = vi.hoisted(() => ({
|
||||
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
|
||||
mockShowTextPreview: vi.fn(),
|
||||
mockTrackExecutionError: vi.fn(),
|
||||
mockTrackExecutionSuccess: vi.fn(),
|
||||
mockTrackSharedWorkflowRun: vi.fn()
|
||||
mockShowTextPreview: vi.fn()
|
||||
}))
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
@@ -47,21 +40,6 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/distribution/types', async () => ({
|
||||
...(await vi.importActual<typeof DistributionTypes>(
|
||||
'@/platform/distribution/types'
|
||||
)),
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackExecutionError: mockTrackExecutionError,
|
||||
trackExecutionSuccess: mockTrackExecutionSuccess,
|
||||
trackSharedWorkflowRun: mockTrackSharedWorkflowRun
|
||||
})
|
||||
}))
|
||||
|
||||
// Remove any previous global types
|
||||
declare global {
|
||||
interface Window {}
|
||||
@@ -1107,50 +1085,6 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
expect(store.activeJobId).toBeNull()
|
||||
expect(store.queuedJobs['job-1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('tracks shared workflow run when the queued workflow has share attribution', () => {
|
||||
const workflow = createQueuedWorkflow()
|
||||
workflow.shareId = 'share-1'
|
||||
store.storeJob({
|
||||
nodes: ['a'],
|
||||
id: 'job-1',
|
||||
promptOutput: {
|
||||
a: createPromptNode('Node A', 'NodeA')
|
||||
},
|
||||
workflow
|
||||
})
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
|
||||
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
|
||||
|
||||
expect(mockTrackExecutionSuccess).toHaveBeenCalledWith({
|
||||
jobId: 'job-1'
|
||||
})
|
||||
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
|
||||
job_id: 'job-1',
|
||||
share_id: 'share-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks shared workflow run from the success event job', () => {
|
||||
const workflow = createQueuedWorkflow()
|
||||
workflow.shareId = 'share-1'
|
||||
store.storeJob({
|
||||
nodes: ['a'],
|
||||
id: 'job-1',
|
||||
promptOutput: {
|
||||
a: createPromptNode('Node A', 'NodeA')
|
||||
},
|
||||
workflow
|
||||
})
|
||||
|
||||
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
|
||||
|
||||
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
|
||||
job_id: 'job-1',
|
||||
share_id: 'share-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('executing', () => {
|
||||
@@ -1317,7 +1251,6 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => {
|
||||
b: { title: 'Node B', type: 'NodeB' }
|
||||
})
|
||||
expect(store.queuedJobs['job-1']?.workflow).toStrictEqual(workflow)
|
||||
expect(store.queuedJobs['job-1']?.shareId).toBeUndefined()
|
||||
expect(store.jobIdToWorkflowId.get('job-1')).toBe('wf-1')
|
||||
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe(
|
||||
'/workflows/foo.json'
|
||||
|
||||
@@ -55,11 +55,6 @@ interface QueuedJob {
|
||||
* This stays stable even if the user switches workflows or edits the canvas.
|
||||
*/
|
||||
nodeLookup?: Record<string, ExecutionNodeInfo>
|
||||
/**
|
||||
* Share attribution snapshotted at queue time. Read this instead of
|
||||
* `workflow.shareId`, which can gain attribution after the job was queued.
|
||||
*/
|
||||
shareId?: string
|
||||
}
|
||||
|
||||
function buildExecutionNodeLookup(
|
||||
@@ -300,20 +295,12 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
|
||||
const jobId = e.detail.prompt_id
|
||||
const queuedJob = queuedJobs.value[jobId]
|
||||
if (isCloud && queuedJob) {
|
||||
const telemetry = useTelemetry()
|
||||
telemetry?.trackExecutionSuccess({
|
||||
jobId
|
||||
if (isCloud && activeJobId.value) {
|
||||
useTelemetry()?.trackExecutionSuccess({
|
||||
jobId: activeJobId.value
|
||||
})
|
||||
if (queuedJob.shareId) {
|
||||
telemetry?.trackSharedWorkflowRun({
|
||||
job_id: jobId,
|
||||
share_id: queuedJob.shareId
|
||||
})
|
||||
}
|
||||
}
|
||||
const jobId = e.detail.prompt_id
|
||||
resetExecutionState(jobId)
|
||||
}
|
||||
|
||||
@@ -593,7 +580,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
|
||||
queuedJob.workflow = workflow
|
||||
queuedJob.shareId = workflow?.shareId
|
||||
const wid = workflow?.activeState?.id ?? workflow?.initialState?.id
|
||||
if (wid) {
|
||||
jobIdToWorkflowId.value.set(id, wid)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useScroll, whenever } from '@vueuse/core'
|
||||
import Panel from 'primevue/panel'
|
||||
import TabMenu from 'primevue/tabmenu'
|
||||
@@ -41,7 +40,10 @@ const isInProgress = computed(
|
||||
() => comfyManagerStore.isProcessingTasks || isRestarting.value
|
||||
)
|
||||
|
||||
const isTaskInProgress = (taskId: string) => {
|
||||
const isTaskInProgress = (index: number) => {
|
||||
const log = focusedLogs.value[index]
|
||||
if (!log) return false
|
||||
|
||||
const taskQueue = comfyManagerStore.taskQueue
|
||||
if (!taskQueue) return false
|
||||
|
||||
@@ -50,13 +52,7 @@ const isTaskInProgress = (taskId: string) => {
|
||||
...(taskQueue.pending_queue || [])
|
||||
]
|
||||
|
||||
return allQueueTasks.some((task) => task.ui_id === taskId)
|
||||
}
|
||||
|
||||
function taskStatusLabel(taskId: string): string {
|
||||
if (isTaskInProgress(taskId)) return t('g.inProgress')
|
||||
if (comfyManagerStore.isTaskFailed(taskId)) return t('manager.failed')
|
||||
return t('g.completedWithCheckmark')
|
||||
return allQueueTasks.some((task) => task.ui_id === log.taskId)
|
||||
}
|
||||
|
||||
const completedTasksCount = computed(() => {
|
||||
@@ -194,16 +190,12 @@ onBeforeUnmount(() => {
|
||||
<div class="flex w-full items-center justify-between py-2">
|
||||
<div class="flex flex-col text-sm/normal font-medium">
|
||||
<span>{{ log.taskName }}</span>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-muted',
|
||||
comfyManagerStore.isTaskFailed(log.taskId) &&
|
||||
'text-danger'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ taskStatusLabel(log.taskId) }}
|
||||
<span class="text-muted">
|
||||
{{
|
||||
isTaskInProgress(index)
|
||||
? t('g.inProgress')
|
||||
: t('g.completedWithCheckmark')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,17 +229,6 @@ onBeforeUnmount(() => {
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div class="h-full">
|
||||
<div
|
||||
v-for="(
|
||||
errorMessage, errorIndex
|
||||
) in comfyManagerStore.getTaskErrorMessages(log.taskId)"
|
||||
:key="`error-${errorIndex}`"
|
||||
class="text-danger"
|
||||
>
|
||||
<pre class="wrap-break-word whitespace-pre-wrap">{{
|
||||
errorMessage
|
||||
}}</pre>
|
||||
</div>
|
||||
<div
|
||||
v-for="(logLine, logIndex) in log.logs"
|
||||
:key="logIndex"
|
||||
|
||||
@@ -114,8 +114,6 @@
|
||||
v-else-if="displayPacks.length === 0"
|
||||
:title="emptyStateTitle"
|
||||
:message="emptyStateMessage"
|
||||
:button-label="searchError ? $t('manager.retry') : undefined"
|
||||
@action="() => void retrySearch()"
|
||||
/>
|
||||
<div v-else class="size-full" @click="handleGridContainerClick">
|
||||
<VirtualGrid
|
||||
@@ -346,8 +344,6 @@ const {
|
||||
searchQuery,
|
||||
pageNumber,
|
||||
isLoading: isSearchLoading,
|
||||
error: searchError,
|
||||
retry: retrySearch,
|
||||
searchResults,
|
||||
searchMode,
|
||||
sortField,
|
||||
@@ -438,15 +434,9 @@ const isManagerErrorRelevant = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// The registry search failing (e.g. offline) is also a connection error worth
|
||||
// surfacing, and unlike the manager-store error it can be retried in place.
|
||||
const hasConnectionError = computed(
|
||||
() => isManagerErrorRelevant.value || !!searchError.value
|
||||
)
|
||||
|
||||
// Empty state messages based on current tab and search state
|
||||
const emptyStateTitle = computed(() => {
|
||||
if (hasConnectionError.value) return t('manager.errorConnecting')
|
||||
if (isManagerErrorRelevant.value) return t('manager.errorConnecting')
|
||||
if (searchQuery.value) return t('manager.noResultsFound')
|
||||
|
||||
const tabId = selectedTab.value?.id
|
||||
@@ -458,7 +448,7 @@ const emptyStateTitle = computed(() => {
|
||||
})
|
||||
|
||||
const emptyStateMessage = computed(() => {
|
||||
if (hasConnectionError.value) return t('manager.tryAgainLater')
|
||||
if (isManagerErrorRelevant.value) return t('manager.tryAgainLater')
|
||||
if (searchQuery.value) {
|
||||
const baseMessage = t('manager.tryDifferentSearch')
|
||||
if (isLegacyManagerSearch.value) {
|
||||
@@ -485,9 +475,6 @@ const onClickWarningLink = () => {
|
||||
}
|
||||
|
||||
const isLoading = computed(() => {
|
||||
// A failed search must not read as "still loading" -- otherwise the spinner
|
||||
// runs forever (e.g. offline) instead of showing the error placeholder.
|
||||
if (searchError.value) return false
|
||||
if (isSearchLoading.value) return searchResults.value.length === 0
|
||||
if (isTabLoading.value) return true
|
||||
return isInitialLoad.value
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
|
||||
import type { NodePackSearchProvider } from '@/types/searchServiceTypes'
|
||||
import { useRegistrySearch } from '@/workbench/extensions/manager/composables/useRegistrySearch'
|
||||
|
||||
vi.mock('@/services/gateway/registrySearchGateway')
|
||||
|
||||
function mockGateway(searchPacks: NodePackSearchProvider['searchPacks']) {
|
||||
vi.mocked(useRegistrySearchGateway).mockReturnValue({
|
||||
searchPacks,
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
})
|
||||
}
|
||||
|
||||
describe('useRegistrySearch', () => {
|
||||
beforeEach(() => {
|
||||
// Suppress the immediate debounced search so each test drives the search
|
||||
// explicitly via retry(); pending timers stay queued and never fire.
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('clears loading and records the error when the search fails', async () => {
|
||||
const searchPacks = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('All search providers failed'))
|
||||
mockGateway(searchPacks)
|
||||
|
||||
const { isLoading, error, retry } = useRegistrySearch()
|
||||
await retry()
|
||||
|
||||
// The bug: without try/finally, isLoading stayed true forever -> infinite spinner.
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(error.value).toBe('All search providers failed')
|
||||
})
|
||||
|
||||
it('recovers and clears the error on a successful retry', async () => {
|
||||
const searchPacks = vi.fn().mockRejectedValue(new Error('offline'))
|
||||
mockGateway(searchPacks)
|
||||
|
||||
const { error, searchResults, retry } = useRegistrySearch()
|
||||
await retry()
|
||||
expect(error.value).toBe('offline')
|
||||
|
||||
searchPacks.mockResolvedValue({
|
||||
nodePacks: [{ id: 'a', name: 'Pack A' }],
|
||||
querySuggestions: []
|
||||
})
|
||||
await retry()
|
||||
|
||||
expect(error.value).toBeNull()
|
||||
expect(searchResults.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -33,7 +33,6 @@ export function useRegistrySearch(
|
||||
} = options
|
||||
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const sortField = ref<string>(initialSortField)
|
||||
const searchMode = ref<SearchMode>(initialSearchMode)
|
||||
const pageSize = ref(DEFAULT_PAGE_SIZE)
|
||||
@@ -53,50 +52,42 @@ export function useRegistrySearch(
|
||||
|
||||
const updateSearchResults = async (options: { append?: boolean }) => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
if (!options.append) {
|
||||
pageNumber.value = 0
|
||||
}
|
||||
try {
|
||||
const { nodePacks, querySuggestions } = await searchPacks(
|
||||
searchQuery.value,
|
||||
{
|
||||
pageSize: pageSize.value,
|
||||
pageNumber: pageNumber.value,
|
||||
restrictSearchableAttributes: searchAttributes.value
|
||||
}
|
||||
const { nodePacks, querySuggestions } = await searchPacks(
|
||||
searchQuery.value,
|
||||
{
|
||||
pageSize: pageSize.value,
|
||||
pageNumber: pageNumber.value,
|
||||
restrictSearchableAttributes: searchAttributes.value
|
||||
}
|
||||
)
|
||||
|
||||
let sortedPacks = nodePacks
|
||||
|
||||
// Results are sorted by the default field to begin with -- so don't manually sort again
|
||||
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
|
||||
// Get the sort direction from the provider's sortable fields
|
||||
const sortableFields = getSortableFields()
|
||||
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
|
||||
const direction = fieldConfig?.direction || 'desc'
|
||||
|
||||
sortedPacks = orderBy(
|
||||
nodePacks,
|
||||
[(pack) => getSortValue(pack, sortField.value)],
|
||||
[direction]
|
||||
)
|
||||
|
||||
let sortedPacks = nodePacks
|
||||
|
||||
// Results are sorted by the default field to begin with -- so don't manually sort again
|
||||
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
|
||||
// Get the sort direction from the provider's sortable fields
|
||||
const sortableFields = getSortableFields()
|
||||
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
|
||||
const direction = fieldConfig?.direction || 'desc'
|
||||
|
||||
sortedPacks = orderBy(
|
||||
nodePacks,
|
||||
[(pack) => getSortValue(pack, sortField.value)],
|
||||
[direction]
|
||||
)
|
||||
}
|
||||
|
||||
if (options.append && searchResults.value?.length) {
|
||||
searchResults.value = searchResults.value.concat(sortedPacks)
|
||||
} else {
|
||||
searchResults.value = sortedPacks
|
||||
}
|
||||
suggestions.value = querySuggestions
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const retry = () => updateSearchResults({ append: false })
|
||||
if (options.append && searchResults.value?.length) {
|
||||
searchResults.value = searchResults.value.concat(sortedPacks)
|
||||
} else {
|
||||
searchResults.value = sortedPacks
|
||||
}
|
||||
suggestions.value = querySuggestions
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const onQueryChange = () => void updateSearchResults({ append: false })
|
||||
const onPageChange = () => {
|
||||
@@ -117,8 +108,6 @@ export function useRegistrySearch(
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
retry,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
sortField,
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
|
||||
|
||||
const { mockClient } = vi.hoisted(() => ({
|
||||
mockClient: { get: vi.fn(), post: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
create: () => mockClient,
|
||||
isAxiosError: (e: unknown): boolean =>
|
||||
!!e &&
|
||||
typeof e === 'object' &&
|
||||
(e as { isAxiosError?: boolean }).isAxiosError === true
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (p: string) => p,
|
||||
clientId: 'test-client',
|
||||
initialClientId: 'test-client'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
useManagerState: () => ({ isNewManagerUI: ref(true) })
|
||||
}))
|
||||
|
||||
function axiosError(status: number, data?: { message: string }) {
|
||||
return { isAxiosError: true, response: { status, data } }
|
||||
}
|
||||
|
||||
describe('useComfyManagerService error messages', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('surfaces the backend security message on a 403 instead of the generic fallback', async () => {
|
||||
const backendMessage =
|
||||
"ERROR: To use this action, '--listen' must be set to a local IP and security_level must be 'normal-' or lower."
|
||||
mockClient.post.mockRejectedValue(
|
||||
axiosError(403, { message: backendMessage })
|
||||
)
|
||||
|
||||
const service = useComfyManagerService()
|
||||
await service.installPack({
|
||||
id: 'some-pack',
|
||||
version: '1.0.0',
|
||||
selected_version: '1.0.0',
|
||||
mode: 'remote',
|
||||
channel: 'default'
|
||||
})
|
||||
|
||||
expect(service.error.value).toBe(backendMessage)
|
||||
})
|
||||
|
||||
it('falls back to the generic security message when the 403 has no body', async () => {
|
||||
mockClient.post.mockRejectedValue(axiosError(403))
|
||||
|
||||
const service = useComfyManagerService()
|
||||
await service.installPack({
|
||||
id: 'some-pack',
|
||||
version: '1.0.0',
|
||||
selected_version: '1.0.0',
|
||||
mode: 'remote',
|
||||
channel: 'default'
|
||||
})
|
||||
|
||||
expect(service.error.value).toContain('security error has occurred')
|
||||
})
|
||||
})
|
||||
@@ -38,13 +38,8 @@ enum ManagerRoute {
|
||||
QUEUE_TASK = 'manager/queue/task'
|
||||
}
|
||||
|
||||
// Without a timeout a hung socket (e.g. no internet, captive portal) never
|
||||
// rejects, leaving callers stuck in their loading state indefinitely.
|
||||
const REQUEST_TIMEOUT_MS = 10_000
|
||||
|
||||
const managerApiClient = axios.create({
|
||||
baseURL: api.apiURL('/v2/'),
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
@@ -79,18 +74,14 @@ export const useComfyManagerService = () => {
|
||||
} else {
|
||||
const axiosError = err as AxiosError<{ message: string }>
|
||||
const status = axiosError.response?.status
|
||||
const backendMessage = axiosError.response?.data?.message
|
||||
// Prefer the backend's message: ComfyUI-Manager returns actionable,
|
||||
// security-aware text (e.g. which security_level/--listen is required)
|
||||
// that is far more useful than our generic per-status fallbacks.
|
||||
if (backendMessage) {
|
||||
message = backendMessage
|
||||
} else if (status && routeSpecificErrors?.[status]) {
|
||||
if (status && routeSpecificErrors?.[status]) {
|
||||
message = routeSpecificErrors[status]
|
||||
} else if (status === 404) {
|
||||
message = 'Could not connect to ComfyUI-Manager'
|
||||
} else {
|
||||
message = `${context} failed with status ${status}`
|
||||
message =
|
||||
axiosError.response?.data?.message ??
|
||||
`${context} failed with status ${status}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -409,51 +409,6 @@ describe('useComfyManagerStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('task failure surfacing', () => {
|
||||
type TaskHistoryItem = ManagerComponents['schemas']['TaskHistoryItem']
|
||||
|
||||
const historyItem = (
|
||||
uiId: string,
|
||||
statusStr: 'success' | 'error' | 'skip',
|
||||
messages: string[]
|
||||
): TaskHistoryItem => ({
|
||||
ui_id: uiId,
|
||||
client_id: 'client',
|
||||
kind: 'install',
|
||||
result: statusStr === 'success' ? 'success' : 'failed',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
status: {
|
||||
status_str: statusStr,
|
||||
completed: statusStr === 'success',
|
||||
messages
|
||||
}
|
||||
})
|
||||
|
||||
it('flags an errored task as failed and surfaces its messages', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
const reason =
|
||||
"ERROR: To use this action, '--listen' must be set and security_level must be 'normal-' or lower."
|
||||
|
||||
store.taskHistory = { 'task-1': historyItem('task-1', 'error', [reason]) }
|
||||
await nextTick()
|
||||
|
||||
expect(store.isTaskFailed('task-1')).toBe(true)
|
||||
expect(store.getTaskErrorMessages('task-1')).toEqual([reason])
|
||||
})
|
||||
|
||||
it('does not surface messages for a successful task', async () => {
|
||||
const store = useComfyManagerStore()
|
||||
|
||||
store.taskHistory = {
|
||||
'task-2': historyItem('task-2', 'success', ['Installed successfully'])
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(store.isTaskFailed('task-2')).toBe(false)
|
||||
expect(store.getTaskErrorMessages('task-2')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshInstalledList with pack ID normalization', () => {
|
||||
it('normalizes pack IDs by removing version suffixes', async () => {
|
||||
const mockPacks = {
|
||||
|
||||
@@ -115,18 +115,6 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const isTaskFailed = (taskId: string): boolean =>
|
||||
failedTasksIds.value.includes(taskId)
|
||||
|
||||
// The actionable reason a task failed (e.g. the security_level/--listen
|
||||
// restriction ComfyUI-Manager reports on a blocked install) lives in task
|
||||
// history, not the streamed server logs -- which stay empty when the request
|
||||
// is rejected before the task ever runs. Surface it so the failure isn't silent.
|
||||
const getTaskErrorMessages = (taskId: string): string[] =>
|
||||
isTaskFailed(taskId)
|
||||
? (taskHistory.value[taskId]?.status?.messages ?? [])
|
||||
: []
|
||||
|
||||
const getPackId = (pack: ManagerPackInstalled) => pack.cnr_id || pack.aux_id
|
||||
|
||||
const isInstalledPackId = (packName: NodePackId | undefined): boolean =>
|
||||
@@ -396,8 +384,6 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
|
||||
failedTasksIds,
|
||||
succeededTasksLogs,
|
||||
failedTasksLogs,
|
||||
isTaskFailed,
|
||||
getTaskErrorMessages,
|
||||
managerQueue,
|
||||
|
||||
// Pack actions
|
||||
|
||||
Reference in New Issue
Block a user