mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-23 05:47:34 +00:00
Compare commits
20 Commits
feat/wire-
...
graphMutat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea615fb8c | ||
|
|
ceac8f3741 | ||
|
|
b1057f164b | ||
|
|
4a189bdc93 | ||
|
|
f0adb4c9d3 | ||
|
|
d5d0aa52c2 | ||
|
|
69c660b3b7 | ||
|
|
88579c2a40 | ||
|
|
7ab247aa1d | ||
|
|
c78d03dd2c | ||
|
|
65785af348 | ||
|
|
ec4ad5ea92 | ||
|
|
e9ddf29507 | ||
|
|
fdd8564c07 | ||
|
|
d18081a54e | ||
|
|
45cc6ca2b4 | ||
|
|
c303a3f037 | ||
|
|
c90fd18ade | ||
|
|
2ed1704749 | ||
|
|
7d5a4d423e |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
`
|
||||
});
|
||||
|
||||
@@ -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
572
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
15
src/components/custom/button/IconButton.vue
Normal file
15
src/components/custom/button/IconButton.vue
Normal 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>
|
||||
67
src/components/custom/widget/ModelSelector.vue
Normal file
67
src/components/custom/widget/ModelSelector.vue
Normal 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>
|
||||
176
src/components/custom/widget/layout/BaseWidgetLayout.vue
Normal file
176
src/components/custom/widget/layout/BaseWidgetLayout.vue
Normal 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>
|
||||
24
src/components/custom/widget/nav/NavItem.vue
Normal file
24
src/components/custom/widget/nav/NavItem.vue
Normal 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>
|
||||
13
src/components/custom/widget/nav/NavTitle.vue
Normal file
13
src/components/custom/widget/nav/NavTitle.vue
Normal 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>
|
||||
75
src/components/custom/widget/panel/LeftSidePanel.vue
Normal file
75
src/components/custom/widget/panel/LeftSidePanel.vue
Normal 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>
|
||||
12
src/components/custom/widget/panel/PanelHeader.vue
Normal file
12
src/components/custom/widget/panel/PanelHeader.vue
Normal 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>
|
||||
5
src/components/custom/widget/panel/RightSidePanel.vue
Normal file
5
src/components/custom/widget/panel/RightSidePanel.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
29
src/composables/useModelSelectorDialog.ts
Normal file
29
src/composables/useModelSelectorDialog.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import './authHeaders'
|
||||
import './clipspace'
|
||||
import './contextMenuFilter'
|
||||
import './dynamicPrompts'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -107,6 +107,9 @@
|
||||
"Comfy_ContactSupport": {
|
||||
"label": "الاتصال بالدعم"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "إظهار منتقي النماذج (للمطورين)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "تكرار سير العمل الحالي"
|
||||
},
|
||||
|
||||
@@ -840,6 +840,7 @@
|
||||
"Save": "حفظ",
|
||||
"Save As": "حفظ باسم",
|
||||
"Show Keybindings Dialog": "عرض مربع حوار اختصارات لوحة المفاتيح",
|
||||
"Show Model Selector (Dev)": "إظهار منتقي النماذج (للمطورين)",
|
||||
"Show Settings Dialog": "عرض نافذة الإعدادات",
|
||||
"Sign Out": "تسجيل خروج",
|
||||
"Toggle Essential Bottom Panel": "تبديل اللوحة السفلية الأساسية",
|
||||
|
||||
@@ -107,6 +107,9 @@
|
||||
"Comfy_ContactSupport": {
|
||||
"label": "Contact Support"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "Show Model Selector (Dev)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "Duplicate Current Workflow"
|
||||
},
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -107,6 +107,9 @@
|
||||
"Comfy_ContactSupport": {
|
||||
"label": "サポートに連絡"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "モデルセレクターを表示(開発用)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "現在のワークフローを複製"
|
||||
},
|
||||
|
||||
@@ -840,6 +840,7 @@
|
||||
"Save": "保存",
|
||||
"Save As": "名前を付けて保存",
|
||||
"Show Keybindings Dialog": "キーバインドダイアログを表示",
|
||||
"Show Model Selector (Dev)": "モデルセレクターを表示(開発用)",
|
||||
"Show Settings Dialog": "設定ダイアログを表示",
|
||||
"Sign Out": "サインアウト",
|
||||
"Toggle Essential Bottom Panel": "エッセンシャル下部パネルの切り替え",
|
||||
|
||||
@@ -107,6 +107,9 @@
|
||||
"Comfy_ContactSupport": {
|
||||
"label": "지원팀에 문의하기"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "모델 선택기 표시 (개발자용)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "현재 워크플로 복제"
|
||||
},
|
||||
|
||||
@@ -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": "필수 하단 패널 전환",
|
||||
|
||||
@@ -107,6 +107,9 @@
|
||||
"Comfy_ContactSupport": {
|
||||
"label": "Связаться с поддержкой"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "Показать выбор модели (Dev)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "Дублировать текущий рабочий процесс"
|
||||
},
|
||||
|
||||
@@ -840,6 +840,7 @@
|
||||
"Save": "Сохранить",
|
||||
"Save As": "Сохранить как",
|
||||
"Show Keybindings Dialog": "Показать диалог клавиш быстрого доступа",
|
||||
"Show Model Selector (Dev)": "Показать выбор модели (Dev)",
|
||||
"Show Settings Dialog": "Показать диалог настроек",
|
||||
"Sign Out": "Выйти",
|
||||
"Toggle Essential Bottom Panel": "Показать/скрыть основную нижнюю панель",
|
||||
|
||||
@@ -107,6 +107,9 @@
|
||||
"Comfy_ContactSupport": {
|
||||
"label": "聯絡支援"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "顯示模型選擇器(開發)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "複製目前工作流程"
|
||||
},
|
||||
|
||||
@@ -840,6 +840,7 @@
|
||||
"Save": "儲存",
|
||||
"Save As": "另存新檔",
|
||||
"Show Keybindings Dialog": "顯示快捷鍵對話框",
|
||||
"Show Model Selector (Dev)": "顯示模型選擇器(開發用)",
|
||||
"Show Settings Dialog": "顯示設定對話框",
|
||||
"Sign Out": "登出",
|
||||
"Toggle Essential Bottom Panel": "切換基本下方面板",
|
||||
|
||||
@@ -107,6 +107,9 @@
|
||||
"Comfy_ContactSupport": {
|
||||
"label": "联系支持"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "顯示模型選擇器(開發)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "复制当前工作流"
|
||||
},
|
||||
|
||||
@@ -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": "更新前端"
|
||||
},
|
||||
|
||||
@@ -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": "校验工作流"
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
462
src/services/GRAPH_MUTATION_SERVICE_DESIGN.md
Normal file
462
src/services/GRAPH_MUTATION_SERVICE_DESIGN.md
Normal 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
|
||||
161
src/services/IGraphMutationService.ts
Normal file
161
src/services/IGraphMutationService.ts
Normal 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>
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
1152
src/services/graphMutationService.ts
Normal file
1152
src/services/graphMutationService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
2
src/types/custom_components/index.d.ts
vendored
Normal file
2
src/types/custom_components/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './navTypes'
|
||||
export * from './widgetTypes'
|
||||
9
src/types/custom_components/navTypes.ts
Normal file
9
src/types/custom_components/navTypes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface NavItemData {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface NavGroupData {
|
||||
title: string
|
||||
items: NavItemData[]
|
||||
}
|
||||
3
src/types/custom_components/widgetTypes.ts
Normal file
3
src/types/custom_components/widgetTypes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { InjectionKey } from 'vue'
|
||||
|
||||
export const OnCloseKey: InjectionKey<() => void> = Symbol()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
1049
tests-ui/tests/services/graphMutationService.test.ts
Normal file
1049
tests-ui/tests/services/graphMutationService.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user