Compare commits
17 Commits
bl/nodes-2
...
feat/tab-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63929f1802 | ||
|
|
71fccf3d8b | ||
|
|
7c2c59b9fb | ||
|
|
2f7f3c4e56 | ||
|
|
4c00d39ade | ||
|
|
f1fc5fa9b3 | ||
|
|
c111fb7758 | ||
|
|
65655ba35f | ||
|
|
852d77159e | ||
|
|
b61029b9da | ||
|
|
0a62ea0b2c | ||
|
|
cccc0944a0 | ||
|
|
aadec87bff | ||
|
|
f5088d6cfb | ||
|
|
fd7ce3a852 | ||
|
|
08a2f8ae15 | ||
|
|
7b9f24f515 |
25
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -4,6 +4,8 @@ name: 'CI: Tests Storybook'
|
||||
on:
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
# Post starting comment for non-forked PRs
|
||||
@@ -138,6 +140,29 @@ jobs:
|
||||
"${{ github.head_ref }}" \
|
||||
"completed"
|
||||
|
||||
# Deploy Storybook to production URL on main branch push
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build Storybook
|
||||
run: pnpm build-storybook
|
||||
|
||||
- name: Deploy to Cloudflare Pages (production)
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
npx wrangler@^4.0.0 pages deploy storybook-static \
|
||||
--project-name=comfy-storybook \
|
||||
--branch=main
|
||||
|
||||
# Update comment with Chromatic URLs for version-bump branches
|
||||
update-comment-with-chromatic:
|
||||
needs: [chromatic-deployment, deploy-and-comment]
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
@@ -1,110 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
|
||||
|
||||
async function getNodeHeader(comfyPage: ComfyPage, title: string) {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle(title).first()
|
||||
await expect(node).toBeVisible()
|
||||
return node.locator('.lg-node-header')
|
||||
}
|
||||
|
||||
async function selectTwoNodes(comfyPage: ComfyPage) {
|
||||
const checkpointHeader = await getNodeHeader(comfyPage, 'Load Checkpoint')
|
||||
const ksamplerHeader = await getNodeHeader(comfyPage, 'KSampler')
|
||||
|
||||
await checkpointHeader.click()
|
||||
await ksamplerHeader.click({ modifiers: ['Control'] })
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test.describe('Vue Node Alignment', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.canvasOps.resetView()
|
||||
await comfyPage.vueNodes.waitForNodes(6)
|
||||
})
|
||||
|
||||
test('snaps a dragged node to another node in Vue nodes mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.AlignNodesWhileDragging',
|
||||
true
|
||||
)
|
||||
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler').first()
|
||||
const checkpointNode = comfyPage.vueNodes
|
||||
.getNodeByTitle('Load Checkpoint')
|
||||
.first()
|
||||
const ksamplerHeader = ksamplerNode.locator('.lg-node-header')
|
||||
|
||||
const ksamplerBox = await ksamplerNode.boundingBox()
|
||||
const checkpointBox = await checkpointNode.boundingBox()
|
||||
const headerBox = await ksamplerHeader.boundingBox()
|
||||
|
||||
if (!ksamplerBox || !checkpointBox || !headerBox) {
|
||||
throw new Error('Expected Vue node bounding boxes to be available')
|
||||
}
|
||||
|
||||
const dragStart = {
|
||||
x: headerBox.x + headerBox.width / 2,
|
||||
y: headerBox.y + headerBox.height / 2
|
||||
}
|
||||
const targetLeft = checkpointBox.x + 5
|
||||
const dragTarget = {
|
||||
x: dragStart.x + (targetLeft - ksamplerBox.x),
|
||||
y: dragStart.y
|
||||
}
|
||||
|
||||
await comfyPage.canvasOps.dragAndDrop(dragStart, dragTarget)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const draggedBox = await ksamplerNode.boundingBox()
|
||||
return draggedBox ? Math.round(draggedBox.x) : null
|
||||
})
|
||||
.toBe(Math.round(checkpointBox.x))
|
||||
})
|
||||
|
||||
test('shows center alignment actions from the multi-node right-click menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await selectTwoNodes(comfyPage)
|
||||
|
||||
const ksamplerHeader = await getNodeHeader(comfyPage, 'KSampler')
|
||||
await ksamplerHeader.click({ button: 'right' })
|
||||
|
||||
const alignMenuItem = comfyPage.page.getByText('Align Selected To', {
|
||||
exact: true
|
||||
})
|
||||
await expect(alignMenuItem).toBeVisible()
|
||||
await alignMenuItem.hover()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByText('Horizontal Center', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByText('Vertical Center', { exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not show alignment actions from the selection toolbox More Options menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await selectTwoNodes(comfyPage)
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
|
||||
await comfyPage.page.click('[data-testid="more-options-button"]')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByText('Rename', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByText('Align Selected To', { exact: true })
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
1
global.d.ts
vendored
@@ -35,6 +35,7 @@ interface Window {
|
||||
mixpanel_token?: string
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_debug?: boolean
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -43,6 +43,9 @@ export function useAppSetDefaultView() {
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
closeDialog()
|
||||
showAppliedDialog(openAsApp)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,6 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
|
||||
const handleClick = (event: Event) => {
|
||||
toggleNodeOptions(event, 'toolbar')
|
||||
toggleNodeOptions(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -36,6 +37,7 @@ import TabErrors from './errors/TabErrors.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
@@ -43,6 +45,8 @@ const { t } = useI18n()
|
||||
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
|
||||
storeToRefs(executionErrorStore)
|
||||
|
||||
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
|
||||
@@ -118,12 +122,21 @@ const hasMissingNodeSelected = computed(
|
||||
)
|
||||
)
|
||||
|
||||
const hasMissingModelSelected = computed(
|
||||
() =>
|
||||
hasSelection.value &&
|
||||
selectedNodes.value.some((node) =>
|
||||
activeMissingModelGraphIds.value.has(String(node.id))
|
||||
)
|
||||
)
|
||||
|
||||
const hasRelevantErrors = computed(() => {
|
||||
if (!hasSelection.value) return hasAnyError.value
|
||||
return (
|
||||
hasDirectNodeError.value ||
|
||||
hasContainerInternalError.value ||
|
||||
hasMissingNodeSelected.value
|
||||
hasMissingNodeSelected.value ||
|
||||
hasMissingModelSelected.value
|
||||
)
|
||||
})
|
||||
|
||||
@@ -314,7 +327,11 @@ function handleTitleCancel() {
|
||||
:value="tab.value"
|
||||
>
|
||||
{{ tab.label() }}
|
||||
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
|
||||
<i
|
||||
v-if="tab.icon"
|
||||
aria-hidden="true"
|
||||
:class="cn(tab.icon, 'size-4')"
|
||||
/>
|
||||
</Tab>
|
||||
</TabList>
|
||||
</nav>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="min-w-0 flex-1 overflow-y-auto">
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
@@ -32,11 +32,7 @@
|
||||
:key="group.title"
|
||||
:collapse="isSectionCollapsed(group.title) && !isSearching"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="
|
||||
group.type === 'missing_node' || group.type === 'swap_nodes'
|
||||
? 'lg'
|
||||
: 'default'
|
||||
"
|
||||
:size="getGroupSize(group)"
|
||||
@update:collapse="setSectionCollapsed(group.title, $event)"
|
||||
>
|
||||
<template #label>
|
||||
@@ -130,6 +126,14 @@
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-else-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-model="handleLocateModel"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
@@ -187,12 +191,14 @@ import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/f
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -211,6 +217,15 @@ const { replaceGroup, replaceAllGroups } = useNodeReplacement()
|
||||
const searchQuery = ref('')
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
const fullSizeGroupTypes = new Set([
|
||||
'missing_node',
|
||||
'swap_nodes',
|
||||
'missing_model'
|
||||
])
|
||||
function getGroupSize(group: ErrorGroup) {
|
||||
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
||||
}
|
||||
|
||||
const showNodeIdBadge = computed(
|
||||
() =>
|
||||
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
|
||||
@@ -226,6 +241,7 @@ const {
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
@@ -283,6 +299,10 @@ function handleLocateMissingNode(nodeId: string) {
|
||||
focusNode(nodeId, missingNodeCache.value)
|
||||
}
|
||||
|
||||
function handleLocateModel(nodeId: string) {
|
||||
focusNode(nodeId)
|
||||
}
|
||||
|
||||
function handleOpenManagerInfo(packId: string) {
|
||||
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
|
||||
if (isKnownToRegistry) {
|
||||
|
||||
@@ -23,3 +23,4 @@ export type ErrorGroup =
|
||||
}
|
||||
| { type: 'missing_node'; title: string; priority: number }
|
||||
| { type: 'swap_nodes'; title: string; priority: number }
|
||||
| { type: 'missing_model'; title: string; priority: number }
|
||||
|
||||
@@ -47,6 +47,13 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
|
||||
isGroupNode: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/missingModel/composables/useMissingModelInteractions',
|
||||
() => ({
|
||||
clearMissingModelState: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
@@ -520,4 +527,115 @@ describe('useErrorGroups', () => {
|
||||
expect(typeof groups.collapseState).toBe('object')
|
||||
})
|
||||
})
|
||||
|
||||
describe('missingModelGroups', () => {
|
||||
function makeModel(
|
||||
name: string,
|
||||
opts: {
|
||||
nodeId?: string | number
|
||||
widgetName?: string
|
||||
directory?: string
|
||||
isAssetSupported?: boolean
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
name,
|
||||
nodeId: opts.nodeId ?? '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: opts.widgetName ?? 'ckpt_name',
|
||||
isAssetSupported: opts.isAssetSupported ?? false,
|
||||
isMissing: true as const,
|
||||
directory: opts.directory
|
||||
}
|
||||
}
|
||||
|
||||
it('returns empty array when no missing models', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.missingModelGroups.value).toEqual([])
|
||||
})
|
||||
|
||||
it('groups asset-supported models by directory', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingModels([
|
||||
makeModel('model_a.safetensors', {
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true
|
||||
}),
|
||||
makeModel('model_b.safetensors', {
|
||||
nodeId: '2',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true
|
||||
}),
|
||||
makeModel('lora_a.safetensors', {
|
||||
nodeId: '3',
|
||||
directory: 'loras',
|
||||
isAssetSupported: true
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.missingModelGroups.value).toHaveLength(2)
|
||||
const ckptGroup = groups.missingModelGroups.value.find(
|
||||
(g) => g.directory === 'checkpoints'
|
||||
)
|
||||
expect(ckptGroup?.models).toHaveLength(2)
|
||||
expect(ckptGroup?.isAssetSupported).toBe(true)
|
||||
})
|
||||
|
||||
it('puts unsupported models in a separate group', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingModels([
|
||||
makeModel('model_a.safetensors', {
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true
|
||||
}),
|
||||
makeModel('custom_model.safetensors', {
|
||||
nodeId: '2',
|
||||
isAssetSupported: false
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.missingModelGroups.value).toHaveLength(2)
|
||||
const unsupported = groups.missingModelGroups.value.find(
|
||||
(g) => !g.isAssetSupported
|
||||
)
|
||||
expect(unsupported?.models).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('merges same-named models into one view model with multiple referencingNodes', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingModels([
|
||||
makeModel('shared_model.safetensors', {
|
||||
nodeId: '1',
|
||||
widgetName: 'ckpt_name',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true
|
||||
}),
|
||||
makeModel('shared_model.safetensors', {
|
||||
nodeId: '2',
|
||||
widgetName: 'ckpt_name',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.missingModelGroups.value).toHaveLength(1)
|
||||
const model = groups.missingModelGroups.value[0].models[0]
|
||||
expect(model.name).toBe('shared_model.safetensors')
|
||||
expect(model.referencingNodes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('includes missing_model group in allErrorGroups', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingModels([makeModel('model_a.safetensors')])
|
||||
await nextTick()
|
||||
|
||||
const modelGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'missing_model'
|
||||
)
|
||||
expect(modelGroup).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MaybeRefOrGetter } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -23,6 +24,11 @@ import { st } from '@/i18n'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelGroup
|
||||
} from '@/platform/missingModel/types'
|
||||
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
|
||||
import {
|
||||
isNodeExecutionId,
|
||||
compareExecutionId
|
||||
@@ -39,6 +45,9 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
|
||||
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
|
||||
const RESOLVING = '__RESOLVING__'
|
||||
|
||||
/** Sentinel key for grouping non-asset-supported missing models. */
|
||||
const UNSUPPORTED = Symbol('unsupported')
|
||||
|
||||
export interface MissingPackGroup {
|
||||
packId: string | null
|
||||
nodeTypes: MissingNodeType[]
|
||||
@@ -231,6 +240,7 @@ export function useErrorGroups(
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
@@ -559,6 +569,60 @@ export function useErrorGroups(
|
||||
return groups.sort((a, b) => a.priority - b.priority)
|
||||
}
|
||||
|
||||
/** Groups missing models. Asset-supported models group by directory; others go into a separate group.
|
||||
* Within each group, candidates with the same model name are merged into a single view model. */
|
||||
const missingModelGroups = computed<MissingModelGroup[]>(() => {
|
||||
const candidates = missingModelStore.missingModelCandidates
|
||||
if (!candidates?.length) return []
|
||||
|
||||
type GroupKey = string | null | typeof UNSUPPORTED
|
||||
const map = new Map<
|
||||
GroupKey,
|
||||
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
|
||||
>()
|
||||
|
||||
for (const c of candidates) {
|
||||
const groupKey: GroupKey = c.isAssetSupported
|
||||
? c.directory || null
|
||||
: UNSUPPORTED
|
||||
|
||||
const existing = map.get(groupKey)
|
||||
if (existing) {
|
||||
existing.candidates.push(c)
|
||||
} else {
|
||||
map.set(groupKey, {
|
||||
candidates: [c],
|
||||
isAssetSupported: c.isAssetSupported
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.entries())
|
||||
.sort(([dirA], [dirB]) => {
|
||||
if (dirA === UNSUPPORTED) return 1
|
||||
if (dirB === UNSUPPORTED) return -1
|
||||
if (dirA === null) return 1
|
||||
if (dirB === null) return -1
|
||||
return dirA.localeCompare(dirB)
|
||||
})
|
||||
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
|
||||
directory: typeof key === 'string' ? key : null,
|
||||
models: groupCandidatesByName(groupCandidates),
|
||||
isAssetSupported
|
||||
}))
|
||||
})
|
||||
|
||||
function buildMissingModelGroups(): ErrorGroup[] {
|
||||
if (!missingModelGroups.value.length) return []
|
||||
return [
|
||||
{
|
||||
type: 'missing_model' as const,
|
||||
title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${missingModelGroups.value.reduce((count, group) => count + group.models.length, 0)})`,
|
||||
priority: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
@@ -566,7 +630,11 @@ export function useErrorGroups(
|
||||
processNodeErrors(groupsMap)
|
||||
processExecutionError(groupsMap)
|
||||
|
||||
return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...toSortedGroups(groupsMap)
|
||||
]
|
||||
})
|
||||
|
||||
const tabErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
@@ -580,7 +648,11 @@ export function useErrorGroups(
|
||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||
: toSortedGroups(groupsMap)
|
||||
|
||||
return [...buildMissingNodeGroups(), ...executionGroups]
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...executionGroups
|
||||
]
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
@@ -615,6 +687,7 @@ export function useErrorGroups(
|
||||
missingNodeCache,
|
||||
groupedErrorMessages,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
swapNodeGroups
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +50,6 @@ const snapToGrid = computed({
|
||||
set: (value) => settingStore.set('pysssss.SnapToGrid', value)
|
||||
})
|
||||
|
||||
const alignNodesWhileDragging = computed({
|
||||
get: () => settingStore.get('Comfy.Canvas.AlignNodesWhileDragging'),
|
||||
set: (value) =>
|
||||
settingStore.set('Comfy.Canvas.AlignNodesWhileDragging', value)
|
||||
})
|
||||
|
||||
// CONNECTION LINKS settings
|
||||
const linkShape = computed({
|
||||
get: () => settingStore.get('Comfy.Graph.LinkMarkers'),
|
||||
@@ -166,11 +160,6 @@ function openFullSettings() {
|
||||
:label="t('rightSidePanel.globalSettings.snapNodesToGrid')"
|
||||
:tooltip="t('settings.pysssss_SnapToGrid.tooltip')"
|
||||
/>
|
||||
<FieldSwitch
|
||||
v-model="alignNodesWhileDragging"
|
||||
:label="t('rightSidePanel.globalSettings.alignNodesWhileDragging')"
|
||||
:tooltip="t('settings.Comfy_Canvas_AlignNodesWhileDragging.tooltip')"
|
||||
/>
|
||||
</div>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
|
||||
@@ -11,13 +11,15 @@
|
||||
}"
|
||||
@click="onLogoMenuClick($event)"
|
||||
>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div class="grid place-items-center-safe gap-0.5">
|
||||
<i
|
||||
class="col-span-full row-span-full icon-[lucide--chevron-down] size-3 translate-x-4 text-muted-foreground"
|
||||
/>
|
||||
<ComfyLogo
|
||||
alt="ComfyUI Logo"
|
||||
class="comfyui-logo h-[18px] w-[18px]"
|
||||
class="comfyui-logo col-span-full row-span-full size-4.5"
|
||||
mode="fill"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down] size-3 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
47
src/components/topbar/WorkflowExecutionIndicator.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { WorkflowExecutionState } from '@/stores/executionStore'
|
||||
|
||||
import WorkflowExecutionIndicator from './WorkflowExecutionIndicator.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
workflowExecution: {
|
||||
running: 'Workflow is running',
|
||||
completed: 'Workflow completed successfully',
|
||||
error: 'Workflow execution failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('WorkflowExecutionIndicator', () => {
|
||||
const mountWithI18n = (props: { state: WorkflowExecutionState }) =>
|
||||
mount(WorkflowExecutionIndicator, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
it('renders nothing for idle state', () => {
|
||||
const wrapper = mountWithI18n({ state: 'idle' })
|
||||
expect(wrapper.find('i').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it.each<{ state: WorkflowExecutionState; label: string }>([
|
||||
{ state: 'running', label: 'Workflow is running' },
|
||||
{ state: 'completed', label: 'Workflow completed successfully' },
|
||||
{ state: 'error', label: 'Workflow execution failed' }
|
||||
])('renders accessible icon for $state state', ({ state, label }) => {
|
||||
const wrapper = mountWithI18n({ state })
|
||||
const icon = wrapper.find('i')
|
||||
expect(icon.exists()).toBe(true)
|
||||
expect(icon.attributes('aria-label')).toBe(label)
|
||||
})
|
||||
})
|
||||
25
src/components/topbar/WorkflowExecutionIndicator.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<i
|
||||
v-if="state === 'running'"
|
||||
class="icon-[lucide--loader-circle] size-4 shrink-0 animate-spin text-muted-foreground"
|
||||
:aria-label="$t('workflowExecution.running')"
|
||||
/>
|
||||
<i
|
||||
v-else-if="state === 'completed'"
|
||||
class="icon-[lucide--circle-check] size-4 shrink-0 text-jade-600"
|
||||
:aria-label="$t('workflowExecution.completed')"
|
||||
/>
|
||||
<i
|
||||
v-else-if="state === 'error'"
|
||||
class="icon-[lucide--circle-alert] size-4 shrink-0 text-coral-600"
|
||||
:aria-label="$t('workflowExecution.error')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WorkflowExecutionState } from '@/stores/executionStore'
|
||||
|
||||
const { state } = defineProps<{
|
||||
state: WorkflowExecutionState
|
||||
}>()
|
||||
</script>
|
||||
@@ -20,6 +20,10 @@
|
||||
>
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<WorkflowExecutionIndicator
|
||||
v-if="showExecutionIndicator"
|
||||
:state="executionState"
|
||||
/>
|
||||
<div class="relative">
|
||||
<span
|
||||
v-if="shouldShowStatusIndicator"
|
||||
@@ -68,11 +72,12 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowExecutionState } from '@/composables/useWorkflowExecutionState'
|
||||
import {
|
||||
usePragmaticDraggable,
|
||||
usePragmaticDroppable
|
||||
@@ -87,6 +92,7 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
|
||||
|
||||
import WorkflowExecutionIndicator from './WorkflowExecutionIndicator.vue'
|
||||
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||
|
||||
interface WorkflowOption {
|
||||
@@ -116,6 +122,33 @@ const workflowTabRef = ref<HTMLElement | null>(null)
|
||||
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
|
||||
const workflowThumbnail = useWorkflowThumbnail()
|
||||
|
||||
const workflowId = computed(() => {
|
||||
const activeState = props.workflowOption.workflow.activeState
|
||||
const initialState = props.workflowOption.workflow.initialState
|
||||
return activeState?.id ?? initialState?.id
|
||||
})
|
||||
|
||||
const { state: executionState, clearResult } =
|
||||
useWorkflowExecutionState(workflowId)
|
||||
|
||||
const clearTimeoutId = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
watch(executionState, (newState) => {
|
||||
if (clearTimeoutId.value) {
|
||||
clearTimeout(clearTimeoutId.value)
|
||||
clearTimeoutId.value = null
|
||||
}
|
||||
|
||||
if (newState === 'completed') {
|
||||
clearTimeoutId.value = setTimeout(() => {
|
||||
clearResult()
|
||||
clearTimeoutId.value = null
|
||||
}, 5000)
|
||||
}
|
||||
})
|
||||
|
||||
const showExecutionIndicator = computed(() => executionState.value !== 'idle')
|
||||
|
||||
// Use computed refs to cache autosave settings
|
||||
const autoSaveSetting = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.AutoSave')
|
||||
@@ -287,6 +320,9 @@ usePragmaticDroppable(tabGetter, {
|
||||
|
||||
onUnmounted(() => {
|
||||
popoverRef.value?.hidePopover()
|
||||
if (clearTimeoutId.value) {
|
||||
clearTimeout(clearTimeoutId.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="prepend" />
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport
|
||||
:class="
|
||||
|
||||
@@ -45,8 +45,6 @@ const CORE_MENU_ITEMS = new Set([
|
||||
'Convert to Subgraph',
|
||||
'Frame selection',
|
||||
'Frame Nodes',
|
||||
'Align Selected To',
|
||||
'Distribute Nodes',
|
||||
'Minimize Node',
|
||||
'Expand',
|
||||
'Collapse',
|
||||
@@ -231,8 +229,6 @@ const MENU_ORDER: string[] = [
|
||||
'Convert to Subgraph',
|
||||
'Frame selection',
|
||||
'Frame Nodes',
|
||||
'Align Selected To',
|
||||
'Distribute Nodes',
|
||||
'Minimize Node',
|
||||
'Expand',
|
||||
'Collapse',
|
||||
@@ -305,14 +301,14 @@ export function buildStructuredMenu(options: MenuOption[]): MenuOption[] {
|
||||
// Section boundaries based on MENU_ORDER indices
|
||||
// Section 1: 0-2 (Rename, Copy, Duplicate)
|
||||
// Section 2: 3-8 (Run Branch, Pin, Unpin, Bypass, Remove Bypass, Mute)
|
||||
// Section 3: 9-17 (Convert to Subgraph ... Clone)
|
||||
// Section 4: 18-19 (Node Info, Color)
|
||||
// Section 5: 20+ (Image operations and fallback items)
|
||||
// Section 3: 9-15 (Convert to Subgraph, Frame selection, Minimize Node, Expand, Collapse, Resize, Clone)
|
||||
// Section 4: 16-17 (Node Info, Color)
|
||||
// Section 5: 18+ (Image operations and fallback items)
|
||||
const getSectionNumber = (index: number): number => {
|
||||
if (index <= 2) return 1
|
||||
if (index <= 8) return 2
|
||||
if (index <= 17) return 3
|
||||
if (index <= 19) return 4
|
||||
if (index <= 15) return 3
|
||||
if (index <= 17) return 4
|
||||
return 5
|
||||
}
|
||||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
showNodeOptions,
|
||||
toggleNodeOptions,
|
||||
useMoreOptionsMenu
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
|
||||
const selectedItems = ref([{ id: 'node-1' }, { id: 'node-2' }])
|
||||
const selectedNodes = ref([{ id: 'node-1' }, { id: 'node-2' }])
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphGroup: () => false
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
useSelectionState: () => ({
|
||||
selectedItems: computed(() => selectedItems.value),
|
||||
selectedNodes: computed(() => selectedNodes.value),
|
||||
nodeDef: computed(() => null),
|
||||
showNodeHelp: vi.fn(),
|
||||
hasSubgraphs: computed(() => false),
|
||||
hasImageNode: computed(() => false),
|
||||
hasOutputNodesSelected: computed(() => false),
|
||||
hasMultipleSelection: computed(() => true),
|
||||
computeSelectionFlags: () => ({
|
||||
collapsed: false,
|
||||
pinned: false,
|
||||
bypassed: false
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useImageMenuOptions', () => ({
|
||||
useImageMenuOptions: () => ({
|
||||
getImageMenuOptions: () => []
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeMenuOptions', () => ({
|
||||
useNodeMenuOptions: () => ({
|
||||
getNodeInfoOption: () => ({ label: 'Node Info' }),
|
||||
getNodeVisualOptions: () => [],
|
||||
getPinOption: () => ({ label: 'Pin' }),
|
||||
getBypassOption: () => ({ label: 'Bypass' }),
|
||||
getRunBranchOption: () => ({ label: 'Run Branch' })
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useGroupMenuOptions', () => ({
|
||||
useGroupMenuOptions: () => ({
|
||||
getFitGroupToNodesOption: () => ({ label: 'Fit Group to Nodes' }),
|
||||
getGroupColorOptions: () => ({ label: 'Group Color' }),
|
||||
getGroupModeOptions: () => []
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionMenuOptions', () => ({
|
||||
useSelectionMenuOptions: () => ({
|
||||
getBasicSelectionOptions: () => [{ label: 'Rename' }],
|
||||
getMultipleNodesOptions: () => [{ label: 'Frame Nodes' }],
|
||||
getSubgraphOptions: () => [],
|
||||
getAlignmentOptions: () => [{ label: 'Align Selected To' }]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/subgraph/promotedWidgetTypes', () => ({
|
||||
isPromotedWidgetView: () => false
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
getExtraOptionsForWidget: () => []
|
||||
}))
|
||||
|
||||
describe('useMoreOptionsMenu', () => {
|
||||
beforeEach(() => {
|
||||
selectedItems.value = [{ id: 'node-1' }, { id: 'node-2' }]
|
||||
selectedNodes.value = [{ id: 'node-1' }, { id: 'node-2' }]
|
||||
toggleNodeOptions(new Event('click'), 'toolbar')
|
||||
})
|
||||
|
||||
it('adds alignment options for right-click menus only', () => {
|
||||
const { menuOptions } = useMoreOptionsMenu()
|
||||
|
||||
expect(
|
||||
menuOptions.value.some((option) => option.label === 'Align Selected To')
|
||||
).toBe(false)
|
||||
|
||||
showNodeOptions(new MouseEvent('contextmenu'))
|
||||
|
||||
expect(
|
||||
menuOptions.value.some((option) => option.label === 'Align Selected To')
|
||||
).toBe(true)
|
||||
|
||||
toggleNodeOptions(new Event('click'), 'toolbar')
|
||||
|
||||
expect(
|
||||
menuOptions.value.some((option) => option.label === 'Align Selected To')
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -43,8 +43,6 @@ export interface SubMenuOption {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type NodeOptionsTriggerSource = 'contextmenu' | 'toolbar'
|
||||
|
||||
export enum BadgeVariant {
|
||||
NEW = 'new',
|
||||
DEPRECATED = 'deprecated'
|
||||
@@ -52,7 +50,6 @@ export enum BadgeVariant {
|
||||
|
||||
// Global singleton for NodeOptions component reference
|
||||
let nodeOptionsInstance: null | NodeOptionsInstance = null
|
||||
const nodeOptionsTriggerSource = ref<NodeOptionsTriggerSource>('toolbar')
|
||||
|
||||
const hoveredWidget = ref<[string, NodeId | undefined]>()
|
||||
|
||||
@@ -60,11 +57,7 @@ const hoveredWidget = ref<[string, NodeId | undefined]>()
|
||||
* Toggle the node options popover
|
||||
* @param event - The trigger event
|
||||
*/
|
||||
export function toggleNodeOptions(
|
||||
event: Event,
|
||||
triggerSource: NodeOptionsTriggerSource = 'toolbar'
|
||||
) {
|
||||
nodeOptionsTriggerSource.value = triggerSource
|
||||
export function toggleNodeOptions(event: Event) {
|
||||
if (nodeOptionsInstance?.toggle) {
|
||||
nodeOptionsInstance.toggle(event)
|
||||
}
|
||||
@@ -78,10 +71,8 @@ export function toggleNodeOptions(
|
||||
export function showNodeOptions(
|
||||
event: MouseEvent,
|
||||
widgetName?: string,
|
||||
nodeId?: NodeId,
|
||||
triggerSource: NodeOptionsTriggerSource = 'contextmenu'
|
||||
nodeId?: NodeId
|
||||
) {
|
||||
nodeOptionsTriggerSource.value = triggerSource
|
||||
hoveredWidget.value = widgetName ? [widgetName, nodeId] : undefined
|
||||
if (nodeOptionsInstance?.show) {
|
||||
nodeOptionsInstance.show(event)
|
||||
@@ -156,8 +147,7 @@ export function useMoreOptionsMenu() {
|
||||
const {
|
||||
getBasicSelectionOptions,
|
||||
getMultipleNodesOptions,
|
||||
getSubgraphOptions,
|
||||
getAlignmentOptions
|
||||
getSubgraphOptions
|
||||
} = useSelectionMenuOptions()
|
||||
|
||||
const hasSubgraphs = hasSubgraphsComputed
|
||||
@@ -237,9 +227,6 @@ export function useMoreOptionsMenu() {
|
||||
)
|
||||
if (hasMultipleNodes.value) {
|
||||
options.push(...getMultipleNodesOptions())
|
||||
if (nodeOptionsTriggerSource.value === 'contextmenu') {
|
||||
options.push(...getAlignmentOptions(selectedNodes.value.length))
|
||||
}
|
||||
}
|
||||
if (groupContext) {
|
||||
options.push(getFitGroupToNodesOption(groupContext))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ArrangementDirection } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Direction } from '@/lib/litegraph/src/interfaces'
|
||||
import { alignNodes, distributeNodes } from '@/lib/litegraph/src/utils/arrange'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
@@ -10,7 +10,7 @@ import { useCanvasRefresh } from './useCanvasRefresh'
|
||||
interface AlignOption {
|
||||
name: string
|
||||
localizedName: string
|
||||
value: ArrangementDirection
|
||||
value: Direction
|
||||
icon: string
|
||||
}
|
||||
|
||||
@@ -52,18 +52,6 @@ export function useNodeArrangement() {
|
||||
localizedName: t('contextMenu.Right'),
|
||||
value: 'right',
|
||||
icon: 'icon-[lucide--align-end-horizontal]'
|
||||
},
|
||||
{
|
||||
name: 'horizontal-center',
|
||||
localizedName: t('contextMenu.Horizontal Center'),
|
||||
value: 'horizontal-center',
|
||||
icon: 'icon-[lucide--align-center-horizontal]'
|
||||
},
|
||||
{
|
||||
name: 'vertical-center',
|
||||
localizedName: t('contextMenu.Vertical Center'),
|
||||
value: 'vertical-center',
|
||||
icon: 'icon-[lucide--align-center-vertical]'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -87,7 +75,7 @@ export function useNodeArrangement() {
|
||||
isLGraphNode(item)
|
||||
)
|
||||
|
||||
if (selectedNodes.length < 2) {
|
||||
if (selectedNodes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -102,7 +90,7 @@ export function useNodeArrangement() {
|
||||
isLGraphNode(item)
|
||||
)
|
||||
|
||||
if (selectedNodes.length < 3) {
|
||||
if (selectedNodes.length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -81,27 +81,6 @@ describe('useSelectionMenuOptions - multiple nodes options', () => {
|
||||
)
|
||||
expect(groupNodeOption).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns align options for two selected nodes', () => {
|
||||
const { getAlignmentOptions } = useSelectionMenuOptions()
|
||||
|
||||
const options = getAlignmentOptions(2)
|
||||
|
||||
expect(options.map((option) => option.label)).toEqual([
|
||||
'contextMenu.Align Selected To'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns align and distribute options for three selected nodes', () => {
|
||||
const { getAlignmentOptions } = useSelectionMenuOptions()
|
||||
|
||||
const options = getAlignmentOptions(3)
|
||||
|
||||
expect(options.map((option) => option.label)).toEqual([
|
||||
'contextMenu.Align Selected To',
|
||||
'contextMenu.Distribute Nodes'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
|
||||
@@ -125,31 +125,22 @@ export function useSelectionMenuOptions() {
|
||||
]
|
||||
}
|
||||
|
||||
const getAlignmentOptions = (selectedNodeCount: number): MenuOption[] => {
|
||||
const options: MenuOption[] = []
|
||||
|
||||
if (selectedNodeCount >= 2) {
|
||||
options.push({
|
||||
label: t('contextMenu.Align Selected To'),
|
||||
icon: 'icon-[lucide--align-start-horizontal]',
|
||||
hasSubmenu: true,
|
||||
submenu: alignSubmenu.value,
|
||||
action: () => {}
|
||||
})
|
||||
const getAlignmentOptions = (): MenuOption[] => [
|
||||
{
|
||||
label: t('contextMenu.Align Selected To'),
|
||||
icon: 'icon-[lucide--align-start-horizontal]',
|
||||
hasSubmenu: true,
|
||||
submenu: alignSubmenu.value,
|
||||
action: () => {}
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Distribute Nodes'),
|
||||
icon: 'icon-[lucide--align-center-horizontal]',
|
||||
hasSubmenu: true,
|
||||
submenu: distributeSubmenu.value,
|
||||
action: () => {}
|
||||
}
|
||||
|
||||
if (selectedNodeCount >= 3) {
|
||||
options.push({
|
||||
label: t('contextMenu.Distribute Nodes'),
|
||||
icon: 'icon-[lucide--align-center-horizontal]',
|
||||
hasSubmenu: true,
|
||||
submenu: distributeSubmenu.value,
|
||||
action: () => {}
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
]
|
||||
|
||||
const getDeleteOption = (): MenuOption => ({
|
||||
label: t('contextMenu.Delete'),
|
||||
|
||||
@@ -357,7 +357,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize viewer in standalone mode (for asset preview)
|
||||
* Initialize viewer in standalone mode (for asset preview).
|
||||
* Creates the Load3d instance once; subsequent calls reuse it.
|
||||
*/
|
||||
const initializeStandaloneViewer = async (
|
||||
containerRef: HTMLElement,
|
||||
@@ -366,6 +367,11 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
if (!containerRef) return
|
||||
|
||||
try {
|
||||
if (load3d) {
|
||||
await loadStandaloneModel(modelUrl)
|
||||
return
|
||||
}
|
||||
|
||||
isStandaloneMode.value = true
|
||||
|
||||
load3d = new Load3d(containerRef, {
|
||||
@@ -392,6 +398,23 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
setupAnimationEvents()
|
||||
} catch (error) {
|
||||
console.error('Error initializing standalone 3D viewer:', error)
|
||||
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a new model into an existing standalone viewer,
|
||||
* reusing the same WebGLRenderer.
|
||||
*/
|
||||
const loadStandaloneModel = async (modelUrl: string) => {
|
||||
if (!load3d) return
|
||||
|
||||
try {
|
||||
await load3d.loadModel(modelUrl)
|
||||
isSplatModel.value = load3d.isSplatModel()
|
||||
isPlyModel.value = load3d.isPlyModel()
|
||||
} catch (error) {
|
||||
console.error('Error loading model in standalone viewer:', error)
|
||||
useToastStore().addAlert('Failed to load 3D model')
|
||||
}
|
||||
}
|
||||
|
||||
71
src/composables/useWorkflowExecutionState.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { WorkflowExecutionState } from '@/stores/executionStore'
|
||||
|
||||
import { useWorkflowExecutionState } from './useWorkflowExecutionState'
|
||||
|
||||
const _workflowExecutionStates = ref(new Map<string, WorkflowExecutionState>())
|
||||
const _clearWorkflowExecutionResult = vi.fn()
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
getWorkflowExecutionState: (wid: string | undefined) => {
|
||||
if (!wid) return 'idle'
|
||||
return _workflowExecutionStates.value.get(wid) ?? 'idle'
|
||||
},
|
||||
clearWorkflowExecutionResult: _clearWorkflowExecutionResult
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useWorkflowExecutionState', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
_workflowExecutionStates.value = new Map()
|
||||
})
|
||||
|
||||
it('returns idle when workflowId is undefined', () => {
|
||||
const { state } = useWorkflowExecutionState(undefined)
|
||||
expect(state.value).toBe('idle')
|
||||
})
|
||||
|
||||
it('returns idle when no execution data exists', () => {
|
||||
const { state } = useWorkflowExecutionState('workflow-1')
|
||||
expect(state.value).toBe('idle')
|
||||
})
|
||||
|
||||
it('returns state from execution store map', () => {
|
||||
_workflowExecutionStates.value = new Map([['workflow-1', 'running']])
|
||||
const { state } = useWorkflowExecutionState('workflow-1')
|
||||
expect(state.value).toBe('running')
|
||||
})
|
||||
|
||||
it('reacts to workflowId ref changes', () => {
|
||||
const wfId = ref<string | undefined>('workflow-1')
|
||||
_workflowExecutionStates.value = new Map([
|
||||
['workflow-1', 'running'],
|
||||
['workflow-2', 'error']
|
||||
])
|
||||
|
||||
const { state } = useWorkflowExecutionState(wfId)
|
||||
expect(state.value).toBe('running')
|
||||
|
||||
wfId.value = 'workflow-2'
|
||||
expect(state.value).toBe('error')
|
||||
})
|
||||
|
||||
it('clearResult delegates to executionStore', () => {
|
||||
const { clearResult } = useWorkflowExecutionState('workflow-1')
|
||||
clearResult()
|
||||
expect(_clearWorkflowExecutionResult).toHaveBeenCalledWith('workflow-1')
|
||||
})
|
||||
|
||||
it('clearResult does nothing when workflowId is undefined', () => {
|
||||
const { clearResult } = useWorkflowExecutionState(undefined)
|
||||
clearResult()
|
||||
expect(_clearWorkflowExecutionResult).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
27
src/composables/useWorkflowExecutionState.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import type { WorkflowExecutionState } from '@/stores/executionStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
export function useWorkflowExecutionState(
|
||||
workflowId: MaybeRefOrGetter<string | undefined>
|
||||
) {
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
const state = computed<WorkflowExecutionState>(() =>
|
||||
executionStore.getWorkflowExecutionState(toValue(workflowId))
|
||||
)
|
||||
|
||||
function clearResult() {
|
||||
const wid = toValue(workflowId)
|
||||
if (wid) {
|
||||
executionStore.clearWorkflowExecutionResult(wid)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
clearResult
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ import type {
|
||||
ContextMenuDivElement,
|
||||
DefaultConnectionColors,
|
||||
Dictionary,
|
||||
ArrangementDirection,
|
||||
Direction,
|
||||
IBoundaryNodes,
|
||||
IColorable,
|
||||
IContextMenuOptions,
|
||||
@@ -1053,7 +1053,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
*/
|
||||
static alignNodes(
|
||||
nodes: Dictionary<LGraphNode>,
|
||||
direction: ArrangementDirection,
|
||||
direction: Direction,
|
||||
align_to?: LGraphNode
|
||||
): void {
|
||||
const newPositions = alignNodes(Object.values(nodes), direction, align_to)
|
||||
@@ -1077,7 +1077,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
function inner_clicked(value: string) {
|
||||
const newPositions = alignNodes(
|
||||
Object.values(LGraphCanvas.active_canvas.selected_nodes),
|
||||
value.toLowerCase() as ArrangementDirection,
|
||||
value.toLowerCase() as Direction,
|
||||
node
|
||||
)
|
||||
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
|
||||
@@ -1100,7 +1100,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
function inner_clicked(value: string) {
|
||||
const newPositions = alignNodes(
|
||||
Object.values(LGraphCanvas.active_canvas.selected_nodes),
|
||||
value.toLowerCase() as ArrangementDirection
|
||||
value.toLowerCase() as Direction
|
||||
)
|
||||
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
|
||||
LGraphCanvas.active_canvas.setDirty(true, true)
|
||||
@@ -4923,7 +4923,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
if (!LiteGraph.vueNodesMode || !this.overlayCtx) {
|
||||
this._drawConnectingLinks(ctx)
|
||||
this._drawVueDragAlignmentGuides(ctx)
|
||||
} else {
|
||||
this._drawOverlayLinks()
|
||||
}
|
||||
@@ -5028,8 +5027,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
octx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height)
|
||||
|
||||
const hasDragGuides = layoutStore.vueDragSnapGuides.value.length > 0
|
||||
if (!this.linkConnector.isConnecting && !hasDragGuides) return
|
||||
if (!this.linkConnector.isConnecting) return
|
||||
|
||||
octx.save()
|
||||
|
||||
@@ -5038,39 +5036,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
this.ds.toCanvasContext(octx)
|
||||
|
||||
if (this.linkConnector.isConnecting) {
|
||||
this._drawConnectingLinks(octx)
|
||||
}
|
||||
this._drawVueDragAlignmentGuides(octx)
|
||||
this._drawConnectingLinks(octx)
|
||||
|
||||
octx.restore()
|
||||
}
|
||||
|
||||
private _drawVueDragAlignmentGuides(ctx: CanvasRenderingContext2D): void {
|
||||
const guides = layoutStore.vueDragSnapGuides.value
|
||||
if (!guides.length) return
|
||||
|
||||
const scale = this.ds.scale || 1
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.lineWidth = 1 / scale
|
||||
ctx.strokeStyle = '#ff4d4f'
|
||||
ctx.setLineDash([6 / scale, 4 / scale])
|
||||
|
||||
for (const guide of guides) {
|
||||
if (guide.axis === 'vertical') {
|
||||
ctx.moveTo(guide.coordinate, guide.start)
|
||||
ctx.lineTo(guide.coordinate, guide.end)
|
||||
} else {
|
||||
ctx.moveTo(guide.start, guide.coordinate)
|
||||
ctx.lineTo(guide.end, guide.coordinate)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/** Get the target snap / highlight point in graph space */
|
||||
private _getHighlightPosition(): Readonly<Point> {
|
||||
return LiteGraph.snaps_for_comfy
|
||||
|
||||
@@ -281,13 +281,7 @@ export interface IBoundaryNodes {
|
||||
left: LGraphNode
|
||||
}
|
||||
|
||||
export type ArrangementDirection =
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'horizontal-center'
|
||||
| 'vertical-center'
|
||||
export type Direction = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
/** Resize handle positions (compass points) */
|
||||
export type CompassCorners = 'NE' | 'SE' | 'SW' | 'NW'
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
import type { LGraphNode } from '../LGraphNode'
|
||||
import type {
|
||||
ArrangementDirection,
|
||||
IBoundaryNodes,
|
||||
NewNodePosition
|
||||
} from '../interfaces'
|
||||
|
||||
interface NodeSelectionBounds {
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
left: number
|
||||
centerX: number
|
||||
centerY: number
|
||||
}
|
||||
import type { Direction, IBoundaryNodes, NewNodePosition } from '../interfaces'
|
||||
|
||||
/**
|
||||
* Finds the nodes that are farthest in all four directions, representing the boundary of the nodes.
|
||||
@@ -58,7 +45,7 @@ export function distributeNodes(
|
||||
horizontal?: boolean
|
||||
): NewNodePosition[] {
|
||||
const nodeCount = nodes?.length
|
||||
if (!(nodeCount > 2)) return []
|
||||
if (!(nodeCount > 1)) return []
|
||||
|
||||
const index = horizontal ? 0 : 1
|
||||
|
||||
@@ -101,7 +88,7 @@ export function distributeNodes(
|
||||
*/
|
||||
export function alignNodes(
|
||||
nodes: LGraphNode[],
|
||||
direction: ArrangementDirection,
|
||||
direction: Direction,
|
||||
align_to?: LGraphNode
|
||||
): NewNodePosition[] {
|
||||
if (!nodes) return []
|
||||
@@ -113,16 +100,6 @@ export function alignNodes(
|
||||
|
||||
if (boundary === null) return []
|
||||
|
||||
const selectionBounds = getNodeSelectionBounds(nodes)
|
||||
const alignToCenterX =
|
||||
align_to === undefined
|
||||
? selectionBounds.centerX
|
||||
: align_to.pos[0] + align_to.size[0] * 0.5
|
||||
const alignToCenterY =
|
||||
align_to === undefined
|
||||
? selectionBounds.centerY
|
||||
: align_to.pos[1] + align_to.size[1] * 0.5
|
||||
|
||||
const nodePositions = nodes.map((node): NewNodePosition => {
|
||||
switch (direction) {
|
||||
case 'right':
|
||||
@@ -157,22 +134,6 @@ export function alignNodes(
|
||||
y: boundary.bottom.pos[1] + boundary.bottom.size[1] - node.size[1]
|
||||
}
|
||||
}
|
||||
case 'horizontal-center':
|
||||
return {
|
||||
node,
|
||||
newPos: {
|
||||
x: node.pos[0],
|
||||
y: alignToCenterY - node.size[1] * 0.5
|
||||
}
|
||||
}
|
||||
case 'vertical-center':
|
||||
return {
|
||||
node,
|
||||
newPos: {
|
||||
x: alignToCenterX - node.size[0] * 0.5,
|
||||
y: node.pos[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -181,24 +142,3 @@ export function alignNodes(
|
||||
}
|
||||
return nodePositions
|
||||
}
|
||||
|
||||
function getNodeSelectionBounds(nodes: LGraphNode[]): NodeSelectionBounds {
|
||||
const boundary = getBoundaryNodes(nodes)
|
||||
if (!boundary) {
|
||||
throw new TypeError('Cannot calculate selection bounds without nodes.')
|
||||
}
|
||||
|
||||
const top = boundary.top.pos[1]
|
||||
const left = boundary.left.pos[0]
|
||||
const right = boundary.right.pos[0] + boundary.right.size[0]
|
||||
const bottom = boundary.bottom.pos[1] + boundary.bottom.size[1]
|
||||
|
||||
return {
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
left,
|
||||
centerX: left + (right - left) * 0.5,
|
||||
centerY: top + (bottom - top) * 0.5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,8 +546,6 @@
|
||||
"Bottom": "Bottom",
|
||||
"Left": "Left",
|
||||
"Right": "Right",
|
||||
"Horizontal Center": "Horizontal Center",
|
||||
"Vertical Center": "Vertical Center",
|
||||
"Horizontal": "Horizontal",
|
||||
"Vertical": "Vertical",
|
||||
"new": "new",
|
||||
@@ -1959,6 +1957,7 @@
|
||||
"exportSuccess": "Successfully exported model as {format}",
|
||||
"fileLoadError": "Unable to find workflow in {fileName}",
|
||||
"dropFileError": "Unable to process dropped item: {error}",
|
||||
"missingModelVerificationFailed": "Failed to verify missing models. Some models may not be shown in the Errors tab.",
|
||||
"interrupted": "Execution has been interrupted",
|
||||
"pendingTasksDeleted": "Pending tasks deleted",
|
||||
"nothingToGroup": "Nothing to group",
|
||||
@@ -3337,7 +3336,6 @@
|
||||
"nodes2": "Nodes 2.0",
|
||||
"gridSpacing": "Grid spacing",
|
||||
"snapNodesToGrid": "Snap nodes to grid",
|
||||
"alignNodesWhileDragging": "Align nodes while dragging",
|
||||
"linkShape": "Link shape",
|
||||
"showConnectedLinks": "Show connected links",
|
||||
"viewAllSettings": "View all settings"
|
||||
@@ -3391,6 +3389,33 @@
|
||||
"viewInManager": "View in Manager",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand"
|
||||
},
|
||||
"missingModels": {
|
||||
"urlPlaceholder": "Paste Model URL (Civitai or Hugging Face)",
|
||||
"or": "OR",
|
||||
"useFromLibrary": "Use from Library",
|
||||
"usingFromLibrary": "Using from Library",
|
||||
"unsupportedUrl": "Only Civitai and Hugging Face URLs are supported.",
|
||||
"metadataFetchFailed": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"imported": "Imported",
|
||||
"importFailed": "Import failed",
|
||||
"typeMismatch": "This model seems to be a \"{detectedType}\". Are you sure?",
|
||||
"importAnyway": "Import Anyway",
|
||||
"alreadyExistsInCategory": "This model already exists in \"{category}\"",
|
||||
"customNodeDownloadDisabled": "Cloud environment does not support model imports for custom nodes in this section. Please use standard loader nodes or substitute with a model from the library below.",
|
||||
"importNotSupported": "Import Not Supported",
|
||||
"copyModelName": "Copy model name",
|
||||
"confirmSelection": "Confirm selection",
|
||||
"locateNode": "Locate node on canvas",
|
||||
"cancelSelection": "Cancel selection",
|
||||
"clearUrl": "Clear URL",
|
||||
"expandNodes": "Show referencing nodes",
|
||||
"collapseNodes": "Hide referencing nodes",
|
||||
"unknownCategory": "Unknown",
|
||||
"missingModelsTitle": "Missing Models",
|
||||
"assetLoadTimeout": "Model detection timed out. Try reloading the workflow."
|
||||
}
|
||||
},
|
||||
"errorOverlay": {
|
||||
@@ -3564,5 +3589,10 @@
|
||||
"builderMenu": {
|
||||
"enterAppMode": "Enter app mode",
|
||||
"exitAppBuilder": "Exit app builder"
|
||||
},
|
||||
"workflowExecution": {
|
||||
"running": "Workflow is running",
|
||||
"completed": "Workflow completed successfully",
|
||||
"error": "Workflow execution failed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +55,6 @@
|
||||
"name": "Show selection toolbox",
|
||||
"tooltip": "Display a floating toolbar when nodes are selected, providing quick access to common actions."
|
||||
},
|
||||
"Comfy_Canvas_AlignNodesWhileDragging": {
|
||||
"name": "Align nodes while dragging",
|
||||
"tooltip": "When enabled in Nodes 2.0, dragging a node selection near another node will snap matching edges and centers together."
|
||||
},
|
||||
"Comfy_ConfirmClear": {
|
||||
"name": "Require confirmation when clearing workflow"
|
||||
},
|
||||
@@ -352,8 +348,8 @@
|
||||
"tooltip": "The maximum number of tasks that show in the queue history."
|
||||
},
|
||||
"Comfy_Queue_QPOV2": {
|
||||
"name": "Use the unified job queue in the Assets side panel",
|
||||
"tooltip": "Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout."
|
||||
"name": "Docked job history/queue panel",
|
||||
"tooltip": "Replaces the floating job queue panel with an equivalent job queue embedded in the job history side panel. You can disable this to return to the floating panel layout."
|
||||
},
|
||||
"Comfy_QueueButton_BatchCountLimit": {
|
||||
"name": "Batch count limit",
|
||||
|
||||
193
src/platform/missingModel/components/MissingModelCard.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
MissingModelGroup,
|
||||
MissingModelViewModel
|
||||
} from '@/platform/missingModel/types'
|
||||
|
||||
vi.mock('./MissingModelRow.vue', () => ({
|
||||
default: {
|
||||
name: 'MissingModelRow',
|
||||
template: '<div class="model-row" />',
|
||||
props: ['model', 'directory', 'showNodeIdBadge', 'isAssetSupported'],
|
||||
emits: ['locate-model']
|
||||
}
|
||||
}))
|
||||
|
||||
import MissingModelCard from './MissingModelCard.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
rightSidePanel: {
|
||||
missingModels: {
|
||||
importNotSupported: 'Import Not Supported',
|
||||
customNodeDownloadDisabled:
|
||||
'Cloud environment does not support model imports for custom nodes.',
|
||||
unknownCategory: 'Unknown Category'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function makeViewModel(
|
||||
name: string,
|
||||
nodeId: string = '1'
|
||||
): MissingModelViewModel {
|
||||
return {
|
||||
name,
|
||||
representative: {
|
||||
name,
|
||||
nodeId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId, widgetName: 'ckpt_name' }]
|
||||
}
|
||||
}
|
||||
|
||||
function makeGroup(
|
||||
opts: {
|
||||
directory?: string | null
|
||||
isAssetSupported?: boolean
|
||||
modelNames?: string[]
|
||||
} = {}
|
||||
): MissingModelGroup {
|
||||
const names = opts.modelNames ?? ['model.safetensors']
|
||||
return {
|
||||
directory: 'directory' in opts ? (opts.directory ?? null) : 'checkpoints',
|
||||
isAssetSupported: opts.isAssetSupported ?? true,
|
||||
models: names.map((n, i) => makeViewModel(n, String(i + 1)))
|
||||
}
|
||||
}
|
||||
|
||||
function mountCard(
|
||||
props: Partial<{
|
||||
missingModelGroups: MissingModelGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}> = {}
|
||||
) {
|
||||
return mount(MissingModelCard, {
|
||||
props: {
|
||||
missingModelGroups: [makeGroup()],
|
||||
showNodeIdBadge: false,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('MissingModelCard', () => {
|
||||
describe('Rendering & Props', () => {
|
||||
it('renders directory name in category header', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ directory: 'loras' })]
|
||||
})
|
||||
expect(wrapper.text()).toContain('loras')
|
||||
})
|
||||
|
||||
it('renders translated unknown category when directory is null', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ directory: null })]
|
||||
})
|
||||
expect(wrapper.text()).toContain('Unknown Category')
|
||||
})
|
||||
|
||||
it('renders model count in category header', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({ modelNames: ['a.safetensors', 'b.safetensors'] })
|
||||
]
|
||||
})
|
||||
expect(wrapper.text()).toContain('(2)')
|
||||
})
|
||||
|
||||
it('renders correct number of MissingModelRow components', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({
|
||||
modelNames: ['a.safetensors', 'b.safetensors', 'c.safetensors']
|
||||
})
|
||||
]
|
||||
})
|
||||
expect(
|
||||
wrapper.findAllComponents({ name: 'MissingModelRow' })
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders multiple groups', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({ directory: 'checkpoints' }),
|
||||
makeGroup({ directory: 'loras' })
|
||||
]
|
||||
})
|
||||
expect(wrapper.text()).toContain('checkpoints')
|
||||
expect(wrapper.text()).toContain('loras')
|
||||
})
|
||||
|
||||
it('renders zero rows when missingModelGroups is empty', () => {
|
||||
const wrapper = mountCard({ missingModelGroups: [] })
|
||||
expect(
|
||||
wrapper.findAllComponents({ name: 'MissingModelRow' })
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('passes props correctly to MissingModelRow children', () => {
|
||||
const wrapper = mountCard({ showNodeIdBadge: true })
|
||||
const row = wrapper.findComponent({ name: 'MissingModelRow' })
|
||||
expect(row.props('showNodeIdBadge')).toBe(true)
|
||||
expect(row.props('isAssetSupported')).toBe(true)
|
||||
expect(row.props('directory')).toBe('checkpoints')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Asset Unsupported Group', () => {
|
||||
it('shows "Import Not Supported" header for unsupported groups', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ isAssetSupported: false })]
|
||||
})
|
||||
expect(wrapper.text()).toContain('Import Not Supported')
|
||||
})
|
||||
|
||||
it('shows info notice for unsupported groups', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ isAssetSupported: false })]
|
||||
})
|
||||
expect(wrapper.text()).toContain(
|
||||
'Cloud environment does not support model imports'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides info notice for supported groups', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ isAssetSupported: true })]
|
||||
})
|
||||
expect(wrapper.text()).not.toContain(
|
||||
'Cloud environment does not support model imports'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Handling', () => {
|
||||
it('emits locateModel when child emits locate-model', async () => {
|
||||
const wrapper = mountCard()
|
||||
const row = wrapper.findComponent({ name: 'MissingModelRow' })
|
||||
await row.vm.$emit('locate-model', '42')
|
||||
expect(wrapper.emitted('locateModel')).toBeTruthy()
|
||||
expect(wrapper.emitted('locateModel')?.[0]).toEqual(['42'])
|
||||
})
|
||||
})
|
||||
})
|
||||
78
src/platform/missingModel/components/MissingModelCard.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2">
|
||||
<!-- Category groups (by directory) -->
|
||||
<div
|
||||
v-for="group in missingModelGroups"
|
||||
:key="`${group.isAssetSupported ? 'supported' : 'unsupported'}::${group.directory ?? '__unknown__'}`"
|
||||
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
|
||||
>
|
||||
<!-- Category header -->
|
||||
<div class="flex h-8 w-full items-center">
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium"
|
||||
:class="
|
||||
!group.isAssetSupported || group.directory === null
|
||||
? 'text-warning-background'
|
||||
: 'text-destructive-background-hover'
|
||||
"
|
||||
>
|
||||
<span v-if="!group.isAssetSupported" class="text-warning-background">
|
||||
{{ t('rightSidePanel.missingModels.importNotSupported') }}
|
||||
({{ group.models.length }})
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
group.directory ??
|
||||
t('rightSidePanel.missingModels.unknownCategory')
|
||||
}}
|
||||
({{ group.models.length }})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Asset unsupported group notice -->
|
||||
<div
|
||||
v-if="!group.isAssetSupported"
|
||||
class="flex items-start gap-1.5 px-0.5 py-1 pl-2"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 icon-[lucide--info] size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Model rows -->
|
||||
<div class="flex flex-col gap-1 overflow-hidden pl-2">
|
||||
<MissingModelRow
|
||||
v-for="model in group.models"
|
||||
:key="model.name"
|
||||
:model="model"
|
||||
:directory="group.directory"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:is-asset-supported="group.isAssetSupported"
|
||||
@locate-model="emit('locateModel', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
|
||||
|
||||
const { missingModelGroups, showNodeIdBadge } = defineProps<{
|
||||
missingModelGroups: MissingModelGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateModel: [nodeId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
|
||||
<span class="text-xs font-bold text-muted-foreground">
|
||||
{{ t('rightSidePanel.missingModels.or') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
:model-value="modelValue"
|
||||
:disabled="options.length === 0"
|
||||
@update:model-value="handleSelect"
|
||||
>
|
||||
<SelectTrigger
|
||||
size="md"
|
||||
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
|
||||
>
|
||||
<SelectValue
|
||||
:placeholder="t('rightSidePanel.missingModels.useFromLibrary')"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<template v-if="options.length > 4" #prepend>
|
||||
<div class="px-1 pb-1.5">
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
v-model="filterQuery"
|
||||
type="text"
|
||||
:aria-label="t('g.searchPlaceholder', { subject: '' })"
|
||||
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('g.searchPlaceholder', { subject: '' })"
|
||||
@keydown.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SelectItem
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ option.name }}
|
||||
</SelectItem>
|
||||
<div
|
||||
v-if="filteredOptions.length === 0"
|
||||
role="status"
|
||||
class="px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('g.noResultsFound') }}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFuse } from '@vueuse/integrations/useFuse'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
|
||||
const { options, showDivider = false } = defineProps<{
|
||||
modelValue: string | undefined
|
||||
options: { name: string; value: string }[]
|
||||
showDivider?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const filterQuery = ref('')
|
||||
|
||||
watch(
|
||||
() => options.length,
|
||||
(len) => {
|
||||
if (len <= 4) filterQuery.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
const { results: fuseResults } = useFuse(filterQuery, () => options, {
|
||||
fuseOptions: {
|
||||
keys: ['name'],
|
||||
threshold: 0.4,
|
||||
ignoreLocation: true
|
||||
},
|
||||
matchAllWhenSearchEmpty: true
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
|
||||
|
||||
function handleSelect(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
filterQuery.value = ''
|
||||
emit('select', value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
224
src/platform/missingModel/components/MissingModelRow.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col pb-3">
|
||||
<!-- Model header -->
|
||||
<div class="flex h-8 w-full items-center gap-2">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="text-foreground icon-[lucide--file-check] size-4 shrink-0"
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 items-center">
|
||||
<p
|
||||
class="text-foreground min-w-0 truncate text-sm font-medium"
|
||||
:title="model.name"
|
||||
>
|
||||
{{ model.name }} ({{ model.referencingNodes.length }})
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 hover:bg-transparent"
|
||||
:aria-label="t('rightSidePanel.missingModels.copyModelName')"
|
||||
:title="t('rightSidePanel.missingModels.copyModelName')"
|
||||
@click="copyToClipboard(model.name)"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--copy] size-3.5 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.confirmSelection')"
|
||||
:disabled="!canConfirm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 rounded-lg transition-colors',
|
||||
canConfirm ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
|
||||
)
|
||||
"
|
||||
@click="handleLibrarySelect"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--check] size-4"
|
||||
:class="canConfirm ? 'text-primary' : 'text-foreground'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="model.referencingNodes.length > 0"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingModels.collapseNodes')
|
||||
: t('rightSidePanel.missingModels.expandNodes')
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-180'
|
||||
)
|
||||
"
|
||||
@click="toggleModelExpand(modelKey)"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Referencing nodes -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
|
||||
>
|
||||
<div
|
||||
v-for="ref in model.referencingNodes"
|
||||
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
|
||||
class="flex h-7 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
#{{ ref.nodeId }}
|
||||
</span>
|
||||
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
|
||||
</p>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.locateNode')"
|
||||
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('locateModel', String(ref.nodeId))"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Status card -->
|
||||
<TransitionCollapse>
|
||||
<MissingModelStatusCard
|
||||
v-if="selectedLibraryModel[modelKey]"
|
||||
:model-name="selectedLibraryModel[modelKey]"
|
||||
:is-download-active="isDownloadActive"
|
||||
:download-status="downloadStatus"
|
||||
:category-mismatch="importCategoryMismatch[modelKey]"
|
||||
@cancel="cancelLibrarySelect(modelKey)"
|
||||
/>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Input area -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="!selectedLibraryModel[modelKey]"
|
||||
class="mt-1 flex flex-col gap-2"
|
||||
>
|
||||
<template v-if="isAssetSupported">
|
||||
<MissingModelUrlInput
|
||||
:model-key="modelKey"
|
||||
:directory="directory"
|
||||
:type-mismatch="typeMismatch"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<TransitionCollapse>
|
||||
<MissingModelLibrarySelect
|
||||
v-if="!urlInputs[modelKey]"
|
||||
:model-value="getComboValue(model.representative)"
|
||||
:options="comboOptions"
|
||||
:show-divider="model.representative.isAssetSupported"
|
||||
@select="handleComboSelect(modelKey, $event)"
|
||||
/>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import MissingModelStatusCard from '@/platform/missingModel/components/MissingModelStatusCard.vue'
|
||||
import MissingModelUrlInput from '@/platform/missingModel/components/MissingModelUrlInput.vue'
|
||||
import MissingModelLibrarySelect from '@/platform/missingModel/components/MissingModelLibrarySelect.vue'
|
||||
import type { MissingModelViewModel } from '@/platform/missingModel/types'
|
||||
|
||||
import {
|
||||
useMissingModelInteractions,
|
||||
getModelStateKey,
|
||||
getNodeDisplayLabel,
|
||||
getComboValue
|
||||
} from '@/platform/missingModel/composables/useMissingModelInteractions'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
|
||||
const { model, directory, isAssetSupported } = defineProps<{
|
||||
model: MissingModelViewModel
|
||||
directory: string | null
|
||||
showNodeIdBadge: boolean
|
||||
isAssetSupported: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateModel: [nodeId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
const modelKey = computed(() =>
|
||||
getModelStateKey(model.name, directory, isAssetSupported)
|
||||
)
|
||||
|
||||
const downloadStatus = computed(() => getDownloadStatus(modelKey.value))
|
||||
const comboOptions = computed(() => getComboOptions(model.representative))
|
||||
const canConfirm = computed(() => isSelectionConfirmable(modelKey.value))
|
||||
const expanded = computed(() => isModelExpanded(modelKey.value))
|
||||
const typeMismatch = computed(() => getTypeMismatch(modelKey.value, directory))
|
||||
const isDownloadActive = computed(
|
||||
() =>
|
||||
downloadStatus.value?.status === 'running' ||
|
||||
downloadStatus.value?.status === 'created'
|
||||
)
|
||||
|
||||
const store = useMissingModelStore()
|
||||
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
|
||||
storeToRefs(store)
|
||||
|
||||
const {
|
||||
toggleModelExpand,
|
||||
isModelExpanded,
|
||||
getComboOptions,
|
||||
handleComboSelect,
|
||||
isSelectionConfirmable,
|
||||
cancelLibrarySelect,
|
||||
confirmLibrarySelect,
|
||||
getTypeMismatch,
|
||||
getDownloadStatus
|
||||
} = useMissingModelInteractions()
|
||||
|
||||
function handleLibrarySelect() {
|
||||
confirmLibrarySelect(
|
||||
modelKey.value,
|
||||
model.name,
|
||||
model.referencingNodes,
|
||||
directory
|
||||
)
|
||||
}
|
||||
</script>
|
||||
108
src/platform/missingModel/components/MissingModelStatusCard.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div
|
||||
aria-live="polite"
|
||||
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
|
||||
>
|
||||
<!-- Progress bar fill -->
|
||||
<div
|
||||
v-if="isDownloadActive"
|
||||
class="absolute inset-y-0 left-0 bg-primary/10 transition-all duration-200 ease-linear"
|
||||
:style="{ width: (downloadStatus?.progress ?? 0) * 100 + '%' }"
|
||||
/>
|
||||
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<i
|
||||
v-if="categoryMismatch"
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 icon-[lucide--triangle-alert] size-5 text-warning-background"
|
||||
/>
|
||||
<i
|
||||
v-else-if="downloadStatus?.status === 'failed'"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--circle-alert] size-5 text-destructive-background"
|
||||
/>
|
||||
<i
|
||||
v-else-if="downloadStatus?.status === 'completed'"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--check-circle] size-5 text-success-background"
|
||||
/>
|
||||
<i
|
||||
v-else-if="isDownloadActive"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--file-check] size-5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<span class="text-foreground truncate text-xs/tight font-medium">
|
||||
{{ modelName }}
|
||||
</span>
|
||||
<span class="mt-0.5 text-xs/tight text-muted-foreground">
|
||||
<template v-if="categoryMismatch">
|
||||
{{
|
||||
t('rightSidePanel.missingModels.alreadyExistsInCategory', {
|
||||
category: categoryMismatch
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="isDownloadActive">
|
||||
{{ t('rightSidePanel.missingModels.importing') }}
|
||||
{{ Math.round((downloadStatus?.progress ?? 0) * 100) }}%
|
||||
</template>
|
||||
<template v-else-if="downloadStatus?.status === 'completed'">
|
||||
{{ t('rightSidePanel.missingModels.imported') }}
|
||||
</template>
|
||||
<template v-else-if="downloadStatus?.status === 'failed'">
|
||||
{{
|
||||
downloadStatus?.error ||
|
||||
t('rightSidePanel.missingModels.importFailed')
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('rightSidePanel.missingModels.usingFromLibrary') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.cancelSelection')"
|
||||
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
|
||||
const {
|
||||
modelName,
|
||||
isDownloadActive,
|
||||
downloadStatus = null,
|
||||
categoryMismatch = null
|
||||
} = defineProps<{
|
||||
modelName: string
|
||||
isDownloadActive: boolean
|
||||
downloadStatus?: AssetDownload | null
|
||||
categoryMismatch?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
156
src/platform/missingModel/components/MissingModelUrlInput.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 items-center rounded-lg border border-transparent bg-secondary-background px-3 transition-colors focus-within:border-interface-stroke',
|
||||
!canImportModels && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
v-bind="upgradePromptAttrs"
|
||||
@click="!canImportModels && showUploadDialog()"
|
||||
>
|
||||
<label :for="`url-input-${modelKey}`" class="sr-only">
|
||||
{{ t('rightSidePanel.missingModels.urlPlaceholder') }}
|
||||
</label>
|
||||
<input
|
||||
:id="`url-input-${modelKey}`"
|
||||
type="text"
|
||||
:value="urlInputs[modelKey] ?? ''"
|
||||
:readonly="!canImportModels"
|
||||
:placeholder="t('rightSidePanel.missingModels.urlPlaceholder')"
|
||||
:class="
|
||||
cn(
|
||||
'text-foreground w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground',
|
||||
!canImportModels && 'pointer-events-none opacity-60'
|
||||
)
|
||||
"
|
||||
@input="
|
||||
handleUrlInput(modelKey, ($event.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
v-if="urlInputs[modelKey]"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.clearUrl')"
|
||||
class="ml-1 shrink-0"
|
||||
@click.stop="handleUrlInput(modelKey, '')"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--x] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TransitionCollapse>
|
||||
<div v-if="urlMetadata[modelKey]" class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 px-0.5 pt-0.5">
|
||||
<span class="text-foreground min-w-0 truncate text-xs font-bold">
|
||||
{{ urlMetadata[modelKey]?.filename }}
|
||||
</span>
|
||||
<span
|
||||
v-if="(urlMetadata[modelKey]?.content_length ?? 0) > 0"
|
||||
class="shrink-0 rounded-sm bg-secondary-background-selected px-1.5 py-0.5 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{{ formatSize(urlMetadata[modelKey]?.content_length ?? 0) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="typeMismatch" class="flex items-start gap-1.5 px-0.5">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 icon-[lucide--triangle-alert] size-3 shrink-0 text-warning-background"
|
||||
/>
|
||||
<span class="text-xs/tight text-warning-background">
|
||||
{{
|
||||
t('rightSidePanel.missingModels.typeMismatch', {
|
||||
detectedType: typeMismatch
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pt-0.5">
|
||||
<Button
|
||||
variant="primary"
|
||||
class="h-9 w-full justify-center gap-2 text-sm font-semibold"
|
||||
:loading="urlImporting[modelKey]"
|
||||
@click="handleImport(modelKey, directory)"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--download] size-4" />
|
||||
{{
|
||||
typeMismatch
|
||||
? t('rightSidePanel.missingModels.importAnyway')
|
||||
: t('rightSidePanel.missingModels.import')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="urlFetching[modelKey]"
|
||||
aria-live="polite"
|
||||
class="flex items-center justify-center py-2"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<span class="sr-only">{{ t('g.loading') }}</span>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<TransitionCollapse>
|
||||
<div v-if="urlErrors[modelKey]" class="px-0.5" role="alert">
|
||||
<span class="text-xs text-destructive-background-hover">
|
||||
{{ urlErrors[modelKey] }}
|
||||
</span>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingModelInteractions } from '@/platform/missingModel/composables/useMissingModelInteractions'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
||||
|
||||
const { modelKey, directory, typeMismatch } = defineProps<{
|
||||
modelKey: string
|
||||
directory: string | null
|
||||
typeMismatch: string | null
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { flags } = useFeatureFlags()
|
||||
const canImportModels = computed(() => flags.privateModelsEnabled)
|
||||
const { showUploadDialog } = useModelUpload()
|
||||
|
||||
const store = useMissingModelStore()
|
||||
const { urlInputs, urlMetadata, urlFetching, urlErrors, urlImporting } =
|
||||
storeToRefs(store)
|
||||
|
||||
const { handleUrlInput, handleImport } = useMissingModelInteractions()
|
||||
|
||||
const upgradePromptAttrs = computed(() =>
|
||||
canImportModels.value
|
||||
? {}
|
||||
: {
|
||||
role: 'button',
|
||||
tabindex: 0,
|
||||
onKeydown: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
showUploadDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,516 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
|
||||
const mockGetNodeByExecutionId = vi.fn()
|
||||
const mockResolveNodeDisplayName = vi.fn()
|
||||
const mockValidateSourceUrl = vi.fn()
|
||||
const mockGetAssetMetadata = vi.fn()
|
||||
const mockGetAssetDisplayName = vi.fn((a: { name: string }) => a.name)
|
||||
const mockGetAssetFilename = vi.fn((a: { name: string }) => a.name)
|
||||
const mockGetAssets = vi.fn()
|
||||
const mockUpdateModelsForNodeType = vi.fn()
|
||||
const mockGetAllNodeProviders = vi.fn()
|
||||
const mockDownloadList = vi.fn(
|
||||
(): Array<{ taskId: string; status: string }> => []
|
||||
)
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((_key: string, fallback: string) => fallback)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: (...args: unknown[]) =>
|
||||
mockGetNodeByExecutionId(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeTitleUtil', () => ({
|
||||
resolveNodeDisplayName: (...args: unknown[]) =>
|
||||
mockResolveNodeDisplayName(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({
|
||||
getAssets: mockGetAssets,
|
||||
updateModelsForNodeType: mockUpdateModelsForNodeType,
|
||||
invalidateModelsForCategory: vi.fn(),
|
||||
updateModelsForTag: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/assetDownloadStore', () => ({
|
||||
useAssetDownloadStore: () => ({
|
||||
get downloadList() {
|
||||
return mockDownloadList()
|
||||
},
|
||||
trackDownload: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({
|
||||
getAllNodeProviders: mockGetAllNodeProviders
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetMetadata: (...args: unknown[]) => mockGetAssetMetadata(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/assetMetadataUtils', () => ({
|
||||
getAssetDisplayName: (a: { name: string }) => mockGetAssetDisplayName(a),
|
||||
getAssetFilename: (a: { name: string }) => mockGetAssetFilename(a)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
|
||||
civitaiImportSource: {
|
||||
type: 'civitai',
|
||||
name: 'Civitai',
|
||||
hostnames: ['civitai.com']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/importSources/huggingfaceImportSource', () => ({
|
||||
huggingfaceImportSource: {
|
||||
type: 'huggingface',
|
||||
name: 'Hugging Face',
|
||||
hostnames: ['huggingface.co']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/importSourceUtil', () => ({
|
||||
validateSourceUrl: (...args: unknown[]) => mockValidateSourceUrl(...args)
|
||||
}))
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import {
|
||||
getComboValue,
|
||||
getModelStateKey,
|
||||
getNodeDisplayLabel,
|
||||
useMissingModelInteractions
|
||||
} from './useMissingModelInteractions'
|
||||
|
||||
function makeCandidate(
|
||||
overrides: Partial<MissingModelCandidate> = {}
|
||||
): MissingModelCandidate {
|
||||
return {
|
||||
name: 'model.safetensors',
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
isMissing: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMissingModelInteractions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.resetAllMocks()
|
||||
mockGetAssetDisplayName.mockImplementation((a: { name: string }) => a.name)
|
||||
mockGetAssetFilename.mockImplementation((a: { name: string }) => a.name)
|
||||
mockDownloadList.mockImplementation(
|
||||
(): Array<{ taskId: string; status: string }> => []
|
||||
)
|
||||
;(app as { rootGraph: unknown }).rootGraph = null
|
||||
})
|
||||
|
||||
describe('getModelStateKey', () => {
|
||||
it('returns key with supported prefix when asset is supported', () => {
|
||||
expect(getModelStateKey('model.safetensors', 'checkpoints', true)).toBe(
|
||||
'supported::checkpoints::model.safetensors'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns key with unsupported prefix when asset is not supported', () => {
|
||||
expect(getModelStateKey('model.safetensors', 'loras', false)).toBe(
|
||||
'unsupported::loras::model.safetensors'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles null directory', () => {
|
||||
expect(getModelStateKey('model.safetensors', null, true)).toBe(
|
||||
'supported::::model.safetensors'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeDisplayLabel', () => {
|
||||
it('returns fallback when graph is null', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = null
|
||||
expect(getNodeDisplayLabel('1', 'Node #1')).toBe('Node #1')
|
||||
})
|
||||
|
||||
it('calls resolveNodeDisplayName when graph is available', () => {
|
||||
const mockGraph = {}
|
||||
const mockNode = { id: 1 }
|
||||
;(app as { rootGraph: unknown }).rootGraph = mockGraph
|
||||
mockGetNodeByExecutionId.mockReturnValue(mockNode)
|
||||
mockResolveNodeDisplayName.mockReturnValue('My Checkpoint')
|
||||
|
||||
const result = getNodeDisplayLabel('1', 'Node #1')
|
||||
|
||||
expect(mockGetNodeByExecutionId).toHaveBeenCalledWith(mockGraph, '1')
|
||||
expect(result).toBe('My Checkpoint')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getComboValue', () => {
|
||||
it('returns undefined when node is not found', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = {}
|
||||
mockGetNodeByExecutionId.mockReturnValue(null)
|
||||
|
||||
const result = getComboValue(makeCandidate())
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when widget is not found', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = {}
|
||||
mockGetNodeByExecutionId.mockReturnValue({
|
||||
widgets: [{ name: 'other_widget', value: 'test' }]
|
||||
})
|
||||
|
||||
const result = getComboValue(makeCandidate())
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns string value directly', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = {}
|
||||
mockGetNodeByExecutionId.mockReturnValue({
|
||||
widgets: [{ name: 'ckpt_name', value: 'v1-5.safetensors' }]
|
||||
})
|
||||
|
||||
expect(getComboValue(makeCandidate())).toBe('v1-5.safetensors')
|
||||
})
|
||||
|
||||
it('returns stringified number value', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = {}
|
||||
mockGetNodeByExecutionId.mockReturnValue({
|
||||
widgets: [{ name: 'ckpt_name', value: 42 }]
|
||||
})
|
||||
|
||||
expect(getComboValue(makeCandidate())).toBe('42')
|
||||
})
|
||||
|
||||
it('returns undefined for unexpected types', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = {}
|
||||
mockGetNodeByExecutionId.mockReturnValue({
|
||||
widgets: [{ name: 'ckpt_name', value: { complex: true } }]
|
||||
})
|
||||
|
||||
expect(getComboValue(makeCandidate())).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when nodeId is null', () => {
|
||||
const result = getComboValue(makeCandidate({ nodeId: undefined }))
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleModelExpand / isModelExpanded', () => {
|
||||
it('starts collapsed by default', () => {
|
||||
const { isModelExpanded } = useMissingModelInteractions()
|
||||
expect(isModelExpanded('key1')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles to expanded', () => {
|
||||
const { toggleModelExpand, isModelExpanded } =
|
||||
useMissingModelInteractions()
|
||||
toggleModelExpand('key1')
|
||||
expect(isModelExpanded('key1')).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles back to collapsed', () => {
|
||||
const { toggleModelExpand, isModelExpanded } =
|
||||
useMissingModelInteractions()
|
||||
toggleModelExpand('key1')
|
||||
toggleModelExpand('key1')
|
||||
expect(isModelExpanded('key1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleComboSelect', () => {
|
||||
it('sets selectedLibraryModel in store', () => {
|
||||
const store = useMissingModelStore()
|
||||
const { handleComboSelect } = useMissingModelInteractions()
|
||||
|
||||
handleComboSelect('key1', 'model_v2.safetensors')
|
||||
expect(store.selectedLibraryModel['key1']).toBe('model_v2.safetensors')
|
||||
})
|
||||
|
||||
it('does not set value when undefined', () => {
|
||||
const store = useMissingModelStore()
|
||||
const { handleComboSelect } = useMissingModelInteractions()
|
||||
|
||||
handleComboSelect('key1', undefined)
|
||||
expect(store.selectedLibraryModel['key1']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSelectionConfirmable', () => {
|
||||
it('returns false when no selection exists', () => {
|
||||
const { isSelectionConfirmable } = useMissingModelInteractions()
|
||||
expect(isSelectionConfirmable('key1')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when download is running', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'model.safetensors'
|
||||
store.importTaskIds['key1'] = 'task-123'
|
||||
mockDownloadList.mockReturnValue([
|
||||
{ taskId: 'task-123', status: 'running' }
|
||||
])
|
||||
|
||||
const { isSelectionConfirmable } = useMissingModelInteractions()
|
||||
expect(isSelectionConfirmable('key1')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when importCategoryMismatch exists', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'model.safetensors'
|
||||
store.importCategoryMismatch['key1'] = 'loras'
|
||||
|
||||
const { isSelectionConfirmable } = useMissingModelInteractions()
|
||||
expect(isSelectionConfirmable('key1')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when selection is ready with no active download', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'model.safetensors'
|
||||
mockDownloadList.mockReturnValue([])
|
||||
|
||||
const { isSelectionConfirmable } = useMissingModelInteractions()
|
||||
expect(isSelectionConfirmable('key1')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelLibrarySelect', () => {
|
||||
it('clears selectedLibraryModel and importCategoryMismatch', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'model.safetensors'
|
||||
store.importCategoryMismatch['key1'] = 'loras'
|
||||
|
||||
const { cancelLibrarySelect } = useMissingModelInteractions()
|
||||
cancelLibrarySelect('key1')
|
||||
|
||||
expect(store.selectedLibraryModel['key1']).toBeUndefined()
|
||||
expect(store.importCategoryMismatch['key1']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmLibrarySelect', () => {
|
||||
it('updates widget values on referencing nodes and removes missing model', () => {
|
||||
const mockGraph = {}
|
||||
;(app as { rootGraph: unknown }).rootGraph = mockGraph
|
||||
|
||||
const widget1 = { name: 'ckpt_name', value: 'old_model.safetensors' }
|
||||
const widget2 = { name: 'ckpt_name', value: 'old_model.safetensors' }
|
||||
const node1 = { widgets: [widget1] }
|
||||
const node2 = { widgets: [widget2] }
|
||||
|
||||
mockGetNodeByExecutionId.mockImplementation(
|
||||
(_graph: unknown, id: string) => {
|
||||
if (id === '10') return node1
|
||||
if (id === '20') return node2
|
||||
return null
|
||||
}
|
||||
)
|
||||
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'new_model.safetensors'
|
||||
store.setMissingModels([
|
||||
makeCandidate({ name: 'old_model.safetensors', nodeId: '10' }),
|
||||
makeCandidate({ name: 'old_model.safetensors', nodeId: '20' })
|
||||
])
|
||||
|
||||
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
|
||||
|
||||
const { confirmLibrarySelect } = useMissingModelInteractions()
|
||||
confirmLibrarySelect(
|
||||
'key1',
|
||||
'old_model.safetensors',
|
||||
[
|
||||
{ nodeId: '10', widgetName: 'ckpt_name' },
|
||||
{ nodeId: '20', widgetName: 'ckpt_name' }
|
||||
],
|
||||
null
|
||||
)
|
||||
|
||||
expect(widget1.value).toBe('new_model.safetensors')
|
||||
expect(widget2.value).toBe('new_model.safetensors')
|
||||
expect(removeSpy).toHaveBeenCalledWith(
|
||||
'old_model.safetensors',
|
||||
new Set(['10', '20'])
|
||||
)
|
||||
expect(store.selectedLibraryModel['key1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does nothing when no selection exists', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = {}
|
||||
const store = useMissingModelStore()
|
||||
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
|
||||
|
||||
const { confirmLibrarySelect } = useMissingModelInteractions()
|
||||
confirmLibrarySelect('key1', 'model.safetensors', [], null)
|
||||
|
||||
expect(removeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when graph is null', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = null
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'new.safetensors'
|
||||
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
|
||||
|
||||
const { confirmLibrarySelect } = useMissingModelInteractions()
|
||||
confirmLibrarySelect('key1', 'model.safetensors', [], null)
|
||||
|
||||
expect(removeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes model cache when directory is provided', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = {}
|
||||
mockGetNodeByExecutionId.mockReturnValue(null)
|
||||
mockGetAllNodeProviders.mockReturnValue([
|
||||
{ nodeDef: { name: 'CheckpointLoaderSimple' } }
|
||||
])
|
||||
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'new.safetensors'
|
||||
|
||||
const { confirmLibrarySelect } = useMissingModelInteractions()
|
||||
confirmLibrarySelect('key1', 'model.safetensors', [], 'checkpoints')
|
||||
|
||||
expect(mockGetAllNodeProviders).toHaveBeenCalledWith('checkpoints')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUrlInput', () => {
|
||||
it('clears previous state on new input', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.urlMetadata['key1'] = { name: 'old' } as never
|
||||
store.urlErrors['key1'] = 'old error'
|
||||
store.urlFetching['key1'] = true
|
||||
|
||||
const { handleUrlInput } = useMissingModelInteractions()
|
||||
handleUrlInput('key1', 'https://civitai.com/models/123')
|
||||
|
||||
expect(store.urlInputs['key1']).toBe('https://civitai.com/models/123')
|
||||
expect(store.urlMetadata['key1']).toBeUndefined()
|
||||
expect(store.urlErrors['key1']).toBeUndefined()
|
||||
expect(store.urlFetching['key1']).toBe(false)
|
||||
})
|
||||
|
||||
it('does not set debounce timer for empty input', () => {
|
||||
const store = useMissingModelStore()
|
||||
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
|
||||
|
||||
const { handleUrlInput } = useMissingModelInteractions()
|
||||
handleUrlInput('key1', ' ')
|
||||
|
||||
expect(setTimerSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets debounce timer for non-empty input', () => {
|
||||
const store = useMissingModelStore()
|
||||
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
|
||||
|
||||
const { handleUrlInput } = useMissingModelInteractions()
|
||||
handleUrlInput('key1', 'https://civitai.com/models/123')
|
||||
|
||||
expect(setTimerSpy).toHaveBeenCalledWith(
|
||||
'key1',
|
||||
expect.any(Function),
|
||||
800
|
||||
)
|
||||
})
|
||||
|
||||
it('clears previous debounce timer', () => {
|
||||
const store = useMissingModelStore()
|
||||
const clearTimerSpy = vi.spyOn(store, 'clearDebounceTimer')
|
||||
|
||||
const { handleUrlInput } = useMissingModelInteractions()
|
||||
handleUrlInput('key1', 'https://civitai.com/models/123')
|
||||
|
||||
expect(clearTimerSpy).toHaveBeenCalledWith('key1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTypeMismatch', () => {
|
||||
it('returns null when groupDirectory is null', () => {
|
||||
const { getTypeMismatch } = useMissingModelInteractions()
|
||||
expect(getTypeMismatch('key1', null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no metadata exists', () => {
|
||||
const { getTypeMismatch } = useMissingModelInteractions()
|
||||
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when metadata has no tags', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.urlMetadata['key1'] = { name: 'model', tags: [] } as never
|
||||
|
||||
const { getTypeMismatch } = useMissingModelInteractions()
|
||||
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when detected type matches directory', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.urlMetadata['key1'] = {
|
||||
name: 'model',
|
||||
tags: ['checkpoints']
|
||||
} as never
|
||||
|
||||
const { getTypeMismatch } = useMissingModelInteractions()
|
||||
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns detected type when it differs from directory', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.urlMetadata['key1'] = {
|
||||
name: 'model',
|
||||
tags: ['loras']
|
||||
} as never
|
||||
|
||||
const { getTypeMismatch } = useMissingModelInteractions()
|
||||
expect(getTypeMismatch('key1', 'checkpoints')).toBe('loras')
|
||||
})
|
||||
|
||||
it('returns null when tags contain no recognized model type', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.urlMetadata['key1'] = {
|
||||
name: 'model',
|
||||
tags: ['other', 'random']
|
||||
} as never
|
||||
|
||||
const { getTypeMismatch } = useMissingModelInteractions()
|
||||
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { st } from '@/i18n'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
getAssetDisplayName,
|
||||
getAssetFilename
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
|
||||
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
|
||||
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelViewModel
|
||||
} from '@/platform/missingModel/types'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
const importSources = [civitaiImportSource, huggingfaceImportSource]
|
||||
|
||||
const MODEL_TYPE_TAGS = [
|
||||
'checkpoints',
|
||||
'loras',
|
||||
'vae',
|
||||
'text_encoders',
|
||||
'diffusion_models'
|
||||
] as const
|
||||
|
||||
const URL_DEBOUNCE_MS = 800
|
||||
|
||||
export function getModelStateKey(
|
||||
modelName: string,
|
||||
directory: string | null,
|
||||
isAssetSupported: boolean
|
||||
): string {
|
||||
const prefix = isAssetSupported ? 'supported' : 'unsupported'
|
||||
return `${prefix}::${directory ?? ''}::${modelName}`
|
||||
}
|
||||
|
||||
export function getNodeDisplayLabel(
|
||||
nodeId: string | number,
|
||||
fallback: string
|
||||
): string {
|
||||
const graph = app.rootGraph
|
||||
if (!graph) return fallback
|
||||
const node = getNodeByExecutionId(graph, String(nodeId))
|
||||
return resolveNodeDisplayName(node, {
|
||||
emptyLabel: fallback,
|
||||
untitledLabel: fallback,
|
||||
st
|
||||
})
|
||||
}
|
||||
|
||||
function getModelComboWidget(
|
||||
model: MissingModelCandidate
|
||||
): { node: LGraphNode; widget: IBaseWidget } | null {
|
||||
if (model.nodeId == null) return null
|
||||
|
||||
const graph = app.rootGraph
|
||||
if (!graph) return null
|
||||
const node = getNodeByExecutionId(graph, String(model.nodeId))
|
||||
if (!node) return null
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === model.widgetName)
|
||||
if (!widget) return null
|
||||
|
||||
return { node, widget }
|
||||
}
|
||||
|
||||
export function getComboValue(
|
||||
model: MissingModelCandidate
|
||||
): string | undefined {
|
||||
const result = getModelComboWidget(model)
|
||||
if (!result) return undefined
|
||||
const val = result.widget.value
|
||||
if (typeof val === 'string') return val
|
||||
if (typeof val === 'number') return String(val)
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function useMissingModelInteractions() {
|
||||
const { t } = useI18n()
|
||||
const store = useMissingModelStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
|
||||
const _requestTokens: Record<string, symbol> = {}
|
||||
|
||||
function toggleModelExpand(key: string) {
|
||||
store.modelExpandState[key] = !isModelExpanded(key)
|
||||
}
|
||||
|
||||
function isModelExpanded(key: string): boolean {
|
||||
return store.modelExpandState[key] ?? false
|
||||
}
|
||||
|
||||
function getComboOptions(
|
||||
model: MissingModelCandidate
|
||||
): { name: string; value: string }[] {
|
||||
if (model.isAssetSupported && model.nodeType) {
|
||||
const assets = assetsStore.getAssets(model.nodeType) ?? []
|
||||
return assets.map((asset) => ({
|
||||
name: getAssetDisplayName(asset),
|
||||
value: getAssetFilename(asset)
|
||||
}))
|
||||
}
|
||||
|
||||
const result = getModelComboWidget(model)
|
||||
if (!result) return []
|
||||
const values = result.widget.options?.values
|
||||
if (!Array.isArray(values)) return []
|
||||
return values.map((v) => ({ name: String(v), value: String(v) }))
|
||||
}
|
||||
|
||||
function handleComboSelect(key: string, value: string | undefined) {
|
||||
if (value) {
|
||||
store.selectedLibraryModel[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
function isSelectionConfirmable(key: string): boolean {
|
||||
if (!store.selectedLibraryModel[key]) return false
|
||||
if (store.importCategoryMismatch[key]) return false
|
||||
|
||||
const status = getDownloadStatus(key)
|
||||
if (
|
||||
status &&
|
||||
(status.status === 'running' || status.status === 'created')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function cancelLibrarySelect(key: string) {
|
||||
delete store.selectedLibraryModel[key]
|
||||
delete store.importCategoryMismatch[key]
|
||||
}
|
||||
|
||||
/** Apply selected model to referencing nodes, removing only that model from the error list. */
|
||||
function confirmLibrarySelect(
|
||||
key: string,
|
||||
modelName: string,
|
||||
referencingNodes: MissingModelViewModel['referencingNodes'],
|
||||
directory: string | null
|
||||
) {
|
||||
const value = store.selectedLibraryModel[key]
|
||||
if (!value) return
|
||||
|
||||
const graph = app.rootGraph
|
||||
if (!graph) return
|
||||
|
||||
if (directory) {
|
||||
const providers = modelToNodeStore.getAllNodeProviders(directory)
|
||||
void Promise.allSettled(
|
||||
providers.map((provider) =>
|
||||
assetsStore.updateModelsForNodeType(provider.nodeDef.name)
|
||||
)
|
||||
).then((results) => {
|
||||
for (const r of results) {
|
||||
if (r.status === 'rejected') {
|
||||
console.warn(
|
||||
'[Missing Model] Failed to refresh model cache:',
|
||||
r.reason
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const ref of referencingNodes) {
|
||||
const node = getNodeByExecutionId(graph, String(ref.nodeId))
|
||||
if (node) {
|
||||
const widget = node.widgets?.find((w) => w.name === ref.widgetName)
|
||||
if (widget) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
}
|
||||
node.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
delete store.selectedLibraryModel[key]
|
||||
const nodeIdSet = new Set(referencingNodes.map((ref) => String(ref.nodeId)))
|
||||
store.removeMissingModelByNameOnNodes(modelName, nodeIdSet)
|
||||
}
|
||||
|
||||
function handleUrlInput(key: string, value: string) {
|
||||
store.urlInputs[key] = value
|
||||
|
||||
delete store.urlMetadata[key]
|
||||
delete store.urlErrors[key]
|
||||
delete store.importCategoryMismatch[key]
|
||||
store.urlFetching[key] = false
|
||||
|
||||
store.clearDebounceTimer(key)
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
store.setDebounceTimer(
|
||||
key,
|
||||
() => {
|
||||
void fetchUrlMetadata(key, trimmed)
|
||||
},
|
||||
URL_DEBOUNCE_MS
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchUrlMetadata(key: string, url: string) {
|
||||
const source = importSources.find((s) => validateSourceUrl(url, s))
|
||||
if (!source) {
|
||||
store.urlErrors[key] = t('rightSidePanel.missingModels.unsupportedUrl')
|
||||
return
|
||||
}
|
||||
|
||||
const token = Symbol()
|
||||
_requestTokens[key] = token
|
||||
|
||||
store.urlFetching[key] = true
|
||||
delete store.urlErrors[key]
|
||||
|
||||
try {
|
||||
const metadata = await assetService.getAssetMetadata(url)
|
||||
|
||||
if (_requestTokens[key] !== token) return
|
||||
|
||||
if (metadata.filename) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(metadata.filename)
|
||||
const basename = decoded.split(/[/\\]/).pop() ?? decoded
|
||||
if (!basename.includes('..')) {
|
||||
metadata.filename = basename
|
||||
}
|
||||
} catch {
|
||||
/* keep original */
|
||||
}
|
||||
}
|
||||
|
||||
store.urlMetadata[key] = metadata
|
||||
} catch (error) {
|
||||
if (_requestTokens[key] !== token) return
|
||||
|
||||
store.urlErrors[key] =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('rightSidePanel.missingModels.metadataFetchFailed')
|
||||
} finally {
|
||||
if (_requestTokens[key] === token) {
|
||||
store.urlFetching[key] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeMismatch(
|
||||
key: string,
|
||||
groupDirectory: string | null
|
||||
): string | null {
|
||||
if (!groupDirectory) return null
|
||||
|
||||
const metadata = store.urlMetadata[key]
|
||||
if (!metadata?.tags?.length) return null
|
||||
|
||||
const detectedType = metadata.tags.find((tag) =>
|
||||
MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
|
||||
)
|
||||
if (!detectedType) return null
|
||||
|
||||
if (detectedType !== groupDirectory) {
|
||||
return detectedType
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getDownloadStatus(key: string) {
|
||||
const taskId = store.importTaskIds[key]
|
||||
if (!taskId) return null
|
||||
return (
|
||||
assetDownloadStore.downloadList.find((d) => d.taskId === taskId) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function handleAsyncPending(
|
||||
key: string,
|
||||
taskId: string,
|
||||
modelType: string | undefined,
|
||||
filename: string
|
||||
) {
|
||||
store.importTaskIds[key] = taskId
|
||||
if (modelType) {
|
||||
assetDownloadStore.trackDownload(taskId, modelType, filename)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAsyncCompleted(modelType: string | undefined) {
|
||||
if (modelType) {
|
||||
assetsStore.invalidateModelsForCategory(modelType)
|
||||
void assetsStore.updateModelsForTag(modelType)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSyncResult(
|
||||
key: string,
|
||||
tags: string[],
|
||||
modelType: string | undefined
|
||||
) {
|
||||
const existingCategory = tags.find((tag) =>
|
||||
MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
|
||||
)
|
||||
if (existingCategory && modelType && existingCategory !== modelType) {
|
||||
store.importCategoryMismatch[key] = existingCategory
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(key: string, groupDirectory: string | null) {
|
||||
const metadata = store.urlMetadata[key]
|
||||
if (!metadata) return
|
||||
|
||||
const url = store.urlInputs[key]?.trim()
|
||||
if (!url) return
|
||||
|
||||
const source = importSources.find((s) => validateSourceUrl(url, s))
|
||||
if (!source) return
|
||||
|
||||
const token = Symbol()
|
||||
_requestTokens[key] = token
|
||||
|
||||
store.urlImporting[key] = true
|
||||
delete store.urlErrors[key]
|
||||
delete store.importCategoryMismatch[key]
|
||||
|
||||
try {
|
||||
const modelType = groupDirectory || undefined
|
||||
const tags = modelType ? ['models', modelType] : ['models']
|
||||
const filename = metadata.filename || metadata.name || 'model'
|
||||
|
||||
const result = await assetService.uploadAssetAsync({
|
||||
source_url: url,
|
||||
tags,
|
||||
user_metadata: {
|
||||
source: source.type,
|
||||
source_url: url,
|
||||
model_type: modelType
|
||||
}
|
||||
})
|
||||
|
||||
if (_requestTokens[key] !== token) return
|
||||
|
||||
if (result.type === 'async' && result.task.status !== 'completed') {
|
||||
handleAsyncPending(key, result.task.task_id, modelType, filename)
|
||||
} else if (result.type === 'async') {
|
||||
handleAsyncCompleted(modelType)
|
||||
} else if (result.type === 'sync') {
|
||||
handleSyncResult(key, result.asset.tags ?? [], modelType)
|
||||
}
|
||||
|
||||
store.selectedLibraryModel[key] = filename
|
||||
} catch (error) {
|
||||
if (_requestTokens[key] !== token) return
|
||||
|
||||
store.urlErrors[key] =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('rightSidePanel.missingModels.importFailed')
|
||||
} finally {
|
||||
if (_requestTokens[key] === token) {
|
||||
store.urlImporting[key] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toggleModelExpand,
|
||||
isModelExpanded,
|
||||
getComboOptions,
|
||||
handleComboSelect,
|
||||
isSelectionConfirmable,
|
||||
cancelLibrarySelect,
|
||||
confirmLibrarySelect,
|
||||
handleUrlInput,
|
||||
getTypeMismatch,
|
||||
getDownloadStatus,
|
||||
handleImport
|
||||
}
|
||||
}
|
||||
1019
src/platform/missingModel/missingModelScan.test.ts
Normal file
386
src/platform/missingModel/missingModelScan.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { flattenWorkflowNodes } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelViewModel,
|
||||
EmbeddedModelWithSource
|
||||
} from './types'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type {
|
||||
IAssetWidget,
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
||||
return widget.type === 'combo'
|
||||
}
|
||||
|
||||
function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
|
||||
return widget.type === 'asset'
|
||||
}
|
||||
|
||||
export const MODEL_FILE_EXTENSIONS = new Set([
|
||||
'.safetensors',
|
||||
'.ckpt',
|
||||
'.pt',
|
||||
'.pth',
|
||||
'.bin',
|
||||
'.sft',
|
||||
'.onnx',
|
||||
'.gguf'
|
||||
])
|
||||
|
||||
export function isModelFileName(name: string): boolean {
|
||||
const lower = name.toLowerCase()
|
||||
for (const ext of MODEL_FILE_EXTENSIONS) {
|
||||
if (lower.endsWith(ext)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveComboOptions(widget: IComboWidget): string[] {
|
||||
const values = widget.options.values
|
||||
if (!values) return []
|
||||
if (typeof values === 'function') return values(widget)
|
||||
if (Array.isArray(values)) return values
|
||||
return Object.keys(values)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan COMBO and asset widgets on configured graph nodes for model-like values.
|
||||
* Must be called after `graph.configure()` so widget name/value mappings are accurate.
|
||||
*
|
||||
* Non-asset-supported nodes: `isMissing` resolved immediately via widget options.
|
||||
* Asset-supported nodes: `isMissing` left `undefined` for async verification.
|
||||
*/
|
||||
export function scanAllModelCandidates(
|
||||
rootGraph: LGraph,
|
||||
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
|
||||
getDirectory?: (nodeType: string) => string | undefined
|
||||
): MissingModelCandidate[] {
|
||||
if (!rootGraph) return []
|
||||
|
||||
const allNodes = collectAllNodes(rootGraph)
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
|
||||
for (const node of allNodes) {
|
||||
if (!node.widgets?.length) continue
|
||||
|
||||
const executionId = getExecutionIdByNode(rootGraph, node)
|
||||
if (!executionId) continue
|
||||
|
||||
for (const widget of node.widgets) {
|
||||
let candidate: MissingModelCandidate | null = null
|
||||
|
||||
if (isAssetWidget(widget)) {
|
||||
candidate = scanAssetWidget(node, widget, executionId, getDirectory)
|
||||
} else if (isComboWidget(widget)) {
|
||||
candidate = scanComboWidget(
|
||||
node,
|
||||
widget,
|
||||
executionId,
|
||||
isAssetSupported,
|
||||
getDirectory
|
||||
)
|
||||
}
|
||||
|
||||
if (candidate) candidates.push(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
function scanAssetWidget(
|
||||
node: { type: string },
|
||||
widget: IAssetWidget,
|
||||
executionId: string,
|
||||
getDirectory: ((nodeType: string) => string | undefined) | undefined
|
||||
): MissingModelCandidate | null {
|
||||
const value = widget.value
|
||||
if (!value.trim()) return null
|
||||
if (!isModelFileName(value)) return null
|
||||
|
||||
return {
|
||||
nodeId: executionId as NodeId,
|
||||
nodeType: node.type,
|
||||
widgetName: widget.name,
|
||||
isAssetSupported: true,
|
||||
name: value,
|
||||
directory: getDirectory?.(node.type),
|
||||
isMissing: undefined
|
||||
}
|
||||
}
|
||||
|
||||
function scanComboWidget(
|
||||
node: { type: string },
|
||||
widget: IComboWidget,
|
||||
executionId: string,
|
||||
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
|
||||
getDirectory: ((nodeType: string) => string | undefined) | undefined
|
||||
): MissingModelCandidate | null {
|
||||
const value = widget.value
|
||||
if (typeof value !== 'string' || !value.trim()) return null
|
||||
if (!isModelFileName(value)) return null
|
||||
|
||||
const nodeIsAssetSupported = isAssetSupported(node.type, widget.name)
|
||||
const options = resolveComboOptions(widget)
|
||||
const inOptions = options.includes(value)
|
||||
|
||||
return {
|
||||
nodeId: executionId as NodeId,
|
||||
nodeType: node.type,
|
||||
widgetName: widget.name,
|
||||
isAssetSupported: nodeIsAssetSupported,
|
||||
name: value,
|
||||
directory: getDirectory?.(node.type),
|
||||
isMissing: nodeIsAssetSupported ? undefined : !inOptions
|
||||
}
|
||||
}
|
||||
|
||||
export async function enrichWithEmbeddedMetadata(
|
||||
candidates: readonly MissingModelCandidate[],
|
||||
graphData: ComfyWorkflowJSON,
|
||||
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
|
||||
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
|
||||
): Promise<MissingModelCandidate[]> {
|
||||
const allNodes = flattenWorkflowNodes(graphData)
|
||||
const embeddedModels = collectEmbeddedModelsWithSource(allNodes, graphData)
|
||||
|
||||
const enriched = candidates.map((c) => ({ ...c }))
|
||||
const candidatesByKey = new Map<string, MissingModelCandidate[]>()
|
||||
for (const c of enriched) {
|
||||
const dirKey = `${c.name}::${c.directory ?? ''}`
|
||||
const dirList = candidatesByKey.get(dirKey)
|
||||
if (dirList) dirList.push(c)
|
||||
else candidatesByKey.set(dirKey, [c])
|
||||
|
||||
const nameKey = c.name
|
||||
const nameList = candidatesByKey.get(nameKey)
|
||||
if (nameList) nameList.push(c)
|
||||
else candidatesByKey.set(nameKey, [c])
|
||||
}
|
||||
|
||||
const deduped: EmbeddedModelWithSource[] = []
|
||||
const enrichedKeys = new Set<string>()
|
||||
for (const model of embeddedModels) {
|
||||
const dedupeKey = `${model.name}::${model.directory}`
|
||||
if (enrichedKeys.has(dedupeKey)) continue
|
||||
enrichedKeys.add(dedupeKey)
|
||||
deduped.push(model)
|
||||
}
|
||||
|
||||
const unmatched: EmbeddedModelWithSource[] = []
|
||||
for (const model of deduped) {
|
||||
const dirKey = `${model.name}::${model.directory}`
|
||||
const exact = candidatesByKey.get(dirKey)
|
||||
const fallback = candidatesByKey.get(model.name)
|
||||
const existing = exact?.length ? exact : fallback
|
||||
if (existing) {
|
||||
for (const c of existing) {
|
||||
if (c.directory && c.directory !== model.directory) continue
|
||||
c.directory ??= model.directory
|
||||
c.url ??= model.url
|
||||
c.hash ??= model.hash
|
||||
c.hashType ??= model.hash_type
|
||||
}
|
||||
} else {
|
||||
unmatched.push(model)
|
||||
}
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
unmatched.map(async (model) => {
|
||||
const installed = await checkModelInstalled(model.name, model.directory)
|
||||
if (installed) return null
|
||||
|
||||
const nodeIsAssetSupported = isAssetSupported
|
||||
? isAssetSupported(model.sourceNodeType, model.sourceWidgetName)
|
||||
: false
|
||||
|
||||
return {
|
||||
nodeId: model.sourceNodeId,
|
||||
nodeType: model.sourceNodeType,
|
||||
widgetName: model.sourceWidgetName,
|
||||
isAssetSupported: nodeIsAssetSupported,
|
||||
name: model.name,
|
||||
directory: model.directory,
|
||||
url: model.url,
|
||||
hash: model.hash,
|
||||
hashType: model.hash_type,
|
||||
isMissing: nodeIsAssetSupported ? undefined : true
|
||||
} satisfies MissingModelCandidate
|
||||
})
|
||||
)
|
||||
|
||||
for (const r of settled) {
|
||||
if (r.status === 'rejected') {
|
||||
console.warn(
|
||||
'[Missing Model Pipeline] checkModelInstalled failed:',
|
||||
r.reason
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (r.value) enriched.push(r.value)
|
||||
}
|
||||
|
||||
return enriched
|
||||
}
|
||||
|
||||
function collectEmbeddedModelsWithSource(
|
||||
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
||||
graphData: ComfyWorkflowJSON
|
||||
): EmbeddedModelWithSource[] {
|
||||
const result: EmbeddedModelWithSource[] = []
|
||||
|
||||
for (const node of allNodes) {
|
||||
const selected = getSelectedModelsMetadata(
|
||||
node as Parameters<typeof getSelectedModelsMetadata>[0]
|
||||
)
|
||||
if (!selected?.length) continue
|
||||
|
||||
for (const model of selected) {
|
||||
result.push({
|
||||
...model,
|
||||
sourceNodeId: node.id,
|
||||
sourceNodeType: node.type,
|
||||
sourceWidgetName: findWidgetNameForModel(node, model.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow-level model entries have no originating node; sourceNodeId
|
||||
// remains undefined and empty-string node type/widget are handled by
|
||||
// groupCandidatesByName (no nodeId → no referencing node entry).
|
||||
if (graphData.models?.length) {
|
||||
for (const model of graphData.models) {
|
||||
result.push({
|
||||
...model,
|
||||
sourceNodeType: '',
|
||||
sourceWidgetName: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function findWidgetNameForModel(
|
||||
node: ReturnType<typeof flattenWorkflowNodes>[number],
|
||||
modelName: string
|
||||
): string {
|
||||
if (Array.isArray(node.widgets_values) || !node.widgets_values) return ''
|
||||
const wv = node.widgets_values as Record<string, unknown>
|
||||
for (const [key, val] of Object.entries(wv)) {
|
||||
if (val === modelName) return key
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
interface AssetVerifier {
|
||||
updateModelsForNodeType: (nodeType: string) => Promise<void>
|
||||
getAssets: (nodeType: string) => AssetItem[] | undefined
|
||||
}
|
||||
|
||||
export async function verifyAssetSupportedCandidates(
|
||||
candidates: MissingModelCandidate[],
|
||||
signal?: AbortSignal,
|
||||
assetsStore?: AssetVerifier
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) return
|
||||
|
||||
const pendingNodeTypes = new Set<string>()
|
||||
for (const c of candidates) {
|
||||
if (c.isAssetSupported && c.isMissing === undefined) {
|
||||
pendingNodeTypes.add(c.nodeType)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingNodeTypes.size === 0) return
|
||||
|
||||
const store =
|
||||
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
|
||||
|
||||
const failedNodeTypes = new Set<string>()
|
||||
await Promise.allSettled(
|
||||
[...pendingNodeTypes].map(async (nodeType) => {
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
await store.updateModelsForNodeType(nodeType)
|
||||
} catch (err) {
|
||||
failedNodeTypes.add(nodeType)
|
||||
console.warn(
|
||||
`[Missing Model Pipeline] Failed to load assets for ${nodeType}:`,
|
||||
err
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (signal?.aborted) return
|
||||
|
||||
for (const c of candidates) {
|
||||
if (!c.isAssetSupported || c.isMissing !== undefined) continue
|
||||
if (failedNodeTypes.has(c.nodeType)) continue
|
||||
|
||||
const assets = store.getAssets(c.nodeType) ?? []
|
||||
c.isMissing = !isAssetInstalled(c, assets)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function isAssetInstalled(
|
||||
candidate: MissingModelCandidate,
|
||||
assets: AssetItem[]
|
||||
): boolean {
|
||||
if (candidate.hash && candidate.hashType) {
|
||||
const candidateHash = `${candidate.hashType}:${candidate.hash}`
|
||||
if (assets.some((a) => a.asset_hash === candidateHash)) return true
|
||||
}
|
||||
|
||||
const normalizedName = normalizePath(candidate.name)
|
||||
return assets.some((a) => {
|
||||
const f = normalizePath(getAssetFilename(a))
|
||||
return f === normalizedName || f.endsWith('/' + normalizedName)
|
||||
})
|
||||
}
|
||||
|
||||
export function groupCandidatesByName(
|
||||
candidates: MissingModelCandidate[]
|
||||
): MissingModelViewModel[] {
|
||||
const map = new Map<string, MissingModelViewModel>()
|
||||
for (const c of candidates) {
|
||||
const existing = map.get(c.name)
|
||||
if (existing) {
|
||||
if (c.nodeId) {
|
||||
existing.referencingNodes.push({
|
||||
nodeId: c.nodeId,
|
||||
widgetName: c.widgetName
|
||||
})
|
||||
}
|
||||
} else {
|
||||
map.set(c.name, {
|
||||
name: c.name,
|
||||
representative: c,
|
||||
referencingNodes: c.nodeId
|
||||
? [{ nodeId: c.nodeId, widgetName: c.widgetName }]
|
||||
: []
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}
|
||||
189
src/platform/missingModel/missingModelStore.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((_key: string, fallback: string) => fallback)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
import { useMissingModelStore } from './missingModelStore'
|
||||
|
||||
function makeModelCandidate(
|
||||
name: string,
|
||||
opts: {
|
||||
nodeId?: string | number
|
||||
nodeType?: string
|
||||
widgetName?: string
|
||||
isAssetSupported?: boolean
|
||||
} = {}
|
||||
): MissingModelCandidate {
|
||||
return {
|
||||
name,
|
||||
nodeId: opts.nodeId ?? '1',
|
||||
nodeType: opts.nodeType ?? 'CheckpointLoaderSimple',
|
||||
widgetName: opts.widgetName ?? 'ckpt_name',
|
||||
isAssetSupported: opts.isAssetSupported ?? false,
|
||||
isMissing: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('missingModelStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('setMissingModels', () => {
|
||||
it('sets missingModelCandidates with provided models', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([makeModelCandidate('model_a.safetensors')])
|
||||
|
||||
expect(store.missingModelCandidates).not.toBeNull()
|
||||
expect(store.missingModelCandidates).toHaveLength(1)
|
||||
expect(store.hasMissingModels).toBe(true)
|
||||
})
|
||||
|
||||
it('clears missingModelCandidates when given empty array', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([makeModelCandidate('model_a.safetensors')])
|
||||
expect(store.missingModelCandidates).not.toBeNull()
|
||||
|
||||
store.setMissingModels([])
|
||||
expect(store.missingModelCandidates).toBeNull()
|
||||
expect(store.hasMissingModels).toBe(false)
|
||||
})
|
||||
|
||||
it('includes model count in missingModelCount', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors'),
|
||||
makeModelCandidate('model_b.safetensors', { nodeId: '2' })
|
||||
])
|
||||
|
||||
expect(store.missingModelCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasMissingModelOnNode', () => {
|
||||
it('returns true when node has missing model', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
|
||||
])
|
||||
|
||||
expect(store.hasMissingModelOnNode('5')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when node has no missing model', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
|
||||
])
|
||||
|
||||
expect(store.hasMissingModelOnNode('99')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no models are missing', () => {
|
||||
const store = useMissingModelStore()
|
||||
expect(store.hasMissingModelOnNode('1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMissingModelByNameOnNodes', () => {
|
||||
it('removes only the named model from specified nodes', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', {
|
||||
nodeId: '1',
|
||||
widgetName: 'ckpt_name'
|
||||
}),
|
||||
makeModelCandidate('model_b.safetensors', {
|
||||
nodeId: '1',
|
||||
widgetName: 'vae_name'
|
||||
}),
|
||||
makeModelCandidate('model_a.safetensors', {
|
||||
nodeId: '2',
|
||||
widgetName: 'ckpt_name'
|
||||
})
|
||||
])
|
||||
|
||||
store.removeMissingModelByNameOnNodes(
|
||||
'model_a.safetensors',
|
||||
new Set(['1'])
|
||||
)
|
||||
|
||||
expect(store.missingModelCandidates).toHaveLength(2)
|
||||
expect(store.missingModelCandidates![0].name).toBe('model_b.safetensors')
|
||||
expect(store.missingModelCandidates![1].name).toBe('model_a.safetensors')
|
||||
expect(String(store.missingModelCandidates![1].nodeId)).toBe('2')
|
||||
})
|
||||
|
||||
it('sets missingModelCandidates to null when all removed', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
|
||||
])
|
||||
|
||||
store.removeMissingModelByNameOnNodes(
|
||||
'model_a.safetensors',
|
||||
new Set(['1'])
|
||||
)
|
||||
|
||||
expect(store.missingModelCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMissingModels', () => {
|
||||
it('clears missingModelCandidates and interaction state', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
|
||||
])
|
||||
store.urlInputs['test-key'] = 'https://example.com'
|
||||
store.selectedLibraryModel['test-key'] = 'some-model'
|
||||
expect(store.missingModelCandidates).not.toBeNull()
|
||||
|
||||
store.clearMissingModels()
|
||||
|
||||
expect(store.missingModelCandidates).toBeNull()
|
||||
expect(store.hasMissingModels).toBe(false)
|
||||
expect(store.urlInputs).toEqual({})
|
||||
expect(store.selectedLibraryModel).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWidgetMissingModel', () => {
|
||||
it('returns true when specific widget has missing model', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', {
|
||||
nodeId: '5',
|
||||
widgetName: 'ckpt_name'
|
||||
})
|
||||
])
|
||||
|
||||
expect(store.isWidgetMissingModel('5', 'ckpt_name')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for different widget on same node', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', {
|
||||
nodeId: '5',
|
||||
widgetName: 'ckpt_name'
|
||||
})
|
||||
])
|
||||
|
||||
expect(store.isWidgetMissingModel('5', 'lora_name')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no models are missing', () => {
|
||||
const store = useMissingModelStore()
|
||||
expect(store.isWidgetMissingModel('1', 'ckpt_name')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
201
src/platform/missingModel/missingModelStore.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, onScopeDispose, ref } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* Missing model error state and interaction state.
|
||||
* Separated from executionErrorStore to keep domain boundaries clean.
|
||||
* The executionErrorStore composes from this store for aggregate error flags.
|
||||
*/
|
||||
export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const missingModelCandidates = ref<MissingModelCandidate[] | null>(null)
|
||||
|
||||
const hasMissingModels = computed(
|
||||
() => !!missingModelCandidates.value?.length
|
||||
)
|
||||
|
||||
const missingModelCount = computed(
|
||||
() => missingModelCandidates.value?.length ?? 0
|
||||
)
|
||||
|
||||
const missingModelNodeIds = computed<Set<string>>(() => {
|
||||
const ids = new Set<string>()
|
||||
if (!missingModelCandidates.value) return ids
|
||||
for (const m of missingModelCandidates.value) {
|
||||
if (m.nodeId != null) ids.add(String(m.nodeId))
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
const missingModelWidgetKeys = computed<Set<string>>(() => {
|
||||
const keys = new Set<string>()
|
||||
if (!missingModelCandidates.value) return keys
|
||||
for (const m of missingModelCandidates.value) {
|
||||
keys.add(`${String(m.nodeId)}::${m.widgetName}`)
|
||||
}
|
||||
return keys
|
||||
})
|
||||
|
||||
/**
|
||||
* Set of all execution ID prefixes derived from missing model node IDs,
|
||||
* including the missing model nodes themselves.
|
||||
*
|
||||
* Example: missing model on node "65:70:63" → Set { "65", "65:70", "65:70:63" }
|
||||
*/
|
||||
const missingModelAncestorExecutionIds = computed<Set<NodeExecutionId>>(
|
||||
() => {
|
||||
const ids = new Set<NodeExecutionId>()
|
||||
for (const nodeId of missingModelNodeIds.value) {
|
||||
for (const id of getAncestorExecutionIds(nodeId)) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
)
|
||||
|
||||
const activeMissingModelGraphIds = computed<Set<string>>(() => {
|
||||
if (!app.rootGraph) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
canvasStore.currentGraph ?? app.rootGraph,
|
||||
missingModelAncestorExecutionIds.value
|
||||
)
|
||||
})
|
||||
|
||||
// Persists across component re-mounts so that download progress,
|
||||
// URL inputs, etc. survive tab switches within the right-side panel.
|
||||
const modelExpandState = ref<Record<string, boolean>>({})
|
||||
const selectedLibraryModel = ref<Record<string, string>>({})
|
||||
const importCategoryMismatch = ref<Record<string, string>>({})
|
||||
const importTaskIds = ref<Record<string, string>>({})
|
||||
const urlInputs = ref<Record<string, string>>({})
|
||||
const urlMetadata = ref<Record<string, AssetMetadata | null>>({})
|
||||
const urlFetching = ref<Record<string, boolean>>({})
|
||||
const urlErrors = ref<Record<string, string>>({})
|
||||
const urlImporting = ref<Record<string, boolean>>({})
|
||||
|
||||
const _urlDebounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
|
||||
|
||||
let _verificationAbortController: AbortController | null = null
|
||||
|
||||
onScopeDispose(cancelDebounceTimers)
|
||||
|
||||
function createVerificationAbortController(): AbortController {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = new AbortController()
|
||||
return _verificationAbortController
|
||||
}
|
||||
|
||||
function setMissingModels(models: MissingModelCandidate[]) {
|
||||
missingModelCandidates.value = models.length ? models : null
|
||||
}
|
||||
|
||||
function removeMissingModelByNameOnNodes(
|
||||
modelName: string,
|
||||
nodeIds: Set<string>
|
||||
) {
|
||||
if (!missingModelCandidates.value) return
|
||||
missingModelCandidates.value = missingModelCandidates.value.filter(
|
||||
(m) =>
|
||||
m.name !== modelName ||
|
||||
m.nodeId == null ||
|
||||
!nodeIds.has(String(m.nodeId))
|
||||
)
|
||||
if (!missingModelCandidates.value.length)
|
||||
missingModelCandidates.value = null
|
||||
}
|
||||
|
||||
function hasMissingModelOnNode(nodeLocatorId: string): boolean {
|
||||
return missingModelNodeIds.value.has(nodeLocatorId)
|
||||
}
|
||||
|
||||
function isWidgetMissingModel(nodeId: string, widgetName: string): boolean {
|
||||
return missingModelWidgetKeys.value.has(`${nodeId}::${widgetName}`)
|
||||
}
|
||||
|
||||
function isContainerWithMissingModel(node: LGraphNode): boolean {
|
||||
return activeMissingModelGraphIds.value.has(String(node.id))
|
||||
}
|
||||
|
||||
function cancelDebounceTimers() {
|
||||
for (const key of Object.keys(_urlDebounceTimers)) {
|
||||
clearTimeout(_urlDebounceTimers[key])
|
||||
delete _urlDebounceTimers[key]
|
||||
}
|
||||
}
|
||||
|
||||
function setDebounceTimer(
|
||||
key: string,
|
||||
callback: () => void,
|
||||
delayMs: number
|
||||
) {
|
||||
if (_urlDebounceTimers[key]) {
|
||||
clearTimeout(_urlDebounceTimers[key])
|
||||
}
|
||||
_urlDebounceTimers[key] = setTimeout(callback, delayMs)
|
||||
}
|
||||
|
||||
function clearDebounceTimer(key: string) {
|
||||
if (_urlDebounceTimers[key]) {
|
||||
clearTimeout(_urlDebounceTimers[key])
|
||||
delete _urlDebounceTimers[key]
|
||||
}
|
||||
}
|
||||
|
||||
function clearMissingModels() {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = null
|
||||
missingModelCandidates.value = null
|
||||
cancelDebounceTimers()
|
||||
modelExpandState.value = {}
|
||||
selectedLibraryModel.value = {}
|
||||
importCategoryMismatch.value = {}
|
||||
importTaskIds.value = {}
|
||||
urlInputs.value = {}
|
||||
urlMetadata.value = {}
|
||||
urlFetching.value = {}
|
||||
urlErrors.value = {}
|
||||
urlImporting.value = {}
|
||||
}
|
||||
|
||||
return {
|
||||
missingModelCandidates,
|
||||
hasMissingModels,
|
||||
missingModelCount,
|
||||
missingModelNodeIds,
|
||||
activeMissingModelGraphIds,
|
||||
|
||||
setMissingModels,
|
||||
removeMissingModelByNameOnNodes,
|
||||
clearMissingModels,
|
||||
createVerificationAbortController,
|
||||
|
||||
hasMissingModelOnNode,
|
||||
isWidgetMissingModel,
|
||||
isContainerWithMissingModel,
|
||||
|
||||
modelExpandState,
|
||||
selectedLibraryModel,
|
||||
importTaskIds,
|
||||
importCategoryMismatch,
|
||||
urlInputs,
|
||||
urlMetadata,
|
||||
urlFetching,
|
||||
urlErrors,
|
||||
urlImporting,
|
||||
|
||||
setDebounceTimer,
|
||||
clearDebounceTimer
|
||||
}
|
||||
})
|
||||
53
src/platform/missingModel/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
ModelFile,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
/**
|
||||
* A single (node, widget, model) binding detected by the missing model pipeline.
|
||||
* The same model name may appear multiple times across different nodes.
|
||||
*/
|
||||
export interface MissingModelCandidate {
|
||||
/** Undefined for workflow-level models not tied to a specific node. */
|
||||
nodeId?: NodeId
|
||||
nodeType: string
|
||||
widgetName: string
|
||||
isAssetSupported: boolean
|
||||
|
||||
name: string
|
||||
directory?: string
|
||||
url?: string
|
||||
hash?: string
|
||||
hashType?: string
|
||||
|
||||
/**
|
||||
* - `true` — confirmed missing
|
||||
* - `false` — confirmed installed
|
||||
* - `undefined` — pending async verification (asset-supported nodes only)
|
||||
*/
|
||||
isMissing: boolean | undefined
|
||||
}
|
||||
|
||||
export interface EmbeddedModelWithSource extends ModelFile {
|
||||
/** Undefined for workflow-level models not tied to a specific node. */
|
||||
sourceNodeId?: NodeId
|
||||
sourceNodeType: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
/** View model grouping multiple candidate references under a single model name. */
|
||||
export interface MissingModelViewModel {
|
||||
name: string
|
||||
representative: MissingModelCandidate
|
||||
referencingNodes: Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
}>
|
||||
}
|
||||
|
||||
/** A category group of missing models sharing the same directory. */
|
||||
export interface MissingModelGroup {
|
||||
directory: string | null
|
||||
models: MissingModelViewModel[]
|
||||
isAssetSupported: boolean
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export type RemoteConfig = {
|
||||
mixpanel_token?: string
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_debug?: boolean
|
||||
subscription_required?: boolean
|
||||
server_health_alert?: ServerHealthAlert
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -996,16 +996,6 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: true,
|
||||
versionAdded: '1.10.5'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.AlignNodesWhileDragging',
|
||||
category: ['LiteGraph', 'Canvas', 'AlignNodesWhileDragging'],
|
||||
name: 'Align nodes while dragging',
|
||||
tooltip:
|
||||
'When enabled in Nodes 2.0, dragging a node selection near another node will snap matching edges and centers together.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
versionAdded: '1.31.0'
|
||||
},
|
||||
{
|
||||
id: 'LiteGraph.Reroute.SplineOffset',
|
||||
name: 'Reroute spline offset',
|
||||
@@ -1211,10 +1201,10 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
{
|
||||
id: 'Comfy.Queue.QPOV2',
|
||||
category: ['Comfy', 'Queue', 'Layout'],
|
||||
name: 'Use the unified job queue in the Assets side panel',
|
||||
name: 'Docked job history/queue panel',
|
||||
type: 'boolean',
|
||||
tooltip:
|
||||
'Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout.',
|
||||
'Replaces the floating job queue panel with an equivalent job queue embedded in the job history side panel. You can disable this to return to the floating panel layout.',
|
||||
defaultValue: false,
|
||||
experimental: true
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AuditLog } from '@/services/customerEventsService'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
@@ -27,7 +28,8 @@ import type {
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
@@ -157,6 +159,14 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackWorkflowOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackWorkflowSaved?.(metadata))
|
||||
}
|
||||
|
||||
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
|
||||
this.dispatch((provider) => provider.trackDefaultViewSet?.(metadata))
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.dispatch((provider) => provider.trackEnterLinear?.(metadata))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
CreditTopupMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
@@ -40,7 +41,8 @@ import type {
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from '../../types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
@@ -359,6 +361,14 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
|
||||
}
|
||||
|
||||
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
|
||||
}
|
||||
|
||||
@@ -80,27 +80,30 @@ describe('PostHogTelemetryProvider', () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith('phc_test_token', {
|
||||
api_host: 'https://t.comfy.org',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: false
|
||||
})
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({
|
||||
api_host: 'https://t.comfy.org',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses custom api_host from config when provided', async () => {
|
||||
it('enables debug mode when posthog_debug is true in config', async () => {
|
||||
window.__CONFIG__ = {
|
||||
posthog_project_token: 'phc_test_token',
|
||||
posthog_api_host: 'https://custom.host.com'
|
||||
posthog_debug: true
|
||||
} as typeof window.__CONFIG__
|
||||
new PostHogTelemetryProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({ api_host: 'https://custom.host.com' })
|
||||
expect.objectContaining({ debug: true })
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
@@ -34,7 +35,8 @@ import type {
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
@@ -102,11 +104,14 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.posthog!.init(apiKey, {
|
||||
api_host:
|
||||
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true'
|
||||
debug:
|
||||
window.__CONFIG__?.posthog_debug ??
|
||||
import.meta.env.VITE_POSTHOG_DEBUG === 'true'
|
||||
})
|
||||
this.isInitialized = true
|
||||
this.flushEventQueue()
|
||||
@@ -344,6 +349,14 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
|
||||
}
|
||||
|
||||
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
|
||||
}
|
||||
|
||||
@@ -149,6 +149,15 @@ export interface EnterLinearMetadata {
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface WorkflowSavedMetadata {
|
||||
is_app: boolean
|
||||
is_new: boolean
|
||||
}
|
||||
|
||||
export interface DefaultViewSetMetadata {
|
||||
default_view: 'app' | 'graph'
|
||||
}
|
||||
|
||||
type ShareFlowStep =
|
||||
| 'dialog_opened'
|
||||
| 'save_prompted'
|
||||
@@ -377,6 +386,8 @@ export interface TelemetryProvider {
|
||||
// Workflow management events
|
||||
trackWorkflowImported?(metadata: WorkflowImportMetadata): void
|
||||
trackWorkflowOpened?(metadata: WorkflowImportMetadata): void
|
||||
trackWorkflowSaved?(metadata: WorkflowSavedMetadata): void
|
||||
trackDefaultViewSet?(metadata: DefaultViewSetMetadata): void
|
||||
trackEnterLinear?(metadata: EnterLinearMetadata): void
|
||||
trackShareFlow?(metadata: ShareFlowMetadata): void
|
||||
|
||||
@@ -490,6 +501,8 @@ export const TelemetryEvents = {
|
||||
|
||||
// Workflow Creation
|
||||
WORKFLOW_CREATED: 'app:workflow_created',
|
||||
WORKFLOW_SAVED: 'app:workflow_saved',
|
||||
DEFAULT_VIEW_SET: 'app:default_view_set',
|
||||
|
||||
// Execution Lifecycle
|
||||
EXECUTION_START: 'execution_start',
|
||||
@@ -540,4 +553,6 @@ export type TelemetryEventProperties =
|
||||
| WorkflowCreatedMetadata
|
||||
| EnterLinearMetadata
|
||||
| ShareFlowMetadata
|
||||
| WorkflowSavedMetadata
|
||||
| DefaultViewSetMetadata
|
||||
| SubscriptionMetadata
|
||||
|
||||
@@ -149,6 +149,8 @@ export const useWorkflowService = () => {
|
||||
await openWorkflow(tempWorkflow)
|
||||
await workflowStore.saveWorkflow(tempWorkflow)
|
||||
}
|
||||
|
||||
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: true })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -189,6 +191,7 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,15 +547,16 @@ export const useWorkflowService = () => {
|
||||
if (settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')) {
|
||||
missingNodesDialog.show({ missingNodeTypes })
|
||||
}
|
||||
|
||||
executionErrorStore.surfaceMissingNodes(missingNodeTypes)
|
||||
}
|
||||
|
||||
if (
|
||||
missingModels &&
|
||||
settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')
|
||||
) {
|
||||
missingModelsDialog.show(missingModels)
|
||||
// Missing models are NOT surfaced to the Errors tab here.
|
||||
// On Cloud, the dedicated pipeline in app.ts handles detection and
|
||||
// surfacing via surfaceMissingModels(). OSS uses only this dialog.
|
||||
if (missingModels) {
|
||||
if (settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')) {
|
||||
missingModelsDialog.show(missingModels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,13 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildSubgraphExecutionPaths,
|
||||
flattenWorkflowNodes,
|
||||
validateComfyWorkflow
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ComfyNode,
|
||||
ComfyWorkflowJSON
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { defaultGraph } from '@/scripts/defaultGraph'
|
||||
|
||||
const WORKFLOW_DIR = 'src/platform/workflow/validation/schemas/__fixtures__'
|
||||
@@ -274,3 +278,48 @@ describe('buildSubgraphExecutionPaths', () => {
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('flattenWorkflowNodes', () => {
|
||||
it('returns root nodes when no subgraphs exist', () => {
|
||||
const result = flattenWorkflowNodes({
|
||||
nodes: [node(1, 'KSampler'), node(2, 'CLIPLoader')]
|
||||
} as ComfyWorkflowJSON)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((n) => n.id)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('returns empty array when nodes is undefined', () => {
|
||||
const result = flattenWorkflowNodes({} as ComfyWorkflowJSON)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('includes subgraph nodes with prefixed IDs', () => {
|
||||
const result = flattenWorkflowNodes({
|
||||
nodes: [node(5, 'def-A')],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
subgraphDef('def-A', [node(10, 'Inner'), node(20, 'Inner2')])
|
||||
]
|
||||
}
|
||||
} as unknown as ComfyWorkflowJSON)
|
||||
|
||||
expect(result).toHaveLength(3) // 1 root + 2 subgraph
|
||||
expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:20'])
|
||||
})
|
||||
|
||||
it('prefixes nested subgraph nodes with full execution path', () => {
|
||||
const result = flattenWorkflowNodes({
|
||||
nodes: [node(5, 'def-A')],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
subgraphDef('def-A', [node(10, 'def-B')]),
|
||||
subgraphDef('def-B', [node(3, 'Leaf')])
|
||||
]
|
||||
}
|
||||
} as unknown as ComfyWorkflowJSON)
|
||||
|
||||
// root:5, def-A inner: 5:10, def-B inner: 5:10:3
|
||||
expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:10:3'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -592,3 +592,56 @@ export function buildSubgraphExecutionPaths(
|
||||
build(rootNodes, '')
|
||||
return pathMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all subgraph definitions from root and nested levels.
|
||||
*/
|
||||
function collectAllSubgraphDefs(rootDefs: unknown[]): SubgraphDefinition[] {
|
||||
const result: SubgraphDefinition[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
function collect(defs: unknown[]) {
|
||||
for (const def of defs) {
|
||||
if (!isSubgraphDefinition(def)) continue
|
||||
if (seen.has(def.id)) continue
|
||||
seen.add(def.id)
|
||||
result.push(def)
|
||||
if (def.definitions?.subgraphs?.length) {
|
||||
collect(def.definitions.subgraphs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect(rootDefs)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten all workflow nodes (root + subgraphs) into a single array.
|
||||
* Each node's `id` is prefixed with its execution path (e.g. node "3" inside container "11" → "11:3").
|
||||
*/
|
||||
export function flattenWorkflowNodes(
|
||||
graphData: ComfyWorkflowJSON
|
||||
): Readonly<ComfyNode>[] {
|
||||
const rootNodes = graphData.nodes ?? []
|
||||
const allDefs = collectAllSubgraphDefs(graphData.definitions?.subgraphs ?? [])
|
||||
const pathMap = buildSubgraphExecutionPaths(rootNodes, allDefs)
|
||||
|
||||
const allNodes: ComfyNode[] = [...rootNodes]
|
||||
|
||||
const subgraphDefMap = new Map(allDefs.map((s) => [s.id, s]))
|
||||
for (const [defId, paths] of pathMap.entries()) {
|
||||
const def = subgraphDefMap.get(defId)
|
||||
if (!def?.nodes) continue
|
||||
for (const prefix of paths) {
|
||||
for (const node of def.nodes) {
|
||||
allNodes.push({
|
||||
...node,
|
||||
id: `${prefix}:${node.id}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allNodes
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { computed, customRef, ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import type { NodeAlignmentGuide } from '@/renderer/extensions/vueNodes/layout/nodeAlignmentSnap'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
|
||||
@@ -139,7 +138,6 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Vue dragging state for selection toolbox (public ref for direct mutation)
|
||||
public isDraggingVueNodes = ref(false)
|
||||
public vueDragSnapGuides = ref<NodeAlignmentGuide[]>([])
|
||||
// Vue resizing state to prevent drag from activating during resize
|
||||
public isResizingVueNodes = ref(false)
|
||||
|
||||
|
||||
131
src/renderer/extensions/linearMode/DropZone.stories.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import DropZone from './DropZone.vue'
|
||||
|
||||
type StoryArgs = ComponentPropsAndSlots<typeof DropZone>
|
||||
|
||||
const defaultLabel = 'Click to browse or drag an image'
|
||||
const defaultIconClass = 'icon-[lucide--image]'
|
||||
|
||||
function createFileInput(onFile: (file: File) => void) {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/*'
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files?.[0]
|
||||
if (file) onFile(file)
|
||||
})
|
||||
return input
|
||||
}
|
||||
|
||||
function fileToObjectUrl(file: File): string {
|
||||
return URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
function extractDroppedImageFile(e: DragEvent): File | undefined {
|
||||
return Array.from(e.dataTransfer?.files ?? []).find((f) =>
|
||||
f.type.startsWith('image/')
|
||||
)
|
||||
}
|
||||
|
||||
const renderStory = (args: StoryArgs) => ({
|
||||
components: { DropZone },
|
||||
setup() {
|
||||
const imageUrl = ref<string | undefined>(undefined)
|
||||
const hovered = ref(false)
|
||||
|
||||
function handleFile(file: File) {
|
||||
imageUrl.value = fileToObjectUrl(file)
|
||||
}
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!e.dataTransfer?.items) return false
|
||||
return Array.from(e.dataTransfer.items).some(
|
||||
(item) => item.kind === 'file' && item.type.startsWith('image/')
|
||||
)
|
||||
}
|
||||
|
||||
const onDragDrop = (e: DragEvent) => {
|
||||
const file = extractDroppedImageFile(e)
|
||||
if (file) handleFile(file)
|
||||
return !!file
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
createFileInput(handleFile).click()
|
||||
}
|
||||
|
||||
const dropIndicator = ref({
|
||||
...args.dropIndicator,
|
||||
onClick
|
||||
})
|
||||
|
||||
return { args, onDragOver, onDragDrop, dropIndicator, imageUrl, hovered }
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
@mouseenter="hovered = true"
|
||||
@mouseleave="hovered = false"
|
||||
>
|
||||
<DropZone
|
||||
v-bind="args"
|
||||
:on-drag-over="onDragOver"
|
||||
:on-drag-drop="onDragDrop"
|
||||
:force-hovered="hovered"
|
||||
:drop-indicator="{
|
||||
...dropIndicator,
|
||||
imageUrl: imageUrl ?? dropIndicator.imageUrl
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/FileUpload',
|
||||
component: DropZone,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Linear mode drag-and-drop target with a file upload indicator. Click to browse or drag an image file to upload.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
onDragOver: { table: { disable: true } },
|
||||
onDragDrop: { table: { disable: true } },
|
||||
dropIndicator: { control: false },
|
||||
forceHovered: { table: { disable: true } }
|
||||
},
|
||||
args: {
|
||||
dropIndicator: {
|
||||
label: defaultLabel,
|
||||
iconClass: defaultIconClass
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: `
|
||||
<div class="w-[440px] rounded-xl bg-component-node-background p-4">
|
||||
<story />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: renderStory
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { useDropZone } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
const {
|
||||
onDragOver,
|
||||
onDragDrop,
|
||||
dropIndicator,
|
||||
forceHovered = false
|
||||
} = defineProps<{
|
||||
onDragOver?: (e: DragEvent) => boolean
|
||||
onDragDrop?: (e: DragEvent) => Promise<boolean> | boolean
|
||||
dropIndicator?: {
|
||||
@@ -13,6 +18,7 @@ const props = defineProps<{
|
||||
label?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
}
|
||||
forceHovered?: boolean
|
||||
}>()
|
||||
|
||||
const dropZoneRef = ref<HTMLElement | null>(null)
|
||||
@@ -23,53 +29,83 @@ const { isOverDropZone } = useDropZone(dropZoneRef, {
|
||||
// Stop propagation to prevent global handlers from creating a new node
|
||||
event?.stopPropagation()
|
||||
|
||||
if (props.onDragDrop && event) {
|
||||
props.onDragDrop(event)
|
||||
if (onDragDrop && event) {
|
||||
onDragDrop(event)
|
||||
}
|
||||
canAcceptDrop.value = false
|
||||
},
|
||||
onOver: (_, event) => {
|
||||
if (props.onDragOver && event) {
|
||||
canAcceptDrop.value = props.onDragOver(event)
|
||||
if (onDragOver && event) {
|
||||
canAcceptDrop.value = onDragOver(event)
|
||||
}
|
||||
},
|
||||
onLeave: () => {
|
||||
canAcceptDrop.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const isHovered = computed(
|
||||
() => forceHovered || (canAcceptDrop.value && isOverDropZone.value)
|
||||
)
|
||||
const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="onDragOver && onDragDrop"
|
||||
ref="dropZoneRef"
|
||||
data-slot="drop-zone"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg ring-primary-500 ring-inset',
|
||||
canAcceptDrop && isOverDropZone && 'bg-primary-500/10 ring-4'
|
||||
'rounded-lg transition-colors',
|
||||
isHovered && 'bg-component-node-widget-background-hovered'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
<component
|
||||
:is="indicatorTag"
|
||||
v-if="dropIndicator"
|
||||
:type="dropIndicator?.onClick ? 'button' : undefined"
|
||||
:aria-label="dropIndicator?.onClick ? dropIndicator.label : undefined"
|
||||
data-slot="drop-zone-indicator"
|
||||
:class="
|
||||
cn(
|
||||
'm-3 flex h-25 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-subtle py-2',
|
||||
'm-3 block w-[calc(100%-1.5rem)] appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
|
||||
dropIndicator?.onClick && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
@click.prevent="dropIndicator?.onClick?.($event)"
|
||||
>
|
||||
<img
|
||||
v-if="dropIndicator?.imageUrl"
|
||||
class="h-23"
|
||||
:src="dropIndicator?.imageUrl"
|
||||
/>
|
||||
<template v-else>
|
||||
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
|
||||
<i v-if="dropIndicator.iconClass" :class="dropIndicator.iconClass" />
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-23 w-full flex-col items-center justify-center gap-2 rounded-[7px] p-6 text-center text-sm/tight transition-colors',
|
||||
isHovered &&
|
||||
!dropIndicator?.imageUrl &&
|
||||
'border border-dashed border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
|
||||
)
|
||||
"
|
||||
>
|
||||
<img
|
||||
v-if="dropIndicator?.imageUrl"
|
||||
class="max-h-23 rounded-md object-contain"
|
||||
:alt="dropIndicator?.label ?? ''"
|
||||
:src="dropIndicator?.imageUrl"
|
||||
/>
|
||||
<template v-else>
|
||||
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
|
||||
<i
|
||||
v-if="dropIndicator.iconClass"
|
||||
:class="
|
||||
cn(
|
||||
'size-4 text-component-node-foreground-secondary',
|
||||
dropIndicator.iconClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</component>
|
||||
</div>
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
@@ -64,6 +64,7 @@ useEventListener(
|
||||
)
|
||||
|
||||
const mappedSelections = computed(() => {
|
||||
void graphNodes.value
|
||||
let unprocessedInputs = appModeStore.selectedInputs.flatMap(
|
||||
([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, watch } from 'vue'
|
||||
import { onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
@@ -19,6 +19,10 @@ watch([containerRef, () => modelUrl], async () => {
|
||||
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
viewer.value.cleanup()
|
||||
})
|
||||
|
||||
//TODO: refactor to add control buttons
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -187,6 +187,46 @@ describe('useMinimapViewport', () => {
|
||||
expect(transform.height).toBeCloseTo(viewportHeight * 0.5) // 300 * 0.5 = 150
|
||||
})
|
||||
|
||||
it('should maintain strict reference equality for viewportTransform when canvas state is unchanged', () => {
|
||||
vi.mocked(calculateNodeBounds).mockReturnValue({
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 500,
|
||||
maxY: 400,
|
||||
width: 500,
|
||||
height: 400
|
||||
})
|
||||
|
||||
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
|
||||
vi.mocked(calculateMinimapScale).mockReturnValue(0.5)
|
||||
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
mockCanvas.ds.scale = 2
|
||||
mockCanvas.ds.offset = [-100, -50]
|
||||
|
||||
viewport.updateBounds()
|
||||
viewport.updateCanvasDimensions()
|
||||
viewport.updateViewport()
|
||||
|
||||
const initialTransform = viewport.viewportTransform.value
|
||||
|
||||
viewport.updateViewport()
|
||||
const transformAfterIdle = viewport.viewportTransform.value
|
||||
|
||||
expect(transformAfterIdle).toBe(initialTransform)
|
||||
|
||||
mockCanvas.ds.offset = [-150, -50]
|
||||
viewport.updateViewport()
|
||||
const transformAfterPan = viewport.viewportTransform.value
|
||||
|
||||
expect(transformAfterPan).not.toBe(initialTransform)
|
||||
expect(transformAfterPan.x).not.toBe(initialTransform.x)
|
||||
})
|
||||
|
||||
it('should center view on world coordinates', () => {
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
@@ -90,12 +90,16 @@ export function useMinimapViewport(
|
||||
const centerOffsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const centerOffsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
viewportTransform.value = {
|
||||
x: (worldX - bounds.value.minX) * scale.value + centerOffsetX,
|
||||
y: (worldY - bounds.value.minY) * scale.value + centerOffsetY,
|
||||
width: viewportWidth * scale.value,
|
||||
height: viewportHeight * scale.value
|
||||
}
|
||||
const x = (worldX - bounds.value.minX) * scale.value + centerOffsetX
|
||||
const y = (worldY - bounds.value.minY) * scale.value + centerOffsetY
|
||||
const w = viewportWidth * scale.value
|
||||
const h = viewportHeight * scale.value
|
||||
|
||||
const curr = viewportTransform.value
|
||||
if (curr.x === x && curr.y === y && curr.width === w && curr.height === h)
|
||||
return
|
||||
|
||||
viewportTransform.value = { x, y, width: w, height: h }
|
||||
}
|
||||
|
||||
const updateBounds = () => {
|
||||
|
||||
@@ -289,6 +289,7 @@ import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useN
|
||||
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
|
||||
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -339,6 +340,7 @@ const isSelected = computed(() => {
|
||||
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
|
||||
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const hasExecutionError = computed(
|
||||
() => executionErrorStore.lastExecutionErrorNodeId === nodeData.id
|
||||
)
|
||||
@@ -349,9 +351,11 @@ const hasAnyError = computed((): boolean => {
|
||||
nodeData.hasErrors ||
|
||||
error ||
|
||||
executionErrorStore.getNodeErrors(nodeLocatorId.value) ||
|
||||
missingModelStore.hasMissingModelOnNode(nodeLocatorId.value) ||
|
||||
(lgraphNode.value &&
|
||||
(executionErrorStore.isContainerWithInternalError(lgraphNode.value) ||
|
||||
executionErrorStore.isContainerWithMissingNode(lgraphNode.value)))
|
||||
executionErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
|
||||
missingModelStore.isContainerWithMissingModel(lgraphNode.value)))
|
||||
)
|
||||
})
|
||||
|
||||
@@ -426,7 +430,7 @@ const handleContextMenu = (event: MouseEvent) => {
|
||||
handleNodeRightClick(event as PointerEvent, nodeData.id)
|
||||
|
||||
// Show the node options menu at the cursor position
|
||||
showNodeOptions(event, undefined, undefined, 'contextmenu')
|
||||
showNodeOptions(event)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -115,6 +115,7 @@ import {
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -134,6 +135,7 @@ const canvasStore = useCanvasStore()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
const promotionStore = usePromotionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
function handleWidgetPointerEvent(event: PointerEvent) {
|
||||
if (shouldHandleNodePointerEvents.value) return
|
||||
@@ -202,6 +204,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
const graphId = canvasStore.canvas?.graph?.rootGraph.id
|
||||
|
||||
const nodeId = nodeData.id
|
||||
const nodeIdStr = String(nodeId)
|
||||
const { widgets } = nodeData
|
||||
const result: ProcessedWidget[] = []
|
||||
|
||||
@@ -275,8 +278,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
widget.name,
|
||||
widget.nodeId !== undefined
|
||||
? String(stripGraphPrefix(widget.nodeId))
|
||||
: undefined,
|
||||
'contextmenu'
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
|
||||
@@ -285,9 +287,11 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
handleContextMenu,
|
||||
hasLayoutSize: widget.hasLayoutSize ?? false,
|
||||
hasError:
|
||||
nodeErrors?.errors?.some(
|
||||
(nodeErrors?.errors?.some(
|
||||
(error) => error.extra_info?.input_name === widget.name
|
||||
) ?? false,
|
||||
) ??
|
||||
false) ||
|
||||
missingModelStore.isWidgetMissingModel(nodeIdStr, widget.name),
|
||||
hidden: widget.options?.hidden ?? false,
|
||||
id: String(bareWidgetId),
|
||||
name: widget.name,
|
||||
|
||||
@@ -102,7 +102,6 @@ export function useNodePointerInteractions(
|
||||
|
||||
function cleanupDragState() {
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
layoutStore.vueDragSnapGuides.value = []
|
||||
}
|
||||
|
||||
function safeDragStart(event: PointerEvent, nodeId: string) {
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveNodeAlignmentSnap } from './nodeAlignmentSnap'
|
||||
|
||||
describe('resolveNodeAlignmentSnap', () => {
|
||||
const selectionBounds = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 80
|
||||
}
|
||||
|
||||
it('snaps matching edges within threshold', () => {
|
||||
const result = resolveNodeAlignmentSnap({
|
||||
selectionBounds,
|
||||
candidateBounds: [
|
||||
{
|
||||
x: 200,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 80
|
||||
}
|
||||
],
|
||||
delta: { x: 193, y: 0 },
|
||||
zoomScale: 1
|
||||
})
|
||||
|
||||
expect(result.delta).toEqual({ x: 200, y: 0 })
|
||||
expect(result.guides).toContainEqual({
|
||||
axis: 'vertical',
|
||||
coordinate: 200,
|
||||
start: 0,
|
||||
end: 80
|
||||
})
|
||||
})
|
||||
|
||||
it('snaps matching centers within threshold', () => {
|
||||
const result = resolveNodeAlignmentSnap({
|
||||
selectionBounds,
|
||||
candidateBounds: [
|
||||
{
|
||||
x: 0,
|
||||
y: 200,
|
||||
width: 100,
|
||||
height: 80
|
||||
}
|
||||
],
|
||||
delta: { x: 0, y: 196 },
|
||||
zoomScale: 1
|
||||
})
|
||||
|
||||
expect(result.delta).toEqual({ x: 0, y: 200 })
|
||||
expect(result.guides).toContainEqual({
|
||||
axis: 'horizontal',
|
||||
coordinate: 200,
|
||||
start: 0,
|
||||
end: 100
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the nearest candidate correction', () => {
|
||||
const result = resolveNodeAlignmentSnap({
|
||||
selectionBounds,
|
||||
candidateBounds: [
|
||||
{
|
||||
x: 200,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 80
|
||||
},
|
||||
{
|
||||
x: 198,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 80
|
||||
}
|
||||
],
|
||||
delta: { x: 193, y: 0 },
|
||||
zoomScale: 1
|
||||
})
|
||||
|
||||
expect(result.delta).toEqual({ x: 198, y: 0 })
|
||||
expect(result.guides).toContainEqual({
|
||||
axis: 'vertical',
|
||||
coordinate: 198,
|
||||
start: 0,
|
||||
end: 80
|
||||
})
|
||||
})
|
||||
|
||||
it('does not snap when outside the zoom-adjusted threshold', () => {
|
||||
const result = resolveNodeAlignmentSnap({
|
||||
selectionBounds,
|
||||
candidateBounds: [
|
||||
{
|
||||
x: 200,
|
||||
y: 200,
|
||||
width: 100,
|
||||
height: 80
|
||||
}
|
||||
],
|
||||
delta: { x: 183, y: 0 },
|
||||
zoomScale: 0.5
|
||||
})
|
||||
|
||||
expect(result.delta).toEqual({ x: 183, y: 0 })
|
||||
expect(result.guides).toEqual([])
|
||||
})
|
||||
|
||||
it('tightens the canvas threshold as zoom increases', () => {
|
||||
const result = resolveNodeAlignmentSnap({
|
||||
selectionBounds,
|
||||
candidateBounds: [
|
||||
{
|
||||
x: 200,
|
||||
y: 200,
|
||||
width: 100,
|
||||
height: 80
|
||||
}
|
||||
],
|
||||
delta: { x: 195, y: 0 },
|
||||
zoomScale: 2
|
||||
})
|
||||
|
||||
expect(result.delta).toEqual({ x: 195, y: 0 })
|
||||
expect(result.guides).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,225 +0,0 @@
|
||||
import type { Bounds, Point } from '@/renderer/core/layout/types'
|
||||
|
||||
const DEFAULT_THRESHOLD_PX = 8
|
||||
|
||||
type HorizontalAnchor = 'bottom' | 'centerY' | 'top'
|
||||
type VerticalAnchor = 'centerX' | 'left' | 'right'
|
||||
|
||||
export interface NodeAlignmentGuide {
|
||||
axis: 'horizontal' | 'vertical'
|
||||
coordinate: number
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface NodeAlignmentSnapResult {
|
||||
delta: Point
|
||||
guides: NodeAlignmentGuide[]
|
||||
}
|
||||
|
||||
interface AxisMatch {
|
||||
axis: 'horizontal' | 'vertical'
|
||||
anchor: HorizontalAnchor | VerticalAnchor
|
||||
candidateBounds: Bounds
|
||||
correction: number
|
||||
}
|
||||
|
||||
interface ResolveNodeAlignmentSnapOptions {
|
||||
candidateBounds: Bounds[]
|
||||
delta: Point
|
||||
selectionBounds: Bounds
|
||||
thresholdPx?: number
|
||||
zoomScale: number
|
||||
}
|
||||
|
||||
export function resolveNodeAlignmentSnap({
|
||||
candidateBounds,
|
||||
delta,
|
||||
selectionBounds,
|
||||
thresholdPx = DEFAULT_THRESHOLD_PX,
|
||||
zoomScale
|
||||
}: ResolveNodeAlignmentSnapOptions): NodeAlignmentSnapResult {
|
||||
if (!candidateBounds.length || zoomScale <= 0) {
|
||||
return { delta, guides: [] }
|
||||
}
|
||||
|
||||
const threshold = thresholdPx / zoomScale
|
||||
const translatedSelectionBounds = translateBounds(selectionBounds, delta)
|
||||
|
||||
const verticalMatch = findBestVerticalMatch(
|
||||
translatedSelectionBounds,
|
||||
candidateBounds,
|
||||
threshold
|
||||
)
|
||||
const horizontalMatch = findBestHorizontalMatch(
|
||||
translatedSelectionBounds,
|
||||
candidateBounds,
|
||||
threshold
|
||||
)
|
||||
|
||||
const snappedDelta = {
|
||||
x: delta.x + (verticalMatch?.correction ?? 0),
|
||||
y: delta.y + (horizontalMatch?.correction ?? 0)
|
||||
}
|
||||
const snappedSelectionBounds = translateBounds(selectionBounds, snappedDelta)
|
||||
const guides = [
|
||||
verticalMatch &&
|
||||
createVerticalGuide(
|
||||
snappedSelectionBounds,
|
||||
verticalMatch.candidateBounds
|
||||
),
|
||||
horizontalMatch &&
|
||||
createHorizontalGuide(
|
||||
snappedSelectionBounds,
|
||||
horizontalMatch.candidateBounds
|
||||
)
|
||||
].filter((guide): guide is NodeAlignmentGuide => guide !== undefined)
|
||||
|
||||
return {
|
||||
delta: snappedDelta,
|
||||
guides
|
||||
}
|
||||
}
|
||||
|
||||
function findBestVerticalMatch(
|
||||
selectionBounds: Bounds,
|
||||
candidateBounds: Bounds[],
|
||||
threshold: number
|
||||
): AxisMatch | undefined {
|
||||
return findBestMatch<VerticalAnchor>(
|
||||
'vertical',
|
||||
getVerticalAnchorValues(selectionBounds),
|
||||
candidateBounds,
|
||||
threshold,
|
||||
getVerticalAnchorValues
|
||||
)
|
||||
}
|
||||
|
||||
function findBestHorizontalMatch(
|
||||
selectionBounds: Bounds,
|
||||
candidateBounds: Bounds[],
|
||||
threshold: number
|
||||
): AxisMatch | undefined {
|
||||
return findBestMatch<HorizontalAnchor>(
|
||||
'horizontal',
|
||||
getHorizontalAnchorValues(selectionBounds),
|
||||
candidateBounds,
|
||||
threshold,
|
||||
getHorizontalAnchorValues
|
||||
)
|
||||
}
|
||||
|
||||
function findBestMatch<TAnchor extends HorizontalAnchor | VerticalAnchor>(
|
||||
axis: 'horizontal' | 'vertical',
|
||||
selectionAnchors: Record<TAnchor, number>,
|
||||
candidateBounds: Bounds[],
|
||||
threshold: number,
|
||||
getCandidateAnchors: (bounds: Bounds) => Record<TAnchor, number>
|
||||
): AxisMatch | undefined {
|
||||
let bestMatch: AxisMatch | undefined
|
||||
|
||||
for (const bounds of candidateBounds) {
|
||||
const candidateAnchors = getCandidateAnchors(bounds)
|
||||
|
||||
for (const anchor of Object.keys(selectionAnchors) as TAnchor[]) {
|
||||
const correction = candidateAnchors[anchor] - selectionAnchors[anchor]
|
||||
if (Math.abs(correction) > threshold) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!bestMatch || Math.abs(correction) < Math.abs(bestMatch.correction)) {
|
||||
bestMatch = {
|
||||
axis,
|
||||
anchor,
|
||||
candidateBounds: bounds,
|
||||
correction
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
function getVerticalAnchorValues(
|
||||
bounds: Bounds
|
||||
): Record<VerticalAnchor, number> {
|
||||
return {
|
||||
left: bounds.x,
|
||||
centerX: bounds.x + bounds.width * 0.5,
|
||||
right: bounds.x + bounds.width
|
||||
}
|
||||
}
|
||||
|
||||
function getHorizontalAnchorValues(
|
||||
bounds: Bounds
|
||||
): Record<HorizontalAnchor, number> {
|
||||
return {
|
||||
top: bounds.y,
|
||||
centerY: bounds.y + bounds.height * 0.5,
|
||||
bottom: bounds.y + bounds.height
|
||||
}
|
||||
}
|
||||
|
||||
function createVerticalGuide(
|
||||
selectionBounds: Bounds,
|
||||
candidateBounds: Bounds
|
||||
): NodeAlignmentGuide {
|
||||
const candidateAnchors = getVerticalAnchorValues(candidateBounds)
|
||||
const selectionAnchors = getVerticalAnchorValues(selectionBounds)
|
||||
const coordinate = getSharedAnchorValue(selectionAnchors, candidateAnchors)
|
||||
|
||||
return {
|
||||
axis: 'vertical',
|
||||
coordinate,
|
||||
start: Math.min(selectionBounds.y, candidateBounds.y),
|
||||
end: Math.max(
|
||||
selectionBounds.y + selectionBounds.height,
|
||||
candidateBounds.y + candidateBounds.height
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function createHorizontalGuide(
|
||||
selectionBounds: Bounds,
|
||||
candidateBounds: Bounds
|
||||
): NodeAlignmentGuide {
|
||||
const candidateAnchors = getHorizontalAnchorValues(candidateBounds)
|
||||
const selectionAnchors = getHorizontalAnchorValues(selectionBounds)
|
||||
const coordinate = getSharedAnchorValue(selectionAnchors, candidateAnchors)
|
||||
|
||||
return {
|
||||
axis: 'horizontal',
|
||||
coordinate,
|
||||
start: Math.min(selectionBounds.x, candidateBounds.x),
|
||||
end: Math.max(
|
||||
selectionBounds.x + selectionBounds.width,
|
||||
candidateBounds.x + candidateBounds.width
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getSharedAnchorValue<
|
||||
TAnchor extends HorizontalAnchor | VerticalAnchor
|
||||
>(
|
||||
selectionAnchors: Record<TAnchor, number>,
|
||||
candidateAnchors: Record<TAnchor, number>
|
||||
): number {
|
||||
const anchors = Object.keys(selectionAnchors) as TAnchor[]
|
||||
|
||||
for (const anchor of anchors) {
|
||||
if (selectionAnchors[anchor] === candidateAnchors[anchor]) {
|
||||
return selectionAnchors[anchor]
|
||||
}
|
||||
}
|
||||
|
||||
return selectionAnchors[anchors[0]]
|
||||
}
|
||||
|
||||
function translateBounds(bounds: Bounds, delta: Point): Bounds {
|
||||
return {
|
||||
...bounds,
|
||||
x: bounds.x + delta.x,
|
||||
y: bounds.y + delta.y
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
|
||||
import type * as VueUseModule from '@vueuse/core'
|
||||
|
||||
const settingValues: Record<string, boolean | number> = {
|
||||
'Comfy.Canvas.AlignNodesWhileDragging': false,
|
||||
'Comfy.SnapToGrid.GridSize': 10,
|
||||
'pysssss.SnapToGrid': false
|
||||
}
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual<typeof VueUseModule>('@vueuse/core')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
createSharedComposable: <TArgs extends unknown[], TResult>(
|
||||
composable: (...args: TArgs) => TResult
|
||||
) => composable
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => settingValues[key]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: null
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useNodeDrag', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
layoutStore.initializeFromLiteGraph([
|
||||
{ id: 'node-1', pos: [0, 40], size: [100, 60] },
|
||||
{ id: 'node-2', pos: [40, 40], size: [100, 60] },
|
||||
{ id: 'node-3', pos: [200, 40], size: [100, 60] }
|
||||
])
|
||||
layoutStore.vueDragSnapGuides.value = []
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
|
||||
LiteGraph.NODE_TITLE_HEIGHT = 30
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.selectedItems = [{ id: 'node-1' }, { id: 'node-2' }] as never[]
|
||||
canvasStore.canvas = {
|
||||
setDirty: vi.fn()
|
||||
} as never
|
||||
|
||||
settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = false
|
||||
settingValues['pysssss.SnapToGrid'] = false
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
})
|
||||
|
||||
it('snaps the dragged multi-selection and clears guides on drag end', () => {
|
||||
settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
|
||||
|
||||
const { startDrag, handleDrag, endDrag } = useNodeDrag()
|
||||
|
||||
startDrag(
|
||||
new PointerEvent('pointerdown', { clientX: 0, clientY: 0 }),
|
||||
'node-1'
|
||||
)
|
||||
handleDrag(
|
||||
new PointerEvent('pointermove', {
|
||||
clientX: 193,
|
||||
clientY: 0
|
||||
}),
|
||||
'node-1'
|
||||
)
|
||||
|
||||
expect(layoutStore.getNodeLayoutRef('node-1').value?.position.x).toBe(200)
|
||||
expect(layoutStore.getNodeLayoutRef('node-2').value?.position.x).toBe(240)
|
||||
expect(layoutStore.vueDragSnapGuides.value).toContainEqual(
|
||||
expect.objectContaining({
|
||||
axis: 'vertical',
|
||||
coordinate: 200
|
||||
})
|
||||
)
|
||||
|
||||
endDrag(
|
||||
new PointerEvent('pointerup', { clientX: 193, clientY: 0 }),
|
||||
'node-1'
|
||||
)
|
||||
|
||||
expect(layoutStore.vueDragSnapGuides.value).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes already selected nodes from alignment candidates', () => {
|
||||
settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
|
||||
|
||||
layoutStore.initializeFromLiteGraph([
|
||||
{ id: 'node-1', pos: [0, 40], size: [100, 60] },
|
||||
{ id: 'node-2', pos: [60, 40], size: [100, 60] }
|
||||
])
|
||||
|
||||
const { startDrag, handleDrag } = useNodeDrag()
|
||||
|
||||
startDrag(
|
||||
new PointerEvent('pointerdown', { clientX: 0, clientY: 0 }),
|
||||
'node-1'
|
||||
)
|
||||
handleDrag(
|
||||
new PointerEvent('pointermove', {
|
||||
clientX: 63,
|
||||
clientY: 0
|
||||
}),
|
||||
'node-1'
|
||||
)
|
||||
|
||||
expect(layoutStore.getNodeLayoutRef('node-1').value?.position.x).toBe(63)
|
||||
expect(layoutStore.vueDragSnapGuides.value).toEqual([])
|
||||
})
|
||||
|
||||
it('suppresses alignment snapping while grid snapping is active', () => {
|
||||
settingValues['Comfy.Canvas.AlignNodesWhileDragging'] = true
|
||||
|
||||
const { startDrag, handleDrag } = useNodeDrag()
|
||||
|
||||
startDrag(
|
||||
new PointerEvent('pointerdown', { clientX: 0, clientY: 0 }),
|
||||
'node-1'
|
||||
)
|
||||
handleDrag(
|
||||
new PointerEvent('pointermove', {
|
||||
clientX: 193,
|
||||
clientY: 0,
|
||||
shiftKey: true
|
||||
}),
|
||||
'node-1'
|
||||
)
|
||||
|
||||
expect(layoutStore.getNodeLayoutRef('node-1').value?.position.x).toBe(193)
|
||||
expect(layoutStore.vueDragSnapGuides.value).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,12 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type {
|
||||
Bounds,
|
||||
NodeBoundsUpdate,
|
||||
NodeId,
|
||||
Point
|
||||
@@ -20,16 +17,11 @@ import { useTransformState } from '@/renderer/core/layout/transform/useTransform
|
||||
import { isLGraphGroup } from '@/utils/litegraphUtil'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { NodeAlignmentSnapResult } from './nodeAlignmentSnap'
|
||||
import { resolveNodeAlignmentSnap } from './nodeAlignmentSnap'
|
||||
|
||||
export const useNodeDrag = createSharedComposable(useNodeDragIndividual)
|
||||
|
||||
function useNodeDragIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const mutations = useLayoutMutations()
|
||||
const { selectedNodeIds, selectedItems } = storeToRefs(canvasStore)
|
||||
const settingStore = useSettingStore()
|
||||
const { selectedNodeIds, selectedItems } = storeToRefs(useCanvasStore())
|
||||
|
||||
// Get transform utilities from TransformPane if available
|
||||
const transformState = useTransformState()
|
||||
@@ -50,8 +42,6 @@ function useNodeDragIndividual() {
|
||||
// For groups: track the last applied canvas delta to compute frame delta
|
||||
let lastCanvasDelta: Point | null = null
|
||||
let selectedGroups: LGraphGroup[] | null = null
|
||||
let draggedSelectionBounds: Bounds | null = null
|
||||
let alignmentCandidateBounds: Bounds[] = []
|
||||
|
||||
function startDrag(event: PointerEvent, nodeId: NodeId) {
|
||||
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
|
||||
@@ -64,15 +54,11 @@ function useNodeDragIndividual() {
|
||||
dragStartPos = { ...position }
|
||||
dragStartMouse = { x: event.clientX, y: event.clientY }
|
||||
|
||||
const selectedNodes = new Set(toValue(selectedNodeIds))
|
||||
selectedNodes.add(nodeId)
|
||||
draggedSelectionBounds = getDraggedSelectionBounds(selectedNodes)
|
||||
alignmentCandidateBounds = getAlignmentCandidateBounds(selectedNodes)
|
||||
updateDragSnapGuides([])
|
||||
const selectedNodes = toValue(selectedNodeIds)
|
||||
|
||||
// capture the starting positions of all other selected nodes
|
||||
// Only move other selected items if the dragged node is part of the selection
|
||||
const isDraggedNodeInSelection = selectedNodes.has(nodeId)
|
||||
const isDraggedNodeInSelection = selectedNodes?.has(nodeId)
|
||||
|
||||
if (isDraggedNodeInSelection && selectedNodes.size > 1) {
|
||||
otherSelectedNodesStartPositions = new Map()
|
||||
@@ -134,12 +120,11 @@ function useNodeDragIndividual() {
|
||||
x: canvasWithDelta.x - canvasOrigin.x,
|
||||
y: canvasWithDelta.y - canvasOrigin.y
|
||||
}
|
||||
const snappedCanvasDelta = maybeResolveAlignmentSnap(event, canvasDelta)
|
||||
|
||||
// Calculate new position for the current node
|
||||
const newPosition = {
|
||||
x: dragStartPos.x + snappedCanvasDelta.x,
|
||||
y: dragStartPos.y + snappedCanvasDelta.y
|
||||
x: dragStartPos.x + canvasDelta.x,
|
||||
y: dragStartPos.y + canvasDelta.y
|
||||
}
|
||||
|
||||
// Apply mutation through the layout system (Vue batches DOM updates automatically)
|
||||
@@ -155,8 +140,8 @@ function useNodeDragIndividual() {
|
||||
startPos
|
||||
] of otherSelectedNodesStartPositions) {
|
||||
const newOtherPosition = {
|
||||
x: startPos.x + snappedCanvasDelta.x,
|
||||
y: startPos.y + snappedCanvasDelta.y
|
||||
x: startPos.x + canvasDelta.x,
|
||||
y: startPos.y + canvasDelta.y
|
||||
}
|
||||
mutations.moveNode(otherNodeId, newOtherPosition)
|
||||
}
|
||||
@@ -166,8 +151,8 @@ function useNodeDragIndividual() {
|
||||
// This matches LiteGraph's behavior which uses delta-based movement
|
||||
if (selectedGroups && selectedGroups.length > 0 && lastCanvasDelta) {
|
||||
const frameDelta = {
|
||||
x: snappedCanvasDelta.x - lastCanvasDelta.x,
|
||||
y: snappedCanvasDelta.y - lastCanvasDelta.y
|
||||
x: canvasDelta.x - lastCanvasDelta.x,
|
||||
y: canvasDelta.y - lastCanvasDelta.y
|
||||
}
|
||||
|
||||
for (const group of selectedGroups) {
|
||||
@@ -175,7 +160,7 @@ function useNodeDragIndividual() {
|
||||
}
|
||||
}
|
||||
|
||||
lastCanvasDelta = snappedCanvasDelta
|
||||
lastCanvasDelta = canvasDelta
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,9 +231,6 @@ function useNodeDragIndividual() {
|
||||
otherSelectedNodesStartPositions = null
|
||||
selectedGroups = null
|
||||
lastCanvasDelta = null
|
||||
draggedSelectionBounds = null
|
||||
alignmentCandidateBounds = []
|
||||
updateDragSnapGuides([])
|
||||
|
||||
// Stop tracking shift key state
|
||||
stopShiftSync?.()
|
||||
@@ -266,99 +248,4 @@ function useNodeDragIndividual() {
|
||||
handleDrag,
|
||||
endDrag
|
||||
}
|
||||
|
||||
function maybeResolveAlignmentSnap(
|
||||
event: PointerEvent,
|
||||
canvasDelta: Point
|
||||
): Point {
|
||||
if (!settingStore.get('Comfy.Canvas.AlignNodesWhileDragging')) {
|
||||
updateDragSnapGuides([])
|
||||
return canvasDelta
|
||||
}
|
||||
|
||||
if (shouldSnap(event) || !draggedSelectionBounds) {
|
||||
updateDragSnapGuides([])
|
||||
return canvasDelta
|
||||
}
|
||||
|
||||
const snapResult: NodeAlignmentSnapResult = resolveNodeAlignmentSnap({
|
||||
selectionBounds: draggedSelectionBounds,
|
||||
candidateBounds: alignmentCandidateBounds,
|
||||
delta: canvasDelta,
|
||||
zoomScale: transformState.camera.z
|
||||
})
|
||||
|
||||
updateDragSnapGuides(snapResult.guides)
|
||||
return snapResult.delta
|
||||
}
|
||||
|
||||
function getDraggedSelectionBounds(nodeIds: Set<NodeId>): Bounds | null {
|
||||
const bounds = Array.from(nodeIds)
|
||||
.map((id) => layoutStore.getNodeLayoutRef(id).value)
|
||||
.filter((layout): layout is NonNullable<typeof layout> => layout !== null)
|
||||
.map(getRenderedNodeBounds)
|
||||
|
||||
return mergeBounds(bounds)
|
||||
}
|
||||
|
||||
function getAlignmentCandidateBounds(selectedNodeSet: Set<NodeId>): Bounds[] {
|
||||
const allNodes = layoutStore.getAllNodes().value
|
||||
const candidates: Bounds[] = []
|
||||
|
||||
for (const [nodeId, layout] of allNodes) {
|
||||
if (selectedNodeSet.has(nodeId)) {
|
||||
continue
|
||||
}
|
||||
candidates.push(getRenderedNodeBounds(layout))
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
function updateDragSnapGuides(
|
||||
guides: typeof layoutStore.vueDragSnapGuides.value
|
||||
) {
|
||||
layoutStore.vueDragSnapGuides.value = guides
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
function getRenderedNodeBounds(layout: {
|
||||
position: Point
|
||||
size: { width: number; height: number }
|
||||
}): Bounds {
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT || 0
|
||||
|
||||
return {
|
||||
x: layout.position.x,
|
||||
y: layout.position.y - titleHeight,
|
||||
width: layout.size.width,
|
||||
height: layout.size.height + titleHeight
|
||||
}
|
||||
}
|
||||
|
||||
function mergeBounds(boundsList: Bounds[]): Bounds | null {
|
||||
const [firstBounds, ...remainingBounds] = boundsList
|
||||
if (!firstBounds) {
|
||||
return null
|
||||
}
|
||||
|
||||
let left = firstBounds.x
|
||||
let top = firstBounds.y
|
||||
let right = firstBounds.x + firstBounds.width
|
||||
let bottom = firstBounds.y + firstBounds.height
|
||||
|
||||
for (const bounds of remainingBounds) {
|
||||
left = Math.min(left, bounds.x)
|
||||
top = Math.min(top, bounds.y)
|
||||
right = Math.max(right, bounds.x + bounds.width)
|
||||
bottom = Math.max(bottom, bounds.y + bounds.height)
|
||||
}
|
||||
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
width: right - left,
|
||||
height: bottom - top
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,34 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import { createMockWidget } from './widgetTestUtils'
|
||||
|
||||
const mockCheckState = vi.hoisted(() => vi.fn())
|
||||
const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] }))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
const actual = await vi.importActual(
|
||||
'@/platform/workflow/management/stores/workflowStore'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: {
|
||||
changeTracker: {
|
||||
checkState: mockCheckState
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn(),
|
||||
apiURL: vi.fn((url: string) => url),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData',
|
||||
() => ({
|
||||
@@ -456,3 +483,69 @@ describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
|
||||
expect(selectedSet.has('missing-missing_model.safetensors')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown undo tracking', () => {
|
||||
interface UndoTrackingInstance extends ComponentPublicInstance {
|
||||
updateSelectedItems: (selectedSet: Set<string>) => void
|
||||
handleFilesUpdate: (files: File[]) => Promise<void>
|
||||
}
|
||||
|
||||
const mountForUndo = (
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<UndoTrackingInstance> => {
|
||||
return mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind: 'image',
|
||||
allowUpload: true,
|
||||
uploadFolder: 'input'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
}) as unknown as VueWrapper<UndoTrackingInstance>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCheckState.mockClear()
|
||||
})
|
||||
|
||||
it('calls checkState after dropdown selection changes modelValue', () => {
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'img_001.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: ['img_001.png', 'photo_abc.jpg'] }
|
||||
})
|
||||
const wrapper = mountForUndo(widget, 'img_001.png')
|
||||
|
||||
wrapper.vm.updateSelectedItems(new Set(['input-1']))
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['photo_abc.jpg'])
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls checkState after file upload completes', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
|
||||
} as Response)
|
||||
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'img_001.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: ['img_001.png'] }
|
||||
})
|
||||
const wrapper = mountForUndo(widget, 'img_001.png')
|
||||
|
||||
const file = new File(['test'], 'uploaded.png', { type: 'image/png' })
|
||||
await wrapper.vm.handleFilesUpdate([file])
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['uploaded.png'])
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getAssetFilename
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
|
||||
import type {
|
||||
FilterOption,
|
||||
@@ -376,6 +377,7 @@ function updateSelectedItems(selectedItems: Set<string>) {
|
||||
return
|
||||
}
|
||||
modelValue.value = name
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const uploadFile = async (
|
||||
@@ -450,6 +452,9 @@ async function handleFilesUpdate(files: File[]) {
|
||||
if (props.widget.callback) {
|
||||
props.widget.callback(uploadedPaths[0])
|
||||
}
|
||||
|
||||
// 5. Snapshot undo state so the image change gets its own undo entry
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toastStore.addAlert(`Upload failed: ${error}`)
|
||||
|
||||
@@ -33,7 +33,7 @@ const hideLayoutField = useHideLayoutField()
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'min-w-0 cursor-default rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted',
|
||||
'min-w-0 cursor-default rounded-lg transition-all has-focus-visible:ring has-focus-visible:ring-component-node-widget-background-highlighted',
|
||||
widget.borderStyle
|
||||
)
|
||||
"
|
||||
|
||||
@@ -40,6 +40,7 @@ function addMultilineWidget(
|
||||
})
|
||||
|
||||
widget.element = inputEl
|
||||
widget.inputEl = inputEl
|
||||
widget.options.minNodeSize = [400, 200]
|
||||
|
||||
inputEl.addEventListener('input', (event) => {
|
||||
|
||||
@@ -464,8 +464,7 @@ const zSettings = z.object({
|
||||
'Comfy.VersionCompatibility.DisableWarnings': z.boolean(),
|
||||
'Comfy.RightSidePanel.IsOpen': z.boolean(),
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': z.boolean(),
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean(),
|
||||
'Comfy.Canvas.AlignNodesWhileDragging': z.boolean()
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean()
|
||||
})
|
||||
|
||||
export type EmbeddingsResponse = z.infer<typeof zEmbeddingsResponse>
|
||||
|
||||
@@ -73,6 +73,7 @@ import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useModelStore } from '@/stores/modelStore'
|
||||
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
|
||||
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -82,6 +83,15 @@ import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
import { getCnrIdFromProperties } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
|
||||
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
|
||||
import {
|
||||
scanAllModelCandidates,
|
||||
enrichWithEmbeddedMetadata,
|
||||
verifyAssetSupportedCandidates
|
||||
} from '@/platform/missingModel/missingModelScan'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
import { anyItemOverlapsRect } from '@/utils/mathUtil'
|
||||
import {
|
||||
collectAllNodes,
|
||||
@@ -104,7 +114,7 @@ import {
|
||||
findLegacyRerouteNodes,
|
||||
noNativeReroutes
|
||||
} from '@/utils/migration/migrateReroute'
|
||||
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
|
||||
|
||||
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
|
||||
|
||||
import { type ComfyApi, PromptExecutionError, api } from './api'
|
||||
@@ -1124,6 +1134,8 @@ export class ComfyApp {
|
||||
} = options
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
|
||||
useMissingModelStore().clearMissingModels()
|
||||
|
||||
if (clean !== false) {
|
||||
this.clean()
|
||||
}
|
||||
@@ -1168,18 +1180,17 @@ export class ComfyApp {
|
||||
useSubgraphService().loadSubgraphs(graphData)
|
||||
|
||||
const missingNodeTypes: MissingNodeType[] = []
|
||||
const missingModels: ModelFile[] = []
|
||||
await useExtensionService().invokeExtensionsAsync(
|
||||
'beforeConfigureGraph',
|
||||
graphData,
|
||||
missingNodeTypes
|
||||
)
|
||||
|
||||
const embeddedModels: ModelFile[] = []
|
||||
|
||||
const nodeReplacementStore = useNodeReplacementStore()
|
||||
await nodeReplacementStore.load()
|
||||
const collectMissingNodesAndModels = (
|
||||
|
||||
// Collect missing node types from all nodes (root + subgraphs)
|
||||
const collectMissingNodes = (
|
||||
nodes: ComfyWorkflowJSON['nodes'],
|
||||
pathPrefix: string = '',
|
||||
displayName: string = ''
|
||||
@@ -1192,16 +1203,11 @@ export class ComfyApp {
|
||||
return
|
||||
}
|
||||
for (let n of nodes) {
|
||||
// Find missing node types
|
||||
if (!(n.type in LiteGraph.registered_node_types)) {
|
||||
const replacement = nodeReplacementStore.getReplacementFor(n.type)
|
||||
|
||||
// To access missing node information in the error tab
|
||||
// we collect the cnr_id and execution_id here.
|
||||
const cnrId = getCnrIdFromProperties(
|
||||
n.properties as Record<string, unknown> | undefined
|
||||
)
|
||||
|
||||
const executionId = pathPrefix
|
||||
? `${pathPrefix}:${n.id}`
|
||||
: String(n.id)
|
||||
@@ -1219,65 +1225,25 @@ export class ComfyApp {
|
||||
|
||||
n.type = sanitizeNodeName(n.type)
|
||||
}
|
||||
|
||||
// Collect models metadata from node
|
||||
const selectedModels = getSelectedModelsMetadata(n)
|
||||
if (selectedModels?.length) {
|
||||
embeddedModels.push(...selectedModels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process nodes at the top level
|
||||
collectMissingNodesAndModels(graphData.nodes)
|
||||
|
||||
// Build map: subgraph definition UUID → full execution path prefix.
|
||||
// Handles arbitrary nesting depth (e.g. root node 11 → "11", node 14 in sg 11 → "11:14").
|
||||
collectMissingNodes(graphData.nodes)
|
||||
const subgraphDefs = graphData.definitions?.subgraphs ?? []
|
||||
const subgraphContainerIdMap = buildSubgraphExecutionPaths(
|
||||
graphData.nodes,
|
||||
graphData.definitions?.subgraphs ?? []
|
||||
subgraphDefs
|
||||
)
|
||||
|
||||
// Process nodes in subgraphs
|
||||
if (graphData.definitions?.subgraphs) {
|
||||
for (const subgraph of graphData.definitions.subgraphs) {
|
||||
if (isSubgraphDefinition(subgraph)) {
|
||||
const paths = subgraphContainerIdMap.get(subgraph.id) ?? []
|
||||
for (const pathPrefix of paths) {
|
||||
collectMissingNodesAndModels(
|
||||
subgraph.nodes,
|
||||
pathPrefix,
|
||||
subgraph.name || subgraph.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge models from the workflow's root-level 'models' field
|
||||
const workflowSchemaV1Models = graphData.models
|
||||
if (workflowSchemaV1Models?.length)
|
||||
embeddedModels.push(...workflowSchemaV1Models)
|
||||
|
||||
const getModelKey = (model: ModelFile) => model.url || model.hash
|
||||
const validModels = embeddedModels.filter(getModelKey)
|
||||
const uniqueModels = _.uniqBy(validModels, getModelKey)
|
||||
|
||||
if (
|
||||
uniqueModels.length &&
|
||||
useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')
|
||||
) {
|
||||
const modelStore = useModelStore()
|
||||
await modelStore.loadModelFolders()
|
||||
for (const m of uniqueModels) {
|
||||
const modelFolder = await modelStore.getLoadedModelFolder(m.directory)
|
||||
const modelsAvailable = modelFolder?.models
|
||||
const modelExists =
|
||||
modelsAvailable &&
|
||||
Object.values(modelsAvailable).some(
|
||||
(model) => model.file_name === m.name
|
||||
for (const subgraph of subgraphDefs) {
|
||||
if (isSubgraphDefinition(subgraph)) {
|
||||
const paths = subgraphContainerIdMap.get(subgraph.id) ?? []
|
||||
for (const pathPrefix of paths) {
|
||||
collectMissingNodes(
|
||||
subgraph.nodes,
|
||||
pathPrefix,
|
||||
subgraph.name || subgraph.id
|
||||
)
|
||||
if (!modelExists) missingModels.push(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1423,21 +1389,12 @@ export class ComfyApp {
|
||||
requestAnimationFrame(() => fitView())
|
||||
}
|
||||
|
||||
// Store pending warnings on the workflow for deferred display
|
||||
const activeWf = useWorkspaceStore().workflow.activeWorkflow
|
||||
if (activeWf) {
|
||||
const warnings: PendingWarnings = {}
|
||||
if (missingNodeTypes.length && showMissingNodesDialog) {
|
||||
warnings.missingNodeTypes = missingNodeTypes
|
||||
}
|
||||
if (missingModels.length && showMissingModelsDialog) {
|
||||
const paths = await api.getFolderPaths()
|
||||
warnings.missingModels = { missingModels: missingModels, paths }
|
||||
}
|
||||
if (warnings.missingNodeTypes || warnings.missingModels) {
|
||||
activeWf.pendingWarnings = warnings
|
||||
}
|
||||
}
|
||||
await this.runMissingModelPipeline(
|
||||
graphData,
|
||||
missingNodeTypes,
|
||||
showMissingNodesDialog,
|
||||
showMissingModelsDialog
|
||||
)
|
||||
|
||||
if (!deferWarnings) {
|
||||
useWorkflowService().showPendingWarnings()
|
||||
@@ -1451,6 +1408,97 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
private async runMissingModelPipeline(
|
||||
graphData: ComfyWorkflowJSON,
|
||||
missingNodeTypes: MissingNodeType[],
|
||||
showMissingNodesDialog: boolean,
|
||||
showMissingModelsDialog: boolean
|
||||
): Promise<{ missingModels: ModelFile[] }> {
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
const candidates = isCloud
|
||||
? scanAllModelCandidates(
|
||||
this.rootGraph,
|
||||
(nodeType, widgetName) =>
|
||||
assetService.shouldUseAssetBrowser(nodeType, widgetName),
|
||||
(nodeType) => useModelToNodeStore().getCategoryForNodeType(nodeType)
|
||||
)
|
||||
: []
|
||||
|
||||
const modelStore = useModelStore()
|
||||
await modelStore.loadModelFolders()
|
||||
const enrichedCandidates = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
async (name, directory) => {
|
||||
const folder = await modelStore.getLoadedModelFolder(directory)
|
||||
const models = folder?.models
|
||||
return !!(
|
||||
models && Object.values(models).some((m) => m.file_name === name)
|
||||
)
|
||||
},
|
||||
isCloud
|
||||
? (nodeType, widgetName) =>
|
||||
assetService.shouldUseAssetBrowser(nodeType, widgetName)
|
||||
: undefined
|
||||
)
|
||||
|
||||
const missingModels: ModelFile[] = enrichedCandidates
|
||||
.filter((c) => c.isMissing === true && c.url)
|
||||
.map((c) => ({
|
||||
name: c.name,
|
||||
url: c.url ?? '',
|
||||
directory: c.directory ?? '',
|
||||
hash: c.hash,
|
||||
hash_type: c.hashType
|
||||
}))
|
||||
|
||||
const activeWf = useWorkspaceStore().workflow.activeWorkflow
|
||||
if (activeWf) {
|
||||
const warnings: PendingWarnings = {}
|
||||
if (missingNodeTypes.length && showMissingNodesDialog) {
|
||||
warnings.missingNodeTypes = missingNodeTypes
|
||||
}
|
||||
if (missingModels.length && showMissingModelsDialog) {
|
||||
const paths = await api.getFolderPaths()
|
||||
warnings.missingModels = { missingModels, paths }
|
||||
}
|
||||
if (warnings.missingNodeTypes || warnings.missingModels) {
|
||||
activeWf.pendingWarnings = warnings
|
||||
}
|
||||
}
|
||||
|
||||
if (isCloud && enrichedCandidates.length) {
|
||||
const controller = missingModelStore.createVerificationAbortController()
|
||||
verifyAssetSupportedCandidates(enrichedCandidates, controller.signal)
|
||||
.then(() => {
|
||||
if (controller.signal.aborted) return
|
||||
const confirmed = enrichedCandidates.filter(
|
||||
(c) => c.isMissing === true
|
||||
)
|
||||
if (confirmed.length) {
|
||||
useExecutionErrorStore().surfaceMissingModels(confirmed)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
'[Missing Model Pipeline] Asset verification failed:',
|
||||
err
|
||||
)
|
||||
useToastStore().add({
|
||||
severity: 'warn',
|
||||
summary: st(
|
||||
'toastMessages.missingModelVerificationFailed',
|
||||
'Failed to verify missing models. Some models may not be shown in the Errors tab.'
|
||||
),
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return { missingModels }
|
||||
}
|
||||
|
||||
async graphToPrompt(graph = this.rootGraph) {
|
||||
return graphToPrompt(graph, {
|
||||
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
|
||||
@@ -1578,6 +1626,18 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
const activeWorkflow = useWorkspaceStore().workflow
|
||||
.activeWorkflow as ComfyWorkflow | undefined
|
||||
const wid =
|
||||
activeWorkflow?.activeState?.id ??
|
||||
activeWorkflow?.initialState?.id
|
||||
if (wid) {
|
||||
executionStore.setWorkflowExecutionResultByWorkflowId(
|
||||
String(wid),
|
||||
'error'
|
||||
)
|
||||
}
|
||||
|
||||
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||
executionErrorStore.showErrorOverlay()
|
||||
}
|
||||
|
||||