Compare commits

...

18 Commits

Author SHA1 Message Date
Comfy Org PR Bot
32b6e58859 [backport cloud/1.40] feat: add cloud frontend build dispatch workflow (#9462)
Backport of #9308 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9462-backport-cloud-1-40-feat-add-cloud-frontend-build-dispatch-workflow-31b6d73d36508155854afa23906a9ffd)
by [Unito](https://www.unito.io)

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-03-06 05:14:29 +00:00
Robin Huang
10a680f690 [backport cloud/1.40] feat: Add PostHog telemetry provider (#9457)
Backport of #9409 to `cloud/1.40`.

Automatically created by manual backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9457-backport-cloud-1-40-feat-Add-PostHog-telemetry-provider-31b6d73d365081ac9eb4d596daafddf3)
by [Unito](https://www.unito.io)
2026-03-05 20:54:12 -08:00
Alexander Brown
416f96649b [backport cloud/1.40] feat: workflow sharing and ComfyHub publish flow (#9454)
Backport of #8951 to `cloud/1.40`.

Cherry-pick of merge commit `1bac5d9bddd2106b04f330733a45094df379b592`
with conflict resolution.

## Conflict Resolution

- **TopMenuSection.vue**: Kept both queue context menu (from target) and
share tooltip/button (from PR)
- **StatusBadge.vue**: Accepted PR version (adds `class` prop,
`badgeClass` computed with `cn()`)
- **WorkflowTab.vue**: Kept target branch version — PR's context menu
additions depend on `WorkflowActionsList.vue` and
`types/workflowMenuItem` which don't exist on `cloud/1.40`
- **composables/README.md**: Merged both — kept `useValueTransform` from
target, used `useWorkflowPersistenceV2` from PR
- **Textarea.vue**: Accepted PR version (new file, deleted in target)
- **Binary snapshot**: Accepted PR version
- **pnpm-lock.yaml**: Regenerated with `pnpm install`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9454-backport-cloud-1-40-feat-workflow-sharing-and-ComfyHub-publish-flow-31b6d73d3650813ebd55e0d2a24860ec)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-06 02:42:47 +00:00
Comfy Org PR Bot
964002cd2a [backport cloud/1.40] [bugfix] Fix workspace dialog pt override losing base styles (#9366)
Backport of #9188 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9366-backport-cloud-1-40-bugfix-Fix-workspace-dialog-pt-override-losing-base-styles-3196d73d3650814e84f1f22dd2e6c10a)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-03 18:37:09 -08:00
Hunter
130e1eed7a [backport cloud/1.40] feat: add ever-present upgrade button for free-tier users (#9316)
Backport of #9315 to `cloud/1.40`

Automatically created by manual backport.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9316-backport-cloud-1-40-feat-add-ever-present-upgrade-button-for-free-tier-users-3166d73d365081039257c489cabc6b38)
by [Unito](https://www.unito.io)
2026-02-28 20:38:41 -08:00
Comfy Org PR Bot
fdda951516 [backport cloud/1.40] fix: remove beta labeling from comfy cloud badges (#9280)
Backport of #9184 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9280-backport-cloud-1-40-fix-remove-beta-labeling-from-comfy-cloud-badges-3146d73d365081129135db83e379de7f)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-02-28 04:15:56 -08:00
Comfy Org PR Bot
23f26efd94 [backport cloud/1.40] fix: add GLSLShader to toolkit node telemetry tracking (#9240)
Backport of #9197 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9240-backport-cloud-1-40-fix-add-GLSLShader-to-toolkit-node-telemetry-tracking-3136d73d365081f6be37e4414eccae20)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-25 23:42:55 -08:00
Comfy Org PR Bot
afbdea2956 [backport cloud/1.40] feat: add toolkit node tracking to execution telemetry (#9238)
Backport of #9073 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9238-backport-cloud-1-40-feat-add-toolkit-node-tracking-to-execution-telemetry-3136d73d365081c99c09f79ffde87773)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-25 23:00:03 -08:00
Comfy Org PR Bot
c46a95bd90 [backport cloud/1.40] Prevent serialization of progress text to prompt (#9225)
Backport of #9221 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9225-backport-cloud-1-40-Prevent-serialization-of-progress-text-to-prompt-3136d73d365081f29603f360cf6edded)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-02-25 17:38:39 -08:00
Comfy Org PR Bot
58460ada48 [backport cloud/1.40] feat: add Free subscription tier support (#9190)
Backport of #8864 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9190-backport-cloud-1-40-feat-add-Free-subscription-tier-support-3126d73d36508153b889ddc3c5d01f57)
by [Unito](https://www.unito.io)

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-02-24 23:49:58 -05:00
Comfy Org PR Bot
9ba3d75c5b [backport cloud/1.40] fix: prevent infinite node resize loop in Vue mode (#9179)
Backport of #9177 to `cloud/1.40`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-02-24 21:18:38 +00:00
AustinMroz
c7cbefc256 [backport cloud/1.40] fix: sync DOM widget values to widgetValueStore on registration (#9174)
Backport of #9166 to cloud/1.40

Manually created.

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-02-24 10:47:43 -08:00
Comfy Org PR Bot
4daba09415 [backport cloud/1.40] fix: open image in new tab on cloud fetches as blob to avoid GCS auto-download (#9158)
Backport of #9122 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9158-backport-cloud-1-40-fix-open-image-in-new-tab-on-cloud-fetches-as-blob-to-avoid-GCS-au-3116d73d365081789cc2fffb3fb538f3)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-23 22:13:19 -08:00
Comfy Org PR Bot
b6ca126eff [backport cloud/1.40] fix: fix error overlay and TabErrors filtering for nested subgraphs (#9133)
Backport of #9129 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9133-backport-cloud-1-40-fix-fix-error-overlay-and-TabErrors-filtering-for-nested-subgraphs-3106d73d3650818cb0d9d8e96e60ae37)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-02-23 04:14:00 -08:00
Comfy Org PR Bot
d4f6a9af0e [backport cloud/1.40] [refactor] Extract executionErrorStore from executionStore (#9131)
Backport of #9060 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9131-backport-cloud-1-40-refactor-Extract-executionErrorStore-from-executionStore-3106d73d3650815e8713d30979eed798)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-02-23 04:01:08 -08:00
Comfy Org PR Bot
b654d7c06a [backport cloud/1.40] feat(node): show Enter Subgraph and Error buttons side by side in node footer (#9128)
Backport of #9126 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9128-backport-cloud-1-40-feat-node-show-Enter-Subgraph-and-Error-buttons-side-by-side-in-n-3106d73d3650818e8a61c8c6efab2a93)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-02-23 01:18:43 -08:00
Benjamin Lu
d3b67511f9 fix: backport load 3D dialog defineAsyncComponent to cloud/1.40 (#9092)
## Summary
Backport the `defineAsyncComponent` fix from #8990 to `cloud/1.40` so
the 3D inspect dialog renders the component instead of a promise
payload.

## Changes
- Add `defineAsyncComponent` import in `AssetsSidebarTab.vue`
- Wrap `Load3dViewerContent` lazy import with
`defineAsyncComponent(...)`

## Validation
- `pnpm typecheck`
- `pnpm lint`
- pre-push hook (`pnpm knip --cache`) ran during `git push`

Backports #8990.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9092-fix-backport-load-3D-dialog-defineAsyncComponent-to-cloud-1-40-30f6d73d365081eb9ea6f2d771cbd762)
by [Unito](https://www.unito.io)
2026-02-22 02:03:54 -08:00
Comfy Org PR Bot
92a193203d [backport cloud/1.40] feat: add feature flag to disable Essentials tab in node library (#9082)
Backport of #9067 to `cloud/1.40`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9082-backport-cloud-1-40-feat-add-feature-flag-to-disable-Essentials-tab-in-node-library-30f6d73d3650818ab63dd02841657bec)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-02-21 22:29:52 -08:00
153 changed files with 11639 additions and 3642 deletions

3
.github/license-clarifications.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"posthog-js@*": { "licenses": "Apache-2.0" }
}

View File

@@ -79,3 +79,22 @@ jobs:
exit 1
fi
echo '✅ No Mixpanel references found'
- name: Scan dist for PostHog telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for PostHog references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '(?i)posthog\.init' \
-e '(?i)posthog\.capture' \
-e 'PostHogTelemetryProvider' \
-e 'ph\.comfy\.org' \
-e 'posthog-js' \
dist; then
echo '❌ ERROR: PostHog references found in dist assets!'
echo 'PostHog must be properly tree-shaken from OSS builds.'
exit 1
fi
echo '✅ No PostHog references found'

View File

@@ -0,0 +1,45 @@
---
# Dispatches a frontend-asset-build event to the cloud repo on push to
# cloud/* branches and main. The cloud repo handles the actual build,
# GCS upload, and secret management (Sentry, Algolia, GCS creds).
#
# This is fire-and-forget — it does NOT wait for the cloud workflow to
# complete. Status is visible in the cloud repo's Actions tab.
name: Cloud Frontend Build Dispatch
on:
push:
branches:
- 'cloud/*'
- 'main'
workflow_dispatch:
permissions: {}
concurrency:
group: cloud-dispatch-${{ github.ref }}
cancel-in-progress: true
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:
- name: Build client payload
id: payload
run: |
payload="$(jq -nc \
--arg ref "${GITHUB_SHA}" \
--arg branch "${GITHUB_REF_NAME}" \
'{ref: $ref, branch: $branch}')"
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
- name: Dispatch to cloud repo
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
repository: Comfy-Org/cloud
event-type: frontend-asset-build
client-payload: ${{ steps.payload.outputs.json }}

View File

@@ -90,7 +90,6 @@ const preview: Preview = {
{ value: 'light', icon: 'sun', title: 'Light' },
{ value: 'dark', icon: 'moon', title: 'Dark' }
],
showName: true,
dynamicTitle: true
}
}

View File

@@ -332,12 +332,9 @@ test.describe('Workflows sidebar', () => {
.getPersistedItem('workflow1.json')
.click({ button: 'right' })
await comfyPage.contextMenu.clickMenuItem('Duplicate')
await comfyPage.nextFrame()
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'*workflow1 (Copy).json'
])
await expect
.poll(() => workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', '*workflow1 (Copy).json'])
})
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

2
global.d.ts vendored
View File

@@ -33,6 +33,8 @@ interface Window {
gtm_container_id?: string
ga_measurement_id?: string
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

View File

@@ -60,6 +60,7 @@
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@formkit/auto-animate": "catalog:",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",
"@primeuix/styled": "catalog:",
@@ -99,6 +100,7 @@
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "catalog:",
"posthog-js": "catalog:",
"primeicons": "catalog:",
"primevue": "catalog:",
"reka-ui": "catalog:",

View File

@@ -609,6 +609,10 @@
}
}
@utility bg-subscription-gradient {
background: var(--color-subscription-button-gradient);
}
@utility scrollbar-hide {
scrollbar-width: none;
&::-webkit-scrollbar {

View File

@@ -3952,7 +3952,7 @@ export interface components {
* @description The subscription tier level
* @enum {string}
*/
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
SubscriptionTier: "FREE" | "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
/**
* @description The subscription billing duration
* @enum {string}

4590
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ catalog:
'@alloc/quick-lru': ^5.2.0
'@comfyorg/comfyui-electron-types': 0.6.2
'@eslint/js': ^9.39.1
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind4': ^1.2.0
@@ -76,6 +77,7 @@ catalog:
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0
posthog-js: ^1.358.1
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5

View File

@@ -2,17 +2,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
downloadFile,
extractFilenameFromContentDisposition
extractFilenameFromContentDisposition,
openFileInNewTab
} from '@/base/common/downloadUtil'
let mockIsCloud = false
const { mockIsCloud } = vi.hoisted(() => ({
mockIsCloud: { value: false }
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud
return mockIsCloud.value
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({ addAlert: vi.fn() }))
}))
// Global stubs
const createObjectURLSpy = vi
.spyOn(URL, 'createObjectURL')
@@ -26,7 +37,7 @@ describe('downloadUtil', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
mockIsCloud = false
mockIsCloud.value = false
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
@@ -154,7 +165,7 @@ describe('downloadUtil', () => {
})
it('streams downloads via blob when running in cloud', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -173,6 +184,7 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -183,7 +195,7 @@ describe('downloadUtil', () => {
})
it('logs an error when cloud fetch fails', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchMock.mockResolvedValue({
@@ -197,14 +209,15 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve()
await Promise.resolve() // let fetchAsBlob throw
await Promise.resolve() // let .catch handler run
expect(consoleSpy).toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('uses filename from Content-Disposition header in cloud mode', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -223,6 +236,7 @@ describe('downloadUtil', () => {
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -231,7 +245,7 @@ describe('downloadUtil', () => {
})
it('uses RFC 5987 filename from Content-Disposition header', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -253,6 +267,7 @@ describe('downloadUtil', () => {
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -260,7 +275,7 @@ describe('downloadUtil', () => {
})
it('falls back to provided filename when Content-Disposition is missing', async () => {
mockIsCloud = true
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
@@ -278,6 +293,7 @@ describe('downloadUtil', () => {
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve() // let fetchAsBlob return
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
@@ -285,6 +301,99 @@ describe('downloadUtil', () => {
})
})
describe('openFileInNewTab', () => {
let windowOpenSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.useFakeTimers()
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
})
afterEach(() => {
vi.useRealTimers()
})
it('opens URL directly when not in cloud mode', async () => {
mockIsCloud.value = false
const testUrl = 'https://example.com/image.png'
await openFileInNewTab(testUrl)
expect(windowOpenSpy).toHaveBeenCalledWith(testUrl, '_blank')
expect(fetchMock).not.toHaveBeenCalled()
})
it('opens blank tab synchronously then navigates to blob URL in cloud mode', async () => {
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab(testUrl)
expect(windowOpenSpy).toHaveBeenCalledWith('', '_blank')
expect(fetchMock).toHaveBeenCalledWith(testUrl)
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
expect(mockTab.location.href).toBe('blob:mock-url')
})
it('revokes blob URL after timeout in cloud mode', async () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab('https://example.com/image.png')
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
vi.advanceTimersByTime(60_000)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
})
it('closes blank tab and logs error when cloud fetch fails', async () => {
mockIsCloud.value = true
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: false,
status: 404
} as unknown as Response)
await openFileInNewTab(testUrl)
expect(mockTab.close).toHaveBeenCalled()
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('revokes blob URL immediately if tab was closed by user', async () => {
mockIsCloud.value = true
const blob = new Blob(['test'], { type: 'image/png' })
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
windowOpenSpy.mockReturnValue(mockTab as unknown as Window)
fetchMock.mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob)
} as unknown as Response)
await openFileInNewTab('https://example.com/image.png')
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
expect(mockTab.location.href).toBe('')
})
})
describe('extractFilenameFromContentDisposition', () => {
it('returns null for null header', () => {
expect(extractFilenameFromContentDisposition(null)).toBeNull()

View File

@@ -1,7 +1,9 @@
/**
* Utility functions for downloading files
*/
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
// Constants
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
@@ -112,14 +114,23 @@ export function extractFilenameFromContentDisposition(
return null
}
const downloadViaBlobFetch = async (
/**
* Fetch a URL and return its body as a Blob.
* Shared by download and open-in-new-tab cloud paths.
*/
async function fetchAsBlob(url: string): Promise<Response> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status}`)
}
return response
}
async function downloadViaBlobFetch(
href: string,
fallbackFilename: string
): Promise<void> => {
const response = await fetch(href)
if (!response.ok) {
throw new Error(`Failed to fetch ${href}: ${response.status}`)
}
): Promise<void> {
const response = await fetchAsBlob(href)
// Try to get filename from Content-Disposition header (set by backend)
const contentDisposition = response.headers.get('Content-Disposition')
@@ -129,3 +140,44 @@ const downloadViaBlobFetch = async (
const blob = await response.blob()
downloadBlob(headerFilename ?? fallbackFilename, blob)
}
/**
* Open a file URL in a new browser tab.
* On cloud, fetches the resource as a blob first to avoid GCS redirects
* that would trigger an auto-download instead of displaying the file.
*
* Opens the tab synchronously to preserve the user-gesture context
* (browsers block window.open after an await), then navigates it to
* the blob URL once the fetch completes.
*/
export async function openFileInNewTab(url: string): Promise<void> {
if (!isCloud) {
window.open(url, '_blank')
return
}
// Open immediately to preserve user-gesture activation.
const tab = window.open('', '_blank')
try {
const response = await fetchAsBlob(url)
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
if (tab && !tab.closed) {
tab.location.href = blobUrl
// Revoke after the tab has had time to load the blob.
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
} else {
URL.revokeObjectURL(blobUrl)
}
} catch (error) {
tab?.close()
console.error('Failed to open image:', error)
useToastStore().addAlert(
t('toastMessages.errorOpenImage', {
error: error instanceof Error ? error.message : String(error)
})
)
}
}

View File

@@ -51,6 +51,7 @@
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@@ -98,6 +99,19 @@
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--share-2] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
@@ -169,11 +183,17 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isDesktop } from '@/platform/distribution/types'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
openShareDialog,
prefetchShareDialog
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
@@ -183,12 +203,14 @@ const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const { flags } = useFeatureFlags()
const { isLoggedIn } = useCurrentUser()
const { t, n } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const queueUIStore = useQueueUIStore()
const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
@@ -257,12 +279,15 @@ const queueContextMenuItems = computed<MenuItem[]>(() => [
}
}
])
const shareTooltipConfig = computed(() =>
buildTooltipConfig(t('actionbar.shareTooltip'))
)
const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
})
const { hasAnyError } = storeToRefs(executionStore)
const { hasAnyError } = storeToRefs(executionErrorStore)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)

View File

@@ -1,27 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { statusBadgeVariants } from './statusBadge.variants'
import type { StatusBadgeVariants } from './statusBadge.variants'
const {
label,
severity = 'default',
variant
variant,
class: className
} = defineProps<{
label?: string | number
severity?: StatusBadgeVariants['severity']
variant?: StatusBadgeVariants['variant']
class?: string
}>()
const badgeClass = computed(() =>
cn(
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
}),
className
)
)
</script>
<template>
<span
:class="
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
"
>
<span :class="badgeClass">
{{ label }}
</span>
</template>

View File

@@ -64,17 +64,17 @@ import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
const { t } = useI18n()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionStore)
const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
const { groupedErrorMessages } = useErrorGroups(ref(''), t)
const errorCountLabel = computed(() =>
@@ -90,7 +90,7 @@ const isVisible = computed(
)
function dismiss() {
executionStore.dismissErrorOverlay()
executionErrorStore.dismissErrorOverlay()
}
function seeErrors() {
@@ -100,6 +100,6 @@ function seeErrors() {
}
rightSidePanelStore.openPanel('errors')
executionStore.dismissErrorOverlay()
executionErrorStore.dismissErrorOverlay()
}
</script>

View File

@@ -18,6 +18,7 @@
>
<WorkflowTabs />
<TopbarBadges />
<TopbarSubscribeButton />
</div>
</div>
</template>
@@ -70,7 +71,7 @@
:key="nodeData.id"
:node-data="nodeData"
:error="
executionStore.lastExecutionError?.node_id === nodeData.id
executionErrorStore.lastExecutionError?.node_id === nodeData.id
? 'Execution error'
: null
"
@@ -133,6 +134,7 @@ import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
@@ -170,6 +172,7 @@ import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
@@ -196,6 +199,7 @@ const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const toastStore = useToastStore()
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
@@ -376,7 +380,7 @@ watch(
// Update node slot errors for LiteGraph nodes
// (Vue nodes read from store directly)
watch(
() => executionStore.lastNodeErrors,
() => executionErrorStore.lastNodeErrors,
(lastNodeErrors) => {
if (!comfyApp.graph) return
@@ -524,8 +528,13 @@ onMounted(async () => {
await workflowPersistence.initializeWorkflow()
workflowPersistence.restoreWorkflowTabsState()
const sharedWorkflowLoadStatus =
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
// Load template from URL if present
await workflowPersistence.loadTemplateFromUrlIfPresent()
if (sharedWorkflowLoadStatus === 'not-present') {
await workflowPersistence.loadTemplateFromUrlIfPresent()
}
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts

View File

@@ -14,7 +14,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -36,12 +36,12 @@ import SubgraphEditor from './subgraph/SubgraphEditor.vue'
import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionStore)
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
const { findParentGroup } = useGraphHierarchy()
@@ -98,7 +98,7 @@ type RightSidePanelTabList = Array<{
const hasDirectNodeError = computed(() =>
selectedNodes.value.some((node) =>
executionStore.activeGraphErrorNodeIds.has(String(node.id))
executionErrorStore.activeGraphErrorNodeIds.has(String(node.id))
)
)
@@ -106,7 +106,7 @@ const hasContainerInternalError = computed(() => {
if (allErrorExecutionIds.value.length === 0) return false
return selectedNodes.value.some((node) => {
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
return executionStore.hasInternalErrorForNode(node.id)
return executionErrorStore.isContainerWithInternalError(node)
})
})

View File

@@ -94,7 +94,7 @@ describe('TabErrors.vue', () => {
it('renders prompt-level errors (Group title = error message)', async () => {
const wrapper = mountComponent({
execution: {
executionError: {
lastPromptError: {
type: 'prompt_no_outputs',
message: 'Server Error: No outputs',
@@ -118,7 +118,7 @@ describe('TabErrors.vue', () => {
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
execution: {
executionError: {
lastNodeErrors: {
'6': {
class_type: 'CLIPTextEncode',
@@ -143,7 +143,7 @@ describe('TabErrors.vue', () => {
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
execution: {
executionError: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '10',
@@ -167,7 +167,7 @@ describe('TabErrors.vue', () => {
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
const wrapper = mountComponent({
execution: {
executionError: {
lastNodeErrors: {
'1': {
class_type: 'CLIPTextEncode',
@@ -198,7 +198,7 @@ describe('TabErrors.vue', () => {
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
const wrapper = mountComponent({
execution: {
executionError: {
lastNodeErrors: {
'1': {
class_type: 'TestNode',

View File

@@ -3,15 +3,17 @@ import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { isCloud } from '@/platform/distribution/types'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
getNodeByExecutionId,
getExecutionIdByNode,
getRootParentNode
} from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -19,6 +21,7 @@ import { isLGraphNode } from '@/utils/litegraphUtil'
import { isGroupNode } from '@/utils/executableGroupNodeDto'
import { st } from '@/i18n'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { isNodeExecutionId } from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
@@ -192,38 +195,42 @@ export function useErrorGroups(
searchQuery: Ref<string>,
t: (key: string) => string
) {
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const canvasStore = useCanvasStore()
const collapseState = reactive<Record<string, boolean>>({})
const selectedNodeInfo = computed(() => {
const items = canvasStore.selectedItems
const nodeIds = new Set<string>()
const containerIds = new Set<string>()
const containerExecutionIds = new Set<NodeExecutionId>()
for (const item of items) {
if (!isLGraphNode(item)) continue
nodeIds.add(String(item.id))
if (item instanceof SubgraphNode || isGroupNode(item)) {
containerIds.add(String(item.id))
if (
(item instanceof SubgraphNode || isGroupNode(item)) &&
app.rootGraph
) {
const execId = getExecutionIdByNode(app.rootGraph, item)
if (execId) containerExecutionIds.add(execId)
}
}
return {
nodeIds: nodeIds.size > 0 ? nodeIds : null,
containerIds
containerExecutionIds
}
})
const isSingleNodeSelected = computed(
() =>
selectedNodeInfo.value.nodeIds?.size === 1 &&
selectedNodeInfo.value.containerIds.size === 0
selectedNodeInfo.value.containerExecutionIds.size === 0
)
const errorNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
for (const execId of executionStore.allErrorExecutionIds) {
for (const execId of executionErrorStore.allErrorExecutionIds) {
const node = getNodeByExecutionId(app.rootGraph, execId)
if (node) map.set(execId, node)
}
@@ -237,8 +244,9 @@ export function useErrorGroups(
const graphNode = errorNodeCache.value.get(executionNodeId)
if (graphNode && nodeIds.has(String(graphNode.id))) return true
for (const containerId of selectedNodeInfo.value.containerIds) {
if (executionNodeId.startsWith(`${containerId}:`)) return true
for (const containerExecId of selectedNodeInfo.value
.containerExecutionIds) {
if (executionNodeId.startsWith(`${containerExecId}:`)) return true
}
return false
@@ -262,10 +270,10 @@ export function useErrorGroups(
}
function processPromptError(groupsMap: Map<string, GroupEntry>) {
if (selectedNodeInfo.value.nodeIds || !executionStore.lastPromptError)
if (selectedNodeInfo.value.nodeIds || !executionErrorStore.lastPromptError)
return
const error = executionStore.lastPromptError
const error = executionErrorStore.lastPromptError
const groupTitle = error.message
const cards = getOrCreateGroup(groupsMap, groupTitle, 0)
const isKnown = KNOWN_PROMPT_ERROR_TYPES.has(error.type)
@@ -293,10 +301,10 @@ export function useErrorGroups(
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionStore.lastNodeErrors) return
if (!executionErrorStore.lastNodeErrors) return
for (const [nodeId, nodeError] of Object.entries(
executionStore.lastNodeErrors
executionErrorStore.lastNodeErrors
)) {
addNodeErrorToGroup(
groupsMap,
@@ -316,9 +324,9 @@ export function useErrorGroups(
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionStore.lastExecutionError) return
if (!executionErrorStore.lastExecutionError) return
const e = executionStore.lastExecutionError
const e = executionErrorStore.lastExecutionError
addNodeErrorToGroup(
groupsMap,
String(e.node_id),

View File

@@ -9,7 +9,7 @@ import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
@@ -62,7 +62,7 @@ watchEffect(() => (widgets.value = widgetsProp))
provide(HideLayoutFieldKey, true)
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const nodeDefStore = useNodeDefStore()
const { t } = useI18n()
@@ -110,7 +110,9 @@ const targetNode = computed<LGraphNode | null>(() => {
const hasDirectError = computed(() => {
if (!targetNode.value) return false
return executionStore.activeGraphErrorNodeIds.has(String(targetNode.value.id))
return executionErrorStore.activeGraphErrorNodeIds.has(
String(targetNode.value.id)
)
})
const hasContainerInternalError = computed(() => {
@@ -119,7 +121,7 @@ const hasContainerInternalError = computed(() => {
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
if (!isContainer) return false
return executionStore.hasInternalErrorForNode(targetNode.value.id)
return executionErrorStore.isContainerWithInternalError(targetNode.value)
})
const nodeHasError = computed(() => {

View File

@@ -53,6 +53,7 @@ import NodeSearchCategoryTreeNode, {
CATEGORY_UNSELECTED_CLASS
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
@@ -64,6 +65,7 @@ const selectedCategory = defineModel<string>('selectedCategory', {
})
const { t } = useI18n()
const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()
const topCategories = computed(() => [
@@ -79,7 +81,7 @@ const hasEssentialNodes = computed(() =>
const sourceCategories = computed(() => {
const categories = []
if (hasEssentialNodes.value) {
if (flags.nodeLibraryEssentialsEnabled && hasEssentialNodes.value) {
categories.push({ id: 'essentials', label: t('g.essentials') })
}
categories.push({ id: 'custom', label: t('g.custom') })

View File

@@ -204,13 +204,22 @@ import {
} from '@vueuse/core'
import Divider from 'primevue/divider'
import { useToast } from 'primevue/usetoast'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
// Lazy-loaded to avoid pulling THREE.js into the main bundle
const Load3dViewerContent = () =>
import('@/components/load3d/Load3dViewerContent.vue')
const Load3dViewerContent = defineAsyncComponent(
() => import('@/components/load3d/Load3dViewerContent.vue')
)
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'

View File

@@ -52,7 +52,7 @@
:value="tab.value"
:class="
cn(
'select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'flex-1 text-center select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'text-sm text-foreground transition-colors',
selectedTab === tab.value
? 'bg-comfy-input font-bold'
@@ -70,7 +70,9 @@
<!-- Tab content (scrollable) -->
<TabsRoot v-model="selectedTab" class="h-full">
<EssentialNodesPanel
v-if="selectedTab === 'essentials'"
v-if="
flags.nodeLibraryEssentialsEnabled && selectedTab === 'essentials'
"
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
@node-click="handleNodeClick"
@@ -109,10 +111,11 @@ import {
TabsRoot,
TabsTrigger
} from 'reka-ui'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBoxV2.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'
import {
@@ -136,11 +139,22 @@ import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
const { flags } = useFeatureFlags()
const selectedTab = useLocalStorage<TabId>(
'Comfy.NodeLibrary.Tab',
DEFAULT_TAB_ID
)
watchEffect(() => {
if (
!flags.nodeLibraryEssentialsEnabled &&
selectedTab.value === 'essentials'
) {
selectedTab.value = DEFAULT_TAB_ID
}
})
const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
'Comfy.NodeLibrary.SortByTab',
{
@@ -324,11 +338,21 @@ async function handleSearch() {
expandedKeys.value = allKeys
}
const tabs = computed(() => [
{ value: 'essentials', label: t('sideToolbar.nodeLibraryTab.essentials') },
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
])
const tabs = computed(() => {
const baseTabs: Array<{ value: TabId; label: string }> = [
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
]
return flags.nodeLibraryEssentialsEnabled
? [
{
value: 'essentials' as TabId,
label: t('sideToolbar.nodeLibraryTab.essentials')
},
...baseTabs
]
: baseTabs
})
onMounted(() => {
searchBoxRef.value?.focus()

View File

@@ -10,7 +10,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'
@@ -31,10 +30,8 @@ withDefaults(
}
)
const { t } = useI18n()
const cloudBadge = computed<TopbarBadgeType>(() => ({
label: t('g.beta'),
icon: 'icon-[lucide--cloud]',
text: 'Comfy Cloud'
}))
</script>

View File

@@ -113,12 +113,13 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
}))
// Mock the useSubscriptionDialog composable
const mockSubscriptionDialogShow = vi.fn()
const mockShowPricingTable = vi.fn()
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: vi.fn(() => ({
show: mockSubscriptionDialogShow,
show: vi.fn(),
showPricingTable: mockShowPricingTable,
hide: vi.fn()
}))
})
@@ -318,8 +319,8 @@ describe('CurrentUserPopoverLegacy', () => {
await plansPricingItem.trigger('click')
// Verify subscription dialog show was called
expect(mockSubscriptionDialogShow).toHaveBeenCalled()
// Verify showPricingTable was called
expect(mockShowPricingTable).toHaveBeenCalled()
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()

View File

@@ -46,6 +46,16 @@
class="icon-[lucide--circle-help] cursor-help text-base text-muted-foreground mr-auto"
/>
<Button
v-if="isFreeTier"
variant="gradient"
size="sm"
data-testid="upgrade-to-add-credits-button"
@click="handleUpgradeToAddCredits"
>
{{ $t('subscription.upgradeToAddCredits') }}
</Button>
<Button
v-else
variant="secondary"
size="sm"
class="text-base-foreground"
@@ -61,7 +71,7 @@
:fluid="false"
:label="$t('subscription.subscribeToComfyCloud')"
size="sm"
variant="gradient"
button-variant="gradient"
@subscribed="handleSubscribed"
/>
</div>
@@ -170,6 +180,7 @@ const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
isFreeTier,
subscriptionTierName,
subscriptionTier,
fetchStatus
@@ -195,7 +206,10 @@ const formattedBalance = computed(() => {
const canUpgrade = computed(() => {
const tier = subscriptionTier.value
return (
tier === 'FOUNDERS_EDITION' || tier === 'STANDARD' || tier === 'CREATOR'
tier === 'FREE' ||
tier === 'FOUNDERS_EDITION' ||
tier === 'STANDARD' ||
tier === 'CREATOR'
)
})
@@ -205,7 +219,7 @@ const handleOpenUserSettings = () => {
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.show()
subscriptionDialog.showPricingTable()
emit('close')
}
@@ -234,6 +248,11 @@ const handleOpenPartnerNodesInfo = () => {
emit('close')
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable()
emit('close')
}
const handleLogout = async () => {
await handleSignOut()
emit('close')

View File

@@ -0,0 +1,25 @@
<template>
<Button
v-if="isFreeTier"
class="mr-2 shrink-0 whitespace-nowrap"
variant="gradient"
size="sm"
data-testid="topbar-subscribe-button"
@click="handleClick"
>
{{ $t('subscription.subscribeForMore') }}
</Button>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
const { isFreeTier } = useBillingContext()
const subscriptionDialog = useSubscriptionDialog()
function handleClick() {
subscriptionDialog.showPricingTable()
}
</script>

View File

@@ -76,7 +76,7 @@
v-if="isLoggedIn"
:show-arrow="false"
compact
class="shrink-0 p-1"
class="shrink-0 p-1 grid w-10"
/>
<LoginButton v-else-if="isDesktop" class="p-1" />
</div>

View File

@@ -19,7 +19,9 @@ export const buttonVariants = cva({
'text-muted-foreground bg-transparent hover:bg-secondary-background-hover',
'destructive-textonly':
'text-destructive-background bg-transparent hover:bg-destructive-background/10',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90'
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
gradient:
'bg-subscription-gradient text-white border-transparent hover:opacity-90'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -47,7 +49,8 @@ const variants = [
'textonly',
'muted-textonly',
'destructive-textonly',
'overlay-white'
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
ButtonVariants['size']

View File

@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Input from './Input.vue'
const meta: Meta<typeof Input> = {
title: 'Components/Input',
component: Input,
tags: ['autodocs'],
render: (args) => ({
components: { Input },
setup: () => ({ args }),
template: '<Input v-bind="args" placeholder="Enter text..." />'
})
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithValue: Story = {
args: {
modelValue: 'Hello, world!'
}
}
export const Disabled: Story = {
render: (args) => ({
components: { Input },
setup: () => ({ args }),
template: '<Input v-bind="args" placeholder="Disabled input" disabled />'
})
}

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useTemplateRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const modelValue = defineModel<string | number>()
const inputRef = useTemplateRef<HTMLInputElement>('inputEl')
defineExpose({
focus: () => inputRef.value?.focus(),
select: () => inputRef.value?.select()
})
</script>
<template>
<input
ref="inputEl"
v-model="modelValue"
:class="
cn(
'flex h-10 w-full min-w-0 appearance-none rounded-lg border-none bg-secondary-background px-4 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-default disabled:pointer-events-none disabled:opacity-50',
className
)
"
/>
</template>

View File

@@ -16,9 +16,15 @@ import type { FocusCallback } from './tagsInputContext'
const {
disabled = false,
alwaysEditing = false,
class: className,
...restProps
} = defineProps<TagsInputRootProps<T> & { class?: HTMLAttributes['class'] }>()
} = defineProps<
TagsInputRootProps<T> & {
class?: HTMLAttributes['class']
alwaysEditing?: boolean
}
>()
const emits = defineEmits<TagsInputRootEmits<T>>()
const isEditing = ref(false)
@@ -28,9 +34,10 @@ const focusInput = ref<FocusCallback>()
provide(tagsInputFocusKey, (callback: FocusCallback) => {
focusInput.value = callback
})
provide(tagsInputIsEditingKey, isEditing)
const isEditingEnabled = computed(() => alwaysEditing || isEditing.value)
provide(tagsInputIsEditingKey, isEditingEnabled)
const internalDisabled = computed(() => disabled || !isEditing.value)
const internalDisabled = computed(() => disabled || !isEditingEnabled.value)
const delegatedProps = computed(() => ({
...restProps,
@@ -40,7 +47,7 @@ const delegatedProps = computed(() => ({
const forwarded = useForwardPropsEmits(delegatedProps, emits)
async function enableEditing() {
if (!disabled && !isEditing.value) {
if (!disabled && !alwaysEditing && !isEditing.value) {
isEditing.value = true
await nextTick()
focusInput.value?.()
@@ -48,7 +55,9 @@ async function enableEditing() {
}
onClickOutside(rootEl, () => {
isEditing.value = false
if (!alwaysEditing) {
isEditing.value = false
}
})
</script>
@@ -61,7 +70,7 @@ onClickOutside(rootEl, () => {
'group relative flex flex-wrap items-center gap-2 rounded-lg bg-transparent p-2 text-xs text-base-foreground',
!internalDisabled &&
'hover:bg-modal-card-background-hovered focus-within:bg-modal-card-background-hovered',
!disabled && !isEditing && 'cursor-pointer',
!disabled && !isEditingEnabled && 'cursor-pointer',
className
)
"
@@ -69,7 +78,7 @@ onClickOutside(rootEl, () => {
>
<slot :is-empty="modelValue.length === 0" />
<i
v-if="!disabled && !isEditing"
v-if="!disabled && !isEditingEnabled"
aria-hidden="true"
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground transition-opacity opacity-0 group-hover:opacity-100"
/>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restAttrs } = defineProps<{
class?: HTMLAttributes['class']
}>()
const modelValue = defineModel<string | number>()
</script>
<template>
<textarea
v-bind="restAttrs"
v-model="modelValue"
:class="
cn(
'flex min-h-16 w-full rounded-lg border-none bg-secondary-background px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-default disabled:pointer-events-none disabled:opacity-50',
className
)
"
/>
</template>

View File

@@ -77,9 +77,7 @@
>
{{ contentTitle }}
</h2>
<div
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
>
<div :class="contentContainerClass">
<slot name="content" />
</div>
</main>
@@ -153,15 +151,20 @@ const SIZE_CLASSES = {
} as const
type ModalSize = keyof typeof SIZE_CLASSES
type ContentPadding = 'default' | 'compact' | 'none'
const {
contentTitle,
rightPanelTitle,
size = 'lg'
size = 'lg',
leftPanelWidth = '14rem',
contentPadding = 'default'
} = defineProps<{
contentTitle: string
rightPanelTitle?: string
size?: ModalSize
leftPanelWidth?: string
contentPadding?: ContentPadding
}>()
const sizeClasses = computed(() => SIZE_CLASSES[size])
@@ -197,10 +200,18 @@ const showLeftPanel = computed(() => {
return shouldShow
})
const contentContainerClass = computed(() =>
cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto scrollbar-custom',
contentPadding === 'default' && 'px-6 pt-0 pb-10',
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
)
)
const gridStyle = computed(() => ({
gridTemplateColumns: hasRightPanel.value
? `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
: `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr`
? `${hasLeftPanel.value && showLeftPanel.value ? leftPanelWidth : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
: `${hasLeftPanel.value && showLeftPanel.value ? leftPanelWidth : '0rem'} 1fr`
}))
const toggleLeftPanel = () => {

View File

@@ -247,7 +247,7 @@ General-purpose composables:
| `useTreeExpansion` | Handles tree node expansion state |
| `useValueTransform` | Transforms values between formats |
| `useWorkflowAutoSave` | Handles automatic workflow saving |
| `useWorkflowPersistence` | Manages workflow persistence |
| `useWorkflowPersistenceV2` | Manages workflow persistence |
| `useWorkflowValidation` | Validates workflow integrity |
## Usage Guidelines

View File

@@ -70,6 +70,11 @@ export interface BillingState {
* Equivalent to `subscription.value?.isActive ?? false`
*/
isActiveSubscription: ComputedRef<boolean>
/**
* Whether the current billing context has a FREE tier subscription.
* Workspace-aware: reflects the active workspace's tier, not the user's personal tier.
*/
isFreeTier: ComputedRef<boolean>
}
export interface BillingContext extends BillingState, BillingActions {

View File

@@ -120,6 +120,8 @@ function useBillingContextInternal(): BillingContext {
toValue(activeContext.value.isActiveSubscription)
)
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
@@ -238,6 +240,7 @@ function useBillingContextInternal(): BillingContext {
isLoading,
error,
isActiveSubscription,
isFreeTier,
getMaxSeats,
initialize,

View File

@@ -40,6 +40,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
const error = ref<string | null>(null)
const isActiveSubscription = computed(() => legacyIsActiveSubscription.value)
const isFreeTier = computed(() => subscriptionTier.value === 'FREE')
const subscription = computed<SubscriptionInfo | null>(() => {
if (!legacyIsActiveSubscription.value && !subscriptionTier.value) {
@@ -85,6 +86,10 @@ export function useLegacyBilling(): BillingState & BillingActions {
error.value = null
try {
await Promise.all([fetchStatus(), fetchBalance()])
// Re-fetch balance if free tier credits were just lazily granted
if (isFreeTier.value && balance.value?.amountMicros === 0) {
await fetchBalance()
}
isInitialized.value = true
} catch (err) {
error.value =
@@ -173,6 +178,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
isLoading,
error,
isActiveSubscription,
isFreeTier,
// Actions
initialize,

View File

@@ -1,6 +1,6 @@
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useCommandStore } from '@/stores/commandStore'
@@ -23,7 +23,7 @@ export function useImageMenuOptions() {
if (!img) return
const url = new URL(img.src)
url.searchParams.delete('preview')
window.open(url.toString(), '_blank')
void openFileInNewTab(url.toString())
}
const copyImage = async (node: LGraphNode) => {

View File

@@ -21,7 +21,11 @@ export enum ServerFeatureFlag {
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements'
NODE_REPLACEMENTS = 'node_replacements',
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
WORKFLOW_SHARING_ENABLED = 'workflow_sharing_enabled',
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled'
}
/**
@@ -100,6 +104,38 @@ export function useFeatureFlags() {
},
get nodeReplacementsEnabled() {
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
},
get nodeLibraryEssentialsEnabled() {
if (isNightly || import.meta.env.DEV) return true
return (
remoteConfig.value.node_library_essentials_enabled ??
api.getServerFeature(
ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED,
false
)
)
},
get workflowSharingEnabled() {
return (
remoteConfig.value.workflow_sharing_enabled ??
api.getServerFeature(ServerFeatureFlag.WORKFLOW_SHARING_ENABLED, false)
)
},
get comfyHubUploadEnabled() {
return (
remoteConfig.value.comfyhub_upload_enabled ??
api.getServerFeature(ServerFeatureFlag.COMFYHUB_UPLOAD_ENABLED, false)
)
},
get comfyHubProfileGateEnabled() {
return (
remoteConfig.value.comfyhub_profile_gate_enabled ??
api.getServerFeature(
ServerFeatureFlag.COMFYHUB_PROFILE_GATE_ENABLED,
false
)
)
}
})

View File

@@ -0,0 +1,41 @@
/**
* Toolkit (Essentials) node detection constants.
*
* Used by telemetry to track toolkit node adoption and popularity.
* Only novel nodes — basic nodes (LoadImage, SaveImage, etc.) are excluded.
*
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
*/
/**
* Canonical node type names for individual toolkit nodes.
*/
export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
// Image Tools
'ImageCrop',
'ImageRotate',
'ImageBlur',
'ImageInvert',
'ImageCompare',
'Canny',
// Video Tools
'Video Slice',
// API Nodes
'RecraftRemoveBackgroundNode',
'RecraftVectorizeImageNode',
'KlingOmniProEditVideoNode',
// Shader Nodes
'GLSLShader'
])
/**
* python_module values that identify toolkit blueprint nodes.
* Essentials blueprints are registered with node_pack 'comfy_essentials',
* which maps to python_module on the node def.
*/
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
'comfy_essentials'
])

View File

@@ -1,7 +1,6 @@
import { computed } from 'vue'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { t } from '@/i18n'
import { useExtensionService } from '@/services/extensionService'
import type { TopbarBadge } from '@/types/comfy'
@@ -21,7 +20,7 @@ const badges = computed<TopbarBadge[]>(() => {
// Always add cloud badge last (furthest right)
result.push({
label: t('g.beta'),
icon: 'icon-[lucide--cloud]',
text: 'Comfy Cloud'
})

View File

@@ -4187,7 +4187,12 @@ export class LGraphNode
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/2652
// TODO: Move the layout logic before drawing of the node shape, so we don't
// need to trigger extra round of rendering.
if (y > bodyHeight) {
// In Vue mode, the DOM is the source of truth for node sizing — the
// ResizeObserver feeds measurements back to the layout store. Allowing
// LiteGraph to also call setSize() here creates an infinite feedback loop
// (LG grows node → CSS min-height increases → textarea fills extra space →
// ResizeObserver reports larger size → LG grows node again).
if (!LiteGraph.vueNodesMode && y > bodyHeight) {
this.setSize([this.size[0], y])
this.graph.setDirtyCanvas(false, true)
}

View File

@@ -69,6 +69,7 @@
"icon": "Icon",
"color": "Color",
"error": "Error",
"enter": "Enter",
"enterSubgraph": "Enter Subgraph",
"resizeFromBottomRight": "Resize from bottom-right corner",
"resizeFromTopRight": "Resize from top-right corner",
@@ -1900,6 +1901,7 @@
"nodeDefinitionsUpdated": "Node definitions updated",
"errorSaveSetting": "Error saving setting {id}: {err}",
"errorCopyImage": "Error copying image: {error}",
"errorOpenImage": "Error opening image: {error}",
"noTemplatesToExport": "No templates to export",
"failedToFetchLogs": "Failed to fetch server logs",
"migrateToLitegraphReroute": "Reroute nodes will be removed in future versions. Click to migrate to litegraph-native reroute.",
@@ -1969,6 +1971,7 @@
"newUser": "New here?",
"userAvatar": "User Avatar",
"signUp": "Sign up",
"signUpFreeTierPromo": "New here? {signUp} with Google to get {credits} free credits every month.",
"emailLabel": "Email",
"emailPlaceholder": "Enter your email",
"passwordLabel": "Password",
@@ -1993,7 +1996,12 @@
"failed": "Login failed",
"insecureContextWarning": "This connection is insecure (HTTP) - your credentials may be intercepted by attackers if you proceed to login.",
"questionsContactPrefix": "Questions? Contact us at",
"noAssociatedUser": "There is no Comfy user associated with the provided API key"
"noAssociatedUser": "There is no Comfy user associated with the provided API key",
"useEmailInstead": "Use email instead",
"freeTierBadge": "Eligible for Free Tier",
"freeTierDescription": "Sign up with Google to get {credits} free credits every month. No card needed.",
"freeTierDescriptionGeneric": "Sign up with Google to get free credits every month. No card needed.",
"backToSocialLogin": "Sign up with Google or Github instead"
},
"signup": {
"title": "Create an account",
@@ -2007,7 +2015,8 @@
"signUpWithGoogle": "Sign up with Google",
"signUpWithGithub": "Sign up with Github",
"regionRestrictionChina": "In accordance with local regulatory requirements, our services are temporarily unavailable to users located in China.",
"personalDataConsentLabel": "I agree to the processing of my personal data."
"personalDataConsentLabel": "I agree to the processing of my personal data.",
"emailNotEligibleForFreeTier": "Email sign-up is not eligible for Free Tier."
},
"signOut": {
"signOut": "Log Out",
@@ -2198,10 +2207,15 @@
"invoiceHistory": "Invoice history",
"benefits": {
"benefit1": "Monthly credits for Partner Nodes — top up when needed",
"benefit2": "Up to 30 min runtime per job"
"benefit1FreeTier": "More monthly credits, top up anytime",
"benefit2": "Up to 1 hour runtime per job on Pro",
"benefit3": "Bring your own models (Creator & Pro)"
},
"yearlyDiscount": "20% DISCOUNT",
"tiers": {
"free": {
"name": "Free"
},
"founder": {
"name": "Founder's Edition"
},
@@ -2225,6 +2239,8 @@
},
"subscribeToRun": "Subscribe",
"subscribeToRunFull": "Subscribe to Run",
"subscribeForMore": "Upgrade",
"upgradeToAddCredits": "Upgrade to add credits",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"workspaceNotSubscribed": "This workspace is not on a subscription",
@@ -2235,6 +2251,21 @@
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "View enterprise",
"freeTier": {
"title": "You're on the Free plan",
"description": "Your free plan includes {credits} credits each month to try Comfy Cloud.",
"descriptionGeneric": "Your free plan includes a monthly credit allowance to try Comfy Cloud.",
"nextRefresh": "Your credits refresh on {date}.",
"subscribeCta": "Subscribe for more",
"outOfCredits": {
"title": "You're out of free credits",
"subtitle": "Subscribe to unlock top-ups and more"
},
"topUpBlocked": {
"title": "Unlock top-ups and more"
},
"upgradeCta": "View plans"
},
"partnerNodesCredits": "Partner nodes pricing",
"plansAndPricing": "Plans & pricing",
"managePlan": "Manage plan",
@@ -2262,6 +2293,7 @@
"upgradeTo": "Upgrade to {plan}",
"changeTo": "Change to {plan}",
"maxDuration": {
"free": "30 min",
"standard": "30 min",
"creator": "30 min",
"pro": "1 hr",
@@ -2892,7 +2924,131 @@
"actionbar": {
"dockToTop": "Dock to top",
"feedback": "Feedback",
"feedbackTooltip": "Feedback"
"feedbackTooltip": "Feedback",
"share": "Share",
"shareTooltip": "Share workflow"
},
"shareWorkflow": {
"shareLinkTab": "Share",
"publishToHubTab": "Publish",
"loadingTitle": "Share workflow",
"unsavedTitle": "Save workflow first",
"unsavedDescription": "You must save your workflow before sharing. Save it now to continue.",
"saveButton": "Save workflow",
"saving": "Saving...",
"workflowNameLabel": "Workflow name",
"createLinkTitle": "Share workflow",
"createLinkDescription": "When you create a link for your workflow, you will share these media items along with your workflow",
"privateAssetsDescription": "Your workflow contains private models and/or media files",
"createLinkButton": "Create a link",
"creatingLink": "Creating a link...",
"successTitle": "Workflow successfully published!",
"successDescription": "Anyone with this link can view and use this workflow. If you make changes to this workflow, you can republish to update the shared version.",
"hasChangesTitle": "Share workflow",
"hasChangesDescription": "You have made changes since this workflow was last published.",
"updateLinkButton": "Update link",
"updatingLink": "Updating link...",
"publishedOn": "Published on {date}",
"copyLink": "Copy",
"linkCopied": "Copied!",
"shareUrlLabel": "Share URL",
"loadFailed": "Failed to load shared workflow",
"saveFailedTitle": "Save failed",
"saveFailedDescription": "Failed to save workflow. Please try again.",
"mediaLabel": "{count} Media File | {count} Media Files",
"modelsLabel": "{count} Model | {count} Models",
"checkingAssets": "Checking media visibility…",
"acknowledgeCheckbox": "I understand these media items will be published and made public",
"inLibrary": "In library",
"comfyHubTitle": "Upload to ComfyHub",
"comfyHubDescription": "ComfyHub is ComfyUI's official community hub.\nYour workflow will have a public page viewable by all.",
"comfyHubButton": "Upload to ComfyHub"
},
"openSharedWorkflow": {
"dialogTitle": "Open shared workflow",
"author": "Author:",
"copyDescription": "Opening the workflow will create a new copy in your workspace",
"nonPublicAssetsWarningLine1": "This workflow comes with non-public assets.",
"nonPublicAssetsWarningLine2": "These will be imported to your library when you open the workflow",
"copyAssetsAndOpen": "Import assets & open workflow",
"openWorkflow": "Open workflow",
"openWithoutImporting": "Open without importing",
"importFailed": "Failed to import workflow assets",
"loadError": "Could not load this shared workflow. Please try again later."
},
"comfyHubPublish": {
"title": "Publish to ComfyHub",
"stepDescribe": "Describe your workflow",
"stepExamples": "Add output examples",
"stepFinish": "Finish publishing",
"workflowName": "Workflow name",
"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",
"selectAThumbnail": "Select a thumbnail",
"showMoreTags": "Show more...",
"showLessTags": "Show less...",
"suggestedTags": "Suggested tags",
"thumbnailImage": "Image",
"thumbnailVideo": "Video",
"thumbnailImageComparison": "Image comparison",
"uploadThumbnail": "Upload an image",
"uploadVideo": "Upload a video",
"uploadComparison": "Upload before and after",
"thumbnailPreview": "Thumbnail preview",
"uploadPromptClickToBrowse": "Click to browse or",
"uploadPromptDropImage": "drop an image here",
"uploadPromptDropVideo": "drop a video here",
"uploadComparisonBeforePrompt": "Before",
"uploadComparisonAfterPrompt": "After",
"uploadThumbnailHint": "1:1 preferred, 1080p max",
"back": "Back",
"next": "Next",
"publishButton": "Publish to ComfyHub",
"examplesDescription": "Add up to {total} additional sample images",
"uploadAnImage": "Click to browse or drag an image",
"uploadExampleImage": "Upload example image",
"exampleImage": "Example image {index}",
"videoPreview": "Video thumbnail preview",
"maxExamples": "You can select up to {max} examples",
"createProfileToPublish": "Create a profile to publish to ComfyHub",
"createProfileCta": "Create a profile"
},
"comfyHubProfile": {
"checkingAccess": "Checking your publishing access...",
"profileCreationNav": "Profile creation",
"introTitle": "Publish to the ComfyHub",
"introDescription": "Publish your workflows, build your portfolio and get discovered by millions of users",
"introSubtitle": "To share your workflow on ComfyHub, let's first create your profile.",
"createProfileButton": "Create my profile",
"startPublishingButton": "Start publishing",
"modalTitle": "Create your profile on ComfyHub",
"createProfileTitle": "Create your Comfy Hub profile",
"uploadCover": "+ Upload a cover",
"uploadProfilePicture": "+ Upload a profile picture",
"chooseProfilePicture": "Choose a profile picture",
"nameLabel": "Your name",
"namePlaceholder": "Enter your name here",
"usernameLabel": "Your username (required)",
"usernamePlaceholder": "@",
"descriptionLabel": "Your description",
"descriptionPlaceholder": "Tell the community about yourself...",
"createProfile": "Create profile",
"creatingProfile": "Creating profile...",
"successTitle": "Looking good, {'@'}{username}!",
"successProfileUrl": "Your profile page is live at",
"successProfileLink": "comfy.com/p/{username}",
"successDescription": "You can now upload your workflow to your creator page",
"uploadWorkflowButton": "Upload my workflow"
},
"desktopDialogs": {
"": {

View File

@@ -2,14 +2,30 @@
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-screen p-2 lg:w-96">
<!-- Header -->
<div class="mt-6 mb-8 flex flex-col gap-4">
<div class="mb-8 flex flex-col gap-4">
<h1 class="my-0 text-xl leading-normal font-medium">
{{ t('auth.login.title') }}
</h1>
<p class="my-0 text-base">
<span class="text-muted">{{ t('auth.login.newUser') }}</span>
<i18n-t
v-if="isFreeTierEnabled"
keypath="auth.login.signUpFreeTierPromo"
tag="p"
class="my-0 text-base text-muted"
:plural="freeTierCredits ?? undefined"
>
<template #signUp>
<span
class="cursor-pointer text-blue-500"
@click="navigateToSignup"
>{{ t('auth.login.signUp') }}</span
>
</template>
<template #credits>{{ freeTierCredits }}</template>
</i18n-t>
<p v-else class="my-0 text-base text-muted">
{{ t('auth.login.newUser') }}
<span
class="ml-1 cursor-pointer text-blue-500"
class="cursor-pointer text-blue-500"
@click="navigateToSignup"
>{{ t('auth.login.signUp') }}</span
>
@@ -20,36 +36,49 @@
{{ t('auth.login.insecureContextWarning') }}
</Message>
<!-- Form -->
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
<template v-if="!showEmailForm">
<!-- OAuth Buttons (primary) -->
<div class="flex flex-col gap-4">
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
<i class="pi pi-google mr-2"></i>
{{ t('auth.login.loginWithGoogle') }}
</Button>
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<Button
type="button"
class="h-10 bg-[#2d2e32]"
variant="secondary"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{ t('auth.login.loginWithGithub') }}
</Button>
</div>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10 bg-[#2d2e32]"
variant="secondary"
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{ t('auth.login.loginWithGoogle') }}
</Button>
<div class="mt-6 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
@click="switchToEmailForm"
>
{{ t('auth.login.useEmailInstead') }}
</Button>
</div>
</template>
<Button
type="button"
class="h-10 bg-[#2d2e32]"
variant="secondary"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{ t('auth.login.loginWithGithub') }}
</Button>
</div>
<template v-else>
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
<div class="mt-4 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
@click="switchToSocialLogin"
>
{{ t('auth.login.backToSocialLogin') }}
</Button>
</div>
</template>
<!-- Terms & Contact -->
<p class="mt-5 text-sm text-gray-600">
@@ -75,7 +104,6 @@
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -84,6 +112,7 @@ import { useRoute, useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SignInData } from '@/schemas/signInSchema'
@@ -95,6 +124,16 @@ const authActions = useFirebaseAuthActions()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const toastStore = useToastStore()
const showEmailForm = ref(false)
const { isFreeTierEnabled, freeTierCredits } = useFreeTierOnboarding()
function switchToEmailForm() {
showEmailForm.value = true
}
function switchToSocialLogin() {
showEmailForm.value = false
}
const navigateToSignup = async () => {
await router.push({ name: 'cloud-signup', query: route.query })

View File

@@ -22,42 +22,77 @@
{{ t('auth.login.insecureContextWarning') }}
</Message>
<!-- Form -->
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
<template v-if="!showEmailForm">
<p v-if="isFreeTierEnabled" class="mb-4 text-sm text-muted-foreground">
{{
freeTierCredits
? t('auth.login.freeTierDescription', {
credits: freeTierCredits
})
: t('auth.login.freeTierDescriptionGeneric')
}}
</p>
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- OAuth Buttons (primary) -->
<div class="flex flex-col gap-4">
<div class="relative">
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
<i class="pi pi-google mr-2"></i>
{{ t('auth.signup.signUpWithGoogle') }}
</Button>
<span
v-if="isFreeTierEnabled"
class="absolute -top-2.5 -right-2.5 rounded-full bg-yellow-400 px-2 py-0.5 text-[10px] font-bold whitespace-nowrap text-gray-900"
>
{{ t('auth.login.freeTierBadge') }}
</span>
</div>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10 bg-[#2d2e32]"
variant="secondary"
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{ t('auth.signup.signUpWithGoogle') }}
</Button>
<Button
type="button"
class="h-10 bg-[#2d2e32]"
variant="secondary"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{ t('auth.signup.signUpWithGithub') }}
</Button>
</div>
<Button
type="button"
class="h-10 bg-[#2d2e32]"
variant="secondary"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{ t('auth.signup.signUpWithGithub') }}
</Button>
</div>
<div class="mt-6 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
@click="switchToEmailForm"
>
{{ t('auth.login.useEmailInstead') }}
</Button>
</div>
</template>
<template v-else>
<Message v-if="isFreeTierEnabled" severity="warn" class="mb-4">
{{ t('auth.signup.emailNotEligibleForFreeTier') }}
</Message>
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
<div class="mt-4 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
@click="switchToSocialLogin"
>
{{ t('auth.login.backToSocialLogin') }}
</Button>
</div>
</template>
<!-- Terms & Contact -->
<div class="mt-5 text-sm text-gray-600">
<p class="mt-5 text-sm text-gray-600">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
@@ -68,30 +103,29 @@
</a>
{{ t('auth.login.andText') }}
<a
href="/privacy-policy"
href="https://www.comfy.org/privacy-policy"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.privacyLink') }} </a
>.
<p class="mt-2">
{{ t('cloudWaitlist_questionsText') }}
<a
href="https://support.comfy.org"
class="cursor-pointer text-blue-400 no-underline"
target="_blank"
rel="noopener noreferrer"
>
{{ t('cloudWaitlist_contactLink') }}</a
>.
</p>
</div>
</p>
<p class="mt-2 text-sm text-gray-600">
{{ t('cloudWaitlist_questionsText') }}
<a
href="https://support.comfy.org"
class="cursor-pointer text-blue-400 no-underline"
target="_blank"
rel="noopener noreferrer"
>
{{ t('cloudWaitlist_contactLink') }}</a
>.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -100,6 +134,7 @@ import { useRoute, useRouter } from 'vue-router'
import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -115,6 +150,14 @@ const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const userIsInChina = ref(false)
const toastStore = useToastStore()
const telemetry = useTelemetry()
const {
showEmailForm,
freeTierCredits,
isFreeTierEnabled,
switchToEmailForm,
switchToSocialLogin
} = useFreeTierOnboarding()
const navigateToLogin = async () => {
await router.push({ name: 'cloud-login', query: route.query })
@@ -161,7 +204,7 @@ const signUpWithEmail = async (values: SignUpData) => {
onMounted(async () => {
// Track signup screen opened
if (isCloud) {
useTelemetry()?.trackSignupOpened()
telemetry?.trackSignupOpened()
}
userIsInChina.value = await isInChina()

View File

@@ -27,6 +27,7 @@ const selectedTierKey = ref<TierKey | null>(null)
const tierDisplayName = computed(() => {
if (!selectedTierKey.value) return ''
const names: Record<TierKey, string> = {
free: t('subscription.tiers.free.name'),
standard: t('subscription.tiers.standard.name'),
creator: t('subscription.tiers.creator.name'),
pro: t('subscription.tiers.pro.name'),
@@ -58,6 +59,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
return
}
// Only paid tiers can be checked out via redirect
const validTierKeys: TierKey[] = ['standard', 'creator', 'pro', 'founder']
if (!(validTierKeys as string[]).includes(tierKeyParam)) {
await router.push('/')

View File

@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
const mockRemoteConfig = vi.hoisted(() => ({
value: { free_tier_credits: 50 } as Record<string, unknown>
}))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: mockRemoteConfig
}))
describe('useFreeTierOnboarding', () => {
describe('showEmailForm', () => {
it('starts as false', () => {
const { showEmailForm } = useFreeTierOnboarding()
expect(showEmailForm.value).toBe(false)
})
it('switchToEmailForm sets it to true', () => {
const { showEmailForm, switchToEmailForm } = useFreeTierOnboarding()
switchToEmailForm()
expect(showEmailForm.value).toBe(true)
})
it('switchToSocialLogin sets it back to false', () => {
const { showEmailForm, switchToEmailForm, switchToSocialLogin } =
useFreeTierOnboarding()
switchToEmailForm()
switchToSocialLogin()
expect(showEmailForm.value).toBe(false)
})
})
describe('freeTierCredits', () => {
it('returns value from remote config', () => {
const { freeTierCredits } = useFreeTierOnboarding()
expect(freeTierCredits.value).toBe(50)
})
})
describe('isFreeTierEnabled', () => {
it('returns true when remote config says enabled', () => {
mockRemoteConfig.value.new_free_tier_subscriptions = true
const { isFreeTierEnabled } = useFreeTierOnboarding()
expect(isFreeTierEnabled.value).toBe(true)
})
it('returns false when remote config says disabled', () => {
mockRemoteConfig.value.new_free_tier_subscriptions = false
const { isFreeTierEnabled } = useFreeTierOnboarding()
expect(isFreeTierEnabled.value).toBe(false)
})
it('defaults to false when not set in remote config', () => {
mockRemoteConfig.value = { free_tier_credits: 50 }
const { isFreeTierEnabled } = useFreeTierOnboarding()
expect(isFreeTierEnabled.value).toBe(false)
})
})
})

View File

@@ -0,0 +1,28 @@
import { computed, ref } from 'vue'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
export function useFreeTierOnboarding() {
const showEmailForm = ref(false)
const freeTierCredits = computed(() => getTierCredits('free'))
const isFreeTierEnabled = computed(
() => remoteConfig.value.new_free_tier_subscriptions ?? false
)
function switchToEmailForm() {
showEmailForm.value = true
}
function switchToSocialLogin() {
showEmailForm.value = false
}
return {
showEmailForm,
freeTierCredits,
isFreeTierEnabled,
switchToEmailForm,
switchToSocialLogin
}
}

View File

@@ -0,0 +1,122 @@
<template>
<div class="relative grid h-full grid-cols-5">
<!-- Custom close button -->
<Button
size="icon"
variant="muted-textonly"
class="rounded-full absolute top-2.5 right-2.5 z-10 h-8 w-8 p-0 text-white hover:bg-white/20"
:aria-label="$t('g.close')"
@click="$emit('close', false)"
>
<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="h-full min-w-[125%] object-cover p-0"
style="margin-left: -20%"
>
<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-6">
<div class="text-sm text-text-primary">
<template v-if="reason === 'out_of_credits'">
{{ $t('subscription.freeTier.outOfCredits.title') }}
</template>
<template v-else-if="reason === 'top_up_blocked'">
{{ $t('subscription.freeTier.topUpBlocked.title') }}
</template>
<template v-else>
{{ $t('subscription.freeTier.title') }}
</template>
</div>
<p
v-if="reason === 'out_of_credits'"
class="m-0 text-sm text-text-secondary"
>
{{ $t('subscription.freeTier.outOfCredits.subtitle') }}
</p>
<p
v-if="!reason || reason === 'subscription_required'"
class="m-0 text-sm text-text-secondary"
>
{{
freeTierCredits
? $t('subscription.freeTier.description', {
credits: freeTierCredits.toLocaleString()
})
: $t('subscription.freeTier.descriptionGeneric')
}}
</p>
<p
v-if="
(!reason || reason === 'subscription_required') &&
formattedRenewalDate
"
class="m-0 text-sm text-text-secondary"
>
{{
$t('subscription.freeTier.nextRefresh', {
date: formattedRenewalDate
})
}}
</p>
</div>
<SubscriptionBenefits is-free-tier class="mt-6 text-muted" />
</div>
<div class="flex flex-col pt-8">
<Button
class="w-full rounded-lg bg-[var(--color-accent-blue,#0B8CE9)] px-4 py-2 font-inter text-sm font-bold text-white hover:bg-[var(--color-accent-blue,#0B8CE9)]/90"
@click="$emit('upgrade')"
>
{{
reason === 'out_of_credits' || reason === 'top_up_blocked'
? $t('subscription.freeTier.upgradeCta')
: $t('subscription.freeTier.subscribeCta')
}}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
defineProps<{
reason?: SubscriptionDialogReason
}>()
defineEmits<{
close: [subscribed: boolean]
upgrade: []
}>()
const { formattedRenewalDate } = useSubscription()
const freeTierCredits = computed(() => getTierCredits('free'))
</script>

View File

@@ -24,6 +24,7 @@ const mockGetCheckoutAttribution = vi.hoisted(() => vi.fn(() => ({})))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isFreeTier: computed(() => false),
subscriptionTier: computed(() => mockSubscriptionTier.value),
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
subscriptionStatus: ref(null)

View File

@@ -272,7 +272,7 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier']
type CheckoutTierKey = Exclude<TierKey, 'founder'>
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
type CheckoutTier = CheckoutTierKey | `${CheckoutTierKey}-yearly`
const getCheckoutTier = (
@@ -344,8 +344,12 @@ const tiers: PricingTierConfig[] = [
isPopular: false
}
]
const { isActiveSubscription, subscriptionTier, isYearlySubscription } =
useSubscription()
const {
isActiveSubscription,
isFreeTier,
subscriptionTier,
isYearlySubscription
} = useSubscription()
const telemetry = useTelemetry()
const { userId } = storeToRefs(useFirebaseAuthStore())
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
@@ -356,6 +360,10 @@ const loadingTier = ref<CheckoutTierKey | null>(null)
const popover = ref()
const currentBillingCycle = ref<BillingCycle>('yearly')
const hasPaidSubscription = computed(
() => isActiveSubscription.value && !isFreeTier.value
)
const currentTierKey = computed<TierKey | null>(() =>
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
)
@@ -392,7 +400,7 @@ const getButtonLabel = (tier: PricingTierConfig): string => {
? t('subscription.tierNameYearly', { name: tier.name })
: tier.name
return isActiveSubscription.value
return hasPaidSubscription.value
? t('subscription.changeTo', { plan: planName })
: t('subscription.subscribeTo', { plan: planName })
}
@@ -427,7 +435,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
loadingTier.value = tierKey
try {
if (isActiveSubscription.value) {
if (hasPaidSubscription.value) {
const checkoutAttribution = await getCheckoutAttributionForCloud()
if (userId.value) {
telemetry?.trackBeginCheckout({

View File

@@ -2,15 +2,7 @@
<Button
:size
:disabled="disabled"
variant="primary"
:style="
variant === 'gradient'
? {
background: 'var(--color-subscription-button-gradient)',
color: 'var(--color-white)'
}
: undefined
"
:variant="buttonVariant === 'gradient' ? 'gradient' : 'primary'"
:class="cn('font-bold', fluid && 'w-full')"
@click="handleSubscribe"
>
@@ -24,19 +16,20 @@ import { onBeforeUnmount, ref, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { cn } from '@/utils/tailwindUtil'
const {
size = 'lg',
fluid = true,
variant = 'default',
buttonVariant = 'default',
label,
disabled = false
} = defineProps<{
label?: string
size?: 'sm' | 'lg'
variant?: 'default' | 'gradient'
buttonVariant?: 'default' | 'gradient'
fluid?: boolean
disabled?: boolean
}>()
@@ -46,6 +39,7 @@ const emit = defineEmits<{
}>()
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
const { subscriptionTier } = useSubscription()
const isAwaitingStripeSubscription = ref(false)
watch(
@@ -60,7 +54,9 @@ watch(
const handleSubscribe = () => {
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked')
useTelemetry()?.trackSubscription('subscribe_clicked', {
current_tier: subscriptionTier.value?.toLowerCase()
})
}
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()

View File

@@ -5,13 +5,8 @@
showDelay: 600
}"
class="subscribe-to-run-button whitespace-nowrap"
variant="primary"
variant="gradient"
size="sm"
:style="{
background: 'var(--color-subscription-button-gradient)',
color: 'var(--color-white)',
borderColor: 'transparent'
}"
data-testid="subscribe-to-run-button"
@click="handleSubscribeToRun"
>

View File

@@ -3,7 +3,11 @@
<div 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('subscription.benefits.benefit1') }}
{{
isFreeTier
? $t('subscription.benefits.benefit1FreeTier')
: $t('subscription.benefits.benefit1')
}}
</span>
</div>
@@ -13,7 +17,18 @@
{{ $t('subscription.benefits.benefit2') }}
</span>
</div>
<div 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('subscription.benefits.benefit3') }}
</span>
</div>
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
const { isFreeTier = false } = defineProps<{
isFreeTier?: boolean
}>()
</script>

View File

@@ -33,7 +33,7 @@
</div>
<Button
v-if="isActiveSubscription"
v-if="isActiveSubscription && !isFreeTier"
variant="secondary"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="
@@ -130,17 +130,25 @@
</tbody>
</table>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-3">
<a
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline text-center text-muted"
class="text-sm underline text-muted"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
v-if="isActiveSubscription"
v-if="isActiveSubscription && isFreeTier"
variant="gradient"
class="p-2 min-h-8 rounded-lg text-sm font-normal w-full"
@click="handleUpgradeToAddCredits"
>
{{ $t('subscription.upgradeToAddCredits') }}
</Button>
<Button
v-else-if="isActiveSubscription"
variant="secondary"
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="handleAddApiCredits"
@@ -213,9 +221,10 @@ import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits,
getTierFeatures,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefits'
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions()
@@ -224,6 +233,7 @@ const { t, n } = useI18n()
const {
isActiveSubscription,
isCancelled,
isFreeTier,
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
@@ -232,7 +242,8 @@ const {
isYearlySubscription
} = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const { show: showSubscriptionDialog, showPricingTable } =
useSubscriptionDialog()
const tierKey = computed(() => {
const tier = subscriptionTier.value
@@ -264,6 +275,7 @@ const creditsRemainingLabel = computed(() =>
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)
if (credits === null) return '—'
const total = isYearlySubscription.value ? credits * 12 : credits
return n(total)
})
@@ -272,54 +284,19 @@ const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
)
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
}
const tierBenefits = computed((): Benefit[] => {
const key = tierKey.value
const benefits: Benefit[] = [
{
key: 'maxDuration',
type: 'metric',
value: t(`subscription.maxDuration.${key}`),
label: t('subscription.maxDurationLabel')
},
{
key: 'gpu',
type: 'feature',
label: t('subscription.gpuLabel')
},
{
key: 'addCredits',
type: 'feature',
label: t('subscription.addCreditsLabel')
}
]
if (getTierFeatures(key).customLoRAs) {
benefits.push({
key: 'customLoRAs',
type: 'feature',
label: t('subscription.customLoRAsLabel')
})
}
return benefits
})
const tierBenefits = computed((): TierBenefit[] =>
getCommonTierBenefits(tierKey.value, t, n)
)
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
function handleUpgradeToAddCredits() {
showPricingTable()
}
// Focus-based polling: refresh balance when user returns from Stripe checkout
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes

View File

@@ -81,7 +81,11 @@
<div class="flex flex-col gap-6">
<div class="inline-flex items-center gap-2">
<div class="text-sm text-text-primary">
{{ $t('subscription.required.title') }}
{{
reason === 'out_of_credits'
? $t('credits.topUp.insufficientTitle')
: $t('subscription.required.title')
}}
</div>
<CloudBadge
reverse-order
@@ -91,6 +95,13 @@
/>
</div>
<p
v-if="reason === 'out_of_credits'"
class="m-0 text-sm text-text-secondary"
>
{{ $t('credits.topUp.insufficientMessage') }}
</p>
<div class="flex items-baseline gap-2">
<span class="text-4xl font-bold">{{ formattedMonthlyPrice }}</span>
<span class="text-xl">{{ $t('subscription.perMonth') }}</span>
@@ -131,9 +142,11 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
const props = defineProps<{
const { onClose, reason } = defineProps<{
onClose: () => void
reason?: SubscriptionDialogReason
}>()
const emit = defineEmits<{
@@ -234,7 +247,7 @@ const handleSubscribed = () => {
const handleClose = () => {
stopPolling()
props.onClose()
onClose()
}
const handleContactUs = async () => {

View File

@@ -8,6 +8,7 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import {
FirebaseAuthStoreError,
@@ -77,6 +78,8 @@ function useSubscriptionInternal() {
() => subscriptionStatus.value?.subscription_tier ?? null
)
const isFreeTier = computed(() => subscriptionTier.value === 'FREE')
const subscriptionDuration = computed(
() => subscriptionStatus.value?.subscription_duration ?? null
)
@@ -130,12 +133,17 @@ function useSubscriptionInternal() {
window.open(response.checkout_url, '_blank')
}, reportError)
const showSubscriptionDialog = () => {
const showSubscriptionDialog = (options?: {
reason?: SubscriptionDialogReason
}) => {
if (isCloud) {
useTelemetry()?.trackSubscription('modal_opened')
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: subscriptionTier.value?.toLowerCase(),
reason: options?.reason
})
}
void showSubscriptionRequiredDialog()
void showSubscriptionRequiredDialog(options)
}
/**
@@ -278,6 +286,7 @@ function useSubscriptionInternal() {
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
isFreeTier,
subscriptionDuration,
isYearlySubscription,
subscriptionTierName,

View File

@@ -2,21 +2,30 @@ import { defineAsyncComponent } from 'vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const DIALOG_KEY = 'subscription-required'
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
export const useSubscriptionDialog = () => {
const { flags } = useFeatureFlags()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const { isFreeTier } = useSubscription()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
dialogStore.closeDialog({ key: FREE_TIER_DIALOG_KEY })
}
function show() {
function showPricingTable(options?: { reason?: SubscriptionDialogReason }) {
const useWorkspaceVariant =
flags.teamWorkspacesEnabled && !workspaceStore.isInPersonalWorkspace
@@ -34,7 +43,8 @@ export const useSubscriptionDialog = () => {
key: DIALOG_KEY,
component,
props: {
onClose: hide
onClose: hide,
reason: options?.reason
},
dialogComponentProps: {
style: 'width: min(1328px, 95vw); max-height: 958px;',
@@ -51,8 +61,46 @@ export const useSubscriptionDialog = () => {
})
}
function show(options?: { reason?: SubscriptionDialogReason }) {
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
const component = defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/FreeTierDialogContent.vue')
)
dialogService.showLayoutDialog({
key: FREE_TIER_DIALOG_KEY,
component,
props: {
reason: options?.reason,
onClose: hide,
onUpgrade: () => {
hide()
showPricingTable(options)
}
},
dialogComponentProps: {
style: 'width: min(640px, 95vw);',
pt: {
root: {
class: 'rounded-2xl bg-transparent'
},
content: {
class:
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
}
}
}
})
return
}
showPricingTable(options)
}
return {
show,
showPricingTable,
hide
}
}

View File

@@ -1,10 +1,12 @@
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier']
export type TierKey = 'standard' | 'creator' | 'pro' | 'founder'
export type TierKey = 'free' | 'standard' | 'creator' | 'pro' | 'founder'
export const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
FREE: 'free',
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
@@ -12,6 +14,7 @@ export const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
}
export const KEY_TO_TIER: Record<TierKey, SubscriptionTier> = {
free: 'FREE',
standard: 'STANDARD',
creator: 'CREATOR',
pro: 'PRO',
@@ -25,7 +28,10 @@ export interface TierPricing {
videoEstimate: number
}
export const TIER_PRICING: Record<Exclude<TierKey, 'founder'>, TierPricing> = {
export const TIER_PRICING: Record<
Exclude<TierKey, 'free' | 'founder'>,
TierPricing
> = {
standard: { monthly: 20, yearly: 16, credits: 4200, videoEstimate: 380 },
creator: { monthly: 35, yearly: 28, credits: 7400, videoEstimate: 670 },
pro: { monthly: 100, yearly: 80, credits: 21100, videoEstimate: 1915 }
@@ -37,6 +43,7 @@ interface TierFeatures {
}
const TIER_FEATURES: Record<TierKey, TierFeatures> = {
free: { customLoRAs: false, maxMembers: 1 },
standard: { customLoRAs: false, maxMembers: 1 },
creator: { customLoRAs: true, maxMembers: 5 },
pro: { customLoRAs: true, maxMembers: 20 },
@@ -49,12 +56,14 @@ const FOUNDER_MONTHLY_PRICE = 20
const FOUNDER_MONTHLY_CREDITS = 5460
export function getTierPrice(tierKey: TierKey, isYearly = false): number {
if (tierKey === 'free') return 0
if (tierKey === 'founder') return FOUNDER_MONTHLY_PRICE
const pricing = TIER_PRICING[tierKey]
return isYearly ? pricing.yearly : pricing.monthly
}
export function getTierCredits(tierKey: TierKey): number {
export function getTierCredits(tierKey: TierKey): number | null {
if (tierKey === 'free') return remoteConfig.value.free_tier_credits ?? null
if (tierKey === 'founder') return FOUNDER_MONTHLY_CREDITS
return TIER_PRICING[tierKey].credits
}

View File

@@ -2,7 +2,7 @@ import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricin
export type BillingCycle = 'monthly' | 'yearly'
type RankedTierKey = Exclude<TierKey, 'founder'>
type RankedTierKey = Exclude<TierKey, 'founder' | 'free'>
type RankedPlanKey = `${BillingCycle}-${RankedTierKey}`
interface PlanDescriptor {
@@ -28,7 +28,7 @@ const toRankedPlanKey = (
tierKey: TierKey,
billingCycle: BillingCycle
): RankedPlanKey | null => {
if (tierKey === 'founder') return null
if (tierKey === 'founder' || tierKey === 'free') return null
return `${billingCycle}-${tierKey}` as RankedPlanKey
}

View File

@@ -0,0 +1,67 @@
import {
getTierCredits,
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
type BenefitType = 'metric' | 'feature' | 'icon'
export interface TierBenefit {
key: string
type: BenefitType
label: string
value?: string
icon?: string
}
export function getCommonTierBenefits(
key: TierKey,
t: (key: string, params?: Record<string, unknown>) => string,
n: (value: number) => string
): TierBenefit[] {
const benefits: TierBenefit[] = []
const isFree = key === 'free'
if (isFree) {
const credits = getTierCredits(key)
if (credits !== null) {
benefits.push({
key: 'monthlyCredits',
type: 'metric',
value: n(credits),
label: t('subscription.monthlyCreditsLabel')
})
}
}
benefits.push({
key: 'maxDuration',
type: 'metric',
value: t(`subscription.maxDuration.${key}`),
label: t('subscription.maxDurationLabel')
})
benefits.push({
key: 'gpu',
type: 'feature',
label: t('subscription.gpuLabel')
})
if (!isFree) {
benefits.push({
key: 'addCredits',
type: 'feature',
label: t('subscription.addCreditsLabel')
})
}
if (getTierFeatures(key).customLoRAs) {
benefits.push({
key: 'customLoRAs',
type: 'feature',
label: t('subscription.customLoRAsLabel')
})
}
return benefits
}

View File

@@ -1,4 +1,5 @@
export const PRESERVED_QUERY_NAMESPACES = {
TEMPLATE: 'template',
INVITE: 'invite'
INVITE: 'invite',
SHARE: 'share'
} as const

View File

@@ -29,6 +29,8 @@ export type RemoteConfig = {
gtm_container_id?: string
ga_measurement_id?: string
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
subscription_required?: boolean
server_health_alert?: ServerHealthAlert
max_upload_size?: number
@@ -43,4 +45,10 @@ export type RemoteConfig = {
linear_toggle_enabled?: boolean
team_workspaces_enabled?: boolean
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
free_tier_credits?: number
new_free_tier_subscriptions?: boolean
workflow_sharing_enabled?: boolean
comfyhub_upload_enabled?: boolean
comfyhub_profile_gate_enabled?: boolean
}

View File

@@ -15,6 +15,7 @@ import type {
PageViewMetadata,
PageVisibilityMetadata,
SettingChangedMetadata,
SubscriptionMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryDispatcher,
@@ -65,8 +66,11 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackUserLoggedIn?.())
}
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
this.dispatch((provider) => provider.trackSubscription?.(event))
trackSubscription(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
): void {
this.dispatch((provider) => provider.trackSubscription?.(event, metadata))
}
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {

View File

@@ -24,18 +24,21 @@ export async function initTelemetry(): Promise<void> {
{ TelemetryRegistry },
{ MixpanelTelemetryProvider },
{ GtmTelemetryProvider },
{ ImpactTelemetryProvider }
{ ImpactTelemetryProvider },
{ PostHogTelemetryProvider }
] = await Promise.all([
import('./TelemetryRegistry'),
import('./providers/cloud/MixpanelTelemetryProvider'),
import('./providers/cloud/GtmTelemetryProvider'),
import('./providers/cloud/ImpactTelemetryProvider')
import('./providers/cloud/ImpactTelemetryProvider'),
import('./providers/cloud/PostHogTelemetryProvider')
])
const registry = new TelemetryRegistry()
registry.registerProvider(new MixpanelTelemetryProvider())
registry.registerProvider(new GtmTelemetryProvider())
registry.registerProvider(new ImpactTelemetryProvider())
registry.registerProvider(new PostHogTelemetryProvider())
setTelemetryRegistry(registry)
})()

View File

@@ -0,0 +1,178 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
watch: vi.fn()
}
})
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserResolved: vi.fn()
})
}))
vi.mock('@/platform/telemetry/topupTracker', () => ({
checkForCompletedTopup: vi.fn(),
clearTopupTracking: vi.fn(),
startTopupTracking: vi.fn()
}))
const hoisted = vi.hoisted(() => ({
mockNodeDefsByName: {} as Record<string, unknown>,
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[]
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
nodeDefsByName: hoisted.mockNodeDefsByName
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
})
}))
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => ({
knownTemplateNames: new Set()
})
})
)
function mockNode(
type: string,
isSubgraph = false
): Pick<LGraphNode, 'type' | 'isSubgraphNode'> {
return {
type,
isSubgraphNode: (() => isSubgraph) as LGraphNode['isSubgraphNode']
}
}
vi.mock('@/utils/graphTraversalUtil', () => ({
reduceAllNodes: vi.fn((_graph, reducer, initial) => {
let result = initial
for (const node of hoisted.mockNodes) {
result = reducer(result, node)
}
return result
})
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: {} }
}))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: { value: null }
}))
import { getExecutionContext } from '../../utils/getExecutionContext'
describe('getExecutionContext', () => {
beforeEach(() => {
vi.clearAllMocks()
hoisted.mockNodes.length = 0
for (const key of Object.keys(hoisted.mockNodeDefsByName)) {
delete hoisted.mockNodeDefsByName[key]
}
})
it('returns has_toolkit_nodes false when no toolkit nodes are present', () => {
hoisted.mockNodes.push(mockNode('KSampler'), mockNode('LoadImage'))
hoisted.mockNodeDefsByName['KSampler'] = {
name: 'KSampler',
python_module: 'nodes'
}
hoisted.mockNodeDefsByName['LoadImage'] = {
name: 'LoadImage',
python_module: 'nodes'
}
const context = getExecutionContext()
expect(context.has_toolkit_nodes).toBe(false)
expect(context.toolkit_node_names).toEqual([])
expect(context.toolkit_node_count).toBe(0)
})
it('detects individual toolkit nodes by type name', () => {
hoisted.mockNodes.push(mockNode('Canny'), mockNode('KSampler'))
hoisted.mockNodeDefsByName['Canny'] = {
name: 'Canny',
python_module: 'comfy_extras.nodes_canny'
}
hoisted.mockNodeDefsByName['KSampler'] = {
name: 'KSampler',
python_module: 'nodes'
}
const context = getExecutionContext()
expect(context.has_toolkit_nodes).toBe(true)
expect(context.toolkit_node_names).toEqual(['Canny'])
expect(context.toolkit_node_count).toBe(1)
})
it('detects blueprint toolkit nodes via python_module', () => {
const blueprintType = 'SubgraphBlueprint.text_to_image'
hoisted.mockNodes.push(mockNode(blueprintType, true))
hoisted.mockNodeDefsByName[blueprintType] = {
name: blueprintType,
python_module: 'comfy_essentials'
}
const context = getExecutionContext()
expect(context.has_toolkit_nodes).toBe(true)
expect(context.toolkit_node_names).toEqual([blueprintType])
expect(context.toolkit_node_count).toBe(1)
})
it('deduplicates toolkit_node_names when same type appears multiple times', () => {
hoisted.mockNodes.push(mockNode('Canny'), mockNode('Canny'))
hoisted.mockNodeDefsByName['Canny'] = {
name: 'Canny',
python_module: 'comfy_extras.nodes_canny'
}
const context = getExecutionContext()
expect(context.toolkit_node_names).toEqual(['Canny'])
expect(context.toolkit_node_count).toBe(2)
})
it('allows a node to appear in both api_node_names and toolkit_node_names', () => {
hoisted.mockNodes.push(mockNode('RecraftRemoveBackgroundNode'))
hoisted.mockNodeDefsByName['RecraftRemoveBackgroundNode'] = {
name: 'RecraftRemoveBackgroundNode',
python_module: 'comfy_extras.nodes_api',
api_node: true
}
const context = getExecutionContext()
expect(context.has_api_nodes).toBe(true)
expect(context.api_node_names).toEqual(['RecraftRemoveBackgroundNode'])
expect(context.has_toolkit_nodes).toBe(true)
expect(context.toolkit_node_names).toEqual(['RecraftRemoveBackgroundNode'])
})
it('uses node.type as tracking name when nodeDef is missing', () => {
hoisted.mockNodes.push(mockNode('ImageCrop'))
const context = getExecutionContext()
expect(context.has_toolkit_nodes).toBe(true)
expect(context.toolkit_node_names).toEqual(['ImageCrop'])
})
})

View File

@@ -7,13 +7,9 @@ import {
clearTopupTracking as clearTopupUtil,
startTopupTracking as startTopupUtil
} from '@/platform/telemetry/topupTracker'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { AuditLog } from '@/services/customerEventsService'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { app } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { reduceAllNodes } from '@/utils/graphTraversalUtil'
import { getExecutionContext } from '../../utils/getExecutionContext'
import type {
AuthMetadata,
@@ -31,6 +27,7 @@ import type {
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
SubscriptionMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
@@ -218,13 +215,16 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
}
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
trackSubscription(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
): void {
const eventName =
event === 'modal_opened'
? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED
: TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED
this.trackEvent(eventName)
this.trackEvent(eventName, metadata)
}
trackAddApiCreditButtonClicked(): void {
@@ -274,7 +274,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const executionContext = this.getExecutionContext()
const executionContext = getExecutionContext()
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
@@ -285,6 +285,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
subgraph_count: executionContext.subgraph_count,
has_api_nodes: executionContext.has_api_nodes,
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
}
@@ -397,7 +399,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
}
trackWorkflowExecution(): void {
const context = this.getExecutionContext()
const context = getExecutionContext()
const eventContext: ExecutionContext = {
...context,
trigger_source: this.lastTriggerSource ?? 'unknown'
@@ -421,98 +423,4 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
this.trackEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
}
getExecutionContext(): ExecutionContext {
const workflowStore = useWorkflowStore()
const templatesStore = useWorkflowTemplatesStore()
const nodeDefStore = useNodeDefStore()
const activeWorkflow = workflowStore.activeWorkflow
// Calculate node metrics in a single traversal
type NodeMetrics = {
custom_node_count: number
api_node_count: number
subgraph_count: number
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
}
const nodeCounts = reduceAllNodes<NodeMetrics>(
app.rootGraph,
(metrics, node) => {
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const isCustomNode =
nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes
const isApiNode = nodeDef?.api_node === true
const isSubgraph = node.isSubgraphNode?.() === true
if (isApiNode) {
metrics.has_api_nodes = true
const canonicalName = nodeDef?.name
if (
canonicalName &&
!metrics.api_node_names.includes(canonicalName)
) {
metrics.api_node_names.push(canonicalName)
}
}
metrics.custom_node_count += isCustomNode ? 1 : 0
metrics.api_node_count += isApiNode ? 1 : 0
metrics.subgraph_count += isSubgraph ? 1 : 0
metrics.total_node_count += 1
return metrics
},
{
custom_node_count: 0,
api_node_count: 0,
subgraph_count: 0,
total_node_count: 0,
has_api_nodes: false,
api_node_names: []
}
)
if (activeWorkflow?.filename) {
const isTemplate = templatesStore.knownTemplateNames.has(
activeWorkflow.filename
)
if (isTemplate) {
const template = templatesStore.getTemplateByName(
activeWorkflow.filename
)
const englishMetadata = templatesStore.getEnglishMetadata(
activeWorkflow.filename
)
return {
is_template: true,
workflow_name: activeWorkflow.filename,
template_source: template?.sourceModule,
template_category: englishMetadata?.category ?? template?.category,
template_tags: englishMetadata?.tags ?? template?.tags,
template_models: englishMetadata?.models ?? template?.models,
template_use_case: englishMetadata?.useCase ?? template?.useCase,
template_license: englishMetadata?.license ?? template?.license,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: activeWorkflow.filename,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: undefined,
...nodeCounts
}
}
}

View File

@@ -0,0 +1,246 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TelemetryEvents } from '../../types'
const hoisted = vi.hoisted(() => {
const mockCapture = vi.fn()
const mockInit = vi.fn()
const mockIdentify = vi.fn()
const mockPeopleSet = vi.fn()
const mockOnUserResolved = vi.fn()
return {
mockCapture,
mockInit,
mockIdentify,
mockPeopleSet,
mockOnUserResolved,
mockPosthog: {
default: {
init: mockInit,
capture: mockCapture,
identify: mockIdentify,
people: { set: mockPeopleSet }
}
}
}
})
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
watch: vi.fn()
}
})
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserResolved: hoisted.mockOnUserResolved
})
}))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: { value: null }
}))
vi.mock('posthog-js', () => hoisted.mockPosthog)
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
function createProvider(
config: Partial<typeof window.__CONFIG__> = {}
): PostHogTelemetryProvider {
const original = window.__CONFIG__
window.__CONFIG__ = { ...original, ...config }
const provider = new PostHogTelemetryProvider()
window.__CONFIG__ = original
return provider
}
describe('PostHogTelemetryProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
window.__CONFIG__ = {
posthog_project_token: 'phc_test_token'
} as typeof window.__CONFIG__
})
describe('initialization', () => {
it('disables itself when posthog_project_token is not provided', async () => {
const provider = createProvider({ posthog_project_token: undefined })
await vi.dynamicImportSettled()
provider.trackSignupOpened()
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
it('calls posthog.init with the token and default api_host', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith('phc_test_token', {
api_host: 'https://ph.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' })
)
})
it('registers onUserResolved callback after init', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockOnUserResolved).toHaveBeenCalledOnce()
})
it('identifies user when onUserResolved fires', async () => {
createProvider()
await vi.dynamicImportSettled()
const callback = hoisted.mockOnUserResolved.mock.calls[0][0]
callback({ id: 'user-123' })
expect(hoisted.mockIdentify).toHaveBeenCalledWith('user-123')
})
})
describe('event tracking', () => {
it('captures events after initialization', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackSignupOpened()
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_SIGN_UP_OPENED,
{}
)
})
it('captures events with metadata', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({ method: 'google' })
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_COMPLETED,
{ method: 'google' }
)
})
it('queues events before initialization and flushes after', async () => {
const provider = createProvider()
provider.trackUserLoggedIn()
expect(hoisted.mockCapture).not.toHaveBeenCalled()
await vi.dynamicImportSettled()
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_LOGGED_IN,
{}
)
})
})
describe('disabled events', () => {
it('does not capture default disabled events', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackWorkflowOpened({
missing_node_count: 0,
missing_node_types: []
})
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
it('captures events not in the disabled list', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackMonthlySubscriptionSucceeded()
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED,
{}
)
})
})
describe('survey tracking', () => {
it('sets user properties on survey submission', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const responses = { familiarity: 'beginner', industry: 'tech' }
provider.trackSurvey('submitted', responses)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_SURVEY_SUBMITTED,
expect.objectContaining({ familiarity: 'beginner' })
)
expect(hoisted.mockPeopleSet).toHaveBeenCalled()
})
it('does not set user properties on survey opened', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackSurvey('opened')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_SURVEY_OPENED,
{}
)
expect(hoisted.mockPeopleSet).not.toHaveBeenCalled()
})
})
describe('page view', () => {
it('captures page view with page_name property', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackPageView('workflow_editor')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.PAGE_VIEW,
{ page_name: 'workflow_editor' }
)
})
it('forwards additional metadata', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackPageView('workflow_editor', {
path: '/workflows/123'
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.PAGE_VIEW,
{ page_name: 'workflow_editor', path: '/workflows/123' }
)
})
})
})

View File

@@ -0,0 +1,417 @@
import type { PostHog } from 'posthog-js'
import { watch } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AuthMetadata,
EnterLinearMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageViewMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
SubscriptionMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
TelemetryEventProperties,
TelemetryProvider,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { getExecutionContext } from '../../utils/getExecutionContext'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
TelemetryEvents.WORKFLOW_OPENED,
TelemetryEvents.PAGE_VISIBILITY_CHANGED,
TelemetryEvents.TAB_COUNT_TRACKING,
TelemetryEvents.NODE_SEARCH,
TelemetryEvents.NODE_SEARCH_RESULT_SELECTED,
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
TelemetryEvents.SETTING_CHANGED,
TelemetryEvents.HELP_CENTER_OPENED,
TelemetryEvents.HELP_RESOURCE_CLICKED,
TelemetryEvents.HELP_CENTER_CLOSED,
TelemetryEvents.WORKFLOW_CREATED,
TelemetryEvents.UI_BUTTON_CLICKED
] as const satisfies TelemetryEventName[]
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
Object.values(TelemetryEvents) as TelemetryEventName[]
)
interface QueuedEvent {
eventName: TelemetryEventName
properties?: TelemetryEventProperties
}
/**
* PostHog Telemetry Provider - Cloud Build Implementation
*
* Sends all telemetry events to PostHog so they can be correlated
* with session recordings. Follows the same pattern as MixpanelTelemetryProvider.
*
* CRITICAL: OSS Build Safety
* Entire file is tree-shaken away in OSS builds (DISTRIBUTION unset).
*/
export class PostHogTelemetryProvider implements TelemetryProvider {
private isEnabled = true
private posthog: PostHog | null = null
private eventQueue: QueuedEvent[] = []
private isInitialized = false
private lastTriggerSource: ExecutionTriggerSource | undefined
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
constructor() {
this.configureDisabledEvents(
(window.__CONFIG__ as Partial<RemoteConfig> | undefined) ?? null
)
watch(
remoteConfig,
(config) => {
this.configureDisabledEvents(config)
},
{ immediate: true }
)
const apiKey = window.__CONFIG__?.posthog_project_token
if (apiKey) {
try {
void import('posthog-js')
.then((posthogModule) => {
this.posthog = posthogModule.default
this.posthog!.init(apiKey, {
api_host:
window.__CONFIG__?.posthog_api_host || 'https://ph.comfy.org',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie'
})
this.isInitialized = true
this.flushEventQueue()
useCurrentUser().onUserResolved((user) => {
if (this.posthog && user.id) {
this.posthog.identify(user.id)
}
})
})
.catch((error) => {
console.error('Failed to load PostHog:', error)
this.isEnabled = false
})
} catch (error) {
console.error('Failed to initialize PostHog:', error)
this.isEnabled = false
}
} else {
console.warn('PostHog API key not provided in runtime config')
this.isEnabled = false
}
}
private flushEventQueue(): void {
if (!this.isInitialized || !this.posthog) return
while (this.eventQueue.length > 0) {
const event = this.eventQueue.shift()!
try {
this.posthog.capture(event.eventName, event.properties || {})
} catch (error) {
console.error('Failed to track queued PostHog event:', error)
}
}
}
private trackEvent(
eventName: TelemetryEventName,
properties?: TelemetryEventProperties
): void {
if (!this.isEnabled) return
if (this.disabledEvents.has(eventName)) return
const event: QueuedEvent = { eventName, properties }
if (this.isInitialized && this.posthog) {
try {
this.posthog.capture(eventName, properties || {})
} catch (error) {
console.error('Failed to track PostHog event:', error)
}
} else {
this.eventQueue.push(event)
}
}
private captureRaw(
eventName: TelemetryEventName,
properties?: Record<string, unknown>
): void {
if (!this.isEnabled) return
if (this.disabledEvents.has(eventName)) return
if (this.isInitialized && this.posthog) {
try {
this.posthog.capture(eventName, properties || {})
} catch (error) {
console.error('Failed to track PostHog event:', error)
}
} else {
this.eventQueue.push({
eventName,
properties: properties as TelemetryEventProperties
})
}
}
private configureDisabledEvents(config: Partial<RemoteConfig> | null): void {
const disabledSource =
config?.telemetry_disabled_events ?? DEFAULT_DISABLED_EVENTS
this.disabledEvents = this.buildEventSet(disabledSource)
}
private buildEventSet(values: TelemetryEventName[]): Set<TelemetryEventName> {
return new Set(
values.filter((value) => {
const isValid = TELEMETRY_EVENT_SET.has(value)
if (!isValid && import.meta.env.DEV) {
console.warn(
`Unknown telemetry event name in disabled list: ${value}`
)
}
return isValid
})
)
}
trackSignupOpened(): void {
this.trackEvent(TelemetryEvents.USER_SIGN_UP_OPENED)
}
trackAuth(metadata: AuthMetadata): void {
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
trackUserLoggedIn(): void {
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
}
trackSubscription(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
): void {
const eventName =
event === 'modal_opened'
? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED
: TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED
this.trackEvent(eventName, metadata)
}
trackAddApiCreditButtonClicked(): void {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
}
trackMonthlySubscriptionCancelled(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED, {
credit_amount: amount
})
}
trackApiCreditTopupSucceeded(): void {
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const executionContext = getExecutionContext()
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
workflow_type: executionContext.is_template ? 'template' : 'custom',
workflow_name: executionContext.workflow_name ?? 'untitled',
custom_node_count: executionContext.custom_node_count,
total_node_count: executionContext.total_node_count,
subgraph_count: executionContext.subgraph_count,
has_api_nodes: executionContext.has_api_nodes,
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
}
this.lastTriggerSource = options?.trigger_source
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses
): void {
const eventName =
stage === 'opened'
? TelemetryEvents.USER_SURVEY_OPENED
: TelemetryEvents.USER_SURVEY_SUBMITTED
const normalizedResponses = responses
? normalizeSurveyResponses(responses)
: undefined
this.trackEvent(eventName, normalizedResponses)
if (
stage === 'submitted' &&
normalizedResponses &&
this.posthog &&
this.isEnabled &&
!this.disabledEvents.has(TelemetryEvents.USER_SURVEY_SUBMITTED)
) {
try {
this.posthog.people.set(normalizedResponses)
} catch (error) {
console.error('Failed to set PostHog user properties:', error)
}
}
}
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
let eventName: TelemetryEventName
switch (stage) {
case 'opened':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_OPENED
break
case 'requested':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_REQUESTED
break
case 'completed':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_COMPLETED
break
}
this.trackEvent(eventName)
}
trackTemplate(metadata: TemplateMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata)
}
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata)
}
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_LIBRARY_CLOSED, metadata)
}
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_IMPORTED, metadata)
}
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}
trackTabCount(metadata: TabCountMetadata): void {
this.trackEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH, metadata)
}
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
}
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
this.trackEvent(TelemetryEvents.HELP_CENTER_OPENED, metadata)
}
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
this.trackEvent(TelemetryEvents.HELP_RESOURCE_CLICKED, metadata)
}
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
this.trackEvent(TelemetryEvents.HELP_CENTER_CLOSED, metadata)
}
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
}
trackWorkflowExecution(): void {
const context = getExecutionContext()
const eventContext: ExecutionContext = {
...context,
trigger_source: this.lastTriggerSource ?? 'unknown'
}
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
this.lastTriggerSource = undefined
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
this.trackEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
this.trackEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
}
trackSettingChanged(metadata: SettingChangedMetadata): void {
this.trackEvent(TelemetryEvents.SETTING_CHANGED, metadata)
}
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
this.trackEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
}
trackPageView(pageName: string, properties?: PageViewMetadata): void {
this.captureRaw(TelemetryEvents.PAGE_VIEW, {
page_name: pageName,
...properties
})
}
}

View File

@@ -12,6 +12,7 @@
* 3. Check dist/assets/*.js files contain no tracking code
*/
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { AuditLog } from '@/services/customerEventsService'
@@ -59,6 +60,8 @@ export interface RunButtonProperties {
subgraph_count: number
has_api_nodes: boolean
api_node_names: string[]
has_toolkit_nodes: boolean
toolkit_node_names: string[]
trigger_source?: ExecutionTriggerSource
}
@@ -82,6 +85,9 @@ export interface ExecutionContext {
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
has_toolkit_nodes: boolean
toolkit_node_names: string[]
toolkit_node_count: number
trigger_source?: ExecutionTriggerSource
}
@@ -296,6 +302,11 @@ export interface CheckoutAttributionMetadata {
wbraid?: string
}
export interface SubscriptionMetadata {
current_tier?: string
reason?: SubscriptionDialogReason
}
export interface BeginCheckoutMetadata
extends Record<string, unknown>, CheckoutAttributionMetadata {
user_id: string
@@ -316,7 +327,10 @@ export interface TelemetryProvider {
trackUserLoggedIn?(): void
// Subscription flow events
trackSubscription?(event: 'modal_opened' | 'subscribe_clicked'): void
trackSubscription?(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
): void
trackBeginCheckout?(metadata: BeginCheckoutMetadata): void
trackMonthlySubscriptionSucceeded?(): void
trackMonthlySubscriptionCancelled?(): void
@@ -507,3 +521,4 @@ export type TelemetryEventProperties =
| HelpCenterClosedMetadata
| WorkflowCreatedMetadata
| EnterLinearMetadata
| SubscriptionMetadata

View File

@@ -0,0 +1,119 @@
import {
TOOLKIT_BLUEPRINT_MODULES,
TOOLKIT_NODE_NAMES
} from '@/constants/toolkitNodes'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { app } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { reduceAllNodes } from '@/utils/graphTraversalUtil'
import type { ExecutionContext } from '../types'
type NodeMetrics = {
custom_node_count: number
api_node_count: number
toolkit_node_count: number
subgraph_count: number
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
has_toolkit_nodes: boolean
toolkit_node_names: string[]
}
export function getExecutionContext(): ExecutionContext {
const workflowStore = useWorkflowStore()
const templatesStore = useWorkflowTemplatesStore()
const nodeDefStore = useNodeDefStore()
const activeWorkflow = workflowStore.activeWorkflow
const nodeCounts = reduceAllNodes<NodeMetrics>(
app.rootGraph,
(metrics, node) => {
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const isCustomNode =
nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes
const isApiNode = nodeDef?.api_node === true
const isSubgraph = node.isSubgraphNode?.() === true
if (isApiNode) {
metrics.has_api_nodes = true
const canonicalName = nodeDef?.name
if (canonicalName && !metrics.api_node_names.includes(canonicalName)) {
metrics.api_node_names.push(canonicalName)
}
}
const isToolkitNode =
TOOLKIT_NODE_NAMES.has(node.type) ||
(nodeDef?.python_module !== undefined &&
TOOLKIT_BLUEPRINT_MODULES.has(nodeDef.python_module))
if (isToolkitNode) {
metrics.has_toolkit_nodes = true
const trackingName = nodeDef?.name ?? node.type
if (!metrics.toolkit_node_names.includes(trackingName)) {
metrics.toolkit_node_names.push(trackingName)
}
}
metrics.custom_node_count += isCustomNode ? 1 : 0
metrics.api_node_count += isApiNode ? 1 : 0
metrics.toolkit_node_count += isToolkitNode ? 1 : 0
metrics.subgraph_count += isSubgraph ? 1 : 0
metrics.total_node_count += 1
return metrics
},
{
custom_node_count: 0,
api_node_count: 0,
toolkit_node_count: 0,
subgraph_count: 0,
total_node_count: 0,
has_api_nodes: false,
api_node_names: [],
has_toolkit_nodes: false,
toolkit_node_names: []
}
)
if (activeWorkflow?.filename) {
const isTemplate = templatesStore.knownTemplateNames.has(
activeWorkflow.filename
)
if (isTemplate) {
const template = templatesStore.getTemplateByName(activeWorkflow.filename)
const englishMetadata = templatesStore.getEnglishMetadata(
activeWorkflow.filename
)
return {
is_template: true,
workflow_name: activeWorkflow.filename,
template_source: template?.sourceModule,
template_category: englishMetadata?.category ?? template?.category,
template_tags: englishMetadata?.tags ?? template?.tags,
template_models: englishMetadata?.models ?? template?.models,
template_use_case: englishMetadata?.useCase ?? template?.useCase,
template_license: englishMetadata?.license ?? template?.license,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: activeWorkflow.filename,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: undefined,
...nodeCounts
}
}

View File

@@ -183,6 +183,31 @@ describe('useWorkflowStore', () => {
const workflow = store.createTemporary('a.json')
expect(workflow.path).toBe('workflows/a (2).json')
})
it('should assign a workflow id to newly created temporary workflows', () => {
const workflow = store.createTemporary('id-test.json')
const state = JSON.parse(workflow.content!)
expect(typeof state.id).toBe('string')
expect(state.id.length).toBeGreaterThan(0)
})
it('should assign an id when temporary workflow data is missing one', () => {
const workflowDataWithoutId = {
...defaultGraph,
id: undefined
}
const workflow = store.createTemporary(
'missing-id.json',
workflowDataWithoutId
)
const state = JSON.parse(workflow.content!)
expect(typeof state.id).toBe('string')
expect(state.id.length).toBeGreaterThan(0)
expect(workflowDataWithoutId.id).toBeUndefined()
})
})
describe('openWorkflow', () => {

View File

@@ -254,6 +254,20 @@ export const useWorkflowStore = defineStore('workflow', () => {
return workflow
}
const ensureWorkflowId = (
workflowData?: ComfyWorkflowJSON
): ComfyWorkflowJSON => {
const base = workflowData
? (JSON.parse(JSON.stringify(workflowData)) as ComfyWorkflowJSON)
: (JSON.parse(defaultGraphJSON) as ComfyWorkflowJSON)
if (!base.id) {
base.id = generateUUID()
}
return base
}
/**
* Helper to create a new temporary workflow
*/
@@ -267,9 +281,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
size: -1
})
workflow.originalContent = workflow.content = workflowData
? JSON.stringify(workflowData)
: defaultGraphJSON
const initialWorkflowData = ensureWorkflowId(workflowData)
workflow.originalContent = workflow.content =
JSON.stringify(initialWorkflowData)
workflowLookup.value[workflow.path] = workflow
return workflow
@@ -283,9 +297,13 @@ export const useWorkflowStore = defineStore('workflow', () => {
ComfyWorkflow.basePath + (path ?? 'Unsaved Workflow.json')
)
const normalizedWorkflowData = workflowData
? ensureWorkflowId(workflowData)
: undefined
// Try to reuse an existing loaded workflow with the same filename
// that is not stored in the workflows directory
if (path && workflowData) {
if (path && normalizedWorkflowData) {
const existingWorkflow = workflows.value.find(
(w) => w.fullFilename === path
)
@@ -295,12 +313,12 @@ export const useWorkflowStore = defineStore('workflow', () => {
ComfyWorkflow.basePath.slice(0, -1)
)
) {
existingWorkflow.changeTracker.reset(workflowData)
existingWorkflow.changeTracker.reset(normalizedWorkflowData)
return existingWorkflow
}
}
return createNewWorkflow(fullPath, workflowData)
return createNewWorkflow(fullPath, normalizedWorkflowData)
}
/**

View File

@@ -1,289 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import type * as I18n from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { WorkflowDraftSnapshot } from '@/platform/workflow/persistence/base/draftCache'
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { defaultGraphJSON } from '@/scripts/defaultGraph'
import { setStorageValue } from '@/scripts/utils'
const settingMocks = vi.hoisted(() => ({
persistRef: null as { value: boolean } | null
}))
vi.mock('@/platform/settings/settingStore', async () => {
const { ref } = await import('vue')
settingMocks.persistRef = ref(true)
return {
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Workflow.Persist')
return settingMocks.persistRef!.value
return undefined
}),
set: vi.fn()
}))
}
})
const mockToastAdd = vi.fn()
vi.mock('primevue', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof I18n>()
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
const loadBlankWorkflow = vi.fn()
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
loadBlankWorkflow
})
}))
vi.mock(
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
() => ({
useTemplateUrlLoader: () => ({
loadTemplateFromUrlParams: vi.fn()
})
})
)
const executeCommand = vi.fn()
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: executeCommand
})
}))
type GraphChangedHandler = (() => void) | null
const mocks = vi.hoisted(() => {
const state = {
graphChangedHandler: null as GraphChangedHandler,
currentGraph: {} as Record<string, unknown>
}
const serializeMock = vi.fn(() => state.currentGraph)
const loadGraphDataMock = vi.fn()
const apiMock = {
clientId: 'test-client',
initialClientId: 'test-client',
addEventListener: vi.fn((event: string, handler: () => void) => {
if (event === 'graphChanged') {
state.graphChangedHandler = handler
}
}),
removeEventListener: vi.fn(),
getUserData: vi.fn(),
storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn(),
storeSetting: vi.fn(),
getSettings: vi.fn(),
deleteUserData: vi.fn(),
moveUserData: vi.fn(),
apiURL: vi.fn((path: string) => path)
}
return { state, serializeMock, loadGraphDataMock, apiMock }
})
vi.mock('@/scripts/app', () => ({
app: {
graph: {
serialize: () => mocks.serializeMock()
},
rootGraph: {
serialize: () => mocks.serializeMock()
},
loadGraphData: (...args: unknown[]) => mocks.loadGraphDataMock(...args),
canvas: {}
}
}))
vi.mock('@/scripts/api', () => ({
api: mocks.apiMock
}))
describe('useWorkflowPersistence', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
setActivePinia(createTestingPinia({ stubActions: false }))
localStorage.clear()
sessionStorage.clear()
vi.clearAllMocks()
settingMocks.persistRef!.value = true
mockToastAdd.mockClear()
useWorkflowDraftStore().reset()
mocks.state.graphChangedHandler = null
mocks.state.currentGraph = { initial: true }
mocks.serializeMock.mockImplementation(() => mocks.state.currentGraph)
mocks.loadGraphDataMock.mockReset()
mocks.apiMock.clientId = 'test-client'
mocks.apiMock.initialClientId = 'test-client'
mocks.apiMock.addEventListener.mockImplementation(
(event: string, handler: () => void) => {
if (event === 'graphChanged') {
mocks.state.graphChangedHandler = handler
}
}
)
mocks.apiMock.removeEventListener.mockImplementation(() => {})
mocks.apiMock.listUserDataFullInfo.mockResolvedValue([])
mocks.apiMock.getUserData.mockResolvedValue({
status: 200,
text: () => Promise.resolve(defaultGraphJSON)
} as Response)
mocks.apiMock.apiURL.mockImplementation((path: string) => path)
})
afterEach(() => {
vi.useRealTimers()
})
it('persists snapshots for multiple workflows', async () => {
const workflowStore = useWorkflowStore()
const workflowA = workflowStore.createTemporary('DraftA.json')
await workflowStore.openWorkflow(workflowA)
const persistence = useWorkflowPersistence()
expect(persistence).toBeDefined()
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
const graphA = { title: 'A' }
mocks.state.currentGraph = graphA
mocks.state.graphChangedHandler!()
await vi.advanceTimersByTimeAsync(800)
const workflowB = workflowStore.createTemporary('DraftB.json')
await workflowStore.openWorkflow(workflowB)
const graphB = { title: 'B' }
mocks.state.currentGraph = graphB
mocks.state.graphChangedHandler!()
await vi.advanceTimersByTimeAsync(800)
const drafts = JSON.parse(
localStorage.getItem('Comfy.Workflow.Drafts') ?? '{}'
) as Record<string, { data: string; isTemporary: boolean }>
expect(Object.keys(drafts)).toEqual(
expect.arrayContaining(['workflows/DraftA.json', 'workflows/DraftB.json'])
)
expect(JSON.parse(drafts['workflows/DraftA.json'].data)).toEqual(graphA)
expect(JSON.parse(drafts['workflows/DraftB.json'].data)).toEqual(graphB)
expect(drafts['workflows/DraftA.json'].isTemporary).toBe(true)
expect(drafts['workflows/DraftB.json'].isTemporary).toBe(true)
})
it('evicts least recently used drafts beyond the limit', async () => {
const workflowStore = useWorkflowStore()
useWorkflowPersistence()
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
for (let i = 0; i < 33; i++) {
const workflow = workflowStore.createTemporary(`Draft${i}.json`)
await workflowStore.openWorkflow(workflow)
mocks.state.currentGraph = { index: i }
mocks.state.graphChangedHandler!()
await vi.advanceTimersByTimeAsync(800)
vi.setSystemTime(new Date(Date.now() + 60000))
}
const drafts = JSON.parse(
localStorage.getItem('Comfy.Workflow.Drafts') ?? '{}'
) as Record<string, WorkflowDraftSnapshot>
expect(Object.keys(drafts).length).toBe(32)
expect(drafts['workflows/Draft0.json']).toBeUndefined()
expect(drafts['workflows/Draft32.json']).toBeDefined()
})
it('restores temporary tabs from cached drafts', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStore()
const draftData = JSON.parse(defaultGraphJSON)
draftStore.saveDraft('workflows/Unsaved Workflow.json', {
data: JSON.stringify(draftData),
updatedAt: Date.now(),
name: 'Unsaved Workflow.json',
isTemporary: true
})
setStorageValue(
'Comfy.OpenWorkflowsPaths',
JSON.stringify(['workflows/Unsaved Workflow.json'])
)
setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(0))
const { restoreWorkflowTabsState } = useWorkflowPersistence()
restoreWorkflowTabsState()
const restored = workflowStore.getWorkflowByPath(
'workflows/Unsaved Workflow.json'
)
expect(restored).toBeTruthy()
expect(restored?.isTemporary).toBe(true)
expect(
workflowStore.openWorkflows.map((workflow) => workflow?.path)
).toContain('workflows/Unsaved Workflow.json')
})
it('shows error toast when draft save fails', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStore()
const workflow = workflowStore.createTemporary('FailingDraft.json')
await workflowStore.openWorkflow(workflow)
useWorkflowPersistence()
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
vi.spyOn(draftStore, 'saveDraft').mockImplementation(() => {
throw new Error('Storage quota exceeded')
})
mocks.state.currentGraph = { title: 'Test' }
mocks.state.graphChangedHandler!()
await vi.advanceTimersByTimeAsync(800)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: expect.any(String)
})
)
})
it('clears all drafts when Persist is switched from true to false', async () => {
const workflowStore = useWorkflowStore()
const draftStore = useWorkflowDraftStore()
const workflow = workflowStore.createTemporary('ClearDraft.json')
await workflowStore.openWorkflow(workflow)
useWorkflowPersistence()
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
mocks.state.currentGraph = { title: 'Draft to clear' }
mocks.state.graphChangedHandler!()
await vi.advanceTimersByTimeAsync(800)
expect(draftStore.getDraft('workflows/ClearDraft.json')).toBeDefined()
settingMocks.persistRef!.value = false
await nextTick()
expect(draftStore.getDraft('workflows/ClearDraft.json')).toBeUndefined()
})
})

View File

@@ -1,263 +0,0 @@
import { useToast } from 'primevue'
import { tryOnScopeDispose } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
import { useCommandStore } from '@/stores/commandStore'
export function useWorkflowPersistence() {
const { t } = useI18n()
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const route = useRoute()
const router = useRouter()
const templateUrlLoader = useTemplateUrlLoader()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
const workflowDraftStore = useWorkflowDraftStore()
const toast = useToast()
const ensureTemplateQueryFromIntent = async () => {
hydratePreservedQuery(TEMPLATE_NAMESPACE)
const mergedQuery = mergePreservedQueryIntoQuery(
TEMPLATE_NAMESPACE,
route.query
)
if (mergedQuery) {
await router.replace({ query: mergedQuery })
}
return mergedQuery ?? route.query
}
const workflowPersistenceEnabled = computed(() =>
settingStore.get('Comfy.Workflow.Persist')
)
const lastSavedJsonByPath = ref<Record<string, string>>({})
watch(workflowPersistenceEnabled, (enabled) => {
if (!enabled) {
workflowDraftStore.reset()
lastSavedJsonByPath.value = {}
}
})
const persistCurrentWorkflow = () => {
if (!workflowPersistenceEnabled.value) return
const activeWorkflow = workflowStore.activeWorkflow
if (!activeWorkflow) return
const graphData = comfyApp.rootGraph.serialize()
const workflowJson = JSON.stringify(graphData)
const workflowPath = activeWorkflow.path
if (workflowJson === lastSavedJsonByPath.value[workflowPath]) return
try {
workflowDraftStore.saveDraft(activeWorkflow.path, {
data: workflowJson,
updatedAt: Date.now(),
name: activeWorkflow.key,
isTemporary: activeWorkflow.isTemporary
})
} catch (error) {
console.error('Failed to save draft', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToSaveDraft'),
life: 3000
})
return
}
try {
localStorage.setItem('workflow', workflowJson)
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflowJson)
}
} catch (error) {
// Only log our own keys and aggregate stats
const ourKeys = Object.keys(sessionStorage).filter(
(key) => key.startsWith('workflow:') || key === 'workflow'
)
console.error('QuotaExceededError details:', {
workflowSizeKB: Math.round(workflowJson.length / 1024),
totalStorageItems: Object.keys(sessionStorage).length,
ourWorkflowKeys: ourKeys.length,
ourWorkflowSizes: ourKeys.map((key) => ({
key,
sizeKB: Math.round(sessionStorage[key].length / 1024)
})),
error: error instanceof Error ? error.message : String(error)
})
throw error
}
lastSavedJsonByPath.value[workflowPath] = workflowJson
if (!activeWorkflow.isTemporary && !activeWorkflow.isModified) {
workflowDraftStore.removeDraft(activeWorkflow.path)
return
}
}
const loadPreviousWorkflowFromStorage = async () => {
const workflowName = getStorageValue('Comfy.PreviousWorkflow')
const preferredPath = workflowName
? `${ComfyWorkflow.basePath}${workflowName}`
: null
return await workflowDraftStore.loadPersistedWorkflow({
workflowName,
preferredPath,
fallbackToLatestDraft: !workflowName
})
}
const loadDefaultWorkflow = async () => {
if (!settingStore.get('Comfy.TutorialCompleted')) {
await settingStore.set('Comfy.TutorialCompleted', true)
await useWorkflowService().loadBlankWorkflow()
await useCommandStore().execute('Comfy.BrowseTemplates')
} else {
await comfyApp.loadGraphData()
}
}
const initializeWorkflow = async () => {
if (!workflowPersistenceEnabled.value) return
try {
const restored = await loadPreviousWorkflowFromStorage()
if (!restored) {
await loadDefaultWorkflow()
}
} catch (err) {
console.error('Error loading previous workflow', err)
await loadDefaultWorkflow()
}
}
const loadTemplateFromUrlIfPresent = async () => {
const query = await ensureTemplateQueryFromIntent()
const hasTemplateUrl = query.template && typeof query.template === 'string'
if (hasTemplateUrl) {
await templateUrlLoader.loadTemplateFromUrl()
}
}
// Setup watchers
watch(
() => workflowStore.activeWorkflow?.key,
(activeWorkflowKey) => {
if (!activeWorkflowKey) return
setStorageValue('Comfy.PreviousWorkflow', activeWorkflowKey)
// When the activeWorkflow changes, the graph has already been loaded.
// Saving the current state of the graph to the localStorage.
persistCurrentWorkflow()
}
)
api.addEventListener('graphChanged', persistCurrentWorkflow)
// Clean up event listener when component unmounts
tryOnScopeDispose(() => {
api.removeEventListener('graphChanged', persistCurrentWorkflow)
})
// Restore workflow tabs states
const openWorkflows = computed(() => workflowStore.openWorkflows)
const activeWorkflow = computed(() => workflowStore.activeWorkflow)
const restoreState = computed<{ paths: string[]; activeIndex: number }>(
() => {
if (!openWorkflows.value || !activeWorkflow.value) {
return { paths: [], activeIndex: -1 }
}
const paths = openWorkflows.value
.map((workflow) => workflow?.path)
.filter(
(path): path is string =>
typeof path === 'string' && path.startsWith(ComfyWorkflow.basePath)
)
const activeIndex = paths.indexOf(activeWorkflow.value.path)
return { paths, activeIndex }
}
)
// Get storage values before setting watchers
const parsedWorkflows = JSON.parse(
getStorageValue('Comfy.OpenWorkflowsPaths') || '[]'
)
const storedWorkflows = Array.isArray(parsedWorkflows)
? parsedWorkflows.filter(
(entry): entry is string => typeof entry === 'string'
)
: []
const parsedIndex = JSON.parse(
getStorageValue('Comfy.ActiveWorkflowIndex') || '-1'
)
const storedActiveIndex =
typeof parsedIndex === 'number' && Number.isFinite(parsedIndex)
? parsedIndex
: -1
watch(restoreState, ({ paths, activeIndex }) => {
if (workflowPersistenceEnabled.value) {
setStorageValue('Comfy.OpenWorkflowsPaths', JSON.stringify(paths))
setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(activeIndex))
}
})
const restoreWorkflowTabsState = () => {
if (!workflowPersistenceEnabled.value) return
const isRestorable = storedWorkflows?.length > 0 && storedActiveIndex >= 0
if (!isRestorable) return
storedWorkflows.forEach((path: string) => {
if (workflowStore.getWorkflowByPath(path)) return
const draft = workflowDraftStore.getDraft(path)
if (!draft?.isTemporary) return
try {
const workflowData = JSON.parse(draft.data)
workflowStore.createTemporary(draft.name, workflowData)
} catch (err) {
console.warn(
'Failed to parse workflow draft, creating with default',
err
)
workflowDraftStore.removeDraft(path)
workflowStore.createTemporary(draft.name)
}
})
workflowStore.openWorkflowsInBackground({
left: storedWorkflows.slice(0, storedActiveIndex),
right: storedWorkflows.slice(storedActiveIndex)
})
}
return {
initializeWorkflow,
loadTemplateFromUrlIfPresent,
restoreWorkflowTabsState
}
}

View File

@@ -33,6 +33,7 @@ import { clearAllV2Storage } from '../base/storageIO'
import { migrateV1toV2 } from '../migration/migrateV1toV2'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
import { useWorkflowTabState } from './useWorkflowTabState'
import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
@@ -44,6 +45,7 @@ export function useWorkflowPersistenceV2() {
const settingStore = useSettingStore()
const route = useRoute()
const router = useRouter()
const sharedWorkflowUrlLoader = useSharedWorkflowUrlLoader()
const templateUrlLoader = useTemplateUrlLoader()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
const draftStore = useWorkflowDraftStoreV2()
@@ -183,6 +185,10 @@ export function useWorkflowPersistenceV2() {
}
}
const loadSharedWorkflowFromUrlIfPresent = async () => {
return await sharedWorkflowUrlLoader.loadSharedWorkflowFromUrl()
}
// Setup watchers
watch(
() => workflowStore.activeWorkflow?.key,
@@ -279,6 +285,7 @@ export function useWorkflowPersistenceV2() {
return {
initializeWorkflow,
loadSharedWorkflowFromUrlIfPresent,
loadTemplateFromUrlIfPresent,
restoreWorkflowTabsState
}

View File

@@ -0,0 +1,92 @@
<template>
<div class="flex flex-col gap-1">
<CollapsibleRoot
v-for="section in sections"
:key="section.id"
class="overflow-hidden rounded-sm"
:open="expandedSectionId === section.id"
@update:open="onSectionOpenChange(section.id, $event)"
>
<CollapsibleTrigger as-child>
<Button
:data-testid="`section-header-${section.id}`"
:aria-expanded="expandedSectionId === section.id"
:aria-controls="`section-content-${section.id}`"
variant="secondary"
class="w-full justify-between px-6 py-1"
>
<span>
{{ $t(section.labelKey, section.items.length) }}
</span>
<i
:class="
cn(
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform',
expandedSectionId === section.id && 'rotate-90'
)
"
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
:id="`section-content-${section.id}`"
:data-testid="`section-content-${section.id}`"
class="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
>
<ul class="max-h-25 overflow-y-auto px-6 pb-1 pt-0.5">
<li
v-for="item in section.items"
:key="item.id"
class="flex items-center gap-2 rounded-sm py-1"
>
<ShareAssetThumbnail
:name="item.name"
:preview-url="item.preview_url"
@thumbnail-error="
onThumbnailError($event.name, $event.previewUrl)
"
/>
<span class="truncate text-xs text-base-foreground">
{{ item.name }}
</span>
<span
v-if="item.in_library"
class="ml-auto shrink-0 text-xs text-muted-foreground"
>
{{ $t('shareWorkflow.inLibrary') }}
</span>
</li>
</ul>
</CollapsibleContent>
</CollapsibleRoot>
</div>
</template>
<script setup lang="ts">
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger
} from 'reka-ui'
import type { AssetInfo } from '@/schemas/apiSchema'
import ShareAssetThumbnail from '@/platform/workflow/sharing/components/ShareAssetThumbnail.vue'
import { useAssetSections } from '@/platform/workflow/sharing/composables/useAssetSections'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const { items } = defineProps<{
items: AssetInfo[]
}>()
const { sections, expandedSectionId, onSectionOpenChange } = useAssetSections(
() => items
)
function onThumbnailError(name: string, previewUrl: string | null | undefined) {
console.warn('[share][assets][thumbnail-error]', {
name,
previewUrl: previewUrl ?? null
})
}
</script>

View File

@@ -0,0 +1,329 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
const mockGetSharedWorkflow = vi.fn()
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
SharedWorkflowLoadError: class extends Error {},
useWorkflowShareService: () => ({
getSharedWorkflow: mockGetSharedWorkflow
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { close: 'Close', cancel: 'Cancel' },
openSharedWorkflow: {
dialogTitle: 'Open shared workflow',
copyDescription:
'Opening the workflow will create a new copy in your workspace',
nonPublicAssetsWarningLine1:
'This workflow comes with non-public assets.',
nonPublicAssetsWarningLine2:
'These will be added to your library when you open the workflow',
copyAssetsAndOpen: 'Copy assets & open workflow',
openWorkflow: 'Open workflow',
openWithoutImporting: 'Open without importing',
loadError:
'Could not load this shared workflow. Please try again later.'
},
shareWorkflow: {
mediaLabel: '{count} Media File | {count} Media Files',
modelsLabel: '{count} Model | {count} Models'
}
}
}
})
function makePayload(
overrides: Partial<SharedWorkflowPayload> = {}
): SharedWorkflowPayload {
return {
shareId: 'share-id-1',
workflowId: 'workflow-id-1',
name: 'Test Workflow',
listed: true,
publishedAt: new Date('2026-02-20T00:00:00Z'),
workflowJson: {
nodes: []
} as unknown as SharedWorkflowPayload['workflowJson'],
assets: [],
...overrides
}
}
function mountComponent(props: Record<string, unknown> = {}) {
return mount(OpenSharedWorkflowDialogContent, {
global: {
plugins: [i18n],
stubs: {
AssetSectionList: { template: '<div class="asset-list-stub" />' },
'asset-section-list': { template: '<div class="asset-list-stub" />' }
}
},
props: {
shareId: 'test-share-id',
onConfirm: vi.fn(),
onOpenWithoutImporting: vi.fn(),
onCancel: vi.fn(),
...props
}
})
}
describe('OpenSharedWorkflowDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('loading state', () => {
it('shows skeleton placeholders while loading', () => {
mockGetSharedWorkflow.mockReturnValue(new Promise(() => {}))
const wrapper = mountComponent()
expect(
wrapper.findAllComponents({ name: 'Skeleton' }).length
).toBeGreaterThan(0)
})
it('shows dialog title in header while loading', () => {
mockGetSharedWorkflow.mockReturnValue(new Promise(() => {}))
const wrapper = mountComponent()
const header = wrapper.find('header h2')
expect(header.text()).toBe('Open shared workflow')
})
})
describe('error state', () => {
it('shows error message when fetch fails', async () => {
mockGetSharedWorkflow.mockRejectedValue(new Error('Network error'))
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain(
'Could not load this shared workflow. Please try again later.'
)
})
it('shows close button in error state', async () => {
mockGetSharedWorkflow.mockRejectedValue(new Error('Network error'))
const wrapper = mountComponent()
await flushPromises()
const footerButtons = wrapper.findAll('footer button')
expect(footerButtons).toHaveLength(1)
expect(footerButtons[0].text()).toBe('Close')
})
it('calls onCancel when close is clicked in error state', async () => {
mockGetSharedWorkflow.mockRejectedValue(new Error('Network error'))
const onCancel = vi.fn()
const wrapper = mountComponent({ onCancel })
await flushPromises()
const closeButton = wrapper
.findAll('footer button')
.find((b) => b.text() === 'Close')
await closeButton!.trigger('click')
expect(onCancel).toHaveBeenCalled()
})
})
describe('loaded state - no assets', () => {
it('shows workflow name in body', async () => {
mockGetSharedWorkflow.mockResolvedValue(
makePayload({ name: 'My Workflow' })
)
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('main h2').text()).toBe('My Workflow')
})
it('shows "Open workflow" as primary CTA', async () => {
mockGetSharedWorkflow.mockResolvedValue(makePayload())
const wrapper = mountComponent()
await flushPromises()
const buttons = wrapper.findAll('footer button')
const primaryButton = buttons[buttons.length - 1]
expect(primaryButton.text()).toBe('Open workflow')
})
it('does not show "Open without importing" button', async () => {
mockGetSharedWorkflow.mockResolvedValue(makePayload())
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).not.toContain('Open without importing')
})
it('does not show warning or asset sections', async () => {
mockGetSharedWorkflow.mockResolvedValue(makePayload())
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).not.toContain('non-public assets')
})
it('calls onConfirm with payload when primary button is clicked', async () => {
const payload = makePayload()
mockGetSharedWorkflow.mockResolvedValue(payload)
const onConfirm = vi.fn()
const wrapper = mountComponent({ onConfirm })
await flushPromises()
const buttons = wrapper.findAll('footer button')
await buttons[buttons.length - 1].trigger('click')
expect(onConfirm).toHaveBeenCalledWith(payload)
})
it('calls onCancel when cancel button is clicked', async () => {
mockGetSharedWorkflow.mockResolvedValue(makePayload())
const onCancel = vi.fn()
const wrapper = mountComponent({ onCancel })
await flushPromises()
const cancelButton = wrapper
.findAll('footer button')
.find((b) => b.text() === 'Cancel')
await cancelButton!.trigger('click')
expect(onCancel).toHaveBeenCalled()
})
})
describe('loaded state - with assets', () => {
const assetsPayload = makePayload({
assets: [
{
id: 'a1',
name: 'photo.png',
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
},
{
id: 'a2',
name: 'image.jpg',
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
},
{
id: 'm1',
name: 'model.safetensors',
preview_url: '',
storage_url: '',
model: true,
public: false,
in_library: false
}
]
})
it('shows "Copy assets & open workflow" as primary CTA', async () => {
mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
const wrapper = mountComponent()
await flushPromises()
const buttons = wrapper.findAll('footer button')
const primaryButton = buttons[buttons.length - 1]
expect(primaryButton.text()).toBe('Copy assets & open workflow')
})
it('shows non-public assets warning', async () => {
mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.text()).toContain('non-public assets')
})
it('shows "Open without importing" button', async () => {
mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
const wrapper = mountComponent()
await flushPromises()
const openWithoutImporting = wrapper
.findAll('button')
.find((b) => b.text() === 'Open without importing')
expect(openWithoutImporting).toBeDefined()
})
it('calls onOpenWithoutImporting with payload', async () => {
mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
const onOpenWithoutImporting = vi.fn()
const wrapper = mountComponent({ onOpenWithoutImporting })
await flushPromises()
const button = wrapper
.findAll('button')
.find((b) => b.text() === 'Open without importing')
await button!.trigger('click')
expect(onOpenWithoutImporting).toHaveBeenCalledWith(assetsPayload)
})
it('calls onConfirm with payload when primary button is clicked', async () => {
mockGetSharedWorkflow.mockResolvedValue(assetsPayload)
const onConfirm = vi.fn()
const wrapper = mountComponent({ onConfirm })
await flushPromises()
const buttons = wrapper.findAll('footer button')
await buttons[buttons.length - 1].trigger('click')
expect(onConfirm).toHaveBeenCalledWith(assetsPayload)
})
it('filters out assets already in library', async () => {
const mixedPayload = makePayload({
assets: [
{
id: 'a1',
name: 'needed.png',
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
},
{
id: 'a2',
name: 'already-have.png',
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: true
}
]
})
mockGetSharedWorkflow.mockResolvedValue(mixedPayload)
const wrapper = mountComponent()
await flushPromises()
// Should still show assets panel (has 1 non-owned)
expect(wrapper.text()).toContain('non-public assets')
})
})
describe('fetches with correct shareId', () => {
it('passes shareId to getSharedWorkflow', async () => {
mockGetSharedWorkflow.mockResolvedValue(makePayload())
mountComponent({ shareId: 'my-share-123' })
await flushPromises()
expect(mockGetSharedWorkflow).toHaveBeenCalledWith('my-share-123')
})
})
})

View File

@@ -0,0 +1,175 @@
<template>
<div class="flex w-full flex-col">
<header
class="flex h-12 items-center justify-between gap-2 border-b border-border-default px-4"
>
<h2 class="text-sm text-base-foreground">
{{ $t('openSharedWorkflow.dialogTitle') }}
</h2>
<Button size="icon" :aria-label="$t('g.close')" @click="onCancel">
<i class="icon-[lucide--x] size-4" />
</Button>
</header>
<template v-if="isLoading">
<main class="flex gap-8 px-8 pt-4 pb-6">
<div class="flex min-w-0 flex-1 flex-col gap-12 py-4">
<Skeleton class="h-8 w-3/5" />
<Skeleton class="h-4 w-4/5" />
</div>
<div class="flex w-84 shrink-0 flex-col gap-2 py-4">
<Skeleton class="h-4 w-full" />
<Skeleton class="h-20 w-full rounded-lg" />
</div>
</main>
<footer
class="flex items-center justify-end gap-2.5 border-t border-border-default px-8 py-4"
>
<Skeleton class="h-10 w-24 rounded-md" />
<Skeleton class="h-10 w-40 rounded-md" />
</footer>
</template>
<template v-else-if="error">
<main class="flex flex-col items-center gap-4 px-8 py-8">
<i
class="icon-[lucide--circle-alert] size-8 text-warning-background"
aria-hidden="true"
/>
<p class="m-0 text-center text-sm text-muted-foreground">
{{ $t('openSharedWorkflow.loadError') }}
</p>
</main>
<footer
class="flex items-center justify-end gap-2.5 border-t border-border-default px-8 py-4"
>
<Button variant="secondary" size="lg" @click="onCancel">
{{ $t('g.close') }}
</Button>
</footer>
</template>
<template v-else-if="sharedWorkflow">
<main :class="cn('flex gap-8 px-8 pt-4 pb-6', !hasAssets && 'flex-col')">
<div class="flex min-w-0 flex-1 flex-col gap-12 py-4">
<h2 class="m-0 text-2xl font-semibold text-base-foreground">
{{ workflowName }}
</h2>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('openSharedWorkflow.copyDescription') }}
</p>
</div>
<div v-if="hasAssets" class="flex w-96 shrink-0 flex-col gap-2 py-4">
<CollapsibleRoot
v-model:open="isWarningExpanded"
class="overflow-hidden rounded-lg bg-secondary-background"
>
<CollapsibleTrigger as-child>
<Button
variant="secondary"
class="w-full justify-between px-4 py-1 text-sm"
>
<i
class="icon-[lucide--circle-alert] size-4 shrink-0 text-warning-background"
aria-hidden="true"
/>
<span
class="m-0 flex-1 text-left text-sm text-muted-foreground"
>
{{ $t('openSharedWorkflow.nonPublicAssetsWarningLine1') }}
</span>
<i
:class="
cn(
'icon-[lucide--chevron-right] size-4 shrink-0 text-muted-foreground transition-transform',
isWarningExpanded && 'rotate-90'
)
"
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
class="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
>
<AssetSectionList :items="nonOwnedAssets" />
</CollapsibleContent>
</CollapsibleRoot>
</div>
</main>
<footer
class="flex items-center justify-end gap-2.5 border-t border-border-default px-8 py-4"
>
<Button variant="secondary" size="lg" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
v-if="hasAssets"
variant="secondary"
size="lg"
@click="onOpenWithoutImporting(sharedWorkflow)"
>
{{ $t('openSharedWorkflow.openWithoutImporting') }}
</Button>
<Button variant="primary" size="lg" @click="onConfirm(sharedWorkflow)">
{{
hasAssets
? $t('openSharedWorkflow.copyAssetsAndOpen')
: $t('openSharedWorkflow.openWorkflow')
}}
</Button>
</footer>
</template>
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger
} from 'reka-ui'
import { computed, ref } from 'vue'
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
import AssetSectionList from '@/platform/workflow/sharing/components/AssetSectionList.vue'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import Button from '@/components/ui/button/Button.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { cn } from '@/utils/tailwindUtil'
const { shareId, onConfirm, onOpenWithoutImporting, onCancel } = defineProps<{
shareId: string
onConfirm: (payload: SharedWorkflowPayload) => void
onOpenWithoutImporting: (payload: SharedWorkflowPayload) => void
onCancel: () => void
}>()
const workflowShareService = useWorkflowShareService()
const isWarningExpanded = ref(true)
const {
state: sharedWorkflow,
isLoading,
error
} = useAsyncState(() => workflowShareService.getSharedWorkflow(shareId), null)
const nonOwnedAssets = computed(
() => sharedWorkflow.value?.assets.filter((a) => !a.in_library) ?? []
)
const hasAssets = computed(() => nonOwnedAssets.value.length > 0)
const workflowName = computed(() => {
if (!sharedWorkflow.value) return ''
if (sharedWorkflow.value.name) return sharedWorkflow.value.name
const jsonName = (
sharedWorkflow.value.workflowJson as Record<string, unknown>
).name
if (typeof jsonName === 'string' && jsonName) return jsonName
return ''
})
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div
class="relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted"
>
<Skeleton
v-if="normalizedPreviewUrl && isLoading"
class="absolute inset-0"
/>
<img
v-if="normalizedPreviewUrl && !error"
:src="normalizedPreviewUrl"
:alt="name"
:class="
cn(
'size-full object-cover transition-opacity duration-200',
isReady ? 'opacity-100' : 'opacity-0'
)
"
@error="
$emit('thumbnailError', { name, previewUrl: normalizedPreviewUrl })
"
/>
<i
v-if="!normalizedPreviewUrl || error"
class="icon-[lucide--image] size-4 text-muted-foreground"
/>
</div>
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import { computed } from 'vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { cn } from '@/utils/tailwindUtil'
const { name, previewUrl } = defineProps<{
name: string
previewUrl: string | null | undefined
}>()
defineEmits<{
thumbnailError: [{ name: string; previewUrl: string | null }]
}>()
const normalizedPreviewUrl = computed(() => {
if (typeof previewUrl !== 'string' || previewUrl.length === 0) return null
try {
const url = new URL(previewUrl, window.location.origin)
if (
!url.origin.includes('googleapis') &&
url.searchParams.has('filename') &&
!url.searchParams.has('res')
)
url.searchParams.set('res', '256')
return url.toString()
} catch {
return previewUrl
}
})
const imageOptions = computed(() => ({
src: normalizedPreviewUrl.value ?? ''
}))
const { isReady, isLoading, error } = useImage(imageOptions)
</script>

View File

@@ -0,0 +1,268 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComponentProps } from 'vue-component-type-helpers'
import ShareAssetWarningBox from '@/platform/workflow/sharing/components/ShareAssetWarningBox.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
shareWorkflow: {
privateAssetsDescription:
'Your workflow contains private models and/or media files',
mediaLabel: '{count} Media File | {count} Media Files',
modelsLabel: '{count} Model | {count} Models',
acknowledgeCheckbox: 'I understand these assets...'
}
}
}
})
describe(ShareAssetWarningBox, () => {
function createWrapper(
props: Partial<ComponentProps<typeof ShareAssetWarningBox>> = {}
) {
return mount(ShareAssetWarningBox, {
props: {
items: [
{
id: 'asset-image',
name: 'image.png',
storage_url: '',
preview_url: 'https://example.com/a.jpg',
model: false,
public: false,
in_library: false
},
{
id: 'model-default',
name: 'model.safetensors',
storage_url: '',
preview_url: '',
model: true,
public: false,
in_library: false
}
],
acknowledged: false,
...props
},
global: {
plugins: [i18n]
}
})
}
it('renders warning text', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain(
'Your workflow contains private models and/or media files'
)
})
it('renders media and model collapsible sections', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('1 Media File')
expect(wrapper.text()).toContain('1 Model')
})
it('keeps at most one accordion section open at a time', async () => {
const wrapper = createWrapper()
const mediaHeader = wrapper.get('[data-testid="section-header-media"]')
const modelsHeader = wrapper.get('[data-testid="section-header-models"]')
const mediaChevron = mediaHeader.get('i')
const modelsChevron = modelsHeader.get('i')
expect(mediaHeader.attributes('aria-expanded')).toBe('true')
expect(modelsHeader.attributes('aria-expanded')).toBe('false')
expect(mediaHeader.attributes('aria-controls')).toBe(
'section-content-media'
)
expect(modelsHeader.attributes('aria-controls')).toBe(
'section-content-models'
)
expect(mediaChevron.classes()).toContain('rotate-90')
expect(modelsChevron.classes()).not.toContain('rotate-90')
await modelsHeader.trigger('click')
await nextTick()
expect(mediaHeader.attributes('aria-expanded')).toBe('false')
expect(modelsHeader.attributes('aria-expanded')).toBe('true')
expect(mediaChevron.classes()).not.toContain('rotate-90')
expect(modelsChevron.classes()).toContain('rotate-90')
await mediaHeader.trigger('click')
await nextTick()
expect(mediaHeader.attributes('aria-expanded')).toBe('true')
expect(modelsHeader.attributes('aria-expanded')).toBe('false')
expect(mediaChevron.classes()).toContain('rotate-90')
expect(modelsChevron.classes()).not.toContain('rotate-90')
await mediaHeader.trigger('click')
await nextTick()
expect(mediaHeader.attributes('aria-expanded')).toBe('false')
expect(modelsHeader.attributes('aria-expanded')).toBe('false')
})
it('defaults to media section when both sections are available', () => {
const wrapper = createWrapper()
const mediaHeader = wrapper.get('[data-testid="section-header-media"]')
const modelsHeader = wrapper.get('[data-testid="section-header-models"]')
expect(mediaHeader.attributes('aria-expanded')).toBe('true')
expect(modelsHeader.attributes('aria-expanded')).toBe('false')
})
it('defaults to models section when media is unavailable', () => {
const wrapper = createWrapper({
items: [
{
id: 'model-default',
name: 'model.safetensors',
storage_url: '',
preview_url: '',
model: true,
public: false,
in_library: false
}
]
})
expect(wrapper.text()).toContain('1 Model')
const modelsHeader = wrapper.get('[data-testid="section-header-models"]')
expect(modelsHeader.attributes('aria-expanded')).toBe('true')
})
it('allows collapsing the only expanded section when models are unavailable', async () => {
const wrapper = createWrapper({
items: [
{
id: 'asset-image',
name: 'image.png',
storage_url: '',
preview_url: 'https://example.com/a.jpg',
model: false,
public: false,
in_library: false
}
]
})
const mediaHeader = wrapper.get('[data-testid="section-header-media"]')
const mediaChevron = mediaHeader.get('i')
expect(mediaHeader.attributes('aria-expanded')).toBe('true')
expect(mediaChevron.classes()).toContain('rotate-90')
await mediaHeader.trigger('click')
await nextTick()
expect(mediaHeader.attributes('aria-expanded')).toBe('false')
expect(mediaChevron.classes()).not.toContain('rotate-90')
})
it('emits acknowledged update when checkbox is toggled', async () => {
const wrapper = createWrapper()
const checkbox = wrapper.find('input[type="checkbox"]')
await checkbox.setValue(true)
await nextTick()
expect(wrapper.emitted('update:acknowledged')).toBeTruthy()
expect(wrapper.emitted('update:acknowledged')![0]).toEqual([true])
})
it('displays asset names in the assets section', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('image.png')
})
it('renders thumbnail previews for assets when URLs are available', () => {
const wrapper = createWrapper()
const images = wrapper.findAll('img')
expect(images).toHaveLength(1)
expect(images[0].attributes('src')).toBe('https://example.com/a.jpg')
expect(images[0].attributes('alt')).toBe('image.png')
})
it('renders fallback icon when thumbnail is missing', () => {
const wrapper = createWrapper({
items: [
{
id: 'asset-image',
name: 'image.png',
storage_url: '',
preview_url: '',
model: false,
public: false,
in_library: false
},
{
id: 'model-default',
name: 'model.safetensors',
storage_url: '',
preview_url: '',
model: true,
public: false,
in_library: false
}
]
})
const fallbackIcons = wrapper
.findAll('i')
.filter((icon) => icon.classes().includes('icon-[lucide--image]'))
expect(fallbackIcons).toHaveLength(1)
})
it('hides assets section when no assets provided', () => {
const wrapper = createWrapper({
items: [
{
id: 'model-default',
name: 'model.safetensors',
storage_url: '',
preview_url: '',
model: true,
public: false,
in_library: false
}
]
})
expect(wrapper.text()).not.toContain('Media File')
})
it('hides models section when no models provided', () => {
const wrapper = createWrapper({
items: [
{
id: 'asset-image',
name: 'image.png',
storage_url: '',
preview_url: '',
model: false,
public: false,
in_library: false
}
]
})
expect(wrapper.text()).not.toContain('Model')
})
})

View File

@@ -0,0 +1,71 @@
<template>
<div class="rounded-lg flex flex-col gap-3">
<CollapsibleRoot
v-model:open="isWarningExpanded"
class="overflow-hidden rounded-lg bg-secondary-background"
>
<CollapsibleTrigger as-child>
<Button
variant="secondary"
class="w-full justify-between px-4 py-1 text-sm"
>
<i
class="icon-[lucide--circle-alert] size-4 shrink-0 text-warning-background"
aria-hidden="true"
/>
<span class="m-0 flex-1 text-left text-sm text-muted-foreground">
{{ $t('shareWorkflow.privateAssetsDescription') }}
</span>
<i
:class="
cn(
'icon-[lucide--chevron-right] size-4 shrink-0 text-muted-foreground transition-transform',
isWarningExpanded && 'rotate-90'
)
"
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
class="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
>
<AssetSectionList :items />
</CollapsibleContent>
</CollapsibleRoot>
<label class="mt-3 flex cursor-pointer items-center gap-2">
<input
v-model="acknowledged"
type="checkbox"
class="size-3.5 shrink-0 cursor-pointer accent-primary-background"
/>
<span class="text-sm text-muted-foreground">
{{ $t('shareWorkflow.acknowledgeCheckbox') }}
</span>
</label>
</div>
</template>
<script setup lang="ts">
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger
} from 'reka-ui'
import { ref } from 'vue'
import type { AssetInfo } from '@/schemas/apiSchema'
import AssetSectionList from '@/platform/workflow/sharing/components/AssetSectionList.vue'
import { cn } from '@/utils/tailwindUtil'
import Button from '@/components/ui/button/Button.vue'
const { items } = defineProps<{
items: AssetInfo[]
}>()
const acknowledged = defineModel<boolean>('acknowledged')
const isWarningExpanded = ref(true)
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div class="flex items-center gap-2">
<Input
readonly
:model-value="url"
:aria-label="$t('shareWorkflow.shareUrlLabel')"
class="flex-1"
@focus="($event.target as HTMLInputElement).select()"
/>
<Button
variant="secondary"
size="lg"
class="font-normal"
@click="handleCopy"
>
{{
copied ? $t('shareWorkflow.linkCopied') : $t('shareWorkflow.copyLink')
}}
<i class="icon-[lucide--link] size-3.5" aria-hidden="true" />
</Button>
</div>
</template>
<script setup lang="ts">
import { refAutoReset } from '@vueuse/core'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
const { url } = defineProps<{
url: string
}>()
const { copyToClipboard } = useCopyToClipboard()
const copied = refAutoReset(false, 2000)
async function handleCopy() {
await copyToClipboard(url)
copied.value = true
}
</script>

View File

@@ -0,0 +1,532 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
const mockWorkflowStore = reactive<{
activeWorkflow: {
path: string
directory: string
filename: string
isTemporary: boolean
isModified: boolean
lastModified: number
} | null
}>({
activeWorkflow: null
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore
}))
const mockToast = vi.hoisted(() => ({ add: vi.fn() }))
vi.mock('primevue/usetoast', () => ({
useToast: () => mockToast
}))
vi.mock('@formkit/auto-animate/vue', () => ({
vAutoAnimate: {}
}))
const mockFlags = vi.hoisted(() => ({
comfyHubUploadEnabled: false,
comfyHubProfileGateEnabled: true
}))
const mockShowPublishDialog = vi.hoisted(() => vi.fn())
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: mockFlags
})
}))
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubPublishDialog',
() => ({
useComfyHubPublishDialog: () => ({
show: mockShowPublishDialog
})
})
)
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
saveWorkflow: vi.fn(),
renameWorkflow: vi.fn()
})
}))
const mockShareServiceData = vi.hoisted(() => ({
items: [
{
id: 'test.png',
name: 'test.png',
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
},
{
id: 'model.safetensors',
name: 'model.safetensors',
preview_url: '',
storage_url: '',
model: true,
public: false,
in_library: false
}
]
}))
const mockGetPublishStatus = vi.hoisted(() => vi.fn())
const mockPublishWorkflow = vi.hoisted(() => vi.fn())
const mockGetShareableAssets = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
useWorkflowShareService: () => ({
getPublishStatus: mockGetPublishStatus,
publishWorkflow: mockPublishWorkflow,
getShareableAssets: mockGetShareableAssets
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { close: 'Close', error: 'Error' },
shareWorkflow: {
unsavedDescription: 'You must save your workflow before sharing.',
shareLinkTab: 'Share',
publishToHubTab: 'Publish',
workflowNameLabel: 'Workflow name',
saving: 'Saving...',
saveButton: 'Save workflow',
createLinkButton: 'Create link',
creatingLink: 'Creating link...',
checkingAssets: 'Checking assets...',
successDescription: 'Anyone with this link...',
hasChangesDescription: 'You have made changes...',
updateLinkButton: 'Update link',
updatingLink: 'Updating link...',
publishedOn: 'Published on {date}',
mediaLabel: '{count} Media File | {count} Media Files',
modelsLabel: '{count} Model | {count} Models',
acknowledgeCheckbox: 'I understand these assets...',
loadFailed: 'Failed to load publish status'
},
comfyHubProfile: {
introTitle: 'Introducing ComfyHub',
createProfileButton: 'Create my profile',
startPublishingButton: 'Start publishing'
}
}
}
})
describe('ShareWorkflowDialogContent', () => {
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockPublishWorkflow.mockReset()
mockGetShareableAssets.mockReset()
mockWorkflowStore.activeWorkflow = {
path: 'workflows/test.json',
directory: 'workflows',
filename: 'test.json',
isTemporary: false,
isModified: false,
lastModified: 1000
}
mockGetPublishStatus.mockResolvedValue({
isPublished: false,
shareId: null,
shareUrl: null,
publishedAt: null
})
mockFlags.comfyHubUploadEnabled = false
mockShareServiceData.items = [
{
id: 'test.png',
name: 'test.png',
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
},
{
id: 'model.safetensors',
name: 'model.safetensors',
preview_url: '',
storage_url: '',
model: true,
public: false,
in_library: false
}
]
mockPublishWorkflow.mockResolvedValue({
shareId: 'test-123',
shareUrl: 'https://comfy.org/shared/test-123',
publishedAt: new Date('2026-01-15')
})
mockGetShareableAssets.mockResolvedValue(mockShareServiceData.items)
})
function createWrapper() {
return mount(ShareWorkflowDialogContent, {
props: { onClose },
global: {
plugins: [i18n],
stubs: {
ComfyHubPublishIntroPanel: {
template:
'<section data-testid="publish-intro"><button data-testid="publish-intro-cta" @click="$props.onCreateProfile()">Start publishing</button></section>',
props: ['onCreateProfile']
},
'comfy-hub-publish-intro-panel': {
template:
'<section data-testid="publish-intro"><button data-testid="publish-intro-cta" @click="$props.onCreateProfile()">Start publishing</button></section>',
props: ['onCreateProfile']
},
Input: {
template: '<input v-bind="$attrs" />',
methods: { focus() {}, select() {} }
}
}
}
})
}
it('renders in unsaved state when workflow is modified', async () => {
mockWorkflowStore.activeWorkflow = {
path: 'workflows/test.json',
directory: 'workflows',
filename: 'test.json',
isTemporary: false,
isModified: true,
lastModified: 1000
}
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain(
'You must save your workflow before sharing.'
)
expect(wrapper.text()).toContain('Save workflow')
})
it('renders share-link and publish tabs when comfy hub upload is enabled', async () => {
mockFlags.comfyHubUploadEnabled = true
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain('Share')
expect(wrapper.text()).toContain('Publish')
const publishTabPanel = wrapper.find('[data-testid="publish-tab-panel"]')
expect(publishTabPanel.exists()).toBe(true)
expect(publishTabPanel.attributes('style')).toContain('display: none')
})
it('hides the publish tab when comfy hub upload is disabled', async () => {
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain('Share')
expect(wrapper.text()).not.toContain('Publish')
expect(wrapper.find('[data-testid="publish-intro"]').exists()).toBe(false)
})
it('shows publish intro panel in the share dialog', async () => {
mockFlags.comfyHubUploadEnabled = true
const wrapper = createWrapper()
await flushPromises()
const publishTab = wrapper
.findAll('button')
.find((button) => button.text().includes('Publish'))
expect(publishTab).toBeDefined()
await publishTab!.trigger('click')
await flushPromises()
expect(wrapper.find('[data-testid="publish-intro"]').exists()).toBe(true)
})
it('shows start publishing CTA in the publish intro panel', async () => {
mockFlags.comfyHubUploadEnabled = true
const wrapper = createWrapper()
await flushPromises()
const publishTab = wrapper
.findAll('button')
.find((button) => button.text().includes('Publish'))
expect(publishTab).toBeDefined()
await publishTab!.trigger('click')
await flushPromises()
expect(wrapper.find('[data-testid="publish-intro-cta"]').text()).toBe(
'Start publishing'
)
})
it('opens publish dialog from intro cta and closes share dialog', async () => {
mockFlags.comfyHubUploadEnabled = true
const wrapper = createWrapper()
await flushPromises()
const publishTab = wrapper
.findAll('button')
.find((button) => button.text().includes('Publish'))
expect(publishTab).toBeDefined()
await publishTab!.trigger('click')
await flushPromises()
await wrapper.find('[data-testid="publish-intro-cta"]').trigger('click')
await nextTick()
expect(onClose).toHaveBeenCalledOnce()
expect(mockShowPublishDialog).toHaveBeenCalledOnce()
})
it('disables publish button when acknowledgment is unchecked', async () => {
const wrapper = createWrapper()
await flushPromises()
const publishButton = wrapper
.findAll('button')
.find((button) => button.text().includes('Create link'))
expect(publishButton?.attributes('disabled')).toBeDefined()
})
it('enables publish button when acknowledgment is checked', async () => {
const wrapper = createWrapper()
await flushPromises()
const checkbox = wrapper.find('input[type="checkbox"]')
await checkbox.setValue(true)
await nextTick()
const publishButton = wrapper
.findAll('button')
.find((button) => button.text().includes('Create link'))
expect(publishButton?.attributes('disabled')).toBeUndefined()
})
it('calls onClose when close button is clicked', async () => {
const wrapper = createWrapper()
await flushPromises()
const closeButton = wrapper.find('[aria-label="Close"]')
await closeButton.trigger('click')
expect(onClose).toHaveBeenCalled()
})
it('publishes using acknowledged assets from initial load', async () => {
const initialShareableAssets = [
{
id: 'local-photo-id',
name: 'photo.png',
preview_url: '',
storage_url: '',
model: false,
public: false,
in_library: false
},
{
id: 'local-model-id',
name: 'model.safetensors',
preview_url: '',
storage_url: '',
model: true,
public: false,
in_library: false
}
]
mockGetShareableAssets.mockResolvedValueOnce(initialShareableAssets)
const wrapper = createWrapper()
await flushPromises()
const checkbox = wrapper.find('input[type="checkbox"]')
await checkbox.setValue(true)
await nextTick()
const publishButton = wrapper
.findAll('button')
.find((button) => button.text().includes('Create link'))
expect(publishButton).toBeDefined()
await publishButton!.trigger('click')
await flushPromises()
expect(mockGetShareableAssets).toHaveBeenCalledTimes(1)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
'workflows/test.json',
initialShareableAssets
)
})
it('shows update button when workflow was saved after last publish', async () => {
const publishedAt = new Date('2026-01-15T00:00:00Z')
const savedAfterPublishMs = publishedAt.getTime() + 60_000
mockWorkflowStore.activeWorkflow = {
path: 'workflows/test.json',
directory: 'workflows',
filename: 'test.json',
isTemporary: false,
isModified: false,
lastModified: savedAfterPublishMs
}
mockGetPublishStatus.mockResolvedValue({
isPublished: true,
shareId: 'abc-123',
shareUrl: 'https://comfy.org/shared/abc-123',
publishedAt
})
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain('You have made changes...')
expect(wrapper.text()).toContain('Update link')
})
it('shows copy URL when workflow has not changed since publish', async () => {
const publishedAt = new Date('2026-01-15T00:00:00Z')
const savedBeforePublishMs = publishedAt.getTime() - 60_000
mockWorkflowStore.activeWorkflow = {
path: 'workflows/test.json',
directory: 'workflows',
filename: 'test.json',
isTemporary: false,
isModified: false,
lastModified: savedBeforePublishMs
}
mockGetPublishStatus.mockResolvedValue({
isPublished: true,
shareId: 'abc-123',
shareUrl: 'https://comfy.org/shared/abc-123',
publishedAt
})
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain('Anyone with this link...')
expect(wrapper.text()).not.toContain('Update link')
})
describe('error and edge cases', () => {
it('renders unsaved state when workflow is temporary', async () => {
mockWorkflowStore.activeWorkflow = {
path: 'workflows/Unsaved Workflow.json',
directory: 'workflows',
filename: 'Unsaved Workflow.json',
isTemporary: true,
isModified: false,
lastModified: 1000
}
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain(
'You must save your workflow before sharing.'
)
expect(wrapper.text()).toContain('Workflow name')
})
it('shows error toast when getPublishStatus rejects', async () => {
mockGetPublishStatus.mockRejectedValue(new Error('Server down'))
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain('Create link')
expect(mockToast.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to load publish status'
})
})
it('shows error toast when publishWorkflow rejects', async () => {
mockGetShareableAssets.mockResolvedValue([])
const wrapper = createWrapper()
await flushPromises()
mockPublishWorkflow.mockRejectedValue(new Error('Publish failed'))
const publishButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Create link'))
expect(publishButton).toBeDefined()
await publishButton!.trigger('click')
await flushPromises()
expect(wrapper.text()).not.toContain('Anyone with this link...')
expect(mockToast.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Publish failed',
life: 5000
})
})
it('renders unsaved state when no active workflow exists', async () => {
mockWorkflowStore.activeWorkflow = null
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain(
'You must save your workflow before sharing.'
)
})
it('does not call publishWorkflow when workflow is null during publish', async () => {
mockGetShareableAssets.mockResolvedValue([])
const wrapper = createWrapper()
await flushPromises()
mockWorkflowStore.activeWorkflow = null
const publishButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Create link'))
if (publishButton) {
await publishButton.trigger('click')
await flushPromises()
}
expect(mockPublishWorkflow).not.toHaveBeenCalled()
})
it('does not switch to publishToHub mode when flag is disabled', async () => {
mockFlags.comfyHubUploadEnabled = false
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.find('[data-testid="publish-tab-panel"]').exists()).toBe(
false
)
expect(wrapper.text()).not.toContain('Publish')
})
})
})

View File

@@ -0,0 +1,400 @@
<template>
<div class="flex w-full flex-col">
<header
class="flex h-12 items-center justify-between gap-2 border-b border-border-default px-4"
>
<div
v-if="showPublishToHubTab"
role="tablist"
class="flex flex-1 items-center gap-2"
>
<Button
id="tab-share-link"
role="tab"
:aria-selected="dialogMode === 'shareLink'"
:class="tabButtonClass('shareLink')"
@click="handleDialogModeChange('shareLink')"
>
{{ $t('shareWorkflow.shareLinkTab') }}
</Button>
<Button
id="tab-publish"
role="tab"
:aria-selected="dialogMode === 'publishToHub'"
:class="tabButtonClass('publishToHub')"
@click="handleDialogModeChange('publishToHub')"
>
<i class="icon-[lucide--globe] size-4" aria-hidden="true" />
{{ $t('shareWorkflow.publishToHubTab') }}
</Button>
</div>
<div v-else class="select-none">
{{ $t('shareWorkflow.shareLinkTab') }}
</div>
<Button size="icon" :aria-label="$t('g.close')" @click="onClose">
<i class="icon-[lucide--x] size-4" />
</Button>
</header>
<main v-auto-animate class="flex flex-col gap-4 p-4">
<div
v-show="dialogMode === 'shareLink'"
v-auto-animate
:role="showPublishToHubTab ? 'tabpanel' : undefined"
:aria-labelledby="showPublishToHubTab ? 'tab-share-link' : undefined"
class="flex 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-if="dialogState === 'unsaved'">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('shareWorkflow.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>
</template>
<template v-if="dialogState === 'ready' || dialogState === 'stale'">
<p
v-if="dialogState === 'stale'"
class="m-0 text-xs text-muted-foreground"
>
{{ $t('shareWorkflow.hasChangesDescription') }}
</p>
<p
v-if="isLoadingAssets"
class="m-0 text-sm italic text-muted-foreground"
>
{{ $t('shareWorkflow.checkingAssets') }}
</p>
<ShareAssetWarningBox
v-else-if="requiresAcknowledgment"
v-model:acknowledged="acknowledged"
:items="assetInfo"
/>
<Button
variant="primary"
size="lg"
:disabled="
isPublishing ||
isLoadingAssets ||
(requiresAcknowledgment && !acknowledged)
"
@click="() => handlePublish()"
>
{{ publishButtonLabel }}
</Button>
</template>
<template v-if="dialogState === 'shared' && publishResult">
<ShareUrlCopyField :url="publishResult.shareUrl" />
<div class="flex flex-col gap-1">
<p
v-if="publishResult.publishedAt"
class="m-0 text-xs text-muted-foreground"
>
{{ $t('shareWorkflow.publishedOn', { date: formattedDate }) }}
</p>
<p class="m-0 text-xs text-muted-foreground">
{{ $t('shareWorkflow.successDescription') }}
</p>
</div>
</template>
</div>
<div
v-if="showPublishToHubTab"
v-show="dialogMode === 'publishToHub'"
v-auto-animate
role="tabpanel"
aria-labelledby="tab-publish"
data-testid="publish-tab-panel"
class="min-h-0"
>
<ComfyHubPublishIntroPanel
data-testid="publish-intro"
:on-create-profile="handleOpenPublishDialog"
:on-close="onClose"
:show-close-button="false"
/>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { useAsyncState } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ComfyHubPublishIntroPanel from '@/platform/workflow/sharing/components/profile/ComfyHubPublishIntroPanel.vue'
import ShareAssetWarningBox from '@/platform/workflow/sharing/components/ShareAssetWarningBox.vue'
import ShareUrlCopyField from '@/platform/workflow/sharing/components/ShareUrlCopyField.vue'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { useComfyHubPublishDialog } from '@/platform/workflow/sharing/composables/useComfyHubPublishDialog'
import type {
WorkflowPublishResult,
WorkflowPublishStatus
} from '@/platform/workflow/sharing/types/shareTypes'
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 { useFeatureFlags } from '@/composables/useFeatureFlags'
import { appendJsonExt } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const { onClose } = defineProps<{
onClose: () => void
}>()
const { t, locale } = useI18n()
const toast = useToast()
const { flags } = useFeatureFlags()
const publishDialog = useComfyHubPublishDialog()
const shareService = useWorkflowShareService()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
type DialogState = 'loading' | 'unsaved' | 'ready' | 'shared' | 'stale'
type DialogMode = 'shareLink' | 'publishToHub'
function resolveDialogStateFromStatus(
status: WorkflowPublishStatus,
workflow: { lastModified: number }
): { publishResult: WorkflowPublishResult | null; dialogState: DialogState } {
if (!status.isPublished) return { publishResult: null, dialogState: 'ready' }
const publishedAtMs = status.publishedAt.getTime()
const lastModifiedMs = workflow.lastModified
return {
publishResult: {
shareId: status.shareId,
shareUrl: status.shareUrl,
publishedAt: status.publishedAt
},
dialogState: lastModifiedMs > publishedAtMs ? 'stale' : 'shared'
}
}
const dialogState = ref<DialogState>('loading')
const dialogMode = ref<DialogMode>('shareLink')
const acknowledged = ref(false)
const workflowName = ref('')
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
function focusNameInput() {
nameInputRef.value?.focus()
nameInputRef.value?.select()
}
const isTemporary = computed(
() => workflowStore.activeWorkflow?.isTemporary ?? false
)
watch(dialogState, async (state) => {
if (state === 'unsaved' && isTemporary.value) {
await nextTick()
focusNameInput()
}
})
const {
state: assetInfo,
isLoading: isLoadingAssets,
execute: reloadAssets
} = useAsyncState(() => shareService.getShareableAssets(), [])
const requiresAcknowledgment = computed(() => assetInfo.value.length > 0)
const showPublishToHubTab = computed(() => flags.comfyHubUploadEnabled)
function handleOpenPublishDialog() {
onClose()
publishDialog.show()
}
function tabButtonClass(mode: DialogMode) {
return cn(
'cursor-pointer border-none transition-colors',
dialogMode.value === mode
? 'bg-secondary-background text-base-foreground'
: 'bg-transparent text-muted-foreground hover:bg-secondary-background-hover'
)
}
function handleDialogModeChange(nextMode: DialogMode) {
if (nextMode === dialogMode.value) return
if (nextMode === 'publishToHub' && !showPublishToHubTab.value) return
dialogMode.value = nextMode
}
watch(showPublishToHubTab, (isVisible) => {
if (!isVisible && dialogMode.value === 'publishToHub') {
dialogMode.value = 'shareLink'
}
})
const formattedDate = computed(() => {
if (!publishResult.value) return ''
return publishResult.value.publishedAt.toLocaleDateString(locale.value, {
year: 'numeric',
month: 'long',
day: 'numeric'
})
})
const publishButtonLabel = computed(() => {
if (dialogState.value === 'stale') {
return isPublishing.value
? t('shareWorkflow.updatingLink')
: t('shareWorkflow.updateLinkButton')
}
return isPublishing.value
? t('shareWorkflow.creatingLink')
: t('shareWorkflow.createLinkButton')
})
function stripJsonExtension(filename: string): string {
return filename.replace(/\.json$/i, '')
}
function buildWorkflowPath(directory: string, filename: string): string {
const normalizedDirectory = directory.replace(/\/+$/, '')
const normalizedFilename = appendJsonExt(stripJsonExtension(filename))
return normalizedDirectory
? `${normalizedDirectory}/${normalizedFilename}`
: normalizedFilename
}
async function refreshDialogState() {
const workflow = workflowStore.activeWorkflow
if (!workflow || workflow.isTemporary || workflow.isModified) {
dialogState.value = 'unsaved'
if (workflow) {
workflowName.value = stripJsonExtension(workflow.filename)
}
return
}
try {
const status = await shareService.getPublishStatus(workflow.path)
const resolved = resolveDialogStateFromStatus(status, workflow)
publishResult.value = resolved.publishResult
dialogState.value = resolved.dialogState
} catch (error) {
console.error('Failed to load publish status:', error)
publishResult.value = null
dialogState.value = 'ready'
toast.add({
severity: 'error',
summary: t('shareWorkflow.loadFailed')
})
}
}
onMounted(() => {
void refreshDialogState()
})
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)
}
acknowledged.value = false
await reloadAssets()
await refreshDialogState()
},
undefined,
{
immediate: false,
onError: (error) => {
console.error('Failed to save workflow:', error)
toast.add({
severity: 'error',
summary: t('shareWorkflow.saveFailedTitle'),
detail: t('shareWorkflow.saveFailedDescription'),
life: 5000
})
}
}
)
const {
state: publishResult,
isLoading: isPublishing,
execute: handlePublish
} = useAsyncState(
async (): Promise<WorkflowPublishResult | null> => {
const workflow = workflowStore.activeWorkflow
if (!workflow) return null
const publishableAssets = assetInfo.value
if (publishableAssets.length > 0 && !acknowledged.value) {
return null
}
const result = await shareService.publishWorkflow(
workflow.path,
publishableAssets
)
dialogState.value = 'shared'
acknowledged.value = false
return result
},
null,
{
immediate: false,
onError: (error) => {
console.error('Failed to publish workflow:', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : t('g.error'),
life: 5000
})
}
}
)
</script>

View File

@@ -0,0 +1,193 @@
<template>
<form
class="flex min-h-0 flex-1 flex-col overflow-hidden bg-base-background"
@submit.prevent
>
<header
v-if="showCloseButton"
class="flex h-16 items-center justify-between px-6"
>
<h2 class="text-base font-normal text-base-foreground">
{{ $t('comfyHubProfile.createProfileTitle') }}
</h2>
<Button size="icon" :aria-label="$t('g.close')" @click="onClose">
<i class="icon-[lucide--x] size-4" />
</Button>
</header>
<h2 v-else class="px-6 pt-6 text-base font-normal text-base-foreground">
{{ $t('comfyHubProfile.createProfileTitle') }}
</h2>
<div class="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-6 py-4">
<div class="flex flex-col gap-4">
<label for="profile-picture" class="text-sm text-muted-foreground">
{{ $t('comfyHubProfile.chooseProfilePicture') }}
</label>
<label
class="flex size-13 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-gradient-to-b from-green-600/50 to-green-900"
>
<input
id="profile-picture"
type="file"
accept="image/*"
class="hidden"
@change="handleProfileSelect"
/>
<template v-if="profilePreviewUrl">
<img
:src="profilePreviewUrl"
:alt="$t('comfyHubProfile.chooseProfilePicture')"
class="size-full rounded-full object-cover"
/>
</template>
<template v-else>
<span class="text-base text-white">
{{ profileInitial }}
</span>
</template>
</label>
</div>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4">
<label for="profile-name" class="text-sm text-muted-foreground">
{{ $t('comfyHubProfile.nameLabel') }}
</label>
<Input
id="profile-name"
v-model="name"
:placeholder="$t('comfyHubProfile.namePlaceholder')"
/>
</div>
<div class="flex flex-col gap-2">
<label for="profile-username" class="text-sm text-muted-foreground">
{{ $t('comfyHubProfile.usernameLabel') }}
</label>
<div class="relative">
<span
:class="
cn(
'pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-sm',
username ? 'text-base-foreground' : 'text-muted-foreground'
)
"
>
@
</span>
<Input id="profile-username" v-model="username" class="pl-7" />
</div>
</div>
<div class="flex flex-col gap-2">
<label
for="profile-description"
class="text-sm text-muted-foreground"
>
{{ $t('comfyHubProfile.descriptionLabel') }}
</label>
<Textarea
id="profile-description"
v-model="description"
:placeholder="$t('comfyHubProfile.descriptionPlaceholder')"
class="h-24 resize-none rounded-lg border-none bg-secondary-background p-4 text-sm shadow-none"
/>
</div>
</div>
</div>
<footer
class="flex items-center justify-end gap-4 border-t border-border-default px-6 py-4"
>
<Button size="lg" @click="onClose">
{{ $t('g.cancel') }}
</Button>
<Button
variant="primary"
size="lg"
:disabled="!username.trim() || isCreating"
@click="handleCreate"
>
{{
isCreating
? $t('comfyHubProfile.creatingProfile')
: $t('comfyHubProfile.createProfile')
}}
</Button>
</footer>
</form>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import { useObjectUrl } from '@vueuse/core'
import { cn } from '@/utils/tailwindUtil'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import {
isFileTooLarge,
MAX_IMAGE_SIZE_MB
} from '@/platform/workflow/sharing/utils/validateFileSize'
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
import type { ComfyHubProfile } from '@/schemas/apiSchema'
const {
onProfileCreated,
onClose,
showCloseButton = true
} = defineProps<{
onProfileCreated: (profile: ComfyHubProfile) => void
onClose: () => void
showCloseButton?: boolean
}>()
const { createProfile } = useComfyHubProfileGate()
const toast = useToast()
const { t } = useI18n()
const name = ref('')
const username = ref('')
const description = ref('')
const profilePictureFile = ref<File | null>(null)
const profilePreviewUrl = useObjectUrl(profilePictureFile)
const isCreating = ref(false)
const profileInitial = computed(() => {
const source = name.value.trim() || username.value.trim()
return source ? source[0].toUpperCase() : 'C'
})
function handleProfileSelect(event: Event) {
if (!(event.target instanceof HTMLInputElement)) return
const file = event.target.files?.[0]
if (!file || isFileTooLarge(file, MAX_IMAGE_SIZE_MB)) return
profilePictureFile.value = file
}
async function handleCreate() {
if (isCreating.value) return
isCreating.value = true
try {
const profile = await createProfile({
username: username.value.trim(),
name: name.value.trim() || undefined,
description: description.value.trim() || undefined,
profilePicture: profilePictureFile.value ?? undefined
})
onProfileCreated(profile)
} catch (error) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : t('g.error'),
life: 5000
})
} finally {
isCreating.value = false
}
}
</script>

View File

@@ -0,0 +1,47 @@
<template>
<div class="flex w-full flex-col overflow-hidden bg-base-background">
<!-- Close button -->
<div v-if="showCloseButton" class="flex justify-end px-2 pt-2">
<Button
size="icon"
class="rounded-full"
:aria-label="$t('g.close')"
@click="onClose"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
<!-- Content -->
<section class="flex flex-col items-center gap-4 px-4 pb-6 pt-4">
<h2 class="m-0 text-base font-semibold text-base-foreground">
{{ $t('comfyHubProfile.introTitle') }}
</h2>
<p class="m-0 text-center text-sm text-muted-foreground">
{{ $t('comfyHubProfile.introDescription') }}
</p>
<Button
variant="primary"
size="lg"
class="mt-2 w-full"
@click="onCreateProfile"
>
{{ $t('comfyHubProfile.startPublishingButton') }}
</Button>
</section>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const {
onCreateProfile,
onClose,
showCloseButton = true
} = defineProps<{
onCreateProfile: () => void
onClose: () => void
showCloseButton?: boolean
}>()
</script>

View File

@@ -0,0 +1,192 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-6 px-6 py-4">
<label class="flex flex-col gap-2">
<span class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.workflowName') }}
</span>
<Input
:model-value="name"
:placeholder="$t('comfyHubPublish.workflowNamePlaceholder')"
@update:model-value="$emit('update:name', String($event))"
/>
</label>
<label class="flex flex-col gap-2">
<span class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.workflowDescription') }}
</span>
<Textarea
:model-value="description"
:placeholder="$t('comfyHubPublish.workflowDescriptionPlaceholder')"
rows="5"
@update:model-value="$emit('update:description', String($event))"
/>
</label>
<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>
<TagsInput
v-slot="{ isEmpty }"
always-editing
class="select-none bg-secondary-background"
:model-value="tags"
@update:model-value="$emit('update:tags', $event as string[])"
>
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput :is-empty />
</TagsInput>
<TagsInput
disabled
class="bg-transparent hover:bg-transparent hover-within:bg-transparent p-0"
>
<div
v-if="displayedSuggestions.length > 0"
class="basis-full flex flex-wrap gap-2"
>
<TagsInputItem
v-for="tag in displayedSuggestions"
:key="tag"
v-auto-animate
:value="tag"
class="cursor-pointer select-none transition-colors bg-secondary-background hover:bg-secondary-background-selected text-muted-foreground px-2"
@click="addTag(tag)"
>
<TagsInputItemText />
</TagsInputItem>
</div>
<Button
v-if="shouldShowSuggestionToggle"
variant="muted-textonly"
size="unset"
class="text-xs px-0 hover:bg-unset"
@click="showAllSuggestions = !showAllSuggestions"
>
{{
$t(
showAllSuggestions
? 'comfyHubPublish.showLessTags'
: 'comfyHubPublish.showMoreTags'
)
}}
</Button>
</TagsInput>
</fieldset>
</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'
import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.vue'
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<{
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)
const availableSuggestions = computed(() =>
COMFY_HUB_TAG_OPTIONS.filter((tag) => !tags.includes(tag))
)
const displayedSuggestions = computed(() =>
showAllSuggestions.value
? availableSuggestions.value
: availableSuggestions.value.slice(0, INITIAL_TAG_SUGGESTION_COUNT)
)
const hasHiddenSuggestions = computed(
() =>
!showAllSuggestions.value &&
availableSuggestions.value.length > INITIAL_TAG_SUGGESTION_COUNT
)
const shouldShowSuggestionToggle = computed(
() => showAllSuggestions.value || hasHiddenSuggestions.value
)
function addTag(tag: string) {
emit('update:tags', [...tags, tag])
}
</script>

View File

@@ -0,0 +1,145 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-6">
<p class="text-sm">
{{
$t('comfyHubPublish.examplesDescription', {
selected: selectedExampleIds.length,
total: MAX_EXAMPLES
})
}}
</p>
<div class="grid grid-cols-4 gap-2.5 overflow-y-auto">
<!-- Upload tile -->
<label
tabindex="0"
role="button"
:aria-label="$t('comfyHubPublish.uploadExampleImage')"
class="flex h-25 aspect-square text-center cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-default transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
@dragenter.stop
@dragleave.stop
@dragover.prevent.stop
@drop.prevent.stop="handleFileDrop"
@keydown.enter.prevent="fileInputRef?.click()"
@keydown.space.prevent="fileInputRef?.click()"
>
<input
ref="fileInputRef"
type="file"
accept="image/*"
multiple
class="hidden"
@change="handleFileSelect"
/>
<i
class="icon-[lucide--plus] size-4 text-muted-foreground"
aria-hidden="true"
/>
<span class="sr-only">{{
$t('comfyHubPublish.uploadExampleImage')
}}</span>
</label>
<!-- Example images -->
<Button
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-2 ring-ring' : 'ring-0'
)
"
@click="toggleSelection(image.id)"
>
<img
:src="image.url"
:alt="$t('comfyHubPublish.exampleImage', { index: index + 1 })"
class="h-full w-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>
</div>
</div>
</template>
<script setup lang="ts">
import { v4 as uuidv4 } from 'uuid'
import { 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'
const fileInputRef = ref<HTMLInputElement | null>(null)
const MAX_EXAMPLES = 8
const { exampleImages, selectedExampleIds } = defineProps<{
exampleImages: ExampleImage[]
selectedExampleIds: string[]
}>()
const emit = defineEmits<{
'update:exampleImages': [value: ExampleImage[]]
'update:selectedExampleIds': [value: string[]]
}>()
function isSelected(id: string): boolean {
return selectedExampleIds.includes(id)
}
function selectionIndex(id: string): number {
return selectedExampleIds.indexOf(id) + 1
}
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 addImages(files: FileList) {
const newImages: ExampleImage[] = Array.from(files)
.filter((f) => f.type.startsWith('image/'))
.filter((f) => !isFileTooLarge(f, MAX_IMAGE_SIZE_MB))
.map((file) => ({
id: uuidv4(),
url: URL.createObjectURL(file),
file
}))
if (newImages.length > 0) {
emit('update:exampleImages', [...exampleImages, ...newImages])
}
}
function handleFileSelect(event: Event) {
if (!(event.target instanceof HTMLInputElement)) return
if (event.target.files?.length) {
addImages(event.target.files)
}
}
function handleFileDrop(event: DragEvent) {
if (event.dataTransfer?.files?.length) {
addImages(event.dataTransfer.files)
}
}
</script>

View File

@@ -0,0 +1,32 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-4 px-6 py-4">
<p class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.createProfileToPublish') }}
</p>
<Button
variant="textonly"
size="unset"
class="flex w-64 items-center gap-4 rounded-2xl border border-dashed border-border-default px-6 py-4 hover:bg-secondary-background-hover"
@click="emit('requestProfile')"
>
<div
class="flex size-12 items-center justify-center rounded-full border border-dashed border-border-default"
>
<i class="icon-[lucide--user] size-4 text-muted-foreground" />
</div>
<span class="inline-flex items-center gap-1 text-sm text-base-foreground">
<i class="icon-[lucide--plus] size-4" />
{{ $t('comfyHubPublish.createProfileCta') }}
</span>
</Button>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const emit = defineEmits<{
requestProfile: []
}>()
</script>

View File

@@ -0,0 +1,139 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import ComfyHubPublishDialog from '@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue'
const mockFetchProfile = vi.hoisted(() => vi.fn())
const mockGoToStep = vi.hoisted(() => vi.fn())
const mockGoNext = vi.hoisted(() => vi.fn())
const mockGoBack = vi.hoisted(() => vi.fn())
const mockOpenProfileCreationStep = vi.hoisted(() => vi.fn())
const mockCloseProfileCreationStep = vi.hoisted(() => vi.fn())
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
() => ({
useComfyHubProfileGate: () => ({
fetchProfile: mockFetchProfile
})
})
)
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubPublishWizard',
() => ({
useComfyHubPublishWizard: () => ({
currentStep: ref('finish'),
formData: ref({
name: '',
description: '',
workflowType: '',
tags: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
selectedExampleIds: []
}),
isFirstStep: ref(false),
isLastStep: ref(true),
goToStep: mockGoToStep,
goNext: mockGoNext,
goBack: mockGoBack,
openProfileCreationStep: mockOpenProfileCreationStep,
closeProfileCreationStep: mockCloseProfileCreationStep
})
})
)
describe('ComfyHubPublishDialog', () => {
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockFetchProfile.mockResolvedValue(null)
})
function createWrapper() {
return mount(ComfyHubPublishDialog, {
props: { onClose },
global: {
mocks: {
$t: (key: string) => key
},
stubs: {
BaseModalLayout: {
template:
'<div data-testid="base-modal-layout"><slot name="leftPanelHeaderTitle" /><slot name="leftPanel" /><slot name="header" /><slot name="content" /></div>'
},
ComfyHubPublishNav: {
template: '<nav data-testid="publish-nav" />',
props: ['currentStep']
},
'comfy-hub-publish-nav': {
template: '<nav data-testid="publish-nav" />',
props: ['currentStep']
},
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>',
props: [
'currentStep',
'formData',
'isFirstStep',
'isLastStep',
'onGoNext',
'onGoBack',
'onRequireProfile',
'onGateComplete',
'onGateClose'
]
}
}
}
})
}
it('starts in publish wizard mode and prefetches profile asynchronously', async () => {
createWrapper()
await flushPromises()
expect(mockFetchProfile).toHaveBeenCalledWith()
})
it('switches to profile creation step when final-step publish requires profile', async () => {
const wrapper = createWrapper()
await flushPromises()
await wrapper.find('[data-testid="require-profile"]').trigger('click')
expect(mockOpenProfileCreationStep).toHaveBeenCalledOnce()
})
it('returns to finish state after gate complete and does not auto-close', async () => {
const wrapper = createWrapper()
await flushPromises()
await wrapper.find('[data-testid="require-profile"]').trigger('click')
await wrapper.find('[data-testid="gate-complete"]').trigger('click')
expect(mockOpenProfileCreationStep).toHaveBeenCalledOnce()
expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
expect(mockFetchProfile).toHaveBeenCalledWith({ force: true })
expect(onClose).not.toHaveBeenCalled()
})
it('returns to finish state when profile gate is closed', async () => {
const wrapper = createWrapper()
await flushPromises()
await wrapper.find('[data-testid="require-profile"]').trigger('click')
await wrapper.find('[data-testid="gate-close"]').trigger('click')
expect(mockOpenProfileCreationStep).toHaveBeenCalledOnce()
expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
expect(onClose).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,94 @@
<template>
<BaseModalLayout
:content-title="$t('comfyHubPublish.title')"
content-padding="none"
left-panel-width="16.5rem"
size="md"
>
<template #leftPanelHeaderTitle>
<h2 class="flex-1 select-none text-base font-semibold">
{{ $t('comfyHubPublish.title') }}
</h2>
</template>
<template #leftPanel>
<ComfyHubPublishNav :current-step @step-click="goToStep" />
</template>
<template #header />
<template #content>
<ComfyHubPublishWizardContent
:current-step
:form-data
:is-first-step
:is-last-step
: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"
/>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, provide } from 'vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.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 { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
import { OnCloseKey } from '@/types/widgetTypes'
const { onClose } = defineProps<{
onClose: () => void
}>()
const { fetchProfile } = useComfyHubProfileGate()
const {
currentStep,
formData,
isFirstStep,
isLastStep,
goToStep,
goNext,
goBack,
openProfileCreationStep,
closeProfileCreationStep
} = useComfyHubPublishWizard()
function handlePublishGateComplete() {
closeProfileCreationStep()
void fetchProfile({ force: true })
}
function handlePublishGateClose() {
closeProfileCreationStep()
}
function handleRequireProfile() {
openProfileCreationStep()
}
function updateFormData(patch: Partial<ComfyHubPublishFormData>) {
formData.value = { ...formData.value, ...patch }
}
onMounted(() => {
// Prefetch profile data in the background so finish-step profile context is ready.
void fetchProfile()
})
onBeforeUnmount(() => {
for (const image of formData.value.exampleImages) {
URL.revokeObjectURL(image.url)
}
})
provide(OnCloseKey, onClose)
</script>

View File

@@ -0,0 +1,41 @@
<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>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
defineProps<{
isFirstStep: boolean
isLastStep: boolean
isPublishDisabled?: boolean
}>()
defineEmits<{
back: []
next: []
publish: []
}>()
</script>

View File

@@ -0,0 +1,122 @@
<template>
<nav class="flex flex-col gap-6 px-3 py-4">
<ol class="flex flex-col">
<li
v-for="step in steps"
:key="step.name"
v-auto-animate
:aria-current="isCurrentStep(step.name) ? 'step' : undefined"
:class="
cn(
isProfileCreationFlow &&
step.name === 'finish' &&
'rounded-lg bg-secondary-background-hover'
)
"
>
<Button
variant="textonly"
size="unset"
:class="
cn(
'h-10 w-full justify-start rounded-lg px-4 py-3 text-left',
isCurrentStep(step.name) &&
!(isProfileCreationFlow && step.name === 'finish')
? 'bg-secondary-background-selected'
: 'hover:bg-interface-menu-component-surface-hovered'
)
"
@click="$emit('stepClick', step.name)"
>
<StatusBadge
:label="step.number"
variant="circle"
severity="contrast"
:class="
cn(
'size-5 shrink-0 border text-xs font-bold font-inter bg-transparent',
isCurrentStep(step.name)
? 'border-base-foreground bg-base-foreground text-base-background'
: isCompletedStep(step.name)
? 'border-base-foreground text-base-foreground'
: 'border-muted-foreground text-muted-foreground'
)
"
/>
<span class="truncate text-sm text-base-foreground">
{{ step.label }}
</span>
</Button>
<div
v-if="isProfileCreationFlow && step.name === 'finish'"
v-auto-animate
class="flex h-10 w-full items-center rounded-lg bg-secondary-background-selected pl-11 select-none"
>
<span class="truncate text-sm text-base-foreground">
{{ $t('comfyHubProfile.profileCreationNav') }}
</span>
</div>
</li>
</ol>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ComfyHubPublishStep } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
import { cn } from '@/utils/tailwindUtil'
import { useI18n } from 'vue-i18n'
type ComfyHubPrimaryStep = Exclude<ComfyHubPublishStep, 'profileCreation'>
const { currentStep } = defineProps<{
currentStep: ComfyHubPublishStep
}>()
defineEmits<{
stepClick: [step: ComfyHubPrimaryStep]
}>()
const { t } = useI18n()
const steps = [
{
name: 'describe' as const,
number: 1,
label: t('comfyHubPublish.stepDescribe')
},
{
name: 'examples' as const,
number: 2,
label: t('comfyHubPublish.stepExamples')
},
{ name: 'finish' as const, number: 3, label: t('comfyHubPublish.stepFinish') }
]
const isProfileCreationFlow = computed(() => currentStep === 'profileCreation')
const currentStepNumber = computed(() => {
if (isProfileCreationFlow.value) {
return 3
}
return steps.find((step) => step.name === currentStep)?.number ?? 0
})
function isCurrentStep(stepName: ComfyHubPrimaryStep) {
return currentStep === stepName
}
function isCompletedStep(stepName: ComfyHubPrimaryStep) {
return (
(steps.find((step) => step.name === stepName)?.number ?? 0) <
currentStepNumber.value
)
}
</script>

View File

@@ -0,0 +1,263 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import ComfyHubPublishWizardContent from './ComfyHubPublishWizardContent.vue'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
const mockCheckProfile = vi.hoisted(() => vi.fn())
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockHasProfile = ref<boolean | null>(true)
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
() => ({
useComfyHubProfileGate: () => ({
checkProfile: mockCheckProfile,
hasProfile: mockHasProfile
})
})
)
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
toastErrorHandler: mockToastErrorHandler
})
}))
const mockFlags = vi.hoisted(() => ({
comfyHubProfileGateEnabled: true
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: mockFlags
})
}))
function createDefaultFormData(): ComfyHubPublishFormData {
return {
name: 'Test Workflow',
description: '',
workflowType: '',
tags: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
selectedExampleIds: []
}
}
describe('ComfyHubPublishWizardContent', () => {
const onPublish = vi.fn()
const onGoNext = vi.fn()
const onGoBack = vi.fn()
const onUpdateFormData = vi.fn()
const onRequireProfile = vi.fn()
const onGateComplete = vi.fn()
const onGateClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockCheckProfile.mockResolvedValue(true)
mockHasProfile.value = true
mockFlags.comfyHubProfileGateEnabled = true
})
function createWrapper(
overrides: Partial<
InstanceType<typeof ComfyHubPublishWizardContent>['$props']
> = {}
) {
return mount(ComfyHubPublishWizardContent, {
props: {
currentStep: 'finish',
formData: createDefaultFormData(),
isFirstStep: false,
isLastStep: true,
onGoNext,
onGoBack,
onUpdateFormData,
onPublish,
onRequireProfile,
onGateComplete,
onGateClose,
...overrides
},
global: {
mocks: {
$t: (key: string) => key
},
stubs: {
ComfyHubCreateProfileForm: {
template: '<div data-testid="publish-gate-flow" />',
props: ['onProfileCreated', 'onClose', 'showCloseButton']
},
'comfy-hub-create-profile-form': {
template: '<div data-testid="publish-gate-flow" />',
props: ['onProfileCreated', 'onClose', 'showCloseButton']
},
ComfyHubDescribeStep: {
template: '<div data-testid="describe-step" />'
},
ComfyHubExamplesStep: {
template: '<div data-testid="examples-step" />'
},
ComfyHubThumbnailStep: {
template: '<div data-testid="thumbnail-step" />'
},
ComfyHubProfilePromptPanel: {
template:
'<div data-testid="profile-prompt"><button data-testid="request-profile" @click="$emit(\'request-profile\')" /></div>',
emits: ['request-profile']
},
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'],
emits: ['publish', 'next', 'back']
}
}
}
})
}
describe('handlePublish — double-click guard', () => {
it('prevents concurrent publish calls', async () => {
let resolveCheck!: (v: boolean) => void
mockCheckProfile.mockReturnValue(
new Promise<boolean>((resolve) => {
resolveCheck = resolve
})
)
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)
})
})
describe('handlePublish — feature flag bypass', () => {
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 — profile check routing', () => {
it('calls onPublish when profile exists', async () => {
mockCheckProfile.mockResolvedValue(true)
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(mockCheckProfile).toHaveBeenCalledOnce()
expect(onPublish).toHaveBeenCalledOnce()
expect(onRequireProfile).not.toHaveBeenCalled()
})
it('calls onRequireProfile when no profile exists', async () => {
mockCheckProfile.mockResolvedValue(false)
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(onRequireProfile).toHaveBeenCalledOnce()
expect(onPublish).not.toHaveBeenCalled()
})
it('shows toast and aborts when checkProfile throws', async () => {
const error = new Error('Network error')
mockCheckProfile.mockRejectedValue(error)
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
expect(onPublish).not.toHaveBeenCalled()
expect(onRequireProfile).not.toHaveBeenCalled()
})
it('resets guard after checkProfile error so retry is possible', async () => {
mockCheckProfile.mockRejectedValueOnce(new Error('Network error'))
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).not.toHaveBeenCalled()
mockCheckProfile.mockResolvedValue(true)
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledOnce()
})
})
describe('isPublishDisabled', () => {
it('disables publish when gate enabled and hasProfile is not true', () => {
mockHasProfile.value = null
const wrapper = createWrapper()
const footer = wrapper.find('[data-testid="publish-footer"]')
expect(footer.attributes('data-publish-disabled')).toBe('true')
})
it('enables publish when gate enabled and hasProfile is true', () => {
mockHasProfile.value = true
const wrapper = createWrapper()
const footer = wrapper.find('[data-testid="publish-footer"]')
expect(footer.attributes('data-publish-disabled')).toBe('false')
})
it('enables publish when gate is disabled regardless of profile', () => {
mockFlags.comfyHubProfileGateEnabled = false
mockHasProfile.value = null
const wrapper = createWrapper()
const footer = wrapper.find('[data-testid="publish-footer"]')
expect(footer.attributes('data-publish-disabled')).toBe('false')
})
})
describe('profileCreation step rendering', () => {
it('shows profile creation form when on profileCreation step', () => {
const wrapper = createWrapper({ currentStep: 'profileCreation' })
expect(wrapper.find('[data-testid="publish-gate-flow"]').exists()).toBe(
true
)
expect(wrapper.find('[data-testid="publish-footer"]').exists()).toBe(
false
)
})
it('shows wizard content when not on profileCreation step', () => {
const wrapper = createWrapper({ currentStep: 'finish' })
expect(wrapper.find('[data-testid="publish-gate-flow"]').exists()).toBe(
false
)
expect(wrapper.find('[data-testid="publish-footer"]').exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,143 @@
<template>
<div class="flex min-h-0 flex-1 flex-col">
<ComfyHubCreateProfileForm
v-if="currentStep === 'profileCreation'"
data-testid="publish-gate-flow"
:on-profile-created="() => onGateComplete()"
:on-close="onGateClose"
:show-close-button="false"
/>
<div v-else class="flex min-h-0 flex-1 flex-col px-6 pb-2 pt-4">
<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
v-else-if="currentStep === 'examples'"
class="flex min-h-0 flex-1 flex-col gap-6 px-6 py-4"
>
<ComfyHubThumbnailStep
:thumbnail-type="formData.thumbnailType"
@update:thumbnail-type="onUpdateFormData({ thumbnailType: $event })"
@update:thumbnail-file="onUpdateFormData({ thumbnailFile: $event })"
@update:comparison-before-file="
onUpdateFormData({ comparisonBeforeFile: $event })
"
@update:comparison-after-file="
onUpdateFormData({ comparisonAfterFile: $event })
"
/>
<ComfyHubExamplesStep
:example-images="formData.exampleImages"
:selected-example-ids="formData.selectedExampleIds"
@update:example-images="onUpdateFormData({ exampleImages: $event })"
@update:selected-example-ids="
onUpdateFormData({ selectedExampleIds: $event })
"
/>
</div>
<ComfyHubProfilePromptPanel
v-else-if="currentStep === 'finish'"
@request-profile="onRequireProfile"
/>
</div>
<ComfyHubPublishFooter
:is-first-step
:is-last-step
:is-publish-disabled
@back="onGoBack"
@next="onGoNext"
@publish="handlePublish"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
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 ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
import ComfyHubProfilePromptPanel from './ComfyHubProfilePromptPanel.vue'
import ComfyHubThumbnailStep from './ComfyHubThumbnailStep.vue'
import ComfyHubPublishFooter from './ComfyHubPublishFooter.vue'
const {
currentStep,
formData,
isFirstStep,
isLastStep,
onGoNext,
onGoBack,
onUpdateFormData,
onPublish,
onRequireProfile,
onGateComplete = () => {},
onGateClose = () => {}
} = defineProps<{
currentStep: ComfyHubPublishStep
formData: ComfyHubPublishFormData
isFirstStep: boolean
isLastStep: boolean
onGoNext: () => void
onGoBack: () => void
onUpdateFormData: (patch: Partial<ComfyHubPublishFormData>) => void
onPublish: () => void
onRequireProfile: () => void
onGateComplete?: () => void
onGateClose?: () => void
}>()
const { toastErrorHandler } = useErrorHandling()
const { flags } = useFeatureFlags()
const { checkProfile, hasProfile } = useComfyHubProfileGate()
const isResolvingPublishAccess = ref(false)
const isPublishDisabled = computed(
() => flags.comfyHubProfileGateEnabled && hasProfile.value !== true
)
async function handlePublish() {
if (isResolvingPublishAccess.value) {
return
}
if (!flags.comfyHubProfileGateEnabled) {
onPublish()
return
}
isResolvingPublishAccess.value = true
try {
let profileExists: boolean
try {
profileExists = await checkProfile()
} catch (error) {
toastErrorHandler(error)
return
}
if (profileExists) {
onPublish()
return
}
onRequireProfile()
} finally {
isResolvingPublishAccess.value = false
}
}
</script>

View File

@@ -0,0 +1,391 @@
<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">
{{ $t('comfyHubPublish.selectAThumbnail') }}
</legend>
<ToggleGroup
type="single"
:model-value="thumbnailType"
class="grid w-full grid-cols-3 gap-4"
@update:model-value="handleThumbnailTypeChange"
>
<ToggleGroupItem
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"
>
<span class="text-center text-sm font-bold text-base-foreground">
{{ option.label }}
</span>
</ToggleGroupItem>
</ToggleGroup>
</fieldset>
<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">
{{ uploadSectionLabel }}
</span>
<Button
v-if="hasThumbnailContent"
variant="muted-textonly"
size="sm"
@click="clearAllPreviews"
>
{{ $t('g.clear') }}
</Button>
</div>
<template v-if="thumbnailType === 'imageComparison'">
<div
class="flex-1 grid grid-cols-1 grid-rows-1 place-content-center-safe"
>
<div
v-if="hasBothComparisonImages"
ref="comparisonPreviewRef"
class="relative col-span-full row-span-full cursor-crosshair overflow-hidden rounded-lg"
>
<img
:src="comparisonPreviewUrls.after!"
:alt="$t('comfyHubPublish.uploadComparisonAfterPrompt')"
class="h-full w-full object-contain"
/>
<img
:src="comparisonPreviewUrls.before!"
:alt="$t('comfyHubPublish.uploadComparisonBeforePrompt')"
class="absolute inset-0 h-full w-full object-contain"
:style="{
clipPath: `inset(0 ${100 - previewSliderPosition}% 0 0)`
}"
/>
<div
class="pointer-events-none absolute inset-y-0 w-0.5 bg-white/30 backdrop-blur-sm"
:style="{ left: `${previewSliderPosition}%` }"
/>
</div>
<div
:class="
cn(
'col-span-full row-span-full flex gap-2',
hasBothComparisonImages && 'invisible'
)
"
>
<label
v-for="slot in comparisonSlots"
:key="slot.key"
: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',
comparisonOverStates[slot.key]
? 'border-muted-foreground'
: 'border-border-default hover:border-muted-foreground'
)
"
@dragenter.stop
@dragleave.stop
@dragover.prevent.stop
@drop.prevent.stop
>
<input
type="file"
accept="image/*"
class="hidden"
@change="(e) => handleComparisonSelect(e, slot.key)"
/>
<template v-if="comparisonPreviewUrls[slot.key]">
<img
:src="comparisonPreviewUrls[slot.key]!"
:alt="slot.label"
class="max-h-full max-w-full object-contain"
/>
</template>
<template v-else>
<span class="text-sm font-medium text-muted-foreground">
{{ slot.label }}
</span>
<span class="text-xs text-muted-foreground">
{{ $t('comfyHubPublish.uploadThumbnailHint') }}
</span>
</template>
</label>
</div>
</div>
</template>
<template v-else>
<label
ref="singleDropRef"
:class="
cn(
'flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
thumbnailPreviewUrl ? 'self-center p-1' : 'flex-1',
isOverSingleDrop
? 'border-muted-foreground'
: 'border-border-default hover:border-muted-foreground'
)
"
@dragenter.stop
@dragleave.stop
@dragover.prevent.stop
@drop.prevent.stop
>
<input
type="file"
:accept="
thumbnailType === 'video'
? 'video/*,image/gif,image/webp'
: 'image/*'
"
class="hidden"
@change="handleFileSelect"
/>
<template v-if="thumbnailPreviewUrl">
<video
v-if="isVideoFile"
:src="thumbnailPreviewUrl"
:aria-label="$t('comfyHubPublish.videoPreview')"
class="max-h-full max-w-full object-contain"
muted
loop
autoplay
/>
<img
v-else
:src="thumbnailPreviewUrl"
:alt="$t('comfyHubPublish.thumbnailPreview')"
class="max-h-full max-w-full object-contain"
/>
</template>
<template v-else>
<span class="text-sm text-muted-foreground">
{{ $t('comfyHubPublish.uploadPromptClickToBrowse') }}
</span>
<span class="text-sm text-muted-foreground">
{{ uploadDropText }}
</span>
<span class="text-xs text-muted-foreground">
{{ $t('comfyHubPublish.uploadThumbnailHint') }}
</span>
</template>
</label>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { useSliderFromMouse } from '@/platform/workflow/sharing/composables/useSliderFromMouse'
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
import {
isFileTooLarge,
MAX_IMAGE_SIZE_MB,
MAX_VIDEO_SIZE_MB
} from '@/platform/workflow/sharing/utils/validateFileSize'
import { cn } from '@/utils/tailwindUtil'
import { useDropZone, useObjectUrl } from '@vueuse/core'
import { computed, reactive, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
const { thumbnailType = 'image' } = defineProps<{
thumbnailType?: ThumbnailType
}>()
const emit = defineEmits<{
'update:thumbnailType': [value: ThumbnailType]
'update:thumbnailFile': [value: File | null]
'update:comparisonBeforeFile': [value: File | null]
'update:comparisonAfterFile': [value: File | null]
}>()
const { t } = useI18n()
function isThumbnailType(value: string): value is ThumbnailType {
return value === 'image' || value === 'video' || value === 'imageComparison'
}
function handleThumbnailTypeChange(value: unknown) {
if (typeof value === 'string' && isThumbnailType(value)) {
comparisonBeforeFile.value = null
comparisonAfterFile.value = null
emit('update:thumbnailFile', null)
emit('update:comparisonBeforeFile', null)
emit('update:comparisonAfterFile', null)
emit('update:thumbnailType', value)
}
}
const uploadSectionLabel = computed(() => {
if (thumbnailType === 'video') return t('comfyHubPublish.uploadVideo')
if (thumbnailType === 'imageComparison') {
return t('comfyHubPublish.uploadComparison')
}
return t('comfyHubPublish.uploadThumbnail')
})
const uploadDropText = computed(() =>
thumbnailType === 'video'
? t('comfyHubPublish.uploadPromptDropVideo')
: t('comfyHubPublish.uploadPromptDropImage')
)
const thumbnailOptions = [
{
value: 'image' as const,
label: t('comfyHubPublish.thumbnailImage')
},
{
value: 'video' as const,
label: t('comfyHubPublish.thumbnailVideo')
},
{
value: 'imageComparison' as const,
label: t('comfyHubPublish.thumbnailImageComparison')
}
]
const thumbnailFile = shallowRef<File | null>(null)
const thumbnailPreviewUrl = useObjectUrl(thumbnailFile)
const isVideoFile = ref(false)
function setThumbnailPreview(file: File) {
const maxSize = file.type.startsWith('video/')
? MAX_VIDEO_SIZE_MB
: MAX_IMAGE_SIZE_MB
if (isFileTooLarge(file, maxSize)) return
thumbnailFile.value = file
isVideoFile.value = file.type.startsWith('video/')
emit('update:thumbnailFile', file)
}
const comparisonBeforeFile = shallowRef<File | null>(null)
const comparisonAfterFile = shallowRef<File | null>(null)
const comparisonPreviewUrls = reactive({
before: useObjectUrl(comparisonBeforeFile),
after: useObjectUrl(comparisonAfterFile)
})
const hasBothComparisonImages = computed(
() => !!(comparisonPreviewUrls.before && comparisonPreviewUrls.after)
)
const comparisonPreviewRef = ref<HTMLElement | null>(null)
const previewSliderPosition = useSliderFromMouse(comparisonPreviewRef)
const hasThumbnailContent = computed(() => {
if (thumbnailType === 'imageComparison') {
return !!(comparisonPreviewUrls.before || comparisonPreviewUrls.after)
}
return !!thumbnailPreviewUrl.value
})
function clearAllPreviews() {
if (thumbnailType === 'imageComparison') {
comparisonBeforeFile.value = null
comparisonAfterFile.value = null
emit('update:comparisonBeforeFile', null)
emit('update:comparisonAfterFile', null)
return
}
thumbnailFile.value = null
emit('update:thumbnailFile', null)
}
function handleFileSelect(event: Event) {
if (!(event.target instanceof HTMLInputElement)) return
const file = event.target.files?.[0]
if (file) setThumbnailPreview(file)
}
const singleDropRef = ref<HTMLElement | null>(null)
function isImageType(types: readonly string[]) {
return types.some((type) => type.startsWith('image/'))
}
function isVideoModeMedia(types: readonly string[]) {
return types.some(
(type) =>
type.startsWith('video/') || type === 'image/gif' || type === 'image/webp'
)
}
const { isOverDropZone: isOverSingleDrop } = useDropZone(singleDropRef, {
dataTypes: (types: readonly string[]) =>
thumbnailType === 'video' ? isVideoModeMedia(types) : isImageType(types),
multiple: false,
onDrop(files) {
const file = files?.[0]
if (file) {
setThumbnailPreview(file)
}
}
})
type ComparisonSlot = 'before' | 'after'
const comparisonSlots = [
{
key: 'before' as const,
label: t('comfyHubPublish.uploadComparisonBeforePrompt')
},
{
key: 'after' as const,
label: t('comfyHubPublish.uploadComparisonAfterPrompt')
}
]
const comparisonFiles: Record<ComparisonSlot, typeof comparisonBeforeFile> = {
before: comparisonBeforeFile,
after: comparisonAfterFile
}
function setComparisonPreview(file: File, slot: ComparisonSlot) {
if (isFileTooLarge(file, MAX_IMAGE_SIZE_MB)) return
comparisonFiles[slot].value = file
if (slot === 'before') {
emit('update:comparisonBeforeFile', file)
return
}
emit('update:comparisonAfterFile', file)
}
function handleComparisonSelect(event: Event, slot: ComparisonSlot) {
if (!(event.target instanceof HTMLInputElement)) return
const file = event.target.files?.[0]
if (file) setComparisonPreview(file, slot)
}
const comparisonDropRefs = reactive<Record<ComparisonSlot, HTMLElement | null>>(
{ before: null, after: null }
)
function useComparisonDropZone(slot: ComparisonSlot) {
return useDropZone(
computed(() => comparisonDropRefs[slot]),
{
dataTypes: isImageType,
multiple: false,
onDrop(files) {
const file = files?.[0]
if (file) setComparisonPreview(file, slot)
}
}
)
}
const { isOverDropZone: isOverBefore } = useComparisonDropZone('before')
const { isOverDropZone: isOverAfter } = useComparisonDropZone('after')
const comparisonOverStates = computed(() => ({
before: isOverBefore.value,
after: isOverAfter.value
}))
</script>

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