Compare commits

..

59 Commits

Author SHA1 Message Date
bymyself
54f94fd8c0 rebase 2025-10-02 12:07:11 -07:00
bymyself
d84ded8d83 [security] Enhance telemetry service with advanced input validation and type safety
Security improvements:
- Enhanced type safety to prevent sensitive data logging
- Runtime validation against sensitive property patterns (password, token, etc.)
- Input sanitization with regex-based filtering
- String length limits to prevent log bombing (1000 chars for properties, 200 for event names)
- Numeric bounds checking and clamping for user properties
- Removal of object-to-string conversion to prevent data leaks

Performance optimizations:
- Pre-compiled regex patterns for better performance
- Efficient property filtering with early returns
- Bounds checking to prevent abuse

All changes maintain complete fail-safe behavior and backward compatibility.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 12:23:01 -07:00
bymyself
8417acd75c [feat] Add comprehensive telemetry instrumentation for user behavior tracking
Implements unified telemetry service with fail-safe error handling:
- Template browsing and usage tracking
- Workflow creation method comparison
- Node addition source tracking
- UI interaction events (menus, sidebar, focus mode)
- Queue management operations
- Settings preference changes
- Advanced gesture/shortcut tracking

Features environment-variable sampling control and dual Electron/cloud support.
All telemetry functions are completely fail-safe and will never break normal app functionality.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 12:23:01 -07:00
bymyself
5c0eef8d3f [bugfix] Fix CSS import path in CloudTemplate.vue for build
- Change from alias path to relative path for fonts.css import
- Fixes build error: "ENOENT: no such file or directory" for fonts.css
2025-09-28 21:09:31 -07:00
bymyself
43db891c1a [bugfix] Fix TypeScript errors in typecheck
- Add @ts-expect-error directive to unused postSurveyStatus function in auth.ts
- Add @ts-expect-error for .mts import extension in vite.electron.config.mts
- Add @ts-expect-error directives for global variable assignments in vitest.setup.ts
- Remove vite.electron.config.mts from ESLint allowDefaultProject to fix duplicate inclusion error
2025-09-28 20:18:05 -07:00
bymyself
1b1cb956e6 Fix unused exports for knip check 2025-09-28 16:17:09 -07:00
bymyself
ff0c15b119 merge main into rh-test 2025-09-28 15:33:29 -07:00
Deep Roy
1c0f151d02 Add base url to index.html (#5732) 2025-09-23 13:50:47 -04:00
Jin Yi
2702ac64fe [bugfix] Fix cloud invite code route authentication issue (#5730)
## Summary
- Remove `requiresAuth: true` from cloud-invite-code route to fix
redirect issues in production

## Problem
The `/cloud/code/:code` route had conflicting configurations:
- Route was marked as `requiresAuth: true` in route definition
- But was treated as a public route in the router guard logic
- This caused authentication redirect issues when unauthenticated users
tried to access invite links

## Solution
Removed the `requiresAuth: true` meta property from the
cloud-invite-code route, allowing it to properly function as a public
route that redirects to login with the invite code.

## Test plan
- [x] Access `/cloud/code/TESTCODE` while logged out - should redirect
to login with `inviteCode` query param
- [x] Verify no authentication errors in console
- [x] Confirm invite code is preserved through the redirect flow

Fixes authentication redirect issue for cloud invite links

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5730-bugfix-Fix-cloud-invite-code-route-authentication-issue-2776d73d36508149b512d7f735b1a231)
by [Unito](https://www.unito.io)
2025-09-22 20:08:30 -07:00
Jin Yi
8ca541e850 feat: add email verification check for cloud onboarding (#5636)
## Summary
- Added email verification flow for new users during onboarding
- Implemented invite code claiming with proper validation 
- Updated API endpoints from `/invite/` to `/invite_code/` for
consistency

## Changes

### Email Verification
- Added `CloudVerifyEmailView` component with email verification UI
- Added email verification check after login in `CloudLoginView`
- Added `isEmailVerified` property to Firebase auth store
- Users must verify email before claiming invite codes

### Invite Code Flow
- Enhanced `CloudClaimInviteView` with full claim invite functionality
- Updated `InviteCheckView` to route users based on email verification
status
- Modified API to return both `claimed` and `expired` status for invite
codes
- Added proper error handling and Sentry logging for invite operations

### API Updates
- Changed endpoint paths from `/invite/` to `/invite_code/` 
- Updated `getInviteCodeStatus()` to return `{ claimed: boolean;
expired: boolean }`
- Updated `claimInvite()` to return `{ success: boolean; message: string
}`

### UI/UX Improvements
- Added Korean translations for all new strings
- Improved button styling and layout in survey and waitlist views
- Added proper loading states and error handling

## Test Plan
- [ ] Test new user signup flow with email verification
- [ ] Test invite code validation (expired/claimed/valid codes)
- [ ] Test email verification redirect flow
- [ ] Test invite claiming after email verification
- [ ] Verify Korean translations display correctly

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-20 20:29:56 -07:00
bymyself
d3a5d9e995 remove old import 2025-09-18 21:27:07 -07:00
bymyself
168e885d50 expose sentry to extensions 2025-09-18 21:16:42 -07:00
Christian Byrne
504aabd097 Disable import map on cloud (#5642)
## Summary

Disabled ImportMap generation for Vue/PrimeVue dependencies to optimize
cloud deployment performance by reducing 600+ HTTP requests to 8 bundled
files.

## Changes

- **What**: Commented out [ImportMap
entries](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap)
for Vue, PrimeVue, and related packages in [Vite
configuration](https://vitejs.dev/config/)
- **Performance**: Reduced from 600+ individual files to ~8 bundled
chunks with proper compression
- **Deployment**: Improved cloud load times by eliminating excessive
HTTP requests to `static/assets/lib/` directory

## Review Focus

Temporary optimization approach and extension ecosystem compatibility.
Verify that core extensions remain functional without ImportMap-based
Vue/PrimeVue imports. Long-term solution should implement CDN cache
headers and etag for frontend version rather than disabling ImportMap
entirely.

## Context

The ImportMap plugin with `recursiveDependence: true` generates
individual files for every PrimeVue component, creating performance
bottlenecks in cloud deployment. This selective approach maintains the
ImportMap system for future extension API imports while bundling
framework dependencies normally.

## Restoration Path

To restore full ImportMap functionality, uncomment the entries in
`vite.config.mts` and verify extension compatibility before production
deployment.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5642-Disable-import-map-on-cloud-2726d73d36508116acdff66756c98473)
by [Unito](https://www.unito.io)
2025-09-18 15:13:29 -07:00
Robin Huang
c7bbab53a6 Explicitly add email scope for social auth login. (#5638)
## Summary

Some users were authenticating successfully but their email addresses
weren't being extracted from the Firebase token. This happened because
we weren't explicitly requesting the email scope during OAuth
authentication.
 
While Firebase's default configuration includes basic profile info, it
doesn't guarantee email access for all account types - particularly
Google Workspace accounts with restrictive policies or users with
privacy-conscious settings.

[Github
Scopes](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps)

## Changes

Adding email scope for Google + Github social OAuth.

## Review Focus
N/A

## Screenshots (if applicable)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5638-Explicitly-add-email-scope-for-social-auth-login-2726d73d3650817ab356fc9c04f8641b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-09-18 14:17:55 -07:00
Jin Yi
33b6df55a8 feat: Add Sentry error tracking to auth API functions (#5623)
## Summary
- Added comprehensive Sentry error tracking to all auth API functions
- Implemented helper functions to reduce code duplication  
- Properly distinguish between HTTP errors and network errors

## Changes
- Added `captureApiError` helper function for consistent error reporting
- Added `isHttpError` helper to prevent duplicate error capture
- Enhanced error tracking with:
  - Proper error type classification (`http_error` vs `network_error`)
  - HTTP status codes and response details
  - Operation names for better context
  - Route templates for better API endpoint tracking

## Test plan
- [ ] Verify auth functions work correctly in normal flow
- [ ] Test error scenarios (network failures, 4xx/5xx responses)
- [ ] Confirm Sentry receives proper error reports without duplicates
- [ ] Check that error messages are informative and actionable

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5623-feat-Add-Sentry-error-tracking-to-auth-API-functions-2716d73d3650819fbb15e73d19642235)
by [Unito](https://www.unito.io)
2025-09-18 11:39:23 -07:00
Jin Yi
16ebe33488 fix: Add validation and consistent data structure for survey 'Other' options (#5620)
## Summary
- Added validation to prevent users from proceeding when "Other" is
selected but text input is empty
- Changed data structure to send consistent string values to database
instead of mixed objects
- Both useCase and industry now send user input directly when "Other" is
selected

## Changes
- Added `useCaseOther` field and input to survey form  
- Updated `validStep2` to validate useCaseOther when useCase is 'other'
- Modified submit payload to send string values consistently for both
useCase and industry fields

## Test plan
- [x] Select "Other" for use case without filling input → Next button
disabled
- [x] Select "Other" for industry without filling input → Next button
disabled
- [x] Fill in "Other" text inputs → Next button enabled
- [x] Submit survey with "Other" selections → Payload sends user input
as string values

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5620-fix-Add-validation-and-consistent-data-structure-for-survey-Other-options-2716d73d3650811faa6efc547db14930)
by [Unito](https://www.unito.io)
2025-09-17 21:14:20 -07:00
bymyself
108ad22d82 set user ID (anonymized) in Sentry for cloud 2025-09-16 17:18:12 -07:00
bymyself
4a10017bd2 remove mixpanel from source (will move to extension hook) 2025-09-16 16:40:44 -07:00
bymyself
646d7a68be fix mixpanel people set call 2025-09-16 15:49:59 -07:00
bymyself
c13371ef47 include uid as explicit property on mixpanel profile 2025-09-16 15:42:15 -07:00
bymyself
775c856bf7 port user ID expose hook from 6786d8e to cloud 2025-09-16 15:18:18 -07:00
Christian Byrne
e035f895a3 [i18n] improve cloud onboarding translations for cultural accuracy (#5613)
Enhances cloud onboarding translations across 8 languages (es, fr, ja, ko, ru, ar, zh, zh-TW) with native-speaker quality improvements focusing on natural flow, professional terminology, and cultural appropriateness rather than literal translations.
2025-09-16 14:34:46 -07:00
Richard Yu
98e543ec31 feat: allow user to add job-id to clipboard 2025-09-15 15:03:19 -07:00
Jin Yi
992efc4486 chore: loading text to loading icon, modify the width for responsive web (#5582) 2025-09-15 00:59:50 -07:00
Jin Yi
88130a9cae feature: font modified (#5583) 2025-09-15 00:59:06 -07:00
Jin Yi
ffd2b0efab Feature/cloud reponsive (#5580)
* fix: hero title font & responsive

* chore: text center added

* chore: style modified

* chore: delete learn about button

* chore: waitlist title added
2025-09-15 00:42:39 -07:00
Christian Byrne
7c9b8bb7a6 feat: add cloudOnboarding translations for all supported languages (#5578)
* feat: add missing translations for cloud onboarding components

Replaces hardcoded text with i18n translations across cloud onboarding flow:

- CloudWaitlistView: Add translations for title lines, questions text, and contact link
- CloudClaimInviteView: Add translations for processing title and claim button
- CloudSorryContactSupportView: Add translation for error title
- CloudVerifyEmailView: Add translation for verification title
- CloudTemplateFooter: Add translation for "Need Help?" link
- CloudLoginView: Replace hardcoded "Questions? Contact us" and "here" with translations
- CloudSignupView: Replace hardcoded "Questions? Contact us" and "here" with translations
- CloudForgotPasswordView: Replace hardcoded "here" with translation
- Remove all eslint-disable @intlify/vue-i18n/no-raw-text comments
- Add proper useI18n imports to all affected components

Ensures consistent internationalization support across the entire cloud onboarding experience.

* feat: add cloudOnboarding translations for all supported languages

Adds comprehensive translations for cloud onboarding components across all supported locales:

English (en): Base translations for waitlist, claim invite, verify email, and support
Chinese Simplified (zh): 等候名单, 邀请码处理, 邮箱验证, 联系支持
Chinese Traditional (zh-TW): 等候名單, 邀請碼處理, 郵箱驗證, 聯繫支援
Japanese (ja): ウェイトリスト, 招待コード処理, メール認証, サポート連絡
Korean (ko): 대기명단, 초대코드 처리, 이메일 인증, 지원 문의
Russian (ru): Список ожидания, обработка кода приглашения, подтверждение почты
French (fr): Liste d'attente, traitement code invitation, vérification email
Spanish (es): Lista de espera, procesamiento código invitación, verificación email
Arabic (ar): قائمة الانتظار, معالجة رمز الدعوة, التحقق من البريد

Ensures consistent internationalization across the entire cloud onboarding experience for global users.

* feat: add missing privateBeta and start section translations

Adds translations for previously missing cloud onboarding text that was already using translation keys:

- privateBeta.title: "Cloud is currently in private beta" message
- privateBeta.desc: Beta signup description text
- start.title: "Start creating in seconds" header
- start.desc: "Zero setup required" subtext
- start.explain: Multiple output generation description
- start.learnAboutButton: "Learn about Cloud" button text
- start.wantToRun: Local ComfyUI option text
- start.download: "Download ComfyUI" button text

All 9 languages updated:
- Traditional Chinese: 私人測試階段, 幾秒內開始創作, 了解 Cloud
- Simplified Chinese: 私人测试阶段, 几秒内开始创作, 了解 Cloud
- Japanese: プライベートベータ版, 数秒で創作を開始, Cloudについて学ぶ
- Korean: 비공개 베타 버전, 몇 초 만에 창작 시작, Cloud에 대해 알아보기
- Russian: закрытая бета-версия, начните создавать за секунды, Узнать о Cloud
- French: bêta privée, commencez à créer en quelques secondes, En savoir plus sur Cloud
- Spanish: beta privada, comienza a crear en segundos, Aprende sobre Cloud
- Arabic: البيتا الخاصة, ابدأ الإبداع في ثوان, تعلم عن Cloud

Fixes missing translations reported during Traditional Chinese testing.

* feat: restore French translation file and add cloudOnboarding translations

- Restored src/locales/fr/main.json from clean backup to remove duplicate sections
- Added complete French translations for cloudOnboarding section
- Includes survey, waitlist, forgotPassword, privateBeta, start, and other subsections
- Structure matches English version exactly

* [feat] Refactor cloud translations to top-level keys

- Replace nested cloudOnboarding.section.key structure with flattened cloudSection_key pattern
- Add comprehensive cloud translations for all 9 supported languages (ar, en, es, fr, ja, ko, ru, zh, zh-TW)
- Update all Vue components to use new translation key structure
- Fix "Need Help?" and other missing translations across all languages
- Simplify translation maintenance and avoid JSON structure conflicts

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-15 00:16:55 -07:00
Christian Byrne
18b3b11b9a feat: add error handling and timeout recovery to cloud onboarding (#5573)
Implements robust error handling and authentication timeout recovery for the cloud onboarding flow:

- Enhanced UserCheckView with VueUse useAsyncState for declarative error handling
- Added parallel API calls for better performance using Promise.all
- Implemented loading states with skeleton views and user-friendly error messages
- Added authentication timeout handling (16s) with recovery options
- Created CloudAuthTimeoutView with "Sign Out & Try Again" functionality
- Added comprehensive i18n support for error states
2025-09-14 23:20:07 -07:00
Christian Byrne
80b1c2aaf7 [feat] Redirect to login page after logout on cloud domains (#5570)
- Add router navigation to cloud-login after successful logout
- Check hostname to ensure we only redirect on cloud domains
- Preserves existing toast notification and error handling
2025-09-14 19:44:22 -07:00
Christian Byrne
a13eeaea7e [feat] Add skeleton loading states to cloud onboarding flow (#5568)
* [feat] Add skeleton loading states to cloud onboarding flow

- Create dedicated skeleton components matching exact layouts
- CloudLoginViewSkeleton for login page with beta notice, form, social buttons
- CloudSurveyViewSkeleton for multi-step survey with progress bar
- CloudWaitlistViewSkeleton for waitlist page with title and messages
- CloudClaimInviteViewSkeleton for invite claiming page
- Update UserCheckView to show contextual skeleton based on redirect destination
- Update InviteCheckView to show appropriate skeleton during loading
- Use i18n for loading text to maintain consistency

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

Co-Authored-By: Claude <noreply@anthropic.com>

* [feat] Fix skeleton loading flow to show progressive states

- Start with simple loading text when checking user status
- Show survey skeleton while checking survey completion
- Show waitlist skeleton while checking user activation status
- Show login skeleton when redirecting to login on error
- Preserve all original comments from upstream authors
- Use progressive disclosure based on API response flow

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-14 19:40:41 -07:00
Jin Yi
59a1380f39 [feat] Cloud onboarding flow implementation (#5494)
* feature: cloud onboarding scaffolding

* fix: redirect unknown routes

* feature: cloud onboarding flow

* chore: code review

* test: api mock for test failing

* refactor: Centralize onboarding routing with dedicated check views

- Add UserCheckView to handle all user status routing decisions
- Add InviteCheckView to manage invite code validation flow
- Simplify auth.ts by removing async operations and extra complexity
- Update login/signup to always redirect through UserCheckView
- Remove distributed routing logic from all onboarding components
- Simplify router guards to delegate to check views
- Fix infinite redirect loops for non-whitelisted users
- Use window.location.href for final navigation to bypass router conflicts

Breaking changes:
- Removed claimInvite from auth.ts (moved to CloudClaimInviteView)
- Changed route names to use cloud- prefix consistently
- Simplified getMe() to synchronous function

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

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: delete unused file

* Revert "test: api mock for test failing"

This reverts commit 06ca56c05e.

* feature: API applied

* feature: survey view

* feature: signup / login view completed

* style: min-h-screen deleted

* feature: completed login flow

* feature: router view added

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-14 16:36:57 -07:00
Richard Yu
f1fbab6e1f remove file mapping TTL 2025-09-06 14:54:33 -07:00
Richard Yu
9bd3d5cbe6 fix: replace uuids with displaynames rather than shas 2025-09-03 22:06:12 -07:00
Richard Yu
bd48649604 update file extension list 2025-09-03 14:01:08 -07:00
Richard Yu
c6c9487c0d feat: add filename mapping to frontend to display human readable names on input nodes 2025-09-03 11:04:00 -07:00
Robin Huang
799795cf56 Add auth token to ws connection as query parameter. 2025-09-01 15:25:34 -07:00
Jennifer Weber
4899c9d25b translations for human friendly auth errors 2025-08-29 21:53:23 -07:00
Jennifer Weber
0bd3c1271d Small fixes after rebase 2025-08-29 11:10:21 -07:00
Jennifer Weber
6eb91e4aed Show signin and signup errors on form 2025-08-29 02:32:06 -07:00
Jennifer Weber
3b3071c975 Fix for maintining the new item optimization in queue store 2025-08-29 02:32:06 -07:00
Jennifer Weber
68f0275a83 Fix for history items sometimes not appearing again
New items from the history endpoint were being ignored due to the sorting based on priority, and left out of the merge
Fixed by removing that optimization so they all go through merge.
2025-08-29 02:32:06 -07:00
Jennifer Weber
a0d66bb0d7 Fix for depulicating tasks in queuestore by promptId to take into account sorting differences 2025-08-29 02:32:06 -07:00
Jennifer Weber
1292ae0f14 Add error log when templates are not found 2025-08-29 02:32:03 -07:00
Christian Byrne
8da2b304ef allow updating outputs on custom nodes and inside subgraphs (#4963) 2025-08-29 02:30:34 -07:00
Jennifer Weber
0950da0b43 Update logic for dev server url after cloud https changes
default to staging http for now
env var can be overrden for local in the .env file
2025-08-29 02:30:34 -07:00
Deep Roy
86e2b1fc61 Add analytics for workflow loading (#4966)
Needs to land after https://github.com/Comfy-Org/cloud/pull/398

## Description

- Adds a postCloudAnalytics method in `api.ts`
- Adds a workflow_loaded event
- The event contains 
  - the source (not file type, more like workflow format) one of: 
    - apiJson (I think this is the "prompt" format?)
    - graph (the richest type)
    - template: don't fully understand this but it works
- The actual data for the workflow, depends on the source type
- If available, missingModels and missingNodeTypes, so we can easily
query those

This talks to a new endpoint on the ingest server that is being added.  

## Tests
Tested manually with:
- loading an image from civitAI with missing models
- loading an image from comfy examples with no missing models
- opening a json file in the prompt format (I asked claude to generate
one - this is the format handled by the loadApiJson function)
- opening a template file (claude generated one - this is the format
handled by loadTemplateJson function)
- Testing these for both dragAndDrop and (menu --> open --> open
workflow)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4966-Add-analytics-for-workflow-loading-24e6d73d36508170acacefb3125b7017)
by [Unito](https://www.unito.io)
2025-08-29 02:30:34 -07:00
bymyself
4a612b09ed feat: Configure vite dev server for staging cloud testing
- Hardcode DEV_SERVER_COMFYUI_URL to staging cloud URL
- Enable Vue DevTools by default for better DX
- Add SSL certificate handling for all proxy endpoints
- Add optional API key support via STAGING_API_KEY env var
- Bypass multi-user auth to simulate single-user mode
- Add comments explaining the staging setup

This allows developers to test frontend changes against the staging
cloud backend by simply running npm run dev without any env configuration.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 02:30:34 -07:00
Robin Huang
4a3c3d9c97 Use hostname to determine environment. 2025-08-29 02:30:34 -07:00
Richard Yu
c3c59988f4 sort history by exec start time rather than priority 2025-08-29 02:30:34 -07:00
Richard Yu
e6d3e94a34 Add "as TaskPrompt" 2025-08-29 02:30:34 -07:00
Richard Yu
1c0c501105 update api.ts to handle prompt formats 2025-08-29 02:30:34 -07:00
Richard Yu
980b727ff8 [fix] handle cancelling pending jobs 2025-08-29 02:30:34 -07:00
Robin Huang
40c47a8e67 Fix type error. 2025-08-29 02:30:34 -07:00
Robin Huang
f0f4313afa Add 2025-08-29 02:30:34 -07:00
Robin Huang
cb5894a100 Enable sentry integrations. 2025-08-29 02:30:34 -07:00
Richard Yu
7649feb47f [feat] Update history API to v2 array format and add comprehensive tests
- Migrate from object-based to array-based history response format
- Update /history endpoint to /history_v2 with max_items parameter
- Add lazy loading of workflows via /history_v2/:prompt_id endpoint
- Implement comprehensive browser tests for history API functionality
- Add unit tests for API methods and queue store
- Update TaskItemImpl to support history workflow loading
- Add proper error handling and edge case coverage
- Follow established test patterns for better maintainability

This change improves performance by reducing initial payload size
and enables on-demand workflow loading for history items.
2025-08-29 02:30:31 -07:00
Robin Huang
c27edb7e94 Add notifications via websocket. 2025-08-29 02:25:37 -07:00
Robin Huang
23e881e220 Prevent access without login. 2025-08-29 02:25:37 -07:00
Robin Huang
c5c06b6ba8 Add client_id to query param. 2025-08-29 02:25:37 -07:00
229 changed files with 27021 additions and 22691 deletions

2
.gitattributes vendored
View File

@@ -12,5 +12,5 @@
*.yaml text eol=lf *.yaml text eol=lf
# Generated files # Generated files
packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true src/types/comfyRegistryTypes.ts linguist-generated=true
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true

View File

@@ -1,67 +0,0 @@
name: Setup Frontend
description: 'Setup ComfyUI frontend development environment'
inputs:
extra_server_params:
description: 'Additional parameters to pass to ComfyUI server'
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Checkout ComfyUI
uses: actions/checkout@v4
with:
repository: 'comfyanonymous/ComfyUI'
path: 'ComfyUI'
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v4
with:
repository: 'Comfy-Org/ComfyUI_frontend'
path: 'ComfyUI_frontend'
- name: Copy ComfyUI_devtools from frontend repo
shell: bash
run: |
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
cache-dependency-path: 'ComfyUI_frontend/pnpm-lock.yaml'
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install Python requirements
shell: bash
working-directory: ComfyUI
run: |
python -m pip install --upgrade pip
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements.txt
pip install wait-for-it
- name: Build & Install ComfyUI_frontend
shell: bash
working-directory: ComfyUI_frontend
run: |
pnpm install --frozen-lockfile
pnpm build
- name: Start ComfyUI server
shell: bash
working-directory: ComfyUI
run: |
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist ${{ inputs.extra_server_params }} &
wait-for-it --service 127.0.0.1:8188 -t 600

View File

@@ -60,7 +60,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0

View File

@@ -1,4 +1,4 @@
name: Storybook and Chromatic CI name: 'Chromatic'
# - [Automate Chromatic with GitHub Actions • Chromatic docs]( https://www.chromatic.com/docs/github-actions/ ) # - [Automate Chromatic with GitHub Actions • Chromatic docs]( https://www.chromatic.com/docs/github-actions/ )
@@ -8,100 +8,13 @@ on:
branches: [main] branches: [main]
jobs: jobs:
# Post starting comment for non-forked PRs
comment-on-pr-start:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"starting" \
"$(date -u '+%m/%d/%Y, %I:%M:%S %p')"
# Build Storybook for all PRs (free Cloudflare deployment)
storybook-build:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Cache tool outputs
uses: actions/cache@v4
with:
path: |
.cache
storybook-static
tsconfig.tsbuildinfo
key: storybook-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.{ts,vue,js}', '*.config.*', '.storybook/**/*') }}
restore-keys: |
storybook-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
storybook-cache-${{ runner.os }}-
storybook-tools-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Storybook
run: pnpm build-storybook
- name: Set job status
id: job-status
if: always()
run: |
echo "conclusion=${{ job.status }}" >> $GITHUB_OUTPUT
- name: Get workflow URL
id: workflow-url
if: always()
run: |
echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
- name: Upload Storybook build
if: success() && github.event.pull_request.head.repo.fork == false
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: storybook-static/
retention-days: 7
# Chromatic deployment only for version-bump-* branches or manual triggers
chromatic-deployment: chromatic-deployment:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && startsWith(github.head_ref, 'version-bump-')) # Only run for PRs from version-bump-* branches or manual triggers
outputs: if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'version-bump-')
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
chromatic-build-url: ${{ steps.chromatic.outputs.buildUrl }}
chromatic-storybook-url: ${{ steps.chromatic.outputs.storybookUrl }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
fetch-depth: 0 # Required for Chromatic baseline fetch-depth: 0 # Required for Chromatic baseline
@@ -116,6 +29,7 @@ jobs:
node-version: '20' node-version: '20'
cache: 'pnpm' cache: 'pnpm'
- name: Cache tool outputs - name: Cache tool outputs
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -140,92 +54,4 @@ jobs:
buildScriptName: build-storybook buildScriptName: build-storybook
autoAcceptChanges: 'main' # Auto-accept changes on main branch autoAcceptChanges: 'main' # Auto-accept changes on main branch
exitOnceUploaded: true # Don't wait for UI tests to complete exitOnceUploaded: true # Don't wait for UI tests to complete
onlyChanged: true # Only capture changed stories
- name: Set job status
id: job-status
if: always()
run: |
echo "conclusion=${{ job.status }}" >> $GITHUB_OUTPUT
- name: Get workflow URL
id: workflow-url
if: always()
run: |
echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [storybook-build]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Download Storybook build
if: needs.storybook-build.outputs.conclusion == 'success'
uses: actions/download-artifact@v4
with:
name: storybook-static
path: storybook-static
- name: Make deployment script executable
run: chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
- name: Deploy Storybook and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
WORKFLOW_CONCLUSION: ${{ needs.storybook-build.outputs.conclusion }}
WORKFLOW_URL: ${{ needs.storybook-build.outputs.workflow-url }}
run: |
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"completed"
# Update comment with Chromatic URLs for version-bump branches
update-comment-with-chromatic:
needs: [chromatic-deployment, deploy-and-comment]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && startsWith(github.head_ref, 'version-bump-') && needs.chromatic-deployment.outputs.chromatic-build-url != ''
permissions:
pull-requests: write
steps:
- name: Update comment with Chromatic URLs
uses: actions/github-script@v7
with:
script: |
const buildUrl = '${{ needs.chromatic-deployment.outputs.chromatic-build-url }}';
const storybookUrl = '${{ needs.chromatic-deployment.outputs.chromatic-storybook-url }}';
// Find the existing Storybook comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.pull_request.number }}
});
const storybookComment = comments.find(comment =>
comment.body.includes('<!-- STORYBOOK_BUILD_STATUS -->')
);
if (storybookComment && buildUrl && storybookUrl) {
// Append Chromatic info to existing comment
const updatedBody = storybookComment.body.replace(
/---\n(.*)$/s,
`---\n### 🎨 Chromatic Visual Tests\n- 📊 [View Chromatic Build](${buildUrl})\n- 📚 [View Chromatic Storybook](${storybookUrl})\n\n$1`
);
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: storybookComment.id,
body: updatedBody
});
}

View File

@@ -50,7 +50,7 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0

View File

@@ -18,7 +18,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.PR_GH_TOKEN || secrets.GITHUB_TOKEN }} token: ${{ secrets.PR_GH_TOKEN || secrets.GITHUB_TOKEN }}

View File

@@ -15,7 +15,7 @@ jobs:
version: ${{ steps.current_version.outputs.version }} version: ${{ steps.current_version.outputs.version }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:
@@ -62,7 +62,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Download dist artifact - name: Download dist artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:

View File

@@ -1,26 +0,0 @@
name: Devtools Python Check
on:
pull_request:
paths:
- 'tools/devtools/**'
push:
branches: [ main ]
paths:
- 'tools/devtools/**'
jobs:
syntax:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Validate Python syntax
run: python3 -m compileall -q tools/devtools

View File

@@ -22,13 +22,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout ComfyUI - name: Checkout ComfyUI
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
repository: comfyanonymous/ComfyUI repository: comfyanonymous/ComfyUI
path: ComfyUI path: ComfyUI
ref: master ref: master
- name: Checkout ComfyUI_frontend - name: Checkout ComfyUI_frontend
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
repository: Comfy-Org/ComfyUI_frontend repository: Comfy-Org/ComfyUI_frontend
path: ComfyUI_frontend path: ComfyUI_frontend
@@ -37,7 +37,7 @@ jobs:
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/ cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
- name: Checkout custom node repository - name: Checkout custom node repository
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
repository: ${{ inputs.owner }}/${{ inputs.repository }} repository: ${{ inputs.owner }}/${{ inputs.repository }}
path: 'ComfyUI/custom_nodes/${{ inputs.repository }}' path: 'ComfyUI/custom_nodes/${{ inputs.repository }}'

View File

@@ -13,8 +13,7 @@ jobs:
update-locales: update-locales:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Setup Frontend - uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
uses: ./.github/actions/setup-frontend
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: pnpm exec playwright install chromium --with-deps run: pnpm exec playwright install chromium --with-deps
working-directory: ComfyUI_frontend working-directory: ComfyUI_frontend

View File

@@ -14,8 +14,7 @@ jobs:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'version-bump-')) if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'version-bump-'))
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Setup Frontend - uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
uses: ./.github/actions/setup-frontend
- name: Cache tool outputs - name: Cache tool outputs
uses: actions/cache@v4 uses: actions/cache@v4

View File

@@ -1,15 +0,0 @@
name: Validate JSON
on:
push:
branches:
- main
pull_request:
jobs:
json-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Validate JSON syntax
run: ./scripts/cicd/check-json.sh

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout PR - name: Checkout PR
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}

View File

@@ -30,7 +30,7 @@ jobs:
echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}" echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}"
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Get PR Number - name: Get PR Number
id: pr id: pr

View File

@@ -0,0 +1,126 @@
name: PR Storybook Comment
on:
workflow_run:
workflows: ['Chromatic']
types: [requested, completed]
jobs:
comment-storybook:
runs-on: ubuntu-latest
if: >-
github.repository == 'Comfy-Org/ComfyUI_frontend'
&& github.event.workflow_run.event == 'pull_request'
&& startsWith(github.event.workflow_run.head_branch, 'version-bump-')
permissions:
pull-requests: write
actions: read
steps:
- name: Get PR number
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: pullRequests } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
});
if (pullRequests.length === 0) {
console.log('No open PR found for this branch');
return null;
}
return pullRequests[0].number;
- name: Log when no PR found
if: steps.pr.outputs.result == 'null'
run: |
echo "⚠️ No open PR found for branch: ${{ github.event.workflow_run.head_branch }}"
echo "Workflow run ID: ${{ github.event.workflow_run.id }}"
echo "Repository: ${{ github.event.workflow_run.repository.full_name }}"
echo "Event: ${{ github.event.workflow_run.event }}"
- name: Get workflow run details
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
id: workflow-run
uses: actions/github-script@v7
with:
script: |
const run = await github.rest.actions.getWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
return {
conclusion: run.data.conclusion,
html_url: run.data.html_url
};
- name: Get completion time
id: completion-time
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
- name: Comment PR - Storybook Started
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
with:
issue-number: ${{ steps.pr.outputs.result }}
body-includes: '<!-- STORYBOOK_BUILD_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: replace
body: |
<!-- STORYBOOK_BUILD_STATUS -->
## 🎨 Storybook Build Status
<img alt='comfy-loading-gif' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px' style='vertical-align: middle; margin-right: 4px;' /> **Build is starting...**
⏰ Started at: ${{ steps.completion-time.outputs.time }} UTC
### 🚀 Building Storybook
- 📦 Installing dependencies...
- 🔧 Building Storybook components...
- 🎨 Running Chromatic visual tests...
---
⏱️ Please wait while the Storybook build is in progress...
- name: Comment PR - Storybook Complete
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
with:
issue-number: ${{ steps.pr.outputs.result }}
body-includes: '<!-- STORYBOOK_BUILD_STATUS -->'
comment-author: 'github-actions[bot]'
edit-mode: replace
body: |
<!-- STORYBOOK_BUILD_STATUS -->
## 🎨 Storybook Build Status
${{
fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && '✅'
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'skipped' && '⏭️'
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'cancelled' && '🚫'
|| '❌'
}} **${{
fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && 'Build completed successfully!'
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'skipped' && 'Build skipped.'
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'cancelled' && 'Build cancelled.'
|| 'Build failed!'
}}**
⏰ Completed at: ${{ steps.completion-time.outputs.time }} UTC
### 🔗 Links
- [📊 View Workflow Run](${{ fromJSON(steps.workflow-run.outputs.result).html_url }})
---
${{
fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && '🎉 Your Storybook is ready for review!'
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'skipped' && ' Chromatic was skipped for this PR.'
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'cancelled' && ' The Chromatic run was cancelled.'
|| '⚠️ Please check the workflow logs for error details.'
}}

View File

@@ -1,90 +0,0 @@
name: PR Storybook Deploy (Forks)
on:
workflow_run:
workflows: ['Storybook and Chromatic CI']
types: [requested, completed]
env:
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
if: |
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.head_repository != null &&
github.event.workflow_run.repository != null &&
github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name
permissions:
pull-requests: write
actions: read
steps:
- name: Log workflow trigger info
run: |
echo "Repository: ${{ github.repository }}"
echo "Event: ${{ github.event.workflow_run.event }}"
echo "Head repo: ${{ github.event.workflow_run.head_repository.full_name || 'null' }}"
echo "Base repo: ${{ github.event.workflow_run.repository.full_name || 'null' }}"
echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}"
- name: Checkout repository
uses: actions/checkout@v5
- name: Get PR Number
id: pr
uses: actions/github-script@v7
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
- name: Handle Storybook Start
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting" \
"$(date -u '${{ env.DATE_FORMAT }}')"
- name: Download and Deploy Storybook
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
uses: actions/download-artifact@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: storybook-static
path: storybook-static
- name: Handle Storybook Completion
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
WORKFLOW_URL: ${{ github.event.workflow_run.html_url }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -10,10 +10,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event.label.name == 'New Browser Test Expectations' if: github.event.label.name == 'New Browser Test Expectations'
steps: steps:
- name: Checkout workflow repo - uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
uses: actions/checkout@v5
- name: Setup Frontend
uses: ./.github/actions/setup-frontend
- name: Cache Playwright browsers - name: Cache Playwright browsers
uses: actions/cache@v4 uses: actions/cache@v4
with: with:

View File

@@ -15,14 +15,14 @@ jobs:
playwright-version: ${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }} playwright-version: ${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }}
steps: steps:
- name: Checkout ComfyUI - name: Checkout ComfyUI
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
repository: 'comfyanonymous/ComfyUI' repository: 'comfyanonymous/ComfyUI'
path: 'ComfyUI' path: 'ComfyUI'
ref: master ref: master
- name: Checkout ComfyUI_frontend - name: Checkout ComfyUI_frontend
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
repository: 'Comfy-Org/ComfyUI_frontend' repository: 'Comfy-Org/ComfyUI_frontend'
path: 'ComfyUI_frontend' path: 'ComfyUI_frontend'
@@ -250,7 +250,7 @@ jobs:
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
steps: steps:
- name: Checkout ComfyUI_frontend - name: Checkout ComfyUI_frontend
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
repository: 'Comfy-Org/ComfyUI_frontend' repository: 'Comfy-Org/ComfyUI_frontend'
path: 'ComfyUI_frontend' path: 'ComfyUI_frontend'
@@ -306,7 +306,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Get start time - name: Get start time
id: start-time id: start-time
@@ -333,7 +333,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Download all playwright reports - name: Download all playwright reports
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4

View File

@@ -12,7 +12,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4

View File

@@ -17,7 +17,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -51,7 +51,7 @@ jobs:
comfyui-manager-repo-${{ runner.os }}- comfyui-manager-repo-${{ runner.os }}-
- name: Checkout ComfyUI-Manager repository - name: Checkout ComfyUI-Manager repository
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
repository: Comfy-Org/ComfyUI-Manager repository: Comfy-Org/ComfyUI-Manager
path: ComfyUI-Manager path: ComfyUI-Manager

View File

@@ -16,7 +16,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -50,7 +50,7 @@ jobs:
comfy-api-repo-${{ runner.os }}- comfy-api-repo-${{ runner.os }}-
- name: Checkout comfy-api repository - name: Checkout comfy-api repository
uses: actions/checkout@v5 uses: actions/checkout@v4
with: with:
repository: Comfy-Org/comfy-api repository: Comfy-Org/comfy-api
path: comfy-api path: comfy-api
@@ -68,18 +68,17 @@ jobs:
- name: Generate API types - name: Generate API types
run: | run: |
echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..." echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..."
mkdir -p ./packages/registry-types/src pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./src/types/comfyRegistryTypes.ts
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./packages/registry-types/src/comfyRegistryTypes.ts
- name: Validate generated types - name: Validate generated types
run: | run: |
if [ ! -f ./packages/registry-types/src/comfyRegistryTypes.ts ]; then if [ ! -f ./src/types/comfyRegistryTypes.ts ]; then
echo "Error: Types file was not generated." echo "Error: Types file was not generated."
exit 1 exit 1
fi fi
# Check if file is not empty # Check if file is not empty
if [ ! -s ./packages/registry-types/src/comfyRegistryTypes.ts ]; then if [ ! -s ./src/types/comfyRegistryTypes.ts ]; then
echo "Error: Generated types file is empty." echo "Error: Generated types file is empty."
exit 1 exit 1
fi fi
@@ -87,12 +86,12 @@ jobs:
- name: Lint generated types - name: Lint generated types
run: | run: |
echo "Linting generated Comfy Registry API types..." echo "Linting generated Comfy Registry API types..."
pnpm lint:fix:no-cache -- ./packages/registry-types/src/comfyRegistryTypes.ts pnpm lint:fix:no-cache -- ./src/types/comfyRegistryTypes.ts
- name: Check for changes - name: Check for changes
id: check-changes id: check-changes
run: | run: |
if [[ -z $(git status --porcelain ./packages/registry-types/src/comfyRegistryTypes.ts) ]]; then if [[ -z $(git status --porcelain ./src/types/comfyRegistryTypes.ts) ]]; then
echo "No changes to Comfy Registry API types detected." echo "No changes to Comfy Registry API types detected."
echo "changed=false" >> $GITHUB_OUTPUT echo "changed=false" >> $GITHUB_OUTPUT
exit 0 exit 0
@@ -122,4 +121,4 @@ jobs:
labels: CNR labels: CNR
delete-branch: true delete-branch: true
add-paths: | add-paths: |
packages/registry-types/src/comfyRegistryTypes.ts src/types/comfyRegistryTypes.ts

View File

@@ -24,7 +24,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4

View File

@@ -1,2 +1,2 @@
packages/registry-types/src/comfyRegistryTypes.ts src/types/comfyRegistryTypes.ts
src/types/generatedManagerTypes.ts src/types/generatedManagerTypes.ts

View File

@@ -31,9 +31,10 @@
- Playwright: place tests in `browser_tests/`; optional tags like `@mobile`, `@2x` are respected by config. - Playwright: place tests in `browser_tests/`; optional tags like `@mobile`, `@2x` are respected by config.
## Commit & Pull Request Guidelines ## Commit & Pull Request Guidelines
- Commits: Use `[skip ci]` for locale-only updates when appropriate. - Commits: Prefer Conventional Commits (e.g., `feat(ui): add sidebar`), `refactor(litegraph): …`. Use `[skip ci]` for locale-only updates when appropriate.
- PRs: Include clear description, linked issues (`- Fixes #123`), and screenshots/GIFs for UI changes. - PRs: Include clear description, linked issues (`Fixes #123`), and screenshots/GIFs for UI changes. Add/adjust tests and i18n strings when applicable.
- Quality gates: `pnpm lint`, `pnpm typecheck`, and relevant tests must pass. Keep PRs focused and small. - Quality gates: `pnpm lint`, `pnpm typecheck`, and relevant tests must pass. Keep PRs focused and small.
## Security & Configuration Tips ## Security & Configuration Tips
- Secrets: Use `.env` (see `.env_example`); do not commit secrets. - Secrets: Use `.env` (see `.env_example`); do not commit secrets.
- Backend: Dev server expects ComfyUI backend at `localhost:8188` by default; configure via `.env`.

View File

@@ -84,7 +84,7 @@ UI mode features:
- **Console/Network Tabs**: View logs and API calls at each step - **Console/Network Tabs**: View logs and API calls at each step
- **Attachments Tab**: View all snapshots with expected and actual images - **Attachments Tab**: View all snapshots with expected and actual images
![Playwright UI Mode](https://github.com/user-attachments/assets/9b9cb09f-6da7-4fa0-81df-2effceced755) ![Playwright UI Mode](https://github.com/user-attachments/assets/c158c93f-b39a-44c5-a1a1-e0cc975ee9f2)
For CI or headless testing: For CI or headless testing:

View File

@@ -1,221 +0,0 @@
{
"id": "e74f5af9-b886-4a21-abbf-ed535d12e2fb",
"revision": 0,
"last_node_id": 8,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadAudio",
"pos": [
41.52964782714844,
16.930862426757812
],
"size": [
444,
125
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "AUDIO",
"type": "AUDIO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadAudio"
},
"widgets_values": [
null,
null,
""
]
},
{
"id": 2,
"type": "LoadVideo",
"pos": [
502.28570556640625,
16.857147216796875
],
"size": [
444,
525
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VIDEO",
"type": "VIDEO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadVideo"
},
"widgets_values": [
null,
"image"
]
},
{
"id": 3,
"type": "DevToolsLoadAnimatedImageTest",
"pos": [
41.71427917480469,
188.0000457763672
],
"size": [
444,
553
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "DevToolsLoadAnimatedImageTest"
},
"widgets_values": [
null,
"image"
]
},
{
"id": 5,
"type": "LoadImage",
"pos": [
958.285888671875,
16.57145118713379
],
"size": [
444,
553
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
null,
"image"
]
},
{
"id": 6,
"type": "LoadImageMask",
"pos": [
503.4285888671875,
588
],
"size": [
444,
563
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImageMask"
},
"widgets_values": [
null,
"alpha",
"image"
]
},
{
"id": 7,
"type": "LoadImageOutput",
"pos": [
965.1429443359375,
612
],
"size": [
444,
553
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImageOutput"
},
"widgets_values": [
null,
false,
"refresh",
"image"
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
},
"frontendVersion": "1.28.3"
},
"version": 0.4
}

View File

@@ -34,17 +34,23 @@ const getContentType = (filename: string, fileType: OutputFileType) => {
} }
const setQueueIndex = (task: TaskItem) => { const setQueueIndex = (task: TaskItem) => {
task.prompt[0] = TaskHistory.queueIndex++ task.prompt.priority = TaskHistory.queueIndex++
} }
const setPromptId = (task: TaskItem) => { const setPromptId = (task: TaskItem) => {
task.prompt[1] = uuidv4() if (!task.prompt.prompt_id || task.prompt.prompt_id === 'prompt-id') {
task.prompt.prompt_id = uuidv4()
}
} }
export default class TaskHistory { export default class TaskHistory {
static queueIndex = 0 static queueIndex = 0
static readonly defaultTask: Readonly<HistoryTaskItem> = { static readonly defaultTask: Readonly<HistoryTaskItem> = {
prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []], prompt: {
priority: 0,
prompt_id: 'prompt-id',
extra_data: { client_id: uuidv4() }
},
outputs: {}, outputs: {},
status: { status: {
status_str: 'success', status_str: 'success',
@@ -66,10 +72,37 @@ export default class TaskHistory {
) )
private async handleGetHistory(route: Route) { private async handleGetHistory(route: Route) {
const url = route.request().url()
// Handle history_v2/:prompt_id endpoint
const promptIdMatch = url.match(/history_v2\/([^?]+)/)
if (promptIdMatch) {
const promptId = promptIdMatch[1]
const task = this.tasks.find((t) => t.prompt.prompt_id === promptId)
const response: Record<string, any> = {}
if (task) {
response[promptId] = task
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
// Handle history_v2 list endpoint
// Convert HistoryTaskItem to RawHistoryItem format expected by API
const rawHistoryItems = this.tasks.map((task) => ({
prompt_id: task.prompt.prompt_id,
prompt: task.prompt,
status: task.status,
outputs: task.outputs,
...(task.meta && { meta: task.meta })
}))
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify(this.tasks) body: JSON.stringify({ history: rawHistoryItems })
}) })
} }
@@ -93,7 +126,7 @@ export default class TaskHistory {
async setupRoutes() { async setupRoutes() {
return this.comfyPage.page.route( return this.comfyPage.page.route(
/.*\/api\/(view|history)(\?.*)?$/, /.*\/api\/(view|history_v2)(\/[^?]*)?(\?.*)?$/,
async (route) => { async (route) => {
const request = route.request() const request = route.request()
const method = request.method() const method = request.method()

View File

@@ -0,0 +1,131 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
test.describe('History API v2', () => {
const TEST_PROMPT_ID = 'test-prompt-id'
const TEST_CLIENT_ID = 'test-client'
test('Can fetch history with new v2 format', async ({ comfyPage }) => {
// Set up mocked history with tasks
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
// Verify history_v2 API response format
const result = await comfyPage.page.evaluate(async () => {
try {
const response = await window['app'].api.getHistory()
return { success: true, data: response }
} catch (error) {
console.error('Failed to fetch history:', error)
return { success: false, error: error.message }
}
})
expect(result.success).toBe(true)
expect(result.data).toHaveProperty('History')
expect(Array.isArray(result.data.History)).toBe(true)
expect(result.data.History.length).toBeGreaterThan(0)
const historyItem = result.data.History[0]
// Verify the new prompt structure (object instead of array)
expect(historyItem.prompt).toHaveProperty('priority')
expect(historyItem.prompt).toHaveProperty('prompt_id')
expect(historyItem.prompt).toHaveProperty('extra_data')
expect(typeof historyItem.prompt.priority).toBe('number')
expect(typeof historyItem.prompt.prompt_id).toBe('string')
expect(historyItem.prompt.extra_data).toHaveProperty('client_id')
})
test('Can load workflow from history using history_v2 endpoint', async ({
comfyPage
}) => {
// Simple mock workflow for testing
const mockWorkflow = {
version: 0.4,
nodes: [{ id: 1, type: 'TestNode', pos: [100, 100], size: [200, 100] }],
links: [],
groups: [],
config: {},
extra: {}
}
// Set up history with workflow data
await comfyPage
.setupHistory()
.withTask(['example.webp'], 'images', {
prompt: {
priority: 0,
prompt_id: TEST_PROMPT_ID,
extra_data: {
client_id: TEST_CLIENT_ID,
extra_pnginfo: { workflow: mockWorkflow }
}
}
})
.setupRoutes()
// Load initial workflow to clear canvas
await comfyPage.loadWorkflow('simple_slider')
await comfyPage.nextFrame()
// Load workflow from history
const loadResult = await comfyPage.page.evaluate(async (promptId) => {
try {
const workflow =
await window['app'].api.getWorkflowFromHistory(promptId)
if (workflow) {
await window['app'].loadGraphData(workflow)
return { success: true }
}
return { success: false, error: 'No workflow found' }
} catch (error) {
console.error('Failed to load workflow from history:', error)
return { success: false, error: error.message }
}
}, TEST_PROMPT_ID)
expect(loadResult.success).toBe(true)
// Verify workflow loaded correctly
await comfyPage.nextFrame()
const nodeInfo = await comfyPage.page.evaluate(() => {
try {
const graph = window['app'].graph
return {
success: true,
nodeCount: graph.nodes?.length || 0,
firstNodeType: graph.nodes?.[0]?.type || null
}
} catch (error) {
return { success: false, error: error.message }
}
})
expect(nodeInfo.success).toBe(true)
expect(nodeInfo.nodeCount).toBe(1)
expect(nodeInfo.firstNodeType).toBe('TestNode')
})
test('Handles missing workflow data gracefully', async ({ comfyPage }) => {
// Set up empty history routes
await comfyPage.setupHistory().setupRoutes()
// Test loading from history with invalid prompt_id
const result = await comfyPage.page.evaluate(async () => {
try {
const workflow =
await window['app'].api.getWorkflowFromHistory('invalid-id')
return { success: true, workflow }
} catch (error) {
console.error('Expected error for invalid prompt_id:', error)
return { success: false, error: error.message }
}
})
// Should handle gracefully without throwing
expect(result.success).toBe(true)
expect(result.workflow).toBeNull()
})
})

View File

@@ -187,6 +187,7 @@ test.describe('Workflows sidebar', () => {
test('Can save workflow as with same name', async ({ comfyPage }) => { test('Can save workflow as with same name', async ({ comfyPage }) => {
await comfyPage.menu.topbar.saveWorkflow('workflow5.json') await comfyPage.menu.topbar.saveWorkflow('workflow5.json')
await comfyPage.nextFrame()
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([ expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow5.json' 'workflow5.json'
]) ])

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,12 +1,10 @@
import type { Locator, Page } from '@playwright/test' import type { Locator } from '@playwright/test'
import type { NodeId } from '../../../../../src/platform/workflow/validation/schemas/workflowSchema'
import { getSlotKey } from '../../../../../src/renderer/core/layout/slots/slotIdentifier' import { getSlotKey } from '../../../../../src/renderer/core/layout/slots/slotIdentifier'
import { import {
comfyExpect as expect, comfyExpect as expect,
comfyPageFixture as test comfyPageFixture as test
} from '../../../../fixtures/ComfyPage' } from '../../../../fixtures/ComfyPage'
import { getMiddlePoint } from '../../../../fixtures/utils/litegraphUtils'
import { fitToViewInstant } from '../../../../helpers/fitToView' import { fitToViewInstant } from '../../../../helpers/fitToView'
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> { async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
@@ -18,87 +16,6 @@ async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
} }
} }
async function getInputLinkDetails(
page: Page,
nodeId: NodeId,
slotIndex: number
) {
return await page.evaluate(
([targetNodeId, targetSlot]) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) return null
const node = graph.getNodeById(targetNodeId)
if (!node) return null
const input = node.inputs?.[targetSlot]
if (!input) return null
const linkId = input.link
if (linkId == null) return null
const link = graph.getLink?.(linkId)
if (!link) return null
return {
id: link.id,
originId: link.origin_id,
originSlot:
typeof link.origin_slot === 'string'
? Number.parseInt(link.origin_slot, 10)
: link.origin_slot,
targetId: link.target_id,
targetSlot:
typeof link.target_slot === 'string'
? Number.parseInt(link.target_slot, 10)
: link.target_slot,
parentId: link.parentId ?? null
}
},
[nodeId, slotIndex] as const
)
}
// Test helpers to reduce repetition across cases
function slotLocator(
page: Page,
nodeId: NodeId,
slotIndex: number,
isInput: boolean
) {
const key = getSlotKey(String(nodeId), slotIndex, isInput)
return page.locator(`[data-slot-key="${key}"]`)
}
async function expectVisibleAll(...locators: Locator[]) {
await Promise.all(locators.map((l) => expect(l).toBeVisible()))
}
async function getSlotCenter(
page: Page,
nodeId: NodeId,
slotIndex: number,
isInput: boolean
) {
const locator = slotLocator(page, nodeId, slotIndex, isInput)
await expect(locator).toBeVisible()
return await getCenter(locator)
}
async function connectSlots(
page: Page,
from: { nodeId: NodeId; index: number },
to: { nodeId: NodeId; index: number },
nextFrame: () => Promise<void>
) {
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
const toLoc = slotLocator(page, to.nodeId, to.index, true)
await expectVisibleAll(fromLoc, toLoc)
await fromLoc.dragTo(toLoc)
await nextFrame()
}
test.describe('Vue Node Link Interaction', () => { test.describe('Vue Node Link Interaction', () => {
test.beforeEach(async ({ comfyPage }) => { test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
@@ -113,13 +30,21 @@ test.describe('Vue Node Link Interaction', () => {
comfyPage, comfyPage,
comfyMouse comfyMouse
}) => { }) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
expect(samplerNode).toBeTruthy() expect(samplerNodes.length).toBeGreaterThan(0)
const slot = slotLocator(comfyPage.page, samplerNode.id, 0, false) const samplerNode = samplerNodes[0]
await expect(slot).toBeVisible() const outputSlot = await samplerNode.getOutput(0)
await outputSlot.removeLinks()
await comfyPage.nextFrame()
const start = await getCenter(slot) const slotKey = getSlotKey(String(samplerNode.id), 0, false)
const slotLocator = comfyPage.page.locator(`[data-slot-key="${slotKey}"]`)
await expect(slotLocator).toBeVisible()
const start = await getCenter(slotLocator)
const canvasBox = await comfyPage.canvas.boundingBox()
if (!canvasBox) throw new Error('Canvas bounding box not available')
// Arbitrary value // Arbitrary value
const dragTarget = { const dragTarget = {
@@ -143,24 +68,58 @@ test.describe('Vue Node Link Interaction', () => {
test('should create a link when dropping on a compatible slot', async ({ test('should create a link when dropping on a compatible slot', async ({
comfyPage comfyPage
}) => { }) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0] expect(samplerNodes.length).toBeGreaterThan(0)
expect(samplerNode && vaeNode).toBeTruthy() const samplerNode = samplerNodes[0]
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
expect(vaeNodes.length).toBeGreaterThan(0)
const vaeNode = vaeNodes[0]
const samplerOutput = await samplerNode.getOutput(0) const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0) const vaeInput = await vaeNode.getInput(0)
await connectSlots( const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
comfyPage.page, const inputSlotKey = getSlotKey(String(vaeNode.id), 0, true)
{ nodeId: samplerNode.id, index: 0 },
{ nodeId: vaeNode.id, index: 0 }, const outputSlot = comfyPage.page.locator(
() => comfyPage.nextFrame() `[data-slot-key="${outputSlotKey}"]`
) )
const inputSlot = comfyPage.page.locator(
`[data-slot-key="${inputSlotKey}"]`
)
await expect(outputSlot).toBeVisible()
await expect(inputSlot).toBeVisible()
await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(1) expect(await samplerOutput.getLinkCount()).toBe(1)
expect(await vaeInput.getLinkCount()).toBe(1) expect(await vaeInput.getLinkCount()).toBe(1)
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0) const linkDetails = await comfyPage.page.evaluate((sourceId) => {
const app = window['app']
const graph = app?.canvas?.graph
if (!graph) return null
const source = graph.getNodeById(sourceId)
if (!source) return null
const linkId = source.outputs[0]?.links?.[0]
if (linkId == null) return null
const link = graph.links[linkId]
if (!link) return null
return {
originId: link.origin_id,
originSlot: link.origin_slot,
targetId: link.target_id,
targetSlot: link.target_slot
}
}, samplerNode.id)
expect(linkDetails).not.toBeNull() expect(linkDetails).not.toBeNull()
expect(linkDetails).toMatchObject({ expect(linkDetails).toMatchObject({
originId: samplerNode.id, originId: samplerNode.id,
@@ -173,16 +132,29 @@ test.describe('Vue Node Link Interaction', () => {
test('should not create a link when slot types are incompatible', async ({ test('should not create a link when slot types are incompatible', async ({
comfyPage comfyPage
}) => { }) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0] expect(samplerNodes.length).toBeGreaterThan(0)
expect(samplerNode && clipNode).toBeTruthy() const samplerNode = samplerNodes[0]
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
expect(clipNodes.length).toBeGreaterThan(0)
const clipNode = clipNodes[0]
const samplerOutput = await samplerNode.getOutput(0) const samplerOutput = await samplerNode.getOutput(0)
const clipInput = await clipNode.getInput(0) const clipInput = await clipNode.getInput(0)
const outputSlot = slotLocator(comfyPage.page, samplerNode.id, 0, false) const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true) const inputSlotKey = getSlotKey(String(clipNode.id), 0, true)
await expectVisibleAll(outputSlot, inputSlot)
const outputSlot = comfyPage.page.locator(
`[data-slot-key="${outputSlotKey}"]`
)
const inputSlot = comfyPage.page.locator(
`[data-slot-key="${inputSlotKey}"]`
)
await expect(outputSlot).toBeVisible()
await expect(inputSlot).toBeVisible()
await outputSlot.dragTo(inputSlot) await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame() await comfyPage.nextFrame()
@@ -190,507 +162,60 @@ test.describe('Vue Node Link Interaction', () => {
expect(await samplerOutput.getLinkCount()).toBe(0) expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await clipInput.getLinkCount()).toBe(0) expect(await clipInput.getLinkCount()).toBe(0)
const graphLinkDetails = await getInputLinkDetails( const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
comfyPage.page, const app = window['app']
clipNode.id, const graph = app?.canvas?.graph
0 if (!graph) return 0
)
expect(graphLinkDetails).toBeNull() const source = graph.getNodeById(sourceId)
if (!source) return 0
return source.outputs[0]?.links?.length ?? 0
}, samplerNode.id)
expect(graphLinkCount).toBe(0)
}) })
test('should not create a link when dropping onto a slot on the same node', async ({ test('should not create a link when dropping onto a slot on the same node', async ({
comfyPage comfyPage
}) => { }) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0] const samplerNodes = await comfyPage.getNodeRefsByType('KSampler')
expect(samplerNode).toBeTruthy() expect(samplerNodes.length).toBeGreaterThan(0)
const samplerNode = samplerNodes[0]
const samplerOutput = await samplerNode.getOutput(0) const samplerOutput = await samplerNode.getOutput(0)
const samplerInput = await samplerNode.getInput(3) const samplerInput = await samplerNode.getInput(3)
const outputSlot = slotLocator(comfyPage.page, samplerNode.id, 0, false) const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false)
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true) const inputSlotKey = getSlotKey(String(samplerNode.id), 3, true)
await expectVisibleAll(outputSlot, inputSlot)
const outputSlot = comfyPage.page.locator(
`[data-slot-key="${outputSlotKey}"]`
)
const inputSlot = comfyPage.page.locator(
`[data-slot-key="${inputSlotKey}"]`
)
await expect(outputSlot).toBeVisible()
await expect(inputSlot).toBeVisible()
await outputSlot.dragTo(inputSlot) await outputSlot.dragTo(inputSlot)
await comfyPage.nextFrame() await comfyPage.nextFrame()
expect(await samplerOutput.getLinkCount()).toBe(0) expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await samplerInput.getLinkCount()).toBe(0) expect(await samplerInput.getLinkCount()).toBe(0)
})
test('should reuse the existing origin when dragging an input link', async ({ const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
comfyPage, const app = window['app']
comfyMouse const graph = app?.canvas?.graph
}) => { if (!graph) return 0
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
await comfyMouse.move(samplerOutputCenter) const source = graph.getNodeById(sourceId)
await comfyMouse.drag(vaeInputCenter) if (!source) return 0
await comfyMouse.drop()
const dragTarget = { return source.outputs[0]?.links?.length ?? 0
x: vaeInputCenter.x + 160, }, samplerNode.id)
y: vaeInputCenter.y - 100
}
await comfyMouse.move(vaeInputCenter) expect(graphLinkCount).toBe(0)
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-input-drag-reuses-origin.png'
)
await comfyMouse.drop()
})
test('ctrl+alt drag from an input starts a fresh link', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
const samplerOutputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
await comfyMouse.move(samplerOutputCenter)
await comfyMouse.drag(vaeInputCenter)
await comfyMouse.drop()
await comfyPage.nextFrame()
const dragTarget = {
x: vaeInputCenter.x + 140,
y: vaeInputCenter.y - 110
}
await comfyMouse.move(vaeInputCenter)
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Alt')
try {
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-input-drag-ctrl-alt.png'
)
} finally {
await comfyMouse.drop().catch(() => {})
await comfyPage.page.keyboard.up('Alt').catch(() => {})
await comfyPage.page.keyboard.up('Control').catch(() => {})
}
await comfyPage.nextFrame()
// Tcehnically intended to disconnect existing as well
expect(await vaeInput.getLinkCount()).toBe(0)
expect(await samplerOutput.getLinkCount()).toBe(0)
})
test('dropping an input link back on its slot restores the original connection', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
const samplerOutputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
await comfyMouse.move(samplerOutputCenter)
try {
await comfyMouse.drag(vaeInputCenter)
} finally {
await comfyMouse.drop()
}
await comfyPage.nextFrame()
const originalLink = await getInputLinkDetails(
comfyPage.page,
vaeNode.id,
0
)
expect(originalLink).not.toBeNull()
const dragTarget = {
x: vaeInputCenter.x + 150,
y: vaeInputCenter.y - 100
}
// To prevent needing a screenshot expectation for whether the link's off
const vaeInputLocator = slotLocator(comfyPage.page, vaeNode.id, 0, true)
const inputBox = await vaeInputLocator.boundingBox()
if (!inputBox) throw new Error('Input slot bounding box not available')
const isOutsideX =
dragTarget.x < inputBox.x || dragTarget.x > inputBox.x + inputBox.width
const isOutsideY =
dragTarget.y < inputBox.y || dragTarget.y > inputBox.y + inputBox.height
expect(isOutsideX || isOutsideY).toBe(true)
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drag(dragTarget)
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drop()
await comfyPage.nextFrame()
const restoredLink = await getInputLinkDetails(
comfyPage.page,
vaeNode.id,
0
)
expect(restoredLink).not.toBeNull()
if (!restoredLink || !originalLink) {
throw new Error('Expected both original and restored links to exist')
}
expect(restoredLink).toMatchObject({
originId: originalLink.originId,
originSlot: originalLink.originSlot,
targetId: originalLink.targetId,
targetSlot: originalLink.targetSlot,
parentId: originalLink.parentId
})
expect(await samplerOutput.getLinkCount()).toBe(1)
expect(await vaeInput.getLinkCount()).toBe(1)
})
test('rerouted input drag preview remains anchored to reroute', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
await connectSlots(
comfyPage.page,
{ nodeId: samplerNode.id, index: 0 },
{ nodeId: vaeNode.id, index: 0 },
() => comfyPage.nextFrame()
)
const outputPosition = await samplerOutput.getPosition()
const inputPosition = await vaeInput.getPosition()
const reroutePoint = getMiddlePoint(outputPosition, inputPosition)
// Insert a reroute programmatically on the existing link between sampler output[0] and VAE input[0].
// This avoids relying on an exact path hit-test position.
await comfyPage.page.evaluate(
([targetNodeId, targetSlot, clientPoint]) => {
const app = (window as any)['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) throw new Error('Graph not available')
const node = graph.getNodeById(targetNodeId)
if (!node) throw new Error('Target node not found')
const input = node.inputs?.[targetSlot]
if (!input) throw new Error('Target input slot not found')
const linkId = input.link
if (linkId == null) throw new Error('Expected existing link on input')
const link = graph.getLink(linkId)
if (!link) throw new Error('Link not found')
// Convert the client/canvas pixel coordinates to graph space
const pos = app.canvas.ds.convertCanvasToOffset([
clientPoint.x,
clientPoint.y
])
graph.createReroute(pos, link)
},
[vaeNode.id, 0, reroutePoint] as const
)
await comfyPage.nextFrame()
const vaeInputCenter = await getSlotCenter(
comfyPage.page,
vaeNode.id,
0,
true
)
const dragTarget = {
x: vaeInputCenter.x + 160,
y: vaeInputCenter.y - 120
}
let dropped = false
try {
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-reroute-input-drag.png'
)
await comfyMouse.move(vaeInputCenter)
await comfyMouse.drop()
dropped = true
} finally {
if (!dropped) {
await comfyMouse.drop().catch(() => {})
}
}
await comfyPage.nextFrame()
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
expect(linkDetails).not.toBeNull()
expect(linkDetails?.originId).toBe(samplerNode.id)
expect(linkDetails?.parentId).not.toBeNull()
})
test('rerouted output shift-drag preview remains anchored to reroute', async ({
comfyPage,
comfyMouse
}) => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutput = await samplerNode.getOutput(0)
const vaeInput = await vaeNode.getInput(0)
await connectSlots(
comfyPage.page,
{ nodeId: samplerNode.id, index: 0 },
{ nodeId: vaeNode.id, index: 0 },
() => comfyPage.nextFrame()
)
const outputPosition = await samplerOutput.getPosition()
const inputPosition = await vaeInput.getPosition()
const reroutePoint = getMiddlePoint(outputPosition, inputPosition)
// Insert a reroute programmatically on the existing link between sampler output[0] and VAE input[0].
// This avoids relying on an exact path hit-test position.
await comfyPage.page.evaluate(
([targetNodeId, targetSlot, clientPoint]) => {
const app = (window as any)['app']
const graph = app?.canvas?.graph ?? app?.graph
if (!graph) throw new Error('Graph not available')
const node = graph.getNodeById(targetNodeId)
if (!node) throw new Error('Target node not found')
const input = node.inputs?.[targetSlot]
if (!input) throw new Error('Target input slot not found')
const linkId = input.link
if (linkId == null) throw new Error('Expected existing link on input')
const link = graph.getLink(linkId)
if (!link) throw new Error('Link not found')
// Convert the client/canvas pixel coordinates to graph space
const pos = app.canvas.ds.convertCanvasToOffset([
clientPoint.x,
clientPoint.y
])
graph.createReroute(pos, link)
},
[vaeNode.id, 0, reroutePoint] as const
)
await comfyPage.nextFrame()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dragTarget = {
x: outputCenter.x + 150,
y: outputCenter.y - 140
}
let dropPending = false
let shiftHeld = false
try {
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
shiftHeld = true
dropPending = true
await comfyMouse.drag(dragTarget)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-reroute-output-shift-drag.png'
)
await comfyMouse.move(outputCenter)
await comfyMouse.drop()
dropPending = false
} finally {
if (dropPending) await comfyMouse.drop().catch(() => {})
if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
await comfyPage.nextFrame()
const linkDetails = await getInputLinkDetails(comfyPage.page, vaeNode.id, 0)
expect(linkDetails).not.toBeNull()
expect(linkDetails?.originId).toBe(samplerNode.id)
expect(linkDetails?.parentId).not.toBeNull()
})
test('dragging input to input drags existing link', async ({
comfyPage,
comfyMouse
}) => {
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(clipNode && samplerNode).toBeTruthy()
// Step 1: Connect CLIP's only output (index 0) to KSampler's second input (index 1)
await connectSlots(
comfyPage.page,
{ nodeId: clipNode.id, index: 0 },
{ nodeId: samplerNode.id, index: 1 },
() => comfyPage.nextFrame()
)
// Verify initial link exists between CLIP -> KSampler input[1]
const initialLink = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
1
)
expect(initialLink).not.toBeNull()
expect(initialLink).toMatchObject({
originId: clipNode.id,
targetId: samplerNode.id,
targetSlot: 1
})
// Step 2: Drag from KSampler's second input to its third input (index 2)
const input2Center = await getSlotCenter(
comfyPage.page,
samplerNode.id,
1,
true
)
const input3Center = await getSlotCenter(
comfyPage.page,
samplerNode.id,
2,
true
)
await comfyMouse.move(input2Center)
await comfyMouse.drag(input3Center)
await comfyMouse.drop()
await comfyPage.nextFrame()
// Expect old link removed from input[1]
const afterSecondInput = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
1
)
expect(afterSecondInput).toBeNull()
// Expect new link exists at input[2] from CLIP
const afterThirdInput = await getInputLinkDetails(
comfyPage.page,
samplerNode.id,
2
)
expect(afterThirdInput).not.toBeNull()
expect(afterThirdInput).toMatchObject({
originId: clipNode.id,
targetId: samplerNode.id,
targetSlot: 2
})
})
test('shift-dragging an output with multiple links should drag all links', async ({
comfyPage,
comfyMouse
}) => {
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(clipNode && samplerNode).toBeTruthy()
const clipOutput = await clipNode.getOutput(0)
// Connect output[0] -> inputs[1] and [2]
await connectSlots(
comfyPage.page,
{ nodeId: clipNode.id, index: 0 },
{ nodeId: samplerNode.id, index: 1 },
() => comfyPage.nextFrame()
)
await connectSlots(
comfyPage.page,
{ nodeId: clipNode.id, index: 0 },
{ nodeId: samplerNode.id, index: 2 },
() => comfyPage.nextFrame()
)
expect(await clipOutput.getLinkCount()).toBe(2)
const outputCenter = await getSlotCenter(
comfyPage.page,
clipNode.id,
0,
false
)
const dragTarget = {
x: outputCenter.x + 40,
y: outputCenter.y - 140
}
let dropPending = false
let shiftHeld = false
try {
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
shiftHeld = true
await comfyMouse.drag(dragTarget)
dropPending = true
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-shift-output-multi-link.png'
)
} finally {
if (dropPending) await comfyMouse.drop().catch(() => {})
if (shiftHeld) await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
}) })
}) })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -1,21 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Upload Widgets', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.loadWorkflow('widgets/all_load_widgets')
await comfyPage.vueNodes.waitForNodes()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-upload-widgets.png'
)
})
})

View File

@@ -18,12 +18,28 @@ export default defineConfig([
'src/scripts/*', 'src/scripts/*',
'src/extensions/core/*', 'src/extensions/core/*',
'src/types/vue-shim.d.ts', 'src/types/vue-shim.d.ts',
'packages/registry-types/src/comfyRegistryTypes.ts', 'src/types/comfyRegistryTypes.ts',
'src/types/generatedManagerTypes.ts', 'src/types/generatedManagerTypes.ts',
'**/vite.config.*.timestamp*', '**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*' '**/vitest.config.*.timestamp*',
'lint-staged.config.js',
'vitest.litegraph.config.ts'
] ]
}, },
{
files: ['./**/*.js'],
languageOptions: {
globals: {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
},
ecmaVersion: 2020,
sourceType: 'module'
},
rules: {
'@typescript-eslint/no-floating-promises': 'off'
}
},
{ {
files: ['./**/*.{ts,mts}'], files: ['./**/*.{ts,mts}'],
languageOptions: { languageOptions: {
@@ -36,8 +52,8 @@ export default defineConfig([
projectService: { projectService: {
allowDefaultProject: [ allowDefaultProject: [
'vite.config.mts', 'vite.config.mts',
'vite.electron.config.mts', 'vite.types.config.mts',
'vite.types.config.mts' 'vitest.litegraph.config.ts'
] ]
}, },
tsConfigRootDir: import.meta.dirname, tsConfigRootDir: import.meta.dirname,

View File

@@ -3,6 +3,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>ComfyUI</title> <title>ComfyUI</title>
<!-- All assets should be loaded from the root no matter the initial path -->
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<link rel="stylesheet" type="text/css" href="materialdesignicons.min.css" /> <link rel="stylesheet" type="text/css" href="materialdesignicons.min.css" />
<link rel="stylesheet" type="text/css" href="user.css" /> <link rel="stylesheet" type="text/css" href="user.css" />
@@ -12,10 +14,10 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<!-- Status bar style (eg. black or transparent) --> <!-- Status bar style (eg. black or transparent) -->
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
</head> </head>
<body class="litegraph grid"> <body class="litegraph grid">
<div id="vue-app"></div> <div id="vue-app"></div>
<script type="module" src="src/main.ts"></script> <script type="module" src="src/main.ts"></script>

View File

@@ -18,13 +18,8 @@ const config: KnipConfig = {
'packages/design-system': { 'packages/design-system': {
entry: ['src/**/*.ts'], entry: ['src/**/*.ts'],
project: ['src/**/*.{js,ts}', '*.{js,ts,mts}'] project: ['src/**/*.{js,ts}', '*.{js,ts,mts}']
},
'packages/registry-types': {
entry: ['src/comfyRegistryTypes.ts'],
project: ['src/**/*.{js,ts}']
} }
}, },
ignoreBinaries: ['python3'],
ignoreDependencies: [ ignoreDependencies: [
// Weird importmap things // Weird importmap things
'@iconify/json', '@iconify/json',
@@ -38,7 +33,7 @@ const config: KnipConfig = {
ignore: [ ignore: [
// Auto generated manager types // Auto generated manager types
'src/workbench/extensions/manager/types/generatedManagerTypes.ts', 'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts', 'src/types/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this) // Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts' 'src/scripts/ui/components/splitButton.ts'
], ],

View File

@@ -1,15 +1,8 @@
export default { export default {
'./**/*.js': (stagedFiles) => formatAndEslint(stagedFiles), './**/*.js': 'pnpm exec eslint --cache --fix',
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [ './**/*.{ts,tsx,vue,mts}': [
...formatAndEslint(stagedFiles), 'pnpm exec eslint --cache --fix',
'pnpm typecheck' 'pnpm exec prettier --cache --write'
]
}
function formatAndEslint(fileNames) {
return [
`pnpm exec eslint --cache --fix ${fileNames.join(' ')}`,
`pnpm exec prettier --cache --write ${fileNames.join(' ')}`
] ]
} }

15
lint-staged.config.mjs Normal file
View File

@@ -0,0 +1,15 @@
export default {
'./**/*.js': (stagedFiles) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
...formatAndEslint(stagedFiles),
'pnpm typecheck'
]
}
function formatAndEslint(fileNames) {
return [
`pnpm exec eslint --cache --fix ${fileNames.join(' ')}`,
`pnpm exec prettier --cache --write ${fileNames.join(' ')}`
]
}

View File

@@ -38,8 +38,7 @@
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts", "collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"json-schema": "tsx scripts/generate-json-schema.ts", "json-schema": "tsx scripts/generate-json-schema.ts",
"storybook": "nx storybook -p 6006", "storybook": "nx storybook -p 6006",
"build-storybook": "storybook build", "build-storybook": "storybook build"
"devtools:pycheck": "python3 -m compileall -q tools/devtools"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.35.0", "@eslint/js": "^9.35.0",
@@ -107,7 +106,6 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1", "@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "0.4.73-0", "@comfyorg/comfyui-electron-types": "0.4.73-0",
"@comfyorg/design-system": "workspace:*", "@comfyorg/design-system": "workspace:*",
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*", "@comfyorg/tailwind-utils": "workspace:*",
"@iconify/json": "^2.2.380", "@iconify/json": "^2.2.380",
"@primeuix/forms": "0.0.2", "@primeuix/forms": "0.0.2",

View File

@@ -1,16 +0,0 @@
{
"name": "@comfyorg/registry-types",
"version": "1.0.0",
"description": "Comfy Registry API TypeScript types",
"packageManager": "pnpm@10.17.1",
"type": "module",
"exports": {
".": "./src/comfyRegistryTypes.ts"
},
"nx": {
"tags": [
"scope:shared",
"type:types"
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
}

View File

@@ -1,22 +0,0 @@
{
"name": "@comfyorg/shared-frontend-utils",
"private": true,
"version": "1.0.0",
"description": "Shared frontend utils for ComfyUI Frontend",
"scripts": {
"typecheck": "tsc --noEmit"
},
"keywords": [],
"packageManager": "pnpm@10.17.1",
"type": "module",
"exports": {
"./formatUtil": "./src/formatUtil.ts",
"./networkUtil": "./src/networkUtil.ts"
},
"dependencies": {
"axios": "^1.11.0"
},
"devDependencies": {
"typescript": "^5.9.2"
}
}

View File

@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
}

23
pnpm-lock.yaml generated
View File

@@ -20,9 +20,6 @@ importers:
'@comfyorg/design-system': '@comfyorg/design-system':
specifier: workspace:* specifier: workspace:*
version: link:packages/design-system version: link:packages/design-system
'@comfyorg/registry-types':
specifier: workspace:*
version: link:packages/registry-types
'@comfyorg/tailwind-utils': '@comfyorg/tailwind-utils':
specifier: workspace:* specifier: workspace:*
version: link:packages/tailwind-utils version: link:packages/tailwind-utils
@@ -371,18 +368,6 @@ importers:
specifier: ^5.4.5 specifier: ^5.4.5
version: 5.9.2 version: 5.9.2
packages/registry-types: {}
packages/shared-frontend-utils:
dependencies:
axios:
specifier: ^1.11.0
version: 1.11.0
devDependencies:
typescript:
specifier: ^5.9.2
version: 5.9.2
packages/tailwind-utils: packages/tailwind-utils:
dependencies: dependencies:
clsx: clsx:
@@ -6522,8 +6507,8 @@ packages:
vue-component-type-helpers@3.0.7: vue-component-type-helpers@3.0.7:
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==} resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
vue-component-type-helpers@3.1.0: vue-component-type-helpers@3.0.8:
resolution: {integrity: sha512-cC1pYNRZkSS1iCvdlaMbbg2sjDwxX098FucEjtz9Yig73zYjWzQsnMe5M9H8dRNv55hAIDGUI29hF2BEUA4FMQ==} resolution: {integrity: sha512-WyR30Eq15Y/+odrUUMax6FmPbZwAp/HnC7qgR1r3lVFAcqwQ4wUoV79Mbh4SxDy3NiqDa+G4TOKD5xXSgBHo5A==}
vue-demi@0.14.10: vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -9081,7 +9066,7 @@ snapshots:
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)) storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0 type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2) vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.1.0 vue-component-type-helpers: 3.0.8
'@swc/helpers@0.5.17': '@swc/helpers@0.5.17':
dependencies: dependencies:
@@ -13879,7 +13864,7 @@ snapshots:
vue-component-type-helpers@3.0.7: {} vue-component-type-helpers@3.0.7: {}
vue-component-type-helpers@3.1.0: {} vue-component-type-helpers@3.0.8: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)): vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies: dependencies:

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,77 +0,0 @@
#!/bin/bash
set -euo pipefail
usage() {
echo "Usage: $0 [--debug]" >&2
}
debug=0
while [ "$#" -gt 0 ]; do
case "$1" in
--debug)
debug=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage
exit 2
;;
esac
shift
done
# Validate JSON syntax in tracked files using jq
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq is required but not installed" >&2
exit 127
fi
EXCLUDE_PATTERNS=(
'**/tsconfig*.json'
)
if [ -n "${JSON_LINT_EXCLUDES:-}" ]; then
# shellcheck disable=SC2206
EXCLUDE_PATTERNS+=( ${JSON_LINT_EXCLUDES} )
fi
pathspecs=(-- '*.json')
for pattern in "${EXCLUDE_PATTERNS[@]}"; do
if [[ ${pattern:0:1} == ':' ]]; then
pathspecs+=("$pattern")
else
pathspecs+=(":(glob,exclude)${pattern}")
fi
done
mapfile -t json_files < <(git ls-files "${pathspecs[@]}")
if [ "${#json_files[@]}" -eq 0 ]; then
echo 'No JSON files found.'
exit 0
fi
if [ "$debug" -eq 1 ]; then
echo 'JSON files to validate:'
printf ' %s\n' "${json_files[@]}"
fi
failed=0
for file in "${json_files[@]}"; do
if ! jq -e . "$file" >/dev/null; then
echo "Invalid JSON syntax: $file" >&2
failed=1
fi
done
if [ "$failed" -ne 0 ]; then
echo 'JSON validation failed.' >&2
exit 1
fi
echo 'All JSON files are valid.'

View File

@@ -1,247 +0,0 @@
#!/bin/bash
set -e
# Deploy Storybook to Cloudflare Pages and comment on PR
# Usage: ./pr-storybook-deploy-and-comment.sh <pr_number> <branch_name> <status> [start_time]
# Input validation
# Validate PR number is numeric
case "$1" in
''|*[!0-9]*)
echo "Error: PR_NUMBER must be numeric" >&2
exit 1
;;
esac
PR_NUMBER="$1"
# Sanitize and validate branch name (allow alphanumeric, dots, dashes, underscores, slashes)
BRANCH_NAME=$(echo "$2" | sed 's/[^a-zA-Z0-9._/-]//g')
if [ -z "$BRANCH_NAME" ]; then
echo "Error: Invalid or empty branch name" >&2
exit 1
fi
# Validate status parameter
STATUS="${3:-completed}"
case "$STATUS" in
starting|completed) ;;
*)
echo "Error: STATUS must be 'starting' or 'completed'" >&2
exit 1
;;
esac
START_TIME="${4:-$(date -u '+%m/%d/%Y, %I:%M:%S %p')}"
# Required environment variables
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
# Cloudflare variables only required for deployment
if [ "$STATUS" = "completed" ]; then
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required for deployment}"
: "${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required for deployment}"
fi
# Configuration
COMMENT_MARKER="<!-- STORYBOOK_BUILD_STATUS -->"
# Install wrangler if not available (output to stderr for debugging)
if ! command -v wrangler > /dev/null 2>&1; then
echo "Installing wrangler v4..." >&2
npm install -g wrangler@^4.0.0 >&2 || {
echo "Failed to install wrangler" >&2
echo "failed"
return
}
fi
# Deploy Storybook report, WARN: ensure inputs are sanitized before calling this function
deploy_storybook() {
dir="$1"
branch="$2"
[ ! -d "$dir" ] && echo "failed" && return
project="comfy-storybook"
echo "Deploying Storybook to project $project on branch $branch..." >&2
# Try deployment up to 3 times
i=1
while [ $i -le 3 ]; do
echo "Deployment attempt $i of 3..." >&2
# Branch is already sanitized, use it directly
if output=$(wrangler pages deploy "$dir" \
--project-name="$project" \
--branch="$branch" 2>&1); then
# Extract URL from output (improved regex for valid URL characters)
url=$(echo "$output" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
result="${url:-https://${branch}.${project}.pages.dev}"
echo "Success! URL: $result" >&2
echo "$result" # Only this goes to stdout for capture
return
else
echo "Deployment failed on attempt $i: $output" >&2
fi
[ $i -lt 3 ] && sleep 10
i=$((i + 1))
done
echo "failed"
}
# Post or update GitHub comment
post_comment() {
body="$1"
temp_file=$(mktemp)
echo "$body" > "$temp_file"
if command -v gh > /dev/null 2>&1; then
# Find existing comment ID
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
--jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" | head -1)
if [ -n "$existing" ]; then
# Update specific comment by ID
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" \
--field body="$(cat "$temp_file")"
else
gh pr comment "$PR_NUMBER" --body-file "$temp_file"
fi
else
echo "GitHub CLI not available, outputting comment:"
cat "$temp_file"
fi
rm -f "$temp_file"
}
# Main execution
if [ "$STATUS" = "starting" ]; then
# Check if this is a version-bump branch
IS_VERSION_BUMP="false"
if echo "$BRANCH_NAME" | grep -q "^version-bump-"; then
IS_VERSION_BUMP="true"
fi
# Post starting comment with appropriate message
if [ "$IS_VERSION_BUMP" = "true" ]; then
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎨 Storybook Build Status
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Build is starting...**
⏰ Started at: $START_TIME UTC
### 🚀 Building Storybook
- 📦 Installing dependencies...
- 🔧 Building Storybook components...
- 🎨 Running Chromatic visual tests...
---
⏱️ Please wait while the Storybook build is in progress...
EOF
)
else
comment=$(cat <<EOF
$COMMENT_MARKER
## 🎨 Storybook Build Status
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Build is starting...**
⏰ Started at: $START_TIME UTC
### 🚀 Building Storybook
- 📦 Installing dependencies...
- 🔧 Building Storybook components...
- 🌐 Preparing deployment to Cloudflare Pages...
---
⏱️ Please wait while the Storybook build is in progress...
EOF
)
fi
post_comment "$comment"
elif [ "$STATUS" = "completed" ]; then
# Deploy and post completion comment
# Convert branch name to Cloudflare-compatible format (lowercase, only alphanumeric and dashes)
cloudflare_branch=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | \
sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
echo "Looking for Storybook build in: $(pwd)/storybook-static"
# Deploy Storybook if build exists
deployment_url="Not deployed"
if [ -d "storybook-static" ]; then
echo "Found Storybook build, deploying..."
url=$(deploy_storybook "storybook-static" "$cloudflare_branch")
if [ "$url" != "failed" ] && [ -n "$url" ]; then
deployment_url="[View Storybook]($url)"
else
deployment_url="Deployment failed"
fi
else
echo "Storybook build not found at storybook-static"
fi
# Get workflow conclusion from environment or default to success
WORKFLOW_CONCLUSION="${WORKFLOW_CONCLUSION:-success}"
WORKFLOW_URL="${WORKFLOW_URL:-}"
# Generate completion comment based on conclusion
if [ "$WORKFLOW_CONCLUSION" = "success" ]; then
status_icon="✅"
status_text="Build completed successfully!"
footer_text="🎉 Your Storybook is ready for review!"
elif [ "$WORKFLOW_CONCLUSION" = "skipped" ]; then
status_icon="⏭️"
status_text="Build skipped."
footer_text=" Chromatic was skipped for this PR."
elif [ "$WORKFLOW_CONCLUSION" = "cancelled" ]; then
status_icon="🚫"
status_text="Build cancelled."
footer_text=" The Chromatic run was cancelled."
else
status_icon="❌"
status_text="Build failed!"
footer_text="⚠️ Please check the workflow logs for error details."
fi
comment="$COMMENT_MARKER
## 🎨 Storybook Build Status
$status_icon **$status_text**
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
### 🔗 Links
- [📊 View Workflow Run]($WORKFLOW_URL)"
# Add deployment status
if [ "$deployment_url" != "Not deployed" ]; then
if [ "$deployment_url" = "Deployment failed" ]; then
comment="$comment
- ❌ Storybook deployment failed"
elif [ "$WORKFLOW_CONCLUSION" = "success" ]; then
comment="$comment
- 🎨 $deployment_url"
else
comment="$comment
- ⚠️ Build failed - $deployment_url"
fi
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
comment="$comment
- ⏭️ Storybook deployment skipped (build did not succeed)"
fi
comment="$comment
---
$footer_text"
post_comment "$comment"
fi

View File

@@ -1,15 +1,12 @@
import * as fs from 'fs' import * as fs from 'fs'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage' import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import {
formatCamelCase,
normalizeI18nKey
} from '../packages/shared-frontend-utils/src/formatUtil'
import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands' import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands'
import { DESKTOP_DIALOGS } from '../src/constants/desktopDialogs' import { DESKTOP_DIALOGS } from '../src/constants/desktopDialogs'
import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig' import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig'
import type { FormItem, SettingParams } from '../src/platform/settings/types' import type { FormItem, SettingParams } from '../src/platform/settings/types'
import type { ComfyCommandImpl } from '../src/stores/commandStore' import type { ComfyCommandImpl } from '../src/stores/commandStore'
import { formatCamelCase, normalizeI18nKey } from '../src/utils/formatUtil'
const localePath = './src/locales/en/main.json' const localePath = './src/locales/en/main.json'
const commandsPath = './src/locales/en/commands.json' const commandsPath = './src/locales/en/commands.json'

View File

@@ -3,8 +3,8 @@ import * as fs from 'fs'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage' import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import { normalizeI18nKey } from '../packages/shared-frontend-utils/src/formatUtil'
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore' import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
import { normalizeI18nKey } from '../src/utils/formatUtil'
const localePath = './src/locales/en/main.json' const localePath = './src/locales/en/main.json'
const nodeDefsPath = './src/locales/en/nodeDefs.json' const nodeDefsPath = './src/locales/en/nodeDefs.json'

365
src/api/auth.ts Normal file
View File

@@ -0,0 +1,365 @@
import * as Sentry from '@sentry/vue'
import { isEmpty } from 'es-toolkit/compat'
import { api } from '@/scripts/api'
interface UserCloudStatus {
status: 'active' | 'waitlisted'
}
const ONBOARDING_SURVEY_KEY = 'onboarding_survey'
/**
* Helper function to capture API errors with Sentry
*/
function captureApiError(
error: Error,
endpoint: string,
errorType: 'http_error' | 'network_error',
httpStatus?: number,
operation?: string,
extraContext?: Record<string, any>
) {
const tags: Record<string, any> = {
api_endpoint: endpoint,
error_type: errorType
}
if (httpStatus !== undefined) {
tags.http_status = httpStatus
}
if (operation) {
tags.operation = operation
}
const sentryOptions: any = {
tags,
extra: extraContext ? { ...extraContext } : undefined
}
Sentry.captureException(error, sentryOptions)
}
/**
* Helper function to check if error is already handled HTTP error
*/
function isHttpError(error: unknown, errorMessagePrefix: string): boolean {
return error instanceof Error && error.message.startsWith(errorMessagePrefix)
}
export async function getUserCloudStatus(): Promise<UserCloudStatus> {
try {
const response = await api.fetchApi('/user', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
const error = new Error(`Failed to get user: ${response.statusText}`)
captureApiError(
error,
'/user',
'http_error',
response.status,
undefined,
{
api: {
method: 'GET',
endpoint: '/user',
status_code: response.status,
status_text: response.statusText
}
}
)
throw error
}
return response.json()
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to get user:')) {
captureApiError(error as Error, '/user', 'network_error')
}
throw error
}
}
export async function getInviteCodeStatus(
inviteCode: string
): Promise<{ claimed: boolean; expired: boolean }> {
try {
const response = await api.fetchApi(
`/invite_code/${encodeURIComponent(inviteCode)}/status`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
)
if (!response.ok) {
const error = new Error(
`Failed to get invite code status: ${response.statusText}`
)
captureApiError(
error,
'/invite_code/{code}/status',
'http_error',
response.status,
undefined,
{
api: {
method: 'GET',
endpoint: `/invite_code/${inviteCode}/status`,
status_code: response.status,
status_text: response.statusText
},
extra: {
invite_code_length: inviteCode.length
},
route_template: '/invite_code/{code}/status',
route_actual: `/invite_code/${inviteCode}/status`
}
)
throw error
}
return response.json()
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to get invite code status:')) {
captureApiError(
error as Error,
'/invite_code/{code}/status',
'network_error',
undefined,
undefined,
{
route_template: '/invite_code/{code}/status',
route_actual: `/invite_code/${inviteCode}/status`
}
)
}
throw error
}
}
export async function getSurveyCompletedStatus(): Promise<boolean> {
try {
const response = await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
// Not an error case - survey not completed is a valid state
Sentry.addBreadcrumb({
category: 'auth',
message: 'Survey status check returned non-ok response',
level: 'info',
data: {
status: response.status,
endpoint: `/settings/${ONBOARDING_SURVEY_KEY}`
}
})
return false
}
const data = await response.json()
// Check if data exists and is not empty
return !isEmpty(data.value)
} catch (error) {
// Network error - still capture it as it's not thrown from above
Sentry.captureException(error, {
tags: {
api_endpoint: '/settings/{key}',
error_type: 'network_error'
},
extra: {
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
},
level: 'warning'
})
return false
}
}
// @ts-expect-error - Unused function kept for future use
async function postSurveyStatus(): Promise<void> {
try {
const response = await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: undefined })
})
if (!response.ok) {
const error = new Error(
`Failed to post survey status: ${response.statusText}`
)
captureApiError(
error,
'/settings/{key}',
'http_error',
response.status,
'post_survey_status',
{
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
}
)
throw error
}
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to post survey status:')) {
captureApiError(
error as Error,
'/settings/{key}',
'network_error',
undefined,
'post_survey_status',
{
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
}
)
}
throw error
}
}
export async function submitSurvey(
survey: Record<string, unknown>
): Promise<void> {
try {
Sentry.addBreadcrumb({
category: 'auth',
message: 'Submitting survey',
level: 'info',
data: {
survey_fields: Object.keys(survey)
}
})
const response = await api.fetchApi('/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: survey })
})
if (!response.ok) {
const error = new Error(`Failed to submit survey: ${response.statusText}`)
captureApiError(
error,
'/settings',
'http_error',
response.status,
'submit_survey',
{
survey: {
field_count: Object.keys(survey).length,
field_names: Object.keys(survey)
}
}
)
throw error
}
// Log successful survey submission
Sentry.addBreadcrumb({
category: 'auth',
message: 'Survey submitted successfully',
level: 'info'
})
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to submit survey:')) {
captureApiError(
error as Error,
'/settings',
'network_error',
undefined,
'submit_survey'
)
}
throw error
}
}
export async function claimInvite(
code: string
): Promise<{ success: boolean; message: string }> {
try {
Sentry.addBreadcrumb({
category: 'auth',
message: 'Attempting to claim invite',
level: 'info',
data: {
code_length: code.length
}
})
const res = await api.fetchApi(
`/invite_code/${encodeURIComponent(code)}/claim`,
{
method: 'POST'
}
)
if (!res.ok) {
const error = new Error(
`Failed to claim invite: ${res.status} ${res.statusText}`
)
captureApiError(
error,
'/invite_code/{code}/claim',
'http_error',
res.status,
'claim_invite',
{
invite: {
code_length: code.length,
status_code: res.status,
status_text: res.statusText
},
route_template: '/invite_code/{code}/claim',
route_actual: `/invite_code/${encodeURIComponent(code)}/claim`
}
)
throw error
}
// Log successful invite claim
Sentry.addBreadcrumb({
category: 'auth',
message: 'Invite claimed successfully',
level: 'info'
})
return res.json()
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to claim invite:')) {
captureApiError(
error as Error,
'/invite_code/{code}/claim',
'network_error',
undefined,
'claim_invite',
{
route_template: '/invite_code/{code}/claim',
route_actual: `/invite_code/${encodeURIComponent(code)}/claim`
}
)
}
throw error
}
}

View File

@@ -32,12 +32,16 @@
</Message> </Message>
<!-- Form --> <!-- Form -->
<SignInForm v-if="isSignIn" @submit="signInWithEmail" /> <SignInForm
v-if="isSignIn"
:auth-error="authError"
@submit="signInWithEmail"
/>
<template v-else> <template v-else>
<Message v-if="userIsInChina" severity="warn" class="mb-4"> <Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }} {{ t('auth.signup.regionRestrictionChina') }}
</Message> </Message>
<SignUpForm v-else @submit="signUpWithEmail" /> <SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
</template> </template>
<!-- Divider --> <!-- Divider -->
@@ -149,6 +153,7 @@ import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi' import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import type { SignInData, SignUpData } from '@/schemas/signInSchema' import type { SignInData, SignUpData } from '@/schemas/signInSchema'
import { translateAuthError } from '@/utils/authErrorTranslation'
import { isInChina } from '@/utils/networkUtil' import { isInChina } from '@/utils/networkUtil'
import ApiKeyForm from './signin/ApiKeyForm.vue' import ApiKeyForm from './signin/ApiKeyForm.vue'
@@ -164,32 +169,58 @@ const authActions = useFirebaseAuthActions()
const isSecureContext = window.isSecureContext const isSecureContext = window.isSecureContext
const isSignIn = ref(true) const isSignIn = ref(true)
const showApiKeyForm = ref(false) const showApiKeyForm = ref(false)
const authError = ref('')
const toggleState = () => { const toggleState = () => {
isSignIn.value = !isSignIn.value isSignIn.value = !isSignIn.value
showApiKeyForm.value = false showApiKeyForm.value = false
authError.value = ''
}
// Custom error handler for inline display
const inlineErrorHandler = (error: unknown) => {
// Set inline error with auth error translation
authError.value = translateAuthError(error)
// Also show toast (original behavior)
authActions.reportError(error)
} }
const signInWithGoogle = async () => { const signInWithGoogle = async () => {
if (await authActions.signInWithGoogle()) { authError.value = ''
if (await authActions.signInWithGoogle(inlineErrorHandler)()) {
onSuccess() onSuccess()
} }
} }
const signInWithGithub = async () => { const signInWithGithub = async () => {
if (await authActions.signInWithGithub()) { authError.value = ''
if (await authActions.signInWithGithub(inlineErrorHandler)()) {
onSuccess() onSuccess()
} }
} }
const signInWithEmail = async (values: SignInData) => { const signInWithEmail = async (values: SignInData) => {
if (await authActions.signInWithEmail(values.email, values.password)) { authError.value = ''
if (
await authActions.signInWithEmail(
values.email,
values.password,
inlineErrorHandler
)()
) {
onSuccess() onSuccess()
} }
} }
const signUpWithEmail = async (values: SignUpData) => { const signUpWithEmail = async (values: SignUpData) => {
if (await authActions.signUpWithEmail(values.email, values.password)) { authError.value = ''
if (
await authActions.signUpWithEmail(
values.email,
values.password,
inlineErrorHandler
)()
) {
onSuccess() onSuccess()
} }
} }

View File

@@ -59,6 +59,11 @@
}}</small> }}</small>
</div> </div>
<!-- Auth Error Message -->
<Message v-if="authError" severity="error">
{{ authError }}
</Message>
<!-- Submit Button --> <!-- Submit Button -->
<ProgressSpinner v-if="loading" class="w-8 h-8" /> <ProgressSpinner v-if="loading" class="w-8 h-8" />
<Button <Button
@@ -76,6 +81,7 @@ import { Form } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod' import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button' import Button from 'primevue/button'
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Password from 'primevue/password' import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner' import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
@@ -93,6 +99,10 @@ const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
defineProps<{
authError?: string
}>()
const emit = defineEmits<{ const emit = defineEmits<{
submit: [values: SignInData] submit: [values: SignInData]
}>() }>()

View File

@@ -44,16 +44,21 @@
> >
{{ t('auth.signup.personalDataConsentLabel') }} {{ t('auth.signup.personalDataConsentLabel') }}
</label> </label>
<small v-if="$field.error" class="text-red-500 -mt-4">{{ <small v-if="$field.error" class="text-red-500 mt-4">{{
$field.error.message $field.error.message
}}</small> }}</small>
</FormField> </FormField>
<!-- Auth Error Message -->
<Message v-if="authError" severity="error">
{{ authError }}
</Message>
<!-- Submit Button --> <!-- Submit Button -->
<Button <Button
type="submit" type="submit"
:label="t('auth.signup.signUpButton')" :label="t('auth.signup.signUpButton')"
class="h-10 font-medium mt-4" class="h-10 font-medium mt-4 text-white"
/> />
</Form> </Form>
</template> </template>
@@ -65,6 +70,7 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button' import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox' import Checkbox from 'primevue/checkbox'
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { type SignUpData, signUpSchema } from '@/schemas/signInSchema' import { type SignUpData, signUpSchema } from '@/schemas/signInSchema'
@@ -73,6 +79,10 @@ import PasswordFields from './PasswordFields.vue'
const { t } = useI18n() const { t } = useI18n()
defineProps<{
authError?: string
}>()
const emit = defineEmits<{ const emit = defineEmits<{
submit: [values: SignUpData] submit: [values: SignUpData]
}>() }>()

View File

@@ -15,9 +15,9 @@
<script setup lang="ts"> <script setup lang="ts">
import Tag from 'primevue/tag' import Tag from 'primevue/tag'
// Global variable from vite build defined in global.d.ts import { isProductionEnvironment } from '@/config/environment'
// eslint-disable-next-line no-undef
const isStaging = !__USE_PROD_CONFIG__ const isStaging = !isProductionEnvironment()
</script> </script>
<style scoped> <style scoped>

View File

@@ -125,43 +125,50 @@ watch(
} }
} }
) )
useEventListener(document, 'mousedown', (event) => {
if (!isDOMWidget(widget) || !widgetState.visible || !widget.element.blur) {
return
}
if (!widget.element.contains(event.target as HTMLElement)) {
widget.element.blur()
}
})
onMounted(() => { // Set up event listeners only after the widget is mounted and visible
if (!isDOMWidget(widget)) { const setupDOMEventListeners = () => {
return if (!isDOMWidget(widget) || !widgetState.visible) return
if (widget.element.blur) {
useEventListener(document, 'mousedown', (event) => {
if (!widget.element.contains(event.target as HTMLElement)) {
widget.element.blur()
}
})
} }
useEventListener(
widget.element, for (const evt of widget.options.selectOn ?? ['focus', 'click']) {
widget.options.selectOn ?? ['focus', 'click'], useEventListener(widget.element, evt, () => {
() => {
const lgCanvas = canvasStore.canvas const lgCanvas = canvasStore.canvas
lgCanvas?.selectNode(widget.node) lgCanvas?.selectNode(widget.node)
lgCanvas?.bringToFront(widget.node) lgCanvas?.bringToFront(widget.node)
})
}
}
// Set up event listeners when widget becomes visible
watch(
() => widgetState.visible,
(visible) => {
if (visible) {
setupDOMEventListeners()
} }
) },
}) { immediate: true }
)
const inputSpec = widget.node.constructor.nodeData const inputSpec = widget.node.constructor.nodeData
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
// Mount DOM element when widget is or becomes visible // Mount DOM element when widget is or becomes visible
const mountElementIfVisible = () => { const mountElementIfVisible = () => {
if (!(widgetState.visible && isDOMWidget(widget) && widgetElement.value)) { if (widgetState.visible && isDOMWidget(widget) && widgetElement.value) {
return // Only append if not already a child
if (!widgetElement.value.contains(widget.element)) {
widgetElement.value.appendChild(widget.element)
}
} }
// Only append if not already a child
if (widgetElement.value.contains(widget.element)) {
return
}
widgetElement.value.appendChild(widget.element)
} }
// Check on mount - but only after next tick to ensure visibility is calculated // Check on mount - but only after next tick to ensure visibility is calculated

View File

@@ -60,8 +60,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import UrlInput from '@/components/common/UrlInput.vue' import UrlInput from '@/components/common/UrlInput.vue'
import type { UVMirror } from '@/constants/uvMirrors' import type { UVMirror } from '@/constants/uvMirrors'
import { st } from '@/i18n' import { st } from '@/i18n'
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
import { normalizeI18nKey } from '@/utils/formatUtil' import { normalizeI18nKey } from '@/utils/formatUtil'
import { checkMirrorReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil' import { ValidationState } from '@/utils/validationUtil'
const FILE_URL_SCHEME = 'file://' const FILE_URL_SCHEME = 'file://'

View File

@@ -88,7 +88,8 @@ const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl) { function addNode(nodeDef: ComfyNodeDefImpl) {
const node = litegraphService.addNodeOnGraph(nodeDef, { const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: getNewNodeLocation() pos: getNewNodeLocation(),
telemetrySource: 'search-popover'
}) })
if (disconnectOnReset && triggerEvent) { if (disconnectOnReset && triggerEvent) {

View File

@@ -265,7 +265,9 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
handleClick(e: MouseEvent) { handleClick(e: MouseEvent) {
if (this.leaf) { if (this.leaf) {
// @ts-expect-error fixme ts strict error // @ts-expect-error fixme ts strict error
useLitegraphService().addNodeOnGraph(this.data) useLitegraphService().addNodeOnGraph(this.data, {
telemetrySource: 'sidebar-click'
})
} else { } else {
toggleNodeOnEvent(e, this) toggleNodeOnEvent(e, this)
} }

View File

@@ -105,9 +105,9 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue' import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema' import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService' import { useLitegraphService } from '@/services/litegraphService'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore' import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
@@ -123,6 +123,7 @@ const toast = useToast()
const queueStore = useQueueStore() const queueStore = useQueueStore()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const commandStore = useCommandStore() const commandStore = useCommandStore()
const workflowService = useWorkflowService()
const { t } = useI18n() const { t } = useI18n()
// Expanded view: show all outputs in a flat list. // Expanded view: show all outputs in a flat list.
@@ -196,6 +197,30 @@ const menuTargetTask = ref<TaskItemImpl | null>(null)
const menuTargetNode = ref<ComfyNode | null>(null) const menuTargetNode = ref<ComfyNode | null>(null)
const menuItems = computed<MenuItem[]>(() => { const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [ const items: MenuItem[] = [
{
label: t('g.copyJobId'),
icon: 'pi pi-copy',
command: async () => {
if (menuTargetTask.value) {
try {
await navigator.clipboard.writeText(menuTargetTask.value.promptId)
toast.add({
severity: 'success',
summary: t('g.copied'),
detail: t('g.jobIdCopied'),
life: 2000
})
} catch (err) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToCopyJobId'),
life: 3000
})
}
}
}
},
{ {
label: t('g.delete'), label: t('g.delete'),
icon: 'pi pi-trash', icon: 'pi pi-trash',
@@ -205,8 +230,16 @@ const menuItems = computed<MenuItem[]>(() => {
{ {
label: t('g.loadWorkflow'), label: t('g.loadWorkflow'),
icon: 'pi pi-file-export', icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow(app), command: () => {
disabled: !menuTargetTask.value?.workflow if (menuTargetTask.value) {
void workflowService.loadTaskWorkflow(menuTargetTask.value)
}
},
disabled: !(
menuTargetTask.value?.workflow ||
(menuTargetTask.value?.isHistory &&
menuTargetTask.value?.prompt.prompt_id)
)
}, },
{ {
label: t('g.goToNode'), label: t('g.goToNode'),

View File

@@ -148,6 +148,7 @@ import {
useWorkflowStore useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore' } from '@/platform/workflow/management/stores/workflowStore'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { TelemetryEvents, trackTypedEvent } from '@/services/telemetryService'
import { useWorkspaceStore } from '@/stores/workspaceStore' import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeNode } from '@/types/treeExplorerTypes' import type { TreeNode } from '@/types/treeExplorerTypes'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes' import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -234,6 +235,13 @@ const renderTreeNode = (
e: MouseEvent e: MouseEvent
) { ) {
if (this.leaf) { if (this.leaf) {
// Track workflow opening from sidebar
trackTypedEvent(TelemetryEvents.WORKFLOW_OPENED_FROM_SIDEBAR, {
workflow_path: workflow.path,
workflow_type: type,
is_bookmarked: type === WorkflowTreeType.Bookmarks,
is_open: type === WorkflowTreeType.Open
})
await workflowService.openWorkflow(workflow) await workflowService.openWorkflow(workflow)
} else { } else {
toggleNodeOnEvent(e, this) toggleNodeOnEvent(e, this)

View File

@@ -1,5 +1,6 @@
import { FirebaseError } from 'firebase/app' import { FirebaseError } from 'firebase/app'
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import { t } from '@/i18n' import { t } from '@/i18n'
@@ -15,6 +16,7 @@ import { usdToMicros } from '@/utils/formatUtil'
export const useFirebaseAuthActions = () => { export const useFirebaseAuthActions = () => {
const authStore = useFirebaseAuthStore() const authStore = useFirebaseAuthStore()
const toastStore = useToastStore() const toastStore = useToastStore()
const router = useRouter()
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling() const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()
const accessError = ref(false) const accessError = ref(false)
@@ -51,6 +53,12 @@ export const useFirebaseAuthActions = () => {
detail: t('auth.signOut.successDetail'), detail: t('auth.signOut.successDetail'),
life: 5000 life: 5000
}) })
// Redirect to login page if we're on cloud domain
const hostname = window.location.hostname
if (hostname.includes('cloud.comfy.org')) {
await router.push({ name: 'cloud-login' })
}
}, reportError) }, reportError)
const sendPasswordReset = wrapWithErrorHandlingAsync( const sendPasswordReset = wrapWithErrorHandlingAsync(
@@ -100,27 +108,33 @@ export const useFirebaseAuthActions = () => {
return await authStore.fetchBalance() return await authStore.fetchBalance()
}, reportError) }, reportError)
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => { const signInWithGoogle = (errorHandler = reportError) =>
return await authStore.loginWithGoogle() wrapWithErrorHandlingAsync(async () => {
}, reportError) return await authStore.loginWithGoogle()
}, errorHandler)
const signInWithGithub = wrapWithErrorHandlingAsync(async () => { const signInWithGithub = (errorHandler = reportError) =>
return await authStore.loginWithGithub() wrapWithErrorHandlingAsync(async () => {
}, reportError) return await authStore.loginWithGithub()
}, errorHandler)
const signInWithEmail = wrapWithErrorHandlingAsync( const signInWithEmail = (
async (email: string, password: string) => { email: string,
password: string,
errorHandler = reportError
) =>
wrapWithErrorHandlingAsync(async () => {
return await authStore.login(email, password) return await authStore.login(email, password)
}, }, errorHandler)
reportError
)
const signUpWithEmail = wrapWithErrorHandlingAsync( const signUpWithEmail = (
async (email: string, password: string) => { email: string,
password: string,
errorHandler = reportError
) =>
wrapWithErrorHandlingAsync(async () => {
return await authStore.register(email, password) return await authStore.register(email, password)
}, }, errorHandler)
reportError
)
const updatePassword = wrapWithErrorHandlingAsync( const updatePassword = wrapWithErrorHandlingAsync(
async (newPassword: string) => { async (newPassword: string) => {
@@ -156,7 +170,8 @@ export const useFirebaseAuthActions = () => {
signInWithEmail, signInWithEmail,
signUpWithEmail, signUpWithEmail,
updatePassword, updatePassword,
deleteAccount, accessError,
accessError reportError,
deleteAccount
} }
} }

View File

@@ -4,7 +4,7 @@ import type { Ref } from 'vue'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { Rect } from '@/lib/litegraph/src/interfaces' import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/litegraph' import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -71,7 +71,7 @@ export function useSelectionToolboxPosition(
visible.value = true visible.value = true
// Get bounds for all selected items // Get bounds for all selected items
const allBounds: Rect[] = [] const allBounds: ReadOnlyRect[] = []
for (const item of selectableItems) { for (const item of selectableItems) {
// Skip items without valid IDs // Skip items without valid IDs
if (item.id == null) continue if (item.id == null) continue

View File

@@ -241,7 +241,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
} }
/** /**
* Sets up widget callbacks for a node * Sets up widget callbacks for a node - now with reduced nesting
*/ */
const setupNodeWidgetCallbacks = (node: LGraphNode) => { const setupNodeWidgetCallbacks = (node: LGraphNode) => {
if (!node.widgets) return if (!node.widgets) return

View File

@@ -31,6 +31,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService' import { useLitegraphService } from '@/services/litegraphService'
import { TelemetryEvents, trackTypedEvent } from '@/services/telemetryService'
import type { ComfyCommand } from '@/stores/commandStore' import type { ComfyCommand } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
import { useHelpCenterStore } from '@/stores/helpCenterStore' import { useHelpCenterStore } from '@/stores/helpCenterStore'
@@ -550,6 +551,11 @@ export function useCoreCommands(): ComfyCommand[] {
versionAdded: '1.3.11', versionAdded: '1.3.11',
category: 'essentials' as const, category: 'essentials' as const,
function: () => { function: () => {
const selectedNodes = getSelectedNodes()
trackTypedEvent(TelemetryEvents.NODE_MUTED, {
node_count: selectedNodes.length,
action_type: 'keyboard_shortcut'
})
toggleSelectedNodesMode(LGraphEventMode.NEVER) toggleSelectedNodesMode(LGraphEventMode.NEVER)
app.canvas.setDirty(true, true) app.canvas.setDirty(true, true)
} }
@@ -561,6 +567,11 @@ export function useCoreCommands(): ComfyCommand[] {
versionAdded: '1.3.11', versionAdded: '1.3.11',
category: 'essentials' as const, category: 'essentials' as const,
function: () => { function: () => {
const selectedNodes = getSelectedNodes()
trackTypedEvent(TelemetryEvents.NODE_BYPASSED, {
node_count: selectedNodes.length,
action_type: 'keyboard_shortcut'
})
toggleSelectedNodesMode(LGraphEventMode.BYPASS) toggleSelectedNodesMode(LGraphEventMode.BYPASS)
app.canvas.setDirty(true, true) app.canvas.setDirty(true, true)
} }
@@ -896,6 +907,7 @@ export function useCoreCommands(): ComfyCommand[] {
const graph = canvas.subgraph ?? canvas.graph const graph = canvas.subgraph ?? canvas.graph
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.') if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
const selectedCount = canvas.selectedItems.size
const res = graph.convertToSubgraph(canvas.selectedItems) const res = graph.convertToSubgraph(canvas.selectedItems)
if (!res) { if (!res) {
toastStore.add({ toastStore.add({
@@ -907,6 +919,12 @@ export function useCoreCommands(): ComfyCommand[] {
return return
} }
// Track subgraph creation
trackTypedEvent(TelemetryEvents.SUBGRAPH_CREATED, {
selected_item_count: selectedCount,
action_type: 'keyboard_shortcut'
})
const { node } = res const { node } = res
canvas.select(node) canvas.select(node)
canvasStore.updateSelectedItems() canvasStore.updateSelectedItems()

View File

@@ -1,7 +1,9 @@
export const COMFY_API_BASE_URL = __USE_PROD_CONFIG__ import { isProductionEnvironment } from './environment'
export const COMFY_API_BASE_URL = isProductionEnvironment()
? 'https://api.comfy.org' ? 'https://api.comfy.org'
: 'https://stagingapi.comfy.org' : 'https://stagingapi.comfy.org'
export const COMFY_PLATFORM_BASE_URL = __USE_PROD_CONFIG__ export const COMFY_PLATFORM_BASE_URL = isProductionEnvironment()
? 'https://platform.comfy.org' ? 'https://platform.comfy.org'
: 'https://stagingplatform.comfy.org' : 'https://stagingplatform.comfy.org'

18
src/config/environment.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* Runtime environment configuration that determines if we're in production or staging
* based on the hostname. Replaces the build-time __USE_PROD_CONFIG__ constant.
*/
/**
* Checks if the application is running in production environment
* @returns true if hostname is cloud.comfy.org (production), false otherwise (staging)
*/
export function isProductionEnvironment(): boolean {
// In SSR/Node.js environments or during build, use the environment variable
if (typeof window === 'undefined') {
return process.env.USE_PROD_CONFIG === 'true'
}
// In browser, check the hostname
return window.location.hostname === 'cloud.comfy.org'
}

View File

@@ -1,5 +1,7 @@
import type { FirebaseOptions } from 'firebase/app' import type { FirebaseOptions } from 'firebase/app'
import { isProductionEnvironment } from './environment'
const DEV_CONFIG: FirebaseOptions = { const DEV_CONFIG: FirebaseOptions = {
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE', apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
authDomain: 'dreamboothy-dev.firebaseapp.com', authDomain: 'dreamboothy-dev.firebaseapp.com',
@@ -23,6 +25,6 @@ const PROD_CONFIG: FirebaseOptions = {
} }
// To test with prod config while using dev server, set USE_PROD_CONFIG=true in .env // To test with prod config while using dev server, set USE_PROD_CONFIG=true in .env
export const FIREBASE_CONFIG: FirebaseOptions = __USE_PROD_CONFIG__ export const FIREBASE_CONFIG: FirebaseOptions = isProductionEnvironment()
? PROD_CONFIG ? PROD_CONFIG
: DEV_CONFIG : DEV_CONFIG

View File

@@ -6,8 +6,8 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil' import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
import { checkMirrorReachable } from '@/utils/networkUtil'
// Desktop documentation URLs // Desktop documentation URLs
const DESKTOP_DOCS = { const DESKTOP_DOCS = {

View File

@@ -15,6 +15,7 @@ import type { ResultItemType } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget' import type { DOMWidget } from '@/scripts/domWidget'
import { useAudioService } from '@/services/audioService' import { useAudioService } from '@/services/audioService'
import { fileNameMappingService } from '@/services/fileNameMappingService'
import { type NodeLocatorId } from '@/types' import { type NodeLocatorId } from '@/types'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil' import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
@@ -66,10 +67,19 @@ async function uploadFile(
if (resp.status === 200) { if (resp.status === 200) {
const data = await resp.json() const data = await resp.json()
// Add the file to the dropdown list and update the widget value // Build the file path
let path = data.name let path = data.name
if (data.subfolder) path = data.subfolder + '/' + path if (data.subfolder) path = data.subfolder + '/' + path
// CRITICAL: Refresh mappings FIRST before updating dropdown
// This ensures new hash→human mappings are available when dropdown renders
try {
await fileNameMappingService.refreshMapping('input')
} catch (error) {
// Continue anyway - will show hash values as fallback
}
// Now add the file to the dropdown list - any filename proxy will use fresh mappings
// @ts-expect-error fixme ts strict error // @ts-expect-error fixme ts strict error
if (!audioWidget.options.values.includes(path)) { if (!audioWidget.options.values.includes(path)) {
// @ts-expect-error fixme ts strict error // @ts-expect-error fixme ts strict error
@@ -241,7 +251,7 @@ app.registerExtension({
inputName, inputName,
'', '',
openFileSelection, openFileSelection,
{ serialize: false, canvasOnly: true } { serialize: false }
) )
uploadWidget.label = t('g.choose_file_to_upload') uploadWidget.label = t('g.choose_file_to_upload')
@@ -398,7 +408,7 @@ app.registerExtension({
mediaRecorder.stop() mediaRecorder.stop()
} }
}, },
{ serialize: false, canvasOnly: true } { serialize: false }
) )
recordWidget.label = t('g.startRecording') recordWidget.label = t('g.startRecording')

View File

@@ -106,8 +106,7 @@ app.registerExtension({
'button', 'button',
'waiting for camera...', 'waiting for camera...',
'capture', 'capture',
capture, capture
{ canvasOnly: true }
) )
btn.disabled = true btn.disabled = true
btn.serializeValue = () => undefined btn.serializeValue = () => undefined

View File

@@ -1,4 +1,4 @@
import type { Point, Rect } from './interfaces' import type { Point, ReadOnlyRect, Rect } from './interfaces'
import { EaseFunction, Rectangle } from './litegraph' import { EaseFunction, Rectangle } from './litegraph'
export interface DragAndScaleState { export interface DragAndScaleState {
@@ -188,7 +188,10 @@ export class DragAndScale {
* Fits the view to the specified bounds. * Fits the view to the specified bounds.
* @param bounds The bounds to fit the view to, defined by a rectangle. * @param bounds The bounds to fit the view to, defined by a rectangle.
*/ */
fitToBounds(bounds: Rect, { zoom = 0.75 }: { zoom?: number } = {}): void { fitToBounds(
bounds: ReadOnlyRect,
{ zoom = 0.75 }: { zoom?: number } = {}
): void {
const cw = this.element.width / window.devicePixelRatio const cw = this.element.width / window.devicePixelRatio
const ch = this.element.height / window.devicePixelRatio const ch = this.element.height / window.devicePixelRatio
let targetScale = this.scale let targetScale = this.scale
@@ -220,7 +223,7 @@ export class DragAndScale {
* @param bounds The bounds to animate the view to, defined by a rectangle. * @param bounds The bounds to animate the view to, defined by a rectangle.
*/ */
animateToBounds( animateToBounds(
bounds: Readonly<Rect | Rectangle>, bounds: ReadOnlyRect,
setDirty: () => void, setDirty: () => void,
{ {
duration = 350, duration = 350,

View File

@@ -4,7 +4,6 @@ import {
SUBGRAPH_INPUT_ID, SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants' } from '@/lib/litegraph/src/constants'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { UUID } from '@/lib/litegraph/src/utils/uuid' import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid' import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -1708,12 +1707,7 @@ export class LGraph
...subgraphNode.subgraph.groups ...subgraphNode.subgraph.groups
].map((p: { pos: Point; size?: Size }): HasBoundingRect => { ].map((p: { pos: Point; size?: Size }): HasBoundingRect => {
return { return {
boundingRect: new Rectangle( boundingRect: [p.pos[0], p.pos[1], p.size?.[0] ?? 0, p.size?.[1] ?? 0]
p.pos[0],
p.pos[1],
p.size?.[0] ?? 0,
p.size?.[1] ?? 0
)
} }
}) })
const bounds = createBounds(positionables) ?? [0, 0, 0, 0] const bounds = createBounds(positionables) ?? [0, 0, 0, 0]

View File

@@ -47,6 +47,8 @@ import type {
NullableProperties, NullableProperties,
Point, Point,
Positionable, Positionable,
ReadOnlyPoint,
ReadOnlyRect,
Rect, Rect,
Size Size
} from './interfaces' } from './interfaces'
@@ -234,11 +236,11 @@ export class LGraphCanvas
implements CustomEventDispatcher<LGraphCanvasEventMap> implements CustomEventDispatcher<LGraphCanvasEventMap>
{ {
// Optimised buffers used during rendering // Optimised buffers used during rendering
static #temp = [0, 0, 0, 0] satisfies Rect static #temp = new Float32Array(4)
static #temp_vec2 = [0, 0] satisfies Point static #temp_vec2 = new Float32Array(2)
static #tmp_area = [0, 0, 0, 0] satisfies Rect static #tmp_area = new Float32Array(4)
static #margin_area = [0, 0, 0, 0] satisfies Rect static #margin_area = new Float32Array(4)
static #link_bounding = [0, 0, 0, 0] satisfies Rect static #link_bounding = new Float32Array(4)
static DEFAULT_BACKGROUND_IMAGE = static DEFAULT_BACKGROUND_IMAGE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=' 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII='
@@ -626,7 +628,7 @@ export class LGraphCanvas
dirty_area?: Rect | null dirty_area?: Rect | null
/** @deprecated Unused */ /** @deprecated Unused */
node_in_panel?: LGraphNode | null node_in_panel?: LGraphNode | null
last_mouse: Point = [0, 0] last_mouse: ReadOnlyPoint = [0, 0]
last_mouseclick: number = 0 last_mouseclick: number = 0
graph: LGraph | Subgraph | null graph: LGraph | Subgraph | null
get _graph(): LGraph | Subgraph { get _graph(): LGraph | Subgraph {
@@ -2632,7 +2634,7 @@ export class LGraphCanvas
pointer: CanvasPointer, pointer: CanvasPointer,
node?: LGraphNode | undefined node?: LGraphNode | undefined
): void { ): void {
const dragRect: [number, number, number, number] = [0, 0, 0, 0] const dragRect = new Float32Array(4)
dragRect[0] = e.canvasX dragRect[0] = e.canvasX
dragRect[1] = e.canvasY dragRect[1] = e.canvasY
@@ -3172,7 +3174,7 @@ export class LGraphCanvas
LGraphCanvas.active_canvas = this LGraphCanvas.active_canvas = this
this.adjustMouseEvent(e) this.adjustMouseEvent(e)
const mouse: Point = [e.clientX, e.clientY] const mouse: ReadOnlyPoint = [e.clientX, e.clientY]
this.mouse[0] = mouse[0] this.mouse[0] = mouse[0]
this.mouse[1] = mouse[1] this.mouse[1] = mouse[1]
const delta = [mouse[0] - this.last_mouse[0], mouse[1] - this.last_mouse[1]] const delta = [mouse[0] - this.last_mouse[0], mouse[1] - this.last_mouse[1]]
@@ -4075,10 +4077,7 @@ export class LGraphCanvas
this.setDirty(true) this.setDirty(true)
} }
#handleMultiSelect( #handleMultiSelect(e: CanvasPointerEvent, dragRect: Float32Array) {
e: CanvasPointerEvent,
dragRect: [number, number, number, number]
) {
// Process drag // Process drag
// Convert Point pair (pos, offset) to Rect // Convert Point pair (pos, offset) to Rect
const { graph, selectedItems, subgraph } = this const { graph, selectedItems, subgraph } = this
@@ -4733,47 +4732,32 @@ export class LGraphCanvas
for (const renderLink of renderLinks) { for (const renderLink of renderLinks) {
const { const {
fromSlot, fromSlot,
fromPos: pos fromPos: pos,
// fromDirection, fromDirection,
// dragDirection dragDirection
} = renderLink } = renderLink
const connShape = fromSlot.shape const connShape = fromSlot.shape
const connType = fromSlot.type const connType = fromSlot.type
const color = resolveConnectingLinkColor(connType) const colour = resolveConnectingLinkColor(connType)
// the connection being dragged by the mouse // the connection being dragged by the mouse
if ( if (this.linkRenderer) {
this.linkRenderer && this.linkRenderer.renderDraggingLink(
renderLink.fromSlotIndex !== undefined && ctx,
renderLink.node !== undefined pos,
) { highlightPos,
const { fromSlotIndex, node } = renderLink colour,
if ( fromDirection,
node instanceof LGraphNode && dragDirection,
('link' in fromSlot || 'links' in fromSlot) {
) { ...this.buildLinkRenderContext(),
this.linkRenderer.renderDraggingLink( linkMarkerShape: LinkMarkerShape.None
ctx, }
node, )
fromSlot,
fromSlotIndex,
highlightPos,
this.buildLinkRenderContext(),
{ fromInput: 'link' in fromSlot, color }
// pos,
// colour,
// fromDirection,
// dragDirection,
// {
// ...this.buildLinkRenderContext(),
// linkMarkerShape: LinkMarkerShape.None
// }
)
}
} }
ctx.fillStyle = color ctx.fillStyle = colour
ctx.beginPath() ctx.beginPath()
if (connType === LiteGraph.EVENT || connShape === RenderShape.BOX) { if (connType === LiteGraph.EVENT || connShape === RenderShape.BOX) {
ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10) ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10)
@@ -4864,7 +4848,7 @@ export class LGraphCanvas
} }
/** Get the target snap / highlight point in graph space */ /** Get the target snap / highlight point in graph space */
#getHighlightPosition(): Point { #getHighlightPosition(): ReadOnlyPoint {
return LiteGraph.snaps_for_comfy return LiteGraph.snaps_for_comfy
? this.linkConnector.state.snapLinksPos ?? ? this.linkConnector.state.snapLinksPos ??
this._highlight_pos ?? this._highlight_pos ??
@@ -4879,7 +4863,7 @@ export class LGraphCanvas
*/ */
#renderSnapHighlight( #renderSnapHighlight(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
highlightPos: Point highlightPos: ReadOnlyPoint
): void { ): void {
const linkConnectorSnap = !!this.linkConnector.state.snapLinksPos const linkConnectorSnap = !!this.linkConnector.state.snapLinksPos
if (!this._highlight_pos && !linkConnectorSnap) return if (!this._highlight_pos && !linkConnectorSnap) return
@@ -5221,8 +5205,7 @@ export class LGraphCanvas
// clip if required (mask) // clip if required (mask)
const shape = node._shape || RenderShape.BOX const shape = node._shape || RenderShape.BOX
const size = LGraphCanvas.#temp_vec2 const size = LGraphCanvas.#temp_vec2
size[0] = node.renderingSize[0] size.set(node.renderingSize)
size[1] = node.renderingSize[1]
if (node.collapsed) { if (node.collapsed) {
ctx.font = this.inner_text_font ctx.font = this.inner_text_font
@@ -5417,10 +5400,7 @@ export class LGraphCanvas
// Normalised node dimensions // Normalised node dimensions
const area = LGraphCanvas.#tmp_area const area = LGraphCanvas.#tmp_area
area[0] = node.boundingRect[0] area.set(node.boundingRect)
area[1] = node.boundingRect[1]
area[2] = node.boundingRect[2]
area[3] = node.boundingRect[3]
area[0] -= node.pos[0] area[0] -= node.pos[0]
area[1] -= node.pos[1] area[1] -= node.pos[1]
@@ -5522,10 +5502,7 @@ export class LGraphCanvas
shape = RenderShape.ROUND shape = RenderShape.ROUND
) { ) {
const snapGuide = LGraphCanvas.#temp const snapGuide = LGraphCanvas.#temp
snapGuide[0] = item.boundingRect[0] snapGuide.set(item.boundingRect)
snapGuide[1] = item.boundingRect[1]
snapGuide[2] = item.boundingRect[2]
snapGuide[3] = item.boundingRect[3]
// Not all items have pos equal to top-left of bounds // Not all items have pos equal to top-left of bounds
const { pos } = item const { pos } = item
@@ -5965,8 +5942,8 @@ export class LGraphCanvas
*/ */
renderLink( renderLink(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
a: Point, a: ReadOnlyPoint,
b: Point, b: ReadOnlyPoint,
link: LLink | null, link: LLink | null,
skip_border: boolean, skip_border: boolean,
flow: number | null, flow: number | null,
@@ -5983,9 +5960,9 @@ export class LGraphCanvas
/** When defined, render data will be saved to this reroute instead of the {@link link}. */ /** When defined, render data will be saved to this reroute instead of the {@link link}. */
reroute?: Reroute reroute?: Reroute
/** Offset of the bezier curve control point from {@link a point a} (output side) */ /** Offset of the bezier curve control point from {@link a point a} (output side) */
startControl?: Point startControl?: ReadOnlyPoint
/** Offset of the bezier curve control point from {@link b point b} (input side) */ /** Offset of the bezier curve control point from {@link b point b} (input side) */
endControl?: Point endControl?: ReadOnlyPoint
/** Number of sublines (useful to represent vec3 or rgb) @todo If implemented, refactor calculations out of the loop */ /** Number of sublines (useful to represent vec3 or rgb) @todo If implemented, refactor calculations out of the loop */
num_sublines?: number num_sublines?: number
/** Whether this is a floating link segment */ /** Whether this is a floating link segment */
@@ -8456,7 +8433,7 @@ export class LGraphCanvas
* Starts an animation to fit the view around the specified selection of nodes. * Starts an animation to fit the view around the specified selection of nodes.
* @param bounds The bounds to animate the view to, defined by a rectangle. * @param bounds The bounds to animate the view to, defined by a rectangle.
*/ */
animateToBounds(bounds: Rect | Rectangle, options: AnimationOptions = {}) { animateToBounds(bounds: ReadOnlyRect, options: AnimationOptions = {}) {
const setDirty = () => this.setDirty(true, true) const setDirty = () => this.setDirty(true, true)
this.ds.animateToBounds(bounds, setDirty, options) this.ds.animateToBounds(bounds, setDirty, options)
} }

View File

@@ -1,5 +1,4 @@
import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError' import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { LGraph } from './LGraph' import type { LGraph } from './LGraph'
import { LGraphCanvas } from './LGraphCanvas' import { LGraphCanvas } from './LGraphCanvas'
@@ -41,15 +40,15 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
title: string title: string
font?: string font?: string
font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24 font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24
_bounding: [number, number, number, number] = [ _bounding: Float32Array = new Float32Array([
10, 10,
10, 10,
LGraphGroup.minWidth, LGraphGroup.minWidth,
LGraphGroup.minHeight LGraphGroup.minHeight
] ])
_pos: Point = [10, 10] _pos: Point = this._bounding.subarray(0, 2)
_size: Size = [LGraphGroup.minWidth, LGraphGroup.minHeight] _size: Size = this._bounding.subarray(2, 4)
/** @deprecated See {@link _children} */ /** @deprecated See {@link _children} */
_nodes: LGraphNode[] = [] _nodes: LGraphNode[] = []
_children: Set<Positionable> = new Set() _children: Set<Positionable> = new Set()
@@ -108,13 +107,8 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
this._size[1] = Math.max(LGraphGroup.minHeight, v[1]) this._size[1] = Math.max(LGraphGroup.minHeight, v[1])
} }
get boundingRect(): Rectangle { get boundingRect() {
return Rectangle.from([ return this._bounding
this._pos[0],
this._pos[1],
this._size[0],
this._size[1]
])
} }
get nodes() { get nodes() {
@@ -151,17 +145,14 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
configure(o: ISerialisedGroup): void { configure(o: ISerialisedGroup): void {
this.id = o.id this.id = o.id
this.title = o.title this.title = o.title
this._pos[0] = o.bounding[0] this._bounding.set(o.bounding)
this._pos[1] = o.bounding[1]
this._size[0] = o.bounding[2]
this._size[1] = o.bounding[3]
this.color = o.color this.color = o.color
this.flags = o.flags || this.flags this.flags = o.flags || this.flags
if (o.font_size) this.font_size = o.font_size if (o.font_size) this.font_size = o.font_size
} }
serialize(): ISerialisedGroup { serialize(): ISerialisedGroup {
const b = [this._pos[0], this._pos[1], this._size[0], this._size[1]] const b = this._bounding
return { return {
id: this.id, id: this.id,
title: this.title, title: this.title,
@@ -219,7 +210,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
) )
if (LiteGraph.highlight_selected_group && this.selected) { if (LiteGraph.highlight_selected_group && this.selected) {
strokeShape(ctx, this.boundingRect, { strokeShape(ctx, this._bounding, {
title_height: this.titleHeight, title_height: this.titleHeight,
padding padding
}) })
@@ -260,7 +251,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
// Move nodes we overlap the centre point of // Move nodes we overlap the centre point of
for (const node of nodes) { for (const node of nodes) {
if (containsCentre(this.boundingRect, node.boundingRect)) { if (containsCentre(this._bounding, node.boundingRect)) {
this._nodes.push(node) this._nodes.push(node)
children.add(node) children.add(node)
} }
@@ -268,13 +259,12 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
// Move reroutes we overlap the centre point of // Move reroutes we overlap the centre point of
for (const reroute of reroutes.values()) { for (const reroute of reroutes.values()) {
if (isPointInRect(reroute.pos, this.boundingRect)) children.add(reroute) if (isPointInRect(reroute.pos, this._bounding)) children.add(reroute)
} }
// Move groups we wholly contain // Move groups we wholly contain
for (const group of groups) { for (const group of groups) {
if (containsRect(this.boundingRect, group.boundingRect)) if (containsRect(this._bounding, group._bounding)) children.add(group)
children.add(group)
} }
groups.sort((a, b) => { groups.sort((a, b) => {

View File

@@ -18,6 +18,7 @@ import type { Reroute, RerouteId } from './Reroute'
import { getNodeInputOnPos, getNodeOutputOnPos } from './canvas/measureSlots' import { getNodeInputOnPos, getNodeOutputOnPos } from './canvas/measureSlots'
import type { IDrawBoundingOptions } from './draw' import type { IDrawBoundingOptions } from './draw'
import { NullGraphError } from './infrastructure/NullGraphError' import { NullGraphError } from './infrastructure/NullGraphError'
import type { ReadOnlyRectangle } from './infrastructure/Rectangle'
import { Rectangle } from './infrastructure/Rectangle' import { Rectangle } from './infrastructure/Rectangle'
import type { import type {
ColorOption, ColorOption,
@@ -36,6 +37,8 @@ import type {
ISlotType, ISlotType,
Point, Point,
Positionable, Positionable,
ReadOnlyPoint,
ReadOnlyRect,
Rect, Rect,
Size Size
} from './interfaces' } from './interfaces'
@@ -384,7 +387,7 @@ export class LGraphNode
* Called once at the start of every frame. Caller may change the values in {@link out}, which will be reflected in {@link boundingRect}. * Called once at the start of every frame. Caller may change the values in {@link out}, which will be reflected in {@link boundingRect}.
* WARNING: Making changes to boundingRect via onBounding is poorly supported, and will likely result in strange behaviour. * WARNING: Making changes to boundingRect via onBounding is poorly supported, and will likely result in strange behaviour.
*/ */
onBounding?(this: LGraphNode, out: Rectangle): void onBounding?(this: LGraphNode, out: Rect): void
console?: string[] console?: string[]
_level?: number _level?: number
_shape?: RenderShape _shape?: RenderShape
@@ -410,12 +413,12 @@ export class LGraphNode
} }
/** @inheritdoc {@link renderArea} */ /** @inheritdoc {@link renderArea} */
#renderArea: [number, number, number, number] = [0, 0, 0, 0] #renderArea: Float32Array = new Float32Array(4)
/** /**
* Rect describing the node area, including shadows and any protrusions. * Rect describing the node area, including shadows and any protrusions.
* Determines if the node is visible. Calculated once at the start of every frame. * Determines if the node is visible. Calculated once at the start of every frame.
*/ */
get renderArea(): Rect { get renderArea(): ReadOnlyRect {
return this.#renderArea return this.#renderArea
} }
@@ -426,12 +429,12 @@ export class LGraphNode
* *
* Determines the node hitbox and other rendering effects. Calculated once at the start of every frame. * Determines the node hitbox and other rendering effects. Calculated once at the start of every frame.
*/ */
get boundingRect(): Rectangle { get boundingRect(): ReadOnlyRectangle {
return this.#boundingRect return this.#boundingRect
} }
/** The offset from {@link pos} to the top-left of {@link boundingRect}. */ /** The offset from {@link pos} to the top-left of {@link boundingRect}. */
get boundingOffset(): Point { get boundingOffset(): ReadOnlyPoint {
const { const {
pos: [posX, posY], pos: [posX, posY],
boundingRect: [bX, bY] boundingRect: [bX, bY]
@@ -440,9 +443,9 @@ export class LGraphNode
} }
/** {@link pos} and {@link size} values are backed by this {@link Rect}. */ /** {@link pos} and {@link size} values are backed by this {@link Rect}. */
_posSize: [number, number, number, number] = [0, 0, 0, 0] _posSize: Float32Array = new Float32Array(4)
_pos: Point = [0, 0] _pos: Point = this._posSize.subarray(0, 2)
_size: Size = [0, 0] _size: Size = this._posSize.subarray(2, 4)
public get pos() { public get pos() {
return this._pos return this._pos
@@ -1650,7 +1653,7 @@ export class LGraphNode
inputs ? inputs.filter((input) => !isWidgetInputSlot(input)).length : 1, inputs ? inputs.filter((input) => !isWidgetInputSlot(input)).length : 1,
outputs ? outputs.length : 1 outputs ? outputs.length : 1
) )
const size = out || [0, 0] const size = out || new Float32Array([0, 0])
rows = Math.max(rows, 1) rows = Math.max(rows, 1)
// although it should be graphcanvas.inner_text_font size // although it should be graphcanvas.inner_text_font size
const font_size = LiteGraph.NODE_TEXT_SIZE const font_size = LiteGraph.NODE_TEXT_SIZE
@@ -1975,7 +1978,7 @@ export class LGraphNode
* @param out `x, y, width, height` are written to this array. * @param out `x, y, width, height` are written to this array.
* @param ctx The canvas context to use for measuring text. * @param ctx The canvas context to use for measuring text.
*/ */
measure(out: Rectangle, ctx: CanvasRenderingContext2D): void { measure(out: Rect, ctx: CanvasRenderingContext2D): void {
const titleMode = this.title_mode const titleMode = this.title_mode
const renderTitle = const renderTitle =
titleMode != TitleMode.TRANSPARENT_TITLE && titleMode != TitleMode.TRANSPARENT_TITLE &&
@@ -2001,13 +2004,13 @@ export class LGraphNode
/** /**
* returns the bounding of the object, used for rendering purposes * returns the bounding of the object, used for rendering purposes
* @param out {Rect?} [optional] a place to store the output, to free garbage * @param out {Float32Array[4]?} [optional] a place to store the output, to free garbage
* @param includeExternal {boolean?} [optional] set to true to * @param includeExternal {boolean?} [optional] set to true to
* include the shadow and connection points in the bounding calculation * include the shadow and connection points in the bounding calculation
* @returns the bounding box in format of [topleft_cornerx, topleft_cornery, width, height] * @returns the bounding box in format of [topleft_cornerx, topleft_cornery, width, height]
*/ */
getBounding(out?: Rect, includeExternal?: boolean): Rect { getBounding(out?: Rect, includeExternal?: boolean): Rect {
out ||= [0, 0, 0, 0] out ||= new Float32Array(4)
const rect = includeExternal ? this.renderArea : this.boundingRect const rect = includeExternal ? this.renderArea : this.boundingRect
out[0] = rect[0] out[0] = rect[0]
@@ -2028,10 +2031,7 @@ export class LGraphNode
this.onBounding?.(bounds) this.onBounding?.(bounds)
const renderArea = this.#renderArea const renderArea = this.#renderArea
renderArea[0] = bounds[0] renderArea.set(bounds)
renderArea[1] = bounds[1]
renderArea[2] = bounds[2]
renderArea[3] = bounds[3]
// 4 offset for collapsed node connection points // 4 offset for collapsed node connection points
renderArea[0] -= 4 renderArea[0] -= 4
renderArea[1] -= 4 renderArea[1] -= 4
@@ -3174,7 +3174,7 @@ export class LGraphNode
* @returns the position * @returns the position
*/ */
getConnectionPos(is_input: boolean, slot_number: number, out?: Point): Point { getConnectionPos(is_input: boolean, slot_number: number, out?: Point): Point {
out ||= [0, 0] out ||= new Float32Array(2)
const { const {
pos: [nodeX, nodeY], pos: [nodeX, nodeY],
@@ -3839,7 +3839,7 @@ export class LGraphNode
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
} }
#measureSlots(): Rect | null { #measureSlots(): ReadOnlyRect | null {
const slots: (NodeInputSlot | NodeOutputSlot)[] = [] const slots: (NodeInputSlot | NodeOutputSlot)[] = []
for (const [slotIndex, slot] of this.#concreteInputs.entries()) { for (const [slotIndex, slot] of this.#concreteInputs.entries()) {

View File

@@ -109,7 +109,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
data?: number | string | boolean | { toToolTip?(): string } data?: number | string | boolean | { toToolTip?(): string }
_data?: unknown _data?: unknown
/** Centre point of the link, calculated during render only - can be inaccurate */ /** Centre point of the link, calculated during render only - can be inaccurate */
_pos: [number, number] _pos: Float32Array
/** @todo Clean up - never implemented in comfy. */ /** @todo Clean up - never implemented in comfy. */
_last_time?: number _last_time?: number
/** The last canvas 2D path that was used to render this link */ /** The last canvas 2D path that was used to render this link */
@@ -171,7 +171,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
this._data = null this._data = null
// center // center
this._pos = [0, 0] this._pos = new Float32Array(2)
} }
/** @deprecated Use {@link LLink.create} */ /** @deprecated Use {@link LLink.create} */

View File

@@ -1,4 +1,3 @@
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types' import { LayoutSource } from '@/renderer/core/layout/types'
@@ -13,8 +12,8 @@ import type {
LinkSegment, LinkSegment,
Point, Point,
Positionable, Positionable,
ReadonlyLinkNetwork, ReadOnlyRect,
Rect ReadonlyLinkNetwork
} from './interfaces' } from './interfaces'
import { distance, isPointInRect } from './measure' import { distance, isPointInRect } from './measure'
import type { Serialisable, SerialisableReroute } from './types/serialisation' import type { Serialisable, SerialisableReroute } from './types/serialisation'
@@ -50,6 +49,8 @@ export class Reroute
return Reroute.radius + gap + Reroute.slotRadius return Reroute.radius + gap + Reroute.slotRadius
} }
#malloc = new Float32Array(8)
/** The network this reroute belongs to. Contains all valid links and reroutes. */ /** The network this reroute belongs to. Contains all valid links and reroutes. */
#network: WeakRef<LinkNetwork> #network: WeakRef<LinkNetwork>
@@ -72,7 +73,7 @@ export class Reroute
/** This property is only defined on the last reroute of a floating reroute chain (closest to input end). */ /** This property is only defined on the last reroute of a floating reroute chain (closest to input end). */
floating?: FloatingRerouteSlot floating?: FloatingRerouteSlot
#pos: [number, number] = [0, 0] #pos = this.#malloc.subarray(0, 2)
/** @inheritdoc */ /** @inheritdoc */
get pos(): Point { get pos(): Point {
return this.#pos return this.#pos
@@ -88,17 +89,17 @@ export class Reroute
} }
/** @inheritdoc */ /** @inheritdoc */
get boundingRect(): Rectangle { get boundingRect(): ReadOnlyRect {
const { radius } = Reroute const { radius } = Reroute
const [x, y] = this.#pos const [x, y] = this.#pos
return Rectangle.from([x - radius, y - radius, 2 * radius, 2 * radius]) return [x - radius, y - radius, 2 * radius, 2 * radius]
} }
/** /**
* Slightly over-sized rectangle, guaranteed to contain the entire surface area for hover detection. * Slightly over-sized rectangle, guaranteed to contain the entire surface area for hover detection.
* Eliminates most hover positions using an extremely cheap check. * Eliminates most hover positions using an extremely cheap check.
*/ */
get #hoverArea(): Rect { get #hoverArea(): ReadOnlyRect {
const xOffset = 2 * Reroute.slotOffset const xOffset = 2 * Reroute.slotOffset
const yOffset = 2 * Math.max(Reroute.radius, Reroute.slotRadius) const yOffset = 2 * Math.max(Reroute.radius, Reroute.slotRadius)
@@ -125,14 +126,14 @@ export class Reroute
sin: number = 0 sin: number = 0
/** Bezier curve control point for the "target" (input) side of the link */ /** Bezier curve control point for the "target" (input) side of the link */
controlPoint: [number, number] = [0, 0] controlPoint: Point = this.#malloc.subarray(4, 6)
/** @inheritdoc */ /** @inheritdoc */
path?: Path2D path?: Path2D
/** @inheritdoc */ /** @inheritdoc */
_centreAngle?: number _centreAngle?: number
/** @inheritdoc */ /** @inheritdoc */
_pos: [number, number] = [0, 0] _pos: Float32Array = this.#malloc.subarray(6, 8)
/** @inheritdoc */ /** @inheritdoc */
_dragging?: boolean _dragging?: boolean

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