mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
9 Commits
docs/weekl
...
feat/missi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c3bb91f1e | ||
|
|
864b14b302 | ||
|
|
e02d58da0c | ||
|
|
f48ae4619d | ||
|
|
cc151d3c22 | ||
|
|
c54b470ca8 | ||
|
|
33f136b38b | ||
|
|
4fbf89ae4c | ||
|
|
aa1c25f98e |
@@ -320,5 +320,15 @@ export default defineConfig([
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Storybook-only mock components (__stories__/**/*.vue)
|
||||
// These are not shipped to production and do not require i18n or strict Vue patterns.
|
||||
{
|
||||
files: ['**/__stories__/**/*.vue'],
|
||||
rules: {
|
||||
'@intlify/vue-i18n/no-raw-text': 'off',
|
||||
'vue/no-unused-properties': 'off'
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorOverlay />
|
||||
<QueueProgressOverlay
|
||||
v-if="isQueueProgressOverlayEnabled"
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
@@ -156,6 +157,7 @@ import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
|
||||
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
|
||||
100
src/components/error/ErrorOverlay.vue
Normal file
100
src/components/error/ErrorOverlay.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="-translate-y-3 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
>
|
||||
<div v-if="isVisible" class="flex justify-end w-full pointer-events-none">
|
||||
<div
|
||||
class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-interface-stroke bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex h-12 items-center gap-2 px-4">
|
||||
<span class="flex-1 text-sm font-bold text-destructive-background">
|
||||
{{ errorCountLabel }}
|
||||
</span>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
@click="dismiss"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-5 leading-none" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 pb-3">
|
||||
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||
<li
|
||||
v-for="(message, idx) in groupedErrorMessages"
|
||||
:key="idx"
|
||||
class="flex items-baseline gap-2 text-sm leading-snug text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
class="mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground"
|
||||
/>
|
||||
<span>{{ message }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-3">
|
||||
<Button variant="muted-textonly" size="unset" @click="dismiss">
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="seeErrors">
|
||||
{{ t('errorOverlay.seeErrors') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionStore = useExecutionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionStore)
|
||||
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
|
||||
|
||||
const errorCountLabel = computed(() =>
|
||||
t(
|
||||
'errorOverlay.errorCount',
|
||||
{ count: totalErrorCount.value },
|
||||
totalErrorCount.value
|
||||
)
|
||||
)
|
||||
|
||||
const isVisible = computed(
|
||||
() => isErrorOverlayOpen.value && totalErrorCount.value > 0
|
||||
)
|
||||
|
||||
function dismiss() {
|
||||
executionStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
@@ -19,8 +19,8 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
|
||||
import TabError from './TabError.vue'
|
||||
import TabInfo from './info/TabInfo.vue'
|
||||
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
|
||||
import TabNodes from './parameters/TabNodes.vue'
|
||||
@@ -41,7 +41,7 @@ const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasAnyError } = storeToRefs(executionStore)
|
||||
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
@@ -96,30 +96,31 @@ type RightSidePanelTabList = Array<{
|
||||
icon?: string
|
||||
}>
|
||||
|
||||
//FIXME all errors if nothing selected?
|
||||
const selectedNodeErrors = computed(() =>
|
||||
selectedNodes.value
|
||||
.map((node) => executionStore.getNodeErrors(`${node.id}`))
|
||||
.filter((nodeError) => !!nodeError)
|
||||
const hasDirectNodeError = computed(() =>
|
||||
selectedNodes.value.some((node) =>
|
||||
executionStore.activeGraphErrorNodeIds.has(String(node.id))
|
||||
)
|
||||
)
|
||||
|
||||
const hasContainerInternalError = computed(() => {
|
||||
if (allErrorExecutionIds.value.length === 0) return false
|
||||
return selectedNodes.value.some((node) => {
|
||||
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
|
||||
return executionStore.hasInternalErrorForNode(node.id)
|
||||
})
|
||||
})
|
||||
|
||||
const hasRelevantErrors = computed(() => {
|
||||
if (!hasSelection.value) return hasAnyError.value
|
||||
return hasDirectNodeError.value || hasContainerInternalError.value
|
||||
})
|
||||
|
||||
const tabs = computed<RightSidePanelTabList>(() => {
|
||||
const list: RightSidePanelTabList = []
|
||||
if (
|
||||
selectedNodeErrors.value.length &&
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
) {
|
||||
list.push({
|
||||
label: () => t('g.error'),
|
||||
value: 'error',
|
||||
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
hasAnyError.value &&
|
||||
!hasSelection.value &&
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') &&
|
||||
hasRelevantErrors.value
|
||||
) {
|
||||
list.push({
|
||||
label: () => t('rightSidePanel.errors'),
|
||||
@@ -315,9 +316,9 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<template v-if="!hasSelection">
|
||||
<TabErrors v-if="activeTab === 'errors'" />
|
||||
<TabGlobalParameters v-else-if="activeTab === 'parameters'" />
|
||||
<TabErrors v-if="activeTab === 'errors'" />
|
||||
<template v-else-if="!hasSelection">
|
||||
<TabGlobalParameters v-if="activeTab === 'parameters'" />
|
||||
<TabNodes v-else-if="activeTab === 'nodes'" />
|
||||
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
|
||||
</template>
|
||||
@@ -326,7 +327,6 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
||||
:node="selectedSingleNode"
|
||||
/>
|
||||
<template v-else>
|
||||
<TabError v-if="activeTab === 'error'" :errors="selectedNodeErrors" />
|
||||
<TabSubgraphInputs
|
||||
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
|
||||
:node="selectedSingleNode as SubgraphNode"
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
errors: NodeError[]
|
||||
}>()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
<template>
|
||||
<div class="m-4">
|
||||
<Button class="w-full" @click="copyToClipboard(JSON.stringify(errors))">
|
||||
{{ t('g.copy') }}
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(error, index) in errors.flatMap((ne) => ne.errors)"
|
||||
:key="index"
|
||||
class="px-2"
|
||||
>
|
||||
<h3 class="text-error" v-text="error.message" />
|
||||
<div class="text-muted-foreground" v-text="error.details" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,10 +1,13 @@
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<!-- Card Header (Node ID & Actions) -->
|
||||
<div v-if="card.nodeId" class="flex flex-wrap items-center gap-2 py-2">
|
||||
<!-- Card Header -->
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
>
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-[10px] font-mono text-muted-foreground font-bold"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold"
|
||||
>
|
||||
#{{ card.nodeId }}
|
||||
</span>
|
||||
@@ -19,7 +22,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="rounded-lg text-sm shrink-0"
|
||||
@click.stop="emit('enterSubgraph', card.nodeId ?? '')"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
@@ -27,7 +30,8 @@
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 text-muted-foreground hover:text-base-foreground shrink-0"
|
||||
@click.stop="emit('locateNode', card.nodeId ?? '')"
|
||||
:aria-label="t('rightSidePanel.locateNode')"
|
||||
@click.stop="handleLocateNode"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-3.5" />
|
||||
</Button>
|
||||
@@ -43,7 +47,7 @@
|
||||
>
|
||||
<!-- Error Message -->
|
||||
<p
|
||||
v-if="error.message"
|
||||
v-if="error.message && !compact"
|
||||
class="m-0 text-sm break-words whitespace-pre-wrap leading-relaxed px-0.5"
|
||||
>
|
||||
{{ error.message }}
|
||||
@@ -69,7 +73,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="w-full justify-center gap-2 h-8 text-[11px]"
|
||||
class="w-full justify-center gap-2 h-8 text-xs"
|
||||
@click="handleCopyError(error)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
@@ -88,9 +92,15 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ErrorCardData, ErrorItem } from './types'
|
||||
|
||||
const { card, showNodeIdBadge = false } = defineProps<{
|
||||
const {
|
||||
card,
|
||||
showNodeIdBadge = false,
|
||||
compact = false
|
||||
} = defineProps<{
|
||||
card: ErrorCardData
|
||||
showNodeIdBadge?: boolean
|
||||
/** Hide card header and error message (used in single-node selection mode) */
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -101,6 +111,18 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleLocateNode() {
|
||||
if (card.nodeId) {
|
||||
emit('locateNode', card.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnterSubgraph() {
|
||||
if (card.nodeId) {
|
||||
emit('enterSubgraph', card.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyError(error: ErrorItem) {
|
||||
emit(
|
||||
'copyToClipboard',
|
||||
|
||||
137
src/components/rightSidePanel/errors/TabErrors.stories.ts
Normal file
137
src/components/rightSidePanel/errors/TabErrors.stories.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @file TabErrors.stories.ts
|
||||
*
|
||||
* Error Tab – Missing Node Packs UX Flow Stories (OSS environment)
|
||||
*/
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import StoryOSSMissingNodePackFlow from './__stories__/StoryOSSMissingNodePackFlow.vue'
|
||||
import MockOSSMissingNodePack from './__stories__/MockOSSMissingNodePack.vue'
|
||||
import MockCloudMissingNodePack from './__stories__/MockCloudMissingNodePack.vue'
|
||||
import MockCloudMissingModel from './__stories__/MockCloudMissingModel.vue'
|
||||
import MockCloudMissingModelBasic from './__stories__/MockCloudMissingModelBasic.vue'
|
||||
import MockOSSMissingModel from './__stories__/MockOSSMissingModel.vue'
|
||||
|
||||
// Storybook Meta
|
||||
|
||||
const meta = {
|
||||
title: 'RightSidePanel/Errors/TabErrors',
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
## Error Tab – Missing Node Packs UX Flow (OSS environment)
|
||||
|
||||
### Right Panel Structure
|
||||
- **Nav Item**: "Workflow Overview" + panel-right button
|
||||
- **Tab bar**: Error (octagon-alert icon) | Inputs | Nodes | Global settings
|
||||
- **Search bar**: 12px, #8a8a8a placeholder
|
||||
- **Missing Node Packs section**: octagon-alert (red) + label + Install All + chevron
|
||||
- **Each widget row** (72px): name (truncate) + info + locate | Install node pack ↓
|
||||
|
||||
> In Cloud environments, the Install button is not displayed.
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
} satisfies Meta
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Stories
|
||||
|
||||
/**
|
||||
* **[Local OSS] Missing Node Packs**
|
||||
*
|
||||
* A standalone story for the Right Side Panel's Error Tab mockup.
|
||||
* This allows testing the tab's interactions (install, locate, etc.) in isolation.
|
||||
*/
|
||||
export const OSS_ErrorTabOnly: Story = {
|
||||
name: '[Local OSS] Missing Node Packs',
|
||||
render: () => ({
|
||||
components: { MockOSSMissingNodePack },
|
||||
template: `
|
||||
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
|
||||
<MockOSSMissingNodePack @log="msg => console.log('Log:', msg)" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* **[Local OSS] UX Flow - Missing Node Pack**
|
||||
*
|
||||
* Full ComfyUI layout simulation:
|
||||
*/
|
||||
export const OSS_MissingNodePacksFullFlow: Story = {
|
||||
name: '[Local OSS] UX Flow - Missing Node Pack',
|
||||
render: () => ({
|
||||
components: { StoryOSSMissingNodePackFlow },
|
||||
template: `<div style="width:100vw;height:100vh;"><StoryOSSMissingNodePackFlow /></div>`
|
||||
}),
|
||||
parameters: {
|
||||
layout: 'fullscreen'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* **[Cloud] Missing Node Pack**
|
||||
*/
|
||||
export const Cloud_MissingNodePacks: Story = {
|
||||
name: '[Cloud] Missing Node Pack',
|
||||
render: () => ({
|
||||
components: { MockCloudMissingNodePack },
|
||||
template: `
|
||||
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
|
||||
<MockCloudMissingNodePack @log="msg => console.log('Log:', msg)" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* **[Local OSS] Missing Model**
|
||||
*/
|
||||
export const OSS_MissingModels: Story = {
|
||||
name: '[Local OSS] Missing Model',
|
||||
render: () => ({
|
||||
components: { MockOSSMissingModel },
|
||||
template: `
|
||||
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
|
||||
<MockOSSMissingModel @locate="name => console.log('Locate:', name)" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* **[Cloud] Missing Model**
|
||||
*/
|
||||
export const Cloud_MissingModels: Story = {
|
||||
name: '[Cloud] Missing Model',
|
||||
render: () => ({
|
||||
components: { MockCloudMissingModelBasic },
|
||||
template: `
|
||||
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
|
||||
<MockCloudMissingModelBasic @locate="name => console.log('Locate:', name)" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* **[Cloud] Missing Model - with model type selector**
|
||||
*/
|
||||
export const Cloud_MissingModelsWithSelector: Story = {
|
||||
name: '[Cloud] Missing Model - with model type selector',
|
||||
render: () => ({
|
||||
components: { MockCloudMissingModel },
|
||||
template: `
|
||||
<div class="h-[800px] border border-[#494a50] rounded-lg overflow-hidden">
|
||||
<MockCloudMissingModel @locate="name => console.log('Locate:', name)" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -55,8 +55,9 @@
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="focusNode"
|
||||
@enter-subgraph="enterSubgraph"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
@@ -97,16 +98,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
@@ -119,10 +120,9 @@ const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { staticUrls } = useExternalLink()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const showNodeIdBadge = computed(
|
||||
() =>
|
||||
@@ -130,24 +130,16 @@ const showNodeIdBadge = computed(
|
||||
NodeBadgeMode.None
|
||||
)
|
||||
|
||||
const { filteredGroups } = useErrorGroups(searchQuery, t)
|
||||
const { filteredGroups, collapseState, isSingleNodeSelected, errorNodeCache } =
|
||||
useErrorGroups(searchQuery, t)
|
||||
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
function handleLocateNode(nodeId: string) {
|
||||
focusNode(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => rightSidePanelStore.focusedErrorNodeId,
|
||||
(graphNodeId) => {
|
||||
if (!graphNodeId) return
|
||||
for (const group of filteredGroups.value) {
|
||||
const hasMatch = group.cards.some(
|
||||
(card) => card.graphNodeId === graphNodeId
|
||||
)
|
||||
collapseState[group.title] = !hasMatch
|
||||
}
|
||||
rightSidePanelStore.focusedErrorNodeId = null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
enterSubgraph(nodeId, errorNodeCache.value)
|
||||
}
|
||||
|
||||
function openGitHubIssues() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
@@ -162,6 +154,11 @@ async function contactSupport() {
|
||||
is_external: true,
|
||||
source: 'error_dialog'
|
||||
})
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
try {
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
useToastStore().addAlert(t('rightSidePanel.contactSupportFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
// Props / Emits
|
||||
|
||||
const emit = defineEmits<{
|
||||
'locate': [name: string],
|
||||
}>()
|
||||
|
||||
// Mock Data
|
||||
|
||||
interface MissingModel {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const INITIAL_MISSING_MODELS: Record<string, MissingModel[]> = {
|
||||
'Lora': [
|
||||
{ id: 'm1', name: 'Flat_color_anime.safetensors', type: 'Lora' },
|
||||
{ id: 'm2', name: 'Bokeh_blur_xl.safetensors', type: 'Lora' },
|
||||
{ id: 'm3', name: 'Skin_texture_realism.safetensors', type: 'Lora' }
|
||||
],
|
||||
'VAE': [
|
||||
{ id: 'v1', name: 'vae-ft-mse-840000-ema-pruned.safetensors', type: 'VAE' },
|
||||
{ id: 'v2', name: 'clear-vae-v1.safetensors', type: 'VAE' }
|
||||
]
|
||||
}
|
||||
|
||||
const MODEL_TYPES = [
|
||||
'AnimateDiff Model',
|
||||
'AnimateDiff Motion LoRA',
|
||||
'Audio Encoders',
|
||||
'Chatterbox/chatterbox',
|
||||
'Chatterbox/chatterbox Multilingual',
|
||||
'Chatterbox/chatterbox Turbo',
|
||||
'Chatterbox/chatterbox Vc',
|
||||
'Checkpoints',
|
||||
'CLIP Vision'
|
||||
]
|
||||
|
||||
const LIBRARY_MODELS = [
|
||||
'v1-5-pruned-emaonly.safetensors',
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'dreamshaper_8.safetensors',
|
||||
'realisticVisionV51_v51VAE.safetensors'
|
||||
]
|
||||
|
||||
// State
|
||||
|
||||
const collapsedCategories = ref<Record<string, boolean>>({
|
||||
'VAE': true
|
||||
})
|
||||
|
||||
// Stores the URL input for each model
|
||||
const modelInputs = ref<Record<string, string>>({})
|
||||
|
||||
// Tracks which models have finished their "revealing" delay
|
||||
const revealingModels = ref<Record<string, boolean>>({})
|
||||
const inputTimeouts = ref<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
|
||||
// Model Status: 'idle' | 'downloading' | 'downloaded' | 'using_library'
|
||||
const importStatus = ref<Record<string, 'idle' | 'downloading' | 'downloaded' | 'using_library'>>({})
|
||||
const downloadProgress = ref<Record<string, number>>({})
|
||||
const downloadTimers = ref<Record<string, ReturnType<typeof setInterval>>>({})
|
||||
const selectedLibraryModel = ref<Record<string, string>>({})
|
||||
|
||||
// Track hidden models (removed after clicking check button)
|
||||
const removedModels = ref<Record<string, boolean>>({})
|
||||
|
||||
// Watch for input changes to trigger the 1s delay
|
||||
watch(modelInputs, (newVal) => {
|
||||
for (const id in newVal) {
|
||||
const value = newVal[id]
|
||||
if (value && !revealingModels.value[id] && !inputTimeouts.value[id]) {
|
||||
inputTimeouts.value[id] = setTimeout(() => {
|
||||
revealingModels.value[id] = true
|
||||
delete inputTimeouts.value[id]
|
||||
}, 1000)
|
||||
} else if (!value) {
|
||||
revealingModels.value[id] = false
|
||||
if (inputTimeouts.value[id]) {
|
||||
clearTimeout(inputTimeouts.value[id])
|
||||
delete inputTimeouts.value[id]
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Compute which categories have at least one visible model
|
||||
const activeCategories = computed(() => {
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const cat in INITIAL_MISSING_MODELS) {
|
||||
result[cat] = INITIAL_MISSING_MODELS[cat].some(m => !removedModels.value[m.id])
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Stores the selected type for each model
|
||||
const selectedModelTypes = ref<Record<string, string>>({})
|
||||
|
||||
// Tracks which model's type dropdown is currently open
|
||||
const activeDropdown = ref<string | null>(null)
|
||||
// Tracks which model's library dropdown is currently open
|
||||
const activeLibraryDropdown = ref<string | null>(null)
|
||||
|
||||
// Actions
|
||||
|
||||
function toggleDropdown(modelId: string) {
|
||||
activeLibraryDropdown.value = null
|
||||
if (activeDropdown.value === modelId) {
|
||||
activeDropdown.value = null
|
||||
} else {
|
||||
activeDropdown.value = modelId
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLibraryDropdown(modelId: string) {
|
||||
activeDropdown.value = null
|
||||
if (activeLibraryDropdown.value === modelId) {
|
||||
activeLibraryDropdown.value = null
|
||||
} else {
|
||||
activeLibraryDropdown.value = modelId
|
||||
}
|
||||
}
|
||||
|
||||
function selectType(modelId: string, type: string) {
|
||||
selectedModelTypes.value[modelId] = type
|
||||
activeDropdown.value = null
|
||||
}
|
||||
|
||||
function selectFromLibrary(modelId: string, fileName: string) {
|
||||
selectedLibraryModel.value[modelId] = fileName
|
||||
importStatus.value[modelId] = 'using_library'
|
||||
activeLibraryDropdown.value = null
|
||||
}
|
||||
|
||||
function startImport(modelId: string) {
|
||||
if (downloadTimers.value[modelId]) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
}
|
||||
|
||||
importStatus.value[modelId] = 'downloading'
|
||||
downloadProgress.value[modelId] = 0
|
||||
|
||||
const startTime = Date.now()
|
||||
const duration = 5000
|
||||
|
||||
downloadTimers.value[modelId] = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min((elapsed / duration) * 100, 100)
|
||||
downloadProgress.value[modelId] = progress
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
delete downloadTimers.value[modelId]
|
||||
importStatus.value[modelId] = 'downloaded'
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function handleCheckClick(modelId: string) {
|
||||
if (importStatus.value[modelId] === 'downloaded' || importStatus.value[modelId] === 'using_library') {
|
||||
// Update object with spread to guarantee reactivity trigger
|
||||
removedModels.value = { ...removedModels.value, [modelId]: true }
|
||||
}
|
||||
}
|
||||
|
||||
function cancelImport(modelId: string) {
|
||||
if (downloadTimers.value[modelId]) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
delete downloadTimers.value[modelId]
|
||||
}
|
||||
importStatus.value[modelId] = 'idle'
|
||||
downloadProgress.value[modelId] = 0
|
||||
modelInputs.value[modelId] = ''
|
||||
selectedLibraryModel.value[modelId] = ''
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
// Clear all status records
|
||||
modelInputs.value = {}
|
||||
revealingModels.value = {}
|
||||
|
||||
// Clear any running timers
|
||||
for (const id in downloadTimers.value) {
|
||||
clearInterval(downloadTimers.value[id])
|
||||
}
|
||||
downloadTimers.value = {}
|
||||
|
||||
for (const id in inputTimeouts.value) {
|
||||
clearTimeout(inputTimeouts.value[id])
|
||||
}
|
||||
inputTimeouts.value = {}
|
||||
|
||||
importStatus.value = {}
|
||||
downloadProgress.value = {}
|
||||
selectedLibraryModel.value = {}
|
||||
removedModels.value = {}
|
||||
activeDropdown.value = null
|
||||
activeLibraryDropdown.value = null
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
function getElementStyle(el: HTMLElement) {
|
||||
return {
|
||||
height: el.style.height,
|
||||
overflow: el.style.overflow,
|
||||
paddingTop: el.style.paddingTop,
|
||||
paddingBottom: el.style.paddingBottom,
|
||||
marginTop: el.style.marginTop,
|
||||
marginBottom: el.style.marginBottom
|
||||
}
|
||||
}
|
||||
|
||||
// Transitions
|
||||
|
||||
const DURATION = 150
|
||||
|
||||
function enterTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { width } = getComputedStyle(el)
|
||||
el.style.width = width
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = ''
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.position = ''
|
||||
el.style.visibility = ''
|
||||
el.style.height = '0px'
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
|
||||
function leaveTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.height = height
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- ① Nav Item -->
|
||||
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
|
||||
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
|
||||
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
|
||||
Workflow Overview
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
|
||||
<i class="icon-[lucide--panel-right] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ② Node Header -->
|
||||
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
|
||||
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
|
||||
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
|
||||
<span class="text-sm text-white">Error</span>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Inputs</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Nodes</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
|
||||
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
|
||||
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
|
||||
Search for nodes or inputs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
|
||||
<!-- ③ Content: Missing Models -->
|
||||
<div class="flex-1 overflow-y-auto min-w-0 no-scrollbar">
|
||||
<template v-for="(models, category) in INITIAL_MISSING_MODELS" :key="category">
|
||||
<div
|
||||
v-if="activeCategories[category]"
|
||||
class="px-4 mb-4"
|
||||
>
|
||||
<!-- Category Header -->
|
||||
<div
|
||||
class="flex h-8 items-center justify-center w-full group"
|
||||
:class="category === 'VAE' ? 'cursor-default' : 'cursor-pointer'"
|
||||
@click="category !== 'VAE' && (collapsedCategories[category] = !collapsedCategories[category])"
|
||||
>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">
|
||||
{{ category }} ({{ models.filter(m => !removedModels[m.id]).length }})
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0">
|
||||
<i
|
||||
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] transition-all"
|
||||
:class="[
|
||||
category !== 'VAE' ? 'group-hover:text-white' : '',
|
||||
collapsedCategories[category] ? '-rotate-180' : ''
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model List -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!collapsedCategories[category]" class="pt-2">
|
||||
<TransitionGroup :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-for="model in models" v-show="!removedModels[model.id]" :key="model.id" class="flex flex-col w-full mb-6 last:mb-4">
|
||||
|
||||
<!-- Model Header (Always visible) -->
|
||||
<div class="flex h-8 items-center w-full gap-2 mb-1">
|
||||
<i class="icon-[lucide--file-check] size-4 text-white shrink-0" />
|
||||
<p class="flex-1 min-w-0 text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ model.name }}
|
||||
</p>
|
||||
|
||||
<!-- Check Button (Highlights blue when downloaded or using from library) -->
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 transition-colors"
|
||||
:class="[
|
||||
(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library')
|
||||
? 'cursor-pointer hover:bg-[#1e2d3d] bg-[#1e2d3d]'
|
||||
: 'opacity-20 cursor-default'
|
||||
]"
|
||||
@click="handleCheckClick(model.id)"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--check] size-4"
|
||||
:class="(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library') ? 'text-[#3b82f6]' : 'text-white'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Locate Button -->
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
|
||||
@click="emit('locate', model.name)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input or Progress Area -->
|
||||
<div class="relative mt-1">
|
||||
<!-- CARD (Download or Library Substitute) -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div
|
||||
v-if="importStatus[model.id] && importStatus[model.id] !== 'idle'"
|
||||
class="relative bg-white/5 border border-[#55565e] rounded-lg overflow-hidden flex items-center p-2 gap-2"
|
||||
>
|
||||
<!-- Progress Filling (Only while downloading) -->
|
||||
<div
|
||||
v-if="importStatus[model.id] === 'downloading'"
|
||||
class="absolute inset-y-0 left-0 bg-[#3b82f6]/10 transition-all duration-100 ease-linear pointer-events-none"
|
||||
:style="{ width: downloadProgress[model.id] + '%' }"
|
||||
/>
|
||||
|
||||
<!-- Left Icon -->
|
||||
<div class="relative z-10 size-[32px] flex items-center justify-center shrink-0">
|
||||
<i class="icon-[lucide--file-check] size-5 text-[#8a8a8a]" />
|
||||
</div>
|
||||
|
||||
<!-- Center Text -->
|
||||
<div class="relative z-10 flex-1 min-w-0 flex flex-col justify-center">
|
||||
<span class="text-[12px] font-medium text-white truncate leading-tight">
|
||||
{{ importStatus[model.id] === 'using_library' ? selectedLibraryModel[model.id] : model.name }}
|
||||
</span>
|
||||
<span class="text-[12px] text-[#8a8a8a] leading-tight mt-0.5">
|
||||
<template v-if="importStatus[model.id] === 'downloading'">Importing ...</template>
|
||||
<template v-else-if="importStatus[model.id] === 'downloaded'">Imported</template>
|
||||
<template v-else-if="importStatus[model.id] === 'using_library'">Using from Library</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Cancel (X) Button (Always visible in this card) -->
|
||||
<div
|
||||
class="relative z-10 size-6 flex items-center justify-center text-[#55565e] hover:text-white cursor-pointer transition-colors shrink-0"
|
||||
@click="cancelImport(model.id)"
|
||||
>
|
||||
<i class="icon-[lucide--circle-x] size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- IDLE / INPUT AREA -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!importStatus[model.id] || importStatus[model.id] === 'idle'" class="flex flex-col gap-2">
|
||||
<!-- URL Input Area -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!selectedLibraryModel[model.id]" class="flex flex-col gap-2">
|
||||
<div class="h-8 bg-[#262729] rounded-lg flex items-center px-3 border border-transparent focus-within:border-[#494a50] transition-colors whitespace-nowrap overflow-hidden">
|
||||
<input
|
||||
v-model="modelInputs[model.id]"
|
||||
type="text"
|
||||
placeholder="Paste Model URL (Civitai or Hugging Face)"
|
||||
class="bg-transparent border-none outline-none text-xs text-white w-full placeholder:text-[#8a8a8a]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="revealingModels[model.id]" class="flex flex-col gap-2">
|
||||
<div class="px-0.5 pt-0.5 whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<span class="text-xs font-bold text-white">something_model.safetensors</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 px-0.5">
|
||||
<span class="text-[11px] text-[#8a8a8a]">What type of model is this?</span>
|
||||
<i class="icon-[lucide--help-circle] size-3 text-[#55565e]" />
|
||||
<span class="text-[11px] text-[#55565e]">Not sure? Just leave this as is</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="h-9 bg-[#262729] rounded-lg flex items-center px-3 border border-transparent cursor-pointer hover:bg-[#303133]" @click="toggleDropdown(model.id)">
|
||||
<span class="flex-1 text-xs text-[#8a8a8a]">{{ selectedModelTypes[model.id] || 'Select model type' }}</span>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-[#8a8a8a]" />
|
||||
</div>
|
||||
<div v-if="activeDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
|
||||
<div v-for="type in MODEL_TYPES" :key="type" class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer" @click="selectType(model.id, type)">
|
||||
{{ type }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-0.5">
|
||||
<Button variant="primary" class="w-full h-9 justify-center gap-2 text-sm font-semibold" @click="startImport(model.id)">
|
||||
<i class="icon-[lucide--download] size-4" /> Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- OR / Library Dropdown -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!modelInputs[model.id]" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-center py-0.5 font-bold text-[10px] text-[#8a8a8a]">OR</div>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="h-8 bg-[#262729] rounded-lg flex items-center px-3 cursor-pointer group/lib hover:border-[#494a50] border border-transparent"
|
||||
@click="toggleLibraryDropdown(model.id)"
|
||||
>
|
||||
<span class="flex-1 text-xs text-white truncate">Use from Library</span>
|
||||
<i class="icon-[lucide--chevron-down] size-3.5 text-[#8a8a8a] group-hover/lib:text-white" />
|
||||
</div>
|
||||
<!-- Library Dropdown Menu -->
|
||||
<div v-if="activeLibraryDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
|
||||
<div
|
||||
v-for="libModel in LIBRARY_MODELS"
|
||||
:key="libModel"
|
||||
class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer flex items-center gap-2"
|
||||
@click="selectFromLibrary(model.id, libModel)"
|
||||
>
|
||||
<i class="icon-[lucide--file-code] size-3.5 text-[#8a8a8a]" />
|
||||
{{ libModel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Bottom Divider -->
|
||||
<div class="-mx-4 mt-6 h-px bg-[#55565e]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Reset Button (Convenience for Storybook testing) -->
|
||||
<div v-if="Object.keys(removedModels).length > 0" class="flex justify-center py-8">
|
||||
<Button variant="muted-textonly" class="text-xs gap-2 hover:text-white" @click="resetAll">
|
||||
<i class="icon-[lucide--rotate-ccw] size-3.5" />
|
||||
Reset Storybook Flow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,472 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
// Props / Emits
|
||||
|
||||
const emit = defineEmits<{
|
||||
'locate': [name: string],
|
||||
}>()
|
||||
|
||||
// Mock Data
|
||||
|
||||
interface MissingModel {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const INITIAL_MISSING_MODELS: Record<string, MissingModel[]> = {
|
||||
'Lora': [
|
||||
{ id: 'm1', name: 'Flat_color_anime.safetensors', type: 'Lora' },
|
||||
{ id: 'm2', name: 'Bokeh_blur_xl.safetensors', type: 'Lora' },
|
||||
{ id: 'm3', name: 'Skin_texture_realism.safetensors', type: 'Lora' }
|
||||
],
|
||||
'VAE': [
|
||||
{ id: 'v1', name: 'vae-ft-mse-840000-ema-pruned.safetensors', type: 'VAE' },
|
||||
{ id: 'v2', name: 'clear-vae-v1.safetensors', type: 'VAE' }
|
||||
]
|
||||
}
|
||||
|
||||
const LIBRARY_MODELS = [
|
||||
'v1-5-pruned-emaonly.safetensors',
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'dreamshaper_8.safetensors',
|
||||
'realisticVisionV51_v51VAE.safetensors'
|
||||
]
|
||||
|
||||
// State
|
||||
|
||||
const collapsedCategories = ref<Record<string, boolean>>({
|
||||
'VAE': true
|
||||
})
|
||||
|
||||
// Stores the URL input for each model
|
||||
const modelInputs = ref<Record<string, string>>({})
|
||||
|
||||
// Tracks which models have finished their "revealing" delay
|
||||
const revealingModels = ref<Record<string, boolean>>({})
|
||||
const inputTimeouts = ref<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
|
||||
// Model Status: 'idle' | 'downloading' | 'downloaded' | 'using_library'
|
||||
const importStatus = ref<Record<string, 'idle' | 'downloading' | 'downloaded' | 'using_library'>>({})
|
||||
const downloadProgress = ref<Record<string, number>>({})
|
||||
const downloadTimers = ref<Record<string, ReturnType<typeof setInterval>>>({})
|
||||
const selectedLibraryModel = ref<Record<string, string>>({})
|
||||
|
||||
// Track hidden models (removed after clicking check button)
|
||||
const removedModels = ref<Record<string, boolean>>({})
|
||||
|
||||
// Watch for input changes to trigger the 1s delay
|
||||
watch(modelInputs, (newVal) => {
|
||||
for (const id in newVal) {
|
||||
const value = newVal[id]
|
||||
if (value && !revealingModels.value[id] && !inputTimeouts.value[id]) {
|
||||
inputTimeouts.value[id] = setTimeout(() => {
|
||||
revealingModels.value[id] = true
|
||||
delete inputTimeouts.value[id]
|
||||
}, 1000)
|
||||
} else if (!value) {
|
||||
revealingModels.value[id] = false
|
||||
if (inputTimeouts.value[id]) {
|
||||
clearTimeout(inputTimeouts.value[id])
|
||||
delete inputTimeouts.value[id]
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Compute which categories have at least one visible model
|
||||
const activeCategories = computed(() => {
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const cat in INITIAL_MISSING_MODELS) {
|
||||
result[cat] = INITIAL_MISSING_MODELS[cat].some(m => !removedModels.value[m.id])
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Tracks which model's library dropdown is currently open
|
||||
const activeLibraryDropdown = ref<string | null>(null)
|
||||
|
||||
// Actions
|
||||
|
||||
function toggleLibraryDropdown(modelId: string) {
|
||||
if (activeLibraryDropdown.value === modelId) {
|
||||
activeLibraryDropdown.value = null
|
||||
} else {
|
||||
activeLibraryDropdown.value = modelId
|
||||
}
|
||||
}
|
||||
|
||||
function selectFromLibrary(modelId: string, fileName: string) {
|
||||
selectedLibraryModel.value[modelId] = fileName
|
||||
importStatus.value[modelId] = 'using_library'
|
||||
activeLibraryDropdown.value = null
|
||||
}
|
||||
|
||||
function startImport(modelId: string) {
|
||||
if (downloadTimers.value[modelId]) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
}
|
||||
|
||||
importStatus.value[modelId] = 'downloading'
|
||||
downloadProgress.value[modelId] = 0
|
||||
|
||||
const startTime = Date.now()
|
||||
const duration = 5000
|
||||
|
||||
downloadTimers.value[modelId] = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min((elapsed / duration) * 100, 100)
|
||||
downloadProgress.value[modelId] = progress
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
delete downloadTimers.value[modelId]
|
||||
importStatus.value[modelId] = 'downloaded'
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function handleCheckClick(modelId: string) {
|
||||
if (importStatus.value[modelId] === 'downloaded' || importStatus.value[modelId] === 'using_library') {
|
||||
// Update object with spread to guarantee reactivity trigger
|
||||
removedModels.value = { ...removedModels.value, [modelId]: true }
|
||||
}
|
||||
}
|
||||
|
||||
function cancelImport(modelId: string) {
|
||||
if (downloadTimers.value[modelId]) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
delete downloadTimers.value[modelId]
|
||||
}
|
||||
importStatus.value[modelId] = 'idle'
|
||||
downloadProgress.value[modelId] = 0
|
||||
modelInputs.value[modelId] = ''
|
||||
selectedLibraryModel.value[modelId] = ''
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
// Clear all status records
|
||||
modelInputs.value = {}
|
||||
revealingModels.value = {}
|
||||
|
||||
// Clear any running timers
|
||||
for (const id in downloadTimers.value) {
|
||||
clearInterval(downloadTimers.value[id])
|
||||
}
|
||||
downloadTimers.value = {}
|
||||
|
||||
for (const id in inputTimeouts.value) {
|
||||
clearTimeout(inputTimeouts.value[id])
|
||||
}
|
||||
inputTimeouts.value = {}
|
||||
|
||||
importStatus.value = {}
|
||||
downloadProgress.value = {}
|
||||
selectedLibraryModel.value = {}
|
||||
removedModels.value = {}
|
||||
activeLibraryDropdown.value = null
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
function getElementStyle(el: HTMLElement) {
|
||||
return {
|
||||
height: el.style.height,
|
||||
overflow: el.style.overflow,
|
||||
paddingTop: el.style.paddingTop,
|
||||
paddingBottom: el.style.paddingBottom,
|
||||
marginTop: el.style.marginTop,
|
||||
marginBottom: el.style.marginBottom
|
||||
}
|
||||
}
|
||||
|
||||
// Transitions
|
||||
|
||||
const DURATION = 150
|
||||
|
||||
function enterTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { width } = getComputedStyle(el)
|
||||
el.style.width = width
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = ''
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.position = ''
|
||||
el.style.visibility = ''
|
||||
el.style.height = '0px'
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
|
||||
function leaveTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.height = height
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- ① Nav Item -->
|
||||
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
|
||||
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
|
||||
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
|
||||
Workflow Overview
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
|
||||
<i class="icon-[lucide--panel-right] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ② Node Header -->
|
||||
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
|
||||
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
|
||||
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
|
||||
<span class="text-sm text-white">Error</span>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Inputs</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Nodes</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
|
||||
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
|
||||
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
|
||||
Search for nodes or inputs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
|
||||
<!-- ③ Content: Missing Models -->
|
||||
<div class="flex-1 overflow-y-auto min-w-0 no-scrollbar">
|
||||
<template v-for="(models, category) in INITIAL_MISSING_MODELS" :key="category">
|
||||
<div
|
||||
v-if="activeCategories[category]"
|
||||
class="px-4 mb-4"
|
||||
>
|
||||
<!-- Category Header -->
|
||||
<div
|
||||
class="flex h-8 items-center justify-center w-full group"
|
||||
:class="category === 'VAE' ? 'cursor-default' : 'cursor-pointer'"
|
||||
@click="category !== 'VAE' && (collapsedCategories[category] = !collapsedCategories[category])"
|
||||
>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">
|
||||
{{ category }} ({{ models.filter(m => !removedModels[m.id]).length }})
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0">
|
||||
<i
|
||||
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] transition-all"
|
||||
:class="[
|
||||
category !== 'VAE' ? 'group-hover:text-white' : '',
|
||||
collapsedCategories[category] ? '-rotate-180' : ''
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model List -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!collapsedCategories[category]" class="pt-2">
|
||||
<TransitionGroup :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-for="model in models" v-show="!removedModels[model.id]" :key="model.id" class="flex flex-col w-full mb-6 last:mb-4">
|
||||
|
||||
<!-- Model Header (Always visible) -->
|
||||
<div class="flex h-8 items-center w-full gap-2 mb-1">
|
||||
<i class="icon-[lucide--file-check] size-4 text-white shrink-0" />
|
||||
<p class="flex-1 min-w-0 text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ model.name }}
|
||||
</p>
|
||||
|
||||
<!-- Check Button (Highlights blue when downloaded or using from library) -->
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 transition-colors"
|
||||
:class="[
|
||||
(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library')
|
||||
? 'cursor-pointer hover:bg-[#1e2d3d] bg-[#1e2d3d]'
|
||||
: 'opacity-20 cursor-default'
|
||||
]"
|
||||
@click="handleCheckClick(model.id)"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--check] size-4"
|
||||
:class="(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library') ? 'text-[#3b82f6]' : 'text-white'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Locate Button -->
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
|
||||
@click="emit('locate', model.name)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input or Progress Area -->
|
||||
<div class="relative mt-1">
|
||||
<!-- CARD (Download or Library Substitute) -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div
|
||||
v-if="importStatus[model.id] && importStatus[model.id] !== 'idle'"
|
||||
class="relative bg-white/5 border border-[#55565e] rounded-lg overflow-hidden flex items-center p-2 gap-2"
|
||||
>
|
||||
<!-- Progress Filling (Only while downloading) -->
|
||||
<div
|
||||
v-if="importStatus[model.id] === 'downloading'"
|
||||
class="absolute inset-y-0 left-0 bg-[#3b82f6]/10 transition-all duration-100 ease-linear pointer-events-none"
|
||||
:style="{ width: downloadProgress[model.id] + '%' }"
|
||||
/>
|
||||
|
||||
<!-- Left Icon -->
|
||||
<div class="relative z-10 size-[32px] flex items-center justify-center shrink-0">
|
||||
<i class="icon-[lucide--file-check] size-5 text-[#8a8a8a]" />
|
||||
</div>
|
||||
|
||||
<!-- Center Text -->
|
||||
<div class="relative z-10 flex-1 min-w-0 flex flex-col justify-center">
|
||||
<span class="text-[12px] font-medium text-white truncate leading-tight">
|
||||
{{ importStatus[model.id] === 'using_library' ? selectedLibraryModel[model.id] : model.name }}
|
||||
</span>
|
||||
<span class="text-[12px] text-[#8a8a8a] leading-tight mt-0.5">
|
||||
<template v-if="importStatus[model.id] === 'downloading'">Importing ...</template>
|
||||
<template v-else-if="importStatus[model.id] === 'downloaded'">Imported</template>
|
||||
<template v-else-if="importStatus[model.id] === 'using_library'">Using from Library</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Cancel (X) Button (Always visible in this card) -->
|
||||
<div
|
||||
class="relative z-10 size-6 flex items-center justify-center text-[#55565e] hover:text-white cursor-pointer transition-colors shrink-0"
|
||||
@click="cancelImport(model.id)"
|
||||
>
|
||||
<i class="icon-[lucide--circle-x] size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- IDLE / INPUT AREA -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!importStatus[model.id] || importStatus[model.id] === 'idle'" class="flex flex-col gap-2">
|
||||
<!-- URL Input Area -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!selectedLibraryModel[model.id]" class="flex flex-col gap-2">
|
||||
<div class="h-8 bg-[#262729] rounded-lg flex items-center px-3 border border-transparent focus-within:border-[#494a50] transition-colors whitespace-nowrap overflow-hidden">
|
||||
<input
|
||||
v-model="modelInputs[model.id]"
|
||||
type="text"
|
||||
placeholder="Paste Model URL (Civitai or Hugging Face)"
|
||||
class="bg-transparent border-none outline-none text-xs text-white w-full placeholder:text-[#8a8a8a]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="revealingModels[model.id]" class="flex flex-col gap-2">
|
||||
<div class="px-0.5 pt-0.5 whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<span class="text-xs font-bold text-white">something_model.safetensors</span>
|
||||
</div>
|
||||
<div class="pt-0.5">
|
||||
<Button variant="primary" class="w-full h-9 justify-center gap-2 text-sm font-semibold" @click="startImport(model.id)">
|
||||
<i class="icon-[lucide--download] size-4" /> Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- OR / Library Dropdown -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!modelInputs[model.id]" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-center py-0.5 font-bold text-[10px] text-[#8a8a8a]">OR</div>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="h-8 bg-[#262729] rounded-lg flex items-center px-3 cursor-pointer group/lib hover:border-[#494a50] border border-transparent"
|
||||
@click="toggleLibraryDropdown(model.id)"
|
||||
>
|
||||
<span class="flex-1 text-xs text-white truncate">Use from Library</span>
|
||||
<i class="icon-[lucide--chevron-down] size-3.5 text-[#8a8a8a] group-hover/lib:text-white" />
|
||||
</div>
|
||||
<!-- Library Dropdown Menu -->
|
||||
<div v-if="activeLibraryDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
|
||||
<div
|
||||
v-for="libModel in LIBRARY_MODELS"
|
||||
:key="libModel"
|
||||
class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer flex items-center gap-2"
|
||||
@click="selectFromLibrary(model.id, libModel)"
|
||||
>
|
||||
<i class="icon-[lucide--file-code] size-3.5 text-[#8a8a8a]" />
|
||||
{{ libModel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Bottom Divider -->
|
||||
<div class="-mx-4 mt-6 h-px bg-[#55565e]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Reset Button (Convenience for Storybook testing) -->
|
||||
<div v-if="Object.keys(removedModels).length > 0" class="flex justify-center py-8">
|
||||
<Button variant="muted-textonly" class="text-xs gap-2 hover:text-white" @click="resetAll">
|
||||
<i class="icon-[lucide--rotate-ccw] size-3.5" />
|
||||
Reset Storybook Flow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { MissingNodePack } from './MockManagerDialog.vue'
|
||||
|
||||
// Props / Emits
|
||||
|
||||
const emit = defineEmits<{
|
||||
'locate': [pack: MissingNodePack]
|
||||
}>()
|
||||
|
||||
// Mock Data
|
||||
|
||||
const MOCK_MISSING_PACKS: MissingNodePack[] = [
|
||||
{
|
||||
id: 'pack-1',
|
||||
displayName: 'MeshGraphormerDepthMapPreprocessor_for_SEGS //Inspire',
|
||||
packId: 'comfyui-inspire-pack',
|
||||
description: 'Inspire Pack provides various creative and utility nodes for ComfyUI workflows.'
|
||||
},
|
||||
{
|
||||
id: 'pack-2',
|
||||
displayName: 'TilePreprocessor_Provider_for_SEGS',
|
||||
packId: 'comfyui-controlnet-aux',
|
||||
description: 'Auxiliary preprocessors for ControlNet including tile, depth, and pose processors.'
|
||||
},
|
||||
{
|
||||
id: 'pack-3',
|
||||
displayName: 'WD14Tagger | pysssss',
|
||||
packId: 'comfyui-wdv14-tagger',
|
||||
description: 'Automatic image tagging using WD14 model from pysssss.'
|
||||
},
|
||||
{
|
||||
id: 'pack-4',
|
||||
displayName: 'CR Simple Image Compare',
|
||||
packId: 'comfyui-crystools',
|
||||
description: 'Crystal Tools suite including image comparison and utility nodes.'
|
||||
},
|
||||
{
|
||||
id: 'pack-5',
|
||||
displayName: 'FaceDetailer | impact',
|
||||
packId: 'comfyui-impact-pack',
|
||||
description: 'Impact Pack provides face detailing, masking, and segmentation utilities.'
|
||||
}
|
||||
]
|
||||
|
||||
// State
|
||||
|
||||
const isSectionCollapsed = ref(false)
|
||||
|
||||
// Helpers
|
||||
|
||||
function getElementStyle(el: HTMLElement) {
|
||||
return {
|
||||
height: el.style.height,
|
||||
overflow: el.style.overflow,
|
||||
paddingTop: el.style.paddingTop,
|
||||
paddingBottom: el.style.paddingBottom,
|
||||
marginTop: el.style.marginTop,
|
||||
marginBottom: el.style.marginBottom
|
||||
}
|
||||
}
|
||||
|
||||
// Transitions
|
||||
|
||||
const DURATION = 150
|
||||
|
||||
function enterTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { width } = getComputedStyle(el)
|
||||
el.style.width = width
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = ''
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.position = ''
|
||||
el.style.visibility = ''
|
||||
el.style.height = '0px'
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
|
||||
function leaveTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.height = height
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- ① Nav Item: "Workflow Overview" + panel-right button -->
|
||||
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
|
||||
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
|
||||
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
|
||||
Workflow Overview
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
|
||||
<i class="icon-[lucide--panel-right] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ② Node Header: tab bar + search -->
|
||||
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
|
||||
<!-- "Error" tab (active) -->
|
||||
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
|
||||
<span class="text-sm text-white">Error</span>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Other tabs -->
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Inputs</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Nodes</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
|
||||
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
|
||||
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
|
||||
Search for nodes or inputs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
|
||||
<!-- ③ Content: Nodes (Cloud Version) -->
|
||||
<div class="flex-1 overflow-y-auto min-w-0">
|
||||
<div class="px-4">
|
||||
<!-- Section Header: Unsupported Node Packs -->
|
||||
<div class="flex h-8 items-center justify-center w-full">
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">Unsupported Node Packs</p>
|
||||
<div
|
||||
class="group flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer"
|
||||
@click="isSectionCollapsed = !isSectionCollapsed"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] group-hover:text-white transition-all"
|
||||
:class="isSectionCollapsed ? '-rotate-180' : ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cloud Warning Text -->
|
||||
<div v-if="!isSectionCollapsed" class="mt-3 mb-5">
|
||||
<p class="m-0 text-sm text-[#8a8a8a] leading-relaxed">
|
||||
This workflow requires custom nodes not yet available on Comfy Cloud.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="-mx-4 border-b border-[#55565e]">
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!isSectionCollapsed" class="px-4 pb-2">
|
||||
<div v-for="pack in MOCK_MISSING_PACKS" :key="pack.id" class="flex flex-col w-full group/card mb-1">
|
||||
<!-- Widget Header -->
|
||||
<div class="flex h-8 items-center w-full">
|
||||
<p class="flex-1 min-w-0 text-sm text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ pack.displayName }}
|
||||
</p>
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
|
||||
@click="emit('locate', pack)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- No Install button in Cloud version -->
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
// Story-only type (Separated from actual production types)
|
||||
export interface MissingNodePack {
|
||||
id: string
|
||||
displayName: string
|
||||
packId: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// Props / Emits
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
selectedPack?: MissingNodePack | null
|
||||
}>(),
|
||||
{ selectedPack: null }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
BaseModalLayout structure reproduction:
|
||||
- Outer: rounded-2xl overflow-hidden
|
||||
- Grid: 14rem(left) 1fr(content) 18rem(right)
|
||||
- Left/Right panel bg: modal-panel-background = charcoal-600 = #262729
|
||||
- Main bg: base-background = charcoal-800 = #171718
|
||||
- Header height: h-18 (4.5rem / 72px)
|
||||
- Border: charcoal-200 = #494a50
|
||||
- NavItem selected: charcoal-300 = #3c3d42
|
||||
- NavItem hovered: charcoal-400 = #313235
|
||||
-->
|
||||
<div
|
||||
class="w-full h-full rounded-2xl overflow-hidden shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
|
||||
style="display:grid; grid-template-columns: 14rem 1fr 18rem;"
|
||||
>
|
||||
|
||||
<!-- ① Left Panel: bg = modal-panel-background = #262729 -->
|
||||
<nav class="h-full overflow-hidden flex flex-col" style="background:#262729;">
|
||||
<!-- Header: h-18 = 72px -->
|
||||
<header class="flex w-full shrink-0 gap-2 pl-6 pr-3 items-center" style="height:4.5rem;">
|
||||
<i class="icon-[comfy--extensions-blocks] text-white" />
|
||||
<h2 class="text-white text-base font-semibold m-0">Nodes Manager</h2>
|
||||
</header>
|
||||
|
||||
<!-- NavItems: px-3 gap-1 flex-col -->
|
||||
<div class="flex flex-col gap-1 px-3 pb-3 overflow-y-auto">
|
||||
<!-- All Extensions -->
|
||||
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
|
||||
<i class="icon-[lucide--list] size-4 text-white/70 shrink-0" />
|
||||
<span class="min-w-0 truncate">All Extensions</span>
|
||||
</div>
|
||||
<!-- Not Installed -->
|
||||
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
|
||||
<i class="icon-[lucide--globe] size-4 text-white/70 shrink-0" />
|
||||
<span class="min-w-0 truncate">Not Installed</span>
|
||||
</div>
|
||||
|
||||
<!-- Installed Group -->
|
||||
<div class="flex flex-col gap-1 mt-2">
|
||||
<p class="px-4 py-1 text-[10px] text-white/40 uppercase tracking-wider font-medium m-0">Installed</p>
|
||||
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
|
||||
<i class="icon-[lucide--download] size-4 text-white/70 shrink-0" />
|
||||
<span class="min-w-0 truncate">All Installed</span>
|
||||
</div>
|
||||
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
|
||||
<i class="icon-[lucide--refresh-cw] size-4 text-white/70 shrink-0" />
|
||||
<span class="min-w-0 truncate">Updates Available</span>
|
||||
</div>
|
||||
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
|
||||
<i class="icon-[lucide--triangle-alert] size-4 text-white/70 shrink-0" />
|
||||
<span class="min-w-0 truncate">Conflicting</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- In Workflow Group -->
|
||||
<div class="flex flex-col gap-1 mt-2">
|
||||
<p class="px-4 py-1 text-[10px] text-white/40 uppercase tracking-wider font-medium m-0">In Workflow</p>
|
||||
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors hover:bg-[#313235]">
|
||||
<i class="icon-[lucide--share-2] size-4 text-white/70 shrink-0" />
|
||||
<span class="min-w-0 truncate">In Workflow</span>
|
||||
</div>
|
||||
<!-- Missing Nodes: active selection = charcoal-300 = #3c3d42 -->
|
||||
<div class="flex cursor-pointer select-none items-center gap-2 rounded-md px-4 py-3 text-sm text-white transition-colors bg-[#3c3d42]">
|
||||
<i class="icon-[lucide--triangle-alert] size-4 text-[#fd9903] shrink-0" />
|
||||
<span class="min-w-0 truncate">Missing Nodes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ② Main Content: bg = base-background = #171718 -->
|
||||
<div class="flex flex-col overflow-hidden" style="background:#171718;">
|
||||
<!-- Header row 1: Node Pack dropdown | Search | Install All -->
|
||||
<header class="w-full px-6 flex items-center gap-3 shrink-0" style="height:4.5rem;">
|
||||
<!-- Node Pack Dropdown -->
|
||||
<div class="flex items-center gap-2 h-10 px-3 rounded-lg shrink-0 cursor-pointer text-sm text-white border border-[#494a50] hover:border-[#55565e]" style="background:#262729;">
|
||||
<span>Node Pack</span>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-[#8a8a8a]" />
|
||||
</div>
|
||||
<!-- Search bar (flex-1) -->
|
||||
<div class="flex items-center h-10 rounded-lg px-4 gap-2 flex-1" style="background:#262729;">
|
||||
<i class="pi pi-search text-xs text-[#8a8a8a] shrink-0" />
|
||||
<span class="text-sm text-[#8a8a8a]">Search</span>
|
||||
</div>
|
||||
<!-- Install All Button (blue, right side) -->
|
||||
<Button variant="primary" size="sm" class="shrink-0 gap-2 px-4 text-sm h-10 font-semibold rounded-xl">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
Install All
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<!-- Header row 2: Downloads Sort Dropdown (right aligned) -->
|
||||
<div class="flex justify-end px-6 py-3 shrink-0">
|
||||
<div class="flex items-center h-10 gap-2 px-4 rounded-xl cursor-pointer text-sm text-white border border-[#494a50] hover:border-[#55565e]" style="background:#262729;">
|
||||
<i class="icon-[lucide--arrow-up-down] size-4 text-[#8a8a8a]" />
|
||||
<span>Downloads</span>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-[#8a8a8a]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pack Grid Content -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-6 py-4">
|
||||
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));">
|
||||
<div
|
||||
v-for="(_, i) in 9"
|
||||
:key="i"
|
||||
:class="[
|
||||
'rounded-xl border p-4 flex flex-col gap-3 cursor-pointer transition-colors',
|
||||
i === 0 && selectedPack
|
||||
? 'border-[#0b8ce9]/70 ring-1 ring-[#0b8ce9]/40'
|
||||
: 'border-[#494a50] hover:border-[#55565e]'
|
||||
]"
|
||||
:style="i === 0 && selectedPack ? 'background:#172d3a;' : 'background:#262729;'"
|
||||
>
|
||||
<!-- Card Image Area -->
|
||||
<div class="w-full h-20 rounded-lg flex items-center justify-center" style="background:#313235;">
|
||||
<i class="icon-[lucide--package] size-6 text-[#8a8a8a]" />
|
||||
</div>
|
||||
<!-- Card Text Content -->
|
||||
<div>
|
||||
<p class="m-0 text-xs font-semibold text-white truncate">
|
||||
{{ i === 0 && selectedPack ? selectedPack.packId : 'node-pack-' + (i + 1) }}
|
||||
</p>
|
||||
<p class="m-0 text-[11px] text-[#8a8a8a] truncate mt-0.5">by publisher</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ③ Right Info Panel -->
|
||||
<aside class="h-full flex flex-col overflow-hidden" style="background:#1c1d1f; border-left: 1px solid #494a50;">
|
||||
<!-- Header: h-18 - Title + Panel icons + X close -->
|
||||
<header class="flex h-[4.5rem] shrink-0 items-center px-5 gap-3 border-b border-[#494a50]">
|
||||
<h2 class="flex-1 select-none text-base font-bold text-white m-0">Node Pack Info</h2>
|
||||
<!-- Panel Collapse Icon -->
|
||||
<button
|
||||
class="flex items-center justify-center text-[#8a8a8a] hover:text-white p-1"
|
||||
style="background:none;border:none;outline:none;cursor:pointer;"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</button>
|
||||
<!-- Close X Icon -->
|
||||
<button
|
||||
class="flex items-center justify-center text-[#8a8a8a] hover:text-white p-1"
|
||||
style="background:none;border:none;outline:none;cursor:pointer;"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Panel Content Area -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<div v-if="props.selectedPack" class="flex flex-col divide-y divide-[#2e2f31]">
|
||||
|
||||
<!-- ACTIONS SECTION -->
|
||||
<div class="flex flex-col gap-3 px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Actions</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
|
||||
</div>
|
||||
<Button variant="primary" class="w-full justify-center gap-2 h-10 font-semibold rounded-xl">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
Install
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- BASIC INFO SECTION -->
|
||||
<div class="flex flex-col gap-4 px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Basic Info</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
|
||||
</div>
|
||||
<!-- Name -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-white">Name</span>
|
||||
<span class="text-sm text-[#8a8a8a]">{{ props.selectedPack.packId }}</span>
|
||||
</div>
|
||||
<!-- Created By -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-white">Created By</span>
|
||||
<span class="text-sm text-[#8a8a8a]">publisher</span>
|
||||
</div>
|
||||
<!-- Downloads -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-white">Downloads</span>
|
||||
<span class="text-sm text-[#8a8a8a]">539,373</span>
|
||||
</div>
|
||||
<!-- Last Updated -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-white">Last Updated</span>
|
||||
<span class="text-sm text-[#8a8a8a]">Jan 21, 2026</span>
|
||||
</div>
|
||||
<!-- Status -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-white">Status</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 w-fit px-2.5 py-1 rounded-full text-xs text-white border border-[#494a50]"
|
||||
style="background:#262729;"
|
||||
>
|
||||
<span class="size-2 rounded-full bg-[#8a8a8a] shrink-0" />
|
||||
Unknown
|
||||
</span>
|
||||
</div>
|
||||
<!-- Version -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-white">Version</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 w-fit px-2.5 py-1 rounded-full text-xs text-white border border-[#494a50]"
|
||||
style="background:#262729;"
|
||||
>
|
||||
1.8.0
|
||||
<i class="icon-[lucide--chevron-right] size-3 text-[#8a8a8a]" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESCRIPTION SECTION -->
|
||||
<div class="flex flex-col gap-4 px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Description</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-white">Description</span>
|
||||
<p class="m-0 text-sm text-[#8a8a8a] leading-relaxed">{{ props.selectedPack.description }}</p>
|
||||
</div>
|
||||
<!-- Repository -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-white">Repository</span>
|
||||
<div class="flex items-start gap-2">
|
||||
<i class="icon-[lucide--github] size-4 text-[#8a8a8a] shrink-0 mt-0.5" />
|
||||
<span class="text-sm text-[#8a8a8a] break-all flex-1">https://github.com/aria1th/{{ props.selectedPack.packId }}</span>
|
||||
<i class="icon-[lucide--external-link] size-4 text-[#8a8a8a] shrink-0 mt-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- License -->
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-white">License</span>
|
||||
<span class="text-sm text-[#8a8a8a]">MIT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NODES SECTION -->
|
||||
<div class="flex flex-col gap-3 px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[11px] font-bold text-[#8a8a8a] uppercase tracking-widest">Nodes</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- No Selection State -->
|
||||
<div v-else class="flex flex-col items-center justify-center h-full gap-3 px-6 opacity-40">
|
||||
<i class="icon-[lucide--package] size-8 text-white" />
|
||||
<p class="m-0 text-sm text-white text-center">Select a pack to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,405 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
// Props / Emits
|
||||
|
||||
const emit = defineEmits<{
|
||||
'locate': [name: string],
|
||||
}>()
|
||||
|
||||
// Mock Data
|
||||
|
||||
interface MissingModel {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const INITIAL_MISSING_MODELS: Record<string, MissingModel[]> = {
|
||||
'Lora': [
|
||||
{ id: 'm1', name: 'Flat_color_anime.safetensors', type: 'Lora' },
|
||||
{ id: 'm2', name: 'Bokeh_blur_xl.safetensors', type: 'Lora' },
|
||||
{ id: 'm3', name: 'Skin_texture_realism.safetensors', type: 'Lora' }
|
||||
],
|
||||
'VAE': [
|
||||
{ id: 'v1', name: 'vae-ft-mse-840000-ema-pruned.safetensors', type: 'VAE' },
|
||||
{ id: 'v2', name: 'clear-vae-v1.safetensors', type: 'VAE' }
|
||||
]
|
||||
}
|
||||
|
||||
const LIBRARY_MODELS = [
|
||||
'v1-5-pruned-emaonly.safetensors',
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'dreamshaper_8.safetensors',
|
||||
'realisticVisionV51_v51VAE.safetensors'
|
||||
]
|
||||
|
||||
// State
|
||||
|
||||
const collapsedCategories = ref<Record<string, boolean>>({
|
||||
'VAE': true
|
||||
})
|
||||
|
||||
// Model Status: 'idle' | 'downloading' | 'downloaded' | 'using_library'
|
||||
const importStatus = ref<Record<string, 'idle' | 'downloading' | 'downloaded' | 'using_library'>>({})
|
||||
const downloadProgress = ref<Record<string, number>>({})
|
||||
const downloadTimers = ref<Record<string, ReturnType<typeof setInterval>>>({})
|
||||
const selectedLibraryModel = ref<Record<string, string>>({})
|
||||
|
||||
// Track hidden models (removed after clicking check button)
|
||||
const removedModels = ref<Record<string, boolean>>({})
|
||||
|
||||
// Compute which categories have at least one visible model
|
||||
const activeCategories = computed(() => {
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const cat in INITIAL_MISSING_MODELS) {
|
||||
result[cat] = INITIAL_MISSING_MODELS[cat].some(m => !removedModels.value[m.id])
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Tracks which model's library dropdown is currently open
|
||||
const activeLibraryDropdown = ref<string | null>(null)
|
||||
|
||||
// Actions
|
||||
|
||||
function toggleLibraryDropdown(modelId: string) {
|
||||
if (activeLibraryDropdown.value === modelId) {
|
||||
activeLibraryDropdown.value = null
|
||||
} else {
|
||||
activeLibraryDropdown.value = modelId
|
||||
}
|
||||
}
|
||||
|
||||
function selectFromLibrary(modelId: string, fileName: string) {
|
||||
selectedLibraryModel.value[modelId] = fileName
|
||||
importStatus.value[modelId] = 'using_library'
|
||||
activeLibraryDropdown.value = null
|
||||
}
|
||||
|
||||
function startUpload(modelId: string) {
|
||||
if (downloadTimers.value[modelId]) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
}
|
||||
|
||||
importStatus.value[modelId] = 'downloading'
|
||||
downloadProgress.value[modelId] = 0
|
||||
|
||||
const startTime = Date.now()
|
||||
const duration = 3000 // Speed up for OSS simulation
|
||||
|
||||
downloadTimers.value[modelId] = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min((elapsed / duration) * 100, 100)
|
||||
downloadProgress.value[modelId] = progress
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
delete downloadTimers.value[modelId]
|
||||
importStatus.value[modelId] = 'downloaded'
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function handleCheckClick(modelId: string) {
|
||||
if (importStatus.value[modelId] === 'downloaded' || importStatus.value[modelId] === 'using_library') {
|
||||
removedModels.value = { ...removedModels.value, [modelId]: true }
|
||||
}
|
||||
}
|
||||
|
||||
function cancelImport(modelId: string) {
|
||||
if (downloadTimers.value[modelId]) {
|
||||
clearInterval(downloadTimers.value[modelId])
|
||||
delete downloadTimers.value[modelId]
|
||||
}
|
||||
importStatus.value[modelId] = 'idle'
|
||||
downloadProgress.value[modelId] = 0
|
||||
selectedLibraryModel.value[modelId] = ''
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
for (const id in downloadTimers.value) {
|
||||
clearInterval(downloadTimers.value[id])
|
||||
}
|
||||
downloadTimers.value = {}
|
||||
importStatus.value = {}
|
||||
downloadProgress.value = {}
|
||||
selectedLibraryModel.value = {}
|
||||
removedModels.value = {}
|
||||
activeLibraryDropdown.value = null
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
function getElementStyle(el: HTMLElement) {
|
||||
return {
|
||||
height: el.style.height,
|
||||
overflow: el.style.overflow,
|
||||
paddingTop: el.style.paddingTop,
|
||||
paddingBottom: el.style.paddingBottom,
|
||||
marginTop: el.style.marginTop,
|
||||
marginBottom: el.style.marginBottom
|
||||
}
|
||||
}
|
||||
|
||||
// Transitions
|
||||
|
||||
const DURATION = 150
|
||||
|
||||
function enterTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { width } = getComputedStyle(el)
|
||||
el.style.width = width
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = ''
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.position = ''
|
||||
el.style.visibility = ''
|
||||
el.style.height = '0px'
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
|
||||
function leaveTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.height = height
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- ① Nav Item -->
|
||||
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
|
||||
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
|
||||
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
|
||||
Workflow Overview
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
|
||||
<i class="icon-[lucide--panel-right] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ② Node Header -->
|
||||
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
|
||||
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
|
||||
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
|
||||
<span class="text-sm text-white">Error</span>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Inputs</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Nodes</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
|
||||
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
|
||||
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
|
||||
Search for nodes or inputs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
|
||||
<!-- ③ Content: Missing Models -->
|
||||
<div class="flex-1 overflow-y-auto min-w-0 no-scrollbar">
|
||||
<template v-for="(models, category) in INITIAL_MISSING_MODELS" :key="category">
|
||||
<div
|
||||
v-if="activeCategories[category]"
|
||||
class="px-4 mb-4"
|
||||
>
|
||||
<!-- Category Header -->
|
||||
<div
|
||||
class="flex h-8 items-center justify-center w-full group"
|
||||
:class="category === 'VAE' ? 'cursor-default' : 'cursor-pointer'"
|
||||
@click="category !== 'VAE' && (collapsedCategories[category] = !collapsedCategories[category])"
|
||||
>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap font-medium">
|
||||
{{ category }} ({{ models.filter(m => !removedModels[m.id]).length }})
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0">
|
||||
<i
|
||||
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] transition-all"
|
||||
:class="[
|
||||
category !== 'VAE' ? 'group-hover:text-white' : '',
|
||||
collapsedCategories[category] ? '-rotate-180' : ''
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model List -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!collapsedCategories[category]" class="pt-2">
|
||||
<TransitionGroup :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-for="model in models" v-show="!removedModels[model.id]" :key="model.id" class="flex flex-col w-full mb-6 last:mb-4">
|
||||
|
||||
<!-- Model Header (Always visible) -->
|
||||
<div class="flex h-8 items-center w-full gap-2 mb-1">
|
||||
<i class="icon-[lucide--file-check] size-4 text-white shrink-0" />
|
||||
<p class="flex-1 min-w-0 text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ model.name }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 transition-colors"
|
||||
:class="[
|
||||
(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library')
|
||||
? 'cursor-pointer hover:bg-[#1e2d3d] bg-[#1e2d3d]'
|
||||
: 'opacity-20 cursor-default'
|
||||
]"
|
||||
@click="handleCheckClick(model.id)"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--check] size-4"
|
||||
:class="(importStatus[model.id] === 'downloaded' || importStatus[model.id] === 'using_library') ? 'text-[#3b82f6]' : 'text-white'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
|
||||
@click="emit('locate', model.name)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input or Progress Area -->
|
||||
<div class="relative mt-1">
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div
|
||||
v-if="importStatus[model.id] && importStatus[model.id] !== 'idle'"
|
||||
class="relative bg-white/5 border border-[#55565e] rounded-lg overflow-hidden flex items-center p-2 gap-2"
|
||||
>
|
||||
<div
|
||||
v-if="importStatus[model.id] === 'downloading'"
|
||||
class="absolute inset-y-0 left-0 bg-[#3b82f6]/10 transition-all duration-100 ease-linear pointer-events-none"
|
||||
:style="{ width: downloadProgress[model.id] + '%' }"
|
||||
/>
|
||||
|
||||
<div class="relative z-10 size-[32px] flex items-center justify-center shrink-0">
|
||||
<i class="icon-[lucide--file-check] size-5 text-[#8a8a8a]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 flex-1 min-w-0 flex flex-col justify-center">
|
||||
<span class="text-[12px] font-medium text-white truncate leading-tight">
|
||||
{{ importStatus[model.id] === 'using_library' ? selectedLibraryModel[model.id] : model.name }}
|
||||
</span>
|
||||
<span class="text-[12px] text-[#8a8a8a] leading-tight mt-0.5">
|
||||
<template v-if="importStatus[model.id] === 'downloading'">Uploading ...</template>
|
||||
<template v-else-if="importStatus[model.id] === 'downloaded'">Uploaded</template>
|
||||
<template v-else-if="importStatus[model.id] === 'using_library'">Using from Library</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative z-10 size-6 flex items-center justify-center text-[#55565e] hover:text-white cursor-pointer transition-colors shrink-0"
|
||||
@click="cancelImport(model.id)"
|
||||
>
|
||||
<i class="icon-[lucide--circle-x] size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- IDLE / UPLOAD AREA -->
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!importStatus[model.id] || importStatus[model.id] === 'idle'" class="flex flex-col gap-2">
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!selectedLibraryModel[model.id]" class="flex flex-col gap-2">
|
||||
<!-- Direct Upload Section -->
|
||||
<div
|
||||
class="h-8 rounded-lg flex items-center justify-center border border-dashed border-[#55565e] hover:border-white transition-colors cursor-pointer group"
|
||||
@click="startUpload(model.id)"
|
||||
>
|
||||
<span class="text-xs text-[#8a8a8a] group-hover:text-white">Upload .safetensors or .ckpt</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-center py-0.5 font-bold text-[10px] text-[#8a8a8a]">OR</div>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="h-8 bg-[#262729] rounded-lg flex items-center px-3 cursor-pointer group/lib hover:border-[#494a50] border border-transparent"
|
||||
@click="toggleLibraryDropdown(model.id)"
|
||||
>
|
||||
<span class="flex-1 text-xs text-white truncate">Use from Library</span>
|
||||
<i class="icon-[lucide--chevron-down] size-3.5 text-[#8a8a8a] group-hover/lib:text-white" />
|
||||
</div>
|
||||
<div v-if="activeLibraryDropdown === model.id" class="absolute top-full left-0 w-full mt-1 bg-[#26272b] border border-[#3f4045] rounded-lg shadow-xl z-50 overflow-hidden py-1">
|
||||
<div
|
||||
v-for="libModel in LIBRARY_MODELS"
|
||||
:key="libModel"
|
||||
class="px-3 py-2 text-xs text-[#e2e2e4] hover:bg-[#323338] cursor-pointer flex items-center gap-2"
|
||||
@click="selectFromLibrary(model.id, libModel)"
|
||||
>
|
||||
<i class="icon-[lucide--file-code] size-3.5 text-[#8a8a8a]" />
|
||||
{{ libModel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="-mx-4 mt-6 h-px bg-[#55565e]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="Object.keys(removedModels).length > 0" class="flex justify-center py-8">
|
||||
<Button variant="muted-textonly" class="text-xs gap-2 hover:text-white" @click="resetAll">
|
||||
<i class="icon-[lucide--rotate-ccw] size-3.5" />
|
||||
Reset Storybook Flow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { MissingNodePack } from './MockManagerDialog.vue'
|
||||
|
||||
// Props / Emits
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-manager': [pack: MissingNodePack],
|
||||
'locate': [pack: MissingNodePack],
|
||||
'log': [msg: string]
|
||||
}>()
|
||||
|
||||
// Mock Data
|
||||
|
||||
const MOCK_MISSING_PACKS: MissingNodePack[] = [
|
||||
{
|
||||
id: 'pack-1',
|
||||
displayName: 'MeshGraphormerDepthMapPreprocessor_for_SEGS //Inspire',
|
||||
packId: 'comfyui-inspire-pack',
|
||||
description: 'Inspire Pack provides various creative and utility nodes for ComfyUI workflows.'
|
||||
},
|
||||
{
|
||||
id: 'pack-2',
|
||||
displayName: 'TilePreprocessor_Provider_for_SEGS',
|
||||
packId: 'comfyui-controlnet-aux',
|
||||
description: 'Auxiliary preprocessors for ControlNet including tile, depth, and pose processors.'
|
||||
},
|
||||
{
|
||||
id: 'pack-3',
|
||||
displayName: 'WD14Tagger | pysssss',
|
||||
packId: 'comfyui-wdv14-tagger',
|
||||
description: 'Automatic image tagging using WD14 model from pysssss.'
|
||||
},
|
||||
{
|
||||
id: 'pack-4',
|
||||
displayName: 'CR Simple Image Compare',
|
||||
packId: 'comfyui-crystools',
|
||||
description: 'Crystal Tools suite including image comparison and utility nodes.'
|
||||
},
|
||||
{
|
||||
id: 'pack-5',
|
||||
displayName: 'FaceDetailer | impact',
|
||||
packId: 'comfyui-impact-pack',
|
||||
description: 'Impact Pack provides face detailing, masking, and segmentation utilities.'
|
||||
}
|
||||
]
|
||||
|
||||
// State
|
||||
|
||||
const isSectionCollapsed = ref(false)
|
||||
const installStates = ref<Record<string, 'idle' | 'installing' | 'error'>>({})
|
||||
const hasSuccessfulInstall = ref(false)
|
||||
|
||||
// Helpers
|
||||
|
||||
function getInstallState(packId: string) {
|
||||
return installStates.value[packId] ?? 'idle'
|
||||
}
|
||||
|
||||
function getElementStyle(el: HTMLElement) {
|
||||
return {
|
||||
height: el.style.height,
|
||||
overflow: el.style.overflow,
|
||||
paddingTop: el.style.paddingTop,
|
||||
paddingBottom: el.style.paddingBottom,
|
||||
marginTop: el.style.marginTop,
|
||||
marginBottom: el.style.marginBottom
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
|
||||
function onInstall(pack: MissingNodePack) {
|
||||
if (getInstallState(pack.id) !== 'idle') return
|
||||
installStates.value[pack.id] = 'installing'
|
||||
emit('log', `⤵ Installing: "${pack.packId}"`)
|
||||
|
||||
const isErrorPack = pack.id === 'pack-2'
|
||||
const delay = isErrorPack ? 2000 : 3000
|
||||
|
||||
setTimeout(() => {
|
||||
if (isErrorPack) {
|
||||
installStates.value[pack.id] = 'error'
|
||||
emit('log', `⚠️ Install failed: "${pack.packId}"`)
|
||||
} else {
|
||||
installStates.value[pack.id] = 'idle'
|
||||
hasSuccessfulInstall.value = true
|
||||
emit('log', `✓ Installed: "${pack.packId}"`)
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function onInstallAll() {
|
||||
const idlePacks = MOCK_MISSING_PACKS.filter(p => getInstallState(p.id) === 'idle')
|
||||
if (!idlePacks.length) {
|
||||
emit('log', 'No packs to install')
|
||||
return
|
||||
}
|
||||
emit('log', `Install All → Starting sequential install of ${idlePacks.length} pack(s)`)
|
||||
idlePacks.forEach((pack, i) => {
|
||||
setTimeout(() => onInstall(pack), i * 1000)
|
||||
})
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
installStates.value = {}
|
||||
hasSuccessfulInstall.value = false
|
||||
emit('log', '🔄 Reboot Server')
|
||||
}
|
||||
|
||||
// Transitions
|
||||
|
||||
const DURATION = 150
|
||||
|
||||
function enterTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { width } = getComputedStyle(el)
|
||||
el.style.width = width
|
||||
el.style.position = 'absolute'
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.height = ''
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.position = ''
|
||||
el.style.visibility = ''
|
||||
el.style.height = '0px'
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height: '0px', opacity: 0 }, { height, opacity: 1 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
|
||||
function leaveTransition(element: Element, done: () => void) {
|
||||
const el = element as HTMLElement
|
||||
const init = getElementStyle(el)
|
||||
const { height } = getComputedStyle(el)
|
||||
el.style.height = height
|
||||
el.style.overflow = 'hidden'
|
||||
const anim = el.animate(
|
||||
[{ height, opacity: 1 }, { height: '0px', opacity: 0 }],
|
||||
{ duration: DURATION, easing: 'ease-in-out' }
|
||||
)
|
||||
el.style.height = init.height
|
||||
anim.onfinish = () => { el.style.overflow = init.overflow; done() }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[320px] h-full shrink-0 flex flex-col gap-4 py-1 bg-[#171718] border-l border-[#494a50] shadow-[1px_1px_8px_0px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- ① Nav Item: "Workflow Overview" + panel-right button -->
|
||||
<div class="flex h-12 items-center overflow-hidden py-2 border-b border-[#55565e] shrink-0">
|
||||
<div class="flex flex-1 gap-2 items-center min-w-0 pl-4 pr-3">
|
||||
<p class="flex-1 min-w-0 font-bold text-sm text-white whitespace-pre-wrap">
|
||||
Workflow Overview
|
||||
</p>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]">
|
||||
<i class="icon-[lucide--panel-right] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ② Node Header: tab bar + search -->
|
||||
<div class="flex flex-col gap-3 items-start px-4 shrink-0">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex gap-2 items-center w-full overflow-x-auto no-scrollbar">
|
||||
<!-- "Error" tab (active) -->
|
||||
<div class="flex gap-1 h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0 bg-[#262729]">
|
||||
<span class="text-sm text-white">Error</span>
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Other tabs -->
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Inputs</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm text-[#8a8a8a]">Nodes</span>
|
||||
</div>
|
||||
<div class="flex h-8 items-center justify-center overflow-hidden px-2 rounded-lg shrink-0">
|
||||
<span class="text-sm whitespace-nowrap text-[#8a8a8a]">Global settings</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="flex gap-2 h-8 items-center min-h-[32px] px-2 py-1.5 rounded-lg bg-[#262729] w-full">
|
||||
<i class="icon-[lucide--search] size-4 text-[#8a8a8a] shrink-0" />
|
||||
<p class="flex-1 text-xs text-[#8a8a8a] truncate leading-normal">
|
||||
Search for nodes or inputs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
|
||||
<!-- ③ Content: Nodes (Missing Node Packs) -->
|
||||
<div class="flex-1 overflow-y-auto min-w-0">
|
||||
<div class="px-4">
|
||||
<!-- Section Header -->
|
||||
<div class="flex h-8 items-center justify-center w-full">
|
||||
<div class="flex items-center justify-center size-6 shrink-0">
|
||||
<i class="icon-[lucide--octagon-alert] size-4 text-[#e04e48]" />
|
||||
</div>
|
||||
<p class="flex-1 min-w-0 text-sm text-[#e04e48] whitespace-pre-wrap">Missing Node Packs</p>
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg bg-[#262729] shrink-0 cursor-pointer hover:bg-[#303133]"
|
||||
@click="onInstallAll"
|
||||
>
|
||||
<span class="text-sm text-white">Install All</span>
|
||||
</div>
|
||||
<div
|
||||
class="group flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer"
|
||||
@click="isSectionCollapsed = !isSectionCollapsed"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--chevron-up] size-4 text-[#8a8a8a] group-hover:text-white transition-all"
|
||||
:class="isSectionCollapsed ? '-rotate-180' : ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-2" />
|
||||
|
||||
<div class="-mx-4 border-b border-[#55565e]">
|
||||
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
|
||||
<div v-if="!isSectionCollapsed" class="px-4 pb-2">
|
||||
<div v-for="pack in MOCK_MISSING_PACKS" :key="pack.id" class="flex flex-col w-full group/card mb-1">
|
||||
<!-- Widget Header -->
|
||||
<div class="flex h-8 items-center w-full">
|
||||
<p class="flex-1 min-w-0 text-sm text-white overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ pack.displayName }}
|
||||
</p>
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
|
||||
@click="emit('open-manager', pack)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4 text-white" />
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center justify-center overflow-hidden p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#262729]"
|
||||
@click="emit('locate', pack)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Install button -->
|
||||
<div class="flex items-start w-full pt-1 pb-2">
|
||||
<div
|
||||
class="flex flex-1 h-8 items-center justify-center overflow-hidden p-2 rounded-lg min-w-0 transition-colors select-none"
|
||||
:class="[
|
||||
getInstallState(pack.id) === 'idle'
|
||||
? 'bg-[#262729] cursor-pointer hover:bg-[#303133]'
|
||||
: getInstallState(pack.id) === 'error'
|
||||
? 'bg-[#3a2020] cursor-pointer hover:bg-[#4a2a2a]'
|
||||
: 'bg-[#262729] opacity-60 cursor-default'
|
||||
]"
|
||||
@click="onInstall(pack)"
|
||||
>
|
||||
<svg
|
||||
v-if="getInstallState(pack.id) === 'installing'"
|
||||
class="animate-spin size-4 text-white shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<i
|
||||
v-else-if="getInstallState(pack.id) === 'error'"
|
||||
class="icon-[lucide--triangle-alert] size-4 text-[#f59e0b] shrink-0"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--download] size-4 text-white shrink-0" />
|
||||
<span class="text-sm text-white ml-1.5 shrink-0">
|
||||
{{ getInstallState(pack.id) === 'installing' ? 'Installing...' : 'Install node pack' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-300 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="hasSuccessfulInstall && !isSectionCollapsed" class="px-4 pb-4 pt-1">
|
||||
<Button
|
||||
variant="primary"
|
||||
class="w-full h-9 justify-center gap-2 text-sm font-semibold"
|
||||
@click="resetAll"
|
||||
>
|
||||
<i class="icon-[lucide--refresh-cw] size-4" />
|
||||
Apply Changes
|
||||
</Button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[#55565e] shrink-0 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import MockManagerDialog from './MockManagerDialog.vue'
|
||||
import MockOSSMissingNodePack from './MockOSSMissingNodePack.vue'
|
||||
import type { MissingNodePack } from './MockManagerDialog.vue'
|
||||
|
||||
// State
|
||||
|
||||
const isManagerOpen = ref(false)
|
||||
const selectedPack = ref<MissingNodePack | null>(null)
|
||||
const statusLog = ref<string>('')
|
||||
|
||||
// Actions
|
||||
|
||||
function log(msg: string) {
|
||||
statusLog.value = msg
|
||||
}
|
||||
|
||||
function openManager(pack: MissingNodePack) {
|
||||
selectedPack.value = pack
|
||||
isManagerOpen.value = true
|
||||
log(`ⓘ Opening Manager: "${pack.displayName.split('//')[0].trim()}"`)
|
||||
}
|
||||
|
||||
function closeManager() {
|
||||
isManagerOpen.value = false
|
||||
selectedPack.value = null
|
||||
log('Manager closed')
|
||||
}
|
||||
|
||||
function onLocate(pack: MissingNodePack) {
|
||||
log(`◎ Locating on canvas: "${pack.displayName.split('//')[0].trim()}"`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ComfyUI layout simulation: canvas + right side panel + manager overlay -->
|
||||
<div class="relative w-full h-full flex overflow-hidden bg-[#0d0e10]">
|
||||
|
||||
<!-- Canvas area -->
|
||||
<div class="flex-1 min-w-0 relative flex flex-col items-center justify-center gap-4 overflow-hidden">
|
||||
<!-- Grid background -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-15"
|
||||
style="background-image: repeating-linear-gradient(#444 0 1px, transparent 1px 100%), repeating-linear-gradient(90deg, #444 0 1px, transparent 1px 100%); background-size: 32px 32px;"
|
||||
/>
|
||||
<div class="relative z-10 flex flex-col items-center gap-4">
|
||||
<div class="text-[#8a8a8a]/30 text-sm select-none">ComfyUI Canvas</div>
|
||||
<div class="flex gap-5 flex-wrap justify-center px-8">
|
||||
<div v-for="i in 4" :key="i" class="w-[160px] h-[80px] rounded-lg border border-[#3a3b3d] bg-[#1a1b1d]/80 flex flex-col p-3 gap-2">
|
||||
<div class="h-3 w-24 rounded bg-[#2a2b2d]" />
|
||||
<div class="h-2 w-16 rounded bg-[#2a2b2d]" />
|
||||
<div class="h-2 w-20 rounded bg-[#2a2b2d]" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center min-h-[36px]">
|
||||
<div
|
||||
v-if="statusLog"
|
||||
class="px-4 py-1.5 rounded-lg text-xs text-center bg-blue-950/70 border border-blue-500/40 text-blue-300"
|
||||
>{{ statusLog }}</div>
|
||||
<div v-else class="px-4 py-1.5 text-xs text-[#8a8a8a]/30 border border-dashed border-[#2a2b2d] rounded-lg">
|
||||
Click the buttons in the right-side error tab
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: MockOSSMissingNodePack (320px) -->
|
||||
<MockOSSMissingNodePack
|
||||
@open-manager="openManager"
|
||||
@locate="onLocate"
|
||||
@log="log"
|
||||
/>
|
||||
|
||||
<!-- Manager dialog overlay (full screen including right panel) -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="isManagerOpen"
|
||||
class="absolute inset-0 z-20 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@click.self="closeManager"
|
||||
>
|
||||
<div class="relative h-[80vh] w-[90vw] max-w-[1400px]">
|
||||
<MockManagerDialog :selected-pack="selectedPack" @close="closeManager" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,15 +1,28 @@
|
||||
import { computed } from 'vue'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { st } from '@/i18n'
|
||||
import type { ErrorCardData, ErrorGroup } from './types'
|
||||
import { isNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import {
|
||||
isNodeExecutionId,
|
||||
parseNodeExecutionId
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
const SINGLE_GROUP_KEY = '__single__'
|
||||
const KNOWN_PROMPT_ERROR_TYPES = new Set(['prompt_no_outputs', 'no_prompt'])
|
||||
|
||||
interface GroupEntry {
|
||||
priority: number
|
||||
@@ -25,20 +38,30 @@ interface ErrorSearchItem {
|
||||
searchableDetails: string
|
||||
}
|
||||
|
||||
const KNOWN_PROMPT_ERROR_TYPES = new Set(['prompt_no_outputs', 'no_prompt'])
|
||||
|
||||
function resolveNodeInfo(nodeId: string): {
|
||||
title: string
|
||||
graphNodeId: string | undefined
|
||||
} {
|
||||
/**
|
||||
* Resolve display info for a node by its execution ID.
|
||||
* For group node internals, resolves the parent group node's title instead.
|
||||
*/
|
||||
function resolveNodeInfo(nodeId: string) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
|
||||
const parts = parseNodeExecutionId(nodeId)
|
||||
const parentId = parts && parts.length > 1 ? String(parts[0]) : null
|
||||
const parentNode = parentId
|
||||
? app.rootGraph.getNodeById(Number(parentId))
|
||||
: null
|
||||
const isParentGroupNode = parentNode ? isGroupNode(parentNode) : false
|
||||
|
||||
return {
|
||||
title: resolveNodeDisplayName(graphNode, {
|
||||
emptyLabel: '',
|
||||
untitledLabel: '',
|
||||
st
|
||||
}),
|
||||
graphNodeId: graphNode ? String(graphNode.id) : undefined
|
||||
title: isParentGroupNode
|
||||
? parentNode?.title || ''
|
||||
: resolveNodeDisplayName(graphNode, {
|
||||
emptyLabel: '',
|
||||
untitledLabel: '',
|
||||
st
|
||||
}),
|
||||
graphNodeId: graphNode ? String(graphNode.id) : undefined,
|
||||
isParentGroupNode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,93 +78,56 @@ function getOrCreateGroup(
|
||||
return entry.cards
|
||||
}
|
||||
|
||||
function processPromptError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
if (!executionStore.lastPromptError) return
|
||||
|
||||
const error = executionStore.lastPromptError
|
||||
const groupTitle = error.message
|
||||
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
|
||||
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
|
||||
|
||||
cards.set('__prompt__', {
|
||||
id: '__prompt__',
|
||||
title: groupTitle,
|
||||
errors: [
|
||||
{
|
||||
message: isKnown
|
||||
? t(`rightSidePanel.promptErrors.${error.type}.desc`)
|
||||
: error.message
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function processNodeErrors(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>
|
||||
) {
|
||||
if (!executionStore.lastNodeErrors) return
|
||||
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
executionStore.lastNodeErrors
|
||||
)) {
|
||||
const cards = getOrCreateGroup(groupsMap, nodeError.class_type, 1)
|
||||
if (!cards.has(nodeId)) {
|
||||
const nodeInfo = resolveNodeInfo(nodeId)
|
||||
cards.set(nodeId, {
|
||||
id: `node-${nodeId}`,
|
||||
title: nodeError.class_type,
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
errors: []
|
||||
})
|
||||
}
|
||||
const card = cards.get(nodeId)
|
||||
if (!card) continue
|
||||
card.errors.push(
|
||||
...nodeError.errors.map((e) => ({
|
||||
message: e.message,
|
||||
details: e.details ?? undefined
|
||||
}))
|
||||
)
|
||||
function createErrorCard(
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string
|
||||
): ErrorCardData {
|
||||
const nodeInfo = resolveNodeInfo(nodeId)
|
||||
return {
|
||||
id: `${idPrefix}-${nodeId}`,
|
||||
title: classType,
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId) && !nodeInfo.isParentGroupNode,
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
function processExecutionError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
executionStore: ReturnType<typeof useExecutionStore>
|
||||
) {
|
||||
if (!executionStore.lastExecutionError) return
|
||||
/**
|
||||
* In single-node mode, regroup cards by error message instead of class_type.
|
||||
* This lets the user see "what kinds of errors this node has" at a glance.
|
||||
*/
|
||||
function regroupByErrorMessage(
|
||||
groupsMap: Map<string, GroupEntry>
|
||||
): Map<string, GroupEntry> {
|
||||
const allCards = Array.from(groupsMap.values()).flatMap((g) =>
|
||||
Array.from(g.cards.values())
|
||||
)
|
||||
|
||||
const e = executionStore.lastExecutionError
|
||||
const nodeId = String(e.node_id)
|
||||
const cards = getOrCreateGroup(groupsMap, e.node_type, 1)
|
||||
const cardErrorPairs = allCards.flatMap((card) =>
|
||||
card.errors.map((error) => ({ card, error }))
|
||||
)
|
||||
|
||||
if (!cards.has(nodeId)) {
|
||||
const nodeInfo = resolveNodeInfo(nodeId)
|
||||
cards.set(nodeId, {
|
||||
id: `exec-${nodeId}`,
|
||||
title: e.node_type,
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
errors: []
|
||||
})
|
||||
const messageMap = new Map<string, GroupEntry>()
|
||||
for (const { card, error } of cardErrorPairs) {
|
||||
addCardErrorToGroup(messageMap, card, error)
|
||||
}
|
||||
const card = cards.get(nodeId)
|
||||
if (!card) return
|
||||
card.errors.push({
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true
|
||||
})
|
||||
|
||||
return messageMap
|
||||
}
|
||||
|
||||
function addCardErrorToGroup(
|
||||
messageMap: Map<string, GroupEntry>,
|
||||
card: ErrorCardData,
|
||||
error: ErrorItem
|
||||
) {
|
||||
const group = getOrCreateGroup(messageMap, error.message, 1)
|
||||
if (!group.has(card.id)) {
|
||||
group.set(card.id, { ...card, errors: [] })
|
||||
}
|
||||
group.get(card.id)?.errors.push(error)
|
||||
}
|
||||
|
||||
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
@@ -157,27 +143,14 @@ function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
})
|
||||
}
|
||||
|
||||
function buildErrorGroups(
|
||||
executionStore: ReturnType<typeof useExecutionStore>,
|
||||
t: (key: string) => string
|
||||
): ErrorGroup[] {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
processPromptError(groupsMap, executionStore, t)
|
||||
processNodeErrors(groupsMap, executionStore)
|
||||
processExecutionError(groupsMap, executionStore)
|
||||
|
||||
return toSortedGroups(groupsMap)
|
||||
}
|
||||
|
||||
function searchErrorGroups(groups: ErrorGroup[], query: string): ErrorGroup[] {
|
||||
function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
if (!query) return groups
|
||||
|
||||
const searchableList: ErrorSearchItem[] = []
|
||||
for (let gi = 0; gi < groups.length; gi++) {
|
||||
const group = groups[gi]!
|
||||
const group = groups[gi]
|
||||
for (let ci = 0; ci < group.cards.length; ci++) {
|
||||
const card = group.cards[ci]!
|
||||
const card = group.cards[ci]
|
||||
searchableList.push({
|
||||
groupIndex: gi,
|
||||
cardIndex: ci,
|
||||
@@ -219,18 +192,214 @@ export function useErrorGroups(
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionStore = useExecutionStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
|
||||
const errorGroups = computed<ErrorGroup[]>(() =>
|
||||
buildErrorGroups(executionStore, t)
|
||||
const selectedNodeInfo = computed(() => {
|
||||
const items = canvasStore.selectedItems
|
||||
const nodeIds = new Set<string>()
|
||||
const containerIds = new Set<string>()
|
||||
|
||||
for (const item of items) {
|
||||
if (!isLGraphNode(item)) continue
|
||||
nodeIds.add(String(item.id))
|
||||
if (item instanceof SubgraphNode || isGroupNode(item)) {
|
||||
containerIds.add(String(item.id))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeIds: nodeIds.size > 0 ? nodeIds : null,
|
||||
containerIds
|
||||
}
|
||||
})
|
||||
|
||||
const isSingleNodeSelected = computed(
|
||||
() =>
|
||||
selectedNodeInfo.value.nodeIds?.size === 1 &&
|
||||
selectedNodeInfo.value.containerIds.size === 0
|
||||
)
|
||||
|
||||
const errorNodeCache = computed(() => {
|
||||
const map = new Map<string, LGraphNode>()
|
||||
for (const execId of executionStore.allErrorExecutionIds) {
|
||||
const node = getNodeByExecutionId(app.rootGraph, execId)
|
||||
if (node) map.set(execId, node)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function isErrorInSelection(executionNodeId: string): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
const graphNode = errorNodeCache.value.get(executionNodeId)
|
||||
if (graphNode && nodeIds.has(String(graphNode.id))) return true
|
||||
|
||||
for (const containerId of selectedNodeInfo.value.containerIds) {
|
||||
if (executionNodeId.startsWith(`${containerId}:`)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function addNodeErrorToGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string,
|
||||
errors: ErrorItem[],
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (filterBySelection && !isErrorInSelection(nodeId)) return
|
||||
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
|
||||
const cards = getOrCreateGroup(groupsMap, groupKey, 1)
|
||||
if (!cards.has(nodeId)) {
|
||||
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
|
||||
}
|
||||
cards.get(nodeId)?.errors.push(...errors)
|
||||
}
|
||||
|
||||
function processPromptError(groupsMap: Map<string, GroupEntry>) {
|
||||
if (selectedNodeInfo.value.nodeIds || !executionStore.lastPromptError)
|
||||
return
|
||||
|
||||
const error = executionStore.lastPromptError
|
||||
const groupTitle = error.message
|
||||
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
|
||||
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
|
||||
|
||||
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
|
||||
cards.set(PROMPT_CARD_ID, {
|
||||
id: PROMPT_CARD_ID,
|
||||
title: groupTitle,
|
||||
errors: [
|
||||
{
|
||||
message: isKnown
|
||||
? t(`rightSidePanel.promptErrors.${error.type}.desc`)
|
||||
: error.message
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function processNodeErrors(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionStore.lastNodeErrors) return
|
||||
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
executionStore.lastNodeErrors
|
||||
)) {
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
nodeId,
|
||||
nodeError.class_type,
|
||||
'node',
|
||||
nodeError.errors.map((e) => ({
|
||||
message: e.message,
|
||||
details: e.details ?? undefined
|
||||
})),
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function processExecutionError(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionStore.lastExecutionError) return
|
||||
|
||||
const e = executionStore.lastExecutionError
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
String(e.node_id),
|
||||
e.node_type,
|
||||
'exec',
|
||||
[
|
||||
{
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true
|
||||
}
|
||||
],
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
|
||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
processPromptError(groupsMap)
|
||||
processNodeErrors(groupsMap)
|
||||
processExecutionError(groupsMap)
|
||||
|
||||
return toSortedGroups(groupsMap)
|
||||
})
|
||||
|
||||
const tabErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
processPromptError(groupsMap)
|
||||
processNodeErrors(groupsMap, true)
|
||||
processExecutionError(groupsMap, true)
|
||||
|
||||
return isSingleNodeSelected.value
|
||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||
: toSortedGroups(groupsMap)
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
const query = searchQuery.value.trim()
|
||||
return searchErrorGroups(errorGroups.value, query)
|
||||
return searchErrorGroups(tabErrorGroups.value, query)
|
||||
})
|
||||
|
||||
const groupedErrorMessages = computed<string[]>(() => {
|
||||
const messages = new Set<string>()
|
||||
for (const group of allErrorGroups.value) {
|
||||
for (const card of group.cards) {
|
||||
for (const err of card.errors) {
|
||||
messages.add(err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(messages)
|
||||
})
|
||||
|
||||
/**
|
||||
* When an external trigger (e.g. "See Error" button in SectionWidgets)
|
||||
* sets focusedErrorNodeId, expand only the group containing the target
|
||||
* node and collapse all others so the user sees the relevant errors
|
||||
* immediately.
|
||||
*/
|
||||
function expandFocusedErrorGroup(graphNodeId: string | null) {
|
||||
if (!graphNodeId) return
|
||||
const prefix = `${graphNodeId}:`
|
||||
for (const group of allErrorGroups.value) {
|
||||
const hasMatch = group.cards.some(
|
||||
(card) =>
|
||||
card.graphNodeId === graphNodeId ||
|
||||
(card.nodeId?.startsWith(prefix) ?? false)
|
||||
)
|
||||
collapseState[group.title] = !hasMatch
|
||||
}
|
||||
rightSidePanelStore.focusedErrorNodeId = null
|
||||
}
|
||||
|
||||
watch(() => rightSidePanelStore.focusedErrorNodeId, expandFocusedErrorGroup, {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
return {
|
||||
errorGroups,
|
||||
filteredGroups
|
||||
allErrorGroups,
|
||||
tabErrorGroups,
|
||||
filteredGroups,
|
||||
collapseState,
|
||||
isSingleNodeSelected,
|
||||
errorNodeCache,
|
||||
groupedErrorMessages
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,15 @@ import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
|
||||
import type {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -110,11 +108,26 @@ const targetNode = computed<LGraphNode | null>(() => {
|
||||
return allSameNode ? widgets.value[0].node : null
|
||||
})
|
||||
|
||||
const nodeHasError = computed(() => {
|
||||
if (canvasStore.selectedItems.length > 0 || !targetNode.value) return false
|
||||
const hasDirectError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
|
||||
})
|
||||
|
||||
const hasContainerInternalError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
const isContainer =
|
||||
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
|
||||
if (!isContainer) return false
|
||||
|
||||
return executionStore.hasInternalErrorForNode(targetNode.value.id)
|
||||
})
|
||||
|
||||
const nodeHasError = computed(() => {
|
||||
if (!targetNode.value) return false
|
||||
if (canvasStore.selectedItems.length === 1) return false
|
||||
return hasDirectError.value || hasContainerInternalError.value
|
||||
})
|
||||
|
||||
const parentGroup = computed<LGraphGroup | null>(() => {
|
||||
if (!targetNode.value || !getNodeParentGroup) return null
|
||||
return getNodeParentGroup(targetNode.value)
|
||||
|
||||
@@ -2,9 +2,15 @@ import { nextTick } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { parseNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
async function navigateToGraph(targetGraph: LGraph) {
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -29,20 +35,44 @@ async function navigateToGraph(targetGraph: LGraph) {
|
||||
export function useFocusNode() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
async function focusNode(nodeId: string) {
|
||||
/* Locate and focus a node on the canvas by its execution ID. */
|
||||
async function focusNode(
|
||||
nodeId: string,
|
||||
executionIdMap?: Map<string, LGraphNode>
|
||||
) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
// For group node internals, locate the parent group node instead
|
||||
const parts = parseNodeExecutionId(nodeId)
|
||||
const parentId = parts && parts.length > 1 ? String(parts[0]) : null
|
||||
const parentNode = parentId
|
||||
? app.rootGraph.getNodeById(Number(parentId))
|
||||
: null
|
||||
|
||||
if (parentNode && isGroupNode(parentNode) && parentNode.graph) {
|
||||
await navigateToGraph(parentNode.graph as LGraph)
|
||||
canvasStore.canvas?.animateToBounds(parentNode.boundingRect)
|
||||
return
|
||||
}
|
||||
|
||||
const graphNode = executionIdMap
|
||||
? executionIdMap.get(nodeId)
|
||||
: getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
canvasStore.canvas?.animateToBounds(graphNode.boundingRect)
|
||||
}
|
||||
|
||||
async function enterSubgraph(nodeId: string) {
|
||||
async function enterSubgraph(
|
||||
nodeId: string,
|
||||
executionIdMap?: Map<string, LGraphNode>
|
||||
) {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
const graphNode = executionIdMap
|
||||
? executionIdMap.get(nodeId)
|
||||
: getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
if (!graphNode?.graph) return
|
||||
|
||||
await navigateToGraph(graphNode.graph as LGraph)
|
||||
|
||||
@@ -1361,6 +1361,7 @@
|
||||
"Execution": "Execution",
|
||||
"PLY": "PLY",
|
||||
"Workspace": "Workspace",
|
||||
"Error System": "Error System",
|
||||
"Other": "Other",
|
||||
"Secrets": "Secrets",
|
||||
"Error System": "Error System"
|
||||
@@ -2986,6 +2987,7 @@
|
||||
"hideAdvancedInputsButton": "Hide advanced inputs",
|
||||
"errors": "Errors",
|
||||
"noErrors": "No errors",
|
||||
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
|
||||
"enterSubgraph": "Enter subgraph",
|
||||
"seeError": "See Error",
|
||||
"promptErrors": {
|
||||
@@ -2999,9 +3001,14 @@
|
||||
"errorHelp": "For more help, {github} or {support}",
|
||||
"errorHelpGithub": "submit a GitHub issue",
|
||||
"errorHelpSupport": "contact our support",
|
||||
"contactSupportFailed": "Unable to open contact support. Please try again later.",
|
||||
"resetToDefault": "Reset to default",
|
||||
"resetAllParameters": "Reset all parameters"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERRORS | {count} ERROR | {count} ERRORS",
|
||||
"seeErrors": "See Errors"
|
||||
},
|
||||
"help": {
|
||||
"recentReleases": "Recent releases",
|
||||
"helpCenterMenu": "Help Center Menu"
|
||||
|
||||
@@ -134,11 +134,8 @@
|
||||
as-child
|
||||
>
|
||||
<button
|
||||
v-if="hasAnyError"
|
||||
@click.stop="
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') &&
|
||||
useRightSidePanelStore().openPanel('error')
|
||||
"
|
||||
v-if="hasAnyError && showErrorsTabEnabled"
|
||||
@click.stop="useRightSidePanelStore().openPanel('errors')"
|
||||
>
|
||||
<span>{{ t('g.error') }}</span>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
@@ -310,6 +307,10 @@ const hasAnyError = computed((): boolean => {
|
||||
)
|
||||
})
|
||||
|
||||
const showErrorsTabEnabled = computed(() =>
|
||||
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
)
|
||||
|
||||
const displayHeader = computed(() => nodeData.titleMode !== TitleMode.NO_TITLE)
|
||||
|
||||
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
|
||||
|
||||
@@ -70,7 +70,6 @@ import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
|
||||
import type { ExtensionManager } from '@/types/extensionTypes'
|
||||
@@ -712,13 +711,11 @@ export class ComfyApp {
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
}
|
||||
} else if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||
useExecutionStore().showErrorOverlay()
|
||||
} else {
|
||||
useDialogService().showExecutionErrorDialog(detail)
|
||||
}
|
||||
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||
this.canvas.deselectAll()
|
||||
useRightSidePanelStore().openPanel('errors')
|
||||
}
|
||||
this.canvas.draw(true, true)
|
||||
})
|
||||
|
||||
@@ -1465,7 +1462,10 @@ export class ComfyApp {
|
||||
{}) as MissingNodeTypeExtraInfo
|
||||
const missingNodeType = createMissingNodeTypeFromError(extraInfo)
|
||||
this.showMissingNodesError([missingNodeType])
|
||||
} else {
|
||||
} else if (
|
||||
!useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab') ||
|
||||
!(error instanceof PromptExecutionError)
|
||||
) {
|
||||
useDialogService().showErrorDialog(error, {
|
||||
title: t('errorDialog.promptExecutionError'),
|
||||
reportType: 'promptExecutionError'
|
||||
@@ -1500,11 +1500,8 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selection and open the error panel so the user can immediately
|
||||
// see the error details without extra clicks.
|
||||
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||
this.canvas.deselectAll()
|
||||
useRightSidePanelStore().openPanel('errors')
|
||||
executionStore.showErrorOverlay()
|
||||
}
|
||||
this.canvas.draw(true, true)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { isEmpty } from 'es-toolkit/compat'
|
||||
|
||||
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -49,6 +48,33 @@ interface QueuedPrompt {
|
||||
workflow?: ComfyWorkflow
|
||||
}
|
||||
|
||||
interface CloudValidationError {
|
||||
error?: { type?: string; message?: string; details?: string } | string
|
||||
node_errors?: Record<NodeId, NodeError>
|
||||
}
|
||||
|
||||
function isCloudValidationError(value: unknown): value is CloudValidationError {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
('error' in value || 'node_errors' in value)
|
||||
)
|
||||
}
|
||||
|
||||
function tryExtractValidationError(
|
||||
exceptionMessage: string
|
||||
): CloudValidationError | null {
|
||||
const jsonStart = exceptionMessage.indexOf('{')
|
||||
if (jsonStart === -1) return null
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(exceptionMessage.substring(jsonStart))
|
||||
return isCloudValidationError(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (node?.isSubgraphNode()) return node.subgraph
|
||||
@@ -291,6 +317,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
lastExecutionError.value = null
|
||||
lastPromptError.value = null
|
||||
lastNodeErrors.value = null
|
||||
isErrorOverlayOpen.value = false
|
||||
activePromptId.value = e.detail.prompt_id
|
||||
queuedPrompts.value[activePromptId.value] ??= { nodes: {} }
|
||||
clearInitializationByPromptId(activePromptId.value)
|
||||
@@ -392,7 +420,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
|
||||
function handleExecutionError(e: CustomEvent<ExecutionErrorWsMessage>) {
|
||||
lastExecutionError.value = e.detail
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackExecutionError({
|
||||
jobId: e.detail.prompt_id,
|
||||
@@ -400,11 +427,73 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
nodeType: e.detail.node_type,
|
||||
error: e.detail.exception_message
|
||||
})
|
||||
|
||||
// Cloud wraps validation errors (400) in exception_message as embedded JSON.
|
||||
if (handleCloudValidationError(e.detail)) return
|
||||
}
|
||||
|
||||
// Service-level errors (e.g. "Job has stagnated") have no associated node.
|
||||
// Route them as prompt errors
|
||||
if (handleServiceLevelError(e.detail)) return
|
||||
|
||||
// OSS path / Cloud fallback (real runtime errors)
|
||||
lastExecutionError.value = e.detail
|
||||
clearInitializationByPromptId(e.detail.prompt_id)
|
||||
resetExecutionState(e.detail.prompt_id)
|
||||
}
|
||||
|
||||
function handleServiceLevelError(detail: ExecutionErrorWsMessage): boolean {
|
||||
const nodeId = detail.node_id
|
||||
if (nodeId !== null && nodeId !== undefined && String(nodeId) !== '')
|
||||
return false
|
||||
|
||||
clearInitializationByPromptId(detail.prompt_id)
|
||||
resetExecutionState(detail.prompt_id)
|
||||
lastPromptError.value = {
|
||||
type: detail.exception_type ?? 'error',
|
||||
message: detail.exception_type
|
||||
? `${detail.exception_type}: ${detail.exception_message}`
|
||||
: (detail.exception_message ?? ''),
|
||||
details: detail.traceback?.join('\n') ?? ''
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function handleCloudValidationError(
|
||||
detail: ExecutionErrorWsMessage
|
||||
): boolean {
|
||||
const extracted = tryExtractValidationError(detail.exception_message)
|
||||
if (!extracted) return false
|
||||
|
||||
const { error, node_errors } = extracted
|
||||
const hasNodeErrors = node_errors && Object.keys(node_errors).length > 0
|
||||
|
||||
let promptError = null
|
||||
if (!hasNodeErrors) {
|
||||
if (error && typeof error === 'object') {
|
||||
promptError = {
|
||||
type: error.type ?? 'error',
|
||||
message: error.message ?? '',
|
||||
details: error.details ?? ''
|
||||
}
|
||||
} else if (typeof error === 'string') {
|
||||
promptError = { type: 'error', message: error, details: '' }
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
clearInitializationByPromptId(detail.prompt_id)
|
||||
resetExecutionState(detail.prompt_id)
|
||||
|
||||
if (hasNodeErrors) {
|
||||
lastNodeErrors.value = node_errors
|
||||
} else if (promptError) {
|
||||
lastPromptError.value = promptError
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification handler used for frontend/cloud initialization tracking.
|
||||
* Marks a prompt as initializing when cloud notifies it is waiting for a machine.
|
||||
@@ -654,7 +743,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
/** Whether any node validation errors are present */
|
||||
const hasNodeError = computed(
|
||||
() => !!lastNodeErrors.value && !isEmpty(lastNodeErrors.value)
|
||||
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
|
||||
)
|
||||
|
||||
/** Whether any error (node validation, runtime execution, or prompt-level) is present */
|
||||
@@ -662,12 +751,44 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
() => hasExecutionError.value || hasPromptError.value || hasNodeError.value
|
||||
)
|
||||
|
||||
/** Pre-computed Set of graph node IDs (as strings) that have errors. */
|
||||
const allErrorExecutionIds = computed<string[]>(() => {
|
||||
const ids: string[] = []
|
||||
if (lastNodeErrors.value) {
|
||||
ids.push(...Object.keys(lastNodeErrors.value))
|
||||
}
|
||||
if (lastExecutionError.value) {
|
||||
const nodeId = lastExecutionError.value.node_id
|
||||
if (nodeId !== null && nodeId !== undefined) {
|
||||
ids.push(String(nodeId))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
/** Total count of all individual errors */
|
||||
const totalErrorCount = computed(() => {
|
||||
let count = 0
|
||||
if (lastPromptError.value) {
|
||||
count += 1
|
||||
}
|
||||
if (lastNodeErrors.value) {
|
||||
for (const nodeError of Object.values(lastNodeErrors.value)) {
|
||||
count += nodeError.errors.length
|
||||
}
|
||||
}
|
||||
if (lastExecutionError.value) {
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
/** Pre-computed Set of graph node IDs (as strings) that have errors in the current graph scope. */
|
||||
const activeGraphErrorNodeIds = computed<Set<string>>(() => {
|
||||
const ids = new Set<string>()
|
||||
if (!app.rootGraph) return ids
|
||||
|
||||
const activeGraph = useCanvasStore().currentGraph ?? app.rootGraph
|
||||
// Fall back to rootGraph when currentGraph hasn't been initialized yet
|
||||
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
|
||||
|
||||
if (lastNodeErrors.value) {
|
||||
for (const executionId of Object.keys(lastNodeErrors.value)) {
|
||||
@@ -689,6 +810,21 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
return ids
|
||||
})
|
||||
|
||||
function hasInternalErrorForNode(nodeId: string | number): boolean {
|
||||
const prefix = `${nodeId}:`
|
||||
return allErrorExecutionIds.value.some((id) => id.startsWith(prefix))
|
||||
}
|
||||
|
||||
const isErrorOverlayOpen = ref(false)
|
||||
|
||||
function showErrorOverlay() {
|
||||
isErrorOverlayOpen.value = true
|
||||
}
|
||||
|
||||
function dismissErrorOverlay() {
|
||||
isErrorOverlayOpen.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
isIdle,
|
||||
clientId,
|
||||
@@ -698,6 +834,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
lastExecutionError,
|
||||
lastPromptError,
|
||||
hasAnyError,
|
||||
allErrorExecutionIds,
|
||||
totalErrorCount,
|
||||
lastExecutionErrorNodeId,
|
||||
executingNodeId,
|
||||
executingNodeIds,
|
||||
@@ -730,6 +868,10 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
// Node error lookup helpers
|
||||
getNodeErrors,
|
||||
slotHasError,
|
||||
activeGraphErrorNodeIds
|
||||
hasInternalErrorForNode,
|
||||
activeGraphErrorNodeIds,
|
||||
isErrorOverlayOpen,
|
||||
showErrorOverlay,
|
||||
dismissErrorOverlay
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
export type RightSidePanelTab =
|
||||
| 'error'
|
||||
| 'parameters'
|
||||
| 'nodes'
|
||||
| 'settings'
|
||||
|
||||
Reference in New Issue
Block a user