Compare commits

..

1 Commits

Author SHA1 Message Date
Jin Yi
a6e5ff1b37 fix: load API format workflows with missing node types 2026-03-10 12:13:56 +09:00
157 changed files with 1664 additions and 7099 deletions

View File

@@ -38,9 +38,6 @@ TEST_COMFYUI_DIR=/home/ComfyUI
ALGOLIA_APP_ID=4E0RO38HS8
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org

View File

@@ -4,8 +4,6 @@ name: 'CI: Tests Storybook'
on:
workflow_dispatch: # Allow manual triggering
pull_request:
push:
branches: [main]
jobs:
# Post starting comment for non-forked PRs
@@ -140,29 +138,6 @@ jobs:
"${{ github.head_ref }}" \
"completed"
# Deploy Storybook to production URL on main branch push
deploy-production:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build Storybook
run: pnpm build-storybook
- name: Deploy to Cloudflare Pages (production)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
npx wrangler@^4.0.0 pages deploy storybook-static \
--project-name=comfy-storybook \
--branch=main
# Update comment with Chromatic URLs for version-bump branches
update-comment-with-chromatic:
needs: [chromatic-deployment, deploy-and-comment]

View File

@@ -46,30 +46,18 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
ACTION: ${{ github.event.action }}
LABEL_NAME: ${{ github.event.label.name }}
PR_LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }}
run: |
if [ "${EVENT_NAME}" = "pull_request" ]; then
REF="${PR_HEAD_SHA}"
BRANCH="${PR_HEAD_REF}"
# Derive variant from all PR labels (default to cpu for frontend-only previews)
VARIANT="cpu"
echo "${PR_LABELS}" | grep -q '"preview-gpu"' && VARIANT="gpu"
else
REF="${GITHUB_SHA}"
BRANCH="${GITHUB_REF_NAME}"
PR_NUMBER=""
VARIANT=""
fi
payload="$(jq -nc \
--arg ref "${REF}" \
--arg branch "${BRANCH}" \
--arg pr_number "${PR_NUMBER}" \
--arg variant "${VARIANT}" \
'{ref: $ref, branch: $branch, pr_number: $pr_number, variant: $variant}')"
'{ref: $ref, branch: $branch}')"
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
- name: Dispatch to cloud repo

View File

@@ -1,39 +0,0 @@
---
# Dispatches a frontend-preview-cleanup event to the cloud repo when a
# frontend PR with a preview label is closed or has its preview label
# removed. The cloud repo handles the actual environment teardown.
#
# This is fire-and-forget — it does NOT wait for the cloud workflow to
# complete. Status is visible in the cloud repo's Actions tab.
name: Cloud Frontend Preview Cleanup Dispatch
on:
pull_request:
types: [closed, unlabeled]
permissions: {}
jobs:
dispatch:
# Only dispatch when:
# - PR closed AND had a preview label
# - Preview label specifically removed
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
((github.event.action == 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))) ||
(github.event.action == 'unlabeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)))
runs-on: ubuntu-latest
steps:
- name: Dispatch to cloud repo
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
repository: Comfy-Org/cloud
event-type: frontend-preview-cleanup
client-payload: >-
{"pr_number": "${{ github.event.pull_request.number }}"}

View File

@@ -58,7 +58,7 @@ export const withTheme = (Story: StoryFn, context: StoryContext) => {
document.documentElement.classList.remove('dark-theme')
document.body.classList.remove('dark-theme')
}
document.body.classList.add('font-inter')
document.body.classList.add('[&_*]:!font-inter')
return Story(context.args, context)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

1
global.d.ts vendored
View File

@@ -35,7 +35,6 @@ interface Window {
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
posthog_debug?: boolean
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

File diff suppressed because one or more lines are too long

View File

@@ -20,10 +20,6 @@ const config: KnipConfig = {
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
'packages/shared-frontend-utils': {
project: ['src/**/*.{js,ts}'],
entry: ['src/formatUtil.ts', 'src/networkUtil.ts']
},
'packages/registry-types': {
project: ['src/**/*.{js,ts}']
}
@@ -36,9 +32,7 @@ const config: KnipConfig = {
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons',
// Used by lucideStrokePlugin.js (CSS @plugin)
'@iconify/utils'
'@primevue/icons'
],
ignore: [
// Auto generated manager types
@@ -53,9 +47,7 @@ const config: KnipConfig = {
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Loaded via @plugin directive in CSS, not detected by knip
'packages/design-system/src/css/lucideStrokePlugin.js'
'.agents/checks/eslint.strict.config.js'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -1,51 +0,0 @@
/* Pre-Vue splash loader — colors set by inline script */
#splash-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
contain: strict;
}
#splash-loader svg {
width: min(200px, 50vw);
height: auto;
transform: translateZ(0);
}
#splash-loader .wave-group {
animation: splash-rise 4s ease-in-out infinite alternate;
will-change: transform;
transform: translateZ(0);
}
#splash-loader .wave-path {
animation: splash-wave 1.2s linear infinite;
will-change: transform;
transform: translateZ(0);
}
@keyframes splash-rise {
from {
transform: translateY(280px);
}
to {
transform: translateY(-80px);
}
}
@keyframes splash-wave {
from {
transform: translateX(0);
}
to {
transform: translateX(-880px);
}
}
@media (prefers-reduced-motion: reduce) {
#splash-loader .wave-group,
#splash-loader .wave-path {
animation: none;
}
#splash-loader .wave-group {
transform: translateY(-80px);
}
}

View File

@@ -2,13 +2,20 @@
<router-view />
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
<div
v-if="isLoading"
class="pointer-events-none fixed inset-0 z-1200 flex items-center justify-center"
>
<LogoComfyWaveLoader size="xl" color="yellow" />
</div>
</template>
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, watch } from 'vue'
import { computed, onMounted } from 'vue'
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -24,16 +31,6 @@ app.extensionManager = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
watch(
isLoading,
(loading, prevLoading) => {
if (prevLoading && !loading) {
document.getElementById('splash-loader')?.remove()
}
},
{ flush: 'post' }
)
const showContextMenu = (event: MouseEvent) => {
const { target } = event
switch (true) {

View File

@@ -1,7 +1,6 @@
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
@@ -43,9 +42,6 @@ export function useAppSetDefaultView() {
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})
closeDialog()
showAppliedDialog(openAsApp)
}
@@ -58,7 +54,6 @@ export function useAppSetDefaultView() {
appliedAsApp,
onViewApp: () => {
closeAppliedDialog()
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
setMode('app')
},
onExitToWorkflow: () => {

View File

@@ -54,12 +54,11 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<i class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
<i class="size-5" :class="item.icon" />
{{ item.label }}
<div
v-else-if="item.new"
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
v-if="item.new"
class="ml-auto flex items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
v-text="t('contextMenu.new')"
/>
</DropdownMenuItem>

View File

@@ -27,7 +27,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
const itemClass = computed(() =>
cn(
'm-1 flex cursor-pointer items-center-safe gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
'm-1 flex cursor-pointer gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
itemProp
)
)

View File

@@ -1,160 +0,0 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref, toRefs } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import ScrubableNumberInput from './ScrubableNumberInput.vue'
type StoryArgs = ComponentPropsAndSlots<typeof ScrubableNumberInput>
const meta: Meta<StoryArgs> = {
title: 'Components/Input/Number',
component: ScrubableNumberInput,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
min: { control: 'number' },
max: { control: 'number' },
step: { control: 'number' },
disabled: { control: 'boolean' },
hideButtons: { control: 'boolean' }
},
args: {
min: 0,
max: 100,
step: 1,
disabled: false,
hideButtons: false
},
decorators: [
(story) => ({
components: { story },
template: '<div class="w-60"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { ScrubableNumberInput },
setup() {
const { min, max, step, disabled, hideButtons } = toRefs(args)
const value = ref(42)
return { value, min, max, step, disabled, hideButtons }
},
template:
'<ScrubableNumberInput v-model="value" :min :max :step :disabled :hideButtons />'
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { ScrubableNumberInput },
setup() {
const { disabled } = toRefs(args)
const value = ref(50)
return { value, disabled }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" :disabled />'
})
}
export const AtMinimum: Story = {
render: () => ({
components: { ScrubableNumberInput },
setup() {
const value = ref(0)
return { value }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" />'
})
}
export const AtMaximum: Story = {
render: () => ({
components: { ScrubableNumberInput },
setup() {
const value = ref(100)
return { value }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" />'
})
}
export const FloatPrecision: Story = {
args: { min: 0, max: 1, step: 0.01 },
render: (args) => ({
components: { ScrubableNumberInput },
setup() {
const { min, max, step } = toRefs(args)
const value = ref(0.75)
return { value, min, max, step }
},
template:
'<ScrubableNumberInput v-model="value" :min :max :step display-value="0.75" />'
})
}
export const LargeNumber: Story = {
render: () => ({
components: { ScrubableNumberInput },
setup() {
const value = ref(1809000312992)
return { value }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="Number.MAX_SAFE_INTEGER" :step="1" />'
})
}
export const HiddenButtons: Story = {
args: { hideButtons: true },
render: (args) => ({
components: { ScrubableNumberInput },
setup() {
const { hideButtons } = toRefs(args)
const value = ref(42)
return { value, hideButtons }
},
template:
'<ScrubableNumberInput v-model="value" :min="0" :max="100" :step="1" :hideButtons />'
})
}
export const WithControlButton: Story = {
render: () => ({
components: { ScrubableNumberInput, Button, Popover },
setup() {
const value = ref(1809000312992)
return { value }
},
template: `
<ScrubableNumberInput v-model="value" :min="0" :max="Number.MAX_SAFE_INTEGER" :step="1">
<Popover>
<template #button>
<Button
variant="textonly"
size="sm"
class="h-4 w-7 self-center rounded-xl bg-primary-background/30 p-0 hover:bg-primary-background-hover/30"
>
<i class="icon-[lucide--shuffle] w-full text-xs text-primary-background" />
</Button>
</template>
<div class="p-4 text-sm">Control popover content</div>
</Popover>
</ScrubableNumberInput>
`
})
}

View File

@@ -33,20 +33,19 @@
spellcheck="false"
@blur="handleBlur"
@keyup.enter="handleBlur"
@keydown.up.prevent="updateValueBy(step)"
@keydown.down.prevent="updateValueBy(-step)"
@keydown.page-up.prevent="updateValueBy(10 * step)"
@keydown.page-down.prevent="updateValueBy(-10 * step)"
@dragstart.prevent
/>
<div
ref="swipeElement"
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize touch-pan-y',
'absolute inset-0 z-10 cursor-ew-resize',
textEdit && 'pointer-events-none hidden'
)
"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="resetDrag"
/>
</div>
<slot />
@@ -66,7 +65,7 @@
</template>
<script setup lang="ts">
import { onClickOutside, usePointerSwipe, whenever } from '@vueuse/core'
import { onClickOutside } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -74,8 +73,8 @@ import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
min = -Number.MAX_VALUE,
max = Number.MAX_VALUE,
min,
max,
step = 1,
disabled = false,
hideButtons = false,
@@ -97,7 +96,6 @@ const modelValue = defineModel<number>({ default: 0 })
const container = useTemplateRef<HTMLDivElement>('container')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const swipeElement = useTemplateRef('swipeElement')
const textEdit = ref(false)
onClickOutside(container, () => {
@@ -105,11 +103,21 @@ onClickOutside(container, () => {
})
function clamp(value: number): number {
return Math.min(max, Math.max(min, value))
const lo = min ?? -Infinity
const hi = max ?? Infinity
return Math.min(hi, Math.max(lo, value))
}
const canDecrement = computed(() => modelValue.value > min && !disabled)
const canIncrement = computed(() => modelValue.value < max && !disabled)
const canDecrement = computed(
() => modelValue.value > (min ?? -Infinity) && !disabled
)
const canIncrement = computed(
() => modelValue.value < (max ?? Infinity) && !disabled
)
const dragging = ref(false)
const dragDelta = ref(0)
const hasDragged = ref(false)
function handleBlur(e: Event) {
const target = e.target as HTMLInputElement
@@ -127,27 +135,41 @@ function handleBlur(e: Event) {
textEdit.value = false
}
let dragDelta = 0
function handlePointerUp() {
if (isSwiping.value) return
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return
if (disabled) return
const target = e.target as HTMLElement
target.setPointerCapture(e.pointerId)
dragging.value = true
dragDelta.value = 0
hasDragged.value = false
}
const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
onSwipeEnd: () => (dragDelta = 0)
})
function handlePointerMove(e: PointerEvent) {
if (!dragging.value) return
dragDelta.value += e.movementX
const steps = (dragDelta.value / 10) | 0
if (steps === 0) return
hasDragged.value = true
const unclipped = modelValue.value + steps * step
dragDelta.value %= 10
modelValue.value = clamp(unclipped)
}
whenever(distanceX, () => {
if (disabled) return
const delta = ((distanceX.value - dragDelta) / 10) | 0
dragDelta += delta * 10
modelValue.value = clamp(modelValue.value - delta * step)
})
function handlePointerUp() {
if (!dragging.value) return
function updateValueBy(delta: number) {
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
if (!hasDragged.value) {
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
resetDrag()
}
function resetDrag() {
dragging.value = false
dragDelta.value = 0
}
</script>

View File

@@ -1,15 +1,9 @@
<template>
<div class="system-stats">
<div class="mb-6">
<div class="mb-4 flex items-center gap-2">
<h2 class="text-2xl font-semibold">
{{ $t('g.systemInfo') }}
</h2>
<Button variant="secondary" @click="copySystemInfo">
<i class="pi pi-copy" />
{{ $t('g.copySystemInfo') }}
</Button>
</div>
<h2 class="mb-4 text-2xl font-semibold">
{{ $t('g.systemInfo') }}
</h2>
<div class="grid grid-cols-2 gap-2">
<template v-for="col in systemColumns" :key="col.field">
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
@@ -52,8 +46,6 @@ import TabView from 'primevue/tabview'
import { computed } from 'vue'
import DeviceInfo from '@/components/common/DeviceInfo.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
@@ -63,8 +55,6 @@ const props = defineProps<{
stats: SystemStats
}>()
const { copyToClipboard } = useCopyToClipboard()
const systemInfo = computed(() => ({
...props.stats.system,
argv: props.stats.system.argv.join(' ')
@@ -118,7 +108,7 @@ function isOutdated(column: ColumnDef): boolean {
return !!installed && !!required && installed !== required
}
function getDisplayValue(column: ColumnDef) {
const getDisplayValue = (column: ColumnDef) => {
const value = systemInfo.value[column.field]
if (column.formatNumber && typeof value === 'number') {
return column.formatNumber(value)
@@ -128,33 +118,4 @@ function getDisplayValue(column: ColumnDef) {
}
return value
}
function formatSystemInfoText(): string {
const lines: string[] = ['## System Info']
for (const col of systemColumns.value) {
const display = getDisplayValue(col)
if (display !== undefined && display !== '') {
lines.push(`${col.header}: ${display}`)
}
}
if (hasDevices.value) {
lines.push('')
lines.push('## Devices')
for (const device of props.stats.devices) {
lines.push(`- ${device.name} (${device.type})`)
lines.push(` VRAM Total: ${formatSize(device.vram_total)}`)
lines.push(` VRAM Free: ${formatSize(device.vram_free)}`)
lines.push(` Torch VRAM Total: ${formatSize(device.torch_vram_total)}`)
lines.push(` Torch VRAM Free: ${formatSize(device.torch_vram_free)}`)
}
}
return lines.join('\n')
}
function copySystemInfo() {
copyToClipboard(formatSystemInfoText())
}
</script>

View File

@@ -8,7 +8,6 @@
<!-- Node -->
<div
v-if="item.value.type === 'node'"
v-bind="$attrs"
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
draggable="true"
@@ -49,7 +48,6 @@
<!-- Folder -->
<div
v-else
v-bind="$attrs"
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
@click.stop="handleClick($event, handleToggle, handleSelect)"
@@ -100,10 +98,6 @@ import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
const ROW_CLASS =
'group/tree-node flex cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'

View File

@@ -178,7 +178,7 @@
v-show="isTemplateVisibleOnDistribution(template)"
:key="template.name"
ref="cardRefs"
size="tall"
size="compact"
variant="ghost"
rounded="lg"
:data-testid="`template-workflow-${template.name}`"
@@ -318,20 +318,6 @@
</Button>
</div>
</div>
<div class="flex">
<span
class="text-neutral flex items-center gap-1.5 text-xs font-bold"
>
<template v-if="isAppTemplate(template)">
<i class="icon-[lucide--panels-top-left]" />
{{ $t('builderToolbar.app', 'App') }}
</template>
<template v-else>
<i class="icon-[lucide--workflow]" />
{{ $t('builderToolbar.nodeGraph', 'Node Graph') }}
</template>
</span>
</div>
</div>
</CardBottom>
</template>
@@ -497,8 +483,6 @@ const {
const getEffectiveSourceModule = (template: TemplateInfo) =>
template.sourceModule || 'default'
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
const getBaseThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')

View File

@@ -1,153 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import CanvasModeSelector from '@/components/graph/CanvasModeSelector.vue'
const mockExecute = vi.fn()
const mockGetCommand = vi.fn().mockReturnValue({
keybinding: {
combo: {
getKeySequences: () => ['V']
}
}
})
const mockFormatKeySequence = vi.fn().mockReturnValue('V')
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mockExecute,
getCommand: mockGetCommand,
formatKeySequence: mockFormatKeySequence
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: { read_only: false }
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
graphCanvasMenu: {
select: 'Select',
hand: 'Hand',
canvasMode: 'Canvas Mode'
}
}
}
})
const mockPopoverHide = vi.fn()
function createWrapper() {
return mount(CanvasModeSelector, {
global: {
plugins: [i18n],
stubs: {
Popover: {
template: '<div><slot /></div>',
methods: {
toggle: vi.fn(),
hide: mockPopoverHide
}
}
}
}
})
}
describe('CanvasModeSelector', () => {
it('should render menu with menuitemradio roles and aria-checked', () => {
const wrapper = createWrapper()
const menu = wrapper.find('[role="menu"]')
expect(menu.exists()).toBe(true)
const menuItems = wrapper.findAll('[role="menuitemradio"]')
expect(menuItems).toHaveLength(2)
// Select mode is active (read_only: false), so select is checked
expect(menuItems[0].attributes('aria-checked')).toBe('true')
expect(menuItems[1].attributes('aria-checked')).toBe('false')
})
it('should render menu items as buttons with aria-labels', () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
menuItems.forEach((btn) => {
expect(btn.element.tagName).toBe('BUTTON')
expect(btn.attributes('type')).toBe('button')
})
expect(menuItems[0].attributes('aria-label')).toBe('Select')
expect(menuItems[1].attributes('aria-label')).toBe('Hand')
})
it('should use roving tabindex based on active mode', () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
// Select is active (read_only: false) → tabindex 0
expect(menuItems[0].attributes('tabindex')).toBe('0')
// Hand is inactive → tabindex -1
expect(menuItems[1].attributes('tabindex')).toBe('-1')
})
it('should mark icons as aria-hidden', () => {
const wrapper = createWrapper()
const icons = wrapper.findAll('[role="menuitemradio"] i')
icons.forEach((icon) => {
expect(icon.attributes('aria-hidden')).toBe('true')
})
})
it('should expose trigger button with aria-haspopup and aria-expanded', () => {
const wrapper = createWrapper()
const trigger = wrapper.find('[aria-haspopup="menu"]')
expect(trigger.exists()).toBe(true)
expect(trigger.attributes('aria-label')).toBe('Canvas Mode')
expect(trigger.attributes('aria-expanded')).toBe('false')
})
it('should call focus on next item when ArrowDown is pressed', async () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const secondItemEl = menuItems[1].element as HTMLElement
const focusSpy = vi.spyOn(secondItemEl, 'focus')
await menuItems[0].trigger('keydown', { key: 'ArrowDown' })
expect(focusSpy).toHaveBeenCalled()
})
it('should call focus on previous item when ArrowUp is pressed', async () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const firstItemEl = menuItems[0].element as HTMLElement
const focusSpy = vi.spyOn(firstItemEl, 'focus')
await menuItems[1].trigger('keydown', { key: 'ArrowUp' })
expect(focusSpy).toHaveBeenCalled()
})
it('should close popover on Escape and restore focus to trigger', async () => {
const wrapper = createWrapper()
const menuItems = wrapper.findAll('[role="menuitemradio"]')
const trigger = wrapper.find('[aria-haspopup="menu"]')
const triggerEl = trigger.element as HTMLElement
const focusSpy = vi.spyOn(triggerEl, 'focus')
await menuItems[0].trigger('keydown', { key: 'Escape' })
expect(mockPopoverHide).toHaveBeenCalled()
expect(focusSpy).toHaveBeenCalled()
})
})

View File

@@ -4,21 +4,15 @@
variant="secondary"
class="group h-8 rounded-none! bg-comfy-menu-bg p-0 transition-none! hover:rounded-lg! hover:bg-interface-button-hover-surface!"
:style="buttonStyles"
:aria-label="$t('graphCanvasMenu.canvasMode')"
aria-haspopup="menu"
:aria-expanded="isOpen"
@click="toggle"
>
<div class="flex items-center gap-1 pr-0.5">
<div
class="rounded-lg bg-interface-panel-selected-surface p-2 group-hover:bg-interface-button-hover-surface"
>
<i :class="currentModeIcon" class="block size-4" aria-hidden="true" />
<i :class="currentModeIcon" class="block size-4" />
</div>
<i
class="icon-[lucide--chevron-down] block size-4 pr-1.5"
aria-hidden="true"
/>
<i class="icon-[lucide--chevron-down] block size-4 pr-1.5" />
</div>
</Button>
@@ -30,54 +24,31 @@
:close-on-escape="true"
unstyled
:pt="popoverPt"
@show="onPopoverShow"
@hide="onPopoverHide"
>
<div
ref="menuRef"
class="flex flex-col gap-1"
role="menu"
:aria-label="$t('graphCanvasMenu.canvasMode')"
>
<button
type="button"
role="menuitemradio"
:aria-checked="!isCanvasReadOnly"
:tabindex="!isCanvasReadOnly ? 0 : -1"
class="flex w-full cursor-pointer items-center justify-between rounded-sm border-none bg-transparent px-3 py-2 text-sm text-text-primary outline-none hover:bg-node-component-surface-hovered focus-visible:bg-node-component-surface-hovered"
:aria-label="$t('graphCanvasMenu.select')"
<div class="flex flex-col gap-1">
<div
class="flex cursor-pointer items-center justify-between px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
@click="setMode('select')"
@keydown.arrow-down.prevent="focusNextItem"
@keydown.arrow-up.prevent="focusPrevItem"
@keydown.escape.prevent="closeAndRestoreFocus"
>
<div class="flex items-center gap-2">
<i class="icon-[lucide--mouse-pointer-2] size-4" aria-hidden="true" />
<i class="icon-[lucide--mouse-pointer-2] size-4" />
<span>{{ $t('graphCanvasMenu.select') }}</span>
</div>
<span class="text-[9px] text-text-primary">{{
unlockCommandText
}}</span>
</button>
</div>
<button
type="button"
role="menuitemradio"
:aria-checked="isCanvasReadOnly"
:tabindex="isCanvasReadOnly ? 0 : -1"
class="flex w-full cursor-pointer items-center justify-between rounded-sm border-none bg-transparent px-3 py-2 text-sm text-text-primary outline-none hover:bg-node-component-surface-hovered focus-visible:bg-node-component-surface-hovered"
:aria-label="$t('graphCanvasMenu.hand')"
<div
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
@click="setMode('hand')"
@keydown.arrow-down.prevent="focusNextItem"
@keydown.arrow-up.prevent="focusPrevItem"
@keydown.escape.prevent="closeAndRestoreFocus"
>
<div class="flex items-center gap-2">
<i class="icon-[lucide--hand] size-4" aria-hidden="true" />
<i class="icon-[lucide--hand] size-4" />
<span>{{ $t('graphCanvasMenu.hand') }}</span>
</div>
<span class="text-[9px] text-text-primary">{{ lockCommandText }}</span>
</button>
</div>
</div>
</Popover>
</template>
@@ -85,7 +56,7 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import type { ComponentPublicInstance } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -98,8 +69,6 @@ interface Props {
defineProps<Props>()
const buttonRef = ref<ComponentPublicInstance | null>(null)
const popover = ref<InstanceType<typeof Popover>>()
const menuRef = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
@@ -137,43 +106,6 @@ const setMode = (mode: 'select' | 'hand') => {
popover.value?.hide()
}
async function onPopoverShow() {
isOpen.value = true
await nextTick()
const checkedItem = menuRef.value?.querySelector<HTMLElement>(
'[aria-checked="true"]'
)
checkedItem?.focus()
}
function onPopoverHide() {
isOpen.value = false
}
function closeAndRestoreFocus() {
popover.value?.hide()
const el = buttonRef.value?.$el || buttonRef.value
;(el as HTMLElement)?.focus()
}
function focusNextItem(event: KeyboardEvent) {
const items = getMenuItems(event)
const index = items.indexOf(event.target as HTMLElement)
items[(index + 1) % items.length]?.focus()
}
function focusPrevItem(event: KeyboardEvent) {
const items = getMenuItems(event)
const index = items.indexOf(event.target as HTMLElement)
items[(index - 1 + items.length) % items.length]?.focus()
}
function getMenuItems(event: KeyboardEvent): HTMLElement[] {
const menu = (event.target as HTMLElement).closest('[role="menu"]')
if (!menu) return []
return Array.from(menu.querySelectorAll('[role="menuitemradio"]'))
}
const popoverPt = computed(() => ({
root: {
class: 'absolute z-50 -translate-y-2'

View File

@@ -479,53 +479,50 @@ useEventListener(
onMounted(async () => {
comfyApp.vueAppReady = true
workspaceStore.spinner = true
try {
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init()
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init()
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
if (settingsError.value) {
if (settingsError.value instanceof UnauthorizedError) {
localStorage.removeItem('Comfy.userId')
localStorage.removeItem('Comfy.userName')
window.location.reload()
return
}
throw settingsError.value
if (settingsError.value) {
if (settingsError.value instanceof UnauthorizedError) {
localStorage.removeItem('Comfy.userId')
localStorage.removeItem('Comfy.userName')
window.location.reload()
return
}
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
useNewUserService().initializeIfNewUser()
])
if (i18nError.value) {
console.warn(
'[GraphCanvas] Failed to load custom nodes i18n:',
i18nError.value
)
}
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
window.app = comfyApp
window.graph = comfyApp.graph
comfyAppReady.value = true
vueNodeLifecycle.setupEmptyGraphListener()
} finally {
workspaceStore.spinner = false
throw settingsError.value
}
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
useNewUserService().initializeIfNewUser()
])
if (i18nError.value) {
console.warn(
'[GraphCanvas] Failed to load custom nodes i18n:',
i18nError.value
)
}
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false
useSearchBoxStore().setPopoverRef(nodeSearchboxPopoverRef.value)
window.app = comfyApp
window.graph = comfyApp.graph
comfyAppReady.value = true
vueNodeLifecycle.setupEmptyGraphListener()
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,
() => canvasStore.updateSelectedItems()

View File

@@ -10,8 +10,6 @@
></div>
<ButtonGroup
role="toolbar"
:aria-label="t('graphCanvasMenu.canvasToolbar')"
class="absolute right-0 bottom-0 z-1200 flex-row gap-1 border border-interface-stroke bg-comfy-menu-bg p-2"
:style="{
...stringifiedMinimapStyles.buttonGroupStyles
@@ -32,7 +30,7 @@
class="size-8 bg-comfy-menu-bg p-0 hover:bg-interface-button-hover-surface!"
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
>
<i class="icon-[lucide--focus] size-4" aria-hidden="true" />
<i class="icon-[lucide--focus] size-4" />
</Button>
<Button
@@ -46,7 +44,7 @@
>
<span class="inline-flex items-center gap-1 px-2 text-xs">
<span>{{ canvasStore.appScalePercentage }}%</span>
<i class="icon-[lucide--chevron-down] size-4" aria-hidden="true" />
<i class="icon-[lucide--chevron-down] size-4" />
</span>
</Button>
@@ -61,7 +59,7 @@
:class="minimapButtonClass"
@click="onMinimapToggleClick"
>
<i class="icon-[lucide--map] size-4" aria-hidden="true" />
<i class="icon-[lucide--map] size-4" />
</Button>
<Button
@@ -80,7 +78,7 @@
:style="stringifiedMinimapStyles.buttonStyles"
@click="onLinkVisibilityToggleClick"
>
<i class="icon-[lucide--route-off] size-4" aria-hidden="true" />
<i class="icon-[lucide--route-off] size-4" />
</Button>
</ButtonGroup>
</div>

View File

@@ -26,18 +26,15 @@ function toggle() {
v-if="visible"
role="status"
aria-live="polite"
:class="
cn(
'fixed inset-x-4 bottom-6 z-9999 mx-auto w-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg transition-all duration-300 sm:inset-x-0',
isExpanded ? 'sm:w-[max(400px,40vw)]' : 'sm:w-fit'
)
"
class="fixed inset-x-4 bottom-6 z-9999 mx-auto w-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg transition-all duration-300 sm:inset-x-0 sm:w-min sm:min-w-0"
>
<div
:class="
cn(
'max-w-full min-w-0 overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-100 w-full' : 'max-h-0 w-0'
isExpanded
? 'max-h-100 w-full sm:w-[max(400px,40vw)]'
: 'max-h-0 w-0'
)
"
>

View File

@@ -1,33 +1,73 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { MultiSelectProps } from 'primevue/multiselect'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
import type { SelectOption } from './types'
const meta: Meta<typeof MultiSelect> = {
title: 'Components/Select/MultiSelect',
// Combine our component props with PrimeVue MultiSelect props
// Since we use v-bind="$attrs", all PrimeVue props are available
interface ExtendedProps extends Partial<MultiSelectProps> {
// Our custom props
label?: string
showSearchBox?: boolean
showSelectedCount?: boolean
showClearButton?: boolean
searchPlaceholder?: string
listMaxHeight?: string
popoverMinWidth?: string
popoverMaxWidth?: string
// Override modelValue type to match our Option type
modelValue?: SelectOption[]
}
const meta: Meta<ExtendedProps> = {
title: 'Components/Input/MultiSelect',
component: MultiSelect,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
() => ({
template: '<div class="pt-4"><story /></div>'
})
],
argTypes: {
label: { control: 'text' },
size: {
control: { type: 'select' },
options: ['lg', 'md']
label: {
control: 'text'
},
showSearchBox: { control: 'boolean' },
showSelectedCount: { control: 'boolean' },
showClearButton: { control: 'boolean' },
searchPlaceholder: { control: 'text' }
options: {
control: 'object'
},
showSearchBox: {
control: 'boolean',
description: 'Toggle searchBar visibility'
},
showSelectedCount: {
control: 'boolean',
description: 'Toggle selected count visibility'
},
showClearButton: {
control: 'boolean',
description: 'Toggle clear button visibility'
},
searchPlaceholder: {
control: 'text'
},
listMaxHeight: {
control: 'text',
description: 'Maximum height of the dropdown list'
},
popoverMinWidth: {
control: 'text',
description: 'Minimum width of the popover'
},
popoverMaxWidth: {
control: 'text',
description: 'Maximum width of the popover'
}
},
args: {
label: 'Category',
size: 'lg',
label: 'Select',
options: [
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
],
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
@@ -38,125 +78,352 @@ const meta: Meta<typeof MultiSelect> = {
export default meta
type Story = StoryObj<typeof meta>
const sampleOptions: SelectOption[] = [
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
]
export const Default: Story = {
render: (args) => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([])
return { selected, sampleOptions, args }
const selected = ref([])
const options = args.options || [
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
]
return { selected, options, args }
},
template:
'<MultiSelect v-model="selected" :options="sampleOptions" :label="args.label" :size="args.size" :show-search-box="args.showSearchBox" :show-selected-count="args.showSelectedCount" :show-clear-button="args.showClearButton" />'
template: `
<div>
<MultiSelect
v-model="selected"
:options="options"
:label="args.label"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<div class="mt-4 p-3 bg-base-background rounded">
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
</div>
</div>
`
})
}
export const MediumSize: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([sampleOptions[0]])
return { selected, sampleOptions }
},
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" size="md" />'
}),
parameters: { controls: { disable: true } }
}
export const WithPreselectedValues: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([sampleOptions[0], sampleOptions[1]])
return { selected, sampleOptions }
},
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" />'
}),
parameters: { controls: { disable: true } }
}
export const Disabled: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([sampleOptions[0]])
return { selected, sampleOptions }
},
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" disabled />'
}),
parameters: { controls: { disable: true } }
}
export const WithSearchBox: Story = {
args: { showSearchBox: true },
render: (args) => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([])
return { selected, sampleOptions, args }
},
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" :show-search-box="args.showSearchBox" />'
})
}
export const AllHeaderFeatures: Story = {
args: {
showSearchBox: true,
showSelectedCount: true,
showClearButton: true
},
render: (args) => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([])
return { selected, sampleOptions, args }
},
template:
'<MultiSelect v-model="selected" :options="sampleOptions" label="Category" :show-search-box="args.showSearchBox" :show-selected-count="args.showSelectedCount" :show-clear-button="args.showClearButton" />'
})
}
export const AllStates: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const a = ref<SelectOption[]>([])
const b = ref<SelectOption[]>([sampleOptions[0]])
const c = ref<SelectOption[]>([sampleOptions[0]])
return { sampleOptions, a, b, c }
const options = args.options || [
{ name: 'JavaScript', value: 'js' },
{ name: 'TypeScript', value: 'ts' },
{ name: 'Python', value: 'python' },
{ name: 'Go', value: 'go' },
{ name: 'Rust', value: 'rust' }
]
const selected = ref([options[0], options[1]])
return { selected, options, args }
},
template: `
<div class="flex flex-col gap-6">
<div>
<p class="mb-2 text-xs text-muted-foreground">Large (Interface)</p>
<div class="flex flex-col gap-3">
<MultiSelect v-model="a" :options="sampleOptions" label="Default" />
<MultiSelect v-model="b" :options="sampleOptions" label="With Selection" />
<MultiSelect v-model="c" :options="sampleOptions" label="Disabled" disabled />
</div>
<div>
<MultiSelect
v-model="selected"
:options="options"
:label="args.label"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<div class="mt-4 p-3 bg-base-background rounded">
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
</div>
<div>
<p class="mb-2 text-xs text-muted-foreground">Medium (Node)</p>
<div class="flex flex-col gap-3">
<MultiSelect v-model="a" :options="sampleOptions" label="Default" size="md" />
<MultiSelect v-model="b" :options="sampleOptions" label="With Selection" size="md" />
<MultiSelect v-model="c" :options="sampleOptions" label="Disabled" size="md" disabled />
</div>
`
}),
args: {
label: 'Select Languages',
options: [
{ name: 'JavaScript', value: 'js' },
{ name: 'TypeScript', value: 'ts' },
{ name: 'Python', value: 'python' },
{ name: 'Go', value: 'go' },
{ name: 'Rust', value: 'rust' }
],
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
searchPlaceholder: 'Search...'
}
}
export const MultipleSelectors: Story = {
render: (args) => ({
components: { MultiSelect },
setup() {
const frameworkOptions = ref([
{ name: 'Vue', value: 'vue' },
{ name: 'React', value: 'react' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' }
])
const projectOptions = ref([
{ name: 'Project A', value: 'proj-a' },
{ name: 'Project B', value: 'proj-b' },
{ name: 'Project C', value: 'proj-c' },
{ name: 'Project D', value: 'proj-d' }
])
const tagOptions = ref([
{ name: 'Frontend', value: 'frontend' },
{ name: 'Backend', value: 'backend' },
{ name: 'Database', value: 'database' },
{ name: 'DevOps', value: 'devops' },
{ name: 'Testing', value: 'testing' }
])
const selectedFrameworks = ref([])
const selectedProjects = ref([])
const selectedTags = ref([])
return {
frameworkOptions,
projectOptions,
tagOptions,
selectedFrameworks,
selectedProjects,
selectedTags,
args
}
},
template: `
<div class="space-y-4">
<div class="flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
:options="frameworkOptions"
label="Select Frameworks"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<MultiSelect
v-model="selectedProjects"
:options="projectOptions"
label="Select Projects"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<MultiSelect
v-model="selectedTags"
:options="tagOptions"
label="Select Tags"
:showSearchBox="args.showSearchBox"
:showSelectedCount="args.showSelectedCount"
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
</div>
<div class="p-4 bg-base-background rounded">
<h4 class="font-medium mt-0">Current Selection:</h4>
<div class="flex flex-col text-sm">
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>
<p>Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}</p>
<p>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p>
</div>
</div>
</div>
`
}),
parameters: {
controls: { disable: true }
args: {
showSearchBox: false,
showSelectedCount: false,
showClearButton: false,
searchPlaceholder: 'Search...'
}
}
export const WithSearchBox: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true
}
}
export const WithSelectedCount: Story = {
...Default,
args: {
...Default.args,
showSelectedCount: true
}
}
export const WithClearButton: Story = {
...Default,
args: {
...Default.args,
showClearButton: true
}
}
export const AllHeaderFeatures: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true,
showSelectedCount: true,
showClearButton: true
}
}
export const CustomSearchPlaceholder: Story = {
...Default,
args: {
...Default.args,
showSearchBox: true,
searchPlaceholder: 'Filter packages...'
}
}
export const CustomMaxHeight: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
name: `Option ${i + 1}`,
value: `option${i + 1}`
}))
return { selected1, selected2, selected3, manyOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
<MultiSelect
v-model="selected1"
:options="manyOptions"
label="Small Dropdown"
list-max-height="10rem"
show-selected-count
/>
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
<MultiSelect
v-model="selected2"
:options="manyOptions"
label="Default Dropdown"
list-max-height="28rem"
show-selected-count
/>
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
<MultiSelect
v-model="selected3"
:options="manyOptions"
label="Large Dropdown"
list-max-height="32rem"
show-selected-count
/>
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMinWidth: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const options = [
{ name: 'A', value: 'a' },
{ name: 'B', value: 'b' },
{ name: 'Very Long Option Name Here', value: 'long' }
]
return { selected1, selected2, selected3, options }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<MultiSelect v-model="selected1" :options="options" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 18rem</h3>
<MultiSelect v-model="selected2" :options="options" label="Min 18rem" popover-min-width="18rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 28rem</h3>
<MultiSelect v-model="selected3" :options="options" label="Min 28rem" popover-min-width="28rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxWidth: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected1 = ref([])
const selected2 = ref([])
const selected3 = ref([])
const longOptions = [
{ name: 'Short', value: 'short' },
{
name: 'This is a very long option name that would normally expand the dropdown',
value: 'long1'
},
{
name: 'Another extremely long option that demonstrates max-width constraint',
value: 'long2'
}
]
return { selected1, selected2, selected3, longOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<MultiSelect v-model="selected1" :options="longOptions" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Max Width 18rem</h3>
<MultiSelect v-model="selected2" :options="longOptions" label="Max 18rem" popover-max-width="18rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min 12rem Max 22rem</h3>
<MultiSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="12rem" popover-max-width="22rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}

View File

@@ -16,23 +16,20 @@
:pt="{
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'relative inline-flex cursor-pointer select-none',
size === 'md' ? 'h-8' : 'h-10',
'relative inline-flex h-10 cursor-pointer select-none',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
'focus-within:border-base-foreground',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
selectedCount > 0
? 'border-node-component-border'
: 'border-transparent',
'focus-within:border-node-component-border',
{ 'cursor-default opacity-60': props.disabled }
)
}),
labelContainer: {
class: cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
class:
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 '
},
label: {
class: 'p-0'
@@ -132,12 +129,12 @@
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
<span class="text-sm">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-primary-background text-xs font-semibold text-base-foreground"
>
{{ selectedCount }}
</span>
@@ -200,8 +197,6 @@ defineOptions({
interface Props {
/** Input label shown on the trigger button */
label?: string
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
/** Show search box in the panel header */
showSearchBox?: boolean
/** Show selected count text in the panel header */
@@ -221,7 +216,6 @@ interface Props {
}
const {
label,
size = 'lg',
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,

View File

@@ -1,219 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
import SingleSelect from './SingleSelect.vue'
import type { SelectOption } from './types'
const meta: Meta = {
title: 'Components/Select/SelectDropdown',
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
() => ({
template: '<div class="pt-4"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
const modelOptions: SelectOption[] = [
{ name: 'ACE-Step', value: 'ace-step' },
{ name: 'Anima', value: 'anima' },
{ name: 'BRIA', value: 'bria' },
{ name: 'ByteDance', value: 'bytedance' },
{ name: 'Capybara', value: 'capybara' },
{ name: 'Chatter Box', value: 'chatter-box' },
{ name: 'Chroma', value: 'chroma' },
{ name: 'ChronoEdit', value: 'chronoedit' },
{ name: 'DWPose', value: 'dwpose' },
{ name: 'Depth Anything v2', value: 'depth-anything-v2' },
{ name: 'ElevenLabs', value: 'elevenlabs' },
{ name: 'Flux', value: 'flux' },
{ name: 'HunyuanVideo', value: 'hunyuan-video' },
{ name: 'Stable Diffusion', value: 'stable-diffusion' },
{ name: 'SDXL', value: 'sdxl' }
]
const useCaseOptions: SelectOption[] = [
{ name: 'Text to Image', value: 'text-to-image' },
{ name: 'Image to Image', value: 'image-to-image' },
{ name: 'Inpainting', value: 'inpainting' },
{ name: 'Upscaling', value: 'upscaling' },
{ name: 'Video Generation', value: 'video-generation' },
{ name: 'Audio Generation', value: 'audio-generation' },
{ name: '3D Generation', value: '3d-generation' }
]
const sortOptions: SelectOption[] = [
{ name: 'Default', value: 'default' },
{ name: 'Recommended', value: 'recommended' },
{ name: 'Popular', value: 'popular' },
{ name: 'Newest', value: 'newest' },
{ name: 'VRAM Usage (Low to High)', value: 'vram-low-to-high' },
{ name: 'Model Size (Low to High)', value: 'model-size-low-to-high' },
{ name: 'Alphabetical (A-Z)', value: 'alphabetical' }
]
export const ModelFilter: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([
modelOptions[1],
modelOptions[2],
modelOptions[3]
])
return { selected, modelOptions }
},
template: `
<MultiSelect
v-model="selected"
:options="modelOptions"
:label="selected.length === 0 ? 'Models' : selected.length === 1 ? selected[0].name : selected.length + ' Models'"
show-search-box
show-selected-count
show-clear-button
class="w-[250px]"
>
<template #icon>
<i class="icon-[lucide--cpu]" />
</template>
</MultiSelect>
`
}),
parameters: { controls: { disable: true } }
}
export const UseCaseFilter: Story = {
render: () => ({
components: { MultiSelect },
setup() {
const selected = ref<SelectOption[]>([])
return { selected, useCaseOptions }
},
template: `
<MultiSelect
v-model="selected"
:options="useCaseOptions"
:label="selected.length === 0 ? 'Use Case' : selected.length === 1 ? selected[0].name : selected.length + ' Use Cases'"
show-search-box
show-selected-count
show-clear-button
>
<template #icon>
<i class="icon-[lucide--target]" />
</template>
</MultiSelect>
`
}),
parameters: { controls: { disable: true } }
}
export const SortDropdown: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | undefined>('default')
return { selected, sortOptions }
},
template: `
<SingleSelect
v-model="selected"
:options="sortOptions"
label="Sort by"
class="w-62.5"
>
<template #icon>
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
</template>
</SingleSelect>
`
}),
parameters: { controls: { disable: true } }
}
export const TemplateFilterBar: Story = {
render: () => ({
components: { MultiSelect, SingleSelect },
setup() {
const selectedModels = ref<SelectOption[]>([
modelOptions[1],
modelOptions[2],
modelOptions[3]
])
const selectedUseCases = ref<SelectOption[]>([])
const sortBy = ref<string | undefined>('default')
const modelLabel = () => {
if (selectedModels.value.length === 0) return 'Models'
if (selectedModels.value.length === 1)
return selectedModels.value[0].name
return selectedModels.value.length + ' Models'
}
const useCaseLabel = () => {
if (selectedUseCases.value.length === 0) return 'Use Case'
if (selectedUseCases.value.length === 1)
return selectedUseCases.value[0].name
return selectedUseCases.value.length + ' Use Cases'
}
return {
selectedModels,
selectedUseCases,
sortBy,
modelOptions,
useCaseOptions,
sortOptions,
modelLabel,
useCaseLabel
}
},
template: `
<div class="flex flex-wrap items-center justify-between gap-2" style="min-width: 700px;">
<div class="flex flex-wrap gap-2">
<MultiSelect
v-model="selectedModels"
:options="modelOptions"
:label="modelLabel()"
show-search-box
show-selected-count
show-clear-button
class="w-[250px]"
>
<template #icon>
<i class="icon-[lucide--cpu]" />
</template>
</MultiSelect>
<MultiSelect
v-model="selectedUseCases"
:options="useCaseOptions"
:label="useCaseLabel()"
show-search-box
show-selected-count
show-clear-button
>
<template #icon>
<i class="icon-[lucide--target]" />
</template>
</MultiSelect>
</div>
<SingleSelect
v-model="sortBy"
:options="sortOptions"
label="Sort by"
class="w-62.5"
>
<template #icon>
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
</template>
</SingleSelect>
</div>
`
}),
parameters: { controls: { disable: true } }
}

View File

@@ -3,31 +3,29 @@ import { ref } from 'vue'
import SingleSelect from './SingleSelect.vue'
// SingleSelect already includes options prop, so no need to extend
const meta: Meta<typeof SingleSelect> = {
title: 'Components/Select/SingleSelect',
title: 'Components/Input/SingleSelect',
component: SingleSelect,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [
() => ({
template: '<div class="pt-4"><story /></div>'
})
],
argTypes: {
label: { control: 'text' },
options: { control: 'object' },
size: {
control: { type: 'select' },
options: ['lg', 'md']
listMaxHeight: {
control: 'text',
description: 'Maximum height of the dropdown list'
},
invalid: { control: 'boolean' },
loading: { control: 'boolean' }
popoverMinWidth: {
control: 'text',
description: 'Minimum width of the popover'
},
popoverMaxWidth: {
control: 'text',
description: 'Maximum width of the popover'
}
},
args: {
label: 'Category',
size: 'lg',
invalid: false,
loading: false,
label: 'Sorting Type',
options: [
{ name: 'Popular', value: 'popular' },
{ name: 'Newest', value: 'newest' },
@@ -39,7 +37,7 @@ const meta: Meta<typeof SingleSelect> = {
}
export default meta
type Story = StoryObj<typeof meta>
export type Story = StoryObj<typeof meta>
const sampleOptions = [
{ name: 'Popular', value: 'popular' },
@@ -54,118 +52,205 @@ export const Default: Story = {
components: { SingleSelect },
setup() {
const selected = ref<string | null>(null)
return { selected, args }
const options = args.options || sampleOptions
return { selected, options, args }
},
template:
'<SingleSelect v-model="selected" :options="args.options" :label="args.label" :size="args.size" :invalid="args.invalid" :loading="args.loading" />'
template: `
<div>
<SingleSelect v-model="selected" :options="options" :label="args.label" />
<div class="mt-4 p-3 bg-base-background rounded">
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
</div>
</div>
`
})
}
export const MediumSize: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('popular')
return { selected, sampleOptions }
},
template:
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" size="md" />'
}),
parameters: { controls: { disable: true } }
}
export const WithIcon: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('popular')
return { selected, sampleOptions }
const options = sampleOptions
return { selected, options }
},
template: `
<SingleSelect v-model="selected" :options="sampleOptions" label="Sorting Type">
<template #icon>
<i class="icon-[lucide--arrow-up-down] size-3.5" />
</template>
</SingleSelect>
`
}),
parameters: { controls: { disable: true } }
}
export const Disabled: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('popular')
return { selected, sampleOptions }
},
template:
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" disabled />'
}),
parameters: { controls: { disable: true } }
}
export const Invalid: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('popular')
return { selected, sampleOptions }
},
template:
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" invalid />'
}),
parameters: { controls: { disable: true } }
}
export const Loading: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('popular')
return { selected, sampleOptions }
},
template:
'<SingleSelect v-model="selected" :options="sampleOptions" label="Category" loading />'
}),
parameters: { controls: { disable: true } }
}
export const AllStates: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const a = ref<string | null>('popular')
const b = ref<string | null>('popular')
const c = ref<string | null>('popular')
const d = ref<string | null>('popular')
const e = ref<string | null>('popular')
return { sampleOptions, a, b, c, d, e }
},
template: `
<div class="flex flex-col gap-6">
<div>
<p class="mb-2 text-xs text-muted-foreground">Large (Interface)</p>
<div class="flex flex-col gap-3">
<SingleSelect v-model="a" :options="sampleOptions" label="Default" />
<SingleSelect v-model="b" :options="sampleOptions" label="Disabled" disabled />
<SingleSelect v-model="c" :options="sampleOptions" label="Invalid" invalid />
<SingleSelect v-model="d" :options="sampleOptions" label="Loading" loading />
</div>
<div>
<SingleSelect v-model="selected" :options="options" label="Sorting Type">
<template #icon>
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template>
</SingleSelect>
<div class="mt-4 p-3 bg-base-background rounded">
<p class="text-sm">Selected: {{ selected }}</p>
</div>
<div>
<p class="mb-2 text-xs text-muted-foreground">Medium (Node)</p>
<div class="flex flex-col gap-3">
<SingleSelect v-model="a" :options="sampleOptions" label="Default" size="md" />
<SingleSelect v-model="b" :options="sampleOptions" label="Disabled" size="md" disabled />
<SingleSelect v-model="c" :options="sampleOptions" label="Invalid" size="md" invalid />
<SingleSelect v-model="e" :options="sampleOptions" label="Loading" size="md" loading />
</div>
</div>
`
})
}
export const Preselected: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>('newest')
const options = sampleOptions
return { selected, options }
},
template: `
<SingleSelect v-model="selected" :options="options" label="Sorting Type" />
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const options = sampleOptions
const a = ref<string | null>(null)
const b = ref<string | null>('popular')
const c = ref<string | null>('az')
return { options, a, b, c }
},
template: `
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<SingleSelect v-model="a" :options="options" label="No Icon" />
</div>
<div class="flex items-center gap-3">
<SingleSelect v-model="b" :options="options" label="With Icon">
<template #icon>
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template>
</SingleSelect>
</div>
<div class="flex items-center gap-3">
<SingleSelect v-model="c" :options="options" label="Preselected (A→Z)" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true }
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxHeight: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected = ref<string | null>(null)
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
name: `Option ${i + 1}`,
value: `option${i + 1}`
}))
return { selected, manyOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Small Height (10rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Small Dropdown" list-max-height="10rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Default Height (28rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Default Dropdown" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Large Height (32rem)</h3>
<SingleSelect v-model="selected" :options="manyOptions" label="Large Dropdown" list-max-height="32rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMinWidth: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected1 = ref<string | null>(null)
const selected2 = ref<string | null>(null)
const selected3 = ref<string | null>(null)
const options = [
{ name: 'A', value: 'a' },
{ name: 'B', value: 'b' },
{ name: 'Very Long Option Name Here', value: 'long' }
]
return { selected1, selected2, selected3, options }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<SingleSelect v-model="selected1" :options="options" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 15rem</h3>
<SingleSelect v-model="selected2" :options="options" label="Min 15rem" popover-min-width="15rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min Width 25rem</h3>
<SingleSelect v-model="selected3" :options="options" label="Min 25rem" popover-min-width="25rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}
export const CustomMaxWidth: Story = {
render: () => ({
components: { SingleSelect },
setup() {
const selected1 = ref<string | null>(null)
const selected2 = ref<string | null>(null)
const selected3 = ref<string | null>(null)
const longOptions = [
{ name: 'Short', value: 'short' },
{
name: 'This is a very long option name that would normally expand the dropdown',
value: 'long1'
},
{
name: 'Another extremely long option that demonstrates max-width constraint',
value: 'long2'
}
]
return { selected1, selected2, selected3, longOptions }
},
template: `
<div class="flex gap-4">
<div>
<h3 class="text-sm font-semibold mb-2">Auto Width</h3>
<SingleSelect v-model="selected1" :options="longOptions" label="Auto" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Max Width 15rem</h3>
<SingleSelect v-model="selected2" :options="longOptions" label="Max 15rem" popover-max-width="15rem" />
</div>
<div>
<h3 class="text-sm font-semibold mb-2">Min 10rem Max 20rem</h3>
<SingleSelect v-model="selected3" :options="longOptions" label="Min & Max" popover-min-width="10rem" popover-max-width="20rem" />
</div>
</div>
`
}),
parameters: {
controls: { disable: true },
actions: { disable: true },
slot: { disable: true }
}
}

View File

@@ -15,26 +15,23 @@
unstyled
:pt="{
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
class: [
// container
'h-10 relative inline-flex cursor-pointer select-none items-center',
// trigger surface
'rounded-lg',
'bg-secondary-background text-base-foreground',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
invalid
? 'border-destructive-background'
: 'border-transparent focus-within:border-node-component-border',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
'focus-within:border-node-component-border',
// disabled
{ 'opacity-60 cursor-default': props.disabled }
]
}),
label: {
class: cn(
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
size === 'md' ? 'pl-3' : 'pl-4'
)
class:
// Align with MultiSelect labelContainer spacing
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
},
dropdown: {
class:
@@ -80,8 +77,6 @@
}
}"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
@@ -89,16 +84,8 @@
>
<!-- Trigger value -->
<template #value="slotProps">
<div
:class="
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
"
>
<i
v-if="loading"
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
/>
<slot v-else name="icon" />
<div class="flex items-center gap-2 text-sm">
<slot name="icon" />
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
class="text-base-foreground"
@@ -111,12 +98,9 @@
</div>
</template>
<!-- Trigger caret (hidden when loading) -->
<!-- Trigger caret -->
<template #dropdownicon>
<i
v-if="!loading"
class="icon-[lucide--chevron-down] text-muted-foreground"
/>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template>
<!-- Option row -->
@@ -149,9 +133,6 @@ defineOptions({
const {
label,
options,
size = 'lg',
invalid = false,
loading = false,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
@@ -163,12 +144,6 @@ const {
* in getLabel() to map values to their display names.
*/
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
/** Show invalid (destructive) border */
invalid?: boolean
/** Show loading spinner instead of chevron */
loading?: boolean
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */

View File

@@ -0,0 +1,96 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import LogoCFillLoader from './LogoCFillLoader.vue'
const meta: Meta<typeof LogoCFillLoader> = {
title: 'Components/Loader/LogoCFillLoader',
component: LogoCFillLoader,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: { default: 'dark' }
},
argTypes: {
size: {
control: 'select',
options: ['sm', 'md', 'lg', 'xl']
},
color: {
control: 'select',
options: ['yellow', 'blue', 'white', 'black']
},
bordered: {
control: 'boolean'
},
disableAnimation: {
control: 'boolean'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Small: Story = {
args: { size: 'sm' }
}
export const Large: Story = {
args: { size: 'lg' }
}
export const ExtraLarge: Story = {
args: { size: 'xl' }
}
export const NoBorder: Story = {
args: { bordered: false }
}
export const Static: Story = {
args: { disableAnimation: true }
}
export const BrandColors: Story = {
render: () => ({
components: { LogoCFillLoader },
template: `
<div class="flex items-end gap-12">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">Yellow</span>
<LogoCFillLoader size="lg" color="yellow" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">Blue</span>
<LogoCFillLoader size="lg" color="blue" />
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-400">White</span>
<LogoCFillLoader size="lg" color="white" />
</div>
<div class="p-4 bg-white rounded" style="background: white">
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-neutral-600">Black</span>
<LogoCFillLoader size="lg" color="black" />
</div>
</div>
</div>
`
})
}
export const AllSizes: Story = {
render: () => ({
components: { LogoCFillLoader },
template: `
<div class="flex items-end gap-8">
<LogoCFillLoader size="sm" color="yellow" />
<LogoCFillLoader size="md" color="yellow" />
<LogoCFillLoader size="lg" color="yellow" />
<LogoCFillLoader size="xl" color="yellow" />
</div>
`
})
}

View File

@@ -0,0 +1,100 @@
<template>
<span role="status" :class="cn('inline-flex', colorClass)">
<svg
:width="Math.round(heightMap[size] * (VB_W / VB_H))"
:height="heightMap[size]"
:viewBox="`0 0 ${VB_W} ${VB_H}`"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<mask :id="maskId">
<path :d="C_PATH" fill="white" />
</mask>
</defs>
<path
v-if="bordered"
:d="C_PATH"
stroke="currentColor"
stroke-width="2"
fill="none"
opacity="0.4"
/>
<g :mask="`url(#${maskId})`">
<rect
:class="disableAnimation ? undefined : 'c-fill-rect'"
:x="-BLEED"
:y="-BLEED"
:width="VB_W + BLEED * 2"
:height="VB_H + BLEED * 2"
fill="currentColor"
/>
</g>
</svg>
<span class="sr-only">{{ t('g.loading') }}</span>
</span>
</template>
<script setup lang="ts">
import { useId, computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
const {
size = 'md',
color = 'black',
bordered = true,
disableAnimation = false
} = defineProps<{
size?: 'sm' | 'md' | 'lg' | 'xl'
color?: 'yellow' | 'blue' | 'white' | 'black'
bordered?: boolean
disableAnimation?: boolean
}>()
const { t } = useI18n()
const maskId = `c-mask-${useId()}`
const VB_W = 185
const VB_H = 201
const BLEED = 1
// Larger than LogoComfyWaveLoader because the C logo is near-square (185×201)
// while the COMFY wordmark is wide (879×284), so larger heights are needed
// for visually comparable perceived size.
const heightMap = { sm: 48, md: 80, lg: 120, xl: 200 } as const
const colorMap = {
yellow: 'text-brand-yellow',
blue: 'text-brand-blue',
white: 'text-white',
black: 'text-black'
} as const
const colorClass = computed(() => colorMap[color])
const C_PATH =
'M42.1217 200.812C37.367 200.812 33.5304 199.045 31.0285 195.703C28.4569 192.27 27.7864 187.477 29.1882 182.557L34.8172 162.791C35.2661 161.217 34.9537 159.523 33.9747 158.214C32.9958 156.908 31.464 156.139 29.8371 156.139L13.6525 156.139C8.89521 156.139 5.05862 154.374 2.55797 151.032C-0.0136533 147.597-0.684085 142.804 0.71869 137.883L20.0565 70.289L22.1916 62.8625C25.0617 52.7847 35.5288 44.5943 45.528 44.5943L64.8938 44.5943C67.2048 44.5943 69.2376 43.0535 69.8738 40.8175L76.2782 18.3344C79.1454 8.26681 89.6127 0.0763962 99.6117 0.0763945L141.029 0.00258328L171.349-2.99253e-05C176.104-3.0756e-05 179.941 1.765 182.442 5.10626C185.013 8.53932 185.684 13.3324 184.282 18.2528L175.612 48.6947C172.746 58.7597 162.279 66.9475 152.28 66.9475L110.771 67.0265L91.4113 67.0265C89.1029 67.0265 87.0727 68.5647 86.4326 70.7983L70.2909 127.179C69.8394 128.756 70.1518 130.454 71.1334 131.763C72.1123 133.07 73.6441 133.839 75.2697 133.839C75.2736 133.839 102.699 133.785 102.699 133.785L132.929 133.785C137.685 133.785 141.522 135.55 144.023 138.892C146.594 142.327 147.265 147.12 145.862 152.041L137.192 182.478C134.326 192.545 123.859 200.733 113.86 200.733L72.3517 200.812L42.1217 200.812Z'
</script>
<style scoped>
.c-fill-rect {
animation: c-fill-up 2.5s cubic-bezier(0.25, 0, 0.3, 1) forwards;
will-change: transform;
}
@keyframes c-fill-up {
0% {
transform: translateY(calc(v-bind(VB_H) * 1px + v-bind(BLEED) * 1px));
}
100% {
transform: translateY(calc(v-bind(BLEED) * -1px));
}
}
@media (prefers-reduced-motion: reduce) {
.c-fill-rect {
animation: none;
}
}
</style>

View File

@@ -13,7 +13,6 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
@@ -37,7 +36,6 @@ import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
@@ -45,8 +43,6 @@ const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
const { findParentGroup } = useGraphHierarchy()
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
@@ -122,21 +118,12 @@ const hasMissingNodeSelected = computed(
)
)
const hasMissingModelSelected = computed(
() =>
hasSelection.value &&
selectedNodes.value.some((node) =>
activeMissingModelGraphIds.value.has(String(node.id))
)
)
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
return (
hasDirectNodeError.value ||
hasContainerInternalError.value ||
hasMissingNodeSelected.value ||
hasMissingModelSelected.value
hasMissingNodeSelected.value
)
})
@@ -327,11 +314,7 @@ function handleTitleCancel() {
:value="tab.value"
>
{{ tab.label() }}
<i
v-if="tab.icon"
aria-hidden="true"
:class="cn(tab.icon, 'size-4')"
/>
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
</Tab>
</TabList>
</nav>

View File

@@ -12,7 +12,7 @@
</div>
<!-- Scrollable content -->
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<div class="min-w-0 flex-1 overflow-y-auto">
<TransitionGroup tag="div" name="list-scale" class="relative">
<div
v-if="filteredGroups.length === 0"
@@ -32,7 +32,11 @@
:key="group.title"
:collapse="isSectionCollapsed(group.title) && !isSearching"
class="border-b border-interface-stroke"
:size="getGroupSize(group)"
:size="
group.type === 'missing_node' || group.type === 'swap_nodes'
? 'lg'
: 'default'
"
@update:collapse="setSectionCollapsed(group.title, $event)"
>
<template #label>
@@ -126,14 +130,6 @@
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Missing Models -->
<MissingModelCard
v-else-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateModel"
/>
</PropertiesAccordionItem>
</TransitionGroup>
</div>
@@ -191,14 +187,12 @@ import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/f
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
const { t } = useI18n()
@@ -217,15 +211,6 @@ const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
const isSearching = computed(() => searchQuery.value.trim() !== '')
const fullSizeGroupTypes = new Set([
'missing_node',
'swap_nodes',
'missing_model'
])
function getGroupSize(group: ErrorGroup) {
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
}
const showNodeIdBadge = computed(
() =>
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
@@ -241,7 +226,6 @@ const {
errorNodeCache,
missingNodeCache,
missingPackGroups,
missingModelGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)
@@ -299,10 +283,6 @@ function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleLocateModel(nodeId: string) {
focusNode(nodeId)
}
function handleOpenManagerInfo(packId: string) {
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
if (isKnownToRegistry) {

View File

@@ -23,4 +23,3 @@ export type ErrorGroup =
}
| { type: 'missing_node'; title: string; priority: number }
| { type: 'swap_nodes'; title: string; priority: number }
| { type: 'missing_model'; title: string; priority: number }

View File

@@ -47,13 +47,6 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
vi.mock(
'@/platform/missingModel/composables/useMissingModelInteractions',
() => ({
clearMissingModelState: vi.fn()
})
)
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
@@ -527,115 +520,4 @@ describe('useErrorGroups', () => {
expect(typeof groups.collapseState).toBe('object')
})
})
describe('missingModelGroups', () => {
function makeModel(
name: string,
opts: {
nodeId?: string | number
widgetName?: string
directory?: string
isAssetSupported?: boolean
} = {}
) {
return {
name,
nodeId: opts.nodeId ?? '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: opts.widgetName ?? 'ckpt_name',
isAssetSupported: opts.isAssetSupported ?? false,
isMissing: true as const,
directory: opts.directory
}
}
it('returns empty array when no missing models', () => {
const { groups } = createErrorGroups()
expect(groups.missingModelGroups.value).toEqual([])
})
it('groups asset-supported models by directory', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('model_a.safetensors', {
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('model_b.safetensors', {
nodeId: '2',
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('lora_a.safetensors', {
nodeId: '3',
directory: 'loras',
isAssetSupported: true
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(2)
const ckptGroup = groups.missingModelGroups.value.find(
(g) => g.directory === 'checkpoints'
)
expect(ckptGroup?.models).toHaveLength(2)
expect(ckptGroup?.isAssetSupported).toBe(true)
})
it('puts unsupported models in a separate group', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('model_a.safetensors', {
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('custom_model.safetensors', {
nodeId: '2',
isAssetSupported: false
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(2)
const unsupported = groups.missingModelGroups.value.find(
(g) => !g.isAssetSupported
)
expect(unsupported?.models).toHaveLength(1)
})
it('merges same-named models into one view model with multiple referencingNodes', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('shared_model.safetensors', {
nodeId: '1',
widgetName: 'ckpt_name',
directory: 'checkpoints',
isAssetSupported: true
}),
makeModel('shared_model.safetensors', {
nodeId: '2',
widgetName: 'ckpt_name',
directory: 'checkpoints',
isAssetSupported: true
})
])
await nextTick()
expect(groups.missingModelGroups.value).toHaveLength(1)
const model = groups.missingModelGroups.value[0].models[0]
expect(model.name).toBe('shared_model.safetensors')
expect(model.referencingNodes).toHaveLength(2)
})
it('includes missing_model group in allErrorGroups', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([makeModel('model_a.safetensors')])
await nextTick()
const modelGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_model'
)
expect(modelGroup).toBeDefined()
})
})
})

View File

@@ -3,7 +3,6 @@ import type { MaybeRefOrGetter } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -24,11 +23,6 @@ import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type {
MissingModelCandidate,
MissingModelGroup
} from '@/platform/missingModel/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import {
isNodeExecutionId,
compareExecutionId
@@ -45,9 +39,6 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
/** Sentinel key for grouping non-asset-supported missing models. */
const UNSUPPORTED = Symbol('unsupported')
export interface MissingPackGroup {
packId: string | null
nodeTypes: MissingNodeType[]
@@ -240,7 +231,6 @@ export function useErrorGroups(
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const collapseState = reactive<Record<string, boolean>>({})
@@ -569,60 +559,6 @@ export function useErrorGroups(
return groups.sort((a, b) => a.priority - b.priority)
}
/** Groups missing models. Asset-supported models group by directory; others go into a separate group.
* Within each group, candidates with the same model name are merged into a single view model. */
const missingModelGroups = computed<MissingModelGroup[]>(() => {
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []
type GroupKey = string | null | typeof UNSUPPORTED
const map = new Map<
GroupKey,
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
>()
for (const c of candidates) {
const groupKey: GroupKey = c.isAssetSupported
? c.directory || null
: UNSUPPORTED
const existing = map.get(groupKey)
if (existing) {
existing.candidates.push(c)
} else {
map.set(groupKey, {
candidates: [c],
isAssetSupported: c.isAssetSupported
})
}
}
return Array.from(map.entries())
.sort(([dirA], [dirB]) => {
if (dirA === UNSUPPORTED) return 1
if (dirB === UNSUPPORTED) return -1
if (dirA === null) return 1
if (dirB === null) return -1
return dirA.localeCompare(dirB)
})
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
directory: typeof key === 'string' ? key : null,
models: groupCandidatesByName(groupCandidates),
isAssetSupported
}))
})
function buildMissingModelGroups(): ErrorGroup[] {
if (!missingModelGroups.value.length) return []
return [
{
type: 'missing_model' as const,
title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${missingModelGroups.value.reduce((count, group) => count + group.models.length, 0)})`,
priority: 2
}
]
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
@@ -630,11 +566,7 @@ export function useErrorGroups(
processNodeErrors(groupsMap)
processExecutionError(groupsMap)
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...toSortedGroups(groupsMap)
]
return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
})
const tabErrorGroups = computed<ErrorGroup[]>(() => {
@@ -648,11 +580,7 @@ export function useErrorGroups(
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...executionGroups
]
return [...buildMissingNodeGroups(), ...executionGroups]
})
const filteredGroups = computed<ErrorGroup[]>(() => {
@@ -687,7 +615,6 @@ export function useErrorGroups(
missingNodeCache,
groupedErrorMessages,
missingPackGroups,
missingModelGroups,
swapNodeGroups
}
}

View File

@@ -11,15 +11,13 @@
}"
@click="onLogoMenuClick($event)"
>
<div class="grid place-items-center-safe gap-0.5">
<i
class="col-span-full row-span-full icon-[lucide--chevron-down] size-3 translate-x-4 text-muted-foreground"
/>
<div class="flex items-center gap-0.5">
<ComfyLogo
alt="ComfyUI Logo"
class="comfyui-logo col-span-full row-span-full size-4.5"
class="comfyui-logo h-[18px] w-[18px]"
mode="fill"
/>
<i class="icon-[lucide--chevron-down] size-3 text-muted-foreground" />
</div>
</div>

View File

@@ -9,18 +9,6 @@ import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserButton from './CurrentUserButton.vue'
const mockFeatureFlags = vi.hoisted(() => ({
teamWorkspacesEnabled: false
}))
const mockTeamWorkspaceStore = vi.hoisted(() => ({
workspaceName: { value: '' },
initState: { value: 'idle' },
isInPersonalWorkspace: { value: false }
}))
const mockIsCloud = vi.hoisted(() => ({ value: false }))
// Mock all firebase modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
@@ -44,19 +32,16 @@ vi.mock('pinia', () => ({
// Mock the useFeatureFlags composable
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: mockFeatureFlags
flags: { teamWorkspacesEnabled: false }
}))
}))
// Mock the useTeamWorkspaceStore
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: vi.fn(() => mockTeamWorkspaceStore)
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
useTeamWorkspaceStore: vi.fn(() => ({
workspaceName: { value: '' },
initState: { value: 'idle' }
}))
}))
// Mock the useCurrentUser composable
@@ -79,16 +64,6 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
// Mock the WorkspaceProfilePic component
vi.mock('@/platform/workspace/components/WorkspaceProfilePic.vue', () => ({
default: {
name: 'WorkspaceProfilePicMock',
render() {
return h('div', 'WorkspaceProfilePic')
}
}
}))
// Mock the CurrentUserPopoverLegacy component
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
default: {
@@ -103,15 +78,9 @@ vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
describe('CurrentUserButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFeatureFlags.teamWorkspacesEnabled = false
mockTeamWorkspaceStore.workspaceName.value = ''
mockTeamWorkspaceStore.initState.value = 'idle'
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
mockIsCloud.value = false
})
const mountComponent = (options?: { stubButton?: boolean }): VueWrapper => {
const { stubButton = true } = options ?? {}
const mountComponent = (): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -130,7 +99,7 @@ describe('CurrentUserButton', () => {
hide: vi.fn()
}
},
...(stubButton ? { Button: true } : {})
Button: true
}
}
})
@@ -168,27 +137,4 @@ describe('CurrentUserButton', () => {
// Verify that popover.hide was called
expect(popoverHideSpy).toHaveBeenCalled()
})
it('shows UserAvatar in personal workspace', () => {
mockIsCloud.value = true
mockFeatureFlags.teamWorkspacesEnabled = true
mockTeamWorkspaceStore.initState.value = 'ready'
mockTeamWorkspaceStore.isInPersonalWorkspace.value = true
const wrapper = mountComponent({ stubButton: false })
expect(wrapper.html()).toContain('Avatar')
expect(wrapper.html()).not.toContain('WorkspaceProfilePic')
})
it('shows WorkspaceProfilePic in team workspace', () => {
mockIsCloud.value = true
mockFeatureFlags.teamWorkspacesEnabled = true
mockTeamWorkspaceStore.initState.value = 'ready'
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
mockTeamWorkspaceStore.workspaceName.value = 'My Team'
const wrapper = mountComponent({ stubButton: false })
expect(wrapper.html()).toContain('WorkspaceProfilePic')
expect(wrapper.html()).not.toContain('Avatar')
})
})

View File

@@ -98,21 +98,15 @@ const photoURL = computed<string | undefined>(
() => userPhotoUrl.value ?? undefined
)
const {
workspaceName: teamWorkspaceName,
initState,
isInPersonalWorkspace
} = storeToRefs(useTeamWorkspaceStore())
const { workspaceName: teamWorkspaceName, initState } = storeToRefs(
useTeamWorkspaceStore()
)
const showWorkspaceSkeleton = computed(
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'loading'
)
const showWorkspaceIcon = computed(
() =>
isCloud &&
teamWorkspacesEnabled.value &&
initState.value === 'ready' &&
!isInPersonalWorkspace.value
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'ready'
)
const workspaceName = computed(() => {

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const buttonVariants = cva({
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer touch-manipulation whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
variants: {
variant: {
secondary:

View File

@@ -1,213 +0,0 @@
<template>
<ComboboxRoot
v-model="modelValue"
v-model:open="isOpen"
ignore-filter
:disabled
:class="className"
>
<ComboboxAnchor
:class="
cn(
searchInputVariants({ size }),
disabled && 'pointer-events-none opacity-50'
)
"
@click="focus"
>
<Button
v-if="modelValue"
:class="cn('absolute', sizeConfig.clearPos)"
variant="textonly"
size="icon-sm"
:aria-label="$t('g.clear')"
@click.stop="clearSearch"
>
<i :class="cn('icon-[lucide--x]', sizeConfig.icon)" />
</Button>
<i
v-else-if="loading"
:class="
cn(
'pointer-events-none absolute icon-[lucide--loader-circle] animate-spin',
sizeConfig.iconPos,
sizeConfig.icon
)
"
/>
<i
v-else
:class="
cn(
'pointer-events-none absolute',
sizeConfig.iconPos,
sizeConfig.icon,
icon
)
"
/>
<ComboboxInput
ref="inputRef"
v-model="modelValue"
:class="
cn(
'size-full border-none bg-transparent outline-none',
sizeConfig.inputPl,
sizeConfig.inputText
)
"
:placeholder="placeholderText"
:auto-focus="autofocus"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
@keydown.enter="onEnterKey"
/>
</ComboboxAnchor>
<ComboboxContent
v-if="suggestions.length > 0"
position="popper"
:side-offset="4"
:class="
cn(
'z-50 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
'rounded-lg border border-border-default bg-base-background p-1 shadow-lg'
)
"
>
<ComboboxItem
v-for="(suggestion, index) in suggestions"
:key="suggestionKey(suggestion, index)"
:value="suggestionValue(suggestion)"
:class="
cn(
'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none',
'data-highlighted:bg-secondary-background-hover'
)
"
@select.prevent="onSelectSuggestion(suggestion)"
>
<slot name="suggestion" :suggestion>
{{ suggestionLabel(suggestion) }}
</slot>
</ComboboxItem>
</ComboboxContent>
</ComboboxRoot>
</template>
<script setup lang="ts" generic="T">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { SearchInputVariants } from './searchInput.variants'
import {
searchInputSizeConfig,
searchInputVariants
} from './searchInput.variants'
const { t } = useI18n()
const {
placeholder,
icon = 'icon-[lucide--search]',
autofocus = false,
loading = false,
disabled = false,
size = 'md',
suggestions = [],
optionLabel,
optionKey,
class: className
} = defineProps<{
placeholder?: string
icon?: string
autofocus?: boolean
loading?: boolean
disabled?: boolean
size?: SearchInputVariants['size']
suggestions?: T[]
optionLabel?: keyof T & string
optionKey?: keyof T & string
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{
select: [item: T]
}>()
const sizeConfig = computed(() => searchInputSizeConfig[size])
const modelValue = defineModel<string>({ required: true })
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
const isOpen = ref(false)
const isComposing = ref(false)
function focus() {
inputRef.value?.$el?.focus()
}
defineExpose({ focus })
const placeholderText = computed(
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
)
function clearSearch() {
modelValue.value = ''
focus()
}
function getItemProperty(item: T, key: keyof T & string): string {
if (typeof item === 'object' && item !== null) {
return String(item[key])
}
return String(item)
}
function suggestionLabel(item: T): string {
if (optionLabel) return getItemProperty(item, optionLabel)
return String(item)
}
function suggestionKey(item: T, index: number): string {
if (optionKey) return getItemProperty(item, optionKey)
return `${suggestionLabel(item)}-${index}`
}
function suggestionValue(item: T): string {
return suggestionLabel(item)
}
function onSelectSuggestion(item: T) {
modelValue.value = suggestionLabel(item)
isOpen.value = false
emit('select', item)
}
function onEnterKey(e: KeyboardEvent) {
if (isComposing.value) {
e.preventDefault()
e.stopPropagation()
}
}
watch(
() => suggestions,
(items) => {
isOpen.value = items.length > 0 && !!modelValue.value
}
)
</script>

View File

@@ -1,5 +1,10 @@
<template>
<ComboboxRoot :open="false" ignore-filter :disabled :class="className">
<ComboboxRoot
:ignore-filter="true"
:open="false"
:disabled="disabled"
:class="className"
>
<ComboboxAnchor
:class="
cn(

View File

@@ -0,0 +1,261 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Select from './Select.vue'
import SelectContent from './SelectContent.vue'
import SelectGroup from './SelectGroup.vue'
import SelectItem from './SelectItem.vue'
import SelectLabel from './SelectLabel.vue'
import SelectSeparator from './SelectSeparator.vue'
import SelectTrigger from './SelectTrigger.vue'
import SelectValue from './SelectValue.vue'
const meta = {
title: 'Components/Select',
component: Select,
tags: ['autodocs'],
argTypes: {
modelValue: {
control: 'text',
description: 'Selected value'
},
disabled: {
control: 'boolean',
description: 'When true, disables the select'
},
'onUpdate:modelValue': { action: 'update:modelValue' }
}
} satisfies Meta<typeof Select>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref(args.modelValue || '')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
<SelectItem value="grape">Grape</SelectItem>
<SelectItem value="orange">Orange</SelectItem>
</SelectContent>
</Select>
<div class="mt-4 text-sm text-muted-foreground">
Selected: {{ value || 'None' }}
</div>
`
}),
args: {
disabled: false
}
}
export const WithPlaceholder: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Choose an option..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
<SelectItem value="option3">Option 3</SelectItem>
</SelectContent>
</Select>
`
}),
args: {
disabled: false
}
}
export const Disabled: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('apple')
return { value, args }
},
template: `
<Select v-model="value" disabled>
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
</SelectContent>
</Select>
`
})
}
export const WithGroups: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a model type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Checkpoints</SelectLabel>
<SelectItem value="sd15">SD 1.5</SelectItem>
<SelectItem value="sdxl">SDXL</SelectItem>
<SelectItem value="flux">Flux</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>LoRAs</SelectLabel>
<SelectItem value="lora-style">Style LoRA</SelectItem>
<SelectItem value="lora-character">Character LoRA</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Other</SelectLabel>
<SelectItem value="vae">VAE</SelectItem>
<SelectItem value="embedding">Embedding</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div class="mt-4 text-sm text-muted-foreground">
Selected: {{ value || 'None' }}
</div>
`
}),
args: {
disabled: false
}
}
export const Scrollable: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
const items = Array.from({ length: 20 }, (_, i) => ({
value: `item-${i + 1}`,
label: `Option ${i + 1}`
}))
return { value, items, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in items"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</SelectItem>
</SelectContent>
</Select>
`
}),
args: {
disabled: false
}
}
export const CustomWidth: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<div class="space-y-4">
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-32">
<SelectValue placeholder="Small" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">A</SelectItem>
<SelectItem value="b">B</SelectItem>
<SelectItem value="c">C</SelectItem>
</SelectContent>
</Select>
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-full">
<SelectValue placeholder="Full width select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
<SelectItem value="option3">Option 3</SelectItem>
</SelectContent>
</Select>
</div>
`
}),
args: {
disabled: false
}
}

View File

@@ -64,7 +64,6 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
)
"
>
<slot name="prepend" />
<SelectScrollUpButton />
<SelectViewport
:class="

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { SelectGroupProps } from 'reka-ui'
import { SelectGroup } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectGroupProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectGroup :class="cn('w-full', className)" v-bind="restProps">
<slot />
</SelectGroup>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import { SelectLabel } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectLabelProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectLabel
v-bind="restProps"
:class="
cn(
'px-3 py-2 text-xs tracking-wide text-muted-foreground uppercase',
className
)
"
>
<slot />
</SelectLabel>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import { SelectSeparator } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectSeparatorProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectSeparator
v-bind="restProps"
:class="cn('-mx-1 my-1 h-px bg-border-default', className)"
/>
</template>

View File

@@ -5,41 +5,24 @@ import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const {
class: className,
size = 'lg',
invalid = false,
...restProps
} = defineProps<
SelectTriggerProps & {
class?: HTMLAttributes['class']
/** Trigger size: 'lg' (40px) or 'md' (32px) */
size?: 'lg' | 'md'
/** Show invalid (destructive) border */
invalid?: boolean
}
const { class: className, ...restProps } = defineProps<
SelectTriggerProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectTrigger
v-bind="restProps"
:aria-invalid="invalid || undefined"
:class="
cn(
'flex w-full cursor-pointer items-center justify-between select-none',
size === 'md' ? 'h-8 px-3 py-1 text-xs' : 'h-10 px-4 py-2 text-sm',
'rounded-lg',
'flex h-10 w-full cursor-pointer items-center justify-between select-none',
'rounded-lg px-4 py-2 text-sm',
'bg-secondary-background text-base-foreground',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
invalid
? 'border-destructive-background'
: 'border-transparent focus:border-node-component-border',
'focus:outline-none',
'focus:border-node-component-border focus:outline-none',
'data-placeholder:text-muted-foreground',
'disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-secondary-background',
'disabled:cursor-not-allowed disabled:opacity-60',
'[&>span]:truncate',
className
)

View File

@@ -25,7 +25,7 @@
:label="String(badge)"
severity="contrast"
variant="circle"
class="ml-auto min-h-5 min-w-5 px-1 text-base-background"
class="ml-auto"
/>
</div>
</template>

View File

@@ -324,8 +324,7 @@ function safeWidgetMapper(
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
slotMetadata: slotInfo,
slotName: name !== widget.name ? widget.name : undefined,
tooltip: widget.tooltip
slotName: name !== widget.name ? widget.name : undefined
}
} catch (error) {
return {

View File

@@ -357,8 +357,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
/**
* Initialize viewer in standalone mode (for asset preview).
* Creates the Load3d instance once; subsequent calls reuse it.
* Initialize viewer in standalone mode (for asset preview)
*/
const initializeStandaloneViewer = async (
containerRef: HTMLElement,
@@ -367,11 +366,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
if (!containerRef) return
try {
if (load3d) {
await loadStandaloneModel(modelUrl)
return
}
isStandaloneMode.value = true
load3d = new Load3d(containerRef, {
@@ -398,23 +392,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
setupAnimationEvents()
} catch (error) {
console.error('Error initializing standalone 3D viewer:', error)
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
}
}
/**
* Load a new model into an existing standalone viewer,
* reusing the same WebGLRenderer.
*/
const loadStandaloneModel = async (modelUrl: string) => {
if (!load3d) return
try {
await load3d.loadModel(modelUrl)
isSplatModel.value = load3d.isSplatModel()
isPlyModel.value = load3d.isPlyModel()
} catch (error) {
console.error('Error loading model in standalone viewer:', error)
useToastStore().addAlert('Failed to load 3D model')
}
}

View File

@@ -27,7 +27,7 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
`${namePrefix}.${depth}.${inputIndex}`,
Array.isArray(input)
? ['COMFY_DYNAMICCOMBO_V3', { options: getSpec(input, depth + 1) }]
: [input, { tooltip: `${groupIndex}` }]
: [input, {}]
])
return {
key: `${groupIndex}`,
@@ -106,13 +106,6 @@ describe('Dynamic Combos', () => {
expect(node.inputs[1].name).toBe('0.0.0.0')
expect(node.inputs[3].name).toBe('2.2.0.0')
})
test('Dynamically added widgets have tooltips', () => {
const node = testNode()
addDynamicCombo(node, [['INT'], ['STRING']])
expect.soft(node.widgets[1].tooltip).toBe('0')
node.widgets[0].value = '1'
expect.soft(node.widgets[1].tooltip).toBe('1')
})
})
describe('Autogrow', () => {
const inputsSpec = { required: { image: ['IMAGE', {}] } }

View File

@@ -93,7 +93,6 @@
"reportIssueTooltip": "Submit the error report to Comfy Org",
"reportSent": "Report Submitted",
"copyToClipboard": "Copy to Clipboard",
"copySystemInfo": "Copy System Info",
"copyAll": "Copy All",
"openNewIssue": "Open New Issue",
"showReport": "Show Report",
@@ -340,8 +339,7 @@
"conflicting": "Conflicting",
"inWorkflowSection": "IN WORKFLOW",
"allInWorkflow": "All in: {workflowName}",
"missingNodes": "Missing Nodes",
"unresolvedNodes": "Unresolved Nodes"
"missingNodes": "Missing Nodes"
},
"infoPanelEmpty": "Click an item to see the info",
"applyChanges": "Apply Changes",
@@ -406,10 +404,6 @@
"noDescription": "No description available",
"installSelected": "Install Selected",
"installAllMissingNodes": "Install All",
"unresolvedNodes": {
"title": "Unresolved Missing Nodes",
"message": "The following nodes are not installed and could not be found in the registry."
},
"allMissingNodesInstalled": "All missing nodes have been successfully installed",
"packsSelected": "packs selected",
"mixedSelectionMessage": "Cannot perform bulk action on mixed selection",
@@ -1052,8 +1046,6 @@
"logoProviderSeparator": " & "
},
"graphCanvasMenu": {
"canvasMode": "Canvas Mode",
"canvasToolbar": "Canvas Toolbar",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"resetView": "Reset View",
@@ -1957,7 +1949,6 @@
"exportSuccess": "Successfully exported model as {format}",
"fileLoadError": "Unable to find workflow in {fileName}",
"dropFileError": "Unable to process dropped item: {error}",
"missingModelVerificationFailed": "Failed to verify missing models. Some models may not be shown in the Errors tab.",
"interrupted": "Execution has been interrupted",
"pendingTasksDeleted": "Pending tasks deleted",
"nothingToGroup": "Nothing to group",
@@ -3190,7 +3181,6 @@
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"viewGraph": "View node graph",
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",
@@ -3389,33 +3379,6 @@
"viewInManager": "View in Manager",
"collapse": "Collapse",
"expand": "Expand"
},
"missingModels": {
"urlPlaceholder": "Paste Model URL (Civitai or Hugging Face)",
"or": "OR",
"useFromLibrary": "Use from Library",
"usingFromLibrary": "Using from Library",
"unsupportedUrl": "Only Civitai and Hugging Face URLs are supported.",
"metadataFetchFailed": "Failed to retrieve metadata. Please check the link and try again.",
"import": "Import",
"importing": "Importing...",
"imported": "Imported",
"importFailed": "Import failed",
"typeMismatch": "This model seems to be a \"{detectedType}\". Are you sure?",
"importAnyway": "Import Anyway",
"alreadyExistsInCategory": "This model already exists in \"{category}\"",
"customNodeDownloadDisabled": "Cloud environment does not support model imports for custom nodes in this section. Please use standard loader nodes or substitute with a model from the library below.",
"importNotSupported": "Import Not Supported",
"copyModelName": "Copy model name",
"confirmSelection": "Confirm selection",
"locateNode": "Locate node on canvas",
"cancelSelection": "Cancel selection",
"clearUrl": "Clear URL",
"expandNodes": "Show referencing nodes",
"collapseNodes": "Hide referencing nodes",
"unknownCategory": "Unknown",
"missingModelsTitle": "Missing Models",
"assetLoadTimeout": "Model detection timed out. Try reloading the workflow."
}
},
"errorOverlay": {

View File

@@ -348,8 +348,8 @@
"tooltip": "The maximum number of tasks that show in the queue history."
},
"Comfy_Queue_QPOV2": {
"name": "Docked job history/queue panel",
"tooltip": "Replaces the floating job queue panel with an equivalent job queue embedded in the job history side panel. You can disable this to return to the floating panel layout."
"name": "Use the unified job queue in the Assets side panel",
"tooltip": "Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout."
},
"Comfy_QueueButton_BatchCountLimit": {
"name": "Batch count limit",

View File

@@ -8,14 +8,9 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import CloudTemplate from './CloudTemplate.vue'
onMounted(() => {
document.getElementById('splash-loader')?.remove()
})
</script>

View File

@@ -1,193 +0,0 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type {
MissingModelGroup,
MissingModelViewModel
} from '@/platform/missingModel/types'
vi.mock('./MissingModelRow.vue', () => ({
default: {
name: 'MissingModelRow',
template: '<div class="model-row" />',
props: ['model', 'directory', 'showNodeIdBadge', 'isAssetSupported'],
emits: ['locate-model']
}
}))
import MissingModelCard from './MissingModelCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
missingModels: {
importNotSupported: 'Import Not Supported',
customNodeDownloadDisabled:
'Cloud environment does not support model imports for custom nodes.',
unknownCategory: 'Unknown Category'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
function makeViewModel(
name: string,
nodeId: string = '1'
): MissingModelViewModel {
return {
name,
representative: {
name,
nodeId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
isMissing: true
},
referencingNodes: [{ nodeId, widgetName: 'ckpt_name' }]
}
}
function makeGroup(
opts: {
directory?: string | null
isAssetSupported?: boolean
modelNames?: string[]
} = {}
): MissingModelGroup {
const names = opts.modelNames ?? ['model.safetensors']
return {
directory: 'directory' in opts ? (opts.directory ?? null) : 'checkpoints',
isAssetSupported: opts.isAssetSupported ?? true,
models: names.map((n, i) => makeViewModel(n, String(i + 1)))
}
}
function mountCard(
props: Partial<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}> = {}
) {
return mount(MissingModelCard, {
props: {
missingModelGroups: [makeGroup()],
showNodeIdBadge: false,
...props
},
global: {
plugins: [PrimeVue, i18n]
}
})
}
describe('MissingModelCard', () => {
describe('Rendering & Props', () => {
it('renders directory name in category header', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ directory: 'loras' })]
})
expect(wrapper.text()).toContain('loras')
})
it('renders translated unknown category when directory is null', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ directory: null })]
})
expect(wrapper.text()).toContain('Unknown Category')
})
it('renders model count in category header', () => {
const wrapper = mountCard({
missingModelGroups: [
makeGroup({ modelNames: ['a.safetensors', 'b.safetensors'] })
]
})
expect(wrapper.text()).toContain('(2)')
})
it('renders correct number of MissingModelRow components', () => {
const wrapper = mountCard({
missingModelGroups: [
makeGroup({
modelNames: ['a.safetensors', 'b.safetensors', 'c.safetensors']
})
]
})
expect(
wrapper.findAllComponents({ name: 'MissingModelRow' })
).toHaveLength(3)
})
it('renders multiple groups', () => {
const wrapper = mountCard({
missingModelGroups: [
makeGroup({ directory: 'checkpoints' }),
makeGroup({ directory: 'loras' })
]
})
expect(wrapper.text()).toContain('checkpoints')
expect(wrapper.text()).toContain('loras')
})
it('renders zero rows when missingModelGroups is empty', () => {
const wrapper = mountCard({ missingModelGroups: [] })
expect(
wrapper.findAllComponents({ name: 'MissingModelRow' })
).toHaveLength(0)
})
it('passes props correctly to MissingModelRow children', () => {
const wrapper = mountCard({ showNodeIdBadge: true })
const row = wrapper.findComponent({ name: 'MissingModelRow' })
expect(row.props('showNodeIdBadge')).toBe(true)
expect(row.props('isAssetSupported')).toBe(true)
expect(row.props('directory')).toBe('checkpoints')
})
})
describe('Asset Unsupported Group', () => {
it('shows "Import Not Supported" header for unsupported groups', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(wrapper.text()).toContain('Import Not Supported')
})
it('shows info notice for unsupported groups', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(wrapper.text()).toContain(
'Cloud environment does not support model imports'
)
})
it('hides info notice for supported groups', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: true })]
})
expect(wrapper.text()).not.toContain(
'Cloud environment does not support model imports'
)
})
})
describe('Event Handling', () => {
it('emits locateModel when child emits locate-model', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingModelRow' })
await row.vm.$emit('locate-model', '42')
expect(wrapper.emitted('locateModel')).toBeTruthy()
expect(wrapper.emitted('locateModel')?.[0]).toEqual(['42'])
})
})
})

View File

@@ -1,78 +0,0 @@
<template>
<div class="px-4 pb-2">
<!-- Category groups (by directory) -->
<div
v-for="group in missingModelGroups"
:key="`${group.isAssetSupported ? 'supported' : 'unsupported'}::${group.directory ?? '__unknown__'}`"
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
>
<!-- Category header -->
<div class="flex h-8 w-full items-center">
<p
class="min-w-0 flex-1 truncate text-sm font-medium"
:class="
!group.isAssetSupported || group.directory === null
? 'text-warning-background'
: 'text-destructive-background-hover'
"
>
<span v-if="!group.isAssetSupported" class="text-warning-background">
{{ t('rightSidePanel.missingModels.importNotSupported') }}
({{ group.models.length }})
</span>
<span v-else>
{{
group.directory ??
t('rightSidePanel.missingModels.unknownCategory')
}}
({{ group.models.length }})
</span>
</p>
</div>
<!-- Asset unsupported group notice -->
<div
v-if="!group.isAssetSupported"
class="flex items-start gap-1.5 px-0.5 py-1 pl-2"
>
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--info] size-3.5 shrink-0 text-muted-foreground"
/>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
</span>
</div>
<!-- Model rows -->
<div class="flex flex-col gap-1 overflow-hidden pl-2">
<MissingModelRow
v-for="model in group.models"
:key="model.name"
:model="model"
:directory="group.directory"
:show-node-id-badge="showNodeIdBadge"
:is-asset-supported="group.isAssetSupported"
@locate-model="emit('locateModel', $event)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
const { missingModelGroups, showNodeIdBadge } = defineProps<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
locateModel: [nodeId: string]
}>()
const { t } = useI18n()
</script>

View File

@@ -1,113 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
<span class="text-xs font-bold text-muted-foreground">
{{ t('rightSidePanel.missingModels.or') }}
</span>
</div>
<Select
:model-value="modelValue"
:disabled="options.length === 0"
@update:model-value="handleSelect"
>
<SelectTrigger
size="md"
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
>
<SelectValue
:placeholder="t('rightSidePanel.missingModels.useFromLibrary')"
/>
</SelectTrigger>
<SelectContent>
<template v-if="options.length > 4" #prepend>
<div class="px-1 pb-1.5">
<div
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
>
<i
aria-hidden="true"
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="filterQuery"
type="text"
:aria-label="t('g.searchPlaceholder', { subject: '' })"
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('g.searchPlaceholder', { subject: '' })"
@keydown.stop
/>
</div>
</div>
</template>
<SelectItem
v-for="option in filteredOptions"
:key="option.value"
:value="option.value"
class="text-xs"
>
{{ option.name }}
</SelectItem>
<div
v-if="filteredOptions.length === 0"
role="status"
class="px-3 py-2 text-xs text-muted-foreground"
>
{{ t('g.noResultsFound') }}
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFuse } from '@vueuse/integrations/useFuse'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
const { options, showDivider = false } = defineProps<{
modelValue: string | undefined
options: { name: string; value: string }[]
showDivider?: boolean
}>()
const emit = defineEmits<{
select: [value: string]
}>()
const { t } = useI18n()
const filterQuery = ref('')
watch(
() => options.length,
(len) => {
if (len <= 4) filterQuery.value = ''
}
)
const { results: fuseResults } = useFuse(filterQuery, () => options, {
fuseOptions: {
keys: ['name'],
threshold: 0.4,
ignoreLocation: true
},
matchAllWhenSearchEmpty: true
})
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
function handleSelect(value: unknown) {
if (typeof value === 'string') {
filterQuery.value = ''
emit('select', value)
}
}
</script>

View File

@@ -1,224 +0,0 @@
<template>
<div class="flex w-full flex-col pb-3">
<!-- Model header -->
<div class="flex h-8 w-full items-center gap-2">
<i
aria-hidden="true"
class="text-foreground icon-[lucide--file-check] size-4 shrink-0"
/>
<div class="flex min-w-0 flex-1 items-center">
<p
class="text-foreground min-w-0 truncate text-sm font-medium"
:title="model.name"
>
{{ model.name }} ({{ model.referencingNodes.length }})
</p>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 hover:bg-transparent"
:aria-label="t('rightSidePanel.missingModels.copyModelName')"
:title="t('rightSidePanel.missingModels.copyModelName')"
@click="copyToClipboard(model.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--copy] size-3.5 text-muted-foreground"
/>
</Button>
</div>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.confirmSelection')"
:disabled="!canConfirm"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
canConfirm ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="handleLibrarySelect"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="canConfirm ? 'text-primary' : 'text-foreground'"
/>
</Button>
<Button
v-if="model.referencingNodes.length > 0"
variant="textonly"
size="icon-sm"
:aria-label="
expanded
? t('rightSidePanel.missingModels.collapseNodes')
: t('rightSidePanel.missingModels.expandNodes')
"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-180'
)
"
@click="toggleModelExpand(modelKey)"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
/>
</Button>
</div>
<!-- Referencing nodes -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
>
<div
v-for="ref in model.referencingNodes"
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
class="flex h-7 items-center"
>
<span
v-if="showNodeIdBadge"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ ref.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
</p>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Status card -->
<TransitionCollapse>
<MissingModelStatusCard
v-if="selectedLibraryModel[modelKey]"
:model-name="selectedLibraryModel[modelKey]"
:is-download-active="isDownloadActive"
:download-status="downloadStatus"
:category-mismatch="importCategoryMismatch[modelKey]"
@cancel="cancelLibrarySelect(modelKey)"
/>
</TransitionCollapse>
<!-- Input area -->
<TransitionCollapse>
<div
v-if="!selectedLibraryModel[modelKey]"
class="mt-1 flex flex-col gap-2"
>
<template v-if="isAssetSupported">
<MissingModelUrlInput
:model-key="modelKey"
:directory="directory"
:type-mismatch="typeMismatch"
/>
</template>
<TransitionCollapse>
<MissingModelLibrarySelect
v-if="!urlInputs[modelKey]"
:model-value="getComboValue(model.representative)"
:options="comboOptions"
:show-divider="model.representative.isAssetSupported"
@select="handleComboSelect(modelKey, $event)"
/>
</TransitionCollapse>
</div>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import MissingModelStatusCard from '@/platform/missingModel/components/MissingModelStatusCard.vue'
import MissingModelUrlInput from '@/platform/missingModel/components/MissingModelUrlInput.vue'
import MissingModelLibrarySelect from '@/platform/missingModel/components/MissingModelLibrarySelect.vue'
import type { MissingModelViewModel } from '@/platform/missingModel/types'
import {
useMissingModelInteractions,
getModelStateKey,
getNodeDisplayLabel,
getComboValue
} from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
const { model, directory, isAssetSupported } = defineProps<{
model: MissingModelViewModel
directory: string | null
showNodeIdBadge: boolean
isAssetSupported: boolean
}>()
const emit = defineEmits<{
locateModel: [nodeId: string]
}>()
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const modelKey = computed(() =>
getModelStateKey(model.name, directory, isAssetSupported)
)
const downloadStatus = computed(() => getDownloadStatus(modelKey.value))
const comboOptions = computed(() => getComboOptions(model.representative))
const canConfirm = computed(() => isSelectionConfirmable(modelKey.value))
const expanded = computed(() => isModelExpanded(modelKey.value))
const typeMismatch = computed(() => getTypeMismatch(modelKey.value, directory))
const isDownloadActive = computed(
() =>
downloadStatus.value?.status === 'running' ||
downloadStatus.value?.status === 'created'
)
const store = useMissingModelStore()
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
storeToRefs(store)
const {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
getTypeMismatch,
getDownloadStatus
} = useMissingModelInteractions()
function handleLibrarySelect() {
confirmLibrarySelect(
modelKey.value,
model.name,
model.referencingNodes,
directory
)
}
</script>

View File

@@ -1,108 +0,0 @@
<template>
<div
aria-live="polite"
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
>
<!-- Progress bar fill -->
<div
v-if="isDownloadActive"
class="absolute inset-y-0 left-0 bg-primary/10 transition-all duration-200 ease-linear"
:style="{ width: (downloadStatus?.progress ?? 0) * 100 + '%' }"
/>
<div class="relative z-10 flex items-center gap-2">
<div class="flex size-8 shrink-0 items-center justify-center">
<i
v-if="categoryMismatch"
aria-hidden="true"
class="mt-0.5 icon-[lucide--triangle-alert] size-5 text-warning-background"
/>
<i
v-else-if="downloadStatus?.status === 'failed'"
aria-hidden="true"
class="icon-[lucide--circle-alert] size-5 text-destructive-background"
/>
<i
v-else-if="downloadStatus?.status === 'completed'"
aria-hidden="true"
class="icon-[lucide--check-circle] size-5 text-success-background"
/>
<i
v-else-if="isDownloadActive"
aria-hidden="true"
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--file-check] size-5 text-muted-foreground"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<span class="text-foreground truncate text-xs/tight font-medium">
{{ modelName }}
</span>
<span class="mt-0.5 text-xs/tight text-muted-foreground">
<template v-if="categoryMismatch">
{{
t('rightSidePanel.missingModels.alreadyExistsInCategory', {
category: categoryMismatch
})
}}
</template>
<template v-else-if="isDownloadActive">
{{ t('rightSidePanel.missingModels.importing') }}
{{ Math.round((downloadStatus?.progress ?? 0) * 100) }}%
</template>
<template v-else-if="downloadStatus?.status === 'completed'">
{{ t('rightSidePanel.missingModels.imported') }}
</template>
<template v-else-if="downloadStatus?.status === 'failed'">
{{
downloadStatus?.error ||
t('rightSidePanel.missingModels.importFailed')
}}
</template>
<template v-else>
{{ t('rightSidePanel.missingModels.usingFromLibrary') }}
</template>
</span>
</div>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.cancelSelection')"
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('cancel')"
>
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
const {
modelName,
isDownloadActive,
downloadStatus = null,
categoryMismatch = null
} = defineProps<{
modelName: string
isDownloadActive: boolean
downloadStatus?: AssetDownload | null
categoryMismatch?: string | null
}>()
const emit = defineEmits<{
cancel: []
}>()
const { t } = useI18n()
</script>

View File

@@ -1,156 +0,0 @@
<template>
<div
:class="
cn(
'flex h-8 items-center rounded-lg border border-transparent bg-secondary-background px-3 transition-colors focus-within:border-interface-stroke',
!canImportModels && 'cursor-pointer'
)
"
v-bind="upgradePromptAttrs"
@click="!canImportModels && showUploadDialog()"
>
<label :for="`url-input-${modelKey}`" class="sr-only">
{{ t('rightSidePanel.missingModels.urlPlaceholder') }}
</label>
<input
:id="`url-input-${modelKey}`"
type="text"
:value="urlInputs[modelKey] ?? ''"
:readonly="!canImportModels"
:placeholder="t('rightSidePanel.missingModels.urlPlaceholder')"
:class="
cn(
'text-foreground w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground',
!canImportModels && 'pointer-events-none opacity-60'
)
"
@input="
handleUrlInput(modelKey, ($event.target as HTMLInputElement).value)
"
/>
<Button
v-if="urlInputs[modelKey]"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.clearUrl')"
class="ml-1 shrink-0"
@click.stop="handleUrlInput(modelKey, '')"
>
<i aria-hidden="true" class="icon-[lucide--x] size-3.5" />
</Button>
</div>
<TransitionCollapse>
<div v-if="urlMetadata[modelKey]" class="flex flex-col gap-2">
<div class="flex items-center gap-2 px-0.5 pt-0.5">
<span class="text-foreground min-w-0 truncate text-xs font-bold">
{{ urlMetadata[modelKey]?.filename }}
</span>
<span
v-if="(urlMetadata[modelKey]?.content_length ?? 0) > 0"
class="shrink-0 rounded-sm bg-secondary-background-selected px-1.5 py-0.5 text-xs font-medium text-muted-foreground"
>
{{ formatSize(urlMetadata[modelKey]?.content_length ?? 0) }}
</span>
</div>
<div v-if="typeMismatch" class="flex items-start gap-1.5 px-0.5">
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--triangle-alert] size-3 shrink-0 text-warning-background"
/>
<span class="text-xs/tight text-warning-background">
{{
t('rightSidePanel.missingModels.typeMismatch', {
detectedType: typeMismatch
})
}}
</span>
</div>
<div class="pt-0.5">
<Button
variant="primary"
class="h-9 w-full justify-center gap-2 text-sm font-semibold"
:loading="urlImporting[modelKey]"
@click="handleImport(modelKey, directory)"
>
<i aria-hidden="true" class="icon-[lucide--download] size-4" />
{{
typeMismatch
? t('rightSidePanel.missingModels.importAnyway')
: t('rightSidePanel.missingModels.import')
}}
</Button>
</div>
</div>
</TransitionCollapse>
<TransitionCollapse>
<div
v-if="urlFetching[modelKey]"
aria-live="polite"
class="flex items-center justify-center py-2"
>
<i
aria-hidden="true"
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
<span class="sr-only">{{ t('g.loading') }}</span>
</div>
</TransitionCollapse>
<TransitionCollapse>
<div v-if="urlErrors[modelKey]" class="px-0.5" role="alert">
<span class="text-xs text-destructive-background-hover">
{{ urlErrors[modelKey] }}
</span>
</div>
</TransitionCollapse>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import { formatSize } from '@/utils/formatUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingModelInteractions } from '@/platform/missingModel/composables/useMissingModelInteractions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
const { modelKey, directory, typeMismatch } = defineProps<{
modelKey: string
directory: string | null
typeMismatch: string | null
}>()
const { t } = useI18n()
const { flags } = useFeatureFlags()
const canImportModels = computed(() => flags.privateModelsEnabled)
const { showUploadDialog } = useModelUpload()
const store = useMissingModelStore()
const { urlInputs, urlMetadata, urlFetching, urlErrors, urlImporting } =
storeToRefs(store)
const { handleUrlInput, handleImport } = useMissingModelInteractions()
const upgradePromptAttrs = computed(() =>
canImportModels.value
? {}
: {
role: 'button',
tabindex: 0,
onKeydown: (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
showUploadDialog()
}
}
}
)
</script>

View File

@@ -1,516 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
const mockGetNodeByExecutionId = vi.fn()
const mockResolveNodeDisplayName = vi.fn()
const mockValidateSourceUrl = vi.fn()
const mockGetAssetMetadata = vi.fn()
const mockGetAssetDisplayName = vi.fn((a: { name: string }) => a.name)
const mockGetAssetFilename = vi.fn((a: { name: string }) => a.name)
const mockGetAssets = vi.fn()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetAllNodeProviders = vi.fn()
const mockDownloadList = vi.fn(
(): Array<{ taskId: string; status: string }> => []
)
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: null
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: (...args: unknown[]) =>
mockGetNodeByExecutionId(...args)
}))
vi.mock('@/utils/nodeTitleUtil', () => ({
resolveNodeDisplayName: (...args: unknown[]) =>
mockResolveNodeDisplayName(...args)
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
getAssets: mockGetAssets,
updateModelsForNodeType: mockUpdateModelsForNodeType,
invalidateModelsForCategory: vi.fn(),
updateModelsForTag: vi.fn()
})
}))
vi.mock('@/stores/assetDownloadStore', () => ({
useAssetDownloadStore: () => ({
get downloadList() {
return mockDownloadList()
},
trackDownload: vi.fn()
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getAllNodeProviders: mockGetAllNodeProviders
})
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetMetadata: (...args: unknown[]) => mockGetAssetMetadata(...args)
}
}))
vi.mock('@/platform/assets/utils/assetMetadataUtils', () => ({
getAssetDisplayName: (a: { name: string }) => mockGetAssetDisplayName(a),
getAssetFilename: (a: { name: string }) => mockGetAssetFilename(a)
}))
vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
civitaiImportSource: {
type: 'civitai',
name: 'Civitai',
hostnames: ['civitai.com']
}
}))
vi.mock('@/platform/assets/importSources/huggingfaceImportSource', () => ({
huggingfaceImportSource: {
type: 'huggingface',
name: 'Hugging Face',
hostnames: ['huggingface.co']
}
}))
vi.mock('@/platform/assets/utils/importSourceUtil', () => ({
validateSourceUrl: (...args: unknown[]) => mockValidateSourceUrl(...args)
}))
import { app } from '@/scripts/app'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
getComboValue,
getModelStateKey,
getNodeDisplayLabel,
useMissingModelInteractions
} from './useMissingModelInteractions'
function makeCandidate(
overrides: Partial<MissingModelCandidate> = {}
): MissingModelCandidate {
return {
name: 'model.safetensors',
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
isMissing: true,
...overrides
}
}
describe('useMissingModelInteractions', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.resetAllMocks()
mockGetAssetDisplayName.mockImplementation((a: { name: string }) => a.name)
mockGetAssetFilename.mockImplementation((a: { name: string }) => a.name)
mockDownloadList.mockImplementation(
(): Array<{ taskId: string; status: string }> => []
)
;(app as { rootGraph: unknown }).rootGraph = null
})
describe('getModelStateKey', () => {
it('returns key with supported prefix when asset is supported', () => {
expect(getModelStateKey('model.safetensors', 'checkpoints', true)).toBe(
'supported::checkpoints::model.safetensors'
)
})
it('returns key with unsupported prefix when asset is not supported', () => {
expect(getModelStateKey('model.safetensors', 'loras', false)).toBe(
'unsupported::loras::model.safetensors'
)
})
it('handles null directory', () => {
expect(getModelStateKey('model.safetensors', null, true)).toBe(
'supported::::model.safetensors'
)
})
})
describe('getNodeDisplayLabel', () => {
it('returns fallback when graph is null', () => {
;(app as { rootGraph: unknown }).rootGraph = null
expect(getNodeDisplayLabel('1', 'Node #1')).toBe('Node #1')
})
it('calls resolveNodeDisplayName when graph is available', () => {
const mockGraph = {}
const mockNode = { id: 1 }
;(app as { rootGraph: unknown }).rootGraph = mockGraph
mockGetNodeByExecutionId.mockReturnValue(mockNode)
mockResolveNodeDisplayName.mockReturnValue('My Checkpoint')
const result = getNodeDisplayLabel('1', 'Node #1')
expect(mockGetNodeByExecutionId).toHaveBeenCalledWith(mockGraph, '1')
expect(result).toBe('My Checkpoint')
})
})
describe('getComboValue', () => {
it('returns undefined when node is not found', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue(null)
const result = getComboValue(makeCandidate())
expect(result).toBeUndefined()
})
it('returns undefined when widget is not found', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'other_widget', value: 'test' }]
})
const result = getComboValue(makeCandidate())
expect(result).toBeUndefined()
})
it('returns string value directly', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: 'v1-5.safetensors' }]
})
expect(getComboValue(makeCandidate())).toBe('v1-5.safetensors')
})
it('returns stringified number value', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: 42 }]
})
expect(getComboValue(makeCandidate())).toBe('42')
})
it('returns undefined for unexpected types', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue({
widgets: [{ name: 'ckpt_name', value: { complex: true } }]
})
expect(getComboValue(makeCandidate())).toBeUndefined()
})
it('returns undefined when nodeId is null', () => {
const result = getComboValue(makeCandidate({ nodeId: undefined }))
expect(result).toBeUndefined()
})
})
describe('toggleModelExpand / isModelExpanded', () => {
it('starts collapsed by default', () => {
const { isModelExpanded } = useMissingModelInteractions()
expect(isModelExpanded('key1')).toBe(false)
})
it('toggles to expanded', () => {
const { toggleModelExpand, isModelExpanded } =
useMissingModelInteractions()
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(true)
})
it('toggles back to collapsed', () => {
const { toggleModelExpand, isModelExpanded } =
useMissingModelInteractions()
toggleModelExpand('key1')
toggleModelExpand('key1')
expect(isModelExpanded('key1')).toBe(false)
})
})
describe('handleComboSelect', () => {
it('sets selectedLibraryModel in store', () => {
const store = useMissingModelStore()
const { handleComboSelect } = useMissingModelInteractions()
handleComboSelect('key1', 'model_v2.safetensors')
expect(store.selectedLibraryModel['key1']).toBe('model_v2.safetensors')
})
it('does not set value when undefined', () => {
const store = useMissingModelStore()
const { handleComboSelect } = useMissingModelInteractions()
handleComboSelect('key1', undefined)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
})
})
describe('isSelectionConfirmable', () => {
it('returns false when no selection exists', () => {
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns false when download is running', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importTaskIds['key1'] = 'task-123'
mockDownloadList.mockReturnValue([
{ taskId: 'task-123', status: 'running' }
])
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns false when importCategoryMismatch exists', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(false)
})
it('returns true when selection is ready with no active download', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
mockDownloadList.mockReturnValue([])
const { isSelectionConfirmable } = useMissingModelInteractions()
expect(isSelectionConfirmable('key1')).toBe(true)
})
})
describe('cancelLibrarySelect', () => {
it('clears selectedLibraryModel and importCategoryMismatch', () => {
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'model.safetensors'
store.importCategoryMismatch['key1'] = 'loras'
const { cancelLibrarySelect } = useMissingModelInteractions()
cancelLibrarySelect('key1')
expect(store.selectedLibraryModel['key1']).toBeUndefined()
expect(store.importCategoryMismatch['key1']).toBeUndefined()
})
})
describe('confirmLibrarySelect', () => {
it('updates widget values on referencing nodes and removes missing model', () => {
const mockGraph = {}
;(app as { rootGraph: unknown }).rootGraph = mockGraph
const widget1 = { name: 'ckpt_name', value: 'old_model.safetensors' }
const widget2 = { name: 'ckpt_name', value: 'old_model.safetensors' }
const node1 = { widgets: [widget1] }
const node2 = { widgets: [widget2] }
mockGetNodeByExecutionId.mockImplementation(
(_graph: unknown, id: string) => {
if (id === '10') return node1
if (id === '20') return node2
return null
}
)
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new_model.safetensors'
store.setMissingModels([
makeCandidate({ name: 'old_model.safetensors', nodeId: '10' }),
makeCandidate({ name: 'old_model.safetensors', nodeId: '20' })
])
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect(
'key1',
'old_model.safetensors',
[
{ nodeId: '10', widgetName: 'ckpt_name' },
{ nodeId: '20', widgetName: 'ckpt_name' }
],
null
)
expect(widget1.value).toBe('new_model.safetensors')
expect(widget2.value).toBe('new_model.safetensors')
expect(removeSpy).toHaveBeenCalledWith(
'old_model.safetensors',
new Set(['10', '20'])
)
expect(store.selectedLibraryModel['key1']).toBeUndefined()
})
it('does nothing when no selection exists', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
const store = useMissingModelStore()
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
})
it('does nothing when graph is null', () => {
;(app as { rootGraph: unknown }).rootGraph = null
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new.safetensors'
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], null)
expect(removeSpy).not.toHaveBeenCalled()
})
it('refreshes model cache when directory is provided', () => {
;(app as { rootGraph: unknown }).rootGraph = {}
mockGetNodeByExecutionId.mockReturnValue(null)
mockGetAllNodeProviders.mockReturnValue([
{ nodeDef: { name: 'CheckpointLoaderSimple' } }
])
const store = useMissingModelStore()
store.selectedLibraryModel['key1'] = 'new.safetensors'
const { confirmLibrarySelect } = useMissingModelInteractions()
confirmLibrarySelect('key1', 'model.safetensors', [], 'checkpoints')
expect(mockGetAllNodeProviders).toHaveBeenCalledWith('checkpoints')
})
})
describe('handleUrlInput', () => {
it('clears previous state on new input', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = { name: 'old' } as never
store.urlErrors['key1'] = 'old error'
store.urlFetching['key1'] = true
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(store.urlInputs['key1']).toBe('https://civitai.com/models/123')
expect(store.urlMetadata['key1']).toBeUndefined()
expect(store.urlErrors['key1']).toBeUndefined()
expect(store.urlFetching['key1']).toBe(false)
})
it('does not set debounce timer for empty input', () => {
const store = useMissingModelStore()
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', ' ')
expect(setTimerSpy).not.toHaveBeenCalled()
})
it('sets debounce timer for non-empty input', () => {
const store = useMissingModelStore()
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(setTimerSpy).toHaveBeenCalledWith(
'key1',
expect.any(Function),
800
)
})
it('clears previous debounce timer', () => {
const store = useMissingModelStore()
const clearTimerSpy = vi.spyOn(store, 'clearDebounceTimer')
const { handleUrlInput } = useMissingModelInteractions()
handleUrlInput('key1', 'https://civitai.com/models/123')
expect(clearTimerSpy).toHaveBeenCalledWith('key1')
})
})
describe('getTypeMismatch', () => {
it('returns null when groupDirectory is null', () => {
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', null)).toBeNull()
})
it('returns null when no metadata exists', () => {
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns null when metadata has no tags', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = { name: 'model', tags: [] } as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns null when detected type matches directory', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['checkpoints']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
it('returns detected type when it differs from directory', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['loras']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBe('loras')
})
it('returns null when tags contain no recognized model type', () => {
const store = useMissingModelStore()
store.urlMetadata['key1'] = {
name: 'model',
tags: ['other', 'random']
} as never
const { getTypeMismatch } = useMissingModelInteractions()
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
})
})
})

View File

@@ -1,393 +0,0 @@
import { useI18n } from 'vue-i18n'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import { assetService } from '@/platform/assets/services/assetService'
import {
getAssetDisplayName,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { app } from '@/scripts/app'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import type {
MissingModelCandidate,
MissingModelViewModel
} from '@/platform/missingModel/types'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
const importSources = [civitaiImportSource, huggingfaceImportSource]
const MODEL_TYPE_TAGS = [
'checkpoints',
'loras',
'vae',
'text_encoders',
'diffusion_models'
] as const
const URL_DEBOUNCE_MS = 800
export function getModelStateKey(
modelName: string,
directory: string | null,
isAssetSupported: boolean
): string {
const prefix = isAssetSupported ? 'supported' : 'unsupported'
return `${prefix}::${directory ?? ''}::${modelName}`
}
export function getNodeDisplayLabel(
nodeId: string | number,
fallback: string
): string {
const graph = app.rootGraph
if (!graph) return fallback
const node = getNodeByExecutionId(graph, String(nodeId))
return resolveNodeDisplayName(node, {
emptyLabel: fallback,
untitledLabel: fallback,
st
})
}
function getModelComboWidget(
model: MissingModelCandidate
): { node: LGraphNode; widget: IBaseWidget } | null {
if (model.nodeId == null) return null
const graph = app.rootGraph
if (!graph) return null
const node = getNodeByExecutionId(graph, String(model.nodeId))
if (!node) return null
const widget = node.widgets?.find((w) => w.name === model.widgetName)
if (!widget) return null
return { node, widget }
}
export function getComboValue(
model: MissingModelCandidate
): string | undefined {
const result = getModelComboWidget(model)
if (!result) return undefined
const val = result.widget.value
if (typeof val === 'string') return val
if (typeof val === 'number') return String(val)
return undefined
}
export function useMissingModelInteractions() {
const { t } = useI18n()
const store = useMissingModelStore()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
const _requestTokens: Record<string, symbol> = {}
function toggleModelExpand(key: string) {
store.modelExpandState[key] = !isModelExpanded(key)
}
function isModelExpanded(key: string): boolean {
return store.modelExpandState[key] ?? false
}
function getComboOptions(
model: MissingModelCandidate
): { name: string; value: string }[] {
if (model.isAssetSupported && model.nodeType) {
const assets = assetsStore.getAssets(model.nodeType) ?? []
return assets.map((asset) => ({
name: getAssetDisplayName(asset),
value: getAssetFilename(asset)
}))
}
const result = getModelComboWidget(model)
if (!result) return []
const values = result.widget.options?.values
if (!Array.isArray(values)) return []
return values.map((v) => ({ name: String(v), value: String(v) }))
}
function handleComboSelect(key: string, value: string | undefined) {
if (value) {
store.selectedLibraryModel[key] = value
}
}
function isSelectionConfirmable(key: string): boolean {
if (!store.selectedLibraryModel[key]) return false
if (store.importCategoryMismatch[key]) return false
const status = getDownloadStatus(key)
if (
status &&
(status.status === 'running' || status.status === 'created')
) {
return false
}
return true
}
function cancelLibrarySelect(key: string) {
delete store.selectedLibraryModel[key]
delete store.importCategoryMismatch[key]
}
/** Apply selected model to referencing nodes, removing only that model from the error list. */
function confirmLibrarySelect(
key: string,
modelName: string,
referencingNodes: MissingModelViewModel['referencingNodes'],
directory: string | null
) {
const value = store.selectedLibraryModel[key]
if (!value) return
const graph = app.rootGraph
if (!graph) return
if (directory) {
const providers = modelToNodeStore.getAllNodeProviders(directory)
void Promise.allSettled(
providers.map((provider) =>
assetsStore.updateModelsForNodeType(provider.nodeDef.name)
)
).then((results) => {
for (const r of results) {
if (r.status === 'rejected') {
console.warn(
'[Missing Model] Failed to refresh model cache:',
r.reason
)
}
}
})
}
for (const ref of referencingNodes) {
const node = getNodeByExecutionId(graph, String(ref.nodeId))
if (node) {
const widget = node.widgets?.find((w) => w.name === ref.widgetName)
if (widget) {
widget.value = value
widget.callback?.(value)
}
node.graph?.setDirtyCanvas(true, true)
}
}
delete store.selectedLibraryModel[key]
const nodeIdSet = new Set(referencingNodes.map((ref) => String(ref.nodeId)))
store.removeMissingModelByNameOnNodes(modelName, nodeIdSet)
}
function handleUrlInput(key: string, value: string) {
store.urlInputs[key] = value
delete store.urlMetadata[key]
delete store.urlErrors[key]
delete store.importCategoryMismatch[key]
store.urlFetching[key] = false
store.clearDebounceTimer(key)
const trimmed = value.trim()
if (!trimmed) return
store.setDebounceTimer(
key,
() => {
void fetchUrlMetadata(key, trimmed)
},
URL_DEBOUNCE_MS
)
}
async function fetchUrlMetadata(key: string, url: string) {
const source = importSources.find((s) => validateSourceUrl(url, s))
if (!source) {
store.urlErrors[key] = t('rightSidePanel.missingModels.unsupportedUrl')
return
}
const token = Symbol()
_requestTokens[key] = token
store.urlFetching[key] = true
delete store.urlErrors[key]
try {
const metadata = await assetService.getAssetMetadata(url)
if (_requestTokens[key] !== token) return
if (metadata.filename) {
try {
const decoded = decodeURIComponent(metadata.filename)
const basename = decoded.split(/[/\\]/).pop() ?? decoded
if (!basename.includes('..')) {
metadata.filename = basename
}
} catch {
/* keep original */
}
}
store.urlMetadata[key] = metadata
} catch (error) {
if (_requestTokens[key] !== token) return
store.urlErrors[key] =
error instanceof Error
? error.message
: t('rightSidePanel.missingModels.metadataFetchFailed')
} finally {
if (_requestTokens[key] === token) {
store.urlFetching[key] = false
}
}
}
function getTypeMismatch(
key: string,
groupDirectory: string | null
): string | null {
if (!groupDirectory) return null
const metadata = store.urlMetadata[key]
if (!metadata?.tags?.length) return null
const detectedType = metadata.tags.find((tag) =>
MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
)
if (!detectedType) return null
if (detectedType !== groupDirectory) {
return detectedType
}
return null
}
function getDownloadStatus(key: string) {
const taskId = store.importTaskIds[key]
if (!taskId) return null
return (
assetDownloadStore.downloadList.find((d) => d.taskId === taskId) ?? null
)
}
function handleAsyncPending(
key: string,
taskId: string,
modelType: string | undefined,
filename: string
) {
store.importTaskIds[key] = taskId
if (modelType) {
assetDownloadStore.trackDownload(taskId, modelType, filename)
}
}
function handleAsyncCompleted(modelType: string | undefined) {
if (modelType) {
assetsStore.invalidateModelsForCategory(modelType)
void assetsStore.updateModelsForTag(modelType)
}
}
function handleSyncResult(
key: string,
tags: string[],
modelType: string | undefined
) {
const existingCategory = tags.find((tag) =>
MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
)
if (existingCategory && modelType && existingCategory !== modelType) {
store.importCategoryMismatch[key] = existingCategory
}
}
async function handleImport(key: string, groupDirectory: string | null) {
const metadata = store.urlMetadata[key]
if (!metadata) return
const url = store.urlInputs[key]?.trim()
if (!url) return
const source = importSources.find((s) => validateSourceUrl(url, s))
if (!source) return
const token = Symbol()
_requestTokens[key] = token
store.urlImporting[key] = true
delete store.urlErrors[key]
delete store.importCategoryMismatch[key]
try {
const modelType = groupDirectory || undefined
const tags = modelType ? ['models', modelType] : ['models']
const filename = metadata.filename || metadata.name || 'model'
const result = await assetService.uploadAssetAsync({
source_url: url,
tags,
user_metadata: {
source: source.type,
source_url: url,
model_type: modelType
}
})
if (_requestTokens[key] !== token) return
if (result.type === 'async' && result.task.status !== 'completed') {
handleAsyncPending(key, result.task.task_id, modelType, filename)
} else if (result.type === 'async') {
handleAsyncCompleted(modelType)
} else if (result.type === 'sync') {
handleSyncResult(key, result.asset.tags ?? [], modelType)
}
store.selectedLibraryModel[key] = filename
} catch (error) {
if (_requestTokens[key] !== token) return
store.urlErrors[key] =
error instanceof Error
? error.message
: t('rightSidePanel.missingModels.importFailed')
} finally {
if (_requestTokens[key] === token) {
store.urlImporting[key] = false
}
}
}
return {
toggleModelExpand,
isModelExpanded,
getComboOptions,
handleComboSelect,
isSelectionConfirmable,
cancelLibrarySelect,
confirmLibrarySelect,
handleUrlInput,
getTypeMismatch,
getDownloadStatus,
handleImport
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,386 +0,0 @@
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { flattenWorkflowNodes } from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
MissingModelCandidate,
MissingModelViewModel,
EmbeddedModelWithSource
} from './types'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type {
IAssetWidget,
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
return widget.type === 'combo'
}
function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
return widget.type === 'asset'
}
export const MODEL_FILE_EXTENSIONS = new Set([
'.safetensors',
'.ckpt',
'.pt',
'.pth',
'.bin',
'.sft',
'.onnx',
'.gguf'
])
export function isModelFileName(name: string): boolean {
const lower = name.toLowerCase()
for (const ext of MODEL_FILE_EXTENSIONS) {
if (lower.endsWith(ext)) return true
}
return false
}
function resolveComboOptions(widget: IComboWidget): string[] {
const values = widget.options.values
if (!values) return []
if (typeof values === 'function') return values(widget)
if (Array.isArray(values)) return values
return Object.keys(values)
}
/**
* Scan COMBO and asset widgets on configured graph nodes for model-like values.
* Must be called after `graph.configure()` so widget name/value mappings are accurate.
*
* Non-asset-supported nodes: `isMissing` resolved immediately via widget options.
* Asset-supported nodes: `isMissing` left `undefined` for async verification.
*/
export function scanAllModelCandidates(
rootGraph: LGraph,
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
getDirectory?: (nodeType: string) => string | undefined
): MissingModelCandidate[] {
if (!rootGraph) return []
const allNodes = collectAllNodes(rootGraph)
const candidates: MissingModelCandidate[] = []
for (const node of allNodes) {
if (!node.widgets?.length) continue
const executionId = getExecutionIdByNode(rootGraph, node)
if (!executionId) continue
for (const widget of node.widgets) {
let candidate: MissingModelCandidate | null = null
if (isAssetWidget(widget)) {
candidate = scanAssetWidget(node, widget, executionId, getDirectory)
} else if (isComboWidget(widget)) {
candidate = scanComboWidget(
node,
widget,
executionId,
isAssetSupported,
getDirectory
)
}
if (candidate) candidates.push(candidate)
}
}
return candidates
}
function scanAssetWidget(
node: { type: string },
widget: IAssetWidget,
executionId: string,
getDirectory: ((nodeType: string) => string | undefined) | undefined
): MissingModelCandidate | null {
const value = widget.value
if (!value.trim()) return null
if (!isModelFileName(value)) return null
return {
nodeId: executionId as NodeId,
nodeType: node.type,
widgetName: widget.name,
isAssetSupported: true,
name: value,
directory: getDirectory?.(node.type),
isMissing: undefined
}
}
function scanComboWidget(
node: { type: string },
widget: IComboWidget,
executionId: string,
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
getDirectory: ((nodeType: string) => string | undefined) | undefined
): MissingModelCandidate | null {
const value = widget.value
if (typeof value !== 'string' || !value.trim()) return null
if (!isModelFileName(value)) return null
const nodeIsAssetSupported = isAssetSupported(node.type, widget.name)
const options = resolveComboOptions(widget)
const inOptions = options.includes(value)
return {
nodeId: executionId as NodeId,
nodeType: node.type,
widgetName: widget.name,
isAssetSupported: nodeIsAssetSupported,
name: value,
directory: getDirectory?.(node.type),
isMissing: nodeIsAssetSupported ? undefined : !inOptions
}
}
export async function enrichWithEmbeddedMetadata(
candidates: readonly MissingModelCandidate[],
graphData: ComfyWorkflowJSON,
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
): Promise<MissingModelCandidate[]> {
const allNodes = flattenWorkflowNodes(graphData)
const embeddedModels = collectEmbeddedModelsWithSource(allNodes, graphData)
const enriched = candidates.map((c) => ({ ...c }))
const candidatesByKey = new Map<string, MissingModelCandidate[]>()
for (const c of enriched) {
const dirKey = `${c.name}::${c.directory ?? ''}`
const dirList = candidatesByKey.get(dirKey)
if (dirList) dirList.push(c)
else candidatesByKey.set(dirKey, [c])
const nameKey = c.name
const nameList = candidatesByKey.get(nameKey)
if (nameList) nameList.push(c)
else candidatesByKey.set(nameKey, [c])
}
const deduped: EmbeddedModelWithSource[] = []
const enrichedKeys = new Set<string>()
for (const model of embeddedModels) {
const dedupeKey = `${model.name}::${model.directory}`
if (enrichedKeys.has(dedupeKey)) continue
enrichedKeys.add(dedupeKey)
deduped.push(model)
}
const unmatched: EmbeddedModelWithSource[] = []
for (const model of deduped) {
const dirKey = `${model.name}::${model.directory}`
const exact = candidatesByKey.get(dirKey)
const fallback = candidatesByKey.get(model.name)
const existing = exact?.length ? exact : fallback
if (existing) {
for (const c of existing) {
if (c.directory && c.directory !== model.directory) continue
c.directory ??= model.directory
c.url ??= model.url
c.hash ??= model.hash
c.hashType ??= model.hash_type
}
} else {
unmatched.push(model)
}
}
const settled = await Promise.allSettled(
unmatched.map(async (model) => {
const installed = await checkModelInstalled(model.name, model.directory)
if (installed) return null
const nodeIsAssetSupported = isAssetSupported
? isAssetSupported(model.sourceNodeType, model.sourceWidgetName)
: false
return {
nodeId: model.sourceNodeId,
nodeType: model.sourceNodeType,
widgetName: model.sourceWidgetName,
isAssetSupported: nodeIsAssetSupported,
name: model.name,
directory: model.directory,
url: model.url,
hash: model.hash,
hashType: model.hash_type,
isMissing: nodeIsAssetSupported ? undefined : true
} satisfies MissingModelCandidate
})
)
for (const r of settled) {
if (r.status === 'rejected') {
console.warn(
'[Missing Model Pipeline] checkModelInstalled failed:',
r.reason
)
continue
}
if (r.value) enriched.push(r.value)
}
return enriched
}
function collectEmbeddedModelsWithSource(
allNodes: ReturnType<typeof flattenWorkflowNodes>,
graphData: ComfyWorkflowJSON
): EmbeddedModelWithSource[] {
const result: EmbeddedModelWithSource[] = []
for (const node of allNodes) {
const selected = getSelectedModelsMetadata(
node as Parameters<typeof getSelectedModelsMetadata>[0]
)
if (!selected?.length) continue
for (const model of selected) {
result.push({
...model,
sourceNodeId: node.id,
sourceNodeType: node.type,
sourceWidgetName: findWidgetNameForModel(node, model.name)
})
}
}
// Workflow-level model entries have no originating node; sourceNodeId
// remains undefined and empty-string node type/widget are handled by
// groupCandidatesByName (no nodeId → no referencing node entry).
if (graphData.models?.length) {
for (const model of graphData.models) {
result.push({
...model,
sourceNodeType: '',
sourceWidgetName: ''
})
}
}
return result
}
function findWidgetNameForModel(
node: ReturnType<typeof flattenWorkflowNodes>[number],
modelName: string
): string {
if (Array.isArray(node.widgets_values) || !node.widgets_values) return ''
const wv = node.widgets_values as Record<string, unknown>
for (const [key, val] of Object.entries(wv)) {
if (val === modelName) return key
}
return ''
}
interface AssetVerifier {
updateModelsForNodeType: (nodeType: string) => Promise<void>
getAssets: (nodeType: string) => AssetItem[] | undefined
}
export async function verifyAssetSupportedCandidates(
candidates: MissingModelCandidate[],
signal?: AbortSignal,
assetsStore?: AssetVerifier
): Promise<void> {
if (signal?.aborted) return
const pendingNodeTypes = new Set<string>()
for (const c of candidates) {
if (c.isAssetSupported && c.isMissing === undefined) {
pendingNodeTypes.add(c.nodeType)
}
}
if (pendingNodeTypes.size === 0) return
const store =
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
const failedNodeTypes = new Set<string>()
await Promise.allSettled(
[...pendingNodeTypes].map(async (nodeType) => {
if (signal?.aborted) return
try {
await store.updateModelsForNodeType(nodeType)
} catch (err) {
failedNodeTypes.add(nodeType)
console.warn(
`[Missing Model Pipeline] Failed to load assets for ${nodeType}:`,
err
)
}
})
)
if (signal?.aborted) return
for (const c of candidates) {
if (!c.isAssetSupported || c.isMissing !== undefined) continue
if (failedNodeTypes.has(c.nodeType)) continue
const assets = store.getAssets(c.nodeType) ?? []
c.isMissing = !isAssetInstalled(c, assets)
}
}
function normalizePath(path: string): string {
return path.replace(/\\/g, '/')
}
function isAssetInstalled(
candidate: MissingModelCandidate,
assets: AssetItem[]
): boolean {
if (candidate.hash && candidate.hashType) {
const candidateHash = `${candidate.hashType}:${candidate.hash}`
if (assets.some((a) => a.asset_hash === candidateHash)) return true
}
const normalizedName = normalizePath(candidate.name)
return assets.some((a) => {
const f = normalizePath(getAssetFilename(a))
return f === normalizedName || f.endsWith('/' + normalizedName)
})
}
export function groupCandidatesByName(
candidates: MissingModelCandidate[]
): MissingModelViewModel[] {
const map = new Map<string, MissingModelViewModel>()
for (const c of candidates) {
const existing = map.get(c.name)
if (existing) {
if (c.nodeId) {
existing.referencingNodes.push({
nodeId: c.nodeId,
widgetName: c.widgetName
})
}
} else {
map.set(c.name, {
name: c.name,
representative: c,
referencingNodes: c.nodeId
? [{ nodeId: c.nodeId, widgetName: c.widgetName }]
: []
})
}
}
return Array.from(map.values())
}

View File

@@ -1,189 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
import { useMissingModelStore } from './missingModelStore'
function makeModelCandidate(
name: string,
opts: {
nodeId?: string | number
nodeType?: string
widgetName?: string
isAssetSupported?: boolean
} = {}
): MissingModelCandidate {
return {
name,
nodeId: opts.nodeId ?? '1',
nodeType: opts.nodeType ?? 'CheckpointLoaderSimple',
widgetName: opts.widgetName ?? 'ckpt_name',
isAssetSupported: opts.isAssetSupported ?? false,
isMissing: true
}
}
describe('missingModelStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('setMissingModels', () => {
it('sets missingModelCandidates with provided models', () => {
const store = useMissingModelStore()
store.setMissingModels([makeModelCandidate('model_a.safetensors')])
expect(store.missingModelCandidates).not.toBeNull()
expect(store.missingModelCandidates).toHaveLength(1)
expect(store.hasMissingModels).toBe(true)
})
it('clears missingModelCandidates when given empty array', () => {
const store = useMissingModelStore()
store.setMissingModels([makeModelCandidate('model_a.safetensors')])
expect(store.missingModelCandidates).not.toBeNull()
store.setMissingModels([])
expect(store.missingModelCandidates).toBeNull()
expect(store.hasMissingModels).toBe(false)
})
it('includes model count in missingModelCount', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors'),
makeModelCandidate('model_b.safetensors', { nodeId: '2' })
])
expect(store.missingModelCount).toBe(2)
})
})
describe('hasMissingModelOnNode', () => {
it('returns true when node has missing model', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
])
expect(store.hasMissingModelOnNode('5')).toBe(true)
})
it('returns false when node has no missing model', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
])
expect(store.hasMissingModelOnNode('99')).toBe(false)
})
it('returns false when no models are missing', () => {
const store = useMissingModelStore()
expect(store.hasMissingModelOnNode('1')).toBe(false)
})
})
describe('removeMissingModelByNameOnNodes', () => {
it('removes only the named model from specified nodes', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', {
nodeId: '1',
widgetName: 'ckpt_name'
}),
makeModelCandidate('model_b.safetensors', {
nodeId: '1',
widgetName: 'vae_name'
}),
makeModelCandidate('model_a.safetensors', {
nodeId: '2',
widgetName: 'ckpt_name'
})
])
store.removeMissingModelByNameOnNodes(
'model_a.safetensors',
new Set(['1'])
)
expect(store.missingModelCandidates).toHaveLength(2)
expect(store.missingModelCandidates![0].name).toBe('model_b.safetensors')
expect(store.missingModelCandidates![1].name).toBe('model_a.safetensors')
expect(String(store.missingModelCandidates![1].nodeId)).toBe('2')
})
it('sets missingModelCandidates to null when all removed', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
])
store.removeMissingModelByNameOnNodes(
'model_a.safetensors',
new Set(['1'])
)
expect(store.missingModelCandidates).toBeNull()
})
})
describe('clearMissingModels', () => {
it('clears missingModelCandidates and interaction state', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
])
store.urlInputs['test-key'] = 'https://example.com'
store.selectedLibraryModel['test-key'] = 'some-model'
expect(store.missingModelCandidates).not.toBeNull()
store.clearMissingModels()
expect(store.missingModelCandidates).toBeNull()
expect(store.hasMissingModels).toBe(false)
expect(store.urlInputs).toEqual({})
expect(store.selectedLibraryModel).toEqual({})
})
})
describe('isWidgetMissingModel', () => {
it('returns true when specific widget has missing model', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', {
nodeId: '5',
widgetName: 'ckpt_name'
})
])
expect(store.isWidgetMissingModel('5', 'ckpt_name')).toBe(true)
})
it('returns false for different widget on same node', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', {
nodeId: '5',
widgetName: 'ckpt_name'
})
])
expect(store.isWidgetMissingModel('5', 'lora_name')).toBe(false)
})
it('returns false when no models are missing', () => {
const store = useMissingModelStore()
expect(store.isWidgetMissingModel('1', 'ckpt_name')).toBe(false)
})
})
})

View File

@@ -1,201 +0,0 @@
import { defineStore } from 'pinia'
import { computed, onScopeDispose, ref } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
/**
* Missing model error state and interaction state.
* Separated from executionErrorStore to keep domain boundaries clean.
* The executionErrorStore composes from this store for aggregate error flags.
*/
export const useMissingModelStore = defineStore('missingModel', () => {
const canvasStore = useCanvasStore()
const missingModelCandidates = ref<MissingModelCandidate[] | null>(null)
const hasMissingModels = computed(
() => !!missingModelCandidates.value?.length
)
const missingModelCount = computed(
() => missingModelCandidates.value?.length ?? 0
)
const missingModelNodeIds = computed<Set<string>>(() => {
const ids = new Set<string>()
if (!missingModelCandidates.value) return ids
for (const m of missingModelCandidates.value) {
if (m.nodeId != null) ids.add(String(m.nodeId))
}
return ids
})
const missingModelWidgetKeys = computed<Set<string>>(() => {
const keys = new Set<string>()
if (!missingModelCandidates.value) return keys
for (const m of missingModelCandidates.value) {
keys.add(`${String(m.nodeId)}::${m.widgetName}`)
}
return keys
})
/**
* Set of all execution ID prefixes derived from missing model node IDs,
* including the missing model nodes themselves.
*
* Example: missing model on node "65:70:63" → Set { "65", "65:70", "65:70:63" }
*/
const missingModelAncestorExecutionIds = computed<Set<NodeExecutionId>>(
() => {
const ids = new Set<NodeExecutionId>()
for (const nodeId of missingModelNodeIds.value) {
for (const id of getAncestorExecutionIds(nodeId)) {
ids.add(id)
}
}
return ids
}
)
const activeMissingModelGraphIds = computed<Set<string>>(() => {
if (!app.rootGraph) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
missingModelAncestorExecutionIds.value
)
})
// Persists across component re-mounts so that download progress,
// URL inputs, etc. survive tab switches within the right-side panel.
const modelExpandState = ref<Record<string, boolean>>({})
const selectedLibraryModel = ref<Record<string, string>>({})
const importCategoryMismatch = ref<Record<string, string>>({})
const importTaskIds = ref<Record<string, string>>({})
const urlInputs = ref<Record<string, string>>({})
const urlMetadata = ref<Record<string, AssetMetadata | null>>({})
const urlFetching = ref<Record<string, boolean>>({})
const urlErrors = ref<Record<string, string>>({})
const urlImporting = ref<Record<string, boolean>>({})
const _urlDebounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
let _verificationAbortController: AbortController | null = null
onScopeDispose(cancelDebounceTimers)
function createVerificationAbortController(): AbortController {
_verificationAbortController?.abort()
_verificationAbortController = new AbortController()
return _verificationAbortController
}
function setMissingModels(models: MissingModelCandidate[]) {
missingModelCandidates.value = models.length ? models : null
}
function removeMissingModelByNameOnNodes(
modelName: string,
nodeIds: Set<string>
) {
if (!missingModelCandidates.value) return
missingModelCandidates.value = missingModelCandidates.value.filter(
(m) =>
m.name !== modelName ||
m.nodeId == null ||
!nodeIds.has(String(m.nodeId))
)
if (!missingModelCandidates.value.length)
missingModelCandidates.value = null
}
function hasMissingModelOnNode(nodeLocatorId: string): boolean {
return missingModelNodeIds.value.has(nodeLocatorId)
}
function isWidgetMissingModel(nodeId: string, widgetName: string): boolean {
return missingModelWidgetKeys.value.has(`${nodeId}::${widgetName}`)
}
function isContainerWithMissingModel(node: LGraphNode): boolean {
return activeMissingModelGraphIds.value.has(String(node.id))
}
function cancelDebounceTimers() {
for (const key of Object.keys(_urlDebounceTimers)) {
clearTimeout(_urlDebounceTimers[key])
delete _urlDebounceTimers[key]
}
}
function setDebounceTimer(
key: string,
callback: () => void,
delayMs: number
) {
if (_urlDebounceTimers[key]) {
clearTimeout(_urlDebounceTimers[key])
}
_urlDebounceTimers[key] = setTimeout(callback, delayMs)
}
function clearDebounceTimer(key: string) {
if (_urlDebounceTimers[key]) {
clearTimeout(_urlDebounceTimers[key])
delete _urlDebounceTimers[key]
}
}
function clearMissingModels() {
_verificationAbortController?.abort()
_verificationAbortController = null
missingModelCandidates.value = null
cancelDebounceTimers()
modelExpandState.value = {}
selectedLibraryModel.value = {}
importCategoryMismatch.value = {}
importTaskIds.value = {}
urlInputs.value = {}
urlMetadata.value = {}
urlFetching.value = {}
urlErrors.value = {}
urlImporting.value = {}
}
return {
missingModelCandidates,
hasMissingModels,
missingModelCount,
missingModelNodeIds,
activeMissingModelGraphIds,
setMissingModels,
removeMissingModelByNameOnNodes,
clearMissingModels,
createVerificationAbortController,
hasMissingModelOnNode,
isWidgetMissingModel,
isContainerWithMissingModel,
modelExpandState,
selectedLibraryModel,
importTaskIds,
importCategoryMismatch,
urlInputs,
urlMetadata,
urlFetching,
urlErrors,
urlImporting,
setDebounceTimer,
clearDebounceTimer
}
})

View File

@@ -1,53 +0,0 @@
import type {
ModelFile,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
/**
* A single (node, widget, model) binding detected by the missing model pipeline.
* The same model name may appear multiple times across different nodes.
*/
export interface MissingModelCandidate {
/** Undefined for workflow-level models not tied to a specific node. */
nodeId?: NodeId
nodeType: string
widgetName: string
isAssetSupported: boolean
name: string
directory?: string
url?: string
hash?: string
hashType?: string
/**
* - `true` — confirmed missing
* - `false` — confirmed installed
* - `undefined` — pending async verification (asset-supported nodes only)
*/
isMissing: boolean | undefined
}
export interface EmbeddedModelWithSource extends ModelFile {
/** Undefined for workflow-level models not tied to a specific node. */
sourceNodeId?: NodeId
sourceNodeType: string
sourceWidgetName: string
}
/** View model grouping multiple candidate references under a single model name. */
export interface MissingModelViewModel {
name: string
representative: MissingModelCandidate
referencingNodes: Array<{
nodeId: NodeId
widgetName: string
}>
}
/** A category group of missing models sharing the same directory. */
export interface MissingModelGroup {
directory: string | null
models: MissingModelViewModel[]
isAssetSupported: boolean
}

View File

@@ -150,41 +150,6 @@ describe('fetchJobs', () => {
expect(result).toEqual([])
})
it('parses batch containing text-only preview outputs', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve(
createMockResponse([
createMockJob('image-job', 'completed', {
preview_output: {
filename: 'output.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
}),
createMockJob('text-job', 'completed', {
preview_output: {
content: 'some generated text',
nodeId: '5',
mediaType: 'text'
}
}),
createMockJob('no-preview-job', 'completed')
])
)
})
const result = await fetchHistory(mockFetch)
expect(result).toHaveLength(3)
expect(result[0].id).toBe('image-job')
expect(result[1].id).toBe('text-job')
expect(result[2].id).toBe('no-preview-job')
})
})
describe('fetchQueue', () => {

View File

@@ -18,16 +18,14 @@ const zJobStatus = z.enum([
'cancelled'
])
const zPreviewOutput = z
.object({
filename: z.string().optional(),
subfolder: z.string().optional(),
type: resultItemType.optional(),
nodeId: z.string(),
mediaType: z.string(),
display_name: z.string().optional()
})
.passthrough()
const zPreviewOutput = z.object({
filename: z.string(),
subfolder: z.string(),
type: resultItemType,
nodeId: z.string(),
mediaType: z.string(),
display_name: z.string().optional()
})
/**
* Execution error from Jobs API.

View File

@@ -31,7 +31,6 @@ export type RemoteConfig = {
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
posthog_debug?: boolean
subscription_required?: boolean
server_health_alert?: ServerHealthAlert
max_upload_size?: number

View File

@@ -1201,10 +1201,10 @@ export const CORE_SETTINGS: SettingParams[] = [
{
id: 'Comfy.Queue.QPOV2',
category: ['Comfy', 'Queue', 'Layout'],
name: 'Docked job history/queue panel',
name: 'Use the unified job queue in the Assets side panel',
type: 'boolean',
tooltip:
'Replaces the floating job queue panel with an equivalent job queue embedded in the job history side panel. You can disable this to return to the floating panel layout.',
'Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout.',
defaultValue: false,
experimental: true
},

Some files were not shown because too many files have changed in this diff Show More