mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-24 14:27:32 +00:00
Compare commits
18 Commits
remove-usa
...
cloud/1.40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32b6e58859 | ||
|
|
10a680f690 | ||
|
|
416f96649b | ||
|
|
964002cd2a | ||
|
|
130e1eed7a | ||
|
|
fdda951516 | ||
|
|
23f26efd94 | ||
|
|
afbdea2956 | ||
|
|
c46a95bd90 | ||
|
|
58460ada48 | ||
|
|
9ba3d75c5b | ||
|
|
c7cbefc256 | ||
|
|
4daba09415 | ||
|
|
b6ca126eff | ||
|
|
d4f6a9af0e | ||
|
|
b654d7c06a | ||
|
|
d3b67511f9 | ||
|
|
92a193203d |
3
.github/license-clarifications.json
vendored
Normal file
3
.github/license-clarifications.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"posthog-js@*": { "licenses": "Apache-2.0" }
|
||||
}
|
||||
19
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
19
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -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'
|
||||
|
||||
45
.github/workflows/cloud-dispatch-build.yaml
vendored
Normal file
45
.github/workflows/cloud-dispatch-build.yaml
vendored
Normal 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 }}
|
||||
@@ -37,9 +37,12 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
|
||||
// Monitor for server feature flags
|
||||
const checkInterval = setInterval(() => {
|
||||
const flags = window.app?.api?.serverFeatureFlags?.value
|
||||
if (flags && Object.keys(flags).length > 0) {
|
||||
window.__capturedMessages!.serverFeatureFlags = flags
|
||||
if (
|
||||
window.app?.api?.serverFeatureFlags &&
|
||||
Object.keys(window.app.api.serverFeatureFlags).length > 0
|
||||
) {
|
||||
window.__capturedMessages!.serverFeatureFlags =
|
||||
window.app.api.serverFeatureFlags
|
||||
clearInterval(checkInterval)
|
||||
}
|
||||
}, 100)
|
||||
@@ -93,7 +96,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
}) => {
|
||||
// Get the actual server feature flags from the backend
|
||||
const serverFlags = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.serverFeatureFlags.value
|
||||
return window.app!.api.serverFeatureFlags
|
||||
})
|
||||
|
||||
// Verify we received real feature flags from the backend
|
||||
@@ -126,8 +129,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
// Test that the method only returns true for boolean true values
|
||||
const testResults = await comfyPage.page.evaluate(() => {
|
||||
// Temporarily modify serverFeatureFlags to test behavior
|
||||
const original = window.app!.api.serverFeatureFlags.value
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
const original = window.app!.api.serverFeatureFlags
|
||||
window.app!.api.serverFeatureFlags = {
|
||||
bool_true: true,
|
||||
bool_false: false,
|
||||
string_value: 'yes',
|
||||
@@ -144,7 +147,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
}
|
||||
|
||||
// Restore original
|
||||
window.app!.api.serverFeatureFlags.value = original
|
||||
window.app!.api.serverFeatureFlags = original
|
||||
return results
|
||||
})
|
||||
|
||||
@@ -279,8 +282,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
// Monitor when feature flags arrive by checking periodically
|
||||
const checkFeatureFlags = setInterval(() => {
|
||||
if (
|
||||
window.app?.api?.serverFeatureFlags?.value
|
||||
?.supports_preview_metadata !== undefined
|
||||
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
||||
undefined
|
||||
) {
|
||||
window.__appReadiness!.featureFlagsReceived = true
|
||||
clearInterval(checkFeatureFlags)
|
||||
@@ -317,8 +320,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
// Wait for feature flags to be received
|
||||
await newPage.waitForFunction(
|
||||
() =>
|
||||
window.app?.api?.serverFeatureFlags?.value
|
||||
?.supports_preview_metadata !== undefined,
|
||||
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
||||
undefined,
|
||||
{
|
||||
timeout: 10000
|
||||
}
|
||||
@@ -328,7 +331,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
const readiness = await newPage.evaluate(() => {
|
||||
return {
|
||||
...window.__appReadiness,
|
||||
currentFlags: window.app!.api.serverFeatureFlags.value
|
||||
currentFlags: window.app!.api.serverFeatureFlags
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 |
@@ -1,22 +1,23 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Nodes Image Preview', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
async function loadImageOnNode(
|
||||
comfyPage: Awaited<
|
||||
ReturnType<(typeof test)['info']>
|
||||
>['fixtures']['comfyPage']
|
||||
) {
|
||||
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
await comfyPage.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
@@ -28,7 +29,6 @@ test.describe('Vue Nodes Image Preview', () => {
|
||||
return imagePreview
|
||||
}
|
||||
|
||||
// TODO(#8143): Re-enable after image preview sync is working in CI
|
||||
test.fixme('opens mask editor from image preview button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -40,7 +40,6 @@ test.describe('Vue Nodes Image Preview', () => {
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
// TODO(#8143): Re-enable after image preview sync is working in CI
|
||||
test.fixme('shows image context menu options', async ({ comfyPage }) => {
|
||||
await loadImageOnNode(comfyPage)
|
||||
|
||||
|
||||
2
global.d.ts
vendored
2
global.d.ts
vendored
@@ -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
|
||||
|
||||
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.1",
|
||||
"version": "1.40.10",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -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:",
|
||||
@@ -70,14 +71,13 @@
|
||||
"@primevue/themes": "catalog:",
|
||||
"@sentry/vue": "catalog:",
|
||||
"@sparkjsdev/spark": "catalog:",
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/extension-link": "catalog:",
|
||||
"@tiptap/extension-table": "catalog:",
|
||||
"@tiptap/extension-table-cell": "catalog:",
|
||||
"@tiptap/extension-table-header": "catalog:",
|
||||
"@tiptap/extension-table-row": "catalog:",
|
||||
"@tiptap/pm": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@tiptap/core": "^2.10.4",
|
||||
"@tiptap/extension-link": "^2.10.4",
|
||||
"@tiptap/extension-table": "^2.10.4",
|
||||
"@tiptap/extension-table-cell": "^2.10.4",
|
||||
"@tiptap/extension-table-header": "^2.10.4",
|
||||
"@tiptap/extension-table-row": "^2.10.4",
|
||||
"@tiptap/starter-kit": "^2.10.4",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -94,12 +94,13 @@
|
||||
"extendable-media-recorder-wav-encoder": "^7.0.129",
|
||||
"firebase": "catalog:",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "catalog:",
|
||||
"glob": "^11.0.3",
|
||||
"jsonata": "catalog:",
|
||||
"jsondiffpatch": "catalog:",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"pinia": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"primeicons": "catalog:",
|
||||
"primevue": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
|
||||
@@ -609,6 +609,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-subscription-gradient {
|
||||
background: var(--color-subscription-button-gradient);
|
||||
}
|
||||
|
||||
@utility scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getMediaTypeFromFilename,
|
||||
highlightQuery,
|
||||
isPreviewableMediaType,
|
||||
truncateFilename
|
||||
} from './formatUtil'
|
||||
|
||||
@@ -57,8 +56,7 @@ describe('formatUtil', () => {
|
||||
{ filename: 'image.jpeg', expected: 'image' },
|
||||
{ filename: 'animation.gif', expected: 'image' },
|
||||
{ filename: 'web.webp', expected: 'image' },
|
||||
{ filename: 'bitmap.bmp', expected: 'image' },
|
||||
{ filename: 'modern.avif', expected: 'image' }
|
||||
{ filename: 'bitmap.bmp', expected: 'image' }
|
||||
]
|
||||
|
||||
it.for(imageTestCases)(
|
||||
@@ -98,37 +96,26 @@ describe('formatUtil', () => {
|
||||
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
|
||||
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
|
||||
})
|
||||
})
|
||||
|
||||
describe('text files', () => {
|
||||
it('should identify text file extensions correctly', () => {
|
||||
expect(getMediaTypeFromFilename('notes.txt')).toBe('text')
|
||||
expect(getMediaTypeFromFilename('readme.md')).toBe('text')
|
||||
expect(getMediaTypeFromFilename('data.json')).toBe('text')
|
||||
expect(getMediaTypeFromFilename('table.csv')).toBe('text')
|
||||
expect(getMediaTypeFromFilename('config.yaml')).toBe('text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty strings', () => {
|
||||
expect(getMediaTypeFromFilename('')).toBe('other')
|
||||
expect(getMediaTypeFromFilename('')).toBe('image')
|
||||
})
|
||||
|
||||
it('should handle files without extensions', () => {
|
||||
expect(getMediaTypeFromFilename('README')).toBe('other')
|
||||
expect(getMediaTypeFromFilename('README')).toBe('image')
|
||||
})
|
||||
|
||||
it('should handle unknown extensions', () => {
|
||||
expect(getMediaTypeFromFilename('document.pdf')).toBe('other')
|
||||
expect(getMediaTypeFromFilename('archive.bin')).toBe('other')
|
||||
expect(getMediaTypeFromFilename('document.pdf')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('data.json')).toBe('image')
|
||||
})
|
||||
|
||||
it('should handle files with multiple dots', () => {
|
||||
expect(getMediaTypeFromFilename('my.file.name.png')).toBe('image')
|
||||
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('other')
|
||||
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('image')
|
||||
})
|
||||
|
||||
it('should handle paths with directories', () => {
|
||||
@@ -137,8 +124,8 @@ describe('formatUtil', () => {
|
||||
})
|
||||
|
||||
it('should handle null and undefined gracefully', () => {
|
||||
expect(getMediaTypeFromFilename(null)).toBe('other')
|
||||
expect(getMediaTypeFromFilename(undefined)).toBe('other')
|
||||
expect(getMediaTypeFromFilename(null)).toBe('image')
|
||||
expect(getMediaTypeFromFilename(undefined)).toBe('image')
|
||||
})
|
||||
|
||||
it('should handle special characters in filenames', () => {
|
||||
@@ -197,18 +184,4 @@ describe('formatUtil', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPreviewableMediaType', () => {
|
||||
it('returns true for image/video/audio/3D', () => {
|
||||
expect(isPreviewableMediaType('image')).toBe(true)
|
||||
expect(isPreviewableMediaType('video')).toBe(true)
|
||||
expect(isPreviewableMediaType('audio')).toBe(true)
|
||||
expect(isPreviewableMediaType('3D')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for text/other', () => {
|
||||
expect(isPreviewableMediaType('text')).toBe(false)
|
||||
expect(isPreviewableMediaType('other')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -494,41 +494,19 @@ export function formatDuration(milliseconds: number): string {
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = [
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'avif',
|
||||
'tif',
|
||||
'tiff'
|
||||
] as const
|
||||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] as const
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
|
||||
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
|
||||
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const
|
||||
const TEXT_EXTENSIONS = [
|
||||
'txt',
|
||||
'md',
|
||||
'markdown',
|
||||
'json',
|
||||
'csv',
|
||||
'yaml',
|
||||
'yml',
|
||||
'xml',
|
||||
'log'
|
||||
] as const
|
||||
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb'] as const
|
||||
|
||||
const MEDIA_TYPES = ['image', 'video', 'audio', '3D', 'text', 'other'] as const
|
||||
export type MediaType = (typeof MEDIA_TYPES)[number]
|
||||
const MEDIA_TYPES = ['image', 'video', 'audio', '3D'] as const
|
||||
type MediaType = (typeof MEDIA_TYPES)[number]
|
||||
|
||||
// Type guard helper for checking array membership
|
||||
type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
|
||||
type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
|
||||
type AudioExtension = (typeof AUDIO_EXTENSIONS)[number]
|
||||
type ThreeDExtension = (typeof THREE_D_EXTENSIONS)[number]
|
||||
type TextExtension = (typeof TEXT_EXTENSIONS)[number]
|
||||
|
||||
/**
|
||||
* Truncates a filename while preserving the extension
|
||||
@@ -565,30 +543,20 @@ export function truncateFilename(
|
||||
/**
|
||||
* Determines the media type from a filename's extension (singular form)
|
||||
* @param filename The filename to analyze
|
||||
* @returns The media type: 'image', 'video', 'audio', '3D', 'text', or 'other'
|
||||
* @returns The media type: 'image', 'video', 'audio', or '3D'
|
||||
*/
|
||||
export function getMediaTypeFromFilename(
|
||||
filename: string | null | undefined
|
||||
): MediaType {
|
||||
if (!filename) return 'other'
|
||||
if (!filename) return 'image'
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
if (!ext) return 'other'
|
||||
if (!ext) return 'image'
|
||||
|
||||
// Type-safe array includes check using type assertion
|
||||
if (IMAGE_EXTENSIONS.includes(ext as ImageExtension)) return 'image'
|
||||
if (VIDEO_EXTENSIONS.includes(ext as VideoExtension)) return 'video'
|
||||
if (AUDIO_EXTENSIONS.includes(ext as AudioExtension)) return 'audio'
|
||||
if (THREE_D_EXTENSIONS.includes(ext as ThreeDExtension)) return '3D'
|
||||
if (TEXT_EXTENSIONS.includes(ext as TextExtension)) return 'text'
|
||||
|
||||
return 'other'
|
||||
}
|
||||
|
||||
export function isPreviewableMediaType(mediaType: MediaType): boolean {
|
||||
return (
|
||||
mediaType === 'image' ||
|
||||
mediaType === 'video' ||
|
||||
mediaType === 'audio' ||
|
||||
mediaType === '3D'
|
||||
)
|
||||
return 'image'
|
||||
}
|
||||
|
||||
1807
pnpm-lock.yaml
generated
1807
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -6,15 +6,16 @@ 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
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.0
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
'@nx/eslint': 22.5.2
|
||||
'@nx/playwright': 22.5.2
|
||||
'@nx/storybook': 22.5.2
|
||||
'@nx/vite': 22.5.2
|
||||
'@nx/eslint': 22.2.6
|
||||
'@nx/playwright': 22.2.6
|
||||
'@nx/storybook': 22.2.4
|
||||
'@nx/vite': 22.2.6
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.58.1
|
||||
'@primeuix/forms': 0.0.2
|
||||
@@ -27,19 +28,11 @@ catalog:
|
||||
'@sentry/vite-plugin': ^4.6.0
|
||||
'@sentry/vue': ^10.32.1
|
||||
'@sparkjsdev/spark': ^0.1.10
|
||||
'@storybook/addon-docs': ^10.2.10
|
||||
'@storybook/addon-docs': ^10.1.9
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.2.10
|
||||
'@storybook/vue3-vite': ^10.2.10
|
||||
'@tailwindcss/vite': ^4.2.0
|
||||
'@tiptap/core': ^2.27.2
|
||||
'@tiptap/extension-link': ^2.27.2
|
||||
'@tiptap/extension-table': ^2.27.2
|
||||
'@tiptap/extension-table-cell': ^2.27.2
|
||||
'@tiptap/extension-table-header': ^2.27.2
|
||||
'@tiptap/extension-table-row': ^2.27.2
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@tiptap/starter-kit': ^2.27.2
|
||||
'@storybook/vue3': ^10.1.9
|
||||
'@storybook/vue3-vite': ^10.1.9
|
||||
'@tailwindcss/vite': ^4.1.12
|
||||
'@types/fs-extra': ^11.0.4
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^24.1.0
|
||||
@@ -53,7 +46,7 @@ catalog:
|
||||
'@vueuse/integrations': ^14.2.0
|
||||
'@webgpu/types': ^0.1.66
|
||||
algoliasearch: ^5.21.0
|
||||
axios: ^1.13.5
|
||||
axios: ^1.8.2
|
||||
cross-env: ^10.1.0
|
||||
cva: 1.0.0-beta.4
|
||||
dompurify: ^3.3.1
|
||||
@@ -63,37 +56,36 @@ catalog:
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-import-x: ^4.16.1
|
||||
eslint-plugin-oxlint: 1.25.0
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-storybook: ^10.1.9
|
||||
eslint-plugin-unused-imports: ^4.3.0
|
||||
eslint-plugin-vue: ^10.6.2
|
||||
firebase: ^11.6.0
|
||||
glob: ^13.0.6
|
||||
globals: ^16.5.0
|
||||
happy-dom: ^20.0.11
|
||||
husky: ^9.1.7
|
||||
jiti: 2.6.1
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
jsondiffpatch: ^0.7.3
|
||||
knip: ^5.75.1
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 22.5.2
|
||||
oxfmt: ^0.34.0
|
||||
oxlint: ^1.49.0
|
||||
oxlint-tsgolint: ^0.14.2
|
||||
nx: 22.2.6
|
||||
oxfmt: ^0.26.0
|
||||
oxlint: ^1.33.0
|
||||
oxlint-tsgolint: ^0.9.1
|
||||
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
|
||||
reka-ui: ^2.5.0
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
storybook: ^10.2.10
|
||||
storybook: ^10.1.9
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.2.0
|
||||
tailwindcss: ^4.1.12
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
tw-animate-css: ^1.3.8
|
||||
@@ -110,10 +102,10 @@ catalog:
|
||||
vitest: ^4.0.16
|
||||
vue: ^3.5.13
|
||||
vue-component-type-helpers: ^3.2.1
|
||||
vue-eslint-parser: ^10.4.0
|
||||
vue-i18n: ^9.14.5
|
||||
vue-eslint-parser: ^10.2.0
|
||||
vue-i18n: ^9.14.3
|
||||
vue-router: ^4.4.3
|
||||
vue-tsc: ^3.2.5
|
||||
vue-tsc: ^3.2.1
|
||||
vuefire: ^3.2.1
|
||||
wwobjloader2: ^6.2.1
|
||||
yjs: ^13.6.27
|
||||
@@ -140,5 +132,4 @@ onlyBuiltDependencies:
|
||||
- oxc-resolver
|
||||
|
||||
overrides:
|
||||
'@tiptap/pm': 2.27.2
|
||||
'@types/eslint': '-'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -174,7 +188,12 @@ 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'
|
||||
@@ -184,6 +203,7 @@ 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()
|
||||
@@ -259,6 +279,9 @@ const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
}
|
||||
}
|
||||
])
|
||||
const shareTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('actionbar.shareTooltip'))
|
||||
)
|
||||
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
return shouldShowConflictRedDot.value
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<slot name="background" />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.decrement')"
|
||||
:aria-label="t('g.ariaLabel.decrement')"
|
||||
data-testid="decrement"
|
||||
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
@@ -51,7 +51,7 @@
|
||||
<slot />
|
||||
<Button
|
||||
v-if="!hideButtons"
|
||||
:aria-label="t('g.increment')"
|
||||
:aria-label="t('g.ariaLabel.increment')"
|
||||
data-testid="increment"
|
||||
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
|
||||
variant="muted-textonly"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -729,11 +729,10 @@ const sortOptions = computed(() => [
|
||||
value: 'popular'
|
||||
},
|
||||
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
|
||||
// TODO: Uncomment when we have a way to get the real VRAM usage
|
||||
// {
|
||||
// name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
|
||||
// value: 'vram-low-to-high'
|
||||
// },
|
||||
{
|
||||
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
|
||||
value: 'vram-low-to-high'
|
||||
},
|
||||
{
|
||||
name: t(
|
||||
'templateWorkflows.sort.modelSizeLowToHigh',
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
>
|
||||
<WorkflowTabs />
|
||||
<TopbarBadges />
|
||||
<TopbarSubscribeButton />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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'
|
||||
@@ -526,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
|
||||
|
||||
@@ -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 executionErrorStore.hasInternalErrorForNode(node.id)
|
||||
return executionErrorStore.isContainerWithInternalError(node)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
getNodeByExecutionId,
|
||||
getExecutionIdByNode,
|
||||
getRootParentNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
@@ -20,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__'
|
||||
@@ -200,26 +202,30 @@ export function useErrorGroups(
|
||||
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(() => {
|
||||
@@ -238,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
|
||||
|
||||
@@ -121,7 +121,7 @@ const hasContainerInternalError = computed(() => {
|
||||
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
|
||||
if (!isContainer) return false
|
||||
|
||||
return executionErrorStore.hasInternalErrorForNode(targetNode.value.id)
|
||||
return executionErrorStore.isContainerWithInternalError(targetNode.value)
|
||||
})
|
||||
|
||||
const nodeHasError = computed(() => {
|
||||
|
||||
@@ -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') })
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
</slot>
|
||||
<span v-if="label && !isSmall" class="side-bar-button-label">{{
|
||||
st(label, label)
|
||||
t(label)
|
||||
}}</span>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -50,10 +50,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { st } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
icon = '',
|
||||
selected = false,
|
||||
@@ -81,7 +83,7 @@ const overlayValue = computed(() =>
|
||||
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
|
||||
)
|
||||
const shouldShowBadge = computed(() => !!overlayValue.value)
|
||||
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
|
||||
const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -112,22 +112,6 @@ const sampleAssets: AssetItem[] = [
|
||||
created_at: baseTimestamp,
|
||||
size: 134217728,
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'asset-text-1',
|
||||
name: 'generation-notes.txt',
|
||||
created_at: baseTimestamp,
|
||||
preview_url: '/assets/images/default-template.png',
|
||||
size: 2048,
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
id: 'asset-other-1',
|
||||
name: 'workflow-payload.bin',
|
||||
created_at: baseTimestamp,
|
||||
preview_url: '/assets/images/default-template.png',
|
||||
size: 4096,
|
||||
tags: []
|
||||
}
|
||||
]
|
||||
|
||||
@@ -150,16 +134,6 @@ export const RunningAndGenerated: Story = {
|
||||
render: renderAssetsSidebarListView
|
||||
}
|
||||
|
||||
export const TextAndMiscGeneratedAssets: Story = {
|
||||
args: {
|
||||
assets: sampleAssets.filter((asset) =>
|
||||
['.txt', '.bin'].some((suffix) => asset.name.endsWith(suffix))
|
||||
),
|
||||
jobs: []
|
||||
},
|
||||
render: renderAssetsSidebarListView
|
||||
}
|
||||
|
||||
function renderAssetsSidebarListView(args: StoryArgs) {
|
||||
return {
|
||||
components: { AssetsSidebarListView },
|
||||
|
||||
@@ -89,21 +89,4 @@ describe('AssetsSidebarListView', () => {
|
||||
expect(assetListItem?.props('previewUrl')).toBe('/api/view/clip.mp4')
|
||||
expect(assetListItem?.props('isVideoPreview')).toBe(true)
|
||||
})
|
||||
|
||||
it('uses icon fallback for text assets even when preview_url exists', () => {
|
||||
const textAsset = {
|
||||
...buildAsset('text-asset', 'notes.txt'),
|
||||
preview_url: '/api/view/notes.txt',
|
||||
user_metadata: {}
|
||||
} satisfies AssetItem
|
||||
|
||||
const wrapper = mountListView([buildOutputItem(textAsset)])
|
||||
|
||||
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||
const assetListItem = listItems.at(-1)
|
||||
|
||||
expect(assetListItem).toBeDefined()
|
||||
expect(assetListItem?.props('previewUrl')).toBe('')
|
||||
expect(assetListItem?.props('isVideoPreview')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
item.isChild && 'pl-6'
|
||||
)
|
||||
"
|
||||
:preview-url="getAssetPreviewUrl(item.asset)"
|
||||
:preview-url="item.asset.preview_url"
|
||||
:preview-alt="item.asset.name"
|
||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||
:is-video-preview="isVideoAsset(item.asset)"
|
||||
@@ -142,14 +142,6 @@ function isVideoAsset(asset: AssetItem): boolean {
|
||||
return getAssetMediaType(asset) === 'video'
|
||||
}
|
||||
|
||||
function getAssetPreviewUrl(asset: AssetItem): string {
|
||||
const mediaType = getAssetMediaType(asset)
|
||||
if (mediaType === 'image' || mediaType === 'video') {
|
||||
return asset.preview_url || ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getAssetSecondaryText(asset: AssetItem): string {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (typeof metadata?.executionTimeInSeconds === 'number') {
|
||||
|
||||
@@ -244,11 +244,7 @@ import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import {
|
||||
formatDuration,
|
||||
getMediaTypeFromFilename,
|
||||
isPreviewableMediaType
|
||||
} from '@/utils/formatUtil'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -409,12 +405,6 @@ const visibleAssets = computed(() => {
|
||||
return listViewSelectableAssets.value
|
||||
})
|
||||
|
||||
const previewableVisibleAssets = computed(() =>
|
||||
visibleAssets.value.filter((asset) =>
|
||||
isPreviewableMediaType(getMediaTypeFromFilename(asset.name))
|
||||
)
|
||||
)
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
|
||||
|
||||
const isBulkMode = computed(
|
||||
@@ -440,10 +430,12 @@ watch(visibleAssets, (newAssets) => {
|
||||
// so selection stays consistent with what this view can act on.
|
||||
reconcileSelection(newAssets)
|
||||
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
||||
const newIndex = previewableVisibleAssets.value.findIndex(
|
||||
const newIndex = newAssets.findIndex(
|
||||
(asset) => asset.id === currentGalleryAssetId.value
|
||||
)
|
||||
galleryActiveIndex.value = newIndex
|
||||
if (newIndex !== -1) {
|
||||
galleryActiveIndex.value = newIndex
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -454,7 +446,7 @@ watch(galleryActiveIndex, (index) => {
|
||||
})
|
||||
|
||||
const galleryItems = computed(() => {
|
||||
return previewableVisibleAssets.value.map((asset) => {
|
||||
return visibleAssets.value.map((asset) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
const resultItem = new ResultItemImpl({
|
||||
filename: asset.name,
|
||||
@@ -560,9 +552,6 @@ const handleDeleteSelected = async () => {
|
||||
|
||||
const handleZoomClick = (asset: AssetItem) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
if (!isPreviewableMediaType(mediaType)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mediaType === '3D') {
|
||||
const dialogStore = useDialogStore()
|
||||
@@ -582,9 +571,7 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
}
|
||||
|
||||
currentGalleryAssetId.value = asset.id
|
||||
const index = previewableVisibleAssets.value.findIndex(
|
||||
(a) => a.id === asset.id
|
||||
)
|
||||
const index = visibleAssets.value.findIndex((a) => a.id === asset.id)
|
||||
if (index !== -1) {
|
||||
galleryActiveIndex.value = index
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
25
src/components/topbar/TopbarSubscribeButton.vue
Normal file
25
src/components/topbar/TopbarSubscribeButton.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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']
|
||||
|
||||
33
src/components/ui/input/Input.stories.ts
Normal file
33
src/components/ui/input/Input.stories.ts
Normal 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 />'
|
||||
})
|
||||
}
|
||||
32
src/components/ui/input/Input.vue
Normal file
32
src/components/ui/input/Input.vue
Normal 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>
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
24
src/components/ui/textarea/Textarea.vue
Normal file
24
src/components/ui/textarea/Textarea.vue
Normal 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>
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
41
src/constants/toolkitNodes.ts
Normal file
41
src/constants/toolkitNodes.ts
Normal 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'
|
||||
])
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -275,9 +275,7 @@
|
||||
"signOut": {
|
||||
"signOut": "تسجيل الخروج",
|
||||
"success": "تم تسجيل الخروج بنجاح",
|
||||
"successDetail": "لقد تم تسجيل خروجك من حسابك.",
|
||||
"unsavedChangesMessage": "لديك تغييرات غير محفوظة ستفقد عند تسجيل الخروج. هل ترغب في المتابعة؟",
|
||||
"unsavedChangesTitle": "تغييرات غير محفوظة"
|
||||
"successDetail": "لقد تم تسجيل خروجك من حسابك."
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "هل لديك حساب بالفعل؟",
|
||||
|
||||
@@ -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": {
|
||||
"": {
|
||||
|
||||
@@ -275,9 +275,7 @@
|
||||
"signOut": {
|
||||
"signOut": "Cerrar sesión",
|
||||
"success": "Sesión cerrada correctamente",
|
||||
"successDetail": "Has cerrado sesión en tu cuenta.",
|
||||
"unsavedChangesMessage": "Tienes cambios no guardados que se perderán al cerrar sesión. ¿Quieres continuar?",
|
||||
"unsavedChangesTitle": "Cambios no guardados"
|
||||
"successDetail": "Has cerrado sesión en tu cuenta."
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
|
||||
|
||||
@@ -275,9 +275,7 @@
|
||||
"signOut": {
|
||||
"signOut": "خروج",
|
||||
"success": "خروج با موفقیت انجام شد",
|
||||
"successDetail": "شما با موفقیت از حساب کاربری خود خارج شدید.",
|
||||
"unsavedChangesMessage": "شما تغییرات ذخیرهنشدهای دارید که با خروج از حساب از بین خواهند رفت. آیا مایل به ادامه هستید؟",
|
||||
"unsavedChangesTitle": "تغییرات ذخیرهنشده"
|
||||
"successDetail": "شما با موفقیت از حساب کاربری خود خارج شدید."
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "قبلاً حساب کاربری دارید؟",
|
||||
|
||||
@@ -275,9 +275,7 @@
|
||||
"signOut": {
|
||||
"signOut": "Se déconnecter",
|
||||
"success": "Déconnexion réussie",
|
||||
"successDetail": "Vous avez été déconnecté de votre compte.",
|
||||
"unsavedChangesMessage": "Vous avez des modifications non enregistrées qui seront perdues si vous vous déconnectez. Voulez-vous continuer ?",
|
||||
"unsavedChangesTitle": "Modifications non enregistrées"
|
||||
"successDetail": "Vous avez été déconnecté de votre compte."
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "Vous avez déjà un compte?",
|
||||
|
||||
@@ -275,9 +275,7 @@
|
||||
"signOut": {
|
||||
"signOut": "ログアウト",
|
||||
"success": "正常にサインアウトしました",
|
||||
"successDetail": "アカウントからサインアウトしました。",
|
||||
"unsavedChangesMessage": "サインアウトすると未保存の変更が失われます。続行しますか?",
|
||||
"unsavedChangesTitle": "未保存の変更"
|
||||
"successDetail": "アカウントからサインアウトしました。"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
|
||||
|
||||
@@ -275,9 +275,7 @@
|
||||
"signOut": {
|
||||
"signOut": "로그아웃",
|
||||
"success": "성공적으로 로그아웃되었습니다",
|
||||
"successDetail": "계정에서 로그아웃되었습니다.",
|
||||
"unsavedChangesMessage": "저장되지 않은 변경 사항이 있습니다. 로그아웃하면 변경 사항이 사라집니다. 계속하시겠습니까?",
|
||||
"unsavedChangesTitle": "저장되지 않은 변경 사항"
|
||||
"successDetail": "계정에서 로그아웃되었습니다."
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "이미 계정이 있으신가요?",
|
||||
|
||||
@@ -275,9 +275,7 @@
|
||||
"signOut": {
|
||||
"signOut": "Sair",
|
||||
"success": "Logout realizado com sucesso",
|
||||
"successDetail": "Você saiu da sua conta.",
|
||||
"unsavedChangesMessage": "Você tem alterações não salvas que serão perdidas ao sair. Deseja continuar?",
|
||||
"unsavedChangesTitle": "Alterações não salvas"
|
||||
"successDetail": "Você saiu da sua conta."
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "Já tem uma conta?",
|
||||
|
||||
@@ -275,9 +275,7 @@
|
||||
"signOut": {
|
||||
"signOut": "Выйти",
|
||||
"success": "Вы успешно вышли из системы",
|
||||
"successDetail": "Вы вышли из своей учетной записи.",
|
||||
"unsavedChangesMessage": "У вас есть несохранённые изменения, которые будут потеряны при выходе из системы. Вы хотите продолжить?",
|
||||
"unsavedChangesTitle": "Несохранённые изменения"
|
||||
"successDetail": "Вы вышли из своей учетной записи."
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "Уже есть аккаунт?",
|
||||
|
||||
@@ -275,9 +275,7 @@
|
||||
"signOut": {
|
||||
"signOut": "Çıkış Yap",
|
||||
"success": "Başarıyla çıkış yapıldı",
|
||||
"successDetail": "Hesabınızdan başarıyla çıkış yaptınız.",
|
||||
"unsavedChangesMessage": "Oturumu kapattığınızda kaydedilmemiş değişiklikleriniz kaybolacak. Devam etmek istiyor musunuz?",
|
||||
"unsavedChangesTitle": "Kaydedilmemiş Değişiklikler"
|
||||
"successDetail": "Hesabınızdan başarıyla çıkış yaptınız."
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "Zaten bir hesabınız var mı?",
|
||||
|
||||
@@ -275,9 +275,7 @@
|
||||
"signOut": {
|
||||
"signOut": "登出",
|
||||
"success": "成功登出",
|
||||
"successDetail": "您已成功登出您的帳戶。",
|
||||
"unsavedChangesMessage": "您有尚未儲存的變更,登出後這些變更將會遺失。您確定要繼續嗎?",
|
||||
"unsavedChangesTitle": "未儲存的變更"
|
||||
"successDetail": "您已成功登出您的帳戶。"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "已經有帳戶?",
|
||||
|
||||
@@ -275,9 +275,7 @@
|
||||
"signOut": {
|
||||
"signOut": "退出登录",
|
||||
"success": "成功退出登录",
|
||||
"successDetail": "您已成功退出账户。",
|
||||
"unsavedChangesMessage": "您有未保存的更改,注销后这些更改将会丢失。是否继续?",
|
||||
"unsavedChangesTitle": "未保存的更改"
|
||||
"successDetail": "您已成功退出账户。"
|
||||
},
|
||||
"signup": {
|
||||
"alreadyHaveAccount": "已经有账户了?",
|
||||
|
||||
@@ -141,40 +141,6 @@ export const AudioAsset: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
export const TextAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-5',
|
||||
name: 'generation-notes.txt',
|
||||
size: 2048,
|
||||
preview_url: SAMPLE_MEDIA.image1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const OtherAsset: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="max-width: 280px;"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: {
|
||||
asset: {
|
||||
...sampleAsset,
|
||||
id: 'asset-6',
|
||||
name: 'workflow-payload.bin',
|
||||
size: 8192,
|
||||
preview_url: SAMPLE_MEDIA.image1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LoadingState: Story = {
|
||||
decorators: [
|
||||
() => ({
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<component
|
||||
:is="getTopComponent(previewKind)"
|
||||
:is="getTopComponent(fileKind)"
|
||||
v-else-if="asset && adaptedAsset"
|
||||
:asset="adaptedAsset"
|
||||
:context="{ type: assetType }"
|
||||
@@ -59,7 +59,6 @@
|
||||
>
|
||||
<IconGroup background-class="bg-white">
|
||||
<Button
|
||||
v-if="canInspect"
|
||||
variant="overlay-white"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.actions.zoom')"
|
||||
@@ -142,8 +141,7 @@ import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
isPreviewableMediaType
|
||||
getMediaTypeFromFilename
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -154,21 +152,17 @@ import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
type PreviewKind = ReturnType<typeof getMediaTypeFromFilename>
|
||||
|
||||
const mediaComponents = {
|
||||
top: {
|
||||
video: defineAsyncComponent(() => import('./MediaVideoTop.vue')),
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageTop.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DTop.vue')),
|
||||
text: defineAsyncComponent(() => import('./MediaTextTop.vue')),
|
||||
other: defineAsyncComponent(() => import('./MediaOtherTop.vue'))
|
||||
'3D': defineAsyncComponent(() => import('./Media3DTop.vue'))
|
||||
}
|
||||
}
|
||||
|
||||
function getTopComponent(kind: PreviewKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.other
|
||||
function getTopComponent(kind: MediaKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.image
|
||||
}
|
||||
|
||||
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
|
||||
@@ -212,15 +206,9 @@ const assetType = computed(() => {
|
||||
|
||||
// Determine file type from extension
|
||||
const fileKind = computed((): MediaKind => {
|
||||
return getMediaTypeFromFilename(asset?.name || '')
|
||||
return getMediaTypeFromFilename(asset?.name || '') as MediaKind
|
||||
})
|
||||
|
||||
const previewKind = computed((): PreviewKind => {
|
||||
return getMediaTypeFromFilename(asset?.name || '')
|
||||
})
|
||||
|
||||
const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
|
||||
|
||||
// Get filename without extension
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset?.name || '').filename
|
||||
@@ -282,7 +270,7 @@ const showActionsOverlay = computed(() => {
|
||||
})
|
||||
|
||||
const handleZoomClick = () => {
|
||||
if (asset && canInspect.value) {
|
||||
if (asset) {
|
||||
emit('zoom', asset)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import { isPreviewableMediaType } from '@/utils/formatUtil'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -207,8 +206,8 @@ const contextMenuItems = computed<MenuItem[]>(() => {
|
||||
|
||||
// Individual mode: Show all menu options
|
||||
|
||||
// Inspect
|
||||
if (isPreviewableMediaType(fileKind)) {
|
||||
// Inspect (if not 3D)
|
||||
if (fileKind !== '3D') {
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.inspect'),
|
||||
icon: 'icon-[lucide--zoom-in]',
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
<div class="relative size-full overflow-hidden rounded">
|
||||
<div
|
||||
class="flex size-full items-center justify-center bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||
>
|
||||
<i class="icon-[lucide--check-check] text-3xl text-base-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
<div class="relative size-full overflow-hidden rounded">
|
||||
<div
|
||||
class="flex size-full items-center justify-center bg-modal-card-placeholder-background transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||
>
|
||||
<i class="icon-[lucide--text] text-3xl text-base-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,14 +3,7 @@ import { z } from 'zod'
|
||||
|
||||
import { assetItemSchema } from './assetSchema'
|
||||
|
||||
const zMediaKindSchema = z.enum([
|
||||
'video',
|
||||
'audio',
|
||||
'image',
|
||||
'3D',
|
||||
'text',
|
||||
'other'
|
||||
])
|
||||
const zMediaKindSchema = z.enum(['video', 'audio', 'image', '3D'])
|
||||
export type MediaKind = z.infer<typeof zMediaKindSchema>
|
||||
|
||||
const zDimensionsSchema = z.object({
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { iconForMediaType } from './mediaIconUtil'
|
||||
|
||||
describe('iconForMediaType', () => {
|
||||
it('maps text and misc fallbacks correctly', () => {
|
||||
expect(iconForMediaType('text')).toBe('icon-[lucide--text]')
|
||||
expect(iconForMediaType('other')).toBe('icon-[lucide--check-check]')
|
||||
})
|
||||
|
||||
it('preserves existing mappings for core media types', () => {
|
||||
expect(iconForMediaType('image')).toBe('icon-[lucide--image]')
|
||||
expect(iconForMediaType('video')).toBe('icon-[lucide--video]')
|
||||
expect(iconForMediaType('audio')).toBe('icon-[lucide--music]')
|
||||
expect(iconForMediaType('3D')).toBe('icon-[lucide--box]')
|
||||
})
|
||||
})
|
||||
@@ -8,10 +8,6 @@ export function iconForMediaType(mediaType: MediaKind): string {
|
||||
return 'icon-[lucide--music]'
|
||||
case '3D':
|
||||
return 'icon-[lucide--box]'
|
||||
case 'text':
|
||||
return 'icon-[lucide--text]'
|
||||
case 'other':
|
||||
return 'icon-[lucide--check-check]'
|
||||
default:
|
||||
return 'icon-[lucide--image]'
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
67
src/platform/cloud/subscription/utils/tierBenefits.ts
Normal file
67
src/platform/cloud/subscription/utils/tierBenefits.ts
Normal 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
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export const PRESERVED_QUERY_NAMESPACES = {
|
||||
TEMPLATE: 'template',
|
||||
INVITE: 'invite'
|
||||
INVITE: 'invite',
|
||||
SHARE: 'share'
|
||||
} as const
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { FeatureSurveyConfig } from './useSurveyEligibility'
|
||||
|
||||
import {
|
||||
FEATURE_SURVEYS,
|
||||
getEnabledSurveys,
|
||||
getSurveyConfig
|
||||
} from './surveyRegistry'
|
||||
|
||||
const TEST_FEATURE_ID = '__test-feature__'
|
||||
const TEST_CONFIG: FeatureSurveyConfig = {
|
||||
featureId: TEST_FEATURE_ID,
|
||||
typeformId: 'test-form-123',
|
||||
triggerThreshold: 5,
|
||||
delayMs: 3000,
|
||||
enabled: true
|
||||
}
|
||||
|
||||
describe('surveyRegistry', () => {
|
||||
let originalEntries: Record<string, FeatureSurveyConfig>
|
||||
|
||||
beforeEach(() => {
|
||||
originalEntries = { ...FEATURE_SURVEYS }
|
||||
FEATURE_SURVEYS[TEST_FEATURE_ID] = TEST_CONFIG
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(FEATURE_SURVEYS)) {
|
||||
delete FEATURE_SURVEYS[key]
|
||||
}
|
||||
Object.assign(FEATURE_SURVEYS, originalEntries)
|
||||
})
|
||||
|
||||
describe('getSurveyConfig', () => {
|
||||
it('returns undefined for unknown feature', () => {
|
||||
expect(getSurveyConfig('nonexistent-feature')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns config for registered feature', () => {
|
||||
const config = getSurveyConfig(TEST_FEATURE_ID)
|
||||
expect(config).toEqual(TEST_CONFIG)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEnabledSurveys', () => {
|
||||
it('includes surveys with enabled: true', () => {
|
||||
const enabled = getEnabledSurveys()
|
||||
expect(enabled).toContainEqual(TEST_CONFIG)
|
||||
})
|
||||
|
||||
it('includes surveys where enabled is undefined', () => {
|
||||
const implicitlyEnabled: FeatureSurveyConfig = {
|
||||
featureId: '__implicit__',
|
||||
typeformId: 'form-456'
|
||||
}
|
||||
FEATURE_SURVEYS['__implicit__'] = implicitlyEnabled
|
||||
|
||||
const enabled = getEnabledSurveys()
|
||||
expect(enabled).toContainEqual(implicitlyEnabled)
|
||||
})
|
||||
|
||||
it('excludes surveys with enabled: false', () => {
|
||||
const disabledConfig: FeatureSurveyConfig = {
|
||||
featureId: '__disabled__',
|
||||
typeformId: 'form-789',
|
||||
enabled: false
|
||||
}
|
||||
FEATURE_SURVEYS['__disabled__'] = disabledConfig
|
||||
|
||||
const enabled = getEnabledSurveys()
|
||||
expect(enabled).not.toContainEqual(disabledConfig)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { FeatureSurveyConfig } from './useSurveyEligibility'
|
||||
|
||||
/**
|
||||
* Registry of all feature surveys.
|
||||
* Add new surveys here when targeting specific features for feedback.
|
||||
*/
|
||||
export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {}
|
||||
|
||||
export function getSurveyConfig(
|
||||
featureId: string
|
||||
): FeatureSurveyConfig | undefined {
|
||||
return FEATURE_SURVEYS[featureId]
|
||||
}
|
||||
|
||||
export function getEnabledSurveys(): FeatureSurveyConfig[] {
|
||||
return Object.values(FEATURE_SURVEYS).filter(
|
||||
(config) => config.enabled !== false
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
|
||||
import { useFeatureUsageTracker } from './useFeatureUsageTracker'
|
||||
|
||||
export interface FeatureSurveyConfig {
|
||||
interface FeatureSurveyConfig {
|
||||
/** Feature identifier. Must remain static after initialization. */
|
||||
featureId: string
|
||||
typeformId: string
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})()
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user