mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Compare commits
49 Commits
vue-widget
...
v1.23.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77d2cae301 | ||
|
|
164a4c4c25 | ||
|
|
47145ce4b8 | ||
|
|
6cf77a9814 | ||
|
|
886e4908d4 | ||
|
|
24cbc41832 | ||
|
|
a80a939324 | ||
|
|
8e2d7cabba | ||
|
|
e8dd26ff59 | ||
|
|
3a1bd1829a | ||
|
|
2f9dcd1669 | ||
|
|
e23547dd5a | ||
|
|
f0f40bc39b | ||
|
|
4b32786ef5 | ||
|
|
9942b17388 | ||
|
|
b99214bf5e | ||
|
|
2ef760c599 | ||
|
|
429ab6c365 | ||
|
|
b7693ae9f5 | ||
|
|
ebedf1074d | ||
|
|
0832347f47 | ||
|
|
c745af0f25 | ||
|
|
8c05266b83 | ||
|
|
fa14ec52f4 | ||
|
|
ec9da0b6c5 | ||
|
|
98bb1df436 | ||
|
|
75077fe9ed | ||
|
|
d5ecfb2c99 | ||
|
|
3211875084 | ||
|
|
a6bd04f951 | ||
|
|
5b32d2aad0 | ||
|
|
23ba7e6501 | ||
|
|
1e2b16f14d | ||
|
|
ec27d50333 | ||
|
|
693e156ab2 | ||
|
|
8274df5075 | ||
|
|
55bf36564d | ||
|
|
48ac4a2b36 | ||
|
|
c9c1275e4c | ||
|
|
78ebc54ebe | ||
|
|
88f2cc7847 | ||
|
|
7907e206da | ||
|
|
c4fa3dfe5a | ||
|
|
587d7a19a1 | ||
|
|
9ca705381c | ||
|
|
a937ac59ad | ||
|
|
995979a4e1 | ||
|
|
c02ac95815 | ||
|
|
d01926b043 |
10
.github/workflows/test-ui.yaml
vendored
10
.github/workflows/test-ui.yaml
vendored
@@ -46,8 +46,8 @@ jobs:
|
||||
id: cache-key
|
||||
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache setup
|
||||
uses: actions/cache@v3
|
||||
- name: Save cache
|
||||
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
with:
|
||||
path: |
|
||||
ComfyUI
|
||||
@@ -62,9 +62,13 @@ jobs:
|
||||
matrix:
|
||||
browser: [chromium, chromium-2x, mobile-chrome]
|
||||
steps:
|
||||
- name: Wait for cache propagation
|
||||
run: sleep 10
|
||||
|
||||
- name: Restore cached setup
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
with:
|
||||
fail-on-cache-miss: true
|
||||
path: |
|
||||
ComfyUI
|
||||
ComfyUI_frontend
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -4,7 +4,7 @@
|
||||
- When trying to set tailwind classes for dark theme, use "dark-theme:" prefix rather than "dark:"
|
||||
- Never add lines to PR descriptions that say "Generated with Claude Code"
|
||||
- When making PR names and commit messages, if you are going to add a prefix like "docs:", "feat:", "bugfix:", use square brackets around the prefix term and do not use a colon (e.g., should be "[docs]" rather than "docs:").
|
||||
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading speicifc branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
|
||||
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading specific branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
|
||||
- For information about ComfyUI, ComfyUI_frontend, or ComfyUI-Manager, you can web search or download these wikis: https://deepwiki.com/Comfy-Org/ComfyUI-Manager, https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview, https://deepwiki.com/comfyanonymous/ComfyUI/2-core-architecture
|
||||
- If a question/project is related to Comfy-Org, Comfy, or ComfyUI ecosystem, you should proactively use the Comfy docs to answer the question. The docs may be referenced with URLs like https://docs.comfy.org
|
||||
- When operating inside a repo, check for README files at key locations in the repo detailing info about the contents of that folder. E.g., top-level key folders like tests-ui, browser_tests, composables, extensions/core, stores, services often have their own README.md files. When writing code, make sure to frequently reference these README files to understand the overall architecture and design of the project. Pay close attention to the snippets to learn particular patterns that seem to be there for a reason, as you should emulate those.
|
||||
@@ -12,7 +12,7 @@
|
||||
- If using a lesser known or complex CLI tool, run the --help to see the documentation before deciding what to run, even if just for double-checking or verifying things.
|
||||
- IMPORTANT: the most important goal when writing code is to create clean, best-practices, sustainable, and scalable public APIs and interfaces. Our app is used by thousands of users and we have thousands of mods/extensions that are constantly changing and updating; and we are also always updating. That's why it is IMPORTANT that we design systems and write code that follows practices of domain-driven design, object-oriented design, and design patterns (such that you can assure stability while allowing for all components around you to change and evolve). We ABSOLUTELY prioritize clean APIs and public interfaces that clearly define and restrict how/what the mods/extensions can access.
|
||||
- If any of these technologies are referenced, you can proactively read their docs at these locations: https://primevue.org/theming, https://primevue.org/forms/, https://www.electronjs.org/docs/latest/api/browser-window, https://vitest.dev/guide/browser/, https://atlassian.design/components/pragmatic-drag-and-drop/core-package/drop-targets/, https://playwright.dev/docs/api/class-test, https://playwright.dev/docs/api/class-electron, https://www.algolia.com/doc/api-reference/rest-api/, https://pyav.org/docs/develop/cookbook/basics.html
|
||||
- IMPORTANT: Never add Co-Authored by Claude or any refrence to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
|
||||
- IMPORTANT: Never add Co-Authored by Claude or any reference to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
|
||||
- The npm script to type check is called "typecheck" NOT "type check"
|
||||
- Use the Vue 3 Composition API instead of the Options API when writing Vue components. An exception is when overriding or extending a PrimeVue component for compatibility, you may use the Options API.
|
||||
- when we are solving an issue we know the link/number for, we should add "Fixes #n" (where n is the issue number) to the PR description.
|
||||
@@ -46,3 +46,11 @@
|
||||
* `TabMenu` → Use `Tabs` without panels
|
||||
* `Steps` → Use `Stepper` without panels
|
||||
* `InlineMessage` → Use `Message` component
|
||||
* Use `api.apiURL()` for all backend API calls and routes
|
||||
- Actual API endpoints like /prompt, /queue, /view, etc.
|
||||
- Image previews: `api.apiURL('/view?...')`
|
||||
- Any backend-generated content or dynamic routes
|
||||
* Use `api.fileURL()` for static files served from the public folder:
|
||||
- Templates: `api.fileURL('/templates/default.json')`
|
||||
- Extensions: `api.fileURL(extensionPath)` for loading JS modules
|
||||
- Any static assets that exist in the public directory
|
||||
|
||||
@@ -762,7 +762,7 @@ export class ComfyPage {
|
||||
y: 625
|
||||
}
|
||||
})
|
||||
this.page.mouse.move(10, 10)
|
||||
await this.page.mouse.move(10, 10)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -774,7 +774,7 @@ export class ComfyPage {
|
||||
},
|
||||
button: 'right'
|
||||
})
|
||||
this.page.mouse.move(10, 10)
|
||||
await this.page.mouse.move(10, 10)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -1046,6 +1046,8 @@ export class ComfyPage {
|
||||
}
|
||||
}
|
||||
|
||||
export const testComfySnapToGridGridSize = 50
|
||||
|
||||
export const comfyPageFixture = base.extend<{
|
||||
comfyPage: ComfyPage
|
||||
comfyMouse: ComfyMouse
|
||||
@@ -1072,7 +1074,8 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.EnableTooltips': false,
|
||||
'Comfy.userId': userId,
|
||||
// Set tutorial completed to true to avoid loading the tutorial workflow.
|
||||
'Comfy.TutorialCompleted': true
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { Position } from '@vueuse/core'
|
||||
|
||||
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
type ComfyPage,
|
||||
comfyPageFixture as test,
|
||||
testComfySnapToGridGridSize
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.describe('Item Interaction', () => {
|
||||
test('Can select/delete all items', async ({ comfyPage }) => {
|
||||
@@ -57,8 +63,10 @@ test.describe('Node Interaction', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
|
||||
})
|
||||
|
||||
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
const dragSelectNodes = async (
|
||||
comfyPage: ComfyPage,
|
||||
clipNodes: NodeReference[]
|
||||
) => {
|
||||
const clipNode1Pos = await clipNodes[0].getPosition()
|
||||
const clipNode2Pos = await clipNodes[1].getPosition()
|
||||
const offset = 64
|
||||
@@ -74,10 +82,67 @@ test.describe('Node Interaction', () => {
|
||||
}
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Meta')
|
||||
}
|
||||
|
||||
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
await dragSelectNodes(comfyPage, clipNodes)
|
||||
expect(await comfyPage.getSelectedGraphNodesCount()).toBe(
|
||||
clipNodes.length
|
||||
)
|
||||
})
|
||||
|
||||
test('Can move selected nodes using the Comfy.Canvas.MoveSelectedNodes.{Up|Down|Left|Right} commands', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
const getPositions = () =>
|
||||
Promise.all(clipNodes.map((node) => node.getPosition()))
|
||||
const testDirection = async ({
|
||||
direction,
|
||||
expectedPosition
|
||||
}: {
|
||||
direction: string
|
||||
expectedPosition: (originalPosition: Position) => Position
|
||||
}) => {
|
||||
const originalPositions = await getPositions()
|
||||
await dragSelectNodes(comfyPage, clipNodes)
|
||||
await comfyPage.executeCommand(
|
||||
`Comfy.Canvas.MoveSelectedNodes.${direction}`
|
||||
)
|
||||
await comfyPage.canvas.press(`Control+Arrow${direction}`)
|
||||
const newPositions = await getPositions()
|
||||
expect(newPositions).toEqual(originalPositions.map(expectedPosition))
|
||||
}
|
||||
await testDirection({
|
||||
direction: 'Down',
|
||||
expectedPosition: (originalPosition) => ({
|
||||
...originalPosition,
|
||||
y: originalPosition.y + testComfySnapToGridGridSize
|
||||
})
|
||||
})
|
||||
await testDirection({
|
||||
direction: 'Right',
|
||||
expectedPosition: (originalPosition) => ({
|
||||
...originalPosition,
|
||||
x: originalPosition.x + testComfySnapToGridGridSize
|
||||
})
|
||||
})
|
||||
await testDirection({
|
||||
direction: 'Up',
|
||||
expectedPosition: (originalPosition) => ({
|
||||
...originalPosition,
|
||||
y: originalPosition.y - testComfySnapToGridGridSize
|
||||
})
|
||||
})
|
||||
await testDirection({
|
||||
direction: 'Left',
|
||||
expectedPosition: (originalPosition) => ({
|
||||
...originalPosition,
|
||||
x: originalPosition.x - testComfySnapToGridGridSize
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Can drag node', async ({ comfyPage }) => {
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.22.1",
|
||||
"version": "1.23.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.22.1",
|
||||
"version": "1.23.2",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.22.1",
|
||||
"version": "1.23.2",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<template>
|
||||
<hr
|
||||
<div
|
||||
:class="{
|
||||
'm-0': true,
|
||||
'border-t': orientation === 'horizontal',
|
||||
'border-l': orientation === 'vertical',
|
||||
'h-full': orientation === 'vertical',
|
||||
'w-full': orientation === 'horizontal'
|
||||
'content-divider': true,
|
||||
'content-divider--horizontal': orientation === 'horizontal',
|
||||
'content-divider--vertical': orientation === 'vertical'
|
||||
}"
|
||||
:style="{
|
||||
borderColor: isLightTheme ? '#DCDAE1' : '#2C2C2C',
|
||||
borderWidth: `${width}px !important`
|
||||
backgroundColor: isLightTheme ? '#DCDAE1' : '#2C2C2C'
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -29,3 +26,25 @@ const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-divider {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content-divider--horizontal {
|
||||
width: 100%;
|
||||
height: v-bind('width + "px"');
|
||||
}
|
||||
|
||||
.content-divider--vertical {
|
||||
height: 100%;
|
||||
width: v-bind('width + "px"');
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -92,12 +92,21 @@ whenever(
|
||||
const updateItemSize = () => {
|
||||
if (container.value) {
|
||||
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
|
||||
itemHeight.value = firstItem?.clientHeight || defaultItemHeight
|
||||
itemWidth.value = firstItem?.clientWidth || defaultItemWidth
|
||||
|
||||
// Don't update item size if the first item is not rendered yet
|
||||
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
|
||||
|
||||
if (itemHeight.value !== firstItem.clientHeight) {
|
||||
itemHeight.value = firstItem.clientHeight
|
||||
}
|
||||
if (itemWidth.value !== firstItem.clientWidth) {
|
||||
itemWidth.value = firstItem.clientWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
const onResize = debounce(updateItemSize, resizeDebounce)
|
||||
watch([width, height], onResize, { flush: 'post' })
|
||||
whenever(() => items, updateItemSize, { flush: 'post' })
|
||||
onBeforeUnmount(() => {
|
||||
onResize.cancel() // Clear pending debounced calls
|
||||
})
|
||||
|
||||
@@ -50,4 +50,17 @@ const dialogStore = useDialogStore()
|
||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
.manager-dialog {
|
||||
height: 80vh;
|
||||
max-width: 1724px;
|
||||
max-height: 1026px;
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.manager-dialog {
|
||||
max-width: 2200px;
|
||||
max-height: 1320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
title="Missing Node Types"
|
||||
message="When loading the graph, the following node types were not found"
|
||||
/>
|
||||
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
|
||||
<ListBox
|
||||
:options="uniqueNodes"
|
||||
option-label="label"
|
||||
@@ -31,6 +32,12 @@
|
||||
</template>
|
||||
</ListBox>
|
||||
<div v-if="isManagerInstalled" class="flex justify-end py-3">
|
||||
<PackInstallButton
|
||||
:disabled="isLoading || !!error || missingNodePacks.length === 0"
|
||||
:node-packs="missingNodePacks"
|
||||
variant="black"
|
||||
:label="$t('manager.installAllMissingNodes')"
|
||||
/>
|
||||
<Button label="Open Manager" size="small" outlined @click="openManager" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,6 +48,9 @@ import ListBox from 'primevue/listbox'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
@@ -52,6 +62,10 @@ const props = defineProps<{
|
||||
|
||||
const aboutPanelStore = useAboutPanelStore()
|
||||
|
||||
// Get missing node packs from workflow with loading and error states
|
||||
const { missingNodePacks, isLoading, error, missingCoreNodes } =
|
||||
useMissingNodes()
|
||||
|
||||
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
|
||||
// This allows us to conditionally show the Manager button only when the extension is available
|
||||
// TODO: Remove this check when Manager functionality is fully migrated into core
|
||||
|
||||
95
src/components/dialog/content/MissingCoreNodesMessage.vue
Normal file
95
src/components/dialog/content/MissingCoreNodesMessage.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<Message
|
||||
v-if="hasMissingCoreNodes"
|
||||
severity="info"
|
||||
icon="pi pi-info-circle"
|
||||
class="my-2 mx-2"
|
||||
:pt="{
|
||||
root: { class: 'flex-col' },
|
||||
text: { class: 'flex-1' }
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
{{
|
||||
currentComfyUIVersion
|
||||
? $t('loadWorkflowWarning.outdatedVersion', {
|
||||
version: currentComfyUIVersion
|
||||
})
|
||||
: $t('loadWorkflowWarning.outdatedVersionGeneric')
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-for="[version, nodes] in sortedMissingCoreNodes"
|
||||
:key="version"
|
||||
class="ml-4"
|
||||
>
|
||||
<div
|
||||
class="text-sm font-medium text-surface-600 dark-theme:text-surface-400"
|
||||
>
|
||||
{{
|
||||
$t('loadWorkflowWarning.coreNodesFromVersion', {
|
||||
version: version || 'unknown'
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="ml-4 text-sm text-surface-500 dark-theme:text-surface-500">
|
||||
{{ getUniqueNodeNames(nodes).join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Message>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import Message from 'primevue/message'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { compareVersions } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
missingCoreNodes: Record<string, LGraphNode[]>
|
||||
}>()
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const hasMissingCoreNodes = computed(() => {
|
||||
return Object.keys(props.missingCoreNodes).length > 0
|
||||
})
|
||||
|
||||
const currentComfyUIVersion = ref<string | null>(null)
|
||||
whenever(
|
||||
hasMissingCoreNodes,
|
||||
async () => {
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await systemStatsStore.fetchSystemStats()
|
||||
}
|
||||
currentComfyUIVersion.value =
|
||||
systemStatsStore.systemStats?.system?.comfyui_version ?? null
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
const sortedMissingCoreNodes = computed(() => {
|
||||
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
|
||||
// Sort by version in descending order (newest first)
|
||||
return compareVersions(b, a) // Reversed for descending order
|
||||
})
|
||||
})
|
||||
|
||||
const getUniqueNodeNames = (nodes: LGraphNode[]): string[] => {
|
||||
return nodes
|
||||
.reduce<string[]>((acc, node) => {
|
||||
if (node.type && !acc.includes(node.type)) {
|
||||
acc.push(node.type)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
.sort()
|
||||
}
|
||||
</script>
|
||||
@@ -1,14 +1,16 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col mx-auto overflow-hidden h-[83vh] relative"
|
||||
class="h-full flex flex-col mx-auto overflow-hidden"
|
||||
:aria-label="$t('manager.title')"
|
||||
>
|
||||
<ContentDivider :width="0.3" />
|
||||
<Button
|
||||
v-if="isSmallScreen"
|
||||
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
|
||||
text
|
||||
severity="secondary"
|
||||
filled
|
||||
class="absolute top-1/2 -translate-y-1/2 z-10"
|
||||
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
|
||||
:class="isSideNavOpen ? 'left-[12rem]' : 'left-2'"
|
||||
@click="toggleSideNav"
|
||||
/>
|
||||
<div class="flex flex-1 relative overflow-hidden">
|
||||
@@ -18,20 +20,20 @@
|
||||
:tabs="tabs"
|
||||
/>
|
||||
<div
|
||||
class="flex-1 overflow-auto pr-80"
|
||||
class="flex-1 overflow-auto bg-gray-50 dark-theme:bg-neutral-900"
|
||||
:class="{
|
||||
'transition-all duration-300': isSmallScreen,
|
||||
'pl-80': isSideNavOpen || !isSmallScreen,
|
||||
'pl-8': !isSideNavOpen && isSmallScreen
|
||||
'transition-all duration-300': isSmallScreen
|
||||
}"
|
||||
>
|
||||
<div class="px-6 pt-6 flex flex-col h-full">
|
||||
<div class="px-6 flex flex-col h-full">
|
||||
<RegistrySearchBar
|
||||
v-model:searchQuery="searchQuery"
|
||||
v-model:searchMode="searchMode"
|
||||
v-model:sortField="sortField"
|
||||
:search-results="searchResults"
|
||||
:suggestions="suggestions"
|
||||
:is-missing-tab="isMissingTab"
|
||||
:sort-options="sortOptions"
|
||||
/>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div
|
||||
@@ -57,7 +59,7 @@
|
||||
<VirtualGrid
|
||||
id="results-grid"
|
||||
:items="resultsWithKeys"
|
||||
:buffer-rows="3"
|
||||
:buffer-rows="4"
|
||||
:grid-style="GRID_STYLE"
|
||||
@approach-end="onApproachEnd"
|
||||
>
|
||||
@@ -75,9 +77,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-80 border-l-0 absolute right-0 top-0 bottom-0 flex z-20">
|
||||
<div class="w-[clamp(250px,33%,306px)] border-l-0 flex z-20">
|
||||
<ContentDivider orientation="vertical" :width="0.2" />
|
||||
<div class="flex-1 flex flex-col isolate">
|
||||
<div class="w-full flex flex-col isolate">
|
||||
<InfoPanel
|
||||
v-if="!hasMultipleSelections && selectedNodePack"
|
||||
:node-pack="selectedNodePack"
|
||||
@@ -93,7 +95,14 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { merge } from 'lodash'
|
||||
import Button from 'primevue/button'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
@@ -106,6 +115,7 @@ import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
|
||||
import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue'
|
||||
import GridSkeleton from '@/components/dialog/content/manager/skeleton/GridSkeleton.vue'
|
||||
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
|
||||
import { useManagerStatePersistence } from '@/composables/manager/useManagerStatePersistence'
|
||||
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
|
||||
@@ -116,13 +126,15 @@ import type { TabItem } from '@/types/comfyManagerTypes'
|
||||
import { ManagerTab } from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const { initialTab = ManagerTab.All } = defineProps<{
|
||||
initialTab: ManagerTab
|
||||
const { initialTab } = defineProps<{
|
||||
initialTab?: ManagerTab
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const { getPackById } = useComfyRegistryStore()
|
||||
const persistedState = useManagerStatePersistence()
|
||||
const initialState = persistedState.loadStoredState()
|
||||
|
||||
const GRID_STYLE = {
|
||||
display: 'grid',
|
||||
@@ -156,8 +168,10 @@ const tabs = ref<TabItem[]>([
|
||||
icon: 'pi-sync'
|
||||
}
|
||||
])
|
||||
|
||||
const initialTabId = initialTab ?? initialState.selectedTabId
|
||||
const selectedTab = ref<TabItem>(
|
||||
tabs.value.find((tab) => tab.id === initialTab) || tabs.value[0]
|
||||
tabs.value.find((tab) => tab.id === initialTabId) || tabs.value[0]
|
||||
)
|
||||
|
||||
const {
|
||||
@@ -167,8 +181,13 @@ const {
|
||||
searchResults,
|
||||
searchMode,
|
||||
sortField,
|
||||
suggestions
|
||||
} = useRegistrySearch()
|
||||
suggestions,
|
||||
sortOptions
|
||||
} = useRegistrySearch({
|
||||
initialSortField: initialState.sortField,
|
||||
initialSearchMode: initialState.searchMode,
|
||||
initialSearchQuery: initialState.searchQuery
|
||||
})
|
||||
pageNumber.value = 0
|
||||
const onApproachEnd = () => {
|
||||
pageNumber.value++
|
||||
@@ -200,10 +219,6 @@ const {
|
||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
whenever(selectedTab, () => {
|
||||
pageNumber.value = 0
|
||||
})
|
||||
|
||||
const isUpdateAvailableTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.UpdateAvailable
|
||||
)
|
||||
@@ -232,7 +247,11 @@ watch(
|
||||
|
||||
if (!isEmptySearch.value) {
|
||||
displayPacks.value = filterOutdatedPacks(installedPacks.value)
|
||||
} else if (!installedPacks.value.length) {
|
||||
} else if (
|
||||
!installedPacks.value.length &&
|
||||
!installedPacksReady.value &&
|
||||
!isLoadingInstalled.value
|
||||
) {
|
||||
await startFetchInstalled()
|
||||
} else {
|
||||
displayPacks.value = filterOutdatedPacks(installedPacks.value)
|
||||
@@ -426,7 +445,13 @@ whenever(selectedNodePack, async () => {
|
||||
if (data?.id === pack.id) {
|
||||
lastFetchedPackId.value = pack.id
|
||||
const mergedPack = merge({}, pack, data)
|
||||
selectedNodePacks.value = [mergedPack]
|
||||
// Update the pack in current selection without changing selection state
|
||||
const packIndex = selectedNodePacks.value.findIndex(
|
||||
(p) => p.id === mergedPack.id
|
||||
)
|
||||
if (packIndex !== -1) {
|
||||
selectedNodePacks.value.splice(packIndex, 1, mergedPack)
|
||||
}
|
||||
// Replace pack in displayPacks so that children receive a fresh prop reference
|
||||
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
|
||||
if (idx !== -1) {
|
||||
@@ -439,13 +464,23 @@ let gridContainer: HTMLElement | null = null
|
||||
onMounted(() => {
|
||||
gridContainer = document.getElementById('results-grid')
|
||||
})
|
||||
watch(searchQuery, () => {
|
||||
watch([searchQuery, selectedTab], () => {
|
||||
gridContainer ??= document.getElementById('results-grid')
|
||||
if (gridContainer) {
|
||||
pageNumber.value = 0
|
||||
gridContainer.scrollTop = 0
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
persistedState.persistState({
|
||||
selectedTabId: selectedTab.value?.id,
|
||||
searchQuery: searchQuery.value,
|
||||
searchMode: searchMode.value,
|
||||
sortField: sortField.value
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
getPackById.cancel()
|
||||
})
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<h2 class="text-lg font-normal text-left">
|
||||
{{ $t('manager.discoverCommunityContent') }}
|
||||
</h2>
|
||||
</div>
|
||||
<ContentDivider :width="0.3" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<aside
|
||||
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out flex"
|
||||
class="flex translate-x-0 max-w-[250px] w-3/12 z-5 transition-transform duration-300 ease-in-out"
|
||||
>
|
||||
<ScrollPanel class="w-80 mt-7">
|
||||
<ScrollPanel class="flex-1">
|
||||
<Listbox
|
||||
v-model="selectedTab"
|
||||
:options="tabs"
|
||||
@@ -10,20 +10,20 @@
|
||||
list-style="max-height:unset"
|
||||
class="w-full border-0 bg-transparent shadow-none"
|
||||
:pt="{
|
||||
list: { class: 'p-5' },
|
||||
option: { class: 'px-8 py-3 text-lg rounded-xl' },
|
||||
list: { class: 'p-3 gap-2' },
|
||||
option: { class: 'px-4 py-2 text-lg rounded-lg' },
|
||||
optionGroup: { class: 'p-0 text-left text-inherit' }
|
||||
}"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="text-left flex items-center">
|
||||
<i :class="['pi', slotProps.option.icon, 'mr-3']" />
|
||||
<span class="text-lg">{{ slotProps.option.label }}</span>
|
||||
<i :class="['pi', slotProps.option.icon, 'text-sm mr-2']" />
|
||||
<span class="text-sm">{{ slotProps.option.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Listbox>
|
||||
</ScrollPanel>
|
||||
<ContentDivider orientation="vertical" />
|
||||
<ContentDivider orientation="vertical" :width="0.3" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ describe('PackVersionBadge', () => {
|
||||
return mount(PackVersionBadge, {
|
||||
props: {
|
||||
nodePack: mockNodePack,
|
||||
isSelected: false,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
@@ -162,4 +163,58 @@ describe('PackVersionBadge', () => {
|
||||
// Verify that the hide method was called
|
||||
expect(mockHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('selection state changes', () => {
|
||||
it('closes the popover when card is deselected', async () => {
|
||||
const wrapper = mountComponent({
|
||||
props: { isSelected: true }
|
||||
})
|
||||
|
||||
// Change isSelected from true to false
|
||||
await wrapper.setProps({ isSelected: false })
|
||||
await nextTick()
|
||||
|
||||
// Verify that the hide method was called
|
||||
expect(mockHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not close the popover when card is selected', async () => {
|
||||
const wrapper = mountComponent({
|
||||
props: { isSelected: false }
|
||||
})
|
||||
|
||||
// Change isSelected from false to true
|
||||
await wrapper.setProps({ isSelected: true })
|
||||
await nextTick()
|
||||
|
||||
// Verify that the hide method was NOT called
|
||||
expect(mockHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not close the popover when isSelected remains false', async () => {
|
||||
const wrapper = mountComponent({
|
||||
props: { isSelected: false }
|
||||
})
|
||||
|
||||
// Change isSelected from false to false (no change)
|
||||
await wrapper.setProps({ isSelected: false })
|
||||
await nextTick()
|
||||
|
||||
// Verify that the hide method was NOT called
|
||||
expect(mockHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not close the popover when isSelected remains true', async () => {
|
||||
const wrapper = mountComponent({
|
||||
props: { isSelected: true }
|
||||
})
|
||||
|
||||
// Change isSelected from true to true (no change)
|
||||
await wrapper.setProps({ isSelected: true })
|
||||
await nextTick()
|
||||
|
||||
// Verify that the hide method was NOT called
|
||||
expect(mockHide).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
@@ -43,8 +43,9 @@ import { isSemVer } from '@/utils/formatUtil'
|
||||
|
||||
const TRUNCATED_HASH_LENGTH = 7
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
const { nodePack, isSelected } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
isSelected: boolean
|
||||
}>()
|
||||
|
||||
const popoverRef = ref()
|
||||
@@ -69,4 +70,14 @@ const toggleVersionSelector = (event: Event) => {
|
||||
const closeVersionSelector = () => {
|
||||
popoverRef.value.hide()
|
||||
}
|
||||
|
||||
// If the card is unselected, automatically close the version selector popover
|
||||
watch(
|
||||
() => isSelected,
|
||||
(isSelected, wasSelected) => {
|
||||
if (wasSelected && !isSelected) {
|
||||
closeVersionSelector()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -191,6 +191,100 @@ describe('PackVersionSelectorPopover', () => {
|
||||
expect(mockGetPackVersions).toHaveBeenCalledWith(newNodePack.id)
|
||||
})
|
||||
|
||||
describe('nodePack.id changes', () => {
|
||||
it('re-fetches versions when nodePack.id changes', async () => {
|
||||
// Set up the mock for the initial fetch
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
// Verify initial fetch
|
||||
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
|
||||
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
|
||||
|
||||
// Set up the mock for the second fetch
|
||||
const newVersions = [
|
||||
{ version: '2.0.0', createdAt: '2023-06-01' },
|
||||
{ version: '1.9.0', createdAt: '2023-05-01' }
|
||||
]
|
||||
mockGetPackVersions.mockResolvedValueOnce(newVersions)
|
||||
|
||||
// Update the nodePack with a new ID
|
||||
const newNodePack = {
|
||||
...mockNodePack,
|
||||
id: 'different-pack',
|
||||
name: 'Different Pack'
|
||||
}
|
||||
await wrapper.setProps({ nodePack: newNodePack })
|
||||
await waitForPromises()
|
||||
|
||||
// Should fetch versions for the new nodePack
|
||||
expect(mockGetPackVersions).toHaveBeenCalledTimes(2)
|
||||
expect(mockGetPackVersions).toHaveBeenLastCalledWith(newNodePack.id)
|
||||
|
||||
// Check that new versions are displayed
|
||||
const listbox = wrapper.findComponent(Listbox)
|
||||
const options = listbox.props('options')!
|
||||
expect(options.some((o) => o.value === '2.0.0')).toBe(true)
|
||||
expect(options.some((o) => o.value === '1.9.0')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not re-fetch when nodePack changes but id remains the same', async () => {
|
||||
// Set up the mock for the initial fetch
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
// Verify initial fetch
|
||||
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Update the nodePack with same ID but different properties
|
||||
const updatedNodePack = {
|
||||
...mockNodePack,
|
||||
name: 'Updated Test Pack',
|
||||
description: 'New description'
|
||||
}
|
||||
await wrapper.setProps({ nodePack: updatedNodePack })
|
||||
await waitForPromises()
|
||||
|
||||
// Should NOT fetch versions again
|
||||
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('maintains selected version when switching to a new pack', async () => {
|
||||
// Set up the mock for the initial fetch
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
// Select a specific version
|
||||
const listbox = wrapper.findComponent(Listbox)
|
||||
await listbox.setValue('0.9.0')
|
||||
expect(listbox.props('modelValue')).toBe('0.9.0')
|
||||
|
||||
// Set up the mock for the second fetch
|
||||
mockGetPackVersions.mockResolvedValueOnce([
|
||||
{ version: '3.0.0', createdAt: '2023-07-01' },
|
||||
{ version: '0.9.0', createdAt: '2023-04-01' }
|
||||
])
|
||||
|
||||
// Update to a new pack that also has version 0.9.0
|
||||
const newNodePack = {
|
||||
id: 'another-pack',
|
||||
name: 'Another Pack',
|
||||
latest_version: { version: '3.0.0' }
|
||||
}
|
||||
await wrapper.setProps({ nodePack: newNodePack })
|
||||
await waitForPromises()
|
||||
|
||||
// Selected version should remain the same if available
|
||||
expect(listbox.props('modelValue')).toBe('0.9.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unclaimed GitHub packs handling', () => {
|
||||
it('falls back to nightly when no versions exist', async () => {
|
||||
// Set up the mock to return versions
|
||||
|
||||
@@ -62,7 +62,7 @@ import { whenever } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import Listbox from 'primevue/listbox'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
@@ -161,9 +161,11 @@ const onNodePackChange = async () => {
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => nodePack,
|
||||
() => {
|
||||
void onNodePackChange()
|
||||
() => nodePack.id,
|
||||
(nodePackId, oldNodePackId) => {
|
||||
if (nodePackId !== oldNodePackId) {
|
||||
void onNodePackChange()
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
@@ -182,8 +184,4 @@ const handleSubmit = async () => {
|
||||
isQueueing.value = false
|
||||
emit('submit')
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
managerStore.installPack.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<template>
|
||||
<Button
|
||||
outlined
|
||||
class="m-0 p-0 rounded-lg border-neutral-700"
|
||||
:class="{
|
||||
'w-full': fullWidth,
|
||||
'w-min-content': !fullWidth
|
||||
}"
|
||||
class="!m-0 p-0 rounded-lg"
|
||||
:class="[
|
||||
variant === 'black'
|
||||
? 'bg-neutral-900 text-white border-neutral-900'
|
||||
: 'border-neutral-700',
|
||||
fullWidth ? 'w-full' : 'w-min-content'
|
||||
]"
|
||||
:disabled="loading"
|
||||
v-bind="$attrs"
|
||||
@click="onClick"
|
||||
>
|
||||
<span class="py-2.5 px-3">
|
||||
<span class="py-2.5 px-3 whitespace-nowrap">
|
||||
<template v-if="loading">
|
||||
{{ loadingMessage ?? $t('g.loading') }}
|
||||
</template>
|
||||
@@ -27,12 +29,14 @@ import Button from 'primevue/button'
|
||||
const {
|
||||
label,
|
||||
loadingMessage,
|
||||
fullWidth = false
|
||||
fullWidth = false,
|
||||
variant = 'default'
|
||||
} = defineProps<{
|
||||
label: string
|
||||
loading?: boolean
|
||||
loadingMessage?: string
|
||||
fullWidth?: boolean
|
||||
variant?: 'default' | 'black'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
<PackActionButton
|
||||
v-bind="$attrs"
|
||||
:label="
|
||||
nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install')
|
||||
label ??
|
||||
(nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install'))
|
||||
"
|
||||
severity="secondary"
|
||||
:severity="variant === 'black' ? undefined : 'secondary'"
|
||||
:variant="variant"
|
||||
:loading="isInstalling"
|
||||
:loading-message="$t('g.installing')"
|
||||
@action="installAllPacks"
|
||||
@@ -27,8 +29,10 @@ import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { nodePacks } = defineProps<{
|
||||
const { nodePacks, variant, label } = defineProps<{
|
||||
nodePacks: NodePack[]
|
||||
variant?: 'default' | 'black'
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const isInstalling = inject(IsInstallingKey, ref(false))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<template v-if="nodePack">
|
||||
<div class="flex flex-col h-full z-40 w-80 overflow-hidden relative">
|
||||
<div class="flex flex-col h-full z-40 overflow-hidden relative">
|
||||
<div class="top-0 z-10 px-6 pt-6 w-full">
|
||||
<InfoPanelHeader :node-packs="[nodePack]" />
|
||||
</div>
|
||||
@@ -32,7 +32,7 @@
|
||||
/>
|
||||
</MetadataRow>
|
||||
<MetadataRow :label="t('manager.version')">
|
||||
<PackVersionBadge :node-pack="nodePack" />
|
||||
<PackVersionBadge :node-pack="nodePack" :is-selected="true" />
|
||||
</MetadataRow>
|
||||
</div>
|
||||
<div class="mb-6 overflow-hidden">
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
|
||||
<div class="pt-4 px-8 flex-1 overflow-hidden text-sm">
|
||||
{{ $t('manager.infoPanelEmpty') }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -118,7 +118,15 @@ const onNodePackChange = () => {
|
||||
y.value = 0
|
||||
}
|
||||
|
||||
whenever(() => nodePack, onNodePackChange, { immediate: true, deep: true })
|
||||
whenever(
|
||||
() => nodePack.id,
|
||||
(nodePackId, oldNodePackId) => {
|
||||
if (nodePackId !== oldNodePackId) {
|
||||
onNodePackChange()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.hidden-scrollbar {
|
||||
|
||||
@@ -51,7 +51,11 @@ const getPackNodes = async (pack: components['schemas']['Node']) => {
|
||||
if (!pack.latest_version?.version) return []
|
||||
const nodeDefs = await getNodeDefs.call({
|
||||
packId: pack.id,
|
||||
version: pack.latest_version?.version
|
||||
version: pack.latest_version?.version,
|
||||
// Fetch all nodes.
|
||||
// TODO: Render all nodes previews and handle pagination.
|
||||
// For determining length, use the `totalNumberOfPages` field of response
|
||||
limit: 8192
|
||||
})
|
||||
return nodeDefs?.comfy_nodes ?? []
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ const isLoading = ref(false)
|
||||
const registryNodeDefs = shallowRef<ListComfyNodesResponse | null>(null)
|
||||
|
||||
const fetchNodeDefs = async () => {
|
||||
getNodeDefs.cancel()
|
||||
isLoading.value = true
|
||||
|
||||
const { id: packId } = nodePack
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
<template>
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="nodePack.name + ' banner'"
|
||||
class="object-cover"
|
||||
:style="{ width: cssWidth, height: cssHeight }"
|
||||
@error="isImageError = true"
|
||||
/>
|
||||
<div :style="{ width: cssWidth, height: cssHeight }" class="overflow-hidden">
|
||||
<!-- default banner show -->
|
||||
<div v-if="showDefaultBanner" class="w-full h-full">
|
||||
<img
|
||||
:src="DEFAULT_BANNER"
|
||||
alt="default banner"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<!-- banner_url or icon show -->
|
||||
<div v-else class="relative w-full h-full">
|
||||
<!-- blur background -->
|
||||
<div
|
||||
v-if="imgSrc"
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
|
||||
:style="{
|
||||
backgroundImage: `url(${imgSrc})`,
|
||||
filter: 'blur(10px)'
|
||||
}"
|
||||
></div>
|
||||
<!-- image -->
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="nodePack.name + ' banner'"
|
||||
:class="
|
||||
isImageError
|
||||
? 'relative w-full h-full object-cover z-10'
|
||||
: 'relative w-full h-full object-contain z-10'
|
||||
"
|
||||
@error="isImageError = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -20,18 +46,15 @@ const {
|
||||
width = '100%',
|
||||
height = '12rem'
|
||||
} = defineProps<{
|
||||
nodePack: components['schemas']['Node'] & { banner?: string } // Temporary measure until banner is in backend
|
||||
nodePack: components['schemas']['Node']
|
||||
width?: string
|
||||
height?: string
|
||||
}>()
|
||||
|
||||
const isImageError = ref(false)
|
||||
const shouldShowFallback = computed(
|
||||
() => !nodePack.banner || nodePack.banner.trim() === '' || isImageError.value
|
||||
)
|
||||
const imgSrc = computed(() =>
|
||||
shouldShowFallback.value ? DEFAULT_BANNER : nodePack.banner
|
||||
)
|
||||
|
||||
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
|
||||
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
|
||||
|
||||
const convertToCssValue = (value: string | number) =>
|
||||
typeof value === 'number' ? `${value}rem` : value
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<Card
|
||||
class="w-full h-full inline-flex flex-col justify-between items-start overflow-hidden rounded-2xl shadow-elevation-3 dark-theme:bg-dark-elevation-2 transition-all duration-200"
|
||||
class="w-full h-full inline-flex flex-col justify-between items-start overflow-hidden rounded-lg shadow-elevation-3 dark-theme:bg-dark-elevation-2 transition-all duration-200"
|
||||
:class="{
|
||||
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected,
|
||||
'selected-card': isSelected,
|
||||
'opacity-60': isDisabled
|
||||
}"
|
||||
:pt="{
|
||||
body: { class: 'p-0 flex flex-col w-full h-full rounded-2xl gap-0' },
|
||||
content: { class: 'flex-1 flex flex-col rounded-2xl min-h-0' },
|
||||
body: { class: 'p-0 flex flex-col w-full h-full rounded-lg gap-0' },
|
||||
content: { class: 'flex-1 flex flex-col rounded-lg min-h-0' },
|
||||
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
|
||||
footer: { class: 'p-0 m-0' }
|
||||
}"
|
||||
@@ -70,7 +70,10 @@
|
||||
>
|
||||
<i class="pi pi-arrow-circle-up text-blue-600" />
|
||||
</div>
|
||||
<PackVersionBadge :node-pack="nodePack" />
|
||||
<PackVersionBadge
|
||||
:node-pack="nodePack"
|
||||
:is-selected="isSelected"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="formattedLatestVersionDate"
|
||||
@@ -113,11 +116,15 @@ import PackBanner from '@/components/dialog/content/manager/packBanner/PackBanne
|
||||
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { IsInstallingKey } from '@/types/comfyManagerTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import {
|
||||
IsInstallingKey,
|
||||
type MergedNodePack,
|
||||
type RegistryPack,
|
||||
isMergedNodePack
|
||||
} from '@/types/comfyManagerTypes'
|
||||
|
||||
const { nodePack, isSelected = false } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
nodePack: MergedNodePack | RegistryPack
|
||||
isSelected?: boolean
|
||||
}>()
|
||||
|
||||
@@ -136,9 +143,9 @@ const isDisabled = computed(
|
||||
|
||||
whenever(isInstalled, () => (isInstalling.value = false))
|
||||
|
||||
// TODO: remove type assertion once comfy_nodes is added to node (pack) info type in backend
|
||||
const nodesCount = computed(() => (nodePack as any).comfy_nodes?.length)
|
||||
|
||||
const nodesCount = computed(() =>
|
||||
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined
|
||||
)
|
||||
const publisherName = computed(() => {
|
||||
if (!nodePack) return null
|
||||
|
||||
@@ -154,3 +161,22 @@ const formattedLatestVersionDate = computed(() => {
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selected-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selected-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 3px solid var(--p-primary-color);
|
||||
border-radius: 0.5rem;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
<i class="pi pi-download text-muted"></i>
|
||||
<span>{{ formattedDownloads }}</span>
|
||||
</div>
|
||||
<PackInstallButton :node-packs="[nodePack]" />
|
||||
<PackInstallButton v-if="!isInstalled" :node-packs="[nodePack]" />
|
||||
<PackEnableToggle v-else :node-pack="nodePack" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -14,13 +15,18 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
}>()
|
||||
|
||||
const { isPackInstalled } = useComfyManagerStore()
|
||||
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
|
||||
|
||||
const { n } = useI18n()
|
||||
|
||||
const formattedDownloads = computed(() =>
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
<template>
|
||||
<div class="relative w-full p-6">
|
||||
<div class="flex items-center w-full">
|
||||
<AutoComplete
|
||||
v-model.lazy="searchQuery"
|
||||
:suggestions="suggestions || []"
|
||||
:placeholder="$t('manager.searchPlaceholder')"
|
||||
:complete-on-focus="false"
|
||||
:delay="8"
|
||||
option-label="query"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
autofocus: true,
|
||||
class: 'w-5/12 rounded-2xl'
|
||||
<div class="h-12 flex items-center gap-1 justify-between">
|
||||
<div class="flex items-center w-5/12">
|
||||
<AutoComplete
|
||||
v-model.lazy="searchQuery"
|
||||
:suggestions="suggestions || []"
|
||||
:placeholder="$t('manager.searchPlaceholder')"
|
||||
:complete-on-focus="false"
|
||||
:delay="8"
|
||||
option-label="query"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
pcInputText: {
|
||||
root: {
|
||||
autofocus: true,
|
||||
class: 'w-full rounded-2xl'
|
||||
}
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
},
|
||||
loader: {
|
||||
style: 'display: none'
|
||||
}
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="stubTrue"
|
||||
@option-select="onOptionSelect"
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="stubTrue"
|
||||
@option-select="onOptionSelect"
|
||||
/>
|
||||
</div>
|
||||
<PackInstallButton
|
||||
v-if="isMissingTab && missingNodePacks.length > 0"
|
||||
variant="black"
|
||||
:disabled="isLoading || !!error"
|
||||
:node-packs="missingNodePacks"
|
||||
:label="$t('manager.installAllMissingNodes')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex mt-3 text-sm">
|
||||
@@ -34,7 +43,7 @@
|
||||
/>
|
||||
<SearchFilterDropdown
|
||||
v-model:modelValue="sortField"
|
||||
:options="sortOptions"
|
||||
:options="availableSortOptions"
|
||||
:label="$t('g.sort')"
|
||||
/>
|
||||
</div>
|
||||
@@ -55,43 +64,55 @@ import AutoComplete, {
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
|
||||
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import {
|
||||
type SearchOption,
|
||||
SortableAlgoliaField
|
||||
} from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import type {
|
||||
QuerySuggestion,
|
||||
SearchMode,
|
||||
SortableField
|
||||
} from '@/types/searchServiceTypes'
|
||||
|
||||
const { searchResults } = defineProps<{
|
||||
const { searchResults, sortOptions } = defineProps<{
|
||||
searchResults?: components['schemas']['Node'][]
|
||||
suggestions?: NodesIndexSuggestion[]
|
||||
suggestions?: QuerySuggestion[]
|
||||
sortOptions?: SortableField[]
|
||||
isMissingTab?: boolean
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
const searchMode = defineModel<string>('searchMode', { default: 'packs' })
|
||||
const sortField = defineModel<SortableAlgoliaField>('sortField', {
|
||||
const searchMode = defineModel<SearchMode>('searchMode', { default: 'packs' })
|
||||
const sortField = defineModel<string>('sortField', {
|
||||
default: SortableAlgoliaField.Downloads
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Get missing node packs from workflow with loading and error states
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
|
||||
const hasResults = computed(
|
||||
() => searchQuery.value?.trim() && searchResults?.length
|
||||
)
|
||||
|
||||
const sortOptions: SearchOption<SortableAlgoliaField>[] = [
|
||||
{ id: SortableAlgoliaField.Downloads, label: t('manager.sort.downloads') },
|
||||
{ id: SortableAlgoliaField.Created, label: t('manager.sort.created') },
|
||||
{ id: SortableAlgoliaField.Updated, label: t('manager.sort.updated') },
|
||||
{ id: SortableAlgoliaField.Publisher, label: t('manager.sort.publisher') },
|
||||
{ id: SortableAlgoliaField.Name, label: t('g.name') }
|
||||
]
|
||||
const filterOptions: SearchOption<string>[] = [
|
||||
const availableSortOptions = computed<SearchOption<string>[]>(() => {
|
||||
if (!sortOptions) return []
|
||||
return sortOptions.map((field) => ({
|
||||
id: field.id,
|
||||
label: field.label
|
||||
}))
|
||||
})
|
||||
const filterOptions: SearchOption<SearchMode>[] = [
|
||||
{ id: 'packs', label: t('manager.filter.nodePack') },
|
||||
{ id: 'nodes', label: t('g.nodes') }
|
||||
]
|
||||
|
||||
// When a dropdown query suggestion is selected, update the search query
|
||||
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
|
||||
searchQuery.value = event.value.query
|
||||
}
|
||||
|
||||
54
src/composables/manager/useManagerStatePersistence.ts
Normal file
54
src/composables/manager/useManagerStatePersistence.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
ManagerState,
|
||||
ManagerTab,
|
||||
SortableAlgoliaField
|
||||
} from '@/types/comfyManagerTypes'
|
||||
|
||||
const STORAGE_KEY = 'Comfy.Manager.UI.State'
|
||||
|
||||
export const useManagerStatePersistence = () => {
|
||||
/**
|
||||
* Load the UI state from localStorage.
|
||||
*/
|
||||
const loadStoredState = (): ManagerState => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
return JSON.parse(stored)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load manager UI state:', e)
|
||||
}
|
||||
return {
|
||||
selectedTabId: ManagerTab.All,
|
||||
searchQuery: '',
|
||||
searchMode: 'packs',
|
||||
sortField: SortableAlgoliaField.Downloads
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the UI state to localStorage.
|
||||
*/
|
||||
const persistState = (state: ManagerState) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the UI state to the default values.
|
||||
*/
|
||||
const reset = () => {
|
||||
persistState({
|
||||
selectedTabId: ManagerTab.All,
|
||||
searchQuery: '',
|
||||
searchMode: 'packs',
|
||||
sortField: SortableAlgoliaField.Downloads
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
loadStoredState,
|
||||
persistState,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,29 @@ import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
|
||||
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
||||
import { useNodePaste } from '@/composables/node/useNodePaste'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
|
||||
const uploadFile = async (file: File, isPasted: boolean) => {
|
||||
interface ImageUploadFormFields {
|
||||
/**
|
||||
* The folder to upload the file to.
|
||||
* @example 'input', 'output', 'temp'
|
||||
*/
|
||||
type: ResultItemType
|
||||
}
|
||||
|
||||
const uploadFile = async (
|
||||
file: File,
|
||||
isPasted: boolean,
|
||||
formFields: Partial<ImageUploadFormFields> = {}
|
||||
) => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
if (isPasted) body.append('subfolder', 'pasted')
|
||||
if (formFields.type) body.append('type', formFields.type)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
@@ -36,6 +50,11 @@ interface ImageUploadOptions {
|
||||
* @example 'image/png,image/jpeg,image/webp,video/webm,video/mp4'
|
||||
*/
|
||||
accept?: string
|
||||
/**
|
||||
* The folder to upload the file to.
|
||||
* @example 'input', 'output', 'temp'
|
||||
*/
|
||||
folder?: ResultItemType
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +72,9 @@ export const useNodeImageUpload = (
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
const path = await uploadFile(file, isPastedFile(file))
|
||||
const path = await uploadFile(file, isPastedFile(file), {
|
||||
type: options.folder
|
||||
})
|
||||
if (!path) return
|
||||
return path
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, onUnmounted } from 'vue'
|
||||
|
||||
import { useNodePacks } from '@/composables/nodePack/useNodePacks'
|
||||
@@ -18,6 +19,16 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
|
||||
const filterInstalledPack = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
const startFetchInstalled = async () => {
|
||||
await comfyManagerStore.refreshInstalledList()
|
||||
await startFetch()
|
||||
}
|
||||
|
||||
// When installedPackIds changes, we need to update the nodePacks
|
||||
whenever(installedPackIds, async () => {
|
||||
await startFetch()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
@@ -27,7 +38,7 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
|
||||
isLoading,
|
||||
isReady,
|
||||
installedPacks: nodePacks,
|
||||
startFetchInstalled: startFetch,
|
||||
startFetchInstalled,
|
||||
filterInstalledPack
|
||||
}
|
||||
}
|
||||
|
||||
76
src/composables/nodePack/useMissingNodes.ts
Normal file
76
src/composables/nodePack/useMissingNodes.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { NodeProperty } from '@comfyorg/litegraph/dist/LGraphNode'
|
||||
import { groupBy } from 'lodash'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
/**
|
||||
* Composable to find missing NodePacks from workflow
|
||||
* Uses the same filtering approach as ManagerDialogContent.vue
|
||||
* Automatically fetches workflow pack data when initialized
|
||||
*/
|
||||
export const useMissingNodes = () => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
|
||||
useWorkflowPacks()
|
||||
|
||||
// Same filtering logic as ManagerDialogContent.vue
|
||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
// Filter only uninstalled packs from workflow packs
|
||||
const missingNodePacks = computed(() => {
|
||||
if (!workflowPacks.value.length) return []
|
||||
return filterMissingPacks(workflowPacks.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if a pack is the ComfyUI builtin node pack (nodes that come pre-installed)
|
||||
* @param packId - The id of the pack to check
|
||||
* @returns True if the pack is the comfy-core pack, false otherwise
|
||||
*/
|
||||
const isCorePack = (packId: NodeProperty) => {
|
||||
return packId === 'comfy-core'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a missing core node
|
||||
* A missing core node is a node that is in the workflow and originates from
|
||||
* the comfy-core pack (pre-installed) but not registered in the node def
|
||||
* store (the node def was not found on the server)
|
||||
* @param node - The node to check
|
||||
* @returns True if the node is a missing core node, false otherwise
|
||||
*/
|
||||
const isMissingCoreNode = (node: LGraphNode) => {
|
||||
const packId = node.properties?.cnr_id
|
||||
if (packId === undefined || !isCorePack(packId)) return false
|
||||
const nodeName = node.type
|
||||
const isRegisteredNodeDef = !!nodeDefStore.nodeDefsByName[nodeName]
|
||||
return !isRegisteredNodeDef
|
||||
}
|
||||
|
||||
const missingCoreNodes = computed<Record<string, LGraphNode[]>>(() => {
|
||||
const missingNodes = app.graph.nodes.filter(isMissingCoreNode)
|
||||
return groupBy(missingNodes, (node) => String(node.properties?.ver || ''))
|
||||
})
|
||||
|
||||
// Automatically fetch workflow pack data when composable is used
|
||||
onMounted(async () => {
|
||||
if (!workflowPacks.value.length && !isLoading.value) {
|
||||
await startFetchWorkflowPacks()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
missingNodePacks,
|
||||
missingCoreNodes,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@comfyorg/litegraph'
|
||||
import { Point } from '@comfyorg/litegraph'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import {
|
||||
@@ -27,6 +28,8 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
|
||||
export function useCoreCommands(): ComfyCommand[] {
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -58,6 +61,20 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
})
|
||||
}
|
||||
|
||||
const moveSelectedNodes = (
|
||||
positionUpdater: (pos: Point, gridSize: number) => Point
|
||||
) => {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
if (selectedNodes.length === 0) return
|
||||
|
||||
const gridSize = useSettingStore().get('Comfy.SnapToGrid.GridSize')
|
||||
selectedNodes.forEach((node) => {
|
||||
node.pos = positionUpdater(node.pos, gridSize)
|
||||
})
|
||||
app.canvas.state.selectionChanged = true
|
||||
app.canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
const commands = [
|
||||
{
|
||||
id: 'Comfy.NewBlankWorkflow',
|
||||
@@ -641,19 +658,19 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
{
|
||||
id: 'Comfy.Manager.CustomNodesManager',
|
||||
icon: 'pi pi-puzzle',
|
||||
label: 'Custom Nodes Manager',
|
||||
label: 'Toggle the Custom Nodes Manager',
|
||||
versionAdded: '1.12.10',
|
||||
function: () => {
|
||||
dialogService.showManagerDialog()
|
||||
dialogService.toggleManagerDialog()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Manager.ToggleManagerProgressDialog',
|
||||
icon: 'pi pi-spinner',
|
||||
label: 'Toggle Progress Dialog',
|
||||
label: 'Toggle the Custom Nodes Manager Progress Bar',
|
||||
versionAdded: '1.13.9',
|
||||
function: () => {
|
||||
dialogService.showManagerProgressDialog()
|
||||
dialogService.toggleManagerProgressDialog()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -673,6 +690,34 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
function: async () => {
|
||||
await firebaseAuthActions.logout()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.MoveSelectedNodes.Up',
|
||||
icon: 'pi pi-arrow-up',
|
||||
label: 'Move Selected Nodes Up',
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x, y - gridSize])
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.MoveSelectedNodes.Down',
|
||||
icon: 'pi pi-arrow-down',
|
||||
label: 'Move Selected Nodes Down',
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x, y + gridSize])
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.MoveSelectedNodes.Left',
|
||||
icon: 'pi pi-arrow-left',
|
||||
label: 'Move Selected Nodes Left',
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x - gridSize, y])
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.MoveSelectedNodes.Right',
|
||||
icon: 'pi pi-arrow-right',
|
||||
label: 'Move Selected Nodes Right',
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x + gridSize, y])
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ export const useProgressFavicon = () => {
|
||||
if (isIdle) {
|
||||
favicon.value = defaultFavicon
|
||||
} else {
|
||||
const frame = Math.floor(progress * totalFrames)
|
||||
const frame = Math.min(
|
||||
Math.max(0, Math.floor(progress * totalFrames)),
|
||||
totalFrames - 1
|
||||
)
|
||||
favicon.value = `/assets/images/favicon_progress_16x16/frame_${frame}.png`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +1,61 @@
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import type { Hit } from 'algoliasearch/dist/lite/browser'
|
||||
import { memoize, orderBy } from 'lodash'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { orderBy } from 'lodash'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
AlgoliaNodePack,
|
||||
SearchAttribute,
|
||||
useAlgoliaSearchService
|
||||
} from '@/services/algoliaSearchService'
|
||||
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
|
||||
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
|
||||
import type { SearchAttribute } from '@/types/algoliaTypes'
|
||||
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import type { QuerySuggestion, SearchMode } from '@/types/searchServiceTypes'
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
|
||||
const SEARCH_DEBOUNCE_TIME = 320
|
||||
const DEFAULT_PAGE_SIZE = 64
|
||||
const DEFAULT_SORT_FIELD = SortableAlgoliaField.Downloads // Set in the index configuration
|
||||
const DEFAULT_MAX_CACHE_SIZE = 64
|
||||
const SORT_DIRECTIONS: Record<SortableAlgoliaField, 'asc' | 'desc'> = {
|
||||
[SortableAlgoliaField.Downloads]: 'desc',
|
||||
[SortableAlgoliaField.Created]: 'desc',
|
||||
[SortableAlgoliaField.Updated]: 'desc',
|
||||
[SortableAlgoliaField.Publisher]: 'asc',
|
||||
[SortableAlgoliaField.Name]: 'asc'
|
||||
}
|
||||
|
||||
const isDateField = (field: SortableAlgoliaField): boolean =>
|
||||
field === SortableAlgoliaField.Created ||
|
||||
field === SortableAlgoliaField.Updated
|
||||
|
||||
/**
|
||||
* Composable for managing UI state of Comfy Node Registry search.
|
||||
*/
|
||||
export function useRegistrySearch(
|
||||
options: {
|
||||
maxCacheSize?: number
|
||||
initialSortField?: string
|
||||
initialSearchMode?: SearchMode
|
||||
initialSearchQuery?: string
|
||||
initialPageNumber?: number
|
||||
} = {}
|
||||
) {
|
||||
const { maxCacheSize = DEFAULT_MAX_CACHE_SIZE } = options
|
||||
const {
|
||||
initialSortField = DEFAULT_SORT_FIELD,
|
||||
initialSearchMode = 'packs',
|
||||
initialSearchQuery = '',
|
||||
initialPageNumber = 0
|
||||
} = options
|
||||
|
||||
const isLoading = ref(false)
|
||||
const sortField = ref<SortableAlgoliaField>(SortableAlgoliaField.Downloads)
|
||||
const searchMode = ref<'nodes' | 'packs'>('packs')
|
||||
const sortField = ref<string>(initialSortField)
|
||||
const searchMode = ref<SearchMode>(initialSearchMode)
|
||||
const pageSize = ref(DEFAULT_PAGE_SIZE)
|
||||
const pageNumber = ref(0)
|
||||
const searchQuery = ref('')
|
||||
const results = ref<AlgoliaNodePack[]>([])
|
||||
const suggestions = ref<NodesIndexSuggestion[]>([])
|
||||
const pageNumber = ref(initialPageNumber)
|
||||
const searchQuery = ref(initialSearchQuery)
|
||||
const searchResults = ref<RegistryNodePack[]>([])
|
||||
const suggestions = ref<QuerySuggestion[]>([])
|
||||
|
||||
const searchAttributes = computed<SearchAttribute[]>(() =>
|
||||
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
|
||||
)
|
||||
|
||||
const resultsAsRegistryPacks = computed(() =>
|
||||
results.value ? results.value.map(algoliaToRegistry) : []
|
||||
)
|
||||
const resultsAsNodes = computed(() =>
|
||||
results.value
|
||||
? results.value.reduce(
|
||||
(acc, hit) => acc.concat(hit.comfy_nodes),
|
||||
[] as string[]
|
||||
)
|
||||
: []
|
||||
)
|
||||
const searchGateway = useRegistrySearchGateway()
|
||||
|
||||
const { searchPacksCached, toRegistryPack, clearSearchPacksCache } =
|
||||
useAlgoliaSearchService({
|
||||
maxCacheSize
|
||||
})
|
||||
|
||||
const algoliaToRegistry = memoize(
|
||||
toRegistryPack,
|
||||
(algoliaNode: AlgoliaNodePack) => algoliaNode.id
|
||||
)
|
||||
const getSortValue = (pack: Hit<AlgoliaNodePack>) => {
|
||||
if (isDateField(sortField.value)) {
|
||||
const value = pack[sortField.value]
|
||||
return value ? new Date(value).getTime() : 0
|
||||
} else {
|
||||
const value = pack[sortField.value]
|
||||
return value ?? 0
|
||||
}
|
||||
}
|
||||
const { searchPacks, clearSearchCache, getSortValue, getSortableFields } =
|
||||
searchGateway
|
||||
|
||||
const updateSearchResults = async (options: { append?: boolean }) => {
|
||||
isLoading.value = true
|
||||
if (!options.append) {
|
||||
pageNumber.value = 0
|
||||
}
|
||||
const { nodePacks, querySuggestions } = await searchPacksCached(
|
||||
const { nodePacks, querySuggestions } = await searchPacks(
|
||||
searchQuery.value,
|
||||
{
|
||||
pageSize: pageSize.value,
|
||||
@@ -98,17 +68,22 @@ export function useRegistrySearch(
|
||||
|
||||
// Results are sorted by the default field to begin with -- so don't manually sort again
|
||||
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
|
||||
// Get the sort direction from the provider's sortable fields
|
||||
const sortableFields = getSortableFields()
|
||||
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
|
||||
const direction = fieldConfig?.direction || 'desc'
|
||||
|
||||
sortedPacks = orderBy(
|
||||
nodePacks,
|
||||
[getSortValue],
|
||||
[SORT_DIRECTIONS[sortField.value]]
|
||||
[(pack) => getSortValue(pack, sortField.value)],
|
||||
[direction]
|
||||
)
|
||||
}
|
||||
|
||||
if (options.append && results.value?.length) {
|
||||
results.value = results.value.concat(sortedPacks)
|
||||
if (options.append && searchResults.value?.length) {
|
||||
searchResults.value = searchResults.value.concat(sortedPacks)
|
||||
} else {
|
||||
results.value = sortedPacks
|
||||
searchResults.value = sortedPacks
|
||||
}
|
||||
suggestions.value = querySuggestions
|
||||
isLoading.value = false
|
||||
@@ -124,7 +99,9 @@ export function useRegistrySearch(
|
||||
immediate: true
|
||||
})
|
||||
|
||||
onUnmounted(clearSearchPacksCache)
|
||||
const sortOptions = computed(() => {
|
||||
return getSortableFields()
|
||||
})
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
@@ -134,7 +111,8 @@ export function useRegistrySearch(
|
||||
searchMode,
|
||||
searchQuery,
|
||||
suggestions,
|
||||
searchResults: resultsAsRegistryPacks,
|
||||
nodeSearchResults: resultsAsNodes
|
||||
searchResults,
|
||||
sortOptions,
|
||||
clearCache: clearSearchCache
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import { t } from '@/i18n'
|
||||
import type { ResultItem } from '@/schemas/apiSchema'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { isImageUploadInput } from '@/types/nodeDefAugmentation'
|
||||
import { createAnnotatedPath } from '@/utils/formatUtil'
|
||||
import { addToComboValues } from '@/utils/litegraphUtil'
|
||||
|
||||
@@ -33,8 +34,15 @@ export const useImageUploadWidget = () => {
|
||||
inputName: string,
|
||||
inputData: InputSpec
|
||||
) => {
|
||||
const inputOptions = inputData[1] ?? {}
|
||||
if (!isImageUploadInput(inputData)) {
|
||||
throw new Error(
|
||||
'Image upload widget requires imageInputName augmentation'
|
||||
)
|
||||
}
|
||||
|
||||
const inputOptions = inputData[1]
|
||||
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
|
||||
const folder: ResultItemType | undefined = image_folder
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const isAnimated = !!inputOptions.animated_image_upload
|
||||
@@ -43,11 +51,9 @@ export const useImageUploadWidget = () => {
|
||||
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
||||
|
||||
const fileFilter = isVideo ? isVideoFile : isImageFile
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
const fileComboWidget = findFileComboWidget(node, imageInputName)
|
||||
const initialFile = `${fileComboWidget.value}`
|
||||
const formatPath = (value: InternalFile) =>
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
createAnnotatedPath(value, { rootFolder: image_folder })
|
||||
|
||||
const transform = (internalValue: InternalValue): ExposedValue => {
|
||||
@@ -67,10 +73,10 @@ export const useImageUploadWidget = () => {
|
||||
|
||||
// Setup file upload handling
|
||||
const { openFileSelection } = useNodeImageUpload(node, {
|
||||
// @ts-expect-error InputSpec is not typed correctly
|
||||
allow_batch,
|
||||
fileFilter,
|
||||
accept,
|
||||
folder,
|
||||
onUploadComplete: (output) => {
|
||||
output.forEach((path) => addToComboValues(fileComboWidget, path))
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
|
||||
@@ -2,7 +2,9 @@ import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { IWidget } from '@comfyorg/litegraph'
|
||||
import axios from 'axios'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const MAX_RETRIES = 5
|
||||
const TIMEOUT = 4096
|
||||
@@ -220,6 +222,46 @@ export function useRemoteWidget<
|
||||
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add auto-refresh toggle widget and execution success listener
|
||||
*/
|
||||
function addAutoRefreshToggle() {
|
||||
let autoRefreshEnabled = false
|
||||
|
||||
// Handler for execution success
|
||||
const handleExecutionSuccess = () => {
|
||||
if (autoRefreshEnabled && widget.refresh) {
|
||||
widget.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Add toggle widget
|
||||
const autoRefreshWidget = node.addWidget(
|
||||
'toggle',
|
||||
'Auto-refresh after generation',
|
||||
false,
|
||||
(value: boolean) => {
|
||||
autoRefreshEnabled = value
|
||||
},
|
||||
{
|
||||
serialize: false
|
||||
}
|
||||
)
|
||||
|
||||
// Register event listener
|
||||
api.addEventListener('execution_success', handleExecutionSuccess)
|
||||
|
||||
// Cleanup on node removal
|
||||
node.onRemoved = useChainCallback(node.onRemoved, function () {
|
||||
api.removeEventListener('execution_success', handleExecutionSuccess)
|
||||
})
|
||||
|
||||
return autoRefreshWidget
|
||||
}
|
||||
|
||||
// Always add auto-refresh toggle for remote widgets
|
||||
addAutoRefreshToggle()
|
||||
|
||||
return {
|
||||
getCachedValue,
|
||||
getValue,
|
||||
|
||||
3
src/constants/searchConstants.ts
Normal file
3
src/constants/searchConstants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const SEARCH_CACHE_MAX_SIZE = 64
|
||||
export const DEFAULT_PAGE_SIZE = 64
|
||||
export const MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA = 2
|
||||
@@ -5,7 +5,7 @@ import { EventManagerInterface, PreviewManagerInterface } from './interfaces'
|
||||
|
||||
export class PreviewManager implements PreviewManagerInterface {
|
||||
previewCamera: THREE.Camera
|
||||
previewContainer: HTMLDivElement = {} as HTMLDivElement
|
||||
previewContainer: HTMLDivElement = null!
|
||||
showPreview: boolean = true
|
||||
previewWidth: number = 120
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
|
||||
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
||||
import { useNodePaste } from '@/composables/node/useNodePaste'
|
||||
import { t } from '@/i18n'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { DOMWidget } from '@/scripts/domWidget'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
@@ -12,8 +13,6 @@ import { useToastStore } from '@/stores/toastStore'
|
||||
import { api } from '../../scripts/api'
|
||||
import { app } from '../../scripts/app'
|
||||
|
||||
type FolderType = 'input' | 'output' | 'temp'
|
||||
|
||||
function splitFilePath(path: string): [string, string] {
|
||||
const folder_separator = path.lastIndexOf('/')
|
||||
if (folder_separator === -1) {
|
||||
@@ -28,7 +27,7 @@ function splitFilePath(path: string): [string, string] {
|
||||
function getResourceURL(
|
||||
subfolder: string,
|
||||
filename: string,
|
||||
type: FolderType = 'input'
|
||||
type: ResultItemType = 'input'
|
||||
): string {
|
||||
const params = [
|
||||
'filename=' + encodeURIComponent(filename),
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"Comfy_Canvas_FitView": {
|
||||
"label": "Fit view to selected nodes"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "Move Selected Nodes Down"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Left": {
|
||||
"label": "Move Selected Nodes Left"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Right": {
|
||||
"label": "Move Selected Nodes Right"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Up": {
|
||||
"label": "Move Selected Nodes Up"
|
||||
},
|
||||
"Comfy_Canvas_ResetView": {
|
||||
"label": "Reset View"
|
||||
},
|
||||
@@ -135,10 +147,10 @@
|
||||
"label": "Load Default Workflow"
|
||||
},
|
||||
"Comfy_Manager_CustomNodesManager": {
|
||||
"label": "Custom Nodes Manager"
|
||||
"label": "Toggle the Custom Nodes Manager"
|
||||
},
|
||||
"Comfy_Manager_ToggleManagerProgressDialog": {
|
||||
"label": "Toggle Progress Dialog"
|
||||
"label": "Toggle the Custom Nodes Manager Progress Bar"
|
||||
},
|
||||
"Comfy_MaskEditor_OpenMaskEditor": {
|
||||
"label": "Open Mask Editor for Selected Node"
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"lastUpdated": "Last Updated",
|
||||
"noDescription": "No description available",
|
||||
"installSelected": "Install Selected",
|
||||
"installAllMissingNodes": "Install All Missing Nodes",
|
||||
"packsSelected": "Packs Selected",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
@@ -794,6 +795,10 @@
|
||||
"Browse Templates": "Browse Templates",
|
||||
"Delete Selected Items": "Delete Selected Items",
|
||||
"Fit view to selected nodes": "Fit view to selected nodes",
|
||||
"Move Selected Nodes Down": "Move Selected Nodes Down",
|
||||
"Move Selected Nodes Left": "Move Selected Nodes Left",
|
||||
"Move Selected Nodes Right": "Move Selected Nodes Right",
|
||||
"Move Selected Nodes Up": "Move Selected Nodes Up",
|
||||
"Reset View": "Reset View",
|
||||
"Resize Selected Nodes": "Resize Selected Nodes",
|
||||
"Canvas Toggle Link Visibility": "Canvas Toggle Link Visibility",
|
||||
@@ -824,8 +829,8 @@
|
||||
"ComfyUI Issues": "ComfyUI Issues",
|
||||
"Interrupt": "Interrupt",
|
||||
"Load Default Workflow": "Load Default Workflow",
|
||||
"Custom Nodes Manager": "Custom Nodes Manager",
|
||||
"Toggle Progress Dialog": "Toggle Progress Dialog",
|
||||
"Toggle the Custom Nodes Manager": "Toggle the Custom Nodes Manager",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
|
||||
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
|
||||
"New": "New",
|
||||
"Clipspace": "Clipspace",
|
||||
@@ -1190,6 +1195,11 @@
|
||||
"missingModels": "Missing Models",
|
||||
"missingModelsMessage": "When loading the graph, the following models were not found"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"outdatedVersion": "Some nodes require a newer version of ComfyUI (current: {version}). Please update to use all nodes.",
|
||||
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
|
||||
"coreNodesFromVersion": "Requires ComfyUI {version}:"
|
||||
},
|
||||
"errorDialog": {
|
||||
"defaultTitle": "An error occurred",
|
||||
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"Comfy_Canvas_FitView": {
|
||||
"label": "Ajustar vista a los nodos seleccionados"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "Mover nodos seleccionados hacia abajo"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Left": {
|
||||
"label": "Mover nodos seleccionados a la izquierda"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Right": {
|
||||
"label": "Mover nodos seleccionados a la derecha"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Up": {
|
||||
"label": "Mover nodos seleccionados hacia arriba"
|
||||
},
|
||||
"Comfy_Canvas_ResetView": {
|
||||
"label": "Restablecer vista"
|
||||
},
|
||||
|
||||
@@ -551,6 +551,11 @@
|
||||
"uploadBackgroundImage": "Subir imagen de fondo",
|
||||
"uploadTexture": "Subir textura"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "Requiere ComfyUI {version}:",
|
||||
"outdatedVersion": "Algunos nodos requieren una versión más reciente de ComfyUI (actual: {version}). Por favor, actualiza para usar todos los nodos.",
|
||||
"outdatedVersionGeneric": "Algunos nodos requieren una versión más reciente de ComfyUI. Por favor, actualiza para usar todos los nodos."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Ninguno",
|
||||
"OK": "OK",
|
||||
@@ -586,6 +591,7 @@
|
||||
},
|
||||
"inWorkflow": "En Flujo de Trabajo",
|
||||
"infoPanelEmpty": "Haz clic en un elemento para ver la información",
|
||||
"installAllMissingNodes": "Instalar todos los nodos faltantes",
|
||||
"installSelected": "Instalar Seleccionado",
|
||||
"installationQueue": "Cola de Instalación",
|
||||
"lastUpdated": "Última Actualización",
|
||||
@@ -694,7 +700,6 @@
|
||||
"ComfyUI Issues": "Problemas de ComfyUI",
|
||||
"Contact Support": "Contactar soporte",
|
||||
"Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo",
|
||||
"Custom Nodes Manager": "Gestor de nodos personalizados",
|
||||
"Delete Selected Items": "Eliminar elementos seleccionados",
|
||||
"Desktop User Guide": "Guía de usuario de escritorio",
|
||||
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
|
||||
@@ -709,6 +714,10 @@
|
||||
"Interrupt": "Interrumpir",
|
||||
"Load Default Workflow": "Cargar flujo de trabajo predeterminado",
|
||||
"Manage group nodes": "Gestionar nodos de grupo",
|
||||
"Move Selected Nodes Down": "Mover nodos seleccionados hacia abajo",
|
||||
"Move Selected Nodes Left": "Mover nodos seleccionados hacia la izquierda",
|
||||
"Move Selected Nodes Right": "Mover nodos seleccionados hacia la derecha",
|
||||
"Move Selected Nodes Up": "Mover nodos seleccionados hacia arriba",
|
||||
"Mute/Unmute Selected Nodes": "Silenciar/Activar sonido de nodos seleccionados",
|
||||
"New": "Nuevo",
|
||||
"Next Opened Workflow": "Siguiente flujo de trabajo abierto",
|
||||
@@ -744,12 +753,13 @@
|
||||
"Toggle Logs Bottom Panel": "Alternar panel inferior de registros",
|
||||
"Toggle Model Library Sidebar": "Alternar barra lateral de biblioteca de modelos",
|
||||
"Toggle Node Library Sidebar": "Alternar barra lateral de biblioteca de nodos",
|
||||
"Toggle Progress Dialog": "Alternar diálogo de progreso",
|
||||
"Toggle Queue Sidebar": "Alternar barra lateral de cola",
|
||||
"Toggle Search Box": "Alternar caja de búsqueda",
|
||||
"Toggle Terminal Bottom Panel": "Alternar panel inferior de terminal",
|
||||
"Toggle Theme (Dark/Light)": "Alternar tema (Oscuro/Claro)",
|
||||
"Toggle Workflows Sidebar": "Alternar barra lateral de flujos de trabajo",
|
||||
"Toggle the Custom Nodes Manager": "Alternar el Administrador de Nodos Personalizados",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
|
||||
"Undo": "Deshacer",
|
||||
"Ungroup selected group nodes": "Desagrupar nodos de grupo seleccionados",
|
||||
"Workflow": "Flujo de trabajo",
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"Comfy_Canvas_FitView": {
|
||||
"label": "Ajuster la vue aux nœuds sélectionnés"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "Déplacer les nœuds sélectionnés vers le bas"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Left": {
|
||||
"label": "Déplacer les nœuds sélectionnés vers la gauche"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Right": {
|
||||
"label": "Déplacer les nœuds sélectionnés vers la droite"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Up": {
|
||||
"label": "Déplacer les nœuds sélectionnés vers le haut"
|
||||
},
|
||||
"Comfy_Canvas_ResetView": {
|
||||
"label": "Réinitialiser la vue"
|
||||
},
|
||||
|
||||
@@ -551,6 +551,11 @@
|
||||
"uploadBackgroundImage": "Télécharger l'image de fond",
|
||||
"uploadTexture": "Télécharger Texture"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "Nécessite ComfyUI {version} :",
|
||||
"outdatedVersion": "Certains nœuds nécessitent une version plus récente de ComfyUI (actuelle : {version}). Veuillez mettre à jour pour utiliser tous les nœuds.",
|
||||
"outdatedVersionGeneric": "Certains nœuds nécessitent une version plus récente de ComfyUI. Veuillez mettre à jour pour utiliser tous les nœuds."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Aucun",
|
||||
"OK": "OK",
|
||||
@@ -586,6 +591,7 @@
|
||||
},
|
||||
"inWorkflow": "Dans le flux de travail",
|
||||
"infoPanelEmpty": "Cliquez sur un élément pour voir les informations",
|
||||
"installAllMissingNodes": "Installer tous les nœuds manquants",
|
||||
"installSelected": "Installer sélectionné",
|
||||
"installationQueue": "File d'attente d'installation",
|
||||
"lastUpdated": "Dernière mise à jour",
|
||||
@@ -694,7 +700,6 @@
|
||||
"ComfyUI Issues": "Problèmes de ComfyUI",
|
||||
"Contact Support": "Contacter le support",
|
||||
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
|
||||
"Custom Nodes Manager": "Gestionnaire de Nœuds Personnalisés",
|
||||
"Delete Selected Items": "Supprimer les éléments sélectionnés",
|
||||
"Desktop User Guide": "Guide de l'utilisateur de bureau",
|
||||
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
|
||||
@@ -709,6 +714,10 @@
|
||||
"Interrupt": "Interrompre",
|
||||
"Load Default Workflow": "Charger le flux de travail par défaut",
|
||||
"Manage group nodes": "Gérer les nœuds de groupe",
|
||||
"Move Selected Nodes Down": "Déplacer les nœuds sélectionnés vers le bas",
|
||||
"Move Selected Nodes Left": "Déplacer les nœuds sélectionnés vers la gauche",
|
||||
"Move Selected Nodes Right": "Déplacer les nœuds sélectionnés vers la droite",
|
||||
"Move Selected Nodes Up": "Déplacer les nœuds sélectionnés vers le haut",
|
||||
"Mute/Unmute Selected Nodes": "Mettre en sourdine/Activer le son des nœuds sélectionnés",
|
||||
"New": "Nouveau",
|
||||
"Next Opened Workflow": "Prochain flux de travail ouvert",
|
||||
@@ -744,12 +753,13 @@
|
||||
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
|
||||
"Toggle Model Library Sidebar": "Basculer la barre latérale de la bibliothèque de modèles",
|
||||
"Toggle Node Library Sidebar": "Basculer la barre latérale de la bibliothèque de nœuds",
|
||||
"Toggle Progress Dialog": "Basculer la boîte de dialogue de progression",
|
||||
"Toggle Queue Sidebar": "Basculer la barre latérale de la file d'attente",
|
||||
"Toggle Search Box": "Basculer la boîte de recherche",
|
||||
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
|
||||
"Toggle Theme (Dark/Light)": "Basculer le thème (Sombre/Clair)",
|
||||
"Toggle Workflows Sidebar": "Basculer la barre latérale des flux de travail",
|
||||
"Toggle the Custom Nodes Manager": "Basculer le gestionnaire de nœuds personnalisés",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
|
||||
"Undo": "Annuler",
|
||||
"Ungroup selected group nodes": "Dégrouper les nœuds de groupe sélectionnés",
|
||||
"Workflow": "Flux de travail",
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"Comfy_Canvas_FitView": {
|
||||
"label": "選択したノードにビューを合わせる"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "選択したノードを下に移動"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Left": {
|
||||
"label": "選択したノードを左に移動"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Right": {
|
||||
"label": "選択したノードを右に移動"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Up": {
|
||||
"label": "選択したノードを上に移動"
|
||||
},
|
||||
"Comfy_Canvas_ResetView": {
|
||||
"label": "ビューをリセット"
|
||||
},
|
||||
|
||||
@@ -551,6 +551,11 @@
|
||||
"uploadBackgroundImage": "背景画像をアップロード",
|
||||
"uploadTexture": "テクスチャをアップロード"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "ComfyUI {version} が必要です:",
|
||||
"outdatedVersion": "一部のノードはより新しいバージョンのComfyUIが必要です(現在のバージョン:{version})。すべてのノードを使用するにはアップデートしてください。",
|
||||
"outdatedVersionGeneric": "一部のノードはより新しいバージョンのComfyUIが必要です。すべてのノードを使用するにはアップデートしてください。"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "なし",
|
||||
"OK": "OK",
|
||||
@@ -586,6 +591,7 @@
|
||||
},
|
||||
"inWorkflow": "ワークフロー内",
|
||||
"infoPanelEmpty": "アイテムをクリックして情報を表示します",
|
||||
"installAllMissingNodes": "すべての不足しているノードをインストール",
|
||||
"installSelected": "選択したものをインストール",
|
||||
"installationQueue": "インストールキュー",
|
||||
"lastUpdated": "最終更新日",
|
||||
@@ -694,7 +700,6 @@
|
||||
"ComfyUI Issues": "ComfyUIの問題",
|
||||
"Contact Support": "サポートに連絡",
|
||||
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
|
||||
"Custom Nodes Manager": "カスタムノードマネージャ",
|
||||
"Delete Selected Items": "選択したアイテムを削除",
|
||||
"Desktop User Guide": "デスクトップユーザーガイド",
|
||||
"Duplicate Current Workflow": "現在のワークフローを複製",
|
||||
@@ -709,6 +714,10 @@
|
||||
"Interrupt": "中断",
|
||||
"Load Default Workflow": "デフォルトワークフローを読み込む",
|
||||
"Manage group nodes": "グループノードを管理",
|
||||
"Move Selected Nodes Down": "選択したノードを下へ移動",
|
||||
"Move Selected Nodes Left": "選択したノードを左へ移動",
|
||||
"Move Selected Nodes Right": "選択したノードを右へ移動",
|
||||
"Move Selected Nodes Up": "選択したノードを上へ移動",
|
||||
"Mute/Unmute Selected Nodes": "選択したノードのミュート/ミュート解除",
|
||||
"New": "新規",
|
||||
"Next Opened Workflow": "次に開いたワークフロー",
|
||||
@@ -744,12 +753,13 @@
|
||||
"Toggle Logs Bottom Panel": "ログパネル下部を切り替え",
|
||||
"Toggle Model Library Sidebar": "モデルライブラリサイドバーを切り替え",
|
||||
"Toggle Node Library Sidebar": "ノードライブラリサイドバーを切り替え",
|
||||
"Toggle Progress Dialog": "進行状況ダイアログの切り替え",
|
||||
"Toggle Queue Sidebar": "キューサイドバーを切り替え",
|
||||
"Toggle Search Box": "検索ボックスの切り替え",
|
||||
"Toggle Terminal Bottom Panel": "ターミナルパネル下部を切り替え",
|
||||
"Toggle Theme (Dark/Light)": "テーマを切り替え(ダーク/ライト)",
|
||||
"Toggle Workflows Sidebar": "ワークフローサイドバーを切り替え",
|
||||
"Toggle the Custom Nodes Manager": "カスタムノードマネージャーを切り替え",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "カスタムノードマネージャーの進行状況バーを切り替え",
|
||||
"Undo": "元に戻す",
|
||||
"Ungroup selected group nodes": "選択したグループノードのグループ解除",
|
||||
"Workflow": "ワークフロー",
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"Comfy_Canvas_FitView": {
|
||||
"label": "선택한 노드에 뷰 맞추기"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "선택한 노드 아래로 이동"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Left": {
|
||||
"label": "선택한 노드 왼쪽으로 이동"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Right": {
|
||||
"label": "선택한 노드 오른쪽으로 이동"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Up": {
|
||||
"label": "선택한 노드 위로 이동"
|
||||
},
|
||||
"Comfy_Canvas_ResetView": {
|
||||
"label": "뷰 재설정"
|
||||
},
|
||||
|
||||
@@ -551,6 +551,11 @@
|
||||
"uploadBackgroundImage": "배경 이미지 업로드",
|
||||
"uploadTexture": "텍스처 업로드"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "ComfyUI {version} 이상 필요:",
|
||||
"outdatedVersion": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다 (현재: {version}). 모든 노드를 사용하려면 업데이트해 주세요.",
|
||||
"outdatedVersionGeneric": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다. 모든 노드를 사용하려면 업데이트해 주세요."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "없음",
|
||||
"OK": "확인",
|
||||
@@ -586,6 +591,7 @@
|
||||
},
|
||||
"inWorkflow": "워크플로우 내",
|
||||
"infoPanelEmpty": "정보를 보려면 항목을 클릭하세요",
|
||||
"installAllMissingNodes": "모든 누락된 노드 설치",
|
||||
"installSelected": "선택한 항목 설치",
|
||||
"installationQueue": "설치 대기열",
|
||||
"lastUpdated": "마지막 업데이트",
|
||||
@@ -694,7 +700,6 @@
|
||||
"ComfyUI Issues": "ComfyUI 이슈 페이지",
|
||||
"Contact Support": "고객 지원 문의",
|
||||
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
|
||||
"Custom Nodes Manager": "사용자 정의 노드 관리자",
|
||||
"Delete Selected Items": "선택한 항목 삭제",
|
||||
"Desktop User Guide": "데스크톱 사용자 가이드",
|
||||
"Duplicate Current Workflow": "현재 워크플로 복제",
|
||||
@@ -709,6 +714,10 @@
|
||||
"Interrupt": "중단",
|
||||
"Load Default Workflow": "기본 워크플로 불러오기",
|
||||
"Manage group nodes": "그룹 노드 관리",
|
||||
"Move Selected Nodes Down": "선택한 노드 아래로 이동",
|
||||
"Move Selected Nodes Left": "선택한 노드 왼쪽으로 이동",
|
||||
"Move Selected Nodes Right": "선택한 노드 오른쪽으로 이동",
|
||||
"Move Selected Nodes Up": "선택한 노드 위로 이동",
|
||||
"Mute/Unmute Selected Nodes": "선택한 노드 활성화/비활성화",
|
||||
"New": "새로 만들기",
|
||||
"Next Opened Workflow": "다음 열린 워크플로",
|
||||
@@ -744,12 +753,13 @@
|
||||
"Toggle Logs Bottom Panel": "로그 하단 패널 전환",
|
||||
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
|
||||
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
|
||||
"Toggle Progress Dialog": "진행 상황 대화 상자 전환",
|
||||
"Toggle Queue Sidebar": "실행 대기열 사이드바 전환",
|
||||
"Toggle Search Box": "검색 상자 전환",
|
||||
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
|
||||
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
|
||||
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
|
||||
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
|
||||
"Undo": "실행 취소",
|
||||
"Ungroup selected group nodes": "선택한 그룹 노드 그룹 해제",
|
||||
"Workflow": "워크플로",
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"Comfy_Canvas_FitView": {
|
||||
"label": "Подогнать вид к выбранным нодам"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "Переместить выбранные узлы вниз"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Left": {
|
||||
"label": "Переместить выбранные узлы влево"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Right": {
|
||||
"label": "Переместить выбранные узлы вправо"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Up": {
|
||||
"label": "Переместить выбранные узлы вверх"
|
||||
},
|
||||
"Comfy_Canvas_ResetView": {
|
||||
"label": "Сбросить вид"
|
||||
},
|
||||
|
||||
@@ -551,6 +551,11 @@
|
||||
"uploadBackgroundImage": "Загрузить фоновое изображение",
|
||||
"uploadTexture": "Загрузить текстуру"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "Требуется ComfyUI {version}:",
|
||||
"outdatedVersion": "Некоторые узлы требуют более новой версии ComfyUI (текущая: {version}). Пожалуйста, обновите, чтобы использовать все узлы.",
|
||||
"outdatedVersionGeneric": "Некоторые узлы требуют более новой версии ComfyUI. Пожалуйста, обновите, чтобы использовать все узлы."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Нет",
|
||||
"OK": "OK",
|
||||
@@ -586,6 +591,7 @@
|
||||
},
|
||||
"inWorkflow": "В рабочем процессе",
|
||||
"infoPanelEmpty": "Нажмите на элемент, чтобы увидеть информацию",
|
||||
"installAllMissingNodes": "Установить все отсутствующие узлы",
|
||||
"installSelected": "Установить выбранное",
|
||||
"installationQueue": "Очередь установки",
|
||||
"lastUpdated": "Последнее обновление",
|
||||
@@ -694,7 +700,6 @@
|
||||
"ComfyUI Issues": "Проблемы ComfyUI",
|
||||
"Contact Support": "Связаться с поддержкой",
|
||||
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
|
||||
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
|
||||
"Delete Selected Items": "Удалить выбранные элементы",
|
||||
"Desktop User Guide": "Руководство пользователя для настольных ПК",
|
||||
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
|
||||
@@ -709,6 +714,10 @@
|
||||
"Interrupt": "Прервать",
|
||||
"Load Default Workflow": "Загрузить стандартный рабочий процесс",
|
||||
"Manage group nodes": "Управление групповыми нодами",
|
||||
"Move Selected Nodes Down": "Переместить выбранные узлы вниз",
|
||||
"Move Selected Nodes Left": "Переместить выбранные узлы влево",
|
||||
"Move Selected Nodes Right": "Переместить выбранные узлы вправо",
|
||||
"Move Selected Nodes Up": "Переместить выбранные узлы вверх",
|
||||
"Mute/Unmute Selected Nodes": "Отключить/включить звук для выбранных нод",
|
||||
"New": "Новый",
|
||||
"Next Opened Workflow": "Следующий открытый рабочий процесс",
|
||||
@@ -744,12 +753,13 @@
|
||||
"Toggle Logs Bottom Panel": "Переключение нижней панели журналов",
|
||||
"Toggle Model Library Sidebar": "Переключение боковой панели библиотеки моделей",
|
||||
"Toggle Node Library Sidebar": "Переключение боковой панели библиотеки нод",
|
||||
"Toggle Progress Dialog": "Переключить диалоговое окно прогресса",
|
||||
"Toggle Queue Sidebar": "Переключение боковой панели очереди",
|
||||
"Toggle Search Box": "Переключить поисковую панель",
|
||||
"Toggle Terminal Bottom Panel": "Переключение нижней панели терминала",
|
||||
"Toggle Theme (Dark/Light)": "Переключение темы (Тёмная/Светлая)",
|
||||
"Toggle Workflows Sidebar": "Переключение боковой панели рабочих процессов",
|
||||
"Toggle the Custom Nodes Manager": "Переключить менеджер пользовательских узлов",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "Переключить индикатор выполнения менеджера пользовательских узлов",
|
||||
"Undo": "Отменить",
|
||||
"Ungroup selected group nodes": "Разгруппировать выбранные групповые ноды",
|
||||
"Workflow": "Рабочий процесс",
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"Comfy_Canvas_FitView": {
|
||||
"label": "适应视图到选中节点"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "下移选中的节点"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Left": {
|
||||
"label": "向左移动选中节点"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Right": {
|
||||
"label": "向右移动选中节点"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Up": {
|
||||
"label": "上移选中的节点"
|
||||
},
|
||||
"Comfy_Canvas_ResetView": {
|
||||
"label": "重置视图"
|
||||
},
|
||||
|
||||
@@ -551,6 +551,11 @@
|
||||
"uploadBackgroundImage": "上传背景图片",
|
||||
"uploadTexture": "上传纹理"
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "需要 ComfyUI {version}:",
|
||||
"outdatedVersion": "某些节点需要更高版本的 ComfyUI(当前版本:{version})。请更新以使用所有节点。",
|
||||
"outdatedVersionGeneric": "某些节点需要更高版本的 ComfyUI。请更新以使用所有节点。"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "无",
|
||||
"OK": "确定",
|
||||
@@ -586,6 +591,7 @@
|
||||
},
|
||||
"inWorkflow": "在工作流中",
|
||||
"infoPanelEmpty": "点击一个项目查看信息",
|
||||
"installAllMissingNodes": "安装所有缺失节点",
|
||||
"installSelected": "安装选定",
|
||||
"installationQueue": "安装队列",
|
||||
"lastUpdated": "最后更新",
|
||||
@@ -694,7 +700,6 @@
|
||||
"ComfyUI Issues": "ComfyUI 问题",
|
||||
"Contact Support": "联系支持",
|
||||
"Convert selected nodes to group node": "将选中节点转换为组节点",
|
||||
"Custom Nodes Manager": "自定义节点管理器",
|
||||
"Delete Selected Items": "删除选定的项目",
|
||||
"Desktop User Guide": "桌面端用户指南",
|
||||
"Duplicate Current Workflow": "复制当前工作流",
|
||||
@@ -709,6 +714,10 @@
|
||||
"Interrupt": "中断",
|
||||
"Load Default Workflow": "加载默认工作流",
|
||||
"Manage group nodes": "管理组节点",
|
||||
"Move Selected Nodes Down": "下移所选节点",
|
||||
"Move Selected Nodes Left": "左移所选节点",
|
||||
"Move Selected Nodes Right": "右移所选节点",
|
||||
"Move Selected Nodes Up": "上移所选节点",
|
||||
"Mute/Unmute Selected Nodes": "静音/取消静音选定节点",
|
||||
"New": "新建",
|
||||
"Next Opened Workflow": "下一个打开的工作流",
|
||||
@@ -744,12 +753,13 @@
|
||||
"Toggle Logs Bottom Panel": "切换日志底部面板",
|
||||
"Toggle Model Library Sidebar": "切换模型库侧边栏",
|
||||
"Toggle Node Library Sidebar": "切换节点库侧边栏",
|
||||
"Toggle Progress Dialog": "切换进度对话框",
|
||||
"Toggle Queue Sidebar": "切换队列侧边栏",
|
||||
"Toggle Search Box": "切换搜索框",
|
||||
"Toggle Terminal Bottom Panel": "切换终端底部面板",
|
||||
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
|
||||
"Toggle Workflows Sidebar": "切换工作流侧边栏",
|
||||
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
|
||||
"Undo": "撤销",
|
||||
"Ungroup selected group nodes": "解散选中组节点",
|
||||
"Workflow": "工作流",
|
||||
@@ -1033,9 +1043,9 @@
|
||||
"Extension": "扩展",
|
||||
"General": "常规",
|
||||
"Graph": "画面",
|
||||
"Group": "组节点",
|
||||
"Group": "组",
|
||||
"Keybinding": "快捷键",
|
||||
"Light": "浅色",
|
||||
"Light": "光照",
|
||||
"Link": "连线",
|
||||
"LinkRelease": "释放链接",
|
||||
"LiteGraph": "画面",
|
||||
|
||||
@@ -11,10 +11,13 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
const zNodeType = z.string()
|
||||
const zQueueIndex = z.number()
|
||||
const zPromptId = z.string()
|
||||
export const resultItemType = z.enum(['input', 'output', 'temp'])
|
||||
export type ResultItemType = z.infer<typeof resultItemType>
|
||||
|
||||
const zResultItem = z.object({
|
||||
filename: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: z.string().optional()
|
||||
type: resultItemType.optional()
|
||||
})
|
||||
export type ResultItem = z.infer<typeof zResultItem>
|
||||
const zOutputs = z
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import { resultItemType } from '@/schemas/apiSchema'
|
||||
|
||||
const zComboOption = z.union([z.string(), z.number()])
|
||||
const zRemoteWidgetConfig = z.object({
|
||||
route: z.string().url().or(z.string().startsWith('/')),
|
||||
@@ -72,7 +74,7 @@ export const zStringInputOptions = zBaseInputOptions.extend({
|
||||
export const zComboInputOptions = zBaseInputOptions.extend({
|
||||
control_after_generate: z.boolean().optional(),
|
||||
image_upload: z.boolean().optional(),
|
||||
image_folder: z.enum(['input', 'output', 'temp']).optional(),
|
||||
image_folder: resultItemType.optional(),
|
||||
allow_batch: z.boolean().optional(),
|
||||
video_upload: z.boolean().optional(),
|
||||
animated_image_upload: z.boolean().optional(),
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import QuickLRU from '@alloc/quick-lru'
|
||||
import type {
|
||||
BaseSearchParamsWithoutQuery,
|
||||
Hit,
|
||||
SearchQuery,
|
||||
SearchResponse
|
||||
} from 'algoliasearch/dist/lite/browser'
|
||||
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
|
||||
import { omit } from 'lodash'
|
||||
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import { paramsToCacheKey } from '@/utils/formatUtil'
|
||||
|
||||
const DEFAULT_MAX_CACHE_SIZE = 64
|
||||
const DEFAULT_MIN_CHARS_FOR_SUGGESTIONS = 2
|
||||
|
||||
type SafeNestedProperty<
|
||||
T,
|
||||
K1 extends keyof T,
|
||||
K2 extends keyof NonNullable<T[K1]>
|
||||
> = T[K1] extends undefined | null ? undefined : NonNullable<T[K1]>[K2]
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
type SearchPacksResult = {
|
||||
nodePacks: Hit<AlgoliaNodePack>[]
|
||||
querySuggestions: Hit<NodesIndexSuggestion>[]
|
||||
}
|
||||
|
||||
export interface AlgoliaNodePack {
|
||||
objectID: RegistryNodePack['id']
|
||||
name: RegistryNodePack['name']
|
||||
publisher_id: SafeNestedProperty<RegistryNodePack, 'publisher', 'id'>
|
||||
description: RegistryNodePack['description']
|
||||
comfy_nodes: string[]
|
||||
total_install: RegistryNodePack['downloads']
|
||||
id: RegistryNodePack['id']
|
||||
create_time: string
|
||||
update_time: SafeNestedProperty<
|
||||
RegistryNodePack,
|
||||
'latest_version',
|
||||
'createdAt'
|
||||
>
|
||||
license: RegistryNodePack['license']
|
||||
repository_url: RegistryNodePack['repository']
|
||||
status: RegistryNodePack['status']
|
||||
latest_version: SafeNestedProperty<
|
||||
RegistryNodePack,
|
||||
'latest_version',
|
||||
'version'
|
||||
>
|
||||
latest_version_status: SafeNestedProperty<
|
||||
RegistryNodePack,
|
||||
'latest_version',
|
||||
'status'
|
||||
>
|
||||
comfy_node_extract_status: SafeNestedProperty<
|
||||
RegistryNodePack,
|
||||
'latest_version',
|
||||
'comfy_node_extract_status'
|
||||
>
|
||||
icon_url: RegistryNodePack['icon']
|
||||
}
|
||||
|
||||
export type SearchAttribute = keyof AlgoliaNodePack
|
||||
|
||||
const RETRIEVE_ATTRIBUTES: SearchAttribute[] = [
|
||||
'comfy_nodes',
|
||||
'name',
|
||||
'description',
|
||||
'latest_version',
|
||||
'status',
|
||||
'publisher_id',
|
||||
'total_install',
|
||||
'create_time',
|
||||
'update_time',
|
||||
'license',
|
||||
'repository_url',
|
||||
'latest_version_status',
|
||||
'comfy_node_extract_status',
|
||||
'id',
|
||||
'icon_url'
|
||||
]
|
||||
|
||||
export interface NodesIndexSuggestion {
|
||||
nb_words: number
|
||||
nodes_index: {
|
||||
exact_nb_hits: number
|
||||
facets: {
|
||||
exact_matches: Record<string, number>
|
||||
analytics: Record<string, any>
|
||||
}
|
||||
}
|
||||
objectID: RegistryNodePack['id']
|
||||
popularity: number
|
||||
query: string
|
||||
}
|
||||
|
||||
type SearchNodePacksParams = BaseSearchParamsWithoutQuery & {
|
||||
pageSize: number
|
||||
pageNumber: number
|
||||
restrictSearchableAttributes: SearchAttribute[]
|
||||
}
|
||||
|
||||
interface AlgoliaSearchServiceOptions {
|
||||
/**
|
||||
* Maximum number of search results to store in the cache.
|
||||
* The cache is automatically cleared when the component is unmounted.
|
||||
* @default 64
|
||||
*/
|
||||
maxCacheSize?: number
|
||||
/**
|
||||
* Minimum number of characters for suggestions. An additional query
|
||||
* will be made to the suggestions/completions index for queries that
|
||||
* are this length or longer.
|
||||
* @default 3
|
||||
*/
|
||||
minCharsForSuggestions?: number
|
||||
}
|
||||
|
||||
export const useAlgoliaSearchService = (
|
||||
options: AlgoliaSearchServiceOptions = {}
|
||||
) => {
|
||||
const {
|
||||
maxCacheSize = DEFAULT_MAX_CACHE_SIZE,
|
||||
minCharsForSuggestions = DEFAULT_MIN_CHARS_FOR_SUGGESTIONS
|
||||
} = options
|
||||
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__, {
|
||||
hosts: [
|
||||
{
|
||||
url: 'search.comfy.org/api/search',
|
||||
accept: 'read',
|
||||
protocol: 'https'
|
||||
}
|
||||
],
|
||||
baseHeaders: {
|
||||
'X-Algolia-Application-Id': __ALGOLIA_APP_ID__,
|
||||
'X-Algolia-API-Key': __ALGOLIA_API_KEY__
|
||||
}
|
||||
})
|
||||
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
|
||||
maxSize: maxCacheSize
|
||||
})
|
||||
|
||||
const toRegistryLatestVersion = (
|
||||
algoliaNode: AlgoliaNodePack
|
||||
): RegistryNodePack['latest_version'] => {
|
||||
return {
|
||||
version: algoliaNode.latest_version,
|
||||
createdAt: algoliaNode.update_time,
|
||||
status: algoliaNode.latest_version_status,
|
||||
comfy_node_extract_status:
|
||||
algoliaNode.comfy_node_extract_status ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
const toRegistryPublisher = (
|
||||
algoliaNode: AlgoliaNodePack
|
||||
): RegistryNodePack['publisher'] => {
|
||||
return {
|
||||
id: algoliaNode.publisher_id,
|
||||
name: algoliaNode.publisher_id
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert from node pack in Algolia format to Comfy Registry format
|
||||
*/
|
||||
function toRegistryPack(algoliaNode: AlgoliaNodePack): RegistryNodePack {
|
||||
return {
|
||||
id: algoliaNode.id ?? algoliaNode.objectID,
|
||||
name: algoliaNode.name,
|
||||
description: algoliaNode.description,
|
||||
repository: algoliaNode.repository_url,
|
||||
license: algoliaNode.license,
|
||||
downloads: algoliaNode.total_install,
|
||||
status: algoliaNode.status,
|
||||
icon: algoliaNode.icon_url,
|
||||
latest_version: toRegistryLatestVersion(algoliaNode),
|
||||
publisher: toRegistryPublisher(algoliaNode),
|
||||
// @ts-expect-error remove when comfy_nodes is added to node (pack) info
|
||||
comfy_nodes: algoliaNode.comfy_nodes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for node packs in Algolia
|
||||
*/
|
||||
const searchPacks = async (
|
||||
query: string,
|
||||
params: SearchNodePacksParams
|
||||
): Promise<SearchPacksResult> => {
|
||||
const { pageSize, pageNumber } = params
|
||||
const rest = omit(params, ['pageSize', 'pageNumber'])
|
||||
|
||||
const requests: SearchQuery[] = [
|
||||
{
|
||||
query,
|
||||
indexName: 'nodes_index',
|
||||
attributesToRetrieve: RETRIEVE_ATTRIBUTES,
|
||||
...rest,
|
||||
hitsPerPage: pageSize,
|
||||
page: pageNumber
|
||||
}
|
||||
]
|
||||
|
||||
const shouldQuerySuggestions = query.length >= minCharsForSuggestions
|
||||
|
||||
// If the query is long enough, also query the suggestions index
|
||||
if (shouldQuerySuggestions) {
|
||||
requests.push({
|
||||
indexName: 'nodes_index_query_suggestions',
|
||||
query
|
||||
})
|
||||
}
|
||||
|
||||
const { results } = await searchClient.search<
|
||||
AlgoliaNodePack | NodesIndexSuggestion
|
||||
>({
|
||||
requests,
|
||||
strategy: 'none'
|
||||
})
|
||||
|
||||
const [nodePacks, querySuggestions = { hits: [] }] = results as [
|
||||
SearchResponse<AlgoliaNodePack>,
|
||||
SearchResponse<NodesIndexSuggestion>
|
||||
]
|
||||
|
||||
return {
|
||||
nodePacks: nodePacks.hits,
|
||||
querySuggestions: querySuggestions.hits
|
||||
}
|
||||
}
|
||||
|
||||
const searchPacksCached = async (
|
||||
query: string,
|
||||
params: SearchNodePacksParams
|
||||
): Promise<SearchPacksResult> => {
|
||||
const cacheKey = paramsToCacheKey({ query, ...params })
|
||||
const cachedResult = searchPacksCache.get(cacheKey)
|
||||
if (cachedResult !== undefined) return cachedResult
|
||||
|
||||
const result = await searchPacks(query, params)
|
||||
searchPacksCache.set(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const clearSearchPacksCache = () => {
|
||||
searchPacksCache.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
searchPacks,
|
||||
searchPacksCached,
|
||||
toRegistryPack,
|
||||
clearSearchPacksCache
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkfl
|
||||
import { t } from '@/i18n'
|
||||
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
|
||||
import { type ShowDialogOptions, useDialogStore } from '@/stores/dialogStore'
|
||||
import { ManagerTab } from '@/types/comfyManagerTypes'
|
||||
|
||||
export type ConfirmationDialogType =
|
||||
| 'default'
|
||||
@@ -129,19 +128,26 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
function showManagerDialog(
|
||||
props: InstanceType<typeof ManagerDialogContent>['$props'] = {
|
||||
initialTab: ManagerTab.All
|
||||
}
|
||||
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
|
||||
) {
|
||||
dialogStore.showDialog({
|
||||
key: 'global-manager',
|
||||
component: ManagerDialogContent,
|
||||
headerComponent: ManagerHeader,
|
||||
dialogComponentProps: {
|
||||
closable: false,
|
||||
closable: true,
|
||||
pt: {
|
||||
header: { class: '!p-0 !m-0' },
|
||||
content: { class: '!px-0 h-[83vh] w-[90vw] overflow-y-hidden' }
|
||||
pcCloseButton: {
|
||||
root: {
|
||||
class:
|
||||
'bg-gray-500 dark-theme:bg-neutral-700 w-9 h-9 p-1.5 rounded-full text-white'
|
||||
}
|
||||
},
|
||||
header: { class: '!py-0 px-6 !m-0 h-[68px]' },
|
||||
content: {
|
||||
class: '!p-0 h-full w-[90vw] max-w-full flex-1 overflow-hidden'
|
||||
},
|
||||
root: { class: 'manager-dialog' }
|
||||
}
|
||||
},
|
||||
props
|
||||
@@ -157,6 +163,7 @@ export const useDialogService = () => {
|
||||
headerComponent: ManagerProgressHeader,
|
||||
footerComponent: ManagerProgressFooter,
|
||||
props: options?.props,
|
||||
priority: 2,
|
||||
dialogComponentProps: {
|
||||
closable: false,
|
||||
modal: false,
|
||||
@@ -397,6 +404,26 @@ export const useDialogService = () => {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleManagerDialog(
|
||||
props?: InstanceType<typeof ManagerDialogContent>['$props']
|
||||
) {
|
||||
if (dialogStore.isDialogOpen('global-manager')) {
|
||||
dialogStore.closeDialog({ key: 'global-manager' })
|
||||
} else {
|
||||
showManagerDialog(props)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleManagerProgressDialog(
|
||||
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
|
||||
) {
|
||||
if (dialogStore.isDialogOpen('global-manager-progress-dialog')) {
|
||||
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
|
||||
} else {
|
||||
showManagerProgressDialog({ props })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showLoadWorkflowWarning,
|
||||
showMissingModelsWarning,
|
||||
@@ -414,6 +441,8 @@ export const useDialogService = () => {
|
||||
showUpdatePasswordDialog,
|
||||
showExtensionDialog,
|
||||
prompt,
|
||||
confirm
|
||||
confirm,
|
||||
toggleManagerDialog,
|
||||
toggleManagerProgressDialog
|
||||
}
|
||||
}
|
||||
|
||||
227
src/services/gateway/registrySearchGateway.ts
Normal file
227
src/services/gateway/registrySearchGateway.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
|
||||
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
|
||||
import type { SearchNodePacksParams } from '@/types/algoliaTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import type {
|
||||
NodePackSearchProvider,
|
||||
SearchPacksResult
|
||||
} from '@/types/searchServiceTypes'
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
|
||||
interface ProviderState {
|
||||
provider: NodePackSearchProvider
|
||||
name: string
|
||||
isHealthy: boolean
|
||||
lastError?: Error
|
||||
lastAttempt?: Date
|
||||
consecutiveFailures: number
|
||||
}
|
||||
|
||||
const CIRCUIT_BREAKER_THRESHOLD = 3 // Number of failures before circuit opens
|
||||
const CIRCUIT_BREAKER_TIMEOUT = 60000 // 1 minute before retry
|
||||
|
||||
/**
|
||||
* API Gateway for registry search providers with circuit breaker pattern.
|
||||
* Acts as a single entry point that routes search requests to appropriate providers
|
||||
* and handles failures gracefully by falling back to alternative providers.
|
||||
*
|
||||
* Implements:
|
||||
* - Gateway pattern: Single entry point for all search requests
|
||||
* - Circuit breaker: Prevents repeated calls to failed services
|
||||
* - Automatic failover: Cascades through providers on failure
|
||||
*/
|
||||
export const useRegistrySearchGateway = (): NodePackSearchProvider => {
|
||||
const providers: ProviderState[] = []
|
||||
let activeProviderIndex = 0
|
||||
|
||||
// Initialize providers in priority order
|
||||
try {
|
||||
providers.push({
|
||||
provider: useAlgoliaSearchProvider(),
|
||||
name: 'Algolia',
|
||||
isHealthy: true,
|
||||
consecutiveFailures: 0
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to initialize Algolia provider:', error)
|
||||
}
|
||||
|
||||
providers.push({
|
||||
provider: useComfyRegistrySearchProvider(),
|
||||
name: 'ComfyRegistry',
|
||||
isHealthy: true,
|
||||
consecutiveFailures: 0
|
||||
})
|
||||
|
||||
// TODO: Add an "offline" provider that operates on a local cache of the registry.
|
||||
|
||||
/**
|
||||
* Check if a provider's circuit breaker should be closed (available to try)
|
||||
*/
|
||||
const isCircuitClosed = (providerState: ProviderState): boolean => {
|
||||
if (providerState.consecutiveFailures < CIRCUIT_BREAKER_THRESHOLD) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if enough time has passed to retry
|
||||
if (providerState.lastAttempt) {
|
||||
const timeSinceLastAttempt =
|
||||
Date.now() - providerState.lastAttempt.getTime()
|
||||
if (timeSinceLastAttempt > CIRCUIT_BREAKER_TIMEOUT) {
|
||||
console.info(
|
||||
`Retrying ${providerState.name} provider after circuit breaker timeout`
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful call to a provider
|
||||
*/
|
||||
const recordSuccess = (providerState: ProviderState) => {
|
||||
providerState.isHealthy = true
|
||||
providerState.consecutiveFailures = 0
|
||||
providerState.lastError = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed call to a provider
|
||||
*/
|
||||
const recordFailure = (providerState: ProviderState, error: Error) => {
|
||||
providerState.consecutiveFailures++
|
||||
providerState.lastError = error
|
||||
providerState.lastAttempt = new Date()
|
||||
|
||||
if (providerState.consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) {
|
||||
providerState.isHealthy = false
|
||||
console.warn(
|
||||
`${providerState.name} provider circuit breaker opened after ${providerState.consecutiveFailures} failures`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active provider based on circuit breaker states
|
||||
*/
|
||||
const getActiveProvider = (): NodePackSearchProvider => {
|
||||
// First, try to use the current active provider if it's healthy
|
||||
const currentProvider = providers[activeProviderIndex]
|
||||
if (currentProvider && isCircuitClosed(currentProvider)) {
|
||||
return currentProvider.provider
|
||||
}
|
||||
|
||||
// Otherwise, find the first healthy provider
|
||||
for (let i = 0; i < providers.length; i++) {
|
||||
const providerState = providers[i]
|
||||
if (isCircuitClosed(providerState)) {
|
||||
activeProviderIndex = i
|
||||
return providerState.provider
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No available search providers')
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the active provider index after a failure.
|
||||
* Move to the next provider if available.
|
||||
*/
|
||||
const updateActiveProviderOnFailure = () => {
|
||||
if (activeProviderIndex < providers.length - 1) {
|
||||
activeProviderIndex++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for node packs.
|
||||
*/
|
||||
const searchPacks = async (
|
||||
query: string,
|
||||
params: SearchNodePacksParams
|
||||
): Promise<SearchPacksResult> => {
|
||||
let lastError: Error | null = null
|
||||
|
||||
// Start with the current active provider
|
||||
for (let attempts = 0; attempts < providers.length; attempts++) {
|
||||
try {
|
||||
const provider = getActiveProvider()
|
||||
const providerState = providers[activeProviderIndex]
|
||||
|
||||
const result = await provider.searchPacks(query, params)
|
||||
recordSuccess(providerState)
|
||||
return result
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
const providerState = providers[activeProviderIndex]
|
||||
recordFailure(providerState, lastError)
|
||||
console.warn(
|
||||
`${providerState.name} search provider failed (${providerState.consecutiveFailures} failures):`,
|
||||
error
|
||||
)
|
||||
|
||||
// Try the next provider
|
||||
updateActiveProviderOnFailure()
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all providers failed
|
||||
throw new Error(
|
||||
`All search providers failed. Last error: ${lastError?.message || 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the search cache for all providers that implement it.
|
||||
*/
|
||||
const clearSearchCache = () => {
|
||||
for (const providerState of providers) {
|
||||
try {
|
||||
providerState.provider.clearSearchCache()
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to clear cache for ${providerState.name} provider:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sort value for a pack.
|
||||
* @example
|
||||
* const pack = {
|
||||
* id: '123',
|
||||
* name: 'Test Pack',
|
||||
* downloads: 100
|
||||
* }
|
||||
* const sortValue = getSortValue(pack, 'downloads')
|
||||
* console.log(sortValue) // 100
|
||||
*/
|
||||
const getSortValue = (
|
||||
pack: RegistryNodePack,
|
||||
sortField: string
|
||||
): string | number => {
|
||||
return getActiveProvider().getSortValue(pack, sortField)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sortable fields for the active provider.
|
||||
* @example
|
||||
* const sortableFields = getSortableFields()
|
||||
* console.log(sortableFields) // ['downloads', 'created', 'updated', 'publisher', 'name']
|
||||
*/
|
||||
const getSortableFields = () => {
|
||||
return getActiveProvider().getSortableFields()
|
||||
}
|
||||
|
||||
return {
|
||||
searchPacks,
|
||||
clearSearchCache,
|
||||
getSortValue,
|
||||
getSortableFields
|
||||
}
|
||||
}
|
||||
247
src/services/providers/algoliaSearchProvider.ts
Normal file
247
src/services/providers/algoliaSearchProvider.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import QuickLRU from '@alloc/quick-lru'
|
||||
import type {
|
||||
SearchQuery,
|
||||
SearchResponse
|
||||
} from 'algoliasearch/dist/lite/browser'
|
||||
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
|
||||
import { memoize, omit } from 'lodash'
|
||||
|
||||
import {
|
||||
MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA,
|
||||
SEARCH_CACHE_MAX_SIZE
|
||||
} from '@/constants/searchConstants'
|
||||
import type {
|
||||
AlgoliaNodePack,
|
||||
NodesIndexSuggestion,
|
||||
SearchAttribute,
|
||||
SearchNodePacksParams
|
||||
} from '@/types/algoliaTypes'
|
||||
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import type {
|
||||
NodePackSearchProvider,
|
||||
SearchPacksResult,
|
||||
SortableField
|
||||
} from '@/types/searchServiceTypes'
|
||||
import { paramsToCacheKey } from '@/utils/formatUtil'
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
|
||||
const RETRIEVE_ATTRIBUTES: SearchAttribute[] = [
|
||||
'comfy_nodes',
|
||||
'name',
|
||||
'description',
|
||||
'latest_version',
|
||||
'status',
|
||||
'publisher_id',
|
||||
'total_install',
|
||||
'create_time',
|
||||
'update_time',
|
||||
'license',
|
||||
'repository_url',
|
||||
'latest_version_status',
|
||||
'comfy_node_extract_status',
|
||||
'id',
|
||||
'icon_url',
|
||||
'github_stars',
|
||||
'supported_os',
|
||||
'supported_comfyui_version',
|
||||
'supported_comfyui_frontend_version',
|
||||
'supported_accelerators',
|
||||
'banner_url'
|
||||
]
|
||||
|
||||
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
|
||||
maxSize: SEARCH_CACHE_MAX_SIZE
|
||||
})
|
||||
|
||||
const toRegistryLatestVersion = (
|
||||
algoliaNode: AlgoliaNodePack
|
||||
): RegistryNodePack['latest_version'] => {
|
||||
return {
|
||||
version: algoliaNode.latest_version,
|
||||
createdAt: algoliaNode.update_time,
|
||||
status: algoliaNode.latest_version_status,
|
||||
comfy_node_extract_status:
|
||||
algoliaNode.comfy_node_extract_status ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
const toRegistryPublisher = (
|
||||
algoliaNode: AlgoliaNodePack
|
||||
): RegistryNodePack['publisher'] => {
|
||||
return {
|
||||
id: algoliaNode.publisher_id,
|
||||
name: algoliaNode.publisher_id
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert from node pack in Algolia format to Comfy Registry format
|
||||
*/
|
||||
const toRegistryPack = memoize(
|
||||
(
|
||||
algoliaNode: AlgoliaNodePack
|
||||
): RegistryNodePack & { comfy_nodes: string[] } => {
|
||||
return {
|
||||
id: algoliaNode.id ?? algoliaNode.objectID,
|
||||
name: algoliaNode.name,
|
||||
description: algoliaNode.description,
|
||||
repository: algoliaNode.repository_url,
|
||||
license: algoliaNode.license,
|
||||
downloads: algoliaNode.total_install,
|
||||
status: algoliaNode.status,
|
||||
icon: algoliaNode.icon_url,
|
||||
latest_version: toRegistryLatestVersion(algoliaNode),
|
||||
publisher: toRegistryPublisher(algoliaNode),
|
||||
created_at: algoliaNode.create_time,
|
||||
category: algoliaNode.category,
|
||||
author: algoliaNode.author,
|
||||
tags: algoliaNode.tags,
|
||||
github_stars: algoliaNode.github_stars,
|
||||
supported_os: algoliaNode.supported_os,
|
||||
supported_comfyui_version: algoliaNode.supported_comfyui_version,
|
||||
supported_comfyui_frontend_version:
|
||||
algoliaNode.supported_comfyui_frontend_version,
|
||||
supported_accelerators: algoliaNode.supported_accelerators,
|
||||
banner_url: algoliaNode.banner_url,
|
||||
comfy_nodes: algoliaNode.comfy_nodes
|
||||
}
|
||||
},
|
||||
(algoliaNode: AlgoliaNodePack) => algoliaNode.id
|
||||
)
|
||||
|
||||
export const useAlgoliaSearchProvider = (): NodePackSearchProvider => {
|
||||
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__)
|
||||
|
||||
/**
|
||||
* Search for node packs in Algolia (internal method)
|
||||
*/
|
||||
const searchPacksInternal = async (
|
||||
query: string,
|
||||
params: SearchNodePacksParams
|
||||
): Promise<SearchPacksResult> => {
|
||||
const { pageSize, pageNumber } = params
|
||||
const rest = omit(params, ['pageSize', 'pageNumber'])
|
||||
|
||||
const requests: SearchQuery[] = [
|
||||
{
|
||||
query,
|
||||
indexName: 'nodes_index',
|
||||
attributesToRetrieve: RETRIEVE_ATTRIBUTES,
|
||||
...rest,
|
||||
hitsPerPage: pageSize,
|
||||
page: pageNumber
|
||||
}
|
||||
]
|
||||
|
||||
const shouldQuerySuggestions =
|
||||
query.length >= MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA
|
||||
|
||||
// If the query is long enough, also query the suggestions index
|
||||
if (shouldQuerySuggestions) {
|
||||
requests.push({
|
||||
indexName: 'nodes_index_query_suggestions',
|
||||
query
|
||||
})
|
||||
}
|
||||
|
||||
const { results } = await searchClient.search<
|
||||
AlgoliaNodePack | NodesIndexSuggestion
|
||||
>({
|
||||
requests,
|
||||
strategy: 'none'
|
||||
})
|
||||
|
||||
const [nodePacks, querySuggestions = { hits: [] }] = results as [
|
||||
SearchResponse<AlgoliaNodePack>,
|
||||
SearchResponse<NodesIndexSuggestion>
|
||||
]
|
||||
|
||||
// Convert Algolia hits to RegistryNodePack format
|
||||
const registryPacks = nodePacks.hits.map(toRegistryPack)
|
||||
|
||||
// Extract query suggestions from search results
|
||||
const suggestions = querySuggestions.hits.map((suggestion) => ({
|
||||
query: suggestion.query,
|
||||
popularity: suggestion.popularity
|
||||
}))
|
||||
|
||||
return {
|
||||
nodePacks: registryPacks,
|
||||
querySuggestions: suggestions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for node packs in Algolia with caching.
|
||||
*/
|
||||
const searchPacks = async (
|
||||
query: string,
|
||||
params: SearchNodePacksParams
|
||||
): Promise<SearchPacksResult> => {
|
||||
const cacheKey = paramsToCacheKey({ query, ...params })
|
||||
const cachedResult = searchPacksCache.get(cacheKey)
|
||||
if (cachedResult !== undefined) return cachedResult
|
||||
|
||||
const result = await searchPacksInternal(query, params)
|
||||
searchPacksCache.set(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const clearSearchCache = () => {
|
||||
searchPacksCache.clear()
|
||||
}
|
||||
|
||||
const getSortValue = (
|
||||
pack: RegistryNodePack,
|
||||
sortField: string
|
||||
): string | number => {
|
||||
// For Algolia, we rely on the default sorting behavior
|
||||
// The results are already sorted by the index configuration
|
||||
// This is mainly used for re-sorting after results are fetched
|
||||
switch (sortField) {
|
||||
case SortableAlgoliaField.Downloads:
|
||||
return pack.downloads ?? 0
|
||||
case SortableAlgoliaField.Created: {
|
||||
const createTime = pack.created_at
|
||||
return createTime ? new Date(createTime).getTime() : 0
|
||||
}
|
||||
case SortableAlgoliaField.Updated:
|
||||
return pack.latest_version?.createdAt
|
||||
? new Date(pack.latest_version.createdAt).getTime()
|
||||
: 0
|
||||
case SortableAlgoliaField.Publisher:
|
||||
return pack.publisher?.name ?? ''
|
||||
case SortableAlgoliaField.Name:
|
||||
return pack.name ?? ''
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const getSortableFields = (): SortableField[] => {
|
||||
return [
|
||||
{
|
||||
id: SortableAlgoliaField.Downloads,
|
||||
label: 'Downloads',
|
||||
direction: 'desc'
|
||||
},
|
||||
{ id: SortableAlgoliaField.Created, label: 'Created', direction: 'desc' },
|
||||
{ id: SortableAlgoliaField.Updated, label: 'Updated', direction: 'desc' },
|
||||
{
|
||||
id: SortableAlgoliaField.Publisher,
|
||||
label: 'Publisher',
|
||||
direction: 'asc'
|
||||
},
|
||||
{ id: SortableAlgoliaField.Name, label: 'Name', direction: 'asc' }
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
searchPacks,
|
||||
clearSearchCache,
|
||||
getSortValue,
|
||||
getSortableFields
|
||||
}
|
||||
}
|
||||
92
src/services/providers/registrySearchProvider.ts
Normal file
92
src/services/providers/registrySearchProvider.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import type { SearchNodePacksParams } from '@/types/algoliaTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import type {
|
||||
NodePackSearchProvider,
|
||||
SearchPacksResult,
|
||||
SortableField
|
||||
} from '@/types/searchServiceTypes'
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
|
||||
/**
|
||||
* Search provider for the Comfy Registry.
|
||||
* Uses public Comfy Registry API.
|
||||
*/
|
||||
export const useComfyRegistrySearchProvider = (): NodePackSearchProvider => {
|
||||
const registryStore = useComfyRegistryStore()
|
||||
|
||||
/**
|
||||
* Search for node packs using the Comfy Registry API.
|
||||
*/
|
||||
const searchPacks = async (
|
||||
query: string,
|
||||
params: SearchNodePacksParams
|
||||
): Promise<SearchPacksResult> => {
|
||||
const { pageSize, pageNumber, restrictSearchableAttributes } = params
|
||||
|
||||
// Determine search mode based on searchable attributes
|
||||
const isNodeSearch = restrictSearchableAttributes?.includes('comfy_nodes')
|
||||
|
||||
const searchParams = {
|
||||
search: isNodeSearch ? undefined : query,
|
||||
comfy_node_search: isNodeSearch ? query : undefined,
|
||||
limit: pageSize,
|
||||
page: pageNumber + 1 // Registry API uses 1-based pagination
|
||||
}
|
||||
|
||||
const searchResult = await registryStore.search.call(searchParams)
|
||||
|
||||
if (!searchResult || !searchResult.nodes) {
|
||||
return {
|
||||
nodePacks: [],
|
||||
querySuggestions: []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodePacks: searchResult.nodes,
|
||||
querySuggestions: [] // Registry doesn't support query suggestions
|
||||
}
|
||||
}
|
||||
|
||||
const clearSearchCache = () => {
|
||||
registryStore.search.clear()
|
||||
}
|
||||
|
||||
const getSortValue = (
|
||||
pack: RegistryNodePack,
|
||||
sortField: string
|
||||
): string | number => {
|
||||
switch (sortField) {
|
||||
case 'downloads':
|
||||
return pack.downloads ?? 0
|
||||
case 'name':
|
||||
return pack.name ?? ''
|
||||
case 'publisher':
|
||||
return pack.publisher?.name ?? ''
|
||||
case 'updated':
|
||||
return pack.latest_version?.createdAt
|
||||
? new Date(pack.latest_version.createdAt).getTime()
|
||||
: 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const getSortableFields = (): SortableField[] => {
|
||||
return [
|
||||
{ id: 'downloads', label: 'Downloads', direction: 'desc' },
|
||||
{ id: 'name', label: 'Name', direction: 'asc' },
|
||||
{ id: 'publisher', label: 'Publisher', direction: 'asc' },
|
||||
{ id: 'updated', label: 'Updated', direction: 'desc' }
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
searchPacks,
|
||||
clearSearchCache,
|
||||
getSortValue,
|
||||
getSortableFields
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
|
||||
isPackInstalled: isInstalledPackId,
|
||||
isPackEnabled: isEnabledPackId,
|
||||
getInstalledPackVersion,
|
||||
refreshInstalledList,
|
||||
|
||||
// Pack actions
|
||||
installPack,
|
||||
|
||||
@@ -40,6 +40,7 @@ interface DialogInstance {
|
||||
contentProps: Record<string, any>
|
||||
footerComponent?: Component
|
||||
dialogComponentProps: DialogComponentProps
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface ShowDialogOptions {
|
||||
@@ -50,6 +51,12 @@ export interface ShowDialogOptions {
|
||||
component: Component
|
||||
props?: Record<string, any>
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
/**
|
||||
* Optional priority for dialog stacking.
|
||||
* A dialog will never be shown above a dialog with a higher priority.
|
||||
* @default 1
|
||||
*/
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export const useDialogStore = defineStore('dialog', () => {
|
||||
@@ -57,13 +64,29 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
|
||||
const genDialogKey = () => `dialog-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
/**
|
||||
* Inserts a dialog into the stack at the correct position based on priority.
|
||||
* Higher priority dialogs are placed before lower priority ones.
|
||||
*/
|
||||
function insertDialogByPriority(dialog: DialogInstance) {
|
||||
const insertIndex = dialogStack.value.findIndex(
|
||||
(d) => d.priority <= dialog.priority
|
||||
)
|
||||
|
||||
dialogStack.value.splice(
|
||||
insertIndex === -1 ? dialogStack.value.length : insertIndex,
|
||||
0,
|
||||
dialog
|
||||
)
|
||||
}
|
||||
|
||||
function riseDialog(options: { key: string }) {
|
||||
const dialogKey = options.key
|
||||
|
||||
const index = dialogStack.value.findIndex((d) => d.key === dialogKey)
|
||||
if (index !== -1) {
|
||||
const dialogs = dialogStack.value.splice(index, 1)
|
||||
dialogStack.value.push(...dialogs)
|
||||
const [dialog] = dialogStack.value.splice(index, 1)
|
||||
insertDialogByPriority(dialog)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,12 +108,13 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
component: Component
|
||||
props?: Record<string, any>
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
priority?: number
|
||||
}) {
|
||||
if (dialogStack.value.length >= 10) {
|
||||
dialogStack.value.shift()
|
||||
}
|
||||
|
||||
const dialog = {
|
||||
const dialog: DialogInstance = {
|
||||
key: options.key,
|
||||
visible: true,
|
||||
title: options.title,
|
||||
@@ -102,6 +126,7 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
: undefined,
|
||||
component: markRaw(options.component),
|
||||
contentProps: { ...options.props },
|
||||
priority: options.priority ?? 1,
|
||||
dialogComponentProps: {
|
||||
maximizable: false,
|
||||
modal: true,
|
||||
@@ -110,6 +135,7 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
dismissableMask: true,
|
||||
...options.dialogComponentProps,
|
||||
maximized: false,
|
||||
// @ts-expect-error TODO: fix this
|
||||
onMaximize: () => {
|
||||
dialog.dialogComponentProps.maximized = true
|
||||
},
|
||||
@@ -128,7 +154,8 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
dialogStack.value.push(dialog)
|
||||
|
||||
insertDialogByPriority(dialog)
|
||||
|
||||
return dialog
|
||||
}
|
||||
@@ -169,11 +196,16 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
return dialog
|
||||
}
|
||||
|
||||
function isDialogOpen(key: string) {
|
||||
return dialogStack.value.some((d) => d.key === key)
|
||||
}
|
||||
|
||||
return {
|
||||
dialogStack,
|
||||
riseDialog,
|
||||
showDialog,
|
||||
closeDialog,
|
||||
showExtensionDialog
|
||||
showExtensionDialog,
|
||||
isDialogOpen
|
||||
}
|
||||
})
|
||||
|
||||
@@ -88,54 +88,30 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
if (!activePrompt.value) return 0
|
||||
const total = totalNodesToExecute.value
|
||||
const done = nodesExecuted.value
|
||||
return done / total
|
||||
return total > 0 ? done / total : 0
|
||||
})
|
||||
|
||||
function bindExecutionEvents() {
|
||||
api.addEventListener(
|
||||
'execution_start',
|
||||
handleExecutionStart as EventListener
|
||||
)
|
||||
api.addEventListener(
|
||||
'execution_cached',
|
||||
handleExecutionCached as EventListener
|
||||
)
|
||||
api.addEventListener('executed', handleExecuted as EventListener)
|
||||
api.addEventListener('executing', handleExecuting as EventListener)
|
||||
api.addEventListener('progress', handleProgress as EventListener)
|
||||
api.addEventListener('status', handleStatus as EventListener)
|
||||
api.addEventListener(
|
||||
'execution_error',
|
||||
handleExecutionError as EventListener
|
||||
)
|
||||
api.addEventListener('execution_start', handleExecutionStart)
|
||||
api.addEventListener('execution_cached', handleExecutionCached)
|
||||
api.addEventListener('executed', handleExecuted)
|
||||
api.addEventListener('executing', handleExecuting)
|
||||
api.addEventListener('progress', handleProgress)
|
||||
api.addEventListener('status', handleStatus)
|
||||
api.addEventListener('execution_error', handleExecutionError)
|
||||
}
|
||||
api.addEventListener('progress_text', handleProgressText as EventListener)
|
||||
api.addEventListener(
|
||||
'display_component',
|
||||
handleDisplayComponent as EventListener
|
||||
)
|
||||
api.addEventListener('progress_text', handleProgressText)
|
||||
api.addEventListener('display_component', handleDisplayComponent)
|
||||
|
||||
function unbindExecutionEvents() {
|
||||
api.removeEventListener(
|
||||
'execution_start',
|
||||
handleExecutionStart as EventListener
|
||||
)
|
||||
api.removeEventListener(
|
||||
'execution_cached',
|
||||
handleExecutionCached as EventListener
|
||||
)
|
||||
api.removeEventListener('executed', handleExecuted as EventListener)
|
||||
api.removeEventListener('executing', handleExecuting as EventListener)
|
||||
api.removeEventListener('progress', handleProgress as EventListener)
|
||||
api.removeEventListener('status', handleStatus as EventListener)
|
||||
api.removeEventListener(
|
||||
'execution_error',
|
||||
handleExecutionError as EventListener
|
||||
)
|
||||
api.removeEventListener(
|
||||
'progress_text',
|
||||
handleProgressText as EventListener
|
||||
)
|
||||
api.removeEventListener('execution_start', handleExecutionStart)
|
||||
api.removeEventListener('execution_cached', handleExecutionCached)
|
||||
api.removeEventListener('executed', handleExecuted)
|
||||
api.removeEventListener('executing', handleExecuting)
|
||||
api.removeEventListener('progress', handleProgress)
|
||||
api.removeEventListener('status', handleStatus)
|
||||
api.removeEventListener('execution_error', handleExecutionError)
|
||||
api.removeEventListener('progress_text', handleProgressText)
|
||||
}
|
||||
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
@@ -184,7 +160,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
clientId.value = api.clientId
|
||||
|
||||
// Once we've received the clientId we no longer need to listen
|
||||
api.removeEventListener('status', handleStatus as EventListener)
|
||||
api.removeEventListener('status', handleStatus)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { ExecutedWsMessage, ResultItem } from '@/schemas/apiSchema'
|
||||
import {
|
||||
ExecutedWsMessage,
|
||||
ResultItem,
|
||||
ResultItemType
|
||||
} from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { parseFilePath } from '@/utils/formatUtil'
|
||||
@@ -9,7 +13,7 @@ import { isVideoNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const createOutputs = (
|
||||
filenames: string[],
|
||||
type: string,
|
||||
type: ResultItemType,
|
||||
isAnimated: boolean
|
||||
): ExecutedWsMessage['output'] => {
|
||||
return {
|
||||
@@ -88,7 +92,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
{
|
||||
folder = 'input',
|
||||
isAnimated = false
|
||||
}: { folder?: string; isAnimated?: boolean } = {}
|
||||
}: { folder?: ResultItemType; isAnimated?: boolean } = {}
|
||||
) {
|
||||
if (!filenames || !node) return
|
||||
|
||||
|
||||
102
src/types/algoliaTypes.ts
Normal file
102
src/types/algoliaTypes.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
BaseSearchParamsWithoutQuery,
|
||||
Hit
|
||||
} from 'algoliasearch/dist/lite/browser'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type SafeNestedProperty<
|
||||
T,
|
||||
K1 extends keyof T,
|
||||
K2 extends keyof NonNullable<T[K1]>
|
||||
> = T[K1] extends undefined | null ? undefined : NonNullable<T[K1]>[K2]
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
|
||||
/**
|
||||
* Result of searching the Algolia index.
|
||||
* Represents the entire result of a search query.
|
||||
*/
|
||||
export type SearchPacksResult = {
|
||||
nodePacks: Hit<AlgoliaNodePack>[]
|
||||
querySuggestions: Hit<NodesIndexSuggestion>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Node pack record after it has been mapped to Algolia index format.
|
||||
* @see https://github.com/Comfy-Org/comfy-api/blob/main/mapper/algolia.go
|
||||
*/
|
||||
export interface AlgoliaNodePack {
|
||||
objectID: RegistryNodePack['id']
|
||||
name: RegistryNodePack['name']
|
||||
publisher_id: SafeNestedProperty<RegistryNodePack, 'publisher', 'id'>
|
||||
description: RegistryNodePack['description']
|
||||
comfy_nodes: string[]
|
||||
total_install: RegistryNodePack['downloads']
|
||||
id: RegistryNodePack['id']
|
||||
create_time: string
|
||||
update_time: SafeNestedProperty<
|
||||
RegistryNodePack,
|
||||
'latest_version',
|
||||
'createdAt'
|
||||
>
|
||||
license: RegistryNodePack['license']
|
||||
repository_url: RegistryNodePack['repository']
|
||||
status: RegistryNodePack['status']
|
||||
latest_version: SafeNestedProperty<
|
||||
RegistryNodePack,
|
||||
'latest_version',
|
||||
'version'
|
||||
>
|
||||
latest_version_status: SafeNestedProperty<
|
||||
RegistryNodePack,
|
||||
'latest_version',
|
||||
'status'
|
||||
>
|
||||
comfy_node_extract_status: SafeNestedProperty<
|
||||
RegistryNodePack,
|
||||
'latest_version',
|
||||
'comfy_node_extract_status'
|
||||
>
|
||||
icon_url: RegistryNodePack['icon']
|
||||
category: RegistryNodePack['category']
|
||||
author: RegistryNodePack['author']
|
||||
tags: RegistryNodePack['tags']
|
||||
github_stars: RegistryNodePack['github_stars']
|
||||
supported_os: RegistryNodePack['supported_os']
|
||||
supported_comfyui_version: RegistryNodePack['supported_comfyui_version']
|
||||
supported_comfyui_frontend_version: RegistryNodePack['supported_comfyui_frontend_version']
|
||||
supported_accelerators: RegistryNodePack['supported_accelerators']
|
||||
banner_url: RegistryNodePack['banner_url']
|
||||
}
|
||||
|
||||
/**
|
||||
* An attribute that can be used to search the Algolia index by.
|
||||
*/
|
||||
export type SearchAttribute = keyof AlgoliaNodePack
|
||||
|
||||
/**
|
||||
* Suggestion for a search query (autocomplete).
|
||||
*/
|
||||
export interface NodesIndexSuggestion {
|
||||
nb_words: number
|
||||
nodes_index: {
|
||||
exact_nb_hits: number
|
||||
facets: {
|
||||
exact_matches: Record<string, number>
|
||||
analytics: Record<string, any>
|
||||
}
|
||||
}
|
||||
objectID: RegistryNodePack['id']
|
||||
popularity: number
|
||||
query: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for searching the Algolia index.
|
||||
*/
|
||||
export type SearchNodePacksParams = BaseSearchParamsWithoutQuery & {
|
||||
pageSize: number
|
||||
pageNumber: number
|
||||
restrictSearchableAttributes?: SearchAttribute[]
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import type { SearchMode } from '@/types/searchServiceTypes'
|
||||
|
||||
type RegistryPack = components['schemas']['Node']
|
||||
type WorkflowNodeProperties = ComfyWorkflowJSON['nodes'][0]['properties']
|
||||
|
||||
export type RegistryPack = components['schemas']['Node']
|
||||
export type MergedNodePack = RegistryPack & AlgoliaNodePack
|
||||
export const isMergedNodePack = (
|
||||
nodePack: RegistryPack | AlgoliaNodePack
|
||||
): nodePack is MergedNodePack => 'comfy_nodes' in nodePack
|
||||
|
||||
export type PackField = keyof RegistryPack | null
|
||||
|
||||
export const IsInstallingKey: InjectionKey<Ref<boolean>> =
|
||||
@@ -32,7 +40,7 @@ export enum SortableAlgoliaField {
|
||||
}
|
||||
|
||||
export interface TabItem {
|
||||
id: string
|
||||
id: ManagerTab
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
@@ -227,3 +235,10 @@ export interface InstallPackParams extends ManagerPackInfo {
|
||||
export interface UpdateAllPacksParams {
|
||||
mode?: ManagerDatabaseSource
|
||||
}
|
||||
|
||||
export interface ManagerState {
|
||||
selectedTabId: ManagerTab
|
||||
searchQuery: string
|
||||
searchMode: SearchMode
|
||||
sortField: string
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
38
src/types/nodeDefAugmentation.ts
Normal file
38
src/types/nodeDefAugmentation.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Frontend augmentations for node definitions.
|
||||
*
|
||||
* This module defines type extensions that augment the backend node definition
|
||||
* types with frontend-specific properties. These augmentations are applied at
|
||||
* runtime and are not part of the backend API contract.
|
||||
*/
|
||||
import type { ComboInputOptions, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
|
||||
/**
|
||||
* Frontend augmentation for image upload combo inputs.
|
||||
* This extends ComboInputOptions with properties injected by the uploadImage extension.
|
||||
*/
|
||||
export interface ImageUploadComboOptions extends ComboInputOptions {
|
||||
/**
|
||||
* Reference to the associated filename combo widget.
|
||||
* Injected by uploadImage.ts to link upload buttons with their combo widgets.
|
||||
*
|
||||
* @remarks This property exists only in the frontend runtime.
|
||||
*/
|
||||
imageInputName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an InputSpec has image upload augmentations.
|
||||
* Narrows from base InputSpec to augmented type.
|
||||
*/
|
||||
export function isImageUploadInput(
|
||||
inputData: InputSpec
|
||||
): inputData is [string, ImageUploadComboOptions] {
|
||||
const options = inputData[1]
|
||||
return (
|
||||
options !== undefined &&
|
||||
typeof options === 'object' &&
|
||||
'imageInputName' in options &&
|
||||
typeof options.imageInputName === 'string'
|
||||
)
|
||||
}
|
||||
49
src/types/searchServiceTypes.ts
Normal file
49
src/types/searchServiceTypes.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { SearchNodePacksParams } from '@/types/algoliaTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
|
||||
/**
|
||||
* Search mode for filtering results
|
||||
*/
|
||||
export type SearchMode = 'nodes' | 'packs'
|
||||
export type QuerySuggestion = {
|
||||
query: string
|
||||
popularity: number
|
||||
}
|
||||
|
||||
export interface SearchPacksResult {
|
||||
nodePacks: RegistryNodePack[]
|
||||
querySuggestions: QuerySuggestion[]
|
||||
}
|
||||
|
||||
export interface SortableField<T = string> {
|
||||
id: T
|
||||
label: string
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface NodePackSearchProvider {
|
||||
/**
|
||||
* Search for node packs
|
||||
*/
|
||||
searchPacks(
|
||||
query: string,
|
||||
params: SearchNodePacksParams
|
||||
): Promise<SearchPacksResult>
|
||||
|
||||
/**
|
||||
* Clear the search cache
|
||||
*/
|
||||
clearSearchCache(): void
|
||||
|
||||
/**
|
||||
* Get the sort value for a pack based on the sort field
|
||||
*/
|
||||
getSortValue(pack: RegistryNodePack, sortField: string): string | number
|
||||
|
||||
/**
|
||||
* Get the list of sortable fields supported by this provider
|
||||
*/
|
||||
getSortableFields(): SortableField[]
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import type { InjectionKey, ModelRef } from 'vue'
|
||||
|
||||
export interface TreeNode extends PrimeVueTreeNode {
|
||||
label: string
|
||||
children?: TreeNode[]
|
||||
children?: this[]
|
||||
}
|
||||
|
||||
export interface TreeExplorerNode<T = any> extends TreeNode {
|
||||
data?: T
|
||||
children?: TreeExplorerNode<T>[]
|
||||
children?: this[]
|
||||
icon?: string
|
||||
/**
|
||||
* Function to override what icon to use for the node.
|
||||
@@ -62,7 +62,7 @@ export interface TreeExplorerNode<T = any> extends TreeNode {
|
||||
}
|
||||
|
||||
export interface RenderedTreeExplorerNode<T = any> extends TreeExplorerNode<T> {
|
||||
children?: RenderedTreeExplorerNode<T>[]
|
||||
children?: this[]
|
||||
icon: string
|
||||
type: 'folder' | 'node'
|
||||
/** Total number of leaves in the subtree */
|
||||
|
||||
@@ -61,8 +61,8 @@ const mergeNumericInputSpec = <T extends IntInputSpec | FloatInputSpec>(
|
||||
}
|
||||
|
||||
return mergeCommonInputSpec(
|
||||
[type, { ...options1, ...mergedOptions }] as unknown as T,
|
||||
[type, { ...options2, ...mergedOptions }] as unknown as T
|
||||
[type, { ...options1, ...mergedOptions }] as T,
|
||||
[type, { ...options2, ...mergedOptions }] as T
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@ const mergeComboInputSpec = <T extends ComboInputSpec | ComboInputSpecV2>(
|
||||
}
|
||||
|
||||
return mergeCommonInputSpec(
|
||||
['COMBO', { ...options1, options: intersection }] as unknown as T,
|
||||
['COMBO', { ...options2, options: intersection }] as unknown as T
|
||||
['COMBO', { ...options1, options: intersection }] as T,
|
||||
['COMBO', { ...options2, options: intersection }] as T
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,9 +107,7 @@ const mergeCommonInputSpec = <T extends InputSpec>(
|
||||
return value1 === value2 || (_.isNil(value1) && _.isNil(value2))
|
||||
})
|
||||
|
||||
return mergeIsValid
|
||||
? ([type, { ...options1, ...options2 }] as unknown as T)
|
||||
: null
|
||||
return mergeIsValid ? ([type, { ...options1, ...options2 }] as T) : null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -116,7 +116,7 @@ export const findNodeByKey = <T extends TreeNode>(
|
||||
return null
|
||||
}
|
||||
for (const child of root.children) {
|
||||
const result = findNodeByKey(child as T, key)
|
||||
const result = findNodeByKey(child, key)
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
@@ -130,11 +130,11 @@ export const findNodeByKey = <T extends TreeNode>(
|
||||
* @returns A deep clone of the node.
|
||||
*/
|
||||
export function cloneTree<T extends TreeNode>(node: T): T {
|
||||
const clone: T = { ...node } as T
|
||||
const clone = { ...node }
|
||||
|
||||
// Clone children recursively
|
||||
if (node.children && node.children.length > 0) {
|
||||
clone.children = node.children.map((child) => cloneTree(child as T))
|
||||
clone.children = node.children.map((child) => cloneTree(child))
|
||||
}
|
||||
|
||||
return clone
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Message from 'primevue/message'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
// Mock the stores
|
||||
vi.mock('@/stores/systemStatsStore', () => ({
|
||||
useSystemStatsStore: vi.fn()
|
||||
}))
|
||||
|
||||
const createMockNode = (type: string, version?: string): LGraphNode =>
|
||||
// @ts-expect-error - Creating a partial mock of LGraphNode for testing purposes.
|
||||
// We only need specific properties for our tests, not the full LGraphNode interface.
|
||||
({
|
||||
type,
|
||||
properties: { cnr_id: 'comfy-core', ver: version },
|
||||
id: 1,
|
||||
title: type,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
graph: null,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
describe('MissingCoreNodesMessage', () => {
|
||||
const mockSystemStatsStore = {
|
||||
systemStats: null as { system?: { comfyui_version?: string } } | null,
|
||||
fetchSystemStats: vi.fn()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset the mock store state
|
||||
mockSystemStatsStore.systemStats = null
|
||||
mockSystemStatsStore.fetchSystemStats = vi.fn()
|
||||
// @ts-expect-error - Mocking the return value of useSystemStatsStore for testing.
|
||||
// The actual store has more properties, but we only need these for our tests.
|
||||
useSystemStatsStore.mockReturnValue(mockSystemStatsStore)
|
||||
})
|
||||
|
||||
const mountComponent = (props = {}) => {
|
||||
return mount(MissingCoreNodesMessage, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { Message },
|
||||
mocks: {
|
||||
$t: (key: string, params?: { version?: string }) => {
|
||||
const translations: Record<string, string> = {
|
||||
'loadWorkflowWarning.outdatedVersion': `Some nodes require a newer version of ComfyUI (current: ${params?.version}). Please update to use all nodes.`,
|
||||
'loadWorkflowWarning.outdatedVersionGeneric':
|
||||
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.',
|
||||
'loadWorkflowWarning.coreNodesFromVersion': `Requires ComfyUI ${params?.version}:`
|
||||
}
|
||||
return translations[key] || key
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
missingCoreNodes: {},
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('does not render when there are no missing core nodes', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.findComponent(Message).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders message when there are missing core nodes', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(Message).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('fetches and displays current ComfyUI version', async () => {
|
||||
// Start with no systemStats to trigger fetch
|
||||
mockSystemStatsStore.fetchSystemStats.mockImplementation(() => {
|
||||
// Simulate the fetch setting the systemStats
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: { comfyui_version: '1.0.0' }
|
||||
}
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
|
||||
// Wait for all async operations
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
|
||||
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain(
|
||||
'Some nodes require a newer version of ComfyUI (current: 1.0.0)'
|
||||
)
|
||||
})
|
||||
|
||||
it('displays generic message when version is unavailable', async () => {
|
||||
// Mock fetchSystemStats to resolve without setting systemStats
|
||||
mockSystemStatsStore.fetchSystemStats.mockResolvedValue(undefined)
|
||||
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [createMockNode('TestNode', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
|
||||
// Wait for the async operations to complete
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.'
|
||||
)
|
||||
})
|
||||
|
||||
it('groups nodes by version and displays them', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [
|
||||
createMockNode('NodeA', '1.2.0'),
|
||||
createMockNode('NodeB', '1.2.0')
|
||||
],
|
||||
'1.3.0': [createMockNode('NodeC', '1.3.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('Requires ComfyUI 1.3.0:')
|
||||
expect(text).toContain('NodeC')
|
||||
expect(text).toContain('Requires ComfyUI 1.2.0:')
|
||||
expect(text).toContain('NodeA, NodeB')
|
||||
})
|
||||
|
||||
it('sorts versions in descending order', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.1.0': [createMockNode('Node1', '1.1.0')],
|
||||
'1.3.0': [createMockNode('Node3', '1.3.0')],
|
||||
'1.2.0': [createMockNode('Node2', '1.2.0')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
const version13Index = text.indexOf('1.3.0')
|
||||
const version12Index = text.indexOf('1.2.0')
|
||||
const version11Index = text.indexOf('1.1.0')
|
||||
|
||||
expect(version13Index).toBeLessThan(version12Index)
|
||||
expect(version12Index).toBeLessThan(version11Index)
|
||||
})
|
||||
|
||||
it('removes duplicate node names within the same version', async () => {
|
||||
const missingCoreNodes = {
|
||||
'1.2.0': [
|
||||
createMockNode('DuplicateNode', '1.2.0'),
|
||||
createMockNode('DuplicateNode', '1.2.0'),
|
||||
createMockNode('UniqueNode', '1.2.0')
|
||||
]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
const text = wrapper.text()
|
||||
// Should only appear once in the sorted list
|
||||
expect(text).toContain('DuplicateNode, UniqueNode')
|
||||
// Count occurrences of 'DuplicateNode' - should be only 1
|
||||
const matches = text.match(/DuplicateNode/g) || []
|
||||
expect(matches.length).toBe(1)
|
||||
})
|
||||
|
||||
it('handles nodes with missing version info', async () => {
|
||||
const missingCoreNodes = {
|
||||
'': [createMockNode('NoVersionNode')]
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({ missingCoreNodes })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Requires ComfyUI unknown:')
|
||||
expect(wrapper.text()).toContain('NoVersionNode')
|
||||
})
|
||||
})
|
||||
385
tests-ui/tests/composables/useMissingNodes.test.ts
Normal file
385
tests-ui/tests/composables/useMissingNodes.test.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
// Mock Vue's onMounted to execute immediately for testing
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue')>('vue')
|
||||
return {
|
||||
...actual,
|
||||
onMounted: (cb: () => void) => cb()
|
||||
}
|
||||
})
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('@/composables/nodePack/useWorkflowPacks', () => ({
|
||||
useWorkflowPacks: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
nodes: []
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockUseWorkflowPacks = vi.mocked(useWorkflowPacks)
|
||||
const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
|
||||
const mockUseNodeDefStore = vi.mocked(useNodeDefStore)
|
||||
|
||||
describe('useMissingNodes', () => {
|
||||
const mockWorkflowPacks = [
|
||||
{
|
||||
id: 'pack-1',
|
||||
name: 'Test Pack 1',
|
||||
latest_version: { version: '1.0.0' }
|
||||
},
|
||||
{
|
||||
id: 'pack-2',
|
||||
name: 'Test Pack 2',
|
||||
latest_version: { version: '2.0.0' }
|
||||
},
|
||||
{
|
||||
id: 'pack-3',
|
||||
name: 'Installed Pack',
|
||||
latest_version: { version: '1.5.0' }
|
||||
}
|
||||
]
|
||||
|
||||
const mockStartFetchWorkflowPacks = vi.fn()
|
||||
const mockIsPackInstalled = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default setup: pack-3 is installed, others are not
|
||||
mockIsPackInstalled.mockImplementation((id: string) => id === 'pack-3')
|
||||
|
||||
// @ts-expect-error - Mocking partial ComfyManagerStore for testing.
|
||||
// We only need isPackInstalled method for these tests.
|
||||
mockUseComfyManagerStore.mockReturnValue({
|
||||
isPackInstalled: mockIsPackInstalled
|
||||
})
|
||||
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(false),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
// Reset node def store mock
|
||||
// @ts-expect-error - Mocking partial NodeDefStore for testing.
|
||||
// We only need nodeDefsByName for these tests.
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {}
|
||||
})
|
||||
|
||||
// Reset app.graph.nodes
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = []
|
||||
})
|
||||
|
||||
describe('core filtering logic', () => {
|
||||
it('filters out installed packs correctly', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
// Should only include packs that are not installed (pack-1, pack-2)
|
||||
expect(missingNodePacks.value).toHaveLength(2)
|
||||
expect(missingNodePacks.value[0].id).toBe('pack-1')
|
||||
expect(missingNodePacks.value[1].id).toBe('pack-2')
|
||||
expect(
|
||||
missingNodePacks.value.find((pack) => pack.id === 'pack-3')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns empty array when all packs are installed', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
// Mock all packs as installed
|
||||
mockIsPackInstalled.mockReturnValue(true)
|
||||
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
expect(missingNodePacks.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns all packs when none are installed', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
// Mock no packs as installed
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
expect(missingNodePacks.value).toHaveLength(3)
|
||||
expect(missingNodePacks.value).toEqual(mockWorkflowPacks)
|
||||
})
|
||||
|
||||
it('returns empty array when no workflow packs exist', () => {
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
expect(missingNodePacks.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('automatic data fetching', () => {
|
||||
it('fetches workflow packs automatically when none exist', async () => {
|
||||
useMissingNodes()
|
||||
|
||||
expect(mockStartFetchWorkflowPacks).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not fetch when packs already exist', async () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
useMissingNodes()
|
||||
|
||||
expect(mockStartFetchWorkflowPacks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fetch when already loading', async () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
isLoading: ref(true),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(false),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
useMissingNodes()
|
||||
|
||||
expect(mockStartFetchWorkflowPacks).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('state management', () => {
|
||||
it('exposes loading state from useWorkflowPacks', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
isLoading: ref(true),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(false),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { isLoading } = useMissingNodes()
|
||||
|
||||
expect(isLoading.value).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes error state from useWorkflowPacks', () => {
|
||||
const testError = 'Failed to fetch workflow packs'
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(testError),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(false),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { error } = useMissingNodes()
|
||||
|
||||
expect(error.value).toBe(testError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reactivity', () => {
|
||||
it('updates when workflow packs change', async () => {
|
||||
const workflowPacksRef = ref([])
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: workflowPacksRef,
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { missingNodePacks } = useMissingNodes()
|
||||
|
||||
// Initially empty
|
||||
expect(missingNodePacks.value).toEqual([])
|
||||
|
||||
// Update workflow packs
|
||||
// @ts-expect-error - mockWorkflowPacks is a simplified version without full WorkflowPack interface.
|
||||
workflowPacksRef.value = mockWorkflowPacks
|
||||
await nextTick()
|
||||
|
||||
// Should update missing packs (2 missing since pack-3 is installed)
|
||||
expect(missingNodePacks.value).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing core nodes detection', () => {
|
||||
const createMockNode = (
|
||||
type: string,
|
||||
packId?: string,
|
||||
version?: string
|
||||
): LGraphNode =>
|
||||
// @ts-expect-error - Creating a partial mock of LGraphNode for testing.
|
||||
// We only need specific properties for our tests, not the full LGraphNode interface.
|
||||
({
|
||||
type,
|
||||
properties: { cnr_id: packId, ver: version },
|
||||
id: 1,
|
||||
title: type,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
graph: null,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
it('identifies missing core nodes not in nodeDefStore', () => {
|
||||
const coreNode1 = createMockNode('CoreNode1', 'comfy-core', '1.2.0')
|
||||
const coreNode2 = createMockNode('CoreNode2', 'comfy-core', '1.2.0')
|
||||
const registeredNode = createMockNode(
|
||||
'RegisteredNode',
|
||||
'comfy-core',
|
||||
'1.0.0'
|
||||
)
|
||||
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = [coreNode1, coreNode2, registeredNode]
|
||||
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {
|
||||
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
|
||||
// Only including required properties for our test assertions.
|
||||
RegisteredNode: { name: 'RegisteredNode' }
|
||||
}
|
||||
})
|
||||
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
expect(Object.keys(missingCoreNodes.value)).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['1.2.0']).toHaveLength(2)
|
||||
expect(missingCoreNodes.value['1.2.0'][0].type).toBe('CoreNode1')
|
||||
expect(missingCoreNodes.value['1.2.0'][1].type).toBe('CoreNode2')
|
||||
})
|
||||
|
||||
it('groups missing core nodes by version', () => {
|
||||
const node120 = createMockNode('Node120', 'comfy-core', '1.2.0')
|
||||
const node130 = createMockNode('Node130', 'comfy-core', '1.3.0')
|
||||
const nodeNoVer = createMockNode('NodeNoVer', 'comfy-core')
|
||||
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = [node120, node130, nodeNoVer]
|
||||
|
||||
// @ts-expect-error - Mocking partial NodeDefStore for testing.
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {}
|
||||
})
|
||||
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
expect(Object.keys(missingCoreNodes.value)).toHaveLength(3)
|
||||
expect(missingCoreNodes.value['1.2.0']).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['1.3.0']).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['']).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores non-core nodes', () => {
|
||||
const coreNode = createMockNode('CoreNode', 'comfy-core', '1.2.0')
|
||||
const customNode = createMockNode('CustomNode', 'custom-pack', '1.0.0')
|
||||
const noPackNode = createMockNode('NoPackNode')
|
||||
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = [coreNode, customNode, noPackNode]
|
||||
|
||||
// @ts-expect-error - Mocking partial NodeDefStore for testing.
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {}
|
||||
})
|
||||
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
expect(Object.keys(missingCoreNodes.value)).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['1.2.0']).toHaveLength(1)
|
||||
expect(missingCoreNodes.value['1.2.0'][0].type).toBe('CoreNode')
|
||||
})
|
||||
|
||||
it('returns empty object when no core nodes are missing', () => {
|
||||
const registeredNode1 = createMockNode(
|
||||
'RegisteredNode1',
|
||||
'comfy-core',
|
||||
'1.0.0'
|
||||
)
|
||||
const registeredNode2 = createMockNode(
|
||||
'RegisteredNode2',
|
||||
'comfy-core',
|
||||
'1.1.0'
|
||||
)
|
||||
|
||||
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
|
||||
app.graph.nodes = [registeredNode1, registeredNode2]
|
||||
|
||||
mockUseNodeDefStore.mockReturnValue({
|
||||
nodeDefsByName: {
|
||||
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
|
||||
// Only including required properties for our test assertions.
|
||||
RegisteredNode1: { name: 'RegisteredNode1' },
|
||||
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
|
||||
RegisteredNode2: { name: 'RegisteredNode2' }
|
||||
}
|
||||
})
|
||||
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
expect(Object.keys(missingCoreNodes.value)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,22 @@ vi.mock('@/stores/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/functional/useChainCallback', () => ({
|
||||
useChainCallback: vi.fn((original, ...callbacks) => {
|
||||
return function (this: any, ...args: any[]) {
|
||||
original?.apply(this, args)
|
||||
callbacks.forEach((cb: any) => cb.apply(this, args))
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
|
||||
const DEFAULT_VALUE = 'Loading...'
|
||||
|
||||
@@ -40,7 +56,9 @@ function createMockConfig(overrides = {}): RemoteWidgetConfig {
|
||||
const createMockOptions = (inputOverrides = {}) => ({
|
||||
remoteConfig: createMockConfig(inputOverrides),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: {} as any,
|
||||
node: {
|
||||
addWidget: vi.fn()
|
||||
} as any,
|
||||
widget: {} as any
|
||||
})
|
||||
|
||||
@@ -499,4 +517,168 @@ describe('useRemoteWidget', () => {
|
||||
expect(data2).toEqual(DEFAULT_VALUE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auto-refresh on task completion', () => {
|
||||
it('should add auto-refresh toggle widget', () => {
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {
|
||||
refresh: vi.fn()
|
||||
}
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget as any
|
||||
})
|
||||
|
||||
// Should add auto-refresh toggle widget
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'toggle',
|
||||
'Auto-refresh after generation',
|
||||
false,
|
||||
expect.any(Function),
|
||||
{
|
||||
serialize: false
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should register event listener when enabled', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {
|
||||
refresh: vi.fn()
|
||||
}
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget as any
|
||||
})
|
||||
|
||||
// Event listener should be registered immediately
|
||||
expect(api.addEventListener).toHaveBeenCalledWith(
|
||||
'execution_success',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('should refresh widget when workflow completes successfully', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
// Capture the event handler
|
||||
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {} as any
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget
|
||||
})
|
||||
|
||||
// Spy on the refresh function that was added by useRemoteWidget
|
||||
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
|
||||
|
||||
// Get the toggle callback and enable auto-refresh
|
||||
const toggleCallback = mockNode.addWidget.mock.calls.find(
|
||||
(call) => call[0] === 'toggle'
|
||||
)?.[3]
|
||||
toggleCallback?.(true)
|
||||
|
||||
// Simulate workflow completion
|
||||
executionSuccessHandler?.()
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not refresh when toggle is disabled', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
// Capture the event handler
|
||||
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: []
|
||||
}
|
||||
const mockWidget = {} as any
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget
|
||||
})
|
||||
|
||||
// Spy on the refresh function that was added by useRemoteWidget
|
||||
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
|
||||
|
||||
// Toggle is disabled by default
|
||||
// Simulate workflow completion
|
||||
executionSuccessHandler?.()
|
||||
|
||||
expect(refreshSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should cleanup event listener on node removal', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
let executionSuccessHandler: (() => void) | undefined
|
||||
|
||||
// Capture the event handler
|
||||
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
|
||||
if (event === 'execution_success') {
|
||||
executionSuccessHandler = handler as () => void
|
||||
}
|
||||
})
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn(),
|
||||
widgets: [],
|
||||
onRemoved: undefined as any
|
||||
}
|
||||
const mockWidget = {
|
||||
refresh: vi.fn()
|
||||
}
|
||||
|
||||
useRemoteWidget({
|
||||
remoteConfig: createMockConfig(),
|
||||
defaultValue: DEFAULT_VALUE,
|
||||
node: mockNode as any,
|
||||
widget: mockWidget as any
|
||||
})
|
||||
|
||||
// Simulate node removal
|
||||
mockNode.onRemoved?.()
|
||||
|
||||
expect(api.removeEventListener).toHaveBeenCalledWith(
|
||||
'execution_success',
|
||||
executionSuccessHandler
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
374
tests-ui/tests/services/algoliaSearchProvider.test.ts
Normal file
374
tests-ui/tests/services/algoliaSearchProvider.test.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
|
||||
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
|
||||
|
||||
// Mock global Algolia constants
|
||||
|
||||
;(global as any).__ALGOLIA_APP_ID__ = 'test-app-id'
|
||||
;(global as any).__ALGOLIA_API_KEY__ = 'test-api-key'
|
||||
|
||||
// Mock algoliasearch
|
||||
vi.mock('algoliasearch/dist/lite/builds/browser', () => ({
|
||||
liteClient: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useAlgoliaSearchProvider', () => {
|
||||
let mockSearchClient: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create mock search client
|
||||
mockSearchClient = {
|
||||
search: vi.fn()
|
||||
}
|
||||
|
||||
vi.mocked(algoliasearch).mockReturnValue(mockSearchClient)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clear the module-level cache between tests
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
provider.clearSearchCache()
|
||||
})
|
||||
|
||||
describe('searchPacks', () => {
|
||||
it('should search for packs and convert results', async () => {
|
||||
const mockAlgoliaResults = {
|
||||
results: [
|
||||
{
|
||||
hits: [
|
||||
{
|
||||
objectID: 'algolia-1',
|
||||
id: 'pack-1',
|
||||
name: 'Test Pack',
|
||||
description: 'A test pack',
|
||||
publisher_id: 'publisher-1',
|
||||
total_install: 500,
|
||||
create_time: '2024-01-01T00:00:00Z',
|
||||
update_time: '2024-01-15T00:00:00Z',
|
||||
repository_url: 'https://github.com/test/pack',
|
||||
license: 'MIT',
|
||||
status: 'active',
|
||||
latest_version: '1.0.0',
|
||||
latest_version_status: 'published',
|
||||
icon_url: 'https://example.com/icon.png',
|
||||
comfy_nodes: ['LoadImage', 'SaveImage']
|
||||
}
|
||||
]
|
||||
},
|
||||
{ hits: [] } // Query suggestions
|
||||
]
|
||||
}
|
||||
|
||||
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
|
||||
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
const result = await provider.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
expect(mockSearchClient.search).toHaveBeenCalledWith({
|
||||
requests: [
|
||||
{
|
||||
query: 'test',
|
||||
indexName: 'nodes_index',
|
||||
attributesToRetrieve: expect.any(Array),
|
||||
hitsPerPage: 10,
|
||||
page: 0
|
||||
},
|
||||
{
|
||||
query: 'test',
|
||||
indexName: 'nodes_index_query_suggestions'
|
||||
}
|
||||
],
|
||||
strategy: 'none'
|
||||
})
|
||||
|
||||
expect(result.nodePacks).toHaveLength(1)
|
||||
expect(result.nodePacks[0]).toEqual({
|
||||
id: 'pack-1',
|
||||
name: 'Test Pack',
|
||||
description: 'A test pack',
|
||||
repository: 'https://github.com/test/pack',
|
||||
license: 'MIT',
|
||||
downloads: 500,
|
||||
status: 'active',
|
||||
icon: 'https://example.com/icon.png',
|
||||
latest_version: {
|
||||
version: '1.0.0',
|
||||
createdAt: '2024-01-15T00:00:00Z',
|
||||
status: 'published',
|
||||
comfy_node_extract_status: undefined
|
||||
},
|
||||
publisher: {
|
||||
id: 'publisher-1',
|
||||
name: 'publisher-1'
|
||||
},
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
comfy_nodes: ['LoadImage', 'SaveImage'],
|
||||
category: undefined,
|
||||
author: undefined,
|
||||
tags: undefined,
|
||||
github_stars: undefined,
|
||||
supported_os: undefined,
|
||||
supported_comfyui_version: undefined,
|
||||
supported_comfyui_frontend_version: undefined,
|
||||
supported_accelerators: undefined,
|
||||
banner_url: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('should include query suggestions when query is long enough', async () => {
|
||||
const mockAlgoliaResults = {
|
||||
results: [
|
||||
{ hits: [] }, // Main results
|
||||
{
|
||||
hits: [
|
||||
{ query: 'test query', popularity: 10 },
|
||||
{ query: 'test pack', popularity: 5 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
|
||||
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
const result = await provider.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
// Should make 2 requests (main + suggestions)
|
||||
expect(mockSearchClient.search).toHaveBeenCalledWith({
|
||||
requests: [
|
||||
expect.objectContaining({ indexName: 'nodes_index' }),
|
||||
expect.objectContaining({
|
||||
indexName: 'nodes_index_query_suggestions'
|
||||
})
|
||||
],
|
||||
strategy: 'none'
|
||||
})
|
||||
|
||||
expect(result.querySuggestions).toEqual([
|
||||
{ query: 'test query', popularity: 10 },
|
||||
{ query: 'test pack', popularity: 5 }
|
||||
])
|
||||
})
|
||||
|
||||
it('should not query suggestions for short queries', async () => {
|
||||
mockSearchClient.search.mockResolvedValue({
|
||||
results: [{ hits: [] }]
|
||||
})
|
||||
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
await provider.searchPacks('a', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
// Should only make 1 request (no suggestions)
|
||||
expect(mockSearchClient.search).toHaveBeenCalledWith({
|
||||
requests: [expect.objectContaining({ indexName: 'nodes_index' })],
|
||||
strategy: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('should cache search results', async () => {
|
||||
mockSearchClient.search.mockResolvedValue({
|
||||
results: [{ hits: [] }, { hits: [] }]
|
||||
})
|
||||
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
const params = { pageSize: 10, pageNumber: 0 }
|
||||
|
||||
// First call
|
||||
await provider.searchPacks('test', params)
|
||||
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second call with same params should use cache
|
||||
await provider.searchPacks('test', params)
|
||||
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Different params should make new request
|
||||
await provider.searchPacks('test', { ...params, pageNumber: 1 })
|
||||
expect(mockSearchClient.search).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should handle missing objectID by using id field', async () => {
|
||||
const mockAlgoliaResults = {
|
||||
results: [
|
||||
{
|
||||
hits: [
|
||||
{
|
||||
id: 'pack-id-only',
|
||||
name: 'Pack without objectID',
|
||||
// ... other required fields
|
||||
publisher_id: 'pub',
|
||||
total_install: 0,
|
||||
comfy_nodes: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{ hits: [] }
|
||||
]
|
||||
}
|
||||
|
||||
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
|
||||
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
const result = await provider.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
expect(result.nodePacks[0].id).toBe('pack-id-only')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearSearchCache', () => {
|
||||
it('should clear the cache', async () => {
|
||||
mockSearchClient.search.mockResolvedValue({
|
||||
results: [{ hits: [] }, { hits: [] }]
|
||||
})
|
||||
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
const params = { pageSize: 10, pageNumber: 0 }
|
||||
|
||||
// Populate cache
|
||||
await provider.searchPacks('test', params)
|
||||
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Clear cache
|
||||
provider.clearSearchCache()
|
||||
|
||||
// Same search should hit API again
|
||||
await provider.searchPacks('test', params)
|
||||
expect(mockSearchClient.search).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortValue', () => {
|
||||
const testPack = {
|
||||
id: '1',
|
||||
name: 'Test Pack',
|
||||
downloads: 100,
|
||||
publisher: { id: 'pub1', name: 'Publisher One' },
|
||||
latest_version: {
|
||||
version: '1.0.0',
|
||||
createdAt: '2024-01-15T10:00:00Z'
|
||||
},
|
||||
created_at: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
|
||||
it('should return correct values for each sort field', () => {
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
|
||||
expect(
|
||||
provider.getSortValue(testPack, SortableAlgoliaField.Downloads)
|
||||
).toBe(100)
|
||||
expect(provider.getSortValue(testPack, SortableAlgoliaField.Name)).toBe(
|
||||
'Test Pack'
|
||||
)
|
||||
expect(
|
||||
provider.getSortValue(testPack, SortableAlgoliaField.Publisher)
|
||||
).toBe('Publisher One')
|
||||
|
||||
const createdTimestamp = new Date('2024-01-01T10:00:00Z').getTime()
|
||||
expect(
|
||||
provider.getSortValue(testPack as any, SortableAlgoliaField.Created)
|
||||
).toBe(createdTimestamp)
|
||||
|
||||
const updatedTimestamp = new Date('2024-01-15T10:00:00Z').getTime()
|
||||
expect(
|
||||
provider.getSortValue(testPack, SortableAlgoliaField.Updated)
|
||||
).toBe(updatedTimestamp)
|
||||
})
|
||||
|
||||
it('should handle missing values', () => {
|
||||
const incompletePack = { id: '1', name: 'Incomplete' }
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
|
||||
expect(
|
||||
provider.getSortValue(incompletePack, SortableAlgoliaField.Downloads)
|
||||
).toBe(0)
|
||||
expect(
|
||||
provider.getSortValue(incompletePack, SortableAlgoliaField.Publisher)
|
||||
).toBe('')
|
||||
expect(
|
||||
provider.getSortValue(
|
||||
incompletePack as any,
|
||||
SortableAlgoliaField.Created
|
||||
)
|
||||
).toBe(0)
|
||||
expect(
|
||||
provider.getSortValue(incompletePack, SortableAlgoliaField.Updated)
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortableFields', () => {
|
||||
it('should return all Algolia sort fields', () => {
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
const fields = provider.getSortableFields()
|
||||
|
||||
expect(fields).toEqual([
|
||||
{
|
||||
id: SortableAlgoliaField.Downloads,
|
||||
label: 'Downloads',
|
||||
direction: 'desc'
|
||||
},
|
||||
{
|
||||
id: SortableAlgoliaField.Created,
|
||||
label: 'Created',
|
||||
direction: 'desc'
|
||||
},
|
||||
{
|
||||
id: SortableAlgoliaField.Updated,
|
||||
label: 'Updated',
|
||||
direction: 'desc'
|
||||
},
|
||||
{
|
||||
id: SortableAlgoliaField.Publisher,
|
||||
label: 'Publisher',
|
||||
direction: 'asc'
|
||||
},
|
||||
{ id: SortableAlgoliaField.Name, label: 'Name', direction: 'asc' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('memoization', () => {
|
||||
it('should memoize toRegistryPack conversions', async () => {
|
||||
const mockHit = {
|
||||
objectID: 'algolia-1',
|
||||
id: 'pack-1',
|
||||
name: 'Test Pack',
|
||||
publisher_id: 'pub1',
|
||||
total_install: 100,
|
||||
comfy_nodes: []
|
||||
}
|
||||
|
||||
mockSearchClient.search.mockResolvedValue({
|
||||
results: [
|
||||
{ hits: [mockHit, mockHit, mockHit] }, // Same object 3 times
|
||||
{ hits: [] }
|
||||
]
|
||||
})
|
||||
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
const result = await provider.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
// All 3 results should be the same object reference due to memoization
|
||||
expect(result.nodePacks[0]).toBe(result.nodePacks[1])
|
||||
expect(result.nodePacks[1]).toBe(result.nodePacks[2])
|
||||
})
|
||||
})
|
||||
})
|
||||
445
tests-ui/tests/services/registrySearchGateway.test.ts
Normal file
445
tests-ui/tests/services/registrySearchGateway.test.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
|
||||
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
|
||||
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
|
||||
|
||||
// Mock the provider modules to control their behavior
|
||||
vi.mock('@/services/providers/algoliaSearchProvider')
|
||||
vi.mock('@/services/providers/registrySearchProvider')
|
||||
|
||||
describe('useRegistrySearchGateway', () => {
|
||||
let consoleWarnSpy: any
|
||||
let consoleInfoSpy: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy.mockRestore()
|
||||
consoleInfoSpy.mockRestore()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Provider initialization', () => {
|
||||
it('should initialize with both providers', () => {
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
expect(useAlgoliaSearchProvider).toHaveBeenCalled()
|
||||
expect(useComfyRegistrySearchProvider).toHaveBeenCalled()
|
||||
expect(gateway).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle Algolia initialization failure gracefully', () => {
|
||||
vi.mocked(useAlgoliaSearchProvider).mockImplementation(() => {
|
||||
throw new Error('Algolia init failed')
|
||||
})
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
// Gateway should still work with just the Registry provider
|
||||
expect(gateway).toBeDefined()
|
||||
expect(typeof gateway.searchPacks).toBe('function')
|
||||
|
||||
// Verify it can still search using the fallback provider
|
||||
return expect(
|
||||
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search functionality', () => {
|
||||
it('should use Algolia provider by default and fallback on failure', async () => {
|
||||
const algoliaResult = {
|
||||
nodePacks: [{ id: 'algolia-1', name: 'Algolia Pack' }],
|
||||
querySuggestions: []
|
||||
}
|
||||
const registryResult = {
|
||||
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
|
||||
querySuggestions: []
|
||||
}
|
||||
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(algoliaResult)
|
||||
.mockRejectedValueOnce(new Error('Algolia failed')),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn().mockResolvedValue(registryResult),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
// First call should use Algolia
|
||||
const result1 = await gateway.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
expect(result1.nodePacks[0].name).toBe('Algolia Pack')
|
||||
|
||||
// Second call should fallback to Registry when Algolia fails
|
||||
const result2 = await gateway.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
expect(result2.nodePacks[0].name).toBe('Registry Pack')
|
||||
})
|
||||
|
||||
it('should throw error when all providers fail', async () => {
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn().mockRejectedValue(new Error('Registry failed')),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
await expect(
|
||||
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
|
||||
).rejects.toThrow('All search providers failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Circuit breaker functionality', () => {
|
||||
it('should switch to fallback provider after failure and log warnings', async () => {
|
||||
const registryResult = {
|
||||
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
|
||||
querySuggestions: []
|
||||
}
|
||||
|
||||
// Create mock that fails
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn().mockResolvedValue(registryResult),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
// First call should try Algolia, fail, and use Registry
|
||||
const result = await gateway.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
expect(mockAlgoliaProvider.searchPacks).toHaveBeenCalledTimes(1)
|
||||
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(1)
|
||||
expect(result.nodePacks[0].name).toBe('Registry Pack')
|
||||
|
||||
// Circuit breaker behavior is internal implementation detail
|
||||
// We only test the observable behavior (fallback works)
|
||||
})
|
||||
|
||||
it('should have circuit breaker timeout mechanism', () => {
|
||||
// This test verifies that the constants exist for circuit breaker behavior
|
||||
// The actual circuit breaker logic is tested in integration with real provider behavior
|
||||
expect(typeof useRegistrySearchGateway).toBe('function')
|
||||
|
||||
// We can test that the gateway logs circuit breaker behavior
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn().mockRejectedValue(new Error('Persistent failure')),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
expect(gateway).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cache management', () => {
|
||||
it('should clear cache for all providers', () => {
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
gateway.clearSearchCache()
|
||||
|
||||
expect(mockAlgoliaProvider.clearSearchCache).toHaveBeenCalled()
|
||||
expect(mockRegistryProvider.clearSearchCache).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle cache clear failures gracefully', () => {
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Cache clear failed')
|
||||
}),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
// Should not throw when clearing cache even if one provider fails
|
||||
expect(() => gateway.clearSearchCache()).not.toThrow()
|
||||
|
||||
// Should still attempt to clear cache for all providers
|
||||
expect(mockAlgoliaProvider.clearSearchCache).toHaveBeenCalled()
|
||||
expect(mockRegistryProvider.clearSearchCache).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sort functionality', () => {
|
||||
it('should use sort fields from active provider', () => {
|
||||
const algoliaFields = [
|
||||
{ id: 'downloads', label: 'Downloads', direction: 'desc' }
|
||||
]
|
||||
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue(algoliaFields)
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
const sortFields = gateway.getSortableFields()
|
||||
|
||||
expect(sortFields).toEqual(algoliaFields)
|
||||
})
|
||||
|
||||
it('should switch sort fields when provider changes', async () => {
|
||||
const algoliaFields = [
|
||||
{ id: 'downloads', label: 'Downloads', direction: 'desc' }
|
||||
]
|
||||
const registryFields = [{ id: 'name', label: 'Name', direction: 'asc' }]
|
||||
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue(algoliaFields)
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue(registryFields)
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
// Initially should use Algolia's sort fields
|
||||
expect(gateway.getSortableFields()).toEqual(algoliaFields)
|
||||
|
||||
// Force a search to trigger provider switch
|
||||
await gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
|
||||
|
||||
// Now should use Registry's sort fields
|
||||
expect(gateway.getSortableFields()).toEqual(registryFields)
|
||||
})
|
||||
|
||||
it('should delegate getSortValue to active provider', () => {
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn().mockReturnValue(100),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
const pack = { id: '1', name: 'Test Pack' }
|
||||
|
||||
const value = gateway.getSortValue(pack, 'downloads')
|
||||
|
||||
expect(mockAlgoliaProvider.getSortValue).toHaveBeenCalledWith(
|
||||
pack,
|
||||
'downloads'
|
||||
)
|
||||
expect(value).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Provider recovery', () => {
|
||||
it('should use fallback provider when primary fails', async () => {
|
||||
const algoliaError = new Error('Algolia service unavailable')
|
||||
const registryResult = {
|
||||
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
|
||||
querySuggestions: []
|
||||
}
|
||||
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn().mockRejectedValue(algoliaError),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn().mockResolvedValue(registryResult),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
// Should fallback to Registry when Algolia fails
|
||||
const result = await gateway.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
expect(result.nodePacks[0].name).toBe('Registry Pack')
|
||||
expect(mockAlgoliaProvider.searchPacks).toHaveBeenCalledTimes(1)
|
||||
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The gateway successfully handled the failure and returned results
|
||||
})
|
||||
})
|
||||
})
|
||||
186
tests-ui/tests/services/registrySearchProvider.test.ts
Normal file
186
tests-ui/tests/services/registrySearchProvider.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
|
||||
// Mock the store
|
||||
vi.mock('@/stores/comfyRegistryStore', () => ({
|
||||
useComfyRegistryStore: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useComfyRegistrySearchProvider', () => {
|
||||
const mockSearchCall = vi.fn()
|
||||
const mockSearchClear = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup store mock
|
||||
vi.mocked(useComfyRegistryStore).mockReturnValue({
|
||||
search: {
|
||||
call: mockSearchCall,
|
||||
clear: mockSearchClear
|
||||
}
|
||||
} as any)
|
||||
})
|
||||
|
||||
describe('searchPacks', () => {
|
||||
it('should search for packs by name', async () => {
|
||||
const mockResults = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Test Pack 1' },
|
||||
{ id: '2', name: 'Test Pack 2' }
|
||||
]
|
||||
}
|
||||
mockSearchCall.mockResolvedValue(mockResults)
|
||||
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
const result = await provider.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0,
|
||||
restrictSearchableAttributes: ['name', 'description']
|
||||
})
|
||||
|
||||
expect(mockSearchCall).toHaveBeenCalledWith({
|
||||
search: 'test',
|
||||
comfy_node_search: undefined,
|
||||
limit: 10,
|
||||
page: 1
|
||||
})
|
||||
expect(result.nodePacks).toEqual(mockResults.nodes)
|
||||
expect(result.querySuggestions).toEqual([])
|
||||
})
|
||||
|
||||
it('should search for packs by node names', async () => {
|
||||
const mockResults = {
|
||||
nodes: [{ id: '1', name: 'Pack with LoadImage node' }]
|
||||
}
|
||||
mockSearchCall.mockResolvedValue(mockResults)
|
||||
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
const result = await provider.searchPacks('LoadImage', {
|
||||
pageSize: 20,
|
||||
pageNumber: 1,
|
||||
restrictSearchableAttributes: ['comfy_nodes']
|
||||
})
|
||||
|
||||
expect(mockSearchCall).toHaveBeenCalledWith({
|
||||
search: undefined,
|
||||
comfy_node_search: 'LoadImage',
|
||||
limit: 20,
|
||||
page: 2
|
||||
})
|
||||
expect(result.nodePacks).toEqual(mockResults.nodes)
|
||||
})
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
mockSearchCall.mockResolvedValue({ nodes: [] })
|
||||
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
const result = await provider.searchPacks('nonexistent', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
expect(result.nodePacks).toEqual([])
|
||||
expect(result.querySuggestions).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle null results', async () => {
|
||||
mockSearchCall.mockResolvedValue(null)
|
||||
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
const result = await provider.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
expect(result.nodePacks).toEqual([])
|
||||
expect(result.querySuggestions).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle results without nodes property', async () => {
|
||||
mockSearchCall.mockResolvedValue({})
|
||||
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
const result = await provider.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
expect(result.nodePacks).toEqual([])
|
||||
expect(result.querySuggestions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearSearchCache', () => {
|
||||
it('should delegate to store search.clear', () => {
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
provider.clearSearchCache()
|
||||
|
||||
expect(mockSearchClear).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortValue', () => {
|
||||
const testPack = {
|
||||
id: '1',
|
||||
name: 'Test Pack',
|
||||
downloads: 100,
|
||||
publisher: { id: 'pub1', name: 'Publisher One' },
|
||||
latest_version: {
|
||||
version: '1.0.0',
|
||||
createdAt: '2024-01-15T10:00:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
it('should return download count for downloads field', () => {
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
expect(provider.getSortValue(testPack, 'downloads')).toBe(100)
|
||||
})
|
||||
|
||||
it('should return pack name for name field', () => {
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
expect(provider.getSortValue(testPack, 'name')).toBe('Test Pack')
|
||||
})
|
||||
|
||||
it('should return publisher name for publisher field', () => {
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
expect(provider.getSortValue(testPack, 'publisher')).toBe('Publisher One')
|
||||
})
|
||||
|
||||
it('should return timestamp for updated field', () => {
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
const timestamp = new Date('2024-01-15T10:00:00Z').getTime()
|
||||
expect(provider.getSortValue(testPack, 'updated')).toBe(timestamp)
|
||||
})
|
||||
|
||||
it('should handle missing values gracefully', () => {
|
||||
const incompletePack = { id: '1', name: 'Incomplete' }
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
|
||||
expect(provider.getSortValue(incompletePack, 'downloads')).toBe(0)
|
||||
expect(provider.getSortValue(incompletePack, 'publisher')).toBe('')
|
||||
expect(provider.getSortValue(incompletePack, 'updated')).toBe(0)
|
||||
})
|
||||
|
||||
it('should return 0 for unknown sort fields', () => {
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
expect(provider.getSortValue(testPack, 'unknown')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortableFields', () => {
|
||||
it('should return supported sort fields', () => {
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
const fields = provider.getSortableFields()
|
||||
|
||||
expect(fields).toEqual([
|
||||
{ id: 'downloads', label: 'Downloads', direction: 'desc' },
|
||||
{ id: 'name', label: 'Name', direction: 'asc' },
|
||||
{ id: 'publisher', label: 'Publisher', direction: 'asc' },
|
||||
{ id: 'updated', label: 'Updated', direction: 'desc' }
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
175
tests-ui/tests/store/dialogStore.test.ts
Normal file
175
tests-ui/tests/store/dialogStore.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const MockComponent = defineComponent({
|
||||
name: 'MockComponent',
|
||||
template: '<div>Mock</div>'
|
||||
})
|
||||
|
||||
describe('dialogStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('priority system', () => {
|
||||
it('should create dialogs in correct priority order', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
// Create dialogs with different priorities
|
||||
store.showDialog({
|
||||
key: 'low-priority',
|
||||
component: MockComponent,
|
||||
priority: 0
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'high-priority',
|
||||
component: MockComponent,
|
||||
priority: 10
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'medium-priority',
|
||||
component: MockComponent,
|
||||
priority: 5
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'no-priority',
|
||||
component: MockComponent
|
||||
})
|
||||
|
||||
// Check order: high (2) -> medium (1) -> low (0)
|
||||
expect(store.dialogStack.map((d) => d.key)).toEqual([
|
||||
'high-priority',
|
||||
'medium-priority',
|
||||
'no-priority',
|
||||
'low-priority'
|
||||
])
|
||||
})
|
||||
|
||||
it('should maintain priority order when rising dialogs', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
// Create dialogs with different priorities
|
||||
store.showDialog({
|
||||
key: 'priority-2',
|
||||
component: MockComponent,
|
||||
priority: 2
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'priority-1',
|
||||
component: MockComponent,
|
||||
priority: 1
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'priority-0',
|
||||
component: MockComponent,
|
||||
priority: 0
|
||||
})
|
||||
|
||||
// Try to rise the lowest priority dialog
|
||||
store.riseDialog({ key: 'priority-0' })
|
||||
|
||||
// Should still be at the bottom because of its priority
|
||||
expect(store.dialogStack.map((d) => d.key)).toEqual([
|
||||
'priority-2',
|
||||
'priority-1',
|
||||
'priority-0'
|
||||
])
|
||||
|
||||
// Rise the medium priority dialog
|
||||
store.riseDialog({ key: 'priority-1' })
|
||||
|
||||
// Should be above priority-0 but below priority-2
|
||||
expect(store.dialogStack.map((d) => d.key)).toEqual([
|
||||
'priority-2',
|
||||
'priority-1',
|
||||
'priority-0'
|
||||
])
|
||||
})
|
||||
|
||||
it('should keep high priority dialogs on top when creating new lower priority dialogs', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
// Create a high priority dialog (like manager progress)
|
||||
store.showDialog({
|
||||
key: 'manager-progress',
|
||||
component: MockComponent,
|
||||
priority: 10
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'dialog-2',
|
||||
component: MockComponent,
|
||||
priority: 0
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'dialog-3',
|
||||
component: MockComponent
|
||||
// Default priority is 1
|
||||
})
|
||||
|
||||
// Manager progress should still be on top
|
||||
expect(store.dialogStack[0].key).toBe('manager-progress')
|
||||
|
||||
// Check full order
|
||||
expect(store.dialogStack.map((d) => d.key)).toEqual([
|
||||
'manager-progress', // priority 2
|
||||
'dialog-3', // priority 1 (default)
|
||||
'dialog-2' // priority 0
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('basic dialog operations', () => {
|
||||
it('should show and close dialogs', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'test-dialog',
|
||||
component: MockComponent
|
||||
})
|
||||
|
||||
expect(store.dialogStack).toHaveLength(1)
|
||||
expect(store.isDialogOpen('test-dialog')).toBe(true)
|
||||
|
||||
store.closeDialog({ key: 'test-dialog' })
|
||||
|
||||
expect(store.dialogStack).toHaveLength(0)
|
||||
expect(store.isDialogOpen('test-dialog')).toBe(false)
|
||||
})
|
||||
|
||||
it('should reuse existing dialog when showing with same key', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'reusable-dialog',
|
||||
component: MockComponent,
|
||||
title: 'Original Title'
|
||||
})
|
||||
|
||||
// First call should create the dialog
|
||||
expect(store.dialogStack).toHaveLength(1)
|
||||
expect(store.dialogStack[0].title).toBe('Original Title')
|
||||
|
||||
// Second call with same key should reuse the dialog
|
||||
store.showDialog({
|
||||
key: 'reusable-dialog',
|
||||
component: MockComponent,
|
||||
title: 'New Title' // This should be ignored
|
||||
})
|
||||
|
||||
// Should still have only one dialog with original title
|
||||
expect(store.dialogStack).toHaveLength(1)
|
||||
expect(store.dialogStack[0].key).toBe('reusable-dialog')
|
||||
expect(store.dialogStack[0].title).toBe('Original Title')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,10 +3,9 @@ import dotenv from 'dotenv'
|
||||
import IconsResolver from 'unplugin-icons/resolver'
|
||||
import Icons from 'unplugin-icons/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import { type UserConfig, defineConfig } from 'vite'
|
||||
import { createHtmlPlugin } from 'vite-plugin-html'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import type { UserConfigExport } from 'vitest/config'
|
||||
|
||||
import {
|
||||
addElementVnodeExportPlugin,
|
||||
@@ -154,4 +153,4 @@ export default defineConfig({
|
||||
optimizeDeps: {
|
||||
exclude: ['@comfyorg/litegraph', '@comfyorg/comfyui-electron-types']
|
||||
}
|
||||
}) as UserConfigExport
|
||||
}) satisfies UserConfig as UserConfig
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Plugin, defineConfig } from 'vite'
|
||||
import { mergeConfig } from 'vite'
|
||||
import type { UserConfig } from 'vitest/config'
|
||||
|
||||
import baseConfig from './vite.config.mts'
|
||||
|
||||
@@ -83,7 +82,7 @@ const mockElectronAPI: Plugin = {
|
||||
}
|
||||
|
||||
export default mergeConfig(
|
||||
baseConfig as unknown as UserConfig,
|
||||
baseConfig,
|
||||
defineConfig({
|
||||
plugins: [mockElectronAPI]
|
||||
})
|
||||
|
||||
@@ -7,7 +7,10 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
include: [
|
||||
'tests-ui/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
|
||||
'src/components/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
|
||||
],
|
||||
coverage: {
|
||||
reporter: ['text', 'json', 'html']
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user