Compare commits

..

12 Commits

Author SHA1 Message Date
ComfyUI Wiki
7e0890aa3a Temporarily remove VRAM sort option
Comment out the VRAM Usage (Low to High) sort option in WorkflowTemplateSelectorDialog.vue and add a TODO, since real VRAM usage data is not available. Keeps the code in-place (commented) so it can be re-enabled when a reliable VRAM metric is implemented.
2026-02-22 10:47:35 +08:00
Alexander Brown
f9317e7078 fix: refresh deps and clear production audit vulnerabilities (#9068)
## Summary
- refresh workspace dependency catalog and lockfile for security and
maintenance updates
- resolve production audit findings (runtime dependencies now report
clean)
- align Tiptap dependencies on v2 and pin `@tiptap/pm` to avoid
mixed-version type issues
- update Storybook preview toolbar config for Storybook 10 typing
(`showName` removed)

## Validation
- `pnpm typecheck` 
- `pnpm lint`  (warnings only)
- `pnpm test:unit` 
- `pnpm audit --prod` 
- `pnpm audit` ⚠️ remaining dev-only transitive advisories
(`minimatch`/`ajv`/`brace-expansion`) in upstream toolchain deps

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9068-fix-refresh-deps-and-clear-production-audit-vulnerabilities-30e6d73d36508122b778ec3ca99d41a4)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-02-21 17:44:33 -08:00
Comfy Org PR Bot
79e71a5761 1.41.1 (#9069)
Patch version increment to 1.41.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9069-1-41-1-30f6d73d365081148845cb3024b7987f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-21 17:19:38 -08:00
Christian Byrne
3e4d273832 fix: update imagePreview browser tests to use current fixture APIs (#8995)
The browser tests added in #8143 were failing on main because they were
written against stale `ComfyPage` APIs that were refactored in #8510
(merged Feb 3, before #8143 merged Feb 18).

### Changes
- `comfyPage.dragAndDropFile` → `comfyPage.dragDrop.dragAndDropFile`
- `comfyPage.setSetting` → `comfyPage.settings.setSetting`
- `comfyPage.loadWorkflow` → `comfyPage.workflow.loadWorkflow`
- `comfyPage.getNodeRefsByType` → `comfyPage.nodeOps.getNodeRefsByType`
- Fix `comfyPage` type parameter to use `ComfyPage` import
- Remove `test.fixme` since root cause was API mismatch, not test logic

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8995-fix-update-imagePreview-browser-tests-to-use-current-fixture-APIs-30d6d73d365081219c1eda4ea7251160)
by [Unito](https://www.unito.io)
2026-02-21 16:56:25 -08:00
jaeone94
8aa4e36fd5 [refactor] Extract executionErrorStore from executionStore (#9060)
## Summary
Extracts error-related state and logic from `executionStore` into a
dedicated `executionErrorStore` for better separation of concerns.

## Changes
- **New store**: `executionErrorStore` with all error state
(`lastNodeErrors`, `lastExecutionError`, `lastPromptError`), computed
properties (`hasAnyError`, `totalErrorCount`,
`activeGraphErrorNodeIds`), and UI state (`isErrorOverlayOpen`,
`showErrorOverlay`, `dismissErrorOverlay`)
- **Moved util**: `executionIdToNodeLocatorId` extracted to
`graphTraversalUtil`, reusing `traverseSubgraphPath` and accepting
`rootGraph` as parameter
- **Updated consumers**: 12 files updated to import from
`executionErrorStore`
- **Backward compat**: Deprecated getters retained in `ComfyApp` for
extension compatibility

## Review Focus
- Deprecated getters in `app.ts` — can be removed in a future
breaking-change PR once extension authors migrate

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9060-refactor-Extract-executionErrorStore-from-executionStore-30e6d73d36508101973de835ab6b199f)
by [Unito](https://www.unito.io)
2026-02-21 16:51:22 -08:00
Christian Byrne
d9fdb01d9b fix: handle failed global subgraph blueprint loading gracefully (#9063)
## Summary

Fix "Failed to load subgraph blueprints Error: [ASSERT] Workflow content
should be loaded" error occurring on cloud.

## Changes

- **What**: `getGlobalSubgraphData` now throws on API failure instead of
returning empty string, global blueprint data is validated before
loading, and individual global blueprint errors are properly propagated
to the toast/console reporting instead of being silently swallowed.

## Review Focus

Two root causes were fixed:
1. `getGlobalSubgraphData` returned `""` on failure — this empty string
was set as `originalContent`, which is falsy, triggering the assertion
in `ComfyWorkflow.load()`.
2. `loadInstalledBlueprints` used an internal `Promise.allSettled` whose
results were discarded, so individual global blueprint failures never
reached the error reporting in `fetchSubgraphs`.

Fixes COM-15199

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9063-fix-handle-failed-global-subgraph-blueprint-loading-gracefully-30e6d73d3650818d9cc8ecf81cd0264e)
by [Unito](https://www.unito.io)
2026-02-21 16:30:01 -08:00
Alexander Brown
8f48b11f6a fix: resolve missing i18n key warnings (#9064)
## Summary

Fix multiple i18n missing key console warnings by correcting key paths
and adding missing translations.

## Changes

- **What**: 
- `ScrubableNumberInput`: Fixed references to non-existent
`g.ariaLabel.decrement`/`g.ariaLabel.increment` keys → use
`g.decrement`/`g.increment`
- `SidebarIcon`: Replaced `t()` with `st()` (safe translate) to prevent
double-translation when parent components pass pre-translated strings
- `en/main.json`: Added missing `menuLabels.Copy`, `menuLabels.Paste`,
`menuLabels.Select All` keys

## Review Focus

The `SidebarIcon` change from `t()` to `st()` is the key design
decision. `SidebarIcon` receives both i18n keys (from sidebar tabs via
`SideToolbar`) and pre-translated strings (from dedicated sidebar button
components). Using `st()` (which checks `te()` before translating)
handles both cases without warnings.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9064-fix-resolve-missing-i18n-key-warnings-30e6d73d3650816eaad3ce030f9c1d3f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-21 15:26:37 -08:00
Christian Byrne
bb40ffae3c feat: add survey registry for feature survey configurations (#8355)
## Summary
Adds a centralized registry for feature survey configurations.

## Changes
- Add `surveyRegistry.ts` with `FEATURE_SURVEYS` record for survey
configs
- Add helper functions `getSurveyConfig()` and `getEnabledSurveys()`
- Export `FeatureId` type for type-safe feature references

## Part of Nightly Survey System
This is part 3 of a stacked PR chain:
1.  feat/feature-usage-tracker - useFeatureUsageTracker (merged in
#8189)
2.  feat/survey-eligibility - useSurveyEligibility (#8189, merged)
3. **feat/survey-config** - surveyRegistry.ts (this PR)
4. feat/survey-popover - NightlySurveyPopover.vue
5. feat/survey-integration - NightlySurveyController.vue

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8355-feat-add-survey-registry-for-feature-survey-configurations-2f66d73d365081faae6bda0c14c069d9)
by [Unito](https://www.unito.io)
2026-02-21 13:38:06 -08:00
Dante
de131133bd fix: make serverFeatureFlags reactive via Vue ref (#9051) 2026-02-21 18:43:58 +09:00
Benjamin Lu
17f34788dc fix: disable inspect for non-previewable assets (#8989)
## Summary
Prevent text/other assets from opening a blank fullscreen viewer by
restricting inspect/zoom to previewable media kinds.

## Changes
- Add `isPreviewableMediaType` helper in shared `formatUtil`.
- Gate inspect/zoom actions in `AssetsSidebarTab`, `MediaAssetCard`, and
`MediaAssetContextMenu` using an allowlist (`image`, `video`, `audio`,
`3D`).
- Build gallery items from previewable assets only.
- Add unit tests for `isPreviewableMediaType`.

## Why
`ResultGallery` only renders image/video/audio; text/other assets could
previously enter fullscreen with no renderable content.

## Review Focus
- Verify text/other assets no longer show Inspect and do not open
fullscreen.
- Verify image/video/audio behavior is unchanged.
- Verify 3D still opens the 3D viewer dialog.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8989-fix-disable-inspect-for-non-previewable-assets-30c6d73d36508103a9b9da4fe50236ea)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-21 01:43:14 -08:00
Benjamin Lu
9184f9bce4 fix: support text and misc generated asset states (#8914)
## Summary
Align generated-asset state classification to a single shared source and
implement the missing text/misc states in both card and list previews.

## Changes
- **What**:
- Extended `getMediaTypeFromFilename` in
`packages/shared-frontend-utils` to return `text` and `other`, and
changed unknown/no-extension fallback from `image` to `other`.
- Added text extension handling (`txt`, `md`, `json`, `csv`, `yaml/yml`,
`xml`, `log`) and kept existing media kinds.
- Updated generated-assets UI to use shared media-type detection
directly (removed the local generated-assets classifier).
  - Added text and misc card preview components:
    - `text` -> `icon-[lucide--text]`
    - `other` -> `icon-[lucide--check-check]`
- Updated list-item preview behavior so only `image`/`video` use preview
media URLs; `text`/`other` use icon fallback.
- Widened media kind schema for asset display metadata to include `text`
and `other`.
- **Breaking**: No API breaking changes; internal media kind union
widened for frontend asset display paths.
- **Dependencies**: None.

## Review Focus
- Verify generated text assets render paragraph/text icon state in card
+ list.
- Verify unknown/misc assets consistently render double-check icon state
in card + list.
- Verify existing image/video/audio/3D behavior remains unchanged.

## Screenshots (if applicable)
<img width="282" height="158" alt="image"
src="https://github.com/user-attachments/assets/76cf2d1b-9d34-4c7c-92a1-50bbc55871e5"
/>
<img width="432" height="489" alt="image"
src="https://github.com/user-attachments/assets/024fece3-f241-484d-a37e-11948559ebbc"
/>
<img width="421" height="494" alt="image"
src="https://github.com/user-attachments/assets/ed64ba0c-bf46-4c3b-996e-4bc613ee029e"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8914-fix-support-text-and-misc-generated-asset-states-3096d73d365081f28ca7c32f306e4b50)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-21 01:27:28 -08:00
Comfy Org PR Bot
ea7bbb744f 1.41.0 (#9059)
Minor version increment to 1.41.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9059-1-41-0-30e6d73d36508103b6cbef6d402f05de)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-21 01:13:57 -08:00
177 changed files with 2609 additions and 9799 deletions

View File

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

View File

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

View File

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

View File

@@ -37,12 +37,9 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Monitor for server feature flags
const checkInterval = setInterval(() => {
if (
window.app?.api?.serverFeatureFlags &&
Object.keys(window.app.api.serverFeatureFlags).length > 0
) {
window.__capturedMessages!.serverFeatureFlags =
window.app.api.serverFeatureFlags
const flags = window.app?.api?.serverFeatureFlags?.value
if (flags && Object.keys(flags).length > 0) {
window.__capturedMessages!.serverFeatureFlags = flags
clearInterval(checkInterval)
}
}, 100)
@@ -96,7 +93,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
return window.app!.api.serverFeatureFlags.value
})
// Verify we received real feature flags from the backend
@@ -129,8 +126,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
window.app!.api.serverFeatureFlags = {
const original = window.app!.api.serverFeatureFlags.value
window.app!.api.serverFeatureFlags.value = {
bool_true: true,
bool_false: false,
string_value: 'yes',
@@ -147,7 +144,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}
// Restore original
window.app!.api.serverFeatureFlags = original
window.app!.api.serverFeatureFlags.value = original
return results
})
@@ -282,8 +279,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Monitor when feature flags arrive by checking periodically
const checkFeatureFlags = setInterval(() => {
if (
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined
window.app?.api?.serverFeatureFlags?.value
?.supports_preview_metadata !== undefined
) {
window.__appReadiness!.featureFlagsReceived = true
clearInterval(checkFeatureFlags)
@@ -320,8 +317,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Wait for feature flags to be received
await newPage.waitForFunction(
() =>
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined,
window.app?.api?.serverFeatureFlags?.value
?.supports_preview_metadata !== undefined,
{
timeout: 10000
}
@@ -331,7 +328,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
const readiness = await newPage.evaluate(() => {
return {
...window.__appReadiness,
currentFlags: window.app!.api.serverFeatureFlags
currentFlags: window.app!.api.serverFeatureFlags.value
}
})

View File

@@ -332,9 +332,12 @@ test.describe('Workflows sidebar', () => {
.getPersistedItem('workflow1.json')
.click({ button: 'right' })
await comfyPage.contextMenu.clickMenuItem('Duplicate')
await expect
.poll(() => workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', '*workflow1 (Copy).json'])
await comfyPage.nextFrame()
expect(await 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

View File

@@ -1,23 +1,22 @@
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.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.loadWorkflow('widgets/load_image_widget')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
})
async function loadImageOnNode(
comfyPage: Awaited<
ReturnType<(typeof test)['info']>
>['fixtures']['comfyPage']
) {
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
async function loadImageOnNode(comfyPage: ComfyPage) {
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragAndDropFile('image64x64.webp', {
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
@@ -29,6 +28,7 @@ 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,6 +40,7 @@ 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
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.40.10",
"version": "1.41.1",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -60,7 +60,6 @@
"@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:",
@@ -71,13 +70,14 @@
"@primevue/themes": "catalog:",
"@sentry/vue": "catalog:",
"@sparkjsdev/spark": "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",
"@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:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"@xterm/addon-fit": "^0.10.0",
@@ -94,13 +94,12 @@
"extendable-media-recorder-wav-encoder": "^7.0.129",
"firebase": "catalog:",
"fuse.js": "^7.0.0",
"glob": "^11.0.3",
"glob": "catalog:",
"jsonata": "catalog:",
"jsondiffpatch": "^0.6.0",
"jsondiffpatch": "catalog:",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "catalog:",
"posthog-js": "catalog:",
"primeicons": "catalog:",
"primevue": "catalog:",
"reka-ui": "catalog:",

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
getMediaTypeFromFilename,
highlightQuery,
isPreviewableMediaType,
truncateFilename
} from './formatUtil'
@@ -56,7 +57,8 @@ describe('formatUtil', () => {
{ filename: 'image.jpeg', expected: 'image' },
{ filename: 'animation.gif', expected: 'image' },
{ filename: 'web.webp', expected: 'image' },
{ filename: 'bitmap.bmp', expected: 'image' }
{ filename: 'bitmap.bmp', expected: 'image' },
{ filename: 'modern.avif', expected: 'image' }
]
it.for(imageTestCases)(
@@ -96,26 +98,37 @@ 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('image')
expect(getMediaTypeFromFilename('')).toBe('other')
})
it('should handle files without extensions', () => {
expect(getMediaTypeFromFilename('README')).toBe('image')
expect(getMediaTypeFromFilename('README')).toBe('other')
})
it('should handle unknown extensions', () => {
expect(getMediaTypeFromFilename('document.pdf')).toBe('image')
expect(getMediaTypeFromFilename('data.json')).toBe('image')
expect(getMediaTypeFromFilename('document.pdf')).toBe('other')
expect(getMediaTypeFromFilename('archive.bin')).toBe('other')
})
it('should handle files with multiple dots', () => {
expect(getMediaTypeFromFilename('my.file.name.png')).toBe('image')
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('image')
expect(getMediaTypeFromFilename('archive.tar.gz')).toBe('other')
})
it('should handle paths with directories', () => {
@@ -124,8 +137,8 @@ describe('formatUtil', () => {
})
it('should handle null and undefined gracefully', () => {
expect(getMediaTypeFromFilename(null)).toBe('image')
expect(getMediaTypeFromFilename(undefined)).toBe('image')
expect(getMediaTypeFromFilename(null)).toBe('other')
expect(getMediaTypeFromFilename(undefined)).toBe('other')
})
it('should handle special characters in filenames', () => {
@@ -184,4 +197,18 @@ 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)
})
})
})

View File

@@ -494,19 +494,41 @@ export function formatDuration(milliseconds: number): string {
return parts.join(' ')
}
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] as const
const IMAGE_EXTENSIONS = [
'png',
'jpg',
'jpeg',
'gif',
'webp',
'bmp',
'avif',
'tif',
'tiff'
] 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'] 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 MEDIA_TYPES = ['image', 'video', 'audio', '3D'] as const
type MediaType = (typeof MEDIA_TYPES)[number]
const MEDIA_TYPES = ['image', 'video', 'audio', '3D', 'text', 'other'] as const
export 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
@@ -543,20 +565,30 @@ 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', or '3D'
* @returns The media type: 'image', 'video', 'audio', '3D', 'text', or 'other'
*/
export function getMediaTypeFromFilename(
filename: string | null | undefined
): MediaType {
if (!filename) return 'image'
if (!filename) return 'other'
const ext = filename.split('.').pop()?.toLowerCase()
if (!ext) return 'image'
if (!ext) return 'other'
// 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 'image'
return 'other'
}
export function isPreviewableMediaType(mediaType: MediaType): boolean {
return (
mediaType === 'image' ||
mediaType === 'video' ||
mediaType === 'audio' ||
mediaType === '3D'
)
}

1807
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,16 +6,15 @@ 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.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
'@lobehub/i18n-cli': ^1.26.1
'@nx/eslint': 22.2.6
'@nx/playwright': 22.2.6
'@nx/storybook': 22.2.4
'@nx/vite': 22.2.6
'@nx/eslint': 22.5.2
'@nx/playwright': 22.5.2
'@nx/storybook': 22.5.2
'@nx/vite': 22.5.2
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
'@primeuix/forms': 0.0.2
@@ -28,11 +27,19 @@ catalog:
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^0.1.10
'@storybook/addon-docs': ^10.1.9
'@storybook/addon-docs': ^10.2.10
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.1.9
'@storybook/vue3-vite': ^10.1.9
'@tailwindcss/vite': ^4.1.12
'@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
'@types/fs-extra': ^11.0.4
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
@@ -46,7 +53,7 @@ catalog:
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
axios: ^1.8.2
axios: ^1.13.5
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dompurify: ^3.3.1
@@ -56,36 +63,37 @@ 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.1.9
eslint-plugin-storybook: ^10.2.10
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.2.6
oxfmt: ^0.26.0
oxlint: ^1.33.0
oxlint-tsgolint: ^0.9.1
nx: 22.5.2
oxfmt: ^0.34.0
oxlint: ^1.49.0
oxlint-tsgolint: ^0.14.2
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.1.9
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.1.12
tailwindcss: ^4.2.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
@@ -102,10 +110,10 @@ catalog:
vitest: ^4.0.16
vue: ^3.5.13
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.2.0
vue-i18n: ^9.14.3
vue-eslint-parser: ^10.4.0
vue-i18n: ^9.14.5
vue-router: ^4.4.3
vue-tsc: ^3.2.1
vue-tsc: ^3.2.5
vuefire: ^3.2.1
wwobjloader2: ^6.2.1
yjs: ^13.6.27
@@ -132,4 +140,5 @@ onlyBuiltDependencies:
- oxc-resolver
overrides:
'@tiptap/pm': 2.27.2
'@types/eslint': '-'

View File

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

View File

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

View File

@@ -51,7 +51,6 @@
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@@ -99,19 +98,6 @@
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"
@@ -188,12 +174,7 @@ import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
openShareDialog,
prefetchShareDialog
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { isDesktop } from '@/platform/distribution/types'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
@@ -203,7 +184,6 @@ 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()
@@ -279,9 +259,6 @@ const queueContextMenuItems = computed<MenuItem[]>(() => [
}
}
])
const shareTooltipConfig = computed(() =>
buildTooltipConfig(t('actionbar.shareTooltip'))
)
const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value

View File

@@ -6,7 +6,7 @@
<slot name="background" />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.decrement')"
:aria-label="t('g.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.ariaLabel.increment')"
:aria-label="t('g.increment')"
data-testid="increment"
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"

View File

@@ -1,36 +1,27 @@
<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,
class: className
variant
} = 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="badgeClass">
<span
:class="
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
"
>
{{ label }}
</span>
</template>

View File

@@ -729,10 +729,11 @@ const sortOptions = computed(() => [
value: 'popular'
},
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
value: 'vram-low-to-high'
},
// 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.modelSizeLowToHigh',

View File

@@ -18,7 +18,6 @@
>
<WorkflowTabs />
<TopbarBadges />
<TopbarSubscribeButton />
</div>
</div>
</template>
@@ -134,7 +133,6 @@ 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'
@@ -528,13 +526,8 @@ onMounted(async () => {
await workflowPersistence.initializeWorkflow()
workflowPersistence.restoreWorkflowTabsState()
const sharedWorkflowLoadStatus =
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
// Load template from URL if present
if (sharedWorkflowLoadStatus === 'not-present') {
await workflowPersistence.loadTemplateFromUrlIfPresent()
}
await workflowPersistence.loadTemplateFromUrlIfPresent()
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts

View File

@@ -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.isContainerWithInternalError(node)
return executionErrorStore.hasInternalErrorForNode(node.id)
})
})

View File

@@ -13,7 +13,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
getNodeByExecutionId,
getExecutionIdByNode,
getRootParentNode
} from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -21,7 +20,6 @@ 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__'
@@ -202,30 +200,26 @@ export function useErrorGroups(
const selectedNodeInfo = computed(() => {
const items = canvasStore.selectedItems
const nodeIds = new Set<string>()
const containerExecutionIds = new Set<NodeExecutionId>()
const containerIds = new Set<string>()
for (const item of items) {
if (!isLGraphNode(item)) continue
nodeIds.add(String(item.id))
if (
(item instanceof SubgraphNode || isGroupNode(item)) &&
app.rootGraph
) {
const execId = getExecutionIdByNode(app.rootGraph, item)
if (execId) containerExecutionIds.add(execId)
if (item instanceof SubgraphNode || isGroupNode(item)) {
containerIds.add(String(item.id))
}
}
return {
nodeIds: nodeIds.size > 0 ? nodeIds : null,
containerExecutionIds
containerIds
}
})
const isSingleNodeSelected = computed(
() =>
selectedNodeInfo.value.nodeIds?.size === 1 &&
selectedNodeInfo.value.containerExecutionIds.size === 0
selectedNodeInfo.value.containerIds.size === 0
)
const errorNodeCache = computed(() => {
@@ -244,9 +238,8 @@ export function useErrorGroups(
const graphNode = errorNodeCache.value.get(executionNodeId)
if (graphNode && nodeIds.has(String(graphNode.id))) return true
for (const containerExecId of selectedNodeInfo.value
.containerExecutionIds) {
if (executionNodeId.startsWith(`${containerExecId}:`)) return true
for (const containerId of selectedNodeInfo.value.containerIds) {
if (executionNodeId.startsWith(`${containerId}:`)) return true
}
return false

View File

@@ -121,7 +121,7 @@ const hasContainerInternalError = computed(() => {
targetNode.value instanceof SubgraphNode || isGroupNode(targetNode.value)
if (!isContainer) return false
return executionErrorStore.isContainerWithInternalError(targetNode.value)
return executionErrorStore.hasInternalErrorForNode(targetNode.value.id)
})
const nodeHasError = computed(() => {

View File

@@ -53,7 +53,6 @@ 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'
@@ -65,7 +64,6 @@ const selectedCategory = defineModel<string>('selectedCategory', {
})
const { t } = useI18n()
const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()
const topCategories = computed(() => [
@@ -81,7 +79,7 @@ const hasEssentialNodes = computed(() =>
const sourceCategories = computed(() => {
const categories = []
if (flags.nodeLibraryEssentialsEnabled && hasEssentialNodes.value) {
if (hasEssentialNodes.value) {
categories.push({ id: 'essentials', label: t('g.essentials') })
}
categories.push({ id: 'custom', label: t('g.custom') })

View File

@@ -41,7 +41,7 @@
</div>
</slot>
<span v-if="label && !isSmall" class="side-bar-button-label">{{
t(label)
st(label, label)
}}</span>
</div>
</Button>
@@ -50,12 +50,10 @@
<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,
@@ -83,7 +81,7 @@ const overlayValue = computed(() =>
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
)
const shouldShowBadge = computed(() => !!overlayValue.value)
const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
</script>
<style>

View File

@@ -112,6 +112,22 @@ 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: []
}
]
@@ -134,6 +150,16 @@ 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 },

View File

@@ -89,4 +89,21 @@ 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)
})
})

View File

@@ -43,7 +43,7 @@
item.isChild && 'pl-6'
)
"
:preview-url="item.asset.preview_url"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="item.asset.name"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
@@ -142,6 +142,14 @@ 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') {

View File

@@ -244,7 +244,11 @@ 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 } from '@/utils/formatUtil'
import {
formatDuration,
getMediaTypeFromFilename,
isPreviewableMediaType
} from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
@@ -405,6 +409,12 @@ 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(
@@ -430,12 +440,10 @@ watch(visibleAssets, (newAssets) => {
// so selection stays consistent with what this view can act on.
reconcileSelection(newAssets)
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex(
const newIndex = previewableVisibleAssets.value.findIndex(
(asset) => asset.id === currentGalleryAssetId.value
)
if (newIndex !== -1) {
galleryActiveIndex.value = newIndex
}
galleryActiveIndex.value = newIndex
}
})
@@ -446,7 +454,7 @@ watch(galleryActiveIndex, (index) => {
})
const galleryItems = computed(() => {
return visibleAssets.value.map((asset) => {
return previewableVisibleAssets.value.map((asset) => {
const mediaType = getMediaTypeFromFilename(asset.name)
const resultItem = new ResultItemImpl({
filename: asset.name,
@@ -552,6 +560,9 @@ const handleDeleteSelected = async () => {
const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name)
if (!isPreviewableMediaType(mediaType)) {
return
}
if (mediaType === '3D') {
const dialogStore = useDialogStore()
@@ -571,7 +582,9 @@ const handleZoomClick = (asset: AssetItem) => {
}
currentGalleryAssetId.value = asset.id
const index = visibleAssets.value.findIndex((a) => a.id === asset.id)
const index = previewableVisibleAssets.value.findIndex(
(a) => a.id === asset.id
)
if (index !== -1) {
galleryActiveIndex.value = index
}

View File

@@ -52,7 +52,7 @@
:value="tab.value"
:class="
cn(
'flex-1 text-center select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'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,9 +70,7 @@
<!-- Tab content (scrollable) -->
<TabsRoot v-model="selectedTab" class="h-full">
<EssentialNodesPanel
v-if="
flags.nodeLibraryEssentialsEnabled && selectedTab === 'essentials'
"
v-if="selectedTab === 'essentials'"
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
@node-click="handleNodeClick"
@@ -111,11 +109,10 @@ import {
TabsRoot,
TabsTrigger
} from 'reka-ui'
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { computed, nextTick, onMounted, ref } 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 {
@@ -139,22 +136,11 @@ 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',
{
@@ -338,21 +324,11 @@ async function handleSearch() {
expandedKeys.value = allKeys
}
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
})
const tabs = computed(() => [
{ value: 'essentials', label: t('sideToolbar.nodeLibraryTab.essentials') },
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
])
onMounted(() => {
searchBoxRef.value?.focus()

View File

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

View File

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

View File

@@ -46,16 +46,6 @@
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"
@@ -71,7 +61,7 @@
:fluid="false"
:label="$t('subscription.subscribeToComfyCloud')"
size="sm"
button-variant="gradient"
variant="gradient"
@subscribed="handleSubscribed"
/>
</div>
@@ -180,7 +170,6 @@ const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
isFreeTier,
subscriptionTierName,
subscriptionTier,
fetchStatus
@@ -206,10 +195,7 @@ const formattedBalance = computed(() => {
const canUpgrade = computed(() => {
const tier = subscriptionTier.value
return (
tier === 'FREE' ||
tier === 'FOUNDERS_EDITION' ||
tier === 'STANDARD' ||
tier === 'CREATOR'
tier === 'FOUNDERS_EDITION' || tier === 'STANDARD' || tier === 'CREATOR'
)
})
@@ -219,7 +205,7 @@ const handleOpenUserSettings = () => {
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.showPricingTable()
subscriptionDialog.show()
emit('close')
}
@@ -248,11 +234,6 @@ const handleOpenPartnerNodesInfo = () => {
emit('close')
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable()
emit('close')
}
const handleLogout = async () => {
await handleSignOut()
emit('close')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -120,8 +120,6 @@ 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
@@ -240,7 +238,6 @@ function useBillingContextInternal(): BillingContext {
isLoading,
error,
isActiveSubscription,
isFreeTier,
getMaxSeats,
initialize,

View File

@@ -40,7 +40,6 @@ 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) {
@@ -86,10 +85,6 @@ 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 =
@@ -178,7 +173,6 @@ export function useLegacyBilling(): BillingState & BillingActions {
isLoading,
error,
isActiveSubscription,
isFreeTier,
// Actions
initialize,

View File

@@ -1,6 +1,6 @@
import { useI18n } from 'vue-i18n'
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
import { downloadFile } 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')
void openFileInNewTab(url.toString())
window.open(url.toString(), '_blank')
}
const copyImage = async (node: LGraphNode) => {

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
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'
@@ -20,7 +21,7 @@ const badges = computed<TopbarBadge[]>(() => {
// Always add cloud badge last (furthest right)
result.push({
icon: 'icon-[lucide--cloud]',
label: t('g.beta'),
text: 'Comfy Cloud'
})

View File

@@ -4187,12 +4187,7 @@ 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.
// 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) {
if (y > bodyHeight) {
this.setSize([this.size[0], y])
this.graph.setDirtyCanvas(false, true)
}

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "تسجيل الخروج",
"success": "تم تسجيل الخروج بنجاح",
"successDetail": "لقد تم تسجيل خروجك من حسابك."
"successDetail": "لقد تم تسجيل خروجك من حسابك.",
"unsavedChangesMessage": "لديك تغييرات غير محفوظة ستفقد عند تسجيل الخروج. هل ترغب في المتابعة؟",
"unsavedChangesTitle": "تغييرات غير محفوظة"
},
"signup": {
"alreadyHaveAccount": "هل لديك حساب بالفعل؟",

View File

@@ -69,7 +69,6 @@
"icon": "Icon",
"color": "Color",
"error": "Error",
"enter": "Enter",
"enterSubgraph": "Enter Subgraph",
"resizeFromBottomRight": "Resize from bottom-right corner",
"resizeFromTopRight": "Resize from top-right corner",
@@ -1901,7 +1900,6 @@
"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.",
@@ -1971,7 +1969,6 @@
"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",
@@ -1996,12 +1993,7 @@
"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",
"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"
"noAssociatedUser": "There is no Comfy user associated with the provided API key"
},
"signup": {
"title": "Create an account",
@@ -2015,8 +2007,7 @@
"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.",
"emailNotEligibleForFreeTier": "Email sign-up is not eligible for Free Tier."
"personalDataConsentLabel": "I agree to the processing of my personal data."
},
"signOut": {
"signOut": "Log Out",
@@ -2207,15 +2198,10 @@
"invoiceHistory": "Invoice history",
"benefits": {
"benefit1": "Monthly credits for Partner Nodes — top up when needed",
"benefit1FreeTier": "More monthly credits, top up anytime",
"benefit2": "Up to 1 hour runtime per job on Pro",
"benefit3": "Bring your own models (Creator & Pro)"
"benefit2": "Up to 30 min runtime per job"
},
"yearlyDiscount": "20% DISCOUNT",
"tiers": {
"free": {
"name": "Free"
},
"founder": {
"name": "Founder's Edition"
},
@@ -2239,8 +2225,6 @@
},
"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",
@@ -2251,21 +2235,6 @@
"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",
@@ -2293,7 +2262,6 @@
"upgradeTo": "Upgrade to {plan}",
"changeTo": "Change to {plan}",
"maxDuration": {
"free": "30 min",
"standard": "30 min",
"creator": "30 min",
"pro": "1 hr",
@@ -2924,131 +2892,7 @@
"actionbar": {
"dockToTop": "Dock to top",
"feedback": "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"
"feedbackTooltip": "Feedback"
},
"desktopDialogs": {
"": {

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "Cerrar sesión",
"success": "Sesión cerrada correctamente",
"successDetail": "Has cerrado sesión en tu cuenta."
"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"
},
"signup": {
"alreadyHaveAccount": "¿Ya tienes una cuenta?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "خروج",
"success": "خروج با موفقیت انجام شد",
"successDetail": "شما با موفقیت از حساب کاربری خود خارج شدید."
"successDetail": "شما با موفقیت از حساب کاربری خود خارج شدید.",
"unsavedChangesMessage": "شما تغییرات ذخیره‌نشده‌ای دارید که با خروج از حساب از بین خواهند رفت. آیا مایل به ادامه هستید؟",
"unsavedChangesTitle": "تغییرات ذخیره‌نشده"
},
"signup": {
"alreadyHaveAccount": "قبلاً حساب کاربری دارید؟",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "Se déconnecter",
"success": "Déconnexion réussie",
"successDetail": "Vous avez été déconnecté de votre compte."
"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"
},
"signup": {
"alreadyHaveAccount": "Vous avez déjà un compte?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "ログアウト",
"success": "正常にサインアウトしました",
"successDetail": "アカウントからサインアウトしました。"
"successDetail": "アカウントからサインアウトしました。",
"unsavedChangesMessage": "サインアウトすると未保存の変更が失われます。続行しますか?",
"unsavedChangesTitle": "未保存の変更"
},
"signup": {
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "로그아웃",
"success": "성공적으로 로그아웃되었습니다",
"successDetail": "계정에서 로그아웃되었습니다."
"successDetail": "계정에서 로그아웃되었습니다.",
"unsavedChangesMessage": "저장되지 않은 변경 사항이 있습니다. 로그아웃하면 변경 사항이 사라집니다. 계속하시겠습니까?",
"unsavedChangesTitle": "저장되지 않은 변경 사항"
},
"signup": {
"alreadyHaveAccount": "이미 계정이 있으신가요?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "Sair",
"success": "Logout realizado com sucesso",
"successDetail": "Você saiu da sua conta."
"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"
},
"signup": {
"alreadyHaveAccount": "Já tem uma conta?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "Выйти",
"success": "Вы успешно вышли из системы",
"successDetail": "Вы вышли из своей учетной записи."
"successDetail": "Вы вышли из своей учетной записи.",
"unsavedChangesMessage": "У вас есть несохранённые изменения, которые будут потеряны при выходе из системы. Вы хотите продолжить?",
"unsavedChangesTitle": "Несохранённые изменения"
},
"signup": {
"alreadyHaveAccount": "Уже есть аккаунт?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": ıkış Yap",
"success": "Başarıyla çıkış yapıldı",
"successDetail": "Hesabınızdan başarıyla çıkış yaptınız."
"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"
},
"signup": {
"alreadyHaveAccount": "Zaten bir hesabınız var mı?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "登出",
"success": "成功登出",
"successDetail": "您已成功登出您的帳戶。"
"successDetail": "您已成功登出您的帳戶。",
"unsavedChangesMessage": "您有尚未儲存的變更,登出後這些變更將會遺失。您確定要繼續嗎?",
"unsavedChangesTitle": "未儲存的變更"
},
"signup": {
"alreadyHaveAccount": "已經有帳戶?",

View File

@@ -275,7 +275,9 @@
"signOut": {
"signOut": "退出登录",
"success": "成功退出登录",
"successDetail": "您已成功退出账户。"
"successDetail": "您已成功退出账户。",
"unsavedChangesMessage": "您有未保存的更改,注销后这些更改将会丢失。是否继续?",
"unsavedChangesTitle": "未保存的更改"
},
"signup": {
"alreadyHaveAccount": "已经有账户了?",

View File

@@ -141,6 +141,40 @@ 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: [
() => ({

View File

@@ -36,7 +36,7 @@
<!-- Content based on asset type -->
<component
:is="getTopComponent(fileKind)"
:is="getTopComponent(previewKind)"
v-else-if="asset && adaptedAsset"
:asset="adaptedAsset"
:context="{ type: assetType }"
@@ -59,6 +59,7 @@
>
<IconGroup background-class="bg-white">
<Button
v-if="canInspect"
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.zoom')"
@@ -141,7 +142,8 @@ import {
formatDuration,
formatSize,
getFilenameDetails,
getMediaTypeFromFilename
getMediaTypeFromFilename,
isPreviewableMediaType
} from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -152,17 +154,21 @@ 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'))
'3D': defineAsyncComponent(() => import('./Media3DTop.vue')),
text: defineAsyncComponent(() => import('./MediaTextTop.vue')),
other: defineAsyncComponent(() => import('./MediaOtherTop.vue'))
}
}
function getTopComponent(kind: MediaKind) {
return mediaComponents.top[kind] || mediaComponents.top.image
function getTopComponent(kind: PreviewKind) {
return mediaComponents.top[kind] || mediaComponents.top.other
}
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
@@ -206,9 +212,15 @@ const assetType = computed(() => {
// Determine file type from extension
const fileKind = computed((): MediaKind => {
return getMediaTypeFromFilename(asset?.name || '') as MediaKind
return getMediaTypeFromFilename(asset?.name || '')
})
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
@@ -270,7 +282,7 @@ const showActionsOverlay = computed(() => {
})
const handleZoomClick = () => {
if (asset) {
if (asset && canInspect.value) {
emit('zoom', asset)
}
}

View File

@@ -39,6 +39,7 @@ 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'
@@ -206,8 +207,8 @@ const contextMenuItems = computed<MenuItem[]>(() => {
// Individual mode: Show all menu options
// Inspect (if not 3D)
if (fileKind !== '3D') {
// Inspect
if (isPreviewableMediaType(fileKind)) {
items.push({
label: t('mediaAsset.actions.inspect'),
icon: 'icon-[lucide--zoom-in]',

View File

@@ -0,0 +1,9 @@
<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>

View File

@@ -0,0 +1,9 @@
<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>

View File

@@ -3,7 +3,14 @@ import { z } from 'zod'
import { assetItemSchema } from './assetSchema'
const zMediaKindSchema = z.enum(['video', 'audio', 'image', '3D'])
const zMediaKindSchema = z.enum([
'video',
'audio',
'image',
'3D',
'text',
'other'
])
export type MediaKind = z.infer<typeof zMediaKindSchema>
const zDimensionsSchema = z.object({

View File

@@ -0,0 +1,17 @@
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]')
})
})

View File

@@ -8,6 +8,10 @@ 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]'
}

View File

@@ -2,30 +2,14 @@
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-screen p-2 lg:w-96">
<!-- Header -->
<div class="mb-8 flex flex-col gap-4">
<div class="mt-6 mb-8 flex flex-col gap-4">
<h1 class="my-0 text-xl leading-normal font-medium">
{{ t('auth.login.title') }}
</h1>
<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') }}
<p class="my-0 text-base">
<span class="text-muted">{{ t('auth.login.newUser') }}</span>
<span
class="cursor-pointer text-blue-500"
class="ml-1 cursor-pointer text-blue-500"
@click="navigateToSignup"
>{{ t('auth.login.signUp') }}</span
>
@@ -36,49 +20,36 @@
{{ t('auth.login.insecureContextWarning') }}
</Message>
<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>
<!-- Form -->
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
<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>
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<div class="mt-6 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
@click="switchToEmailForm"
>
{{ t('auth.login.useEmailInstead') }}
</Button>
</div>
</template>
<!-- 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>
<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>
<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>
<!-- Terms & Contact -->
<p class="mt-5 text-sm text-gray-600">
@@ -104,6 +75,7 @@
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -112,7 +84,6 @@ 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'
@@ -124,16 +95,6 @@ const authActions = useFirebaseAuthActions()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const toastStore = useToastStore()
const showEmailForm = ref(false)
const { isFreeTierEnabled, freeTierCredits } = useFreeTierOnboarding()
function switchToEmailForm() {
showEmailForm.value = true
}
function switchToSocialLogin() {
showEmailForm.value = false
}
const navigateToSignup = async () => {
await router.push({ name: 'cloud-signup', query: route.query })

View File

@@ -22,77 +22,42 @@
{{ t('auth.login.insecureContextWarning') }}
</Message>
<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>
<!-- Form -->
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
<!-- 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>
<!-- 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.signup.signUpWithGithub') }}
</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.signup.signUpWithGoogle') }}
</Button>
<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>
<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>
<!-- Terms & Contact -->
<p class="mt-5 text-sm text-gray-600">
<div class="mt-5 text-sm text-gray-600">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
@@ -103,29 +68,30 @@
</a>
{{ t('auth.login.andText') }}
<a
href="https://www.comfy.org/privacy-policy"
href="/privacy-policy"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.privacyLink') }} </a
>.
</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>
<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>
</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'
@@ -134,7 +100,6 @@ 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'
@@ -150,14 +115,6 @@ 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 })
@@ -204,7 +161,7 @@ const signUpWithEmail = async (values: SignUpData) => {
onMounted(async () => {
// Track signup screen opened
if (isCloud) {
telemetry?.trackSignupOpened()
useTelemetry()?.trackSignupOpened()
}
userIsInChina.value = await isInChina()

View File

@@ -27,7 +27,6 @@ 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'),
@@ -59,7 +58,6 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
return
}
// Only paid tiers can be checked out via redirect
const validTierKeys: TierKey[] = ['standard', 'creator', 'pro', 'founder']
if (!(validTierKeys as string[]).includes(tierKeyParam)) {
await router.push('/')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,7 +33,7 @@
</div>
<Button
v-if="isActiveSubscription && !isFreeTier"
v-if="isActiveSubscription"
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,25 +130,17 @@
</tbody>
</table>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<a
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline text-muted"
class="text-sm underline text-center text-muted"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
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"
v-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"
@@ -221,10 +213,9 @@ 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()
@@ -233,7 +224,6 @@ const { t, n } = useI18n()
const {
isActiveSubscription,
isCancelled,
isFreeTier,
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
@@ -242,8 +232,7 @@ const {
isYearlySubscription
} = useSubscription()
const { show: showSubscriptionDialog, showPricingTable } =
useSubscriptionDialog()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const tierKey = computed(() => {
const tier = subscriptionTier.value
@@ -275,7 +264,6 @@ 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)
})
@@ -284,19 +272,54 @@ const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
)
const tierBenefits = computed((): TierBenefit[] =>
getCommonTierBenefits(tierKey.value, t, n)
)
// 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 { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
function handleUpgradeToAddCredits() {
showPricingTable()
}
// Focus-based polling: refresh balance when user returns from Stripe checkout
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes

View File

@@ -81,11 +81,7 @@
<div class="flex flex-col gap-6">
<div class="inline-flex items-center gap-2">
<div class="text-sm text-text-primary">
{{
reason === 'out_of_credits'
? $t('credits.topUp.insufficientTitle')
: $t('subscription.required.title')
}}
{{ $t('subscription.required.title') }}
</div>
<CloudBadge
reverse-order
@@ -95,13 +91,6 @@
/>
</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>
@@ -142,11 +131,9 @@ 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 { onClose, reason } = defineProps<{
const props = defineProps<{
onClose: () => void
reason?: SubscriptionDialogReason
}>()
const emit = defineEmits<{
@@ -247,7 +234,7 @@ const handleSubscribed = () => {
const handleClose = () => {
stopPolling()
onClose()
props.onClose()
}
const handleContactUs = async () => {

View File

@@ -8,7 +8,6 @@ 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,
@@ -78,8 +77,6 @@ function useSubscriptionInternal() {
() => subscriptionStatus.value?.subscription_tier ?? null
)
const isFreeTier = computed(() => subscriptionTier.value === 'FREE')
const subscriptionDuration = computed(
() => subscriptionStatus.value?.subscription_duration ?? null
)
@@ -133,17 +130,12 @@ function useSubscriptionInternal() {
window.open(response.checkout_url, '_blank')
}, reportError)
const showSubscriptionDialog = (options?: {
reason?: SubscriptionDialogReason
}) => {
const showSubscriptionDialog = () => {
if (isCloud) {
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: subscriptionTier.value?.toLowerCase(),
reason: options?.reason
})
useTelemetry()?.trackSubscription('modal_opened')
}
void showSubscriptionRequiredDialog(options)
void showSubscriptionRequiredDialog()
}
/**
@@ -286,7 +278,6 @@ function useSubscriptionInternal() {
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
isFreeTier,
subscriptionDuration,
isYearlySubscription,
subscriptionTierName,

View File

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

View File

@@ -1,12 +1,10 @@
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier']
export type TierKey = 'free' | 'standard' | 'creator' | 'pro' | 'founder'
export type TierKey = 'standard' | 'creator' | 'pro' | 'founder'
export const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
FREE: 'free',
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
@@ -14,7 +12,6 @@ export const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
}
export const KEY_TO_TIER: Record<TierKey, SubscriptionTier> = {
free: 'FREE',
standard: 'STANDARD',
creator: 'CREATOR',
pro: 'PRO',
@@ -28,10 +25,7 @@ export interface TierPricing {
videoEstimate: number
}
export const TIER_PRICING: Record<
Exclude<TierKey, 'free' | 'founder'>,
TierPricing
> = {
export const TIER_PRICING: Record<Exclude<TierKey, '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 }
@@ -43,7 +37,6 @@ 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 },
@@ -56,14 +49,12 @@ 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 | null {
if (tierKey === 'free') return remoteConfig.value.free_tier_credits ?? null
export function getTierCredits(tierKey: TierKey): number {
if (tierKey === 'founder') return FOUNDER_MONTHLY_CREDITS
return TIER_PRICING[tierKey].credits
}

View File

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

View File

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

View File

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

View File

@@ -29,8 +29,6 @@ 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
@@ -45,10 +43,4 @@ export type RemoteConfig = {
linear_toggle_enabled?: boolean
team_workspaces_enabled?: boolean
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
free_tier_credits?: number
new_free_tier_subscriptions?: boolean
workflow_sharing_enabled?: boolean
comfyhub_upload_enabled?: boolean
comfyhub_profile_gate_enabled?: boolean
}

View File

@@ -0,0 +1,75 @@
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)
})
})
})

View File

@@ -0,0 +1,19 @@
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
)
}

View File

@@ -6,7 +6,7 @@ import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { useFeatureUsageTracker } from './useFeatureUsageTracker'
interface FeatureSurveyConfig {
export interface FeatureSurveyConfig {
/** Feature identifier. Must remain static after initialization. */
featureId: string
typeformId: string

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,246 +0,0 @@
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