mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-22 15:54:09 +00:00
merge main into rh-test
This commit is contained in:
42
src/platform/assets/components/AssetBadgeGroup.vue
Normal file
42
src/platform/assets/components/AssetBadgeGroup.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="absolute bottom-2 right-2 flex flex-wrap justify-end gap-1">
|
||||
<span
|
||||
v-for="badge in badges"
|
||||
:key="badge.label"
|
||||
:class="
|
||||
cn(
|
||||
'px-2 py-1 rounded text-xs font-medium uppercase tracking-wider text-white',
|
||||
getBadgeColor(badge.type)
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type AssetBadge = {
|
||||
label: string
|
||||
type: 'type' | 'base' | 'size'
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
badges: AssetBadge[]
|
||||
}>()
|
||||
|
||||
function getBadgeColor(type: AssetBadge['type']): string {
|
||||
switch (type) {
|
||||
case 'type':
|
||||
return 'bg-blue-100/90 dark-theme:bg-blue-100/80'
|
||||
case 'base':
|
||||
return 'bg-success-100/90 dark-theme:bg-success-100/80'
|
||||
case 'size':
|
||||
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
|
||||
default:
|
||||
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
179
src/platform/assets/components/AssetBrowserModal.stories.ts
Normal file
179
src/platform/assets/components/AssetBrowserModal.stories.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import {
|
||||
createMockAssets,
|
||||
mockAssets
|
||||
} from '@/platform/assets/fixtures/ui-mock-assets'
|
||||
|
||||
// Story arguments interface
|
||||
interface StoryArgs {
|
||||
nodeType: string
|
||||
inputName: string
|
||||
currentValue: string
|
||||
showLeftPanel?: boolean
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Platform/Assets/AssetBrowserModal',
|
||||
component: AssetBrowserModal,
|
||||
parameters: {
|
||||
layout: 'fullscreen'
|
||||
},
|
||||
argTypes: {
|
||||
nodeType: {
|
||||
control: 'select',
|
||||
options: ['CheckpointLoaderSimple', 'VAELoader', 'ControlNetLoader'],
|
||||
description: 'ComfyUI node type for context'
|
||||
},
|
||||
inputName: {
|
||||
control: 'select',
|
||||
options: ['ckpt_name', 'vae_name', 'control_net_name'],
|
||||
description: 'Widget input name'
|
||||
},
|
||||
currentValue: {
|
||||
control: 'text',
|
||||
description: 'Current selected asset value'
|
||||
},
|
||||
showLeftPanel: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show the left panel with categories'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Modal Layout Stories
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: '',
|
||||
showLeftPanel: true
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { AssetBrowserModal },
|
||||
setup() {
|
||||
const onAssetSelect = (asset: AssetDisplayItem) => {
|
||||
console.log('Selected asset:', asset)
|
||||
}
|
||||
const onClose = () => {
|
||||
console.log('Modal closed')
|
||||
}
|
||||
|
||||
return {
|
||||
...args,
|
||||
onAssetSelect,
|
||||
onClose,
|
||||
assets: mockAssets
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
|
||||
<AssetBrowserModal
|
||||
:node-type="nodeType"
|
||||
:input-name="inputName"
|
||||
:show-left-panel="showLeftPanel"
|
||||
:assets="assets"
|
||||
@asset-select="onAssetSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
// Story demonstrating single asset type (auto-hides left panel)
|
||||
export const SingleAssetType: Story = {
|
||||
args: {
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: '',
|
||||
showLeftPanel: false
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { AssetBrowserModal },
|
||||
setup() {
|
||||
const onAssetSelect = (asset: AssetDisplayItem) => {
|
||||
console.log('Selected asset:', asset)
|
||||
}
|
||||
const onClose = () => {
|
||||
console.log('Modal closed')
|
||||
}
|
||||
|
||||
// Create assets with only one type (checkpoints)
|
||||
const singleTypeAssets = createMockAssets(15).map((asset) => ({
|
||||
...asset,
|
||||
type: 'checkpoint'
|
||||
}))
|
||||
|
||||
return { ...args, onAssetSelect, onClose, assets: singleTypeAssets }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
|
||||
<AssetBrowserModal
|
||||
:node-type="nodeType"
|
||||
:input-name="inputName"
|
||||
:show-left-panel="showLeftPanel"
|
||||
:assets="assets"
|
||||
@asset-select="onAssetSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Modal with assets of only one type (checkpoint) - left panel auto-hidden.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Story with left panel explicitly hidden
|
||||
export const NoLeftPanel: Story = {
|
||||
args: {
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: '',
|
||||
showLeftPanel: false
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { AssetBrowserModal },
|
||||
setup() {
|
||||
const onAssetSelect = (asset: AssetDisplayItem) => {
|
||||
console.log('Selected asset:', asset)
|
||||
}
|
||||
const onClose = () => {
|
||||
console.log('Modal closed')
|
||||
}
|
||||
|
||||
return { ...args, onAssetSelect, onClose, assets: mockAssets }
|
||||
},
|
||||
template: `
|
||||
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
|
||||
<AssetBrowserModal
|
||||
:node-type="nodeType"
|
||||
:input-name="inputName"
|
||||
:show-left-panel="showLeftPanel"
|
||||
:assets="assets"
|
||||
@asset-select="onAssetSelect"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Modal with left panel explicitly disabled via showLeftPanel=false.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
95
src/platform/assets/components/AssetBrowserModal.vue
Normal file
95
src/platform/assets/components/AssetBrowserModal.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<BaseModalLayout
|
||||
data-component-id="AssetBrowserModal"
|
||||
class="size-full max-h-full max-w-full min-w-0"
|
||||
:content-title="contentTitle"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template v-if="shouldShowLeftPanel" #leftPanel>
|
||||
<LeftSidePanel
|
||||
v-model="selectedCategory"
|
||||
data-component-id="AssetBrowserModal-LeftSidePanel"
|
||||
:nav-items="availableCategories"
|
||||
>
|
||||
<template #header-icon>
|
||||
<div class="icon-[lucide--folder] size-4" />
|
||||
</template>
|
||||
<template #header-title>{{ $t('assetBrowser.browseAssets') }}</template>
|
||||
</LeftSidePanel>
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
size="lg"
|
||||
:placeholder="$t('assetBrowser.searchAssetsPlaceholder')"
|
||||
class="max-w-96"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #contentFilter>
|
||||
<AssetFilterBar :assets="assets" @filter-change="updateFilters" />
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<AssetGrid
|
||||
:assets="filteredAssets"
|
||||
@asset-select="handleAssetSelectAndEmit"
|
||||
/>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
nodeType?: string
|
||||
inputName?: string
|
||||
onSelect?: (assetPath: string) => void
|
||||
onClose?: () => void
|
||||
showLeftPanel?: boolean
|
||||
assets?: AssetItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'asset-select': [asset: AssetDisplayItem]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
provide(OnCloseKey, props.onClose ?? (() => {}))
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
selectedCategory,
|
||||
availableCategories,
|
||||
contentTitle,
|
||||
filteredAssets,
|
||||
selectAssetWithCallback,
|
||||
updateFilters
|
||||
} = useAssetBrowser(props.assets)
|
||||
|
||||
const shouldShowLeftPanel = computed(() => {
|
||||
return props.showLeftPanel ?? true
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
props.onClose?.()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
|
||||
emit('asset-select', asset)
|
||||
await selectAssetWithCallback(asset.id, props.onSelect)
|
||||
}
|
||||
</script>
|
||||
182
src/platform/assets/components/AssetCard.stories.ts
Normal file
182
src/platform/assets/components/AssetCard.stories.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import AssetCard from '@/platform/assets/components/AssetCard.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets'
|
||||
|
||||
// Use the first mock asset as base and transform it to display format
|
||||
const baseAsset = mockAssets[0]
|
||||
const createAssetData = (
|
||||
overrides: Partial<AssetDisplayItem> = {}
|
||||
): AssetDisplayItem => ({
|
||||
...baseAsset,
|
||||
description:
|
||||
'High-quality realistic images with perfect detail and natural lighting effects for professional photography',
|
||||
formattedSize: '2.1 GB',
|
||||
badges: [
|
||||
{ label: 'checkpoints', type: 'type' },
|
||||
{ label: '2.1 GB', type: 'size' }
|
||||
],
|
||||
stats: {
|
||||
formattedDate: '3/15/25',
|
||||
downloadCount: '1.8k',
|
||||
stars: '4.2k'
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
|
||||
const meta: Meta<typeof AssetCard> = {
|
||||
title: 'Platform/Assets/AssetCard',
|
||||
component: AssetCard,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
asset: createAssetData(),
|
||||
interactive: true
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
|
||||
})
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Default AssetCard with complete data including badges and all stats.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const NonInteractive: Story = {
|
||||
args: {
|
||||
asset: createAssetData(),
|
||||
interactive: false
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
|
||||
})
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'AssetCard in non-interactive mode - renders as div without button semantics.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EdgeCases: Story = {
|
||||
render: () => ({
|
||||
components: { AssetCard },
|
||||
setup() {
|
||||
const edgeCases = [
|
||||
// Default case for comparison
|
||||
createAssetData({
|
||||
name: 'Complete Data',
|
||||
description: 'Asset with all data present for comparison'
|
||||
}),
|
||||
// No badges
|
||||
createAssetData({
|
||||
id: 'no-badges',
|
||||
name: 'No Badges',
|
||||
description: 'Testing graceful handling when badges are not provided',
|
||||
badges: []
|
||||
}),
|
||||
// No stars
|
||||
createAssetData({
|
||||
id: 'no-stars',
|
||||
name: 'No Stars',
|
||||
description: 'Testing missing stars data gracefully',
|
||||
stats: {
|
||||
downloadCount: '1.8k',
|
||||
formattedDate: '3/15/25'
|
||||
}
|
||||
}),
|
||||
// No downloads
|
||||
createAssetData({
|
||||
id: 'no-downloads',
|
||||
name: 'No Downloads',
|
||||
description: 'Testing missing downloads data gracefully',
|
||||
stats: {
|
||||
stars: '4.2k',
|
||||
formattedDate: '3/15/25'
|
||||
}
|
||||
}),
|
||||
// No date
|
||||
createAssetData({
|
||||
id: 'no-date',
|
||||
name: 'No Date',
|
||||
description: 'Testing missing date data gracefully',
|
||||
stats: {
|
||||
stars: '4.2k',
|
||||
downloadCount: '1.8k'
|
||||
}
|
||||
}),
|
||||
// No stats at all
|
||||
createAssetData({
|
||||
id: 'no-stats',
|
||||
name: 'No Stats',
|
||||
description: 'Testing when all stats are missing',
|
||||
stats: {}
|
||||
}),
|
||||
// Long description
|
||||
createAssetData({
|
||||
id: 'long-desc',
|
||||
name: 'Long Description',
|
||||
description:
|
||||
'This is a very long description that should demonstrate how the component handles text overflow and truncation with ellipsis. The description continues with even more content to ensure we test the 2-line clamp behavior properly and see how it renders when there is significantly more text than can fit in the allocated space.'
|
||||
}),
|
||||
// Minimal data
|
||||
createAssetData({
|
||||
id: 'minimal',
|
||||
name: 'Minimal',
|
||||
description: 'Basic model',
|
||||
tags: ['models'],
|
||||
badges: [],
|
||||
stats: {}
|
||||
})
|
||||
]
|
||||
|
||||
return { edgeCases }
|
||||
},
|
||||
template: `
|
||||
<div class="grid grid-cols-4 gap-6 p-8 bg-gray-50 dark-theme:bg-gray-900">
|
||||
<AssetCard
|
||||
v-for="asset in edgeCases"
|
||||
:key="asset.id"
|
||||
:asset="asset"
|
||||
:interactive="true"
|
||||
@select="(asset) => console.log('Selected:', asset)"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'All AssetCard edge cases in a grid layout to test graceful handling of missing data, badges, stats, and long descriptions.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
109
src/platform/assets/components/AssetCard.vue
Normal file
109
src/platform/assets/components/AssetCard.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<component
|
||||
:is="interactive ? 'button' : 'div'"
|
||||
data-component-id="AssetCard"
|
||||
:data-asset-id="asset.id"
|
||||
v-bind="elementProps"
|
||||
:class="
|
||||
cn(
|
||||
// Base layout and container styles (always applied)
|
||||
'rounded-xl overflow-hidden transition-all duration-200',
|
||||
interactive && 'group',
|
||||
// Button-specific styles
|
||||
interactive && [
|
||||
'appearance-none bg-transparent p-0 m-0 font-inherit text-inherit outline-none cursor-pointer text-left',
|
||||
'bg-gray-100 dark-theme:bg-charcoal-800',
|
||||
'hover:bg-gray-200 dark-theme:hover:bg-charcoal-600',
|
||||
'border-none',
|
||||
'focus:outline-solid outline-blue-100 outline-4'
|
||||
],
|
||||
// Div-specific styles
|
||||
!interactive && 'bg-gray-100 dark-theme:bg-charcoal-800'
|
||||
)
|
||||
"
|
||||
@click="interactive && $emit('select', asset)"
|
||||
@keydown.enter="interactive && $emit('select', asset)"
|
||||
>
|
||||
<div class="relative w-full aspect-square overflow-hidden">
|
||||
<div
|
||||
class="w-full h-full bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-600 flex items-center justify-center"
|
||||
></div>
|
||||
<AssetBadgeGroup :badges="asset.badges" />
|
||||
</div>
|
||||
<div :class="cn('p-4 h-32 flex flex-col justify-between')">
|
||||
<div>
|
||||
<h3
|
||||
:class="
|
||||
cn(
|
||||
'mb-2 m-0 text-base font-semibold overflow-hidden text-ellipsis whitespace-nowrap',
|
||||
'text-slate-800',
|
||||
'dark-theme:text-white'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ asset.name }}
|
||||
</h3>
|
||||
<p
|
||||
:class="
|
||||
cn(
|
||||
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
|
||||
'text-stone-100',
|
||||
'dark-theme:text-slate-100'
|
||||
)
|
||||
"
|
||||
:title="asset.description"
|
||||
>
|
||||
{{ asset.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex gap-4 text-xs',
|
||||
'text-stone-400',
|
||||
'dark-theme:text-stone-300'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span v-if="asset.stats.stars" class="flex items-center gap-1">
|
||||
<i-lucide:star class="size-3" />
|
||||
{{ asset.stats.stars }}
|
||||
</span>
|
||||
<span v-if="asset.stats.downloadCount" class="flex items-center gap-1">
|
||||
<i-lucide:download class="size-3" />
|
||||
{{ asset.stats.downloadCount }}
|
||||
</span>
|
||||
<span v-if="asset.stats.formattedDate" class="flex items-center gap-1">
|
||||
<i-lucide:clock class="size-3" />
|
||||
{{ asset.stats.formattedDate }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
asset: AssetDisplayItem
|
||||
interactive?: boolean
|
||||
}>()
|
||||
|
||||
const elementProps = computed(() =>
|
||||
props.interactive
|
||||
? {
|
||||
type: 'button',
|
||||
'aria-label': `Select asset ${props.asset.name}`
|
||||
}
|
||||
: {}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
select: [asset: AssetDisplayItem]
|
||||
}>()
|
||||
</script>
|
||||
135
src/platform/assets/components/AssetFilterBar.stories.ts
Normal file
135
src/platform/assets/components/AssetFilterBar.stories.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import {
|
||||
createAssetWithSpecificBaseModel,
|
||||
createAssetWithSpecificExtension,
|
||||
createAssetWithoutBaseModel,
|
||||
createAssetWithoutExtension
|
||||
} from '@/platform/assets/fixtures/ui-mock-assets'
|
||||
|
||||
import AssetFilterBar from './AssetFilterBar.vue'
|
||||
|
||||
const meta: Meta<typeof AssetFilterBar> = {
|
||||
title: 'Platform/Assets/AssetFilterBar',
|
||||
component: AssetFilterBar,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Filter bar for asset browser that dynamically shows/hides filters based on available options.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: `
|
||||
<div class="min-h-screen bg-white dark-theme:bg-charcoal-900">
|
||||
<div class="bg-gray-50 dark-theme:bg-charcoal-800 border-b border-gray-200 dark-theme:border-charcoal-600">
|
||||
<story />
|
||||
</div>
|
||||
<div class="p-6 text-sm text-gray-600 dark-theme:text-gray-400">
|
||||
<p>Filter bar with proper chrome styling showing contextual background and borders.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
],
|
||||
argTypes: {
|
||||
assets: {
|
||||
description: 'Array of assets to generate filter options from'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const BothFiltersVisible: Story = {
|
||||
args: {
|
||||
assets: [
|
||||
createAssetWithSpecificExtension('safetensors'),
|
||||
createAssetWithSpecificExtension('ckpt'),
|
||||
createAssetWithSpecificBaseModel('sd15'),
|
||||
createAssetWithSpecificBaseModel('sdxl')
|
||||
]
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows both file format and base model filters when assets contain both types of options.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const OnlyFileFormatFilter: Story = {
|
||||
args: {
|
||||
assets: [
|
||||
// Assets with extensions but explicitly NO base models
|
||||
{
|
||||
...createAssetWithSpecificExtension('safetensors'),
|
||||
user_metadata: undefined
|
||||
},
|
||||
{ ...createAssetWithSpecificExtension('ckpt'), user_metadata: undefined }
|
||||
]
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows only file format filter when assets have file extensions but no base model metadata.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const OnlyBaseModelFilter: Story = {
|
||||
args: {
|
||||
assets: [
|
||||
// Assets with base models but no recognizable extensions
|
||||
{
|
||||
...createAssetWithSpecificBaseModel('sd15'),
|
||||
name: 'model_without_extension'
|
||||
},
|
||||
{ ...createAssetWithSpecificBaseModel('sdxl'), name: 'another_model' }
|
||||
]
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows only base model filter when assets have base model metadata but no recognizable file extensions.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const NoFiltersVisible: Story = {
|
||||
args: {
|
||||
assets: []
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows no filters when no assets are provided or assets contain no filterable options.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const NoFiltersFromAssetsWithoutOptions: Story = {
|
||||
args: {
|
||||
assets: [createAssetWithoutExtension(), createAssetWithoutBaseModel()]
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Shows no filters when assets are provided but contain no filterable options (no extensions or base models).'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/platform/assets/components/AssetFilterBar.vue
Normal file
98
src/platform/assets/components/AssetFilterBar.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div :class="containerClasses" data-component-id="asset-filter-bar">
|
||||
<div :class="leftSideClasses" data-component-id="asset-filter-bar-left">
|
||||
<MultiSelect
|
||||
v-if="availableFileFormats.length > 0"
|
||||
v-model="fileFormats"
|
||||
:label="$t('assetBrowser.fileFormats')"
|
||||
:options="availableFileFormats"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-file-formats"
|
||||
@update:model-value="handleFilterChange"
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
v-if="availableBaseModels.length > 0"
|
||||
v-model="baseModels"
|
||||
:label="$t('assetBrowser.baseModels')"
|
||||
:options="availableBaseModels"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-base-models"
|
||||
@update:model-value="handleFilterChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="rightSideClasses" data-component-id="asset-filter-bar-right">
|
||||
<SingleSelect
|
||||
v-model="sortBy"
|
||||
:label="$t('assetBrowser.sortBy')"
|
||||
:options="sortOptions"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-sort"
|
||||
@update:model-value="handleFilterChange"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:arrow-up-down class="size-3" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import type { SelectOption } from '@/components/input/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
export interface FilterState {
|
||||
fileFormats: string[]
|
||||
baseModels: string[]
|
||||
sortBy: string
|
||||
}
|
||||
|
||||
const { assets = [] } = defineProps<{
|
||||
assets?: AssetItem[]
|
||||
}>()
|
||||
|
||||
const fileFormats = ref<SelectOption[]>([])
|
||||
const baseModels = ref<SelectOption[]>([])
|
||||
const sortBy = ref('name-asc')
|
||||
|
||||
const { availableFileFormats, availableBaseModels } =
|
||||
useAssetFilterOptions(assets)
|
||||
|
||||
// TODO: Make sortOptions configurable via props
|
||||
// Different asset types might need different sorting options
|
||||
const sortOptions = [
|
||||
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' },
|
||||
{ name: t('assetBrowser.sortZA'), value: 'name-desc' },
|
||||
{ name: t('assetBrowser.sortRecent'), value: 'recent' },
|
||||
{ name: t('assetBrowser.sortPopular'), value: 'popular' }
|
||||
]
|
||||
|
||||
const emit = defineEmits<{
|
||||
filterChange: [filters: FilterState]
|
||||
}>()
|
||||
|
||||
const containerClasses = cn(
|
||||
'flex gap-4 items-center justify-between',
|
||||
'px-6 pt-2 pb-6'
|
||||
)
|
||||
const leftSideClasses = cn('flex gap-4 items-center')
|
||||
const rightSideClasses = cn('flex items-center')
|
||||
const selectClasses = cn('min-w-32')
|
||||
|
||||
function handleFilterChange() {
|
||||
emit('filterChange', {
|
||||
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
|
||||
baseModels: baseModels.value.map((option: SelectOption) => option.value),
|
||||
sortBy: sortBy.value
|
||||
})
|
||||
}
|
||||
</script>
|
||||
70
src/platform/assets/components/AssetGrid.vue
Normal file
70
src/platform/assets/components/AssetGrid.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div
|
||||
data-component-id="AssetGrid"
|
||||
:style="gridStyle"
|
||||
role="grid"
|
||||
aria-label="Asset collection"
|
||||
:aria-rowcount="-1"
|
||||
:aria-colcount="-1"
|
||||
:aria-setsize="assets.length"
|
||||
>
|
||||
<AssetCard
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
:asset="asset"
|
||||
:interactive="true"
|
||||
role="gridcell"
|
||||
@select="$emit('assetSelect', $event)"
|
||||
/>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="assets.length === 0"
|
||||
:class="
|
||||
cn(
|
||||
'col-span-full flex flex-col items-center justify-center py-16',
|
||||
'text-stone-300 dark-theme:text-stone-200'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i-lucide:search class="size-10 mb-4" />
|
||||
<h3 class="text-lg font-medium mb-2">
|
||||
{{ $t('assetBrowser.noAssetsFound') }}
|
||||
</h3>
|
||||
<p class="text-sm">{{ $t('assetBrowser.tryAdjustingFilters') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="col-span-full flex items-center justify-center py-16"
|
||||
>
|
||||
<i-lucide:loader
|
||||
:class="
|
||||
cn('size-6 animate-spin', 'text-stone-300 dark-theme:text-stone-200')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AssetCard from '@/platform/assets/components/AssetCard.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
assets: AssetDisplayItem[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
assetSelect: [asset: AssetDisplayItem]
|
||||
}>()
|
||||
|
||||
// Use same grid style as BaseModalLayout
|
||||
const gridStyle = computed(() => createGridStyle())
|
||||
</script>
|
||||
242
src/platform/assets/composables/useAssetBrowser.ts
Normal file
242
src/platform/assets/composables/useAssetBrowser.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { d, t } from '@/i18n'
|
||||
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetFilenameSchema } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
getAssetBaseModel,
|
||||
getAssetDescription
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
function filterByCategory(category: string) {
|
||||
return (asset: AssetItem) => {
|
||||
return category === 'all' || asset.tags.includes(category)
|
||||
}
|
||||
}
|
||||
|
||||
function filterByQuery(query: string) {
|
||||
return (asset: AssetItem) => {
|
||||
if (!query) return true
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const description = getAssetDescription(asset)
|
||||
return (
|
||||
asset.name.toLowerCase().includes(lowerQuery) ||
|
||||
(description && description.toLowerCase().includes(lowerQuery)) ||
|
||||
asset.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function filterByFileFormats(formats: string[]) {
|
||||
return (asset: AssetItem) => {
|
||||
if (formats.length === 0) return true
|
||||
const formatSet = new Set(formats)
|
||||
const extension = asset.name.split('.').pop()?.toLowerCase()
|
||||
return extension ? formatSet.has(extension) : false
|
||||
}
|
||||
}
|
||||
|
||||
function filterByBaseModels(models: string[]) {
|
||||
return (asset: AssetItem) => {
|
||||
if (models.length === 0) return true
|
||||
const modelSet = new Set(models)
|
||||
const baseModel = getAssetBaseModel(asset)
|
||||
return baseModel ? modelSet.has(baseModel) : false
|
||||
}
|
||||
}
|
||||
|
||||
type AssetBadge = {
|
||||
label: string
|
||||
type: 'type' | 'base' | 'size'
|
||||
}
|
||||
|
||||
// Display properties for transformed assets
|
||||
export interface AssetDisplayItem extends AssetItem {
|
||||
description: string
|
||||
formattedSize: string
|
||||
badges: AssetBadge[]
|
||||
stats: {
|
||||
formattedDate?: string
|
||||
downloadCount?: string
|
||||
stars?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset Browser composable
|
||||
* Manages search, filtering, asset transformation and selection logic
|
||||
*/
|
||||
export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
// State
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('all')
|
||||
const filters = ref<FilterState>({
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: [],
|
||||
baseModels: []
|
||||
})
|
||||
|
||||
// Transform API asset to display asset
|
||||
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
|
||||
// Extract description from metadata or create from tags
|
||||
const typeTag = asset.tags.find((tag) => tag !== 'models')
|
||||
const description =
|
||||
getAssetDescription(asset) ||
|
||||
`${typeTag || t('assetBrowser.unknown')} model`
|
||||
|
||||
// Format file size
|
||||
const formattedSize = formatSize(asset.size)
|
||||
|
||||
// Create badges from tags and metadata
|
||||
const badges: AssetBadge[] = []
|
||||
|
||||
// Type badge from non-root tag
|
||||
if (typeTag) {
|
||||
badges.push({ label: typeTag, type: 'type' })
|
||||
}
|
||||
|
||||
// Base model badge from metadata
|
||||
const baseModel = getAssetBaseModel(asset)
|
||||
if (baseModel) {
|
||||
badges.push({
|
||||
label: baseModel,
|
||||
type: 'base'
|
||||
})
|
||||
}
|
||||
|
||||
// Size badge
|
||||
badges.push({ label: formattedSize, type: 'size' })
|
||||
|
||||
// Create display stats from API data
|
||||
const stats = {
|
||||
formattedDate: d(new Date(asset.created_at), { dateStyle: 'short' }),
|
||||
downloadCount: undefined, // Not available in API
|
||||
stars: undefined // Not available in API
|
||||
}
|
||||
|
||||
return {
|
||||
...asset,
|
||||
description,
|
||||
formattedSize,
|
||||
badges,
|
||||
stats
|
||||
}
|
||||
}
|
||||
|
||||
const availableCategories = computed(() => {
|
||||
const categories = assets
|
||||
.filter((asset) => asset.tags[0] === 'models' && asset.tags[1])
|
||||
.map((asset) => asset.tags[1])
|
||||
|
||||
const uniqueCategories = Array.from(new Set(categories))
|
||||
.sort()
|
||||
.map((category) => ({
|
||||
id: category,
|
||||
label: category.charAt(0).toUpperCase() + category.slice(1),
|
||||
icon: 'icon-[lucide--package]'
|
||||
}))
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
label: t('assetBrowser.allModels'),
|
||||
icon: 'icon-[lucide--folder]'
|
||||
},
|
||||
...uniqueCategories
|
||||
]
|
||||
})
|
||||
|
||||
// Compute content title from selected category
|
||||
const contentTitle = computed(() => {
|
||||
if (selectedCategory.value === 'all') {
|
||||
return t('assetBrowser.allModels')
|
||||
}
|
||||
|
||||
const category = availableCategories.value.find(
|
||||
(cat) => cat.id === selectedCategory.value
|
||||
)
|
||||
return category?.label || t('assetBrowser.assets')
|
||||
})
|
||||
|
||||
const filteredAssets = computed(() => {
|
||||
const filtered = assets
|
||||
.filter(filterByCategory(selectedCategory.value))
|
||||
.filter(filterByQuery(searchQuery.value))
|
||||
.filter(filterByFileFormats(filters.value.fileFormats))
|
||||
.filter(filterByBaseModels(filters.value.baseModels))
|
||||
|
||||
// Sort assets
|
||||
filtered.sort((a, b) => {
|
||||
switch (filters.value.sortBy) {
|
||||
case 'name-desc':
|
||||
return b.name.localeCompare(a.name)
|
||||
case 'recent':
|
||||
return (
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)
|
||||
case 'popular':
|
||||
return a.name.localeCompare(b.name)
|
||||
case 'name-asc':
|
||||
default:
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
})
|
||||
|
||||
// Transform to display format
|
||||
return filtered.map(transformAssetForDisplay)
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset selection that fetches full details and executes callback with filename
|
||||
* @param assetId - The asset ID to select and fetch details for
|
||||
* @param onSelect - Optional callback to execute with the asset filename
|
||||
*/
|
||||
async function selectAssetWithCallback(
|
||||
assetId: string,
|
||||
onSelect?: (filename: string) => void
|
||||
): Promise<void> {
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('Asset selected:', assetId)
|
||||
}
|
||||
|
||||
if (!onSelect) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const detailAsset = await assetService.getAssetDetails(assetId)
|
||||
const filename = detailAsset.user_metadata?.filename
|
||||
const validatedFilename = assetFilenameSchema.safeParse(filename)
|
||||
if (!validatedFilename.success) {
|
||||
console.error(
|
||||
'Invalid asset filename:',
|
||||
validatedFilename.error.errors,
|
||||
'for asset:',
|
||||
assetId
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(validatedFilename.data)
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch asset details for ${assetId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilters(newFilters: FilterState) {
|
||||
filters.value = { ...newFilters }
|
||||
}
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
selectedCategory,
|
||||
availableCategories,
|
||||
contentTitle,
|
||||
filteredAssets,
|
||||
selectAssetWithCallback,
|
||||
updateFilters
|
||||
}
|
||||
}
|
||||
203
src/platform/assets/composables/useAssetBrowserDialog.stories.ts
Normal file
203
src/platform/assets/composables/useAssetBrowserDialog.stories.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets'
|
||||
|
||||
// Component that simulates the useAssetBrowserDialog functionality with working close
|
||||
const DialogDemoComponent = {
|
||||
components: { AssetBrowserModal },
|
||||
setup() {
|
||||
const isDialogOpen = ref(false)
|
||||
const currentNodeType = ref('CheckpointLoaderSimple')
|
||||
const currentInputName = ref('ckpt_name')
|
||||
const currentValue = ref('')
|
||||
|
||||
const handleOpenDialog = (
|
||||
nodeType: string,
|
||||
inputName: string,
|
||||
value = ''
|
||||
) => {
|
||||
currentNodeType.value = nodeType
|
||||
currentInputName.value = inputName
|
||||
currentValue.value = value
|
||||
isDialogOpen.value = true
|
||||
}
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
isDialogOpen.value = false
|
||||
}
|
||||
|
||||
const handleAssetSelected = (assetPath: string) => {
|
||||
console.log('Asset selected:', assetPath)
|
||||
alert(`Selected asset: ${assetPath}`)
|
||||
isDialogOpen.value = false // Auto-close like the real composable
|
||||
}
|
||||
|
||||
const handleOpenWithCurrentValue = () => {
|
||||
handleOpenDialog(
|
||||
'CheckpointLoaderSimple',
|
||||
'ckpt_name',
|
||||
'realistic_vision_v5.safetensors'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
isDialogOpen,
|
||||
currentNodeType,
|
||||
currentInputName,
|
||||
currentValue,
|
||||
handleOpenDialog,
|
||||
handleOpenWithCurrentValue,
|
||||
handleCloseDialog,
|
||||
handleAssetSelected,
|
||||
mockAssets
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="relative">
|
||||
<div class="p-8 space-y-4">
|
||||
<h2 class="text-2xl font-bold mb-6">Asset Browser Dialog Demo</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-2">Different Node Types</h3>
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<button
|
||||
@click="handleOpenDialog('CheckpointLoaderSimple', 'ckpt_name')"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Browse Checkpoints
|
||||
</button>
|
||||
<button
|
||||
@click="handleOpenDialog('VAELoader', 'vae_name')"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
Browse VAE
|
||||
</button>
|
||||
<button
|
||||
@click="handleOpenDialog('ControlNetLoader', 'control_net_name')"
|
||||
class="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||
>
|
||||
Browse ControlNet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-2">With Current Value</h3>
|
||||
<button
|
||||
@click="handleOpenWithCurrentValue"
|
||||
class="px-4 py-2 bg-orange-600 text-white rounded hover:bg-orange-700"
|
||||
>
|
||||
Change Current Model
|
||||
</button>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
Opens with "realistic_vision_v5.safetensors" as current value
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 p-4 bg-gray-100 rounded">
|
||||
<h4 class="font-semibold mb-2">Instructions:</h4>
|
||||
<ul class="text-sm space-y-1">
|
||||
<li>• Click any button to open the Asset Browser dialog</li>
|
||||
<li>• Select an asset to see the callback in action</li>
|
||||
<li>• Check the browser console for logged events</li>
|
||||
<li>• Try toggling the left panel with different asset types</li>
|
||||
<li>• Close button will work properly in this demo</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog Modal Overlay -->
|
||||
<div
|
||||
v-if="isDialogOpen"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||
@click.self="handleCloseDialog"
|
||||
>
|
||||
<div class="w-[80vw] h-[80vh] max-w-[80vw] max-h-[80vh] rounded-2xl overflow-hidden">
|
||||
<AssetBrowserModal
|
||||
:assets="mockAssets"
|
||||
:node-type="currentNodeType"
|
||||
:input-name="currentInputName"
|
||||
:current-value="currentValue"
|
||||
@asset-select="handleAssetSelected"
|
||||
@close="handleCloseDialog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Platform/Assets/useAssetBrowserDialog',
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Demonstrates the AssetBrowserModal functionality as used by the useAssetBrowserDialog composable.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Demo: Story = {
|
||||
render: () => ({
|
||||
components: { DialogDemoComponent },
|
||||
template: `
|
||||
<div>
|
||||
<DialogDemoComponent />
|
||||
|
||||
<!-- Code Example Section -->
|
||||
<div class="p-8 border-t border-gray-200 bg-gray-50">
|
||||
<h2 class="text-2xl font-bold mb-4">Code Example</h2>
|
||||
<p class="text-gray-600 mb-4">
|
||||
This is how you would use the composable in your component:
|
||||
</p>
|
||||
<div class="bg-white p-4 rounded-lg border shadow-sm">
|
||||
<pre><code class="text-sm">import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
|
||||
const openBrowser = () => {
|
||||
assetBrowserDialog.show({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: '',
|
||||
onAssetSelected: (assetPath) => {
|
||||
console.log('Selected:', assetPath)
|
||||
// Update your component state
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { openBrowser }
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded">
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>💡 Try it:</strong> Use the interactive buttons above to see this code in action!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Complete demo showing both interactive functionality and code examples for using useAssetBrowserDialog to open the Asset Browser modal programmatically.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/platform/assets/composables/useAssetBrowserDialog.ts
Normal file
73
src/platform/assets/composables/useAssetBrowserDialog.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { type DialogComponentProps, useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
interface AssetBrowserDialogProps {
|
||||
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
|
||||
nodeType: string
|
||||
/** Widget input name (e.g., 'ckpt_name') */
|
||||
inputName: string
|
||||
/** Current selected asset value */
|
||||
currentValue?: string
|
||||
/**
|
||||
* Callback for when an asset is selected
|
||||
* @param {string} filename - The validated filename from user_metadata.filename
|
||||
*/
|
||||
onAssetSelected?: (filename: string) => void
|
||||
}
|
||||
|
||||
export const useAssetBrowserDialog = () => {
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogKey = 'global-asset-browser'
|
||||
|
||||
async function show(props: AssetBrowserDialogProps) {
|
||||
const handleAssetSelected = (filename: string) => {
|
||||
props.onAssetSelected?.(filename)
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
}
|
||||
const dialogComponentProps: DialogComponentProps = {
|
||||
headless: true,
|
||||
modal: true,
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden asset-browser-dialog'
|
||||
},
|
||||
header: {
|
||||
class: '!p-0 hidden'
|
||||
},
|
||||
content: {
|
||||
class: '!p-0 !m-0 h-full w-full'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const assets: AssetItem[] = await assetService
|
||||
.getAssetsForNodeType(props.nodeType)
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to fetch assets for node type:',
|
||||
props.nodeType,
|
||||
error
|
||||
)
|
||||
return []
|
||||
})
|
||||
|
||||
dialogStore.showDialog({
|
||||
key: dialogKey,
|
||||
component: AssetBrowserModal,
|
||||
props: {
|
||||
nodeType: props.nodeType,
|
||||
inputName: props.inputName,
|
||||
currentValue: props.currentValue,
|
||||
assets,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: () => dialogStore.closeDialog({ key: dialogKey })
|
||||
},
|
||||
dialogComponentProps
|
||||
})
|
||||
}
|
||||
|
||||
return { show }
|
||||
}
|
||||
56
src/platform/assets/composables/useAssetFilterOptions.ts
Normal file
56
src/platform/assets/composables/useAssetFilterOptions.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { uniqWith } from 'es-toolkit'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SelectOption } from '@/components/input/types'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
/**
|
||||
* Composable that extracts available filter options from asset data
|
||||
* Provides reactive computed properties for file formats and base models
|
||||
*/
|
||||
export function useAssetFilterOptions(assets: AssetItem[] = []) {
|
||||
/**
|
||||
* Extract unique file formats from asset names
|
||||
* Returns sorted SelectOption array with extensions
|
||||
*/
|
||||
const availableFileFormats = computed<SelectOption[]>(() => {
|
||||
const extensions = assets
|
||||
.map((asset) => {
|
||||
const extension = asset.name.split('.').pop()
|
||||
return extension && extension !== asset.name ? extension : null
|
||||
})
|
||||
.filter((extension): extension is string => extension !== null)
|
||||
|
||||
const uniqueExtensions = uniqWith(extensions, (a, b) => a === b)
|
||||
|
||||
return uniqueExtensions.sort().map((format) => ({
|
||||
name: `.${format}`,
|
||||
value: format
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* Extract unique base models from asset user metadata
|
||||
* Returns sorted SelectOption array with base model names
|
||||
*/
|
||||
const availableBaseModels = computed<SelectOption[]>(() => {
|
||||
const models = assets
|
||||
.map((asset) => asset.user_metadata?.base_model)
|
||||
.filter(
|
||||
(baseModel): baseModel is string =>
|
||||
baseModel !== undefined && typeof baseModel === 'string'
|
||||
)
|
||||
|
||||
const uniqueModels = uniqWith(models, (a, b) => a === b)
|
||||
|
||||
return uniqueModels.sort().map((model) => ({
|
||||
name: model,
|
||||
value: model
|
||||
}))
|
||||
})
|
||||
|
||||
return {
|
||||
availableFileFormats,
|
||||
availableBaseModels
|
||||
}
|
||||
}
|
||||
159
src/platform/assets/fixtures/ui-mock-assets.ts
Normal file
159
src/platform/assets/fixtures/ui-mock-assets.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
// 🎭 OBVIOUSLY FAKE MOCK DATA - DO NOT USE IN PRODUCTION! 🎭
|
||||
const fakeFunnyModelNames = [
|
||||
'🎯_totally_real_model_v420.69',
|
||||
'🚀_definitely_not_fake_v999',
|
||||
'🎪_super_legit_checkpoint_pro_max',
|
||||
'🦄_unicorn_dreams_totally_real.model',
|
||||
'🍕_pizza_generator_supreme',
|
||||
'🎸_rock_star_fake_data_v1337',
|
||||
'🌮_taco_tuesday_model_deluxe',
|
||||
'🦖_dino_nugget_generator_v3',
|
||||
'🎮_gamer_fuel_checkpoint_xl',
|
||||
'🍄_mushroom_kingdom_diffusion',
|
||||
'🏴☠️_pirate_treasure_model_arr',
|
||||
'🦋_butterfly_effect_generator',
|
||||
'🎺_jazz_hands_checkpoint_pro',
|
||||
'🥨_pretzel_logic_model_v2',
|
||||
'🌙_midnight_snack_generator',
|
||||
'🎭_drama_llama_checkpoint',
|
||||
'🧙♀️_wizard_hat_diffusion_xl',
|
||||
'🎪_circus_peanut_model_v4',
|
||||
'🦒_giraffe_neck_generator',
|
||||
'🎲_random_stuff_checkpoint_max'
|
||||
]
|
||||
|
||||
const obviouslyFakeDescriptions = [
|
||||
'⚠️ FAKE DATA: Generates 100% authentic fake images with premium mock quality',
|
||||
'🎭 MOCK ALERT: This totally real model creates absolutely genuine fake content',
|
||||
'🚨 NOT REAL: Professional-grade fake imagery for your mock data needs',
|
||||
'🎪 DEMO ONLY: Circus-quality fake generation with extra mock seasoning',
|
||||
'🍕 FAKE FOOD: Generates delicious fake pizzas (not edible in reality)',
|
||||
"🎸 MOCK ROCK: Creates fake rock stars who definitely don't exist",
|
||||
'🌮 TACO FAKERY: Tuesday-themed fake tacos for your mock appetite',
|
||||
'🦖 PREHISTORIC FAKE: Generates extinct fake dinosaurs for demo purposes',
|
||||
'🎮 FAKE GAMING: Level up your mock data with obviously fake content',
|
||||
'🍄 FUNGI FICTION: Magical fake mushrooms from the demo dimension',
|
||||
'🏴☠️ FAKE TREASURE: Arr! This be mock data for ye demo needs, matey!',
|
||||
'🦋 DEMO EFFECT: Small fake changes create big mock differences',
|
||||
'🎺 JAZZ FAKERY: Smooth fake jazz for your mock listening pleasure',
|
||||
'🥨 MOCK LOGIC: Twisted fake reasoning for your demo requirements',
|
||||
'🌙 MIDNIGHT MOCK: Late-night fake snacks for your demo hunger',
|
||||
'🎭 FAKE DRAMA: Over-the-top mock emotions for demo entertainment',
|
||||
'🧙♀️ WIZARD MOCK: Magically fake spells cast with demo ingredients',
|
||||
'🎪 CIRCUS FAKE: Big top mock entertainment under the demo tent',
|
||||
'🦒 TALL FAKE: Reaches new heights of obviously fake content',
|
||||
'🎲 RANDOM MOCK: Generates random fake stuff for your demo pleasure'
|
||||
]
|
||||
|
||||
// API-compliant tag structure: first tag must be root (models/input/output), second is category
|
||||
const modelCategories = ['checkpoints', 'loras', 'embeddings', 'vae']
|
||||
const baseModels = ['sd15', 'sdxl', 'sd35']
|
||||
const fileExtensions = ['.safetensors', '.ckpt', '.pt']
|
||||
const mimeTypes = [
|
||||
'application/octet-stream',
|
||||
'application/x-pytorch',
|
||||
'application/x-safetensors'
|
||||
]
|
||||
|
||||
function getRandomElement<T>(array: T[]): T {
|
||||
return array[Math.floor(Math.random() * array.length)]
|
||||
}
|
||||
|
||||
function getRandomNumber(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
function getRandomISODate(): string {
|
||||
const start = new Date('2024-01-01').getTime()
|
||||
const end = new Date('2024-12-31').getTime()
|
||||
const randomTime = start + Math.random() * (end - start)
|
||||
return new Date(randomTime).toISOString()
|
||||
}
|
||||
|
||||
function generateFakeAssetHash(): string {
|
||||
const chars = '0123456789abcdef'
|
||||
let hash = 'blake3:'
|
||||
for (let i = 0; i < 64; i++) {
|
||||
hash += chars[Math.floor(Math.random() * chars.length)]
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
// 🎭 CREATES OBVIOUSLY FAKE ASSETS FOR DEMO/TEST PURPOSES ONLY! 🎭
|
||||
export function createMockAssets(count: number = 20): AssetItem[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const category = getRandomElement(modelCategories)
|
||||
const baseModel = getRandomElement(baseModels)
|
||||
const extension = getRandomElement(fileExtensions)
|
||||
const mimeType = getRandomElement(mimeTypes)
|
||||
const sizeInBytes = getRandomNumber(
|
||||
500 * 1024 * 1024,
|
||||
8 * 1024 * 1024 * 1024
|
||||
) // 500MB to 8GB
|
||||
const createdAt = getRandomISODate()
|
||||
const updatedAt = createdAt
|
||||
const lastAccessTime = getRandomISODate()
|
||||
|
||||
const fakeFileName = `${fakeFunnyModelNames[index]}${extension}`
|
||||
|
||||
return {
|
||||
id: `mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake`,
|
||||
name: fakeFileName,
|
||||
asset_hash: generateFakeAssetHash(),
|
||||
size: sizeInBytes,
|
||||
mime_type: mimeType,
|
||||
tags: [
|
||||
'models', // Root tag (required first)
|
||||
category, // Category tag (required second for models)
|
||||
'fake-data', // Obviously fake tag
|
||||
...(Math.random() > 0.5 ? ['demo-mode'] : ['test-only']),
|
||||
...(Math.random() > 0.7 ? ['obviously-mock'] : [])
|
||||
],
|
||||
preview_url: `/api/assets/mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake/content`,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
last_access_time: lastAccessTime,
|
||||
user_metadata: {
|
||||
description: obviouslyFakeDescriptions[index],
|
||||
base_model: baseModel,
|
||||
original_name: fakeFunnyModelNames[index],
|
||||
warning: '🚨 THIS IS FAKE DEMO DATA - NOT A REAL MODEL! 🚨'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const mockAssets = createMockAssets(20)
|
||||
|
||||
// 🧪 Test helpers for edge cases - built on mock asset foundation
|
||||
export function createAssetWithoutExtension() {
|
||||
const asset = createMockAssets(1)[0]
|
||||
asset.name = 'model_no_extension'
|
||||
return asset
|
||||
}
|
||||
|
||||
export function createAssetWithoutBaseModel() {
|
||||
const asset = createMockAssets(1)[0]
|
||||
asset.user_metadata = { description: 'A test model' }
|
||||
return asset
|
||||
}
|
||||
|
||||
export function createAssetWithoutUserMetadata() {
|
||||
const asset = createMockAssets(1)[0]
|
||||
asset.user_metadata = undefined
|
||||
return asset
|
||||
}
|
||||
|
||||
export function createAssetWithSpecificExtension(extension: string) {
|
||||
const asset = createMockAssets(1)[0]
|
||||
asset.name = `test-model.${extension}`
|
||||
return asset
|
||||
}
|
||||
|
||||
export function createAssetWithSpecificBaseModel(baseModel: string) {
|
||||
const asset = createMockAssets(1)[0]
|
||||
asset.user_metadata = { ...asset.user_metadata, base_model: baseModel }
|
||||
return asset
|
||||
}
|
||||
57
src/platform/assets/schemas/assetSchema.ts
Normal file
57
src/platform/assets/schemas/assetSchema.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
|
||||
const zAsset = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
asset_hash: z.string().nullable(),
|
||||
size: z.number(),
|
||||
mime_type: z.string().nullable(),
|
||||
tags: z.array(z.string()),
|
||||
preview_url: z.string().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().optional(),
|
||||
last_access_time: z.string(),
|
||||
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
|
||||
preview_id: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
const zAssetResponse = z.object({
|
||||
assets: z.array(zAsset).optional(),
|
||||
total: z.number().optional(),
|
||||
has_more: z.boolean().optional()
|
||||
})
|
||||
|
||||
const zModelFolder = z.object({
|
||||
name: z.string(),
|
||||
folders: z.array(z.string())
|
||||
})
|
||||
|
||||
// Zod schema for ModelFile to align with interface
|
||||
const zModelFile = z.object({
|
||||
name: z.string(),
|
||||
pathIndex: z.number()
|
||||
})
|
||||
|
||||
// Filename validation schema
|
||||
export const assetFilenameSchema = z
|
||||
.string()
|
||||
.min(1, 'Filename cannot be empty')
|
||||
.regex(/^[^\\:*?"<>|]+$/, 'Invalid filename characters') // Allow forward slashes, block backslashes and other unsafe chars
|
||||
.regex(/^(?!\/|.*\.\.)/, 'Path must not start with / or contain ..') // Prevent absolute paths and directory traversal
|
||||
.trim()
|
||||
|
||||
// Export schemas following repository patterns
|
||||
export const assetResponseSchema = zAssetResponse
|
||||
|
||||
// Export types derived from Zod schemas
|
||||
export type AssetItem = z.infer<typeof zAsset>
|
||||
export type AssetResponse = z.infer<typeof zAssetResponse>
|
||||
export type ModelFolder = z.infer<typeof zModelFolder>
|
||||
export type ModelFile = z.infer<typeof zModelFile>
|
||||
|
||||
// Legacy interface for backward compatibility (now aligned with Zod schema)
|
||||
export interface ModelFolderInfo {
|
||||
name: string
|
||||
folders: string[]
|
||||
}
|
||||
203
src/platform/assets/services/assetService.ts
Normal file
203
src/platform/assets/services/assetService.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import {
|
||||
type AssetItem,
|
||||
type AssetResponse,
|
||||
type ModelFile,
|
||||
type ModelFolder,
|
||||
assetResponseSchema
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
const ASSETS_ENDPOINT = '/assets'
|
||||
const MODELS_TAG = 'models'
|
||||
const MISSING_TAG = 'missing'
|
||||
|
||||
/**
|
||||
* Input names that are eligible for asset browser
|
||||
*/
|
||||
const WHITELISTED_INPUTS = new Set(['ckpt_name', 'lora_name', 'vae_name'])
|
||||
|
||||
/**
|
||||
* Validates asset response data using Zod schema
|
||||
*/
|
||||
function validateAssetResponse(data: unknown): AssetResponse {
|
||||
const result = assetResponseSchema.safeParse(data)
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
throw new Error(`Invalid asset response against zod schema:\n${error}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Private service for asset-related network requests
|
||||
* Not exposed globally - used internally by ComfyApi
|
||||
*/
|
||||
function createAssetService() {
|
||||
/**
|
||||
* Handles API response with consistent error handling and Zod validation
|
||||
*/
|
||||
async function handleAssetRequest(
|
||||
url: string,
|
||||
context: string
|
||||
): Promise<AssetResponse> {
|
||||
const res = await api.fetchApi(url)
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Unable to load ${context}: Server returned ${res.status}. Please try again.`
|
||||
)
|
||||
}
|
||||
const data = await res.json()
|
||||
return validateAssetResponse(data)
|
||||
}
|
||||
/**
|
||||
* Gets a list of model folder keys from the asset API
|
||||
*
|
||||
* Logic:
|
||||
* 1. Extract directory names directly from asset tags
|
||||
* 2. Filter out blacklisted directories
|
||||
* 3. Return alphabetically sorted directories with assets
|
||||
*
|
||||
* @returns The list of model folder keys
|
||||
*/
|
||||
async function getAssetModelFolders(): Promise<ModelFolder[]> {
|
||||
const data = await handleAssetRequest(
|
||||
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG}`,
|
||||
'model folders'
|
||||
)
|
||||
|
||||
// Blacklist directories we don't want to show
|
||||
const blacklistedDirectories = new Set(['configs'])
|
||||
|
||||
// Extract directory names from assets that actually exist, exclude missing assets
|
||||
const discoveredFolders = new Set<string>(
|
||||
data?.assets
|
||||
?.filter((asset) => !asset.tags.includes(MISSING_TAG))
|
||||
?.flatMap((asset) => asset.tags)
|
||||
?.filter(
|
||||
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag)
|
||||
) ?? []
|
||||
)
|
||||
|
||||
// Return only discovered folders in alphabetical order
|
||||
const sortedFolders = Array.from(discoveredFolders).toSorted()
|
||||
return sortedFolders.map((name) => ({ name, folders: [] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of models in the specified folder from the asset API
|
||||
* @param folder The folder to list models from, such as 'checkpoints'
|
||||
* @returns The list of model filenames within the specified folder
|
||||
*/
|
||||
async function getAssetModels(folder: string): Promise<ModelFile[]> {
|
||||
const data = await handleAssetRequest(
|
||||
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${folder}`,
|
||||
`models for ${folder}`
|
||||
)
|
||||
|
||||
return (
|
||||
data?.assets
|
||||
?.filter(
|
||||
(asset) =>
|
||||
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(folder)
|
||||
)
|
||||
?.map((asset) => ({
|
||||
name: asset.name,
|
||||
pathIndex: 0
|
||||
})) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a widget input should use the asset browser based on both input name and node comfyClass
|
||||
*
|
||||
* @param inputName - The input name (e.g., 'ckpt_name', 'lora_name')
|
||||
* @param nodeType - The ComfyUI node comfyClass (e.g., 'CheckpointLoaderSimple', 'LoraLoader')
|
||||
* @returns true if this input should use asset browser
|
||||
*/
|
||||
function isAssetBrowserEligible(
|
||||
inputName: string,
|
||||
nodeType: string
|
||||
): boolean {
|
||||
return (
|
||||
// Must be an approved input name
|
||||
WHITELISTED_INPUTS.has(inputName) &&
|
||||
// Must be a registered node type
|
||||
useModelToNodeStore().getRegisteredNodeTypes().has(nodeType)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets assets for a specific node type by finding the matching category
|
||||
* and fetching all assets with that category tag
|
||||
*
|
||||
* @param nodeType - The ComfyUI node type (e.g., 'CheckpointLoaderSimple')
|
||||
* @returns Promise<AssetItem[]> - Full asset objects with preserved metadata
|
||||
*/
|
||||
async function getAssetsForNodeType(nodeType: string): Promise<AssetItem[]> {
|
||||
if (!nodeType || typeof nodeType !== 'string') {
|
||||
return []
|
||||
}
|
||||
|
||||
// Find the category for this node type using efficient O(1) lookup
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const category = modelToNodeStore.getCategoryForNodeType(nodeType)
|
||||
|
||||
if (!category) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Fetch assets for this category using same API pattern as getAssetModels
|
||||
const data = await handleAssetRequest(
|
||||
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${category}`,
|
||||
`assets for ${nodeType}`
|
||||
)
|
||||
|
||||
// Return full AssetItem[] objects (don't strip like getAssetModels does)
|
||||
return (
|
||||
data?.assets?.filter(
|
||||
(asset) =>
|
||||
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(category)
|
||||
) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets complete details for a specific asset by ID
|
||||
* Calls the detail endpoint which includes user_metadata and all fields
|
||||
*
|
||||
* @param id - The asset ID
|
||||
* @returns Promise<AssetItem> - Complete asset object with user_metadata
|
||||
*/
|
||||
async function getAssetDetails(id: string): Promise<AssetItem> {
|
||||
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Unable to load asset details for ${id}: Server returned ${res.status}. Please try again.`
|
||||
)
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
// Validate the single asset response against our schema
|
||||
const result = assetResponseSchema.safeParse({ assets: [data] })
|
||||
if (result.success && result.data.assets?.[0]) {
|
||||
return result.data.assets[0]
|
||||
}
|
||||
|
||||
const error = result.error
|
||||
? fromZodError(result.error)
|
||||
: 'Unknown validation error'
|
||||
throw new Error(`Invalid asset response against zod schema:\n${error}`)
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
isAssetBrowserEligible,
|
||||
getAssetsForNodeType,
|
||||
getAssetDetails
|
||||
}
|
||||
}
|
||||
|
||||
export const assetService = createAssetService()
|
||||
27
src/platform/assets/utils/assetMetadataUtils.ts
Normal file
27
src/platform/assets/utils/assetMetadataUtils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
/**
|
||||
* Type-safe utilities for extracting metadata from assets
|
||||
*/
|
||||
|
||||
/**
|
||||
* Safely extracts string description from asset metadata
|
||||
* @param asset - The asset to extract description from
|
||||
* @returns The description string or null if not present/not a string
|
||||
*/
|
||||
export function getAssetDescription(asset: AssetItem): string | null {
|
||||
return typeof asset.user_metadata?.description === 'string'
|
||||
? asset.user_metadata.description
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts string base_model from asset metadata
|
||||
* @param asset - The asset to extract base_model from
|
||||
* @returns The base_model string or null if not present/not a string
|
||||
*/
|
||||
export function getAssetBaseModel(asset: AssetItem): string | null {
|
||||
return typeof asset.user_metadata?.base_model === 'string'
|
||||
? asset.user_metadata.base_model
|
||||
: null
|
||||
}
|
||||
@@ -110,7 +110,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import CloudSignInForm from '@/platform/onboarding/cloud/components/CloudSignInForm.vue'
|
||||
import { type SignInData } from '@/schemas/signInSchema'
|
||||
import type { SignInData } from '@/schemas/signInSchema'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { translateAuthError } from '@/utils/authErrorTranslation'
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { SignUpData } from '@/schemas/signInSchema'
|
||||
import type { SignUpData } from '@/schemas/signInSchema'
|
||||
import { translateAuthError } from '@/utils/authErrorTranslation'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ import { onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Form, FormSubmitEvent } from '@primevue/forms'
|
||||
import type { FormSubmitEvent } from '@primevue/forms'
|
||||
import { Form } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
61
src/platform/settings/components/ColorPaletteMessage.vue
Normal file
61
src/platform/settings/components/ColorPaletteMessage.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<Message severity="info" icon="pi pi-palette" pt:text="w-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
{{ $t('settingsCategories.ColorPalette') }}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<Select
|
||||
v-model="activePaletteId"
|
||||
class="w-44"
|
||||
:options="palettes"
|
||||
option-label="name"
|
||||
option-value="id"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-file-export"
|
||||
text
|
||||
:title="$t('g.export')"
|
||||
@click="colorPaletteService.exportColorPalette(activePaletteId)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-file-import"
|
||||
text
|
||||
:title="$t('g.import')"
|
||||
@click="importCustomPalette"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
:title="$t('g.delete')"
|
||||
:disabled="!colorPaletteStore.isCustomPalette(activePaletteId)"
|
||||
@click="colorPaletteService.deleteCustomColorPalette(activePaletteId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Message>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
import Select from 'primevue/select'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const colorPaletteService = useColorPaletteService()
|
||||
const { palettes, activePaletteId } = storeToRefs(colorPaletteStore)
|
||||
|
||||
const importCustomPalette = async () => {
|
||||
const palette = await colorPaletteService.importColorPalette()
|
||||
if (palette) {
|
||||
await settingStore.set('Comfy.ColorPalette', palette.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
238
src/platform/settings/components/ExtensionPanel.vue
Normal file
238
src/platform/settings/components/ExtensionPanel.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<PanelTemplate value="Extension" class="extension-panel">
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model="filters['global'].value"
|
||||
:placeholder="$t('g.searchExtensions') + '...'"
|
||||
/>
|
||||
<Message
|
||||
v-if="hasChanges"
|
||||
severity="info"
|
||||
pt:text="w-full"
|
||||
class="max-h-96 overflow-y-auto"
|
||||
>
|
||||
<ul>
|
||||
<li v-for="ext in changedExtensions" :key="ext.name">
|
||||
<span>
|
||||
{{ extensionStore.isExtensionEnabled(ext.name) ? '[-]' : '[+]' }}
|
||||
</span>
|
||||
{{ ext.name }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
:label="$t('g.reloadToApplyChanges')"
|
||||
outlined
|
||||
severity="danger"
|
||||
@click="applyChanges"
|
||||
/>
|
||||
</div>
|
||||
</Message>
|
||||
</template>
|
||||
<div class="mb-3 flex gap-2">
|
||||
<SelectButton v-model="filterType" :options="filterTypes" />
|
||||
</div>
|
||||
<DataTable
|
||||
v-model:selection="selectedExtensions"
|
||||
:value="filteredExtensions"
|
||||
striped-rows
|
||||
size="small"
|
||||
:filters="filters"
|
||||
selection-mode="multiple"
|
||||
data-key="name"
|
||||
>
|
||||
<Column selection-mode="multiple" :frozen="true" style="width: 3rem" />
|
||||
<Column :header="$t('g.extensionName')" sortable field="name">
|
||||
<template #body="slotProps">
|
||||
{{ slotProps.data.name }}
|
||||
<Tag
|
||||
v-if="extensionStore.isCoreExtension(slotProps.data.name)"
|
||||
value="Core"
|
||||
/>
|
||||
<Tag v-else value="Custom" severity="info" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
:pt="{
|
||||
headerCell: 'flex items-center justify-end',
|
||||
bodyCell: 'flex items-center justify-end'
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<Button
|
||||
icon="pi pi-ellipsis-h"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="menu?.show($event)"
|
||||
/>
|
||||
<ContextMenu ref="menu" :model="contextMenuItems" />
|
||||
</template>
|
||||
<template #body="slotProps">
|
||||
<ToggleSwitch
|
||||
v-model="editingEnabledExtensions[slotProps.data.name]"
|
||||
:disabled="extensionStore.isExtensionReadOnly(slotProps.data.name)"
|
||||
@change="updateExtensionStatus"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</PanelTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FilterMatchMode } from '@primevue/core/api'
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Message from 'primevue/message'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import Tag from 'primevue/tag'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
|
||||
const filterTypes = ['All', 'Core', 'Custom']
|
||||
const filterType = ref('All')
|
||||
const selectedExtensions = ref<Array<any>>([])
|
||||
|
||||
const filters = ref({
|
||||
global: { value: '', matchMode: FilterMatchMode.CONTAINS }
|
||||
})
|
||||
|
||||
const extensionStore = useExtensionStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const editingEnabledExtensions = ref<Record<string, boolean>>({})
|
||||
|
||||
const filteredExtensions = computed(() => {
|
||||
const extensions = extensionStore.extensions
|
||||
switch (filterType.value) {
|
||||
case 'Core':
|
||||
return extensions.filter((ext) =>
|
||||
extensionStore.isCoreExtension(ext.name)
|
||||
)
|
||||
case 'Custom':
|
||||
return extensions.filter(
|
||||
(ext) => !extensionStore.isCoreExtension(ext.name)
|
||||
)
|
||||
default:
|
||||
return extensions
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
extensionStore.extensions.forEach((ext) => {
|
||||
editingEnabledExtensions.value[ext.name] =
|
||||
extensionStore.isExtensionEnabled(ext.name)
|
||||
})
|
||||
})
|
||||
|
||||
const changedExtensions = computed(() => {
|
||||
return extensionStore.extensions.filter(
|
||||
(ext) =>
|
||||
editingEnabledExtensions.value[ext.name] !==
|
||||
extensionStore.isExtensionEnabled(ext.name)
|
||||
)
|
||||
})
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
return changedExtensions.value.length > 0
|
||||
})
|
||||
|
||||
const updateExtensionStatus = async () => {
|
||||
const editingDisabledExtensionNames = Object.entries(
|
||||
editingEnabledExtensions.value
|
||||
)
|
||||
.filter(([_, enabled]) => !enabled)
|
||||
.map(([name]) => name)
|
||||
|
||||
await settingStore.set('Comfy.Extension.Disabled', [
|
||||
...extensionStore.inactiveDisabledExtensionNames,
|
||||
...editingDisabledExtensionNames
|
||||
])
|
||||
}
|
||||
|
||||
const enableAllExtensions = async () => {
|
||||
extensionStore.extensions.forEach((ext) => {
|
||||
if (extensionStore.isExtensionReadOnly(ext.name)) return
|
||||
|
||||
editingEnabledExtensions.value[ext.name] = true
|
||||
})
|
||||
await updateExtensionStatus()
|
||||
}
|
||||
|
||||
const disableAllExtensions = async () => {
|
||||
extensionStore.extensions.forEach((ext) => {
|
||||
if (extensionStore.isExtensionReadOnly(ext.name)) return
|
||||
|
||||
editingEnabledExtensions.value[ext.name] = false
|
||||
})
|
||||
await updateExtensionStatus()
|
||||
}
|
||||
|
||||
const disableThirdPartyExtensions = async () => {
|
||||
extensionStore.extensions.forEach((ext) => {
|
||||
if (extensionStore.isCoreExtension(ext.name)) return
|
||||
|
||||
editingEnabledExtensions.value[ext.name] = false
|
||||
})
|
||||
await updateExtensionStatus()
|
||||
}
|
||||
|
||||
const applyChanges = () => {
|
||||
// Refresh the page to apply changes
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const menu = ref<InstanceType<typeof ContextMenu>>()
|
||||
const contextMenuItems = [
|
||||
{
|
||||
label: 'Enable Selected',
|
||||
icon: 'pi pi-check',
|
||||
command: async () => {
|
||||
selectedExtensions.value.forEach((ext) => {
|
||||
if (!extensionStore.isExtensionReadOnly(ext.name)) {
|
||||
editingEnabledExtensions.value[ext.name] = true
|
||||
}
|
||||
})
|
||||
await updateExtensionStatus()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Disable Selected',
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
selectedExtensions.value.forEach((ext) => {
|
||||
if (!extensionStore.isExtensionReadOnly(ext.name)) {
|
||||
editingEnabledExtensions.value[ext.name] = false
|
||||
}
|
||||
})
|
||||
await updateExtensionStatus()
|
||||
}
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Enable All',
|
||||
icon: 'pi pi-check',
|
||||
command: enableAllExtensions
|
||||
},
|
||||
{
|
||||
label: 'Disable All',
|
||||
icon: 'pi pi-times',
|
||||
command: disableAllExtensions
|
||||
},
|
||||
{
|
||||
label: 'Disable 3rd Party',
|
||||
icon: 'pi pi-times',
|
||||
command: disableThirdPartyExtensions,
|
||||
disabled: !extensionStore.hasThirdPartyExtensions
|
||||
}
|
||||
]
|
||||
</script>
|
||||
126
src/platform/settings/components/ServerConfigPanel.vue
Normal file
126
src/platform/settings/components/ServerConfigPanel.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<PanelTemplate value="Server-Config" class="server-config-panel">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Message
|
||||
v-if="modifiedConfigs.length > 0"
|
||||
severity="info"
|
||||
pt:text="w-full"
|
||||
>
|
||||
<p>
|
||||
{{ $t('serverConfig.modifiedConfigs') }}
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="config in modifiedConfigs" :key="config.id">
|
||||
{{ config.name }}: {{ config.initialValue }} → {{ config.value }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
:label="$t('serverConfig.revertChanges')"
|
||||
outlined
|
||||
@click="revertChanges"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('serverConfig.restart')"
|
||||
outlined
|
||||
severity="danger"
|
||||
@click="restartApp"
|
||||
/>
|
||||
</div>
|
||||
</Message>
|
||||
<Message v-if="commandLineArgs" severity="secondary" pt:text="w-full">
|
||||
<template #icon>
|
||||
<i-lucide:terminal class="text-xl font-bold" />
|
||||
</template>
|
||||
<div class="flex items-center justify-between">
|
||||
<p>{{ commandLineArgs }}</p>
|
||||
<Button
|
||||
icon="pi pi-clipboard"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="copyCommandLineArgs"
|
||||
/>
|
||||
</div>
|
||||
</Message>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-for="([label, items], i) in Object.entries(serverConfigsByCategory)"
|
||||
:key="label"
|
||||
>
|
||||
<Divider v-if="i > 0" />
|
||||
<h3>{{ $t(`serverConfigCategories.${label}`, label) }}</h3>
|
||||
<div v-for="item in items" :key="item.name" class="mb-4">
|
||||
<FormItem
|
||||
:id="item.id"
|
||||
v-model:form-value="item.value"
|
||||
:item="translateItem(item)"
|
||||
:label-class="{
|
||||
'text-highlight': item.initialValue !== item.value
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PanelTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Message from 'primevue/message'
|
||||
import { watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormItem from '@/components/common/FormItem.vue'
|
||||
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import type { ServerConfig } from '@/constants/serverConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { FormItem as FormItemType } from '@/platform/settings/types'
|
||||
import { useServerConfigStore } from '@/stores/serverConfigStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const serverConfigStore = useServerConfigStore()
|
||||
const {
|
||||
serverConfigsByCategory,
|
||||
serverConfigValues,
|
||||
launchArgs,
|
||||
commandLineArgs,
|
||||
modifiedConfigs
|
||||
} = storeToRefs(serverConfigStore)
|
||||
|
||||
const revertChanges = () => {
|
||||
serverConfigStore.revertChanges()
|
||||
}
|
||||
|
||||
const restartApp = async () => {
|
||||
await electronAPI().restartApp()
|
||||
}
|
||||
|
||||
watch(launchArgs, async (newVal) => {
|
||||
await settingStore.set('Comfy.Server.LaunchArgs', newVal)
|
||||
})
|
||||
|
||||
watch(serverConfigValues, async (newVal) => {
|
||||
await settingStore.set('Comfy.Server.ServerConfigValues', newVal)
|
||||
})
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const copyCommandLineArgs = async () => {
|
||||
await copyToClipboard(commandLineArgs.value)
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const translateItem = (item: ServerConfig<any>): FormItemType => {
|
||||
return {
|
||||
...item,
|
||||
name: t(`serverConfigItems.${item.id}.name`, item.name),
|
||||
tooltip: item.tooltip
|
||||
? t(`serverConfigItems.${item.id}.tooltip`, item.tooltip)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
</script>
|
||||
190
src/platform/settings/components/SettingDialogContent.vue
Normal file
190
src/platform/settings/components/SettingDialogContent.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div class="settings-container">
|
||||
<ScrollPanel class="settings-sidebar shrink-0 p-2 w-48 2xl:w-64">
|
||||
<SearchBox
|
||||
v-model:model-value="searchQuery"
|
||||
class="settings-search-box w-full mb-2"
|
||||
:placeholder="$t('g.searchSettings') + '...'"
|
||||
:debounce-time="128"
|
||||
autofocus
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<Listbox
|
||||
v-model="activeCategory"
|
||||
:options="groupedMenuTreeNodes"
|
||||
option-label="translatedLabel"
|
||||
option-group-label="label"
|
||||
option-group-children="children"
|
||||
scroll-height="100%"
|
||||
:option-disabled="
|
||||
(option: SettingTreeNode) =>
|
||||
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
|
||||
"
|
||||
class="border-none w-full"
|
||||
>
|
||||
<template #optiongroup>
|
||||
<Divider class="my-0" />
|
||||
</template>
|
||||
</Listbox>
|
||||
</ScrollPanel>
|
||||
<Divider layout="vertical" class="mx-1 2xl:mx-4 hidden md:flex" />
|
||||
<Divider layout="horizontal" class="flex md:hidden" />
|
||||
<Tabs :value="tabValue" :lazy="true" class="settings-content h-full w-full">
|
||||
<TabPanels class="settings-tab-panels h-full w-full pr-0">
|
||||
<PanelTemplate value="Search Results">
|
||||
<SettingsPanel :setting-groups="searchResults" />
|
||||
</PanelTemplate>
|
||||
|
||||
<PanelTemplate
|
||||
v-for="category in settingCategories"
|
||||
:key="category.key"
|
||||
:value="category.label ?? ''"
|
||||
>
|
||||
<template #header>
|
||||
<CurrentUserMessage v-if="tabValue === 'Comfy'" />
|
||||
<ColorPaletteMessage v-if="tabValue === 'Appearance'" />
|
||||
</template>
|
||||
<SettingsPanel :setting-groups="sortedGroups(category)" />
|
||||
</PanelTemplate>
|
||||
|
||||
<Suspense v-for="panel in panels" :key="panel.node.key">
|
||||
<component :is="panel.component" />
|
||||
<template #fallback>
|
||||
<div>{{ $t('g.loadingPanel', { panel: panel.node.label }) }}</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import Listbox from 'primevue/listbox'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
|
||||
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
|
||||
import { flattenTree } from '@/utils/treeUtil'
|
||||
|
||||
const { defaultPanel } = defineProps<{
|
||||
defaultPanel?:
|
||||
| 'about'
|
||||
| 'keybinding'
|
||||
| 'extension'
|
||||
| 'server-config'
|
||||
| 'user'
|
||||
| 'credits'
|
||||
}>()
|
||||
|
||||
const {
|
||||
activeCategory,
|
||||
defaultCategory,
|
||||
settingCategories,
|
||||
groupedMenuTreeNodes,
|
||||
panels
|
||||
} = useSettingUI(defaultPanel)
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
searchResultsCategories,
|
||||
queryIsEmpty,
|
||||
inSearch,
|
||||
handleSearch: handleSearchBase,
|
||||
getSearchResults
|
||||
} = useSettingSearch()
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
|
||||
// Sort groups for a category
|
||||
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
|
||||
return [...(category.children ?? [])]
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.map((group) => ({
|
||||
label: group.label,
|
||||
settings: flattenTree<SettingParams>(group).sort((a, b) => {
|
||||
const sortOrderA = a.sortOrder ?? 0
|
||||
const sortOrderB = b.sortOrder ?? 0
|
||||
|
||||
return sortOrderB - sortOrderA
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
handleSearchBase(query.trim())
|
||||
activeCategory.value = query ? null : defaultCategory.value
|
||||
}
|
||||
|
||||
// Get search results
|
||||
const searchResults = computed<ISettingGroup[]>(() =>
|
||||
getSearchResults(activeCategory.value)
|
||||
)
|
||||
|
||||
const tabValue = computed<string>(() =>
|
||||
inSearch.value ? 'Search Results' : activeCategory.value?.label ?? ''
|
||||
)
|
||||
|
||||
// Don't allow null category to be set outside of search.
|
||||
// In search mode, the active category can be null to show all search results.
|
||||
watch(activeCategory, (_, oldValue) => {
|
||||
if (!tabValue.value) {
|
||||
activeCategory.value = oldValue
|
||||
}
|
||||
if (activeCategory.value?.key === 'credits') {
|
||||
void authActions.fetchBalance()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.settings-tab-panels {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.settings-container {
|
||||
display: flex;
|
||||
height: 70vh;
|
||||
width: 60vw;
|
||||
max-width: 64rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-container {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
width: 80vw;
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
height: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide the first group separator */
|
||||
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
33
src/platform/settings/components/SettingGroup.vue
Normal file
33
src/platform/settings/components/SettingGroup.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="setting-group">
|
||||
<Divider v-if="divider" />
|
||||
<h3>
|
||||
{{
|
||||
$t(`settingsCategories.${normalizeI18nKey(group.label)}`, group.label)
|
||||
}}
|
||||
</h3>
|
||||
<div
|
||||
v-for="setting in group.settings.filter((s) => !s.deprecated)"
|
||||
:key="setting.id"
|
||||
class="setting-item mb-4"
|
||||
>
|
||||
<SettingItem :setting="setting" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
import SettingItem from '@/platform/settings/components/SettingItem.vue'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
defineProps<{
|
||||
group: {
|
||||
label: string
|
||||
settings: SettingParams[]
|
||||
}
|
||||
divider?: boolean
|
||||
}>()
|
||||
</script>
|
||||
81
src/platform/settings/components/SettingItem.vue
Normal file
81
src/platform/settings/components/SettingItem.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<FormItem
|
||||
:id="setting.id"
|
||||
:item="formItem"
|
||||
:form-value="settingValue"
|
||||
@update:form-value="updateSettingValue"
|
||||
>
|
||||
<template #name-prefix>
|
||||
<Tag v-if="setting.id === 'Comfy.Locale'" class="pi pi-language" />
|
||||
<Tag
|
||||
v-if="setting.experimental"
|
||||
v-tooltip="{
|
||||
value: $t('g.experimental'),
|
||||
showDelay: 600
|
||||
}"
|
||||
>
|
||||
<template #icon>
|
||||
<i-material-symbols:experiment-outline />
|
||||
</template>
|
||||
</Tag>
|
||||
</template>
|
||||
</FormItem>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormItem from '@/components/common/FormItem.vue'
|
||||
import { st } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingOption, SettingParams } from '@/platform/settings/types'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
setting: SettingParams
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
function translateOptions(options: (SettingOption | string)[]) {
|
||||
if (typeof options === 'function') {
|
||||
// @ts-expect-error: Audit and deprecate usage of legacy options type:
|
||||
// (value) => [string | {text: string, value: string}]
|
||||
return translateOptions(options(props.setting.value ?? ''))
|
||||
}
|
||||
|
||||
return options.map((option) => {
|
||||
const optionLabel = typeof option === 'string' ? option : option.text
|
||||
const optionValue = typeof option === 'string' ? option : option.value
|
||||
|
||||
return {
|
||||
text: t(
|
||||
`settings.${normalizeI18nKey(props.setting.id)}.options.${normalizeI18nKey(optionLabel)}`,
|
||||
optionLabel
|
||||
),
|
||||
value: optionValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const formItem = computed(() => {
|
||||
const normalizedId = normalizeI18nKey(props.setting.id)
|
||||
return {
|
||||
...props.setting,
|
||||
name: t(`settings.${normalizedId}.name`, props.setting.name),
|
||||
tooltip: props.setting.tooltip
|
||||
? st(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
|
||||
: undefined,
|
||||
options: props.setting.options
|
||||
? translateOptions(props.setting.options)
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const settingValue = computed(() => settingStore.get(props.setting.id))
|
||||
const updateSettingValue = async (value: any) => {
|
||||
await settingStore.set(props.setting.id, value)
|
||||
}
|
||||
</script>
|
||||
26
src/platform/settings/components/SettingsPanel.vue
Normal file
26
src/platform/settings/components/SettingsPanel.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div v-if="props.settingGroups.length > 0">
|
||||
<SettingGroup
|
||||
v-for="(group, i) in props.settingGroups"
|
||||
:key="group.label"
|
||||
:divider="i !== 0"
|
||||
:group="group"
|
||||
/>
|
||||
</div>
|
||||
<NoResultsPlaceholder
|
||||
v-else
|
||||
icon="pi pi-search"
|
||||
:title="$t('g.noResultsFound')"
|
||||
:message="$t('g.searchFailedMessage')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import SettingGroup from '@/platform/settings/components/SettingGroup.vue'
|
||||
import type { ISettingGroup } from '@/platform/settings/types'
|
||||
|
||||
const props = defineProps<{
|
||||
settingGroups: ISettingGroup[]
|
||||
}>()
|
||||
</script>
|
||||
159
src/platform/settings/composables/useLitegraphSettings.ts
Normal file
159
src/platform/settings/composables/useLitegraphSettings.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { watchEffect } from 'vue'
|
||||
|
||||
import {
|
||||
CanvasPointer,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
/**
|
||||
* Watch for changes in the setting store and update the LiteGraph settings accordingly.
|
||||
*/
|
||||
export const useLitegraphSettings = () => {
|
||||
const settingStore = useSettingStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
watchEffect(() => {
|
||||
const canvasInfoEnabled = settingStore.get('Comfy.Graph.CanvasInfo')
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.show_info = canvasInfoEnabled
|
||||
canvasStore.canvas.draw(false, true)
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const zoomSpeed = settingStore.get('Comfy.Graph.ZoomSpeed')
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.zoom_speed = zoomSpeed
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.snaps_for_comfy = settingStore.get(
|
||||
'Comfy.Node.AutoSnapLinkToSlot'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.snap_highlights_node = settingStore.get(
|
||||
'Comfy.Node.SnapHighlightsNode'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LGraphNode.keepAllLinksOnBypass = settingStore.get(
|
||||
'Comfy.Node.BypassAllLinksOnDelete'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.middle_click_slot_add_default_node = settingStore.get(
|
||||
'Comfy.Node.MiddleClickRerouteNode'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const linkRenderMode = settingStore.get('Comfy.LinkRenderMode')
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.links_render_mode = linkRenderMode
|
||||
canvasStore.canvas.setDirty(/* fg */ false, /* bg */ true)
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const minFontSizeForLOD = settingStore.get(
|
||||
'LiteGraph.Canvas.MinFontSizeForLOD'
|
||||
)
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.min_font_size_for_lod = minFontSizeForLOD
|
||||
canvasStore.canvas.setDirty(/* fg */ true, /* bg */ true)
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const linkMarkerShape = settingStore.get('Comfy.Graph.LinkMarkers')
|
||||
const { canvas } = canvasStore
|
||||
if (canvas) {
|
||||
canvas.linkMarkerShape = linkMarkerShape
|
||||
canvas.setDirty(false, true)
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const maximumFps = settingStore.get('LiteGraph.Canvas.MaximumFps')
|
||||
const { canvas } = canvasStore
|
||||
if (canvas) canvas.maximumFps = maximumFps
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const dragZoomEnabled = settingStore.get('Comfy.Graph.CtrlShiftZoom')
|
||||
const { canvas } = canvasStore
|
||||
if (canvas) canvas.dragZoomEnabled = dragZoomEnabled
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
CanvasPointer.doubleClickTime = settingStore.get(
|
||||
'Comfy.Pointer.DoubleClickTime'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
CanvasPointer.bufferTime = settingStore.get('Comfy.Pointer.ClickBufferTime')
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
CanvasPointer.maxClickDrift = settingStore.get('Comfy.Pointer.ClickDrift')
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.CANVAS_GRID_SIZE = settingStore.get('Comfy.SnapToGrid.GridSize')
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.alwaysSnapToGrid = settingStore.get('pysssss.SnapToGrid')
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.context_menu_scaling = settingStore.get(
|
||||
'LiteGraph.ContextMenu.Scaling'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.Reroute.maxSplineOffset = settingStore.get(
|
||||
'LiteGraph.Reroute.SplineOffset'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const navigationMode = settingStore.get('Comfy.Canvas.NavigationMode') as
|
||||
| 'standard'
|
||||
| 'legacy'
|
||||
| 'custom'
|
||||
|
||||
LiteGraph.canvasNavigationMode = navigationMode
|
||||
LiteGraph.macTrackpadGestures = navigationMode === 'standard'
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const leftMouseBehavior = settingStore.get(
|
||||
'Comfy.Canvas.LeftMouseClickBehavior'
|
||||
) as 'panning' | 'select'
|
||||
LiteGraph.leftMouseClickBehavior = leftMouseBehavior
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const mouseWheelScroll = settingStore.get(
|
||||
'Comfy.Canvas.MouseWheelScroll'
|
||||
) as 'panning' | 'zoom'
|
||||
LiteGraph.mouseWheelScroll = mouseWheelScroll
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.saveViewportWithGraph = settingStore.get(
|
||||
'Comfy.EnableWorkflowViewRestore'
|
||||
)
|
||||
})
|
||||
}
|
||||
127
src/platform/settings/composables/useSettingSearch.ts
Normal file
127
src/platform/settings/composables/useSettingSearch.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import {
|
||||
getSettingInfo,
|
||||
useSettingStore
|
||||
} from '@/platform/settings/settingStore'
|
||||
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
export function useSettingSearch() {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
const filteredSettingIds = ref<string[]>([])
|
||||
const searchInProgress = ref<boolean>(false)
|
||||
|
||||
watch(searchQuery, () => (searchInProgress.value = true))
|
||||
|
||||
/**
|
||||
* Settings categories that contains at least one setting in search results.
|
||||
*/
|
||||
const searchResultsCategories = computed<Set<string>>(() => {
|
||||
return new Set(
|
||||
filteredSettingIds.value.map(
|
||||
(id) => getSettingInfo(settingStore.settingsById[id]).category
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if the search query is empty
|
||||
*/
|
||||
const queryIsEmpty = computed(() => searchQuery.value.length === 0)
|
||||
|
||||
/**
|
||||
* Check if we're in search mode
|
||||
*/
|
||||
const inSearch = computed(
|
||||
() => !queryIsEmpty.value && !searchInProgress.value
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle search functionality
|
||||
*/
|
||||
const handleSearch = (query: string) => {
|
||||
if (!query) {
|
||||
filteredSettingIds.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const queryLower = query.toLocaleLowerCase()
|
||||
const allSettings = Object.values(settingStore.settingsById)
|
||||
const filteredSettings = allSettings.filter((setting) => {
|
||||
// Filter out hidden and deprecated settings, just like in normal settings tree
|
||||
if (setting.type === 'hidden' || setting.deprecated) {
|
||||
return false
|
||||
}
|
||||
|
||||
const idLower = setting.id.toLowerCase()
|
||||
const nameLower = setting.name.toLowerCase()
|
||||
const translatedName = st(
|
||||
`settings.${normalizeI18nKey(setting.id)}.name`,
|
||||
setting.name
|
||||
).toLocaleLowerCase()
|
||||
const info = getSettingInfo(setting)
|
||||
const translatedCategory = st(
|
||||
`settingsCategories.${normalizeI18nKey(info.category)}`,
|
||||
info.category
|
||||
).toLocaleLowerCase()
|
||||
const translatedSubCategory = st(
|
||||
`settingsCategories.${normalizeI18nKey(info.subCategory)}`,
|
||||
info.subCategory
|
||||
).toLocaleLowerCase()
|
||||
|
||||
return (
|
||||
idLower.includes(queryLower) ||
|
||||
nameLower.includes(queryLower) ||
|
||||
translatedName.includes(queryLower) ||
|
||||
translatedCategory.includes(queryLower) ||
|
||||
translatedSubCategory.includes(queryLower)
|
||||
)
|
||||
})
|
||||
|
||||
filteredSettingIds.value = filteredSettings.map((x) => x.id)
|
||||
searchInProgress.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search results grouped by category
|
||||
*/
|
||||
const getSearchResults = (
|
||||
activeCategory: SettingTreeNode | null
|
||||
): ISettingGroup[] => {
|
||||
const groupedSettings: { [key: string]: SettingParams[] } = {}
|
||||
|
||||
filteredSettingIds.value.forEach((id) => {
|
||||
const setting = settingStore.settingsById[id]
|
||||
const info = getSettingInfo(setting)
|
||||
const groupLabel = info.subCategory
|
||||
|
||||
if (activeCategory === null || activeCategory.label === info.category) {
|
||||
if (!groupedSettings[groupLabel]) {
|
||||
groupedSettings[groupLabel] = []
|
||||
}
|
||||
groupedSettings[groupLabel].push(setting)
|
||||
}
|
||||
})
|
||||
|
||||
return Object.entries(groupedSettings).map(([label, settings]) => ({
|
||||
label,
|
||||
settings
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
filteredSettingIds,
|
||||
searchInProgress,
|
||||
searchResultsCategories,
|
||||
queryIsEmpty,
|
||||
inSearch,
|
||||
handleSearch,
|
||||
getSearchResults
|
||||
}
|
||||
}
|
||||
202
src/platform/settings/composables/useSettingUI.ts
Normal file
202
src/platform/settings/composables/useSettingUI.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
type Component,
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
onMounted,
|
||||
ref
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
|
||||
interface SettingPanelItem {
|
||||
node: SettingTreeNode
|
||||
component: Component
|
||||
}
|
||||
|
||||
export function useSettingUI(
|
||||
defaultPanel?:
|
||||
| 'about'
|
||||
| 'keybinding'
|
||||
| 'extension'
|
||||
| 'server-config'
|
||||
| 'user'
|
||||
| 'credits'
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const settingStore = useSettingStore()
|
||||
const activeCategory = ref<SettingTreeNode | null>(null)
|
||||
|
||||
const settingRoot = computed<SettingTreeNode>(() => {
|
||||
const root = buildTree(
|
||||
Object.values(settingStore.settingsById).filter(
|
||||
(setting: SettingParams) => setting.type !== 'hidden'
|
||||
),
|
||||
(setting: SettingParams) => setting.category || setting.id.split('.')
|
||||
)
|
||||
|
||||
const floatingSettings = (root.children ?? []).filter((node) => node.leaf)
|
||||
if (floatingSettings.length) {
|
||||
root.children = (root.children ?? []).filter((node) => !node.leaf)
|
||||
root.children.push({
|
||||
key: 'Other',
|
||||
label: 'Other',
|
||||
leaf: false,
|
||||
children: floatingSettings
|
||||
})
|
||||
}
|
||||
|
||||
return root
|
||||
})
|
||||
|
||||
const settingCategories = computed<SettingTreeNode[]>(
|
||||
() => settingRoot.value.children ?? []
|
||||
)
|
||||
|
||||
// Define panel items
|
||||
const aboutPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'about',
|
||||
label: 'About',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/AboutPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const creditsPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'credits',
|
||||
label: 'Credits',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const userPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'user',
|
||||
label: 'User',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/UserPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const keybindingPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'keybinding',
|
||||
label: 'Keybinding',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/KeybindingPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const extensionPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'extension',
|
||||
label: 'Extension',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/platform/settings/components/ExtensionPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const serverConfigPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'server-config',
|
||||
label: 'Server-Config',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/platform/settings/components/ServerConfigPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const panels = computed<SettingPanelItem[]>(() =>
|
||||
[
|
||||
aboutPanel,
|
||||
creditsPanel,
|
||||
userPanel,
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isElectron() ? [serverConfigPanel] : [])
|
||||
].filter((panel) => panel.component)
|
||||
)
|
||||
|
||||
/**
|
||||
* The default category to show when the dialog is opened.
|
||||
*/
|
||||
const defaultCategory = computed<SettingTreeNode>(() => {
|
||||
if (!defaultPanel) return settingCategories.value[0]
|
||||
// Search through all groups in groupedMenuTreeNodes
|
||||
for (const group of groupedMenuTreeNodes.value) {
|
||||
const found = group.children?.find((node) => node.key === defaultPanel)
|
||||
if (found) return found
|
||||
}
|
||||
return settingCategories.value[0]
|
||||
})
|
||||
|
||||
const translateCategory = (node: SettingTreeNode) => ({
|
||||
...node,
|
||||
translatedLabel: t(
|
||||
`settingsCategories.${normalizeI18nKey(node.label)}`,
|
||||
node.label
|
||||
)
|
||||
})
|
||||
|
||||
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() => [
|
||||
// Account settings - only show credits when user is authenticated
|
||||
{
|
||||
key: 'account',
|
||||
label: 'Account',
|
||||
children: [
|
||||
userPanel.node,
|
||||
...(isLoggedIn.value ? [creditsPanel.node] : [])
|
||||
].map(translateCategory)
|
||||
},
|
||||
// Normal settings stored in the settingStore
|
||||
{
|
||||
key: 'settings',
|
||||
label: 'Application Settings',
|
||||
children: settingCategories.value.map(translateCategory)
|
||||
},
|
||||
// Special settings such as about, keybinding, extension, server-config
|
||||
{
|
||||
key: 'specialSettings',
|
||||
label: 'Special Settings',
|
||||
children: [
|
||||
keybindingPanel.node,
|
||||
extensionPanel.node,
|
||||
aboutPanel.node,
|
||||
...(isElectron() ? [serverConfigPanel.node] : [])
|
||||
].map(translateCategory)
|
||||
}
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
activeCategory.value = defaultCategory.value
|
||||
})
|
||||
|
||||
return {
|
||||
panels,
|
||||
activeCategory,
|
||||
defaultCategory,
|
||||
groupedMenuTreeNodes,
|
||||
settingCategories
|
||||
}
|
||||
}
|
||||
1060
src/platform/settings/constants/coreSettings.ts
Normal file
1060
src/platform/settings/constants/coreSettings.ts
Normal file
File diff suppressed because it is too large
Load Diff
250
src/platform/settings/settingStore.ts
Normal file
250
src/platform/settings/settingStore.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { defineStore } from 'pinia'
|
||||
import { compare, valid } from 'semver'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
export const getSettingInfo = (setting: SettingParams) => {
|
||||
const parts = setting.category || setting.id.split('.')
|
||||
return {
|
||||
category: parts[0] ?? 'Other',
|
||||
subCategory: parts[1] ?? 'Other'
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingTreeNode extends TreeNode {
|
||||
data?: SettingParams
|
||||
}
|
||||
|
||||
function tryMigrateDeprecatedValue(
|
||||
setting: SettingParams | undefined,
|
||||
value: unknown
|
||||
) {
|
||||
return setting?.migrateDeprecatedValue?.(value) ?? value
|
||||
}
|
||||
|
||||
function onChange(
|
||||
setting: SettingParams | undefined,
|
||||
newValue: unknown,
|
||||
oldValue: unknown
|
||||
) {
|
||||
if (setting?.onChange) {
|
||||
setting.onChange(newValue, oldValue)
|
||||
}
|
||||
// Backward compatibility with old settings dialog.
|
||||
// Some extensions still listens event emitted by the old settings dialog.
|
||||
// @ts-expect-error 'setting' is possibly 'undefined'.ts(18048)
|
||||
app.ui.settings.dispatchChange(setting.id, newValue, oldValue)
|
||||
}
|
||||
|
||||
export const useSettingStore = defineStore('setting', () => {
|
||||
const settingValues = ref<Record<string, any>>({})
|
||||
const settingsById = ref<Record<string, SettingParams>>({})
|
||||
|
||||
/**
|
||||
* Check if a setting's value exists, i.e. if the user has set it manually.
|
||||
* @param key - The key of the setting to check.
|
||||
* @returns Whether the setting exists.
|
||||
*/
|
||||
function exists(key: string) {
|
||||
return settingValues.value[key] !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a setting value.
|
||||
* @param key - The key of the setting to set.
|
||||
* @param value - The value to set.
|
||||
*/
|
||||
async function set<K extends keyof Settings>(key: K, value: Settings[K]) {
|
||||
// Clone the incoming value to prevent external mutations
|
||||
const clonedValue = _.cloneDeep(value)
|
||||
const newValue = tryMigrateDeprecatedValue(
|
||||
settingsById.value[key],
|
||||
clonedValue
|
||||
)
|
||||
const oldValue = get(key)
|
||||
if (newValue === oldValue) return
|
||||
|
||||
onChange(settingsById.value[key], newValue, oldValue)
|
||||
settingValues.value[key] = newValue
|
||||
await api.storeSetting(key, newValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a setting value.
|
||||
* @param key - The key of the setting to get.
|
||||
* @returns The value of the setting.
|
||||
*/
|
||||
function get<K extends keyof Settings>(key: K): Settings[K] {
|
||||
// Clone the value when returning to prevent external mutations
|
||||
return _.cloneDeep(settingValues.value[key] ?? getDefaultValue(key))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the setting params, asserting the type that is intentionally left off
|
||||
* of {@link settingsById}.
|
||||
* @param key The key of the setting to get.
|
||||
* @returns The setting.
|
||||
*/
|
||||
function getSettingById<K extends keyof Settings>(
|
||||
key: K
|
||||
): SettingParams<Settings[K]> | undefined {
|
||||
return settingsById.value[key] as SettingParams<Settings[K]> | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default value of a setting.
|
||||
* @param key - The key of the setting to get.
|
||||
* @returns The default value of the setting.
|
||||
*/
|
||||
function getDefaultValue<K extends keyof Settings>(
|
||||
key: K
|
||||
): Settings[K] | undefined {
|
||||
// Assertion: settingsById is not typed.
|
||||
const param = getSettingById(key)
|
||||
|
||||
if (param === undefined) return
|
||||
|
||||
const versionedDefault = getVersionedDefaultValue(key, param)
|
||||
|
||||
if (versionedDefault) {
|
||||
return versionedDefault
|
||||
}
|
||||
|
||||
return typeof param.defaultValue === 'function'
|
||||
? param.defaultValue()
|
||||
: param.defaultValue
|
||||
}
|
||||
|
||||
function getVersionedDefaultValue<
|
||||
K extends keyof Settings,
|
||||
TValue = Settings[K]
|
||||
>(key: K, param: SettingParams<TValue> | undefined): TValue | null {
|
||||
// get default versioned value, skipping if the key is 'Comfy.InstalledVersion' to prevent infinite loop
|
||||
const defaultsByInstallVersion = param?.defaultsByInstallVersion
|
||||
if (defaultsByInstallVersion && key !== 'Comfy.InstalledVersion') {
|
||||
const installedVersion = get('Comfy.InstalledVersion')
|
||||
|
||||
if (installedVersion) {
|
||||
const sortedVersions = Object.keys(defaultsByInstallVersion).sort(
|
||||
(a, b) => compare(b, a)
|
||||
)
|
||||
|
||||
for (const version of sortedVersions) {
|
||||
// Ensure the version is in a valid format before comparing
|
||||
if (!valid(version)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (compare(installedVersion, version) >= 0) {
|
||||
const versionedDefault =
|
||||
defaultsByInstallVersion[
|
||||
version as keyof typeof defaultsByInstallVersion
|
||||
]
|
||||
if (versionedDefault !== undefined) {
|
||||
return typeof versionedDefault === 'function'
|
||||
? versionedDefault()
|
||||
: versionedDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a setting.
|
||||
* @param setting - The setting to register.
|
||||
*/
|
||||
function addSetting(setting: SettingParams) {
|
||||
if (!setting.id) {
|
||||
throw new Error('Settings must have an ID')
|
||||
}
|
||||
if (setting.id in settingsById.value) {
|
||||
throw new Error(`Setting ${setting.id} must have a unique ID.`)
|
||||
}
|
||||
|
||||
settingsById.value[setting.id] = setting
|
||||
|
||||
if (settingValues.value[setting.id] !== undefined) {
|
||||
settingValues.value[setting.id] = tryMigrateDeprecatedValue(
|
||||
setting,
|
||||
settingValues.value[setting.id]
|
||||
)
|
||||
}
|
||||
onChange(setting, get(setting.id), undefined)
|
||||
}
|
||||
|
||||
/*
|
||||
* Load setting values from server.
|
||||
* This needs to be called before any setting is registered.
|
||||
*/
|
||||
async function loadSettingValues() {
|
||||
if (Object.keys(settingsById.value).length) {
|
||||
throw new Error(
|
||||
'Setting values must be loaded before any setting is registered.'
|
||||
)
|
||||
}
|
||||
settingValues.value = await api.getSettings()
|
||||
|
||||
// Migrate old zoom threshold setting to new font size setting
|
||||
await migrateZoomThresholdToFontSize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate the old zoom threshold setting to the new font size setting.
|
||||
* Preserves the exact zoom threshold behavior by converting it to equivalent font size.
|
||||
*/
|
||||
async function migrateZoomThresholdToFontSize() {
|
||||
const oldKey = 'LiteGraph.Canvas.LowQualityRenderingZoomThreshold'
|
||||
const newKey = 'LiteGraph.Canvas.MinFontSizeForLOD'
|
||||
|
||||
// Only migrate if old setting exists and new setting doesn't
|
||||
if (
|
||||
settingValues.value[oldKey] !== undefined &&
|
||||
settingValues.value[newKey] === undefined
|
||||
) {
|
||||
const oldValue = settingValues.value[oldKey] as number
|
||||
|
||||
// Convert zoom threshold to equivalent font size to preserve exact behavior
|
||||
// The threshold formula is: threshold = font_size / (14 * sqrt(DPR))
|
||||
// For DPR=1: threshold = font_size / 14
|
||||
// Therefore: font_size = threshold * 14
|
||||
//
|
||||
// Examples:
|
||||
// - Old 0.6 threshold → 0.6 * 14 = 8.4px → rounds to 8px (preserves ~60% zoom threshold)
|
||||
// - Old 0.5 threshold → 0.5 * 14 = 7px (preserves 50% zoom threshold)
|
||||
// - Old 1.0 threshold → 1.0 * 14 = 14px (preserves 100% zoom threshold)
|
||||
const mappedFontSize = Math.round(oldValue * 14)
|
||||
const clampedFontSize = Math.max(1, Math.min(24, mappedFontSize))
|
||||
|
||||
// Set the new value
|
||||
settingValues.value[newKey] = clampedFontSize
|
||||
|
||||
// Remove the old setting to prevent confusion
|
||||
delete settingValues.value[oldKey]
|
||||
|
||||
// Store the migrated setting
|
||||
await api.storeSetting(newKey, clampedFontSize)
|
||||
await api.storeSetting(oldKey, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
settingValues,
|
||||
settingsById,
|
||||
addSetting,
|
||||
loadSettingValues,
|
||||
set,
|
||||
get,
|
||||
exists,
|
||||
getDefaultValue
|
||||
}
|
||||
})
|
||||
66
src/platform/settings/types.ts
Normal file
66
src/platform/settings/types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
|
||||
type SettingInputType =
|
||||
| 'boolean'
|
||||
| 'number'
|
||||
| 'slider'
|
||||
| 'knob'
|
||||
| 'combo'
|
||||
| 'radio'
|
||||
| 'text'
|
||||
| 'image'
|
||||
| 'color'
|
||||
| 'url'
|
||||
| 'hidden'
|
||||
| 'backgroundImage'
|
||||
|
||||
type SettingCustomRenderer = (
|
||||
name: string,
|
||||
setter: (v: any) => void,
|
||||
value: any,
|
||||
attrs: any
|
||||
) => HTMLElement
|
||||
|
||||
export interface SettingOption {
|
||||
text: string
|
||||
value?: any
|
||||
}
|
||||
|
||||
export interface SettingParams<TValue = unknown> extends FormItem {
|
||||
id: keyof Settings
|
||||
defaultValue: any | (() => any)
|
||||
defaultsByInstallVersion?: Record<`${number}.${number}.${number}`, TValue>
|
||||
onChange?: (newValue: any, oldValue?: any) => void
|
||||
// By default category is id.split('.'). However, changing id to assign
|
||||
// new category has poor backward compatibility. Use this field to overwrite
|
||||
// default category from id.
|
||||
// Note: Like id, category value need to be unique.
|
||||
category?: string[]
|
||||
experimental?: boolean
|
||||
deprecated?: boolean
|
||||
// Deprecated values are mapped to new values.
|
||||
migrateDeprecatedValue?: (value: any) => any
|
||||
// Version of the setting when it was added
|
||||
versionAdded?: string
|
||||
// Version of the setting when it was last modified
|
||||
versionModified?: string
|
||||
// sortOrder for sorting settings within a group. Higher values appear first.
|
||||
// Default is 0 if not specified.
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The base form item for rendering in a form.
|
||||
*/
|
||||
export interface FormItem {
|
||||
name: string
|
||||
type: SettingInputType | SettingCustomRenderer
|
||||
tooltip?: string
|
||||
attrs?: Record<string, any>
|
||||
options?: Array<string | SettingOption>
|
||||
}
|
||||
|
||||
export interface ISettingGroup {
|
||||
label: string
|
||||
settings: SettingParams[]
|
||||
}
|
||||
120
src/platform/updates/common/releaseService.ts
Normal file
120
src/platform/updates/common/releaseService.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { AxiosError, AxiosResponse } from 'axios'
|
||||
import axios from 'axios'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
|
||||
import type { components, operations } from '@/types/comfyRegistryTypes'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
|
||||
const releaseApiClient = axios.create({
|
||||
baseURL: COMFY_API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// Use generated types from OpenAPI spec
|
||||
export type ReleaseNote = components['schemas']['ReleaseNote']
|
||||
type GetReleasesParams = operations['getReleaseNotes']['parameters']['query']
|
||||
|
||||
// Use generated error response type
|
||||
type ErrorResponse = components['schemas']['ErrorResponse']
|
||||
|
||||
// Release service for fetching release notes
|
||||
export const useReleaseService = () => {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// No transformation needed - API response matches the generated type
|
||||
|
||||
// Handle API errors with context
|
||||
const handleApiError = (
|
||||
err: unknown,
|
||||
context: string,
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
): string => {
|
||||
if (!axios.isAxiosError(err))
|
||||
return err instanceof Error
|
||||
? `${context}: ${err.message}`
|
||||
: `${context}: Unknown error occurred`
|
||||
|
||||
const axiosError = err as AxiosError<ErrorResponse>
|
||||
|
||||
if (axiosError.response) {
|
||||
const { status, data } = axiosError.response
|
||||
|
||||
if (routeSpecificErrors && routeSpecificErrors[status])
|
||||
return routeSpecificErrors[status]
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
return `Bad request: ${data?.message || 'Invalid input'}`
|
||||
case 401:
|
||||
return 'Unauthorized: Authentication required'
|
||||
case 403:
|
||||
return `Forbidden: ${data?.message || 'Access denied'}`
|
||||
case 404:
|
||||
return `Not found: ${data?.message || 'Resource not found'}`
|
||||
case 500:
|
||||
return `Server error: ${data?.message || 'Internal server error'}`
|
||||
default:
|
||||
return `${context}: ${data?.message || axiosError.message}`
|
||||
}
|
||||
}
|
||||
|
||||
return `${context}: ${axiosError.message}`
|
||||
}
|
||||
|
||||
// Execute API request with error handling
|
||||
const executeApiRequest = async <T>(
|
||||
apiCall: () => Promise<AxiosResponse<T>>,
|
||||
errorContext: string,
|
||||
routeSpecificErrors?: Record<number, string>
|
||||
): Promise<T | null> => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await apiCall()
|
||||
return response.data
|
||||
} catch (err) {
|
||||
// Don't treat cancellations as errors
|
||||
if (isAbortError(err)) return null
|
||||
|
||||
error.value = handleApiError(err, errorContext, routeSpecificErrors)
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch release notes from API
|
||||
const getReleases = async (
|
||||
params: GetReleasesParams,
|
||||
signal?: AbortSignal
|
||||
): Promise<ReleaseNote[] | null> => {
|
||||
const endpoint = '/releases'
|
||||
const errorContext = 'Failed to get releases'
|
||||
const routeSpecificErrors = {
|
||||
400: 'Invalid project or version parameter'
|
||||
}
|
||||
|
||||
const apiResponse = await executeApiRequest(
|
||||
() =>
|
||||
releaseApiClient.get<ReleaseNote[]>(endpoint, {
|
||||
params,
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
)
|
||||
|
||||
return apiResponse
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
getReleases
|
||||
}
|
||||
}
|
||||
293
src/platform/updates/common/releaseStore.ts
Normal file
293
src/platform/updates/common/releaseStore.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { until } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { compare } from 'semver'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { stringToLocale } from '@/utils/formatUtil'
|
||||
|
||||
import { type ReleaseNote, useReleaseService } from './releaseService'
|
||||
|
||||
// Store for managing release notes
|
||||
export const useReleaseStore = defineStore('release', () => {
|
||||
// State
|
||||
const releases = ref<ReleaseNote[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Services
|
||||
const releaseService = useReleaseService()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// Current ComfyUI version
|
||||
const currentComfyUIVersion = computed(
|
||||
() => systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
|
||||
)
|
||||
|
||||
// Release data from settings
|
||||
const locale = computed(() => settingStore.get('Comfy.Locale'))
|
||||
const releaseVersion = computed(() =>
|
||||
settingStore.get('Comfy.Release.Version')
|
||||
)
|
||||
const releaseStatus = computed(() => settingStore.get('Comfy.Release.Status'))
|
||||
const releaseTimestamp = computed(() =>
|
||||
settingStore.get('Comfy.Release.Timestamp')
|
||||
)
|
||||
const showVersionUpdates = computed(() =>
|
||||
settingStore.get('Comfy.Notification.ShowVersionUpdates')
|
||||
)
|
||||
|
||||
// Most recent release
|
||||
const recentRelease = computed(() => {
|
||||
return releases.value[0] ?? null
|
||||
})
|
||||
|
||||
// 3 most recent releases
|
||||
const recentReleases = computed(() => {
|
||||
return releases.value.slice(0, 3)
|
||||
})
|
||||
|
||||
// Helper constants
|
||||
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000 // 3 days
|
||||
|
||||
// New version available?
|
||||
const isNewVersionAvailable = computed(
|
||||
() =>
|
||||
!!recentRelease.value &&
|
||||
compare(
|
||||
recentRelease.value.version,
|
||||
currentComfyUIVersion.value || '0.0.0'
|
||||
) > 0
|
||||
)
|
||||
|
||||
const isLatestVersion = computed(
|
||||
() =>
|
||||
!!recentRelease.value &&
|
||||
compare(
|
||||
recentRelease.value.version,
|
||||
currentComfyUIVersion.value || '0.0.0'
|
||||
) === 0
|
||||
)
|
||||
|
||||
const hasMediumOrHighAttention = computed(() =>
|
||||
recentReleases.value
|
||||
.slice(0, -1)
|
||||
.some(
|
||||
(release) =>
|
||||
release.attention === 'medium' || release.attention === 'high'
|
||||
)
|
||||
)
|
||||
|
||||
// Show toast if needed
|
||||
const shouldShowToast = computed(() => {
|
||||
// Only show on desktop version
|
||||
if (!isElectron()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip if notifications are disabled
|
||||
if (!showVersionUpdates.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isNewVersionAvailable.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip if low attention
|
||||
if (!hasMediumOrHighAttention.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip if user already skipped or changelog seen
|
||||
if (
|
||||
releaseVersion.value === recentRelease.value?.version &&
|
||||
['skipped', 'changelog seen'].includes(releaseStatus.value)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Show red-dot indicator
|
||||
const shouldShowRedDot = computed(() => {
|
||||
// Only show on desktop version
|
||||
if (!isElectron()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip if notifications are disabled
|
||||
if (!showVersionUpdates.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Already latest → no dot
|
||||
if (!isNewVersionAvailable.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { version } = recentRelease.value
|
||||
|
||||
// Changelog seen → clear dot
|
||||
if (
|
||||
releaseVersion.value === version &&
|
||||
releaseStatus.value === 'changelog seen'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Attention medium / high (levels 2 & 3)
|
||||
if (hasMediumOrHighAttention.value) {
|
||||
// Persist until changelog is opened
|
||||
return true
|
||||
}
|
||||
|
||||
// Attention low (level 1) and skipped → keep up to 3 d
|
||||
if (
|
||||
releaseVersion.value === version &&
|
||||
releaseStatus.value === 'skipped' &&
|
||||
releaseTimestamp.value &&
|
||||
Date.now() - releaseTimestamp.value >= THREE_DAYS_MS
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Not skipped → show
|
||||
return true
|
||||
})
|
||||
|
||||
// Show "What's New" popup
|
||||
const shouldShowPopup = computed(() => {
|
||||
// Only show on desktop version
|
||||
if (!isElectron()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip if notifications are disabled
|
||||
if (!showVersionUpdates.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isLatestVersion.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Hide if already seen
|
||||
if (
|
||||
releaseVersion.value === recentRelease.value.version &&
|
||||
releaseStatus.value === "what's new seen"
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Action handlers for user interactions
|
||||
async function handleSkipRelease(version: string): Promise<void> {
|
||||
if (
|
||||
version !== recentRelease.value?.version ||
|
||||
releaseStatus.value === 'changelog seen'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
await settingStore.set('Comfy.Release.Version', version)
|
||||
await settingStore.set('Comfy.Release.Status', 'skipped')
|
||||
await settingStore.set('Comfy.Release.Timestamp', Date.now())
|
||||
}
|
||||
|
||||
async function handleShowChangelog(version: string): Promise<void> {
|
||||
if (version !== recentRelease.value?.version) {
|
||||
return
|
||||
}
|
||||
|
||||
await settingStore.set('Comfy.Release.Version', version)
|
||||
await settingStore.set('Comfy.Release.Status', 'changelog seen')
|
||||
await settingStore.set('Comfy.Release.Timestamp', Date.now())
|
||||
}
|
||||
|
||||
async function handleWhatsNewSeen(version: string): Promise<void> {
|
||||
if (version !== recentRelease.value?.version) {
|
||||
return
|
||||
}
|
||||
|
||||
await settingStore.set('Comfy.Release.Version', version)
|
||||
await settingStore.set('Comfy.Release.Status', "what's new seen")
|
||||
await settingStore.set('Comfy.Release.Timestamp', Date.now())
|
||||
}
|
||||
|
||||
// Fetch releases from API
|
||||
async function fetchReleases(): Promise<void> {
|
||||
if (isLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip fetching if notifications are disabled
|
||||
if (!showVersionUpdates.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip fetching if API nodes are disabled via argv
|
||||
if (
|
||||
systemStatsStore.systemStats?.system?.argv?.includes(
|
||||
'--disable-api-nodes'
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Ensure system stats are loaded
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await until(systemStatsStore.isInitialized)
|
||||
}
|
||||
|
||||
const fetchedReleases = await releaseService.getReleases({
|
||||
project: 'comfyui',
|
||||
current_version: currentComfyUIVersion.value,
|
||||
form_factor: systemStatsStore.getFormFactor(),
|
||||
locale: stringToLocale(locale.value)
|
||||
})
|
||||
|
||||
if (fetchedReleases !== null) {
|
||||
releases.value = fetchedReleases
|
||||
} else if (releaseService.error.value) {
|
||||
error.value = releaseService.error.value
|
||||
}
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize store
|
||||
async function initialize(): Promise<void> {
|
||||
await fetchReleases()
|
||||
}
|
||||
|
||||
return {
|
||||
releases,
|
||||
isLoading,
|
||||
error,
|
||||
recentRelease,
|
||||
recentReleases,
|
||||
shouldShowToast,
|
||||
shouldShowRedDot,
|
||||
shouldShowPopup,
|
||||
shouldShowUpdateButton: isNewVersionAvailable,
|
||||
handleSkipRelease,
|
||||
handleShowChangelog,
|
||||
handleWhatsNewSeen,
|
||||
fetchReleases,
|
||||
initialize
|
||||
}
|
||||
})
|
||||
39
src/platform/updates/common/toastStore.ts
Normal file
39
src/platform/updates/common/toastStore.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Within Vue component context, you can directly call useToast().add()
|
||||
// instead of going through the store.
|
||||
// The store is useful when you need to call it from outside the Vue component context.
|
||||
import { defineStore } from 'pinia'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useToastStore = defineStore('toast', () => {
|
||||
const messagesToAdd = ref<ToastMessageOptions[]>([])
|
||||
const messagesToRemove = ref<ToastMessageOptions[]>([])
|
||||
const removeAllRequested = ref(false)
|
||||
|
||||
function add(message: ToastMessageOptions) {
|
||||
messagesToAdd.value = [...messagesToAdd.value, message]
|
||||
}
|
||||
|
||||
function remove(message: ToastMessageOptions) {
|
||||
messagesToRemove.value = [...messagesToRemove.value, message]
|
||||
}
|
||||
|
||||
function removeAll() {
|
||||
removeAllRequested.value = true
|
||||
}
|
||||
|
||||
function addAlert(message: string) {
|
||||
add({ severity: 'warn', summary: 'Alert', detail: message })
|
||||
}
|
||||
|
||||
return {
|
||||
messagesToAdd,
|
||||
messagesToRemove,
|
||||
removeAllRequested,
|
||||
|
||||
add,
|
||||
remove,
|
||||
removeAll,
|
||||
addAlert
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useToastStore } from './toastStore'
|
||||
import { useVersionCompatibilityStore } from './versionCompatibilityStore'
|
||||
|
||||
interface UseFrontendVersionMismatchWarningOptions {
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for handling frontend version mismatch warnings.
|
||||
*
|
||||
* Displays toast notifications when the frontend version is incompatible with the backend,
|
||||
* either because the frontend is outdated or newer than the backend expects.
|
||||
* Automatically dismisses warnings when shown and persists dismissal state for 7 days.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @param options.immediate - If true, automatically shows warning when version mismatch is detected
|
||||
* @returns Object with methods and computed properties for managing version warnings
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Show warning immediately when mismatch detected
|
||||
* const { showWarning, shouldShowWarning } = useFrontendVersionMismatchWarning({ immediate: true })
|
||||
*
|
||||
* // Manual control
|
||||
* const { showWarning } = useFrontendVersionMismatchWarning()
|
||||
* showWarning() // Call when needed
|
||||
* ```
|
||||
*/
|
||||
export function useFrontendVersionMismatchWarning(
|
||||
options: UseFrontendVersionMismatchWarningOptions = {}
|
||||
) {
|
||||
const { immediate = false } = options
|
||||
const { t } = useI18n()
|
||||
const toastStore = useToastStore()
|
||||
const versionCompatibilityStore = useVersionCompatibilityStore()
|
||||
|
||||
// Track if we've already shown the warning
|
||||
let hasShownWarning = false
|
||||
|
||||
const showWarning = () => {
|
||||
// Prevent showing the warning multiple times
|
||||
if (hasShownWarning) return
|
||||
|
||||
const message = versionCompatibilityStore.warningMessage
|
||||
if (!message) return
|
||||
|
||||
const detailMessage = t('g.frontendOutdated', {
|
||||
frontendVersion: message.frontendVersion,
|
||||
requiredVersion: message.requiredVersion
|
||||
})
|
||||
|
||||
const fullMessage = t('g.versionMismatchWarningMessage', {
|
||||
warning: t('g.versionMismatchWarning'),
|
||||
detail: detailMessage
|
||||
})
|
||||
|
||||
toastStore.addAlert(fullMessage)
|
||||
hasShownWarning = true
|
||||
|
||||
// Automatically dismiss the warning so it won't show again for 7 days
|
||||
versionCompatibilityStore.dismissWarning()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Only set up the watcher if immediate is true
|
||||
if (immediate) {
|
||||
whenever(
|
||||
() => versionCompatibilityStore.shouldShowWarning,
|
||||
() => {
|
||||
showWarning()
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
once: true
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
showWarning,
|
||||
shouldShowWarning: computed(
|
||||
() => versionCompatibilityStore.shouldShowWarning
|
||||
),
|
||||
dismissWarning: versionCompatibilityStore.dismissWarning,
|
||||
hasVersionMismatch: computed(
|
||||
() => versionCompatibilityStore.hasVersionMismatch
|
||||
)
|
||||
}
|
||||
}
|
||||
138
src/platform/updates/common/versionCompatibilityStore.ts
Normal file
138
src/platform/updates/common/versionCompatibilityStore.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { until, useStorage } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { gt, valid } from 'semver'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import config from '@/config'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
const DISMISSAL_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
export const useVersionCompatibilityStore = defineStore(
|
||||
'versionCompatibility',
|
||||
() => {
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const frontendVersion = computed(() => config.app_version)
|
||||
const backendVersion = computed(
|
||||
() => systemStatsStore.systemStats?.system?.comfyui_version ?? ''
|
||||
)
|
||||
const requiredFrontendVersion = computed(
|
||||
() =>
|
||||
systemStatsStore.systemStats?.system?.required_frontend_version ?? ''
|
||||
)
|
||||
|
||||
const isFrontendOutdated = computed(() => {
|
||||
if (
|
||||
!frontendVersion.value ||
|
||||
!requiredFrontendVersion.value ||
|
||||
!valid(frontendVersion.value) ||
|
||||
!valid(requiredFrontendVersion.value)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Returns true if required version is greater than frontend version
|
||||
return gt(requiredFrontendVersion.value, frontendVersion.value)
|
||||
})
|
||||
|
||||
const isFrontendNewer = computed(() => {
|
||||
// We don't warn about frontend being newer than backend
|
||||
// Only warn when frontend is outdated (behind required version)
|
||||
return false
|
||||
})
|
||||
|
||||
const hasVersionMismatch = computed(() => {
|
||||
return isFrontendOutdated.value
|
||||
})
|
||||
|
||||
const versionKey = computed(() => {
|
||||
if (
|
||||
!frontendVersion.value ||
|
||||
!backendVersion.value ||
|
||||
!requiredFrontendVersion.value
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return `${frontendVersion.value}-${backendVersion.value}-${requiredFrontendVersion.value}`
|
||||
})
|
||||
|
||||
// Use reactive storage for dismissals - creates a reactive ref that syncs with localStorage
|
||||
// All version mismatch dismissals are stored in a single object for clean localStorage organization
|
||||
const dismissalStorage = useStorage(
|
||||
'comfy.versionMismatch.dismissals',
|
||||
{} as Record<string, number>,
|
||||
localStorage,
|
||||
{
|
||||
serializer: {
|
||||
read: (value: string) => {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
write: (value: Record<string, number>) => JSON.stringify(value)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const isDismissed = computed(() => {
|
||||
if (!versionKey.value) return false
|
||||
|
||||
const dismissedUntil = dismissalStorage.value[versionKey.value]
|
||||
if (!dismissedUntil) return false
|
||||
|
||||
// Check if dismissal has expired
|
||||
return Date.now() < dismissedUntil
|
||||
})
|
||||
|
||||
const shouldShowWarning = computed(() => {
|
||||
return hasVersionMismatch.value && !isDismissed.value
|
||||
})
|
||||
|
||||
const warningMessage = computed(() => {
|
||||
if (isFrontendOutdated.value) {
|
||||
return {
|
||||
type: 'outdated' as const,
|
||||
frontendVersion: frontendVersion.value,
|
||||
requiredVersion: requiredFrontendVersion.value
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
async function checkVersionCompatibility() {
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await until(systemStatsStore.isInitialized)
|
||||
}
|
||||
}
|
||||
|
||||
function dismissWarning() {
|
||||
if (!versionKey.value) return
|
||||
|
||||
const dismissUntil = Date.now() + DISMISSAL_DURATION_MS
|
||||
dismissalStorage.value = {
|
||||
...dismissalStorage.value,
|
||||
[versionKey.value]: dismissUntil
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await checkVersionCompatibility()
|
||||
}
|
||||
|
||||
return {
|
||||
frontendVersion,
|
||||
backendVersion,
|
||||
requiredFrontendVersion,
|
||||
hasVersionMismatch,
|
||||
shouldShowWarning,
|
||||
warningMessage,
|
||||
isFrontendOutdated,
|
||||
isFrontendNewer,
|
||||
checkVersionCompatibility,
|
||||
dismissWarning,
|
||||
initialize
|
||||
}
|
||||
}
|
||||
)
|
||||
309
src/platform/updates/components/ReleaseNotificationToast.vue
Normal file
309
src/platform/updates/components/ReleaseNotificationToast.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<div v-if="shouldShow" class="release-toast-popup">
|
||||
<div class="release-notification-toast">
|
||||
<!-- Header section with icon and text -->
|
||||
<div class="toast-header">
|
||||
<div class="toast-icon">
|
||||
<i class="pi pi-download" />
|
||||
</div>
|
||||
<div class="toast-text">
|
||||
<div class="toast-title">
|
||||
{{ $t('releaseToast.newVersionAvailable') }}
|
||||
</div>
|
||||
<div class="toast-version-badge">
|
||||
{{ latestRelease?.version }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions section -->
|
||||
<div class="toast-actions-section">
|
||||
<div class="actions-row">
|
||||
<div class="left-actions">
|
||||
<a
|
||||
class="learn-more-link"
|
||||
:href="changelogUrl"
|
||||
target="_blank"
|
||||
rel="noopener,noreferrer"
|
||||
@click="handleLearnMore"
|
||||
>
|
||||
{{ $t('releaseToast.whatsNew') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="right-actions">
|
||||
<button class="skip-button" @click="handleSkip">
|
||||
{{ $t('releaseToast.skip') }}
|
||||
</button>
|
||||
<button class="cta-button" @click="handleUpdate">
|
||||
{{ $t('releaseToast.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
|
||||
import type { ReleaseNote } from '../common/releaseService'
|
||||
import { useReleaseStore } from '../common/releaseStore'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const releaseStore = useReleaseStore()
|
||||
|
||||
// Local state for dismissed status
|
||||
const isDismissed = ref(false)
|
||||
|
||||
// Get latest release from store
|
||||
const latestRelease = computed<ReleaseNote | null>(
|
||||
() => releaseStore.recentRelease
|
||||
)
|
||||
|
||||
// Show toast when new version available and not dismissed
|
||||
const shouldShow = computed(
|
||||
() => releaseStore.shouldShowToast && !isDismissed.value
|
||||
)
|
||||
|
||||
// Generate changelog URL with version anchor (language-aware)
|
||||
const changelogUrl = computed(() => {
|
||||
const isChineseLocale = locale.value === 'zh'
|
||||
const baseUrl = isChineseLocale
|
||||
? 'https://docs.comfy.org/zh-CN/changelog'
|
||||
: 'https://docs.comfy.org/changelog'
|
||||
|
||||
if (latestRelease.value?.version) {
|
||||
const versionAnchor = formatVersionAnchor(latestRelease.value.version)
|
||||
return `${baseUrl}#${versionAnchor}`
|
||||
}
|
||||
return baseUrl
|
||||
})
|
||||
|
||||
// Auto-hide timer
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const startAutoHide = () => {
|
||||
if (hideTimer) clearTimeout(hideTimer)
|
||||
hideTimer = setTimeout(() => {
|
||||
dismissToast()
|
||||
}, 8000) // 8 second auto-hide
|
||||
}
|
||||
|
||||
const clearAutoHide = () => {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const dismissToast = () => {
|
||||
isDismissed.value = true
|
||||
clearAutoHide()
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
if (latestRelease.value) {
|
||||
void releaseStore.handleSkipRelease(latestRelease.value.version)
|
||||
}
|
||||
dismissToast()
|
||||
}
|
||||
|
||||
const handleLearnMore = () => {
|
||||
if (latestRelease.value) {
|
||||
void releaseStore.handleShowChangelog(latestRelease.value.version)
|
||||
}
|
||||
// Do not dismiss; anchor will navigate in new tab but keep toast? spec maybe wants dismiss? We'll dismiss.
|
||||
dismissToast()
|
||||
}
|
||||
|
||||
const handleUpdate = () => {
|
||||
window.open('https://docs.comfy.org/installation/update_comfyui', '_blank')
|
||||
dismissToast()
|
||||
}
|
||||
|
||||
// Learn more handled by anchor href
|
||||
|
||||
// Start auto-hide when toast becomes visible
|
||||
watch(shouldShow, (isVisible) => {
|
||||
if (isVisible) {
|
||||
startAutoHide()
|
||||
} else {
|
||||
clearAutoHide()
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize on mount
|
||||
onMounted(async () => {
|
||||
// Fetch releases if not already loaded
|
||||
if (!releaseStore.releases.length) {
|
||||
await releaseStore.fetchReleases()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Toast popup - positioning handled by parent */
|
||||
.release-toast-popup {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
z-index: 1000;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Sidebar positioning classes applied by parent - matching help center */
|
||||
.release-toast-popup.sidebar-left {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.release-toast-popup.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.release-toast-popup.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
/* Main toast container */
|
||||
.release-notification-toast {
|
||||
width: 448px;
|
||||
padding: 16px 16px 8px;
|
||||
background: #353535;
|
||||
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
|
||||
border-radius: 12px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Header section */
|
||||
.toast-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Icon container */
|
||||
.toast-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 10px;
|
||||
background: rgba(0, 122, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-icon i {
|
||||
color: #007aff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Text content */
|
||||
.toast-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-family: 'Satoshi', sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 18.2px;
|
||||
}
|
||||
|
||||
.toast-version-badge {
|
||||
color: #a0a1a2;
|
||||
font-size: 12px;
|
||||
font-family: 'Satoshi', sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 15.6px;
|
||||
}
|
||||
|
||||
/* Actions section */
|
||||
.toast-actions-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
padding-left: 58px; /* Align with text content */
|
||||
padding-right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.left-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Learn more link - simple text link */
|
||||
.learn-more-link {
|
||||
color: #60a5fa;
|
||||
font-size: 12px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 15.6px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.learn-more-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.right-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.skip-button {
|
||||
padding: 8px 16px;
|
||||
background: #353535;
|
||||
border-radius: 6px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
border: none;
|
||||
color: #aeaeb2;
|
||||
font-size: 12px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.skip-button:hover {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
border: none;
|
||||
color: black;
|
||||
font-size: 12px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
462
src/platform/updates/components/WhatsNewPopup.vue
Normal file
462
src/platform/updates/components/WhatsNewPopup.vue
Normal file
@@ -0,0 +1,462 @@
|
||||
<template>
|
||||
<div v-if="shouldShow" class="whats-new-popup-container">
|
||||
<!-- Arrow pointing to help center -->
|
||||
<div class="help-center-arrow">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="19"
|
||||
viewBox="0 0 16 19"
|
||||
fill="none"
|
||||
>
|
||||
<!-- Arrow fill -->
|
||||
<path
|
||||
d="M15.25 1.27246L15.25 17.7275L0.999023 9.5L15.25 1.27246Z"
|
||||
fill="#353535"
|
||||
/>
|
||||
<!-- Top and bottom outlines only -->
|
||||
<path
|
||||
d="M15.25 1.27246L0.999023 9.5"
|
||||
stroke="#4e4e4e"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M0.999023 9.5L15.25 17.7275"
|
||||
stroke="#4e4e4e"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="whats-new-popup" @click.stop>
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
class="close-button"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="closePopup"
|
||||
>
|
||||
<div class="close-icon"></div>
|
||||
</button>
|
||||
|
||||
<!-- Release Content -->
|
||||
<div class="popup-content">
|
||||
<div class="content-text" v-html="formattedContent"></div>
|
||||
|
||||
<!-- Actions Section -->
|
||||
<div class="popup-actions">
|
||||
<a
|
||||
class="learn-more-link"
|
||||
:href="changelogUrl"
|
||||
target="_blank"
|
||||
rel="noopener,noreferrer"
|
||||
@click="closePopup"
|
||||
>
|
||||
{{ $t('whatsNewPopup.learnMore') }}
|
||||
</a>
|
||||
<!-- TODO: CTA button -->
|
||||
<!-- <button class="cta-button" @click="handleCTA">CTA</button> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
|
||||
import type { ReleaseNote } from '../common/releaseService'
|
||||
import { useReleaseStore } from '../common/releaseStore'
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
const releaseStore = useReleaseStore()
|
||||
|
||||
// Define emits
|
||||
const emit = defineEmits<{
|
||||
'whats-new-dismissed': []
|
||||
}>()
|
||||
|
||||
// Local state for dismissed status
|
||||
const isDismissed = ref(false)
|
||||
|
||||
// Get latest release from store
|
||||
const latestRelease = computed<ReleaseNote | null>(
|
||||
() => releaseStore.recentRelease
|
||||
)
|
||||
|
||||
// Show popup when on latest version and not dismissed
|
||||
const shouldShow = computed(
|
||||
() => releaseStore.shouldShowPopup && !isDismissed.value
|
||||
)
|
||||
|
||||
// Generate changelog URL with version anchor (language-aware)
|
||||
const changelogUrl = computed(() => {
|
||||
const isChineseLocale = locale.value === 'zh'
|
||||
const baseUrl = isChineseLocale
|
||||
? 'https://docs.comfy.org/zh-CN/changelog'
|
||||
: 'https://docs.comfy.org/changelog'
|
||||
|
||||
if (latestRelease.value?.version) {
|
||||
const versionAnchor = formatVersionAnchor(latestRelease.value.version)
|
||||
return `${baseUrl}#${versionAnchor}`
|
||||
}
|
||||
return baseUrl
|
||||
})
|
||||
|
||||
// Format release content for display using marked
|
||||
const formattedContent = computed(() => {
|
||||
if (!latestRelease.value?.content) {
|
||||
return `<p>${t('whatsNewPopup.noReleaseNotes')}</p>`
|
||||
}
|
||||
|
||||
try {
|
||||
// Use marked to parse markdown to HTML
|
||||
return marked(latestRelease.value.content, {
|
||||
gfm: true // Enable GitHub Flavored Markdown
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error parsing markdown:', error)
|
||||
// Fallback to plain text with line breaks
|
||||
return latestRelease.value.content.replace(/\n/g, '<br>')
|
||||
}
|
||||
})
|
||||
|
||||
const show = () => {
|
||||
isDismissed.value = false
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
isDismissed.value = true
|
||||
emit('whats-new-dismissed')
|
||||
}
|
||||
|
||||
const closePopup = async () => {
|
||||
// Mark "what's new" seen when popup is closed
|
||||
if (latestRelease.value) {
|
||||
await releaseStore.handleWhatsNewSeen(latestRelease.value.version)
|
||||
}
|
||||
hide()
|
||||
}
|
||||
|
||||
// const handleCTA = async () => {
|
||||
// window.open('https://docs.comfy.org/installation/update_comfyui', '_blank')
|
||||
// await closePopup()
|
||||
// }
|
||||
|
||||
// Initialize on mount
|
||||
onMounted(async () => {
|
||||
// Fetch releases if not already loaded
|
||||
if (!releaseStore.releases.length) {
|
||||
await releaseStore.fetchReleases()
|
||||
}
|
||||
})
|
||||
|
||||
// Expose methods for parent component
|
||||
defineExpose({
|
||||
show,
|
||||
hide
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Popup container - positioning handled by parent */
|
||||
.whats-new-popup-container {
|
||||
--whats-new-popup-bottom: 1rem;
|
||||
|
||||
position: absolute;
|
||||
bottom: var(--whats-new-popup-bottom);
|
||||
z-index: 1000;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Arrow pointing to help center */
|
||||
.help-center-arrow {
|
||||
position: absolute;
|
||||
bottom: calc(
|
||||
var(--sidebar-width) * 2 + var(--sidebar-width) / 2
|
||||
); /* Position to center of help center icon (2 icons below + half icon height for center) */
|
||||
transform: none;
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Position arrow based on sidebar location */
|
||||
.whats-new-popup-container.sidebar-left .help-center-arrow {
|
||||
left: -14px; /* Overlap with popup outline */
|
||||
}
|
||||
|
||||
.whats-new-popup-container.sidebar-left.small-sidebar .help-center-arrow {
|
||||
left: -14px; /* Overlap with popup outline */
|
||||
bottom: calc(
|
||||
var(--sidebar-width) * 2 + var(--sidebar-icon-size) / 2 -
|
||||
var(--whats-new-popup-bottom)
|
||||
); /* Position to center of help center icon (2 icons below + half icon height for center - whats new popup bottom position ) */
|
||||
}
|
||||
|
||||
/* Sidebar positioning classes applied by parent */
|
||||
.whats-new-popup-container.sidebar-left {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.whats-new-popup-container.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.whats-new-popup-container.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.whats-new-popup {
|
||||
background: #353535;
|
||||
border-radius: 12px;
|
||||
max-width: 400px;
|
||||
width: 400px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
box-shadow: 0px 8px 32px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Content Section */
|
||||
.popup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 32px 32px 24px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Close button */
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
background: #7c7c7c;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transform: translate(30%, -30%);
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.1s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: #8e8e8e;
|
||||
}
|
||||
|
||||
.close-button:active {
|
||||
background: #6a6a6a;
|
||||
transform: translate(30%, -30%) scale(0.95);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: relative;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover .close-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.close-icon::before,
|
||||
.close-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 2px;
|
||||
background: white;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.close-icon::after {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
/* Content Section */
|
||||
.popup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Style the markdown content */
|
||||
/* Title */
|
||||
.content-text :deep(*) {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content-text :deep(h1) {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Version subtitle - targets the first p tag after h1 */
|
||||
.content-text :deep(h1 + p) {
|
||||
color: #c0c0c0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Regular paragraphs - short description */
|
||||
.content-text :deep(p) {
|
||||
margin-bottom: 16px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* List */
|
||||
.content-text :deep(ul),
|
||||
.content-text :deep(ol) {
|
||||
margin-bottom: 16px;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.content-text :deep(ul:first-child),
|
||||
.content-text :deep(ol:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content-text :deep(ul:last-child),
|
||||
.content-text :deep(ol:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* List items */
|
||||
.content-text :deep(li) {
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.content-text :deep(li:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Custom bullet points */
|
||||
.content-text :deep(li::before) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10px;
|
||||
display: flex;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 100px;
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
/* List item strong text */
|
||||
.content-text :deep(li strong) {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.content-text :deep(li p) {
|
||||
font-size: 12px;
|
||||
margin-bottom: 0;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
/* Code styling */
|
||||
.content-text :deep(code) {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
color: #f8f8f2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Remove top margin for first media element */
|
||||
.content-text :deep(img:first-child),
|
||||
.content-text :deep(video:first-child),
|
||||
.content-text :deep(iframe:first-child) {
|
||||
margin-top: -32px; /* Align with the top edge of the popup content */
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Media elements */
|
||||
.content-text :deep(img),
|
||||
.content-text :deep(video),
|
||||
.content-text :deep(iframe) {
|
||||
width: calc(100% + 64px);
|
||||
height: auto;
|
||||
margin: 24px -32px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Actions Section */
|
||||
.popup-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.learn-more-link {
|
||||
color: #60a5fa;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 18.2px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.learn-more-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
border: none;
|
||||
color: #121212;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
453
src/platform/workflow/core/services/workflowService.ts
Normal file
453
src/platform/workflow/core/services/workflowService.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import { toRaw } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import type { Point, SerialisableGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
|
||||
import { downloadBlob } from '@/scripts/utils'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
|
||||
export const useWorkflowService = () => {
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const toastStore = useToastStore()
|
||||
const dialogService = useDialogService()
|
||||
const workflowThumbnail = useWorkflowThumbnail()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
async function getFilename(defaultName: string): Promise<string | null> {
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
let filename = await dialogService.prompt({
|
||||
title: t('workflowService.exportWorkflow'),
|
||||
message: t('workflowService.enterFilename') + ':',
|
||||
defaultValue: defaultName
|
||||
})
|
||||
if (!filename) return null
|
||||
if (!filename.toLowerCase().endsWith('.json')) {
|
||||
filename += '.json'
|
||||
}
|
||||
return filename
|
||||
}
|
||||
return defaultName
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds scale and offset from litegraph canvas to the workflow JSON.
|
||||
* @param workflow The workflow to add the view restore data to
|
||||
*/
|
||||
function addViewRestore(workflow: ComfyWorkflowJSON) {
|
||||
if (!settingStore.get('Comfy.EnableWorkflowViewRestore')) return
|
||||
|
||||
const { offset, scale } = app.canvas.ds
|
||||
const [x, y] = offset
|
||||
|
||||
workflow.extra ??= {}
|
||||
workflow.extra.ds = { scale, offset: [x, y] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the current workflow as a JSON file
|
||||
* @param filename The filename to save the workflow as
|
||||
* @param promptProperty The property of the prompt to export
|
||||
*/
|
||||
const exportWorkflow = async (
|
||||
filename: string,
|
||||
promptProperty: 'workflow' | 'output'
|
||||
): Promise<void> => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (workflow?.path) {
|
||||
filename = workflow.filename
|
||||
}
|
||||
const p = await app.graphToPrompt()
|
||||
|
||||
addViewRestore(p.workflow)
|
||||
const json = JSON.stringify(p[promptProperty], null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const file = await getFilename(filename)
|
||||
if (!file) return
|
||||
downloadBlob(file, blob)
|
||||
}
|
||||
/**
|
||||
* Save a workflow as a new file
|
||||
* @param workflow The workflow to save
|
||||
*/
|
||||
const saveWorkflowAs = async (workflow: ComfyWorkflow) => {
|
||||
const newFilename = await workflow.promptSave()
|
||||
if (!newFilename) return
|
||||
|
||||
const newPath = workflow.directory + '/' + appendJsonExt(newFilename)
|
||||
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
|
||||
|
||||
if (existingWorkflow && !existingWorkflow.isTemporary) {
|
||||
const res = await dialogService.confirm({
|
||||
title: t('sideToolbar.workflowTab.confirmOverwriteTitle'),
|
||||
type: 'overwrite',
|
||||
message: t('sideToolbar.workflowTab.confirmOverwrite'),
|
||||
itemList: [newPath]
|
||||
})
|
||||
|
||||
if (res !== true) return
|
||||
|
||||
if (existingWorkflow.path === workflow.path) {
|
||||
await saveWorkflow(workflow)
|
||||
return
|
||||
}
|
||||
const deleted = await deleteWorkflow(existingWorkflow, true)
|
||||
if (!deleted) return
|
||||
}
|
||||
|
||||
if (workflow.isTemporary) {
|
||||
await renameWorkflow(workflow, newPath)
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
} else {
|
||||
const tempWorkflow = workflowStore.saveAs(workflow, newPath)
|
||||
await openWorkflow(tempWorkflow)
|
||||
await workflowStore.saveWorkflow(tempWorkflow)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a workflow
|
||||
* @param workflow The workflow to save
|
||||
*/
|
||||
const saveWorkflow = async (workflow: ComfyWorkflow) => {
|
||||
if (workflow.isTemporary) {
|
||||
await saveWorkflowAs(workflow)
|
||||
} else {
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the default workflow
|
||||
*/
|
||||
const loadDefaultWorkflow = async () => {
|
||||
await app.loadGraphData(defaultGraph)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a blank workflow
|
||||
*/
|
||||
const loadBlankWorkflow = async () => {
|
||||
await app.loadGraphData(blankGraph)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a workflow from a task item (queue/history)
|
||||
* For history items, fetches workflow data from /history_v2/{prompt_id}
|
||||
* @param task The task item to load the workflow from
|
||||
*/
|
||||
const loadTaskWorkflow = async (task: TaskItemImpl) => {
|
||||
let workflowData = task.workflow
|
||||
|
||||
// History items don't include workflow data - fetch from API
|
||||
if (task.isHistory) {
|
||||
const promptId = task.prompt.prompt_id
|
||||
if (promptId) {
|
||||
workflowData = (await api.getWorkflowFromHistory(promptId)) || undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (!workflowData) {
|
||||
return
|
||||
}
|
||||
|
||||
await app.loadGraphData(toRaw(workflowData))
|
||||
if (task.outputs) {
|
||||
const nodeOutputsStore = useNodeOutputStore()
|
||||
const rawOutputs = toRaw(task.outputs)
|
||||
|
||||
// Set outputs by execution ID to account for outputs inside of subgraphs
|
||||
for (const nodeExecutionId in rawOutputs) {
|
||||
nodeOutputsStore.setNodeOutputsByExecutionId(
|
||||
nodeExecutionId,
|
||||
rawOutputs[nodeExecutionId]
|
||||
)
|
||||
}
|
||||
|
||||
// Invoke extension (e.g., 3D nodes) hooks to allow them to update
|
||||
useExtensionService().invokeExtensions(
|
||||
'onNodeOutputsUpdated',
|
||||
app.nodeOutputs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the current workflow
|
||||
* This is used to refresh the node definitions update, e.g. when the locale changes.
|
||||
*/
|
||||
const reloadCurrentWorkflow = async () => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (workflow) {
|
||||
await openWorkflow(workflow, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a workflow in the current workspace
|
||||
* @param workflow The workflow to open
|
||||
* @param options The options for opening the workflow
|
||||
*/
|
||||
const openWorkflow = async (
|
||||
workflow: ComfyWorkflow,
|
||||
options: { force: boolean } = { force: false }
|
||||
) => {
|
||||
if (workflowStore.isActive(workflow) && !options.force) return
|
||||
|
||||
const loadFromRemote = !workflow.isLoaded
|
||||
if (loadFromRemote) {
|
||||
await workflow.load()
|
||||
}
|
||||
|
||||
await app.loadGraphData(
|
||||
toRaw(workflow.activeState) as ComfyWorkflowJSON,
|
||||
/* clean=*/ true,
|
||||
/* restore_view=*/ true,
|
||||
workflow,
|
||||
{
|
||||
showMissingModelsDialog: loadFromRemote,
|
||||
showMissingNodesDialog: loadFromRemote,
|
||||
checkForRerouteMigration: false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a workflow with confirmation if there are unsaved changes
|
||||
* @param workflow The workflow to close
|
||||
* @returns true if the workflow was closed, false if the user cancelled
|
||||
*/
|
||||
const closeWorkflow = async (
|
||||
workflow: ComfyWorkflow,
|
||||
options: { warnIfUnsaved: boolean; hint?: string } = {
|
||||
warnIfUnsaved: true
|
||||
}
|
||||
): Promise<boolean> => {
|
||||
if (workflow.isModified && options.warnIfUnsaved) {
|
||||
const confirmed = await dialogService.confirm({
|
||||
title: t('sideToolbar.workflowTab.dirtyCloseTitle'),
|
||||
type: 'dirtyClose',
|
||||
message: t('sideToolbar.workflowTab.dirtyClose'),
|
||||
itemList: [workflow.path],
|
||||
hint: options.hint
|
||||
})
|
||||
// Cancel
|
||||
if (confirmed === null) return false
|
||||
|
||||
if (confirmed === true) {
|
||||
await saveWorkflow(workflow)
|
||||
}
|
||||
}
|
||||
|
||||
// If this is the last workflow, create a new default temporary workflow
|
||||
if (workflowStore.openWorkflows.length === 1) {
|
||||
await loadDefaultWorkflow()
|
||||
}
|
||||
// If this is the active workflow, load the next workflow
|
||||
if (workflowStore.isActive(workflow)) {
|
||||
await loadNextOpenedWorkflow()
|
||||
}
|
||||
|
||||
await workflowStore.closeWorkflow(workflow)
|
||||
return true
|
||||
}
|
||||
|
||||
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
|
||||
await workflowStore.renameWorkflow(workflow, newPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a workflow
|
||||
* @param workflow The workflow to delete
|
||||
* @returns `true` if the workflow was deleted, `false` if the user cancelled
|
||||
*/
|
||||
const deleteWorkflow = async (
|
||||
workflow: ComfyWorkflow,
|
||||
silent = false
|
||||
): Promise<boolean> => {
|
||||
const bypassConfirm = !settingStore.get('Comfy.Workflow.ConfirmDelete')
|
||||
let confirmed: boolean | null = bypassConfirm || silent
|
||||
|
||||
if (!confirmed) {
|
||||
confirmed = await dialogService.confirm({
|
||||
title: t('sideToolbar.workflowTab.confirmDeleteTitle'),
|
||||
type: 'delete',
|
||||
message: t('sideToolbar.workflowTab.confirmDelete'),
|
||||
itemList: [workflow.path]
|
||||
})
|
||||
if (!confirmed) return false
|
||||
}
|
||||
|
||||
if (workflowStore.isOpen(workflow)) {
|
||||
const closed = await closeWorkflow(workflow, {
|
||||
warnIfUnsaved: !confirmed
|
||||
})
|
||||
if (!closed) return false
|
||||
}
|
||||
await workflowStore.deleteWorkflow(workflow)
|
||||
if (!silent) {
|
||||
toastStore.add({
|
||||
severity: 'info',
|
||||
summary: t('sideToolbar.workflowTab.deleted'),
|
||||
life: 1000
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called before loading a new graph.
|
||||
* There are 3 major functions that loads a new graph to the graph editor:
|
||||
* 1. loadGraphData
|
||||
* 2. loadApiJson
|
||||
* 3. importA1111
|
||||
*
|
||||
* This function is used to save the current workflow states before loading
|
||||
* a new graph.
|
||||
*/
|
||||
const beforeLoadNewGraph = () => {
|
||||
// Use workspaceStore here as it is patched in unit tests.
|
||||
const workflowStore = useWorkspaceStore().workflow
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
if (activeWorkflow) {
|
||||
activeWorkflow.changeTracker.store()
|
||||
// Capture thumbnail before loading new graph
|
||||
void workflowThumbnail.storeThumbnail(activeWorkflow)
|
||||
domWidgetStore.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active workflow after the new graph is loaded.
|
||||
*
|
||||
* The call relationship is
|
||||
* useWorkflowService().openWorkflow -> app.loadGraphData -> useWorkflowService().afterLoadNewGraph
|
||||
* app.loadApiJson -> useWorkflowService().afterLoadNewGraph
|
||||
* app.importA1111 -> useWorkflowService().afterLoadNewGraph
|
||||
*
|
||||
* @param value The value to set as the active workflow.
|
||||
* @param workflowData The initial workflow data loaded to the graph editor.
|
||||
*/
|
||||
const afterLoadNewGraph = async (
|
||||
value: string | ComfyWorkflow | null,
|
||||
workflowData: ComfyWorkflowJSON
|
||||
) => {
|
||||
// Use workspaceStore here as it is patched in unit tests.
|
||||
const workflowStore = useWorkspaceStore().workflow
|
||||
if (typeof value === 'string') {
|
||||
const workflow = workflowStore.getWorkflowByPath(
|
||||
ComfyWorkflow.basePath + appendJsonExt(value)
|
||||
)
|
||||
if (workflow?.isPersisted) {
|
||||
const loadedWorkflow = await workflowStore.openWorkflow(workflow)
|
||||
loadedWorkflow.changeTracker.restore()
|
||||
loadedWorkflow.changeTracker.reset(workflowData)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (value === null || typeof value === 'string') {
|
||||
const path = value as string | null
|
||||
const tempWorkflow = workflowStore.createTemporary(
|
||||
path ? appendJsonExt(path) : undefined,
|
||||
workflowData
|
||||
)
|
||||
await workflowStore.openWorkflow(tempWorkflow)
|
||||
return
|
||||
}
|
||||
|
||||
// value is a ComfyWorkflow.
|
||||
const loadedWorkflow = await workflowStore.openWorkflow(value)
|
||||
loadedWorkflow.changeTracker.reset(workflowData)
|
||||
loadedWorkflow.changeTracker.restore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the given workflow into the current graph editor.
|
||||
*/
|
||||
const insertWorkflow = async (
|
||||
workflow: ComfyWorkflow,
|
||||
options: { position?: Point } = {}
|
||||
) => {
|
||||
const loadedWorkflow = await workflow.load()
|
||||
const workflowJSON = toRaw(loadedWorkflow.initialState)
|
||||
const old = localStorage.getItem('litegrapheditor_clipboard')
|
||||
// unknown conversion: ComfyWorkflowJSON is stricter than LiteGraph's
|
||||
// serialisation schema.
|
||||
const graph = new LGraph(workflowJSON as unknown as SerialisableGraph)
|
||||
const canvasElement = document.createElement('canvas')
|
||||
const canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_events: true,
|
||||
skip_render: true
|
||||
})
|
||||
canvas.selectItems()
|
||||
canvas.copyToClipboard()
|
||||
app.canvas.pasteFromClipboard(options)
|
||||
if (old !== null) {
|
||||
localStorage.setItem('litegrapheditor_clipboard', old)
|
||||
}
|
||||
}
|
||||
|
||||
const loadNextOpenedWorkflow = async () => {
|
||||
const nextWorkflow = workflowStore.openedWorkflowIndexShift(1)
|
||||
if (nextWorkflow) {
|
||||
await openWorkflow(nextWorkflow)
|
||||
}
|
||||
}
|
||||
|
||||
const loadPreviousOpenedWorkflow = async () => {
|
||||
const previousWorkflow = workflowStore.openedWorkflowIndexShift(-1)
|
||||
if (previousWorkflow) {
|
||||
await openWorkflow(previousWorkflow)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an existing workflow and duplicates it with a new name
|
||||
*/
|
||||
const duplicateWorkflow = async (workflow: ComfyWorkflow) => {
|
||||
const state = JSON.parse(JSON.stringify(workflow.activeState))
|
||||
const suffix = workflow.isPersisted ? ' (Copy)' : ''
|
||||
// Remove the suffix `(2)` or similar
|
||||
const filename = workflow.filename.replace(/\s*\(\d+\)$/, '') + suffix
|
||||
|
||||
await app.loadGraphData(state, true, true, filename)
|
||||
}
|
||||
|
||||
return {
|
||||
exportWorkflow,
|
||||
saveWorkflowAs,
|
||||
saveWorkflow,
|
||||
loadDefaultWorkflow,
|
||||
loadBlankWorkflow,
|
||||
loadTaskWorkflow,
|
||||
reloadCurrentWorkflow,
|
||||
openWorkflow,
|
||||
closeWorkflow,
|
||||
renameWorkflow,
|
||||
deleteWorkflow,
|
||||
insertWorkflow,
|
||||
loadNextOpenedWorkflow,
|
||||
loadPreviousOpenedWorkflow,
|
||||
duplicateWorkflow,
|
||||
afterLoadNewGraph,
|
||||
beforeLoadNewGraph
|
||||
}
|
||||
}
|
||||
72
src/platform/workflow/core/types/formats.ts
Normal file
72
src/platform/workflow/core/types/formats.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Supported workflow file formats organized by type category
|
||||
*/
|
||||
|
||||
/**
|
||||
* All supported image formats that can contain workflow data
|
||||
*/
|
||||
const IMAGE_WORKFLOW_FORMATS = {
|
||||
extensions: ['.png', '.webp', '.svg', '.avif'],
|
||||
mimeTypes: ['image/png', 'image/webp', 'image/svg+xml', 'image/avif']
|
||||
}
|
||||
|
||||
/**
|
||||
* All supported audio formats that can contain workflow data
|
||||
*/
|
||||
const AUDIO_WORKFLOW_FORMATS = {
|
||||
extensions: ['.mp3', '.ogg', '.flac'],
|
||||
mimeTypes: ['audio/mpeg', 'audio/ogg', 'audio/flac', 'audio/x-flac']
|
||||
}
|
||||
|
||||
/**
|
||||
* All supported video formats that can contain workflow data
|
||||
*/
|
||||
const VIDEO_WORKFLOW_FORMATS = {
|
||||
extensions: ['.mp4', '.mov', '.m4v', '.webm'],
|
||||
mimeTypes: ['video/mp4', 'video/quicktime', 'video/x-m4v', 'video/webm']
|
||||
}
|
||||
|
||||
/**
|
||||
* All supported 3D model formats that can contain workflow data
|
||||
*/
|
||||
const MODEL_WORKFLOW_FORMATS = {
|
||||
extensions: ['.glb'],
|
||||
mimeTypes: ['model/gltf-binary']
|
||||
}
|
||||
|
||||
/**
|
||||
* All supported data formats that directly contain workflow data
|
||||
*/
|
||||
const DATA_WORKFLOW_FORMATS = {
|
||||
extensions: ['.json', '.latent', '.safetensors'],
|
||||
mimeTypes: ['application/json']
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines all supported formats into a single object
|
||||
*/
|
||||
const ALL_WORKFLOW_FORMATS = {
|
||||
extensions: [
|
||||
...IMAGE_WORKFLOW_FORMATS.extensions,
|
||||
...AUDIO_WORKFLOW_FORMATS.extensions,
|
||||
...VIDEO_WORKFLOW_FORMATS.extensions,
|
||||
...MODEL_WORKFLOW_FORMATS.extensions,
|
||||
...DATA_WORKFLOW_FORMATS.extensions
|
||||
],
|
||||
mimeTypes: [
|
||||
...IMAGE_WORKFLOW_FORMATS.mimeTypes,
|
||||
...AUDIO_WORKFLOW_FORMATS.mimeTypes,
|
||||
...VIDEO_WORKFLOW_FORMATS.mimeTypes,
|
||||
...MODEL_WORKFLOW_FORMATS.mimeTypes,
|
||||
...DATA_WORKFLOW_FORMATS.mimeTypes
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a comma-separated accept string for file inputs
|
||||
* Combines all extensions and mime types
|
||||
*/
|
||||
export const WORKFLOW_ACCEPT_STRING = [
|
||||
...ALL_WORKFLOW_FORMATS.extensions,
|
||||
...ALL_WORKFLOW_FORMATS.mimeTypes
|
||||
].join(',')
|
||||
@@ -0,0 +1,29 @@
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
return {
|
||||
id: 'workflows',
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
iconBadge: () => {
|
||||
if (
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition') !== 'Sidebar'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const value = workflowStore.openWorkflows.length.toString()
|
||||
return value === '0' ? null : value
|
||||
},
|
||||
title: 'sideToolbar.workflows',
|
||||
tooltip: 'sideToolbar.workflows',
|
||||
label: 'sideToolbar.labels.workflows',
|
||||
component: markRaw(WorkflowsSidebarTab),
|
||||
type: 'vue'
|
||||
}
|
||||
}
|
||||
772
src/platform/workflow/management/stores/workflowStore.ts
Normal file
772
src/platform/workflow/management/stores/workflowStore.ts
Normal file
@@ -0,0 +1,772 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { defineStore } from 'pinia'
|
||||
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { UserFile } from '@/stores/userFileStore'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
createNodeExecutionId,
|
||||
createNodeLocatorId,
|
||||
parseNodeExecutionId,
|
||||
parseNodeLocatorId
|
||||
} from '@/types/nodeIdentification'
|
||||
import { generateUUID, getPathDetails } from '@/utils/formatUtil'
|
||||
import { syncEntities } from '@/utils/syncUtil'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
export class ComfyWorkflow extends UserFile {
|
||||
static readonly basePath: string = 'workflows/'
|
||||
readonly tintCanvasBg?: string
|
||||
|
||||
/**
|
||||
* The change tracker for the workflow. Non-reactive raw object.
|
||||
*/
|
||||
changeTracker: ChangeTracker | null = null
|
||||
/**
|
||||
* Whether the workflow has been modified comparing to the initial state.
|
||||
*/
|
||||
_isModified: boolean = false
|
||||
|
||||
/**
|
||||
* @param options The path, modified, and size of the workflow.
|
||||
* Note: path is the full path, including the 'workflows/' prefix.
|
||||
*/
|
||||
constructor(options: { path: string; modified: number; size: number }) {
|
||||
super(options.path, options.modified, options.size)
|
||||
}
|
||||
|
||||
override get key() {
|
||||
return this.path.substring(ComfyWorkflow.basePath.length)
|
||||
}
|
||||
|
||||
get activeState(): ComfyWorkflowJSON | null {
|
||||
return this.changeTracker?.activeState ?? null
|
||||
}
|
||||
|
||||
get initialState(): ComfyWorkflowJSON | null {
|
||||
return this.changeTracker?.initialState ?? null
|
||||
}
|
||||
|
||||
override get isLoaded(): boolean {
|
||||
return this.changeTracker !== null
|
||||
}
|
||||
|
||||
override get isModified(): boolean {
|
||||
return this._isModified
|
||||
}
|
||||
|
||||
override set isModified(value: boolean) {
|
||||
this._isModified = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the workflow content from remote storage. Directly returns the loaded
|
||||
* workflow if the content is already loaded.
|
||||
*
|
||||
* @param force Whether to force loading the content even if it is already loaded.
|
||||
* @returns this
|
||||
*/
|
||||
override async load({
|
||||
force = false
|
||||
}: { force?: boolean } = {}): Promise<LoadedComfyWorkflow> {
|
||||
await super.load({ force })
|
||||
if (!force && this.isLoaded) return this as LoadedComfyWorkflow
|
||||
|
||||
if (!this.originalContent) {
|
||||
throw new Error('[ASSERT] Workflow content should be loaded')
|
||||
}
|
||||
|
||||
// Note: originalContent is populated by super.load()
|
||||
console.debug('load and start tracking of workflow', this.path)
|
||||
this.changeTracker = markRaw(
|
||||
new ChangeTracker(
|
||||
this,
|
||||
/* initialState= */ JSON.parse(this.originalContent)
|
||||
)
|
||||
)
|
||||
return this as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
override unload(): void {
|
||||
console.debug('unload workflow', this.path)
|
||||
this.changeTracker = null
|
||||
super.unload()
|
||||
}
|
||||
|
||||
override async save() {
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
// Force save to ensure the content is updated in remote storage incase
|
||||
// the isModified state is screwed by changeTracker.
|
||||
const ret = await super.save({ force: true })
|
||||
this.changeTracker?.reset()
|
||||
this.isModified = false
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the workflow as a new file.
|
||||
* @param path The path to save the workflow to. Note: with 'workflows/' prefix.
|
||||
* @returns this
|
||||
*/
|
||||
override async saveAs(path: string) {
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
return await super.saveAs(path)
|
||||
}
|
||||
|
||||
async promptSave(): Promise<string | null> {
|
||||
return await useDialogService().prompt({
|
||||
title: t('workflowService.saveWorkflow'),
|
||||
message: t('workflowService.enterFilename') + ':',
|
||||
defaultValue: this.filename
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadedComfyWorkflow extends ComfyWorkflow {
|
||||
isLoaded: true
|
||||
originalContent: string
|
||||
content: string
|
||||
changeTracker: ChangeTracker
|
||||
initialState: ComfyWorkflowJSON
|
||||
activeState: ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposed store interface for the workflow store.
|
||||
* Explicitly typed to avoid trigger following error:
|
||||
* error TS7056: The inferred type of this node exceeds the maximum length the
|
||||
* compiler will serialize. An explicit type annotation is needed.
|
||||
*/
|
||||
interface WorkflowStore {
|
||||
activeWorkflow: LoadedComfyWorkflow | null
|
||||
attachWorkflow: (workflow: ComfyWorkflow, openIndex?: number) => void
|
||||
isActive: (workflow: ComfyWorkflow) => boolean
|
||||
openWorkflows: ComfyWorkflow[]
|
||||
openedWorkflowIndexShift: (shift: number) => ComfyWorkflow | null
|
||||
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
|
||||
openWorkflowsInBackground: (paths: {
|
||||
left?: string[]
|
||||
right?: string[]
|
||||
}) => void
|
||||
isOpen: (workflow: ComfyWorkflow) => boolean
|
||||
isBusy: boolean
|
||||
closeWorkflow: (workflow: ComfyWorkflow) => Promise<void>
|
||||
createTemporary: (
|
||||
path?: string,
|
||||
workflowData?: ComfyWorkflowJSON
|
||||
) => ComfyWorkflow
|
||||
renameWorkflow: (workflow: ComfyWorkflow, newPath: string) => Promise<void>
|
||||
deleteWorkflow: (workflow: ComfyWorkflow) => Promise<void>
|
||||
saveWorkflow: (workflow: ComfyWorkflow) => Promise<void>
|
||||
|
||||
workflows: ComfyWorkflow[]
|
||||
bookmarkedWorkflows: ComfyWorkflow[]
|
||||
persistedWorkflows: ComfyWorkflow[]
|
||||
modifiedWorkflows: ComfyWorkflow[]
|
||||
getWorkflowByPath: (path: string) => ComfyWorkflow | null
|
||||
syncWorkflows: (dir?: string) => Promise<void>
|
||||
reorderWorkflows: (from: number, to: number) => void
|
||||
|
||||
/** `true` if any subgraph is currently being viewed. */
|
||||
isSubgraphActive: boolean
|
||||
activeSubgraph: Subgraph | undefined
|
||||
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
|
||||
updateActiveGraph: () => void
|
||||
executionIdToCurrentId: (id: string) => any
|
||||
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
|
||||
nodeExecutionIdToNodeLocatorId: (
|
||||
nodeExecutionId: NodeExecutionId | string
|
||||
) => NodeLocatorId | null
|
||||
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId | string) => NodeId | null
|
||||
nodeLocatorIdToNodeExecutionId: (
|
||||
locatorId: NodeLocatorId | string,
|
||||
targetSubgraph?: Subgraph
|
||||
) => NodeExecutionId | null
|
||||
}
|
||||
|
||||
export const useWorkflowStore = defineStore('workflow', () => {
|
||||
/**
|
||||
* Detach the workflow from the store. lightweight helper function.
|
||||
* @param workflow The workflow to detach.
|
||||
* @returns The index of the workflow in the openWorkflowPaths array, or -1 if the workflow was not open.
|
||||
*/
|
||||
const detachWorkflow = (workflow: ComfyWorkflow) => {
|
||||
delete workflowLookup.value[workflow.path]
|
||||
const index = openWorkflowPaths.value.indexOf(workflow.path)
|
||||
if (index !== -1) {
|
||||
openWorkflowPaths.value = openWorkflowPaths.value.filter(
|
||||
(path) => path !== workflow.path
|
||||
)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the workflow to the store. lightweight helper function.
|
||||
* @param workflow The workflow to attach.
|
||||
* @param openIndex The index to open the workflow at.
|
||||
*/
|
||||
const attachWorkflow = (workflow: ComfyWorkflow, openIndex: number = -1) => {
|
||||
workflowLookup.value[workflow.path] = workflow
|
||||
|
||||
if (openIndex !== -1) {
|
||||
openWorkflowPaths.value.splice(openIndex, 0, workflow.path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The active workflow currently being edited.
|
||||
*/
|
||||
const activeWorkflow = ref<LoadedComfyWorkflow | null>(null)
|
||||
const isActive = (workflow: ComfyWorkflow) =>
|
||||
activeWorkflow.value?.path === workflow.path
|
||||
/**
|
||||
* All workflows.
|
||||
*/
|
||||
const workflowLookup = ref<Record<string, ComfyWorkflow>>({})
|
||||
const workflows = computed<ComfyWorkflow[]>(() =>
|
||||
Object.values(workflowLookup.value)
|
||||
)
|
||||
const getWorkflowByPath = (path: string): ComfyWorkflow | null =>
|
||||
workflowLookup.value[path] ?? null
|
||||
|
||||
/**
|
||||
* The paths of the open workflows. It is setup as a ref to allow user
|
||||
* to reorder the workflows opened.
|
||||
*/
|
||||
const openWorkflowPaths = ref<string[]>([])
|
||||
const openWorkflowPathSet = computed(() => new Set(openWorkflowPaths.value))
|
||||
const openWorkflows = computed(() =>
|
||||
openWorkflowPaths.value.map((path) => workflowLookup.value[path])
|
||||
)
|
||||
const reorderWorkflows = (from: number, to: number) => {
|
||||
const movedTab = openWorkflowPaths.value[from]
|
||||
openWorkflowPaths.value.splice(from, 1)
|
||||
openWorkflowPaths.value.splice(to, 0, movedTab)
|
||||
}
|
||||
const isOpen = (workflow: ComfyWorkflow) =>
|
||||
openWorkflowPathSet.value.has(workflow.path)
|
||||
|
||||
/**
|
||||
* Add paths to the list of open workflow paths without loading the files
|
||||
* or changing the active workflow.
|
||||
*
|
||||
* @param paths - The workflows to open, specified as:
|
||||
* - `left`: Workflows to be added to the left.
|
||||
* - `right`: Workflows to be added to the right.
|
||||
*
|
||||
* Invalid paths (non-strings or paths not found in `workflowLookup.value`)
|
||||
* will be ignored. Duplicate paths are automatically removed.
|
||||
*/
|
||||
const openWorkflowsInBackground = (paths: {
|
||||
left?: string[]
|
||||
right?: string[]
|
||||
}) => {
|
||||
const { left = [], right = [] } = paths
|
||||
if (!left.length && !right.length) return
|
||||
|
||||
const isValidPath = (
|
||||
path: unknown
|
||||
): path is keyof typeof workflowLookup.value =>
|
||||
typeof path === 'string' && path in workflowLookup.value
|
||||
|
||||
openWorkflowPaths.value = _.union(
|
||||
left,
|
||||
openWorkflowPaths.value,
|
||||
right
|
||||
).filter(isValidPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the workflow as the active workflow.
|
||||
* @param workflow The workflow to open.
|
||||
*/
|
||||
const openWorkflow = async (
|
||||
workflow: ComfyWorkflow
|
||||
): Promise<LoadedComfyWorkflow> => {
|
||||
if (isActive(workflow)) return workflow as LoadedComfyWorkflow
|
||||
|
||||
if (!openWorkflowPaths.value.includes(workflow.path)) {
|
||||
openWorkflowPaths.value.push(workflow.path)
|
||||
}
|
||||
const loadedWorkflow = await workflow.load()
|
||||
activeWorkflow.value = loadedWorkflow
|
||||
comfyApp.canvas.bg_tint = loadedWorkflow.tintCanvasBg
|
||||
console.debug('[workflowStore] open workflow', workflow.path)
|
||||
return loadedWorkflow
|
||||
}
|
||||
|
||||
const getUnconflictedPath = (basePath: string): string => {
|
||||
const { directory, filename, suffix } = getPathDetails(basePath)
|
||||
let counter = 2
|
||||
let newPath = basePath
|
||||
while (workflowLookup.value[newPath]) {
|
||||
newPath = `${directory}/${filename} (${counter}).${suffix}`
|
||||
counter++
|
||||
}
|
||||
return newPath
|
||||
}
|
||||
const saveAs = (
|
||||
existingWorkflow: ComfyWorkflow,
|
||||
path: string
|
||||
): ComfyWorkflow => {
|
||||
// Generate new id when saving existing workflow as a new file
|
||||
const id = generateUUID()
|
||||
const state = JSON.parse(
|
||||
JSON.stringify(existingWorkflow.activeState)
|
||||
) as ComfyWorkflowJSON
|
||||
state.id = id
|
||||
|
||||
const workflow: ComfyWorkflow = new (existingWorkflow.constructor as any)({
|
||||
path,
|
||||
modified: Date.now(),
|
||||
size: -1
|
||||
})
|
||||
workflow.originalContent = workflow.content = JSON.stringify(state)
|
||||
workflowLookup.value[workflow.path] = workflow
|
||||
return workflow
|
||||
}
|
||||
|
||||
const createTemporary = (path?: string, workflowData?: ComfyWorkflowJSON) => {
|
||||
const fullPath = getUnconflictedPath(
|
||||
ComfyWorkflow.basePath + (path ?? 'Unsaved Workflow.json')
|
||||
)
|
||||
const existingWorkflow = workflows.value.find((w) => w.fullFilename == path)
|
||||
if (
|
||||
path &&
|
||||
workflowData &&
|
||||
existingWorkflow?.changeTracker &&
|
||||
!existingWorkflow.directory.startsWith(
|
||||
ComfyWorkflow.basePath.slice(0, -1)
|
||||
)
|
||||
) {
|
||||
existingWorkflow.changeTracker.reset(workflowData)
|
||||
return existingWorkflow
|
||||
}
|
||||
|
||||
const workflow = new ComfyWorkflow({
|
||||
path: fullPath,
|
||||
modified: Date.now(),
|
||||
size: -1
|
||||
})
|
||||
|
||||
workflow.originalContent = workflow.content = workflowData
|
||||
? JSON.stringify(workflowData)
|
||||
: defaultGraphJSON
|
||||
|
||||
workflowLookup.value[workflow.path] = workflow
|
||||
return workflow
|
||||
}
|
||||
|
||||
const closeWorkflow = async (workflow: ComfyWorkflow) => {
|
||||
openWorkflowPaths.value = openWorkflowPaths.value.filter(
|
||||
(path) => path !== workflow.path
|
||||
)
|
||||
if (workflow.isTemporary) {
|
||||
// Clear thumbnail when temporary workflow is closed
|
||||
clearThumbnail(workflow.key)
|
||||
delete workflowLookup.value[workflow.path]
|
||||
} else {
|
||||
workflow.unload()
|
||||
}
|
||||
console.debug('[workflowStore] close workflow', workflow.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the workflow at the given index shift from the active workflow.
|
||||
* @param shift The shift to the next workflow. Positive for next, negative for previous.
|
||||
* @returns The next workflow or null if the shift is out of bounds.
|
||||
*/
|
||||
const openedWorkflowIndexShift = (shift: number): ComfyWorkflow | null => {
|
||||
const index = openWorkflowPaths.value.indexOf(
|
||||
activeWorkflow.value?.path ?? ''
|
||||
)
|
||||
|
||||
if (index !== -1) {
|
||||
const length = openWorkflows.value.length
|
||||
const nextIndex = (index + shift + length) % length
|
||||
const nextWorkflow = openWorkflows.value[nextIndex]
|
||||
return nextWorkflow ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const persistedWorkflows = computed(() =>
|
||||
Array.from(workflows.value).filter(
|
||||
(workflow) =>
|
||||
workflow.isPersisted && !workflow.path.startsWith('subgraphs/')
|
||||
)
|
||||
)
|
||||
const syncWorkflows = async (dir: string = '') => {
|
||||
await syncEntities(
|
||||
dir ? 'workflows/' + dir : 'workflows',
|
||||
workflowLookup.value,
|
||||
(file) =>
|
||||
new ComfyWorkflow({
|
||||
path: file.path,
|
||||
modified: file.modified,
|
||||
size: file.size
|
||||
}),
|
||||
(existingWorkflow, file) => {
|
||||
existingWorkflow.lastModified = file.modified
|
||||
existingWorkflow.size = file.size
|
||||
existingWorkflow.unload()
|
||||
},
|
||||
/* exclude */ (workflow) => workflow.isTemporary
|
||||
)
|
||||
}
|
||||
|
||||
const bookmarkStore = useWorkflowBookmarkStore()
|
||||
const bookmarkedWorkflows = computed(() =>
|
||||
workflows.value.filter((workflow) =>
|
||||
bookmarkStore.isBookmarked(workflow.path)
|
||||
)
|
||||
)
|
||||
const modifiedWorkflows = computed(() =>
|
||||
workflows.value.filter((workflow) => workflow.isModified)
|
||||
)
|
||||
|
||||
/** A filesystem operation is currently in progress (e.g. save, rename, delete) */
|
||||
const isBusy = ref<boolean>(false)
|
||||
const { moveWorkflowThumbnail, clearThumbnail } = useWorkflowThumbnail()
|
||||
|
||||
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
|
||||
isBusy.value = true
|
||||
try {
|
||||
// Capture all needed values upfront
|
||||
const oldPath = workflow.path
|
||||
const oldKey = workflow.key
|
||||
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
|
||||
|
||||
const openIndex = detachWorkflow(workflow)
|
||||
// Perform the actual rename operation first
|
||||
try {
|
||||
await workflow.rename(newPath)
|
||||
} finally {
|
||||
attachWorkflow(workflow, openIndex)
|
||||
}
|
||||
|
||||
// Move thumbnail from old key to new key (using workflow keys, not full paths)
|
||||
const newKey = workflow.key
|
||||
moveWorkflowThumbnail(oldKey, newKey)
|
||||
// Update bookmarks
|
||||
if (wasBookmarked) {
|
||||
await bookmarkStore.setBookmarked(oldPath, false)
|
||||
await bookmarkStore.setBookmarked(newPath, true)
|
||||
}
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteWorkflow = async (workflow: ComfyWorkflow) => {
|
||||
isBusy.value = true
|
||||
try {
|
||||
await workflow.delete()
|
||||
if (bookmarkStore.isBookmarked(workflow.path)) {
|
||||
await bookmarkStore.setBookmarked(workflow.path, false)
|
||||
}
|
||||
// Clear thumbnail when workflow is deleted
|
||||
clearThumbnail(workflow.key)
|
||||
delete workflowLookup.value[workflow.path]
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a workflow.
|
||||
* @param workflow The workflow to save.
|
||||
*/
|
||||
const saveWorkflow = async (workflow: ComfyWorkflow) => {
|
||||
isBusy.value = true
|
||||
try {
|
||||
// Detach the workflow and re-attach to force refresh the tree objects.
|
||||
const openIndex = detachWorkflow(workflow)
|
||||
try {
|
||||
await workflow.save()
|
||||
} finally {
|
||||
attachWorkflow(workflow, openIndex)
|
||||
}
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** @see WorkflowStore.isSubgraphActive */
|
||||
const isSubgraphActive = ref(false)
|
||||
|
||||
/** @see WorkflowStore.activeSubgraph */
|
||||
const activeSubgraph = shallowRef<Raw<Subgraph>>()
|
||||
|
||||
/** @see WorkflowStore.updateActiveGraph */
|
||||
const updateActiveGraph = () => {
|
||||
const subgraph = comfyApp.canvas?.subgraph
|
||||
activeSubgraph.value = subgraph ? markRaw(subgraph) : undefined
|
||||
if (!comfyApp.canvas) return
|
||||
|
||||
isSubgraphActive.value = isSubgraph(subgraph)
|
||||
}
|
||||
|
||||
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (node?.isSubgraphNode()) return node.subgraph
|
||||
}
|
||||
|
||||
const getSubgraphsFromInstanceIds = (
|
||||
currentGraph: LGraph | Subgraph,
|
||||
subgraphNodeIds: string[],
|
||||
subgraphs: Subgraph[] = []
|
||||
): Subgraph[] => {
|
||||
const currentPart = subgraphNodeIds.shift()
|
||||
if (currentPart === undefined) return subgraphs
|
||||
|
||||
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
|
||||
if (subgraph === undefined) throw new Error('Subgraph not found')
|
||||
|
||||
subgraphs.push(subgraph)
|
||||
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
|
||||
}
|
||||
|
||||
const executionIdToCurrentId = (id: string) => {
|
||||
const subgraph = activeSubgraph.value
|
||||
|
||||
// Short-circuit: ID belongs to the parent workflow / no active subgraph
|
||||
if (!id.includes(':')) {
|
||||
return !subgraph ? id : undefined
|
||||
} else if (!subgraph) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the execution ID (e.g., "123:456:789")
|
||||
const subgraphNodeIds = id.split(':')
|
||||
|
||||
// Start from the root graph
|
||||
const { graph } = comfyApp
|
||||
|
||||
// If the last subgraph is the active subgraph, return the node ID
|
||||
const subgraphs = getSubgraphsFromInstanceIds(graph, subgraphNodeIds)
|
||||
if (subgraphs.at(-1) === subgraph) {
|
||||
return subgraphNodeIds.at(-1)
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeWorkflow, updateActiveGraph)
|
||||
|
||||
/**
|
||||
* Convert a node ID to a NodeLocatorId
|
||||
* @param nodeId The local node ID
|
||||
* @param subgraph The subgraph containing the node (defaults to active subgraph)
|
||||
* @returns The NodeLocatorId (for root graph nodes, returns the node ID as-is)
|
||||
*/
|
||||
const nodeIdToNodeLocatorId = (
|
||||
nodeId: NodeId,
|
||||
subgraph?: Subgraph
|
||||
): NodeLocatorId => {
|
||||
const targetSubgraph = subgraph ?? activeSubgraph.value
|
||||
if (!targetSubgraph) {
|
||||
// Node is in the root graph, return the node ID as-is
|
||||
return String(nodeId)
|
||||
}
|
||||
|
||||
return createNodeLocatorId(targetSubgraph.id, nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an execution ID to a NodeLocatorId
|
||||
* @param nodeExecutionId The execution node ID (e.g., "123:456:789")
|
||||
* @returns The NodeLocatorId or null if conversion fails
|
||||
*/
|
||||
const nodeExecutionIdToNodeLocatorId = (
|
||||
nodeExecutionId: NodeExecutionId | string
|
||||
): NodeLocatorId | null => {
|
||||
// Handle simple node IDs (root graph - no colons)
|
||||
if (!nodeExecutionId.includes(':')) {
|
||||
return nodeExecutionId
|
||||
}
|
||||
|
||||
const parts = parseNodeExecutionId(nodeExecutionId)
|
||||
if (!parts || parts.length === 0) return null
|
||||
|
||||
const nodeId = parts[parts.length - 1]
|
||||
const subgraphNodeIds = parts.slice(0, -1)
|
||||
|
||||
if (subgraphNodeIds.length === 0) {
|
||||
// Node is in root graph, return the node ID as-is
|
||||
return String(nodeId)
|
||||
}
|
||||
|
||||
try {
|
||||
const subgraphs = getSubgraphsFromInstanceIds(
|
||||
comfyApp.graph,
|
||||
subgraphNodeIds.map((id) => String(id))
|
||||
)
|
||||
const immediateSubgraph = subgraphs[subgraphs.length - 1]
|
||||
return createNodeLocatorId(immediateSubgraph.id, nodeId)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the node ID from a NodeLocatorId
|
||||
* @param locatorId The NodeLocatorId
|
||||
* @returns The local node ID or null if invalid
|
||||
*/
|
||||
const nodeLocatorIdToNodeId = (
|
||||
locatorId: NodeLocatorId | string
|
||||
): NodeId | null => {
|
||||
const parsed = parseNodeLocatorId(locatorId)
|
||||
return parsed?.localNodeId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a NodeLocatorId to an execution ID for a specific context
|
||||
* @param locatorId The NodeLocatorId
|
||||
* @param targetSubgraph The subgraph context (defaults to active subgraph)
|
||||
* @returns The execution ID or null if the node is not accessible from the target context
|
||||
*/
|
||||
const nodeLocatorIdToNodeExecutionId = (
|
||||
locatorId: NodeLocatorId | string,
|
||||
targetSubgraph?: Subgraph
|
||||
): NodeExecutionId | null => {
|
||||
const parsed = parseNodeLocatorId(locatorId)
|
||||
if (!parsed) return null
|
||||
|
||||
const { subgraphUuid, localNodeId } = parsed
|
||||
|
||||
// If no subgraph UUID, this is a root graph node
|
||||
if (!subgraphUuid) {
|
||||
return String(localNodeId)
|
||||
}
|
||||
|
||||
// Find the path from root to the subgraph with this UUID
|
||||
const findSubgraphPath = (
|
||||
graph: LGraph | Subgraph,
|
||||
targetUuid: string,
|
||||
path: NodeId[] = []
|
||||
): NodeId[] | null => {
|
||||
if (isSubgraph(graph) && graph.id === targetUuid) {
|
||||
return path
|
||||
}
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
if (node.isSubgraphNode() && node.subgraph) {
|
||||
const result = findSubgraphPath(node.subgraph, targetUuid, [
|
||||
...path,
|
||||
node.id
|
||||
])
|
||||
if (result) return result
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const path = findSubgraphPath(comfyApp.graph, subgraphUuid)
|
||||
if (!path) return null
|
||||
|
||||
// If we have a target subgraph, check if the path goes through it
|
||||
if (
|
||||
targetSubgraph &&
|
||||
!path.some((_, idx) => {
|
||||
const subgraphs = getSubgraphsFromInstanceIds(
|
||||
comfyApp.graph,
|
||||
path.slice(0, idx + 1).map((id) => String(id))
|
||||
)
|
||||
return subgraphs[subgraphs.length - 1] === targetSubgraph
|
||||
})
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return createNodeExecutionId([...path, localNodeId])
|
||||
}
|
||||
|
||||
return {
|
||||
activeWorkflow,
|
||||
attachWorkflow,
|
||||
isActive,
|
||||
openWorkflows,
|
||||
openedWorkflowIndexShift,
|
||||
openWorkflow,
|
||||
openWorkflowsInBackground,
|
||||
isOpen,
|
||||
isBusy,
|
||||
closeWorkflow,
|
||||
createTemporary,
|
||||
renameWorkflow,
|
||||
deleteWorkflow,
|
||||
saveAs,
|
||||
saveWorkflow,
|
||||
reorderWorkflows,
|
||||
|
||||
workflows,
|
||||
bookmarkedWorkflows,
|
||||
persistedWorkflows,
|
||||
modifiedWorkflows,
|
||||
getWorkflowByPath,
|
||||
syncWorkflows,
|
||||
|
||||
isSubgraphActive,
|
||||
activeSubgraph,
|
||||
updateActiveGraph,
|
||||
executionIdToCurrentId,
|
||||
nodeIdToNodeLocatorId,
|
||||
nodeExecutionIdToNodeLocatorId,
|
||||
nodeLocatorIdToNodeId,
|
||||
nodeLocatorIdToNodeExecutionId
|
||||
}
|
||||
}) satisfies () => WorkflowStore
|
||||
|
||||
export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => {
|
||||
const bookmarks = ref<Set<string>>(new Set())
|
||||
|
||||
const isBookmarked = (path: string) => bookmarks.value.has(path)
|
||||
|
||||
const loadBookmarks = async () => {
|
||||
const resp = await api.getUserData('workflows/.index.json')
|
||||
if (resp.status === 200) {
|
||||
const info = await resp.json()
|
||||
bookmarks.value = new Set(info?.favorites ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
const saveBookmarks = async () => {
|
||||
await api.storeUserData('workflows/.index.json', {
|
||||
favorites: Array.from(bookmarks.value)
|
||||
})
|
||||
}
|
||||
|
||||
const setBookmarked = async (path: string, value: boolean) => {
|
||||
if (bookmarks.value.has(path) === value) return
|
||||
if (value) {
|
||||
bookmarks.value.add(path)
|
||||
} else {
|
||||
bookmarks.value.delete(path)
|
||||
}
|
||||
await saveBookmarks()
|
||||
}
|
||||
|
||||
const toggleBookmarked = async (path: string) => {
|
||||
await setBookmarked(path, !bookmarks.value.has(path))
|
||||
}
|
||||
|
||||
return {
|
||||
isBookmarked,
|
||||
loadBookmarks,
|
||||
saveBookmarks,
|
||||
setBookmarked,
|
||||
toggleBookmarked
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { computed, onUnmounted, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
export function useWorkflowAutoSave() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowService = useWorkflowService()
|
||||
|
||||
// Use computed refs to cache autosave settings
|
||||
const autoSaveSetting = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.AutoSave')
|
||||
)
|
||||
const autoSaveDelay = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.AutoSaveDelay')
|
||||
)
|
||||
|
||||
let autoSaveTimeout: NodeJS.Timeout | null = null
|
||||
let isSaving = false
|
||||
let needsAutoSave = false
|
||||
|
||||
const scheduleAutoSave = () => {
|
||||
// Clear any existing timeout
|
||||
if (autoSaveTimeout) {
|
||||
clearTimeout(autoSaveTimeout)
|
||||
autoSaveTimeout = null
|
||||
}
|
||||
|
||||
// If autosave is enabled
|
||||
if (autoSaveSetting.value === 'after delay') {
|
||||
// If a save is in progress, mark that we need an autosave after saving
|
||||
if (isSaving) {
|
||||
needsAutoSave = true
|
||||
return
|
||||
}
|
||||
const delay = autoSaveDelay.value
|
||||
autoSaveTimeout = setTimeout(async () => {
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
if (activeWorkflow?.isModified && activeWorkflow.isPersisted) {
|
||||
try {
|
||||
isSaving = true
|
||||
await workflowService.saveWorkflow(activeWorkflow)
|
||||
} catch (err) {
|
||||
console.error('Auto save failed:', err)
|
||||
} finally {
|
||||
isSaving = false
|
||||
if (needsAutoSave) {
|
||||
needsAutoSave = false
|
||||
scheduleAutoSave()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for autosave setting changes
|
||||
watch(
|
||||
autoSaveSetting,
|
||||
(newSetting) => {
|
||||
// Clear any existing timeout when settings change
|
||||
if (autoSaveTimeout) {
|
||||
clearTimeout(autoSaveTimeout)
|
||||
autoSaveTimeout = null
|
||||
}
|
||||
|
||||
// If there's an active modified workflow and autosave is enabled, schedule a save
|
||||
if (
|
||||
newSetting === 'after delay' &&
|
||||
workflowStore.activeWorkflow?.isModified
|
||||
) {
|
||||
scheduleAutoSave()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Listen for graph changes and schedule autosave when they occur
|
||||
const onGraphChanged = () => {
|
||||
scheduleAutoSave()
|
||||
}
|
||||
|
||||
api.addEventListener('graphChanged', onGraphChanged)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (autoSaveTimeout) {
|
||||
clearTimeout(autoSaveTimeout)
|
||||
autoSaveTimeout = null
|
||||
}
|
||||
api.removeEventListener('graphChanged', onGraphChanged)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { tryOnScopeDispose } from '@vueuse/core'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { getStorageValue, setStorageValue } from '@/scripts/utils'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
export function useWorkflowPersistence() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const workflowPersistenceEnabled = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.Persist')
|
||||
)
|
||||
|
||||
const persistCurrentWorkflow = () => {
|
||||
if (!workflowPersistenceEnabled.value) return
|
||||
const workflow = JSON.stringify(comfyApp.graph.serialize())
|
||||
localStorage.setItem('workflow', workflow)
|
||||
if (api.clientId) {
|
||||
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
|
||||
}
|
||||
}
|
||||
|
||||
const loadWorkflowFromStorage = async (
|
||||
json: string | null,
|
||||
workflowName: string | null
|
||||
) => {
|
||||
if (!json) return false
|
||||
const workflow = JSON.parse(json)
|
||||
await comfyApp.loadGraphData(workflow, true, true, workflowName)
|
||||
return true
|
||||
}
|
||||
|
||||
const loadPreviousWorkflowFromStorage = async () => {
|
||||
const workflowName = getStorageValue('Comfy.PreviousWorkflow')
|
||||
const clientId = api.initialClientId ?? api.clientId
|
||||
|
||||
// Try loading from session storage first
|
||||
if (clientId) {
|
||||
const sessionWorkflow = sessionStorage.getItem(`workflow:${clientId}`)
|
||||
if (await loadWorkflowFromStorage(sessionWorkflow, workflowName)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to local storage
|
||||
const localWorkflow = localStorage.getItem('workflow')
|
||||
return await loadWorkflowFromStorage(localWorkflow, workflowName)
|
||||
}
|
||||
|
||||
const loadDefaultWorkflow = async () => {
|
||||
if (!settingStore.get('Comfy.TutorialCompleted')) {
|
||||
await settingStore.set('Comfy.TutorialCompleted', true)
|
||||
await useWorkflowService().loadBlankWorkflow()
|
||||
await useCommandStore().execute('Comfy.BrowseTemplates')
|
||||
} else {
|
||||
await comfyApp.loadGraphData()
|
||||
}
|
||||
}
|
||||
|
||||
const restorePreviousWorkflow = async () => {
|
||||
if (!workflowPersistenceEnabled.value) return
|
||||
try {
|
||||
const restored = await loadPreviousWorkflowFromStorage()
|
||||
if (!restored) {
|
||||
await loadDefaultWorkflow()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading previous workflow', err)
|
||||
await loadDefaultWorkflow()
|
||||
}
|
||||
}
|
||||
|
||||
// Setup watchers
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow?.key,
|
||||
(activeWorkflowKey) => {
|
||||
if (!activeWorkflowKey) return
|
||||
setStorageValue('Comfy.PreviousWorkflow', activeWorkflowKey)
|
||||
// When the activeWorkflow changes, the graph has already been loaded.
|
||||
// Saving the current state of the graph to the localStorage.
|
||||
persistCurrentWorkflow()
|
||||
}
|
||||
)
|
||||
api.addEventListener('graphChanged', persistCurrentWorkflow)
|
||||
|
||||
// Clean up event listener when component unmounts
|
||||
tryOnScopeDispose(() => {
|
||||
api.removeEventListener('graphChanged', persistCurrentWorkflow)
|
||||
})
|
||||
|
||||
// Restore workflow tabs states
|
||||
const openWorkflows = computed(() => workflowStore.openWorkflows)
|
||||
const activeWorkflow = computed(() => workflowStore.activeWorkflow)
|
||||
const restoreState = computed<{ paths: string[]; activeIndex: number }>(
|
||||
() => {
|
||||
if (!openWorkflows.value || !activeWorkflow.value) {
|
||||
return { paths: [], activeIndex: -1 }
|
||||
}
|
||||
|
||||
const paths = openWorkflows.value
|
||||
.filter((workflow) => workflow?.isPersisted && !workflow.isModified)
|
||||
.map((workflow) => workflow.path)
|
||||
const activeIndex = openWorkflows.value.findIndex(
|
||||
(workflow) => workflow.path === activeWorkflow.value?.path
|
||||
)
|
||||
|
||||
return { paths, activeIndex }
|
||||
}
|
||||
)
|
||||
|
||||
// Get storage values before setting watchers
|
||||
const storedWorkflows = JSON.parse(
|
||||
getStorageValue('Comfy.OpenWorkflowsPaths') || '[]'
|
||||
)
|
||||
const storedActiveIndex = JSON.parse(
|
||||
getStorageValue('Comfy.ActiveWorkflowIndex') || '-1'
|
||||
)
|
||||
|
||||
watch(restoreState, ({ paths, activeIndex }) => {
|
||||
if (workflowPersistenceEnabled.value) {
|
||||
setStorageValue('Comfy.OpenWorkflowsPaths', JSON.stringify(paths))
|
||||
setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(activeIndex))
|
||||
}
|
||||
})
|
||||
|
||||
const restoreWorkflowTabsState = () => {
|
||||
if (!workflowPersistenceEnabled.value) return
|
||||
const isRestorable = storedWorkflows?.length > 0 && storedActiveIndex >= 0
|
||||
if (isRestorable) {
|
||||
workflowStore.openWorkflowsInBackground({
|
||||
left: storedWorkflows.slice(0, storedActiveIndex),
|
||||
right: storedWorkflows.slice(storedActiveIndex)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
restorePreviousWorkflow,
|
||||
restoreWorkflowTabsState
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type {
|
||||
TemplateGroup,
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
export function useTemplateWorkflows() {
|
||||
const { t } = useI18n()
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
// State
|
||||
const selectedTemplate = ref<WorkflowTemplates | null>(null)
|
||||
const loadingTemplateId = ref<string | null>(null)
|
||||
|
||||
// Computed
|
||||
const isTemplatesLoaded = computed(() => workflowTemplatesStore.isLoaded)
|
||||
const allTemplateGroups = computed<TemplateGroup[]>(
|
||||
() => workflowTemplatesStore.groupedTemplates
|
||||
)
|
||||
|
||||
/**
|
||||
* Loads all template workflows from the API
|
||||
*/
|
||||
const loadTemplates = async () => {
|
||||
if (!workflowTemplatesStore.isLoaded) {
|
||||
await workflowTemplatesStore.loadWorkflowTemplates()
|
||||
}
|
||||
return workflowTemplatesStore.isLoaded
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the first template category as default
|
||||
*/
|
||||
const selectFirstTemplateCategory = () => {
|
||||
if (allTemplateGroups.value.length > 0) {
|
||||
const firstCategory = allTemplateGroups.value[0].modules[0]
|
||||
selectTemplateCategory(firstCategory)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a template category
|
||||
*/
|
||||
const selectTemplateCategory = (category: WorkflowTemplates | null) => {
|
||||
selectedTemplate.value = category
|
||||
return category !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets template thumbnail URL
|
||||
*/
|
||||
const getTemplateThumbnailUrl = (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string,
|
||||
index = '1'
|
||||
) => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
|
||||
|
||||
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets formatted template title
|
||||
*/
|
||||
const getTemplateTitle = (template: TemplateInfo, sourceModule: string) => {
|
||||
const fallback =
|
||||
template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
: fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets formatted template description
|
||||
*/
|
||||
const getTemplateDescription = (template: TemplateInfo) => {
|
||||
return (
|
||||
(template.localizedDescription || template.description)
|
||||
?.replace(/[-_]/g, ' ')
|
||||
.trim() ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a workflow template
|
||||
*/
|
||||
const loadWorkflowTemplate = async (id: string, sourceModule: string) => {
|
||||
if (!isTemplatesLoaded.value) return false
|
||||
|
||||
loadingTemplateId.value = id
|
||||
let json
|
||||
|
||||
try {
|
||||
// Handle "All" category as a special case
|
||||
if (sourceModule === 'all') {
|
||||
// Find "All" category in the ComfyUI Examples group
|
||||
const comfyExamplesGroup = allTemplateGroups.value.find(
|
||||
(g) =>
|
||||
g.label ===
|
||||
t('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
|
||||
)
|
||||
const allCategory = comfyExamplesGroup?.modules.find(
|
||||
(m) => m.moduleName === 'all'
|
||||
)
|
||||
const template = allCategory?.templates.find((t) => t.name === id)
|
||||
|
||||
if (!template || !template.sourceModule) return false
|
||||
|
||||
// Use the stored source module for loading
|
||||
const actualSourceModule = template.sourceModule
|
||||
json = await fetchTemplateJson(id, actualSourceModule)
|
||||
|
||||
// Use source module for name
|
||||
const workflowName =
|
||||
actualSourceModule === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Regular case for normal categories
|
||||
json = await fetchTemplateJson(id, sourceModule)
|
||||
|
||||
const workflowName =
|
||||
sourceModule === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error loading workflow template:', error)
|
||||
return false
|
||||
} finally {
|
||||
loadingTemplateId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches template JSON from the appropriate endpoint
|
||||
*/
|
||||
const fetchTemplateJson = async (id: string, sourceModule: string) => {
|
||||
if (sourceModule === 'default') {
|
||||
// Default templates provided by frontend are served on this separate endpoint
|
||||
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
|
||||
} else {
|
||||
return fetch(
|
||||
api.apiURL(`/workflow_templates/${sourceModule}/${id}.json`)
|
||||
).then((r) => r.json())
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedTemplate,
|
||||
loadingTemplateId,
|
||||
|
||||
// Computed
|
||||
isTemplatesLoaded,
|
||||
allTemplateGroups,
|
||||
|
||||
// Methods
|
||||
loadTemplates,
|
||||
selectFirstTemplateCategory,
|
||||
selectTemplateCategory,
|
||||
getTemplateThumbnailUrl,
|
||||
getTemplateTitle,
|
||||
getTemplateDescription,
|
||||
loadWorkflowTemplate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import Fuse from 'fuse.js'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { i18n, st } from '@/i18n'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { getCategoryIcon } from '@/utils/categoryIcons'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import type {
|
||||
TemplateGroup,
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '../types/template'
|
||||
|
||||
// Enhanced template interface for easier filtering
|
||||
interface EnhancedTemplate extends TemplateInfo {
|
||||
sourceModule: string
|
||||
category?: string
|
||||
categoryType?: string
|
||||
categoryGroup?: string // 'GENERATION TYPE' or 'CLOSED SOURCE MODELS'
|
||||
isEssential?: boolean
|
||||
searchableText?: string
|
||||
}
|
||||
|
||||
export const useWorkflowTemplatesStore = defineStore(
|
||||
'workflowTemplates',
|
||||
() => {
|
||||
const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({})
|
||||
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
|
||||
const isLoaded = ref(false)
|
||||
|
||||
// Store filter mappings for dynamic categories
|
||||
type FilterData = {
|
||||
category?: string
|
||||
categoryGroup?: string
|
||||
}
|
||||
|
||||
const categoryFilters = ref(new Map<string, FilterData>())
|
||||
|
||||
/**
|
||||
* Add localization fields to a template.
|
||||
*/
|
||||
const addLocalizedFieldsToTemplate = (
|
||||
template: TemplateInfo,
|
||||
categoryTitle: string
|
||||
) => ({
|
||||
...template,
|
||||
localizedTitle: st(
|
||||
`templateWorkflows.template.${normalizeI18nKey(categoryTitle)}.${normalizeI18nKey(template.name)}`,
|
||||
template.title ?? template.name
|
||||
),
|
||||
localizedDescription: st(
|
||||
`templateWorkflows.templateDescription.${normalizeI18nKey(categoryTitle)}.${normalizeI18nKey(template.name)}`,
|
||||
template.description
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Add localization fields to all templates in a list of templates.
|
||||
*/
|
||||
const localizeTemplateList = (
|
||||
templates: TemplateInfo[],
|
||||
categoryTitle: string
|
||||
) =>
|
||||
templates.map((template) =>
|
||||
addLocalizedFieldsToTemplate(template, categoryTitle)
|
||||
)
|
||||
|
||||
/**
|
||||
* Add localization fields to a template category and all its constituent templates.
|
||||
*/
|
||||
const localizeTemplateCategory = (templateCategory: WorkflowTemplates) => ({
|
||||
...templateCategory,
|
||||
localizedTitle: st(
|
||||
`templateWorkflows.category.${normalizeI18nKey(templateCategory.title)}`,
|
||||
templateCategory.title ?? templateCategory.moduleName
|
||||
),
|
||||
templates: localizeTemplateList(
|
||||
templateCategory.templates,
|
||||
templateCategory.title
|
||||
)
|
||||
})
|
||||
|
||||
// Create an "All" category that combines all templates
|
||||
const createAllCategory = () => {
|
||||
// First, get core templates with source module added
|
||||
const coreTemplatesWithSourceModule = coreTemplates.value.flatMap(
|
||||
(category) =>
|
||||
// For each template in each category, add the sourceModule and pass through any localized fields
|
||||
category.templates.map((template) => {
|
||||
// Get localized template with its original category title for i18n lookup
|
||||
const localizedTemplate = addLocalizedFieldsToTemplate(
|
||||
template,
|
||||
category.title
|
||||
)
|
||||
return {
|
||||
...localizedTemplate,
|
||||
sourceModule: category.moduleName
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Now handle custom templates
|
||||
const customTemplatesWithSourceModule = Object.entries(
|
||||
customTemplates.value
|
||||
).flatMap(([moduleName, templates]) =>
|
||||
templates.map((name) => ({
|
||||
name,
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: name,
|
||||
sourceModule: moduleName
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
moduleName: 'all',
|
||||
title: 'All',
|
||||
localizedTitle: st('templateWorkflows.category.All', 'All Templates'),
|
||||
templates: [
|
||||
...coreTemplatesWithSourceModule,
|
||||
...customTemplatesWithSourceModule
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Original grouped templates for backward compatibility
|
||||
*/
|
||||
const groupedTemplates = computed<TemplateGroup[]>(() => {
|
||||
// Get regular categories
|
||||
const allTemplates = [
|
||||
...coreTemplates.value.map(localizeTemplateCategory),
|
||||
...Object.entries(customTemplates.value).map(
|
||||
([moduleName, templates]) => ({
|
||||
moduleName,
|
||||
title: moduleName,
|
||||
localizedTitle: st(
|
||||
`templateWorkflows.category.${normalizeI18nKey(moduleName)}`,
|
||||
moduleName
|
||||
),
|
||||
templates: templates.map((name) => ({
|
||||
name,
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: name
|
||||
}))
|
||||
})
|
||||
)
|
||||
]
|
||||
|
||||
// Group templates by their main category
|
||||
const groupedByCategory = [
|
||||
{
|
||||
label: st(
|
||||
'templateWorkflows.category.ComfyUI Examples',
|
||||
'ComfyUI Examples'
|
||||
),
|
||||
modules: [
|
||||
createAllCategory(),
|
||||
...allTemplates.filter((t) => t.moduleName === 'default')
|
||||
]
|
||||
},
|
||||
...(Object.keys(customTemplates.value).length > 0
|
||||
? [
|
||||
{
|
||||
label: st(
|
||||
'templateWorkflows.category.Custom Nodes',
|
||||
'Custom Nodes'
|
||||
),
|
||||
modules: allTemplates.filter((t) => t.moduleName !== 'default')
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
|
||||
return groupedByCategory
|
||||
})
|
||||
|
||||
/**
|
||||
* Enhanced templates with proper categorization for filtering
|
||||
*/
|
||||
const enhancedTemplates = computed<EnhancedTemplate[]>(() => {
|
||||
const allTemplates: EnhancedTemplate[] = []
|
||||
|
||||
// Process core templates
|
||||
coreTemplates.value.forEach((category) => {
|
||||
category.templates.forEach((template) => {
|
||||
const enhancedTemplate: EnhancedTemplate = {
|
||||
...template,
|
||||
sourceModule: category.moduleName,
|
||||
category: category.title,
|
||||
categoryType: category.type,
|
||||
categoryGroup: category.category,
|
||||
isEssential: category.isEssential,
|
||||
searchableText: [
|
||||
template.title || template.name,
|
||||
template.description || '',
|
||||
category.title,
|
||||
...(template.tags || []),
|
||||
...(template.models || [])
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
allTemplates.push(enhancedTemplate)
|
||||
})
|
||||
})
|
||||
|
||||
// Process custom templates
|
||||
Object.entries(customTemplates.value).forEach(
|
||||
([moduleName, templates]) => {
|
||||
templates.forEach((name) => {
|
||||
const enhancedTemplate: EnhancedTemplate = {
|
||||
name,
|
||||
title: name,
|
||||
description: name,
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
sourceModule: moduleName,
|
||||
category: 'Extensions',
|
||||
categoryType: 'extension',
|
||||
searchableText: `${name} ${moduleName} extension`
|
||||
}
|
||||
allTemplates.push(enhancedTemplate)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return allTemplates
|
||||
})
|
||||
|
||||
/**
|
||||
* Fuse.js instance for advanced template searching and filtering
|
||||
*/
|
||||
const templateFuse = computed(() => {
|
||||
const fuseOptions = {
|
||||
keys: [
|
||||
{ name: 'searchableText', weight: 0.4 },
|
||||
{ name: 'title', weight: 0.3 },
|
||||
{ name: 'name', weight: 0.2 },
|
||||
{ name: 'tags', weight: 0.1 }
|
||||
],
|
||||
threshold: 0.3,
|
||||
includeScore: true
|
||||
}
|
||||
|
||||
return new Fuse(enhancedTemplates.value, fuseOptions)
|
||||
})
|
||||
|
||||
/**
|
||||
* Filter templates by category ID using stored filter mappings
|
||||
*/
|
||||
const filterTemplatesByCategory = (categoryId: string) => {
|
||||
if (categoryId === 'all') {
|
||||
return enhancedTemplates.value
|
||||
}
|
||||
|
||||
if (categoryId === 'basics') {
|
||||
// Filter for templates from categories marked as essential
|
||||
return enhancedTemplates.value.filter((t) => t.isEssential)
|
||||
}
|
||||
|
||||
// Handle extension-specific filters
|
||||
if (categoryId.startsWith('extension-')) {
|
||||
const moduleName = categoryId.replace('extension-', '')
|
||||
return enhancedTemplates.value.filter(
|
||||
(t) => t.sourceModule === moduleName
|
||||
)
|
||||
}
|
||||
|
||||
// Look up the filter from our stored mappings
|
||||
const filter = categoryFilters.value.get(categoryId)
|
||||
if (!filter) {
|
||||
return enhancedTemplates.value
|
||||
}
|
||||
|
||||
// Apply the filter
|
||||
return enhancedTemplates.value.filter((template) => {
|
||||
if (filter.category && template.category !== filter.category) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filter.categoryGroup &&
|
||||
template.categoryGroup !== filter.categoryGroup
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* New navigation structure dynamically built from JSON categories
|
||||
*/
|
||||
const navGroupedTemplates = computed<(NavItemData | NavGroupData)[]>(() => {
|
||||
if (!isLoaded.value) return []
|
||||
|
||||
const items: (NavItemData | NavGroupData)[] = []
|
||||
|
||||
// Clear and rebuild filter mappings
|
||||
categoryFilters.value.clear()
|
||||
|
||||
// 1. All Templates - always first
|
||||
items.push({
|
||||
id: 'all',
|
||||
label: st('templateWorkflows.category.All', 'All Templates'),
|
||||
icon: getCategoryIcon('all')
|
||||
})
|
||||
|
||||
// 2. Basics (isEssential categories) - always second if it exists
|
||||
let gettingStartedText = 'Getting Started'
|
||||
const essentialCat = coreTemplates.value.find(
|
||||
(cat) => cat.isEssential && cat.templates.length > 0
|
||||
)
|
||||
const hasEssentialCategories = Boolean(essentialCat)
|
||||
|
||||
if (essentialCat) {
|
||||
gettingStartedText = essentialCat.title
|
||||
}
|
||||
if (hasEssentialCategories) {
|
||||
items.push({
|
||||
id: 'basics',
|
||||
label: gettingStartedText,
|
||||
icon: 'icon-[lucide--graduation-cap]'
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Group categories from JSON dynamically
|
||||
const categoryGroups = new Map<
|
||||
string,
|
||||
{ title: string; items: NavItemData[] }
|
||||
>()
|
||||
|
||||
// Process all categories from JSON
|
||||
coreTemplates.value.forEach((category) => {
|
||||
// Skip essential categories as they're handled as Basics
|
||||
if (category.isEssential) return
|
||||
|
||||
const categoryGroup = category.category
|
||||
const categoryIcon = category.icon
|
||||
|
||||
if (categoryGroup) {
|
||||
if (!categoryGroups.has(categoryGroup)) {
|
||||
categoryGroups.set(categoryGroup, {
|
||||
title: categoryGroup,
|
||||
items: []
|
||||
})
|
||||
}
|
||||
|
||||
const group = categoryGroups.get(categoryGroup)!
|
||||
|
||||
// Generate unique ID for this category
|
||||
const categoryId = `${categoryGroup.toLowerCase().replace(/\s+/g, '-')}-${category.title.toLowerCase().replace(/\s+/g, '-')}`
|
||||
|
||||
// Store the filter mapping
|
||||
categoryFilters.value.set(categoryId, {
|
||||
category: category.title,
|
||||
categoryGroup: categoryGroup
|
||||
})
|
||||
|
||||
group.items.push({
|
||||
id: categoryId,
|
||||
label: st(
|
||||
`templateWorkflows.category.${normalizeI18nKey(category.title)}`,
|
||||
category.title
|
||||
),
|
||||
icon: categoryIcon || getCategoryIcon(category.type || 'default')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Add grouped categories
|
||||
categoryGroups.forEach((group, groupName) => {
|
||||
if (group.items.length > 0) {
|
||||
items.push({
|
||||
title: st(
|
||||
`templateWorkflows.category.${normalizeI18nKey(groupName)}`,
|
||||
groupName
|
||||
.split(' ')
|
||||
.map(
|
||||
(word) =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||
)
|
||||
.join(' ')
|
||||
),
|
||||
items: group.items
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 4. Extensions - always last
|
||||
const extensionCounts = enhancedTemplates.value.filter(
|
||||
(t) => t.sourceModule !== 'default'
|
||||
).length
|
||||
|
||||
if (extensionCounts > 0) {
|
||||
// Get unique extension modules
|
||||
const extensionModules = Array.from(
|
||||
new Set(
|
||||
enhancedTemplates.value
|
||||
.filter((t) => t.sourceModule !== 'default')
|
||||
.map((t) => t.sourceModule)
|
||||
)
|
||||
).sort()
|
||||
|
||||
const extensionItems: NavItemData[] = extensionModules.map(
|
||||
(moduleName) => ({
|
||||
id: `extension-${moduleName}`,
|
||||
label: st(
|
||||
`templateWorkflows.category.${normalizeI18nKey(moduleName)}`,
|
||||
moduleName
|
||||
),
|
||||
icon: getCategoryIcon('extensions')
|
||||
})
|
||||
)
|
||||
|
||||
items.push({
|
||||
title: st('templateWorkflows.category.Extensions', 'Extensions'),
|
||||
items: extensionItems,
|
||||
collapsible: true
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
async function loadWorkflowTemplates() {
|
||||
try {
|
||||
if (!isLoaded.value) {
|
||||
customTemplates.value = await api.getWorkflowTemplates()
|
||||
const locale = i18n.global.locale.value
|
||||
coreTemplates.value = await api.getCoreWorkflowTemplates(locale)
|
||||
isLoaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching workflow templates:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groupedTemplates,
|
||||
navGroupedTemplates,
|
||||
enhancedTemplates,
|
||||
templateFuse,
|
||||
filterTemplatesByCategory,
|
||||
isLoaded,
|
||||
loadWorkflowTemplates
|
||||
}
|
||||
}
|
||||
)
|
||||
39
src/platform/workflow/templates/types/template.ts
Normal file
39
src/platform/workflow/templates/types/template.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export interface TemplateInfo {
|
||||
name: string
|
||||
/**
|
||||
* Optional title which is used as the fallback if the name is not in the locales dictionary.
|
||||
*/
|
||||
title?: string
|
||||
tutorialUrl?: string
|
||||
mediaType: string
|
||||
mediaSubtype: string
|
||||
thumbnailVariant?: string
|
||||
description: string
|
||||
localizedTitle?: string
|
||||
localizedDescription?: string
|
||||
isEssential?: boolean
|
||||
sourceModule?: string
|
||||
tags?: string[]
|
||||
models?: string[]
|
||||
date?: string
|
||||
useCase?: string
|
||||
license?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface WorkflowTemplates {
|
||||
moduleName: string
|
||||
templates: TemplateInfo[]
|
||||
title: string
|
||||
localizedTitle?: string
|
||||
category?: string
|
||||
type?: string
|
||||
icon?: string
|
||||
isEssential?: boolean
|
||||
}
|
||||
|
||||
export interface TemplateGroup {
|
||||
label: string
|
||||
icon?: string
|
||||
modules: WorkflowTemplates[]
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { fixBadLinks } from '@/utils/linkFixer'
|
||||
|
||||
interface ValidationResult {
|
||||
graphData: ComfyWorkflowJSON | null
|
||||
}
|
||||
|
||||
export function useWorkflowValidation() {
|
||||
const toastStore = useToastStore()
|
||||
|
||||
function tryFixLinks(
|
||||
graphData: ComfyWorkflowJSON,
|
||||
options: { silent?: boolean } = {}
|
||||
) {
|
||||
const { silent = false } = options
|
||||
|
||||
// Collect all logs in an array
|
||||
const logs: string[] = []
|
||||
// Then validate and fix links if schema validation passed
|
||||
const linkValidation = fixBadLinks(
|
||||
graphData as unknown as ISerialisedGraph,
|
||||
{
|
||||
fix: true,
|
||||
silent,
|
||||
logger: {
|
||||
log: (message: string) => {
|
||||
logs.push(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!silent && logs.length > 0) {
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: 'Workflow Validation',
|
||||
detail: logs.join('\n')
|
||||
})
|
||||
}
|
||||
|
||||
// If links were fixed, notify the user
|
||||
if (linkValidation.fixed) {
|
||||
if (!silent) {
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: 'Workflow Links Fixed',
|
||||
detail: `Fixed ${linkValidation.patched} node connections and removed ${linkValidation.deleted} invalid links.`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return linkValidation.graph as unknown as ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a workflow, including link validation and schema validation
|
||||
*/
|
||||
async function validateWorkflow(
|
||||
graphData: ComfyWorkflowJSON,
|
||||
options: {
|
||||
silent?: boolean
|
||||
} = {}
|
||||
): Promise<ValidationResult> {
|
||||
const { silent = false } = options
|
||||
|
||||
let validatedData: ComfyWorkflowJSON | null = null
|
||||
|
||||
// First do schema validation
|
||||
const validatedGraphData = await validateComfyWorkflow(
|
||||
graphData,
|
||||
/* onError=*/ (err) => {
|
||||
if (!silent) {
|
||||
toastStore.addAlert(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (validatedGraphData) {
|
||||
try {
|
||||
validatedData = tryFixLinks(validatedGraphData, { silent })
|
||||
} catch (err) {
|
||||
// Link fixer itself is throwing an error
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
graphData: validatedData
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
validateWorkflow
|
||||
}
|
||||
}
|
||||
526
src/platform/workflow/validation/schemas/workflowSchema.ts
Normal file
526
src/platform/workflow/validation/schemas/workflowSchema.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
import { type SafeParseReturnType, z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
// GroupNode is hacking node id to be a string, so we need to allow that.
|
||||
// innerNode.id = `${this.node.id}:${i}`
|
||||
// Remove it after GroupNode is redesigned.
|
||||
export const zNodeId = z.union([z.number().int(), z.string()])
|
||||
const zNodeInputName = z.string()
|
||||
export type NodeId = z.infer<typeof zNodeId>
|
||||
const zSlotIndex = z.union([
|
||||
z.number().int(),
|
||||
z
|
||||
.string()
|
||||
.transform((val) => parseInt(val))
|
||||
.refine((val) => !isNaN(val), {
|
||||
message: 'Invalid number'
|
||||
})
|
||||
])
|
||||
|
||||
// TODO: Investigate usage of array and number as data type usage in custom nodes.
|
||||
// Known usage:
|
||||
// - https://github.com/rgthree/rgthree-comfy Context Big node is using array as type.
|
||||
const zDataType = z.union([z.string(), z.array(z.string()), z.number()])
|
||||
|
||||
const zVector2 = z.union([
|
||||
z
|
||||
.object({ 0: z.number(), 1: z.number() })
|
||||
.passthrough()
|
||||
.transform((v) => [v[0], v[1]] as [number, number]),
|
||||
z.tuple([z.number(), z.number()])
|
||||
])
|
||||
|
||||
// Definition of an AI model file used in the workflow.
|
||||
const zModelFile = z.object({
|
||||
name: z.string(),
|
||||
url: z.string().url(),
|
||||
hash: z.string().optional(),
|
||||
hash_type: z.string().optional(),
|
||||
directory: z.string()
|
||||
})
|
||||
|
||||
const zGraphState = z
|
||||
.object({
|
||||
lastGroupId: z.number(),
|
||||
lastNodeId: z.number(),
|
||||
lastLinkId: z.number(),
|
||||
lastRerouteId: z.number()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zComfyLink = z.tuple([
|
||||
z.number(), // Link id
|
||||
zNodeId, // Node id of source node
|
||||
zSlotIndex, // Output slot# of source node
|
||||
zNodeId, // Node id of destination node
|
||||
zSlotIndex, // Input slot# of destination node
|
||||
zDataType // Data type
|
||||
])
|
||||
|
||||
/** Extension to 0.4 schema (links as arrays): parent reroute ID */
|
||||
const zComfyLinkExtension = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
parentId: z.number()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zComfyLinkObject = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
origin_id: zNodeId,
|
||||
origin_slot: zSlotIndex,
|
||||
target_id: zNodeId,
|
||||
target_slot: zSlotIndex,
|
||||
type: zDataType,
|
||||
parentId: z.number().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zReroute = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
parentId: z.number().optional(),
|
||||
pos: zVector2,
|
||||
linkIds: z.array(z.number()).nullish(),
|
||||
floating: z
|
||||
.object({
|
||||
slotType: z.enum(['input', 'output'])
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zNodeOutput = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
type: zDataType,
|
||||
links: z.array(z.number()).nullable().optional(),
|
||||
slot_index: zSlotIndex.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zNodeInput = z
|
||||
.object({
|
||||
name: zNodeInputName,
|
||||
type: zDataType,
|
||||
link: z.number().nullable().optional(),
|
||||
slot_index: zSlotIndex.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zFlags = z
|
||||
.object({
|
||||
collapsed: z.boolean().optional(),
|
||||
pinned: z.boolean().optional(),
|
||||
allow_interaction: z.boolean().optional(),
|
||||
horizontal: z.boolean().optional(),
|
||||
skip_repeated_outputs: z.boolean().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const repoLikeIdPattern = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/
|
||||
const githubUsernamePattern = /^(?!-)(?!.*--)[a-zA-Z0-9-]+(?<!-)$/
|
||||
const gitHashPattern = /^[0-9a-f]{4,40}$/i
|
||||
const semverPattern =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([\da-z-]+(?:\.[\da-z-]+)*))?(?:\+([\da-z-]+(?:\.[\da-z-]+)*))?$/
|
||||
|
||||
// Shared schema for Comfy Node Registry IDs and GitHub repo names
|
||||
const zRepoLikeId = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(repoLikeIdPattern, {
|
||||
message: "ID can only contain ASCII letters, digits, '_', '-', and '.'"
|
||||
})
|
||||
.refine((id) => !/^[_\-.]|[_\-.]$/.test(id), {
|
||||
message: "ID must not start or end with '_', '-', or '.'"
|
||||
})
|
||||
|
||||
const zCnrId = zRepoLikeId
|
||||
const zGithubRepoName = zRepoLikeId
|
||||
|
||||
// GitHub username/organization schema
|
||||
const zGithubUsername = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(39)
|
||||
.regex(githubUsernamePattern, 'Invalid GitHub username/org')
|
||||
|
||||
// Auxiliary ID identifies node packs not installed via the Comfy Node Registry
|
||||
const zAuxId = z
|
||||
.string()
|
||||
.regex(/^[^/]+\/[^/]+$/, "Invalid format. Must be 'github-user/repo-name'")
|
||||
.transform((id) => id.split('/'))
|
||||
.refine(
|
||||
([username, repo]) =>
|
||||
zGithubUsername.safeParse(username).success &&
|
||||
zGithubRepoName.safeParse(repo).success,
|
||||
"Invalid aux_id: Must be valid 'github-username/github-repo-name'"
|
||||
)
|
||||
.transform(([username, repo]) => `${username}/${repo}`)
|
||||
|
||||
const zGitHash = z.string().superRefine((val: string, ctx) => {
|
||||
if (!gitHashPattern.test(val)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Node pack version has invalid Git commit hash: "${val}"`
|
||||
})
|
||||
}
|
||||
})
|
||||
const zSemVer = z.string().superRefine((val: string, ctx) => {
|
||||
if (!semverPattern.test(val)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Node pack version has invalid semantic version: "${val}"`
|
||||
})
|
||||
}
|
||||
})
|
||||
const zVersion = z.union([
|
||||
z
|
||||
.string()
|
||||
.transform((ver) => ver.replace(/^v/, '')) // Strip leading 'v'
|
||||
.pipe(z.union([zSemVer, zGitHash])),
|
||||
z.literal('unknown')
|
||||
])
|
||||
|
||||
const zProperties = z
|
||||
.object({
|
||||
['Node name for S&R']: z.string().optional(),
|
||||
cnr_id: zCnrId.optional(),
|
||||
aux_id: zAuxId.optional(),
|
||||
ver: zVersion.optional(),
|
||||
models: z.array(zModelFile).optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zWidgetValues = z.union([z.array(z.any()), z.record(z.any())])
|
||||
|
||||
const zComfyNode = z
|
||||
.object({
|
||||
id: zNodeId,
|
||||
type: z.string(),
|
||||
pos: zVector2,
|
||||
size: zVector2,
|
||||
flags: zFlags,
|
||||
order: z.number(),
|
||||
mode: z.number(),
|
||||
inputs: z.array(zNodeInput).optional(),
|
||||
outputs: z.array(zNodeOutput).optional(),
|
||||
properties: zProperties,
|
||||
widgets_values: zWidgetValues.optional(),
|
||||
color: z.string().optional(),
|
||||
bgcolor: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zSubgraphIO = zNodeInput.extend({
|
||||
/** Slot ID (internal; never changes once instantiated). */
|
||||
id: z.string().uuid(),
|
||||
/** The data type this slot uses. Unlike nodes, this does not support legacy numeric types. */
|
||||
type: z.string(),
|
||||
/** Links connected to this slot, or `undefined` if not connected. An ouptut slot should only ever have one link. */
|
||||
linkIds: z.array(z.number()).optional()
|
||||
})
|
||||
|
||||
const zSubgraphInstance = z
|
||||
.object({
|
||||
id: zNodeId,
|
||||
type: z.string().uuid(),
|
||||
pos: zVector2,
|
||||
size: zVector2,
|
||||
flags: zFlags,
|
||||
order: z.number(),
|
||||
mode: z.number(),
|
||||
inputs: z.array(zSubgraphIO).optional(),
|
||||
outputs: z.array(zSubgraphIO).optional(),
|
||||
widgets_values: zWidgetValues.optional(),
|
||||
color: z.string().optional(),
|
||||
bgcolor: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zGroup = z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
title: z.string(),
|
||||
bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]),
|
||||
color: z.string().optional(),
|
||||
font_size: z.number().optional(),
|
||||
locked: z.boolean().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zDS = z
|
||||
.object({
|
||||
scale: z.number(),
|
||||
offset: zVector2
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zConfig = z
|
||||
.object({
|
||||
links_ontop: z.boolean().optional(),
|
||||
align_to_grid: z.boolean().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zExtra = z
|
||||
.object({
|
||||
ds: zDS.optional(),
|
||||
frontendVersion: z.string().optional(),
|
||||
linkExtensions: z.array(zComfyLinkExtension).optional(),
|
||||
reroutes: z.array(zReroute).optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zGraphDefinitions = z.object({
|
||||
subgraphs: z.lazy(() => z.array(zSubgraphDefinition))
|
||||
})
|
||||
|
||||
const zBaseExportableGraph = z.object({
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
config: zConfig.optional().nullable(),
|
||||
/** Details of the appearance and location of subgraphs shown in this graph. Similar to */
|
||||
subgraphs: z.array(zSubgraphInstance).optional()
|
||||
})
|
||||
|
||||
/** Schema version 0.4 */
|
||||
export const zComfyWorkflow = zBaseExportableGraph
|
||||
.extend({
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
last_node_id: zNodeId,
|
||||
last_link_id: z.number(),
|
||||
nodes: z.array(zComfyNode),
|
||||
links: z.array(zComfyLink),
|
||||
floatingLinks: z.array(zComfyLinkObject).optional(),
|
||||
groups: z.array(zGroup).optional(),
|
||||
config: zConfig.optional().nullable(),
|
||||
extra: zExtra.optional().nullable(),
|
||||
version: z.number(),
|
||||
models: z.array(zModelFile).optional(),
|
||||
definitions: zGraphDefinitions.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
/** Required for recursive definition of subgraphs. */
|
||||
interface ComfyWorkflow1BaseType {
|
||||
id?: string
|
||||
revision?: number
|
||||
version: 1
|
||||
models?: z.infer<typeof zModelFile>[]
|
||||
state: z.infer<typeof zGraphState>
|
||||
}
|
||||
|
||||
/** Required for recursive definition of subgraphs w/ZodEffects. */
|
||||
interface ComfyWorkflow1BaseInput extends ComfyWorkflow1BaseType {
|
||||
groups?: z.input<typeof zGroup>[]
|
||||
nodes: z.input<typeof zComfyNode>[]
|
||||
links?: z.input<typeof zComfyLinkObject>[]
|
||||
floatingLinks?: z.input<typeof zComfyLinkObject>[]
|
||||
reroutes?: z.input<typeof zReroute>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseInput>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Required for recursive definition of subgraphs w/ZodEffects. */
|
||||
interface ComfyWorkflow1BaseOutput extends ComfyWorkflow1BaseType {
|
||||
groups?: z.output<typeof zGroup>[]
|
||||
nodes: z.output<typeof zComfyNode>[]
|
||||
links?: z.output<typeof zComfyLinkObject>[]
|
||||
floatingLinks?: z.output<typeof zComfyLinkObject>[]
|
||||
reroutes?: z.output<typeof zReroute>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Schema version 1 */
|
||||
export const zComfyWorkflow1 = zBaseExportableGraph
|
||||
.extend({
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
version: z.literal(1),
|
||||
config: zConfig.optional().nullable(),
|
||||
state: zGraphState,
|
||||
groups: z.array(zGroup).optional(),
|
||||
nodes: z.array(zComfyNode),
|
||||
links: z.array(zComfyLinkObject).optional(),
|
||||
floatingLinks: z.array(zComfyLinkObject).optional(),
|
||||
reroutes: z.array(zReroute).optional(),
|
||||
extra: zExtra.optional().nullable(),
|
||||
models: z.array(zModelFile).optional(),
|
||||
definitions: z
|
||||
.object({
|
||||
subgraphs: z.lazy(
|
||||
(): z.ZodArray<
|
||||
z.ZodType<
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>,
|
||||
z.ZodTypeDef,
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
|
||||
>,
|
||||
'many'
|
||||
> => z.array(zSubgraphDefinition)
|
||||
)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zExportedSubgraphIONode = z.object({
|
||||
id: zNodeId,
|
||||
bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]),
|
||||
pinned: z.boolean().optional()
|
||||
})
|
||||
|
||||
const zExposedWidget = z.object({
|
||||
id: z.string(),
|
||||
name: z.string()
|
||||
})
|
||||
|
||||
interface SubgraphDefinitionBase<
|
||||
T extends ComfyWorkflow1BaseInput | ComfyWorkflow1BaseOutput
|
||||
> {
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: string
|
||||
revision: number
|
||||
name: string
|
||||
|
||||
inputNode: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExportedSubgraphIONode>
|
||||
: z.output<typeof zExportedSubgraphIONode>
|
||||
outputNode: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExportedSubgraphIONode>
|
||||
: z.output<typeof zExportedSubgraphIONode>
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
inputs?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zSubgraphIO>[]
|
||||
: z.output<typeof zSubgraphIO>[]
|
||||
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
|
||||
outputs?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zSubgraphIO>[]
|
||||
: z.output<typeof zSubgraphIO>[]
|
||||
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
|
||||
widgets?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExposedWidget>[]
|
||||
: z.output<typeof zExposedWidget>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<T>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** A subgraph definition `worfklow.definitions.subgraphs` */
|
||||
const zSubgraphDefinition = zComfyWorkflow1
|
||||
.extend({
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: z.string().uuid(),
|
||||
revision: z.number(),
|
||||
name: z.string(),
|
||||
inputNode: zExportedSubgraphIONode,
|
||||
outputNode: zExportedSubgraphIONode,
|
||||
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
inputs: z.array(zSubgraphIO).optional(),
|
||||
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
|
||||
outputs: z.array(zSubgraphIO).optional(),
|
||||
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
|
||||
widgets: z.array(zExposedWidget).optional(),
|
||||
definitions: z
|
||||
.object({
|
||||
subgraphs: z.lazy(
|
||||
(): z.ZodArray<
|
||||
z.ZodType<
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>,
|
||||
z.ZodTypeDef,
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
|
||||
>,
|
||||
'many'
|
||||
> => zSubgraphDefinition.array()
|
||||
)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export type ModelFile = z.infer<typeof zModelFile>
|
||||
export type ComfyLink = z.infer<typeof zComfyLink>
|
||||
export type ComfyLinkObject = z.infer<typeof zComfyLinkObject>
|
||||
export type ComfyNode = z.infer<typeof zComfyNode>
|
||||
export type Reroute = z.infer<typeof zReroute>
|
||||
export type WorkflowJSON04 = z.infer<typeof zComfyWorkflow>
|
||||
export type ComfyWorkflowJSON = z.infer<
|
||||
typeof zComfyWorkflow | typeof zComfyWorkflow1
|
||||
>
|
||||
type SubgraphDefinition = z.infer<typeof zSubgraphDefinition>
|
||||
|
||||
/**
|
||||
* Type guard to check if an object is a SubgraphDefinition.
|
||||
* This helps TypeScript understand the type when z.lazy() breaks inference.
|
||||
*/
|
||||
export function isSubgraphDefinition(obj: any): obj is SubgraphDefinition {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj === 'object' &&
|
||||
'id' in obj &&
|
||||
'name' in obj &&
|
||||
'nodes' in obj &&
|
||||
Array.isArray(obj.nodes) &&
|
||||
'inputNode' in obj &&
|
||||
'outputNode' in obj
|
||||
)
|
||||
}
|
||||
|
||||
const zWorkflowVersion = z.object({
|
||||
version: z.number()
|
||||
})
|
||||
|
||||
export async function validateComfyWorkflow(
|
||||
data: unknown,
|
||||
onError: (error: string) => void = console.warn
|
||||
): Promise<ComfyWorkflowJSON | null> {
|
||||
const versionResult = zWorkflowVersion.safeParse(data)
|
||||
|
||||
let result: SafeParseReturnType<unknown, ComfyWorkflowJSON>
|
||||
if (!versionResult.success) {
|
||||
// Invalid workflow
|
||||
const error = fromZodError(versionResult.error)
|
||||
onError(`Workflow does not contain a valid version. Zod error:\n${error}`)
|
||||
return null
|
||||
} else if (versionResult.data.version === 1) {
|
||||
// Schema version 1
|
||||
result = await zComfyWorkflow1.safeParseAsync(data)
|
||||
} else {
|
||||
// Unknown or old version: 0.4
|
||||
result = await zComfyWorkflow.safeParseAsync(data)
|
||||
}
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
onError(`Invalid workflow against zod schema:\n${error}`)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* API format workflow for direct API usage.
|
||||
*/
|
||||
const zNodeInputValue = z.union([
|
||||
// For widget values (can be any type)
|
||||
z.any(),
|
||||
// For node links [nodeId, slotIndex]
|
||||
z.tuple([zNodeId, zSlotIndex])
|
||||
])
|
||||
|
||||
const zNodeData = z.object({
|
||||
inputs: z.record(zNodeInputName, zNodeInputValue),
|
||||
class_type: z.string(),
|
||||
_meta: z.object({
|
||||
title: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
const zComfyApiWorkflow = z.record(zNodeId, zNodeData)
|
||||
export type ComfyApiWorkflow = z.infer<typeof zComfyApiWorkflow>
|
||||
Reference in New Issue
Block a user