Compare commits

..

20 Commits

Author SHA1 Message Date
Terry Jia
6ea615fb8c graph mutation service implementation 2025-08-17 23:48:31 -04:00
Jin Yi
ceac8f3741 Modal Standardization (#4784) 2025-08-18 09:41:15 +09:00
Christian Byrne
b1057f164b [fix] Resolve group node execution error when connecting to external nodes (#5054)
* [fix] resolve group node execution error when connecting to external nodes

Fixed ExecutableGroupNodeChildDTO.resolveInput to properly handle connections from group node children to external nodes. The method now tries to find nodes by their full ID first (for external nodes) before falling back to the shortened ID (for internal group nodes).

Added comprehensive unit tests to prevent regression.

* [feat] Add error check for unsupported group nodes inside subgraphs

Added validation to detect when group node children are executing within subgraph contexts (execution ID has >2 segments) and provide clear error message directing users to convert to subgraphs instead.

Includes comprehensive test coverage for the new validation.
2025-08-17 16:39:06 -07:00
pythongosssss
4a189bdc93 Minor updates to subgraph breadcrumb item (#5060)
- change active item text to primary color
- change rename action to behave the same as double clicking label
2025-08-17 11:18:36 -07:00
Christian Byrne
f0adb4c9d3 [bugfix] Allow removeInput/removeOutput on nodes without graph reference (#5053)
- Modified removeInput/removeOutput to skip disconnect operations when node has no graph
- Both methods now safely handle nodes that aren't part of a graph
- Added comprehensive tests for the new behavior
- Fixes #5037
2025-08-17 11:14:53 -07:00
Christian Byrne
d5d0aa52c2 [refactor] Refactor minimap initialization logic (#5052)
* move ref initialization to the component

* remove redundant init
2025-08-17 10:52:25 -07:00
ComfyUI Wiki
69c660b3b7 handle minimap cleanup called before map set (#5038)
Co-authored-by: bymyself <cbyrne@comfy.org>
2025-08-17 09:46:59 -07:00
pythongosssss
88579c2a40 Update menu items with a active toggle state to not close menu when clicked (#5050) 2025-08-17 09:01:41 -07:00
Christian Byrne
7ab247aa1d Improve release command flow and GTM criteria (#5040)
- Reorganize steps to complete all analysis before execution
- Move Breaking Change Analysis to Step 3 (was Step 6)
- Move Dependency Analysis to Step 4 (was Step 7)
- Move GTM Feature Summary to Step 5 (was Step 16)
- Add stricter GTM criteria to avoid minor features
- Simplify PR data extraction to prevent timeouts
- Enhance Version Preview to suggest version based on analysis

These changes ensure critical analysis steps aren't skipped during
execution and provide clearer criteria for marketing-worthy features.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-16 12:25:28 -07:00
Alexander Piskun
c78d03dd2c api_nodes: added prices for Vidu Video nodes (#5035) 2025-08-16 07:45:15 -07:00
Comfy Org PR Bot
65785af348 [release] Increment version to 1.26.4 (#5032)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-08-15 20:21:20 -07:00
Arjan Singh
ec4ad5ea92 fix: issue #4121 (#5029) 2025-08-15 18:41:14 -07:00
Christian Byrne
e9ddf29507 [bugfix] Preserve nested subgraph widget values during serialization (#5023)
When saving workflows with nested subgraphs, promoted widget values were not being synchronized back to the subgraph definitions before serialization. This caused widget values to revert to their original defaults when reloading the workflow.

The fix overrides the serialize() method in SubgraphNode to sync promoted widget values to their corresponding widgets in the subgraph definition before serialization occurs.

Fixes the issue where nested subgraph widget values would be lost after save/reload.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-15 14:35:11 -07:00
AustinMroz
fdd8564c07 Deep copy subgraphs to clipboard, update nested ids on paste (#5003)
* Deep copy to clipboard, update nested ids on paste

The copyToClipboard function wasn't walking subgraphs and leaving nested
subgraphs unserialized. This has now been fixed.

This requires that equivalent support be added to _pasteFromClipboard to
update the ids of nested subgraphs which are pasted.

* Add extra advisory comments
2025-08-15 14:03:29 -07:00
Christian Byrne
d18081a54e fix: improve minimap subgraph navigation with graph UUID callback tracking (#5018)
- Replace single callback storage with Map using graph UUIDs as keys
- Fix minimap not updating when navigating between subgraphs
- Add proper cleanup and error handling for callback management
- Switch from app.canvas.graph to reactive workflowStore.activeSubgraph
- Prevent callback wrapping recursion by tracking setup state per graph
2025-08-15 13:34:44 -07:00
Christian Byrne
45cc6ca2b4 Fix widget disconnection issue in subgraphs #4922 (#5015)
* [bugfix] Fix widget disconnection issue in subgraphs

When disconnecting a node from a SubgraphInput, the target input's link
reference was not being cleared in LLink.disconnect(). This caused
widgets to remain greyed out because they still thought they were
connected (slot.link was not null).

The fix ensures that when a link is disconnected, the target node's
input slot is properly cleaned up by setting input.link = null.

Fixes #4922

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

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

* [test] Add tests for LLink disconnect fix for widget issue

Add comprehensive tests for the LLink.disconnect() method to verify
that target input link references are properly cleared when disconnecting.
This prevents widgets from remaining greyed out after disconnection.

Tests cover:
- Basic disconnect functionality with link reference cleanup
- Edge cases with invalid target nodes
- Preventing interference between different connections

Related to #4922

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-15 13:12:47 -07:00
Christian Byrne
c303a3f037 [fix] Complete traditional to simplified Chinese character conversion (#5013)
* [fix] Complete traditional to simplified Chinese character conversion

Fixes issue where the automated translation system was incorrectly
mixing traditional Chinese characters into simplified Chinese (zh)
locale files after PR #4410 added zh-TW support.

Changes:
- Updated .i18nrc.cjs with explicit guidelines for AI model to
  distinguish between simplified and traditional Chinese
- Fixed 50+ traditional characters in zh locale files:
  - commands.json: 畫→画, 減→减, 筆→笔
  - main.json: 關→关, 刪→删, 複→复, 製→制, 輸→输, etc.
  - settings.json: 舊→旧, 標→标, 選→选, etc.

Completed the systematic conversion work started in PRs #5005 and #4865
without overwriting any human translator decisions.

Fixes #5010

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

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

* Update locales [skip ci]

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-15 13:01:05 -07:00
Alexander Piskun
c90fd18ade api_nodes: added prices for gpt-5 series models (#4958) 2025-08-15 12:36:18 -07:00
Johnpaul Chiwetelu
2ed1704749 Translated Keyboard Shortcuts (#5007)
* fix: Update command label rendering to use i18n normalization

* fix: Replace deprecated  with t for command label rendering

* fix: Simplify command rendering check in ShortcutsList tests

* fix: Add missing translation for command label in ShortcutsList tests
2025-08-15 11:45:10 -07:00
Christian Byrne
7d5a4d423e [feat] Improve low quality rendering zoom threshold tooltip (#5009)
* [docs] Improve low quality rendering zoom threshold tooltip

Clarify the behavior of the setting to explain that lower values maintain quality when zoomed out, while higher values enable simplified rendering at normal zoom levels.

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

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

* Update locales [skip ci]

* [docs] Improve low quality rendering zoom threshold tooltip

Clarify the behavior of the setting to explain that lower values maintain quality when zoomed out, while higher values enable simplified rendering at normal zoom levels.

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

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

* Update locales [skip ci]

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-15 11:44:26 -07:00
102 changed files with 4412 additions and 3384 deletions

View File

@@ -111,50 +111,7 @@ echo "Last stable release: $LAST_STABLE"
```
7. **HUMAN ANALYSIS**: Review change summary and verify scope
### Step 3: Version Preview
**Version Preview:**
- Current: `${CURRENT_VERSION}`
- Proposed: Show exact version number
- **CONFIRMATION REQUIRED**: Proceed with version `X.Y.Z`?
### Step 4: Security and Dependency Audit
1. Run security audit:
```bash
npm audit --audit-level moderate
```
2. Check for known vulnerabilities in dependencies
3. Scan for hardcoded secrets or credentials:
```bash
git log -p ${BASE_TAG}..HEAD | grep -iE "(password|key|secret|token)" || echo "No sensitive data found"
```
4. Verify no sensitive data in recent commits
5. **SECURITY REVIEW**: Address any critical findings before proceeding?
### Step 5: Pre-Release Testing
1. Run complete test suite:
```bash
npm run test:unit
npm run test:component
```
2. Run type checking:
```bash
npm run typecheck
```
3. Run linting (may have issues with missing packages):
```bash
npm run lint || echo "Lint issues - verify if critical"
```
4. Test build process:
```bash
npm run build
npm run build:types
```
5. **QUALITY GATE**: All tests and builds passing?
### Step 6: Breaking Change Analysis
### Step 3: Breaking Change Analysis
1. Analyze API changes in:
- Public TypeScript interfaces
@@ -169,7 +126,7 @@ echo "Last stable release: $LAST_STABLE"
3. Generate breaking change summary
4. **COMPATIBILITY REVIEW**: Breaking changes documented and justified?
### Step 7: Analyze Dependency Updates
### Step 4: Analyze Dependency Updates
1. **Check significant dependency updates:**
```bash
@@ -195,7 +152,117 @@ echo "Last stable release: $LAST_STABLE"
done
```
### Step 8: Generate Comprehensive Release Notes
### Step 5: Generate GTM Feature Summary
1. **Collect PR data for analysis:**
```bash
# Get list of PR numbers from commits
PR_NUMBERS=$(git log ${BASE_TAG}..HEAD --oneline --no-merges --first-parent | \
grep -oE "#[0-9]+" | tr -d '#' | sort -u)
# Save PR data for each PR
echo "[" > prs-${NEW_VERSION}.json
first=true
for PR in $PR_NUMBERS; do
[[ "$first" == true ]] && first=false || echo "," >> prs-${NEW_VERSION}.json
gh pr view $PR --json number,title,author,body,labels 2>/dev/null >> prs-${NEW_VERSION}.json || echo "{}" >> prs-${NEW_VERSION}.json
done
echo "]" >> prs-${NEW_VERSION}.json
```
2. **Analyze for GTM-worthy features:**
```
<task>
Review these PRs to identify features worthy of marketing attention.
A feature is GTM-worthy if it meets ALL of these criteria:
- Introduces a NEW capability users didn't have before (not just improvements)
- Would be a compelling reason for users to upgrade to this version
- Can be demonstrated visually or has clear before/after comparison
- Affects a significant portion of the user base
NOT GTM-worthy:
- Bug fixes (even important ones)
- Minor UI tweaks or color changes
- Performance improvements without user-visible impact
- Internal refactoring
- Small convenience features
- Features that only improve existing functionality marginally
For each GTM-worthy feature, note:
- PR number, title, and author
- Media links from the PR description
- One compelling sentence on why users should care
If there are no GTM-worthy features, just say "No marketing-worthy features in this release."
</task>
PR data: [contents of prs-${NEW_VERSION}.json]
```
3. **Generate GTM notification:**
```bash
# Save to gtm-summary-${NEW_VERSION}.md based on analysis
# If GTM-worthy features exist, include them with testing instructions
# If not, note that this is a maintenance/bug fix release
# Check if notification is needed
if grep -q "No marketing-worthy features" gtm-summary-${NEW_VERSION}.md; then
echo "✅ No GTM notification needed for this release"
echo "📄 Summary saved to: gtm-summary-${NEW_VERSION}.md"
else
echo "📋 GTM summary saved to: gtm-summary-${NEW_VERSION}.md"
echo "📤 Share this file in #gtm channel to notify the team"
fi
```
### Step 6: Version Preview
**Version Preview:**
- Current: `${CURRENT_VERSION}`
- Proposed: Show exact version number based on analysis:
- Major version if breaking changes detected
- Minor version if new features added
- Patch version if only bug fixes
- **CONFIRMATION REQUIRED**: Proceed with version `X.Y.Z`?
### Step 7: Security and Dependency Audit
1. Run security audit:
```bash
npm audit --audit-level moderate
```
2. Check for known vulnerabilities in dependencies
3. Scan for hardcoded secrets or credentials:
```bash
git log -p ${BASE_TAG}..HEAD | grep -iE "(password|key|secret|token)" || echo "No sensitive data found"
```
4. Verify no sensitive data in recent commits
5. **SECURITY REVIEW**: Address any critical findings before proceeding?
### Step 8: Pre-Release Testing
1. Run complete test suite:
```bash
npm run test:unit
npm run test:component
```
2. Run type checking:
```bash
npm run typecheck
```
3. Run linting (may have issues with missing packages):
```bash
npm run lint || echo "Lint issues - verify if critical"
```
4. Test build process:
```bash
npm run build
npm run build:types
```
5. **QUALITY GATE**: All tests and builds passing?
### Step 9: Generate Comprehensive Release Notes
1. Extract commit messages since base release:
```bash
@@ -257,7 +324,7 @@ echo "Last stable release: $LAST_STABLE"
- Ensure consistent bullet format: `- Description (#PR_NUMBER)`
5. **CONTENT REVIEW**: Release notes follow standard format?
### Step 9: Create Version Bump PR
### Step 10: Create Version Bump PR
**For standard version bumps (patch/minor/major):**
```bash
@@ -303,7 +370,7 @@ echo "Workflow triggered. Waiting for PR creation..."
```
4. **PR REVIEW**: Version bump PR created with standardized release notes?
### Step 10: Critical Release PR Verification
### Step 11: Critical Release PR Verification
1. **CRITICAL**: Verify PR has "Release" label:
```bash
@@ -325,7 +392,7 @@ echo "Workflow triggered. Waiting for PR creation..."
```
7. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
### Step 11: Pre-Merge Validation
### Step 12: Pre-Merge Validation
1. **Review Requirements**: Release PRs require approval
2. Monitor CI checks - watch for update-locales
@@ -333,7 +400,7 @@ echo "Workflow triggered. Waiting for PR creation..."
4. Check no new commits to main since PR creation
5. **DEPLOYMENT READINESS**: Ready to merge?
### Step 12: Execute Release
### Step 13: Execute Release
1. **FINAL CONFIRMATION**: Merge PR to trigger release?
2. Merge the Release PR:
@@ -366,7 +433,7 @@ echo "Workflow triggered. Waiting for PR creation..."
gh run watch ${WORKFLOW_RUN_ID}
```
### Step 13: Enhance GitHub Release
### Step 14: Enhance GitHub Release
1. Wait for automatic release creation:
```bash
@@ -394,7 +461,7 @@ echo "Workflow triggered. Waiting for PR creation..."
gh release view v${NEW_VERSION}
```
### Step 14: Verify Multi-Channel Distribution
### Step 15: Verify Multi-Channel Distribution
1. **GitHub Release:**
```bash
@@ -432,7 +499,7 @@ echo "Workflow triggered. Waiting for PR creation..."
4. **DISTRIBUTION VERIFICATION**: All channels published successfully?
### Step 15: Post-Release Monitoring Setup
### Step 16: Post-Release Monitoring Setup
1. **Monitor immediate release health:**
```bash
@@ -508,87 +575,42 @@ echo "Workflow triggered. Waiting for PR creation..."
4. **RELEASE COMPLETION**: All post-release setup completed?
### Step 16: Generate GTM Feature Summary
### Step 17: Create Release Summary
1. **Extract and analyze PR data:**
1. **Create comprehensive release summary:**
```bash
echo "📊 Checking for marketing-worthy features..."
# Extract all PR data inline
PR_DATA=$(
PR_LIST=$(git log ${BASE_TAG}..HEAD --grep="Merge pull request" --pretty=format:"%s" | grep -oE "#[0-9]+" | tr -d '#' | sort -u)
echo "["
first=true
for PR in $PR_LIST; do
[[ "$first" == true ]] && first=false || echo ","
gh pr view $PR --json number,title,author,body,files,labels,closedAt 2>/dev/null || continue
done
echo "]"
)
# Save for analysis
echo "$PR_DATA" > prs-${NEW_VERSION}.json
cat > release-summary-${NEW_VERSION}.md << EOF
# Release Summary: ComfyUI Frontend v${NEW_VERSION}
**Released:** $(date)
**Type:** ${VERSION_TYPE}
**Duration:** ~${RELEASE_DURATION} minutes
**Release Commit:** ${RELEASE_COMMIT}
## Metrics
- **Commits Included:** ${COMMITS_COUNT}
- **Contributors:** ${CONTRIBUTORS_COUNT}
- **Files Changed:** ${FILES_CHANGED}
- **Lines Added/Removed:** +${LINES_ADDED}/-${LINES_REMOVED}
## Distribution Status
- ✅ GitHub Release: Published
- ✅ PyPI Package: Available
- ✅ npm Types: Available
## Next Steps
- Monitor for 24-48 hours
- Address any critical issues immediately
- Plan next release cycle
## Files Generated
- \`release-notes-${NEW_VERSION}.md\` - Comprehensive release notes
- \`post-release-checklist.md\` - Follow-up tasks
- \`gtm-summary-${NEW_VERSION}.md\` - Marketing team notification
EOF
```
2. **Analyze for GTM-worthy features:**
```
<task>
Review these PRs to identify if ANY would interest a marketing/growth team.
Consider if a PR:
- Changes something users directly interact with or experience
- Makes something noticeably better, faster, or easier
- Introduces capabilities users have been asking for
- Has visual assets (screenshots, GIFs, videos) that could be shared
- Tells a compelling story about improvement or innovation
- Would make users excited if they heard about it
Many releases contain only technical improvements, bug fixes, or internal changes -
that's perfectly normal. Only flag PRs that would genuinely interest end users.
If you find marketing-worthy PRs, note:
- PR number, title, and author
- Any media links from the description
- One sentence on why it's worth showcasing
If nothing is marketing-worthy, just say "No marketing-worthy features in this release."
</task>
PR data: [contents of prs-${NEW_VERSION}.json]
```
3. **Generate GTM notification (only if needed):**
```
If there are marketing-worthy features, create a message for #gtm with:
🚀 Frontend Release v${NEW_VERSION}
Timeline: Available now in nightly, ~2-3 weeks for core
Features worth showcasing:
[List the selected PRs with media links and authors]
Testing: --front-end-version ${NEW_VERSION}
If there are NO marketing-worthy features, generate:
"No marketing-worthy features in v${NEW_VERSION} - mostly internal improvements and bug fixes."
```
4. **Save the output:**
```bash
# Claude generates the GTM summary and saves it
# Save to gtm-summary-${NEW_VERSION}.md
# Check if notification is needed
if grep -q "No marketing-worthy features" gtm-summary-${NEW_VERSION}.md; then
echo "✅ No GTM notification needed for this release"
echo "📄 Summary saved to: gtm-summary-${NEW_VERSION}.md"
else
echo "📋 GTM summary saved to: gtm-summary-${NEW_VERSION}.md"
echo "📤 Share this file in #gtm channel to notify the team"
fi
```
2. **RELEASE COMPLETION**: All steps completed successfully?
## Advanced Safety Features

View File

@@ -13,6 +13,10 @@ module.exports = defineConfig({
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.
Note: For Traditional Chinese (Taiwan), use Taiwan-specific terminology and traditional characters.
IMPORTANT Chinese Translation Guidelines:
- For 'zh' locale: Use ONLY Simplified Chinese characters (简体中文). Common examples: 节点 (not 節點), 画布 (not 畫布), 图像 (not 圖像), 选择 (not 選擇), 减小 (not 減小).
- For 'zh-TW' locale: Use ONLY Traditional Chinese characters (繁體中文) with Taiwan-specific terminology.
- NEVER mix Simplified and Traditional Chinese characters within the same locale.
`
});

View File

@@ -73,6 +73,77 @@ test.describe('Menu', () => {
expect(isTextCutoff).toBe(false)
})
test('Clicking on active state items does not close menu', async ({
comfyPage
}) => {
// Open the menu
await comfyPage.menu.topbar.openTopbarMenu()
const menu = comfyPage.page.locator('.comfy-command-menu')
// Navigate to View menu
const viewMenuItem = comfyPage.page.locator(
'.p-menubar-item-label:text-is("View")'
)
await viewMenuItem.hover()
// Wait for submenu to appear
const viewSubmenu = comfyPage.page
.locator('.p-tieredmenu-submenu:visible')
.first()
await viewSubmenu.waitFor({ state: 'visible' })
// Find Bottom Panel menu item
const bottomPanelMenuItem = viewSubmenu
.locator('.p-tieredmenu-item:has-text("Bottom Panel")')
.first()
const bottomPanelItem = bottomPanelMenuItem.locator(
'.p-menubar-item-label:text-is("Bottom Panel")'
)
await bottomPanelItem.waitFor({ state: 'visible' })
// Get checkmark icon element
const checkmark = bottomPanelMenuItem.locator('.pi-check')
// Check initial state of bottom panel (it's initially hidden)
const bottomPanel = comfyPage.page.locator('.bottom-panel')
await expect(bottomPanel).not.toBeVisible()
// Checkmark should be invisible initially (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
// Click Bottom Panel to toggle it on
await bottomPanelItem.click()
// Verify menu is still visible after clicking
await expect(menu).toBeVisible()
await expect(viewSubmenu).toBeVisible()
// Verify bottom panel is now visible
await expect(bottomPanel).toBeVisible()
// Checkmark should now be visible (panel is shown)
await expect(checkmark).not.toHaveClass(/invisible/)
// Click Bottom Panel again to toggle it off
await bottomPanelItem.click()
// Verify menu is still visible after second click
await expect(menu).toBeVisible()
await expect(viewSubmenu).toBeVisible()
// Verify bottom panel is hidden again
await expect(bottomPanel).not.toBeVisible()
// Checkmark should be invisible again (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
// Click outside to close menu
await comfyPage.page.locator('body').click({ position: { x: 10, y: 10 } })
// Verify menu is now closed
await expect(menu).not.toBeVisible()
})
test('Displays keybinding next to item', async ({ comfyPage }) => {
await comfyPage.menu.topbar.openTopbarMenu()
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('File')

572
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.26.3",
"version": "1.26.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.26.3",
"version": "1.26.4",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -975,32 +975,6 @@
"integrity": "sha512-o6WFbYn9yAkGbkOwvhPF7pbKDvN0occZ21Tfyhya8CIsIqKpTHLft0aOqo4yhSh+kTxN16FYjsfrTH5Olk4WuA==",
"license": "GPL-3.0-only"
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
@@ -4717,17 +4691,6 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@trivago/prettier-plugin-sort-imports": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-5.2.0.tgz",
@@ -4763,38 +4726,6 @@
}
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
@@ -5720,15 +5651,6 @@
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="
},
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
"deprecated": "Use your platform's native atob() and btoa() methods instead",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
@@ -5798,18 +5720,6 @@
"node": ">=0.4.0"
}
},
"node_modules/acorn-globals": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz",
"integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"acorn": "^8.1.0",
"acorn-walk": "^8.0.2"
}
},
"node_modules/acorn-jsx": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
@@ -5820,34 +5730,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.3",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz",
"integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/agentkeepalive": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz",
@@ -6058,14 +5940,6 @@
"node": ">= 8"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -7227,14 +7101,6 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
@@ -7305,58 +7171,12 @@
"node": ">=4"
}
},
"node_modules/cssom": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/cssstyle": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz",
"integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"cssom": "~0.3.6"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cssstyle/node_modules/cssom": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz",
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/data-urls": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
"integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"abab": "^2.0.6",
"whatwg-mimetype": "^3.0.0",
"whatwg-url": "^11.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@@ -7420,14 +7240,6 @@
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
"integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/decode-named-character-reference": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz",
@@ -7583,17 +7395,6 @@
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
@@ -7689,21 +7490,6 @@
}
]
},
"node_modules/domexception": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
"integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==",
"deprecated": "Use your platform's native DOMException instead",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/domhandler": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
@@ -8065,29 +7851,6 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"dev": true
},
"node_modules/escodegen": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"esprima": "^4.0.1",
"estraverse": "^5.2.0",
"esutils": "^2.0.2"
},
"bin": {
"escodegen": "bin/escodegen.js",
"esgenerate": "bin/esgenerate.js"
},
"engines": {
"node": ">=6.0"
},
"optionalDependencies": {
"source-map": "~0.6.1"
}
},
"node_modules/eslint": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.12.0.tgz",
@@ -9638,20 +9401,6 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"dev": true
},
"node_modules/html-encoding-sniffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
"integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"whatwg-encoding": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/html-minifier-terser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
@@ -9703,37 +9452,6 @@
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA=="
},
"node_modules/http-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
"integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@tootallnate/once": "2",
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
@@ -10353,14 +10071,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -10592,53 +10302,6 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsdom": {
"version": "20.0.3",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz",
"integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"abab": "^2.0.6",
"acorn": "^8.8.1",
"acorn-globals": "^7.0.0",
"cssom": "^0.5.0",
"cssstyle": "^2.3.0",
"data-urls": "^3.0.2",
"decimal.js": "^10.4.2",
"domexception": "^4.0.0",
"escodegen": "^2.0.0",
"form-data": "^4.0.0",
"html-encoding-sniffer": "^3.0.0",
"http-proxy-agent": "^5.0.0",
"https-proxy-agent": "^5.0.1",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.2",
"parse5": "^7.1.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^4.1.2",
"w3c-xmlserializer": "^4.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^2.0.0",
"whatwg-mimetype": "^3.0.0",
"whatwg-url": "^11.0.0",
"ws": "^8.11.0",
"xml-name-validator": "^4.0.0"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"canvas": "^2.5.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -11651,14 +11314,6 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
@@ -13048,14 +12703,6 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nwsapi": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz",
"integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -14215,14 +13862,6 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -14288,14 +13927,6 @@
}
]
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -14801,14 +14432,6 @@
"node": ">=0.10.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/resolve": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
@@ -15016,20 +14639,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -15681,14 +15290,6 @@
"react": "^16.11.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/synckit": {
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.3.tgz",
@@ -15943,37 +15544,6 @@
"node": ">=6"
}
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tr46": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
"integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"punycode": "^2.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/trough": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
@@ -16004,51 +15574,6 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -16762,17 +16287,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -17014,18 +16528,6 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",
@@ -17102,14 +16604,6 @@
"node": ">=6"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -17911,20 +17405,6 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
"integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"xml-name-validator": "^4.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/walk-up-path": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz",
@@ -17986,20 +17466,6 @@
"node": ">=0.8.0"
}
},
"node_modules/whatwg-encoding": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
"integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
@@ -18009,21 +17475,6 @@
"node": ">=12"
}
},
"node_modules/whatwg-url": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
"integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"tr46": "^3.0.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/when-exit": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.3.tgz",
@@ -18320,14 +17771,6 @@
"node": ">=12"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -18433,17 +17876,6 @@
"node": ">=8"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

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

View File

@@ -20,7 +20,7 @@
>
<div class="shortcut-info flex-grow pr-4">
<div class="shortcut-name text-sm font-medium">
{{ command.label || command.id }}
{{ t(`commands.${normalizeI18nKey(command.id)}.label`) }}
</div>
</div>
@@ -50,6 +50,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ComfyCommandImpl } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
const { t } = useI18n()

View File

@@ -10,7 +10,8 @@
:class="{
'flex items-center gap-1': isActive,
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
'p-breadcrumb-item-link-icon-visible': isActive
'p-breadcrumb-item-link-icon-visible': isActive,
'active-breadcrumb-item': isActive
}"
@click="handleClick"
>
@@ -111,21 +112,7 @@ const menuItems = computed<MenuItem[]>(() => {
{
label: t('g.rename'),
icon: 'pi pi-pencil',
command: async () => {
let initialName =
workflowStore.activeSubgraph?.name ??
workflowStore.activeWorkflow?.filename
if (!initialName) return
const newName = await dialogService.prompt({
title: t('g.rename'),
message: t('breadcrumbsMenu.enterNewName'),
defaultValue: initialName
})
await rename(newName, initialName)
}
command: startRename
},
{
label: t('breadcrumbsMenu.duplicate'),
@@ -175,20 +162,24 @@ const handleClick = (event: MouseEvent) => {
menu.value?.hide()
event.stopPropagation()
event.preventDefault()
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
}
}
})
startRename()
}
}
const startRename = () => {
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
}
}
})
}
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
@@ -212,4 +203,8 @@ const inputBlur = async (doRename: boolean) => {
.p-breadcrumb-item-label {
@apply whitespace-nowrap text-ellipsis overflow-hidden;
}
.active-breadcrumb-item {
color: var(--text-primary);
}
</style>

View File

@@ -0,0 +1,15 @@
<template>
<button
class="flex justify-center items-center outline-none border-none p-0 bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white w-8 h-8 rounded-lg cursor-pointer"
role="button"
@click="onClick"
>
<slot></slot>
</button>
</template>
<script setup lang="ts">
const { onClick } = defineProps<{
onClick: () => void
}>()
</script>

View File

@@ -0,0 +1,67 @@
<template>
<BaseWidgetLayout>
<template #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<i-lucide:puzzle class="text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">{{ t('g.title') }}</span>
</template>
</LeftSidePanel>
</template>
<template #header>
<!-- here -->
</template>
<template #content>
<!-- here -->
</template>
<template #rightPanel>
<RightSidePanel></RightSidePanel>
</template>
</BaseWidgetLayout>
</template>
<script setup lang="ts">
import { provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { NavGroupData, NavItemData } from '@/types/custom_components/navTypes'
import { OnCloseKey } from '@/types/custom_components/widgetTypes'
import BaseWidgetLayout from './layout/BaseWidgetLayout.vue'
import LeftSidePanel from './panel/LeftSidePanel.vue'
import RightSidePanel from './panel/RightSidePanel.vue'
const { t } = useI18n()
const { onClose } = defineProps<{
onClose: () => void
}>()
provide(OnCloseKey, onClose)
const tempNavigation = ref<(NavItemData | NavGroupData)[]>([
{ id: 'installed', label: 'Installed' },
{
title: 'TAGS',
items: [
{ id: 'tag-sd15', label: 'SD 1.5' },
{ id: 'tag-sdxl', label: 'SDXL' },
{ id: 'tag-utility', label: 'Utility' }
]
},
{
title: 'CATEGORIES',
items: [
{ id: 'cat-models', label: 'Models' },
{ id: 'cat-nodes', label: 'Nodes' }
]
}
])
const selectedNavItem = ref<string | null>('installed')
</script>

View File

@@ -0,0 +1,176 @@
<template>
<div
class="base-widget-layout rounded-2xl overflow-hidden relative bg-zinc-100 dark-theme:bg-zinc-800"
>
<IconButton
v-show="!isRightPanelOpen && hasRightPanel"
class="absolute top-4 right-16 z-10 transition-opacity duration-200"
:class="{
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
}"
@click="toggleRightPanel"
>
<i-lucide:panel-right class="text-sm" />
</IconButton>
<IconButton
class="absolute top-4 right-6 z-10 transition-opacity duration-200"
@click="closeDialog"
>
<i class="pi pi-times text-sm"></i>
</IconButton>
<div class="flex w-full h-full">
<Transition name="slide-panel">
<nav
v-if="$slots.leftPanel && showLeftPanel"
:class="[
PANEL_SIZES.width,
PANEL_SIZES.minWidth,
PANEL_SIZES.maxWidth
]"
>
<slot name="leftPanel"></slot>
</nav>
</Transition>
<div class="flex-1 flex bg-zinc-100 dark-theme:bg-neutral-900">
<div class="w-full h-full flex flex-col">
<header
v-if="$slots.header"
class="w-full h-16 px-6 py-4 flex justify-between gap-2"
>
<div class="flex-1 flex gap-2 flex-shrink-0">
<IconButton v-if="!notMobile" @click="toggleLeftPanel">
<i-lucide:panel-left v-if="!showLeftPanel" class="text-sm" />
<i-lucide:panel-left-close v-else class="text-sm" />
</IconButton>
<slot name="header"></slot>
</div>
<div class="flex justify-end gap-2 min-w-20">
<slot name="header-right-area"></slot>
<IconButton
v-if="isRightPanelOpen && hasRightPanel"
@click="toggleRightPanel"
>
<i-lucide:panel-right-close class="text-sm" />
</IconButton>
</div>
</header>
<main class="flex-1">
<slot name="content"></slot>
</main>
</div>
<Transition name="slide-panel-right">
<aside
v-if="hasRightPanel && isRightPanelOpen"
class="w-1/4 min-w-40 max-w-80"
>
<slot name="rightPanel"></slot>
</aside>
</Transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useBreakpoints } from '@vueuse/core'
import { computed, inject, ref, useSlots, watch } from 'vue'
import IconButton from '@/components/custom/button/IconButton.vue'
import { OnCloseKey } from '@/types/custom_components/widgetTypes'
const BREAKPOINTS = { sm: 480 }
const PANEL_SIZES = {
width: 'w-1/3',
minWidth: 'min-w-40',
maxWidth: 'max-w-56'
}
const slots = useSlots()
const closeDialog = inject(OnCloseKey, () => {})
const breakpoints = useBreakpoints(BREAKPOINTS)
const notMobile = breakpoints.greater('sm')
const isLeftPanelOpen = ref<boolean>(true)
const isRightPanelOpen = ref<boolean>(false)
const mobileMenuOpen = ref<boolean>(false)
const hasRightPanel = computed(() => !!slots.rightPanel)
watch(notMobile, (isDesktop) => {
if (!isDesktop) {
mobileMenuOpen.value = false
}
})
const showLeftPanel = computed(() => {
const shouldShow = notMobile.value
? isLeftPanelOpen.value
: mobileMenuOpen.value
return shouldShow
})
const toggleLeftPanel = () => {
if (notMobile.value) {
isLeftPanelOpen.value = !isLeftPanelOpen.value
} else {
mobileMenuOpen.value = !mobileMenuOpen.value
}
}
const toggleRightPanel = () => {
isRightPanelOpen.value = !isRightPanelOpen.value
}
</script>
<style scoped>
.base-widget-layout {
height: 80vh;
width: 90vw;
max-width: 1280px;
aspect-ratio: 20/13;
}
@media (min-width: 1450px) {
.base-widget-layout {
max-width: 1724px;
}
}
/* Fade transition for buttons */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Slide transition for left panel */
.slide-panel-enter-active,
.slide-panel-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
backface-visibility: hidden;
}
.slide-panel-enter-from,
.slide-panel-leave-to {
transform: translateX(-100%);
}
/* Slide transition for right panel */
.slide-panel-right-enter-active,
.slide-panel-right-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
backface-visibility: hidden;
}
.slide-panel-right-enter-from,
.slide-panel-right-leave-to {
transform: translateX(100%);
}
</style>

View File

@@ -0,0 +1,24 @@
<template>
<div
class="flex items-center gap-2 px-4 py-2 text-xs rounded-md transition-colors cursor-pointer"
:class="
active
? 'bg-neutral-100 dark-theme:bg-zinc-700 text-neutral'
: 'text-neutral hover:bg-zinc-100 hover:dark-theme:bg-zinc-700/50'
"
role="button"
@click="onClick"
>
<i-lucide:folder class="text-xs text-neutral" />
<span>
<slot></slot>
</span>
</div>
</template>
<script setup lang="ts">
const { active, onClick } = defineProps<{
active?: boolean
onClick: () => void
}>()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<h3
class="m-0 px-3 py-0 pt-5 text-sm font-bold uppercase text-neutral-400 dark-theme:text-neutral-400"
>
{{ title }}
</h3>
</template>
<script setup lang="ts">
const { title } = defineProps<{
title: string
}>()
</script>

View File

@@ -0,0 +1,75 @@
<template>
<div class="flex flex-col h-full w-full bg-white dark-theme:bg-zinc-800">
<PanelHeader>
<template #icon>
<slot name="header-icon"></slot>
</template>
<slot name="header-title"></slot>
</PanelHeader>
<nav class="flex-1 px-3 py-4 flex flex-col gap-2">
<template v-for="(item, index) in navItems" :key="index">
<div v-if="'items' in item" class="flex flex-col gap-2">
<NavTitle :title="item.title" />
<NavItem
v-for="subItem in item.items"
:key="subItem.id"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
{{ subItem.label }}
</NavItem>
</div>
<div v-else class="flex flex-col gap-2">
<NavItem
:active="activeItem === item.id"
@click="activeItem = item.id"
>
{{ item.label }}
</NavItem>
</div>
</template>
</nav>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { NavGroupData, NavItemData } from '@/types/custom_components/navTypes'
import NavItem from '../nav/NavItem.vue'
import NavTitle from '../nav/NavTitle.vue'
import PanelHeader from './PanelHeader.vue'
const { navItems = [], modelValue } = defineProps<{
navItems?: (NavItemData | NavGroupData)[]
modelValue?: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const getFirstItemId = () => {
if (!navItems || navItems.length === 0) {
return null
}
const firstEntry = navItems[0]
if ('items' in firstEntry && firstEntry.items.length > 0) {
return firstEntry.items[0].id
}
if ('id' in firstEntry) {
return firstEntry.id
}
return null
}
const activeItem = computed({
get: () => modelValue ?? getFirstItemId(),
set: (value: string | null) => emit('update:modelValue', value)
})
</script>

View File

@@ -0,0 +1,12 @@
<template>
<header class="flex items-center justify-between h-16 px-6">
<div class="flex items-center gap-2 pl-1">
<slot name="icon">
<i-lucide:puzzle class="text-neutral text-base" />
</slot>
<h2 class="font-bold text-base text-neutral">
<slot></slot>
</h2>
</div>
</header>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="w-full h-full pl-4 pr-6 pb-8 bg-white dark-theme:bg-zinc-800">
<slot></slot>
</div>
</template>

View File

@@ -10,14 +10,16 @@
:aria-labelledby="item.key"
>
<template #header>
<component
:is="item.headerComponent"
v-if="item.headerComponent"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
<div v-if="!item.dialogComponentProps?.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
:id="item.key"
/>
<h3 v-else :id="item.key">
{{ item.title || ' ' }}
</h3>
</div>
</template>
<component

View File

@@ -21,7 +21,6 @@
<MiniMap
v-if="comfyAppReady && minimapEnabled"
ref="minimapRef"
class="pointer-events-auto"
/>
</template>
@@ -71,7 +70,6 @@ import { useContextMenuTranslation } from '@/composables/useContextMenuTranslati
import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { useLitegraphSettings } from '@/composables/useLitegraphSettings'
import { useMinimap } from '@/composables/useMinimap'
import { usePaste } from '@/composables/usePaste'
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
@@ -119,9 +117,7 @@ const selectionToolboxEnabled = computed(() =>
settingStore.get('Comfy.Canvas.SelectionToolbox')
)
const minimapRef = ref<InstanceType<typeof MiniMap>>()
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
const minimap = useMinimap()
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
@@ -358,13 +354,6 @@ onMounted(async () => {
}
)
whenever(
() => minimapRef.value,
(ref) => {
minimap.setMinimapRef(ref)
}
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {

View File

@@ -1,6 +1,7 @@
<template>
<div
v-if="visible && initialized"
ref="minimapRef"
class="minimap-main-container flex absolute bottom-[20px] right-[90px] z-[1000]"
>
<MiniMapPanel
@@ -54,15 +55,13 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useMinimap } from '@/composables/useMinimap'
import { useCanvasStore } from '@/stores/graphStore'
import MiniMapPanel from './MiniMapPanel.vue'
const minimap = useMinimap()
const canvasStore = useCanvasStore()
const minimapRef = ref<HTMLDivElement>()
const {
initialized,
@@ -80,13 +79,13 @@ const {
renderBypass,
renderError,
updateOption,
init,
destroy,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handleWheel
} = minimap
handleWheel,
setMinimapRef
} = useMinimap()
const showOptionsPanel = ref(false)
@@ -94,20 +93,8 @@ const toggleOptionsPanel = () => {
showOptionsPanel.value = !showOptionsPanel.value
}
watch(
() => canvasStore.canvas,
async (canvas) => {
if (canvas && !initialized.value) {
await init()
}
},
{ immediate: true }
)
onMounted(async () => {
if (canvasStore.canvas) {
await init()
}
onMounted(() => {
setMinimapRef(minimapRef.value)
})
onUnmounted(() => {

View File

@@ -24,7 +24,7 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type { LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useGraphMutationService } from '@/services/graphMutationService'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -42,19 +42,23 @@ const inputStyle = computed<CSSProperties>(() => ({
const titleEditorStore = useTitleEditorStore()
const canvasStore = useCanvasStore()
const previousCanvasDraggable = ref(true)
const graphMutationService = useGraphMutationService()
const onEdit = (newValue: string) => {
const onEdit = async (newValue: string) => {
if (titleEditorStore.titleEditorTarget && newValue.trim() !== '') {
const trimmedTitle = newValue.trim()
titleEditorStore.titleEditorTarget.title = trimmedTitle
// If this is a subgraph node, sync the runtime subgraph name for breadcrumb reactivity
const target = titleEditorStore.titleEditorTarget
if (target instanceof LGraphNode && target.isSubgraphNode?.()) {
target.subgraph.name = trimmedTitle
}
app.graph.setDirtyCanvas(true, true)
if (target instanceof LGraphNode) {
await graphMutationService.updateNodeTitle(target.id, trimmedTitle)
// If this is a subgraph node, sync the runtime subgraph name for breadcrumb reactivity
if (target.isSubgraphNode?.()) {
target.subgraph.name = trimmedTitle
}
} else if (target instanceof LGraphGroup) {
await graphMutationService.updateGroupTitle(target.id, trimmedTitle)
}
}
showInput.value = false
titleEditorStore.titleEditorTarget = null

View File

@@ -90,18 +90,16 @@ const closeDialog = () => {
const canvasStore = useCanvasStore()
const addNode = (nodeDef: ComfyNodeDefImpl) => {
if (!triggerEvent) {
console.warn('The trigger event was undefined when addNode was called.')
return
}
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: getNewNodeLocation()
})
if (disconnectOnReset) {
if (disconnectOnReset && triggerEvent) {
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
} else if (!triggerEvent) {
console.warn('The trigger event was undefined when addNode was called.')
}
disconnectOnReset = false
// Notify changeTracker - new step should be added

View File

@@ -59,7 +59,7 @@
@mousedown="
isZoomCommand(item) ? handleZoomMouseDown(item, $event) : undefined
"
@click="isZoomCommand(item) ? handleZoomClick($event) : undefined"
@click="handleItemClick(item, $event)"
>
<i
v-if="hasActiveStateSiblings(item)"
@@ -285,11 +285,19 @@ const handleZoomMouseDown = (item: MenuItem, event: MouseEvent) => {
}
}
const handleZoomClick = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
// Prevent the menu from closing for zoom commands
return false
const handleItemClick = (item: MenuItem, event: MouseEvent) => {
// Prevent the menu from closing for zoom commands or commands that have active state
if (isZoomCommand(item) || item.comfyCommand?.active) {
event.preventDefault()
event.stopPropagation()
if (item.comfyCommand?.active) {
item.command?.({
item,
originalEvent: event
})
}
return false
}
}
const hasActiveStateSiblings = (item: MenuItem): boolean => {

View File

@@ -1362,9 +1362,27 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$0.0004/$0.0016 per 1K tokens'
} else if (model.includes('gpt-4.1')) {
return '$0.002/$0.008 per 1K tokens'
} else if (model.includes('gpt-5-nano')) {
return '$0.00005/$0.0004 per 1K tokens'
} else if (model.includes('gpt-5-mini')) {
return '$0.00025/$0.002 per 1K tokens'
} else if (model.includes('gpt-5')) {
return '$0.00125/$0.01 per 1K tokens'
}
return 'Token-based'
}
},
ViduTextToVideoNode: {
displayPrice: '$0.4/Run'
},
ViduImageToVideoNode: {
displayPrice: '$0.4/Run'
},
ViduReferenceVideoNode: {
displayPrice: '$0.4/Run'
},
ViduStartEndToVideoNode: {
displayPrice: '$0.4/Run'
}
}

View File

@@ -1,5 +1,6 @@
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
import {
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
@@ -878,6 +879,17 @@ export function useCoreCommands(): ComfyCommand[] {
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
}
},
{
id: 'Comfy.Dev.ShowModelSelector',
icon: 'pi pi-box',
label: 'Show Model Selector (Dev)',
versionAdded: '1.26.2',
category: 'view-controls' as const,
function: () => {
const modelSelectorDialog = useModelSelectorDialog()
modelSelectorDialog.show()
}
}
]

View File

@@ -2,7 +2,6 @@ import { whenever } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { useCivitaiModel } from '@/composables/useCivitaiModel'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
export function useDownload(url: string, fileName?: string) {
@@ -15,7 +14,7 @@ export function useDownload(url: string, fileName?: string) {
const fetchFileSize = async () => {
try {
const response = await fetchWithHeaders(url, { method: 'HEAD' })
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) throw new Error('Failed to fetch file size')
const size = response.headers.get('content-length')

View File

@@ -5,9 +5,9 @@ import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformS
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
@@ -27,6 +27,7 @@ export type MinimapOptionKey =
export function useMinimap() {
const settingStore = useSettingStore()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const colorPaletteStore = useColorPaletteStore()
const containerRef = ref<HTMLDivElement>()
@@ -147,7 +148,11 @@ export function useMinimap() {
}
const canvas = computed(() => canvasStore.canvas)
const graph = ref(app.canvas?.graph)
const graph = computed(() => {
// If we're in a subgraph, use that; otherwise use the canvas graph
const activeSubgraph = workflowStore.activeSubgraph
return activeSubgraph || canvas.value?.graph
})
const containerStyles = computed(() => ({
width: `${width}px`,
@@ -627,7 +632,8 @@ export function useMinimap() {
c.setDirty(true, true)
}
let originalCallbacks: GraphCallbacks = {}
// Map to store original callbacks per graph ID
const originalCallbacksMap = new Map<string, GraphCallbacks>()
const handleGraphChanged = useThrottleFn(() => {
needsFullRedraw.value = true
@@ -641,11 +647,18 @@ export function useMinimap() {
const g = graph.value
if (!g) return
originalCallbacks = {
// Check if we've already wrapped this graph's callbacks
if (originalCallbacksMap.has(g.id)) {
return
}
// Store the original callbacks for this graph
const originalCallbacks: GraphCallbacks = {
onNodeAdded: g.onNodeAdded,
onNodeRemoved: g.onNodeRemoved,
onConnectionChange: g.onConnectionChange
}
originalCallbacksMap.set(g.id, originalCallbacks)
g.onNodeAdded = function (node) {
originalCallbacks.onNodeAdded?.call(this, node)
@@ -670,15 +683,19 @@ export function useMinimap() {
const g = graph.value
if (!g) return
if (originalCallbacks.onNodeAdded !== undefined) {
g.onNodeAdded = originalCallbacks.onNodeAdded
}
if (originalCallbacks.onNodeRemoved !== undefined) {
g.onNodeRemoved = originalCallbacks.onNodeRemoved
}
if (originalCallbacks.onConnectionChange !== undefined) {
g.onConnectionChange = originalCallbacks.onConnectionChange
const originalCallbacks = originalCallbacksMap.get(g.id)
if (!originalCallbacks) {
console.error(
'Attempted to cleanup event listeners for graph that was never set up'
)
return
}
g.onNodeAdded = originalCallbacks.onNodeAdded
g.onNodeRemoved = originalCallbacks.onNodeRemoved
g.onConnectionChange = originalCallbacks.onConnectionChange
originalCallbacksMap.delete(g.id)
}
const init = async () => {
@@ -751,6 +768,19 @@ export function useMinimap() {
{ immediate: true, flush: 'post' }
)
// Watch for graph changes (e.g., when navigating to/from subgraphs)
watch(graph, (newGraph, oldGraph) => {
if (newGraph && newGraph !== oldGraph) {
cleanupEventListeners()
setupEventListeners()
needsFullRedraw.value = true
updateFlags.value.bounds = true
updateFlags.value.nodes = true
updateFlags.value.connections = true
updateMinimap()
}
})
watch(visible, async (isVisible) => {
if (isVisible) {
if (containerRef.value) {

View File

@@ -0,0 +1,29 @@
import ModelSelector from '@/components/custom/widget/ModelSelector.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
const DIALOG_KEY = 'global-model-selector'
export const useModelSelectorDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: ModelSelector,
props: {
onClose: hide
}
})
}
return {
show,
hide
}
}

View File

@@ -3,7 +3,6 @@ import { useI18n } from 'vue-i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { useDialogStore } from '@/stores/dialogStore'
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
import type {
@@ -161,17 +160,12 @@ export function useTemplateWorkflows() {
*/
const fetchTemplateJson = async (id: string, sourceModule: string) => {
if (sourceModule === 'default') {
// Default templates provided by frontend are served as static files
const response = await fetchWithHeaders(
api.fileURL(`/templates/${id}.json`)
)
return await response.json()
// Default templates provided by frontend are served on this separate endpoint
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
} else {
// Custom node templates served via API
const response = await api.fetchApi(
`/workflow_templates/${sourceModule}/${id}.json`
)
return await response.json()
return fetch(
api.apiURL(`/workflow_templates/${sourceModule}/${id}.json`)
).then((r) => r.json())
}
}

View File

@@ -1,16 +1,14 @@
import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { IWidget } from '@/lib/litegraph/src/litegraph'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { createAxiosWithHeaders } from '@/services/networkClientAdapter'
const MAX_RETRIES = 5
const TIMEOUT = 4096
// Create axios client with header injection
const axiosClient = createAxiosWithHeaders()
export interface CacheEntry<T> {
data: T
timestamp?: number
@@ -60,7 +58,7 @@ const fetchData = async (
controller: AbortController
) => {
const { route, response_key, query_params, timeout = TIMEOUT } = config
const res = await axiosClient.get(route, {
const res = await axios.get(route, {
params: query_params,
signal: controller.signal,
timeout

View File

@@ -772,7 +772,8 @@ export const CORE_SETTINGS: SettingParams[] = [
{
id: 'LiteGraph.Canvas.LowQualityRenderingZoomThreshold',
name: 'Low quality rendering zoom threshold',
tooltip: 'Render low quality shapes when zoomed out',
tooltip:
'Zoom level threshold for performance mode. Lower values (0.1) = quality at all zoom levels. Higher values (1.0) = performance mode even when zoomed in. Performance mode simplifies rendering by hiding text labels, shadows, and details.',
type: 'slider',
attrs: {
min: 0.1,

View File

@@ -1,180 +0,0 @@
/**
* Example showing how authentication headers are automatically injected
* with the new header registration system.
*
* Before: Services had to manually retrieve and add auth headers
* After: Headers are automatically injected via the network adapters
*/
import {
createAxiosWithHeaders,
fetchWithHeaders
} from '@/services/networkClientAdapter'
// ============================================
// BEFORE: Manual header management
// ============================================
// This is how services used to handle auth headers:
/*
import axios from 'axios'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
export async function oldWayToMakeRequest() {
// Had to manually get auth headers
const authHeaders = await useFirebaseAuthStore().getAuthHeader()
if (!authHeaders) {
throw new Error('Not authenticated')
}
// Had to manually add headers to each request
const response = await axios.get('/api/data', {
headers: {
...authHeaders,
'Content-Type': 'application/json'
}
})
return response.data
}
*/
// ============================================
// AFTER: Automatic header injection
// ============================================
// With the new system, auth headers are automatically injected:
/**
* Example 1: Using fetchWithHeaders
* Headers are automatically injected - no manual auth handling needed
*/
export async function modernFetchExample() {
// Just make the request - auth headers are added automatically!
const response = await fetchWithHeaders('/api/data', {
headers: {
'Content-Type': 'application/json'
// Auth headers are automatically added by the AuthHeaderProvider
}
})
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
return response.json()
}
/**
* Example 2: Using createAxiosWithHeaders
* Create an axios client that automatically injects headers
*/
export function createModernApiClient() {
// Create a client with automatic header injection
const client = createAxiosWithHeaders({
baseURL: '/api',
timeout: 30000
})
return {
async getData() {
// No need to manually add auth headers!
const response = await client.get('/data')
return response.data
},
async postData(data: any) {
// Auth headers are automatically included
const response = await client.post('/data', data)
return response.data
},
async updateData(id: string, data: any) {
// Works with all HTTP methods
const response = await client.put(`/data/${id}`, data)
return response.data
}
}
}
/**
* Example 3: Real-world service refactoring
* Shows how to update an existing service to use automatic headers
*/
// Before: CustomerEventsService with manual auth
/*
class OldCustomerEventsService {
private async makeRequest(url: string) {
const authHeaders = await useFirebaseAuthStore().getAuthHeader()
if (!authHeaders) {
throw new Error('Authentication required')
}
return axios.get(url, { headers: authHeaders })
}
async getEvents() {
return this.makeRequest('/customers/events')
}
}
*/
// After: CustomerEventsService with automatic auth
class ModernCustomerEventsService {
private client = createAxiosWithHeaders({
baseURL: '/api'
})
async getEvents() {
// Auth headers are automatically included!
const response = await this.client.get('/customers/events')
return response.data
}
async getEventDetails(eventId: string) {
// No manual auth handling needed
const response = await this.client.get(`/customers/events/${eventId}`)
return response.data
}
}
// ============================================
// Benefits of the new system:
// ============================================
/**
* 1. Cleaner code - no auth header boilerplate
* 2. Consistent auth handling across all services
* 3. Automatic token refresh (handled by Firebase SDK)
* 4. Fallback to API key when Firebase auth unavailable
* 5. Easy to add new header providers (debug headers, etc.)
* 6. Headers can be conditionally applied based on URL/method
* 7. Priority system allows overriding headers when needed
*/
// ============================================
// How it works behind the scenes:
// ============================================
/**
* 1. During app initialization (preInit hook), the AuthHeadersExtension
* registers the AuthHeaderProvider with the header registry
*
* 2. When you use fetchWithHeaders or createAxiosWithHeaders, they
* automatically query the header registry for all registered providers
*
* 3. The AuthHeaderProvider checks for Firebase token first, then
* falls back to API key if needed
*
* 4. Headers are merged and added to the request automatically
*
* 5. If authentication fails, the request proceeds without auth headers
* (the backend will handle the 401/403 response)
*/
export const examples = {
modernFetchExample,
createModernApiClient,
ModernCustomerEventsService
}

View File

@@ -1,133 +0,0 @@
/**
* Example of how extensions can register header providers
* This file demonstrates the header registration API for extension developers
*/
import { headerRegistry } from '@/services/headerRegistry'
import type {
HeaderMap,
HeaderProviderContext,
IHeaderProvider
} from '@/types/headerTypes'
/**
* Example 1: Simple static header provider
*/
class StaticHeaderProvider implements IHeaderProvider {
provideHeaders(_context: HeaderProviderContext): HeaderMap {
return {
'X-Extension-Name': 'my-extension',
'X-Extension-Version': '1.0.0'
}
}
}
/**
* Example 2: Dynamic header provider that adds headers based on the request
*/
class DynamicHeaderProvider implements IHeaderProvider {
async provideHeaders(context: HeaderProviderContext): Promise<HeaderMap> {
const headers: HeaderMap = {}
// Add different headers based on the URL
if (context.url.includes('/api/')) {
headers['X-API-Version'] = 'v2'
}
// Add headers based on request method
if (context.method === 'POST' || context.method === 'PUT') {
headers['X-Request-ID'] = () => crypto.randomUUID()
}
// Add timestamp header
headers['X-Timestamp'] = () => new Date().toISOString()
return headers
}
}
/**
* Example 3: Auth token provider
*/
class AuthTokenProvider implements IHeaderProvider {
private getToken(): string | null {
// This could retrieve a token from storage, state, etc.
return localStorage.getItem('auth-token')
}
provideHeaders(_context: HeaderProviderContext): HeaderMap {
const token = this.getToken()
if (token) {
return {
Authorization: `Bearer ${token}`
}
}
return {}
}
}
/**
* Example of how to register providers in an extension
*/
export function setupHeaderProviders() {
// Register a simple static provider
const staticRegistration = headerRegistry.registerHeaderProvider(
new StaticHeaderProvider()
)
// Register a dynamic provider with higher priority
const dynamicRegistration = headerRegistry.registerHeaderProvider(
new DynamicHeaderProvider(),
{ priority: 10 }
)
// Register an auth provider that only applies to API endpoints
const authRegistration = headerRegistry.registerHeaderProvider(
new AuthTokenProvider(),
{
priority: 20, // Higher priority to override other auth headers
filter: (context) => context.url.includes('/api/')
}
)
// Return cleanup function for when extension is unloaded
return () => {
staticRegistration.dispose()
dynamicRegistration.dispose()
authRegistration.dispose()
}
}
/**
* Example of a provider that integrates with a cloud service
*/
export class CloudServiceHeaderProvider implements IHeaderProvider {
constructor(
private apiKey: string,
private workspaceId: string
) {}
async provideHeaders(context: HeaderProviderContext): Promise<HeaderMap> {
// Only add headers for requests to the cloud service
if (!context.url.includes('cloud.comfyui.com')) {
return {}
}
return {
'X-API-Key': this.apiKey,
'X-Workspace-ID': this.workspaceId,
'X-Client-Version': '1.0.0',
'X-Session-ID': async () => {
// Could fetch or generate session ID asynchronously
const sessionId = await this.getOrCreateSessionId()
return sessionId
}
}
}
private async getOrCreateSessionId(): Promise<string> {
// Simulate async session creation
return 'session-' + Date.now()
}
}

View File

@@ -1,167 +0,0 @@
import { headerRegistry } from '@/services/headerRegistry'
import type { ComfyExtension } from '@/types/comfy'
import type {
HeaderMap,
HeaderProviderContext,
IHeaderProvider
} from '@/types/headerTypes'
/**
* Example extension showing how to register header providers
* during the pre-init lifecycle hook.
*
* The pre-init hook is the earliest extension lifecycle hook,
* called before the canvas is created. This makes it perfect
* for registering cross-cutting concerns like header providers.
*/
// Example: Authentication token provider
class AuthTokenProvider implements IHeaderProvider {
async provideHeaders(_context: HeaderProviderContext): Promise<HeaderMap> {
// This could fetch tokens from a secure store, refresh them, etc.
const token = await this.getAuthToken()
if (token) {
return {
Authorization: `Bearer ${token}`
}
}
return {}
}
private async getAuthToken(): Promise<string | null> {
// Example: Get token from localStorage or a secure store
// In a real implementation, this might refresh tokens, handle expiration, etc.
return localStorage.getItem('auth_token')
}
}
// Example: API key provider for specific domains
class ApiKeyProvider implements IHeaderProvider {
private apiKeys: Record<string, string> = {
'api.example.com': 'example-api-key',
'api.another.com': 'another-api-key'
}
provideHeaders(context: HeaderProviderContext): HeaderMap {
const url = new URL(context.url)
const apiKey = this.apiKeys[url.hostname]
if (apiKey) {
return {
'X-API-Key': apiKey
}
}
return {}
}
}
// Example: Custom header provider for debugging
class DebugHeaderProvider implements IHeaderProvider {
provideHeaders(_context: HeaderProviderContext): HeaderMap {
if (process.env.NODE_ENV === 'development') {
return {
'X-Debug-Mode': 'true',
'X-Request-ID': crypto.randomUUID()
}
}
return {}
}
}
export const headerRegistrationExtension: ComfyExtension = {
name: 'HeaderRegistration',
/**
* Pre-init hook - called before canvas creation.
* This is the perfect place to register header providers.
*/
async preInit(_app) {
console.log(
'[HeaderRegistration] Registering header providers in pre-init hook'
)
// Register auth token provider with high priority
const authRegistration = headerRegistry.registerHeaderProvider(
new AuthTokenProvider(),
{
priority: 100
}
)
// Register API key provider
const apiKeyRegistration = headerRegistry.registerHeaderProvider(
new ApiKeyProvider(),
{
priority: 90
}
)
// Register debug header provider with lower priority
const debugRegistration = headerRegistry.registerHeaderProvider(
new DebugHeaderProvider(),
{
priority: 10
}
)
// Store registrations for potential cleanup later
// Extensions can store their data on the app instance
const extensionData = {
headerRegistrations: [
authRegistration,
apiKeyRegistration,
debugRegistration
]
}
// Store a reference on the extension itself for potential cleanup
;(headerRegistrationExtension as any).registrations =
extensionData.headerRegistrations
},
/**
* Standard init hook - called after canvas creation.
* At this point, header providers are already active.
*/
async init(_app) {
console.log(
'[HeaderRegistration] Headers are now being injected into all HTTP requests'
)
},
/**
* Setup hook - called after app is fully initialized.
* We could add UI elements here to manage headers.
*/
async setup(_app) {
// Example: Add a command to test header injection
const { useCommandStore } = await import('@/stores/commandStore')
useCommandStore().registerCommand({
id: 'header-registration.test',
icon: 'pi pi-globe',
label: 'Test Header Injection',
function: async () => {
try {
// Make a test request to see headers in action
const response = await fetch('/api/test')
console.log('[HeaderRegistration] Test request completed', {
status: response.status,
headers: response.headers
})
} catch (error) {
console.error('[HeaderRegistration] Test request failed', error)
}
}
})
}
}
// Extension usage:
// 1. Import this extension in your extension index
// 2. Register it with app.registerExtension(headerRegistrationExtension)
// 3. Header providers will be automatically registered before any network activity

View File

@@ -1,29 +0,0 @@
import { AuthHeaderProvider } from '@/providers/authHeaderProvider'
import { app } from '@/scripts/app'
import { headerRegistry } from '@/services/headerRegistry'
/**
* Core extension that registers authentication header providers.
* This ensures all HTTP requests automatically include authentication headers.
*/
app.registerExtension({
name: 'Comfy.AuthHeaders',
/**
* Register authentication header provider in the pre-init phase.
* This ensures headers are available before any network activity.
*/
async preInit(_app) {
console.log('[AuthHeaders] Registering authentication header provider')
// Register the auth header provider with high priority
// This ensures auth headers are added to all requests
headerRegistry.registerHeaderProvider(new AuthHeaderProvider(), {
priority: 1000 // High priority to ensure auth headers are applied
})
console.log(
'[AuthHeaders] Authentication headers will be automatically injected'
)
}
})

View File

@@ -1,4 +1,3 @@
import './authHeaders'
import './clipspace'
import './contextMenuFilter'
import './dynamicPrompts'

View File

@@ -1,7 +1,6 @@
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { useToastStore } from '@/stores/toastStore'
class Load3dUtils {
@@ -10,7 +9,7 @@ class Load3dUtils {
prefix: string,
fileType: string = 'png'
) {
const blob = await fetchWithHeaders(imageData).then((r) => r.blob())
const blob = await fetch(imageData).then((r) => r.blob())
const name = `${prefix}_${Date.now()}.${fileType}`
const file = new File([blob], name, {
type: fileType === 'mp4' ? 'video/mp4' : 'image/png'

View File

@@ -4,8 +4,6 @@ import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter'
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter'
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { useToastStore } from '@/stores/toastStore'
export class ModelExporter {
@@ -38,18 +36,7 @@ export class ModelExporter {
desiredFilename: string
): Promise<void> {
try {
// Check if this is a ComfyUI relative URL
const isComfyUrl = url.startsWith('/') || url.includes('/view?')
let response: Response
if (isComfyUrl) {
// Use ComfyUI API client for internal URLs
response = await fetchWithHeaders(api.apiURL(url))
} else {
// Use direct fetch for external URLs
response = await fetchWithHeaders(url)
}
const response = await fetch(url)
const blob = await response.blob()
const link = document.createElement('a')

View File

@@ -3608,6 +3608,7 @@ export class LGraphCanvas
subgraphs: []
}
// NOTE: logic for traversing nested subgraphs depends on this being a set.
const subgraphs = new Set<Subgraph>()
// Create serialisable objects
@@ -3646,8 +3647,13 @@ export class LGraphCanvas
}
// Add unique subgraph entries
// TODO: Must find all nested subgraphs
// NOTE: subgraphs is appended to mid iteration.
for (const subgraph of subgraphs) {
for (const node of subgraph.nodes) {
if (node instanceof SubgraphNode) {
subgraphs.add(node.subgraph)
}
}
const cloned = subgraph.clone(true).asSerialisable()
serialisable.subgraphs.push(cloned)
}
@@ -3764,12 +3770,19 @@ export class LGraphCanvas
created.push(group)
}
// Update subgraph ids with nesting
function updateSubgraphIds(nodes: { type: string }[]) {
for (const info of nodes) {
const subgraph = results.subgraphs.get(info.type)
if (!subgraph) continue
info.type = subgraph.id
updateSubgraphIds(subgraph.nodes)
}
}
updateSubgraphIds(parsed.nodes)
// Nodes
for (const info of parsed.nodes) {
// If the subgraph was cloned, update references to use the new subgraph ID.
const subgraph = results.subgraphs.get(info.type)
if (subgraph) info.type = subgraph.id
const node = info.type == null ? null : LiteGraph.createNode(info.type)
if (!node) {
// failedNodes.push(info)

View File

@@ -24,6 +24,8 @@ import {
} from './measure'
import type { ISerialisedGroup } from './types/serialisation'
export type GroupId = number
export interface IGraphGroupFlags extends Record<string, unknown> {
pinned?: true
}

View File

@@ -1574,7 +1574,10 @@ export class LGraphNode
* remove an existing output slot
*/
removeOutput(slot: number): void {
this.disconnectOutput(slot)
// Only disconnect if node is part of a graph
if (this.graph) {
this.disconnectOutput(slot)
}
const { outputs } = this
outputs.splice(slot, 1)
@@ -1582,11 +1585,12 @@ export class LGraphNode
const output = outputs[i]
if (!output || !output.links) continue
for (const linkId of output.links) {
if (!this.graph) throw new NullGraphError()
const link = this.graph._links.get(linkId)
if (link) link.origin_slot--
// Only update link indices if node is part of a graph
if (this.graph) {
for (const linkId of output.links) {
const link = this.graph._links.get(linkId)
if (link) link.origin_slot--
}
}
}
@@ -1626,7 +1630,10 @@ export class LGraphNode
* remove an existing input slot
*/
removeInput(slot: number): void {
this.disconnectInput(slot, true)
// Only disconnect if node is part of a graph
if (this.graph) {
this.disconnectInput(slot, true)
}
const { inputs } = this
const slot_info = inputs.splice(slot, 1)
@@ -1634,9 +1641,11 @@ export class LGraphNode
const input = inputs[i]
if (!input?.link) continue
if (!this.graph) throw new NullGraphError()
const link = this.graph._links.get(input.link)
if (link) link.target_slot--
// Only update link indices if node is part of a graph
if (this.graph) {
const link = this.graph._links.get(input.link)
if (link) link.target_slot--
}
}
this.onInputRemoved?.(slot, slot_info[0])
this.setDirtyCanvas(true, true)

View File

@@ -414,6 +414,18 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
* If `input` or `output`, reroutes will not be automatically removed, and retain a connection to the input or output, respectively.
*/
disconnect(network: LinkNetwork, keepReroutes?: 'input' | 'output'): void {
// Clean up the target node's input slot
if (this.target_id !== -1) {
const targetNode = network.getNodeById(this.target_id)
if (targetNode) {
const targetInput = targetNode.inputs?.[this.target_slot]
if (targetInput && targetInput.link === this.id) {
targetInput.link = null
targetNode.setDirtyCanvas?.(true, false)
}
}
}
const reroutes = LLink.getReroutes(network, this)
const lastReroute = reroutes.at(-1)

View File

@@ -16,7 +16,10 @@ import type {
GraphOrSubgraph,
Subgraph
} from '@/lib/litegraph/src/subgraph/Subgraph'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import type {
ExportedSubgraphInstance,
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
@@ -28,6 +31,8 @@ import {
} from './ExecutableNodeDTO'
import type { SubgraphInput } from './SubgraphInput'
export type SubgraphId = string
/**
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
*/
@@ -540,4 +545,36 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
}
/**
* Synchronizes widget values from this SubgraphNode instance to the
* corresponding widgets in the subgraph definition before serialization.
* This ensures nested subgraph widget values are preserved when saving.
*/
override serialize(): ISerialisedNode {
// Sync widget values to subgraph definition before serialization
for (let i = 0; i < this.widgets.length; i++) {
const widget = this.widgets[i]
const input = this.inputs.find((inp) => inp.name === widget.name)
if (input) {
const subgraphInput = this.subgraph.inputNode.slots.find(
(slot) => slot.name === input.name
)
if (subgraphInput) {
// Find all widgets connected to this subgraph input
const connectedWidgets = subgraphInput.getConnectedWidgets()
// Update the value of all connected widgets
for (const connectedWidget of connectedWidgets) {
connectedWidget.value = widget.value
}
}
}
}
// Call parent serialize method
return super.serialize()
}
}

View File

@@ -656,4 +656,119 @@ describe('LGraphNode', () => {
spy.mockRestore()
})
})
describe('removeInput/removeOutput on copied nodes', () => {
beforeEach(() => {
// Register a test node type so clone() can work
LiteGraph.registerNodeType('TestNode', LGraphNode)
})
test('should NOT throw error when calling removeInput on a copied node without graph', () => {
// Create a node with an input
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addInput('input1', 'number')
// Clone the node (which creates a node without graph reference)
const copiedNode = originalNode.clone()
// This should NOT throw anymore - we can remove inputs on nodes without graph
expect(() => copiedNode!.removeInput(0)).not.toThrow()
expect(copiedNode!.inputs).toHaveLength(0)
})
test('should NOT throw error when calling removeOutput on a copied node without graph', () => {
// Create a node with an output
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addOutput('output1', 'number')
// Clone the node (which creates a node without graph reference)
const copiedNode = originalNode.clone()
// This should NOT throw anymore - we can remove outputs on nodes without graph
expect(() => copiedNode!.removeOutput(0)).not.toThrow()
expect(copiedNode!.outputs).toHaveLength(0)
})
test('should skip disconnectInput/disconnectOutput when node has no graph', () => {
// Create nodes with input/output
const nodeWithInput = new LGraphNode('Test Node')
nodeWithInput.type = 'TestNode'
nodeWithInput.addInput('input1', 'number')
const nodeWithOutput = new LGraphNode('Test Node')
nodeWithOutput.type = 'TestNode'
nodeWithOutput.addOutput('output1', 'number')
// Clone nodes (no graph reference)
const clonedInput = nodeWithInput.clone()
const clonedOutput = nodeWithOutput.clone()
// Mock disconnect methods to verify they're not called
clonedInput!.disconnectInput = vi.fn()
clonedOutput!.disconnectOutput = vi.fn()
// Remove input/output - disconnect methods should NOT be called
clonedInput!.removeInput(0)
clonedOutput!.removeOutput(0)
expect(clonedInput!.disconnectInput).not.toHaveBeenCalled()
expect(clonedOutput!.disconnectOutput).not.toHaveBeenCalled()
})
test('should be able to removeInput on a copied node after adding to graph', () => {
// Create a graph and a node with an input
const graph = new LGraph()
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addInput('input1', 'number')
// Clone the node and add to graph
const copiedNode = originalNode.clone()
expect(copiedNode).not.toBeNull()
graph.add(copiedNode!)
// This should work now that the node has a graph reference
expect(() => copiedNode!.removeInput(0)).not.toThrow()
expect(copiedNode!.inputs).toHaveLength(0)
})
test('should be able to removeOutput on a copied node after adding to graph', () => {
// Create a graph and a node with an output
const graph = new LGraph()
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addOutput('output1', 'number')
// Clone the node and add to graph
const copiedNode = originalNode.clone()
expect(copiedNode).not.toBeNull()
graph.add(copiedNode!)
// This should work now that the node has a graph reference
expect(() => copiedNode!.removeOutput(0)).not.toThrow()
expect(copiedNode!.outputs).toHaveLength(0)
})
test('RerouteNode clone scenario - should be able to removeOutput and addOutput on cloned node', () => {
// This simulates the RerouteNode clone method behavior
const originalNode = new LGraphNode('Reroute')
originalNode.type = 'TestNode'
originalNode.addOutput('*', '*')
// Clone the node (simulating RerouteNode.clone)
const clonedNode = originalNode.clone()
expect(clonedNode).not.toBeNull()
// This should not throw - we should be able to modify outputs on a cloned node
expect(() => {
clonedNode!.removeOutput(0)
clonedNode!.addOutput('renamed', '*')
}).not.toThrow()
expect(clonedNode!.outputs).toHaveLength(1)
expect(clonedNode!.outputs[0].name).toBe('renamed')
})
})
})

View File

@@ -1,6 +1,6 @@
import { describe, expect } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { LLink } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { test } from './testExtensions'
@@ -14,4 +14,84 @@ describe('LLink', () => {
const link = new LLink(1, 'float', 4, 2, 5, 3)
expect(link.serialize()).toMatchSnapshot('Basic')
})
describe('disconnect', () => {
it('should clear the target input link reference when disconnecting', () => {
// Create a graph and nodes
const graph = new LGraph()
const sourceNode = new LGraphNode('Source')
const targetNode = new LGraphNode('Target')
// Add nodes to graph
graph.add(sourceNode)
graph.add(targetNode)
// Add slots
sourceNode.addOutput('out', 'number')
targetNode.addInput('in', 'number')
// Connect the nodes
const link = sourceNode.connect(0, targetNode, 0)
expect(link).toBeDefined()
expect(targetNode.inputs[0].link).toBe(link?.id)
// Mock setDirtyCanvas
const setDirtyCanvasSpy = vi.spyOn(targetNode, 'setDirtyCanvas')
// Disconnect the link
link?.disconnect(graph)
// Verify the target input's link reference is cleared
expect(targetNode.inputs[0].link).toBeNull()
// Verify setDirtyCanvas was called
expect(setDirtyCanvasSpy).toHaveBeenCalledWith(true, false)
})
it('should handle disconnecting when target node is not found', () => {
// Create a link with invalid target
const graph = new LGraph()
const link = new LLink(1, 'number', 1, 0, 999, 0) // Invalid target id
// Should not throw when disconnecting
expect(() => link.disconnect(graph)).not.toThrow()
})
it('should only clear link reference if it matches the current link id', () => {
// Create a graph and nodes
const graph = new LGraph()
const sourceNode1 = new LGraphNode('Source1')
const sourceNode2 = new LGraphNode('Source2')
const targetNode = new LGraphNode('Target')
// Add nodes to graph
graph.add(sourceNode1)
graph.add(sourceNode2)
graph.add(targetNode)
// Add slots
sourceNode1.addOutput('out', 'number')
sourceNode2.addOutput('out', 'number')
targetNode.addInput('in', 'number')
// Create first connection
const link1 = sourceNode1.connect(0, targetNode, 0)
expect(link1).toBeDefined()
// Disconnect first connection
targetNode.disconnectInput(0)
// Create second connection
const link2 = sourceNode2.connect(0, targetNode, 0)
expect(link2).toBeDefined()
expect(targetNode.inputs[0].link).toBe(link2?.id)
// Try to disconnect the first link (which is already disconnected)
// It should not affect the current connection
link1?.disconnect(graph)
// The input should still have the second link
expect(targetNode.inputs[0].link).toBe(link2?.id)
})
})
})

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "الاتصال بالدعم"
},
"Comfy_Dev_ShowModelSelector": {
"label": "إظهار منتقي النماذج (للمطورين)"
},
"Comfy_DuplicateWorkflow": {
"label": "تكرار سير العمل الحالي"
},

View File

@@ -840,6 +840,7 @@
"Save": "حفظ",
"Save As": "حفظ باسم",
"Show Keybindings Dialog": "عرض مربع حوار اختصارات لوحة المفاتيح",
"Show Model Selector (Dev)": "إظهار منتقي النماذج (للمطورين)",
"Show Settings Dialog": "عرض نافذة الإعدادات",
"Sign Out": "تسجيل خروج",
"Toggle Essential Bottom Panel": "تبديل اللوحة السفلية الأساسية",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "Contact Support"
},
"Comfy_Dev_ShowModelSelector": {
"label": "Show Model Selector (Dev)"
},
"Comfy_DuplicateWorkflow": {
"label": "Duplicate Current Workflow"
},

View File

@@ -980,6 +980,7 @@
"Clear Pending Tasks": "Clear Pending Tasks",
"Clear Workflow": "Clear Workflow",
"Contact Support": "Contact Support",
"Show Model Selector (Dev)": "Show Model Selector (Dev)",
"Duplicate Current Workflow": "Duplicate Current Workflow",
"Export": "Export",
"Export (API)": "Export (API)",

View File

@@ -390,7 +390,7 @@
},
"LiteGraph_Canvas_LowQualityRenderingZoomThreshold": {
"name": "Low quality rendering zoom threshold",
"tooltip": "Render low quality shapes when zoomed out"
"tooltip": "Zoom level threshold for performance mode. Lower values (0.1) = quality at all zoom levels. Higher values (1.0) = performance mode even when zoomed in. Performance mode simplifies rendering by hiding text labels, shadows, and details."
},
"LiteGraph_Canvas_MaximumFps": {
"name": "Maximum FPS",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "Contactar soporte"
},
"Comfy_Dev_ShowModelSelector": {
"label": "Mostrar selector de modelo (Dev)"
},
"Comfy_DuplicateWorkflow": {
"label": "Duplicar flujo de trabajo actual"
},

View File

@@ -840,6 +840,7 @@
"Save": "Guardar",
"Save As": "Guardar como",
"Show Keybindings Dialog": "Mostrar diálogo de combinaciones de teclas",
"Show Model Selector (Dev)": "Mostrar selector de modelo (Desarrollo)",
"Show Settings Dialog": "Mostrar diálogo de configuración",
"Sign Out": "Cerrar sesión",
"Toggle Essential Bottom Panel": "Alternar panel inferior esencial",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "Contacter le support"
},
"Comfy_Dev_ShowModelSelector": {
"label": "Afficher le sélecteur de modèle (Dev)"
},
"Comfy_DuplicateWorkflow": {
"label": "Dupliquer le flux de travail actuel"
},

View File

@@ -840,6 +840,7 @@
"Save": "Enregistrer",
"Save As": "Enregistrer sous",
"Show Keybindings Dialog": "Afficher la boîte de dialogue des raccourcis clavier",
"Show Model Selector (Dev)": "Afficher le sélecteur de modèle (Dev)",
"Show Settings Dialog": "Afficher la boîte de dialogue des paramètres",
"Sign Out": "Se déconnecter",
"Toggle Essential Bottom Panel": "Afficher/Masquer le panneau inférieur essentiel",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "サポートに連絡"
},
"Comfy_Dev_ShowModelSelector": {
"label": "モデルセレクターを表示(開発用)"
},
"Comfy_DuplicateWorkflow": {
"label": "現在のワークフローを複製"
},

View File

@@ -840,6 +840,7 @@
"Save": "保存",
"Save As": "名前を付けて保存",
"Show Keybindings Dialog": "キーバインドダイアログを表示",
"Show Model Selector (Dev)": "モデルセレクターを表示(開発用)",
"Show Settings Dialog": "設定ダイアログを表示",
"Sign Out": "サインアウト",
"Toggle Essential Bottom Panel": "エッセンシャル下部パネルの切り替え",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "지원팀에 문의하기"
},
"Comfy_Dev_ShowModelSelector": {
"label": "모델 선택기 표시 (개발자용)"
},
"Comfy_DuplicateWorkflow": {
"label": "현재 워크플로 복제"
},

View File

@@ -839,7 +839,8 @@
"Restart": "재시작",
"Save": "저장",
"Save As": "다른 이름으로 저장",
"Show Keybindings Dialog": "키 바인딩 대화상자 표시",
"Show Keybindings Dialog": "단축키 대화상자 표시",
"Show Model Selector (Dev)": "모델 선택기 표시 (개발자용)",
"Show Settings Dialog": "설정 대화상자 표시",
"Sign Out": "로그아웃",
"Toggle Essential Bottom Panel": "필수 하단 패널 전환",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "Связаться с поддержкой"
},
"Comfy_Dev_ShowModelSelector": {
"label": "Показать выбор модели (Dev)"
},
"Comfy_DuplicateWorkflow": {
"label": "Дублировать текущий рабочий процесс"
},

View File

@@ -840,6 +840,7 @@
"Save": "Сохранить",
"Save As": "Сохранить как",
"Show Keybindings Dialog": "Показать диалог клавиш быстрого доступа",
"Show Model Selector (Dev)": "Показать выбор модели (Dev)",
"Show Settings Dialog": "Показать диалог настроек",
"Sign Out": "Выйти",
"Toggle Essential Bottom Panel": "Показать/скрыть основную нижнюю панель",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "聯絡支援"
},
"Comfy_Dev_ShowModelSelector": {
"label": "顯示模型選擇器(開發)"
},
"Comfy_DuplicateWorkflow": {
"label": "複製目前工作流程"
},

View File

@@ -840,6 +840,7 @@
"Save": "儲存",
"Save As": "另存新檔",
"Show Keybindings Dialog": "顯示快捷鍵對話框",
"Show Model Selector (Dev)": "顯示模型選擇器(開發用)",
"Show Settings Dialog": "顯示設定對話框",
"Sign Out": "登出",
"Toggle Essential Bottom Panel": "切換基本下方面板",

View File

@@ -107,6 +107,9 @@
"Comfy_ContactSupport": {
"label": "联系支持"
},
"Comfy_Dev_ShowModelSelector": {
"label": "顯示模型選擇器(開發)"
},
"Comfy_DuplicateWorkflow": {
"label": "复制当前工作流"
},

View File

@@ -83,8 +83,8 @@
}
},
"breadcrumbsMenu": {
"clearWorkflow": "清工作流",
"deleteWorkflow": "删除工作流",
"clearWorkflow": "清工作流",
"deleteWorkflow": "删除工作流",
"duplicate": "复制",
"enterNewName": "输入新名称"
},
@@ -218,7 +218,7 @@
"WEBCAM": "摄像头"
},
"desktopMenu": {
"confirmQuit": "存在未保存的工作流;任何未保存的更改都将丢失。忽略此警告并退出?",
"confirmQuit": "未保存的工作流程开启;任何未保存的更改都将丢失。忽略此警告并退出?",
"confirmReinstall": "这将清除您的 extra_models_config.yaml 文件,并重新开始安装。您确定吗?",
"quit": "退出",
"reinstall": "重新安装"
@@ -313,7 +313,7 @@
"filter": "过滤",
"findIssues": "查找问题",
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
"frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不容。",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不容。",
"frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 或更高版本。",
"goToNode": "转到节点",
"help": "帮助",
@@ -400,8 +400,8 @@
"upload": "上传",
"usageHint": "使用提示",
"user": "用户",
"versionMismatchWarning": "版本容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 请参 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新说明。",
"versionMismatchWarning": "版本容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 请参 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新说明。",
"videoFailedToLoad": "视频加载失败",
"workflow": "工作流"
},
@@ -594,7 +594,7 @@
"wireframe": "线框"
},
"model": "模型",
"openIn3DViewer": "在 3D 浏览器中打开",
"openIn3DViewer": "在 3D 查看器中打开",
"previewOutput": "预览输出",
"removeBackgroundImage": "移除背景图片",
"resizeNodeMatchOutput": "调整节点以匹配输出",
@@ -611,7 +611,7 @@
"uploadBackgroundImage": "上传背景图片",
"uploadTexture": "上传纹理",
"viewer": {
"apply": "用",
"apply": "用",
"cameraSettings": "相机设置",
"cameraType": "相机类型",
"cancel": "取消",
@@ -619,7 +619,7 @@
"lightSettings": "灯光设置",
"modelSettings": "模型设置",
"sceneSettings": "场景设置",
"title": "3D 浏览器(测试版)"
"title": "3D 查看器(测试版)"
}
},
"loadWorkflowWarning": {
@@ -740,24 +740,24 @@
"disabled": "禁用",
"disabledTooltip": "工作流将不会自动执行",
"execute": "执行",
"help": "帮助",
"help": "说明",
"hideMenu": "隐藏菜单",
"instant": "实时",
"instantTooltip": "工作流将会在生成完成后立即执行",
"interrupt": "取消当前任务",
"light": "淺色",
"manageExtensions": "管理扩展功能",
"manageExtensions": "管理擴充功能",
"onChange": "更改时",
"onChangeTooltip": "一旦进行更改,工作流将添加到执行队列",
"queue": "队列面板",
"refresh": "刷新节点",
"resetView": "重置视图",
"run": "运行",
"runWorkflow": "运行工作流Shift插队",
"runWorkflowFront": "运行工作流(插队",
"settings": "设",
"runWorkflow": "运行工作流Shift排在前面",
"runWorkflowFront": "运行工作流程(排在前面",
"settings": "设",
"showMenu": "显示菜单",
"theme": "主",
"theme": "主",
"toggleBottomPanel": "底部面板"
},
"menuLabels": {
@@ -786,7 +786,7 @@
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",
"Edit": "编辑",
"Exit Subgraph": "退出子",
"Exit Subgraph": "退出子",
"Export": "导出",
"Export (API)": "导出 (API)",
"File": "文件",
@@ -801,7 +801,7 @@
"Load Default Workflow": "加载默认工作流",
"Manage group nodes": "管理组节点",
"Manager": "管理器",
"Minimap": "缩略地图",
"Minimap": "地图",
"Model Library": "模型库",
"Move Selected Nodes Down": "下移所选节点",
"Move Selected Nodes Left": "左移所选节点",
@@ -811,9 +811,9 @@
"New": "新建",
"Next Opened Workflow": "下一个打开的工作流",
"Node Library": "节点库",
"Node Links": "节点连线",
"Node Links": "节点连",
"Open": "打开",
"Open 3D Viewer (Beta) for Selected Node": "为所选节点开启 3D 浏览器Beta 版)",
"Open 3D Viewer (Beta) for Selected Node": "为选中节点打开3D查看器测试版)",
"Open Custom Nodes Folder": "打开自定义节点文件夹",
"Open DevTools": "打开开发者工具",
"Open Inputs Folder": "打开输入文件夹",
@@ -839,31 +839,32 @@
"Restart": "重启",
"Save": "保存",
"Save As": "另存为",
"Show Keybindings Dialog": "示快捷键对话框",
"Show Keybindings Dialog": "示快捷鍵對話框",
"Show Model Selector (Dev)": "顯示模型選擇器(開發用)",
"Show Settings Dialog": "显示设置对话框",
"Sign Out": "退出登录",
"Toggle Essential Bottom Panel": "切换基本下方面板",
"Toggle Essential Bottom Panel": "切换基础底部面板",
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
"Toggle View Controls Bottom Panel": "切换视图控制下方面板",
"Toggle View Controls Bottom Panel": "切换视图控制底部面板",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
"Ungroup selected group nodes": "解散选中组节点",
"Unpack the selected Subgraph": "解開所選子圖",
"Workflows": "工作流",
"Unpack the selected Subgraph": "解包选中子图",
"Workflows": "工作流",
"Zoom In": "放大画面",
"Zoom Out": "缩小画面",
"Zoom to fit": "缩放至适合大小"
"Zoom to fit": "缩放以适应"
},
"minimap": {
"nodeColors": "节点颜色",
"renderBypassState": "显示绕过状态",
"renderErrorState": "显示错误状态",
"showGroups": "显示框架/组",
"showLinks": "显示连线"
"renderBypassState": "渲染绕过状态",
"renderErrorState": "渲染错误状态",
"showGroups": "显示框架/组",
"showLinks": "显示连"
},
"missingModelsDialog": {
"doNotAskAgain": "不再显示此消息",
@@ -1131,7 +1132,7 @@
},
"settingsCategories": {
"3D": "3D",
"3DViewer": "3D 浏览器",
"3DViewer": "3D查看器",
"API Nodes": "API 节点",
"About": "关于",
"Appearance": "外观",
@@ -1184,7 +1185,7 @@
"Workflow": "工作流"
},
"shortcuts": {
"essentials": "基本功能",
"essentials": "常用",
"keyboardShortcuts": "键盘快捷键",
"manageShortcuts": "管理快捷键",
"noKeybinding": "无快捷键",
@@ -1616,7 +1617,7 @@
"failedToExportModel": "无法将模型导出为 {format}",
"failedToFetchBalance": "获取余额失败:{error}",
"failedToFetchLogs": "无法获取服务器日志",
"failedToInitializeLoad3dViewer": "初始化 3D 浏览器失败",
"failedToInitializeLoad3dViewer": "初始化3D查看器失败",
"failedToInitiateCreditPurchase": "发起积分购买失败:{error}",
"failedToPurchaseCredits": "购买积分失败:{error}",
"fileLoadError": "无法在 {fileName} 中找到工作流",
@@ -1675,7 +1676,7 @@
"versionMismatchWarning": {
"dismiss": "关闭",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 版或更高版本。",
"frontendOutdated": "前端版本 {frontendVersion} 已过时。後端需要 {requiredVersion} 版或更高版本。",
"title": "版本相容性警告",
"updateFrontend": "更新前端"
},

View File

@@ -120,8 +120,8 @@
}
},
"Comfy_Load3D_3DViewerEnable": {
"name": "启用 3D 浏览器(测试版)",
"tooltip": "为选节点启用 3D 浏览器(测试版)。此功能可让您直接在全尺寸 3D 浏览器中浏览并与 3D 模型交互。"
"name": "启用3D查看器(测试版)",
"tooltip": "为选节点启用3D查看器(测试版)。此功能允许你在全尺寸3D查看器中直接可视化和交互3D模型。"
},
"Comfy_Load3D_BackgroundColor": {
"name": "初始背景颜色",
@@ -338,7 +338,7 @@
"Disabled": "禁用",
"Top": "顶部"
},
"tooltip": "单列位置。在移动设备上,单始终显示于顶端。"
"tooltip": "单列位置。在行动装置上,单始终显示于顶端。"
},
"Comfy_Validation_Workflows": {
"name": "校验工作流"

View File

@@ -1,64 +0,0 @@
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type {
HeaderMap,
HeaderProviderContext,
IHeaderProvider
} from '@/types/headerTypes'
/**
* Header provider for authentication headers.
* Automatically adds Firebase Bearer tokens or API keys to outgoing requests.
*
* Priority order:
* 1. Firebase Bearer token (if user is authenticated)
* 2. API key (if configured)
* 3. No authentication header
*/
export class AuthHeaderProvider implements IHeaderProvider {
async provideHeaders(_context: HeaderProviderContext): Promise<HeaderMap> {
// Try to get Firebase auth header first (includes fallback to API key)
const authHeader = await useFirebaseAuthStore().getAuthHeader()
if (authHeader) {
return authHeader
}
// No authentication available
return {}
}
}
/**
* Header provider specifically for API key authentication.
* Only provides API key headers, ignoring Firebase auth.
* Useful for specific endpoints that require API key auth.
*/
export class ApiKeyHeaderProvider implements IHeaderProvider {
provideHeaders(_context: HeaderProviderContext): HeaderMap {
const apiKeyHeader = useApiKeyAuthStore().getAuthHeader()
return apiKeyHeader || {}
}
}
/**
* Header provider specifically for Firebase Bearer token authentication.
* Only provides Firebase auth headers, ignoring API keys.
* Useful for specific endpoints that require Firebase auth.
*/
export class FirebaseAuthHeaderProvider implements IHeaderProvider {
async provideHeaders(_context: HeaderProviderContext): Promise<HeaderMap> {
const firebaseStore = useFirebaseAuthStore()
// Only get Firebase token, not the fallback API key
const token = await firebaseStore.getIdToken()
if (token) {
return {
Authorization: `Bearer ${token}`
}
}
return {}
}
}

View File

@@ -1,3 +1,5 @@
import axios from 'axios'
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json'
import type {
DisplayComponentWsMessage,
@@ -33,7 +35,6 @@ import type {
NodeId
} from '@/schemas/comfyWorkflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { WorkflowTemplates } from '@/types/workflowTemplateTypes'
@@ -328,7 +329,7 @@ export class ComfyApi extends EventTarget {
} else {
options.headers['Comfy-User'] = this.user
}
return fetchWithHeaders(this.apiURL(route), options)
return fetch(this.apiURL(route), options)
}
override addEventListener<TEvent extends keyof ApiEvents>(
@@ -598,9 +599,9 @@ export class ComfyApi extends EventTarget {
* Gets the index of core workflow templates.
*/
async getCoreWorkflowTemplates(): Promise<WorkflowTemplates[]> {
const res = await fetchWithHeaders(this.fileURL('/templates/index.json'))
const contentType = res.headers.get('content-type')
return contentType?.includes('application/json') ? await res.json() : []
const res = await axios.get(this.fileURL('/templates/index.json'))
const contentType = res.headers['content-type']
return contentType?.includes('application/json') ? res.data : []
}
/**
@@ -1001,31 +1002,22 @@ export class ComfyApi extends EventTarget {
}
async getLogs(): Promise<string> {
const response = await fetchWithHeaders(this.internalURL('/logs'))
return response.text()
return (await axios.get(this.internalURL('/logs'))).data
}
async getRawLogs(): Promise<LogsRawResponse> {
const response = await fetchWithHeaders(this.internalURL('/logs/raw'))
return response.json()
return (await axios.get(this.internalURL('/logs/raw'))).data
}
async subscribeLogs(enabled: boolean): Promise<void> {
await fetchWithHeaders(this.internalURL('/logs/subscribe'), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
enabled,
clientId: this.clientId
})
return await axios.patch(this.internalURL('/logs/subscribe'), {
enabled,
clientId: this.clientId
})
}
async getFolderPaths(): Promise<Record<string, string[]>> {
const response = await fetchWithHeaders(this.internalURL('/folder_paths'))
return response.json()
return (await axios.get(this.internalURL('/folder_paths'))).data
}
/**
@@ -1034,8 +1026,7 @@ export class ComfyApi extends EventTarget {
* @returns The custom nodes i18n data
*/
async getCustomNodesI18n(): Promise<Record<string, any>> {
const response = await fetchWithHeaders(this.apiURL('/i18n'))
return response.json()
return (await axios.get(this.apiURL('/i18n'))).data
}
/**

View File

@@ -40,7 +40,6 @@ import { getSvgMetadata } from '@/scripts/metadata/svg'
import { useDialogService } from '@/services/dialogService'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { useSubgraphService } from '@/services/subgraphService'
import { useWorkflowService } from '@/services/workflowService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
@@ -534,7 +533,7 @@ export class ComfyApp {
if (match) {
const uri = event.dataTransfer.getData(match)?.split('\n')?.[0]
if (uri) {
const blob = await (await fetchWithHeaders(uri)).blob()
const blob = await (await fetch(uri)).blob()
await this.handleFile(new File([blob], uri, { type: blob.type }))
}
}
@@ -799,9 +798,6 @@ export class ComfyApp {
await useWorkspaceStore().workflow.syncWorkflows()
await useExtensionService().loadExtensions()
// Call preInit hook before any other initialization
await useExtensionService().invokeExtensionsAsync('preInit')
this.#addProcessKeyHandler()
this.#addConfigureHandler()
this.#addApiUpdateHandlers()

View File

@@ -0,0 +1,462 @@
# GraphMutationService Design and Implementation
## Overview
GraphMutationService is the centralized service layer for all graph modification operations in ComfyUI Frontend. It provides a unified and validated API for graph mutations, serving as the single entry point for all graph modification operations.
## Project Background
### Current System Analysis
ComfyUI Frontend uses the LiteGraph library for graph operations, with main components including:
1. **LGraph** (`src/lib/litegraph/src/LGraph.ts`)
- Core graph management class
- Provides basic operations like `add()`, `remove()`
- Supports `beforeChange()`/`afterChange()` transaction mechanism
2. **LGraphNode** (`src/lib/litegraph/src/LGraphNode.ts`)
- Node class containing position, connections, and other properties
- Provides methods like `connect()`, `disconnectInput()`, `disconnectOutput()`
3. **ChangeTracker** (`src/scripts/changeTracker.ts`)
- Existing undo/redo system
- Snapshot-based history tracking
- Supports up to 50 history states
**Primary Goals:**
- Single entry point for all graph modifications
- Built-in validation and error handling
- Transaction support for atomic operations
- Natural undo/redo through existing ChangeTracker
- Clean architecture for future extensibility
### Interface-Based Architecture
The GraphMutationService follows an **interface-based design pattern** with singleton state management:
- **IGraphMutationService Interface**: Defines the complete contract for all graph operations
- **GraphMutationService Class**: Implements the interface with LiteGraph integration
- **Singleton State**: Shared clipboard and transaction state across components
```typescript
interface IGraphMutationService {
// Node operations
addNode(params: AddNodeParams): Promise<NodeId>
removeNode(nodeId: NodeId): Promise<void>
updateNodeProperty(nodeId: NodeId, property: string, value: any): Promise<void>
// ... 50+ total operations
// Transaction support
transaction<T>(fn: () => Promise<T>): Promise<T>
// Undo/Redo
undo(): Promise<void>
redo(): Promise<void>
}
class GraphMutationService implements IGraphMutationService {
// Implementation details...
}
```
The `useGraphMutationService()` hook returns the interface type, maintaining backward compatibility while enabling new architectural benefits.
### Core Components
```typescript
// Interface Definition
interface IGraphMutationService {
// Complete method signatures for all 50+ operations
// Organized by functional categories
}
// Implementation Class
class GraphMutationService implements IGraphMutationService {
private workflowStore = useWorkflowStore()
private transactionDepth = 0
private clipboard: ClipboardData | null = null
// All interface methods implemented
}
// Singleton Hook
export const useGraphMutationService = (): IGraphMutationService => {
if (!graphMutationServiceInstance) {
graphMutationServiceInstance = new GraphMutationService()
}
return graphMutationServiceInstance
}
```
### Validation Framework
Each operation includes validation to ensure data integrity:
```typescript
interface ValidationResult {
isValid: boolean
errors: ValidationError[]
warnings: ValidationWarning[]
}
```
## Implemented Operations
### Node Operations (6 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `addNode` | Add a new node to the graph | src/scripts/app.ts:1589-1593, src/lib/litegraph/src/LGraph.ts:823-893 |
| `removeNode` | Remove a node from the graph | src/lib/litegraph/src/LGraph.ts:899-986 |
| `updateNodeProperty` | Update a custom node property | src/lib/litegraph/src/LGraphNode.ts:974-984 |
| `updateNodeTitle` | Change the node's title | src/services/litegraphService.ts:369 (direct assignment) |
| `changeNodeMode` | Change execution mode (ALWAYS/ON_TRIGGER/NEVER/ON_REQUEST/ON_EVENT) | src/lib/litegraph/src/LGraphNode.ts:1295-1320 |
| `cloneNode` | Create a copy of a node | src/lib/litegraph/src/LGraphNode.ts:923-950 |
### Connection Operations (6 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `connect` | Create a connection between nodes | src/lib/litegraph/src/LGraphNode.ts:2641-2743, :2753-2870 (connectSlots) |
| `disconnect` | Generic disconnect (auto-detects input/output) | Wrapper combining disconnectInput/disconnectOutput |
| `disconnectInput` | Disconnect a specific input slot | src/lib/litegraph/src/LGraphNode.ts:3050-3144 |
| `disconnectOutput` | Disconnect all connections from an output slot | src/lib/litegraph/src/LGraphNode.ts:2931-3043 |
| `disconnectOutputTo` | Disconnect output to a specific target node | src/lib/litegraph/src/LGraphNode.ts:2931 (with target_node param) |
| `disconnectLink` | Disconnect by link ID | src/lib/litegraph/src/LGraph.ts:1433-1441 |
### Group Operations (6 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `createGroup` | Create a new node group | src/composables/useCoreCommands.ts:425-430, src/lib/litegraph/src/LGraph.ts:823-848 (add method for groups) |
| `removeGroup` | Delete a group (nodes remain) | src/lib/litegraph/src/LGraph.ts:899-913 (remove method for groups) |
| `updateGroupTitle` | Change group title | Direct assignment (group.title = value) |
| `moveGroup` | Move group and its contents | src/lib/litegraph/src/LGraphGroup.ts:230-240 |
| `addNodesToGroup` | Add nodes to group and auto-resize | src/lib/litegraph/src/LGraphGroup.ts:303-306 |
| `recomputeGroupNodes` | Recalculate which nodes are in group | src/lib/litegraph/src/LGraphGroup.ts:247-273 |
### Subgraph Node Slot Operations (4 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `addSubgraphNodeInput` | Add an input slot to a subgraph node | src/lib/litegraph/src/LGraphNode.ts:1606-1627 |
| `addSubgraphNodeOutput` | Add an output slot to a subgraph node | src/lib/litegraph/src/LGraphNode.ts:1551-1571 |
| `removeSubgraphNodeInput` | Remove an input slot from a subgraph node | src/lib/litegraph/src/LGraphNode.ts:1632-1652 |
| `removeSubgraphNodeOutput` | Remove an output slot from a subgraph node | src/lib/litegraph/src/LGraphNode.ts:1576-1599 |
### Batch Operations (3 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `addNodes` | Add multiple nodes in one operation | Custom implementation based on single addNode logic |
| `removeNodes` | Remove multiple nodes in one operation | src/composables/useCoreCommands.ts:180 (forEach pattern) |
| `duplicateNodes` | Duplicate selected nodes with their connections | src/utils/vintageClipboard.ts:32 (node.clone pattern) |
### Clipboard Operations (6 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `copyNodes` | Copy nodes to clipboard | src/lib/litegraph/src/LGraphCanvas.ts:3602-3687 |
| `cutNodes` | Cut nodes to clipboard | Custom implementation (copy + mark for deletion) |
| `pasteNodes` | Paste nodes from clipboard | src/lib/litegraph/src/LGraphCanvas.ts:3693-3871 |
| `getClipboard` | Get current clipboard content | Custom implementation (returns internal clipboard) |
| `clearClipboard` | Clear clipboard content | Custom implementation (sets clipboard to null) |
| `hasClipboardContent` | Check if clipboard has content | Custom implementation (checks clipboard state) |
### Reroute Operations (2 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `addReroute` | Add a reroute point on a connection | src/lib/litegraph/src/LGraph.ts:1338-1361 (createReroute) |
| `removeReroute` | Remove a reroute point | src/lib/litegraph/src/LGraph.ts:1381-1407 |
### Subgraph Operations (6 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `createSubgraph` | Create a subgraph from selected nodes | src/lib/litegraph/src/LGraph.ts:1459-1566 (convertToSubgraph) |
| `unpackSubgraph` | Unpack a subgraph node back into regular nodes | src/lib/litegraph/src/LGraph.ts:1672-1841 |
| `addSubgraphInput` | Add an input to a subgraph | src/lib/litegraph/src/LGraph.ts:2440-2456 |
| `addSubgraphOutput` | Add an output to a subgraph | src/lib/litegraph/src/LGraph.ts:2458-2474 |
| `removeSubgraphInput` | Remove a subgraph input | src/lib/litegraph/src/LGraph.ts:2520-2535 |
| `removeSubgraphOutput` | Remove a subgraph output | src/lib/litegraph/src/LGraph.ts:2541-2559 |
### Graph-level Operations (1 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `clearGraph` | Clear all nodes and connections | src/lib/litegraph/src/LGraph.ts:293-362 |
### Execution Control Operations (2 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `bypassNode` | Set node to bypass mode (never execute) | Direct mode assignment (node.mode = LGraphEventMode.BYPASS) |
| `unbypassNode` | Set node to normal mode (always execute) | Direct mode assignment (node.mode = LGraphEventMode.ALWAYS) |
### Transaction and History Operations (3 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `transaction` | Execute multiple operations atomically | Custom implementation using beforeChange/afterChange |
| `undo` | Undo the last operation | src/scripts/changeTracker.ts (uses changeTracker.undo) |
| `redo` | Redo the previously undone operation | src/scripts/changeTracker.ts (uses changeTracker.redo) |
## Usage Examples
### Basic Node Operations
```typescript
import { useGraphMutationService } from '@/services/graphMutationService'
const service = useGraphMutationService()
// Add a node
const nodeId = await service.addNode({
type: 'LoadImage',
pos: [100, 100],
title: 'Image Loader'
})
// Update node properties
await service.updateNodeTitle(nodeId, 'My Image')
await service.updateNodeProperty(nodeId, 'seed', 12345)
// Clone a node
const clonedId = await service.cloneNode(nodeId, [300, 200])
```
### Connection Management
```typescript
// Create a connection
const linkId = await service.connect({
sourceNodeId: node1Id,
sourceSlot: 0,
targetNodeId: node2Id,
targetSlot: 0
})
// Various disconnect methods
await service.disconnectInput(node2Id, 0)
await service.disconnectOutput(node1Id, 0)
await service.disconnectLink(linkId)
```
### Group Management
```typescript
// Create a group
const groupId = await service.createGroup({
title: 'Image Processing',
pos: [100, 100],
size: [400, 300],
color: '#335577'
})
// Manage group content
await service.addNodesToGroup(groupId, [node1Id, node2Id])
await service.moveGroup(groupId, 50, 100) // deltaX, deltaY
await service.resizeGroup(groupId, [500, 400])
```
### Batch Operations
```typescript
// Add multiple nodes
const nodeIds = await service.addNodes([
{ type: 'LoadImage', pos: [100, 100] },
{ type: 'VAEEncode', pos: [300, 100] },
{ type: 'KSampler', pos: [500, 100] }
])
// Duplicate with connections preserved
const duplicatedIds = await service.duplicateNodes(
[node1Id, node2Id, node3Id],
[100, 100] // offset
)
// Batch delete
await service.removeNodes([node1Id, node2Id, node3Id])
```
### Clipboard Operations
```typescript
// Copy/Cut/Paste workflow
await service.copyNodes([node1Id, node2Id])
await service.cutNodes([node3Id, node4Id])
const pastedNodes = await service.pasteNodes([200, 200])
// Check clipboard
if (service.hasClipboardContent()) {
const clipboard = service.getClipboard()
console.log(`${clipboard.nodes.length} nodes in clipboard`)
}
```
### Transactions
```typescript
// Atomic operations
await service.transaction(async () => {
const node1 = await service.addNode({ type: 'LoadImage' })
const node2 = await service.addNode({ type: 'SaveImage' })
await service.connect({
sourceNodeId: node1,
sourceSlot: 0,
targetNodeId: node2,
targetSlot: 0
})
})
// Entire transaction can be undone as one operation
await service.undo()
```
### Graph-level Operations
```typescript
// Clear entire graph
await service.clearGraph()
// Distribute nodes evenly
await service.distributeNodes([node1Id, node2Id, node3Id], 'horizontal')
```
### Execution Control
```typescript
// Bypass node (set to never execute)
await service.bypassNode(nodeId)
// Re-enable node execution
await service.unbypassNode(nodeId)
```
### Subgraph Operations
```typescript
// Create subgraph from selected nodes
const subgraphId = await service.createSubgraph({
name: 'Image Processing',
nodeIds: [node1Id, node2Id, node3Id]
})
// Configure subgraph I/O
await service.addSubgraphInput(subgraphId, 'image', 'IMAGE')
await service.addSubgraphOutput(subgraphId, 'result', 'IMAGE')
// Add dynamic slots to subgraph nodes
await service.addSubgraphNodeInput({
nodeId: subgraphNodeId,
name: 'extra_input',
type: 'LATENT'
})
```
## Implementation Details
### Integration Points
1. **LiteGraph Integration**
- Uses `app.graph` for graph access
- Calls `beforeChange()`/`afterChange()` for transactions
- Integrates with existing LiteGraph node/connection APIs
2. **ChangeTracker Integration**
- Maintains compatibility with existing undo/redo system
- Calls `checkState()` after operations
- Provides undo/redo through existing tracker
## Validation System
### Current Validations (Placeholder)
- `validateAddNode()` - Check node type exists
- `validateRemoveNode()` - Check node can be removed
- `validateConnect()` - Check connection compatibility
- `validateUpdateNodePosition()` - Check position bounds
### Future Validations
- Type compatibility checking
- Circular dependency detection
- Resource limit enforcement
- Permission validation
- Business rule enforcement
## Technical Decisions
### Why Validation Layer?
- **Data Integrity**: Prevent invalid graph states
- **User Experience**: Early error detection
- **Security**: Prevent malicious operations
- **Extensibility**: Easy to add new rules
### Why Transaction Support?
- **Atomicity**: Multiple operations succeed or fail together
- **Consistency**: Graph remains valid throughout
- **User Experience**: Natural undo/redo boundaries
## Related Files
- **Interface Definition**: `src/services/IGraphMutationService.ts`
- **Implementation**: `src/services/GraphMutationService.ts`
- **LiteGraph Core**: `src/lib/litegraph/src/LGraph.ts`
- **Node Implementation**: `src/lib/litegraph/src/LGraphNode.ts`
- **Change Tracking**: `src/scripts/changeTracker.ts`
## Implementation Compatibility Notes
### Critical Implementation Details to Maintain:
1. **beforeChange/afterChange Pattern**
- All mutations MUST be wrapped with `graph.beforeChange()` and `graph.afterChange()`
- This enables undo/redo functionality through ChangeTracker
- Reference: `src/scripts/changeTracker.ts:200-208`
2. **Node ID Management**
- Node IDs can be numbers or strings (for API compatibility)
- Reference: `src/scripts/app.ts:1591` - `node.id = isNaN(+id) ? id : +id`
3. **Clipboard Implementation**
- Current implementation uses localStorage for persistence
- Must maintain compatibility with existing clipboard format
- Reference: `src/lib/litegraph/src/LGraphCanvas.ts:3602-3857`
4. **Group Resizing**
- Groups should auto-resize when adding nodes using `recomputeInsideNodes()`
- Reference: `src/composables/useCoreCommands.ts:430` - `group.resizeTo()`
5. **Canvas Dirty Flag**
- Visual operations (groups, reroutes) must call `graph.setDirtyCanvas(true, false)`
- This triggers canvas redraw
6. **Error Handling**
- Node creation can return null (for invalid types)
- Connection operations return null/false on failure
- Must validate before operations
7. **Subgraph Support**
- Subgraph operations use specialized Subgraph and SubgraphNode classes
- Reference: `src/lib/litegraph/src/subgraph/`
## Migration Strategy
1. Start by replacing direct `app.graph.add()` calls with `graphMutationService.addNode()`
2. Replace `graph.remove()` calls with `graphMutationService.removeNode()`
3. Update connection operations to use service methods
4. Migrate clipboard operations to use centralized service
5. Ensure all operations maintain existing beforeChange/afterChange patterns
## Important Notes
1. **Always use GraphMutationService** - Never call graph methods directly
2. **Backward Compatibility** - Service maintains compatibility with existing code
3. **Gradual Migration** - Existing code can be migrated incrementally
4. **Performance** - Command recording has minimal overhead

View File

@@ -0,0 +1,161 @@
import { GroupId } from '@/lib/litegraph/src/LGraphGroup'
import { LinkId } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import { SubgraphId } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { NodeId } from '@/schemas/comfyWorkflowSchema'
export interface AddNodeParams {
type: string
pos?: [number, number]
properties?: Record<string, any>
title?: string
}
export interface ConnectParams {
sourceNodeId: NodeId
sourceSlot: number | string
targetNodeId: NodeId
targetSlot: number | string
}
export interface CreateGroupParams {
title?: string
pos?: [number, number]
size?: [number, number]
color?: string
fontSize?: number
}
export interface AddRerouteParams {
pos: [number, number]
linkId?: LinkId
parentRerouteId?: RerouteId
}
export interface AddNodeInputParams {
nodeId: NodeId
name: string
type: string
extra_info?: Record<string, any>
}
export interface AddNodeOutputParams {
nodeId: NodeId
name: string
type: string
extra_info?: Record<string, any>
}
export interface CreateSubgraphParams {
selectedItems: Set<any>
}
export interface ClipboardData {
nodes: any[]
connections: any[]
isCut: boolean
}
export interface ValidationResult {
valid: boolean
errors?: ValidationError[]
warnings?: ValidationWarning[]
}
export interface ValidationError {
code: string
message: string
field?: string
}
export interface ValidationWarning {
code: string
message: string
}
export interface IGraphMutationService {
// Node operations
addNode(params: AddNodeParams): Promise<NodeId>
removeNode(nodeId: NodeId): Promise<void>
updateNodeProperty(
nodeId: NodeId,
property: string,
value: any
): Promise<void>
updateNodeTitle(nodeId: NodeId, title: string): Promise<void>
changeNodeMode(nodeId: NodeId, mode: number): Promise<void>
cloneNode(nodeId: NodeId, pos?: [number, number]): Promise<NodeId>
connect(params: ConnectParams): Promise<LinkId>
disconnect(
nodeId: NodeId,
slot: number | string,
slotType: 'input' | 'output',
targetNodeId?: NodeId
): Promise<boolean>
disconnectInput(nodeId: NodeId, slot: number | string): Promise<boolean>
disconnectOutput(nodeId: NodeId, slot: number | string): Promise<boolean>
disconnectOutputTo(
nodeId: NodeId,
slot: number | string,
targetNodeId: NodeId
): Promise<boolean>
disconnectLink(linkId: LinkId): Promise<void>
createGroup(params: CreateGroupParams): Promise<GroupId>
removeGroup(groupId: GroupId): Promise<void>
updateGroupTitle(groupId: GroupId, title: string): Promise<void>
moveGroup(groupId: GroupId, deltaX: number, deltaY: number): Promise<void>
addNodesToGroup(groupId: GroupId, nodeIds: NodeId[]): Promise<void>
recomputeGroupNodes(groupId: GroupId): Promise<void>
addReroute(params: AddRerouteParams): Promise<RerouteId>
removeReroute(rerouteId: RerouteId): Promise<void>
addNodes(nodes: AddNodeParams[]): Promise<NodeId[]>
removeNodes(nodeIds: NodeId[]): Promise<void>
duplicateNodes(
nodeIds: NodeId[],
offset?: [number, number]
): Promise<NodeId[]>
copyNodes(nodeIds: NodeId[]): Promise<void>
cutNodes(nodeIds: NodeId[]): Promise<void>
pasteNodes(position?: [number, number]): Promise<NodeId[]>
getClipboard(): ClipboardData | null
clearClipboard(): void
hasClipboardContent(): boolean
addSubgraphNodeInput(params: AddNodeInputParams): Promise<number>
addSubgraphNodeOutput(params: AddNodeOutputParams): Promise<number>
removeSubgraphNodeInput(nodeId: NodeId, slot: number): Promise<void>
removeSubgraphNodeOutput(nodeId: NodeId, slot: number): Promise<void>
createSubgraph(params: CreateSubgraphParams): Promise<{
subgraph: any
node: any
}>
unpackSubgraph(subgraphNodeId: NodeId): Promise<void>
addSubgraphInput(
subgraphId: SubgraphId,
name: string,
type: string
): Promise<void>
addSubgraphOutput(
subgraphId: SubgraphId,
name: string,
type: string
): Promise<void>
removeSubgraphInput(subgraphId: SubgraphId, index: number): Promise<void>
removeSubgraphOutput(subgraphId: SubgraphId, index: number): Promise<void>
clearGraph(): Promise<void>
bypassNode(nodeId: NodeId): Promise<void>
unbypassNode(nodeId: NodeId): Promise<void>
transaction<T>(fn: () => Promise<T>): Promise<T>
undo(): Promise<void>
redo(): Promise<void>
}

View File

@@ -2,7 +2,6 @@ import axios, { AxiosError, AxiosResponse } from 'axios'
import { ref } from 'vue'
import { api } from '@/scripts/api'
import { createAxiosWithHeaders } from '@/services/networkClientAdapter'
import {
type InstallPackParams,
type InstalledPacksResponse,
@@ -36,7 +35,7 @@ enum ManagerRoute {
REBOOT = 'manager/reboot'
}
const managerApiClient = createAxiosWithHeaders({
const managerApiClient = axios.create({
baseURL: api.apiURL(''),
headers: {
'Content-Type': 'application/json'

View File

@@ -1,13 +1,12 @@
import axios, { AxiosError, AxiosResponse } from 'axios'
import { ref } from 'vue'
import { createAxiosWithHeaders } from '@/services/networkClientAdapter'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const API_BASE_URL = 'https://api.comfy.org'
const registryApiClient = createAxiosWithHeaders({
const registryApiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'

View File

@@ -3,7 +3,6 @@ import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { createAxiosWithHeaders } from '@/services/networkClientAdapter'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { type components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
@@ -23,7 +22,7 @@ type CustomerEventsResponseQuery =
export type AuditLog = components['schemas']['AuditLog']
const customerApiClient = createAxiosWithHeaders({
const customerApiClient = axios.create({
baseURL: COMFY_API_BASE_URL,
headers: {
'Content-Type': 'application/json'

View File

@@ -1,3 +1,6 @@
import { merge } from 'es-toolkit/compat'
import { Component } from 'vue'
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
@@ -20,7 +23,11 @@ import TemplateWorkflowsContent from '@/components/templates/TemplateWorkflowsCo
import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkflowsDialogHeader.vue'
import { t } from '@/i18n'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import { type ShowDialogOptions, useDialogStore } from '@/stores/dialogStore'
import {
type DialogComponentProps,
type ShowDialogOptions,
useDialogStore
} from '@/stores/dialogStore'
export type ConfirmationDialogType =
| 'default'
@@ -424,6 +431,33 @@ export const useDialogService = () => {
}
}
function showLayoutDialog(options: {
key: string
component: Component
props: { onClose: () => void }
dialogComponentProps?: DialogComponentProps
}) {
const layoutDefaultProps: DialogComponentProps = {
headless: true,
unstyled: true,
modal: true,
closable: false,
pt: {
mask: {
class: 'bg-black bg-opacity-40'
}
}
}
return dialogStore.showDialog({
...options,
dialogComponentProps: merge(
layoutDefaultProps,
options.dialogComponentProps || {}
)
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -443,6 +477,7 @@ export const useDialogService = () => {
prompt,
confirm,
toggleManagerDialog,
toggleManagerProgressDialog
toggleManagerProgressDialog,
showLayoutDialog
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,129 +0,0 @@
import type {
HeaderMap,
HeaderProviderContext,
HeaderProviderOptions,
HeaderValue,
IHeaderProvider,
IHeaderProviderRegistration
} from '@/types/headerTypes'
/**
* Internal registration entry
*/
interface HeaderProviderEntry {
id: string
provider: IHeaderProvider
options: HeaderProviderOptions
}
/**
* Registry for HTTP header providers
* Follows VSCode extension patterns for registration and lifecycle
*/
class HeaderRegistry {
private providers: HeaderProviderEntry[] = []
private nextId = 1
/**
* Registers a header provider
* @param provider - The header provider implementation
* @param options - Registration options
* @returns Registration handle for disposal
*/
registerHeaderProvider(
provider: IHeaderProvider,
options: HeaderProviderOptions = {}
): IHeaderProviderRegistration {
const id = `header-provider-${this.nextId++}`
const entry: HeaderProviderEntry = {
id,
provider,
options: {
priority: options.priority ?? 0,
filter: options.filter
}
}
// Insert provider in priority order (higher priority = later in array)
const insertIndex = this.providers.findIndex(
(p) => (p.options.priority ?? 0) > (entry.options.priority ?? 0)
)
if (insertIndex === -1) {
this.providers.push(entry)
} else {
this.providers.splice(insertIndex, 0, entry)
}
// Return disposable handle
return {
id,
dispose: () => {
const index = this.providers.findIndex((p) => p.id === id)
if (index !== -1) {
this.providers.splice(index, 1)
}
}
}
}
/**
* Gets all headers for a request by combining all registered providers
* @param context - Request context
* @returns Combined headers from all providers
*/
async getHeaders(context: HeaderProviderContext): Promise<HeaderMap> {
const result: HeaderMap = {}
// Process providers in order (lower priority first, so higher priority can override)
for (const entry of this.providers) {
// Check filter if provided
if (entry.options.filter && !entry.options.filter(context)) {
continue
}
try {
const headers = await entry.provider.provideHeaders(context)
// Merge headers, resolving any function values
for (const [key, value] of Object.entries(headers)) {
result[key] = await this.resolveHeaderValue(value)
}
} catch (error) {
console.error(`Error getting headers from provider ${entry.id}:`, error)
// Continue with other providers even if one fails
}
}
return result
}
/**
* Resolves a header value, handling functions
*/
private async resolveHeaderValue(
value: HeaderValue
): Promise<string | number | boolean> {
if (typeof value === 'function') {
return await value()
}
return value
}
/**
* Clears all registered providers
*/
clear(): void {
this.providers = []
}
/**
* Gets the count of registered providers
*/
get providerCount(): number {
return this.providers.length
}
}
// Export singleton instance
export const headerRegistry = new HeaderRegistry()

View File

@@ -1,87 +0,0 @@
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
import axios from 'axios'
import { headerRegistry } from '@/services/headerRegistry'
import type { HeaderProviderContext } from '@/types/headerTypes'
/**
* Creates an axios instance with automatic header injection from the registry
* @param config - Base axios configuration
* @returns Axios instance with header injection
*/
export function createAxiosWithHeaders(
config?: AxiosRequestConfig
): AxiosInstance {
const instance = axios.create(config)
// Add request interceptor to inject headers
instance.interceptors.request.use(
async (requestConfig) => {
// Build context for header providers
const context: HeaderProviderContext = {
url: requestConfig.url || '',
method: requestConfig.method || 'GET',
body: requestConfig.data,
config: requestConfig
}
// Get headers from registry
const registryHeaders = await headerRegistry.getHeaders(context)
// Merge with existing headers (registry headers take precedence)
for (const [key, value] of Object.entries(registryHeaders)) {
requestConfig.headers[key] = value
}
return requestConfig
},
(error) => {
return Promise.reject(error)
}
)
return instance
}
/**
* Wraps the native fetch API with header injection from the registry
* @param input - Request URL or Request object
* @param init - Request initialization options
* @returns Promise resolving to Response
*/
export async function fetchWithHeaders(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
// Extract URL and method for context
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url
const method =
init?.method || (input instanceof Request ? input.method : 'GET')
// Build context for header providers
const context: HeaderProviderContext = {
url,
method,
body: init?.body
}
// Get headers from registry
const registryHeaders = await headerRegistry.getHeaders(context)
// Convert registry headers to Headers object format
const headers = new Headers(init?.headers)
for (const [key, value] of Object.entries(registryHeaders)) {
headers.set(key, String(value))
}
// Perform fetch with merged headers
return fetch(input, {
...init,
headers
})
}

View File

@@ -1,5 +1,4 @@
import { api } from '@/scripts/api'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
import { extractCustomNodeName } from '@/utils/nodeHelpUtil'
@@ -26,12 +25,12 @@ export class NodeHelpService {
// Try locale-specific path first
const localePath = `/extensions/${customNodeName}/docs/${node.name}/${locale}.md`
let res = await fetchWithHeaders(api.fileURL(localePath))
let res = await fetch(api.fileURL(localePath))
if (!res.ok) {
// Fall back to non-locale path
const fallbackPath = `/extensions/${customNodeName}/docs/${node.name}.md`
res = await fetchWithHeaders(api.fileURL(fallbackPath))
res = await fetch(api.fileURL(fallbackPath))
}
if (!res.ok) {
@@ -46,7 +45,7 @@ export class NodeHelpService {
locale: string
): Promise<string> {
const mdUrl = `/docs/${node.name}/${locale}.md`
const res = await fetchWithHeaders(api.fileURL(mdUrl))
const res = await fetch(api.fileURL(mdUrl))
if (!res.ok) {
throw new Error(res.statusText)

View File

@@ -2,11 +2,10 @@ import axios, { AxiosError, AxiosResponse } from 'axios'
import { ref } from 'vue'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { createAxiosWithHeaders } from '@/services/networkClientAdapter'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const releaseApiClient = createAxiosWithHeaders({
const releaseApiClient = axios.create({
baseURL: COMFY_API_BASE_URL,
headers: {
'Content-Type': 'application/json'

View File

@@ -28,9 +28,11 @@ interface CustomDialogComponentProps {
pt?: DialogPassThroughOptions
closeOnEscape?: boolean
dismissableMask?: boolean
unstyled?: boolean
headless?: boolean
}
type DialogComponentProps = InstanceType<typeof GlobalDialog>['$props'] &
export type DialogComponentProps = InstanceType<typeof GlobalDialog>['$props'] &
CustomDialogComponentProps
interface DialogInstance {

View File

@@ -1,4 +1,3 @@
import axios from 'axios'
import {
type Auth,
GithubAuthProvider,
@@ -21,7 +20,6 @@ import { useFirebaseAuth } from 'vuefire'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { t } from '@/i18n'
import { createAxiosWithHeaders } from '@/services/networkClientAdapter'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { type AuthHeader } from '@/types/authTypes'
import { operations } from '@/types/comfyRegistryTypes'
@@ -46,15 +44,6 @@ export class FirebaseAuthStoreError extends Error {
}
}
// Customer API client - follows the same pattern as other services
// Now with automatic header injection from the registry
const customerApiClient = createAxiosWithHeaders({
baseURL: COMFY_API_BASE_URL,
headers: {
'Content-Type': 'application/json'
}
})
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
// State
const loading = ref(false)
@@ -140,27 +129,27 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
)
}
let balanceData
try {
const response = await customerApiClient.get('/customers/balance', {
headers: authHeader
})
balanceData = response.data
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
if (error.response.status === 404) {
// Customer not found is expected for new users
return null
}
const errorData = error.response.data
throw new FirebaseAuthStoreError(
t('toastMessages.failedToFetchBalance', {
error: errorData.message
})
)
const response = await fetch(`${COMFY_API_BASE_URL}/customers/balance`, {
headers: {
...authHeader,
'Content-Type': 'application/json'
}
throw error
})
if (!response.ok) {
if (response.status === 404) {
// Customer not found is expected for new users
return null
}
const errorData = await response.json()
throw new FirebaseAuthStoreError(
t('toastMessages.failedToFetchBalance', {
error: errorData.message
})
)
}
const balanceData = await response.json()
// Update the last balance update time
lastBalanceUpdateTime.value = new Date()
balance.value = balanceData
@@ -176,26 +165,23 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
let createCustomerResJson: CreateCustomerResponse
try {
const createCustomerRes = await customerApiClient.post(
'/customers',
{},
{
headers: authHeader
}
)
createCustomerResJson = createCustomerRes.data
} catch (error) {
if (axios.isAxiosError(error)) {
throw new FirebaseAuthStoreError(
t('toastMessages.failedToCreateCustomer', {
error: error.response?.statusText || error.message
})
)
const createCustomerRes = await fetch(`${COMFY_API_BASE_URL}/customers`, {
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
}
throw error
})
if (!createCustomerRes.ok) {
throw new FirebaseAuthStoreError(
t('toastMessages.failedToCreateCustomer', {
error: createCustomerRes.statusText
})
)
}
const createCustomerResJson: CreateCustomerResponse =
await createCustomerRes.json()
if (!createCustomerResJson?.id) {
throw new FirebaseAuthStoreError(
t('toastMessages.failedToCreateCustomer', {
@@ -296,26 +282,25 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
customerCreated.value = true
}
try {
const response = await customerApiClient.post(
'/customers/credit',
requestBodyContent,
{
headers: authHeader
}
const response = await fetch(`${COMFY_API_BASE_URL}/customers/credit`, {
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBodyContent)
})
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
t('toastMessages.failedToInitiateCreditPurchase', {
error: errorData.message
})
)
return response.data
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
const errorData = error.response.data
throw new FirebaseAuthStoreError(
t('toastMessages.failedToInitiateCreditPurchase', {
error: errorData.message
})
)
}
throw error
}
return response.json()
}
const initiateCreditPurchase = async (
@@ -331,26 +316,27 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
try {
const response = await customerApiClient.post(
'/customers/billing',
requestBody,
{
headers: authHeader
}
const response = await fetch(`${COMFY_API_BASE_URL}/customers/billing`, {
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
},
...(requestBody && {
body: JSON.stringify(requestBody)
})
})
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
t('toastMessages.failedToAccessBillingPortal', {
error: errorData.message
})
)
return response.data
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
const errorData = error.response.data
throw new FirebaseAuthStoreError(
t('toastMessages.failedToAccessBillingPortal', {
error: errorData.message
})
)
}
throw error
}
return response.json()
}
return {

View File

@@ -70,13 +70,6 @@ export interface ComfyExtension {
* Badges to add to the about page
*/
aboutPageBadges?: AboutPageBadge[]
/**
* Allows the extension to run code before the app is initialized. This is the earliest lifecycle hook.
* Called before the canvas is created and before any other extension hooks.
* Useful for registering services, header providers, or other cross-cutting concerns.
* @param app The ComfyUI app instance
*/
preInit?(app: ComfyApp): Promise<void> | void
/**
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
* @param app The ComfyUI app instance

View File

@@ -0,0 +1,2 @@
export * from './navTypes'
export * from './widgetTypes'

View File

@@ -0,0 +1,9 @@
export interface NavItemData {
id: string
label: string
}
export interface NavGroupData {
title: string
items: NavItemData[]
}

View File

@@ -0,0 +1,3 @@
import { InjectionKey } from 'vue'
export const OnCloseKey: InjectionKey<() => void> = Symbol()

View File

@@ -1,61 +0,0 @@
import type { AxiosRequestConfig } from 'axios'
/**
* Header value can be a string, number, boolean, or a function that returns one of these
*/
export type HeaderValue =
| string
| number
| boolean
| (() => string | number | boolean | Promise<string | number | boolean>)
/**
* Header provider interface for extensions to implement
*/
export interface IHeaderProvider {
/**
* Provides headers for HTTP requests
* @param context - Request context containing URL and method
* @returns Headers to be added to the request
*/
provideHeaders(context: HeaderProviderContext): HeaderMap | Promise<HeaderMap>
}
/**
* Context passed to header providers
*/
export interface HeaderProviderContext {
/** The URL being requested */
url: string
/** HTTP method */
method: string
/** Optional request body */
body?: any
/** Original request config if available */
config?: AxiosRequestConfig
}
/**
* Map of header names to values
*/
export type HeaderMap = Record<string, HeaderValue>
/**
* Registration handle returned when registering a header provider
*/
export interface IHeaderProviderRegistration {
/** Unique ID for this registration */
id: string
/** Disposes of this registration */
dispose(): void
}
/**
* Options for registering a header provider
*/
export interface HeaderProviderOptions {
/** Priority for this provider (higher = runs later, can override earlier providers) */
priority?: number
/** Optional filter to limit which requests this provider applies to */
filter?: (context: HeaderProviderContext) => boolean
}

View File

@@ -27,25 +27,41 @@ export class ExecutableGroupNodeChildDTO extends ExecutableNodeDTO {
}
override resolveInput(slot: number) {
// Check if this group node is inside a subgraph (unsupported)
if (this.id.split(':').length > 2) {
throw new Error(
'Group nodes inside subgraphs are not supported. Please convert the group node to a subgraph instead.'
)
}
const inputNode = this.node.getInputNode(slot)
if (!inputNode) return
const link = this.node.getInputLink(slot)
if (!link) throw new Error('Failed to get input link')
const id = String(inputNode.id).split(':').at(-1)
if (id === undefined) throw new Error('Invalid input node id')
const inputNodeId = String(inputNode.id)
// Try to find the node using the full ID first (for nodes outside the group)
let inputNodeDto = this.nodesByExecutionId?.get(inputNodeId)
// If not found, try with just the last part of the ID (for nodes inside the group)
if (!inputNodeDto) {
const id = inputNodeId.split(':').at(-1)
if (id !== undefined) {
inputNodeDto = this.nodesByExecutionId?.get(id)
}
}
const inputNodeDto = this.nodesByExecutionId?.get(id)
if (!inputNodeDto) {
throw new Error(
`Failed to get input node ${id} for group node child ${this.id} with slot ${slot}`
`Failed to get input node ${inputNodeId} for group node child ${this.id} with slot ${slot}`
)
}
return {
node: inputNodeDto,
origin_id: String(inputNode.id),
origin_id: inputNodeId,
origin_slot: link.origin_slot
}
}

View File

@@ -79,14 +79,14 @@ export default {
colors: {
zinc: {
50: '#fafafa',
100: '#f4f4f5',
100: '#8282821a',
200: '#e4e4e7',
300: '#d4d4d8',
400: '#a1a1aa',
500: '#71717a',
600: '#52525b',
700: '#3f3f46',
800: '#27272a',
700: '#38393b',
800: '#262729',
900: '#18181b',
950: '#09090b'
},

View File

@@ -11,7 +11,8 @@ const mockT = vi.fn((key: string) => {
'shortcuts.subcategories.node': 'Node',
'shortcuts.subcategories.queue': 'Queue',
'shortcuts.subcategories.view': 'View',
'shortcuts.subcategories.panelControls': 'Panel Controls'
'shortcuts.subcategories.panelControls': 'Panel Controls',
'commands.Workflow_New.label': 'New Blank Workflow'
}
return translations[key] || key
})
@@ -76,9 +77,7 @@ describe('ShortcutsList', () => {
expect(wrapper.text()).toContain('Queue')
// Check that commands are rendered
expect(wrapper.text()).toContain('New Workflow')
expect(wrapper.text()).toContain('Add Node')
expect(wrapper.text()).toContain('Clear Queue')
expect(wrapper.text()).toContain('New Blank Workflow')
})
it('should format keyboard shortcuts correctly', () => {

View File

@@ -1512,7 +1512,10 @@ describe('useNodePricing', () => {
{ model: 'gpt-4o', expected: '$0.0025/$0.01 per 1K tokens' },
{ model: 'gpt-4.1-nano', expected: '$0.0001/$0.0004 per 1K tokens' },
{ model: 'gpt-4.1-mini', expected: '$0.0004/$0.0016 per 1K tokens' },
{ model: 'gpt-4.1', expected: '$0.002/$0.008 per 1K tokens' }
{ model: 'gpt-4.1', expected: '$0.002/$0.008 per 1K tokens' },
{ model: 'gpt-5-nano', expected: '$0.00005/$0.0004 per 1K tokens' },
{ model: 'gpt-5-mini', expected: '$0.00025/$0.002 per 1K tokens' },
{ model: 'gpt-5', expected: '$0.00125/$0.01 per 1K tokens' }
]
testCases.forEach(({ model, expected }) => {

View File

@@ -2,7 +2,6 @@ import { flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
// Mock the store
@@ -42,11 +41,6 @@ vi.mock('@/stores/dialogStore', () => ({
// Mock fetch
global.fetch = vi.fn()
// Mock fetchWithHeaders
vi.mock('@/services/networkClientAdapter', () => ({
fetchWithHeaders: vi.fn()
}))
describe('useTemplateWorkflows', () => {
let mockWorkflowTemplatesStore: any
@@ -106,11 +100,6 @@ describe('useTemplateWorkflows', () => {
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ workflow: 'data' })
} as unknown as Response)
// Also mock fetchWithHeaders
vi.mocked(fetchWithHeaders).mockResolvedValue({
json: vi.fn().mockResolvedValue({ workflow: 'data' })
} as unknown as Response)
})
it('should load templates from store', async () => {
@@ -269,9 +258,7 @@ describe('useTemplateWorkflows', () => {
await flushPromises()
expect(result).toBe(true)
expect(vi.mocked(fetchWithHeaders)).toHaveBeenCalledWith(
'mock-file-url/templates/template1.json'
)
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
expect(loadingTemplateId.value).toBe(null) // Should reset after loading
})
@@ -286,9 +273,7 @@ describe('useTemplateWorkflows', () => {
await flushPromises()
expect(result).toBe(true)
expect(vi.mocked(fetchWithHeaders)).toHaveBeenCalledWith(
'mock-file-url/templates/template1.json'
)
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
})
it('should handle errors when loading templates', async () => {
@@ -297,10 +282,8 @@ describe('useTemplateWorkflows', () => {
// Set the store as loaded
mockWorkflowTemplatesStore.isLoaded = true
// Mock fetchWithHeaders to throw an error
vi.mocked(fetchWithHeaders).mockRejectedValueOnce(
new Error('Failed to fetch')
)
// Mock fetch to throw an error
vi.mocked(fetch).mockRejectedValueOnce(new Error('Failed to fetch'))
// Spy on console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

View File

@@ -1,35 +1,17 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRemoteWidget } from '@/composables/widgets/useRemoteWidget'
import { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
// Hoist the mock to avoid hoisting issues
const mockAxiosInstance = vi.hoisted(() => ({
get: vi.fn(),
interceptors: {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
}
}))
vi.mock('axios', () => {
return {
default: {
get: vi.fn(),
create: vi.fn(() => mockAxiosInstance)
get: vi.fn()
}
}
})
// Mock networkClientAdapter to return the same axios instance
vi.mock('@/services/networkClientAdapter', () => ({
createAxiosWithHeaders: vi.fn(() => mockAxiosInstance)
}))
vi.mock('@/i18n', () => ({
i18n: {
global: {
@@ -81,12 +63,12 @@ const createMockOptions = (inputOverrides = {}) => ({
})
function mockAxiosResponse(data: unknown, status = 200) {
vi.mocked(mockAxiosInstance.get).mockResolvedValueOnce({ data, status })
vi.mocked(axios.get).mockResolvedValueOnce({ data, status })
}
function mockAxiosError(error: Error | string) {
const err = error instanceof Error ? error : new Error(error)
vi.mocked(mockAxiosInstance.get).mockRejectedValueOnce(err)
vi.mocked(axios.get).mockRejectedValueOnce(err)
}
function createHookWithData(data: unknown, inputOverrides = {}) {
@@ -114,7 +96,7 @@ describe('useRemoteWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset mocks
vi.mocked(mockAxiosInstance.get).mockReset()
vi.mocked(axios.get).mockReset()
// Reset cache between tests
vi.spyOn(Map.prototype, 'get').mockClear()
vi.spyOn(Map.prototype, 'set').mockClear()
@@ -155,7 +137,7 @@ describe('useRemoteWidget', () => {
const mockData = ['optionA', 'optionB']
const { hook, result } = await setupHookWithResponse(mockData)
expect(result).toEqual(mockData)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledWith(
expect(vi.mocked(axios.get)).toHaveBeenCalledWith(
hook.cacheKey.split(';')[0], // Get the route part from cache key
expect.any(Object)
)
@@ -234,7 +216,7 @@ describe('useRemoteWidget', () => {
await getResolvedValue(hook)
await getResolvedValue(hook)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('permanent widgets should re-fetch if refreshValue is called', async () => {
@@ -255,12 +237,12 @@ describe('useRemoteWidget', () => {
const hook = useRemoteWidget(createMockOptions())
await getResolvedValue(hook)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
const secondData = await getResolvedValue(hook)
expect(secondData).toBe('Loading...')
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(2)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('should treat empty refresh field as permanent', async () => {
@@ -269,7 +251,7 @@ describe('useRemoteWidget', () => {
await getResolvedValue(hook)
await getResolvedValue(hook)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
})
@@ -285,7 +267,7 @@ describe('useRemoteWidget', () => {
const newData = await getResolvedValue(hook)
expect(newData).toEqual(mockData2)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(2)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('should not refresh when data is not stale', async () => {
@@ -296,7 +278,7 @@ describe('useRemoteWidget', () => {
vi.setSystemTime(Date.now() + 128)
await getResolvedValue(hook)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('should use backoff instead of refresh after error', async () => {
@@ -308,13 +290,13 @@ describe('useRemoteWidget', () => {
mockAxiosError('Network error')
vi.setSystemTime(Date.now() + refresh)
await getResolvedValue(hook)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(2)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
mockAxiosResponse(['second success'])
vi.setSystemTime(Date.now() + FIRST_BACKOFF)
const thirdData = await getResolvedValue(hook)
expect(thirdData).toEqual(['second success'])
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(3)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(3)
})
it('should use last valid value after error', async () => {
@@ -328,7 +310,7 @@ describe('useRemoteWidget', () => {
const secondData = await getResolvedValue(hook)
expect(secondData).toEqual(['a valid value'])
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(2)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
})
@@ -350,15 +332,15 @@ describe('useRemoteWidget', () => {
expect(entry1?.error).toBeTruthy()
await getResolvedValue(hook)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
vi.setSystemTime(Date.now() + 500)
await getResolvedValue(hook)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1) // Still backing off
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1) // Still backing off
vi.setSystemTime(Date.now() + 3000)
await getResolvedValue(hook)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(2)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
expect(entry1?.data).toBeDefined()
})
@@ -436,9 +418,7 @@ describe('useRemoteWidget', () => {
it('should prevent duplicate in-flight requests', async () => {
const promise = Promise.resolve({ data: ['non-duplicate'] })
vi.mocked(mockAxiosInstance.get).mockImplementationOnce(
() => promise as any
)
vi.mocked(axios.get).mockImplementationOnce(() => promise as any)
const hook = useRemoteWidget(createMockOptions())
const [result1, result2] = await Promise.all([
@@ -447,7 +427,7 @@ describe('useRemoteWidget', () => {
])
expect(result1).toBe(result2)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
})
@@ -466,7 +446,7 @@ describe('useRemoteWidget', () => {
expect(data1).toEqual(['shared data'])
expect(data2).toEqual(['shared data'])
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
})
@@ -487,7 +467,7 @@ describe('useRemoteWidget', () => {
expect(data2).toBe(data1)
expect(data3).toBe(data1)
expect(data4).toBe(data1)
expect(vi.mocked(mockAxiosInstance.get)).toHaveBeenCalledTimes(1)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
expect(hook1.getCachedValue()).toBe(hook2.getCachedValue())
expect(hook2.getCachedValue()).toBe(hook3.getCachedValue())
expect(hook3.getCachedValue()).toBe(hook4.getCachedValue())
@@ -499,9 +479,7 @@ describe('useRemoteWidget', () => {
resolvePromise = resolve
})
vi.mocked(mockAxiosInstance.get).mockImplementationOnce(
() => delayedPromise as any
)
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise as any)
const hook = useRemoteWidget(createMockOptions())
hook.getValue()
@@ -522,9 +500,7 @@ describe('useRemoteWidget', () => {
resolvePromise = resolve
})
vi.mocked(mockAxiosInstance.get).mockImplementationOnce(
() => delayedPromise as any
)
vi.mocked(axios.get).mockImplementationOnce(() => delayedPromise as any)
let hook = useRemoteWidget(createMockOptions())
const fetchPromise = hook.getValue()

View File

@@ -1,83 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthHeaderProvider } from '@/providers/authHeaderProvider'
import { headerRegistry } from '@/services/headerRegistry'
// Mock the providers module
vi.mock('@/providers/authHeaderProvider', () => ({
AuthHeaderProvider: vi.fn()
}))
// Mock headerRegistry
vi.mock('@/services/headerRegistry', () => ({
headerRegistry: {
registerHeaderProvider: vi.fn()
}
}))
// Mock app
const mockApp = {
registerExtension: vi.fn()
}
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
describe('authHeaders extension', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset module cache to ensure fresh imports
vi.resetModules()
})
it('should register extension with correct name', async () => {
// Import the extension (this will call app.registerExtension)
await import('@/extensions/core/authHeaders')
expect(mockApp.registerExtension).toHaveBeenCalledOnce()
const extensionConfig = mockApp.registerExtension.mock.calls[0][0]
expect(extensionConfig.name).toBe('Comfy.AuthHeaders')
})
it('should register auth header provider in preInit hook', async () => {
// Import the extension
await import('@/extensions/core/authHeaders')
const extensionConfig = mockApp.registerExtension.mock.calls[0][0]
expect(extensionConfig.preInit).toBeDefined()
// Call the preInit hook
await extensionConfig.preInit({})
// Verify AuthHeaderProvider was instantiated
expect(AuthHeaderProvider).toHaveBeenCalledOnce()
// Verify header provider was registered with high priority
expect(headerRegistry.registerHeaderProvider).toHaveBeenCalledWith(
expect.any(Object), // The AuthHeaderProvider instance
{ priority: 1000 }
)
})
it('should log initialization messages', async () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
// Import the extension
await import('@/extensions/core/authHeaders')
const extensionConfig = mockApp.registerExtension.mock.calls[0][0]
// Call the preInit hook
await extensionConfig.preInit({})
expect(consoleLogSpy).toHaveBeenCalledWith(
'[AuthHeaders] Registering authentication header provider'
)
expect(consoleLogSpy).toHaveBeenCalledWith(
'[AuthHeaders] Authentication headers will be automatically injected'
)
consoleLogSpy.mockRestore()
})
})

View File

@@ -1,328 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { ComfyApp } from '@/scripts/app'
import type { ComfyExtension } from '@/types/comfy'
// Create mock extension service
const mockExtensionService = {
loadExtensions: vi.fn().mockResolvedValue(undefined),
registerExtension: vi.fn(),
invokeExtensions: vi.fn(),
invokeExtensionsAsync: vi.fn().mockResolvedValue(undefined),
enabledExtensions: [] as ComfyExtension[]
}
// Mock extension service
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => mockExtensionService
}))
// Mock dependencies
vi.mock('@/stores/toastStore', () => ({
useToastStore: () => ({
add: vi.fn()
})
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({
workflow: {
syncWorkflows: vi.fn().mockResolvedValue(undefined)
}
})
}))
vi.mock('@/services/subgraphService', () => ({
useSubgraphService: () => ({
registerNewSubgraph: vi.fn()
})
}))
// Mock LiteGraph
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
const actual = (await importOriginal()) as any
return {
...actual,
LGraph: vi.fn().mockImplementation(() => ({
events: {
addEventListener: vi.fn()
},
start: vi.fn(),
stop: vi.fn(),
registerNodeType: vi.fn(),
createNode: vi.fn()
})),
LGraphCanvas: vi.fn().mockImplementation((canvasEl) => ({
state: reactive({}),
draw: vi.fn(),
canvas: canvasEl
})),
LiteGraph: {
...actual.LiteGraph,
alt_drag_do_clone_nodes: false,
macGesturesRequireMac: true
}
}
})
// Mock other required methods
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: () => ({
disabledExtensions: new Set()
})
}))
vi.mock('@/utils/app.utils', () => ({
makeUUID: vi.fn(() => 'test-uuid')
}))
// Mock API
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
apiURL: vi.fn((path) => `/api${path}`),
connect: vi.fn(),
init: vi.fn().mockResolvedValue(undefined),
getSystemStats: vi.fn().mockResolvedValue({}),
getNodeDefs: vi.fn().mockResolvedValue({})
}
}))
describe('Extension Pre-Init Hook', () => {
let app: ComfyApp
let mockCanvas: HTMLCanvasElement
let callOrder: string[]
beforeEach(() => {
vi.clearAllMocks()
callOrder = []
// Reset mock extension service
mockExtensionService.enabledExtensions = []
mockExtensionService.invokeExtensionsAsync.mockReset()
mockExtensionService.invokeExtensionsAsync.mockImplementation(
async (method: keyof ComfyExtension) => {
// Call the appropriate hook on all registered extensions
for (const ext of mockExtensionService.enabledExtensions) {
const hookFn = ext[method]
if (typeof hookFn === 'function') {
try {
await hookFn.call(ext, app)
} catch (error) {
console.error(`Error in extension ${ext.name} ${method}`, error)
}
}
}
}
)
// Create mock canvas
mockCanvas = document.createElement('canvas')
mockCanvas.getContext = vi.fn().mockReturnValue({
scale: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
clearRect: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn()
})
// Create mock DOM elements
const createMockElement = (id: string) => {
const el = document.createElement('div')
el.id = id
document.body.appendChild(el)
return el
}
createMockElement('comfyui-body-top')
createMockElement('comfyui-body-left')
createMockElement('comfyui-body-right')
createMockElement('comfyui-body-bottom')
createMockElement('graph-canvas-container')
app = new ComfyApp()
// Mock app methods that are called during setup
app.registerNodes = vi.fn().mockResolvedValue(undefined)
// Mock addEventListener for canvas element
mockCanvas.addEventListener = vi.fn()
mockCanvas.removeEventListener = vi.fn()
// Mock window methods
window.addEventListener = vi.fn()
window.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
disconnect: vi.fn()
}))
// Mock WebSocket
const mockWebSocket = vi.fn().mockImplementation(() => ({
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
send: vi.fn(),
close: vi.fn(),
readyState: 1,
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3
}))
;(mockWebSocket as any).CONNECTING = 0
;(mockWebSocket as any).OPEN = 1
;(mockWebSocket as any).CLOSING = 2
;(mockWebSocket as any).CLOSED = 3
global.WebSocket = mockWebSocket as any
})
afterEach(() => {
// Clean up DOM elements
document.body.innerHTML = ''
})
it('should call preInit hook before init hook', async () => {
const testExtension: ComfyExtension = {
name: 'TestExtension',
preInit: vi.fn(async () => {
callOrder.push('preInit')
}),
init: vi.fn(async () => {
callOrder.push('init')
}),
setup: vi.fn(async () => {
callOrder.push('setup')
})
}
// Register the extension
mockExtensionService.enabledExtensions.push(testExtension)
// Run app setup
await app.setup(mockCanvas)
// Verify all hooks were called
expect(testExtension.preInit).toHaveBeenCalledWith(app)
expect(testExtension.init).toHaveBeenCalledWith(app)
expect(testExtension.setup).toHaveBeenCalledWith(app)
// Verify correct order
expect(callOrder).toEqual(['preInit', 'init', 'setup'])
})
it('should call preInit before canvas creation', async () => {
const events: string[] = []
const testExtension: ComfyExtension = {
name: 'CanvasTestExtension',
preInit: vi.fn(async () => {
events.push('preInit')
// Canvas should not exist yet
expect(app.canvas).toBeUndefined()
}),
init: vi.fn(async () => {
events.push('init')
// Canvas should exist by init
expect(app.canvas).toBeDefined()
})
}
mockExtensionService.enabledExtensions.push(testExtension)
await app.setup(mockCanvas)
expect(events).toEqual(['preInit', 'init'])
})
it('should handle async preInit hooks', async () => {
const preInitComplete = vi.fn()
const testExtension: ComfyExtension = {
name: 'AsyncExtension',
preInit: vi.fn(async () => {
// Simulate async operation
await new Promise((resolve) => setTimeout(resolve, 10))
preInitComplete()
}),
init: vi.fn()
}
mockExtensionService.enabledExtensions.push(testExtension)
await app.setup(mockCanvas)
// Ensure async preInit completed before init
expect(preInitComplete).toHaveBeenCalled()
expect(testExtension.init).toHaveBeenCalled()
// Verify order - preInit should be called before init
const preInitCallOrder = (preInitComplete as any).mock
.invocationCallOrder[0]
const initCallOrder = (testExtension.init as any).mock
.invocationCallOrder[0]
expect(preInitCallOrder).toBeLessThan(initCallOrder)
})
it('should call preInit for multiple extensions in registration order', async () => {
const extension1: ComfyExtension = {
name: 'Extension1',
preInit: vi.fn(() => {
callOrder.push('ext1-preInit')
})
}
const extension2: ComfyExtension = {
name: 'Extension2',
preInit: vi.fn(() => {
callOrder.push('ext2-preInit')
})
}
const extension3: ComfyExtension = {
name: 'Extension3',
preInit: vi.fn(() => {
callOrder.push('ext3-preInit')
})
}
mockExtensionService.enabledExtensions.push(
extension1,
extension2,
extension3
)
await app.setup(mockCanvas)
expect(callOrder).toContain('ext1-preInit')
expect(callOrder).toContain('ext2-preInit')
expect(callOrder).toContain('ext3-preInit')
})
it('should handle errors in preInit gracefully', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const errorExtension: ComfyExtension = {
name: 'ErrorExtension',
preInit: vi.fn(async () => {
throw new Error('PreInit error')
}),
init: vi.fn() // Should still be called
}
mockExtensionService.enabledExtensions.push(errorExtension)
await app.setup(mockCanvas)
// Error should be logged
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('Error in extension ErrorExtension'),
expect.any(Error)
)
// Other hooks should still be called
expect(errorExtension.init).toHaveBeenCalled()
consoleError.mockRestore()
})
})

View File

@@ -1,234 +0,0 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthHeaderProvider } from '@/providers/authHeaderProvider'
import { headerRegistry } from '@/services/headerRegistry'
import {
createAxiosWithHeaders,
fetchWithHeaders
} from '@/services/networkClientAdapter'
// Mock stores
const mockFirebaseAuthStore = {
getAuthHeader: vi.fn(),
getIdToken: vi.fn()
}
const mockApiKeyAuthStore = {
getAuthHeader: vi.fn()
}
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => mockFirebaseAuthStore
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => mockApiKeyAuthStore
}))
// Mock fetch
const mockFetch = vi.fn()
global.fetch = mockFetch
// Mock axios
vi.mock('axios')
const mockedAxios = axios as any
describe('Auth Header Integration', () => {
let authProviderRegistration: any
beforeEach(() => {
vi.clearAllMocks()
// Reset fetch mock
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ success: true })
})
// Reset axios mock
mockedAxios.create.mockReturnValue({
interceptors: {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
},
defaults: {
headers: {
common: {},
get: {},
post: {},
put: {},
patch: {},
delete: {}
}
}
})
// Register auth header provider
authProviderRegistration = headerRegistry.registerHeaderProvider(
new AuthHeaderProvider(),
{ priority: 1000 }
)
})
afterEach(() => {
// Unregister the provider
authProviderRegistration.unregister()
vi.restoreAllMocks()
})
describe('fetchWithHeaders integration', () => {
it('should automatically add Firebase auth headers to fetch requests', async () => {
const mockAuthHeader = { Authorization: 'Bearer firebase-token-123' }
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeader)
await fetchWithHeaders('https://api.example.com/data')
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/data',
expect.objectContaining({
headers: expect.any(Headers)
})
)
// Verify the auth header was added
const callArgs = mockFetch.mock.calls[0]
const headers = callArgs[1].headers as Headers
expect(headers.get('Authorization')).toBe('Bearer firebase-token-123')
})
it('should automatically add API key headers when Firebase is not available', async () => {
const mockApiKeyHeader = { 'X-API-KEY': 'test-api-key' }
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockApiKeyHeader)
await fetchWithHeaders('https://api.example.com/data')
const callArgs = mockFetch.mock.calls[0]
const headers = callArgs[1].headers as Headers
expect(headers.get('X-API-KEY')).toBe('test-api-key')
})
it('should merge auth headers with existing headers', async () => {
const mockAuthHeader = { Authorization: 'Bearer firebase-token-123' }
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeader)
await fetchWithHeaders('https://api.example.com/data', {
headers: {
'Content-Type': 'application/json',
'X-Custom-Header': 'custom-value'
}
})
const callArgs = mockFetch.mock.calls[0]
const headers = callArgs[1].headers as Headers
expect(headers.get('Authorization')).toBe('Bearer firebase-token-123')
expect(headers.get('Content-Type')).toBe('application/json')
expect(headers.get('X-Custom-Header')).toBe('custom-value')
})
it('should not add headers when no auth is available', async () => {
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(null)
await fetchWithHeaders('https://api.example.com/data')
const callArgs = mockFetch.mock.calls[0]
const headers = callArgs[1].headers as Headers
expect(headers.get('Authorization')).toBeNull()
expect(headers.get('X-API-KEY')).toBeNull()
})
})
describe('createAxiosWithHeaders integration', () => {
it('should setup interceptor to add auth headers', async () => {
const mockInstance = {
interceptors: {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
},
defaults: {
headers: {
common: {},
get: {},
post: {},
put: {},
patch: {},
delete: {}
}
}
}
mockedAxios.create.mockReturnValue(mockInstance)
createAxiosWithHeaders({ baseURL: 'https://api.example.com' })
// Verify interceptor was registered
expect(mockInstance.interceptors.request.use).toHaveBeenCalledOnce()
// Get the interceptor function
const interceptorCall =
mockInstance.interceptors.request.use.mock.calls[0]
const requestInterceptor = interceptorCall[0]
// Test the interceptor
const mockAuthHeader = { Authorization: 'Bearer firebase-token-123' }
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeader)
const config = {
url: '/test',
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
const modifiedConfig = await requestInterceptor(config)
expect(modifiedConfig.headers.Authorization).toBe(
'Bearer firebase-token-123'
)
expect(modifiedConfig.headers['Content-Type']).toBe('application/json')
})
})
describe('Multiple providers with priority', () => {
it('should apply headers in priority order', async () => {
// Register a second provider with higher priority
const customProvider = {
provideHeaders: vi.fn().mockResolvedValue({
'X-Custom': 'high-priority',
Authorization: 'Bearer custom-token' // This should override the auth provider
})
}
const customRegistration = headerRegistry.registerHeaderProvider(
customProvider,
{ priority: 2000 } // Higher priority than auth provider
)
// Auth provider returns different token
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue({
Authorization: 'Bearer firebase-token'
})
await fetchWithHeaders('https://api.example.com/data')
const callArgs = mockFetch.mock.calls[0]
const headers = callArgs[1].headers as Headers
// Higher priority provider should win
expect(headers.get('Authorization')).toBe('Bearer custom-token')
expect(headers.get('X-Custom')).toBe('high-priority')
customRegistration.dispose()
})
})
})

View File

@@ -1,144 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
ApiKeyHeaderProvider,
AuthHeaderProvider,
FirebaseAuthHeaderProvider
} from '@/providers/authHeaderProvider'
import type { HeaderProviderContext } from '@/types/headerTypes'
// Mock stores
const mockFirebaseAuthStore = {
getAuthHeader: vi.fn(),
getIdToken: vi.fn()
}
const mockApiKeyAuthStore = {
getAuthHeader: vi.fn()
}
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => mockFirebaseAuthStore
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => mockApiKeyAuthStore
}))
describe('authHeaderProvider', () => {
const mockContext: HeaderProviderContext = {
url: 'https://api.example.com/test',
method: 'GET'
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('AuthHeaderProvider', () => {
it('should provide Firebase auth header when available', async () => {
const provider = new AuthHeaderProvider()
const mockAuthHeader = { Authorization: 'Bearer firebase-token-123' }
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeader)
const headers = await provider.provideHeaders(mockContext)
expect(headers).toEqual(mockAuthHeader)
expect(mockFirebaseAuthStore.getAuthHeader).toHaveBeenCalledOnce()
})
it('should provide API key header when Firebase auth is not available', async () => {
const provider = new AuthHeaderProvider()
const mockApiKeyHeader = { 'X-API-KEY': 'test-api-key' }
// Firebase returns null, but includes API key as fallback
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockApiKeyHeader)
const headers = await provider.provideHeaders(mockContext)
expect(headers).toEqual(mockApiKeyHeader)
})
it('should return empty object when no auth is available', async () => {
const provider = new AuthHeaderProvider()
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(null)
const headers = await provider.provideHeaders(mockContext)
expect(headers).toEqual({})
})
})
describe('ApiKeyHeaderProvider', () => {
it('should provide API key header when available', () => {
const provider = new ApiKeyHeaderProvider()
const mockApiKeyHeader = { 'X-API-KEY': 'test-api-key' }
mockApiKeyAuthStore.getAuthHeader.mockReturnValue(mockApiKeyHeader)
const headers = provider.provideHeaders(mockContext)
expect(headers).toEqual(mockApiKeyHeader)
expect(mockApiKeyAuthStore.getAuthHeader).toHaveBeenCalledOnce()
})
it('should return empty object when no API key is available', () => {
const provider = new ApiKeyHeaderProvider()
mockApiKeyAuthStore.getAuthHeader.mockReturnValue(null)
const headers = provider.provideHeaders(mockContext)
expect(headers).toEqual({})
})
})
describe('FirebaseAuthHeaderProvider', () => {
it('should provide Firebase auth header when available', async () => {
const provider = new FirebaseAuthHeaderProvider()
const mockToken = 'firebase-token-456'
mockFirebaseAuthStore.getIdToken.mockResolvedValue(mockToken)
const headers = await provider.provideHeaders(mockContext)
expect(headers).toEqual({
Authorization: `Bearer ${mockToken}`
})
expect(mockFirebaseAuthStore.getIdToken).toHaveBeenCalledOnce()
})
it('should return empty object when no Firebase token is available', async () => {
const provider = new FirebaseAuthHeaderProvider()
mockFirebaseAuthStore.getIdToken.mockResolvedValue(null)
const headers = await provider.provideHeaders(mockContext)
expect(headers).toEqual({})
})
it('should not fall back to API key', async () => {
const provider = new FirebaseAuthHeaderProvider()
// Firebase has no token
mockFirebaseAuthStore.getIdToken.mockResolvedValue(null)
// API key is available
mockApiKeyAuthStore.getAuthHeader.mockReturnValue({
'X-API-KEY': 'test-key'
})
const headers = await provider.provideHeaders(mockContext)
expect(headers).toEqual({})
// Should not call API key store
expect(mockApiKeyAuthStore.getAuthHeader).not.toHaveBeenCalled()
})
})
})

View File

@@ -27,11 +27,6 @@ vi.mock('axios', () => ({
}
}))
// Mock networkClientAdapter to return the same axios instance
vi.mock('@/services/networkClientAdapter', () => ({
createAxiosWithHeaders: vi.fn(() => mockAxiosInstance)
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => mockFirebaseAuthStore)
}))

File diff suppressed because it is too large Load Diff

View File

@@ -1,253 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { headerRegistry } from '@/services/headerRegistry'
import type {
HeaderProviderContext,
IHeaderProvider
} from '@/types/headerTypes'
describe('headerRegistry', () => {
beforeEach(() => {
headerRegistry.clear()
})
describe('registerHeaderProvider', () => {
it('should register a header provider', () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({ 'X-Test': 'value' })
}
const registration = headerRegistry.registerHeaderProvider(provider)
expect(registration).toBeDefined()
expect(registration.id).toMatch(/^header-provider-\d+$/)
expect(headerRegistry.providerCount).toBe(1)
})
it('should return a disposable registration', () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn()
}
const registration = headerRegistry.registerHeaderProvider(provider)
expect(headerRegistry.providerCount).toBe(1)
registration.dispose()
expect(headerRegistry.providerCount).toBe(0)
})
it('should insert providers in priority order', async () => {
const provider1: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({ 'X-Priority': 'low' })
}
const provider2: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({ 'X-Priority': 'high' })
}
const provider3: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({ 'X-Priority': 'medium' })
}
headerRegistry.registerHeaderProvider(provider1, { priority: 1 })
headerRegistry.registerHeaderProvider(provider2, { priority: 10 })
headerRegistry.registerHeaderProvider(provider3, { priority: 5 })
const context: HeaderProviderContext = {
url: 'https://api.example.com',
method: 'GET'
}
const headers = await headerRegistry.getHeaders(context)
// Higher priority provider should override
expect(headers['X-Priority']).toBe('high')
})
})
describe('getHeaders', () => {
it('should combine headers from all providers', async () => {
const provider1: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Header-1': 'value1',
'X-Common': 'provider1'
})
}
const provider2: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Header-2': 'value2',
'X-Common': 'provider2'
})
}
headerRegistry.registerHeaderProvider(provider1, { priority: 1 })
headerRegistry.registerHeaderProvider(provider2, { priority: 2 })
const context: HeaderProviderContext = {
url: 'https://api.example.com',
method: 'GET'
}
const headers = await headerRegistry.getHeaders(context)
expect(headers).toEqual({
'X-Header-1': 'value1',
'X-Header-2': 'value2',
'X-Common': 'provider2' // Higher priority wins
})
})
it('should resolve function header values', async () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Static': 'static',
'X-Dynamic': () => 'dynamic',
'X-Async': async () => 'async-value'
})
}
headerRegistry.registerHeaderProvider(provider)
const context: HeaderProviderContext = {
url: 'https://api.example.com',
method: 'GET'
}
const headers = await headerRegistry.getHeaders(context)
expect(headers).toEqual({
'X-Static': 'static',
'X-Dynamic': 'dynamic',
'X-Async': 'async-value'
})
})
it('should handle async providers', async () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
return { 'X-Async': 'resolved' }
})
}
headerRegistry.registerHeaderProvider(provider)
const context: HeaderProviderContext = {
url: 'https://api.example.com',
method: 'GET'
}
const headers = await headerRegistry.getHeaders(context)
expect(headers).toEqual({ 'X-Async': 'resolved' })
})
it('should apply filters when provided', async () => {
const provider1: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({ 'X-Api': 'api-header' })
}
const provider2: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({ 'X-Other': 'other-header' })
}
// Only apply to API URLs
headerRegistry.registerHeaderProvider(provider1, {
filter: (ctx) => ctx.url.includes('/api/')
})
// Apply to all URLs
headerRegistry.registerHeaderProvider(provider2)
const apiContext: HeaderProviderContext = {
url: 'https://example.com/api/users',
method: 'GET'
}
const otherContext: HeaderProviderContext = {
url: 'https://example.com/assets/image.png',
method: 'GET'
}
const apiHeaders = await headerRegistry.getHeaders(apiContext)
const otherHeaders = await headerRegistry.getHeaders(otherContext)
expect(apiHeaders).toEqual({
'X-Api': 'api-header',
'X-Other': 'other-header'
})
expect(otherHeaders).toEqual({
'X-Other': 'other-header'
})
})
it('should continue with other providers if one fails', async () => {
const provider1: IHeaderProvider = {
provideHeaders: vi.fn().mockRejectedValue(new Error('Provider error'))
}
const provider2: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({ 'X-Header': 'value' })
}
headerRegistry.registerHeaderProvider(provider1)
headerRegistry.registerHeaderProvider(provider2)
const context: HeaderProviderContext = {
url: 'https://api.example.com',
method: 'GET'
}
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const headers = await headerRegistry.getHeaders(context)
expect(headers).toEqual({ 'X-Header': 'value' })
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Error getting headers from provider'),
expect.any(Error)
)
consoleSpy.mockRestore()
})
})
describe('clear', () => {
it('should remove all providers', () => {
const provider1: IHeaderProvider = {
provideHeaders: vi.fn()
}
const provider2: IHeaderProvider = {
provideHeaders: vi.fn()
}
headerRegistry.registerHeaderProvider(provider1)
headerRegistry.registerHeaderProvider(provider2)
expect(headerRegistry.providerCount).toBe(2)
headerRegistry.clear()
expect(headerRegistry.providerCount).toBe(0)
})
})
describe('providerCount', () => {
it('should return the correct count of providers', () => {
expect(headerRegistry.providerCount).toBe(0)
const provider: IHeaderProvider = {
provideHeaders: vi.fn()
}
const reg1 = headerRegistry.registerHeaderProvider(provider)
expect(headerRegistry.providerCount).toBe(1)
const reg2 = headerRegistry.registerHeaderProvider(provider)
expect(headerRegistry.providerCount).toBe(2)
reg1.dispose()
expect(headerRegistry.providerCount).toBe(1)
reg2.dispose()
expect(headerRegistry.providerCount).toBe(0)
})
})
})

View File

@@ -1,289 +0,0 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { headerRegistry } from '@/services/headerRegistry'
import {
createAxiosWithHeaders,
fetchWithHeaders
} from '@/services/networkClientAdapter'
import type { IHeaderProvider } from '@/types/headerTypes'
// Mock axios
vi.mock('axios')
// Mock fetch globally
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
describe('networkClientAdapter', () => {
beforeEach(() => {
vi.clearAllMocks()
headerRegistry.clear()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('createAxiosWithHeaders', () => {
it('should create an axios instance with header injection', async () => {
// Setup mock axios instance
const mockInterceptors = {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
}
const mockAxiosInstance = {
interceptors: mockInterceptors,
get: vi.fn(),
post: vi.fn()
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
// Create instance
createAxiosWithHeaders({ baseURL: 'https://api.example.com' })
// Verify axios.create was called with config
expect(axios.create).toHaveBeenCalledWith({
baseURL: 'https://api.example.com'
})
// Verify interceptor was added
expect(mockInterceptors.request.use).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function)
)
})
it('should inject headers from registry on request', async () => {
// Setup header provider
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Custom-Header': 'custom-value'
})
}
headerRegistry.registerHeaderProvider(provider)
// Setup mock axios
const mockInterceptors = {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
}
const mockAxiosInstance = {
interceptors: mockInterceptors
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
// Create instance
createAxiosWithHeaders()
// Get the interceptor function
const [interceptorFn] = mockInterceptors.request.use.mock.calls[0]
// Test the interceptor
const config = {
url: '/api/test',
method: 'POST',
data: { foo: 'bar' },
headers: {
'Content-Type': 'application/json'
}
}
const result = await interceptorFn(config)
// Verify provider was called with correct context
expect(provider.provideHeaders).toHaveBeenCalledWith({
url: '/api/test',
method: 'POST',
body: { foo: 'bar' },
config
})
// Verify headers were merged
expect(result.headers).toEqual({
'Content-Type': 'application/json',
'X-Custom-Header': 'custom-value'
})
})
it('should handle interceptor errors', async () => {
// Setup mock axios
const mockInterceptors = {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
}
const mockAxiosInstance = {
interceptors: mockInterceptors
}
vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as any)
// Create instance
createAxiosWithHeaders()
// Get the error handler
const [, errorHandler] = mockInterceptors.request.use.mock.calls[0]
// Test error handling
const error = new Error('Request error')
await expect(errorHandler(error)).rejects.toThrow('Request error')
})
})
describe('fetchWithHeaders', () => {
it('should inject headers from registry into fetch requests', async () => {
// Setup header provider
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Api-Key': 'test-key',
'X-Request-ID': '12345'
})
}
headerRegistry.registerHeaderProvider(provider)
// Setup fetch mock
mockFetch.mockResolvedValue(new Response('OK'))
// Make request
await fetchWithHeaders('https://api.example.com/data', {
method: 'GET',
headers: {
Accept: 'application/json'
}
})
// Verify provider was called
expect(provider.provideHeaders).toHaveBeenCalledWith({
url: 'https://api.example.com/data',
method: 'GET',
body: undefined
})
// Verify fetch was called with merged headers
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/data',
expect.objectContaining({
method: 'GET',
headers: expect.any(Headers)
})
)
// Check the headers
const [, init] = mockFetch.mock.calls[0]
const headers = init.headers as Headers
expect(headers.get('Accept')).toBe('application/json')
expect(headers.get('X-Api-Key')).toBe('test-key')
expect(headers.get('X-Request-ID')).toBe('12345')
})
it('should handle URL objects', async () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({})
}
headerRegistry.registerHeaderProvider(provider)
mockFetch.mockResolvedValue(new Response('OK'))
const url = new URL('https://api.example.com/test')
await fetchWithHeaders(url)
expect(provider.provideHeaders).toHaveBeenCalledWith({
url: 'https://api.example.com/test',
method: 'GET',
body: undefined
})
})
it('should handle Request objects', async () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Custom': 'value'
})
}
headerRegistry.registerHeaderProvider(provider)
mockFetch.mockResolvedValue(new Response('OK'))
const request = new Request('https://api.example.com/test', {
method: 'POST',
body: JSON.stringify({ data: 'test' })
})
await fetchWithHeaders(request)
expect(provider.provideHeaders).toHaveBeenCalledWith({
url: 'https://api.example.com/test',
method: 'POST',
body: undefined // init.body is undefined when using Request object
})
// Verify headers were added
const [, init] = mockFetch.mock.calls[0]
const headers = init.headers as Headers
expect(headers.get('X-Custom')).toBe('value')
})
it('should convert header values to strings', async () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Number': 123,
'X-Boolean': true,
'X-String': 'test'
})
}
headerRegistry.registerHeaderProvider(provider)
mockFetch.mockResolvedValue(new Response('OK'))
await fetchWithHeaders('https://api.example.com')
const [, init] = mockFetch.mock.calls[0]
const headers = init.headers as Headers
expect(headers.get('X-Number')).toBe('123')
expect(headers.get('X-Boolean')).toBe('true')
expect(headers.get('X-String')).toBe('test')
})
it('should preserve existing headers and let registry override', async () => {
const provider: IHeaderProvider = {
provideHeaders: vi.fn().mockReturnValue({
'X-Override': 'new-value',
'X-New': 'added'
})
}
headerRegistry.registerHeaderProvider(provider)
mockFetch.mockResolvedValue(new Response('OK'))
await fetchWithHeaders('https://api.example.com', {
headers: {
'X-Override': 'old-value',
'X-Existing': 'keep-me'
}
})
const [, init] = mockFetch.mock.calls[0]
const headers = init.headers as Headers
expect(headers.get('X-Override')).toBe('new-value') // Registry wins
expect(headers.get('X-Existing')).toBe('keep-me')
expect(headers.get('X-New')).toBe('added')
})
})
})

View File

@@ -15,11 +15,6 @@ vi.mock('axios', () => ({
}
}))
// Mock networkClientAdapter to return the same axios instance
vi.mock('@/services/networkClientAdapter', () => ({
createAxiosWithHeaders: vi.fn(() => mockAxiosInstance)
}))
describe('useReleaseService', () => {
let service: ReturnType<typeof useReleaseService>

View File

@@ -1,4 +1,3 @@
import axios from 'axios'
import * as firebaseAuth from 'firebase/auth'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -6,42 +5,6 @@ import * as vuefire from 'vuefire'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
// Hoist the mock to avoid hoisting issues
const mockAxiosInstance = vi.hoisted(() => ({
get: vi.fn().mockResolvedValue({
data: { balance: { credits: 0 } },
status: 200
}),
post: vi.fn().mockResolvedValue({
data: { id: 'test-customer-id' },
status: 201
}),
interceptors: {
request: {
use: vi.fn()
},
response: {
use: vi.fn()
}
}
}))
vi.mock('axios', () => {
return {
default: {
create: vi.fn().mockReturnValue(mockAxiosInstance),
isAxiosError: vi.fn().mockImplementation(() => false)
}
}
})
// Mock networkClientAdapter
vi.mock('@/services/networkClientAdapter', () => ({
createAxiosWithHeaders: vi.fn(() => mockAxiosInstance)
}))
const mockedAxios = vi.mocked(axios)
// Mock fetch
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
@@ -128,18 +91,7 @@ describe('useFirebaseAuthStore', () => {
}
beforeEach(() => {
vi.clearAllMocks()
// Reset axios mock responses to defaults
mockAxiosInstance.get.mockResolvedValue({
data: { balance: { credits: 0 } },
status: 200
})
mockAxiosInstance.post.mockResolvedValue({
data: { id: 'test-customer-id' },
status: 201
})
;(mockedAxios.isAxiosError as any).mockReturnValue(false)
vi.resetAllMocks()
// Mock useFirebaseAuth to return our mock auth object
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any)

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