Compare commits

...

18 Commits

Author SHA1 Message Date
Terry Jia
5b3bc77926 call test 2025-11-10 20:25:20 -05:00
BunnyAI
c94cedf8ee add 'SaveVideo' into saveNodeTypes (#6647)
## Summary

add 'SaveVideo' into saveNodeTypes to fix [ComfyUI
10285](https://github.com/comfyanonymous/ComfyUI/issues/10285) and
[ComfyUI 10479](https://github.com/comfyanonymous/ComfyUI/issues/10479)

## Changes

add 'SaveVideo' into saveNodeTypes values

## Review Focus

is this enough?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6647-add-SaveVideo-into-saveNodeTypes-2a76d73d365081d9bb7eccc358c9836d)
by [Unito](https://www.unito.io)
2025-11-10 19:47:33 -05:00
Comfy Org PR Bot
ba355b543d 1.32.4 (#6641)
Patch version increment to 1.32.4

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6641-1-32-4-2a56d73d365081a8abaaeca5d7473390)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-11-08 12:24:08 -07:00
Jin Yi
e9b641cfb7 [bugfix] Fix Storybook _sfc_main undefined error after v1.29.3 (#6635)
## Summary

Fixes Storybook rendering issue where all components fail to load with
`_sfc_main is not defined` error in **local development** since v1.29.3.

## Problem

After upgrading to v1.29.3, all Storybook components fail to render **in
local development** (`pnpm storybook`) with the following error:

```
ReferenceError: _sfc_main is not defined
The component failed to render properly, likely due to a configuration issue in Storybook.
```

**Important**: This issue only affects **local development**
environments. The deployed/built Storybook works correctly.

This affects both:
- Main Storybook (`pnpm storybook`)
- Desktop-ui Storybook instances

## Root Cause

In v1.29.3, commit `64430708e` ("perf: tree shaking and minify #6068")
enabled build optimizations in `vite.config.mts`:

```typescript
// Before (v1.29.2)
rollupOptions: {
  treeshake: false
}
esbuild: {
  minifyIdentifiers: false
}

// After (v1.29.3)
rollupOptions: {
  treeshake: true  // ⚠️ Enabled
}
esbuild: {
  minifyIdentifiers: SHOULD_MINIFY  // ⚠️ Conditionally enabled
}
```

While these optimizations are beneficial for production builds, they
cause issues in **Storybook's local dev server**:

1. **Tree-shaking in dev mode**: Rollup incorrectly identifies Vue SFC's
`_sfc_main` exports as unused code during the dev server's module
transformation
2. **Identifier minification**: esbuild minifies `_sfc_main` to shorter
names in development, breaking Storybook's HMR (Hot Module Replacement)
and dynamic module loading

Since Storybook's `main.ts` inherits settings from `vite.config.mts` via
`mergeConfig`, these optimizations were applied to Storybook's dev
server configuration, causing Vue components to fail rendering in local
development.

**Why deployed Storybook works**: Production builds have different
optimization pipelines that handle Vue SFCs correctly, but the dev
server's real-time transformation breaks with these settings.

## Solution

Added explicit build configuration overrides in both Storybook
configurations to ensure the **dev server** doesn't inherit problematic
optimizations:

**Files changed:**
- `.storybook/main.ts`
- `apps/desktop-ui/.storybook/main.ts`

**Changes:**
```typescript
esbuild: {
  // Prevent minification of identifiers to preserve _sfc_main in dev mode
  minifyIdentifiers: false,
  keepNames: true
},
build: {
  rollupOptions: {
    // Disable tree-shaking for Storybook dev server to prevent Vue SFC exports from being removed
    treeshake: false,
    // ... existing onwarn config
  }
}
```

This ensures Storybook's **local development server** prioritizes
stability and debuggability over bundle size optimization, while
production builds continue to benefit from tree-shaking and
minification.

## Testing

1. Cleared Storybook and Vite caches: `rm -rf .storybook/.cache
node_modules/.vite`
2. Started local Storybook dev server with `pnpm storybook`
3. Verified all component stories render correctly without `_sfc_main`
errors
4. Ran `pnpm typecheck` to ensure TypeScript compilation succeeds
5. Tested HMR (Hot Module Replacement) works correctly with component
changes

## Context

- This is a **local development-only** issue; deployed Storybook builds
work fine
- Storybook dev server requires special handling because it dynamically
imports and hot-reloads all stories at runtime
- Vue SFC compilation generates `_sfc_main` as an internal identifier
that must be preserved during dev transformations
- Development tools like Storybook benefit from unoptimized builds for
better debugging, HMR, and stability
- Production builds remain optimized with tree-shaking and minification
enabled

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6635-bugfix-Fix-Storybook-_sfc_main-undefined-error-after-v1-29-3-2a56d73d36508194a25eef56789e5e2b)
by [Unito](https://www.unito.io)
2025-11-08 10:40:33 -07:00
Christian Byrne
56153596d9 fix: release summary comment action (#6640)
Fixes
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/19188216288/job/54858802407

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6640-fix-release-summary-comment-action-2a56d73d365081f89bf1ec7c4e4818a4)
by [Unito](https://www.unito.io)
2025-11-08 10:39:46 -07:00
Christian Byrne
b679bfe8f8 logging: log context on session storage write error (QuotaError) (#6636)
adds some context to the storage write errors observed in telemetry data
- in order to pinpoint the exact cause.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6636-logging-log-context-on-session-storage-write-error-QuotaError-2a56d73d365081c28bb0dbfdfef8395a)
by [Unito](https://www.unito.io)
2025-11-08 10:19:12 -07:00
Johnpaul Chiwetelu
54979701d0 Cloud feedback Extension (#6626)
This pull request introduces a new extensible system for adding custom
action bar buttons to the top menu, and demonstrates its use by adding a
cloud feedback button. The changes include defining a new
`ActionBarButton` type, updating the extension system to support action
bar buttons, creating a Pinia store to aggregate buttons from
extensions, and updating the UI to render these buttons in the top menu.
The cloud feedback button is now conditionally loaded for cloud
environments.

**Extensible Action Bar Button System**

* Defined a new `ActionBarButton` interface in `comfy.ts` for describing
buttons (icon, label, tooltip, class, click handler) and added an
`actionBarButtons` property to the `ComfyExtension` interface to allow
extensions to contribute buttons.
[[1]](diffhunk://#diff-c29886a1b0c982c6fff3545af0ca
<img width="1134" height="437" alt="Screenshot 2025-11-07 at 01 07 36"
src="https://github.com/user-attachments/assets/36b6145a-0b82-4f7d-88e8-f2ea350a359b"
/>
8ec269876c2cf3948f867d08c14032c04d66R60-R82)
[[2]](diffhunk://#diff-c29886a1b0c982c6fff3545af0ca8ec269876c2cf3948f867d08c14032c04d66R128-R131)
* Created a Pinia store (`actionBarButtonStore.ts`) that collects all
action bar buttons from registered extensions for use in the UI.

**UI Integration**

* Added a new `ActionBarButtons.vue` component that renders all action
bar buttons using the store, and integrated it into the top menu section
(`TopMenuSection.vue`).
[[1]](diffhunk://#diff-d6820f57cde865083d515ac0c23e1ad09e6fbc6792d742ae948a1d3b772be473R1-R28)
[[2]](diffhunk://#diff-45dffe390927ed2d5ba2426a139c52adae28ce15f81821c88e7991944562074cR10)
[[3]](diffhunk://#diff-45dffe390927ed2d5ba2426a139c52adae28ce15f81821c88e7991944562074cR28)

**Cloud Feedback Button Extension**

* Implemented a new extension (`cloudFeedbackTopbarButton.ts`) that
registers a "Feedback" button opening the Zendesk feedback page, and
ensured it loads only in cloud environments.
[[1]](diffhunk://#diff-b84a6a0dfaca19fd77b3fae6999a40c3ab8d04ed22636fcdecc65b385a8e9a98R1-R24)
[[2]](diffhunk://#diff-236993d9e4213efe96d267c75c3292d32b93aa4dd6c3318d26a397e0ae56bc87R32)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6626-Cloud-feedback-Extension-2a46d73d36508170bd07f582ccfabb3c)
by [Unito](https://www.unito.io)
2025-11-08 12:36:04 +01:00
Comfy Org PR Bot
64e704c2f9 1.32.3 (#6634)
Patch version increment to 1.32.3

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6634-1-32-3-2a56d73d365081c7ad4bda4aba8b4076)
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>
2025-11-07 21:50:23 -07:00
Benjamin Lu
b1050e3195 Fix session cookie creation race: skip initial token refresh, wrap extension auth hooks (#6563)
Fixes a race causing “No auth header available for session creation”
during sign‑in, by skipping the initial token refresh event, and
wrapping extension auth hooks with async error handling.

Sentry:
https://comfy-org.sentry.io/issues/6990347926/?alert_rule_id=1614600&project=4509681221369857

Context
- Error surfaced as an unhandled rejection when session creation was
triggered without a valid auth header.
- Triggers: both onAuthUserResolved and onAuthTokenRefreshed fired
during initial login.
- Pre‑fix, onIdTokenChanged treated the very first token emission as a
“refresh” as well, so two concurrent createSession() calls ran
back‑to‑back.
- One of those calls could land before a Firebase ID token existed, so
getAuthHeader() returned null → createSession threw “No auth header
available for session creation”.

Exact pre‑fix failure path
- src/extensions/core/cloudSessionCookie.ts
  - onAuthUserResolved → useSessionCookie().createSession()
  - onAuthTokenRefreshed → useSessionCookie().createSession()
- src/stores/firebaseAuthStore.ts
- onIdTokenChanged increments tokenRefreshTrigger even for the initial
token (treated as a refresh)
- getAuthHeader() → getIdToken() may be undefined briefly during
initialization
- src/platform/auth/session/useSessionCookie.ts
- createSession(): calls authStore.getAuthHeader(); if falsy, throws
Error('No auth header available for session creation')

What this PR changes
1) Skip initial token “refresh”
- Track lastTokenUserId and ignore the first onIdTokenChanged for a
user; only subsequent token changes count as refresh events.
   - File: src/stores/firebaseAuthStore.ts
2) Wrap extension auth hooks with async error handling
- Use wrapWithErrorHandlingAsync for
onAuthUserResolved/onAuthTokenRefreshed/onAuthUserLogout callbacks to
avoid unhandled rejections.
   - File: src/services/extensionService.ts

Result
- Eliminates the timing window where createSession() runs before
getIdToken() returns a token.
- Ensures any remaining errors are caught and reported instead of
surfacing as unhandled promise rejections.

Notes
- Lint and typecheck run clean (pnpm lint:fix && pnpm typecheck).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6563-Fix-session-cookie-creation-race-dedupe-calls-skip-initial-token-refresh-wrap-extensio-2a16d73d365081ef8c22c5ac8cb948aa)
by [Unito](https://www.unito.io)
2025-11-07 21:30:49 -07:00
Benjamin Lu
ba100c4a04 Hide browser tab star when autosave is enabled (#6568)
- Hide "*" indicator in the browser tab title when autosave is enabled
(Comfy.Workflow.AutoSave === 'after delay').
- Refactor: extract readable computed values
(`shouldShowUnsavedIndicator`, `isActiveWorkflowModified`,
`isActiveWorkflowPersisted`).
- Aligns with workflow tab behavior; also hides while Shift is held
(matches in-app tab logic).

Files touched:
- src/composables/useBrowserTabTitle.ts

Validation:
- Ran `pnpm lint:fix` and `pnpm typecheck` — both passed.

Manual test suggestions:
- With autosave set to 'after delay': modify a workflow → browser tab
should not show `*`.
- With autosave 'off': modify or open non-persisted workflow → browser
tab shows `*`.
- Hold Shift: indicator hidden while held (consistent with workflow
tab).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6568-Hide-browser-tab-star-when-autosave-is-enabled-refactor-title-logic-2a16d73d365081549906e9d1fed07a42)
by [Unito](https://www.unito.io)
2025-11-07 21:30:34 -07:00
Christian Byrne
cafd2de961 ci: comment when publish to npm/pypi finishes successfully (#6628)
This change adds a reusable `post-release-summary` composite action that
automatically figures out the current/previous version, generates diff +
PyPI/npm links, and posts (or updates) the release summary comment
whenever the publish jobs succeed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6628-ci-comment-when-publish-to-npm-pypi-finishes-successfully-2a46d73d36508181a8d0eb050efe7762)
by [Unito](https://www.unito.io)
2025-11-07 20:55:51 -07:00
Christian Byrne
1f3fb90b1b increase tracking heartbeat interval from 30sec to 5min (#6631)
This event is taking up too much of the quota, increase the interval for
now.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6631-increase-tracking-heartbeat-interval-from-30sec-to-5min-2a46d73d3650814291c4fae50227d4ec)
by [Unito](https://www.unito.io)
2025-11-07 00:05:38 -07:00
Christian Byrne
27afd01297 add language selector to desktop onboarding views (#6591)
Allows changing language during desktop onboarding

<img width="3816" height="2045" alt="Selection_2231"
src="https://github.com/user-attachments/assets/b8a0dda3-70e7-42a9-96f1-10d00e2fd85c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6591-add-language-selector-to-desktop-onboarding-views-2a26d73d365081f58d00dca2b2759d82)
by [Unito](https://www.unito.io)
2025-11-06 22:17:21 -07:00
Christian Byrne
535f857330 refactor: move renderer-dependent utils into workbench scope (#6621)
This PR cleans up the base-layer utilities so they no longer pull
renderer or workbench code. The renderer-only `isPrimitiveNode` guard
now lives in `src/renderer/utils/nodeTypeGuards.ts`, and the node
help/model/ordering helpers have moved into `src/workbench/utils`. All
affected services, stores, scripts, and tests were updated to import
from the new locations.

The idea is to reduce the number of Base→Renderer/Base→Workbench edges
(higher scoped base/common utils should not import from
renderer/workbench layers).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6621-refactor-move-renderer-dependent-utils-into-workbench-scope-2a36d73d36508167aff0fc8a22202d7f)
by [Unito](https://www.unito.io)
2025-11-06 19:32:41 -07:00
Marwan Ahmed
adb15aac40 feat: pre-fill user info in Zendesk support link (#6586)
Add user email and ID as URL parameters when opening the Contact Support
link to improve support experience. Only includes user data when logged
in.

## Summary

Enhanced the Contact Support command to automatically pre-fill user
email and ID in Zendesk support tickets, streamlining the support
request process for authenticated users.

## Changes

- **What**: 
- Added `useCurrentUser` composable to access authenticated user data in
`useCoreCommands.ts`
- Modified `Comfy.ContactSupport` command to append user email
(`tf_anonymous_requester_email` and `tf_40029135130388`) and user ID
(`tf_42515251051412`) as URL parameters when available
- Maintained backward compatibility by only adding user parameters when
user is logged in
- Preserved existing `tf_42243568391700` parameter for distribution type
(oss/ccloud)

## Review Focus

- Verify that the URL parameters are correctly appended only when user
is authenticated
- Confirm that non-authenticated users still get the base support URL
with just the distribution type parameter
- Check that both Firebase auth and API key auth users have their
information properly included

Example URLs generated when you press on help locally (it will change
automatically to ccloud on Cloud):
- **Logged out**:
`https://support.comfy.org/hc/en-us/requests/new?tf_42243568391700=oss`
- **Logged in**:
`https://support.comfy.org/hc/en-us/requests/new?tf_42243568391700=ccloud&tf_anonymous_requester_email=user@example.com&tf_40029135130388=user@example.com&tf_42515251051412=abc123xyz`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6586-feat-pre-fill-user-info-in-Zendesk-support-link-2a26d73d36508171b428c634b310f68b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: bymyself <cbyrne@comfy.org>
2025-11-06 19:17:01 -07:00
Alexander Brown
8752f1b06d fix: re-add translations dropped in 6564 (#6613)
## Summary

Re-adding some strings that got dropped in the merge.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6613-fix-re-add-translations-dropped-in-6564-2a36d73d3650818ca617cb5bddd11bc7)
by [Unito](https://www.unito.io)
2025-11-06 01:02:09 -08:00
Christian Byrne
90c2c0fae0 style: update Vue node designs to use semantic tokens (#6304)
## Summary

Use semantic tokens instead of colors

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6304-style-update-Vue-node-designs-to-use-semantic-tokens-2986d73d365081f69acce7793a218699)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-06 01:01:39 -08:00
Jin Yi
34155bccb1 fix: Handle vite:preloadError for graceful deployment asset updates (#6609)
## Summary
- Implement graceful handling of Vite preload errors that occur when
assets are deleted after new deployments
- Auto-reload when safe (no unsaved changes), show confirmation dialog
when user has unsaved work
- Add i18n support for user-friendly error messages

## Implementation Details
- Add `vite:preloadError` event listener in App.vue 
- Smart reload logic: check `app.vueAppReady` and
`workflowStore.activeWorkflow?.isModified`
- User confirmation dialog using existing `dialogService.confirm`
- Comprehensive i18n keys for title and message

## Background
This addresses the issue described in [Vite
documentation](https://vite.dev/guide/build.html#load-error-handling)
where users encounter import errors when hosting services delete old
assets after new deployments.

[screen-capture
(1).webm](https://github.com/user-attachments/assets/beed3b8e-6f32-4288-a560-55da391a79a1)

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6609-fix-Handle-vite-preloadError-for-graceful-deployment-asset-updates-2a36d73d365081a0b3adeac9fcd1e1dc)
by [Unito](https://www.unito.io)
2025-11-06 00:53:56 -08:00
48 changed files with 998 additions and 148 deletions

View File

@@ -0,0 +1,116 @@
name: Post Release Summary Comment
description: Post or update a PR comment summarizing release links with diff, derived versions, and optional extras.
author: ComfyUI Frontend Team
inputs:
issue-number:
description: Optional PR number override (defaults to the current pull request)
default: ''
version_file:
description: Path to the JSON file containing the current version (relative to repo root)
required: true
outputs:
prev_version:
description: Previous version derived from the parent commit
value: ${{ steps.build.outputs.prev_version }}
runs:
using: composite
steps:
- name: Build comment body
id: build
shell: bash
run: |
set -euo pipefail
VERSION_FILE="${{ inputs.version_file }}"
REPO="${{ github.repository }}"
if [[ -z "$VERSION_FILE" ]]; then
echo '::error::version_file input is required' >&2
exit 1
fi
PREV_JSON=$(git show HEAD^1:"$VERSION_FILE" 2>/dev/null || true)
if [[ -z "$PREV_JSON" ]]; then
echo "::error::Unable to read $VERSION_FILE from parent commit" >&2
exit 1
fi
PREV_VERSION=$(printf '%s' "$PREV_JSON" | node -pe "const data = JSON.parse(require('fs').readFileSync(0, 'utf8')); if (!data.version) { process.exit(1); } data.version")
if [[ -z "$PREV_VERSION" ]]; then
echo "::error::Unable to determine previous version from $VERSION_FILE" >&2
exit 1
fi
NEW_VERSION=$(node -pe "const fs=require('fs');const data=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));if(!data.version){process.exit(1);}data.version" "$VERSION_FILE")
if [[ -z "$NEW_VERSION" ]]; then
echo "::error::Unable to determine current version from $VERSION_FILE" >&2
exit 1
fi
MARKER='release-summary'
MESSAGE='Publish jobs finished successfully:'
LINKS_VALUE=''
case "$VERSION_FILE" in
package.json)
LINKS_VALUE=$'PyPI|https://pypi.org/project/comfyui-frontend-package/{{version}}/\n''npm types|https://npm.im/@comfyorg/comfyui-frontend-types@{{version}}'
;;
apps/desktop-ui/package.json)
MARKER='desktop-release-summary'
LINKS_VALUE='npm desktop UI|https://npm.im/@comfyorg/desktop-ui@{{version}}'
;;
esac
DIFF_PREFIX='v'
DIFF_LABEL='Diff'
DIFF_URL="https://github.com/${REPO}/compare/${DIFF_PREFIX}${PREV_VERSION}...${DIFF_PREFIX}${NEW_VERSION}"
COMMENT_FILE=$(mktemp)
{
printf '<!--%s:%s%s-->\n' "$MARKER" "$DIFF_PREFIX" "$NEW_VERSION"
printf '%s\n\n' "$MESSAGE"
printf -- '- %s: [%s%s...%s%s](%s)\n' "$DIFF_LABEL" "$DIFF_PREFIX" "$PREV_VERSION" "$DIFF_PREFIX" "$NEW_VERSION" "$DIFF_URL"
while IFS= read -r RAW_LINE; do
LINE=$(printf '%s' "$RAW_LINE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[[ -z "$LINE" ]] && continue
if [[ "$LINE" != *"|"* ]]; then
echo "::warning::Skipping malformed link entry: $LINE" >&2
continue
fi
LABEL=${LINE%%|*}
URL_TEMPLATE=${LINE#*|}
URL=${URL_TEMPLATE//\{\{version\}\}/$NEW_VERSION}
URL=${URL//\{\{prev_version\}\}/$PREV_VERSION}
printf -- '- %s: %s\n' "$LABEL" "$URL"
done <<< "$LINKS_VALUE"
printf '\n'
} > "$COMMENT_FILE"
{
echo "body<<'EOF'"
cat "$COMMENT_FILE"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
echo "prev_version=$PREV_VERSION" >> "$GITHUB_OUTPUT"
echo "marker_search=<!--$MARKER:" >> "$GITHUB_OUTPUT"
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
- name: Find existing comment
id: find
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
with:
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
comment-author: github-actions[bot]
body-includes: ${{ steps.build.outputs.marker_search }}
- name: Post or update comment
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
with:
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
comment-id: ${{ steps.find.outputs.comment-id }}
body: ${{ steps.build.outputs.body }}
edit-mode: replace

View File

@@ -1,9 +1,10 @@
---
name: Publish Desktop UI on PR Merge
on:
pull_request:
types: [ closed ]
branches: [ main, core/* ]
types: ['closed']
branches: [main, core/*]
paths:
- 'apps/desktop-ui/package.json'
@@ -57,3 +58,26 @@ jobs:
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
comment_desktop_publish:
name: Comment Desktop Publish Summary
needs:
- resolve
- publish
if: success()
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout merge commit
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 2
- name: Post desktop release summary comment
uses: ./.github/actions/comment-release-links
with:
issue-number: ${{ github.event.pull_request.number }}
version_file: apps/desktop-ui/package.json

View File

@@ -1,9 +1,10 @@
---
name: Release Draft Create
on:
pull_request:
types: [ closed ]
branches: [ main, core/* ]
types: ['closed']
branches: [main, core/*]
paths:
- 'package.json'
@@ -30,7 +31,9 @@ jobs:
- name: Get current version
id: current_version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Check if prerelease
id: check_prerelease
run: |
@@ -71,7 +74,8 @@ jobs:
name: dist-files
- name: Create release
id: create_release
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
uses: >-
softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -79,9 +83,14 @@ jobs:
dist.zip
tag_name: v${{ needs.build.outputs.version }}
target_commitish: ${{ github.event.pull_request.base.ref }}
make_latest: ${{ github.event.pull_request.base.ref == 'main' && needs.build.outputs.is_prerelease == 'false' }}
draft: ${{ github.event.pull_request.base.ref != 'main' || needs.build.outputs.is_prerelease == 'true' }}
prerelease: ${{ needs.build.outputs.is_prerelease == 'true' }}
make_latest: >-
${{ github.event.pull_request.base.ref == 'main' &&
needs.build.outputs.is_prerelease == 'false' }}
draft: >-
${{ github.event.pull_request.base.ref != 'main' ||
needs.build.outputs.is_prerelease == 'true' }}
prerelease: >-
${{ needs.build.outputs.is_prerelease == 'true' }}
generate_release_notes: true
publish_pypi:
@@ -110,7 +119,8 @@ jobs:
env:
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
- name: Publish pypi package
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
uses: >-
pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist
@@ -122,3 +132,28 @@ jobs:
version: ${{ needs.build.outputs.version }}
ref: ${{ github.event.pull_request.merge_commit_sha }}
secrets: inherit
comment_release_summary:
name: Comment Release Summary
needs:
- draft_release
- publish_pypi
- publish_types
if: success()
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout merge commit
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 2
- name: Post release summary comment
uses: ./.github/actions/comment-release-links
with:
issue-number: ${{ github.event.pull_request.number }}
version_file: package.json

View File

@@ -74,8 +74,15 @@ const config: StorybookConfig = {
'@': process.cwd() + '/src'
}
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main
minifyIdentifiers: false,
keepNames: true
},
build: {
rollupOptions: {
// Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
treeshake: false,
onwarn: (warning, warn) => {
// Suppress specific warnings
if (

View File

@@ -75,8 +75,15 @@ const config: StorybookConfig = {
'@frontend-locales': process.cwd() + '/../../src/locales'
}
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main
minifyIdentifiers: false,
keepNames: true
},
build: {
rollupOptions: {
// Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
treeshake: false,
onwarn: (warning, warn) => {
// Suppress specific warnings
if (

View File

@@ -0,0 +1,206 @@
<template>
<Select
:id="dropdownId"
v-model="selectedLocale"
:options="localeOptions"
option-label="label"
option-value="value"
:disabled="isSwitching"
:pt="dropdownPt"
:size="props.size"
class="language-selector"
@change="onLocaleChange"
>
<template #value="{ value }">
<span :class="valueClass">
<i class="pi pi-language" :class="iconClass" />
<span>{{ displayLabel(value as SupportedLocale) }}</span>
</span>
</template>
<template #option="{ option }">
<span :class="optionClass">
<i class="pi pi-language" :class="iconClass" />
<span class="leading-none">{{ option.label }}</span>
</span>
</template>
</Select>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import type { SelectChangeEvent } from 'primevue/select'
import { computed, ref, watch } from 'vue'
import { i18n, loadLocale, st } from '@/i18n'
type VariantKey = 'dark' | 'light'
type SizeKey = 'small' | 'large'
const props = withDefaults(
defineProps<{
variant?: VariantKey
size?: SizeKey
}>(),
{
variant: 'dark',
size: 'small'
}
)
const dropdownId = `language-select-${Math.random().toString(36).slice(2)}`
const LOCALES = [
['en', 'English'],
['zh', '中文'],
['zh-TW', '繁體中文'],
['ru', 'Русский'],
['ja', '日本語'],
['ko', '한국어'],
['fr', 'Français'],
['es', 'Español'],
['ar', 'عربي'],
['tr', 'Türkçe']
] as const satisfies ReadonlyArray<[string, string]>
type SupportedLocale = (typeof LOCALES)[number][0]
const SIZE_PRESETS = {
large: {
wrapper: 'px-3 py-1 min-w-[7rem]',
gap: 'gap-2',
valueText: 'text-xs',
optionText: 'text-sm',
icon: 'text-sm'
},
small: {
wrapper: 'px-2 py-0.5 min-w-[5rem]',
gap: 'gap-1',
valueText: 'text-[0.65rem]',
optionText: 'text-xs',
icon: 'text-xs'
}
} as const satisfies Record<SizeKey, Record<string, string>>
const VARIANT_PRESETS = {
light: {
root: 'bg-white/80 border border-neutral-200 text-neutral-700 rounded-full shadow-sm backdrop-blur hover:border-neutral-400 transition-colors focus-visible:ring-offset-2 focus-visible:ring-offset-white',
trigger: 'text-neutral-500 hover:text-neutral-700',
item: 'text-neutral-700 bg-transparent hover:bg-neutral-100 focus-visible:outline-none',
valueText: 'text-neutral-600',
optionText: 'text-neutral-600',
icon: 'text-neutral-500'
},
dark: {
root: 'bg-neutral-900/70 border border-neutral-700 text-neutral-200 rounded-full shadow-sm backdrop-blur hover:border-neutral-500 transition-colors focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900',
trigger: 'text-neutral-400 hover:text-neutral-200',
item: 'text-neutral-200 bg-transparent hover:bg-neutral-800/80 focus-visible:outline-none',
valueText: 'text-neutral-100',
optionText: 'text-neutral-100',
icon: 'text-neutral-300'
}
} as const satisfies Record<VariantKey, Record<string, string>>
const selectedLocale = ref<string>(i18n.global.locale.value)
const isSwitching = ref(false)
const sizePreset = computed(() => SIZE_PRESETS[props.size as SizeKey])
const variantPreset = computed(
() => VARIANT_PRESETS[props.variant as VariantKey]
)
const dropdownPt = computed(() => ({
root: {
class: `${variantPreset.value.root} ${sizePreset.value.wrapper}`
},
trigger: {
class: variantPreset.value.trigger
},
item: {
class: `${variantPreset.value.item} ${sizePreset.value.optionText}`
}
}))
const valueClass = computed(() =>
[
'flex items-center font-medium uppercase tracking-wide leading-tight',
sizePreset.value.gap,
sizePreset.value.valueText,
variantPreset.value.valueText
].join(' ')
)
const optionClass = computed(() =>
[
'flex items-center leading-tight',
sizePreset.value.gap,
variantPreset.value.optionText,
sizePreset.value.optionText
].join(' ')
)
const iconClass = computed(() =>
[sizePreset.value.icon, variantPreset.value.icon].join(' ')
)
const localeOptions = computed(() =>
LOCALES.map(([value, fallback]) => ({
value,
label: st(`settings.Comfy_Locale.options.${value}`, fallback)
}))
)
const labelLookup = computed(() =>
localeOptions.value.reduce<Record<string, string>>((acc, option) => {
acc[option.value] = option.label
return acc
}, {})
)
function displayLabel(locale?: SupportedLocale) {
if (!locale) {
return st('settings.Comfy_Locale.name', 'Language')
}
return labelLookup.value[locale] ?? locale
}
watch(
() => i18n.global.locale.value,
(newLocale) => {
if (newLocale !== selectedLocale.value) {
selectedLocale.value = newLocale
}
}
)
async function onLocaleChange(event: SelectChangeEvent) {
const nextLocale = event.value as SupportedLocale | undefined
if (!nextLocale || nextLocale === i18n.global.locale.value) {
return
}
isSwitching.value = true
try {
await loadLocale(nextLocale)
i18n.global.locale.value = nextLocale
} catch (error) {
console.error(`Failed to change locale to "${nextLocale}"`, error)
selectedLocale.value = i18n.global.locale.value
} finally {
isSwitching.value = false
}
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.p-dropdown-panel .p-dropdown-item) {
@apply transition-colors;
}
:deep(.p-dropdown) {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-yellow/60 focus-visible:ring-offset-2;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<BaseViewTemplate dark>
<BaseViewTemplate dark hide-language-selector>
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
<div
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"

View File

@@ -1,7 +1,7 @@
<template>
<BaseViewTemplate dark>
<div class="flex items-center justify-center min-h-screen">
<div class="grid grid-rows-2 gap-8">
<div class="grid gap-8">
<!-- Top container: Logo -->
<div class="flex items-end justify-center">
<img

View File

@@ -1,12 +1,15 @@
<template>
<div
class="font-sans w-screen h-screen flex flex-col"
class="font-sans w-screen h-screen flex flex-col relative"
:class="[
dark
? 'text-neutral-300 bg-neutral-900 dark-theme'
: 'text-neutral-900 bg-neutral-300'
]"
>
<div v-if="showLanguageSelector" class="absolute top-6 right-6 z-10">
<LanguageSelector :variant="variant" />
</div>
<!-- Virtual top menu for native window (drag handle) -->
<div
v-show="isNativeWindow()"
@@ -20,14 +23,20 @@
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import LanguageSelector from '@/components/common/LanguageSelector.vue'
import { electronAPI, isElectron, isNativeWindow } from '../../utils/envUtil'
const { dark = false } = defineProps<{
const { dark = false, hideLanguageSelector = false } = defineProps<{
dark?: boolean
hideLanguageSelector?: boolean
}>()
const variant = computed(() => (dark ? 'dark' : 'light'))
const showLanguageSelector = computed(() => !hideLanguageSelector)
const darkTheme = {
color: 'rgba(0, 0, 0, 0)',
symbolColor: '#d4d4d4'

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.32.2",
"version": "1.32.4",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -16,6 +16,10 @@ import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { t } from '@/i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
@@ -23,6 +27,8 @@ import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
@@ -48,6 +54,26 @@ onMounted(() => {
document.addEventListener('contextmenu', showContextMenu)
}
// Handle Vite preload errors (e.g., when assets are deleted after deployment)
window.addEventListener('vite:preloadError', async (_event) => {
// Auto-reload if app is not ready or there are no unsaved changes
if (!app.vueAppReady || !workflowStore.activeWorkflow?.isModified) {
window.location.reload()
} else {
// Show confirmation dialog if there are unsaved changes
await dialogService
.confirm({
title: t('g.vitePreloadErrorTitle'),
message: t('g.vitePreloadErrorMessage')
})
.then((confirmed) => {
if (confirmed) {
window.location.reload()
}
})
}
})
// Initialize conflict detection in background
// This runs async and doesn't block UI setup
void conflictDetection.initializeConflictDetection()

View File

@@ -7,6 +7,7 @@
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
@@ -24,6 +25,7 @@ import { onMounted, ref } from 'vue'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'

View File

@@ -103,7 +103,7 @@ if (isComponentWidget(props.widget)) {
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
const {
// configs
// config
sceneConfig,
modelConfig,
cameraConfig,

View File

@@ -0,0 +1,29 @@
<template>
<div class="flex h-full shrink-0 items-center gap-1">
<Button
v-for="(button, index) in actionBarButtonStore.buttons"
:key="index"
v-tooltip.bottom="button.tooltip"
:label="button.label"
:aria-label="button.tooltip || button.label"
:class="button.class"
text
rounded
severity="secondary"
class="h-7"
@click="button.onClick"
>
<template #icon>
<i :class="button.icon" />
</template>
</Button>
</div>
</template>
<script lang="ts" setup>
import Button from 'primevue/button'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
const actionBarButtonStore = useActionBarButtonStore()
</script>

View File

@@ -5,6 +5,7 @@ import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const DEFAULT_TITLE = 'ComfyUI'
const TITLE_SUFFIX = ' - ComfyUI'
@@ -13,6 +14,7 @@ export const useBrowserTabTitle = () => {
const executionStore = useExecutionStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const workspaceStore = useWorkspaceStore()
const executionText = computed(() =>
executionStore.isIdle
@@ -24,11 +26,27 @@ export const useBrowserTabTitle = () => {
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const isAutoSaveEnabled = computed(
() => settingStore.get('Comfy.Workflow.AutoSave') === 'after delay'
)
const isActiveWorkflowModified = computed(
() => !!workflowStore.activeWorkflow?.isModified
)
const isActiveWorkflowPersisted = computed(
() => !!workflowStore.activeWorkflow?.isPersisted
)
const shouldShowUnsavedIndicator = computed(() => {
if (workspaceStore.shiftDown) return false
if (isAutoSaveEnabled.value) return false
if (!isActiveWorkflowPersisted.value) return true
if (isActiveWorkflowModified.value) return true
return false
})
const isUnsavedText = computed(() =>
workflowStore.activeWorkflow?.isModified ||
!workflowStore.activeWorkflow?.isPersisted
? ' *'
: ''
shouldShowUnsavedIndicator.value ? ' *' : ''
)
const workflowNameText = computed(() => {
const workflowName = workflowStore.activeWorkflow?.filename

View File

@@ -1,3 +1,4 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
@@ -20,7 +21,7 @@ import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBro
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSettingStore } from '@/platform/settings/settingStore'
import { SUPPORT_URL } from '@/platform/support/config'
import { buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
import type { ExecutionTriggerSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -840,7 +841,12 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Contact Support',
versionAdded: '1.17.8',
function: () => {
window.open(SUPPORT_URL, '_blank')
const { userEmail, resolvedUserInfo } = useCurrentUser()
const supportUrl = buildSupportUrl({
userEmail: userEmail.value,
userId: resolvedUserInfo.value?.id
})
window.open(supportUrl, '_blank')
}
},
{

View File

@@ -0,0 +1,23 @@
import { t } from '@/i18n'
import { useExtensionService } from '@/services/extensionService'
import type { ActionBarButton } from '@/types/comfy'
// Zendesk feedback URL - update this with the actual URL
const ZENDESK_FEEDBACK_URL =
'https://support.comfy.org/hc/en-us/requests/new?ticket_form_id=43066738713236'
const buttons: ActionBarButton[] = [
{
icon: 'icon-[lucide--message-circle-question-mark]',
label: t('actionbar.feedback'),
tooltip: t('actionbar.feedbackTooltip'),
onClick: () => {
window.open(ZENDESK_FEEDBACK_URL, '_blank', 'noopener,noreferrer')
}
}
]
useExtensionService().registerExtension({
name: 'Comfy.Cloud.FeedbackButton',
actionBarButtons: buttons
})

View File

@@ -29,6 +29,7 @@ if (isCloud) {
await import('./cloudRemoteConfig')
await import('./cloudBadges')
await import('./cloudSessionCookie')
await import('./cloudFeedbackTopbarButton')
if (window.__CONFIG__?.subscription_required) {
await import('./cloudSubscription')

View File

@@ -4,6 +4,7 @@ import { app } from '../../scripts/app'
const saveNodeTypes = new Set([
'SaveImage',
'SaveVideo',
'SaveAnimatedWEBP',
'SaveWEBM',
'SaveAudio',

View File

@@ -18,7 +18,7 @@ import { ComfyWidgets, addValueControlWidgets } from '@/scripts/widgets'
import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
import { mergeInputSpec } from '@/utils/nodeDefUtil'
import { applyTextReplacements } from '@/utils/searchAndReplace'
import { isPrimitiveNode } from '@/utils/typeGuardUtil'
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
const replacePropertyName = 'Run widget replace on values'
export class PrimitiveNode extends LGraphNode {

View File

@@ -40,6 +40,8 @@
"comfy": "Comfy",
"refresh": "Refresh",
"refreshNode": "Refresh Node",
"vitePreloadErrorTitle": "New Version Available",
"vitePreloadErrorMessage": "A new version of the app has been released. Would you like to reload?\nIf not, some parts of the app might not work as expected.\nFeel free to decline and save your progress before reloading.",
"terminal": "Terminal",
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
@@ -611,8 +613,17 @@
"nodes": "Nodes",
"models": "Models",
"workflows": "Workflows",
"templates": "Templates"
"templates": "Templates",
"console": "Console",
"menu": "Menu",
"assets": "Assets",
"imported": "Imported",
"generated": "Generated"
},
"noFilesFound": "No files found",
"noImportedFiles": "No imported files found",
"noGeneratedFiles": "No generated files found",
"noFilesFoundMessage": "Upload files or generate content to see them here",
"browseTemplates": "Browse example templates",
"openWorkflow": "Open workflow in local file system",
"newBlankWorkflow": "Create a new blank workflow",
@@ -1772,7 +1783,10 @@
"exportSettings": "Export Settings",
"modelSettings": "Model Settings"
},
"openIn3DViewer": "Open in 3D Viewer"
"openIn3DViewer": "Open in 3D Viewer",
"dropToLoad": "Drop 3D model to load",
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl)",
"uploadingModel": "Uploading 3D model..."
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",
@@ -2268,7 +2282,9 @@
}
},
"actionbar": {
"dockToTop": "Dock to top"
"dockToTop": "Dock to top",
"feedback": "Feedback",
"feedbackTooltip": "Feedback"
},
"desktopDialogs": {
"": {

View File

@@ -1,17 +1,43 @@
import { isCloud } from '@/platform/distribution/types'
/**
* Zendesk ticket form field ID for the distribution tag.
* This field is used to categorize support requests by their source (cloud vs OSS).
* Zendesk ticket form field IDs.
*/
const DISTRIBUTION_FIELD_ID = 'tf_42243568391700'
const ZENDESK_FIELDS = {
/** Distribution tag (cloud vs OSS) */
DISTRIBUTION: 'tf_42243568391700',
/** User email (anonymous requester) */
ANONYMOUS_EMAIL: 'tf_anonymous_requester_email',
/** User email (authenticated) */
EMAIL: 'tf_40029135130388',
/** User ID */
USER_ID: 'tf_42515251051412'
} as const
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
/**
* Support URLs for the ComfyUI platform.
* The URL varies based on whether the application is running in Cloud or OSS distribution.
* Builds the support URL with optional user information for pre-filling.
* Users without login information will still get a valid support URL without pre-fill.
*
* - Cloud: Includes 'ccloud' tag for identifying cloud-based support requests
* - OSS: Includes 'oss' tag for identifying open-source support requests
* @param params - User information to pre-fill in the support form
* @returns Complete Zendesk support URL with query parameters
*/
const TAG = isCloud ? 'ccloud' : 'oss'
export const SUPPORT_URL = `https://support.comfy.org/hc/en-us/requests/new?${DISTRIBUTION_FIELD_ID}=${TAG}`
export function buildSupportUrl(params?: {
userEmail?: string | null
userId?: string | null
}): string {
const searchParams = new URLSearchParams({
[ZENDESK_FIELDS.DISTRIBUTION]: isCloud ? 'ccloud' : 'oss'
})
if (params?.userEmail) {
searchParams.append(ZENDESK_FIELDS.ANONYMOUS_EMAIL, params.userEmail)
searchParams.append(ZENDESK_FIELDS.EMAIL, params.userEmail)
}
if (params?.userId) {
searchParams.append(ZENDESK_FIELDS.USER_ID, params.userId)
}
return `${SUPPORT_BASE_URL}?${searchParams.toString()}`
}

View File

@@ -20,9 +20,28 @@ export function useWorkflowPersistence() {
const persistCurrentWorkflow = () => {
if (!workflowPersistenceEnabled.value) return
const workflow = JSON.stringify(comfyApp.graph.serialize())
localStorage.setItem('workflow', workflow)
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
try {
localStorage.setItem('workflow', workflow)
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
}
} catch (error) {
// Only log our own keys and aggregate stats
const ourKeys = Object.keys(sessionStorage).filter(
(key) => key.startsWith('workflow:') || key === 'workflow'
)
console.error('QuotaExceededError details:', {
workflowSizeKB: Math.round(workflow.length / 1024),
totalStorageItems: Object.keys(sessionStorage).length,
ourWorkflowKeys: ourKeys.length,
ourWorkflowSizes: ourKeys.map((key) => ({
key,
sizeKB: Math.round(sessionStorage[key].length / 1024)
})),
error: error instanceof Error ? error.message : String(error)
})
throw error
}
}

View File

@@ -107,10 +107,14 @@ describe('WidgetSelectButton Button Selection', () => {
const selectedButton = buttons[1] // 'banana'
const unselectedButton = buttons[0] // 'apple'
expect(selectedButton.classes()).toContain('bg-white')
expect(selectedButton.classes()).toContain('text-neutral-900')
expect(unselectedButton.classes()).not.toContain('bg-white')
expect(unselectedButton.classes()).not.toContain('text-neutral-900')
expect(selectedButton.classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(selectedButton.classes()).toContain('text-primary')
expect(unselectedButton.classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(unselectedButton.classes()).toContain('text-secondary')
})
it('handles no selection gracefully', () => {
@@ -120,8 +124,10 @@ describe('WidgetSelectButton Button Selection', () => {
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain('bg-white')
expect(button.classes()).not.toContain('text-neutral-900')
expect(button.classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(button.classes()).toContain('text-secondary')
})
})
@@ -134,13 +140,19 @@ describe('WidgetSelectButton Button Selection', () => {
// Initially 'first' is selected
let buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
// Update to 'second'
await wrapper.setProps({ modelValue: 'second' })
buttons = wrapper.findAll('button')
expect(buttons[0].classes()).not.toContain('bg-white')
expect(buttons[1].classes()).toContain('bg-white')
expect(buttons[0].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
})
})
@@ -222,7 +234,9 @@ describe('WidgetSelectButton Button Selection', () => {
expect(buttons[2].text()).toBe('3')
// The selected button should be the one with '2'
expect(buttons[1].classes()).toContain('bg-white')
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
})
it('handles object options with label and value', () => {
@@ -240,7 +254,9 @@ describe('WidgetSelectButton Button Selection', () => {
expect(buttons[2].text()).toBe('Third Option')
// 'second' should be selected
expect(buttons[1].classes()).toContain('bg-white')
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
})
it('emits correct values for object options', async () => {
@@ -277,7 +293,9 @@ describe('WidgetSelectButton Button Selection', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
expect(buttons[0].classes()).toContain('bg-white') // Empty string is selected
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Empty string is selected
})
it('handles null/undefined in options', () => {
@@ -292,7 +310,9 @@ describe('WidgetSelectButton Button Selection', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
})
it('handles very long option text', () => {
@@ -313,7 +333,9 @@ describe('WidgetSelectButton Button Selection', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(20)
expect(buttons[4].classes()).toContain('bg-white') // option5 is at index 4
expect(buttons[4].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // option5 is at index 4
})
it('handles duplicate options', () => {
@@ -324,8 +346,12 @@ describe('WidgetSelectButton Button Selection', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
// Both 'duplicate' buttons should be highlighted (due to value matching)
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[2].classes()).toContain('bg-white')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[2].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
})
})
@@ -354,7 +380,9 @@ describe('WidgetSelectButton Button Selection', () => {
const buttons = wrapper.findAll('button')
const unselectedButton = buttons[1] // 'option2'
expect(unselectedButton.classes()).toContain('hover:bg-zinc-200/50')
expect(unselectedButton.classes()).toContain(
'hover:bg-interface-menu-component-surface-hovered'
)
expect(unselectedButton.classes()).toContain('cursor-pointer')
})
})

View File

@@ -24,17 +24,14 @@
role="button"
:tabindex="0"
aria-label="Play/Pause"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
@click="togglePlayPause"
>
<i
v-if="!isPlaying"
class="icon-[lucide--play] size-4 text-smoke-600 dark-theme:text-smoke-800"
/>
<i
v-else
class="icon-[lucide--pause] size-4 text-smoke-600 dark-theme:text-smoke-800"
class="text-secondary icon-[lucide--play] size-4"
/>
<i v-else class="text-secondary icon-[lucide--pause] size-4" />
</div>
<!-- Time Display -->
@@ -44,11 +41,9 @@
</div>
<!-- Progress Bar -->
<div
class="relative h-0.5 flex-1 rounded-full bg-smoke-300 dark-theme:bg-ash-800"
>
<div class="relative h-0.5 flex-1 rounded-full bg-interface-stroke">
<div
class="absolute top-0 left-0 h-full rounded-full bg-smoke-600 transition-all dark-theme:bg-white/50"
class="absolute top-0 left-0 h-full rounded-full bg-button-icon transition-all"
:style="{ width: `${progressPercentage}%` }"
/>
<input
@@ -70,21 +65,18 @@
role="button"
:tabindex="0"
aria-label="Volume"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
@click="toggleMute"
>
<i
v-if="showVolumeTwo"
class="icon-[lucide--volume-2] size-4 text-smoke-600 dark-theme:text-smoke-800"
class="text-secondary icon-[lucide--volume-2] size-4"
/>
<i
v-else-if="showVolumeOne"
class="icon-[lucide--volume-1] size-4 text-smoke-600 dark-theme:text-smoke-800"
/>
<i
v-else
class="icon-[lucide--volume-x] size-4 text-smoke-600 dark-theme:text-smoke-800"
class="text-secondary icon-[lucide--volume-1] size-4"
/>
<i v-else class="text-secondary icon-[lucide--volume-x] size-4" />
</div>
<!-- Options Button -->
@@ -94,12 +86,10 @@
role="button"
:tabindex="0"
aria-label="More Options"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
@click="toggleOptionsMenu"
>
<i
class="icon-[lucide--more-vertical] size-4 text-smoke-600 dark-theme:text-smoke-800"
/>
<i class="text-secondary icon-[lucide--more-vertical] size-4" />
</div>
</div>

View File

@@ -124,10 +124,16 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('option2', options)
const buttons = wrapper.findAll('button')
expect(buttons[1].classes()).toContain('bg-white')
expect(buttons[1].classes()).toContain('text-neutral-900')
expect(buttons[0].classes()).not.toContain('bg-white')
expect(buttons[2].classes()).not.toContain('bg-white')
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[1].classes()).toContain('text-primary')
expect(buttons[0].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[2].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
})
})
@@ -159,8 +165,10 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('200', options)
const buttons = wrapper.findAll('button')
expect(buttons[1].classes()).toContain('bg-white')
expect(buttons[1].classes()).toContain('text-neutral-900')
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[1].classes()).toContain('text-primary')
})
})
@@ -201,9 +209,15 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('md', options)
const buttons = wrapper.findAll('button')
expect(buttons[1].classes()).toContain('bg-white') // Medium
expect(buttons[0].classes()).not.toContain('bg-white')
expect(buttons[2].classes()).not.toContain('bg-white')
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Medium
expect(buttons[0].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[2].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
})
it('handles objects without value field', () => {
@@ -216,7 +230,9 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('First')
expect(buttons[1].text()).toBe('Second')
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
})
it('handles objects without label field', () => {
@@ -253,8 +269,12 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('first_id', options, { optionValue: 'id' })
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[1].classes()).not.toContain('bg-white')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[1].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
})
it('emits custom optionValue when clicked', async () => {
@@ -301,7 +321,9 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain('hover:bg-zinc-200/50')
expect(button.classes()).not.toContain(
'hover:bg-interface-menu-component-surface-hovered'
)
expect(button.classes()).not.toContain('cursor-pointer')
})
})
@@ -311,7 +333,9 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('option1', options, { disabled: true })
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).not.toContain('bg-white') // Selected styling disabled
expect(buttons[0].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
) // Selected styling disabled
expect(buttons[0].classes()).toContain('opacity-50')
expect(buttons[0].classes()).toContain('text-secondary')
})
@@ -324,7 +348,9 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain('bg-white')
expect(button.classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
})
})
@@ -334,7 +360,9 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain('bg-white')
expect(button.classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
})
})
@@ -343,8 +371,12 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('', options)
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain('bg-white') // Empty string is selected
expect(buttons[1].classes()).not.toContain('bg-white')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Empty string is selected
expect(buttons[1].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
})
it('compares values as strings', () => {
@@ -352,7 +384,9 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('1', options)
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain('bg-white') // '1' matches number 1 as string
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // '1' matches number 1 as string
})
})
@@ -362,8 +396,10 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('option1', options)
const selectedButton = wrapper.findAll('button')[0]
expect(selectedButton.classes()).toContain('bg-white')
expect(selectedButton.classes()).toContain('text-neutral-900')
expect(selectedButton.classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(selectedButton.classes()).toContain('text-primary')
})
it('applies unselected styling to inactive options', () => {
@@ -380,7 +416,9 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('option1', options, { disabled: false })
const unselectedButton = wrapper.findAll('button')[1]
expect(unselectedButton.classes()).toContain('hover:bg-zinc-200/50')
expect(unselectedButton.classes()).toContain(
'hover:bg-interface-menu-component-surface-hovered'
)
expect(unselectedButton.classes()).toContain('cursor-pointer')
})
})
@@ -403,7 +441,9 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('@#$%^&*()')
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
})
it('handles unicode characters in options', () => {
@@ -412,7 +452,9 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('🎨 Art')
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
})
it('handles duplicate option values', () => {
@@ -420,9 +462,15 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('duplicate', duplicateOptions)
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[2].classes()).toContain('bg-white') // Both duplicates selected
expect(buttons[1].classes()).not.toContain('bg-white')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[2].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Both duplicates selected
expect(buttons[1].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
})
it('handles mixed type options safely', () => {
@@ -436,7 +484,9 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
expect(buttons[1].classes()).toContain('bg-white') // Number 123 as string
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Number 123 as string
})
it('handles objects with missing properties gracefully', () => {
@@ -450,7 +500,9 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
expect(buttons[2].classes()).toContain('bg-white')
expect(buttons[2].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
})
it('handles large number of options', () => {
@@ -462,7 +514,9 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(50)
expect(buttons[24].classes()).toContain('bg-white') // Option 25 at index 24
expect(buttons[24].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Option 25 at index 24
})
it('fallback to index when all object properties are missing', () => {
@@ -474,7 +528,9 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(2)
expect(buttons[0].classes()).toContain('bg-white') // Falls back to index 0
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Falls back to index 0
})
})

View File

@@ -16,14 +16,14 @@
'bg-transparent border-none',
'text-center text-xs font-normal',
{
'bg-white': isSelected(option) && !disabled,
'hover:bg-zinc-200/50': !isSelected(option) && !disabled,
'bg-interface-menu-component-surface-selected':
isSelected(option) && !disabled,
'hover:bg-interface-menu-component-surface-hovered':
!isSelected(option) && !disabled,
'opacity-50 cursor-not-allowed': disabled,
'cursor-pointer': !disabled
},
isSelected(option) && !disabled
? 'text-neutral-900'
: 'text-secondary'
isSelected(option) && !disabled ? 'text-primary' : 'text-secondary'
)
"
:disabled="disabled"

View File

@@ -11,7 +11,7 @@ const filterSelected = defineModel<OptionId>('filterSelected')
</script>
<template>
<div class="mb-4 flex gap-1 px-4 text-zinc-400">
<div class="text-secondary mb-4 flex gap-1 px-4">
<div
v-for="option in filterOptions"
:key="option.id"
@@ -19,10 +19,10 @@ const filterSelected = defineModel<OptionId>('filterSelected')
cn(
'px-4 py-2 rounded-md inline-flex justify-center items-center cursor-pointer select-none',
'transition-all duration-150',
'hover:text-base-foreground hover:bg-zinc-500/10',
'hover:text-base-foreground hover:bg-interface-menu-component-surface-hovered',
'active:scale-95',
filterSelected === option.id
? '!bg-zinc-500/20 text-base-foreground'
? '!bg-interface-menu-component-surface-selected text-base-foreground'
: 'bg-transparent'
)
"

View File

@@ -61,9 +61,9 @@ function handleVideoLoad(event: Event) {
'transition-all duration-150',
{
'flex-col text-center': layout === 'grid',
'flex-row text-left max-h-16 bg-zinc-500/20 rounded-lg hover:scale-102 active:scale-98':
'flex-row text-left max-h-16 bg-interface-menu-component-surface-hovered rounded-lg hover:scale-102 active:scale-98':
layout === 'list',
'flex-row text-left hover:bg-zinc-500/20 rounded-lg':
'flex-row text-left hover:bg-interface-menu-component-surface-hovered rounded-lg':
layout === 'list-small',
// selection
'ring-2 ring-blue-500': layout === 'list' && selected
@@ -78,7 +78,7 @@ function handleVideoLoad(event: Event) {
:class="
cn(
'relative',
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-zinc-300/10',
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-interface-stroke',
'transition-all duration-150',
{
'min-w-16 max-w-16 rounded-l-lg': layout === 'list',
@@ -144,7 +144,7 @@ function handleVideoLoad(event: Event) {
{{ label ?? name }}
</span>
<!-- Meta Data -->
<span class="block text-xs text-slate-400">{{
<span class="text-secondary block text-xs">{{
metadata || actualDimensions
}}</span>
</div>

View File

@@ -0,0 +1,6 @@
import type { PrimitiveNode } from '@/extensions/core/widgetInputs'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
export const isPrimitiveNode = (
node: LGraphNode
): node is PrimitiveNode & LGraphNode => node.type === 'PrimitiveNode'

View File

@@ -78,7 +78,7 @@ import {
findLegacyRerouteNodes,
noNativeReroutes
} from '@/utils/migration/migrateReroute'
import { getSelectedModelsMetadata } from '@/utils/modelMetadataUtil'
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'

View File

@@ -11,12 +11,17 @@ import { useMenuItemStore } from '@/stores/menuItemStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { ComfyExtension } from '@/types/comfy'
import type { AuthUserInfo } from '@/types/authTypes'
export const useExtensionService = () => {
const extensionStore = useExtensionStore()
const settingStore = useSettingStore()
const keybindingStore = useKeybindingStore()
const { wrapWithErrorHandling } = useErrorHandling()
const {
wrapWithErrorHandling,
wrapWithErrorHandlingAsync,
toastErrorHandler
} = useErrorHandling()
/**
* Loads all extensions from the API into the window in parallel
@@ -77,22 +82,55 @@ export const useExtensionService = () => {
if (extension.onAuthUserResolved) {
const { onUserResolved } = useCurrentUser()
const handleUserResolved = wrapWithErrorHandlingAsync(
(user: AuthUserInfo) => extension.onAuthUserResolved?.(user, app),
(error) => {
console.error('[Extension Auth Hook Error]', {
extension: extension.name,
hook: 'onAuthUserResolved',
error
})
toastErrorHandler(error)
}
)
onUserResolved((user) => {
void extension.onAuthUserResolved?.(user, app)
void handleUserResolved(user)
})
}
if (extension.onAuthTokenRefreshed) {
const { onTokenRefreshed } = useCurrentUser()
const handleTokenRefreshed = wrapWithErrorHandlingAsync(
() => extension.onAuthTokenRefreshed?.(),
(error) => {
console.error('[Extension Auth Hook Error]', {
extension: extension.name,
hook: 'onAuthTokenRefreshed',
error
})
toastErrorHandler(error)
}
)
onTokenRefreshed(() => {
void extension.onAuthTokenRefreshed?.()
void handleTokenRefreshed()
})
}
if (extension.onAuthUserLogout) {
const { onUserLogout } = useCurrentUser()
const handleUserLogout = wrapWithErrorHandlingAsync(
() => extension.onAuthUserLogout?.(),
(error) => {
console.error('[Extension Auth Hook Error]', {
extension: extension.name,
hook: 'onAuthUserLogout',
error
})
toastErrorHandler(error)
}
)
onUserLogout(() => {
void extension.onAuthUserLogout?.()
void handleUserLogout()
})
}
}

View File

@@ -55,7 +55,7 @@ import {
isVideoNode,
migrateWidgetsValues
} from '@/utils/litegraphUtil'
import { getOrderedInputSpecs } from '@/utils/nodeDefOrderingUtil'
import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil'
import { useExtensionService } from './extensionService'

View File

@@ -1,7 +1,7 @@
import { api } from '@/scripts/api'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
import { extractCustomNodeName } from '@/utils/nodeHelpUtil'
import { extractCustomNodeName } from '@/workbench/utils/nodeHelpUtil'
class NodeHelpService {
async fetchNodeHelp(node: ComfyNodeDefImpl, locale: string): Promise<string> {

View File

@@ -0,0 +1,18 @@
import { defineStore } from 'pinia'
import { computed } from 'vue'
import type { ActionBarButton } from '@/types/comfy'
import { useExtensionStore } from './extensionStore'
export const useActionBarButtonStore = defineStore('actionBarButton', () => {
const extensionStore = useExtensionStore()
const buttons = computed<ActionBarButton[]>(() =>
extensionStore.extensions.flatMap((e) => e.actionBarButtons ?? [])
)
return {
buttons
}
})

View File

@@ -64,6 +64,11 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
// Token refresh trigger - increments when token is refreshed
const tokenRefreshTrigger = ref(0)
/**
* The user ID for which the initial ID token has been observed.
* When a token changes for the same user, that is a refresh.
*/
const lastTokenUserId = ref<string | null>(null)
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
@@ -95,6 +100,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
onAuthStateChanged(auth, (user) => {
currentUser.value = user
isInitialized.value = true
if (user === null) {
lastTokenUserId.value = null
}
// Reset balance when auth state changes
balance.value = null
@@ -104,6 +112,11 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
// Listen for token refresh events
onIdTokenChanged(auth, (user) => {
if (user && isCloud) {
// Skip initial token change
if (lastTokenUserId.value !== user.uid) {
lastTokenUserId.value = user.uid
return
}
tokenRefreshTrigger.value++
}
})

View File

@@ -5,7 +5,7 @@ import { i18n } from '@/i18n'
import { nodeHelpService } from '@/services/nodeHelpService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import { getNodeHelpBaseUrl } from '@/utils/nodeHelpUtil'
import { getNodeHelpBaseUrl } from '@/workbench/utils/nodeHelpUtil'
export const useNodeHelpStore = defineStore('nodeHelp', () => {
const currentHelpNode = ref<ComfyNodeDefImpl | null>(null)

View File

@@ -57,6 +57,32 @@ export interface TopbarBadge {
tooltip?: string
}
/*
* Action bar button definition: add buttons to the action bar
*/
export interface ActionBarButton {
/**
* Icon class to display (e.g., "icon-[lucide--message-circle-question-mark]")
*/
icon: string
/**
* Optional label text to display next to the icon
*/
label?: string
/**
* Optional tooltip text to show on hover
*/
tooltip?: string
/**
* Optional CSS classes to apply to the button
*/
class?: string
/**
* Click handler for the button
*/
onClick: () => void
}
export type MissingNodeType =
| string
// Primarily used by group nodes.
@@ -102,6 +128,10 @@ export interface ComfyExtension {
* Badges to add to the top bar
*/
topbarBadges?: TopbarBadge[]
/**
* Buttons to add to the action bar
*/
actionBarButtons?: ActionBarButton[]
/**
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
* @param app The ComfyUI app instance

View File

@@ -1,4 +1,3 @@
import type { PrimitiveNode } from '@/extensions/core/widgetInputs'
import type {
INodeSlot,
LGraph,
@@ -6,12 +5,6 @@ import type {
Subgraph
} from '@/lib/litegraph/src/litegraph'
export function isPrimitiveNode(
node: LGraphNode
): node is PrimitiveNode & LGraphNode {
return node.type === 'PrimitiveNode'
}
/**
* Check if an error is an AbortError triggered by `AbortController#abort`
* when cancelling a request.

View File

@@ -330,7 +330,7 @@ const onGraphReady = () => {
}
}
// 30-second heartbeat interval
// 5-minute heartbeat interval
tabCountInterval = window.setInterval(() => {
const now = Date.now()
@@ -347,7 +347,7 @@ const onGraphReady = () => {
// Track tab count (include current tab)
const tabCount = activeTabs.size + 1
telemetry.trackTabCount({ tab_count: tabCount })
}, 30000)
}, 60000 * 5)
// Send initial heartbeat
tabCountChannel.postMessage({ type: 'heartbeat', tabId: currentTabId })

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import { effectScope, nextTick, reactive } from 'vue'
import type { EffectScope } from 'vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
@@ -38,6 +39,14 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStore
}))
// Mock the workspace store
const workspaceStore = reactive({
shiftDown: false
})
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => workspaceStore
}))
describe('useBrowserTabTitle', () => {
beforeEach(() => {
// reset execution store
@@ -51,14 +60,17 @@ describe('useBrowserTabTitle', () => {
// reset setting and workflow stores
;(settingStore.get as any).mockReturnValue('Enabled')
workflowStore.activeWorkflow = null
workspaceStore.shiftDown = false
// reset document title
document.title = ''
})
it('sets default title when idle and no workflow', () => {
useBrowserTabTitle()
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
expect(document.title).toBe('ComfyUI')
scope.stop()
})
it('sets workflow name as title when workflow exists and menu enabled', async () => {
@@ -68,9 +80,11 @@ describe('useBrowserTabTitle', () => {
isModified: false,
isPersisted: true
}
useBrowserTabTitle()
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('myFlow - ComfyUI')
scope.stop()
})
it('adds asterisk for unsaved workflow', async () => {
@@ -80,9 +94,44 @@ describe('useBrowserTabTitle', () => {
isModified: true,
isPersisted: true
}
useBrowserTabTitle()
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('*myFlow - ComfyUI')
scope.stop()
})
it('hides asterisk when autosave is enabled', async () => {
;(settingStore.get as any).mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.AutoSave') return 'after delay'
if (key === 'Comfy.UseNewMenu') return 'Enabled'
return 'Enabled'
})
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: true,
isPersisted: true
}
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('myFlow - ComfyUI')
})
it('hides asterisk while Shift key is held', async () => {
;(settingStore.get as any).mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.AutoSave') return 'off'
if (key === 'Comfy.UseNewMenu') return 'Enabled'
return 'Enabled'
})
workspaceStore.shiftDown = true
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: true,
isPersisted: true
}
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('myFlow - ComfyUI')
})
// Fails when run together with other tests. Suspect to be caused by leaked
@@ -94,17 +143,21 @@ describe('useBrowserTabTitle', () => {
isModified: false,
isPersisted: true
}
useBrowserTabTitle()
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('ComfyUI')
scope.stop()
})
it('shows execution progress when not idle without workflow', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.3
useBrowserTabTitle()
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('[30%]ComfyUI')
scope.stop()
})
it('shows node execution title when executing a node using nodeProgressStates', async () => {
@@ -122,9 +175,11 @@ describe('useBrowserTabTitle', () => {
}
}
}
useBrowserTabTitle()
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('[40%][50%] Foo')
scope.stop()
})
it('shows multiple nodes running when multiple nodes are executing', async () => {
@@ -140,8 +195,10 @@ describe('useBrowserTabTitle', () => {
},
'2': { state: 'running', value: 8, max: 10, node: '2', prompt_id: 'test' }
}
useBrowserTabTitle()
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('[40%][2 nodes running]')
scope.stop()
})
})

View File

@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import { sortWidgetValuesByInputOrder } from '@/utils/nodeDefOrderingUtil'
import { sortWidgetValuesByInputOrder } from '@/workbench/utils/nodeDefOrderingUtil'
describe('LGraphNode widget ordering', () => {
let node: LGraphNode

View File

@@ -83,6 +83,7 @@ vi.mock('@/services/dialogService')
describe('useFirebaseAuthStore', () => {
let store: ReturnType<typeof useFirebaseAuthStore>
let authStateCallback: (user: any) => void
let idTokenCallback: (user: any) => void
const mockAuth = {
/* mock Auth object */
@@ -143,6 +144,55 @@ describe('useFirebaseAuthStore', () => {
mockUser.getIdToken.mockResolvedValue('mock-id-token')
})
describe('token refresh events', () => {
beforeEach(async () => {
vi.resetModules()
vi.doMock('@/platform/distribution/types', () => ({
isCloud: true,
isDesktop: true
}))
vi.mocked(firebaseAuth.onIdTokenChanged).mockImplementation(
(_auth, callback) => {
idTokenCallback = callback as (user: any) => void
return vi.fn()
}
)
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any)
setActivePinia(createPinia())
const storeModule = await import('@/stores/firebaseAuthStore')
store = storeModule.useFirebaseAuthStore()
})
it("should not increment tokenRefreshTrigger on the user's first ID token event", () => {
idTokenCallback?.(mockUser)
expect(store.tokenRefreshTrigger).toBe(0)
})
it('should increment tokenRefreshTrigger on subsequent ID token events for the same user', () => {
idTokenCallback?.(mockUser)
idTokenCallback?.(mockUser)
expect(store.tokenRefreshTrigger).toBe(1)
})
it('should not increment when ID token event is for a different user UID', () => {
const otherUser = { uid: 'other-user-id' }
idTokenCallback?.(mockUser)
idTokenCallback?.(otherUser)
expect(store.tokenRefreshTrigger).toBe(0)
})
it('should increment after switching to a new UID and receiving a second event for that UID', () => {
const otherUser = { uid: 'other-user-id' }
idTokenCallback?.(mockUser)
idTokenCallback?.(otherUser)
idTokenCallback?.(otherUser)
expect(store.tokenRefreshTrigger).toBe(1)
})
})
it('should initialize with the current user', () => {
expect(store.currentUser).toEqual(mockUser)
expect(store.isAuthenticated).toBe(true)

View File

@@ -5,7 +5,7 @@ import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import {
getOrderedInputSpecs,
sortWidgetValuesByInputOrder
} from '@/utils/nodeDefOrderingUtil'
} from '@/workbench/utils/nodeDefOrderingUtil'
describe('nodeDefOrderingUtil', () => {
describe('getOrderedInputSpecs', () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { getSelectedModelsMetadata } from '@/utils/modelMetadataUtil'
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
describe('modelMetadataUtil', () => {
describe('filterModelsByCurrentSelection', () => {