mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 17:37:46 +00:00
Merge branch 'main' into vue-nodes-migration
This commit is contained in:
@@ -27,6 +27,11 @@
|
||||
--content-fg: #000;
|
||||
--content-hover-bg: #adadad;
|
||||
--content-hover-fg: #000;
|
||||
|
||||
/* Code styling colors for help menu*/
|
||||
--code-text-color: rgba(0, 122, 255, 1);
|
||||
--code-bg-color: rgba(96, 165, 250, 0.2);
|
||||
--code-block-bg-color: rgba(60, 60, 60, 0.12);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="workflowStore.isSubgraphActive"
|
||||
class="fixed top-[var(--comfy-topbar-height)] left-[var(--sidebar-width)] p-2 subgraph-breadcrumb"
|
||||
>
|
||||
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
|
||||
<Breadcrumb
|
||||
class="bg-transparent"
|
||||
:home="home"
|
||||
@@ -14,28 +11,30 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
|
||||
const items = computed(() => {
|
||||
if (!workflowStore.subgraphNamePath.length) return []
|
||||
if (!navigationStore.navigationStack.length) return []
|
||||
|
||||
return workflowStore.subgraphNamePath.map<MenuItem>((name) => ({
|
||||
label: name,
|
||||
command: async () => {
|
||||
const workflow = workflowStore.getWorkflowByPath(name)
|
||||
if (workflow) await workflowService.openWorkflow(workflow)
|
||||
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
label: subgraph.name,
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(subgraph)
|
||||
}
|
||||
}))
|
||||
})
|
||||
@@ -43,7 +42,7 @@ const items = computed(() => {
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
command: async () => {
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
@@ -55,22 +54,32 @@ const handleItemClick = (event: MenuItemCommandEvent) => {
|
||||
event.item.command?.(event)
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
// Escape exits from the current subgraph.
|
||||
useEventListener(document, 'keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(
|
||||
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.subgraph-breadcrumb {
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
@apply select-none;
|
||||
|
||||
color: #d26565;
|
||||
user-select: none;
|
||||
text-shadow:
|
||||
1px 1px 0 #000,
|
||||
-1px -1px 0 #000,
|
||||
1px -1px 0 #000,
|
||||
-1px 1px 0 #000,
|
||||
0 0 0.375rem #000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -33,6 +32,12 @@ vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/nodePack/usePackUpdateStatus', () => ({
|
||||
usePackUpdateStatus: vi.fn(() => ({
|
||||
isUpdateAvailable: false
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockToggle = vi.fn()
|
||||
const mockHide = vi.fn()
|
||||
const PopoverStub = {
|
||||
@@ -78,9 +83,9 @@ describe('PackVersionBadge', () => {
|
||||
it('renders with installed version from store', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const button = wrapper.findComponent(Button)
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.props('label')).toBe('1.5.0') // From mockInstalledPacks
|
||||
const badge = wrapper.find('[role="button"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.find('span').text()).toBe('1.5.0') // From mockInstalledPacks
|
||||
})
|
||||
|
||||
it('falls back to latest_version when not installed', () => {
|
||||
@@ -97,9 +102,9 @@ describe('PackVersionBadge', () => {
|
||||
props: { nodePack: uninstalledPack }
|
||||
})
|
||||
|
||||
const button = wrapper.findComponent(Button)
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.props('label')).toBe('3.0.0') // From latest_version
|
||||
const badge = wrapper.find('[role="button"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.find('span').text()).toBe('3.0.0') // From latest_version
|
||||
})
|
||||
|
||||
it('falls back to NIGHTLY when no latest_version and not installed', () => {
|
||||
@@ -113,9 +118,9 @@ describe('PackVersionBadge', () => {
|
||||
props: { nodePack: noVersionPack }
|
||||
})
|
||||
|
||||
const button = wrapper.findComponent(Button)
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
|
||||
const badge = wrapper.find('[role="button"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
|
||||
})
|
||||
|
||||
it('falls back to NIGHTLY when nodePack.id is missing', () => {
|
||||
@@ -127,16 +132,16 @@ describe('PackVersionBadge', () => {
|
||||
props: { nodePack: invalidPack }
|
||||
})
|
||||
|
||||
const button = wrapper.findComponent(Button)
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
|
||||
const badge = wrapper.find('[role="button"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
|
||||
})
|
||||
|
||||
it('toggles the popover when button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Click the button
|
||||
await wrapper.findComponent(Button).trigger('click')
|
||||
// Click the badge
|
||||
await wrapper.find('[role="button"]').trigger('click')
|
||||
|
||||
// Verify that the toggle method was called
|
||||
expect(mockToggle).toHaveBeenCalled()
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
:label="installedVersion"
|
||||
severity="secondary"
|
||||
icon="pi pi-chevron-right"
|
||||
icon-pos="right"
|
||||
class="rounded-xl text-xs tracking-tighter p-0"
|
||||
:pt="{
|
||||
label: { class: 'pl-2 pr-0 py-0.5' },
|
||||
icon: { class: 'text-xs pl-0 pr-2 py-0.5' }
|
||||
}"
|
||||
<div>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer px-2 py-1"
|
||||
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700': fill }"
|
||||
aria-haspopup="true"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="toggleVersionSelector"
|
||||
/>
|
||||
@keydown.enter="toggleVersionSelector"
|
||||
@keydown.space="toggleVersionSelector"
|
||||
>
|
||||
<i
|
||||
v-if="isUpdateAvailable"
|
||||
class="pi pi-arrow-circle-up text-blue-600"
|
||||
style="font-size: 8px"
|
||||
/>
|
||||
<span>{{ installedVersion }}</span>
|
||||
<i class="pi pi-chevron-right" style="font-size: 8px" />
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
@@ -31,11 +36,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { SelectedVersion } from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
@@ -43,11 +48,17 @@ import { isSemVer } from '@/utils/formatUtil'
|
||||
|
||||
const TRUNCATED_HASH_LENGTH = 7
|
||||
|
||||
const { nodePack, isSelected } = defineProps<{
|
||||
const {
|
||||
nodePack,
|
||||
isSelected,
|
||||
fill = true
|
||||
} = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
isSelected: boolean
|
||||
fill?: boolean
|
||||
}>()
|
||||
|
||||
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
|
||||
const popoverRef = ref()
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Button
|
||||
outlined
|
||||
class="!m-0 p-0 rounded-lg"
|
||||
class="!m-0 p-0 rounded-lg text-gray-900 dark-theme:text-gray-50"
|
||||
:class="[
|
||||
variant === 'black'
|
||||
? 'bg-neutral-900 text-white border-neutral-900'
|
||||
@@ -12,7 +12,7 @@
|
||||
v-bind="$attrs"
|
||||
@click="onClick"
|
||||
>
|
||||
<span class="py-2.5 px-3 whitespace-nowrap">
|
||||
<span class="py-2 px-3 whitespace-nowrap">
|
||||
<template v-if="loading">
|
||||
{{ loadingMessage ?? $t('g.loading') }}
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div :style="{ width: cssWidth, height: cssHeight }" class="overflow-hidden">
|
||||
<div class="w-full aspect-[7/3] overflow-hidden">
|
||||
<!-- default banner show -->
|
||||
<div v-if="showDefaultBanner" class="w-full h-full">
|
||||
<img
|
||||
@@ -41,24 +41,12 @@ import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
|
||||
|
||||
const {
|
||||
nodePack,
|
||||
width = '100%',
|
||||
height = '12rem'
|
||||
} = defineProps<{
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
width?: string
|
||||
height?: string
|
||||
}>()
|
||||
|
||||
const isImageError = ref(false)
|
||||
|
||||
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
|
||||
|
||||
const cssWidth = computed(() => convertToCssValue(width))
|
||||
const cssHeight = computed(() => convertToCssValue(height))
|
||||
</script>
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
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' }
|
||||
footer: {
|
||||
class: 'p-0 m-0 flex flex-col gap-0',
|
||||
style: {
|
||||
borderTop: isLightTheme ? '1px solid #f4f4f4' : '1px solid #2C2C2C'
|
||||
}
|
||||
}
|
||||
}"
|
||||
>
|
||||
<template #title>
|
||||
@@ -29,75 +34,50 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="self-stretch inline-flex flex-col justify-start items-start"
|
||||
>
|
||||
<div
|
||||
class="px-4 py-3 inline-flex justify-start items-start cursor-pointer w-full"
|
||||
>
|
||||
<div
|
||||
class="inline-flex flex-col justify-start items-start overflow-hidden gap-y-3 w-full"
|
||||
<div class="pt-4 px-4 pb-3 w-full h-full">
|
||||
<div class="flex flex-col gap-y-1 w-full h-full">
|
||||
<span
|
||||
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
|
||||
>
|
||||
<span
|
||||
class="text-base font-bold truncate overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ nodePack.name }}
|
||||
</span>
|
||||
<p
|
||||
v-if="nodePack.description"
|
||||
class="flex-1 justify-start text-muted text-sm font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-5"
|
||||
>
|
||||
{{ nodePack.description }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
{{ nodePack.name }}
|
||||
</span>
|
||||
<p
|
||||
v-if="nodePack.description"
|
||||
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-4 mb-1 overflow-hidden"
|
||||
>
|
||||
{{ nodePack.description }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<div v-if="nodesCount" class="p-2 pl-0 text-xs">
|
||||
{{ nodesCount }} {{ $t('g.nodes') }}
|
||||
</div>
|
||||
<PackVersionBadge
|
||||
:node-pack="nodePack"
|
||||
:is-selected="isSelected"
|
||||
:fill="false"
|
||||
/>
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-1"
|
||||
v-if="formattedLatestVersionDate"
|
||||
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
|
||||
>
|
||||
<div
|
||||
v-if="nodesCount"
|
||||
class="pr-2 py-1 flex justify-center text-sm items-center gap-1"
|
||||
>
|
||||
<div
|
||||
class="text-center justify-center font-medium leading-3"
|
||||
>
|
||||
{{ nodesCount }} {{ $t('g.nodes') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-1 flex justify-center items-center gap-1">
|
||||
<div
|
||||
v-if="isUpdateAvailable"
|
||||
class="w-4 h-4 relative overflow-hidden"
|
||||
>
|
||||
<i class="pi pi-arrow-circle-up text-blue-600" />
|
||||
</div>
|
||||
<PackVersionBadge
|
||||
:node-pack="nodePack"
|
||||
:is-selected="isSelected"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="formattedLatestVersionDate"
|
||||
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
|
||||
>
|
||||
{{ formattedLatestVersionDate }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
v-if="publisherName"
|
||||
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
|
||||
>
|
||||
{{ publisherName }}
|
||||
</span>
|
||||
{{ formattedLatestVersionDate }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
v-if="publisherName"
|
||||
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
|
||||
>
|
||||
{{ publisherName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #footer>
|
||||
<ContentDivider :width="0.1" />
|
||||
<PackCardFooter :node-pack="nodePack" />
|
||||
</template>
|
||||
</Card>
|
||||
@@ -110,12 +90,11 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
|
||||
import PackBanner from '@/components/dialog/content/manager/packBanner/PackBanner.vue'
|
||||
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import {
|
||||
IsInstallingKey,
|
||||
type MergedNodePack,
|
||||
@@ -130,11 +109,15 @@ const { nodePack, isSelected = false } = defineProps<{
|
||||
|
||||
const { d } = useI18n()
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
|
||||
const isInstalling = ref(false)
|
||||
provide(IsInstallingKey, isInstalling)
|
||||
|
||||
const { isPackInstalled, isPackEnabled } = useComfyManagerStore()
|
||||
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
|
||||
|
||||
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
|
||||
const isDisabled = computed(
|
||||
@@ -167,14 +150,14 @@ const formattedLatestVersionDate = computed(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selected-card::before {
|
||||
.selected-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 3px solid var(--p-primary-color);
|
||||
border: 4px solid var(--p-primary-color);
|
||||
border-radius: 0.5rem;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
|
||||
class="min-h-12 flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
|
||||
>
|
||||
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
|
||||
<i class="pi pi-download text-muted"></i>
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-8">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3>{{ $t('credits.activity') }}</h3>
|
||||
<Button
|
||||
:label="$t('credits.invoiceHistory')"
|
||||
text
|
||||
@@ -81,6 +82,8 @@
|
||||
|
||||
<Divider />
|
||||
|
||||
<UsageLogsTable ref="usageLogsTableRef" />
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
:label="$t('credits.faqs')"
|
||||
@@ -108,10 +111,11 @@ import DataTable from 'primevue/datatable'
|
||||
import Divider from 'primevue/divider'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
@@ -131,12 +135,23 @@ const authActions = useFirebaseAuthActions()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||
|
||||
const formattedLastUpdateTime = computed(() =>
|
||||
authStore.lastBalanceUpdateTime
|
||||
? authStore.lastBalanceUpdateTime.toLocaleString()
|
||||
: ''
|
||||
)
|
||||
|
||||
watch(
|
||||
() => authStore.lastBalanceUpdateTime,
|
||||
(newTime, oldTime) => {
|
||||
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
|
||||
usageLogsTableRef.value.refresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handlePurchaseCreditsClick = () => {
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
399
src/components/dialog/content/setting/UsageLogsTable.spec.ts
Normal file
399
src/components/dialog/content/setting/UsageLogsTable.spec.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Badge from 'primevue/badge'
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
import UsageLogsTable from './UsageLogsTable.vue'
|
||||
|
||||
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
events: any[]
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
dataTableFirst: number
|
||||
tooltipContentMap: Map<string, string>
|
||||
loadEvents: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
onPageChange: (event: { page: number }) => void
|
||||
}
|
||||
|
||||
// Mock the customerEventsService
|
||||
const mockCustomerEventsService = vi.hoisted(() => ({
|
||||
getMyEvents: vi.fn(),
|
||||
formatEventType: vi.fn(),
|
||||
getEventSeverity: vi.fn(),
|
||||
formatAmount: vi.fn(),
|
||||
formatDate: vi.fn(),
|
||||
hasAdditionalInfo: vi.fn(),
|
||||
getTooltipContent: vi.fn(),
|
||||
error: { value: null },
|
||||
isLoading: { value: false }
|
||||
}))
|
||||
|
||||
vi.mock('@/services/customerEventsService', () => ({
|
||||
useCustomerEventsService: () => mockCustomerEventsService,
|
||||
EventType: {
|
||||
CREDIT_ADDED: 'credit_added',
|
||||
ACCOUNT_CREATED: 'account_created',
|
||||
API_USAGE_STARTED: 'api_usage_started',
|
||||
API_USAGE_COMPLETED: 'api_usage_completed'
|
||||
}
|
||||
}))
|
||||
|
||||
// Create i18n instance
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
credits: {
|
||||
eventType: 'Event Type',
|
||||
details: 'Details',
|
||||
time: 'Time',
|
||||
additionalInfo: 'Additional Info',
|
||||
added: 'Added',
|
||||
accountInitialized: 'Account initialized',
|
||||
model: 'Model'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('UsageLogsTable', () => {
|
||||
const mockEventsResponse = {
|
||||
events: [
|
||||
{
|
||||
event_id: 'event-1',
|
||||
event_type: 'credit_added',
|
||||
params: {
|
||||
amount: 1000,
|
||||
transaction_id: 'txn-123'
|
||||
},
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
},
|
||||
{
|
||||
event_id: 'event-2',
|
||||
event_type: 'api_usage_completed',
|
||||
params: {
|
||||
api_name: 'Image Generation',
|
||||
model: 'sdxl-base',
|
||||
duration: 5000
|
||||
},
|
||||
createdAt: '2024-01-02T10:00:00Z'
|
||||
}
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 7,
|
||||
totalPages: 1
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup default service mock implementations
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockCustomerEventsService.formatEventType.mockImplementation((type) => {
|
||||
switch (type) {
|
||||
case EventType.CREDIT_ADDED:
|
||||
return 'Credits Added'
|
||||
case EventType.ACCOUNT_CREATED:
|
||||
return 'Account Created'
|
||||
case EventType.API_USAGE_COMPLETED:
|
||||
return 'API Usage'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
})
|
||||
mockCustomerEventsService.getEventSeverity.mockImplementation((type) => {
|
||||
switch (type) {
|
||||
case EventType.CREDIT_ADDED:
|
||||
return 'success'
|
||||
case EventType.ACCOUNT_CREATED:
|
||||
return 'info'
|
||||
case EventType.API_USAGE_COMPLETED:
|
||||
return 'warning'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
})
|
||||
mockCustomerEventsService.formatAmount.mockImplementation((amount) => {
|
||||
if (!amount) return '0.00'
|
||||
return (amount / 100).toFixed(2)
|
||||
})
|
||||
mockCustomerEventsService.formatDate.mockImplementation((dateString) => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
})
|
||||
mockCustomerEventsService.hasAdditionalInfo.mockImplementation((event) => {
|
||||
const { amount, api_name, model, ...otherParams } = event.params || {}
|
||||
return Object.keys(otherParams).length > 0
|
||||
})
|
||||
mockCustomerEventsService.getTooltipContent.mockImplementation(() => {
|
||||
return '<strong>Transaction Id:</strong> txn-123'
|
||||
})
|
||||
mockCustomerEventsService.error.value = null
|
||||
mockCustomerEventsService.isLoading.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const mountComponent = (options = {}) => {
|
||||
return mount(UsageLogsTable, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, createTestingPinia()],
|
||||
components: {
|
||||
DataTable,
|
||||
Column,
|
||||
Badge,
|
||||
Button,
|
||||
Message,
|
||||
ProgressSpinner
|
||||
},
|
||||
directives: {
|
||||
tooltip: Tooltip
|
||||
}
|
||||
},
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
describe('loading states', () => {
|
||||
it('shows loading spinner when loading is true', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = true
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(DataTable).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows error message when error exists', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.error = 'Failed to load events'
|
||||
vm.loading = false
|
||||
await nextTick()
|
||||
|
||||
const messageComponent = wrapper.findComponent(Message)
|
||||
expect(messageComponent.exists()).toBe(true)
|
||||
expect(messageComponent.props('severity')).toBe('error')
|
||||
expect(messageComponent.text()).toContain('Failed to load events')
|
||||
})
|
||||
|
||||
it('shows data table when loaded successfully', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
// Wait for component to mount and load data
|
||||
await wrapper.vm.$nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
|
||||
expect(wrapper.findComponent(Message).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('data rendering', () => {
|
||||
it('renders events data correctly', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
const dataTable = wrapper.findComponent(DataTable)
|
||||
expect(dataTable.props('value')).toEqual(mockEventsResponse.events)
|
||||
expect(dataTable.props('rows')).toBe(7)
|
||||
expect(dataTable.props('paginator')).toBe(true)
|
||||
expect(dataTable.props('lazy')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders badge for event types correctly', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
const badges = wrapper.findAllComponents(Badge)
|
||||
expect(badges.length).toBeGreaterThan(0)
|
||||
|
||||
// Check if formatEventType and getEventSeverity are called
|
||||
expect(mockCustomerEventsService.formatEventType).toHaveBeenCalled()
|
||||
expect(mockCustomerEventsService.getEventSeverity).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders different event details based on event type', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
// Check if formatAmount is called for credit_added events
|
||||
expect(mockCustomerEventsService.formatAmount).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders tooltip buttons for events with additional info', async () => {
|
||||
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
expect(mockCustomerEventsService.hasAdditionalInfo).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pagination', () => {
|
||||
it('handles page change correctly', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
// Simulate page change
|
||||
const dataTable = wrapper.findComponent(DataTable)
|
||||
await dataTable.vm.$emit('page', { page: 1 })
|
||||
|
||||
expect(vm.pagination.page).toBe(1) // page + 1
|
||||
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
|
||||
page: 2,
|
||||
limit: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('calculates dataTableFirst correctly', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.pagination = { page: 2, limit: 7, total: 20, totalPages: 3 }
|
||||
await nextTick()
|
||||
|
||||
expect(vm.dataTableFirst).toBe(7) // (2-1) * 7
|
||||
})
|
||||
})
|
||||
|
||||
describe('tooltip functionality', () => {
|
||||
it('generates tooltip content map correctly', async () => {
|
||||
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
|
||||
mockCustomerEventsService.getTooltipContent.mockReturnValue(
|
||||
'<strong>Test:</strong> value'
|
||||
)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
const tooltipMap = vm.tooltipContentMap
|
||||
expect(tooltipMap.get('event-1')).toBe('<strong>Test:</strong> value')
|
||||
})
|
||||
|
||||
it('excludes events without additional info from tooltip map', async () => {
|
||||
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(false)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
const tooltipMap = vm.tooltipContentMap
|
||||
expect(tooltipMap.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('component methods', () => {
|
||||
it('exposes refresh method', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(typeof wrapper.vm.refresh).toBe('function')
|
||||
})
|
||||
|
||||
it('resets to first page on refresh', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
vm.pagination.page = 3
|
||||
|
||||
await vm.refresh()
|
||||
|
||||
expect(vm.pagination.page).toBe(1)
|
||||
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 7
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('component lifecycle', () => {
|
||||
it('initializes with correct default values', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
expect(vm.events).toEqual([])
|
||||
expect(vm.loading).toBe(true)
|
||||
expect(vm.error).toBeNull()
|
||||
expect(vm.pagination).toEqual({
|
||||
page: 1,
|
||||
limit: 7,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventType integration', () => {
|
||||
it('uses EventType enum in template conditions', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
vm.loading = false
|
||||
vm.events = [
|
||||
{
|
||||
event_id: 'event-1',
|
||||
event_type: EventType.CREDIT_ADDED,
|
||||
params: { amount: 1000 },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
// Verify that the component can access EventType enum
|
||||
expect(EventType.CREDIT_ADDED).toBe('credit_added')
|
||||
expect(EventType.ACCOUNT_CREATED).toBe('account_created')
|
||||
expect(EventType.API_USAGE_COMPLETED).toBe('api_usage_completed')
|
||||
})
|
||||
})
|
||||
})
|
||||
188
src/components/dialog/content/setting/UsageLogsTable.vue
Normal file
188
src/components/dialog/content/setting/UsageLogsTable.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="loading" class="flex items-center justify-center p-8">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
<div v-else-if="error" class="p-4">
|
||||
<Message severity="error" :closable="false">{{ error }}</Message>
|
||||
</div>
|
||||
<DataTable
|
||||
v-else
|
||||
:value="events"
|
||||
:paginator="true"
|
||||
:rows="pagination.limit"
|
||||
:total-records="pagination.total"
|
||||
:first="dataTableFirst"
|
||||
:lazy="true"
|
||||
class="p-datatable-sm custom-datatable"
|
||||
@page="onPageChange"
|
||||
>
|
||||
<Column field="event_type" :header="$t('credits.eventType')">
|
||||
<template #body="{ data }">
|
||||
<Badge
|
||||
:value="customerEventService.formatEventType(data.event_type)"
|
||||
:severity="customerEventService.getEventSeverity(data.event_type)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="details" :header="$t('credits.details')">
|
||||
<template #body="{ data }">
|
||||
<div class="event-details">
|
||||
<!-- Credits Added -->
|
||||
<template v-if="data.event_type === EventType.CREDIT_ADDED">
|
||||
<div class="text-green-500 font-semibold">
|
||||
{{ $t('credits.added') }} ${{
|
||||
customerEventService.formatAmount(data.params?.amount)
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Account Created -->
|
||||
<template v-else-if="data.event_type === EventType.ACCOUNT_CREATED">
|
||||
<div>{{ $t('credits.accountInitialized') }}</div>
|
||||
</template>
|
||||
|
||||
<!-- API Usage -->
|
||||
<template
|
||||
v-else-if="data.event_type === EventType.API_USAGE_COMPLETED"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="font-semibold">
|
||||
{{ data.params?.api_name || 'API' }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-400">
|
||||
{{ $t('credits.model') }}: {{ data.params?.model || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="createdAt" :header="$t('credits.time')">
|
||||
<template #body="{ data }">
|
||||
{{ customerEventService.formatDate(data.createdAt) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="params" :header="$t('credits.additionalInfo')">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
v-if="customerEventService.hasAdditionalInfo(data)"
|
||||
v-tooltip.top="{
|
||||
escape: false,
|
||||
value: tooltipContentMap.get(data.event_id) || '',
|
||||
pt: {
|
||||
text: {
|
||||
style: {
|
||||
width: 'max-content !important'
|
||||
}
|
||||
}
|
||||
}
|
||||
}"
|
||||
icon="pi pi-info-circle"
|
||||
class="p-button-text p-button-sm"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Badge from 'primevue/badge'
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
AuditLog,
|
||||
EventType,
|
||||
useCustomerEventsService
|
||||
} from '@/services/customerEventsService'
|
||||
|
||||
const events = ref<AuditLog[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const customerEventService = useCustomerEventsService()
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
limit: 7,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
|
||||
const dataTableFirst = computed(
|
||||
() => (pagination.value.page - 1) * pagination.value.limit
|
||||
)
|
||||
|
||||
const tooltipContentMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
events.value.forEach((event) => {
|
||||
if (customerEventService.hasAdditionalInfo(event) && event.event_id) {
|
||||
map.set(event.event_id, customerEventService.getTooltipContent(event))
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const loadEvents = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await customerEventService.getMyEvents({
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit
|
||||
})
|
||||
|
||||
if (response) {
|
||||
if (response.events) {
|
||||
events.value = response.events
|
||||
}
|
||||
|
||||
if (response.page) {
|
||||
pagination.value.page = response.page
|
||||
}
|
||||
|
||||
if (response.limit) {
|
||||
pagination.value.limit = response.limit
|
||||
}
|
||||
|
||||
if (response.total) {
|
||||
pagination.value.total = response.total
|
||||
}
|
||||
|
||||
if (response.totalPages) {
|
||||
pagination.value.totalPages = response.totalPages
|
||||
}
|
||||
} else {
|
||||
error.value = customerEventService.error.value || 'Failed to load events'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error'
|
||||
console.error('Error loading events:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onPageChange = (event: { page: number }) => {
|
||||
pagination.value.page = event.page + 1
|
||||
loadEvents().catch((error) => {
|
||||
console.error('Error loading events:', error)
|
||||
})
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
pagination.value.page = 1
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refresh
|
||||
})
|
||||
</script>
|
||||
@@ -21,16 +21,14 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const widgetStates = computed(() =>
|
||||
Array.from(domWidgetStore.widgetStates.values())
|
||||
)
|
||||
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
|
||||
|
||||
const updateWidgets = () => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas) return
|
||||
|
||||
const lowQuality = lgCanvas.low_quality
|
||||
for (const widgetState of domWidgetStore.widgetStates.values()) {
|
||||
for (const widgetState of widgetStates.value) {
|
||||
const widget = widgetState.widget
|
||||
const node = widget.node as LGraphNode
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
<div class="absolute top-0 left-0 w-auto max-w-full pointer-events-auto">
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
/>
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
|
||||
</template>
|
||||
</LiteGraphCanvasSplitterOverlay>
|
||||
@@ -39,12 +41,11 @@
|
||||
</SelectionOverlay>
|
||||
<DomWidgets />
|
||||
</template>
|
||||
<SubgraphBreadcrumb />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
@@ -84,6 +85,7 @@ import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
@@ -192,10 +194,10 @@ watch(
|
||||
// Update the progress of the executing node
|
||||
watch(
|
||||
() =>
|
||||
[executionStore.executingNodeId, executionStore.executingNodeProgress] as [
|
||||
NodeId | null,
|
||||
number | null
|
||||
],
|
||||
[
|
||||
executionStore.executingNodeId,
|
||||
executionStore.executingNodeProgress
|
||||
] satisfies [NodeId | null, number | null],
|
||||
([executingNodeId, executingNodeProgress]) => {
|
||||
for (const node of comfyApp.graph.nodes) {
|
||||
if (node.id == executingNodeId) {
|
||||
@@ -325,6 +327,11 @@ onMounted(async () => {
|
||||
await workflowPersistence.restorePreviousWorkflow()
|
||||
workflowPersistence.restoreWorkflowTabsState()
|
||||
|
||||
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
||||
const { useReleaseStore } = await import('@/stores/releaseStore')
|
||||
const releaseStore = useReleaseStore()
|
||||
void releaseStore.initialize()
|
||||
|
||||
// Start watching for locale change after the initial value is loaded.
|
||||
watch(
|
||||
() => settingStore.get('Comfy.Locale'),
|
||||
@@ -334,6 +341,16 @@ onMounted(async () => {
|
||||
}
|
||||
)
|
||||
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
emit('ready')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
<ColorPickerButton />
|
||||
<BypassButton />
|
||||
<PinButton />
|
||||
<EditModelButton />
|
||||
<MaskEditorButton />
|
||||
<ConvertToSubgraphButton />
|
||||
<DeleteButton />
|
||||
<RefreshButton />
|
||||
<ExtensionCommandButton
|
||||
@@ -28,7 +30,9 @@ import { computed } from 'vue'
|
||||
|
||||
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
|
||||
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
|
||||
import EditModelButton from '@/components/graph/selectionToolbox/EditModelButton.vue'
|
||||
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
||||
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
|
||||
import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isVisible"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-box"
|
||||
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isVisible = computed(() => {
|
||||
return (
|
||||
canvasStore.groupSelected ||
|
||||
canvasStore.rerouteSelected ||
|
||||
canvasStore.nodeSelected
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isDeletable"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
|
||||
showDelay: 1000
|
||||
@@ -13,10 +14,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isDeletable = computed(() =>
|
||||
canvasStore.selectedItems.some((x) => x.removable !== false)
|
||||
)
|
||||
</script>
|
||||
|
||||
37
src/components/graph/selectionToolbox/EditModelButton.vue
Normal file
37
src/components/graph/selectionToolbox/EditModelButton.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isImageOutputSelected"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_AddEditModelStep.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-pen-to-square"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.AddEditModelStep')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isImageOutputOrEditModelNode = (node: unknown) =>
|
||||
isLGraphNode(node) &&
|
||||
(isImageNode(node) || node.type === 'workflow>FLUX.1 Kontext Image Edit')
|
||||
|
||||
const isImageOutputSelected = computed(
|
||||
() =>
|
||||
canvasStore.selectedItems.length === 1 &&
|
||||
isImageOutputOrEditModelNode(canvasStore.selectedItems[0])
|
||||
)
|
||||
</script>
|
||||
@@ -25,8 +25,9 @@ const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isSingleImageNode = computed(() => {
|
||||
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
|
||||
return nodes.length === 1 && nodes.some(isImageNode)
|
||||
const { selectedItems } = canvasStore
|
||||
const item = selectedItems[0]
|
||||
return selectedItems.length === 1 && isLGraphNode(item) && isImageNode(item)
|
||||
})
|
||||
|
||||
const openMaskEditor = () => {
|
||||
|
||||
586
src/components/helpcenter/HelpCenterMenuContent.vue
Normal file
586
src/components/helpcenter/HelpCenterMenuContent.vue
Normal file
@@ -0,0 +1,586 @@
|
||||
<template>
|
||||
<div class="help-center-menu" role="menu" aria-label="Help Center Menu">
|
||||
<!-- Main Menu Items -->
|
||||
<nav class="help-menu-section" role="menubar">
|
||||
<button
|
||||
v-for="menuItem in menuItems"
|
||||
:key="menuItem.key"
|
||||
type="button"
|
||||
class="help-menu-item"
|
||||
:class="{ 'more-item': menuItem.key === 'more' }"
|
||||
role="menuitem"
|
||||
@click="menuItem.action"
|
||||
@mouseenter="onMenuItemHover(menuItem.key, $event)"
|
||||
@mouseleave="onMenuItemLeave(menuItem.key)"
|
||||
>
|
||||
<i :class="menuItem.icon" class="help-menu-icon" />
|
||||
<span class="menu-label">{{ menuItem.label }}</span>
|
||||
<i v-if="menuItem.key === 'more'" class="pi pi-chevron-right" />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- More Submenu -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isSubmenuVisible"
|
||||
ref="submenuRef"
|
||||
class="more-submenu"
|
||||
:style="submenuStyle"
|
||||
@mouseenter="onSubmenuHover"
|
||||
@mouseleave="onSubmenuLeave"
|
||||
>
|
||||
<template v-for="submenuItem in submenuItems" :key="submenuItem.key">
|
||||
<div v-if="submenuItem.type === 'divider'" class="submenu-divider" />
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="help-menu-item submenu-item"
|
||||
:class="{ disabled: submenuItem.disabled }"
|
||||
:disabled="submenuItem.disabled"
|
||||
role="menuitem"
|
||||
@click="submenuItem.action"
|
||||
>
|
||||
<span class="menu-label">{{ submenuItem.label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- What's New Section -->
|
||||
<section class="whats-new-section">
|
||||
<h3 class="section-description">{{ $t('helpCenter.whatsNew') }}</h3>
|
||||
|
||||
<!-- Release Items -->
|
||||
<div v-if="hasReleases" role="group" aria-label="Recent releases">
|
||||
<article
|
||||
v-for="release in releaseStore.recentReleases"
|
||||
:key="release.id || release.version"
|
||||
class="help-menu-item release-menu-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onReleaseClick(release)"
|
||||
@keydown.enter="onReleaseClick(release)"
|
||||
@keydown.space.prevent="onReleaseClick(release)"
|
||||
>
|
||||
<i class="pi pi-refresh help-menu-icon" aria-hidden="true" />
|
||||
<div class="release-content">
|
||||
<span class="release-title">
|
||||
Comfy {{ release.version }} Release
|
||||
</span>
|
||||
<time class="release-date" :datetime="release.published_at">
|
||||
<span class="normal-state">
|
||||
{{ formatReleaseDate(release.published_at) }}
|
||||
</span>
|
||||
<span class="hover-state">
|
||||
{{ $t('helpCenter.clickToLearnMore') }}
|
||||
</span>
|
||||
</time>
|
||||
</div>
|
||||
<Button
|
||||
v-if="shouldShowUpdateButton(release)"
|
||||
:label="$t('helpCenter.updateAvailable')"
|
||||
size="small"
|
||||
class="update-button"
|
||||
@click.stop="onUpdate(release)"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-else-if="releaseStore.isLoading"
|
||||
class="help-menu-item"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<i class="pi pi-spin pi-spinner help-menu-icon" aria-hidden="true" />
|
||||
<span>{{ $t('helpCenter.loadingReleases') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- No Releases State -->
|
||||
<div v-else class="help-menu-item" role="status">
|
||||
<i class="pi pi-info-circle help-menu-icon" aria-hidden="true" />
|
||||
<span>{{ $t('helpCenter.noRecentReleases') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { type CSSProperties, computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { type ReleaseNote } from '@/services/releaseService'
|
||||
import { useReleaseStore } from '@/stores/releaseStore'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
|
||||
// Types
|
||||
interface MenuItem {
|
||||
key: string
|
||||
icon: string
|
||||
label: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
interface SubmenuItem {
|
||||
key: string
|
||||
type?: 'item' | 'divider'
|
||||
label?: string
|
||||
action?: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// Constants
|
||||
const EXTERNAL_LINKS = {
|
||||
DOCS: 'https://docs.comfy.org/',
|
||||
DISCORD: 'https://www.comfy.org/discord',
|
||||
GITHUB: 'https://github.com/comfyanonymous/ComfyUI',
|
||||
DESKTOP_GUIDE: 'https://docs.comfy.org/installation/desktop',
|
||||
UPDATE_GUIDE: 'https://docs.comfy.org/installation/update_comfyui'
|
||||
} as const
|
||||
|
||||
const TIME_UNITS = {
|
||||
MINUTE: 60 * 1000,
|
||||
HOUR: 60 * 60 * 1000,
|
||||
DAY: 24 * 60 * 60 * 1000,
|
||||
WEEK: 7 * 24 * 60 * 60 * 1000,
|
||||
MONTH: 30 * 24 * 60 * 60 * 1000,
|
||||
YEAR: 365 * 24 * 60 * 60 * 1000
|
||||
} as const
|
||||
|
||||
const SUBMENU_CONFIG = {
|
||||
DELAY_MS: 100,
|
||||
OFFSET_PX: 8,
|
||||
Z_INDEX: 1002
|
||||
} as const
|
||||
|
||||
// Composables
|
||||
const { t, locale } = useI18n()
|
||||
const releaseStore = useReleaseStore()
|
||||
|
||||
// State
|
||||
const isSubmenuVisible = ref(false)
|
||||
const submenuRef = ref<HTMLElement | null>(null)
|
||||
const submenuStyle = ref<CSSProperties>({})
|
||||
let hoverTimeout: number | null = null
|
||||
|
||||
// Computed
|
||||
const hasReleases = computed(() => releaseStore.releases.length > 0)
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
key: 'docs',
|
||||
icon: 'pi pi-book',
|
||||
label: t('helpCenter.docs'),
|
||||
action: () => openExternalLink(EXTERNAL_LINKS.DOCS)
|
||||
},
|
||||
{
|
||||
key: 'discord',
|
||||
icon: 'pi pi-discord',
|
||||
label: 'Discord',
|
||||
action: () => openExternalLink(EXTERNAL_LINKS.DISCORD)
|
||||
},
|
||||
{
|
||||
key: 'github',
|
||||
icon: 'pi pi-github',
|
||||
label: t('helpCenter.github'),
|
||||
action: () => openExternalLink(EXTERNAL_LINKS.GITHUB)
|
||||
},
|
||||
{
|
||||
key: 'help',
|
||||
icon: 'pi pi-question-circle',
|
||||
label: t('helpCenter.helpFeedback'),
|
||||
action: () => openExternalLink(EXTERNAL_LINKS.DISCORD)
|
||||
},
|
||||
{
|
||||
key: 'more',
|
||||
icon: '',
|
||||
label: t('helpCenter.more'),
|
||||
action: () => {} // No action for more item
|
||||
}
|
||||
])
|
||||
|
||||
const submenuItems = computed<SubmenuItem[]>(() => [
|
||||
{
|
||||
key: 'desktop-guide',
|
||||
type: 'item',
|
||||
label: t('helpCenter.desktopUserGuide'),
|
||||
action: () => openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE),
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
key: 'dev-tools',
|
||||
type: 'item',
|
||||
label: t('helpCenter.openDevTools'),
|
||||
action: openDevTools,
|
||||
disabled: !isElectron()
|
||||
},
|
||||
{
|
||||
key: 'divider-1',
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'reinstall',
|
||||
type: 'item',
|
||||
label: t('helpCenter.reinstall'),
|
||||
action: onReinstall,
|
||||
disabled: !isElectron()
|
||||
}
|
||||
])
|
||||
|
||||
// Utility Functions
|
||||
const openExternalLink = (url: string): void => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const clearHoverTimeout = (): void => {
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout)
|
||||
hoverTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
const calculateSubmenuPosition = (button: HTMLElement): CSSProperties => {
|
||||
const rect = button.getBoundingClientRect()
|
||||
const submenuWidth = 210 // Width defined in CSS
|
||||
|
||||
// Get actual submenu height if available, otherwise use estimated height
|
||||
const submenuHeight = submenuRef.value?.offsetHeight || 120 // More realistic estimate for 2 items
|
||||
|
||||
// Get viewport dimensions
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
// Calculate basic position (aligned with button)
|
||||
let top = rect.top
|
||||
let left = rect.right + SUBMENU_CONFIG.OFFSET_PX
|
||||
|
||||
// Check if submenu would overflow viewport on the right
|
||||
if (left + submenuWidth > viewportWidth) {
|
||||
// Position submenu to the left of the button instead
|
||||
left = rect.left - submenuWidth - SUBMENU_CONFIG.OFFSET_PX
|
||||
}
|
||||
|
||||
// Check if submenu would overflow viewport at the bottom
|
||||
if (top + submenuHeight > viewportHeight) {
|
||||
// Position submenu above the button, aligned to bottom
|
||||
top = Math.max(
|
||||
SUBMENU_CONFIG.OFFSET_PX, // Minimum distance from top of viewport
|
||||
rect.bottom - submenuHeight
|
||||
)
|
||||
}
|
||||
|
||||
// Ensure submenu doesn't go above viewport
|
||||
if (top < SUBMENU_CONFIG.OFFSET_PX) {
|
||||
top = SUBMENU_CONFIG.OFFSET_PX
|
||||
}
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
zIndex: SUBMENU_CONFIG.Z_INDEX
|
||||
}
|
||||
}
|
||||
|
||||
const formatReleaseDate = (dateString?: string): string => {
|
||||
if (!dateString) return 'date'
|
||||
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffTime = Math.abs(now.getTime() - date.getTime())
|
||||
|
||||
const timeUnits = [
|
||||
{ unit: TIME_UNITS.YEAR, suffix: 'y' },
|
||||
{ unit: TIME_UNITS.MONTH, suffix: 'mo' },
|
||||
{ unit: TIME_UNITS.WEEK, suffix: 'w' },
|
||||
{ unit: TIME_UNITS.DAY, suffix: 'd' },
|
||||
{ unit: TIME_UNITS.HOUR, suffix: 'h' },
|
||||
{ unit: TIME_UNITS.MINUTE, suffix: 'min' }
|
||||
]
|
||||
|
||||
for (const { unit, suffix } of timeUnits) {
|
||||
const value = Math.floor(diffTime / unit)
|
||||
if (value > 0) {
|
||||
return `${value}${suffix} ago`
|
||||
}
|
||||
}
|
||||
|
||||
return 'now'
|
||||
}
|
||||
|
||||
const shouldShowUpdateButton = (release: ReleaseNote): boolean => {
|
||||
return (
|
||||
releaseStore.shouldShowUpdateButton &&
|
||||
release === releaseStore.recentReleases[0]
|
||||
)
|
||||
}
|
||||
|
||||
// Event Handlers
|
||||
const onMenuItemHover = async (
|
||||
key: string,
|
||||
event: MouseEvent
|
||||
): Promise<void> => {
|
||||
if (key !== 'more') return
|
||||
|
||||
clearHoverTimeout()
|
||||
|
||||
const moreButton = event.currentTarget as HTMLElement
|
||||
|
||||
// Calculate initial position before showing submenu
|
||||
submenuStyle.value = calculateSubmenuPosition(moreButton)
|
||||
|
||||
// Show submenu with correct position
|
||||
isSubmenuVisible.value = true
|
||||
|
||||
// After submenu is rendered, refine position if needed
|
||||
await nextTick()
|
||||
if (submenuRef.value) {
|
||||
submenuStyle.value = calculateSubmenuPosition(moreButton)
|
||||
}
|
||||
}
|
||||
|
||||
const onMenuItemLeave = (key: string): void => {
|
||||
if (key !== 'more') return
|
||||
|
||||
hoverTimeout = window.setTimeout(() => {
|
||||
isSubmenuVisible.value = false
|
||||
}, SUBMENU_CONFIG.DELAY_MS)
|
||||
}
|
||||
|
||||
const onSubmenuHover = (): void => {
|
||||
clearHoverTimeout()
|
||||
}
|
||||
|
||||
const onSubmenuLeave = (): void => {
|
||||
isSubmenuVisible.value = false
|
||||
}
|
||||
|
||||
const openDevTools = (): void => {
|
||||
if (isElectron()) {
|
||||
electronAPI().openDevTools()
|
||||
}
|
||||
}
|
||||
|
||||
const onReinstall = (): void => {
|
||||
if (isElectron()) {
|
||||
void electronAPI().reinstall()
|
||||
}
|
||||
}
|
||||
|
||||
const onReleaseClick = (release: ReleaseNote): void => {
|
||||
void releaseStore.handleShowChangelog(release.version)
|
||||
const versionAnchor = formatVersionAnchor(release.version)
|
||||
const changelogUrl = `${getChangelogUrl()}#${versionAnchor}`
|
||||
openExternalLink(changelogUrl)
|
||||
}
|
||||
|
||||
const onUpdate = (_: ReleaseNote): void => {
|
||||
openExternalLink(EXTERNAL_LINKS.UPDATE_GUIDE)
|
||||
}
|
||||
|
||||
// Generate language-aware changelog URL
|
||||
const getChangelogUrl = (): string => {
|
||||
const isChineseLocale = locale.value === 'zh'
|
||||
return isChineseLocale
|
||||
? 'https://docs.comfy.org/zh-CN/changelog'
|
||||
: 'https://docs.comfy.org/changelog'
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
if (!hasReleases.value) {
|
||||
await releaseStore.fetchReleases()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help-center-menu {
|
||||
width: 380px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
background: var(--p-content-background);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
backdrop-filter: blur(8px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.help-menu-section {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--p-content-border-color);
|
||||
}
|
||||
|
||||
.help-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 0.9rem;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.help-menu-item:hover {
|
||||
background-color: #007aff26;
|
||||
}
|
||||
|
||||
.help-menu-item:focus,
|
||||
.help-menu-item:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.help-menu-icon {
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1rem;
|
||||
color: var(--p-text-muted-color);
|
||||
width: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.more-item {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.whats-new-section {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--p-text-muted-color);
|
||||
margin: 0 0 0.5rem 0;
|
||||
padding: 0 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.release-menu-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.release-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.release-title {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.release-date {
|
||||
height: 16px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--p-text-muted-color);
|
||||
}
|
||||
|
||||
.release-date .hover-state {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.release-menu-item:hover .release-date .normal-state,
|
||||
.release-menu-item:focus-within .release-date .normal-state {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.release-menu-item:hover .release-date .hover-state,
|
||||
.release-menu-item:focus-within .release-date .hover-state {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.update-button {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Submenu Styles */
|
||||
.more-submenu {
|
||||
width: 210px;
|
||||
padding: 0.5rem 0;
|
||||
background: var(--p-content-background);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
.submenu-item {
|
||||
padding: 0.75rem 1rem;
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.submenu-item:hover {
|
||||
background-color: #007aff26;
|
||||
}
|
||||
|
||||
.submenu-item:focus,
|
||||
.submenu-item:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.submenu-item.disabled,
|
||||
.submenu-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.submenu-divider {
|
||||
height: 1px;
|
||||
background: #3e3e3e;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
.help-center-menu::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.help-center-menu::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.help-center-menu::-webkit-scrollbar-thumb {
|
||||
background: var(--p-content-border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.help-center-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--p-text-muted-color);
|
||||
}
|
||||
|
||||
/* Reduced Motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.help-menu-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
308
src/components/helpcenter/ReleaseNotificationToast.vue
Normal file
308
src/components/helpcenter/ReleaseNotificationToast.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div v-if="shouldShow" class="release-toast-popup">
|
||||
<div class="release-notification-toast">
|
||||
<!-- Header section with icon and text -->
|
||||
<div class="toast-header">
|
||||
<div class="toast-icon">
|
||||
<i class="pi pi-download" />
|
||||
</div>
|
||||
<div class="toast-text">
|
||||
<div class="toast-title">
|
||||
{{ $t('releaseToast.newVersionAvailable') }}
|
||||
</div>
|
||||
<div class="toast-version-badge">
|
||||
{{ latestRelease?.version }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions section -->
|
||||
<div class="toast-actions-section">
|
||||
<div class="actions-row">
|
||||
<div class="left-actions">
|
||||
<a
|
||||
class="learn-more-link"
|
||||
:href="changelogUrl"
|
||||
target="_blank"
|
||||
rel="noopener,noreferrer"
|
||||
@click="handleLearnMore"
|
||||
>
|
||||
{{ $t('releaseToast.whatsNew') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="right-actions">
|
||||
<button class="skip-button" @click="handleSkip">
|
||||
{{ $t('releaseToast.skip') }}
|
||||
</button>
|
||||
<button class="cta-button" @click="handleUpdate">
|
||||
{{ $t('releaseToast.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ReleaseNote } from '@/services/releaseService'
|
||||
import { useReleaseStore } from '@/stores/releaseStore'
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const releaseStore = useReleaseStore()
|
||||
|
||||
// Local state for dismissed status
|
||||
const isDismissed = ref(false)
|
||||
|
||||
// Get latest release from store
|
||||
const latestRelease = computed<ReleaseNote | null>(
|
||||
() => releaseStore.recentRelease
|
||||
)
|
||||
|
||||
// Show toast when new version available and not dismissed
|
||||
const shouldShow = computed(
|
||||
() => releaseStore.shouldShowToast && !isDismissed.value
|
||||
)
|
||||
|
||||
// Generate changelog URL with version anchor (language-aware)
|
||||
const changelogUrl = computed(() => {
|
||||
const isChineseLocale = locale.value === 'zh'
|
||||
const baseUrl = isChineseLocale
|
||||
? 'https://docs.comfy.org/zh-CN/changelog'
|
||||
: 'https://docs.comfy.org/changelog'
|
||||
|
||||
if (latestRelease.value?.version) {
|
||||
const versionAnchor = formatVersionAnchor(latestRelease.value.version)
|
||||
return `${baseUrl}#${versionAnchor}`
|
||||
}
|
||||
return baseUrl
|
||||
})
|
||||
|
||||
// Auto-hide timer
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const startAutoHide = () => {
|
||||
if (hideTimer) clearTimeout(hideTimer)
|
||||
hideTimer = setTimeout(() => {
|
||||
dismissToast()
|
||||
}, 8000) // 8 second auto-hide
|
||||
}
|
||||
|
||||
const clearAutoHide = () => {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const dismissToast = () => {
|
||||
isDismissed.value = true
|
||||
clearAutoHide()
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
if (latestRelease.value) {
|
||||
void releaseStore.handleSkipRelease(latestRelease.value.version)
|
||||
}
|
||||
dismissToast()
|
||||
}
|
||||
|
||||
const handleLearnMore = () => {
|
||||
if (latestRelease.value) {
|
||||
void releaseStore.handleShowChangelog(latestRelease.value.version)
|
||||
}
|
||||
// Do not dismiss; anchor will navigate in new tab but keep toast? spec maybe wants dismiss? We'll dismiss.
|
||||
dismissToast()
|
||||
}
|
||||
|
||||
const handleUpdate = () => {
|
||||
window.open('https://docs.comfy.org/installation/update_comfyui', '_blank')
|
||||
dismissToast()
|
||||
}
|
||||
|
||||
// Learn more handled by anchor href
|
||||
|
||||
// Start auto-hide when toast becomes visible
|
||||
watch(shouldShow, (isVisible) => {
|
||||
if (isVisible) {
|
||||
startAutoHide()
|
||||
} else {
|
||||
clearAutoHide()
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize on mount
|
||||
onMounted(async () => {
|
||||
// Fetch releases if not already loaded
|
||||
if (!releaseStore.releases.length) {
|
||||
await releaseStore.fetchReleases()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Toast popup - positioning handled by parent */
|
||||
.release-toast-popup {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
z-index: 1000;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Sidebar positioning classes applied by parent - matching help center */
|
||||
.release-toast-popup.sidebar-left {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.release-toast-popup.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.release-toast-popup.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
/* Main toast container */
|
||||
.release-notification-toast {
|
||||
width: 448px;
|
||||
padding: 16px 16px 8px;
|
||||
background: #353535;
|
||||
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
|
||||
border-radius: 12px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Header section */
|
||||
.toast-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Icon container */
|
||||
.toast-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 10px;
|
||||
background: rgba(0, 122, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-icon i {
|
||||
color: #007aff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Text content */
|
||||
.toast-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-family: 'Satoshi', sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 18.2px;
|
||||
}
|
||||
|
||||
.toast-version-badge {
|
||||
color: #a0a1a2;
|
||||
font-size: 12px;
|
||||
font-family: 'Satoshi', sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 15.6px;
|
||||
}
|
||||
|
||||
/* Actions section */
|
||||
.toast-actions-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
padding-left: 58px; /* Align with text content */
|
||||
padding-right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.left-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Learn more link - simple text link */
|
||||
.learn-more-link {
|
||||
color: #60a5fa;
|
||||
font-size: 12px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 15.6px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.learn-more-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.right-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.skip-button {
|
||||
padding: 8px 16px;
|
||||
background: #353535;
|
||||
border-radius: 6px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
border: none;
|
||||
color: #aeaeb2;
|
||||
font-size: 12px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.skip-button:hover {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
border: none;
|
||||
color: black;
|
||||
font-size: 12px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
428
src/components/helpcenter/WhatsNewPopup.vue
Normal file
428
src/components/helpcenter/WhatsNewPopup.vue
Normal file
@@ -0,0 +1,428 @@
|
||||
<template>
|
||||
<div v-if="shouldShow" class="whats-new-popup-container">
|
||||
<!-- Arrow pointing to help center -->
|
||||
<div class="help-center-arrow">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="19"
|
||||
viewBox="0 0 16 19"
|
||||
fill="none"
|
||||
>
|
||||
<!-- Arrow fill -->
|
||||
<path
|
||||
d="M15.25 1.27246L15.25 17.7275L0.999023 9.5L15.25 1.27246Z"
|
||||
fill="#353535"
|
||||
/>
|
||||
<!-- Top and bottom outlines only -->
|
||||
<path
|
||||
d="M15.25 1.27246L0.999023 9.5"
|
||||
stroke="#4e4e4e"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M0.999023 9.5L15.25 17.7275"
|
||||
stroke="#4e4e4e"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="whats-new-popup" @click.stop>
|
||||
<!-- Close Button -->
|
||||
<button class="close-button" aria-label="Close" @click="closePopup">
|
||||
<div class="close-icon"></div>
|
||||
</button>
|
||||
|
||||
<!-- Release Content -->
|
||||
<div class="popup-content">
|
||||
<div class="content-text" v-html="formattedContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Section -->
|
||||
<div class="popup-actions">
|
||||
<a
|
||||
class="learn-more-link"
|
||||
:href="changelogUrl"
|
||||
target="_blank"
|
||||
rel="noopener,noreferrer"
|
||||
@click="closePopup"
|
||||
>
|
||||
{{ $t('whatsNewPopup.learnMore') }}
|
||||
</a>
|
||||
<!-- TODO: CTA button -->
|
||||
<!-- <button class="cta-button" @click="handleCTA">CTA</button> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ReleaseNote } from '@/services/releaseService'
|
||||
import { useReleaseStore } from '@/stores/releaseStore'
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const releaseStore = useReleaseStore()
|
||||
|
||||
// Local state for dismissed status
|
||||
const isDismissed = ref(false)
|
||||
|
||||
// Get latest release from store
|
||||
const latestRelease = computed<ReleaseNote | null>(
|
||||
() => releaseStore.recentRelease
|
||||
)
|
||||
|
||||
// Show popup when on latest version and not dismissed
|
||||
const shouldShow = computed(
|
||||
() => releaseStore.shouldShowPopup && !isDismissed.value
|
||||
)
|
||||
|
||||
// Generate changelog URL with version anchor (language-aware)
|
||||
const changelogUrl = computed(() => {
|
||||
const isChineseLocale = locale.value === 'zh'
|
||||
const baseUrl = isChineseLocale
|
||||
? 'https://docs.comfy.org/zh-CN/changelog'
|
||||
: 'https://docs.comfy.org/changelog'
|
||||
|
||||
if (latestRelease.value?.version) {
|
||||
const versionAnchor = formatVersionAnchor(latestRelease.value.version)
|
||||
return `${baseUrl}#${versionAnchor}`
|
||||
}
|
||||
return baseUrl
|
||||
})
|
||||
|
||||
// Format release content for display using marked
|
||||
const formattedContent = computed(() => {
|
||||
if (!latestRelease.value?.content) {
|
||||
return '<p>No release notes available.</p>'
|
||||
}
|
||||
|
||||
try {
|
||||
// Use marked to parse markdown to HTML
|
||||
return marked(latestRelease.value.content, {
|
||||
breaks: true, // Convert line breaks to <br>
|
||||
gfm: true // Enable GitHub Flavored Markdown
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error parsing markdown:', error)
|
||||
// Fallback to plain text with line breaks
|
||||
return latestRelease.value.content.replace(/\n/g, '<br>')
|
||||
}
|
||||
})
|
||||
|
||||
const show = () => {
|
||||
isDismissed.value = false
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
isDismissed.value = true
|
||||
}
|
||||
|
||||
const closePopup = async () => {
|
||||
// Mark "what's new" seen when popup is closed
|
||||
if (latestRelease.value) {
|
||||
await releaseStore.handleWhatsNewSeen(latestRelease.value.version)
|
||||
}
|
||||
hide()
|
||||
}
|
||||
|
||||
// Learn more handled by anchor href
|
||||
|
||||
// const handleCTA = async () => {
|
||||
// window.open('https://docs.comfy.org/installation/update_comfyui', '_blank')
|
||||
// await closePopup()
|
||||
// }
|
||||
|
||||
// Initialize on mount
|
||||
onMounted(async () => {
|
||||
// Fetch releases if not already loaded
|
||||
if (!releaseStore.releases.length) {
|
||||
await releaseStore.fetchReleases()
|
||||
}
|
||||
})
|
||||
|
||||
// Expose methods for parent component
|
||||
defineExpose({
|
||||
show,
|
||||
hide
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Popup container - positioning handled by parent */
|
||||
.whats-new-popup-container {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
z-index: 1000;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Arrow pointing to help center */
|
||||
.help-center-arrow {
|
||||
position: absolute;
|
||||
bottom: calc(
|
||||
var(--sidebar-width, 4rem) + 0.25rem
|
||||
); /* Position toward center of help center icon */
|
||||
transform: none;
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Position arrow based on sidebar location */
|
||||
.whats-new-popup-container.sidebar-left .help-center-arrow {
|
||||
left: -14px; /* Overlap with popup outline */
|
||||
}
|
||||
|
||||
.whats-new-popup-container.sidebar-left.small-sidebar .help-center-arrow {
|
||||
left: -14px; /* Overlap with popup outline */
|
||||
bottom: calc(2.5rem + 0.25rem); /* Adjust for small sidebar */
|
||||
}
|
||||
|
||||
/* Sidebar positioning classes applied by parent */
|
||||
.whats-new-popup-container.sidebar-left {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.whats-new-popup-container.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.whats-new-popup-container.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.whats-new-popup {
|
||||
padding: 32px 32px 24px;
|
||||
background: #353535;
|
||||
border-radius: 12px;
|
||||
max-width: 400px;
|
||||
width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
box-shadow: 0px 8px 32px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Content Section */
|
||||
.popup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Close button */
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
padding: 6px 7px;
|
||||
background: #7c7c7c;
|
||||
border-radius: 15.5px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transform: translate(50%, -50%);
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.1s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: #8e8e8e;
|
||||
}
|
||||
|
||||
.close-button:active {
|
||||
background: #6a6a6a;
|
||||
transform: translate(50%, -50%) scale(0.95);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: relative;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover .close-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.close-icon::before,
|
||||
.close-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 2px;
|
||||
background: white;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.close-icon::after {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
/* Content Section */
|
||||
.popup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Style the markdown content */
|
||||
.content-text :deep(h1) {
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.content-text :deep(h2) {
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 16px 0 12px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.content-text :deep(h2:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content-text :deep(h3) {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 12px 0 8px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.content-text :deep(h3:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content-text :deep(h4) {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 8px 0 6px 0;
|
||||
}
|
||||
|
||||
.content-text :deep(h4:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content-text :deep(p) {
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content-text :deep(p:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content-text :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.content-text :deep(ul),
|
||||
.content-text :deep(ol) {
|
||||
margin: 0 0 12px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.content-text :deep(ul:first-child),
|
||||
.content-text :deep(ol:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content-text :deep(ul:last-child),
|
||||
.content-text :deep(ol:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Remove top margin for first media element */
|
||||
.content-text :deep(img:first-child),
|
||||
.content-text :deep(video:first-child),
|
||||
.content-text :deep(iframe:first-child) {
|
||||
margin-top: -32px; /* Align with the top edge of the popup content */
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Media elements */
|
||||
.content-text :deep(img),
|
||||
.content-text :deep(video),
|
||||
.content-text :deep(iframe) {
|
||||
width: calc(100% + 64px);
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
margin: 12px -32px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Actions Section */
|
||||
.popup-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.learn-more-link {
|
||||
color: #60a5fa;
|
||||
font-size: 14px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 18.2px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.learn-more-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
border: none;
|
||||
color: #121212;
|
||||
font-size: 14px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
@@ -95,12 +95,14 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
return
|
||||
}
|
||||
|
||||
disconnectOnReset = false
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: getNewNodeLocation()
|
||||
})
|
||||
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
if (disconnectOnReset) {
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
}
|
||||
disconnectOnReset = false
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<div class="side-tool-bar-end">
|
||||
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
|
||||
<SidebarThemeToggleIcon />
|
||||
<SidebarHelpCenterIcon />
|
||||
<SidebarSettingsToggleIcon />
|
||||
</div>
|
||||
</nav>
|
||||
@@ -36,6 +37,7 @@ import { useUserStore } from '@/stores/userStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
|
||||
import SidebarSettingsToggleIcon from './SidebarSettingsToggleIcon.vue'
|
||||
|
||||
157
src/components/sidebar/SidebarHelpCenterIcon.vue
Normal file
157
src/components/sidebar/SidebarHelpCenterIcon.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div>
|
||||
<SidebarIcon
|
||||
icon="pi pi-question-circle"
|
||||
class="comfy-help-center-btn"
|
||||
:tooltip="$t('sideToolbar.helpCenter')"
|
||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||
@click="toggleHelpCenter"
|
||||
/>
|
||||
|
||||
<!-- Help Center Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': sidebarSize === 'small'
|
||||
}"
|
||||
>
|
||||
<HelpCenterMenuContent @close="closeHelpCenter" />
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Release Notification Toast positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<ReleaseNotificationToast
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': sidebarSize === 'small'
|
||||
}"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- WhatsNew Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<WhatsNewPopup
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': sidebarSize === 'small'
|
||||
}"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- Backdrop to close popup when clicking outside -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-backdrop"
|
||||
@click="closeHelpCenter"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
|
||||
import ReleaseNotificationToast from '@/components/helpcenter/ReleaseNotificationToast.vue'
|
||||
import WhatsNewPopup from '@/components/helpcenter/WhatsNewPopup.vue'
|
||||
import { useReleaseStore } from '@/stores/releaseStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const releaseStore = useReleaseStore()
|
||||
const { shouldShowRedDot } = storeToRefs(releaseStore)
|
||||
const isHelpCenterVisible = ref(false)
|
||||
|
||||
const sidebarLocation = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
|
||||
const sidebarSize = computed(() => settingStore.get('Comfy.Sidebar.Size'))
|
||||
|
||||
const toggleHelpCenter = () => {
|
||||
isHelpCenterVisible.value = !isHelpCenterVisible.value
|
||||
}
|
||||
|
||||
const closeHelpCenter = () => {
|
||||
isHelpCenterVisible.value = false
|
||||
}
|
||||
|
||||
// Initialize release store on mount
|
||||
onMounted(async () => {
|
||||
// Initialize release store to fetch releases for toast and popup
|
||||
await releaseStore.initialize()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help-center-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.help-center-popup {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
z-index: 1000;
|
||||
animation: slideInUp 0.2s ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-left {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.p-badge) {
|
||||
background: #ff3b30;
|
||||
color: #ff3b30;
|
||||
min-width: 8px;
|
||||
height: 8px;
|
||||
padding: 0;
|
||||
border-radius: 9999px;
|
||||
font-size: 0;
|
||||
margin-top: 4px;
|
||||
margin-right: 4px;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.p-badge.p-badge-dot) {
|
||||
width: 8px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -12,7 +12,7 @@
|
||||
/>
|
||||
<span class="ml-2 font-semibold">{{ node.display_name }}</span>
|
||||
</div>
|
||||
<div class="p-4 flex-grow node-help-content max-w-[600px] mx-auto">
|
||||
<div class="p-4 flex-grow node-help-content w-full mx-auto">
|
||||
<ProgressSpinner
|
||||
v-if="isLoading"
|
||||
class="m-auto"
|
||||
@@ -188,7 +188,7 @@ const outputList = computed(() =>
|
||||
|
||||
.markdown-content :deep(li),
|
||||
.fallback-content li {
|
||||
@apply my-1;
|
||||
@apply my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(*:first-child),
|
||||
@@ -198,7 +198,9 @@ const outputList = computed(() =>
|
||||
|
||||
.markdown-content :deep(code),
|
||||
.fallback-content code {
|
||||
@apply text-[var(--error-text)] bg-[var(--content-bg)] rounded px-1 py-0.5;
|
||||
color: var(--code-text-color);
|
||||
background-color: var(--code-bg-color);
|
||||
@apply rounded px-1.5 py-0.5;
|
||||
}
|
||||
|
||||
.markdown-content :deep(table),
|
||||
@@ -227,4 +229,15 @@ const outputList = computed(() =>
|
||||
.fallback-content thead {
|
||||
border-bottom: 1px solid var(--p-text-color);
|
||||
}
|
||||
|
||||
.markdown-content :deep(pre),
|
||||
.fallback-content pre {
|
||||
@apply rounded p-4 my-4 overflow-x-auto;
|
||||
background-color: var(--code-block-bg-color);
|
||||
|
||||
code {
|
||||
@apply bg-transparent p-0;
|
||||
color: var(--p-text-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="absolute top-0 left-0 w-auto max-w-full">
|
||||
<div class="w-auto max-w-full">
|
||||
<WorkflowTabs />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -66,50 +66,150 @@ The following diagram shows how composables fit into the application architectur
|
||||
|
||||
## Composable Categories
|
||||
|
||||
ComfyUI's composables are organized into several categories:
|
||||
The following tables list ALL composables in the system as of 2025-01-30:
|
||||
|
||||
### Auth
|
||||
|
||||
Composables for authentication and user management:
|
||||
- `useCurrentUser` - Provides access to the current user information
|
||||
- `useFirebaseAuthActions` - Handles Firebase authentication operations
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
| `useCurrentUser` | Provides access to the current user information |
|
||||
| `useFirebaseAuthActions` | Handles Firebase authentication operations |
|
||||
|
||||
### Bottom Panel Tabs
|
||||
Composables for terminal and bottom panel functionality:
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
| `useTerminal` | Core terminal functionality |
|
||||
| `useTerminalBuffer` | Manages terminal output buffer |
|
||||
| `useTerminalTabs` | Handles multiple terminal tab management |
|
||||
|
||||
### Element
|
||||
|
||||
Composables for DOM and element interactions:
|
||||
- `useAbsolutePosition` - Handles element positioning
|
||||
- `useDomClipping` - Manages clipping of DOM elements
|
||||
- `useResponsiveCollapse` - Manages responsive collapsing of elements
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
| `useAbsolutePosition` | Handles element positioning |
|
||||
| `useCanvasPositionConversion` | Converts between canvas and DOM coordinates |
|
||||
| `useDomClipping` | Manages clipping of DOM elements |
|
||||
| `useResponsiveCollapse` | Manages responsive collapsing of elements |
|
||||
|
||||
### Functional
|
||||
Utility composables for common patterns:
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
| `useChainCallback` | Chains multiple callbacks together |
|
||||
|
||||
### Manager
|
||||
Composables for ComfyUI Manager integration:
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
| `useManagerStatePersistence` | Persists manager UI state |
|
||||
|
||||
### Node Pack
|
||||
Composables for node package management:
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
| `useInstalledPacks` | Manages installed node packages |
|
||||
| `useMissingNodes` | Detects and handles missing nodes |
|
||||
| `useNodePacks` | Core node package functionality |
|
||||
| `usePackUpdateStatus` | Tracks package update availability |
|
||||
| `useWorkflowPacks` | Manages packages used in workflows |
|
||||
|
||||
### Node
|
||||
|
||||
Composables for node-specific functionality:
|
||||
- `useNodeBadge` - Handles node badge display and interaction
|
||||
- `useNodeImage` - Manages node image preview
|
||||
- `useNodeDragAndDrop` - Handles drag and drop for nodes
|
||||
- `useNodeChatHistory` - Manages chat history for nodes
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
| `useNodeAnimatedImage` | Handles animated images in nodes |
|
||||
| `useNodeBadge` | Handles node badge display and interaction |
|
||||
| `useNodeCanvasImagePreview` | Canvas-based image preview for nodes |
|
||||
| `useNodeChatHistory` | Manages chat history for nodes |
|
||||
| `useNodeDragAndDrop` | Handles drag and drop for nodes |
|
||||
| `useNodeFileInput` | Manages file input widgets in nodes |
|
||||
| `useNodeImage` | Manages node image preview |
|
||||
| `useNodeImageUpload` | Handles image upload for nodes |
|
||||
| `useNodePaste` | Manages paste operations for nodes |
|
||||
| `useNodePricing` | Handles pricing display for nodes |
|
||||
| `useNodeProgressText` | Displays progress text in nodes |
|
||||
| `useWatchWidget` | Watches widget value changes |
|
||||
|
||||
### Settings
|
||||
|
||||
Composables for settings management:
|
||||
- `useSettingSearch` - Provides search functionality for settings
|
||||
- `useSettingUI` - Manages settings UI interactions
|
||||
|
||||
### Sidebar
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
| `useSettingSearch` | Provides search functionality for settings |
|
||||
| `useSettingUI` | Manages settings UI interactions |
|
||||
|
||||
### Sidebar Tabs
|
||||
Composables for sidebar functionality:
|
||||
- `useNodeLibrarySidebarTab` - Manages the node library sidebar tab
|
||||
- `useQueueSidebarTab` - Manages the queue sidebar tab
|
||||
- `useWorkflowsSidebarTab` - Manages the workflows sidebar tab
|
||||
- `useTemplateWorkflows` - Manages template workflow loading, selection, and display
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
| `useModelLibrarySidebarTab` | Manages the model library sidebar tab |
|
||||
| `useNodeLibrarySidebarTab` | Manages the node library sidebar tab |
|
||||
| `useQueueSidebarTab` | Manages the queue sidebar tab |
|
||||
| `useWorkflowsSidebarTab` | Manages the workflows sidebar tab |
|
||||
|
||||
### Tree
|
||||
Composables for tree structure operations:
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
| `useTreeFolderOperations` | Handles folder operations in tree views |
|
||||
|
||||
### Widgets
|
||||
|
||||
Composables for widget functionality:
|
||||
- `useBooleanWidget` - Manages boolean widget interactions
|
||||
- `useComboWidget` - Manages combo box widget interactions
|
||||
- `useFloatWidget` - Manages float input widget interactions
|
||||
- `useImagePreviewWidget` - Manages image preview widget
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
| `useBooleanWidget` | Manages boolean widget interactions |
|
||||
| `useChatHistoryWidget` | Handles chat history widget |
|
||||
| `useComboWidget` | Manages combo box widget interactions |
|
||||
| `useFloatWidget` | Manages float input widget interactions |
|
||||
| `useImagePreviewWidget` | Manages image preview widget |
|
||||
| `useImageUploadWidget` | Handles image upload widget |
|
||||
| `useIntWidget` | Manages integer input widget |
|
||||
| `useMarkdownWidget` | Handles markdown display widget |
|
||||
| `useProgressTextWidget` | Manages progress text widget |
|
||||
| `useRemoteWidget` | Handles remote widget connections |
|
||||
| `useStringWidget` | Manages string input widget |
|
||||
|
||||
### Root-level Composables
|
||||
General-purpose composables:
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
| `useBrowserTabTitle` | Manages browser tab title updates |
|
||||
| `useCachedRequest` | Provides request caching functionality |
|
||||
| `useCanvasDrop` | Handles drop operations on canvas |
|
||||
| `useCivitaiModel` | Integrates with Civitai model API |
|
||||
| `useContextMenuTranslation` | Handles context menu translations |
|
||||
| `useCopy` | Provides copy functionality |
|
||||
| `useCopyToClipboard` | Manages clipboard operations |
|
||||
| `useCoreCommands` | Provides core command functionality |
|
||||
| `useDownload` | Handles file download operations |
|
||||
| `useErrorHandling` | Centralized error handling |
|
||||
| `useGlobalLitegraph` | Access to global LiteGraph instance |
|
||||
| `useLitegraphSettings` | Manages LiteGraph configuration |
|
||||
| `useManagerQueue` | Handles manager queue operations |
|
||||
| `usePaste` | Provides paste functionality |
|
||||
| `usePragmaticDragAndDrop` | Integrates Atlassian's drag-and-drop library |
|
||||
| `useProgressFavicon` | Updates favicon with progress indicator |
|
||||
| `useRefreshableSelection` | Manages refreshable selections |
|
||||
| `useRegistrySearch` | Searches the ComfyUI registry |
|
||||
| `useServerLogs` | Manages server log display |
|
||||
| `useTemplateWorkflows` | Manages template workflow loading, selection, and display |
|
||||
| `useTreeExpansion` | Handles tree node expansion state |
|
||||
| `useValueTransform` | Transforms values between formats |
|
||||
| `useWorkflowAutoSave` | Handles automatic workflow saving |
|
||||
| `useWorkflowPersistence` | Manages workflow persistence |
|
||||
| `useWorkflowValidation` | Validates workflow integrity |
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
|
||||
@@ -14,11 +14,12 @@ import {
|
||||
import { t } from '@/i18n'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { addFluxKontextGroupNode } from '@/scripts/fluxKontextEditNode'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
@@ -37,6 +38,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
|
||||
const getSelectedNodes = (): LGraphNode[] => {
|
||||
@@ -718,6 +720,41 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Move Selected Nodes Right',
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x + gridSize, y])
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.AddEditModelStep',
|
||||
icon: 'pi pi-pen-to-square',
|
||||
label: 'Add Edit Model Step',
|
||||
versionAdded: '1.23.3',
|
||||
function: async () => {
|
||||
const node = app.canvas.selectedItems.values().next().value
|
||||
if (!(node instanceof LGraphNode)) return
|
||||
await addFluxKontextGroupNode(node)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.ConvertToSubgraph',
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Convert Selection to Subgraph',
|
||||
versionAdded: '1.20.1',
|
||||
function: () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
|
||||
|
||||
const res = graph.convertToSubgraph(canvas.selectedItems)
|
||||
if (!res) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.cannotCreateSubgraph'),
|
||||
detail: t('toastMessages.failedToConvertToSubgraph'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export function useErrorHandling() {
|
||||
summary: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
const wrapWithErrorHandling =
|
||||
|
||||
@@ -173,5 +173,13 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
key: 'f'
|
||||
},
|
||||
commandId: 'Workspace.ToggleFocusMode'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'e',
|
||||
ctrl: true,
|
||||
shift: true
|
||||
},
|
||||
commandId: 'Comfy.Graph.ConvertToSubgraph'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -847,5 +847,24 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
versionAdded: '1.19.1'
|
||||
},
|
||||
// Release data stored in settings
|
||||
{
|
||||
id: 'Comfy.Release.Version',
|
||||
name: 'Last seen release version',
|
||||
type: 'hidden',
|
||||
defaultValue: ''
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Release.Status',
|
||||
name: 'Release status',
|
||||
type: 'hidden',
|
||||
defaultValue: 'skipped'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Release.Timestamp',
|
||||
name: 'Release seen timestamp',
|
||||
type: 'hidden',
|
||||
defaultValue: 0
|
||||
}
|
||||
]
|
||||
|
||||
@@ -25,20 +25,85 @@ ComfyUI's extension system follows these key principles:
|
||||
|
||||
## Core Extensions List
|
||||
|
||||
The core extensions include:
|
||||
The following table lists ALL core extensions in the system as of 2025-01-30:
|
||||
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| clipspace.ts | Implements the Clipspace feature for temporary image storage |
|
||||
| dynamicPrompts.ts | Provides dynamic prompt generation capabilities |
|
||||
| groupNode.ts | Implements the group node functionality to organize workflows |
|
||||
| load3d.ts | Supports 3D model loading and visualization |
|
||||
| maskeditor.ts | Implements the mask editor for image masking operations |
|
||||
| noteNode.ts | Adds note nodes for documentation within workflows |
|
||||
| rerouteNode.ts | Implements reroute nodes for cleaner workflow connections |
|
||||
| uploadImage.ts | Handles image upload functionality |
|
||||
| webcamCapture.ts | Provides webcam capture capabilities |
|
||||
| widgetInputs.ts | Implements various widget input types |
|
||||
### Main Extensions
|
||||
|
||||
| Extension | Description | Category |
|
||||
|-----------|-------------|----------|
|
||||
| clipspace.ts | Implements the Clipspace feature for temporary image storage | Image |
|
||||
| contextMenuFilter.ts | Provides context menu filtering capabilities | UI |
|
||||
| dynamicPrompts.ts | Provides dynamic prompt generation capabilities | Prompts |
|
||||
| editAttention.ts | Implements attention editing functionality | Text |
|
||||
| electronAdapter.ts | Adapts functionality for Electron environment | Platform |
|
||||
| groupNode.ts | Implements the group node functionality to organize workflows | Graph |
|
||||
| groupNodeManage.ts | Provides group node management operations | Graph |
|
||||
| groupOptions.ts | Handles group node configuration options | Graph |
|
||||
| index.ts | Main extension registration and coordination | Core |
|
||||
| load3d.ts | Supports 3D model loading and visualization | 3D |
|
||||
| maskEditorOld.ts | Legacy mask editor implementation | Image |
|
||||
| maskeditor.ts | Implements the mask editor for image masking operations | Image |
|
||||
| nodeTemplates.ts | Provides node template functionality | Templates |
|
||||
| noteNode.ts | Adds note nodes for documentation within workflows | Graph |
|
||||
| previewAny.ts | Universal preview functionality for various data types | Preview |
|
||||
| rerouteNode.ts | Implements reroute nodes for cleaner workflow connections | Graph |
|
||||
| saveImageExtraOutput.ts | Handles additional image output saving | Image |
|
||||
| saveMesh.ts | Implements 3D mesh saving functionality | 3D |
|
||||
| simpleTouchSupport.ts | Provides basic touch interaction support | Input |
|
||||
| slotDefaults.ts | Manages default values for node slots | Nodes |
|
||||
| uploadAudio.ts | Handles audio file upload functionality | Audio |
|
||||
| uploadImage.ts | Handles image upload functionality | Image |
|
||||
| webcamCapture.ts | Provides webcam capture capabilities | Media |
|
||||
| widgetInputs.ts | Implements various widget input types | Widgets |
|
||||
|
||||
### load3d Subdirectory
|
||||
Located in `extensions/core/load3d/`:
|
||||
|
||||
| File | Description | Type |
|
||||
|------|-------------|------|
|
||||
| AnimationManager.ts | Manages 3D animations | Manager |
|
||||
| CameraManager.ts | Handles camera controls and positioning | Manager |
|
||||
| ControlsManager.ts | Manages 3D scene controls | Manager |
|
||||
| EventManager.ts | Handles 3D scene events | Manager |
|
||||
| interfaces.ts | TypeScript interfaces for 3D functionality | Types |
|
||||
| LightingManager.ts | Manages scene lighting | Manager |
|
||||
| Load3DConfiguration.ts | Configuration for 3D loading | Config |
|
||||
| Load3d.ts | Core 3D loading functionality | Core |
|
||||
| Load3dAnimation.ts | Animation-specific 3D operations | Animation |
|
||||
| Load3dUtils.ts | Utility functions for 3D operations | Utils |
|
||||
| LoaderManager.ts | Manages various 3D file loaders | Manager |
|
||||
| ModelExporter.ts | Exports 3D models to different formats | Export |
|
||||
| ModelManager.ts | Manages 3D model lifecycle | Manager |
|
||||
| NodeStorage.ts | Handles storage for 3D nodes | Storage |
|
||||
| PreviewManager.ts | Manages 3D model previews | Manager |
|
||||
| RecordingManager.ts | Handles 3D scene recording | Manager |
|
||||
| SceneManager.ts | Core 3D scene management | Manager |
|
||||
| ViewHelperManager.ts | Manages 3D view helpers and gizmos | Manager |
|
||||
|
||||
### Conditional Lines Subdirectory
|
||||
Located in `extensions/core/load3d/conditional-lines/`:
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| ColoredShadowMaterial.js | Material for colored shadow rendering |
|
||||
| ConditionalEdgesGeometry.js | Geometry for conditional edge rendering |
|
||||
| ConditionalEdgesShader.js | Shader for conditional edges |
|
||||
| OutsideEdgesGeometry.js | Geometry for outside edge detection |
|
||||
|
||||
### Lines2 Subdirectory
|
||||
Located in `extensions/core/load3d/conditional-lines/Lines2/`:
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| ConditionalLineMaterial.js | Material for conditional line rendering |
|
||||
| ConditionalLineSegmentsGeometry.js | Geometry for conditional line segments |
|
||||
|
||||
### ThreeJS Override Subdirectory
|
||||
Located in `extensions/core/load3d/threejsOverride/`:
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| OverrideMTLLoader.js | Custom MTL loader with enhanced functionality |
|
||||
|
||||
## Extension Development
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
|
||||
import { LiteGraph } from '@comfyorg/litegraph'
|
||||
import { LGraphNode, type NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
@@ -1583,57 +1583,6 @@ export class GroupNodeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
function addConvertToGroupOptions() {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
function addConvertOption(options, index) {
|
||||
const selected = Object.values(app.canvas.selected_nodes ?? {})
|
||||
const disabled =
|
||||
selected.length < 2 ||
|
||||
selected.find((n) => GroupNodeHandler.isGroupNode(n))
|
||||
options.splice(index, null, {
|
||||
content: `Convert to Group Node`,
|
||||
disabled,
|
||||
callback: convertSelectedNodesToGroupNode
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
function addManageOption(options, index) {
|
||||
const groups = app.graph.extra?.groupNodes
|
||||
const disabled = !groups || !Object.keys(groups).length
|
||||
options.splice(index, null, {
|
||||
content: `Manage Group Nodes`,
|
||||
disabled,
|
||||
callback: () => manageGroupNodes()
|
||||
})
|
||||
}
|
||||
|
||||
// Add to canvas
|
||||
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const options = getCanvasMenuOptions.apply(this, arguments)
|
||||
const index = options.findIndex((o) => o?.content === 'Add Group')
|
||||
const insertAt = index === -1 ? options.length - 1 : index + 2
|
||||
addConvertOption(options, insertAt)
|
||||
addManageOption(options, insertAt + 1)
|
||||
return options
|
||||
}
|
||||
|
||||
// Add to nodes
|
||||
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const options = getNodeMenuOptions.apply(this, arguments)
|
||||
if (!GroupNodeHandler.isGroupNode(node)) {
|
||||
const index = options.findIndex((o) => o?.content === 'Properties')
|
||||
const insertAt = index === -1 ? options.length - 1 : index
|
||||
addConvertOption(options, insertAt)
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
|
||||
for (const node of nodes) {
|
||||
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
|
||||
@@ -1723,9 +1672,6 @@ const ext: ComfyExtension = {
|
||||
}
|
||||
}
|
||||
],
|
||||
setup() {
|
||||
addConvertToGroupOptions()
|
||||
},
|
||||
async beforeConfigureGraph(
|
||||
graphData: ComfyWorkflowJSON,
|
||||
missingNodeTypes: string[]
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
import { type ComfyApp, app } from '../../scripts/app'
|
||||
import { $el, ComfyDialog } from '../../scripts/ui'
|
||||
import { $el } from '../../scripts/ui'
|
||||
import { ComfyDialog } from '../../scripts/ui/dialog'
|
||||
import { DraggableList } from '../../scripts/ui/draggableList'
|
||||
import { GroupNodeConfig, GroupNodeHandler } from './groupNode'
|
||||
import './groupNodeManage.css'
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "Browse Templates"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "Add Edit Model Step"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "Delete Selected Items"
|
||||
},
|
||||
@@ -110,6 +113,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Give Feedback"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convert Selection to Subgraph"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Fit Group To Contents"
|
||||
},
|
||||
|
||||
@@ -410,6 +410,7 @@
|
||||
},
|
||||
"sideToolbar": {
|
||||
"themeToggle": "Toggle Theme",
|
||||
"helpCenter": "Help Center",
|
||||
"logout": "Logout",
|
||||
"queue": "Queue",
|
||||
"nodeLibrary": "Node Library",
|
||||
@@ -468,6 +469,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"helpCenter": {
|
||||
"docs": "Docs",
|
||||
"github": "Github",
|
||||
"helpFeedback": "Help & Feedback",
|
||||
"more": "More...",
|
||||
"whatsNew": "What's New?",
|
||||
"clickToLearnMore": "Click to learn more →",
|
||||
"loadingReleases": "Loading releases...",
|
||||
"noRecentReleases": "No recent releases",
|
||||
"updateAvailable": "Update",
|
||||
"desktopUserGuide": "Desktop User Guide",
|
||||
"openDevTools": "Open Dev Tools",
|
||||
"reinstall": "Re-Install"
|
||||
},
|
||||
"releaseToast": {
|
||||
"newVersionAvailable": "New Version Available!",
|
||||
"whatsNew": "What's New?",
|
||||
"skip": "Skip",
|
||||
"update": "Update"
|
||||
},
|
||||
"menu": {
|
||||
"hideMenu": "Hide Menu",
|
||||
"showMenu": "Show Menu",
|
||||
@@ -793,6 +814,7 @@
|
||||
"Reinstall": "Reinstall",
|
||||
"Restart": "Restart",
|
||||
"Browse Templates": "Browse Templates",
|
||||
"Add Edit Model Step": "Add Edit Model Step",
|
||||
"Delete Selected Items": "Delete Selected Items",
|
||||
"Fit view to selected nodes": "Fit view to selected nodes",
|
||||
"Move Selected Nodes Down": "Move Selected Nodes Down",
|
||||
@@ -817,6 +839,7 @@
|
||||
"Export": "Export",
|
||||
"Export (API)": "Export (API)",
|
||||
"Give Feedback": "Give Feedback",
|
||||
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
|
||||
"Fit Group To Contents": "Fit Group To Contents",
|
||||
"Group Selected Nodes": "Group Selected Nodes",
|
||||
"Convert selected nodes to group node": "Convert selected nodes to group node",
|
||||
@@ -1302,7 +1325,9 @@
|
||||
"failedToPurchaseCredits": "Failed to purchase credits: {error}",
|
||||
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist.",
|
||||
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
|
||||
"nothingSelected": "Nothing selected"
|
||||
"nothingSelected": "Nothing selected",
|
||||
"cannotCreateSubgraph": "Cannot create subgraph",
|
||||
"failedToConvertToSubgraph": "Failed to convert items to subgraph"
|
||||
},
|
||||
"auth": {
|
||||
"apiKey": {
|
||||
@@ -1397,6 +1422,7 @@
|
||||
"personalDataConsentRequired": "You must agree to the processing of your personal data."
|
||||
},
|
||||
"credits": {
|
||||
"activity": "Activity",
|
||||
"credits": "Credits",
|
||||
"yourCreditBalance": "Your credit balance",
|
||||
"purchaseCredits": "Purchase Credits",
|
||||
@@ -1413,7 +1439,14 @@
|
||||
"buyNow": "Buy now",
|
||||
"seeDetails": "See details",
|
||||
"topUp": "Top Up"
|
||||
}
|
||||
},
|
||||
"eventType": "Event Type",
|
||||
"details": "Details",
|
||||
"time": "Time",
|
||||
"additionalInfo": "Additional Info",
|
||||
"model": "Model",
|
||||
"added": "Added",
|
||||
"accountInitialized": "Account initialized"
|
||||
},
|
||||
"userSettings": {
|
||||
"title": "User Settings",
|
||||
@@ -1443,5 +1476,8 @@
|
||||
"moreHelp": "For more help, visit the",
|
||||
"documentationPage": "documentation page",
|
||||
"loadError": "Failed to load help: {error}"
|
||||
},
|
||||
"whatsNewPopup": {
|
||||
"learnMore": "Learn more"
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,9 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "Explorar plantillas"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "Agregar paso de edición de modelo"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "Eliminar elementos seleccionados"
|
||||
},
|
||||
@@ -110,6 +113,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Dar retroalimentación"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convertir selección en subgrafo"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Ajustar grupo al contenido"
|
||||
},
|
||||
|
||||
@@ -138,13 +138,21 @@
|
||||
"Unpin": "Desanclar"
|
||||
},
|
||||
"credits": {
|
||||
"accountInitialized": "Cuenta inicializada",
|
||||
"activity": "Actividad",
|
||||
"added": "Añadido",
|
||||
"additionalInfo": "Información adicional",
|
||||
"apiPricing": "Precios de la API",
|
||||
"credits": "Créditos",
|
||||
"details": "Detalles",
|
||||
"eventType": "Tipo de evento",
|
||||
"faqs": "Preguntas frecuentes",
|
||||
"invoiceHistory": "Historial de facturas",
|
||||
"lastUpdated": "Última actualización",
|
||||
"messageSupport": "Contactar soporte",
|
||||
"model": "Modelo",
|
||||
"purchaseCredits": "Comprar créditos",
|
||||
"time": "Hora",
|
||||
"topUp": {
|
||||
"buyNow": "Comprar ahora",
|
||||
"insufficientMessage": "No tienes suficientes créditos para ejecutar este flujo de trabajo.",
|
||||
@@ -381,6 +389,20 @@
|
||||
"create": "Crear nodo de grupo",
|
||||
"enterName": "Introduzca el nombre"
|
||||
},
|
||||
"helpCenter": {
|
||||
"clickToLearnMore": "Haz clic para saber más →",
|
||||
"desktopUserGuide": "Guía de usuario de escritorio",
|
||||
"docs": "Documentación",
|
||||
"github": "Github",
|
||||
"helpFeedback": "Ayuda y comentarios",
|
||||
"loadingReleases": "Cargando versiones...",
|
||||
"more": "Más...",
|
||||
"noRecentReleases": "No hay versiones recientes",
|
||||
"openDevTools": "Abrir herramientas de desarrollo",
|
||||
"reinstall": "Reinstalar",
|
||||
"updateAvailable": "Actualizar",
|
||||
"whatsNew": "¿Qué hay de nuevo?"
|
||||
},
|
||||
"icon": {
|
||||
"bookmark": "Marcador",
|
||||
"box": "Caja",
|
||||
@@ -684,6 +706,7 @@
|
||||
},
|
||||
"menuLabels": {
|
||||
"About ComfyUI": "Acerca de ComfyUI",
|
||||
"Add Edit Model Step": "Agregar paso de edición de modelo",
|
||||
"Browse Templates": "Explorar plantillas",
|
||||
"Bypass/Unbypass Selected Nodes": "Evitar/No evitar nodos seleccionados",
|
||||
"Canvas Toggle Link Visibility": "Alternar visibilidad de enlace en lienzo",
|
||||
@@ -699,6 +722,7 @@
|
||||
"ComfyUI Forum": "Foro de ComfyUI",
|
||||
"ComfyUI Issues": "Problemas de ComfyUI",
|
||||
"Contact Support": "Contactar soporte",
|
||||
"Convert Selection to Subgraph": "Convertir selección en subgrafo",
|
||||
"Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo",
|
||||
"Delete Selected Items": "Eliminar elementos seleccionados",
|
||||
"Desktop User Guide": "Guía de usuario de escritorio",
|
||||
@@ -871,6 +895,12 @@
|
||||
},
|
||||
"title": "Tu dispositivo no es compatible"
|
||||
},
|
||||
"releaseToast": {
|
||||
"newVersionAvailable": "¡Nueva versión disponible!",
|
||||
"skip": "Omitir",
|
||||
"update": "Actualizar",
|
||||
"whatsNew": "¿Qué hay de nuevo?"
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
"disabledTooltip": "No hay nodos de salida seleccionados",
|
||||
@@ -1079,6 +1109,7 @@
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "Explorar plantillas de ejemplo",
|
||||
"downloads": "Descargas",
|
||||
"helpCenter": "Centro de ayuda",
|
||||
"logout": "Cerrar sesión",
|
||||
"modelLibrary": "Biblioteca de modelos",
|
||||
"newBlankWorkflow": "Crear un nuevo flujo de trabajo en blanco",
|
||||
@@ -1367,6 +1398,7 @@
|
||||
"title": "Comienza con una Plantilla"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "No se puede crear el subgrafo",
|
||||
"couldNotDetermineFileType": "No se pudo determinar el tipo de archivo",
|
||||
"dropFileError": "No se puede procesar el elemento soltado: {error}",
|
||||
"emptyCanvas": "Lienzo vacío",
|
||||
@@ -1375,6 +1407,7 @@
|
||||
"errorSaveSetting": "Error al guardar la configuración {id}: {err}",
|
||||
"failedToAccessBillingPortal": "No se pudo acceder al portal de facturación: {error}",
|
||||
"failedToApplyTexture": "Error al aplicar textura",
|
||||
"failedToConvertToSubgraph": "No se pudo convertir los elementos en subgrafo",
|
||||
"failedToCreateCustomer": "No se pudo crear el cliente: {error}",
|
||||
"failedToDownloadFile": "Error al descargar el archivo",
|
||||
"failedToExportModel": "Error al exportar modelo como {format}",
|
||||
@@ -1439,6 +1472,9 @@
|
||||
"getStarted": "Empezar",
|
||||
"title": "Bienvenido a ComfyUI"
|
||||
},
|
||||
"whatsNewPopup": {
|
||||
"learnMore": "Aprende más"
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "Introduzca el nombre del archivo",
|
||||
"exportWorkflow": "Exportar flujo de trabajo",
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "Parcourir les modèles"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "Ajouter/Modifier une étape de modèle"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "Supprimer les éléments sélectionnés"
|
||||
},
|
||||
@@ -110,6 +113,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Retour d'information"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convertir la sélection en sous-graphe"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Ajuster le groupe au contenu"
|
||||
},
|
||||
|
||||
@@ -138,13 +138,21 @@
|
||||
"Unpin": "Désépingler"
|
||||
},
|
||||
"credits": {
|
||||
"accountInitialized": "Compte initialisé",
|
||||
"activity": "Activité",
|
||||
"added": "Ajouté",
|
||||
"additionalInfo": "Informations supplémentaires",
|
||||
"apiPricing": "Tarification de l’API",
|
||||
"credits": "Crédits",
|
||||
"details": "Détails",
|
||||
"eventType": "Type d'événement",
|
||||
"faqs": "FAQ",
|
||||
"invoiceHistory": "Historique des factures",
|
||||
"lastUpdated": "Dernière mise à jour",
|
||||
"messageSupport": "Contacter le support",
|
||||
"model": "Modèle",
|
||||
"purchaseCredits": "Acheter des crédits",
|
||||
"time": "Heure",
|
||||
"topUp": {
|
||||
"buyNow": "Acheter maintenant",
|
||||
"insufficientMessage": "Vous n'avez pas assez de crédits pour exécuter ce workflow.",
|
||||
@@ -381,6 +389,20 @@
|
||||
"create": "Créer un nœud de groupe",
|
||||
"enterName": "Entrer le nom"
|
||||
},
|
||||
"helpCenter": {
|
||||
"clickToLearnMore": "Cliquez pour en savoir plus →",
|
||||
"desktopUserGuide": "Guide utilisateur de bureau",
|
||||
"docs": "Docs",
|
||||
"github": "Github",
|
||||
"helpFeedback": "Aide & Retour",
|
||||
"loadingReleases": "Chargement des versions...",
|
||||
"more": "Plus...",
|
||||
"noRecentReleases": "Aucune version récente",
|
||||
"openDevTools": "Ouvrir les outils de développement",
|
||||
"reinstall": "Réinstaller",
|
||||
"updateAvailable": "Mise à jour",
|
||||
"whatsNew": "Quoi de neuf ?"
|
||||
},
|
||||
"icon": {
|
||||
"bookmark": "Favori",
|
||||
"box": "Boîte",
|
||||
@@ -684,6 +706,7 @@
|
||||
},
|
||||
"menuLabels": {
|
||||
"About ComfyUI": "À propos de ComfyUI",
|
||||
"Add Edit Model Step": "Ajouter une étape d’édition de modèle",
|
||||
"Browse Templates": "Parcourir les modèles",
|
||||
"Bypass/Unbypass Selected Nodes": "Contourner/Ne pas contourner les nœuds sélectionnés",
|
||||
"Canvas Toggle Link Visibility": "Basculer la visibilité du lien de la toile",
|
||||
@@ -699,6 +722,7 @@
|
||||
"ComfyUI Forum": "Forum ComfyUI",
|
||||
"ComfyUI Issues": "Problèmes de ComfyUI",
|
||||
"Contact Support": "Contacter le support",
|
||||
"Convert Selection to Subgraph": "Convertir la sélection en sous-graphe",
|
||||
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
|
||||
"Delete Selected Items": "Supprimer les éléments sélectionnés",
|
||||
"Desktop User Guide": "Guide de l'utilisateur de bureau",
|
||||
@@ -871,6 +895,12 @@
|
||||
},
|
||||
"title": "Votre appareil n'est pas pris en charge"
|
||||
},
|
||||
"releaseToast": {
|
||||
"newVersionAvailable": "Nouvelle version disponible !",
|
||||
"skip": "Ignorer",
|
||||
"update": "Mettre à jour",
|
||||
"whatsNew": "Quoi de neuf ?"
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
"disabledTooltip": "Aucun nœud de sortie sélectionné",
|
||||
@@ -1079,6 +1109,7 @@
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "Parcourir les modèles d'exemple",
|
||||
"downloads": "Téléchargements",
|
||||
"helpCenter": "Centre d'aide",
|
||||
"logout": "Déconnexion",
|
||||
"modelLibrary": "Bibliothèque de modèles",
|
||||
"newBlankWorkflow": "Créer un nouveau flux de travail vierge",
|
||||
@@ -1367,6 +1398,7 @@
|
||||
"title": "Commencez avec un modèle"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "Impossible de créer le sous-graphe",
|
||||
"couldNotDetermineFileType": "Impossible de déterminer le type de fichier",
|
||||
"dropFileError": "Impossible de traiter l'élément déposé : {error}",
|
||||
"emptyCanvas": "Toile vide",
|
||||
@@ -1375,6 +1407,7 @@
|
||||
"errorSaveSetting": "Erreur lors de l'enregistrement du paramètre {id}: {err}",
|
||||
"failedToAccessBillingPortal": "Échec de l'accès au portail de facturation : {error}",
|
||||
"failedToApplyTexture": "Échec de l'application de la texture",
|
||||
"failedToConvertToSubgraph": "Échec de la conversion des éléments en sous-graphe",
|
||||
"failedToCreateCustomer": "Échec de la création du client : {error}",
|
||||
"failedToDownloadFile": "Échec du téléchargement du fichier",
|
||||
"failedToExportModel": "Échec de l'exportation du modèle en {format}",
|
||||
@@ -1439,6 +1472,9 @@
|
||||
"getStarted": "Commencer",
|
||||
"title": "Bienvenue sur ComfyUI"
|
||||
},
|
||||
"whatsNewPopup": {
|
||||
"learnMore": "En savoir plus"
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "Entrez le nom du fichier",
|
||||
"exportWorkflow": "Exporter le flux de travail",
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "テンプレートを参照"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "編集モデルステップを追加"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "選択したアイテムを削除"
|
||||
},
|
||||
@@ -110,6 +113,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "フィードバック"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "選択範囲をサブグラフに変換"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "グループを内容に合わせて調整"
|
||||
},
|
||||
|
||||
@@ -138,13 +138,21 @@
|
||||
"Unpin": "ピンを解除"
|
||||
},
|
||||
"credits": {
|
||||
"accountInitialized": "アカウントが初期化されました",
|
||||
"activity": "アクティビティ",
|
||||
"added": "追加済み",
|
||||
"additionalInfo": "追加情報",
|
||||
"apiPricing": "API料金",
|
||||
"credits": "クレジット",
|
||||
"details": "詳細",
|
||||
"eventType": "イベントタイプ",
|
||||
"faqs": "よくある質問",
|
||||
"invoiceHistory": "請求履歴",
|
||||
"lastUpdated": "最終更新",
|
||||
"messageSupport": "サポートにメッセージ",
|
||||
"model": "モデル",
|
||||
"purchaseCredits": "クレジットを購入",
|
||||
"time": "時間",
|
||||
"topUp": {
|
||||
"buyNow": "今すぐ購入",
|
||||
"insufficientMessage": "このワークフローを実行するのに十分なクレジットがありません。",
|
||||
@@ -381,6 +389,20 @@
|
||||
"create": "グループノードを作成",
|
||||
"enterName": "名前を入力"
|
||||
},
|
||||
"helpCenter": {
|
||||
"clickToLearnMore": "詳しくはこちらをクリック →",
|
||||
"desktopUserGuide": "デスクトップユーザーガイド",
|
||||
"docs": "ドキュメント",
|
||||
"github": "Github",
|
||||
"helpFeedback": "ヘルプとフィードバック",
|
||||
"loadingReleases": "リリースを読み込み中...",
|
||||
"more": "もっと見る...",
|
||||
"noRecentReleases": "最近のリリースはありません",
|
||||
"openDevTools": "開発者ツールを開く",
|
||||
"reinstall": "再インストール",
|
||||
"updateAvailable": "アップデート",
|
||||
"whatsNew": "新着情報"
|
||||
},
|
||||
"icon": {
|
||||
"bookmark": "ブックマーク",
|
||||
"box": "ボックス",
|
||||
@@ -684,6 +706,7 @@
|
||||
},
|
||||
"menuLabels": {
|
||||
"About ComfyUI": "ComfyUIについて",
|
||||
"Add Edit Model Step": "モデル編集ステップを追加",
|
||||
"Browse Templates": "テンプレートを参照",
|
||||
"Bypass/Unbypass Selected Nodes": "選択したノードのバイパス/バイパス解除",
|
||||
"Canvas Toggle Link Visibility": "キャンバスのリンク表示を切り替え",
|
||||
@@ -699,6 +722,7 @@
|
||||
"ComfyUI Forum": "ComfyUI フォーラム",
|
||||
"ComfyUI Issues": "ComfyUIの問題",
|
||||
"Contact Support": "サポートに連絡",
|
||||
"Convert Selection to Subgraph": "選択範囲をサブグラフに変換",
|
||||
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
|
||||
"Delete Selected Items": "選択したアイテムを削除",
|
||||
"Desktop User Guide": "デスクトップユーザーガイド",
|
||||
@@ -871,6 +895,12 @@
|
||||
},
|
||||
"title": "お使いのデバイスはサポートされていません"
|
||||
},
|
||||
"releaseToast": {
|
||||
"newVersionAvailable": "新しいバージョンが利用可能です!",
|
||||
"skip": "スキップ",
|
||||
"update": "アップデート",
|
||||
"whatsNew": "新機能"
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
"disabledTooltip": "出力ノードが選択されていません",
|
||||
@@ -1079,6 +1109,7 @@
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "サンプルテンプレートを表示",
|
||||
"downloads": "ダウンロード",
|
||||
"helpCenter": "ヘルプセンター",
|
||||
"logout": "ログアウト",
|
||||
"modelLibrary": "モデルライブラリ",
|
||||
"newBlankWorkflow": "新しい空のワークフローを作成",
|
||||
@@ -1367,6 +1398,7 @@
|
||||
"title": "テンプレートを利用して開始"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "サブグラフを作成できません",
|
||||
"couldNotDetermineFileType": "ファイルタイプを判断できませんでした",
|
||||
"dropFileError": "ドロップされたアイテムを処理できません: {error}",
|
||||
"emptyCanvas": "キャンバスが空です",
|
||||
@@ -1375,6 +1407,7 @@
|
||||
"errorSaveSetting": "設定{id}の保存エラー: {err}",
|
||||
"failedToAccessBillingPortal": "請求ポータルへのアクセスに失敗しました: {error}",
|
||||
"failedToApplyTexture": "テクスチャの適用に失敗しました",
|
||||
"failedToConvertToSubgraph": "アイテムをサブグラフに変換できませんでした",
|
||||
"failedToCreateCustomer": "顧客の作成に失敗しました: {error}",
|
||||
"failedToDownloadFile": "ファイルのダウンロードに失敗しました",
|
||||
"failedToExportModel": "{format}としてモデルのエクスポートに失敗しました",
|
||||
@@ -1439,6 +1472,9 @@
|
||||
"getStarted": "はじめる",
|
||||
"title": "ComfyUIへようこそ"
|
||||
},
|
||||
"whatsNewPopup": {
|
||||
"learnMore": "詳細はこちら"
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "ファイル名を入力",
|
||||
"exportWorkflow": "ワークフローをエクスポート",
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "템플릿 탐색"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "모델 편집 단계 추가"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "선택한 항목 삭제"
|
||||
},
|
||||
@@ -110,6 +113,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "피드백"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "선택 영역을 서브그래프로 변환"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "그룹을 내용에 맞게 맞추기"
|
||||
},
|
||||
|
||||
@@ -138,13 +138,21 @@
|
||||
"Unpin": "고정 해제"
|
||||
},
|
||||
"credits": {
|
||||
"accountInitialized": "계정이 초기화됨",
|
||||
"activity": "활동",
|
||||
"added": "추가됨",
|
||||
"additionalInfo": "추가 정보",
|
||||
"apiPricing": "API 가격",
|
||||
"credits": "크레딧",
|
||||
"details": "세부 정보",
|
||||
"eventType": "이벤트 유형",
|
||||
"faqs": "자주 묻는 질문",
|
||||
"invoiceHistory": "청구서 내역",
|
||||
"lastUpdated": "마지막 업데이트",
|
||||
"messageSupport": "지원 문의",
|
||||
"model": "모델",
|
||||
"purchaseCredits": "크레딧 구매",
|
||||
"time": "시간",
|
||||
"topUp": {
|
||||
"buyNow": "지금 구매",
|
||||
"insufficientMessage": "이 워크플로우를 실행하기에 크레딧이 부족합니다.",
|
||||
@@ -381,6 +389,20 @@
|
||||
"create": "그룹 노드 만들기",
|
||||
"enterName": "이름 입력"
|
||||
},
|
||||
"helpCenter": {
|
||||
"clickToLearnMore": "자세히 알아보기 →",
|
||||
"desktopUserGuide": "데스크톱 사용자 가이드",
|
||||
"docs": "문서",
|
||||
"github": "Github",
|
||||
"helpFeedback": "도움말 및 피드백",
|
||||
"loadingReleases": "릴리즈 불러오는 중...",
|
||||
"more": "더보기...",
|
||||
"noRecentReleases": "최근 릴리즈 없음",
|
||||
"openDevTools": "개발자 도구 열기",
|
||||
"reinstall": "재설치",
|
||||
"updateAvailable": "업데이트",
|
||||
"whatsNew": "새로운 소식?"
|
||||
},
|
||||
"icon": {
|
||||
"bookmark": "북마크",
|
||||
"box": "상자",
|
||||
@@ -684,6 +706,7 @@
|
||||
},
|
||||
"menuLabels": {
|
||||
"About ComfyUI": "ComfyUI에 대하여",
|
||||
"Add Edit Model Step": "모델 편집 단계 추가",
|
||||
"Browse Templates": "템플릿 탐색",
|
||||
"Bypass/Unbypass Selected Nodes": "선택한 노드 우회/우회 해제",
|
||||
"Canvas Toggle Link Visibility": "캔버스 토글 링크 가시성",
|
||||
@@ -699,6 +722,7 @@
|
||||
"ComfyUI Forum": "ComfyUI 포럼",
|
||||
"ComfyUI Issues": "ComfyUI 이슈 페이지",
|
||||
"Contact Support": "고객 지원 문의",
|
||||
"Convert Selection to Subgraph": "선택 영역을 서브그래프로 변환",
|
||||
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
|
||||
"Delete Selected Items": "선택한 항목 삭제",
|
||||
"Desktop User Guide": "데스크톱 사용자 가이드",
|
||||
@@ -871,6 +895,12 @@
|
||||
},
|
||||
"title": "이 장치는 지원되지 않습니다."
|
||||
},
|
||||
"releaseToast": {
|
||||
"newVersionAvailable": "새 버전이 있습니다!",
|
||||
"skip": "건너뛰기",
|
||||
"update": "업데이트",
|
||||
"whatsNew": "새로운 기능 보기"
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
"disabledTooltip": "선택된 출력 노드가 없습니다",
|
||||
@@ -1079,6 +1109,7 @@
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "예제 템플릿 탐색",
|
||||
"downloads": "다운로드",
|
||||
"helpCenter": "도움말 센터",
|
||||
"logout": "로그아웃",
|
||||
"modelLibrary": "모델 라이브러리",
|
||||
"newBlankWorkflow": "새 빈 워크플로 만들기",
|
||||
@@ -1367,6 +1398,7 @@
|
||||
"title": "템플릿으로 시작하기"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "서브그래프를 생성할 수 없습니다",
|
||||
"couldNotDetermineFileType": "파일 유형을 결정할 수 없습니다",
|
||||
"dropFileError": "드롭된 항목을 처리할 수 없습니다: {error}",
|
||||
"emptyCanvas": "빈 캔버스",
|
||||
@@ -1375,6 +1407,7 @@
|
||||
"errorSaveSetting": "설정 {id} 저장 오류: {err}",
|
||||
"failedToAccessBillingPortal": "결제 포털에 접근하지 못했습니다: {error}",
|
||||
"failedToApplyTexture": "텍스처 적용에 실패했습니다",
|
||||
"failedToConvertToSubgraph": "항목을 서브그래프로 변환하지 못했습니다",
|
||||
"failedToCreateCustomer": "고객 생성에 실패했습니다: {error}",
|
||||
"failedToDownloadFile": "파일 다운로드에 실패했습니다",
|
||||
"failedToExportModel": "{format} 형식으로 모델 내보내기에 실패했습니다",
|
||||
@@ -1439,6 +1472,9 @@
|
||||
"getStarted": "시작하기",
|
||||
"title": "ComfyUI에 오신 것을 환영합니다"
|
||||
},
|
||||
"whatsNewPopup": {
|
||||
"learnMore": "자세히 알아보기"
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "파일 이름 입력",
|
||||
"exportWorkflow": "워크플로 내보내기",
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "Просмотр шаблонов"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "Добавить или изменить шаг модели"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "Удалить выбранные элементы"
|
||||
},
|
||||
@@ -110,6 +113,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Обратная связь"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Преобразовать выделенное в подграф"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Подогнать группу к содержимому"
|
||||
},
|
||||
|
||||
@@ -138,13 +138,21 @@
|
||||
"Unpin": "Открепить"
|
||||
},
|
||||
"credits": {
|
||||
"accountInitialized": "Аккаунт инициализирован",
|
||||
"activity": "Активность",
|
||||
"added": "Добавлено",
|
||||
"additionalInfo": "Дополнительная информация",
|
||||
"apiPricing": "Цены на API",
|
||||
"credits": "Кредиты",
|
||||
"details": "Детали",
|
||||
"eventType": "Тип события",
|
||||
"faqs": "Часто задаваемые вопросы",
|
||||
"invoiceHistory": "История счетов",
|
||||
"lastUpdated": "Последнее обновление",
|
||||
"messageSupport": "Связаться с поддержкой",
|
||||
"model": "Модель",
|
||||
"purchaseCredits": "Купить кредиты",
|
||||
"time": "Время",
|
||||
"topUp": {
|
||||
"buyNow": "Купить сейчас",
|
||||
"insufficientMessage": "У вас недостаточно кредитов для запуска этого рабочего процесса.",
|
||||
@@ -381,6 +389,20 @@
|
||||
"create": "Создать ноду группы",
|
||||
"enterName": "Введите название"
|
||||
},
|
||||
"helpCenter": {
|
||||
"clickToLearnMore": "Нажмите, чтобы узнать больше →",
|
||||
"desktopUserGuide": "Руководство пользователя для Desktop",
|
||||
"docs": "Документация",
|
||||
"github": "Github",
|
||||
"helpFeedback": "Помощь и обратная связь",
|
||||
"loadingReleases": "Загрузка релизов...",
|
||||
"more": "Ещё...",
|
||||
"noRecentReleases": "Нет недавних релизов",
|
||||
"openDevTools": "Открыть инструменты разработчика",
|
||||
"reinstall": "Переустановить",
|
||||
"updateAvailable": "Обновить",
|
||||
"whatsNew": "Что нового?"
|
||||
},
|
||||
"icon": {
|
||||
"bookmark": "Закладка",
|
||||
"box": "Коробка",
|
||||
@@ -684,6 +706,7 @@
|
||||
},
|
||||
"menuLabels": {
|
||||
"About ComfyUI": "О ComfyUI",
|
||||
"Add Edit Model Step": "Добавить или изменить шаг модели",
|
||||
"Browse Templates": "Просмотреть шаблоны",
|
||||
"Bypass/Unbypass Selected Nodes": "Обойти/восстановить выбранные ноды",
|
||||
"Canvas Toggle Link Visibility": "Переключение видимости ссылки на холст",
|
||||
@@ -699,6 +722,7 @@
|
||||
"ComfyUI Forum": "Форум ComfyUI",
|
||||
"ComfyUI Issues": "Проблемы ComfyUI",
|
||||
"Contact Support": "Связаться с поддержкой",
|
||||
"Convert Selection to Subgraph": "Преобразовать выделенное в подграф",
|
||||
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
|
||||
"Delete Selected Items": "Удалить выбранные элементы",
|
||||
"Desktop User Guide": "Руководство пользователя для настольных ПК",
|
||||
@@ -871,6 +895,12 @@
|
||||
},
|
||||
"title": "Ваше устройство не поддерживается"
|
||||
},
|
||||
"releaseToast": {
|
||||
"newVersionAvailable": "Доступна новая версия!",
|
||||
"skip": "Пропустить",
|
||||
"update": "Обновить",
|
||||
"whatsNew": "Что нового?"
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
"disabledTooltip": "Выходные узлы не выбраны",
|
||||
@@ -1079,6 +1109,7 @@
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "Просмотреть примеры шаблонов",
|
||||
"downloads": "Загрузки",
|
||||
"helpCenter": "Центр поддержки",
|
||||
"logout": "Выйти",
|
||||
"modelLibrary": "Библиотека моделей",
|
||||
"newBlankWorkflow": "Создайте новый пустой рабочий процесс",
|
||||
@@ -1367,6 +1398,7 @@
|
||||
"title": "Начните с шаблона"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "Невозможно создать подграф",
|
||||
"couldNotDetermineFileType": "Не удалось определить тип файла",
|
||||
"dropFileError": "Не удалось обработать перетаскиваемый элемент: {error}",
|
||||
"emptyCanvas": "Пустой холст",
|
||||
@@ -1375,6 +1407,7 @@
|
||||
"errorSaveSetting": "Ошибка сохранения настройки {id}: {err}",
|
||||
"failedToAccessBillingPortal": "Не удалось получить доступ к биллинговому порталу: {error}",
|
||||
"failedToApplyTexture": "Не удалось применить текстуру",
|
||||
"failedToConvertToSubgraph": "Не удалось преобразовать элементы в подграф",
|
||||
"failedToCreateCustomer": "Не удалось создать клиента: {error}",
|
||||
"failedToDownloadFile": "Не удалось скачать файл",
|
||||
"failedToExportModel": "Не удалось экспортировать модель как {format}",
|
||||
@@ -1439,6 +1472,9 @@
|
||||
"getStarted": "Начать",
|
||||
"title": "Добро пожаловать в ComfyUI"
|
||||
},
|
||||
"whatsNewPopup": {
|
||||
"learnMore": "Узнать больше"
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "Введите название файла",
|
||||
"exportWorkflow": "Экспорт рабочего процесса",
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
"Comfy_BrowseTemplates": {
|
||||
"label": "浏览模板"
|
||||
},
|
||||
"Comfy_Canvas_AddEditModelStep": {
|
||||
"label": "添加编辑模型步骤"
|
||||
},
|
||||
"Comfy_Canvas_DeleteSelectedItems": {
|
||||
"label": "删除选定的项目"
|
||||
},
|
||||
@@ -110,6 +113,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "反馈"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "将选区转换为子图"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "适应节点框到内容"
|
||||
},
|
||||
|
||||
@@ -138,13 +138,21 @@
|
||||
"Unpin": "取消固定"
|
||||
},
|
||||
"credits": {
|
||||
"accountInitialized": "账户已初始化",
|
||||
"activity": "活动",
|
||||
"added": "已添加",
|
||||
"additionalInfo": "附加信息",
|
||||
"apiPricing": "API 价格",
|
||||
"credits": "积分",
|
||||
"details": "详情",
|
||||
"eventType": "事件类型",
|
||||
"faqs": "常见问题",
|
||||
"invoiceHistory": "发票历史",
|
||||
"lastUpdated": "最近更新",
|
||||
"messageSupport": "联系客服",
|
||||
"model": "模型",
|
||||
"purchaseCredits": "购买积分",
|
||||
"time": "时间",
|
||||
"topUp": {
|
||||
"buyNow": "立即购买",
|
||||
"insufficientMessage": "您的积分不足,无法运行此工作流。",
|
||||
@@ -381,6 +389,20 @@
|
||||
"create": "创建组节点",
|
||||
"enterName": "输入名称"
|
||||
},
|
||||
"helpCenter": {
|
||||
"clickToLearnMore": "点击了解更多 →",
|
||||
"desktopUserGuide": "桌面端用户指南",
|
||||
"docs": "文档",
|
||||
"github": "Github",
|
||||
"helpFeedback": "帮助与反馈",
|
||||
"loadingReleases": "加载发布信息...",
|
||||
"more": "更多...",
|
||||
"noRecentReleases": "没有最近的发布",
|
||||
"openDevTools": "打开开发者工具",
|
||||
"reinstall": "重新安装",
|
||||
"updateAvailable": "更新",
|
||||
"whatsNew": "新功能?"
|
||||
},
|
||||
"icon": {
|
||||
"bookmark": "书签",
|
||||
"box": "盒子",
|
||||
@@ -684,6 +706,7 @@
|
||||
},
|
||||
"menuLabels": {
|
||||
"About ComfyUI": "关于ComfyUI",
|
||||
"Add Edit Model Step": "添加编辑模型步骤",
|
||||
"Browse Templates": "浏览模板",
|
||||
"Bypass/Unbypass Selected Nodes": "忽略/取消忽略选定节点",
|
||||
"Canvas Toggle Link Visibility": "切换连线可见性",
|
||||
@@ -699,6 +722,7 @@
|
||||
"ComfyUI Forum": "ComfyUI 论坛",
|
||||
"ComfyUI Issues": "ComfyUI 问题",
|
||||
"Contact Support": "联系支持",
|
||||
"Convert Selection to Subgraph": "将选中内容转换为子图",
|
||||
"Convert selected nodes to group node": "将选中节点转换为组节点",
|
||||
"Delete Selected Items": "删除选定的项目",
|
||||
"Desktop User Guide": "桌面端用户指南",
|
||||
@@ -871,6 +895,12 @@
|
||||
},
|
||||
"title": "您的设备不受支持"
|
||||
},
|
||||
"releaseToast": {
|
||||
"newVersionAvailable": "新版本可用!",
|
||||
"skip": "跳过",
|
||||
"update": "更新",
|
||||
"whatsNew": "新功能?"
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
"disabledTooltip": "未选择输出节点",
|
||||
@@ -1079,6 +1109,7 @@
|
||||
"sideToolbar": {
|
||||
"browseTemplates": "浏览示例模板",
|
||||
"downloads": "下载",
|
||||
"helpCenter": "帮助中心",
|
||||
"logout": "登出",
|
||||
"modelLibrary": "模型库",
|
||||
"newBlankWorkflow": "创建空白工作流",
|
||||
@@ -1367,6 +1398,7 @@
|
||||
"title": "从模板开始"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "无法创建子图",
|
||||
"couldNotDetermineFileType": "无法确定文件类型",
|
||||
"dropFileError": "无法处理掉落的项目:{error}",
|
||||
"emptyCanvas": "画布为空",
|
||||
@@ -1375,6 +1407,7 @@
|
||||
"errorSaveSetting": "保存设置 {id} 出错:{err}",
|
||||
"failedToAccessBillingPortal": "访问账单门户失败:{error}",
|
||||
"failedToApplyTexture": "应用纹理失败",
|
||||
"failedToConvertToSubgraph": "无法将项目转换为子图",
|
||||
"failedToCreateCustomer": "创建客户失败:{error}",
|
||||
"failedToDownloadFile": "文件下载失败",
|
||||
"failedToExportModel": "无法将模型导出为 {format}",
|
||||
@@ -1439,6 +1472,9 @@
|
||||
"getStarted": "开始使用",
|
||||
"title": "欢迎使用 ComfyUI"
|
||||
},
|
||||
"whatsNewPopup": {
|
||||
"learnMore": "了解更多"
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "输入文件名",
|
||||
"exportWorkflow": "导出工作流",
|
||||
|
||||
@@ -472,6 +472,14 @@ const zSettings = z.object({
|
||||
'pysssss.SnapToGrid': z.boolean(),
|
||||
/** VHS setting is used for queue video preview support. */
|
||||
'VHS.AdvancedPreviews': z.string(),
|
||||
/** Release data settings */
|
||||
'Comfy.Release.Version': z.string(),
|
||||
'Comfy.Release.Status': z.enum([
|
||||
'skipped',
|
||||
'changelog seen',
|
||||
"what's new seen"
|
||||
]),
|
||||
'Comfy.Release.Timestamp': z.number(),
|
||||
/** Settings used for testing */
|
||||
'test.setting': z.any(),
|
||||
'main.sub.setting.name': z.any(),
|
||||
|
||||
@@ -41,10 +41,10 @@ const zModelFile = z.object({
|
||||
|
||||
const zGraphState = z
|
||||
.object({
|
||||
lastGroupid: z.number().optional(),
|
||||
lastNodeId: z.number().optional(),
|
||||
lastLinkId: z.number().optional(),
|
||||
lastRerouteId: z.number().optional()
|
||||
lastGroupId: z.number(),
|
||||
lastNodeId: z.number(),
|
||||
lastLinkId: z.number(),
|
||||
lastRerouteId: z.number()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
@@ -214,6 +214,32 @@ const zComfyNode = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const zSubgraphIO = zNodeInput.extend({
|
||||
/** Slot ID (internal; never changes once instantiated). */
|
||||
id: z.string().uuid(),
|
||||
/** The data type this slot uses. Unlike nodes, this does not support legacy numeric types. */
|
||||
type: z.string(),
|
||||
/** Links connected to this slot, or `undefined` if not connected. An ouptut slot should only ever have one link. */
|
||||
linkIds: z.array(z.number()).optional()
|
||||
})
|
||||
|
||||
const zSubgraphInstance = z
|
||||
.object({
|
||||
id: zNodeId,
|
||||
type: z.string().uuid(),
|
||||
pos: zVector2,
|
||||
size: zVector2,
|
||||
flags: zFlags,
|
||||
order: z.number(),
|
||||
mode: z.number(),
|
||||
inputs: z.array(zSubgraphIO).optional(),
|
||||
outputs: z.array(zSubgraphIO).optional(),
|
||||
widgets_values: zWidgetValues.optional(),
|
||||
color: z.string().optional(),
|
||||
bgcolor: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zGroup = z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
@@ -248,9 +274,22 @@ const zExtra = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const zGraphDefinitions = z.object({
|
||||
subgraphs: z.lazy(() => z.array(zSubgraphDefinition))
|
||||
})
|
||||
|
||||
export const zBaseExportableGraph = z.object({
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
config: zConfig.optional().nullable(),
|
||||
/** Details of the appearance and location of subgraphs shown in this graph. Similar to */
|
||||
subgraphs: z.array(zSubgraphInstance).optional()
|
||||
})
|
||||
|
||||
/** Schema version 0.4 */
|
||||
export const zComfyWorkflow = z
|
||||
.object({
|
||||
export const zComfyWorkflow = zBaseExportableGraph
|
||||
.extend({
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
last_node_id: zNodeId,
|
||||
@@ -262,13 +301,47 @@ export const zComfyWorkflow = z
|
||||
config: zConfig.optional().nullable(),
|
||||
extra: zExtra.optional().nullable(),
|
||||
version: z.number(),
|
||||
models: z.array(zModelFile).optional()
|
||||
models: z.array(zModelFile).optional(),
|
||||
definitions: zGraphDefinitions.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
/** Required for recursive definition of subgraphs. */
|
||||
interface ComfyWorkflow1BaseType {
|
||||
id?: string
|
||||
revision?: number
|
||||
version: 1
|
||||
models?: z.infer<typeof zModelFile>[]
|
||||
state: z.infer<typeof zGraphState>
|
||||
}
|
||||
|
||||
/** Required for recursive definition of subgraphs w/ZodEffects. */
|
||||
interface ComfyWorkflow1BaseInput extends ComfyWorkflow1BaseType {
|
||||
groups?: z.input<typeof zGroup>[]
|
||||
nodes: z.input<typeof zComfyNode>[]
|
||||
links?: z.input<typeof zComfyLinkObject>[]
|
||||
floatingLinks?: z.input<typeof zComfyLinkObject>[]
|
||||
reroutes?: z.input<typeof zReroute>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseInput>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Required for recursive definition of subgraphs w/ZodEffects. */
|
||||
interface ComfyWorkflow1BaseOutput extends ComfyWorkflow1BaseType {
|
||||
groups?: z.output<typeof zGroup>[]
|
||||
nodes: z.output<typeof zComfyNode>[]
|
||||
links?: z.output<typeof zComfyLinkObject>[]
|
||||
floatingLinks?: z.output<typeof zComfyLinkObject>[]
|
||||
reroutes?: z.output<typeof zReroute>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Schema version 1 */
|
||||
export const zComfyWorkflow1 = z
|
||||
.object({
|
||||
export const zComfyWorkflow1 = zBaseExportableGraph
|
||||
.extend({
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
version: z.literal(1),
|
||||
@@ -280,7 +353,96 @@ export const zComfyWorkflow1 = z
|
||||
floatingLinks: z.array(zComfyLinkObject).optional(),
|
||||
reroutes: z.array(zReroute).optional(),
|
||||
extra: zExtra.optional().nullable(),
|
||||
models: z.array(zModelFile).optional()
|
||||
models: z.array(zModelFile).optional(),
|
||||
definitions: z
|
||||
.object({
|
||||
subgraphs: z.lazy(
|
||||
(): z.ZodArray<
|
||||
z.ZodType<
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>,
|
||||
z.ZodTypeDef,
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
|
||||
>,
|
||||
'many'
|
||||
> => z.array(zSubgraphDefinition)
|
||||
)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const zExportedSubgraphIONode = z.object({
|
||||
id: zNodeId,
|
||||
bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]),
|
||||
pinned: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zExposedWidget = z.object({
|
||||
id: z.string(),
|
||||
name: z.string()
|
||||
})
|
||||
|
||||
interface SubgraphDefinitionBase<
|
||||
T extends ComfyWorkflow1BaseInput | ComfyWorkflow1BaseOutput
|
||||
> {
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: string
|
||||
revision: number
|
||||
name: string
|
||||
|
||||
inputNode: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExportedSubgraphIONode>
|
||||
: z.output<typeof zExportedSubgraphIONode>
|
||||
outputNode: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExportedSubgraphIONode>
|
||||
: z.output<typeof zExportedSubgraphIONode>
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
inputs?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zSubgraphIO>[]
|
||||
: z.output<typeof zSubgraphIO>[]
|
||||
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
|
||||
outputs?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zSubgraphIO>[]
|
||||
: z.output<typeof zSubgraphIO>[]
|
||||
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
|
||||
widgets?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExposedWidget>[]
|
||||
: z.output<typeof zExposedWidget>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<T>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** A subgraph definition `worfklow.definitions.subgraphs` */
|
||||
export const zSubgraphDefinition = zComfyWorkflow1
|
||||
.extend({
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: z.string().uuid(),
|
||||
revision: z.number(),
|
||||
name: z.string(),
|
||||
inputNode: zExportedSubgraphIONode,
|
||||
outputNode: zExportedSubgraphIONode,
|
||||
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
inputs: z.array(zSubgraphIO).optional(),
|
||||
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
|
||||
outputs: z.array(zSubgraphIO).optional(),
|
||||
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
|
||||
widgets: z.array(zExposedWidget).optional(),
|
||||
definitions: z
|
||||
.object({
|
||||
subgraphs: z.lazy(
|
||||
(): z.ZodArray<
|
||||
z.ZodType<
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>,
|
||||
z.ZodTypeDef,
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
|
||||
>,
|
||||
'many'
|
||||
> => zSubgraphDefinition.array()
|
||||
)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
|
||||
@@ -46,22 +46,26 @@ export function transformNodeDefV1ToV2(
|
||||
const outputs: OutputSpecV2[] = []
|
||||
|
||||
if (nodeDefV1.output) {
|
||||
nodeDefV1.output.forEach((outputType, index) => {
|
||||
const outputSpec: OutputSpecV2 = {
|
||||
index,
|
||||
name: nodeDefV1.output_name?.[index] || `output_${index}`,
|
||||
type: Array.isArray(outputType) ? 'COMBO' : outputType,
|
||||
is_list: nodeDefV1.output_is_list?.[index] || false,
|
||||
tooltip: nodeDefV1.output_tooltips?.[index]
|
||||
}
|
||||
if (Array.isArray(nodeDefV1.output)) {
|
||||
nodeDefV1.output.forEach((outputType, index) => {
|
||||
const outputSpec: OutputSpecV2 = {
|
||||
index,
|
||||
name: nodeDefV1.output_name?.[index] || `output_${index}`,
|
||||
type: Array.isArray(outputType) ? 'COMBO' : outputType,
|
||||
is_list: nodeDefV1.output_is_list?.[index] || false,
|
||||
tooltip: nodeDefV1.output_tooltips?.[index]
|
||||
}
|
||||
|
||||
// Add options for combo outputs
|
||||
if (Array.isArray(outputType)) {
|
||||
outputSpec.options = outputType
|
||||
}
|
||||
// Add options for combo outputs
|
||||
if (Array.isArray(outputType)) {
|
||||
outputSpec.options = outputType
|
||||
}
|
||||
|
||||
outputs.push(outputSpec)
|
||||
})
|
||||
outputs.push(outputSpec)
|
||||
})
|
||||
} else {
|
||||
console.warn('nodeDefV1.output is not an array:', nodeDefV1.output)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the V2 node definition
|
||||
|
||||
@@ -39,9 +39,11 @@ import { getSvgMetadata } from '@/scripts/metadata/svg'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useSubgraphService } from '@/services/subgraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
@@ -72,7 +74,6 @@ import { deserialiseAndCreate } from '@/utils/vintageClipboard'
|
||||
|
||||
import { type ComfyApi, PromptExecutionError, api } from './api'
|
||||
import { defaultGraph } from './defaultGraph'
|
||||
import { pruneWidgets } from './domWidget'
|
||||
import {
|
||||
getFlacMetadata,
|
||||
getLatentMetadata,
|
||||
@@ -669,13 +670,15 @@ export class ComfyApp {
|
||||
api.addEventListener('execution_error', ({ detail }) => {
|
||||
// Check if this is an auth-related error or credits-related error
|
||||
if (
|
||||
detail.exception_message ===
|
||||
'Unauthorized: Please login first to use this node.'
|
||||
detail.exception_message?.includes(
|
||||
'Unauthorized: Please login first to use this node.'
|
||||
)
|
||||
) {
|
||||
useDialogService().showApiNodesSignInDialog([detail.node_type])
|
||||
} else if (
|
||||
detail.exception_message ===
|
||||
'Payment Required: Please add credits to your account to use this node.'
|
||||
detail.exception_message?.includes(
|
||||
'Payment Required: Please add credits to your account to use this node.'
|
||||
)
|
||||
) {
|
||||
useDialogService().showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
@@ -715,25 +718,23 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
#addAfterConfigureHandler() {
|
||||
const app = this
|
||||
const onConfigure = app.graph.onConfigure
|
||||
app.graph.onConfigure = function (this: LGraph, ...args) {
|
||||
const { graph } = this
|
||||
const { onConfigure } = graph
|
||||
graph.onConfigure = function (...args) {
|
||||
fixLinkInputSlots(this)
|
||||
|
||||
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
|
||||
for (const node of app.graph.nodes) {
|
||||
for (const node of graph.nodes) {
|
||||
node.onGraphConfigured?.()
|
||||
}
|
||||
|
||||
const r = onConfigure?.apply(this, args)
|
||||
|
||||
// Fire after onConfigure, used by primitives to generate widget using input nodes config
|
||||
for (const node of app.graph.nodes) {
|
||||
for (const node of graph.nodes) {
|
||||
node.onAfterGraphConfigured?.()
|
||||
}
|
||||
|
||||
pruneWidgets(this.nodes)
|
||||
|
||||
return r
|
||||
}
|
||||
}
|
||||
@@ -765,6 +766,21 @@ export class ComfyApp {
|
||||
|
||||
this.#graph = new LGraph()
|
||||
|
||||
// Register the subgraph - adds type wrapper for Litegraph's `createNode` factory
|
||||
this.graph.events.addEventListener('subgraph-created', (e) => {
|
||||
try {
|
||||
const { subgraph, data } = e.detail
|
||||
useSubgraphService().registerNewSubgraph(subgraph, data)
|
||||
} catch (err) {
|
||||
console.error('Failed to register subgraph', err)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: 'Failed to register subgraph',
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
this.#addAfterConfigureHandler()
|
||||
|
||||
this.canvas = new LGraphCanvas(canvasEl, this.graph)
|
||||
@@ -777,6 +793,30 @@ export class ComfyApp {
|
||||
LiteGraph.alt_drag_do_clone_nodes = true
|
||||
LiteGraph.macGesturesRequireMac = false
|
||||
|
||||
this.canvas.canvas.addEventListener<'litegraph:set-graph'>(
|
||||
'litegraph:set-graph',
|
||||
(e) => {
|
||||
// Assertion: Not yet defined in litegraph.
|
||||
const { newGraph } = e.detail
|
||||
|
||||
const nodeSet = new Set(newGraph.nodes)
|
||||
const widgetStore = useDomWidgetStore()
|
||||
|
||||
// Assertions: UnwrapRef
|
||||
for (const { widget } of widgetStore.activeWidgetStates) {
|
||||
if (!nodeSet.has(widget.node)) {
|
||||
widgetStore.deactivateWidget(widget.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const { widget } of widgetStore.inactiveWidgetStates) {
|
||||
if (nodeSet.has(widget.node)) {
|
||||
widgetStore.activateWidget(widget.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
this.graph.start()
|
||||
|
||||
// Ensure the canvas fills the window
|
||||
@@ -1013,6 +1053,7 @@ export class ComfyApp {
|
||||
})
|
||||
}
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
useSubgraphService().loadSubgraphs(graphData)
|
||||
|
||||
const missingNodeTypes: MissingNodeType[] = []
|
||||
const missingModels: ModelFile[] = []
|
||||
@@ -1210,6 +1251,9 @@ export class ComfyApp {
|
||||
// Allow widgets to run callbacks before a prompt has been queued
|
||||
// e.g. random seed before every gen
|
||||
executeWidgetsCallback(this.graph.nodes, 'beforeQueued')
|
||||
for (const subgraph of this.graph.subgraphs.values()) {
|
||||
executeWidgetsCallback(subgraph.nodes, 'beforeQueued')
|
||||
}
|
||||
|
||||
const p = await this.graphToPrompt(this.graph, { queueNodeIds })
|
||||
try {
|
||||
@@ -1252,9 +1296,13 @@ export class ComfyApp {
|
||||
executeWidgetsCallback(
|
||||
p.workflow.nodes
|
||||
.map((n) => this.graph.getNodeById(n.id))
|
||||
.filter((n) => !!n) as LGraphNode[],
|
||||
.filter((n) => !!n),
|
||||
'afterQueued'
|
||||
)
|
||||
for (const subgraph of this.graph.subgraphs.values()) {
|
||||
executeWidgetsCallback(subgraph.nodes, 'afterQueued')
|
||||
}
|
||||
|
||||
this.canvas.draw(true, true)
|
||||
await this.ui.queue.update()
|
||||
}
|
||||
@@ -1650,6 +1698,8 @@ export class ComfyApp {
|
||||
const executionStore = useExecutionStore()
|
||||
executionStore.lastNodeErrors = null
|
||||
executionStore.lastExecutionError = null
|
||||
|
||||
useDomWidgetStore().clear()
|
||||
}
|
||||
|
||||
clientPosToCanvasPos(pos: Vector2): Vector2 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import log from 'loglevel'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
import { api } from './api'
|
||||
@@ -37,6 +38,10 @@ export class ChangeTracker {
|
||||
ds?: { scale: number; offset: [number, number] }
|
||||
nodeOutputs?: Record<string, any>
|
||||
|
||||
private subgraphState?: {
|
||||
navigation: string[]
|
||||
}
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* The workflow that this change tracker is tracking
|
||||
@@ -67,6 +72,8 @@ export class ChangeTracker {
|
||||
scale: app.canvas.ds.scale,
|
||||
offset: [app.canvas.ds.offset[0], app.canvas.ds.offset[1]]
|
||||
}
|
||||
const navigation = useSubgraphNavigationStore().exportState()
|
||||
this.subgraphState = navigation.length ? { navigation } : undefined
|
||||
}
|
||||
|
||||
restore() {
|
||||
@@ -77,6 +84,16 @@ export class ChangeTracker {
|
||||
if (this.nodeOutputs) {
|
||||
app.nodeOutputs = this.nodeOutputs
|
||||
}
|
||||
if (this.subgraphState) {
|
||||
const { navigation } = this.subgraphState
|
||||
useSubgraphNavigationStore().restoreState(navigation)
|
||||
|
||||
const activeId = navigation.at(-1)
|
||||
if (activeId) {
|
||||
const subgraph = app.graph.subgraphs.get(activeId)
|
||||
if (subgraph) app.canvas.setGraph(subgraph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateModified() {
|
||||
@@ -376,7 +393,14 @@ export class ChangeTracker {
|
||||
return false
|
||||
|
||||
// Compare other properties normally
|
||||
for (const key of ['links', 'floatingLinks', 'reroutes', 'groups']) {
|
||||
for (const key of [
|
||||
'links',
|
||||
'floatingLinks',
|
||||
'reroutes',
|
||||
'groups',
|
||||
'definitions',
|
||||
'subgraphs'
|
||||
]) {
|
||||
if (!_.isEqual(a[key], b[key])) {
|
||||
return false
|
||||
}
|
||||
@@ -392,7 +416,12 @@ export class ChangeTracker {
|
||||
function sortGraphNodes(graph: ComfyWorkflowJSON) {
|
||||
return {
|
||||
links: graph.links,
|
||||
floatingLinks: graph.floatingLinks,
|
||||
reroutes: graph.reroutes,
|
||||
groups: graph.groups,
|
||||
extra: graph.extra,
|
||||
definitions: graph.definitions,
|
||||
subgraphs: graph.subgraphs,
|
||||
nodes: graph.nodes.sort((a, b) => {
|
||||
if (typeof a.id === 'number' && typeof b.id === 'number') {
|
||||
return a.id - b.id
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { generateUUID } from '@/utils/formatUtil'
|
||||
|
||||
export interface BaseDOMWidget<V extends object | string>
|
||||
export interface BaseDOMWidget<V extends object | string = object | string>
|
||||
extends IBaseWidget<V, string, DOMWidgetOptions<V>> {
|
||||
// ICustomWidget properties
|
||||
type: string
|
||||
@@ -330,9 +330,8 @@ LGraphNode.prototype.addDOMWidget = function <
|
||||
export const pruneWidgets = (nodes: LGraphNode[]) => {
|
||||
const nodeSet = new Set(nodes)
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
for (const widgetState of domWidgetStore.widgetStates.values()) {
|
||||
const widget = widgetState.widget
|
||||
if (!nodeSet.has(widget.node as LGraphNode)) {
|
||||
for (const { widget } of domWidgetStore.widgetStates.values()) {
|
||||
if (!nodeSet.has(widget.node)) {
|
||||
domWidgetStore.unregisterWidget(widget.id)
|
||||
}
|
||||
}
|
||||
|
||||
693
src/scripts/fluxKontextEditNode.ts
Normal file
693
src/scripts/fluxKontextEditNode.ts
Normal file
@@ -0,0 +1,693 @@
|
||||
import {
|
||||
type INodeOutputSlot,
|
||||
type LGraph,
|
||||
type LGraphNode,
|
||||
LLink,
|
||||
LiteGraph,
|
||||
type Point
|
||||
} from '@comfyorg/litegraph'
|
||||
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import _ from 'lodash'
|
||||
|
||||
import { parseFilePath } from '@/utils/formatUtil'
|
||||
|
||||
import { app } from './app'
|
||||
|
||||
const fluxKontextGroupNode = {
|
||||
nodes: [
|
||||
{
|
||||
id: -1,
|
||||
type: 'Reroute',
|
||||
pos: [2354.87890625, -127.23468780517578],
|
||||
size: [75, 26],
|
||||
flags: {},
|
||||
order: 20,
|
||||
mode: 0,
|
||||
inputs: [{ name: '', type: '*', link: null }],
|
||||
outputs: [{ name: '', type: '*', links: null }],
|
||||
properties: { showOutputText: false, horizontal: false },
|
||||
index: 0
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'ReferenceLatent',
|
||||
pos: [2730, -220],
|
||||
size: [197.712890625, 46],
|
||||
flags: {},
|
||||
order: 22,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'conditioning',
|
||||
name: 'conditioning',
|
||||
type: 'CONDITIONING',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'latent',
|
||||
name: 'latent',
|
||||
shape: 7,
|
||||
type: 'LATENT',
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'CONDITIONING',
|
||||
name: 'CONDITIONING',
|
||||
type: 'CONDITIONING',
|
||||
links: []
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'ReferenceLatent',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
index: 1
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'VAEDecode',
|
||||
pos: [3270, -110],
|
||||
size: [210, 46],
|
||||
flags: {},
|
||||
order: 25,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'samples',
|
||||
name: 'samples',
|
||||
type: 'LATENT',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'vae',
|
||||
name: 'vae',
|
||||
type: 'VAE',
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'IMAGE',
|
||||
name: 'IMAGE',
|
||||
type: 'IMAGE',
|
||||
slot_index: 0,
|
||||
links: []
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'VAEDecode',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
index: 2
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'KSampler',
|
||||
pos: [2930, -110],
|
||||
size: [315, 262],
|
||||
flags: {},
|
||||
order: 24,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'model',
|
||||
name: 'model',
|
||||
type: 'MODEL',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'positive',
|
||||
name: 'positive',
|
||||
type: 'CONDITIONING',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'negative',
|
||||
name: 'negative',
|
||||
type: 'CONDITIONING',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'latent_image',
|
||||
name: 'latent_image',
|
||||
type: 'LATENT',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'seed',
|
||||
name: 'seed',
|
||||
type: 'INT',
|
||||
widget: { name: 'seed' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'steps',
|
||||
name: 'steps',
|
||||
type: 'INT',
|
||||
widget: { name: 'steps' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'cfg',
|
||||
name: 'cfg',
|
||||
type: 'FLOAT',
|
||||
widget: { name: 'cfg' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'sampler_name',
|
||||
name: 'sampler_name',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'sampler_name' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'scheduler',
|
||||
name: 'scheduler',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'scheduler' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'denoise',
|
||||
name: 'denoise',
|
||||
type: 'FLOAT',
|
||||
widget: { name: 'denoise' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'LATENT',
|
||||
name: 'LATENT',
|
||||
type: 'LATENT',
|
||||
slot_index: 0,
|
||||
links: []
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'KSampler',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
widgets_values: [972054013131369, 'fixed', 20, 1, 'euler', 'simple', 1],
|
||||
index: 3
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'FluxGuidance',
|
||||
pos: [2940, -220],
|
||||
size: [211.60000610351562, 58],
|
||||
flags: {},
|
||||
order: 23,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'conditioning',
|
||||
name: 'conditioning',
|
||||
type: 'CONDITIONING',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'guidance',
|
||||
name: 'guidance',
|
||||
type: 'FLOAT',
|
||||
widget: { name: 'guidance' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'CONDITIONING',
|
||||
name: 'CONDITIONING',
|
||||
type: 'CONDITIONING',
|
||||
slot_index: 0,
|
||||
links: []
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'FluxGuidance',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
widgets_values: [2.5],
|
||||
index: 4
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'SaveImage',
|
||||
pos: [3490, -110],
|
||||
size: [985.3012084960938, 1060.3828125],
|
||||
flags: {},
|
||||
order: 26,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'images',
|
||||
name: 'images',
|
||||
type: 'IMAGE',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'filename_prefix',
|
||||
name: 'filename_prefix',
|
||||
type: 'STRING',
|
||||
widget: { name: 'filename_prefix' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [],
|
||||
properties: { cnr_id: 'comfy-core', ver: '0.3.38' },
|
||||
widgets_values: ['ComfyUI'],
|
||||
index: 5
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'CLIPTextEncode',
|
||||
pos: [2500, -110],
|
||||
size: [422.84503173828125, 164.31304931640625],
|
||||
flags: {},
|
||||
order: 12,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'clip',
|
||||
name: 'clip',
|
||||
type: 'CLIP',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'text',
|
||||
name: 'text',
|
||||
type: 'STRING',
|
||||
widget: { name: 'text' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'CONDITIONING',
|
||||
name: 'CONDITIONING',
|
||||
type: 'CONDITIONING',
|
||||
slot_index: 0,
|
||||
links: []
|
||||
}
|
||||
],
|
||||
title: 'CLIP Text Encode (Positive Prompt)',
|
||||
properties: {
|
||||
'Node name for S&R': 'CLIPTextEncode',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
widgets_values: ['there is a bright light'],
|
||||
color: '#232',
|
||||
bgcolor: '#353',
|
||||
index: 6
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'CLIPTextEncode',
|
||||
pos: [2504.1435546875, 97.9598617553711],
|
||||
size: [422.84503173828125, 164.31304931640625],
|
||||
flags: { collapsed: true },
|
||||
order: 13,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'clip',
|
||||
name: 'clip',
|
||||
type: 'CLIP',
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'text',
|
||||
name: 'text',
|
||||
type: 'STRING',
|
||||
widget: { name: 'text' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'CONDITIONING',
|
||||
name: 'CONDITIONING',
|
||||
type: 'CONDITIONING',
|
||||
slot_index: 0,
|
||||
links: []
|
||||
}
|
||||
],
|
||||
title: 'CLIP Text Encode (Negative Prompt)',
|
||||
properties: {
|
||||
'Node name for S&R': 'CLIPTextEncode',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
widgets_values: [''],
|
||||
color: '#322',
|
||||
bgcolor: '#533',
|
||||
index: 7
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'UNETLoader',
|
||||
pos: [2630, -370],
|
||||
size: [270, 82],
|
||||
flags: {},
|
||||
order: 6,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'unet_name',
|
||||
name: 'unet_name',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'unet_name' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'weight_dtype',
|
||||
name: 'weight_dtype',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'weight_dtype' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'MODEL',
|
||||
name: 'MODEL',
|
||||
type: 'MODEL',
|
||||
links: []
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'UNETLoader',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
widgets_values: ['flux1-kontext-dev.safetensors', 'default'],
|
||||
color: '#223',
|
||||
bgcolor: '#335',
|
||||
index: 8
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'DualCLIPLoader',
|
||||
pos: [2100, -290],
|
||||
size: [337.76861572265625, 130],
|
||||
flags: {},
|
||||
order: 8,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'clip_name1',
|
||||
name: 'clip_name1',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'clip_name1' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'clip_name2',
|
||||
name: 'clip_name2',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'clip_name2' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'type',
|
||||
name: 'type',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'type' },
|
||||
link: null
|
||||
},
|
||||
{
|
||||
localized_name: 'device',
|
||||
name: 'device',
|
||||
shape: 7,
|
||||
type: 'COMBO',
|
||||
widget: { name: 'device' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'CLIP',
|
||||
name: 'CLIP',
|
||||
type: 'CLIP',
|
||||
links: []
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'DualCLIPLoader',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
widgets_values: [
|
||||
'clip_l.safetensors',
|
||||
't5xxl_fp8_e4m3fn_scaled.safetensors',
|
||||
'flux',
|
||||
'default'
|
||||
],
|
||||
color: '#223',
|
||||
bgcolor: '#335',
|
||||
index: 9
|
||||
},
|
||||
{
|
||||
id: -1,
|
||||
type: 'VAELoader',
|
||||
pos: [2960, -370],
|
||||
size: [270, 58],
|
||||
flags: {},
|
||||
order: 7,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'vae_name',
|
||||
name: 'vae_name',
|
||||
type: 'COMBO',
|
||||
widget: { name: 'vae_name' },
|
||||
link: null
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'VAE',
|
||||
name: 'VAE',
|
||||
type: 'VAE',
|
||||
links: []
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
'Node name for S&R': 'VAELoader',
|
||||
cnr_id: 'comfy-core',
|
||||
ver: '0.3.38'
|
||||
},
|
||||
widgets_values: ['ae.safetensors'],
|
||||
color: '#223',
|
||||
bgcolor: '#335',
|
||||
index: 10
|
||||
}
|
||||
],
|
||||
links: [
|
||||
[6, 0, 1, 0, 72, 'CONDITIONING'],
|
||||
[0, 0, 1, 1, 66, '*'],
|
||||
[3, 0, 2, 0, 69, 'LATENT'],
|
||||
[10, 0, 2, 1, 76, 'VAE'],
|
||||
[8, 0, 3, 0, 74, 'MODEL'],
|
||||
[4, 0, 3, 1, 70, 'CONDITIONING'],
|
||||
[7, 0, 3, 2, 73, 'CONDITIONING'],
|
||||
[0, 0, 3, 3, 66, '*'],
|
||||
[1, 0, 4, 0, 67, 'CONDITIONING'],
|
||||
[2, 0, 5, 0, 68, 'IMAGE'],
|
||||
[9, 0, 6, 0, 75, 'CLIP'],
|
||||
[9, 0, 7, 0, 75, 'CLIP']
|
||||
],
|
||||
external: [],
|
||||
config: {
|
||||
'0': {},
|
||||
'1': {},
|
||||
'2': { output: { '0': { visible: true } } },
|
||||
'3': {
|
||||
output: { '0': { visible: true } },
|
||||
input: {
|
||||
denoise: { visible: false },
|
||||
cfg: { visible: false }
|
||||
}
|
||||
},
|
||||
'4': {},
|
||||
'5': {},
|
||||
'6': {},
|
||||
'7': { input: { text: { visible: false } } },
|
||||
'8': { input: { weight_dtype: { visible: false } } },
|
||||
'9': { input: { type: { visible: false }, device: { visible: false } } },
|
||||
'10': {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureGraphHasFluxKontextGroupNode(
|
||||
graph: LGraph & { extra: { groupNodes?: Record<string, any> } }
|
||||
) {
|
||||
graph.extra ??= {}
|
||||
graph.extra.groupNodes ??= {}
|
||||
if (graph.extra.groupNodes['FLUX.1 Kontext Image Edit']) return
|
||||
|
||||
graph.extra.groupNodes['FLUX.1 Kontext Image Edit'] =
|
||||
structuredClone(fluxKontextGroupNode)
|
||||
|
||||
// Lazy import to avoid circular dependency issues
|
||||
const { GroupNodeConfig } = await import('@/extensions/core/groupNode')
|
||||
await GroupNodeConfig.registerFromWorkflow(
|
||||
{
|
||||
'FLUX.1 Kontext Image Edit':
|
||||
graph.extra.groupNodes['FLUX.1 Kontext Image Edit']
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
export async function addFluxKontextGroupNode(fromNode: LGraphNode) {
|
||||
const { canvas } = app
|
||||
const { graph } = canvas
|
||||
if (!graph) throw new TypeError('Graph is not initialized')
|
||||
await ensureGraphHasFluxKontextGroupNode(graph)
|
||||
|
||||
const node = LiteGraph.createNode('workflow>FLUX.1 Kontext Image Edit')
|
||||
if (!node) throw new TypeError('Failed to create node')
|
||||
|
||||
const pos = getPosToRightOfNode(fromNode)
|
||||
|
||||
graph.add(node)
|
||||
node.pos = pos
|
||||
app.canvas.processSelect(node, undefined)
|
||||
|
||||
connectPreviousLatent(fromNode, node)
|
||||
|
||||
const symb = Object.getOwnPropertySymbols(node)[0]
|
||||
// @ts-expect-error It's there -- promise.
|
||||
node[symb].populateWidgets()
|
||||
|
||||
setWidgetValues(node)
|
||||
}
|
||||
|
||||
function setWidgetValues(node: LGraphNode) {
|
||||
const seedInput = node.widgets?.find((x) => x.name === 'seed')
|
||||
if (!seedInput) throw new TypeError('Seed input not found')
|
||||
seedInput.value = Math.floor(Math.random() * 1_125_899_906_842_624)
|
||||
|
||||
const firstClip = node.widgets?.find((x) => x.name === 'clip_name1')
|
||||
setPreferredValue('t5xxl_fp8_e4m3fn_scaled.safetensors', 't5xxl', firstClip)
|
||||
|
||||
const secondClip = node.widgets?.find((x) => x.name === 'clip_name2')
|
||||
setPreferredValue('clip_l.safetensors', 'clip_l', secondClip)
|
||||
|
||||
const unet = node.widgets?.find((x) => x.name === 'unet_name')
|
||||
setPreferredValue('flux1-dev-kontext_fp8_scaled.safetensors', 'kontext', unet)
|
||||
|
||||
const vae = node.widgets?.find((x) => x.name === 'vae_name')
|
||||
setPreferredValue('ae.safetensors', 'ae.s', vae)
|
||||
}
|
||||
|
||||
function setPreferredValue(
|
||||
preferred: string,
|
||||
match: string,
|
||||
widget: IBaseWidget | undefined
|
||||
): void {
|
||||
if (!widget) throw new TypeError('Widget not found')
|
||||
|
||||
const { values } = widget.options
|
||||
if (!Array.isArray(values)) return
|
||||
|
||||
// Match against filename portion only
|
||||
const mapped = values.map((x) => parseFilePath(x).filename)
|
||||
const value =
|
||||
mapped.find((x) => x === preferred) ??
|
||||
mapped.find((x) => x.includes?.(match))
|
||||
widget.value = value ?? preferred
|
||||
}
|
||||
|
||||
function getPosToRightOfNode(fromNode: LGraphNode) {
|
||||
const nodes = app.canvas.graph?.nodes
|
||||
if (!nodes) throw new TypeError('Could not get graph nodes')
|
||||
|
||||
const pos = [
|
||||
fromNode.pos[0] + fromNode.size[0] + 100,
|
||||
fromNode.pos[1]
|
||||
] satisfies Point
|
||||
|
||||
while (nodes.find((x) => isPointTooClose(x.pos, pos))) {
|
||||
pos[0] += 20
|
||||
pos[1] += 20
|
||||
}
|
||||
|
||||
return pos
|
||||
}
|
||||
|
||||
function connectPreviousLatent(fromNode: LGraphNode, toEditNode: LGraphNode) {
|
||||
const { canvas } = app
|
||||
const { graph } = canvas
|
||||
if (!graph) throw new TypeError('Graph is not initialized')
|
||||
|
||||
const l = findNearestOutputOfType([fromNode], 'LATENT')
|
||||
if (!l) {
|
||||
const imageOutput = findNearestOutputOfType([fromNode], 'IMAGE')
|
||||
if (!imageOutput) throw new TypeError('No image output found')
|
||||
|
||||
const vaeEncode = LiteGraph.createNode('VAEEncode')
|
||||
if (!vaeEncode) throw new TypeError('Failed to create node')
|
||||
|
||||
const { node: imageNode, index: imageIndex } = imageOutput
|
||||
graph.add(vaeEncode)
|
||||
vaeEncode.pos = getPosToRightOfNode(fromNode)
|
||||
vaeEncode.pos[1] -= 200
|
||||
|
||||
vaeEncode.connect(0, toEditNode, 0)
|
||||
imageNode.connect(imageIndex, vaeEncode, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const { node, index } = l
|
||||
|
||||
node.connect(index, toEditNode, 0)
|
||||
}
|
||||
|
||||
function getInputNodes(node: LGraphNode): LGraphNode[] {
|
||||
return node.inputs
|
||||
.map((x) => LLink.resolve(x.link, app.graph)?.outputNode)
|
||||
.filter((x) => !!x)
|
||||
}
|
||||
|
||||
function getOutputOfType(
|
||||
node: LGraphNode,
|
||||
type: string
|
||||
): {
|
||||
output: INodeOutputSlot
|
||||
index: number
|
||||
} {
|
||||
const index = node.outputs.findIndex((x) => x.type === type)
|
||||
const output = node.outputs[index]
|
||||
return { output, index }
|
||||
}
|
||||
|
||||
function findNearestOutputOfType(
|
||||
nodes: Iterable<LGraphNode>,
|
||||
type: string = 'LATENT',
|
||||
depth: number = 0
|
||||
): { node: LGraphNode; index: number } | undefined {
|
||||
for (const node of nodes) {
|
||||
const { output, index } = getOutputOfType(node, type)
|
||||
if (output) return { node, index }
|
||||
}
|
||||
|
||||
if (depth < 3) {
|
||||
const closestNodes = new Set([...nodes].flatMap((x) => getInputNodes(x)))
|
||||
const res = findNearestOutputOfType(closestNodes, type, depth + 1)
|
||||
if (res) return res
|
||||
}
|
||||
}
|
||||
|
||||
function isPointTooClose(a: Point, b: Point, precision: number = 5) {
|
||||
return Math.abs(a[0] - b[0]) < precision && Math.abs(a[1] - b[1]) < precision
|
||||
}
|
||||
@@ -57,22 +57,42 @@ While services can interact with both UI components and stores (centralized stat
|
||||
|
||||
## Core Services
|
||||
|
||||
The core services include:
|
||||
The following table lists ALL services in the system as of 2025-01-30:
|
||||
|
||||
### Main Services
|
||||
|
||||
| Service | Description | Category |
|
||||
|---------|-------------|----------|
|
||||
| autoQueueService.ts | Manages automatic queue execution | Execution |
|
||||
| colorPaletteService.ts | Handles color palette management and customization | UI |
|
||||
| comfyManagerService.ts | Manages ComfyUI application packages and updates | Manager |
|
||||
| comfyRegistryService.ts | Handles registration and discovery of ComfyUI extensions | Registry |
|
||||
| dialogService.ts | Provides dialog and modal management | UI |
|
||||
| extensionService.ts | Manages extension registration and lifecycle | Extensions |
|
||||
| keybindingService.ts | Handles keyboard shortcuts and keybindings | Input |
|
||||
| litegraphService.ts | Provides utilities for working with the LiteGraph library | Graph |
|
||||
| load3dService.ts | Manages 3D model loading and visualization | 3D |
|
||||
| nodeHelpService.ts | Provides node documentation and help | Nodes |
|
||||
| nodeOrganizationService.ts | Handles node organization and categorization | Nodes |
|
||||
| nodeSearchService.ts | Implements node search functionality | Search |
|
||||
| releaseService.ts | Manages application release information and updates | System |
|
||||
| subgraphService.ts | Handles subgraph operations and navigation | Graph |
|
||||
| workflowService.ts | Handles workflow operations (save, load, execute) | Workflows |
|
||||
|
||||
### Gateway Services
|
||||
Located in `services/gateway/`:
|
||||
|
||||
| Service | Description |
|
||||
|---------|-------------|
|
||||
| algoliaSearchService.ts | Implements search functionality using Algolia |
|
||||
| autoQueueService.ts | Manages automatic queue execution |
|
||||
| colorPaletteService.ts | Handles color palette management and customization |
|
||||
| comfyManagerService.ts | Manages ComfyUI application packages and updates |
|
||||
| comfyRegistryService.ts | Handles registration and discovery of ComfyUI extensions |
|
||||
| dialogService.ts | Provides dialog and modal management |
|
||||
| extensionService.ts | Manages extension registration and lifecycle |
|
||||
| keybindingService.ts | Handles keyboard shortcuts and keybindings |
|
||||
| litegraphService.ts | Provides utilities for working with the LiteGraph library |
|
||||
| load3dService.ts | Manages 3D model loading and visualization |
|
||||
| nodeSearchService.ts | Implements node search functionality |
|
||||
| workflowService.ts | Handles workflow operations (save, load, execute) |
|
||||
| registrySearchGateway.ts | Gateway for registry search operations |
|
||||
|
||||
### Provider Services
|
||||
Located in `services/providers/`:
|
||||
|
||||
| Service | Description |
|
||||
|---------|-------------|
|
||||
| algoliaSearchProvider.ts | Implements search functionality using Algolia |
|
||||
| registrySearchProvider.ts | Provides registry search capabilities |
|
||||
|
||||
## Service Development Guidelines
|
||||
|
||||
|
||||
207
src/services/customerEventsService.ts
Normal file
207
src/services/customerEventsService.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import axios, { AxiosError, AxiosResponse } from 'axios'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { type components, operations } from '@/types/comfyRegistryTypes'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
|
||||
export enum EventType {
|
||||
CREDIT_ADDED = 'credit_added',
|
||||
ACCOUNT_CREATED = 'account_created',
|
||||
API_USAGE_STARTED = 'api_usage_started',
|
||||
API_USAGE_COMPLETED = 'api_usage_completed'
|
||||
}
|
||||
|
||||
type CustomerEventsResponse =
|
||||
operations['GetCustomerEvents']['responses']['200']['content']['application/json']
|
||||
|
||||
type CustomerEventsResponseQuery =
|
||||
operations['GetCustomerEvents']['parameters']['query']
|
||||
|
||||
export type AuditLog = components['schemas']['AuditLog']
|
||||
|
||||
const customerApiClient = axios.create({
|
||||
baseURL: COMFY_API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
export const useCustomerEventsService = () => {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const { d } = useI18n()
|
||||
|
||||
const handleRequestError = (
|
||||
err: unknown,
|
||||
context: string,
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
) => {
|
||||
// Don't treat cancellation as an error
|
||||
if (isAbortError(err)) return
|
||||
|
||||
let message: string
|
||||
if (!axios.isAxiosError(err)) {
|
||||
message = `${context} failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
} else {
|
||||
const axiosError = err as AxiosError<{ message: string }>
|
||||
const status = axiosError.response?.status
|
||||
if (status && routeSpecificErrors?.[status]) {
|
||||
message = routeSpecificErrors[status]
|
||||
} else {
|
||||
message =
|
||||
axiosError.response?.data?.message ??
|
||||
`${context} failed with status ${status}`
|
||||
}
|
||||
}
|
||||
|
||||
error.value = message
|
||||
}
|
||||
|
||||
const executeRequest = async <T>(
|
||||
requestCall: () => Promise<AxiosResponse<T>>,
|
||||
options: {
|
||||
errorContext: string
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
}
|
||||
): Promise<T | null> => {
|
||||
const { errorContext, routeSpecificErrors } = options
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await requestCall()
|
||||
return response.data
|
||||
} catch (err) {
|
||||
handleRequestError(err, errorContext, routeSpecificErrors)
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatEventType(eventType: string) {
|
||||
switch (eventType) {
|
||||
case 'credit_added':
|
||||
return 'Credits Added'
|
||||
case 'account_created':
|
||||
return 'Account Created'
|
||||
case 'api_usage_completed':
|
||||
return 'API Usage'
|
||||
default:
|
||||
return eventType
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
|
||||
return d(date, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function formatJsonKey(key: string) {
|
||||
return key
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function formatJsonValue(value: any) {
|
||||
if (typeof value === 'number') {
|
||||
// Format numbers with commas and decimals if needed
|
||||
return value.toLocaleString()
|
||||
}
|
||||
if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/)) {
|
||||
// Format dates nicely
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getEventSeverity(eventType: string) {
|
||||
switch (eventType) {
|
||||
case 'credit_added':
|
||||
return 'success'
|
||||
case 'account_created':
|
||||
return 'info'
|
||||
case 'api_usage_completed':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
function hasAdditionalInfo(event: AuditLog) {
|
||||
const { amount, api_name, model, ...otherParams } = event.params || {}
|
||||
return Object.keys(otherParams).length > 0
|
||||
}
|
||||
|
||||
function getTooltipContent(event: AuditLog) {
|
||||
const { ...params } = event.params || {}
|
||||
|
||||
return Object.entries(params)
|
||||
.map(([key, value]) => {
|
||||
const formattedKey = formatJsonKey(key)
|
||||
const formattedValue = formatJsonValue(value)
|
||||
return `<strong>${formattedKey}:</strong> ${formattedValue}`
|
||||
})
|
||||
.join('<br>')
|
||||
}
|
||||
|
||||
function formatAmount(amountMicros?: number) {
|
||||
if (!amountMicros) return '0.00'
|
||||
return (amountMicros / 100).toFixed(2)
|
||||
}
|
||||
|
||||
async function getMyEvents({
|
||||
page = 1,
|
||||
limit = 10
|
||||
}: CustomerEventsResponseQuery = {}): Promise<CustomerEventsResponse | null> {
|
||||
const errorContext = 'Fetching customer events'
|
||||
const routeSpecificErrors = {
|
||||
400: 'Invalid input, object invalid',
|
||||
404: 'Not found'
|
||||
}
|
||||
|
||||
// Get auth headers
|
||||
const authHeaders = await useFirebaseAuthStore().getAuthHeader()
|
||||
if (!authHeaders) {
|
||||
error.value = 'Authentication header is missing'
|
||||
return null
|
||||
}
|
||||
|
||||
return executeRequest<CustomerEventsResponse>(
|
||||
() =>
|
||||
customerApiClient.get('/customers/events', {
|
||||
params: { page, limit },
|
||||
headers: authHeaders
|
||||
}),
|
||||
{ errorContext, routeSpecificErrors }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Methods
|
||||
getMyEvents,
|
||||
formatEventType,
|
||||
getEventSeverity,
|
||||
formatAmount,
|
||||
hasAdditionalInfo,
|
||||
formatDate,
|
||||
formatJsonKey,
|
||||
formatJsonValue,
|
||||
getTooltipContent
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import {
|
||||
type IContextMenuValue,
|
||||
LGraphBadge,
|
||||
LGraphCanvas,
|
||||
LGraphEventMode,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
RenderShape,
|
||||
type Subgraph,
|
||||
SubgraphNode,
|
||||
type Vector2,
|
||||
createBounds
|
||||
} from '@comfyorg/litegraph'
|
||||
import type {
|
||||
ExportedSubgraphInstance,
|
||||
ISerialisableNodeInput,
|
||||
ISerialisableNodeOutput,
|
||||
ISerialisedNode
|
||||
@@ -35,6 +39,7 @@ import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import {
|
||||
isImageNode,
|
||||
@@ -56,6 +61,267 @@ export const useLitegraphService = () => {
|
||||
const widgetStore = useWidgetStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
|
||||
function registerSubgraphNodeDef(
|
||||
nodeDefV1: ComfyNodeDefV1,
|
||||
subgraph: Subgraph,
|
||||
instanceData: ExportedSubgraphInstance
|
||||
) {
|
||||
const node = class ComfyNode extends SubgraphNode {
|
||||
static comfyClass: string
|
||||
static override title: string
|
||||
static override category: string
|
||||
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
|
||||
|
||||
/**
|
||||
* @internal The initial minimum size of the node.
|
||||
*/
|
||||
#initialMinSize = { width: 1, height: 1 }
|
||||
/**
|
||||
* @internal The key for the node definition in the i18n file.
|
||||
*/
|
||||
get #nodeKey(): string {
|
||||
return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}`
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super(app.graph, subgraph, instanceData)
|
||||
|
||||
this.#setupStrokeStyles()
|
||||
this.#addInputs(ComfyNode.nodeData.inputs)
|
||||
this.#addOutputs(ComfyNode.nodeData.outputs)
|
||||
this.#setInitialSize()
|
||||
this.serialize_widgets = true
|
||||
void extensionService.invokeExtensionsAsync('nodeCreated', this)
|
||||
this.badges.push(
|
||||
new LGraphBadge({
|
||||
text: '⇌',
|
||||
fgColor: '#dad0de',
|
||||
bgColor: '#b3b'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Setup stroke styles for the node under various conditions.
|
||||
*/
|
||||
#setupStrokeStyles() {
|
||||
this.strokeStyles['running'] = function (this: LGraphNode) {
|
||||
if (this.id == app.runningNodeId) {
|
||||
return { color: '#0f0' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['nodeError'] = function (this: LGraphNode) {
|
||||
if (app.lastNodeErrors?.[this.id]?.errors) {
|
||||
return { color: 'red' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['dragOver'] = function (this: LGraphNode) {
|
||||
if (app.dragOverNode?.id == this.id) {
|
||||
return { color: 'dodgerblue' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['executionError'] = function (this: LGraphNode) {
|
||||
if (app.lastExecutionError?.node_id == this.id) {
|
||||
return { color: '#f0f', lineWidth: 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add input sockets to the node. (No widget)
|
||||
*/
|
||||
#addInputSocket(inputSpec: InputSpec) {
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(
|
||||
inputSpec.widgetType ?? inputSpec.type
|
||||
)
|
||||
if (widgetConstructor && !inputSpec.forceInput) return
|
||||
|
||||
this.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add a widget to the node. For both primitive types and custom widgets
|
||||
* (unless `socketless`), an input socket is also added.
|
||||
*/
|
||||
#addInputWidget(inputSpec: InputSpec) {
|
||||
const widgetInputSpec = { ...inputSpec }
|
||||
if (inputSpec.widgetType) {
|
||||
widgetInputSpec.type = inputSpec.widgetType
|
||||
}
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
|
||||
if (!widgetConstructor || inputSpec.forceInput) return
|
||||
|
||||
const {
|
||||
widget,
|
||||
minWidth = 1,
|
||||
minHeight = 1
|
||||
} = widgetConstructor(
|
||||
this,
|
||||
inputName,
|
||||
transformInputSpecV2ToV1(widgetInputSpec),
|
||||
app
|
||||
) ?? {}
|
||||
|
||||
if (widget) {
|
||||
widget.label = st(nameKey, widget.label ?? inputName)
|
||||
widget.options ??= {}
|
||||
Object.assign(widget.options, {
|
||||
advanced: inputSpec.advanced,
|
||||
hidden: inputSpec.hidden
|
||||
})
|
||||
}
|
||||
|
||||
if (!widget?.options?.socketless) {
|
||||
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
|
||||
this.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName),
|
||||
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
|
||||
})
|
||||
}
|
||||
|
||||
this.#initialMinSize.width = Math.max(
|
||||
this.#initialMinSize.width,
|
||||
minWidth
|
||||
)
|
||||
this.#initialMinSize.height = Math.max(
|
||||
this.#initialMinSize.height,
|
||||
minHeight
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add inputs to the node.
|
||||
*/
|
||||
#addInputs(inputs: Record<string, InputSpec>) {
|
||||
for (const inputSpec of Object.values(inputs))
|
||||
this.#addInputSocket(inputSpec)
|
||||
for (const inputSpec of Object.values(inputs))
|
||||
this.#addInputWidget(inputSpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add outputs to the node.
|
||||
*/
|
||||
#addOutputs(outputs: OutputSpec[]) {
|
||||
for (const output of outputs) {
|
||||
const { name, type, is_list } = output
|
||||
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
|
||||
const nameKey = `${this.#nodeKey}.outputs.${output.index}.name`
|
||||
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
|
||||
const outputOptions = {
|
||||
...shapeOptions,
|
||||
// If the output name is different from the output type, use the output name.
|
||||
// e.g.
|
||||
// - type ("INT"); name ("Positive") => translate name
|
||||
// - type ("FLOAT"); name ("FLOAT") => translate type
|
||||
localized_name:
|
||||
type !== name ? st(nameKey, name) : st(typeKey, name)
|
||||
}
|
||||
this.addOutput(name, type, outputOptions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Set the initial size of the node.
|
||||
*/
|
||||
#setInitialSize() {
|
||||
const s = this.computeSize()
|
||||
// Expand the width a little to fit widget values on screen.
|
||||
const pad =
|
||||
this.widgets?.length &&
|
||||
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
|
||||
s[0] = Math.max(this.#initialMinSize.width, s[0] + (pad ? 60 : 0))
|
||||
s[1] = Math.max(this.#initialMinSize.height, s[1])
|
||||
this.setSize(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the node from a serialised node. Keep 'name', 'type', 'shape',
|
||||
* and 'localized_name' information from the original node definition.
|
||||
*/
|
||||
override configure(data: ISerialisedNode): void {
|
||||
const RESERVED_KEYS = ['name', 'type', 'shape', 'localized_name']
|
||||
|
||||
// Note: input name is unique in a node definition, so we can lookup
|
||||
// input by name.
|
||||
const inputByName = new Map<string, ISerialisableNodeInput>(
|
||||
data.inputs?.map((input) => [input.name, input]) ?? []
|
||||
)
|
||||
// Inputs defined by the node definition.
|
||||
const definedInputNames = new Set(
|
||||
this.inputs.map((input) => input.name)
|
||||
)
|
||||
const definedInputs = this.inputs.map((input) => {
|
||||
const inputData = inputByName.get(input.name)
|
||||
return inputData
|
||||
? {
|
||||
...inputData,
|
||||
// Whether the input has associated widget follows the
|
||||
// original node definition.
|
||||
..._.pick(input, RESERVED_KEYS.concat('widget'))
|
||||
}
|
||||
: input
|
||||
})
|
||||
// Extra inputs that potentially dynamically added by custom js logic.
|
||||
const extraInputs = data.inputs?.filter(
|
||||
(input) => !definedInputNames.has(input.name)
|
||||
)
|
||||
data.inputs = [...definedInputs, ...(extraInputs ?? [])]
|
||||
|
||||
// Note: output name is not unique, so we cannot lookup output by name.
|
||||
// Use index instead.
|
||||
data.outputs = _.zip(this.outputs, data.outputs).map(
|
||||
([output, outputData]) => {
|
||||
// If there are extra outputs in the serialised node, use them directly.
|
||||
// There are currently custom nodes that dynamically add outputs via
|
||||
// js logic.
|
||||
if (!output) return outputData as ISerialisableNodeOutput
|
||||
|
||||
return outputData
|
||||
? {
|
||||
...outputData,
|
||||
..._.pick(output, RESERVED_KEYS)
|
||||
}
|
||||
: output
|
||||
}
|
||||
)
|
||||
|
||||
data.widgets_values = migrateWidgetsValues(
|
||||
ComfyNode.nodeData.inputs,
|
||||
this.widgets ?? [],
|
||||
data.widgets_values ?? []
|
||||
)
|
||||
|
||||
super.configure(data)
|
||||
}
|
||||
}
|
||||
|
||||
addNodeContextMenuHandler(node)
|
||||
addDrawBackgroundHandler(node)
|
||||
addNodeKeyHandler(node)
|
||||
// Note: Some extensions expects node.comfyClass to be set in
|
||||
// `beforeRegisterNodeDef`.
|
||||
node.prototype.comfyClass = nodeDefV1.name
|
||||
node.comfyClass = nodeDefV1.name
|
||||
|
||||
const nodeDef = new ComfyNodeDefImpl(nodeDefV1)
|
||||
node.nodeData = nodeDef
|
||||
LiteGraph.registerNodeType(subgraph.id, node)
|
||||
// Note: Do not following assignments before `LiteGraph.registerNodeType`
|
||||
// because `registerNodeType` will overwrite the assignments.
|
||||
node.category = nodeDef.category
|
||||
node.title = nodeDef.display_name || nodeDef.name
|
||||
}
|
||||
|
||||
async function registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) {
|
||||
const node = class ComfyNode extends LGraphNode {
|
||||
static comfyClass: string
|
||||
@@ -622,8 +888,10 @@ export const useLitegraphService = () => {
|
||||
options
|
||||
)
|
||||
|
||||
const graph = useWorkflowStore().activeSubgraph ?? app.graph
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
app.graph.add(node)
|
||||
graph.add(node)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
return node
|
||||
}
|
||||
@@ -665,6 +933,7 @@ export const useLitegraphService = () => {
|
||||
|
||||
return {
|
||||
registerNodeDef,
|
||||
registerSubgraphNodeDef,
|
||||
addNodeOnGraph,
|
||||
getCanvasCenter,
|
||||
goToNode,
|
||||
|
||||
120
src/services/releaseService.ts
Normal file
120
src/services/releaseService.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import axios, { AxiosError, AxiosResponse } from 'axios'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
|
||||
import type { components, operations } from '@/types/comfyRegistryTypes'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
|
||||
const releaseApiClient = axios.create({
|
||||
baseURL: COMFY_API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// Use generated types from OpenAPI spec
|
||||
export type ReleaseNote = components['schemas']['ReleaseNote']
|
||||
export type GetReleasesParams =
|
||||
operations['getReleaseNotes']['parameters']['query']
|
||||
|
||||
// Use generated error response type
|
||||
export type ErrorResponse = components['schemas']['ErrorResponse']
|
||||
|
||||
// Release service for fetching release notes
|
||||
export const useReleaseService = () => {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// No transformation needed - API response matches the generated type
|
||||
|
||||
// Handle API errors with context
|
||||
const handleApiError = (
|
||||
err: unknown,
|
||||
context: string,
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
): string => {
|
||||
if (!axios.isAxiosError(err))
|
||||
return err instanceof Error
|
||||
? `${context}: ${err.message}`
|
||||
: `${context}: Unknown error occurred`
|
||||
|
||||
const axiosError = err as AxiosError<ErrorResponse>
|
||||
|
||||
if (axiosError.response) {
|
||||
const { status, data } = axiosError.response
|
||||
|
||||
if (routeSpecificErrors && routeSpecificErrors[status])
|
||||
return routeSpecificErrors[status]
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
return `Bad request: ${data?.message || 'Invalid input'}`
|
||||
case 401:
|
||||
return 'Unauthorized: Authentication required'
|
||||
case 403:
|
||||
return `Forbidden: ${data?.message || 'Access denied'}`
|
||||
case 404:
|
||||
return `Not found: ${data?.message || 'Resource not found'}`
|
||||
case 500:
|
||||
return `Server error: ${data?.message || 'Internal server error'}`
|
||||
default:
|
||||
return `${context}: ${data?.message || axiosError.message}`
|
||||
}
|
||||
}
|
||||
|
||||
return `${context}: ${axiosError.message}`
|
||||
}
|
||||
|
||||
// Execute API request with error handling
|
||||
const executeApiRequest = async <T>(
|
||||
apiCall: () => Promise<AxiosResponse<T>>,
|
||||
errorContext: string,
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
): Promise<T | null> => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await apiCall()
|
||||
return response.data
|
||||
} catch (err) {
|
||||
// Don't treat cancellations as errors
|
||||
if (isAbortError(err)) return null
|
||||
|
||||
error.value = handleApiError(err, errorContext, routeSpecificErrors)
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch release notes from API
|
||||
const getReleases = async (
|
||||
params: GetReleasesParams,
|
||||
signal?: AbortSignal
|
||||
): Promise<ReleaseNote[] | null> => {
|
||||
const endpoint = '/releases'
|
||||
const errorContext = 'Failed to get releases'
|
||||
const routeSpecificErrors = {
|
||||
400: 'Invalid project or version parameter'
|
||||
}
|
||||
|
||||
const apiResponse = await executeApiRequest(
|
||||
() =>
|
||||
releaseApiClient.get<ReleaseNote[]>(endpoint, {
|
||||
params,
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
)
|
||||
|
||||
return apiResponse
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
getReleases
|
||||
}
|
||||
}
|
||||
91
src/services/subgraphService.ts
Normal file
91
src/services/subgraphService.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
type ExportedSubgraph,
|
||||
type ExportedSubgraphInstance,
|
||||
type Subgraph
|
||||
} from '@comfyorg/litegraph'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import { useLitegraphService } from './litegraphService'
|
||||
|
||||
export const useSubgraphService = () => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
/** Loads a single subgraph definition and registers it with the node def store */
|
||||
function registerLitegraphNode(
|
||||
nodeDef: ComfyNodeDefV1,
|
||||
subgraph: Subgraph,
|
||||
exportedSubgraph: ExportedSubgraph
|
||||
) {
|
||||
const instanceData: ExportedSubgraphInstance = {
|
||||
id: -1,
|
||||
type: exportedSubgraph.id,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
}
|
||||
|
||||
useLitegraphService().registerSubgraphNodeDef(
|
||||
nodeDef,
|
||||
subgraph,
|
||||
instanceData
|
||||
)
|
||||
}
|
||||
|
||||
function createNodeDef(exportedSubgraph: ExportedSubgraph) {
|
||||
const { id, name } = exportedSubgraph
|
||||
|
||||
const nodeDef: ComfyNodeDefV1 = {
|
||||
input: { required: {} },
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_tooltips: [],
|
||||
name: id,
|
||||
display_name: name,
|
||||
description: `Subgraph node for ${name}`,
|
||||
category: 'subgraph',
|
||||
output_node: false,
|
||||
python_module: 'nodes'
|
||||
}
|
||||
|
||||
nodeDefStore.addNodeDef(nodeDef)
|
||||
return nodeDef
|
||||
}
|
||||
|
||||
/** Loads all exported subgraph definitions from workflow */
|
||||
function loadSubgraphs(graphData: ComfyWorkflowJSON) {
|
||||
const subgraphs = graphData.definitions?.subgraphs
|
||||
if (!subgraphs) return
|
||||
|
||||
// Assertion: overriding Zod schema
|
||||
for (const subgraphData of subgraphs as ExportedSubgraph[]) {
|
||||
const subgraph =
|
||||
comfyApp.graph.subgraphs.get(subgraphData.id) ??
|
||||
comfyApp.graph.createSubgraph(subgraphData)
|
||||
|
||||
registerNewSubgraph(subgraph, subgraphData)
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers a new subgraph (e.g. user converted from nodes) */
|
||||
function registerNewSubgraph(
|
||||
subgraph: Subgraph,
|
||||
exportedSubgraph: ExportedSubgraph
|
||||
) {
|
||||
const nodeDef = createNodeDef(exportedSubgraph)
|
||||
registerLitegraphNode(nodeDef, subgraph, exportedSubgraph)
|
||||
}
|
||||
|
||||
return {
|
||||
loadSubgraphs,
|
||||
registerNewSubgraph
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
|
||||
import { downloadBlob } from '@/scripts/utils'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
@@ -20,6 +21,7 @@ export const useWorkflowService = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const toastStore = useToastStore()
|
||||
const dialogService = useDialogService()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
async function getFilename(defaultName: string): Promise<string | null> {
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
@@ -285,11 +287,8 @@ export const useWorkflowService = () => {
|
||||
*/
|
||||
const beforeLoadNewGraph = () => {
|
||||
// Use workspaceStore here as it is patched in unit tests.
|
||||
const workflowStore = useWorkspaceStore().workflow
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
if (activeWorkflow) {
|
||||
activeWorkflow.changeTracker.store()
|
||||
}
|
||||
useWorkspaceStore().workflow.activeWorkflow?.changeTracker?.store()
|
||||
domWidgetStore.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,8 +344,7 @@ export const useWorkflowService = () => {
|
||||
options: { position?: Vector2 } = {}
|
||||
) => {
|
||||
const loadedWorkflow = await workflow.load()
|
||||
const data = loadedWorkflow.initialState
|
||||
const workflowJSON = data
|
||||
const workflowJSON = toRaw(loadedWorkflow.initialState)
|
||||
const old = localStorage.getItem('litegrapheditor_clipboard')
|
||||
// unknown conversion: ComfyWorkflowJSON is stricter than LiteGraph's
|
||||
// serialisation schema.
|
||||
|
||||
@@ -100,31 +100,56 @@ The following diagram illustrates the store architecture and data flow:
|
||||
|
||||
## Core Stores
|
||||
|
||||
The core stores include:
|
||||
The following table lists ALL stores in the system as of 2025-01-30:
|
||||
|
||||
### Main Stores
|
||||
|
||||
| Store | Description | Category |
|
||||
|-------|-------------|----------|
|
||||
| aboutPanelStore.ts | Manages the About panel state and badges | UI |
|
||||
| apiKeyAuthStore.ts | Handles API key authentication | Auth |
|
||||
| comfyManagerStore.ts | Manages ComfyUI application state | Core |
|
||||
| comfyRegistryStore.ts | Handles extensions registry | Registry |
|
||||
| commandStore.ts | Manages commands and command execution | Core |
|
||||
| dialogStore.ts | Controls dialog/modal display and state | UI |
|
||||
| domWidgetStore.ts | Manages DOM widget state | Widgets |
|
||||
| electronDownloadStore.ts | Handles Electron-specific download operations | Platform |
|
||||
| executionStore.ts | Tracks workflow execution state | Execution |
|
||||
| extensionStore.ts | Manages extension registration and state | Extensions |
|
||||
| firebaseAuthStore.ts | Handles Firebase authentication | Auth |
|
||||
| graphStore.ts | Manages the graph canvas state | Core |
|
||||
| imagePreviewStore.ts | Controls image preview functionality | Media |
|
||||
| keybindingStore.ts | Manages keyboard shortcuts | Input |
|
||||
| maintenanceTaskStore.ts | Handles system maintenance tasks | System |
|
||||
| menuItemStore.ts | Handles menu items and their state | UI |
|
||||
| modelStore.ts | Manages AI models information | Models |
|
||||
| modelToNodeStore.ts | Maps models to compatible nodes | Models |
|
||||
| nodeBookmarkStore.ts | Manages node bookmarks and favorites | Nodes |
|
||||
| nodeDefStore.ts | Manages node definitions | Nodes |
|
||||
| queueStore.ts | Handles the execution queue | Execution |
|
||||
| releaseStore.ts | Manages application release information | System |
|
||||
| serverConfigStore.ts | Handles server configuration | Config |
|
||||
| settingStore.ts | Manages application settings | Config |
|
||||
| subgraphNavigationStore.ts | Handles subgraph navigation state | Navigation |
|
||||
| systemStatsStore.ts | Tracks system performance statistics | System |
|
||||
| toastStore.ts | Manages toast notifications | UI |
|
||||
| userFileStore.ts | Manages user file operations | Files |
|
||||
| userStore.ts | Manages user data and preferences | User |
|
||||
| widgetStore.ts | Manages widget configurations | Widgets |
|
||||
| workflowStore.ts | Handles workflow data and operations | Workflows |
|
||||
| workflowTemplatesStore.ts | Manages workflow templates | Workflows |
|
||||
| workspaceStore.ts | Manages overall workspace state | Workspace |
|
||||
|
||||
### Workspace Stores
|
||||
Located in `stores/workspace/`:
|
||||
|
||||
| Store | Description |
|
||||
|-------|-------------|
|
||||
| aboutPanelStore.ts | Manages the About panel state and badges |
|
||||
| apiKeyAuthStore.ts | Handles API key authentication |
|
||||
| comfyManagerStore.ts | Manages ComfyUI application state |
|
||||
| comfyRegistryStore.ts | Handles extensions registry |
|
||||
| commandStore.ts | Manages commands and command execution |
|
||||
| dialogStore.ts | Controls dialog/modal display and state |
|
||||
| domWidgetStore.ts | Manages DOM widget state |
|
||||
| executionStore.ts | Tracks workflow execution state |
|
||||
| extensionStore.ts | Manages extension registration and state |
|
||||
| firebaseAuthStore.ts | Handles Firebase authentication |
|
||||
| graphStore.ts | Manages the graph canvas state |
|
||||
| imagePreviewStore.ts | Controls image preview functionality |
|
||||
| keybindingStore.ts | Manages keyboard shortcuts |
|
||||
| menuItemStore.ts | Handles menu items and their state |
|
||||
| modelStore.ts | Manages AI models information |
|
||||
| nodeDefStore.ts | Manages node definitions |
|
||||
| queueStore.ts | Handles the execution queue |
|
||||
| settingStore.ts | Manages application settings |
|
||||
| userStore.ts | Manages user data and preferences |
|
||||
| workflowStore.ts | Handles workflow data and operations |
|
||||
| workspace/* | Stores related to the workspace UI |
|
||||
| bottomPanelStore.ts | Controls bottom panel visibility and state |
|
||||
| colorPaletteStore.ts | Manages color palette configurations |
|
||||
| nodeHelpStore.ts | Handles node help and documentation display |
|
||||
| searchBoxStore.ts | Manages search box functionality |
|
||||
| sidebarTabStore.ts | Controls sidebar tab states and navigation |
|
||||
|
||||
## Store Development Guidelines
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Stores all DOM widgets that are used in the canvas.
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { type Raw, markRaw, ref } from 'vue'
|
||||
import { type Raw, computed, markRaw, ref } from 'vue'
|
||||
|
||||
import type { PositionConfig } from '@/composables/element/useAbsolutePosition'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
@@ -13,11 +13,20 @@ export interface DomWidgetState extends PositionConfig {
|
||||
visible: boolean
|
||||
readonly: boolean
|
||||
zIndex: number
|
||||
/** If the widget belongs to the current graph/subgraph. */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
const widgetStates = ref<Map<string, DomWidgetState>>(new Map())
|
||||
|
||||
const activeWidgetStates = computed(() =>
|
||||
[...widgetStates.value.values()].filter((state) => state.active)
|
||||
)
|
||||
const inactiveWidgetStates = computed(() =>
|
||||
[...widgetStates.value.values()].filter((state) => !state.active)
|
||||
)
|
||||
|
||||
// Register a widget with the store
|
||||
const registerWidget = <V extends object | string>(
|
||||
widget: BaseDOMWidget<V>
|
||||
@@ -28,7 +37,8 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
readonly: false,
|
||||
zIndex: 0,
|
||||
pos: [0, 0],
|
||||
size: [0, 0]
|
||||
size: [0, 0],
|
||||
active: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,9 +47,28 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
widgetStates.value.delete(widgetId)
|
||||
}
|
||||
|
||||
const activateWidget = (widgetId: string) => {
|
||||
const state = widgetStates.value.get(widgetId)
|
||||
if (state) state.active = true
|
||||
}
|
||||
|
||||
const deactivateWidget = (widgetId: string) => {
|
||||
const state = widgetStates.value.get(widgetId)
|
||||
if (state) state.active = false
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
widgetStates.value.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
widgetStates,
|
||||
activeWidgetStates,
|
||||
inactiveWidgetStates,
|
||||
registerWidget,
|
||||
unregisterWidget
|
||||
unregisterWidget,
|
||||
activateWidget,
|
||||
deactivateWidget,
|
||||
clear
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { LGraph, Subgraph } from '@comfyorg/litegraph'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
@@ -20,9 +21,9 @@ import type {
|
||||
NodeId
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
import { ComfyWorkflow } from './workflowStore'
|
||||
import { useCanvasStore } from './graphStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from './workflowStore'
|
||||
|
||||
export interface QueuedPrompt {
|
||||
/**
|
||||
@@ -37,6 +38,9 @@ export interface QueuedPrompt {
|
||||
}
|
||||
|
||||
export const useExecutionStore = defineStore('execution', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const clientId = ref<string | null>(null)
|
||||
const activePromptId = ref<string | null>(null)
|
||||
const queuedPrompts = ref<Record<NodeId, QueuedPrompt>>({})
|
||||
@@ -54,12 +58,64 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
if (!canvasState) return null
|
||||
|
||||
return (
|
||||
canvasState.nodes.find(
|
||||
(n: ComfyNode) => String(n.id) === executingNodeId.value
|
||||
) ?? null
|
||||
canvasState.nodes.find((n) => String(n.id) === executingNodeId.value) ??
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (node?.isSubgraphNode()) return node.subgraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively get the subgraph objects for the given subgraph instance IDs
|
||||
* @param currentGraph The current graph
|
||||
* @param subgraphNodeIds The instance IDs
|
||||
* @param subgraphs The subgraphs
|
||||
* @returns The subgraphs that correspond to each of the instance IDs.
|
||||
*/
|
||||
const getSubgraphsFromInstanceIds = (
|
||||
currentGraph: LGraph | Subgraph,
|
||||
subgraphNodeIds: string[],
|
||||
subgraphs: Subgraph[] = []
|
||||
): Subgraph[] => {
|
||||
// Last segment is the node portion; nothing to do.
|
||||
if (subgraphNodeIds.length === 1) return subgraphs
|
||||
|
||||
const currentPart = subgraphNodeIds.shift()
|
||||
if (currentPart === undefined) return subgraphs
|
||||
|
||||
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
|
||||
if (!subgraph) throw new Error(`Subgraph not found: ${currentPart}`)
|
||||
|
||||
subgraphs.push(subgraph)
|
||||
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
|
||||
}
|
||||
|
||||
const executionIdToCurrentId = (id: string) => {
|
||||
const subgraph = workflowStore.activeSubgraph
|
||||
|
||||
// Short-circuit: ID belongs to the parent workflow / no active subgraph
|
||||
if (!id.includes(':')) {
|
||||
return !subgraph ? id : undefined
|
||||
} else if (!subgraph) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the hierarchical ID (e.g., "123:456:789")
|
||||
const subgraphNodeIds = id.split(':')
|
||||
|
||||
// If the last subgraph is the active subgraph, return the node ID
|
||||
const subgraphs = getSubgraphsFromInstanceIds(
|
||||
subgraph.rootGraph,
|
||||
subgraphNodeIds
|
||||
)
|
||||
if (subgraphs.at(-1) === subgraph) {
|
||||
return subgraphNodeIds.at(-1)
|
||||
}
|
||||
}
|
||||
|
||||
// This is the progress of the currently executing node, if any
|
||||
const _executingNodeProgress = ref<ProgressWsMessage | null>(null)
|
||||
const executingNodeProgress = computed(() =>
|
||||
@@ -132,7 +188,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
activePrompt.value.nodes[e.detail.node] = true
|
||||
}
|
||||
|
||||
function handleExecuting(e: CustomEvent<NodeId | null>) {
|
||||
function handleExecuting(e: CustomEvent<NodeId | null>): void {
|
||||
// Clear the current node progress when a new node starts executing
|
||||
_executingNodeProgress.value = null
|
||||
|
||||
@@ -142,12 +198,16 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
// Seems sometimes nodes that are cached fire executing but not executed
|
||||
activePrompt.value.nodes[executingNodeId.value] = true
|
||||
}
|
||||
executingNodeId.value = e.detail
|
||||
if (executingNodeId.value === null) {
|
||||
if (activePromptId.value) {
|
||||
delete queuedPrompts.value[activePromptId.value]
|
||||
if (typeof e.detail === 'string') {
|
||||
executingNodeId.value = executionIdToCurrentId(e.detail) ?? null
|
||||
} else {
|
||||
executingNodeId.value = e.detail
|
||||
if (executingNodeId.value === null) {
|
||||
if (activePromptId.value) {
|
||||
delete queuedPrompts.value[activePromptId.value]
|
||||
}
|
||||
activePromptId.value = null
|
||||
}
|
||||
activePromptId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,19 +228,31 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
lastExecutionError.value = e.detail
|
||||
}
|
||||
|
||||
function getNodeIdIfExecuting(nodeId: string | number) {
|
||||
const nodeIdStr = String(nodeId)
|
||||
return nodeIdStr.includes(':')
|
||||
? workflowStore.executionIdToCurrentId(nodeIdStr)
|
||||
: nodeIdStr
|
||||
}
|
||||
|
||||
function handleProgressText(e: CustomEvent<ProgressTextWsMessage>) {
|
||||
const { nodeId, text } = e.detail
|
||||
if (!text || !nodeId) return
|
||||
|
||||
const node = app.graph.getNodeById(nodeId)
|
||||
// Handle hierarchical node IDs for subgraphs
|
||||
const currentId = getNodeIdIfExecuting(nodeId)
|
||||
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
|
||||
if (!node) return
|
||||
|
||||
useNodeProgressText().showTextPreview(node, text)
|
||||
}
|
||||
|
||||
function handleDisplayComponent(e: CustomEvent<DisplayComponentWsMessage>) {
|
||||
const { node_id, component, props = {} } = e.detail
|
||||
const node = app.graph.getNodeById(node_id)
|
||||
const { node_id: nodeId, component, props = {} } = e.detail
|
||||
|
||||
// Handle hierarchical node IDs for subgraphs
|
||||
const currentId = getNodeIdIfExecuting(nodeId)
|
||||
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
|
||||
if (!node) return
|
||||
|
||||
if (component === 'ChatHistoryWidget') {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Positionable } from '@comfyorg/litegraph/dist/interfaces'
|
||||
import { defineStore } from 'pinia'
|
||||
import { type Raw, computed, markRaw, ref, shallowRef } from 'vue'
|
||||
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isLGraphGroup, isLGraphNode, isReroute } from '@/utils/litegraphUtil'
|
||||
|
||||
export const useTitleEditorStore = defineStore('titleEditor', () => {
|
||||
const titleEditorTarget = shallowRef<LGraphNode | LGraphGroup | null>(null)
|
||||
@@ -31,6 +31,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
|
||||
const nodeSelected = computed(() => selectedItems.value.some(isLGraphNode))
|
||||
const groupSelected = computed(() => selectedItems.value.some(isLGraphGroup))
|
||||
const rerouteSelected = computed(() => selectedItems.value.some(isReroute))
|
||||
|
||||
const getCanvas = () => {
|
||||
if (!canvas.value) throw new Error('getCanvas: canvas is null')
|
||||
@@ -42,6 +43,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
selectedItems,
|
||||
nodeSelected,
|
||||
groupSelected,
|
||||
rerouteSelected,
|
||||
updateSelectedItems,
|
||||
getCanvas
|
||||
}
|
||||
|
||||
@@ -306,8 +306,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
}
|
||||
function fromLGraphNode(node: LGraphNode): ComfyNodeDefImpl | null {
|
||||
// Frontend-only nodes don't have nodeDef
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Optional chaining used in index
|
||||
// @ts-expect-error Optional chaining used in index
|
||||
return nodeDefsByName.value[node.constructor?.nodeData?.name] ?? null
|
||||
}
|
||||
|
||||
|
||||
238
src/stores/releaseStore.ts
Normal file
238
src/stores/releaseStore.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { type ReleaseNote, useReleaseService } from '@/services/releaseService'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { compareVersions, stringToLocale } from '@/utils/formatUtil'
|
||||
|
||||
// Store for managing release notes
|
||||
export const useReleaseStore = defineStore('release', () => {
|
||||
// State
|
||||
const releases = ref<ReleaseNote[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Services
|
||||
const releaseService = useReleaseService()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// Current ComfyUI version
|
||||
const currentComfyUIVersion = computed(
|
||||
() => systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
|
||||
)
|
||||
|
||||
// Release data from settings
|
||||
const locale = computed(() => settingStore.get('Comfy.Locale'))
|
||||
const releaseVersion = computed(() =>
|
||||
settingStore.get('Comfy.Release.Version')
|
||||
)
|
||||
const releaseStatus = computed(() => settingStore.get('Comfy.Release.Status'))
|
||||
const releaseTimestamp = computed(() =>
|
||||
settingStore.get('Comfy.Release.Timestamp')
|
||||
)
|
||||
|
||||
// Most recent release
|
||||
const recentRelease = computed(() => {
|
||||
return releases.value[0] ?? null
|
||||
})
|
||||
|
||||
// 3 most recent releases
|
||||
const recentReleases = computed(() => {
|
||||
return releases.value.slice(0, 3)
|
||||
})
|
||||
|
||||
// Helper constants
|
||||
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000 // 3 days
|
||||
|
||||
// New version available?
|
||||
const isNewVersionAvailable = computed(
|
||||
() =>
|
||||
!!recentRelease.value &&
|
||||
compareVersions(
|
||||
recentRelease.value.version,
|
||||
currentComfyUIVersion.value
|
||||
) > 0
|
||||
)
|
||||
|
||||
const isLatestVersion = computed(
|
||||
() =>
|
||||
!!recentRelease.value &&
|
||||
!compareVersions(recentRelease.value.version, currentComfyUIVersion.value)
|
||||
)
|
||||
|
||||
const hasMediumOrHighAttention = computed(() =>
|
||||
recentReleases.value
|
||||
.slice(0, -1)
|
||||
.some(
|
||||
(release) =>
|
||||
release.attention === 'medium' || release.attention === 'high'
|
||||
)
|
||||
)
|
||||
|
||||
// Show toast if needed
|
||||
const shouldShowToast = computed(() => {
|
||||
if (!isNewVersionAvailable.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip if low attention
|
||||
if (!hasMediumOrHighAttention.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip if user already skipped or changelog seen
|
||||
if (
|
||||
releaseVersion.value === recentRelease.value?.version &&
|
||||
!['skipped', 'changelog seen'].includes(releaseStatus.value)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Show red-dot indicator
|
||||
const shouldShowRedDot = computed(() => {
|
||||
// Already latest → no dot
|
||||
if (!isNewVersionAvailable.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { version } = recentRelease.value
|
||||
|
||||
// Changelog seen → clear dot
|
||||
if (
|
||||
releaseVersion.value === version &&
|
||||
releaseStatus.value === 'changelog seen'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Attention medium / high (levels 2 & 3)
|
||||
if (hasMediumOrHighAttention.value) {
|
||||
// Persist until changelog is opened
|
||||
return true
|
||||
}
|
||||
|
||||
// Attention low (level 1) and skipped → keep up to 3 d
|
||||
if (
|
||||
releaseVersion.value === version &&
|
||||
releaseStatus.value === 'skipped' &&
|
||||
releaseTimestamp.value &&
|
||||
Date.now() - releaseTimestamp.value >= THREE_DAYS_MS
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Not skipped → show
|
||||
return true
|
||||
})
|
||||
|
||||
// Show "What's New" popup
|
||||
const shouldShowPopup = computed(() => {
|
||||
if (!isLatestVersion.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Hide if already seen
|
||||
if (
|
||||
releaseVersion.value === recentRelease.value.version &&
|
||||
releaseStatus.value === "what's new seen"
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Action handlers for user interactions
|
||||
async function handleSkipRelease(version: string): Promise<void> {
|
||||
if (
|
||||
version !== recentRelease.value?.version ||
|
||||
releaseStatus.value === 'changelog seen'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
await settingStore.set('Comfy.Release.Version', version)
|
||||
await settingStore.set('Comfy.Release.Status', 'skipped')
|
||||
await settingStore.set('Comfy.Release.Timestamp', Date.now())
|
||||
}
|
||||
|
||||
async function handleShowChangelog(version: string): Promise<void> {
|
||||
if (version !== recentRelease.value?.version) {
|
||||
return
|
||||
}
|
||||
|
||||
await settingStore.set('Comfy.Release.Version', version)
|
||||
await settingStore.set('Comfy.Release.Status', 'changelog seen')
|
||||
await settingStore.set('Comfy.Release.Timestamp', Date.now())
|
||||
}
|
||||
|
||||
async function handleWhatsNewSeen(version: string): Promise<void> {
|
||||
if (version !== recentRelease.value?.version) {
|
||||
return
|
||||
}
|
||||
|
||||
await settingStore.set('Comfy.Release.Version', version)
|
||||
await settingStore.set('Comfy.Release.Status', "what's new seen")
|
||||
await settingStore.set('Comfy.Release.Timestamp', Date.now())
|
||||
}
|
||||
|
||||
// Fetch releases from API
|
||||
async function fetchReleases(): Promise<void> {
|
||||
if (isLoading.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Ensure system stats are loaded
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await systemStatsStore.fetchSystemStats()
|
||||
}
|
||||
|
||||
const fetchedReleases = await releaseService.getReleases({
|
||||
project: 'comfyui',
|
||||
current_version: currentComfyUIVersion.value,
|
||||
form_factor: systemStatsStore.getFormFactor(),
|
||||
locale: stringToLocale(locale.value)
|
||||
})
|
||||
|
||||
if (fetchedReleases !== null) {
|
||||
releases.value = fetchedReleases
|
||||
} else if (releaseService.error.value) {
|
||||
error.value = releaseService.error.value
|
||||
}
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize store
|
||||
async function initialize(): Promise<void> {
|
||||
await fetchReleases()
|
||||
}
|
||||
|
||||
return {
|
||||
releases,
|
||||
isLoading,
|
||||
error,
|
||||
recentRelease,
|
||||
recentReleases,
|
||||
shouldShowToast,
|
||||
shouldShowRedDot,
|
||||
shouldShowPopup,
|
||||
shouldShowUpdateButton: isNewVersionAvailable,
|
||||
handleSkipRelease,
|
||||
handleShowChangelog,
|
||||
handleWhatsNewSeen,
|
||||
fetchReleases,
|
||||
initialize
|
||||
}
|
||||
})
|
||||
87
src/stores/subgraphNavigationStore.ts
Normal file
87
src/stores/subgraphNavigationStore.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Subgraph } from '@comfyorg/litegraph'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, shallowReactive, shallowRef, watch } from 'vue'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { isNonNullish } from '@/utils/typeGuardUtil'
|
||||
|
||||
import { useWorkflowStore } from './workflowStore'
|
||||
|
||||
/**
|
||||
* Stores the current subgraph navigation state; a stack representing subgraph
|
||||
* navigation history from the root graph to the subgraph that is currently
|
||||
* open.
|
||||
*/
|
||||
export const useSubgraphNavigationStore = defineStore(
|
||||
'subgraphNavigation',
|
||||
() => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
/** The currently opened subgraph. */
|
||||
const activeSubgraph = shallowRef<Subgraph>()
|
||||
|
||||
/** The stack of subgraph IDs from the root graph to the currently opened subgraph. */
|
||||
const idStack = shallowReactive<string[]>([])
|
||||
|
||||
/**
|
||||
* A stack representing subgraph navigation history from the root graph to
|
||||
* the current opened subgraph.
|
||||
*/
|
||||
const navigationStack = computed(() =>
|
||||
idStack.map((id) => app.graph.subgraphs.get(id)).filter(isNonNullish)
|
||||
)
|
||||
|
||||
/**
|
||||
* Restore the navigation stack from a list of subgraph IDs.
|
||||
* @param subgraphIds The list of subgraph IDs to restore the navigation stack from.
|
||||
* @see exportState
|
||||
*/
|
||||
const restoreState = (subgraphIds: string[]) => {
|
||||
idStack.length = 0
|
||||
for (const id of subgraphIds) idStack.push(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the navigation stack as a list of subgraph IDs.
|
||||
* @returns The list of subgraph IDs, ending with the currently active subgraph.
|
||||
* @see restoreState
|
||||
*/
|
||||
const exportState = () => [...idStack]
|
||||
|
||||
// Reset on workflow change
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
() => (idStack.length = 0)
|
||||
)
|
||||
|
||||
// Update navigation stack when opened subgraph changes
|
||||
watch(
|
||||
() => workflowStore.activeSubgraph,
|
||||
(subgraph) => {
|
||||
// Navigated back to the root graph
|
||||
if (!subgraph) {
|
||||
idStack.length = 0
|
||||
return
|
||||
}
|
||||
|
||||
const index = idStack.lastIndexOf(subgraph.id)
|
||||
const lastIndex = idStack.length - 1
|
||||
|
||||
if (index === -1) {
|
||||
// Opened a new subgraph
|
||||
idStack.push(subgraph.id)
|
||||
} else if (index !== lastIndex) {
|
||||
// Navigated to a different subgraph
|
||||
idStack.splice(index + 1, lastIndex - index)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
activeSubgraph,
|
||||
navigationStack,
|
||||
restoreState,
|
||||
exportState
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
export const useSystemStatsStore = defineStore('systemStats', () => {
|
||||
const systemStats = ref<SystemStats | null>(null)
|
||||
@@ -26,10 +27,42 @@ export const useSystemStatsStore = defineStore('systemStats', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function getFormFactor(): string {
|
||||
if (!systemStats.value?.system?.os) {
|
||||
return 'other'
|
||||
}
|
||||
|
||||
const os = systemStats.value.system.os.toLowerCase()
|
||||
const isDesktop = isElectron()
|
||||
|
||||
if (isDesktop) {
|
||||
if (os.includes('windows')) {
|
||||
return 'desktop-windows'
|
||||
}
|
||||
if (os.includes('darwin') || os.includes('mac')) {
|
||||
return 'desktop-mac'
|
||||
}
|
||||
} else {
|
||||
// Git/source installation
|
||||
if (os.includes('windows')) {
|
||||
return 'git-windows'
|
||||
}
|
||||
if (os.includes('darwin') || os.includes('mac')) {
|
||||
return 'git-mac'
|
||||
}
|
||||
if (os.includes('linux')) {
|
||||
return 'git-linux'
|
||||
}
|
||||
}
|
||||
|
||||
return 'other'
|
||||
}
|
||||
|
||||
return {
|
||||
systemStats,
|
||||
isLoading,
|
||||
error,
|
||||
fetchSystemStats
|
||||
fetchSystemStats,
|
||||
getFormFactor
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LGraph, Subgraph } from '@comfyorg/litegraph'
|
||||
import _ from 'lodash'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, markRaw, ref, watch } from 'vue'
|
||||
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -156,12 +157,12 @@ export interface WorkflowStore {
|
||||
syncWorkflows: (dir?: string) => Promise<void>
|
||||
reorderWorkflows: (from: number, to: number) => void
|
||||
|
||||
/** An ordered list of all parent subgraphs, ending with the current subgraph. */
|
||||
subgraphNamePath: string[]
|
||||
/** `true` if any subgraph is currently being viewed. */
|
||||
isSubgraphActive: boolean
|
||||
activeSubgraph: Subgraph | undefined
|
||||
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
|
||||
updateActiveGraph: () => void
|
||||
executionIdToCurrentId: (id: string) => any
|
||||
}
|
||||
|
||||
export const useWorkflowStore = defineStore('workflow', () => {
|
||||
@@ -427,24 +428,61 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** @see WorkflowStore.subgraphNamePath */
|
||||
const subgraphNamePath = ref<string[]>([])
|
||||
/** @see WorkflowStore.isSubgraphActive */
|
||||
const isSubgraphActive = ref(false)
|
||||
|
||||
/** @see WorkflowStore.activeSubgraph */
|
||||
const activeSubgraph = shallowRef<Raw<Subgraph>>()
|
||||
|
||||
/** @see WorkflowStore.updateActiveGraph */
|
||||
const updateActiveGraph = () => {
|
||||
const subgraph = comfyApp.canvas?.subgraph
|
||||
activeSubgraph.value = subgraph ? markRaw(subgraph) : undefined
|
||||
if (!comfyApp.canvas) return
|
||||
|
||||
const { subgraph } = comfyApp.canvas
|
||||
isSubgraphActive.value = isSubgraph(subgraph)
|
||||
}
|
||||
|
||||
if (subgraph) {
|
||||
const [, ...pathFromRoot] = subgraph.pathToRootGraph
|
||||
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (node?.isSubgraphNode()) return node.subgraph
|
||||
}
|
||||
|
||||
subgraphNamePath.value = pathFromRoot.map((graph) => graph.name)
|
||||
} else {
|
||||
subgraphNamePath.value = []
|
||||
const getSubgraphsFromInstanceIds = (
|
||||
currentGraph: LGraph | Subgraph,
|
||||
subgraphNodeIds: string[],
|
||||
subgraphs: Subgraph[] = []
|
||||
): Subgraph[] => {
|
||||
const currentPart = subgraphNodeIds.shift()
|
||||
if (currentPart === undefined) return subgraphs
|
||||
|
||||
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
|
||||
if (subgraph === undefined) throw new Error('Subgraph not found')
|
||||
|
||||
subgraphs.push(subgraph)
|
||||
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
|
||||
}
|
||||
|
||||
const executionIdToCurrentId = (id: string) => {
|
||||
const subgraph = activeSubgraph.value
|
||||
|
||||
// Short-circuit: ID belongs to the parent workflow / no active subgraph
|
||||
if (!id.includes(':')) {
|
||||
return !subgraph ? id : undefined
|
||||
} else if (!subgraph) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the hierarchical ID (e.g., "123:456:789")
|
||||
const subgraphNodeIds = id.split(':')
|
||||
|
||||
// Start from the root graph
|
||||
const { graph } = comfyApp
|
||||
|
||||
// If the last subgraph is the active subgraph, return the node ID
|
||||
const subgraphs = getSubgraphsFromInstanceIds(graph, subgraphNodeIds)
|
||||
if (subgraphs.at(-1) === subgraph) {
|
||||
return subgraphNodeIds.at(-1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,9 +511,10 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
getWorkflowByPath,
|
||||
syncWorkflows,
|
||||
|
||||
subgraphNamePath,
|
||||
isSubgraphActive,
|
||||
updateActiveGraph
|
||||
activeSubgraph,
|
||||
updateActiveGraph,
|
||||
executionIdToCurrentId
|
||||
}
|
||||
}) satisfies () => WorkflowStore
|
||||
|
||||
|
||||
@@ -8742,16 +8742,42 @@ export interface components {
|
||||
MoonvalleyUploadResponse: {
|
||||
access_url?: string
|
||||
}
|
||||
/** @description GitHub release webhook payload based on official webhook documentation */
|
||||
GithubReleaseWebhook: {
|
||||
/** @description The action performed on the release */
|
||||
action: string
|
||||
/**
|
||||
* @description The action performed on the release
|
||||
* @enum {string}
|
||||
*/
|
||||
action:
|
||||
| 'published'
|
||||
| 'unpublished'
|
||||
| 'created'
|
||||
| 'edited'
|
||||
| 'deleted'
|
||||
| 'prereleased'
|
||||
| 'released'
|
||||
/** @description The release object */
|
||||
release: {
|
||||
/** @description The ID of the release */
|
||||
id: number
|
||||
/** @description The node ID of the release */
|
||||
node_id: string
|
||||
/** @description The API URL of the release */
|
||||
url: string
|
||||
/** @description The HTML URL of the release */
|
||||
html_url: string
|
||||
/** @description The URL to the release assets */
|
||||
assets_url?: string
|
||||
/** @description The URL to upload release assets */
|
||||
upload_url?: string
|
||||
/** @description The tag name of the release */
|
||||
tag_name: string
|
||||
/** @description The branch or commit the release was created from */
|
||||
target_commitish: string
|
||||
/** @description The name of the release */
|
||||
name: string
|
||||
name?: string | null
|
||||
/** @description The release notes/body */
|
||||
body: string
|
||||
body?: string | null
|
||||
/** @description Whether the release is a draft */
|
||||
draft: boolean
|
||||
/** @description Whether the release is a prerelease */
|
||||
@@ -8760,33 +8786,228 @@ export interface components {
|
||||
* Format: date-time
|
||||
* @description When the release was created
|
||||
*/
|
||||
created_at?: string
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the release was published
|
||||
*/
|
||||
published_at?: string
|
||||
published_at?: string | null
|
||||
author: components['schemas']['GithubUser']
|
||||
/** @description URL to the tarball */
|
||||
tarball_url?: string
|
||||
tarball_url: string
|
||||
/** @description URL to the zipball */
|
||||
zipball_url?: string
|
||||
/** @description The branch or commit the release was created from */
|
||||
target_commitish: string
|
||||
}
|
||||
repository: {
|
||||
/** @description The name of the repository */
|
||||
name: string
|
||||
/** @description The full name of the repository (owner/repo) */
|
||||
full_name: string
|
||||
/** @description The HTML URL of the repository */
|
||||
html_url: string
|
||||
/** @description The clone URL of the repository */
|
||||
clone_url: string
|
||||
zipball_url: string
|
||||
/** @description Array of release assets */
|
||||
assets: components['schemas']['GithubReleaseAsset'][]
|
||||
}
|
||||
repository: components['schemas']['GithubRepository']
|
||||
sender: components['schemas']['GithubUser']
|
||||
organization?: components['schemas']['GithubOrganization']
|
||||
installation?: components['schemas']['GithubInstallation']
|
||||
enterprise?: components['schemas']['GithubEnterprise']
|
||||
}
|
||||
/** @description A GitHub user */
|
||||
GithubUser: {
|
||||
/** @description The user's login name */
|
||||
login: string
|
||||
/** @description The user's ID */
|
||||
id: number
|
||||
/** @description The user's node ID */
|
||||
node_id: string
|
||||
/** @description URL to the user's avatar */
|
||||
avatar_url: string
|
||||
/** @description The user's gravatar ID */
|
||||
gravatar_id?: string | null
|
||||
/** @description The API URL of the user */
|
||||
url: string
|
||||
/** @description The HTML URL of the user */
|
||||
html_url: string
|
||||
/**
|
||||
* @description The type of user
|
||||
* @enum {string}
|
||||
*/
|
||||
type: 'Bot' | 'User' | 'Organization'
|
||||
/** @description Whether the user is a site admin */
|
||||
site_admin: boolean
|
||||
}
|
||||
/** @description A GitHub repository */
|
||||
GithubRepository: {
|
||||
/** @description The repository ID */
|
||||
id: number
|
||||
/** @description The repository node ID */
|
||||
node_id: string
|
||||
/** @description The name of the repository */
|
||||
name: string
|
||||
/** @description The full name of the repository (owner/repo) */
|
||||
full_name: string
|
||||
/** @description Whether the repository is private */
|
||||
private: boolean
|
||||
owner: components['schemas']['GithubUser']
|
||||
/** @description The HTML URL of the repository */
|
||||
html_url: string
|
||||
/** @description The repository description */
|
||||
description?: string | null
|
||||
/** @description Whether the repository is a fork */
|
||||
fork: boolean
|
||||
/** @description The API URL of the repository */
|
||||
url: string
|
||||
/** @description The clone URL of the repository */
|
||||
clone_url: string
|
||||
/** @description The git URL of the repository */
|
||||
git_url: string
|
||||
/** @description The SSH URL of the repository */
|
||||
ssh_url: string
|
||||
/** @description The default branch of the repository */
|
||||
default_branch: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the repository was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the repository was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the repository was last pushed to
|
||||
*/
|
||||
pushed_at: string
|
||||
}
|
||||
/** @description A GitHub release asset */
|
||||
GithubReleaseAsset: {
|
||||
/** @description The asset ID */
|
||||
id: number
|
||||
/** @description The asset node ID */
|
||||
node_id: string
|
||||
/** @description The name of the asset */
|
||||
name: string
|
||||
/** @description The label of the asset */
|
||||
label?: string | null
|
||||
/** @description The content type of the asset */
|
||||
content_type: string
|
||||
/**
|
||||
* @description The state of the asset
|
||||
* @enum {string}
|
||||
*/
|
||||
state: 'uploaded' | 'open'
|
||||
/** @description The size of the asset in bytes */
|
||||
size: number
|
||||
/** @description The number of downloads */
|
||||
download_count: number
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the asset was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the asset was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
/** @description The browser download URL */
|
||||
browser_download_url: string
|
||||
uploader: components['schemas']['GithubUser']
|
||||
}
|
||||
/** @description A GitHub organization */
|
||||
GithubOrganization: {
|
||||
/** @description The organization's login name */
|
||||
login: string
|
||||
/** @description The organization ID */
|
||||
id: number
|
||||
/** @description The organization node ID */
|
||||
node_id: string
|
||||
/** @description The API URL of the organization */
|
||||
url: string
|
||||
/** @description The API URL of the organization's repositories */
|
||||
repos_url: string
|
||||
/** @description The API URL of the organization's events */
|
||||
events_url: string
|
||||
/** @description The API URL of the organization's hooks */
|
||||
hooks_url: string
|
||||
/** @description The API URL of the organization's issues */
|
||||
issues_url: string
|
||||
/** @description The API URL of the organization's members */
|
||||
members_url: string
|
||||
/** @description The API URL of the organization's public members */
|
||||
public_members_url: string
|
||||
/** @description URL to the organization's avatar */
|
||||
avatar_url: string
|
||||
/** @description The organization description */
|
||||
description?: string | null
|
||||
}
|
||||
/** @description A GitHub App installation */
|
||||
GithubInstallation: {
|
||||
/** @description The installation ID */
|
||||
id: number
|
||||
account: components['schemas']['GithubUser']
|
||||
/**
|
||||
* @description Repository selection for the installation
|
||||
* @enum {string}
|
||||
*/
|
||||
repository_selection: 'selected' | 'all'
|
||||
/** @description The API URL for access tokens */
|
||||
access_tokens_url: string
|
||||
/** @description The API URL for repositories */
|
||||
repositories_url: string
|
||||
/** @description The HTML URL of the installation */
|
||||
html_url: string
|
||||
/** @description The GitHub App ID */
|
||||
app_id: number
|
||||
/** @description The target ID */
|
||||
target_id: number
|
||||
/** @description The target type */
|
||||
target_type: string
|
||||
/** @description The installation permissions */
|
||||
permissions: Record<string, never>
|
||||
/** @description The events the installation subscribes to */
|
||||
events: string[]
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the installation was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the installation was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
/** @description The single file name if applicable */
|
||||
single_file_name?: string | null
|
||||
}
|
||||
/** @description A GitHub enterprise */
|
||||
GithubEnterprise: {
|
||||
/** @description The enterprise ID */
|
||||
id: number
|
||||
/** @description The enterprise slug */
|
||||
slug: string
|
||||
/** @description The enterprise name */
|
||||
name: string
|
||||
/** @description The enterprise node ID */
|
||||
node_id: string
|
||||
/** @description URL to the enterprise avatar */
|
||||
avatar_url: string
|
||||
/** @description The enterprise description */
|
||||
description?: string | null
|
||||
/** @description The enterprise website URL */
|
||||
website_url?: string | null
|
||||
/** @description The HTML URL of the enterprise */
|
||||
html_url: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the enterprise was created
|
||||
*/
|
||||
created_at: string
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description When the enterprise was last updated
|
||||
*/
|
||||
updated_at: string
|
||||
}
|
||||
ReleaseNote: {
|
||||
/** @description Unique identifier for the release note */
|
||||
id?: number
|
||||
id: number
|
||||
/**
|
||||
* @description The project this release note belongs to
|
||||
* @enum {string}
|
||||
@@ -8805,7 +9026,7 @@ export interface components {
|
||||
* Format: date-time
|
||||
* @description When the release note was published
|
||||
*/
|
||||
published_at?: string
|
||||
published_at: string
|
||||
}
|
||||
}
|
||||
responses: never
|
||||
@@ -11011,6 +11232,8 @@ export interface operations {
|
||||
sort?: string[]
|
||||
/** @description node_id to use as filter */
|
||||
node_id?: string[]
|
||||
/** @description Comfy UI version */
|
||||
comfyui_version?: string
|
||||
/** @description The platform requesting the nodes */
|
||||
form_factor?: string
|
||||
}
|
||||
@@ -11584,6 +11807,10 @@ export interface operations {
|
||||
project: 'comfyui' | 'comfyui_frontend' | 'desktop'
|
||||
/** @description The current version to filter release notes */
|
||||
current_version?: string
|
||||
/** @description The locale for the release notes */
|
||||
locale?: 'en' | 'es' | 'fr' | 'ja' | 'ko' | 'ru' | 'zh'
|
||||
/** @description The platform requesting the release notes */
|
||||
form_factor?: string
|
||||
}
|
||||
header?: never
|
||||
path?: never
|
||||
@@ -11623,7 +11850,20 @@ export interface operations {
|
||||
processReleaseWebhook: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
header: {
|
||||
/** @description The name of the event that triggered the delivery */
|
||||
'X-GitHub-Event': 'release'
|
||||
/** @description A globally unique identifier (GUID) to identify the event */
|
||||
'X-GitHub-Delivery': string
|
||||
/** @description The unique identifier of the webhook */
|
||||
'X-GitHub-Hook-ID': string
|
||||
/** @description HMAC hex digest of the request body using SHA-256 hash function */
|
||||
'X-Hub-Signature-256'?: string
|
||||
/** @description The type of resource where the webhook was created */
|
||||
'X-GitHub-Hook-Installation-Target-Type'?: string
|
||||
/** @description The unique identifier of the resource where the webhook was created */
|
||||
'X-GitHub-Hook-Installation-Target-ID'?: string
|
||||
}
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
@@ -11649,6 +11889,15 @@ export interface operations {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
/** @description Validation failed or endpoint has been spammed */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['ErrorResponse']
|
||||
}
|
||||
}
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
headers: {
|
||||
|
||||
6
src/types/litegraph-augmentation.d.ts
vendored
6
src/types/litegraph-augmentation.d.ts
vendored
@@ -60,6 +60,7 @@ declare module '@comfyorg/litegraph/dist/types/widgets' {
|
||||
* ComfyUI extensions of litegraph
|
||||
*/
|
||||
declare module '@comfyorg/litegraph' {
|
||||
import type { ExecutableLGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
|
||||
@@ -88,7 +89,10 @@ declare module '@comfyorg/litegraph' {
|
||||
/** @deprecated groupNode */
|
||||
setInnerNodes?(nodes: LGraphNode[]): void
|
||||
/** Originally a group node API. */
|
||||
getInnerNodes?(): LGraphNode[]
|
||||
getInnerNodes?(
|
||||
nodes?: ExecutableLGraphNode[],
|
||||
subgraphs?: WeakSet<LGraphNode>
|
||||
): ExecutableLGraphNode[]
|
||||
/** @deprecated groupNode */
|
||||
convertToNodes?(): LGraphNode[]
|
||||
recreate?(): Promise<LGraphNode>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { LGraph, NodeId } from '@comfyorg/litegraph'
|
||||
import { LGraphEventMode } from '@comfyorg/litegraph'
|
||||
import {
|
||||
ExecutableNodeDTO,
|
||||
LGraphEventMode,
|
||||
SubgraphNode
|
||||
} from '@comfyorg/litegraph'
|
||||
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
@@ -74,20 +78,31 @@ export const graphToPrompt = async (
|
||||
workflow.extra ??= {}
|
||||
workflow.extra.frontendVersion = __COMFYUI_FRONTEND_VERSION__
|
||||
|
||||
const computedNodeDtos = graph
|
||||
.computeExecutionOrder(false)
|
||||
.map(
|
||||
(node) =>
|
||||
new ExecutableNodeDTO(
|
||||
node,
|
||||
[],
|
||||
node instanceof SubgraphNode ? node : undefined
|
||||
)
|
||||
)
|
||||
|
||||
let output: ComfyApiWorkflow = {}
|
||||
// Process nodes in order of execution
|
||||
for (const outerNode of graph.computeExecutionOrder(false)) {
|
||||
const skipNode =
|
||||
for (const outerNode of computedNodeDtos) {
|
||||
// Don't serialize muted nodes
|
||||
if (
|
||||
outerNode.mode === LGraphEventMode.NEVER ||
|
||||
outerNode.mode === LGraphEventMode.BYPASS
|
||||
const innerNodes =
|
||||
!skipNode && outerNode.getInnerNodes
|
||||
? outerNode.getInnerNodes()
|
||||
: [outerNode]
|
||||
for (const node of innerNodes) {
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const node of outerNode.getInnerNodes()) {
|
||||
if (
|
||||
node.isVirtualNode ||
|
||||
// Don't serialize muted nodes
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
) {
|
||||
@@ -120,55 +135,14 @@ export const graphToPrompt = async (
|
||||
|
||||
// Store all node links
|
||||
for (const [i, input] of node.inputs.entries()) {
|
||||
let parent = node.getInputNode(i)
|
||||
if (!parent) continue
|
||||
const resolvedInput = node.resolveInput(i)
|
||||
if (!resolvedInput) continue
|
||||
|
||||
let link = node.getInputLink(i)
|
||||
while (
|
||||
parent?.mode === LGraphEventMode.BYPASS ||
|
||||
parent?.isVirtualNode
|
||||
) {
|
||||
if (!link) break
|
||||
|
||||
if (parent.isVirtualNode) {
|
||||
link = parent.getInputLink(link.origin_slot)
|
||||
if (!link) break
|
||||
|
||||
parent = parent.getInputNode(link.target_slot)
|
||||
if (!parent) break
|
||||
} else if (!parent.inputs) {
|
||||
// Maintains existing behaviour if parent.getInputLink is overriden
|
||||
break
|
||||
} else if (parent.mode === LGraphEventMode.BYPASS) {
|
||||
// Bypass nodes by finding first input with matching type
|
||||
const parentInputIndexes = Object.keys(parent.inputs).map(Number)
|
||||
// Prioritise exact slot index
|
||||
const indexes = [link.origin_slot].concat(parentInputIndexes)
|
||||
|
||||
const matchingIndex = indexes.find(
|
||||
(index) => parent?.inputs[index]?.type === input.type
|
||||
)
|
||||
// No input types match
|
||||
if (matchingIndex === undefined) break
|
||||
|
||||
link = parent.getInputLink(matchingIndex)
|
||||
if (link) parent = parent.getInputNode(matchingIndex)
|
||||
}
|
||||
}
|
||||
|
||||
if (link) {
|
||||
if (parent?.updateLink) {
|
||||
// Subgraph node / groupNode callback; deprecated, should be replaced
|
||||
link = parent.updateLink(link)
|
||||
}
|
||||
if (link) {
|
||||
inputs[input.name] = [
|
||||
String(link.origin_id),
|
||||
// @ts-expect-error link.origin_slot is already number.
|
||||
parseInt(link.origin_slot)
|
||||
]
|
||||
}
|
||||
}
|
||||
inputs[input.name] = [
|
||||
String(resolvedInput.origin_id),
|
||||
// @ts-expect-error link.origin_slot is already number.
|
||||
parseInt(resolvedInput.origin_slot)
|
||||
]
|
||||
}
|
||||
|
||||
output[String(node.id)] = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ResultItem } from '@/schemas/apiSchema'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
|
||||
export function formatCamelCase(str: string): string {
|
||||
// Check if the string is camel case
|
||||
@@ -502,3 +503,46 @@ export function nl2br(text: string): string {
|
||||
if (!text) return ''
|
||||
return text.replace(/\n/g, '<br />')
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a version string to an anchor-safe format by replacing dots with dashes.
|
||||
* @param version The version string (e.g., "1.0.0", "2.1.3-beta.1")
|
||||
* @returns The anchor-safe version string (e.g., "v1-0-0", "v2-1-3-beta-1")
|
||||
* @example
|
||||
* formatVersionAnchor("1.0.0") // returns "v1-0-0"
|
||||
* formatVersionAnchor("2.1.3-beta.1") // returns "v2-1-3-beta-1"
|
||||
*/
|
||||
export function formatVersionAnchor(version: string): string {
|
||||
return `v${version.replace(/\./g, '-')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported locale types for the application (from OpenAPI schema)
|
||||
*/
|
||||
export type SupportedLocale = NonNullable<
|
||||
operations['getReleaseNotes']['parameters']['query']['locale']
|
||||
>
|
||||
|
||||
/**
|
||||
* Converts a string to a valid locale type with 'en' as default
|
||||
* @param locale - The locale string to validate and convert
|
||||
* @returns A valid SupportedLocale type, defaults to 'en' if invalid
|
||||
* @example
|
||||
* stringToLocale('fr') // returns 'fr'
|
||||
* stringToLocale('invalid') // returns 'en'
|
||||
* stringToLocale('') // returns 'en'
|
||||
*/
|
||||
export function stringToLocale(locale: string): SupportedLocale {
|
||||
const supportedLocales: SupportedLocale[] = [
|
||||
'en',
|
||||
'es',
|
||||
'fr',
|
||||
'ja',
|
||||
'ko',
|
||||
'ru',
|
||||
'zh'
|
||||
]
|
||||
return supportedLocales.includes(locale as SupportedLocale)
|
||||
? (locale as SupportedLocale)
|
||||
: 'en'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { ColorOption, LGraph } from '@comfyorg/litegraph'
|
||||
import { ColorOption, LGraph, Reroute } from '@comfyorg/litegraph'
|
||||
import { LGraphGroup, LGraphNode, isColorable } from '@comfyorg/litegraph'
|
||||
import type { ISerialisedGraph } from '@comfyorg/litegraph/dist/types/serialisation'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ISerialisableNodeInput,
|
||||
ISerialisedGraph
|
||||
} from '@comfyorg/litegraph/dist/types/serialisation'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
@@ -50,6 +54,10 @@ export const isLGraphGroup = (item: unknown): item is LGraphGroup => {
|
||||
return item instanceof LGraphGroup
|
||||
}
|
||||
|
||||
export const isReroute = (item: unknown): item is Reroute => {
|
||||
return item instanceof Reroute
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color option of all canvas items if they are all the same.
|
||||
* @param items - The items to get the color option of.
|
||||
@@ -163,12 +171,11 @@ export function fixLinkInputSlots(graph: LGraph) {
|
||||
* This should match the serialization format of legacy widget conversion.
|
||||
*
|
||||
* @param graph - The graph to compress widget input slots for.
|
||||
* @throws If an infinite loop is detected.
|
||||
*/
|
||||
export function compressWidgetInputSlots(graph: ISerialisedGraph) {
|
||||
for (const node of graph.nodes) {
|
||||
node.inputs = node.inputs?.filter(
|
||||
(input) => !(input.widget && input.link === null)
|
||||
)
|
||||
node.inputs = node.inputs?.filter(matchesLegacyApi)
|
||||
|
||||
for (const [inputIndex, input] of node.inputs?.entries() ?? []) {
|
||||
if (input.link) {
|
||||
@@ -179,4 +186,44 @@ export function compressWidgetInputSlots(graph: ISerialisedGraph) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compressSubgraphWidgetInputSlots(graph.definitions?.subgraphs)
|
||||
}
|
||||
|
||||
function matchesLegacyApi(input: ISerialisableNodeInput) {
|
||||
return !(input.widget && input.link === null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplication to handle the legacy link arrays in the root workflow.
|
||||
* @see compressWidgetInputSlots
|
||||
* @param subgraph The subgraph to compress widget input slots for.
|
||||
*/
|
||||
function compressSubgraphWidgetInputSlots(
|
||||
subgraphs: ExportedSubgraph[] | undefined,
|
||||
visited = new WeakSet<ExportedSubgraph>()
|
||||
) {
|
||||
if (!subgraphs) return
|
||||
|
||||
for (const subgraph of subgraphs) {
|
||||
if (visited.has(subgraph)) throw new Error('Infinite loop detected')
|
||||
visited.add(subgraph)
|
||||
|
||||
if (subgraph.nodes) {
|
||||
for (const node of subgraph.nodes) {
|
||||
node.inputs = node.inputs?.filter(matchesLegacyApi)
|
||||
|
||||
if (!subgraph.links) continue
|
||||
|
||||
for (const [inputIndex, input] of node.inputs?.entries() ?? []) {
|
||||
if (input.link) {
|
||||
const link = subgraph.links.find((link) => link.id === input.link)
|
||||
if (link) link.target_slot = inputIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compressSubgraphWidgetInputSlots(subgraph.definitions?.subgraphs, visited)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,9 @@ export const isAbortError = (
|
||||
export const isSubgraph = (
|
||||
item: LGraph | Subgraph | undefined | null
|
||||
): item is Subgraph => item?.isRootGraph === false
|
||||
|
||||
/**
|
||||
* Check if an item is non-nullish.
|
||||
*/
|
||||
export const isNonNullish = <T>(item: T | undefined | null): item is T =>
|
||||
item != null
|
||||
|
||||
Reference in New Issue
Block a user