mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-18 21:48:37 +00:00
Compare commits
135 Commits
core/1.41
...
cloud/1.41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cae988effe | ||
|
|
ad2612009e | ||
|
|
54e488ad7f | ||
|
|
662e5368c1 | ||
|
|
5851925937 | ||
|
|
8983fdd49d | ||
|
|
6869b5c6db | ||
|
|
e9e3d6d10f | ||
|
|
9632e33e39 | ||
|
|
db3801f21b | ||
|
|
af9bb6c222 | ||
|
|
46be292a3d | ||
|
|
fdd68d4e04 | ||
|
|
3233ea001c | ||
|
|
617e2cd259 | ||
|
|
2e1977dc7d | ||
|
|
0fa937205c | ||
|
|
925a5d2d63 | ||
|
|
6d36f83a83 | ||
|
|
82d92a6ea0 | ||
|
|
323d457958 | ||
|
|
40d3b16133 | ||
|
|
df3fb6ca2a | ||
|
|
b0da48e168 | ||
|
|
9f470b22c0 | ||
|
|
80cc488e55 | ||
|
|
ac8f75c8a7 | ||
|
|
19a31f2204 | ||
|
|
72ab4d5ec9 | ||
|
|
7883f03196 | ||
|
|
5a9c7980d6 | ||
|
|
2b25755e4f | ||
|
|
3b0cbd4035 | ||
|
|
c954ee1a77 | ||
|
|
fc15e6a329 | ||
|
|
5f99f7bdba | ||
|
|
6bb46d688f | ||
|
|
2fb463a200 | ||
|
|
8c54bfd9a9 | ||
|
|
72547cdc9d | ||
|
|
a73d31e538 | ||
|
|
855c4d74b6 | ||
|
|
ecdca891c0 | ||
|
|
f151941eec | ||
|
|
591c43f2f5 | ||
|
|
93b7b7305a | ||
|
|
0024cc6ff7 | ||
|
|
21f23b95b8 | ||
|
|
76e5620109 | ||
|
|
2cba8ff108 | ||
|
|
2cb1f580b1 | ||
|
|
63a2a36b3f | ||
|
|
6f2eb0ebea | ||
|
|
65e9e79313 | ||
|
|
b4022e1de6 | ||
|
|
ff2e7c47ad | ||
|
|
ec5527123c | ||
|
|
a8fc9d67a7 | ||
|
|
f8191837ec | ||
|
|
a0f0b27eee | ||
|
|
f9efc35abd | ||
|
|
464ec47c5e | ||
|
|
64fcbeee29 | ||
|
|
89aae59a4f | ||
|
|
83d1aebc21 | ||
|
|
dc64fc2fe1 | ||
|
|
7918a79d61 | ||
|
|
4ee2bbd65f | ||
|
|
196a171893 | ||
|
|
a3c9d3e84a | ||
|
|
a6c99423c8 | ||
|
|
a1634cedea | ||
|
|
7fe1c1e8c5 | ||
|
|
876592fe9b | ||
|
|
da1d915898 | ||
|
|
2d93372e67 | ||
|
|
67432c5db5 | ||
|
|
9c93f11abe | ||
|
|
765ae28c53 | ||
|
|
ab5e360391 | ||
|
|
3d4d324019 | ||
|
|
1829fe32da | ||
|
|
0de694ce8d | ||
|
|
03d192e605 | ||
|
|
8ca6a1799b | ||
|
|
02d6ecf897 | ||
|
|
d9db335f58 | ||
|
|
d1827eecf3 | ||
|
|
73435ee2d9 | ||
|
|
fa8716572d | ||
|
|
951a26775c | ||
|
|
80155ee06c | ||
|
|
9764c80116 | ||
|
|
d13061b943 | ||
|
|
c0ea8f1e31 | ||
|
|
2c8ad1380f | ||
|
|
af8d502d81 | ||
|
|
c48953839f | ||
|
|
86e67d163d | ||
|
|
7ad2f195e4 | ||
|
|
eedf03a709 | ||
|
|
4b91e35d1d | ||
|
|
32f757b54a | ||
|
|
37b9593b9a | ||
|
|
e3baf6df0b | ||
|
|
34953a7f98 | ||
|
|
b1bfe5fb46 | ||
|
|
a5e5e4813a | ||
|
|
1d05f08edd | ||
|
|
936291eb85 | ||
|
|
fd352a4a8f | ||
|
|
99db641662 | ||
|
|
4e933d504c | ||
|
|
e3dc4a96e8 | ||
|
|
ce40e5ef85 | ||
|
|
7302f550bd | ||
|
|
37be2eca91 | ||
|
|
ca66943826 | ||
|
|
64e1983231 | ||
|
|
2c8974d250 | ||
|
|
a77b452186 | ||
|
|
9b3d80955b | ||
|
|
c1cfe6ac73 | ||
|
|
653c278f91 | ||
|
|
165856e1a0 | ||
|
|
f27da404f0 | ||
|
|
0f4ad8098d | ||
|
|
f4828c4a25 | ||
|
|
af17ab440d | ||
|
|
444816053f | ||
|
|
0483c6819e | ||
|
|
df9b359cc3 | ||
|
|
1c9edeb604 | ||
|
|
859070f3fe | ||
|
|
65ac0e586d |
@@ -38,6 +38,9 @@ TEST_COMFYUI_DIR=/home/ComfyUI
|
||||
ALGOLIA_APP_ID=4E0RO38HS8
|
||||
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Sentry ENV vars replace with real ones for debugging
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
|
||||
1
global.d.ts
vendored
1
global.d.ts
vendored
@@ -36,6 +36,7 @@ interface Window {
|
||||
mixpanel_token?: string
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_config?: Record<string, unknown>
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
max_upload_size?: number
|
||||
|
||||
54
index.html
54
index.html
@@ -47,6 +47,60 @@
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
/* Pre-Vue splash loader — inlined to avoid SPA fallback serving
|
||||
index.html instead of CSS on cloud/ephemeral environments */
|
||||
#splash-loader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
contain: strict;
|
||||
}
|
||||
#splash-loader svg {
|
||||
width: min(200px, 50vw);
|
||||
height: auto;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
#splash-loader .wave-group {
|
||||
animation: splash-rise 4s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
#splash-loader .wave-path {
|
||||
animation: splash-wave 1.2s linear infinite;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
@keyframes splash-rise {
|
||||
from {
|
||||
transform: translateY(280px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-80px);
|
||||
}
|
||||
}
|
||||
@keyframes splash-wave {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-880px);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#splash-loader .wave-group,
|
||||
#splash-loader .wave-path {
|
||||
animation: none;
|
||||
}
|
||||
#splash-loader .wave-group {
|
||||
transform: translateY(-80px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
</head>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.21",
|
||||
"version": "1.41.13",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
32
src/App.vue
32
src/App.vue
@@ -13,19 +13,18 @@
|
||||
<script setup lang="ts">
|
||||
import { captureException } from '@sentry/vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
app.extensionManager = useWorkspaceStore()
|
||||
|
||||
@@ -66,26 +65,5 @@ onMounted(() => {
|
||||
// Initialize conflict detection in background
|
||||
// This runs async and doesn't block UI setup
|
||||
void conflictDetection.initializeConflictDetection()
|
||||
|
||||
// Show cloud notification for macOS desktop users (one-time)
|
||||
if (isDesktop && electronAPI()?.getPlatform() === 'darwin') {
|
||||
const settingStore = useSettingStore()
|
||||
if (!settingStore.get('Comfy.Desktop.CloudNotificationShown')) {
|
||||
const dialogService = useDialogService()
|
||||
cloudNotificationTimer = setTimeout(async () => {
|
||||
try {
|
||||
await dialogService.showCloudNotification()
|
||||
} catch (e) {
|
||||
console.warn('[CloudNotification] Failed to show', e)
|
||||
}
|
||||
await settingStore.set('Comfy.Desktop.CloudNotificationShown', true)
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let cloudNotificationTimer: ReturnType<typeof setTimeout> | undefined
|
||||
onUnmounted(() => {
|
||||
if (cloudNotificationTimer) clearTimeout(cloudNotificationTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
51
src/assets/splash.css
Normal file
51
src/assets/splash.css
Normal file
@@ -0,0 +1,51 @@
|
||||
/* Pre-Vue splash loader — colors set by inline script */
|
||||
#splash-loader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
contain: strict;
|
||||
}
|
||||
#splash-loader svg {
|
||||
width: min(200px, 50vw);
|
||||
height: auto;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
#splash-loader .wave-group {
|
||||
animation: splash-rise 4s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
#splash-loader .wave-path {
|
||||
animation: splash-wave 1.2s linear infinite;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
@keyframes splash-rise {
|
||||
from {
|
||||
transform: translateY(280px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-80px);
|
||||
}
|
||||
}
|
||||
@keyframes splash-wave {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-880px);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#splash-loader .wave-group,
|
||||
#splash-loader .wave-path {
|
||||
animation: none;
|
||||
}
|
||||
#splash-loader .wave-group {
|
||||
transform: translateY(-80px);
|
||||
}
|
||||
}
|
||||
@@ -186,13 +186,22 @@ describe('TopMenuSection', () => {
|
||||
})
|
||||
|
||||
describe('authentication state', () => {
|
||||
function createLegacyTabBarWrapper() {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.UI.TabBarLayout' ? 'Legacy' : undefined
|
||||
)
|
||||
return createWrapper({ pinia })
|
||||
}
|
||||
|
||||
describe('when user is logged in', () => {
|
||||
beforeEach(() => {
|
||||
mockData.isLoggedIn = true
|
||||
})
|
||||
|
||||
it('should display CurrentUserButton and not display LoginButton', () => {
|
||||
const wrapper = createWrapper()
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||
})
|
||||
@@ -206,7 +215,7 @@ describe('TopMenuSection', () => {
|
||||
describe('on desktop platform', () => {
|
||||
it('should display LoginButton and not display CurrentUserButton', () => {
|
||||
mockData.isDesktop = true
|
||||
const wrapper = createWrapper()
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
})
|
||||
@@ -214,7 +223,7 @@ describe('TopMenuSection', () => {
|
||||
|
||||
describe('on web platform', () => {
|
||||
it('should not display CurrentUserButton and not display LoginButton', () => {
|
||||
const wrapper = createWrapper()
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--share-2] size-4" />
|
||||
<i class="icon-[comfy--send] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('actionbar.share') }}
|
||||
</span>
|
||||
@@ -214,7 +214,7 @@ const actionbarContainerClass = computed(() => {
|
||||
return cn(base, 'px-2', borderClass)
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||
useQueueFeatureFlags()
|
||||
@@ -289,7 +289,8 @@ function scheduleLegacyContentCheck() {
|
||||
|
||||
useMutationObserver(legacyCommandsContainerRef, scheduleLegacyContentCheck, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
subtree: true,
|
||||
characterData: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -42,6 +43,9 @@ export function useAppSetDefaultView() {
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
closeDialog()
|
||||
showAppliedDialog(openAsApp)
|
||||
}
|
||||
@@ -54,6 +58,7 @@ export function useAppSetDefaultView() {
|
||||
appliedAsApp,
|
||||
onViewApp: () => {
|
||||
closeAppliedDialog()
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
},
|
||||
onExitToWorkflow: () => {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<div class="system-stats">
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-4 text-2xl font-semibold">
|
||||
{{ $t('g.systemInfo') }}
|
||||
</h2>
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{{ $t('g.systemInfo') }}
|
||||
</h2>
|
||||
<Button variant="secondary" @click="copySystemInfo">
|
||||
<i class="pi pi-copy" />
|
||||
{{ $t('g.copySystemInfo') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<template v-for="col in systemColumns" :key="col.field">
|
||||
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
|
||||
@@ -46,6 +52,8 @@ import TabView from 'primevue/tabview'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DeviceInfo from '@/components/common/DeviceInfo.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
@@ -57,6 +65,8 @@ const props = defineProps<{
|
||||
stats: SystemStats
|
||||
}>()
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
const systemInfo = computed(() => ({
|
||||
...props.stats.system,
|
||||
argv: props.stats.system.argv.join(' ')
|
||||
@@ -124,4 +134,33 @@ function getDisplayValue(column: ColumnDef) {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function formatSystemInfoText(): string {
|
||||
const lines: string[] = ['## System Info']
|
||||
|
||||
for (const col of systemColumns.value) {
|
||||
const display = getDisplayValue(col)
|
||||
if (display !== undefined && display !== '') {
|
||||
lines.push(`${col.header}: ${display}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDevices.value) {
|
||||
lines.push('')
|
||||
lines.push('## Devices')
|
||||
for (const device of props.stats.devices) {
|
||||
lines.push(`- ${device.name} (${device.type})`)
|
||||
lines.push(` VRAM Total: ${formatSize(device.vram_total)}`)
|
||||
lines.push(` VRAM Free: ${formatSize(device.vram_free)}`)
|
||||
lines.push(` Torch VRAM Total: ${formatSize(device.torch_vram_total)}`)
|
||||
lines.push(` Torch VRAM Free: ${formatSize(device.torch_vram_free)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function copySystemInfo() {
|
||||
copyToClipboard(formatSystemInfoText())
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Avatar
|
||||
class="bg-interface-panel-selected-surface"
|
||||
class="aspect-square bg-interface-panel-selected-surface"
|
||||
:image="photoUrl ?? undefined"
|
||||
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
|
||||
:pt:icon:class="{ 'size-4': !hasAvatar }"
|
||||
|
||||
@@ -30,33 +30,31 @@
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Skeleton v-if="showSkeleton(model)" class="ml-1.5 h-4 w-12" />
|
||||
<template v-else-if="model.isDownloadable">
|
||||
<span
|
||||
v-if="fileSizes.get(model.url)"
|
||||
class="pl-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatSize(fileSizes.get(model.url)) }}
|
||||
</span>
|
||||
<a
|
||||
v-if="gatedModelUrls.has(model.url)"
|
||||
:href="gatedModelUrls.get(model.url)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ $t('missingModelsDialog.acceptTerms') }}
|
||||
</a>
|
||||
<Button
|
||||
v-else
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:title="model.url"
|
||||
:aria-label="$t('g.download')"
|
||||
@click="downloadModel(model, paths)"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<span
|
||||
v-else-if="model.isDownloadable && fileSizes.get(model.url)"
|
||||
class="pl-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatSize(fileSizes.get(model.url)) }}
|
||||
</span>
|
||||
<a
|
||||
v-else-if="gatedModelUrls.has(model.url)"
|
||||
:href="gatedModelUrls.get(model.url)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ $t('missingModelsDialog.acceptTerms') }}
|
||||
</a>
|
||||
<Button
|
||||
v-else-if="model.isDownloadable"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:title="model.url"
|
||||
:aria-label="$t('g.download')"
|
||||
@click="downloadModel(model, paths)"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="textonly"
|
||||
|
||||
@@ -183,13 +183,13 @@ const toggleState = () => {
|
||||
}
|
||||
|
||||
const signInWithGoogle = async () => {
|
||||
if (await authActions.signInWithGoogle()) {
|
||||
if (await authActions.signInWithGoogle({ isNewUser: !isSignIn.value })) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithGithub = async () => {
|
||||
if (await authActions.signInWithGithub()) {
|
||||
if (await authActions.signInWithGithub({ isNewUser: !isSignIn.value })) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -471,8 +472,9 @@ useEventListener(
|
||||
const comfyAppReady = ref(false)
|
||||
const workflowPersistence = useWorkflowPersistence()
|
||||
const { flags } = useFeatureFlags()
|
||||
// Set up invite loader during setup phase so useRoute/useRouter work correctly
|
||||
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
|
||||
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
|
||||
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
@@ -584,6 +586,18 @@ onMounted(async () => {
|
||||
await inviteUrlLoader.loadInviteFromUrl()
|
||||
}
|
||||
|
||||
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1)
|
||||
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
try {
|
||||
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[GraphCanvas] Failed to load create workspace from URL:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
||||
const { useReleaseStore } =
|
||||
await import('@/platform/updates/common/releaseStore')
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'left',
|
||||
'sidebar-right':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'right',
|
||||
'topbar-right': triggerLocation === 'topbar',
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
>
|
||||
@@ -63,7 +60,6 @@ const { isSmall = false } = defineProps<{
|
||||
|
||||
const {
|
||||
isHelpCenterVisible,
|
||||
triggerLocation,
|
||||
sidebarLocation,
|
||||
closeHelpCenter,
|
||||
handleWhatsNewDismissed
|
||||
@@ -101,25 +97,6 @@ const {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.topbar-right {
|
||||
top: 2rem;
|
||||
right: 1rem;
|
||||
bottom: auto;
|
||||
animation: slideInDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
v-if="userStore.isMultiUserServer"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
|
||||
<SidebarHelpCenterIcon :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
@@ -95,9 +95,6 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const isConnected = computed(
|
||||
() =>
|
||||
selectedTab.value ||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
|
||||
:is-small="isSmall"
|
||||
@click="toggleHelpCenter"
|
||||
@click="toggleHelpCenter()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: item.asset.name,
|
||||
name: getAssetDisplayName(item.asset),
|
||||
type: getAssetMediaType(item.asset)
|
||||
})
|
||||
"
|
||||
@@ -46,7 +46,7 @@
|
||||
)
|
||||
"
|
||||
:preview-url="getAssetPreviewUrl(item.asset)"
|
||||
:preview-alt="item.asset.name"
|
||||
:preview-alt="getAssetDisplayName(item.asset)"
|
||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||
:is-video-preview="isVideoAsset(item.asset)"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
@@ -135,8 +135,12 @@ const listGridStyle = {
|
||||
gap: '0.5rem'
|
||||
}
|
||||
|
||||
function getAssetDisplayName(asset: AssetItem): string {
|
||||
return asset.display_name || asset.name
|
||||
}
|
||||
|
||||
function getAssetPrimaryText(asset: AssetItem): string {
|
||||
return truncateFilename(asset.name)
|
||||
return truncateFilename(getAssetDisplayName(asset))
|
||||
}
|
||||
|
||||
function getAssetMediaType(asset: AssetItem) {
|
||||
|
||||
@@ -569,7 +569,7 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.showDialog({
|
||||
key: 'asset-3d-viewer',
|
||||
title: asset.name,
|
||||
title: asset.display_name || asset.name,
|
||||
component: Load3dViewerContent,
|
||||
props: {
|
||||
modelUrl: asset.preview_url || ''
|
||||
|
||||
@@ -9,6 +9,18 @@ import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import CurrentUserButton from './CurrentUserButton.vue'
|
||||
|
||||
const mockFeatureFlags = vi.hoisted(() => ({
|
||||
teamWorkspacesEnabled: false
|
||||
}))
|
||||
|
||||
const mockTeamWorkspaceStore = vi.hoisted(() => ({
|
||||
workspaceName: { value: '' },
|
||||
initState: { value: 'idle' },
|
||||
isInPersonalWorkspace: { value: false }
|
||||
}))
|
||||
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
@@ -32,16 +44,19 @@ vi.mock('pinia', () => ({
|
||||
// Mock the useFeatureFlags composable
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: vi.fn(() => ({
|
||||
flags: { teamWorkspacesEnabled: false }
|
||||
flags: mockFeatureFlags
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useTeamWorkspaceStore
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: vi.fn(() => ({
|
||||
workspaceName: { value: '' },
|
||||
initState: { value: 'idle' }
|
||||
}))
|
||||
useTeamWorkspaceStore: vi.fn(() => mockTeamWorkspaceStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
@@ -64,6 +79,16 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the WorkspaceProfilePic component
|
||||
vi.mock('@/platform/workspace/components/WorkspaceProfilePic.vue', () => ({
|
||||
default: {
|
||||
name: 'WorkspaceProfilePicMock',
|
||||
render() {
|
||||
return h('div', 'WorkspaceProfilePic')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the CurrentUserPopoverLegacy component
|
||||
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
|
||||
default: {
|
||||
@@ -78,9 +103,15 @@ vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
|
||||
describe('CurrentUserButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFeatureFlags.teamWorkspacesEnabled = false
|
||||
mockTeamWorkspaceStore.workspaceName.value = ''
|
||||
mockTeamWorkspaceStore.initState.value = 'idle'
|
||||
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
|
||||
mockIsCloud.value = false
|
||||
})
|
||||
|
||||
const mountComponent = (): VueWrapper => {
|
||||
const mountComponent = (options?: { stubButton?: boolean }): VueWrapper => {
|
||||
const { stubButton = true } = options ?? {}
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -99,7 +130,7 @@ describe('CurrentUserButton', () => {
|
||||
hide: vi.fn()
|
||||
}
|
||||
},
|
||||
Button: true
|
||||
...(stubButton ? { Button: true } : {})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -137,4 +168,27 @@ describe('CurrentUserButton', () => {
|
||||
// Verify that popover.hide was called
|
||||
expect(popoverHideSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows UserAvatar in personal workspace', () => {
|
||||
mockIsCloud.value = true
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockTeamWorkspaceStore.initState.value = 'ready'
|
||||
mockTeamWorkspaceStore.isInPersonalWorkspace.value = true
|
||||
|
||||
const wrapper = mountComponent({ stubButton: false })
|
||||
expect(wrapper.html()).toContain('Avatar')
|
||||
expect(wrapper.html()).not.toContain('WorkspaceProfilePic')
|
||||
})
|
||||
|
||||
it('shows WorkspaceProfilePic in team workspace', () => {
|
||||
mockIsCloud.value = true
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockTeamWorkspaceStore.initState.value = 'ready'
|
||||
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
|
||||
mockTeamWorkspaceStore.workspaceName.value = 'My Team'
|
||||
|
||||
const wrapper = mountComponent({ stubButton: false })
|
||||
expect(wrapper.html()).toContain('WorkspaceProfilePic')
|
||||
expect(wrapper.html()).not.toContain('Avatar')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<UserAvatar
|
||||
v-else
|
||||
:photo-url="photoURL"
|
||||
:class="compact && 'size-full'"
|
||||
:class="compact && 'h-full w-auto'"
|
||||
/>
|
||||
|
||||
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-4 px-1" />
|
||||
@@ -98,15 +98,21 @@ const photoURL = computed<string | undefined>(
|
||||
() => userPhotoUrl.value ?? undefined
|
||||
)
|
||||
|
||||
const { workspaceName: teamWorkspaceName, initState } = storeToRefs(
|
||||
useTeamWorkspaceStore()
|
||||
)
|
||||
const {
|
||||
workspaceName: teamWorkspaceName,
|
||||
initState,
|
||||
isInPersonalWorkspace
|
||||
} = storeToRefs(useTeamWorkspaceStore())
|
||||
|
||||
const showWorkspaceSkeleton = computed(
|
||||
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'loading'
|
||||
)
|
||||
const showWorkspaceIcon = computed(
|
||||
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'ready'
|
||||
() =>
|
||||
isCloud &&
|
||||
teamWorkspacesEnabled.value &&
|
||||
initState.value === 'ready' &&
|
||||
!isInPersonalWorkspace.value
|
||||
)
|
||||
|
||||
const workspaceName = computed(() => {
|
||||
|
||||
@@ -167,12 +167,9 @@ vi.mock('@/platform/telemetry', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock isCloud with hoisted state for per-test toggling
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
// Mock isCloud
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
|
||||
@@ -187,7 +184,6 @@ vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
|
||||
describe('CurrentUserPopoverLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockAuthStoreState.balance = {
|
||||
amount_micros: 100_000,
|
||||
effective_balance_micros: 100_000,
|
||||
@@ -429,60 +425,4 @@ describe('CurrentUserPopoverLegacy', () => {
|
||||
expect(wrapper.text()).toContain('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-cloud distribution', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = false
|
||||
})
|
||||
|
||||
it('hides credits section', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('[data-testid="add-credits-button"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
wrapper.find('[data-testid="upgrade-to-add-credits-button"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides subscribe button', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.text()).not.toContain('Subscribe Button')
|
||||
})
|
||||
|
||||
it('hides partner nodes menu item', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(
|
||||
wrapper.find('[data-testid="partner-nodes-menu-item"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides plans & pricing menu item', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(
|
||||
wrapper.find('[data-testid="plans-pricing-menu-item"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides manage plan menu item', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(
|
||||
wrapper.find('[data-testid="manage-plan-menu-item"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('still shows user settings menu item', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(
|
||||
wrapper.find('[data-testid="user-settings-menu-item"]').exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('still shows logout menu item', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('[data-testid="logout-menu-item"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,11 +29,8 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Credits Section (cloud only) -->
|
||||
<div
|
||||
v-if="isCloud && isActiveSubscription"
|
||||
class="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="authStore.isFetchingBalance"
|
||||
@@ -69,7 +66,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isCloud" class="flex justify-center px-4">
|
||||
<div v-else class="flex justify-center px-4">
|
||||
<SubscribeButton
|
||||
:fluid="false"
|
||||
:label="$t('subscription.subscribeToComfyCloud')"
|
||||
@@ -82,7 +79,7 @@
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<div
|
||||
v-if="isCloud && isActiveSubscription"
|
||||
v-if="isActiveSubscription"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="partner-nodes-menu-item"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
@@ -94,7 +91,6 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCloud"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="plans-pricing-menu-item"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
@@ -112,7 +108,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCloud && isActiveSubscription"
|
||||
v-if="isActiveSubscription"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="manage-plan-menu-item"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
class="comfy-help-center-btn relative text-base-foreground"
|
||||
variant="textonly"
|
||||
@click="toggleHelpCenter"
|
||||
>
|
||||
<div class="not-md:hidden">{{ $t('menu.helpAndFeedback') }}</div>
|
||||
<i class="ml-0.5 icon-[lucide--circle-help]" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
class="absolute top-[7px] right-[7px] size-1.5 rounded-full bg-[#ff3b30]"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter('topbar')
|
||||
</script>
|
||||
@@ -1,79 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import TopbarSubscribeButton from './TopbarSubscribeButton.vue'
|
||||
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
const mockShowPricingTable = vi.fn()
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: vi.fn(() => ({
|
||||
showPricingTable: mockShowPricingTable
|
||||
}))
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isFreeTier: { value: true }
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('pinia')
|
||||
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}))
|
||||
|
||||
function mountComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return mount(TopbarSubscribeButton, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TopbarSubscribeButton', () => {
|
||||
it('renders on cloud when isFreeTier is true', () => {
|
||||
mockIsCloud.value = true
|
||||
const wrapper = mountComponent()
|
||||
expect(
|
||||
wrapper.find('[data-testid="topbar-subscribe-button"]').exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides on non-cloud distribution', () => {
|
||||
mockIsCloud.value = false
|
||||
const wrapper = mountComponent()
|
||||
expect(
|
||||
wrapper.find('[data-testid="topbar-subscribe-button"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Button
|
||||
v-if="isCloud && isFreeTier"
|
||||
v-if="isFreeTier"
|
||||
class="mr-2 shrink-0 whitespace-nowrap"
|
||||
variant="gradient"
|
||||
size="sm"
|
||||
@@ -15,7 +15,6 @@
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
const { isFreeTier } = useBillingContext()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
@@ -83,13 +83,18 @@
|
||||
v-if="isIntegratedTabBar"
|
||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||
>
|
||||
<TopMenuHelpButton />
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn"
|
||||
:show-arrow="false"
|
||||
compact
|
||||
class="grid w-10 shrink-0 p-1"
|
||||
/>
|
||||
<Button
|
||||
v-if="isCloud || isNightly"
|
||||
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 text-base-foreground"
|
||||
:aria-label="$t('actionbar.feedback')"
|
||||
@click="openFeedback"
|
||||
>
|
||||
<i class="icon-[lucide--message-square-text]" />
|
||||
</Button>
|
||||
<CurrentUserButton v-if="showCurrentUser" compact class="shrink-0 p-1" />
|
||||
<LoginButton v-else-if="isDesktop" class="p-1" />
|
||||
</div>
|
||||
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
|
||||
@@ -102,21 +107,20 @@ import ScrollPanel from 'primevue/scrollpanel'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
|
||||
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackUrl } from '@/platform/support/config'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
|
||||
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
|
||||
@@ -138,8 +142,14 @@ const commandStore = useCommandStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
|
||||
|
||||
const feedbackUrl = buildFeedbackUrl()
|
||||
function openFeedback() {
|
||||
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const showOverflowArrows = ref(false)
|
||||
|
||||
@@ -140,13 +140,19 @@ export const useFirebaseAuthActions = () => {
|
||||
return result
|
||||
}, reportError)
|
||||
|
||||
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {
|
||||
return await authStore.loginWithGoogle()
|
||||
}, reportError)
|
||||
const signInWithGoogle = wrapWithErrorHandlingAsync(
|
||||
async (options?: { isNewUser?: boolean }) => {
|
||||
return await authStore.loginWithGoogle(options)
|
||||
},
|
||||
reportError
|
||||
)
|
||||
|
||||
const signInWithGithub = wrapWithErrorHandlingAsync(async () => {
|
||||
return await authStore.loginWithGithub()
|
||||
}, reportError)
|
||||
const signInWithGithub = wrapWithErrorHandlingAsync(
|
||||
async (options?: { isNewUser?: boolean }) => {
|
||||
return await authStore.loginWithGithub(options)
|
||||
},
|
||||
reportError
|
||||
)
|
||||
|
||||
const signInWithEmail = wrapWithErrorHandlingAsync(
|
||||
async (email: string, password: string) => {
|
||||
|
||||
@@ -241,6 +241,32 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
||||
})
|
||||
|
||||
it('clears stale slotMetadata when input no longer matches widget', async () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = vueNodeData.get(String(node.id))!
|
||||
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
|
||||
|
||||
expect(widgetData.slotMetadata?.linked).toBe(true)
|
||||
|
||||
node.inputs[0].name = 'other'
|
||||
node.inputs[0].widget = { name: 'other' }
|
||||
node.inputs[0].link = null
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: 42
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(widgetData.slotMetadata).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
@@ -296,31 +322,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
expect(secondMappedWidget.name).not.toBe('stale_widget')
|
||||
})
|
||||
it('clears stale slotMetadata when input no longer matches widget', async () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = vueNodeData.get(String(node.id))!
|
||||
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
|
||||
|
||||
expect(widgetData.slotMetadata?.linked).toBe(true)
|
||||
|
||||
node.inputs[0].name = 'other'
|
||||
node.inputs[0].widget = { name: 'other' }
|
||||
node.inputs[0].link = null
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: 42
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(widgetData.slotMetadata).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph output slot label reactivity', () => {
|
||||
|
||||
@@ -5,19 +5,15 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import type { HelpCenterTriggerLocation } from '@/stores/helpCenterStore'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
import { useNodeConflictDialog } from '@/workbench/extensions/manager/composables/useNodeConflictDialog'
|
||||
|
||||
export function useHelpCenter(
|
||||
triggerFrom: HelpCenterTriggerLocation = 'sidebar'
|
||||
) {
|
||||
export function useHelpCenter() {
|
||||
const settingStore = useSettingStore()
|
||||
const releaseStore = useReleaseStore()
|
||||
const helpCenterStore = useHelpCenterStore()
|
||||
const { isVisible: isHelpCenterVisible, triggerLocation } =
|
||||
storeToRefs(helpCenterStore)
|
||||
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
|
||||
const conflictDetection = useConflictDetection()
|
||||
@@ -42,9 +38,9 @@ export function useHelpCenter(
|
||||
*/
|
||||
const toggleHelpCenter = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: `${triggerFrom}_help_center_toggled`
|
||||
button_id: 'sidebar_help_center_toggled'
|
||||
})
|
||||
helpCenterStore.toggle(triggerFrom)
|
||||
helpCenterStore.toggle()
|
||||
}
|
||||
|
||||
const closeHelpCenter = () => {
|
||||
@@ -90,7 +86,6 @@ export function useHelpCenter(
|
||||
|
||||
return {
|
||||
isHelpCenterVisible,
|
||||
triggerLocation,
|
||||
shouldShowRedDot,
|
||||
sidebarLocation,
|
||||
toggleHelpCenter,
|
||||
|
||||
@@ -35,7 +35,8 @@ vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
removeEventListener: vi.fn(),
|
||||
getServerFeature: vi.fn(() => false)
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ import { nextTick, ref, toRaw, watch } from 'vue'
|
||||
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import {
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
} from '@/platform/assets/utils/assetPreviewUtil'
|
||||
import type {
|
||||
AnimationItem,
|
||||
CameraConfig,
|
||||
@@ -514,19 +518,21 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
// Reset skeleton visibility when loading new model
|
||||
modelConfig.value.showSkeleton = false
|
||||
|
||||
if (load3d) {
|
||||
if (load3d && isAssetPreviewSupported()) {
|
||||
const node = nodeRef.value
|
||||
|
||||
const modelWidget = node?.widgets?.find(
|
||||
(w) => w.name === 'model_file' || w.name === 'image'
|
||||
)
|
||||
const value = modelWidget?.value
|
||||
if (typeof value === 'string') {
|
||||
void Load3dUtils.generateThumbnailIfNeeded(
|
||||
load3d,
|
||||
value,
|
||||
isPreview.value ? 'output' : 'input'
|
||||
)
|
||||
if (typeof value === 'string' && value) {
|
||||
const filename = value.trim().replace(/\s*\[output\]$/, '')
|
||||
const modelName = Load3dUtils.splitFilePath(filename)[1]
|
||||
load3d
|
||||
.captureThumbnail(256, 256)
|
||||
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
|
||||
.then((blob) => persistThumbnail(modelName, blob))
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -357,7 +357,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize viewer in standalone mode (for asset preview)
|
||||
* Initialize viewer in standalone mode (for asset preview).
|
||||
* Creates the Load3d instance once; subsequent calls reuse it.
|
||||
*/
|
||||
const initializeStandaloneViewer = async (
|
||||
containerRef: HTMLElement,
|
||||
@@ -366,6 +367,11 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
if (!containerRef) return
|
||||
|
||||
try {
|
||||
if (load3d) {
|
||||
await loadStandaloneModel(modelUrl)
|
||||
return
|
||||
}
|
||||
|
||||
isStandaloneMode.value = true
|
||||
|
||||
load3d = new Load3d(containerRef, {
|
||||
@@ -392,6 +398,23 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
setupAnimationEvents()
|
||||
} catch (error) {
|
||||
console.error('Error initializing standalone 3D viewer:', error)
|
||||
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a new model into an existing standalone viewer,
|
||||
* reusing the same WebGLRenderer.
|
||||
*/
|
||||
const loadStandaloneModel = async (modelUrl: string) => {
|
||||
if (!load3d) return
|
||||
|
||||
try {
|
||||
await load3d.loadModel(modelUrl)
|
||||
isSplatModel.value = load3d.isSplatModel()
|
||||
isPlyModel.value = load3d.isPlyModel()
|
||||
} catch (error) {
|
||||
console.error('Error loading model in standalone viewer:', error)
|
||||
useToastStore().addAlert('Failed to load 3D model')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { computed, watch } from 'vue'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { t } from '@/i18n'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { TopbarBadge } from '@/types/comfy'
|
||||
|
||||
@@ -17,16 +18,20 @@ const badges = computed<TopbarBadge[]>(() => {
|
||||
tooltip: alert.tooltip
|
||||
})
|
||||
}
|
||||
|
||||
// Always add cloud badge last (furthest right)
|
||||
result.push({
|
||||
icon: 'icon-[lucide--cloud]',
|
||||
text: 'Comfy Cloud'
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
watch(
|
||||
() => canvasStore.canvas,
|
||||
(canvas) => {
|
||||
if (canvas) {
|
||||
canvas.info_text = t('g.comfyCloud')
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Cloud.Badges',
|
||||
get topbarBadges() {
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { t } from '@/i18n'
|
||||
import { getDistribution, ZENDESK_FIELDS } from '@/platform/support/config'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackUrl } from '@/platform/support/config'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
const ZENDESK_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
|
||||
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
|
||||
|
||||
const distribution = getDistribution()
|
||||
const params = new URLSearchParams({
|
||||
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
|
||||
[ZENDESK_FIELDS.DISTRIBUTION]: distribution
|
||||
})
|
||||
const feedbackUrl = `${ZENDESK_BASE_URL}?${params.toString()}`
|
||||
const feedbackUrl = buildFeedbackUrl()
|
||||
|
||||
const buttons: ActionBarButton[] = [
|
||||
{
|
||||
icon: 'icon-[lucide--message-circle-question-mark]',
|
||||
icon: 'icon-[lucide--message-square-text]',
|
||||
label: t('actionbar.feedback'),
|
||||
tooltip: t('actionbar.feedbackTooltip'),
|
||||
onClick: () => {
|
||||
@@ -25,6 +18,10 @@ const buttons: ActionBarButton[] = [
|
||||
]
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Cloud.FeedbackButton',
|
||||
actionBarButtons: buttons
|
||||
name: 'Comfy.FeedbackButton',
|
||||
get actionBarButtons() {
|
||||
return useSettingStore().get('Comfy.UI.TabBarLayout') === 'Legacy'
|
||||
? buttons
|
||||
: []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -20,6 +20,25 @@ import {
|
||||
type UpDirection
|
||||
} from './interfaces'
|
||||
|
||||
function positionThumbnailCamera(
|
||||
camera: THREE.PerspectiveCamera,
|
||||
model: THREE.Object3D
|
||||
) {
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
const distance = maxDim * 1.5
|
||||
|
||||
camera.position.set(
|
||||
center.x + distance * 0.7,
|
||||
center.y + distance * 0.5,
|
||||
center.z + distance * 0.7
|
||||
)
|
||||
camera.lookAt(center)
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
class Load3d {
|
||||
renderer: THREE.WebGLRenderer
|
||||
protected clock: THREE.Clock
|
||||
@@ -781,25 +800,18 @@ class Load3d {
|
||||
this.cameraManager.toggleCamera('perspective')
|
||||
}
|
||||
|
||||
const box = new THREE.Box3().setFromObject(this.modelManager.currentModel)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
const distance = maxDim * 1.5
|
||||
|
||||
const cameraPosition = new THREE.Vector3(
|
||||
center.x - distance * 0.8,
|
||||
center.y + distance * 0.4,
|
||||
center.z + distance * 0.3
|
||||
positionThumbnailCamera(
|
||||
this.cameraManager.perspectiveCamera,
|
||||
this.modelManager.currentModel
|
||||
)
|
||||
|
||||
this.cameraManager.perspectiveCamera.position.copy(cameraPosition)
|
||||
this.cameraManager.perspectiveCamera.lookAt(center)
|
||||
this.cameraManager.perspectiveCamera.updateProjectionMatrix()
|
||||
|
||||
if (this.controlsManager.controls) {
|
||||
this.controlsManager.controls.target.copy(center)
|
||||
const box = new THREE.Box3().setFromObject(
|
||||
this.modelManager.currentModel
|
||||
)
|
||||
this.controlsManager.controls.target.copy(
|
||||
box.getCenter(new THREE.Vector3())
|
||||
)
|
||||
this.controlsManager.controls.update()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,9 @@
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
class Load3dUtils {
|
||||
static async generateThumbnailIfNeeded(
|
||||
load3d: Load3d,
|
||||
modelPath: string,
|
||||
folderType: 'input' | 'output'
|
||||
): Promise<void> {
|
||||
const [subfolder, filename] = this.splitFilePath(modelPath)
|
||||
const thumbnailFilename = this.getThumbnailFilename(filename)
|
||||
|
||||
const exists = await this.fileExists(
|
||||
subfolder,
|
||||
thumbnailFilename,
|
||||
folderType
|
||||
)
|
||||
if (exists) return
|
||||
|
||||
const imageData = await load3d.captureThumbnail(256, 256)
|
||||
await this.uploadThumbnail(
|
||||
imageData,
|
||||
subfolder,
|
||||
thumbnailFilename,
|
||||
folderType
|
||||
)
|
||||
}
|
||||
|
||||
static async uploadTempImage(
|
||||
imageData: string,
|
||||
prefix: string,
|
||||
@@ -147,46 +122,6 @@ class Load3dUtils {
|
||||
|
||||
await Promise.all(uploadPromises)
|
||||
}
|
||||
|
||||
static getThumbnailFilename(modelFilename: string): string {
|
||||
return `${modelFilename}.png`
|
||||
}
|
||||
|
||||
static async fileExists(
|
||||
subfolder: string,
|
||||
filename: string,
|
||||
type: string = 'input'
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const url = api.apiURL(this.getResourceURL(subfolder, filename, type))
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static async uploadThumbnail(
|
||||
imageData: string,
|
||||
subfolder: string,
|
||||
filename: string,
|
||||
type: string = 'input'
|
||||
): Promise<boolean> {
|
||||
const blob = await fetch(imageData).then((r) => r.blob())
|
||||
const file = new File([blob], filename, { type: 'image/png' })
|
||||
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
body.append('subfolder', subfolder)
|
||||
body.append('type', type)
|
||||
|
||||
const resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
return resp.status === 200
|
||||
}
|
||||
}
|
||||
|
||||
export default Load3dUtils
|
||||
|
||||
@@ -4,7 +4,6 @@ import Load3D from '@/components/load3d/Load3D.vue'
|
||||
import { useLoad3d } from '@/composables/useLoad3d'
|
||||
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
|
||||
@@ -14,6 +13,10 @@ type SaveMeshOutput = NodeOutputWith<{
|
||||
'3d'?: ResultItem[]
|
||||
}>
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
} from '@/platform/assets/utils/assetPreviewUtil'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
@@ -100,17 +103,20 @@ useExtensionService().registerExtension({
|
||||
|
||||
const loadFolder = fileInfo.type as 'input' | 'output'
|
||||
|
||||
const onModelLoaded = () => {
|
||||
load3d.removeEventListener('modelLoadingEnd', onModelLoaded)
|
||||
void Load3dUtils.generateThumbnailIfNeeded(
|
||||
load3d,
|
||||
filePath,
|
||||
loadFolder
|
||||
)
|
||||
}
|
||||
load3d.addEventListener('modelLoadingEnd', onModelLoaded)
|
||||
|
||||
config.configureForSaveMesh(loadFolder, filePath)
|
||||
|
||||
if (isAssetPreviewSupported()) {
|
||||
const filename = fileInfo.filename ?? ''
|
||||
const onModelLoaded = () => {
|
||||
load3d.removeEventListener('modelLoadingEnd', onModelLoaded)
|
||||
load3d
|
||||
.captureThumbnail(256, 256)
|
||||
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
|
||||
.then((blob) => persistThumbnail(filename, blob))
|
||||
.catch(() => {})
|
||||
}
|
||||
load3d.addEventListener('modelLoadingEnd', onModelLoaded)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -559,6 +559,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
clear_background_color: string
|
||||
render_only_selected: boolean
|
||||
show_info: boolean
|
||||
/** Additional text appended to the canvas info overlay (rendered by {@link renderInfo}). */
|
||||
info_text: string | undefined
|
||||
allow_dragcanvas: boolean
|
||||
allow_dragnodes: boolean
|
||||
allow_interaction: boolean
|
||||
@@ -5195,8 +5197,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* draws some useful stats in the corner of the canvas
|
||||
*/
|
||||
renderInfo(ctx: CanvasRenderingContext2D, x: number, y: number): void {
|
||||
const lineHeight = 13
|
||||
const lineCount = (this.graph ? 5 : 1) + (this.info_text ? 1 : 0)
|
||||
x = x || 10
|
||||
y = y || this.canvas.offsetHeight - 80
|
||||
y = y || this.canvas.offsetHeight - (lineCount + 1) * lineHeight
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(x, y)
|
||||
@@ -5204,18 +5208,26 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
ctx.font = `10px ${LiteGraph.DEFAULT_FONT}`
|
||||
ctx.fillStyle = '#888'
|
||||
ctx.textAlign = 'left'
|
||||
let line = 1
|
||||
if (this.graph) {
|
||||
ctx.fillText(`T: ${this.graph.globaltime.toFixed(2)}s`, 5, 13 * 1)
|
||||
ctx.fillText(`I: ${this.graph.iteration}`, 5, 13 * 2)
|
||||
ctx.fillText(
|
||||
`T: ${this.graph.globaltime.toFixed(2)}s`,
|
||||
5,
|
||||
lineHeight * line++
|
||||
)
|
||||
ctx.fillText(`I: ${this.graph.iteration}`, 5, lineHeight * line++)
|
||||
ctx.fillText(
|
||||
`N: ${this.graph._nodes.length} [${this.visible_nodes.length}]`,
|
||||
5,
|
||||
13 * 3
|
||||
lineHeight * line++
|
||||
)
|
||||
ctx.fillText(`V: ${this.graph._version}`, 5, 13 * 4)
|
||||
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, 13 * 5)
|
||||
ctx.fillText(`V: ${this.graph._version}`, 5, lineHeight * line++)
|
||||
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, lineHeight * line++)
|
||||
} else {
|
||||
ctx.fillText('No graph selected', 5, 13 * 1)
|
||||
ctx.fillText('No graph selected', 5, lineHeight * line++)
|
||||
}
|
||||
if (this.info_text) {
|
||||
ctx.fillText(this.info_text, 5, lineHeight * line++)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
@@ -1765,7 +1765,6 @@
|
||||
"execute": "تنفيذ",
|
||||
"fullscreen": "ملء الشاشة",
|
||||
"help": "مساعدة",
|
||||
"helpAndFeedback": "المساعدة والتعليقات",
|
||||
"hideMenu": "إخفاء القائمة",
|
||||
"instant": "فوري",
|
||||
"instantTooltip": "سيتم وضع سير العمل في قائمة الانتظار فور انتهاء التوليد",
|
||||
@@ -2827,7 +2826,6 @@
|
||||
"title": "تم إلغاء اشتراكك"
|
||||
},
|
||||
"changeTo": "تغيير إلى {plan}",
|
||||
"chooseBestPlanWorkspace": "اختر أفضل خطة لمساحة العمل الخاصة بك",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "شعار Comfy Cloud",
|
||||
"contactOwnerToSubscribe": "يرجى التواصل مع مالك مساحة العمل للاشتراك",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"reportIssueTooltip": "Submit the error report to Comfy Org",
|
||||
"reportSent": "Report Submitted",
|
||||
"copyToClipboard": "Copy to Clipboard",
|
||||
"copySystemInfo": "Copy System Info",
|
||||
"copyAll": "Copy All",
|
||||
"openNewIssue": "Open New Issue",
|
||||
"showReport": "Show Report",
|
||||
@@ -302,6 +303,7 @@
|
||||
"1x": "1x",
|
||||
"2x": "2x",
|
||||
"beta": "BETA",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"nightly": "NIGHTLY",
|
||||
"profile": "Profile",
|
||||
"noItems": "No items",
|
||||
@@ -974,7 +976,6 @@
|
||||
"customNodesManager": "Custom Nodes Manager",
|
||||
"settings": "Settings",
|
||||
"help": "Help",
|
||||
"helpAndFeedback": "Help & Feedback",
|
||||
"queue": "Queue Panel",
|
||||
"fullscreen": "Fullscreen"
|
||||
},
|
||||
@@ -2223,7 +2224,12 @@
|
||||
"topupTimeout": "Top-up verification timed out"
|
||||
},
|
||||
"subscription": {
|
||||
"chooseBestPlanWorkspace": "Choose the best plan for your workspace",
|
||||
"plansForWorkspace": "Plans for {workspace}",
|
||||
"personalWorkspace": "Personal Workspace",
|
||||
"teamWorkspace": "Team Workspace",
|
||||
"soloUseOnly": "Solo use only",
|
||||
"needTeamWorkspace": "Need team workspace?",
|
||||
"inviteUpTo": "Invite up to",
|
||||
"title": "Subscription",
|
||||
"titleUnsubscribed": "Subscribe to Comfy Cloud",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
@@ -2505,7 +2511,7 @@
|
||||
},
|
||||
"createWorkspaceDialog": {
|
||||
"title": "Create a new workspace",
|
||||
"message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.",
|
||||
"message": "Workspaces create a new credit pool that can be shared among members. You'll become the owner after creating this.",
|
||||
"nameLabel": "Workspace name*",
|
||||
"namePlaceholder": "Enter workspace name",
|
||||
"create": "Create"
|
||||
@@ -2535,6 +2541,18 @@
|
||||
"failedToFetchWorkspaces": "Failed to load workspaces"
|
||||
}
|
||||
},
|
||||
"teamWorkspacesDialog": {
|
||||
"title": "Team Workspaces",
|
||||
"subtitle": "Switch to an existing one or create a new workspace",
|
||||
"subtitleNoWorkspaces": "Create a new team workspace to share credits",
|
||||
"confirmCallbackFailed": "Workspace created but setup incomplete",
|
||||
"yourTeamWorkspaces": "Your team workspaces",
|
||||
"switch": "Switch",
|
||||
"newWorkspace": "New workspace",
|
||||
"namePlaceholder": "e.g. Marketing Team",
|
||||
"createWorkspace": "Create workspace",
|
||||
"nameValidationError": "Name must be 1–50 characters using letters, numbers, spaces, or common punctuation."
|
||||
},
|
||||
"workspaceSwitcher": {
|
||||
"switchWorkspace": "Switch workspace",
|
||||
"subscribe": "Subscribe",
|
||||
@@ -2542,7 +2560,8 @@
|
||||
"roleOwner": "Owner",
|
||||
"roleMember": "Member",
|
||||
"createWorkspace": "Create new workspace",
|
||||
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one."
|
||||
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
|
||||
"failedToSwitch": "Failed to switch workspace"
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
@@ -3077,6 +3096,7 @@
|
||||
},
|
||||
"comfyHubPublish": {
|
||||
"title": "Publish to ComfyHub",
|
||||
"unsavedDescription": "You must save your workflow before publishing to ComfyHub. Save it now to continue.",
|
||||
"stepDescribe": "Describe your workflow",
|
||||
"stepExamples": "Add output examples",
|
||||
"stepFinish": "Finish publishing",
|
||||
@@ -3084,12 +3104,6 @@
|
||||
"workflowNamePlaceholder": "Tip: enter a descriptive name that's easy to search",
|
||||
"workflowDescription": "Workflow description",
|
||||
"workflowDescriptionPlaceholder": "What makes your workflow exciting and special? Be specific so people know what to expect.",
|
||||
"workflowType": "Workflow type",
|
||||
"workflowTypePlaceholder": "Select the type",
|
||||
"workflowTypeImageGeneration": "Image generation",
|
||||
"workflowTypeVideoGeneration": "Video generation",
|
||||
"workflowTypeUpscaling": "Upscaling",
|
||||
"workflowTypeEditing": "Editing",
|
||||
"tags": "Tags",
|
||||
"tagsDescription": "Select tags so people can find your workflow faster",
|
||||
"tagsPlaceholder": "Enter tags that match your workflow to help people find it e.g #nanobanana, #anime or #faceswap",
|
||||
@@ -3116,11 +3130,17 @@
|
||||
"examplesDescription": "Add up to {total} additional sample images",
|
||||
"uploadAnImage": "Click to browse or drag an image",
|
||||
"uploadExampleImage": "Upload example image",
|
||||
"removeExampleImage": "Remove example image",
|
||||
"exampleImage": "Example image {index}",
|
||||
"exampleImagePosition": "Example image {index} of {total}",
|
||||
"videoPreview": "Video thumbnail preview",
|
||||
"maxExamples": "You can select up to {max} examples",
|
||||
"shareAs": "Share as",
|
||||
"additionalInfo": "Additional information",
|
||||
"createProfileToPublish": "Create a profile to publish to ComfyHub",
|
||||
"createProfileCta": "Create a profile"
|
||||
"createProfileCta": "Create a profile",
|
||||
"publishFailedTitle": "Publish failed",
|
||||
"publishFailedDescription": "Something went wrong while publishing your workflow. Please try again."
|
||||
},
|
||||
"comfyHubProfile": {
|
||||
"checkingAccess": "Checking your publishing access...",
|
||||
@@ -3139,6 +3159,7 @@
|
||||
"namePlaceholder": "Enter your name here",
|
||||
"usernameLabel": "Your username (required)",
|
||||
"usernamePlaceholder": "@",
|
||||
"usernameError": "3–42 lowercase alphanumeric characters and hyphens, must start and end with a letter or number",
|
||||
"descriptionLabel": "Your description",
|
||||
"descriptionPlaceholder": "Tell the community about yourself...",
|
||||
"createProfile": "Create profile",
|
||||
@@ -3568,16 +3589,5 @@
|
||||
"builderMenu": {
|
||||
"enterAppMode": "Enter app mode",
|
||||
"exitAppBuilder": "Exit app builder"
|
||||
},
|
||||
"cloudNotification": {
|
||||
"title": "Run ComfyUI in the Cloud",
|
||||
"message": "From setup to creation in seconds. Popular models, extensions, and powerful GPUs — ready when you are.",
|
||||
"feature1Title": "400 Free Credits Monthly",
|
||||
"feature2Title": "Works Anywhere, Instantly",
|
||||
"feature3Title": "Models Ready to Use",
|
||||
"feature4Title": "Top Custom Node Packs Pre-installed",
|
||||
"footer": "ComfyUI stays free and open source. Cloud is optional.",
|
||||
"continueLocally": "Continue Locally",
|
||||
"exploreCloud": "Try Cloud for Free"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1765,7 +1765,6 @@
|
||||
"execute": "Ejecutar",
|
||||
"fullscreen": "Pantalla completa",
|
||||
"help": "Ayuda",
|
||||
"helpAndFeedback": "Ayuda y comentarios",
|
||||
"hideMenu": "Ocultar menú",
|
||||
"instant": "Instantáneo",
|
||||
"instantTooltip": "El flujo de trabajo se encolará instantáneamente después de que finalice una generación",
|
||||
@@ -2827,7 +2826,6 @@
|
||||
"title": "Tu suscripción ha sido cancelada"
|
||||
},
|
||||
"changeTo": "Cambiar a {plan}",
|
||||
"chooseBestPlanWorkspace": "Elige el mejor plan para tu espacio de trabajo",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Logo de Comfy Cloud",
|
||||
"contactOwnerToSubscribe": "Contacta al propietario del espacio de trabajo para suscribirte",
|
||||
|
||||
@@ -1765,7 +1765,6 @@
|
||||
"execute": "اجرا",
|
||||
"fullscreen": "تمامصفحه",
|
||||
"help": "راهنما",
|
||||
"helpAndFeedback": "راهنما و بازخورد",
|
||||
"hideMenu": "مخفی کردن منو",
|
||||
"instant": "فوری",
|
||||
"instantTooltip": "workflow بلافاصله پس از پایان تولید در صف قرار میگیرد",
|
||||
@@ -2839,7 +2838,6 @@
|
||||
"title": "اشتراک شما لغو شده است"
|
||||
},
|
||||
"changeTo": "تغییر به {plan}",
|
||||
"chooseBestPlanWorkspace": "بهترین طرح را برای فضای کاری خود انتخاب کنید",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "لوگوی Comfy Cloud",
|
||||
"contactOwnerToSubscribe": "برای فعالسازی اشتراک با مالک محیط کاری تماس بگیرید",
|
||||
|
||||
@@ -1765,7 +1765,6 @@
|
||||
"execute": "Exécuter",
|
||||
"fullscreen": "Plein écran",
|
||||
"help": "Aide",
|
||||
"helpAndFeedback": "Aide et commentaires",
|
||||
"hideMenu": "Masquer le menu",
|
||||
"instant": "Instantané",
|
||||
"instantTooltip": "Le flux de travail sera mis en file d'attente immédiatement après la fin d'une génération",
|
||||
@@ -2827,7 +2826,6 @@
|
||||
"title": "Votre abonnement a été annulé"
|
||||
},
|
||||
"changeTo": "Changer pour {plan}",
|
||||
"chooseBestPlanWorkspace": "Choisissez la meilleure offre pour votre espace de travail",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Logo Comfy Cloud",
|
||||
"contactOwnerToSubscribe": "Contactez le propriétaire de l’espace de travail pour vous abonner",
|
||||
|
||||
@@ -1765,7 +1765,6 @@
|
||||
"execute": "実行",
|
||||
"fullscreen": "全画面表示",
|
||||
"help": "ヘルプ",
|
||||
"helpAndFeedback": "ヘルプとフィードバック",
|
||||
"hideMenu": "メニューを隠す",
|
||||
"instant": "即時",
|
||||
"instantTooltip": "生成完了後すぐにキューに追加",
|
||||
@@ -2827,7 +2826,6 @@
|
||||
"title": "サブスクリプションはキャンセルされました"
|
||||
},
|
||||
"changeTo": "{plan}に変更",
|
||||
"chooseBestPlanWorkspace": "ワークスペースに最適なプランを選択してください",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Comfy Cloud ロゴ",
|
||||
"contactOwnerToSubscribe": "サブスクリプションのためにワークスペースのオーナーに連絡してください",
|
||||
|
||||
@@ -1765,7 +1765,6 @@
|
||||
"execute": "실행",
|
||||
"fullscreen": "전체 화면",
|
||||
"help": "도움말",
|
||||
"helpAndFeedback": "도움말 및 피드백",
|
||||
"hideMenu": "메뉴 숨기기",
|
||||
"instant": "즉시",
|
||||
"instantTooltip": "워크플로 실행이 완료되면 즉시 실행 대기열에 추가합니다.",
|
||||
@@ -2827,7 +2826,6 @@
|
||||
"title": "구독이 취소되었습니다"
|
||||
},
|
||||
"changeTo": "{plan}로 변경",
|
||||
"chooseBestPlanWorkspace": "워크스페이스에 가장 적합한 플랜을 선택하세요",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Comfy Cloud 로고",
|
||||
"contactOwnerToSubscribe": "워크스페이스 소유자에게 구독을 요청하세요",
|
||||
|
||||
@@ -1765,7 +1765,6 @@
|
||||
"execute": "Executar",
|
||||
"fullscreen": "Tela cheia",
|
||||
"help": "Ajuda",
|
||||
"helpAndFeedback": "Ajuda e feedback",
|
||||
"hideMenu": "Ocultar menu",
|
||||
"instant": "Instantâneo",
|
||||
"instantTooltip": "O fluxo de trabalho será enfileirado instantaneamente após uma geração terminar",
|
||||
@@ -2839,7 +2838,6 @@
|
||||
"title": "Sua assinatura foi cancelada"
|
||||
},
|
||||
"changeTo": "Mudar para {plan}",
|
||||
"chooseBestPlanWorkspace": "Escolha o melhor plano para seu workspace",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Logo do Comfy Cloud",
|
||||
"contactOwnerToSubscribe": "Entre em contato com o proprietário do espaço de trabalho para assinar",
|
||||
|
||||
@@ -1765,7 +1765,6 @@
|
||||
"execute": "Выполнить",
|
||||
"fullscreen": "Полноэкранный режим",
|
||||
"help": "Справка",
|
||||
"helpAndFeedback": "Помощь и обратная связь",
|
||||
"hideMenu": "Скрыть меню",
|
||||
"instant": "Мгновенно",
|
||||
"instantTooltip": "Рабочий процесс будет помещён в очередь сразу же после завершения генерации",
|
||||
@@ -2827,7 +2826,6 @@
|
||||
"title": "Ваша подписка отменена"
|
||||
},
|
||||
"changeTo": "Перейти на {plan}",
|
||||
"chooseBestPlanWorkspace": "Выберите лучший тариф для вашего рабочего пространства",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Логотип Comfy Cloud",
|
||||
"contactOwnerToSubscribe": "Свяжитесь с владельцем рабочего пространства для оформления подписки",
|
||||
|
||||
@@ -1765,7 +1765,6 @@
|
||||
"execute": "Yürüt",
|
||||
"fullscreen": "Tam ekran",
|
||||
"help": "Yardım",
|
||||
"helpAndFeedback": "Yardım ve Geri Bildirim",
|
||||
"hideMenu": "Menüyü Gizle",
|
||||
"instant": "Anında",
|
||||
"instantTooltip": "İş akışı, bir oluşturma işlemi bittikten sonra anında kuyruğa alınacak",
|
||||
@@ -2827,7 +2826,6 @@
|
||||
"title": "Aboneliğiniz iptal edildi"
|
||||
},
|
||||
"changeTo": "{plan} planına geç",
|
||||
"chooseBestPlanWorkspace": "Çalışma alanınız için en iyi planı seçin",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Comfy Cloud Logosu",
|
||||
"contactOwnerToSubscribe": "Abone olmak için çalışma alanı sahibiyle iletişime geçin",
|
||||
|
||||
@@ -1765,7 +1765,6 @@
|
||||
"execute": "執行",
|
||||
"fullscreen": "全螢幕",
|
||||
"help": "說明",
|
||||
"helpAndFeedback": "說明與回饋",
|
||||
"hideMenu": "隱藏選單",
|
||||
"instant": "立即",
|
||||
"instantTooltip": "每次產生完成後,工作流程會立即排入佇列",
|
||||
@@ -2827,7 +2826,6 @@
|
||||
"title": "您的訂閱已取消"
|
||||
},
|
||||
"changeTo": "切換至 {plan}",
|
||||
"chooseBestPlanWorkspace": "為您的工作區選擇最佳方案",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyCloudLogo": "Comfy Cloud 標誌",
|
||||
"contactOwnerToSubscribe": "請聯絡工作區擁有者以訂閱",
|
||||
|
||||
@@ -1765,7 +1765,6 @@
|
||||
"execute": "执行",
|
||||
"fullscreen": "全屏",
|
||||
"help": "说明",
|
||||
"helpAndFeedback": "帮助与反馈",
|
||||
"hideMenu": "隐藏菜单",
|
||||
"instant": "实时",
|
||||
"instantTooltip": "工作流将会在生成完成后立即执行",
|
||||
@@ -2839,7 +2838,6 @@
|
||||
"title": "您的订阅已被取消"
|
||||
},
|
||||
"changeTo": "更改为 {plan}",
|
||||
"chooseBestPlanWorkspace": "为您的工作区选择最佳方案",
|
||||
"comfyCloud": "Comfy 云",
|
||||
"comfyCloudLogo": "Comfy Cloud Logo",
|
||||
"contactOwnerToSubscribe": "请联系工作区所有者进行订阅",
|
||||
|
||||
@@ -186,7 +186,7 @@ const tooltipDelay = computed<number>(() =>
|
||||
|
||||
const { isLoading, error } = useImage({
|
||||
src: asset.preview_url ?? '',
|
||||
alt: asset.name
|
||||
alt: asset.display_name || asset.name
|
||||
})
|
||||
|
||||
function handleSelect() {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<div class="relative size-full overflow-hidden rounded-sm">
|
||||
<div ref="containerRef" class="relative size-full overflow-hidden rounded-sm">
|
||||
<img
|
||||
v-if="!thumbnailError"
|
||||
v-if="thumbnailSrc"
|
||||
:src="thumbnailSrc"
|
||||
:alt="asset?.name"
|
||||
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||
@error="thumbnailError = true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
@@ -20,16 +19,60 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import {
|
||||
findServerPreviewUrl,
|
||||
isAssetPreviewSupported
|
||||
} from '../utils/assetPreviewUtil'
|
||||
|
||||
const { asset } = defineProps<{ asset: AssetMeta }>()
|
||||
|
||||
const thumbnailError = ref(false)
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const thumbnailSrc = ref<string | null>(null)
|
||||
const hasAttempted = ref(false)
|
||||
|
||||
const thumbnailSrc = computed(() => {
|
||||
if (!asset?.src) return ''
|
||||
return asset.src.replace(/([?&]filename=)([^&]*)/, '$1$2.png')
|
||||
useIntersectionObserver(containerRef, ([entry]) => {
|
||||
if (entry?.isIntersecting && !hasAttempted.value) {
|
||||
hasAttempted.value = true
|
||||
void loadThumbnail()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadThumbnail() {
|
||||
if (asset?.preview_id && asset?.preview_url) {
|
||||
thumbnailSrc.value = asset.preview_url
|
||||
return
|
||||
}
|
||||
|
||||
if (!asset?.src) return
|
||||
|
||||
if (asset.name && isAssetPreviewSupported()) {
|
||||
const serverPreviewUrl = await findServerPreviewUrl(asset.name)
|
||||
if (serverPreviewUrl) {
|
||||
thumbnailSrc.value = serverPreviewUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function revokeThumbnail() {
|
||||
if (thumbnailSrc.value?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(thumbnailSrc.value)
|
||||
}
|
||||
thumbnailSrc.value = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => asset?.src,
|
||||
() => {
|
||||
if (hasAttempted.value) {
|
||||
hasAttempted.value = false
|
||||
revokeThumbnail()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(revokeThumbnail)
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:aria-label="
|
||||
asset
|
||||
? $t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: asset.name,
|
||||
name: asset.display_name || asset.name,
|
||||
type: fileKind
|
||||
})
|
||||
: $t('assetBrowser.ariaLabel.loadingAsset')
|
||||
@@ -139,6 +139,7 @@ import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import {
|
||||
formatDuration,
|
||||
@@ -150,6 +151,7 @@ import {
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { getAssetType } from '../composables/media/assetMappers'
|
||||
import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
@@ -225,7 +227,7 @@ const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
|
||||
|
||||
// Get filename without extension
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset?.name || '').filename
|
||||
return getFilenameDetails(asset?.display_name || asset?.name || '').filename
|
||||
})
|
||||
|
||||
// Adapt AssetItem to legacy AssetMeta format for existing components
|
||||
@@ -234,8 +236,14 @@ const adaptedAsset = computed(() => {
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
display_name: asset.display_name,
|
||||
kind: fileKind.value,
|
||||
src: asset.thumbnail_url || asset.preview_url || '',
|
||||
src:
|
||||
fileKind.value === '3D'
|
||||
? getAssetUrl(asset)
|
||||
: asset.thumbnail_url || asset.preview_url || '',
|
||||
preview_url: asset.preview_url,
|
||||
preview_id: asset.preview_id,
|
||||
size: asset.size,
|
||||
tags: asset.tags || [],
|
||||
created_at: asset.created_at,
|
||||
@@ -269,7 +277,8 @@ const formattedDuration = computed(() => {
|
||||
// Get metadata info based on file kind
|
||||
const metaInfo = computed(() => {
|
||||
if (!asset) return ''
|
||||
if (fileKind.value === 'image' && imageDimensions.value) {
|
||||
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
|
||||
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
|
||||
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
|
||||
}
|
||||
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<img
|
||||
v-if="!error"
|
||||
:src="asset.src"
|
||||
:alt="asset.name"
|
||||
:alt="asset.display_name || asset.name"
|
||||
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||
/>
|
||||
<div
|
||||
@@ -34,7 +34,7 @@ const emit = defineEmits<{
|
||||
|
||||
const { state, error, isReady } = useImage({
|
||||
src: asset.src ?? '',
|
||||
alt: asset.name
|
||||
alt: asset.display_name || asset.name
|
||||
})
|
||||
|
||||
whenever(
|
||||
|
||||
@@ -39,6 +39,7 @@ export function mapTaskOutputToAssetItem(
|
||||
return {
|
||||
id: taskItem.jobId,
|
||||
name: output.filename,
|
||||
display_name: output.display_name,
|
||||
size: 0,
|
||||
created_at: taskItem.executionStartTimestamp
|
||||
? new Date(taskItem.executionStartTimestamp).toISOString()
|
||||
|
||||
@@ -119,17 +119,31 @@ vi.mock('@/platform/assets/utils/assetTypeUtil', () => ({
|
||||
getAssetType: mockGetAssetType
|
||||
}))
|
||||
|
||||
const mockGetOutputAssetMetadata = vi.hoisted(() =>
|
||||
vi.fn().mockReturnValue(null)
|
||||
)
|
||||
vi.mock('../schemas/assetMetadataSchema', () => ({
|
||||
getOutputAssetMetadata: vi.fn().mockReturnValue(null)
|
||||
getOutputAssetMetadata: mockGetOutputAssetMetadata
|
||||
}))
|
||||
|
||||
const mockDeleteAsset = vi.hoisted(() => vi.fn())
|
||||
const mockCreateAssetExport = vi.hoisted(() =>
|
||||
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
|
||||
)
|
||||
vi.mock('../services/assetService', () => ({
|
||||
assetService: {
|
||||
deleteAsset: mockDeleteAsset
|
||||
deleteAsset: mockDeleteAsset,
|
||||
createAssetExport: mockCreateAssetExport
|
||||
}
|
||||
}))
|
||||
|
||||
const mockTrackExport = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/assetExportStore', () => ({
|
||||
useAssetExportStore: () => ({
|
||||
trackExport: mockTrackExport
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
deleteItem: vi.fn(),
|
||||
@@ -259,6 +273,106 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadMultipleAssets - job_asset_name_filters', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
mockCreateAssetExport.mockClear()
|
||||
mockTrackExport.mockClear()
|
||||
mockGetAssetType.mockReturnValue('output')
|
||||
mockGetOutputAssetMetadata.mockImplementation(
|
||||
(meta: Record<string, unknown> | undefined) =>
|
||||
meta && 'jobId' in meta ? meta : null
|
||||
)
|
||||
})
|
||||
|
||||
function createOutputAsset(
|
||||
id: string,
|
||||
name: string,
|
||||
jobId: string,
|
||||
outputCount?: number
|
||||
): AssetItem {
|
||||
return createMockAsset({
|
||||
id,
|
||||
name,
|
||||
tags: ['output'],
|
||||
user_metadata: { jobId, nodeId: '1', subfolder: '', outputCount }
|
||||
})
|
||||
}
|
||||
|
||||
it('should omit name filters for job-level selections (outputCount known)', async () => {
|
||||
const assets = [
|
||||
createOutputAsset('a1', 'img1.png', 'job1', 3),
|
||||
createOutputAsset('a2', 'img2.png', 'job1', 3),
|
||||
createOutputAsset('a3', 'img3.png', 'job1', 3)
|
||||
]
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadMultipleAssets(assets)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const payload = mockCreateAssetExport.mock.calls[0][0]
|
||||
expect(payload.job_ids).toEqual(['job1'])
|
||||
expect(payload.job_asset_name_filters).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should omit name filters for multiple job-level selections', async () => {
|
||||
const j1a = createOutputAsset('a1', 'out1a.png', 'job1', 2)
|
||||
const j1b = createOutputAsset('a2', 'out1b.png', 'job1', 2)
|
||||
const j2 = createOutputAsset('a3', 'out2.png', 'job2', 1)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadMultipleAssets([j1a, j1b, j2])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const payload = mockCreateAssetExport.mock.calls[0][0]
|
||||
expect(payload.job_ids).toEqual(['job1', 'job2'])
|
||||
expect(payload.job_asset_name_filters).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should include name filters when outputCount is unknown', async () => {
|
||||
const asset1 = createOutputAsset('a1', 'img1.png', 'job1')
|
||||
const asset2 = createOutputAsset('a2', 'img2.png', 'job2')
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadMultipleAssets([asset1, asset2])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const payload = mockCreateAssetExport.mock.calls[0][0]
|
||||
expect(payload.job_asset_name_filters).toEqual({
|
||||
job1: ['img1.png'],
|
||||
job2: ['img2.png']
|
||||
})
|
||||
})
|
||||
|
||||
it('should mix: omit filters for known outputCount, keep for unknown', async () => {
|
||||
const j1a = createOutputAsset('a1', 'img1a.png', 'job1', 2)
|
||||
const j1b = createOutputAsset('a2', 'img1b.png', 'job1', 2)
|
||||
const j2 = createOutputAsset('a3', 'img2.png', 'job2')
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadMultipleAssets([j1a, j1b, j2])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const payload = mockCreateAssetExport.mock.calls[0][0]
|
||||
expect(payload.job_ids).toEqual(['job1', 'job2'])
|
||||
expect(payload.job_asset_name_filters).toEqual({
|
||||
job2: ['img2.png']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAssets - model cache invalidation', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
|
||||
@@ -68,7 +68,7 @@ export function useMediaAssetActions() {
|
||||
if (!targetAsset) return
|
||||
|
||||
try {
|
||||
const filename = targetAsset.name
|
||||
const filename = targetAsset.display_name || targetAsset.name
|
||||
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
|
||||
const downloadUrl = targetAsset.preview_url || getAssetUrl(targetAsset)
|
||||
|
||||
@@ -109,7 +109,7 @@ export function useMediaAssetActions() {
|
||||
|
||||
try {
|
||||
assets.forEach((asset) => {
|
||||
const filename = asset.name
|
||||
const filename = asset.display_name || asset.name
|
||||
const downloadUrl = asset.preview_url || getAssetUrl(asset)
|
||||
downloadFile(downloadUrl, filename)
|
||||
})
|
||||
@@ -145,7 +145,10 @@ export function useMediaAssetActions() {
|
||||
if (!jobIds.includes(jobId)) {
|
||||
jobIds.push(jobId)
|
||||
}
|
||||
if (metadata?.jobId && asset.name) {
|
||||
// Only add name filters when outputCount is unknown.
|
||||
// When outputCount is set, the asset is a job-level selection
|
||||
// from the gallery and the user wants all outputs for that job.
|
||||
if (metadata?.jobId && asset.name && metadata.outputCount == null) {
|
||||
if (!jobAssetNameFilters[metadata.jobId]) {
|
||||
jobAssetNameFilters[metadata.jobId] = []
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const zAsset = z.object({
|
||||
mime_type: z.string().nullish(),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
preview_id: z.string().nullable().optional(),
|
||||
display_name: z.string().optional(),
|
||||
preview_url: z.string().optional(),
|
||||
thumbnail_url: z.string().optional(),
|
||||
created_at: z.string().optional(),
|
||||
@@ -94,7 +95,7 @@ export type ModelFile = z.infer<typeof zModelFile>
|
||||
|
||||
/** Payload for updating an asset via PUT /assets/:id */
|
||||
export type AssetUpdatePayload = Partial<
|
||||
Pick<AssetItem, 'name' | 'tags' | 'user_metadata'>
|
||||
Pick<AssetItem, 'name' | 'tags' | 'user_metadata' | 'preview_id'>
|
||||
>
|
||||
|
||||
/** User-editable metadata fields for model assets */
|
||||
|
||||
267
src/platform/assets/utils/assetPreviewUtil.test.ts
Normal file
267
src/platform/assets/utils/assetPreviewUtil.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
findOutputAsset,
|
||||
findServerPreviewUrl,
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
} from '@/platform/assets/utils/assetPreviewUtil'
|
||||
|
||||
const mockFetchApi = vi.hoisted(() => vi.fn())
|
||||
const mockApiURL = vi.hoisted(() =>
|
||||
vi.fn((path: string) => `http://localhost:8188${path}`)
|
||||
)
|
||||
const mockGetServerFeature = vi.hoisted(() => vi.fn(() => false))
|
||||
const mockIsAssetAPIEnabled = vi.hoisted(() => vi.fn(() => false))
|
||||
const mockUploadAssetFromBase64 = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateAsset = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: mockFetchApi,
|
||||
apiURL: mockApiURL,
|
||||
api_base: '',
|
||||
getServerFeature: mockGetServerFeature
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
isAssetAPIEnabled: mockIsAssetAPIEnabled,
|
||||
uploadAssetFromBase64: mockUploadAssetFromBase64,
|
||||
updateAsset: mockUpdateAsset
|
||||
}
|
||||
}))
|
||||
|
||||
function mockFetchResponse(assets: Record<string, unknown>[]) {
|
||||
mockFetchApi.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ assets })
|
||||
})
|
||||
}
|
||||
|
||||
function mockFetchEmpty() {
|
||||
mockFetchResponse([])
|
||||
}
|
||||
|
||||
function mockFetchError() {
|
||||
mockFetchApi.mockResolvedValueOnce({ ok: false })
|
||||
}
|
||||
|
||||
const cloudAsset = {
|
||||
id: '72d169cc-7f9a-40d2-9382-35eadcba0a6a',
|
||||
name: 'mesh/ComfyUI_00003_.glb',
|
||||
asset_hash: 'c6cadcee57dd.glb',
|
||||
preview_id: null,
|
||||
preview_url: undefined
|
||||
}
|
||||
|
||||
const cloudAssetWithPreview = {
|
||||
...cloudAsset,
|
||||
preview_id: 'aaaa-bbbb',
|
||||
preview_url: '/api/view?type=output&filename=preview.png'
|
||||
}
|
||||
|
||||
const localAsset = {
|
||||
id: '50bf419e-7ecb-4c96-a0c7-c1eb4dff00cb',
|
||||
name: 'ComfyUI_00081_.glb',
|
||||
preview_id: null,
|
||||
preview_url:
|
||||
'/api/view?type=output&filename=ComfyUI_00081_.glb&subfolder=mesh'
|
||||
}
|
||||
|
||||
const localAssetWithPreview = {
|
||||
...localAsset,
|
||||
preview_id: '3df94ee8-preview',
|
||||
preview_url: '/api/view?type=output&filename=preview.png'
|
||||
}
|
||||
|
||||
describe('isAssetPreviewSupported', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns true when asset API is enabled (cloud)', () => {
|
||||
mockIsAssetAPIEnabled.mockReturnValue(true)
|
||||
expect(isAssetPreviewSupported()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when server assets feature is enabled (local)', () => {
|
||||
mockGetServerFeature.mockReturnValue(true)
|
||||
expect(isAssetPreviewSupported()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when neither is enabled', () => {
|
||||
mockIsAssetAPIEnabled.mockReturnValue(false)
|
||||
mockGetServerFeature.mockReturnValue(false)
|
||||
expect(isAssetPreviewSupported()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findOutputAsset', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('finds asset by hash (cloud)', async () => {
|
||||
mockFetchResponse([cloudAsset])
|
||||
|
||||
const result = await findOutputAsset('c6cadcee57dd.glb')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledOnce()
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain(
|
||||
'asset_hash=c6cadcee57dd.glb'
|
||||
)
|
||||
expect(result).toEqual(cloudAsset)
|
||||
})
|
||||
|
||||
it('falls back to name_contains when hash returns empty (local)', async () => {
|
||||
mockFetchEmpty()
|
||||
mockFetchResponse([localAsset])
|
||||
|
||||
const result = await findOutputAsset('ComfyUI_00081_.glb')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain('asset_hash=')
|
||||
expect(mockFetchApi.mock.calls[1][0]).toContain('name_contains=')
|
||||
expect(result).toEqual(localAsset)
|
||||
})
|
||||
|
||||
it('returns undefined when no asset matches', async () => {
|
||||
mockFetchEmpty()
|
||||
mockFetchEmpty()
|
||||
|
||||
const result = await findOutputAsset('nonexistent.glb')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('matches exact name from name_contains results', async () => {
|
||||
mockFetchEmpty()
|
||||
mockFetchResponse([
|
||||
{ id: '1', name: 'ComfyUI_00001_.glb_preview.png' },
|
||||
{ id: '2', name: 'ComfyUI_00001_.glb' }
|
||||
])
|
||||
|
||||
const result = await findOutputAsset('ComfyUI_00001_.glb')
|
||||
expect(result?.id).toBe('2')
|
||||
})
|
||||
|
||||
it('returns empty array on fetch error', async () => {
|
||||
mockFetchError()
|
||||
mockFetchError()
|
||||
|
||||
const result = await findOutputAsset('test.glb')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findServerPreviewUrl', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns null when asset has no preview_id', async () => {
|
||||
mockFetchResponse([cloudAsset])
|
||||
|
||||
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns preview_url via apiURL when preview_id is set', async () => {
|
||||
mockFetchResponse([cloudAssetWithPreview])
|
||||
|
||||
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
|
||||
|
||||
expect(mockApiURL).toHaveBeenCalledWith(cloudAssetWithPreview.preview_url)
|
||||
expect(result).toBe(
|
||||
`http://localhost:8188${cloudAssetWithPreview.preview_url}`
|
||||
)
|
||||
})
|
||||
|
||||
it('constructs URL from preview_id when preview_url is missing', async () => {
|
||||
mockFetchResponse([{ ...cloudAsset, preview_id: 'aaaa-bbbb' }])
|
||||
|
||||
const result = await findServerPreviewUrl('c6cadcee57dd.glb')
|
||||
expect(result).toBe('http://localhost:8188/assets/aaaa-bbbb/content')
|
||||
})
|
||||
|
||||
it('falls back to asset id when preview_id is null but set', async () => {
|
||||
// Edge case: asset has preview_id explicitly null, no preview_url
|
||||
mockFetchEmpty()
|
||||
mockFetchEmpty()
|
||||
|
||||
const result = await findServerPreviewUrl('nonexistent.glb')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null on error', async () => {
|
||||
mockFetchApi.mockRejectedValueOnce(new Error('network error'))
|
||||
|
||||
const result = await findServerPreviewUrl('test.glb')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistThumbnail', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('uploads thumbnail and links preview_id', async () => {
|
||||
mockFetchEmpty()
|
||||
mockFetchResponse([localAsset])
|
||||
mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
|
||||
mockUpdateAsset.mockResolvedValue({})
|
||||
|
||||
const blob = new Blob(['fake-png'], { type: 'image/png' })
|
||||
await persistThumbnail('ComfyUI_00081_.glb', blob)
|
||||
|
||||
expect(mockUploadAssetFromBase64).toHaveBeenCalledOnce()
|
||||
expect(mockUploadAssetFromBase64.mock.calls[0][0].name).toBe(
|
||||
'ComfyUI_00081_.glb_preview.png'
|
||||
)
|
||||
expect(mockUpdateAsset).toHaveBeenCalledWith(localAsset.id, {
|
||||
preview_id: 'new-preview-id'
|
||||
})
|
||||
})
|
||||
|
||||
it('skips when asset already has preview_id', async () => {
|
||||
mockFetchEmpty()
|
||||
mockFetchResponse([localAssetWithPreview])
|
||||
|
||||
const blob = new Blob(['fake-png'], { type: 'image/png' })
|
||||
await persistThumbnail('ComfyUI_00081_.glb', blob)
|
||||
|
||||
expect(mockUploadAssetFromBase64).not.toHaveBeenCalled()
|
||||
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips when no asset found', async () => {
|
||||
mockFetchEmpty()
|
||||
mockFetchEmpty()
|
||||
|
||||
const blob = new Blob(['fake-png'], { type: 'image/png' })
|
||||
await persistThumbnail('nonexistent.glb', blob)
|
||||
|
||||
expect(mockUploadAssetFromBase64).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swallows errors silently', async () => {
|
||||
mockFetchEmpty()
|
||||
mockFetchResponse([localAsset])
|
||||
mockUploadAssetFromBase64.mockRejectedValue(new Error('upload failed'))
|
||||
|
||||
const blob = new Blob(['fake-png'], { type: 'image/png' })
|
||||
await expect(
|
||||
persistThumbnail('ComfyUI_00081_.glb', blob)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('works with cloud hash filename', async () => {
|
||||
mockFetchResponse([cloudAsset])
|
||||
mockUploadAssetFromBase64.mockResolvedValue({ id: 'new-preview-id' })
|
||||
mockUpdateAsset.mockResolvedValue({})
|
||||
|
||||
const blob = new Blob(['fake-png'], { type: 'image/png' })
|
||||
await persistThumbnail('c6cadcee57dd.glb', blob)
|
||||
|
||||
expect(mockUploadAssetFromBase64.mock.calls[0][0].name).toBe(
|
||||
'mesh/ComfyUI_00003_.glb_preview.png'
|
||||
)
|
||||
expect(mockUpdateAsset).toHaveBeenCalledWith(cloudAsset.id, {
|
||||
preview_id: 'new-preview-id'
|
||||
})
|
||||
})
|
||||
})
|
||||
95
src/platform/assets/utils/assetPreviewUtil.ts
Normal file
95
src/platform/assets/utils/assetPreviewUtil.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
interface AssetRecord {
|
||||
id: string
|
||||
name: string
|
||||
asset_hash?: string
|
||||
preview_url?: string
|
||||
preview_id?: string | null
|
||||
}
|
||||
|
||||
export function isAssetPreviewSupported(): boolean {
|
||||
return (
|
||||
assetService.isAssetAPIEnabled() || api.getServerFeature('assets', false)
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchAssets(
|
||||
params: Record<string, string>
|
||||
): Promise<AssetRecord[]> {
|
||||
const query = new URLSearchParams(params)
|
||||
const res = await api.fetchApi(`/assets?${query}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.assets ?? []
|
||||
}
|
||||
|
||||
function resolvePreviewUrl(asset: AssetRecord): string {
|
||||
if (asset.preview_url) return api.apiURL(asset.preview_url)
|
||||
|
||||
const contentId = asset.preview_id ?? asset.id
|
||||
return api.apiURL(`/assets/${contentId}/content`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an output asset record by content hash, falling back to name.
|
||||
* On cloud, output filenames are content-hashed; use asset_hash to match.
|
||||
* On local, filenames are not hashed; use name_contains to match.
|
||||
*/
|
||||
export async function findOutputAsset(
|
||||
name: string
|
||||
): Promise<AssetRecord | undefined> {
|
||||
const byHash = await fetchAssets({ asset_hash: name })
|
||||
const hashMatch = byHash.find((a) => a.asset_hash === name)
|
||||
if (hashMatch) return hashMatch
|
||||
|
||||
const byName = await fetchAssets({ name_contains: name })
|
||||
return byName.find((a) => a.name === name)
|
||||
}
|
||||
|
||||
export async function findServerPreviewUrl(
|
||||
name: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const asset = await findOutputAsset(name)
|
||||
if (!asset?.preview_id) return null
|
||||
|
||||
return resolvePreviewUrl(asset)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistThumbnail(
|
||||
name: string,
|
||||
blob: Blob
|
||||
): Promise<void> {
|
||||
try {
|
||||
const asset = await findOutputAsset(name)
|
||||
if (!asset || asset.preview_id) return
|
||||
|
||||
const previewFilename = `${asset.name}_preview.png`
|
||||
const uploaded = await assetService.uploadAssetFromBase64({
|
||||
data: await blobToDataUrl(blob),
|
||||
name: previewFilename,
|
||||
tags: ['output'],
|
||||
user_metadata: { filename: previewFilename }
|
||||
})
|
||||
|
||||
await assetService.updateAsset(asset.id, {
|
||||
preview_id: uploaded.id
|
||||
})
|
||||
} catch {
|
||||
// Non-critical — client still shows the rendered thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type OutputOverrides = Partial<{
|
||||
subfolder: string
|
||||
nodeId: string
|
||||
url: string
|
||||
display_name: string
|
||||
}>
|
||||
|
||||
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
|
||||
@@ -32,7 +33,8 @@ function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
|
||||
}
|
||||
return {
|
||||
...merged,
|
||||
previewUrl: merged.url
|
||||
previewUrl: merged.url,
|
||||
display_name: merged.display_name
|
||||
} as ResultItemImpl
|
||||
}
|
||||
|
||||
@@ -87,7 +89,7 @@ describe('resolveOutputAssetItems', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('loads full outputs when metadata indicates more outputs', async () => {
|
||||
it('loads full outputs when metadata indicates more outputs (newest first)', async () => {
|
||||
const previewOutput = createOutput({
|
||||
filename: 'preview.png',
|
||||
nodeId: '1',
|
||||
@@ -119,12 +121,108 @@ describe('resolveOutputAssetItems', () => {
|
||||
expect(mocks.getPreviewableOutputsFromJobDetail).toHaveBeenCalledWith(
|
||||
jobDetail
|
||||
)
|
||||
// Outputs are reversed so the most recent appears first
|
||||
expect(results.map((asset) => asset.name)).toEqual([
|
||||
'full.png',
|
||||
'preview.png'
|
||||
'preview.png',
|
||||
'full.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('reverses outputs and excludes the correct key simultaneously', async () => {
|
||||
const outputA = createOutput({
|
||||
filename: 'a.png',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/a.png'
|
||||
})
|
||||
const outputB = createOutput({
|
||||
filename: 'b.png',
|
||||
nodeId: '2',
|
||||
url: 'https://example.com/b.png'
|
||||
})
|
||||
const outputC = createOutput({
|
||||
filename: 'c.png',
|
||||
nodeId: '3',
|
||||
url: 'https://example.com/c.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
jobId: 'job-combo',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
outputCount: 3,
|
||||
allOutputs: [outputA, outputB, outputC]
|
||||
}
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata, {
|
||||
excludeOutputKey: '2-sub-b.png'
|
||||
})
|
||||
|
||||
// outputB excluded, remaining reversed: [C, A]
|
||||
expect(results.map((asset) => asset.name)).toEqual(['c.png', 'a.png'])
|
||||
})
|
||||
|
||||
it('returns empty array when all outputs are excluded', async () => {
|
||||
const output = createOutput({
|
||||
filename: 'only.png',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/only.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
jobId: 'job-empty',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
outputCount: 1,
|
||||
allOutputs: [output]
|
||||
}
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata, {
|
||||
excludeOutputKey: '1-sub-only.png'
|
||||
})
|
||||
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('propagates display_name from output to asset item', async () => {
|
||||
const output = createOutput({
|
||||
filename: 'abc123hash.png',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/abc123hash.png',
|
||||
display_name: 'ComfyUI_00001_.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
jobId: 'job-dn',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
outputCount: 1,
|
||||
allOutputs: [output]
|
||||
}
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].name).toBe('abc123hash.png')
|
||||
expect(results[0].display_name).toBe('ComfyUI_00001_.png')
|
||||
})
|
||||
|
||||
it('omits display_name when not present in output', async () => {
|
||||
const output = createOutput({
|
||||
filename: 'file.png',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/file.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
jobId: 'job-nodn',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
outputCount: 1,
|
||||
allOutputs: [output]
|
||||
}
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].display_name).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps root outputs with empty subfolders', async () => {
|
||||
const output = createOutput({
|
||||
filename: 'root.png',
|
||||
|
||||
@@ -69,6 +69,7 @@ function mapOutputsToAssetItems({
|
||||
items.push({
|
||||
id: `${jobId}-${outputKey}`,
|
||||
name: output.filename,
|
||||
display_name: output.display_name,
|
||||
size: 0,
|
||||
created_at: createdAtValue,
|
||||
tags: ['output'],
|
||||
@@ -100,9 +101,10 @@ export async function resolveOutputAssetItems(
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse so the most recent outputs appear first
|
||||
return mapOutputsToAssetItems({
|
||||
jobId: metadata.jobId,
|
||||
outputs: outputsToDisplay,
|
||||
outputs: outputsToDisplay.toReversed(),
|
||||
createdAt,
|
||||
executionTimeInSeconds: metadata.executionTimeInSeconds,
|
||||
workflow: metadata.workflow,
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<template>
|
||||
<div class="relative grid h-full grid-cols-5">
|
||||
<Button
|
||||
size="unset"
|
||||
variant="muted-textonly"
|
||||
class="absolute top-2.5 right-2.5 z-10 size-8 rounded-full p-0 text-white hover:bg-white/20"
|
||||
:aria-label="t('g.close')"
|
||||
@click="onDismiss"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="relative col-span-2 flex items-center justify-center overflow-hidden rounded-sm"
|
||||
>
|
||||
<video
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="-ml-[20%] h-full min-w-5/4 object-cover p-0"
|
||||
>
|
||||
<source
|
||||
src="/assets/images/cloud-subscription.webm"
|
||||
type="video/webm"
|
||||
/>
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 flex flex-col justify-between p-8">
|
||||
<div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-sm font-semibold text-text-primary">
|
||||
{{ t('cloudNotification.title') }}
|
||||
</div>
|
||||
<p class="m-0 text-sm text-text-secondary">
|
||||
{{ t('cloudNotification.message') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-col items-start gap-0 self-stretch">
|
||||
<div v-for="n in 4" :key="n" class="flex items-center gap-2 py-2">
|
||||
<i class="pi pi-check text-xs text-text-primary" />
|
||||
<span class="text-sm text-text-primary">
|
||||
{{ t(`cloudNotification.feature${n}Title`) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 pt-8">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="w-full font-bold"
|
||||
@click="onExplore"
|
||||
>
|
||||
{{ t('cloudNotification.exploreCloud') }}
|
||||
</Button>
|
||||
<Button variant="textonly" size="sm" class="w-full" @click="onDismiss">
|
||||
{{ t('cloudNotification.continueLocally') }}
|
||||
</Button>
|
||||
<p class="m-0 text-center text-xs text-text-secondary">
|
||||
{{ t('cloudNotification.footer') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
onMounted(() => {
|
||||
// Impression event — uses trackUiButtonClicked as no dedicated impression tracker exists
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'cloud_notification_modal_impression'
|
||||
})
|
||||
})
|
||||
|
||||
function onDismiss() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'cloud_notification_continue_locally_clicked'
|
||||
})
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
function onExplore() {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'cloud_notification_explore_cloud_clicked'
|
||||
})
|
||||
|
||||
const params = new URLSearchParams({
|
||||
utm_source: 'desktop',
|
||||
utm_medium: 'onload-modal',
|
||||
utm_campaign: 'local-to-cloud-conversion',
|
||||
utm_id: 'desktop-onload-modal',
|
||||
utm_source_platform: 'mac-desktop'
|
||||
})
|
||||
|
||||
window.open(
|
||||
`https://www.comfy.org/cloud?${params}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
</script>
|
||||
@@ -182,14 +182,14 @@ const onSuccess = async () => {
|
||||
|
||||
const signInWithGoogle = async () => {
|
||||
authError.value = ''
|
||||
if (await authActions.signInWithGoogle()) {
|
||||
if (await authActions.signInWithGoogle({ isNewUser: true })) {
|
||||
await onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithGithub = async () => {
|
||||
authError.value = ''
|
||||
if (await authActions.signInWithGithub()) {
|
||||
if (await authActions.signInWithGithub({ isNewUser: true })) {
|
||||
await onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { until } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
@@ -20,7 +19,7 @@ const router = useRouter()
|
||||
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const { isActiveSubscription, isInitialized } = useBillingContext()
|
||||
const { isActiveSubscription, isInitialized, initialize } = useBillingContext()
|
||||
|
||||
const selectedTierKey = ref<TierKey | null>(null)
|
||||
|
||||
@@ -76,7 +75,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
}
|
||||
|
||||
if (!isInitialized.value) {
|
||||
await until(isInitialized).toBe(true)
|
||||
await initialize()
|
||||
}
|
||||
|
||||
if (isActiveSubscription.value) {
|
||||
|
||||
@@ -8,9 +8,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
||||
|
||||
import CloudTemplate from './CloudTemplate.vue'
|
||||
|
||||
onMounted(() => {
|
||||
document.getElementById('splash-loader')?.remove()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -31,7 +31,19 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
path: 'signup',
|
||||
name: 'cloud-signup',
|
||||
component: () =>
|
||||
import('@/platform/cloud/onboarding/CloudSignupView.vue')
|
||||
import('@/platform/cloud/onboarding/CloudSignupView.vue'),
|
||||
beforeEnter: async (to, _from, next) => {
|
||||
if (!to.query.switchAccount) {
|
||||
const { useCurrentUser } =
|
||||
await import('@/composables/auth/useCurrentUser')
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
return next({ name: 'cloud-user-check' })
|
||||
}
|
||||
}
|
||||
next()
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'forgot-password',
|
||||
|
||||
@@ -107,6 +107,8 @@ const i18n = createI18n({
|
||||
videoEstimateHelp: 'How is this calculated?',
|
||||
videoEstimateExplanation: 'Based on average usage.',
|
||||
videoEstimateTryTemplate: 'Try template',
|
||||
soloUseOnly: 'Solo use only',
|
||||
needTeamWorkspace: 'Need team workspace?',
|
||||
maxDuration: {
|
||||
standard: '30 min',
|
||||
creator: '30 min',
|
||||
@@ -296,4 +298,20 @@ describe('PricingTable', () => {
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalledWith('standard-yearly')
|
||||
})
|
||||
})
|
||||
|
||||
describe('team workspace link', () => {
|
||||
it('should emit chooseTeamWorkspace when clicking "Need team workspace?" link', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const teamLink = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text().includes('Need team workspace?'))
|
||||
|
||||
expect(teamLink).toBeDefined()
|
||||
await teamLink?.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('chooseTeamWorkspace')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex justify-center">
|
||||
<SelectButton
|
||||
v-model="currentBillingCycle"
|
||||
@@ -38,7 +38,7 @@
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<div class="flex flex-col items-stretch gap-6 xl:flex-row">
|
||||
<div class="flex flex-col items-stretch gap-4 xl:flex-row">
|
||||
<div
|
||||
v-for="tier in tiers"
|
||||
:key="tier.id"
|
||||
@@ -49,7 +49,7 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex flex-col gap-8 p-8 pb-0">
|
||||
<div class="flex flex-col gap-4 p-6 pb-0">
|
||||
<div class="flex flex-row items-center justify-between gap-2">
|
||||
<span
|
||||
class="font-inter text-base/normal font-bold text-base-foreground"
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row items-baseline gap-2">
|
||||
<span
|
||||
class="font-inter text-[32px] leading-normal font-semibold text-base-foreground"
|
||||
class="font-inter text-[28px] leading-normal font-semibold text-base-foreground"
|
||||
>
|
||||
<span
|
||||
v-show="currentBillingCycle === 'yearly'"
|
||||
@@ -95,7 +95,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-4 pb-0">
|
||||
<p
|
||||
role="note"
|
||||
:aria-label="t('subscription.soloUseOnly')"
|
||||
class="m-0 flex h-10 items-center rounded-lg bg-muted-foreground/30 px-3 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('subscription.soloUseOnly') }}
|
||||
<span class="mx-1 text-muted-foreground">–</span>
|
||||
<button
|
||||
class="text-primary-foreground cursor-pointer border-none bg-transparent p-0 text-sm font-medium underline hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
|
||||
@click="emit('chooseTeamWorkspace')"
|
||||
>
|
||||
{{ t('subscription.needTeamWorkspace') }}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-3 pb-0">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span
|
||||
class="text-foreground font-inter text-sm/normal font-normal"
|
||||
@@ -179,7 +194,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col p-8">
|
||||
<div class="flex flex-col p-6">
|
||||
<Button
|
||||
:variant="getButtonSeverity(tier)"
|
||||
:disabled="isLoading || isCurrentPlan(tier.key)"
|
||||
@@ -303,6 +318,10 @@ interface PricingTierConfig {
|
||||
isPopular?: boolean
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
chooseTeamWorkspace: []
|
||||
}>()
|
||||
|
||||
const { t, n } = useI18n()
|
||||
|
||||
const billingCycleOptions: BillingCycleOption[] = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="showCustomPricingTable"
|
||||
class="relative flex h-full flex-col gap-8 overflow-y-auto! p-4 pt-8 md:p-16"
|
||||
class="relative flex h-full flex-col gap-6 overflow-y-auto p-4 pt-8 md:px-16 md:py-8"
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -10,15 +10,30 @@
|
||||
:aria-label="$t('g.close')"
|
||||
@click="handleClose"
|
||||
>
|
||||
<i class="pi pi-times text-xl" />
|
||||
<i class="pi pi-times text-xl" aria-hidden="true" />
|
||||
</Button>
|
||||
<div class="text-center">
|
||||
<h2 class="m-0 text-xl text-muted-foreground lg:text-2xl">
|
||||
{{ $t('subscription.description') }}
|
||||
</h2>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div
|
||||
class="flex size-10 items-center justify-center rounded-xl bg-muted-foreground/30 text-lg font-semibold text-white"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- Decorative initial for "Personal" workspace icon; not user-facing text -->
|
||||
P
|
||||
</div>
|
||||
<i18n-t
|
||||
keypath="subscription.plansForWorkspace"
|
||||
tag="h2"
|
||||
class="m-0 font-inter text-2xl font-semibold text-base-foreground"
|
||||
>
|
||||
<template #workspace>
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t('subscription.personalWorkspace') }}
|
||||
</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<PricingTable class="flex-1" />
|
||||
<PricingTable class="flex-1" @choose-team-workspace="handleChooseTeam" />
|
||||
|
||||
<!-- Contact and Enterprise Links -->
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
@@ -55,7 +70,7 @@
|
||||
:aria-label="$t('g.close')"
|
||||
@click="handleClose"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
<i class="pi pi-times" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
@@ -144,9 +159,10 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
|
||||
const { onClose, reason } = defineProps<{
|
||||
const { onClose, reason, onChooseTeam } = defineProps<{
|
||||
onClose: () => void
|
||||
reason?: SubscriptionDialogReason
|
||||
onChooseTeam?: () => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -236,6 +252,7 @@ watch(
|
||||
() => isActiveSubscription.value,
|
||||
(isActive) => {
|
||||
if (isActive && showCustomPricingTable.value) {
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
emit('close', true)
|
||||
}
|
||||
}
|
||||
@@ -245,6 +262,15 @@ const handleSubscribed = () => {
|
||||
emit('close', true)
|
||||
}
|
||||
|
||||
const handleChooseTeam = () => {
|
||||
stopPolling()
|
||||
if (onChooseTeam) {
|
||||
onChooseTeam()
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
stopPolling()
|
||||
onClose()
|
||||
|
||||
@@ -1,77 +1,196 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSubscriptionDialog } from './useSubscriptionDialog'
|
||||
|
||||
const mockCloseDialog = vi.fn()
|
||||
const mockShowLayoutDialog = vi.fn()
|
||||
const mockShowTeamWorkspacesDialog = vi.fn()
|
||||
const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
|
||||
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
|
||||
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
|
||||
vi.mock('vue', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
defineAsyncComponent: vi.fn((loader) => loader)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: mockCloseDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showLayoutDialog: mockShowLayoutDialog,
|
||||
showTeamWorkspacesDialog: mockShowTeamWorkspacesDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isFreeTier: mockIsFreeTier
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
const mockShowLayoutDialog = vi.fn()
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => ({
|
||||
showLayoutDialog: mockShowLayoutDialog
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: vi.fn(() => ({
|
||||
closeDialog: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: vi.fn(() => ({
|
||||
flags: { teamWorkspacesEnabled: false }
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: vi.fn(() => ({
|
||||
isFreeTier: { value: false }
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: vi.fn(() => ({
|
||||
isInPersonalWorkspace: true
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('pinia')
|
||||
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get isInPersonalWorkspace() {
|
||||
return mockIsInPersonalWorkspace.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useSubscriptionDialog', () => {
|
||||
it('showPricingTable does not open dialog on non-cloud', async () => {
|
||||
mockIsCloud.value = false
|
||||
const { useSubscriptionDialog } = await import('./useSubscriptionDialog')
|
||||
const dialog = useSubscriptionDialog()
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
|
||||
dialog.showPricingTable()
|
||||
|
||||
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
|
||||
try {
|
||||
sessionStorage.clear()
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
})
|
||||
|
||||
it('showPricingTable opens dialog on cloud', async () => {
|
||||
mockIsCloud.value = true
|
||||
const { useSubscriptionDialog } = await import('./useSubscriptionDialog')
|
||||
const dialog = useSubscriptionDialog()
|
||||
describe('showPricingTable', () => {
|
||||
it('does not open dialog on non-cloud', () => {
|
||||
mockIsCloud.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
dialog.showPricingTable()
|
||||
showPricingTable()
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalled()
|
||||
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens dialog on cloud', () => {
|
||||
mockIsCloud.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable()
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('startTeamWorkspaceUpgradeFlow', () => {
|
||||
it('closes existing dialogs before opening team workspace dialog', () => {
|
||||
mockShowTeamWorkspacesDialog.mockResolvedValue(undefined)
|
||||
const { startTeamWorkspaceUpgradeFlow } = useSubscriptionDialog()
|
||||
|
||||
startTeamWorkspaceUpgradeFlow()
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'subscription-required'
|
||||
})
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'free-tier-info'
|
||||
})
|
||||
expect(mockShowTeamWorkspacesDialog).toHaveBeenCalledWith(
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('persists resume intent to sessionStorage via onConfirm callback', () => {
|
||||
mockShowTeamWorkspacesDialog.mockResolvedValue(undefined)
|
||||
const { startTeamWorkspaceUpgradeFlow } = useSubscriptionDialog()
|
||||
|
||||
startTeamWorkspaceUpgradeFlow()
|
||||
|
||||
const onConfirm = mockShowTeamWorkspacesDialog.mock.calls[0][0]
|
||||
onConfirm()
|
||||
|
||||
expect(sessionStorage.getItem('comfy:resume-team-pricing')).toBe('1')
|
||||
})
|
||||
|
||||
it('reopens pricing table on dialog rejection', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockShowTeamWorkspacesDialog.mockRejectedValue(new Error('dialog error'))
|
||||
|
||||
const { startTeamWorkspaceUpgradeFlow } = useSubscriptionDialog()
|
||||
startTeamWorkspaceUpgradeFlow()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[useSubscriptionDialog] Failed to open team workspaces dialog:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'subscription-required' })
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resumePendingPricingFlow', () => {
|
||||
it('does nothing when no resume intent is stored', () => {
|
||||
const { resumePendingPricingFlow } = useSubscriptionDialog()
|
||||
|
||||
resumePendingPricingFlow()
|
||||
|
||||
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows pricing table and clears intent when in team workspace', () => {
|
||||
sessionStorage.setItem('comfy:resume-team-pricing', '1')
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
|
||||
const { resumePendingPricingFlow } = useSubscriptionDialog()
|
||||
resumePendingPricingFlow()
|
||||
|
||||
expect(sessionStorage.getItem('comfy:resume-team-pricing')).toBeNull()
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'subscription-required' })
|
||||
)
|
||||
})
|
||||
|
||||
it('clears intent but does not show pricing if still in personal workspace', () => {
|
||||
sessionStorage.setItem('comfy:resume-team-pricing', '1')
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
|
||||
const { resumePendingPricingFlow } = useSubscriptionDialog()
|
||||
resumePendingPricingFlow()
|
||||
|
||||
expect(sessionStorage.getItem('comfy:resume-team-pricing')).toBeNull()
|
||||
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('consumes intent so second call is a no-op', () => {
|
||||
sessionStorage.setItem('comfy:resume-team-pricing', '1')
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
|
||||
const { resumePendingPricingFlow } = useSubscriptionDialog()
|
||||
resumePendingPricingFlow()
|
||||
mockShowLayoutDialog.mockClear()
|
||||
|
||||
resumePendingPricingFlow()
|
||||
expect(mockShowLayoutDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspace
|
||||
|
||||
const DIALOG_KEY = 'subscription-required'
|
||||
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
|
||||
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
|
||||
|
||||
export type SubscriptionDialogReason =
|
||||
| 'subscription_required'
|
||||
@@ -42,13 +43,20 @@ export const useSubscriptionDialog = () => {
|
||||
import('@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue')
|
||||
)
|
||||
|
||||
const personalProps = {
|
||||
onClose: hide,
|
||||
reason: options?.reason,
|
||||
onChooseTeam: () => startTeamWorkspaceUpgradeFlow()
|
||||
}
|
||||
const workspaceProps = {
|
||||
onClose: hide,
|
||||
reason: options?.reason
|
||||
}
|
||||
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component,
|
||||
props: {
|
||||
onClose: hide,
|
||||
reason: options?.reason
|
||||
},
|
||||
props: useWorkspaceVariant ? workspaceProps : personalProps,
|
||||
dialogComponentProps: {
|
||||
style: 'width: min(1328px, 95vw); max-height: 958px;',
|
||||
pt: {
|
||||
@@ -101,9 +109,58 @@ export const useSubscriptionDialog = () => {
|
||||
showPricingTable(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the two-stage team workspace upgrade flow:
|
||||
* 1. Close the current pricing dialog
|
||||
* 2. Open the create workspace dialog
|
||||
* 3. On successful creation, persist a resume intent so the team pricing
|
||||
* dialog reopens automatically after the page reload
|
||||
*
|
||||
* Uses sessionStorage (not a store) because the intent must survive
|
||||
* a full page reload triggered by workspace switching.
|
||||
*/
|
||||
function startTeamWorkspaceUpgradeFlow() {
|
||||
hide()
|
||||
dialogService
|
||||
.showTeamWorkspacesDialog(() => {
|
||||
try {
|
||||
sessionStorage.setItem(RESUME_PRICING_KEY, '1')
|
||||
} catch {
|
||||
// sessionStorage may be unavailable
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'[useSubscriptionDialog] Failed to open team workspaces dialog:',
|
||||
error
|
||||
)
|
||||
showPricingTable()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for and consume a pending team pricing resume intent.
|
||||
* Call once after workspace initialization on app boot.
|
||||
*/
|
||||
function resumePendingPricingFlow() {
|
||||
try {
|
||||
const pending = sessionStorage.getItem(RESUME_PRICING_KEY)
|
||||
if (!pending) return
|
||||
sessionStorage.removeItem(RESUME_PRICING_KEY)
|
||||
|
||||
if (!workspaceStore.isInPersonalWorkspace) {
|
||||
showPricingTable()
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
showPricingTable,
|
||||
hide
|
||||
hide,
|
||||
startTeamWorkspaceUpgradeFlow,
|
||||
resumePendingPricingFlow
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const PRESERVED_QUERY_NAMESPACES = {
|
||||
TEMPLATE: 'template',
|
||||
INVITE: 'invite',
|
||||
SHARE: 'share'
|
||||
SHARE: 'share',
|
||||
CREATE_WORKSPACE: 'create_workspace'
|
||||
} as const
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PostHogConfig } from 'posthog-js'
|
||||
|
||||
import type { TelemetryEventName } from '@/platform/telemetry/types'
|
||||
|
||||
/**
|
||||
@@ -31,6 +33,7 @@ export type RemoteConfig = {
|
||||
mixpanel_token?: string
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_config?: Partial<PostHogConfig>
|
||||
subscription_required?: boolean
|
||||
server_health_alert?: ServerHealthAlert
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -299,12 +299,6 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'boolean',
|
||||
defaultValue: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Desktop.CloudNotificationShown',
|
||||
name: 'Cloud notification shown',
|
||||
type: 'hidden',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.ZoomSpeed',
|
||||
category: ['LiteGraph', 'Canvas', 'ZoomSpeed'],
|
||||
@@ -576,10 +570,11 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
category: ['Appearance', 'General'],
|
||||
name: 'Tab Bar Layout',
|
||||
type: 'combo',
|
||||
options: ['Default', 'Integrated'],
|
||||
tooltip:
|
||||
'Controls the layout of the tab bar. "Integrated" moves Help and User controls into the tab bar area.',
|
||||
defaultValue: 'Default'
|
||||
options: ['Default', 'Legacy'],
|
||||
tooltip: 'Controls the elements contained in the integrated tab bar.',
|
||||
defaultValue: 'Default',
|
||||
migrateDeprecatedValue: (value: unknown) =>
|
||||
value === 'Integrated' ? 'Default' : value
|
||||
},
|
||||
{
|
||||
id: 'Comfy.UseNewMenu',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
/**
|
||||
* Zendesk ticket form field IDs.
|
||||
*/
|
||||
export const ZENDESK_FIELDS = {
|
||||
const ZENDESK_FIELDS = {
|
||||
/** Distribution tag (cloud vs OSS) */
|
||||
DISTRIBUTION: 'tf_42243568391700',
|
||||
/** User email (anonymous requester) */
|
||||
@@ -18,13 +18,25 @@ export const ZENDESK_FIELDS = {
|
||||
* Gets the distribution identifier for Zendesk tracking.
|
||||
* Helps distinguish feedback from different build types.
|
||||
*/
|
||||
export function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
|
||||
function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
|
||||
if (isCloud) return 'ccloud'
|
||||
if (isNightly) return 'oss-nightly'
|
||||
return 'oss'
|
||||
}
|
||||
|
||||
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
|
||||
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
|
||||
|
||||
/**
|
||||
* Builds the feedback form URL with the appropriate distribution tag.
|
||||
*/
|
||||
export function buildFeedbackUrl(): string {
|
||||
const params = new URLSearchParams({
|
||||
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
|
||||
[ZENDESK_FIELDS.DISTRIBUTION]: getDistribution()
|
||||
})
|
||||
return `${SUPPORT_BASE_URL}?${params.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the support URL with optional user information for pre-filling.
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { AuditLog } from '@/services/customerEventsService'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ExecutionTriggerSource,
|
||||
@@ -26,7 +28,8 @@ import type {
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
@@ -156,10 +159,22 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackWorkflowOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackWorkflowSaved?.(metadata))
|
||||
}
|
||||
|
||||
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
|
||||
this.dispatch((provider) => provider.trackDefaultViewSet?.(metadata))
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.dispatch((provider) => provider.trackEnterLinear?.(metadata))
|
||||
}
|
||||
|
||||
trackShareFlow(metadata: ShareFlowMetadata): void {
|
||||
this.dispatch((provider) => provider.trackShareFlow?.(metadata))
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.dispatch((provider) => provider.trackPageVisibilityChanged?.(metadata))
|
||||
}
|
||||
|
||||
@@ -66,4 +66,64 @@ describe('GtmTelemetryProvider', () => {
|
||||
|
||||
expect(gtagScripts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('pushes subscription_success for subscription activation', () => {
|
||||
window.__CONFIG__ = {
|
||||
gtm_container_id: 'GTM-TEST123'
|
||||
}
|
||||
|
||||
const provider = new GtmTelemetryProvider()
|
||||
provider.trackMonthlySubscriptionSucceeded()
|
||||
|
||||
const lastEntry = window.dataLayer?.[window.dataLayer.length - 1]
|
||||
expect(lastEntry).toMatchObject({
|
||||
event: 'subscription_success'
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes normalized email as user_data before auth event', () => {
|
||||
window.__CONFIG__ = {
|
||||
gtm_container_id: 'GTM-TEST123'
|
||||
}
|
||||
|
||||
const provider = new GtmTelemetryProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'email',
|
||||
is_new_user: true,
|
||||
user_id: 'uid-123',
|
||||
email: ' Test@Example.com '
|
||||
})
|
||||
|
||||
const dl = window.dataLayer as Record<string, unknown>[]
|
||||
const userData = dl.find((entry) => 'user_data' in entry)
|
||||
expect(userData).toMatchObject({
|
||||
user_data: { email: 'test@example.com' }
|
||||
})
|
||||
|
||||
// Verify user_data is pushed before the sign_up event
|
||||
const userDataIndex = dl.findIndex((entry) => 'user_data' in entry)
|
||||
const signUpIndex = dl.findIndex(
|
||||
(entry) => (entry as Record<string, unknown>).event === 'sign_up'
|
||||
)
|
||||
expect(userDataIndex).toBeLessThan(signUpIndex)
|
||||
})
|
||||
|
||||
it('does not push user_data when email is absent', () => {
|
||||
window.__CONFIG__ = {
|
||||
gtm_container_id: 'GTM-TEST123'
|
||||
}
|
||||
|
||||
const provider = new GtmTelemetryProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: false,
|
||||
user_id: 'uid-456'
|
||||
})
|
||||
|
||||
const dl = window.dataLayer as Record<string, unknown>[]
|
||||
const userData = dl.find((entry) => 'user_data' in entry)
|
||||
expect(userData).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,6 +103,12 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
...(metadata.user_id ? { user_id: metadata.user_id } : {})
|
||||
}
|
||||
|
||||
if (metadata.email) {
|
||||
window.dataLayer?.push({
|
||||
user_data: { email: metadata.email.trim().toLowerCase() }
|
||||
})
|
||||
}
|
||||
|
||||
if (metadata.is_new_user) {
|
||||
this.pushEvent('sign_up', basePayload)
|
||||
return
|
||||
@@ -114,4 +120,8 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
|
||||
this.pushEvent('begin_checkout', metadata)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.pushEvent('subscription_success')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import {
|
||||
checkForCompletedTopup as checkTopupUtil,
|
||||
@@ -14,7 +15,9 @@ import { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
CreditTopupMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
ExecutionTriggerSource,
|
||||
ExecutionErrorMetadata,
|
||||
@@ -39,7 +42,8 @@ import type {
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from '../../types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
@@ -275,6 +279,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
const executionContext = getExecutionContext()
|
||||
const { mode, isAppMode } = useAppMode()
|
||||
|
||||
const runButtonProperties: RunButtonProperties = {
|
||||
subscribe_to_run: options?.subscribe_to_run || false,
|
||||
@@ -287,7 +292,9 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
api_node_names: executionContext.api_node_names,
|
||||
has_toolkit_nodes: executionContext.has_toolkit_nodes,
|
||||
toolkit_node_names: executionContext.toolkit_node_names,
|
||||
trigger_source: options?.trigger_source
|
||||
trigger_source: options?.trigger_source,
|
||||
view_mode: mode.value,
|
||||
is_app_mode: isAppMode.value
|
||||
}
|
||||
|
||||
this.lastTriggerSource = options?.trigger_source
|
||||
@@ -358,10 +365,22 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
|
||||
}
|
||||
|
||||
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
|
||||
}
|
||||
|
||||
trackShareFlow(metadata: ShareFlowMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -40,12 +40,22 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockRemoteConfig = vi.hoisted(
|
||||
() => ({ value: null }) as { value: Record<string, unknown> | null }
|
||||
)
|
||||
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: { value: null }
|
||||
remoteConfig: mockRemoteConfig
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => hoisted.mockPosthog)
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
subscriptionTier: { value: null }
|
||||
})
|
||||
}))
|
||||
|
||||
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
|
||||
|
||||
function createProvider(
|
||||
@@ -61,6 +71,7 @@ function createProvider(
|
||||
describe('PostHogTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRemoteConfig.value = null
|
||||
window.__CONFIG__ = {
|
||||
posthog_project_token: 'phc_test_token'
|
||||
} as typeof window.__CONFIG__
|
||||
@@ -76,30 +87,39 @@ describe('PostHogTelemetryProvider', () => {
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls posthog.init with the token and default api_host', async () => {
|
||||
it('calls posthog.init with the token and default config', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith('phc_test_token', {
|
||||
api_host: 'https://t.comfy.org',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses custom api_host from config when provided', async () => {
|
||||
window.__CONFIG__ = {
|
||||
posthog_project_token: 'phc_test_token',
|
||||
posthog_api_host: 'https://custom.host.com'
|
||||
} as typeof window.__CONFIG__
|
||||
new PostHogTelemetryProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({ api_host: 'https://custom.host.com' })
|
||||
expect.objectContaining({
|
||||
api_host: 'https://t.comfy.org',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('applies posthog_config overrides from remote config', async () => {
|
||||
mockRemoteConfig.value = {
|
||||
posthog_config: {
|
||||
debug: true,
|
||||
api_host: 'https://custom.host.com'
|
||||
}
|
||||
}
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({
|
||||
debug: true,
|
||||
api_host: 'https://custom.host.com'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
@@ -33,7 +37,8 @@ import type {
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
} from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
@@ -98,13 +103,17 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
void import('posthog-js')
|
||||
.then((posthogModule) => {
|
||||
this.posthog = posthogModule.default
|
||||
const serverConfig = remoteConfig.value?.posthog_config ?? {}
|
||||
this.posthog!.init(apiKey, {
|
||||
api_host:
|
||||
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie'
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
|
||||
...serverConfig
|
||||
})
|
||||
this.isInitialized = true
|
||||
this.flushEventQueue()
|
||||
@@ -112,6 +121,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
useCurrentUser().onUserResolved((user) => {
|
||||
if (this.posthog && user.id) {
|
||||
this.posthog.identify(user.id)
|
||||
this.setSubscriptionProperties()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -204,6 +214,19 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
)
|
||||
}
|
||||
|
||||
private setSubscriptionProperties(): void {
|
||||
const { subscriptionTier } = useSubscription()
|
||||
watch(
|
||||
subscriptionTier,
|
||||
(tier) => {
|
||||
if (tier && this.posthog) {
|
||||
this.posthog.people.set({ subscription_tier: tier })
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
|
||||
trackSignupOpened(): void {
|
||||
this.trackEvent(TelemetryEvents.USER_SIGN_UP_OPENED)
|
||||
}
|
||||
@@ -255,6 +278,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
const executionContext = getExecutionContext()
|
||||
const { mode, isAppMode } = useAppMode()
|
||||
|
||||
const runButtonProperties: RunButtonProperties = {
|
||||
subscribe_to_run: options?.subscribe_to_run || false,
|
||||
@@ -267,7 +291,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
api_node_names: executionContext.api_node_names,
|
||||
has_toolkit_nodes: executionContext.has_toolkit_nodes,
|
||||
toolkit_node_names: executionContext.toolkit_node_names,
|
||||
trigger_source: options?.trigger_source
|
||||
trigger_source: options?.trigger_source,
|
||||
view_mode: mode.value,
|
||||
is_app_mode: isAppMode.value
|
||||
}
|
||||
|
||||
this.lastTriggerSource = options?.trigger_source
|
||||
@@ -342,10 +368,22 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
|
||||
}
|
||||
|
||||
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
|
||||
}
|
||||
|
||||
trackShareFlow(metadata: ShareFlowMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface AuthMetadata {
|
||||
method?: 'email' | 'google' | 'github'
|
||||
is_new_user?: boolean
|
||||
user_id?: string
|
||||
email?: string
|
||||
referrer_url?: string
|
||||
utm_source?: string
|
||||
utm_medium?: string
|
||||
@@ -63,6 +64,8 @@ export interface RunButtonProperties {
|
||||
has_toolkit_nodes: boolean
|
||||
toolkit_node_names: string[]
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
view_mode?: string
|
||||
is_app_mode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,13 +140,38 @@ export interface WorkflowImportMetadata {
|
||||
/**
|
||||
* The source of the workflow open/import action
|
||||
*/
|
||||
open_source?: 'file_button' | 'file_drop' | 'template' | 'unknown'
|
||||
open_source?:
|
||||
| 'file_button'
|
||||
| 'file_drop'
|
||||
| 'template'
|
||||
| 'shared_url'
|
||||
| 'unknown'
|
||||
}
|
||||
|
||||
export interface EnterLinearMetadata {
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface WorkflowSavedMetadata {
|
||||
is_app: boolean
|
||||
is_new: boolean
|
||||
}
|
||||
|
||||
export interface DefaultViewSetMetadata {
|
||||
default_view: 'app' | 'graph'
|
||||
}
|
||||
|
||||
type ShareFlowStep =
|
||||
| 'dialog_opened'
|
||||
| 'save_prompted'
|
||||
| 'link_created'
|
||||
| 'link_copied'
|
||||
|
||||
export interface ShareFlowMetadata {
|
||||
step: ShareFlowStep
|
||||
source?: 'app_mode' | 'graph_mode'
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow open metadata
|
||||
*/
|
||||
@@ -361,7 +389,10 @@ export interface TelemetryProvider {
|
||||
// Workflow management events
|
||||
trackWorkflowImported?(metadata: WorkflowImportMetadata): void
|
||||
trackWorkflowOpened?(metadata: WorkflowImportMetadata): void
|
||||
trackWorkflowSaved?(metadata: WorkflowSavedMetadata): void
|
||||
trackDefaultViewSet?(metadata: DefaultViewSetMetadata): void
|
||||
trackEnterLinear?(metadata: EnterLinearMetadata): void
|
||||
trackShareFlow?(metadata: ShareFlowMetadata): void
|
||||
|
||||
// Page visibility events
|
||||
trackPageVisibilityChanged?(metadata: PageVisibilityMetadata): void
|
||||
@@ -447,7 +478,8 @@ export const TelemetryEvents = {
|
||||
// Workflow Management
|
||||
WORKFLOW_IMPORTED: 'app:workflow_imported',
|
||||
WORKFLOW_OPENED: 'app:workflow_opened',
|
||||
ENTER_LINEAR_MODE: 'app:toggle_linear_mode',
|
||||
ENTER_LINEAR_MODE: 'app:app_mode_opened',
|
||||
SHARE_FLOW: 'app:share_flow',
|
||||
|
||||
// Page Visibility
|
||||
PAGE_VISIBILITY_CHANGED: 'app:page_visibility_changed',
|
||||
@@ -472,6 +504,8 @@ export const TelemetryEvents = {
|
||||
|
||||
// Workflow Creation
|
||||
WORKFLOW_CREATED: 'app:workflow_created',
|
||||
WORKFLOW_SAVED: 'app:workflow_saved',
|
||||
DEFAULT_VIEW_SET: 'app:default_view_set',
|
||||
|
||||
// Execution Lifecycle
|
||||
EXECUTION_START: 'execution_start',
|
||||
@@ -521,4 +555,7 @@ export type TelemetryEventProperties =
|
||||
| HelpCenterClosedMetadata
|
||||
| WorkflowCreatedMetadata
|
||||
| EnterLinearMetadata
|
||||
| ShareFlowMetadata
|
||||
| WorkflowSavedMetadata
|
||||
| DefaultViewSetMetadata
|
||||
| SubscriptionMetadata
|
||||
|
||||
@@ -149,6 +149,8 @@ export const useWorkflowService = () => {
|
||||
await openWorkflow(tempWorkflow)
|
||||
await workflowStore.saveWorkflow(tempWorkflow)
|
||||
}
|
||||
|
||||
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: true })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -189,6 +191,7 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,17 +26,24 @@ import { refAutoReset } from '@vueuse/core'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
const { url } = defineProps<{
|
||||
url: string
|
||||
}>()
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { isAppMode } = useAppMode()
|
||||
const copied = refAutoReset(false, 2000)
|
||||
|
||||
async function handleCopy() {
|
||||
await copyToClipboard(url)
|
||||
copied.value = true
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'link_copied',
|
||||
source: isAppMode.value ? 'app_mode' : 'graph_mode'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -133,9 +133,45 @@
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-publish"
|
||||
data-testid="publish-tab-panel"
|
||||
class="min-h-0"
|
||||
class="flex min-h-0 flex-col gap-4"
|
||||
>
|
||||
<template v-if="dialogState === 'loading'">
|
||||
<Skeleton class="h-3 w-4/5" />
|
||||
<Skeleton class="h-3 w-3/5" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="dialogState === 'unsaved'">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('comfyHubPublish.unsavedDescription') }}
|
||||
</p>
|
||||
<label v-if="isTemporary" class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted-foreground">
|
||||
{{ $t('shareWorkflow.workflowNameLabel') }}
|
||||
</span>
|
||||
<Input
|
||||
ref="publishNameInputRef"
|
||||
v-model="workflowName"
|
||||
:disabled="isSaving"
|
||||
@keydown.enter="() => handleSave()"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading="isSaving"
|
||||
@click="() => handleSave()"
|
||||
>
|
||||
{{
|
||||
isSaving
|
||||
? $t('shareWorkflow.saving')
|
||||
: $t('shareWorkflow.saveButton')
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<ComfyHubPublishIntroPanel
|
||||
v-else
|
||||
data-testid="publish-intro"
|
||||
:on-create-profile="handleOpenPublishDialog"
|
||||
:on-close="onClose"
|
||||
@@ -167,7 +203,9 @@ import type {
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -182,6 +220,11 @@ const publishDialog = useComfyHubPublishDialog()
|
||||
const shareService = useWorkflowShareService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const { isAppMode } = useAppMode()
|
||||
|
||||
function getShareSource() {
|
||||
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
|
||||
}
|
||||
|
||||
type DialogState = 'loading' | 'unsaved' | 'ready' | 'shared' | 'stale'
|
||||
type DialogMode = 'shareLink' | 'publishToHub'
|
||||
@@ -208,10 +251,15 @@ const dialogMode = ref<DialogMode>('shareLink')
|
||||
const acknowledged = ref(false)
|
||||
const workflowName = ref('')
|
||||
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
|
||||
const publishNameInputRef = ref<InstanceType<typeof Input> | null>(null)
|
||||
|
||||
function focusNameInput() {
|
||||
nameInputRef.value?.focus()
|
||||
nameInputRef.value?.select()
|
||||
function focusActiveNameInput() {
|
||||
const input =
|
||||
dialogMode.value === 'publishToHub'
|
||||
? publishNameInputRef.value
|
||||
: nameInputRef.value
|
||||
input?.focus()
|
||||
input?.select()
|
||||
}
|
||||
|
||||
const isTemporary = computed(
|
||||
@@ -221,7 +269,7 @@ const isTemporary = computed(
|
||||
watch(dialogState, async (state) => {
|
||||
if (state === 'unsaved' && isTemporary.value) {
|
||||
await nextTick()
|
||||
focusNameInput()
|
||||
focusActiveNameInput()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -248,10 +296,14 @@ function tabButtonClass(mode: DialogMode) {
|
||||
)
|
||||
}
|
||||
|
||||
function handleDialogModeChange(nextMode: DialogMode) {
|
||||
async function handleDialogModeChange(nextMode: DialogMode) {
|
||||
if (nextMode === dialogMode.value) return
|
||||
if (nextMode === 'publishToHub' && !showPublishToHubTab.value) return
|
||||
dialogMode.value = nextMode
|
||||
if (dialogState.value === 'unsaved' && isTemporary.value) {
|
||||
await nextTick()
|
||||
focusActiveNameInput()
|
||||
}
|
||||
}
|
||||
|
||||
watch(showPublishToHubTab, (isVisible) => {
|
||||
@@ -298,6 +350,10 @@ async function refreshDialogState() {
|
||||
|
||||
if (!workflow || workflow.isTemporary || workflow.isModified) {
|
||||
dialogState.value = 'unsaved'
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'save_prompted',
|
||||
source: getShareSource()
|
||||
})
|
||||
if (workflow) {
|
||||
workflowName.value = stripJsonExtension(workflow.filename)
|
||||
}
|
||||
@@ -379,6 +435,10 @@ const {
|
||||
)
|
||||
dialogState.value = 'shared'
|
||||
acknowledged.value = false
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'link_created',
|
||||
source: getShareSource()
|
||||
})
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
@@ -75,8 +75,23 @@
|
||||
>
|
||||
@
|
||||
</span>
|
||||
<Input id="profile-username" v-model="username" class="pl-7" />
|
||||
<Input
|
||||
id="profile-username"
|
||||
v-model="username"
|
||||
class="pl-7"
|
||||
:aria-invalid="showUsernameError ? 'true' : 'false'"
|
||||
:aria-describedby="
|
||||
showUsernameError ? 'profile-username-error' : undefined
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="showUsernameError"
|
||||
id="profile-username-error"
|
||||
class="text-xs text-destructive-background"
|
||||
>
|
||||
{{ $t('comfyHubProfile.usernameError') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -105,7 +120,7 @@
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="!username.trim() || isCreating"
|
||||
:disabled="!isUsernameValid || isCreating"
|
||||
@click="handleCreate"
|
||||
>
|
||||
{{
|
||||
@@ -156,6 +171,16 @@ const profilePictureFile = ref<File | null>(null)
|
||||
const profilePreviewUrl = useObjectUrl(profilePictureFile)
|
||||
const isCreating = ref(false)
|
||||
|
||||
const VALID_USERNAME_PATTERN = /^[a-z0-9][a-z0-9-]{1,40}[a-z0-9]$/
|
||||
|
||||
const isUsernameValid = computed(() =>
|
||||
VALID_USERNAME_PATTERN.test(username.value)
|
||||
)
|
||||
|
||||
const showUsernameError = computed(
|
||||
() => username.value.length > 0 && !isUsernameValid.value
|
||||
)
|
||||
|
||||
const profileInitial = computed(() => {
|
||||
const source = name.value.trim() || username.value.trim()
|
||||
return source ? source[0].toUpperCase() : 'C'
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
|
||||
|
||||
import ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
|
||||
|
||||
function mountStep(
|
||||
props: Partial<InstanceType<typeof ComfyHubDescribeStep>['$props']> = {}
|
||||
) {
|
||||
return mount(ComfyHubDescribeStep, {
|
||||
props: {
|
||||
name: 'Workflow Name',
|
||||
description: 'Workflow description',
|
||||
tags: [],
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
},
|
||||
stubs: {
|
||||
Input: {
|
||||
template:
|
||||
'<input data-testid="name-input" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['modelValue']
|
||||
},
|
||||
Textarea: {
|
||||
template:
|
||||
'<textarea data-testid="description-input" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['modelValue']
|
||||
},
|
||||
TagsInput: {
|
||||
template:
|
||||
'<div data-testid="tags-input" :data-disabled="disabled ? \'true\' : \'false\'"><slot :is-empty="!modelValue || modelValue.length === 0" /></div>',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
disabled: Boolean
|
||||
}
|
||||
},
|
||||
TagsInputItem: {
|
||||
template:
|
||||
'<button data-testid="tag-item" :data-value="value" type="button"><slot /></button>',
|
||||
props: ['value']
|
||||
},
|
||||
TagsInputItemText: {
|
||||
template: '<span data-testid="tag-item-text" />'
|
||||
},
|
||||
TagsInputItemDelete: {
|
||||
template: '<button data-testid="tag-item-delete" type="button" />'
|
||||
},
|
||||
TagsInputInput: {
|
||||
template: '<input data-testid="tags-input-input" />'
|
||||
},
|
||||
Button: {
|
||||
template:
|
||||
'<button data-testid="toggle-suggestions" type="button"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyHubDescribeStep', () => {
|
||||
it('emits name and description updates', async () => {
|
||||
const wrapper = mountStep()
|
||||
|
||||
await wrapper.find('[data-testid="name-input"]').setValue('New workflow')
|
||||
await wrapper
|
||||
.find('[data-testid="description-input"]')
|
||||
.setValue('New description')
|
||||
|
||||
expect(wrapper.emitted('update:name')).toEqual([['New workflow']])
|
||||
expect(wrapper.emitted('update:description')).toEqual([['New description']])
|
||||
})
|
||||
|
||||
it('adds a suggested tag when clicked', async () => {
|
||||
const wrapper = mountStep()
|
||||
const suggestionButtons = wrapper.findAll(
|
||||
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
|
||||
)
|
||||
|
||||
expect(suggestionButtons.length).toBeGreaterThan(0)
|
||||
|
||||
const firstSuggestion = suggestionButtons[0].attributes('data-value')
|
||||
await suggestionButtons[0].trigger('click')
|
||||
|
||||
const tagUpdates = wrapper.emitted('update:tags')
|
||||
expect(tagUpdates?.at(-1)).toEqual([[firstSuggestion]])
|
||||
})
|
||||
|
||||
it('hides already-selected tags from suggestions', () => {
|
||||
const selectedTag = COMFY_HUB_TAG_OPTIONS[0]
|
||||
const wrapper = mountStep({ tags: [selectedTag] })
|
||||
const suggestionValues = wrapper
|
||||
.findAll(
|
||||
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
|
||||
)
|
||||
.map((button) => button.attributes('data-value'))
|
||||
|
||||
expect(suggestionValues).not.toContain(selectedTag)
|
||||
})
|
||||
|
||||
it('toggles between default and full suggestion lists', async () => {
|
||||
const wrapper = mountStep()
|
||||
|
||||
const defaultSuggestions = wrapper.findAll(
|
||||
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
|
||||
)
|
||||
expect(defaultSuggestions).toHaveLength(10)
|
||||
expect(wrapper.text()).toContain('comfyHubPublish.showMoreTags')
|
||||
|
||||
await wrapper.find('[data-testid="toggle-suggestions"]').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const allSuggestions = wrapper.findAll(
|
||||
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
|
||||
)
|
||||
expect(allSuggestions).toHaveLength(COMFY_HUB_TAG_OPTIONS.length)
|
||||
expect(wrapper.text()).toContain('comfyHubPublish.showLessTags')
|
||||
})
|
||||
})
|
||||
@@ -25,35 +25,8 @@
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.workflowType') }}
|
||||
</span>
|
||||
<Select
|
||||
:model-value="workflowType"
|
||||
@update:model-value="
|
||||
emit('update:workflowType', $event as ComfyHubWorkflowType)
|
||||
"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
:placeholder="$t('comfyHubPublish.workflowTypePlaceholder')"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in workflowTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<legend class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.tagsDescription') }}
|
||||
</legend>
|
||||
</span>
|
||||
<TagsInput
|
||||
v-slot="{ isEmpty }"
|
||||
always-editing
|
||||
@@ -67,54 +40,48 @@
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty />
|
||||
</TagsInput>
|
||||
|
||||
<TagsInput
|
||||
disabled
|
||||
class="hover-within:bg-transparent bg-transparent p-0 hover:bg-transparent"
|
||||
</label>
|
||||
<TagsInput
|
||||
disabled
|
||||
class="hover-within:bg-transparent bg-transparent p-0 hover:bg-transparent"
|
||||
>
|
||||
<div
|
||||
v-if="displayedSuggestions.length > 0"
|
||||
class="flex basis-full flex-wrap gap-2"
|
||||
>
|
||||
<div
|
||||
v-if="displayedSuggestions.length > 0"
|
||||
class="flex basis-full flex-wrap gap-2"
|
||||
<TagsInputItem
|
||||
v-for="tag in displayedSuggestions"
|
||||
:key="tag"
|
||||
v-auto-animate
|
||||
:value="tag"
|
||||
class="cursor-pointer bg-secondary-background px-2 text-muted-foreground transition-colors select-none hover:bg-secondary-background-selected"
|
||||
@click="addTag(tag)"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="tag in displayedSuggestions"
|
||||
:key="tag"
|
||||
v-auto-animate
|
||||
:value="tag"
|
||||
class="cursor-pointer bg-secondary-background px-2 text-muted-foreground transition-colors select-none hover:bg-secondary-background-selected"
|
||||
@click="addTag(tag)"
|
||||
>
|
||||
<TagsInputItemText />
|
||||
</TagsInputItem>
|
||||
</div>
|
||||
<Button
|
||||
v-if="shouldShowSuggestionToggle"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
class="hover:bg-unset px-0 text-xs"
|
||||
@click="showAllSuggestions = !showAllSuggestions"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
showAllSuggestions
|
||||
? 'comfyHubPublish.showLessTags'
|
||||
: 'comfyHubPublish.showMoreTags'
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</TagsInput>
|
||||
</fieldset>
|
||||
<TagsInputItemText />
|
||||
</TagsInputItem>
|
||||
</div>
|
||||
<Button
|
||||
v-if="shouldShowSuggestionToggle"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
class="hover:bg-unset px-0 text-xs"
|
||||
@click="showAllSuggestions = !showAllSuggestions"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
showAllSuggestions
|
||||
? 'comfyHubPublish.showLessTags'
|
||||
: 'comfyHubPublish.showMoreTags'
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</TagsInput>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
|
||||
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
|
||||
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
|
||||
@@ -122,46 +89,21 @@ import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.
|
||||
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
|
||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
|
||||
import type { ComfyHubWorkflowType } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
|
||||
const { tags, workflowType } = defineProps<{
|
||||
const { tags } = defineProps<{
|
||||
name: string
|
||||
description: string
|
||||
workflowType: ComfyHubWorkflowType | ''
|
||||
tags: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:name': [value: string]
|
||||
'update:description': [value: string]
|
||||
'update:workflowType': [value: ComfyHubWorkflowType | '']
|
||||
'update:tags': [value: string[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const workflowTypeOptions = computed(() => [
|
||||
{
|
||||
value: 'imageGeneration',
|
||||
label: t('comfyHubPublish.workflowTypeImageGeneration')
|
||||
},
|
||||
{
|
||||
value: 'videoGeneration',
|
||||
label: t('comfyHubPublish.workflowTypeVideoGeneration')
|
||||
},
|
||||
{
|
||||
value: 'upscaling',
|
||||
label: t('comfyHubPublish.workflowTypeUpscaling')
|
||||
},
|
||||
{
|
||||
value: 'editing',
|
||||
label: t('comfyHubPublish.workflowTypeEditing')
|
||||
}
|
||||
])
|
||||
|
||||
const INITIAL_TAG_SUGGESTION_COUNT = 10
|
||||
|
||||
const showAllSuggestions = ref(false)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
|
||||
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
|
||||
|
||||
vi.mock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({
|
||||
draggable: vi.fn(() => vi.fn()),
|
||||
dropTargetForElements: vi.fn(() => vi.fn()),
|
||||
monitorForElements: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
function createImages(count: number): ExampleImage[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `img-${i}`,
|
||||
url: `blob:http://localhost/img-${i}`
|
||||
}))
|
||||
}
|
||||
|
||||
function mountStep(images: ExampleImage[]) {
|
||||
return mount(ComfyHubExamplesStep, {
|
||||
props: { exampleImages: images },
|
||||
global: {
|
||||
mocks: { $t: (key: string) => key }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyHubExamplesStep', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders all example images', () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
expect(wrapper.findAll('[role="listitem"]')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('emits reordered array when moving image left via keyboard', async () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
|
||||
const tiles = wrapper.findAll('[role="listitem"]')
|
||||
await tiles[1].trigger('keydown', { key: 'ArrowLeft', shiftKey: true })
|
||||
|
||||
const emitted = wrapper.emitted('update:exampleImages')
|
||||
expect(emitted).toBeTruthy()
|
||||
const reordered = emitted![0][0] as ExampleImage[]
|
||||
expect(reordered.map((img) => img.id)).toEqual(['img-1', 'img-0', 'img-2'])
|
||||
})
|
||||
|
||||
it('emits reordered array when moving image right via keyboard', async () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
|
||||
const tiles = wrapper.findAll('[role="listitem"]')
|
||||
await tiles[1].trigger('keydown', { key: 'ArrowRight', shiftKey: true })
|
||||
|
||||
const emitted = wrapper.emitted('update:exampleImages')
|
||||
expect(emitted).toBeTruthy()
|
||||
const reordered = emitted![0][0] as ExampleImage[]
|
||||
expect(reordered.map((img) => img.id)).toEqual(['img-0', 'img-2', 'img-1'])
|
||||
})
|
||||
|
||||
it('does not emit when moving first image left (boundary)', async () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
|
||||
const tiles = wrapper.findAll('[role="listitem"]')
|
||||
await tiles[0].trigger('keydown', { key: 'ArrowLeft', shiftKey: true })
|
||||
|
||||
expect(wrapper.emitted('update:exampleImages')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('does not emit when moving last image right (boundary)', async () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
|
||||
const tiles = wrapper.findAll('[role="listitem"]')
|
||||
await tiles[2].trigger('keydown', { key: 'ArrowRight', shiftKey: true })
|
||||
|
||||
expect(wrapper.emitted('update:exampleImages')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('emits filtered array when removing an image', async () => {
|
||||
const wrapper = mountStep(createImages(2))
|
||||
|
||||
const removeBtn = wrapper.find(
|
||||
'button[aria-label="comfyHubPublish.removeExampleImage"]'
|
||||
)
|
||||
expect(removeBtn.exists()).toBe(true)
|
||||
await removeBtn.trigger('click')
|
||||
|
||||
const emitted = wrapper.emitted('update:exampleImages')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted![0][0]).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,25 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<p class="text-sm">
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<p class="text-sm select-none">
|
||||
{{
|
||||
$t('comfyHubPublish.examplesDescription', {
|
||||
selected: selectedExampleIds.length,
|
||||
total: MAX_EXAMPLES
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-4 gap-2.5 overflow-y-auto">
|
||||
<!-- Upload tile -->
|
||||
<div
|
||||
role="list"
|
||||
class="group/grid grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, 8rem)"
|
||||
>
|
||||
<!-- Upload tile (hidden when max images reached) -->
|
||||
<label
|
||||
v-if="showUploadTile"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:aria-label="$t('comfyHubPublish.uploadExampleImage')"
|
||||
class="focus-visible:outline-ring flex aspect-square h-25 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-default text-center transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
class="focus-visible:outline-ring flex aspect-square cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-default text-center transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
@dragenter.stop
|
||||
@dragleave.stop
|
||||
@dragover.prevent.stop
|
||||
@@ -40,83 +44,100 @@
|
||||
}}</span>
|
||||
</label>
|
||||
|
||||
<!-- Example images -->
|
||||
<Button
|
||||
<!-- Example images (drag to reorder) -->
|
||||
<ReorderableExampleImage
|
||||
v-for="(image, index) in exampleImages"
|
||||
:key="image.id"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'relative h-25 cursor-pointer overflow-hidden rounded-sm p-0',
|
||||
isSelected(image.id) ? 'ring-ring ring-2' : 'ring-0'
|
||||
)
|
||||
"
|
||||
@click="toggleSelection(image.id)"
|
||||
>
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="$t('comfyHubPublish.exampleImage', { index: index + 1 })"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-if="isSelected(image.id)"
|
||||
class="absolute bottom-1.5 left-1.5 flex size-7 items-center justify-center rounded-full bg-primary-background text-sm font-bold text-base-foreground"
|
||||
>
|
||||
{{ selectionIndex(image.id) }}
|
||||
</div>
|
||||
</Button>
|
||||
:image="image"
|
||||
:index="index"
|
||||
:total="exampleImages.length"
|
||||
:instance-id="instanceId"
|
||||
@remove="removeImage"
|
||||
@move="moveImage"
|
||||
@insert-files="insertImagesAt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import {
|
||||
isFileTooLarge,
|
||||
MAX_IMAGE_SIZE_MB
|
||||
} from '@/platform/workflow/sharing/utils/validateFileSize'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import ReorderableExampleImage from './ReorderableExampleImage.vue'
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const MAX_EXAMPLES = 8
|
||||
|
||||
const { exampleImages, selectedExampleIds } = defineProps<{
|
||||
exampleImages: ExampleImage[]
|
||||
selectedExampleIds: string[]
|
||||
}>()
|
||||
const exampleImages = defineModel<ExampleImage[]>('exampleImages', {
|
||||
required: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:exampleImages': [value: ExampleImage[]]
|
||||
'update:selectedExampleIds': [value: string[]]
|
||||
}>()
|
||||
const showUploadTile = computed(() => exampleImages.value.length < MAX_EXAMPLES)
|
||||
|
||||
function isSelected(id: string): boolean {
|
||||
return selectedExampleIds.includes(id)
|
||||
const instanceId = Symbol('example-images')
|
||||
|
||||
let cleanupMonitor = () => {}
|
||||
|
||||
onMounted(() => {
|
||||
cleanupMonitor = monitorForElements({
|
||||
canMonitor: ({ source }) => source.data.instanceId === instanceId,
|
||||
onDrop: ({ source, location }) => {
|
||||
const destination = location.current.dropTargets[0]
|
||||
if (!destination) return
|
||||
|
||||
const fromId = source.data.imageId
|
||||
const toId = destination.data.imageId
|
||||
if (typeof fromId !== 'string' || typeof toId !== 'string') return
|
||||
|
||||
reorderImages(fromId, toId)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupMonitor()
|
||||
})
|
||||
|
||||
function moveByIndex(fromIndex: number, toIndex: number) {
|
||||
if (fromIndex < 0 || toIndex < 0) return
|
||||
if (toIndex >= exampleImages.value.length || fromIndex === toIndex) return
|
||||
|
||||
const updated = [...exampleImages.value]
|
||||
const [moved] = updated.splice(fromIndex, 1)
|
||||
updated.splice(toIndex, 0, moved)
|
||||
exampleImages.value = updated
|
||||
}
|
||||
|
||||
function selectionIndex(id: string): number {
|
||||
return selectedExampleIds.indexOf(id) + 1
|
||||
function reorderImages(fromId: string, toId: string) {
|
||||
moveByIndex(
|
||||
exampleImages.value.findIndex((img) => img.id === fromId),
|
||||
exampleImages.value.findIndex((img) => img.id === toId)
|
||||
)
|
||||
}
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
if (isSelected(id)) {
|
||||
emit(
|
||||
'update:selectedExampleIds',
|
||||
selectedExampleIds.filter((sid) => sid !== id)
|
||||
)
|
||||
} else if (selectedExampleIds.length < MAX_EXAMPLES) {
|
||||
emit('update:selectedExampleIds', [...selectedExampleIds, id])
|
||||
function moveImage(id: string, direction: number) {
|
||||
const currentIndex = exampleImages.value.findIndex((img) => img.id === id)
|
||||
moveByIndex(currentIndex, currentIndex + direction)
|
||||
}
|
||||
|
||||
function removeImage(id: string) {
|
||||
const image = exampleImages.value.find((img) => img.id === id)
|
||||
if (image?.file) {
|
||||
URL.revokeObjectURL(image.url)
|
||||
}
|
||||
exampleImages.value = exampleImages.value.filter((img) => img.id !== id)
|
||||
}
|
||||
|
||||
function addImages(files: FileList) {
|
||||
const newImages: ExampleImage[] = Array.from(files)
|
||||
function createExampleImages(files: FileList): ExampleImage[] {
|
||||
return Array.from(files)
|
||||
.filter((f) => f.type.startsWith('image/'))
|
||||
.filter((f) => !isFileTooLarge(f, MAX_IMAGE_SIZE_MB))
|
||||
.map((file) => ({
|
||||
@@ -124,10 +145,51 @@ function addImages(files: FileList) {
|
||||
url: URL.createObjectURL(file),
|
||||
file
|
||||
}))
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
emit('update:exampleImages', [...exampleImages, ...newImages])
|
||||
function addImages(files: FileList) {
|
||||
const remaining = MAX_EXAMPLES - exampleImages.value.length
|
||||
if (remaining <= 0) return
|
||||
|
||||
const created = createExampleImages(files)
|
||||
const newImages = created.slice(0, remaining)
|
||||
for (const img of created.slice(remaining)) {
|
||||
URL.revokeObjectURL(img.url)
|
||||
}
|
||||
if (newImages.length > 0) {
|
||||
exampleImages.value = [...newImages, ...exampleImages.value]
|
||||
}
|
||||
}
|
||||
|
||||
function insertImagesAt(index: number, files: FileList) {
|
||||
const created = createExampleImages(files)
|
||||
if (created.length === 0) return
|
||||
|
||||
const updated = [...exampleImages.value]
|
||||
const safeIndex = Math.min(Math.max(index, 0), updated.length)
|
||||
const remaining = MAX_EXAMPLES - exampleImages.value.length
|
||||
const maxInsert =
|
||||
remaining <= 0 ? Math.max(updated.length - safeIndex, 0) : remaining
|
||||
const newImages = created.slice(0, maxInsert)
|
||||
for (const img of created.slice(maxInsert)) {
|
||||
URL.revokeObjectURL(img.url)
|
||||
}
|
||||
|
||||
if (newImages.length === 0) return
|
||||
if (remaining <= 0) {
|
||||
const replacedImages = updated.splice(
|
||||
safeIndex,
|
||||
newImages.length,
|
||||
...newImages
|
||||
)
|
||||
for (const img of replacedImages) {
|
||||
if (img.file) URL.revokeObjectURL(img.url)
|
||||
}
|
||||
} else {
|
||||
updated.splice(safeIndex, 0, ...newImages)
|
||||
}
|
||||
|
||||
exampleImages.value = updated
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-8 px-6 py-4">
|
||||
<section class="flex flex-col gap-4">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.shareAs') }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-4 rounded-2xl bg-secondary-background px-6 py-4"
|
||||
>
|
||||
<div
|
||||
class="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full bg-linear-to-b from-green-600/50 to-green-900"
|
||||
>
|
||||
<img
|
||||
v-if="profile.profilePictureUrl"
|
||||
:src="profile.profilePictureUrl"
|
||||
:alt="profile.username"
|
||||
class="size-full rounded-full object-cover"
|
||||
/>
|
||||
<span v-else class="text-base text-white">
|
||||
{{ (profile.name ?? profile.username).charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ profile.name ?? profile.username }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
@{{ profile.username }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="isLoadingAssets || hasPrivateAssets"
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.additionalInfo') }}
|
||||
</span>
|
||||
|
||||
<p
|
||||
v-if="isLoadingAssets"
|
||||
class="m-0 text-sm text-muted-foreground italic"
|
||||
>
|
||||
{{ $t('shareWorkflow.checkingAssets') }}
|
||||
</p>
|
||||
<ShareAssetWarningBox
|
||||
v-else
|
||||
v-model:acknowledged="acknowledged"
|
||||
:items="privateAssets"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import type { ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
import ShareAssetWarningBox from '@/platform/workflow/sharing/components/ShareAssetWarningBox.vue'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
|
||||
const { profile } = defineProps<{
|
||||
profile: ComfyHubProfile
|
||||
}>()
|
||||
|
||||
const acknowledged = defineModel<boolean>('acknowledged', { default: false })
|
||||
const ready = defineModel<boolean>('ready', { default: false })
|
||||
|
||||
const shareService = useWorkflowShareService()
|
||||
|
||||
const {
|
||||
state: privateAssets,
|
||||
isLoading: isLoadingAssets,
|
||||
error: privateAssetsError
|
||||
} = useAsyncState(() => shareService.getShareableAssets(), [])
|
||||
|
||||
const hasPrivateAssets = computed(() => privateAssets.value.length > 0)
|
||||
const isReady = computed(
|
||||
() =>
|
||||
!isLoadingAssets.value &&
|
||||
!privateAssetsError.value &&
|
||||
(!hasPrivateAssets.value || acknowledged.value)
|
||||
)
|
||||
|
||||
watch(
|
||||
isReady,
|
||||
(val) => {
|
||||
ready.value = val
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -2,6 +2,18 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
import ComfyHubPublishDialog from '@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue'
|
||||
|
||||
const mockFetchProfile = vi.hoisted(() => vi.fn())
|
||||
@@ -10,6 +22,11 @@ const mockGoNext = vi.hoisted(() => vi.fn())
|
||||
const mockGoBack = vi.hoisted(() => vi.fn())
|
||||
const mockOpenProfileCreationStep = vi.hoisted(() => vi.fn())
|
||||
const mockCloseProfileCreationStep = vi.hoisted(() => vi.fn())
|
||||
const mockApplyPrefill = vi.hoisted(() => vi.fn())
|
||||
const mockCachePublishPrefill = vi.hoisted(() => vi.fn())
|
||||
const mockGetCachedPrefill = vi.hoisted(() => vi.fn())
|
||||
const mockSubmitToComfyHub = vi.hoisted(() => vi.fn())
|
||||
const mockGetPublishStatus = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
|
||||
@@ -28,14 +45,16 @@ vi.mock(
|
||||
formData: ref({
|
||||
name: '',
|
||||
description: '',
|
||||
workflowType: '',
|
||||
tags: [],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
selectedExampleIds: []
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
}),
|
||||
isFirstStep: ref(false),
|
||||
isLastStep: ref(true),
|
||||
@@ -43,17 +62,64 @@ vi.mock(
|
||||
goNext: mockGoNext,
|
||||
goBack: mockGoBack,
|
||||
openProfileCreationStep: mockOpenProfileCreationStep,
|
||||
closeProfileCreationStep: mockCloseProfileCreationStep
|
||||
closeProfileCreationStep: mockCloseProfileCreationStep,
|
||||
applyPrefill: mockApplyPrefill
|
||||
}),
|
||||
cachePublishPrefill: mockCachePublishPrefill,
|
||||
getCachedPrefill: mockGetCachedPrefill
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubPublishSubmission',
|
||||
() => ({
|
||||
useComfyHubPublishSubmission: () => ({
|
||||
submitToComfyHub: mockSubmitToComfyHub
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
|
||||
useWorkflowShareService: () => ({
|
||||
getPublishStatus: mockGetPublishStatus
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
renameWorkflow: vi.fn(),
|
||||
saveWorkflow: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: {
|
||||
path: 'workflows/test.json',
|
||||
filename: 'test.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
},
|
||||
saveWorkflow: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('ComfyHubPublishDialog', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchProfile.mockResolvedValue(null)
|
||||
mockSubmitToComfyHub.mockResolvedValue(undefined)
|
||||
mockGetCachedPrefill.mockReturnValue(null)
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: false,
|
||||
shareId: null,
|
||||
shareUrl: null,
|
||||
publishedAt: null,
|
||||
prefill: null
|
||||
})
|
||||
})
|
||||
|
||||
function createWrapper() {
|
||||
@@ -78,14 +144,16 @@ describe('ComfyHubPublishDialog', () => {
|
||||
},
|
||||
ComfyHubPublishWizardContent: {
|
||||
template:
|
||||
'<div><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /></div>',
|
||||
'<div :data-is-publishing="$props.isPublishing"><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /><button data-testid="publish" @click="$props.onPublish()" /></div>',
|
||||
props: [
|
||||
'currentStep',
|
||||
'formData',
|
||||
'isFirstStep',
|
||||
'isLastStep',
|
||||
'isPublishing',
|
||||
'onGoNext',
|
||||
'onGoBack',
|
||||
'onPublish',
|
||||
'onRequireProfile',
|
||||
'onGateComplete',
|
||||
'onGateClose'
|
||||
@@ -136,4 +204,72 @@ describe('ComfyHubPublishDialog', () => {
|
||||
expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes dialog after successful publish', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('[data-testid="publish"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('applies prefill when workflow is already published with metadata', async () => {
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: true,
|
||||
shareId: 'abc123',
|
||||
shareUrl: 'http://localhost/?share=abc123',
|
||||
publishedAt: new Date(),
|
||||
prefill: {
|
||||
description: 'Existing description',
|
||||
tags: ['art', 'upscale'],
|
||||
thumbnailType: 'video',
|
||||
sampleImageUrls: ['https://example.com/img1.png']
|
||||
}
|
||||
})
|
||||
|
||||
createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).toHaveBeenCalledWith({
|
||||
description: 'Existing description',
|
||||
tags: ['art', 'upscale'],
|
||||
thumbnailType: 'video',
|
||||
sampleImageUrls: ['https://example.com/img1.png']
|
||||
})
|
||||
})
|
||||
|
||||
it('does not apply prefill when workflow is not published', async () => {
|
||||
createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not apply prefill when status has no prefill data', async () => {
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: true,
|
||||
shareId: 'abc123',
|
||||
shareUrl: 'http://localhost/?share=abc123',
|
||||
publishedAt: new Date(),
|
||||
prefill: null
|
||||
})
|
||||
|
||||
createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('silently ignores prefill fetch errors', async () => {
|
||||
mockGetPublishStatus.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,44 +12,106 @@
|
||||
</template>
|
||||
|
||||
<template #leftPanel>
|
||||
<ComfyHubPublishNav :current-step @step-click="goToStep" />
|
||||
<ComfyHubPublishNav
|
||||
v-if="!needsSave"
|
||||
:current-step
|
||||
@step-click="goToStep"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #header />
|
||||
<template #content>
|
||||
<div v-if="needsSave" class="flex flex-col gap-4 p-6">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('comfyHubPublish.unsavedDescription') }}
|
||||
</p>
|
||||
<label v-if="isTemporary" class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted-foreground">
|
||||
{{ $t('shareWorkflow.workflowNameLabel') }}
|
||||
</span>
|
||||
<Input
|
||||
ref="nameInputRef"
|
||||
v-model="workflowName"
|
||||
:disabled="isSaving"
|
||||
@keydown.enter="() => handleSave()"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading="isSaving"
|
||||
@click="() => handleSave()"
|
||||
>
|
||||
{{
|
||||
isSaving
|
||||
? $t('shareWorkflow.saving')
|
||||
: $t('shareWorkflow.saveButton')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
<ComfyHubPublishWizardContent
|
||||
v-else
|
||||
:current-step
|
||||
:form-data
|
||||
:is-first-step
|
||||
:is-last-step
|
||||
:is-publishing
|
||||
:on-update-form-data="updateFormData"
|
||||
:on-go-next="goNext"
|
||||
:on-go-back="goBack"
|
||||
:on-require-profile="handleRequireProfile"
|
||||
:on-gate-complete="handlePublishGateComplete"
|
||||
:on-gate-close="handlePublishGateClose"
|
||||
:on-publish="onClose"
|
||||
:on-publish="handlePublish"
|
||||
/>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, provide } from 'vue'
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
provide,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import ComfyHubPublishNav from '@/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue'
|
||||
import ComfyHubPublishWizardContent from '@/platform/workflow/sharing/components/publish/ComfyHubPublishWizardContent.vue'
|
||||
import { useComfyHubPublishWizard } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
|
||||
import { useComfyHubPublishSubmission } from '@/platform/workflow/sharing/composables/useComfyHubPublishSubmission'
|
||||
import {
|
||||
cachePublishPrefill,
|
||||
getCachedPrefill,
|
||||
useComfyHubPublishWizard
|
||||
} from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
|
||||
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { fetchProfile } = useComfyHubProfileGate()
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
const shareService = useWorkflowShareService()
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const {
|
||||
currentStep,
|
||||
formData,
|
||||
@@ -59,8 +121,72 @@ const {
|
||||
goNext,
|
||||
goBack,
|
||||
openProfileCreationStep,
|
||||
closeProfileCreationStep
|
||||
closeProfileCreationStep,
|
||||
applyPrefill
|
||||
} = useComfyHubPublishWizard()
|
||||
const isPublishing = ref(false)
|
||||
const needsSave = ref(false)
|
||||
const workflowName = ref('')
|
||||
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
|
||||
|
||||
const isTemporary = computed(
|
||||
() => workflowStore.activeWorkflow?.isTemporary ?? false
|
||||
)
|
||||
|
||||
function checkNeedsSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
needsSave.value = !workflow || workflow.isTemporary || workflow.isModified
|
||||
if (workflow) {
|
||||
workflowName.value = workflow.filename.replace(/\.json$/i, '')
|
||||
}
|
||||
}
|
||||
|
||||
watch(needsSave, async (needs) => {
|
||||
if (needs && isTemporary.value) {
|
||||
await nextTick()
|
||||
nameInputRef.value?.focus()
|
||||
nameInputRef.value?.select()
|
||||
}
|
||||
})
|
||||
|
||||
function buildWorkflowPath(directory: string, filename: string): string {
|
||||
const normalizedDirectory = directory.replace(/\/+$/, '')
|
||||
const normalizedFilename = appendJsonExt(filename.replace(/\.json$/i, ''))
|
||||
return normalizedDirectory
|
||||
? `${normalizedDirectory}/${normalizedFilename}`
|
||||
: normalizedFilename
|
||||
}
|
||||
|
||||
const { isLoading: isSaving, execute: handleSave } = useAsyncState(
|
||||
async () => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
if (workflow.isTemporary) {
|
||||
const name = workflowName.value.trim()
|
||||
if (!name) return
|
||||
const newPath = buildWorkflowPath(workflow.directory, name)
|
||||
await workflowService.renameWorkflow(workflow, newPath)
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
} else {
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
}
|
||||
|
||||
checkNeedsSave()
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
immediate: false,
|
||||
onError: (error) => {
|
||||
console.error('Failed to save workflow:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('shareWorkflow.saveFailedTitle'),
|
||||
detail: t('shareWorkflow.saveFailedDescription')
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function handlePublishGateComplete() {
|
||||
closeProfileCreationStep()
|
||||
@@ -75,18 +201,67 @@ function handleRequireProfile() {
|
||||
openProfileCreationStep()
|
||||
}
|
||||
|
||||
async function handlePublish(): Promise<void> {
|
||||
if (isPublishing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isPublishing.value = true
|
||||
try {
|
||||
await submitToComfyHub(formData.value)
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (path) {
|
||||
cachePublishPrefill(path, formData.value)
|
||||
}
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to publish workflow:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('comfyHubPublish.publishFailedTitle'),
|
||||
detail: t('comfyHubPublish.publishFailedDescription')
|
||||
})
|
||||
} finally {
|
||||
isPublishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateFormData(patch: Partial<ComfyHubPublishFormData>) {
|
||||
formData.value = { ...formData.value, ...patch }
|
||||
}
|
||||
|
||||
async function fetchPublishPrefill() {
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (!path) return
|
||||
|
||||
try {
|
||||
const status = await shareService.getPublishStatus(path)
|
||||
const prefill = status.isPublished
|
||||
? (status.prefill ?? getCachedPrefill(path))
|
||||
: getCachedPrefill(path)
|
||||
if (prefill) {
|
||||
applyPrefill(prefill)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch publish prefill:', error)
|
||||
const cached = getCachedPrefill(path)
|
||||
if (cached) {
|
||||
applyPrefill(cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Prefetch profile data in the background so finish-step profile context is ready.
|
||||
checkNeedsSave()
|
||||
void fetchProfile()
|
||||
void fetchPublishPrefill()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const image of formData.value.exampleImages) {
|
||||
URL.revokeObjectURL(image.url)
|
||||
if (image.file) {
|
||||
URL.revokeObjectURL(image.url)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
<template>
|
||||
<footer class="flex shrink items-center justify-between py-2">
|
||||
<div>
|
||||
<Button v-if="!isFirstStep" size="lg" @click="$emit('back')">
|
||||
{{ $t('comfyHubPublish.back') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<Button v-if="!isLastStep" size="lg" @click="$emit('next')">
|
||||
{{ $t('comfyHubPublish.next') }}
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="isPublishDisabled"
|
||||
@click="$emit('publish')"
|
||||
>
|
||||
<i class="icon-[lucide--upload] size-4" />
|
||||
{{ $t('comfyHubPublish.publishButton') }}
|
||||
</Button>
|
||||
</div>
|
||||
<footer
|
||||
class="flex shrink items-center justify-end gap-4 border-t border-border-default px-6 py-4"
|
||||
>
|
||||
<Button v-if="!isFirstStep" size="lg" @click="$emit('back')">
|
||||
{{ $t('comfyHubPublish.back') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isLastStep"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
{{ $t('comfyHubPublish.next') }}
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="isPublishDisabled || isPublishing"
|
||||
:loading="isPublishing"
|
||||
@click="$emit('publish')"
|
||||
>
|
||||
<i class="icon-[lucide--upload] size-4" />
|
||||
{{ $t('comfyHubPublish.publishButton') }}
|
||||
</Button>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
@@ -31,6 +35,7 @@ defineProps<{
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
isPublishDisabled?: boolean
|
||||
isPublishing?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<nav class="flex flex-col gap-6 px-3 py-4">
|
||||
<ol class="flex flex-col">
|
||||
<ol class="flex list-none flex-col p-0">
|
||||
<li
|
||||
v-for="step in steps"
|
||||
:key="step.name"
|
||||
|
||||
@@ -8,13 +8,20 @@ import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/
|
||||
const mockCheckProfile = vi.hoisted(() => vi.fn())
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
const mockHasProfile = ref<boolean | null>(true)
|
||||
const mockIsFetchingProfile = ref(false)
|
||||
const mockProfile = ref<{ username: string; name?: string } | null>({
|
||||
username: 'testuser',
|
||||
name: 'Test User'
|
||||
})
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
|
||||
() => ({
|
||||
useComfyHubProfileGate: () => ({
|
||||
checkProfile: mockCheckProfile,
|
||||
hasProfile: mockHasProfile
|
||||
hasProfile: mockHasProfile,
|
||||
isFetchingProfile: mockIsFetchingProfile,
|
||||
profile: mockProfile
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -39,14 +46,16 @@ function createDefaultFormData(): ComfyHubPublishFormData {
|
||||
return {
|
||||
name: 'Test Workflow',
|
||||
description: '',
|
||||
workflowType: '',
|
||||
tags: [],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
selectedExampleIds: []
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +70,11 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
onPublish.mockResolvedValue(undefined)
|
||||
mockCheckProfile.mockResolvedValue(true)
|
||||
mockHasProfile.value = true
|
||||
mockIsFetchingProfile.value = false
|
||||
mockProfile.value = { username: 'testuser', name: 'Test User' }
|
||||
mockFlags.comfyHubProfileGateEnabled = true
|
||||
})
|
||||
|
||||
@@ -99,9 +111,23 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
template: '<div data-testid="publish-gate-flow" />',
|
||||
props: ['onProfileCreated', 'onClose', 'showCloseButton']
|
||||
},
|
||||
Skeleton: {
|
||||
template: '<div class="skeleton" />'
|
||||
},
|
||||
ComfyHubDescribeStep: {
|
||||
template: '<div data-testid="describe-step" />'
|
||||
},
|
||||
ComfyHubFinishStep: {
|
||||
template: '<div data-testid="finish-step" />',
|
||||
props: ['profile', 'acknowledged', 'ready'],
|
||||
emits: ['update:ready', 'update:acknowledged'],
|
||||
setup(
|
||||
_: unknown,
|
||||
{ emit }: { emit: (e: string, v: boolean) => void }
|
||||
) {
|
||||
emit('update:ready', true)
|
||||
}
|
||||
},
|
||||
ComfyHubExamplesStep: {
|
||||
template: '<div data-testid="examples-step" />'
|
||||
},
|
||||
@@ -115,8 +141,13 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
},
|
||||
ComfyHubPublishFooter: {
|
||||
template:
|
||||
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /></div>',
|
||||
props: ['isFirstStep', 'isLastStep', 'isPublishDisabled'],
|
||||
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled" :data-is-publishing="isPublishing"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /></div>',
|
||||
props: [
|
||||
'isFirstStep',
|
||||
'isLastStep',
|
||||
'isPublishDisabled',
|
||||
'isPublishing'
|
||||
],
|
||||
emits: ['publish', 'next', 'back']
|
||||
}
|
||||
}
|
||||
@@ -124,43 +155,19 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
})
|
||||
}
|
||||
|
||||
describe('handlePublish — double-click guard', () => {
|
||||
it('prevents concurrent publish calls', async () => {
|
||||
let resolveCheck!: (v: boolean) => void
|
||||
mockCheckProfile.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveCheck = resolve
|
||||
})
|
||||
)
|
||||
function createDeferred<T>() {
|
||||
let resolve: (value: T) => void = () => {}
|
||||
let reject: (error: unknown) => void = () => {}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
|
||||
await publishBtn.trigger('click')
|
||||
await publishBtn.trigger('click')
|
||||
|
||||
resolveCheck(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCheckProfile).toHaveBeenCalledTimes(1)
|
||||
expect(onPublish).toHaveBeenCalledTimes(1)
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePublish — feature flag bypass', () => {
|
||||
it('calls onPublish directly when profile gate is disabled', async () => {
|
||||
mockFlags.comfyHubProfileGateEnabled = false
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCheckProfile).not.toHaveBeenCalled()
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePublish — profile check routing', () => {
|
||||
describe('handlePublish - profile check routing', () => {
|
||||
it('calls onPublish when profile exists', async () => {
|
||||
mockCheckProfile.mockResolvedValue(true)
|
||||
|
||||
@@ -197,20 +204,83 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
expect(onRequireProfile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets guard after checkProfile error so retry is possible', async () => {
|
||||
mockCheckProfile.mockRejectedValueOnce(new Error('Network error'))
|
||||
it('calls onPublish directly when profile gate is disabled', async () => {
|
||||
mockFlags.comfyHubProfileGateEnabled = false
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCheckProfile).not.toHaveBeenCalled()
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePublish - async submission', () => {
|
||||
it('prevents duplicate publish submissions while in-flight', async () => {
|
||||
const publishDeferred = createDeferred<void>()
|
||||
onPublish.mockReturnValue(publishDeferred.promise)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
|
||||
|
||||
await publishBtn.trigger('click')
|
||||
await publishBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(onPublish).toHaveBeenCalledTimes(1)
|
||||
|
||||
publishDeferred.resolve(undefined)
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('calls onPublish and does not close when publish request fails', async () => {
|
||||
const publishError = new Error('Publish failed')
|
||||
onPublish.mockRejectedValueOnce(publishError)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(publishError)
|
||||
expect(onGateClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows publish disabled while submitting', async () => {
|
||||
const publishDeferred = createDeferred<void>()
|
||||
onPublish.mockReturnValue(publishDeferred.promise)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
|
||||
|
||||
await publishBtn.trigger('click')
|
||||
await flushPromises()
|
||||
expect(onPublish).not.toHaveBeenCalled()
|
||||
|
||||
mockCheckProfile.mockResolvedValue(true)
|
||||
const footer = wrapper.find('[data-testid="publish-footer"]')
|
||||
expect(footer.attributes('data-publish-disabled')).toBe('true')
|
||||
expect(footer.attributes('data-is-publishing')).toBe('true')
|
||||
|
||||
publishDeferred.resolve(undefined)
|
||||
await flushPromises()
|
||||
|
||||
expect(footer.attributes('data-is-publishing')).toBe('false')
|
||||
})
|
||||
|
||||
it('resets guard after publish error so retry is possible', async () => {
|
||||
onPublish.mockRejectedValueOnce(new Error('Publish failed'))
|
||||
|
||||
const wrapper = createWrapper()
|
||||
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
|
||||
|
||||
await publishBtn.trigger('click')
|
||||
await flushPromises()
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
|
||||
onPublish.mockResolvedValueOnce(undefined)
|
||||
await publishBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(onPublish).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -223,9 +293,10 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
expect(footer.attributes('data-publish-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('enables publish when gate enabled and hasProfile is true', () => {
|
||||
it('enables publish when gate enabled and hasProfile is true', async () => {
|
||||
mockHasProfile.value = true
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const footer = wrapper.find('[data-testid="publish-footer"]')
|
||||
expect(footer.attributes('data-publish-disabled')).toBe('false')
|
||||
|
||||
@@ -7,17 +7,15 @@
|
||||
:on-close="onGateClose"
|
||||
:show-close-button="false"
|
||||
/>
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col px-6 pt-4 pb-2">
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<ComfyHubDescribeStep
|
||||
v-if="currentStep === 'describe'"
|
||||
:name="formData.name"
|
||||
:description="formData.description"
|
||||
:workflow-type="formData.workflowType"
|
||||
:tags="formData.tags"
|
||||
@update:name="onUpdateFormData({ name: $event })"
|
||||
@update:description="onUpdateFormData({ description: $event })"
|
||||
@update:workflow-type="onUpdateFormData({ workflowType: $event })"
|
||||
@update:tags="onUpdateFormData({ tags: $event })"
|
||||
/>
|
||||
<div
|
||||
@@ -37,13 +35,22 @@
|
||||
/>
|
||||
<ComfyHubExamplesStep
|
||||
:example-images="formData.exampleImages"
|
||||
:selected-example-ids="formData.selectedExampleIds"
|
||||
@update:example-images="onUpdateFormData({ exampleImages: $event })"
|
||||
@update:selected-example-ids="
|
||||
onUpdateFormData({ selectedExampleIds: $event })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="currentStep === 'finish' && isProfileLoading"
|
||||
class="flex min-h-0 flex-1 flex-col gap-4 px-6 py-4"
|
||||
>
|
||||
<Skeleton class="h-4 w-1/4" />
|
||||
<Skeleton class="h-20 w-full rounded-2xl" />
|
||||
</div>
|
||||
<ComfyHubFinishStep
|
||||
v-else-if="currentStep === 'finish' && hasProfile && profile"
|
||||
v-model:ready="finishStepReady"
|
||||
v-model:acknowledged="assetsAcknowledged"
|
||||
:profile
|
||||
/>
|
||||
<ComfyHubProfilePromptPanel
|
||||
v-else-if="currentStep === 'finish'"
|
||||
@request-profile="onRequireProfile"
|
||||
@@ -53,6 +60,7 @@
|
||||
:is-first-step
|
||||
:is-last-step
|
||||
:is-publish-disabled
|
||||
:is-publishing="isPublishInFlight"
|
||||
@back="onGoBack"
|
||||
@next="onGoNext"
|
||||
@publish="handlePublish"
|
||||
@@ -70,8 +78,10 @@ import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/
|
||||
import ComfyHubCreateProfileForm from '@/platform/workflow/sharing/components/profile/ComfyHubCreateProfileForm.vue'
|
||||
import type { ComfyHubPublishStep } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
|
||||
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
|
||||
import ComfyHubFinishStep from './ComfyHubFinishStep.vue'
|
||||
import ComfyHubProfilePromptPanel from './ComfyHubProfilePromptPanel.vue'
|
||||
import ComfyHubThumbnailStep from './ComfyHubThumbnailStep.vue'
|
||||
import ComfyHubPublishFooter from './ComfyHubPublishFooter.vue'
|
||||
@@ -81,6 +91,7 @@ const {
|
||||
formData,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isPublishing = false,
|
||||
onGoNext,
|
||||
onGoBack,
|
||||
onUpdateFormData,
|
||||
@@ -93,10 +104,11 @@ const {
|
||||
formData: ComfyHubPublishFormData
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
isPublishing?: boolean
|
||||
onGoNext: () => void
|
||||
onGoBack: () => void
|
||||
onUpdateFormData: (patch: Partial<ComfyHubPublishFormData>) => void
|
||||
onPublish: () => void
|
||||
onPublish: () => Promise<void>
|
||||
onRequireProfile: () => void
|
||||
onGateComplete?: () => void
|
||||
onGateClose?: () => void
|
||||
@@ -104,24 +116,42 @@ const {
|
||||
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { checkProfile, hasProfile } = useComfyHubProfileGate()
|
||||
const { checkProfile, hasProfile, isFetchingProfile, profile } =
|
||||
useComfyHubProfileGate()
|
||||
const isProfileLoading = computed(
|
||||
() => hasProfile.value === null || isFetchingProfile.value
|
||||
)
|
||||
const finishStepReady = ref(false)
|
||||
const assetsAcknowledged = ref(false)
|
||||
const isResolvingPublishAccess = ref(false)
|
||||
const isPublishInFlight = computed(
|
||||
() => isPublishing || isResolvingPublishAccess.value
|
||||
)
|
||||
const isFinishStepVisible = computed(
|
||||
() =>
|
||||
currentStep === 'finish' &&
|
||||
hasProfile.value === true &&
|
||||
profile.value !== null
|
||||
)
|
||||
const isPublishDisabled = computed(
|
||||
() => flags.comfyHubProfileGateEnabled && hasProfile.value !== true
|
||||
() =>
|
||||
isPublishInFlight.value ||
|
||||
(flags.comfyHubProfileGateEnabled && hasProfile.value !== true) ||
|
||||
(isFinishStepVisible.value && !finishStepReady.value)
|
||||
)
|
||||
|
||||
async function handlePublish() {
|
||||
if (isResolvingPublishAccess.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!flags.comfyHubProfileGateEnabled) {
|
||||
onPublish()
|
||||
if (isResolvingPublishAccess.value || isPublishing) {
|
||||
return
|
||||
}
|
||||
|
||||
isResolvingPublishAccess.value = true
|
||||
try {
|
||||
if (!flags.comfyHubProfileGateEnabled) {
|
||||
await onPublish()
|
||||
return
|
||||
}
|
||||
|
||||
let profileExists: boolean
|
||||
try {
|
||||
profileExists = await checkProfile()
|
||||
@@ -131,11 +161,13 @@ async function handlePublish() {
|
||||
}
|
||||
|
||||
if (profileExists) {
|
||||
onPublish()
|
||||
await onPublish()
|
||||
return
|
||||
}
|
||||
|
||||
onRequireProfile()
|
||||
} catch (error) {
|
||||
toastErrorHandler(error)
|
||||
} finally {
|
||||
isResolvingPublishAccess.value = false
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<legend class="text-sm text-base-foreground">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-base-foreground select-none">
|
||||
{{ $t('comfyHubPublish.selectAThumbnail') }}
|
||||
</legend>
|
||||
</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
:model-value="thumbnailType"
|
||||
@@ -14,18 +14,19 @@
|
||||
v-for="option in thumbnailOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="h-auto w-full rounded-sm bg-node-component-surface p-2 data-[state=on]:bg-muted-background"
|
||||
class="flex h-auto w-full gap-2 rounded-sm bg-node-component-surface p-2 font-inter text-base-foreground data-[state=on]:bg-muted-background"
|
||||
>
|
||||
<span class="text-center text-sm font-bold text-base-foreground">
|
||||
<i :class="cn('size-4', option.icon)" />
|
||||
<span class="text-center text-sm font-bold">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
<span class="text-sm text-base-foreground select-none">
|
||||
{{ uploadSectionLabel }}
|
||||
</span>
|
||||
<Button
|
||||
@@ -40,7 +41,7 @@
|
||||
|
||||
<template v-if="thumbnailType === 'imageComparison'">
|
||||
<div
|
||||
class="grid flex-1 grid-cols-1 grid-rows-1 place-content-center-safe"
|
||||
class="grid flex-1 grid-cols-1 grid-rows-1 place-content-center-safe overflow-hidden"
|
||||
>
|
||||
<div
|
||||
v-if="hasBothComparisonImages"
|
||||
@@ -69,7 +70,7 @@
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'col-span-full row-span-full flex gap-2',
|
||||
'col-span-full row-span-full flex items-center-safe justify-center-safe gap-2',
|
||||
hasBothComparisonImages && 'invisible'
|
||||
)
|
||||
"
|
||||
@@ -80,8 +81,10 @@
|
||||
:ref="(el) => (comparisonDropRefs[slot.key] = el as HTMLElement)"
|
||||
:class="
|
||||
cn(
|
||||
'flex max-w-1/2 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
|
||||
comparisonPreviewUrls[slot.key] ? 'self-start' : 'flex-1',
|
||||
'flex aspect-square h-full min-h-0 flex-[0_1_auto] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed text-center transition-colors',
|
||||
comparisonPreviewUrls[slot.key]
|
||||
? 'self-start'
|
||||
: 'flex-[0_1_1]',
|
||||
comparisonOverStates[slot.key]
|
||||
? 'border-muted-foreground'
|
||||
: 'border-border-default hover:border-muted-foreground'
|
||||
@@ -123,7 +126,7 @@
|
||||
ref="singleDropRef"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
|
||||
'm-auto flex aspect-square min-h-0 w-full max-w-48 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed text-center transition-colors',
|
||||
thumbnailPreviewUrl ? 'self-center p-1' : 'flex-1',
|
||||
isOverSingleDrop
|
||||
? 'border-muted-foreground'
|
||||
@@ -239,15 +242,18 @@ const uploadDropText = computed(() =>
|
||||
const thumbnailOptions = [
|
||||
{
|
||||
value: 'image' as const,
|
||||
label: t('comfyHubPublish.thumbnailImage')
|
||||
label: t('comfyHubPublish.thumbnailImage'),
|
||||
icon: 'icon-[lucide--image]'
|
||||
},
|
||||
{
|
||||
value: 'video' as const,
|
||||
label: t('comfyHubPublish.thumbnailVideo')
|
||||
label: t('comfyHubPublish.thumbnailVideo'),
|
||||
icon: 'icon-[lucide--video]'
|
||||
},
|
||||
{
|
||||
value: 'imageComparison' as const,
|
||||
label: t('comfyHubPublish.thumbnailImageComparison')
|
||||
label: t('comfyHubPublish.thumbnailImageComparison'),
|
||||
icon: 'icon-[lucide--diff]'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user