mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-22 13:27:34 +00:00
Compare commits
25 Commits
docs/updat
...
ry-temp2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e5b281dd | ||
|
|
d83af149b0 | ||
|
|
4899c9d25b | ||
|
|
0bd3c1271d | ||
|
|
6eb91e4aed | ||
|
|
3b3071c975 | ||
|
|
68f0275a83 | ||
|
|
a0d66bb0d7 | ||
|
|
1292ae0f14 | ||
|
|
8da2b304ef | ||
|
|
0950da0b43 | ||
|
|
86e2b1fc61 | ||
|
|
4a612b09ed | ||
|
|
4a3c3d9c97 | ||
|
|
c3c59988f4 | ||
|
|
e6d3e94a34 | ||
|
|
1c0c501105 | ||
|
|
980b727ff8 | ||
|
|
40c47a8e67 | ||
|
|
f0f4313afa | ||
|
|
cb5894a100 | ||
|
|
7649feb47f | ||
|
|
c27edb7e94 | ||
|
|
23e881e220 | ||
|
|
c5c06b6ba8 |
@@ -128,25 +128,7 @@ echo "Last stable release: $LAST_STABLE"
|
||||
|
||||
### Step 4: Analyze Dependency Updates
|
||||
|
||||
1. **Use pnpm's built-in dependency analysis:**
|
||||
```bash
|
||||
# Get outdated dependencies with pnpm
|
||||
pnpm outdated --format table > outdated-deps-${NEW_VERSION}.txt
|
||||
|
||||
# Check for license compliance
|
||||
pnpm licenses ls --json > licenses-${NEW_VERSION}.json
|
||||
|
||||
# Analyze why specific dependencies exist
|
||||
echo "Dependency analysis:" > dep-analysis-${NEW_VERSION}.md
|
||||
MAJOR_DEPS=("vue" "vite" "@vitejs/plugin-vue" "typescript" "pinia")
|
||||
for dep in "${MAJOR_DEPS[@]}"; do
|
||||
echo -e "\n## $dep\n\`\`\`" >> dep-analysis-${NEW_VERSION}.md
|
||||
pnpm why "$dep" >> dep-analysis-${NEW_VERSION}.md || echo "Not found" >> dep-analysis-${NEW_VERSION}.md
|
||||
echo "\`\`\`" >> dep-analysis-${NEW_VERSION}.md
|
||||
done
|
||||
```
|
||||
|
||||
2. **Check for significant dependency updates:**
|
||||
1. **Check significant dependency updates:**
|
||||
```bash
|
||||
# Extract all dependency changes for major version bumps
|
||||
OTHER_DEP_CHANGES=""
|
||||
@@ -218,48 +200,22 @@ echo "Last stable release: $LAST_STABLE"
|
||||
PR data: [contents of prs-${NEW_VERSION}.json]
|
||||
```
|
||||
|
||||
3. **Generate GTM notification using this EXACT Slack-compatible format:**
|
||||
3. **Generate GTM notification:**
|
||||
```bash
|
||||
# Only create file if GTM-worthy features exist:
|
||||
if [ "$GTM_FEATURES_FOUND" = "true" ]; then
|
||||
cat > gtm-summary-${NEW_VERSION}.md << 'EOF'
|
||||
*GTM Summary: ComfyUI Frontend v${NEW_VERSION}*
|
||||
|
||||
_Disclaimer: the below is AI-generated_
|
||||
|
||||
1. *[Feature Title]* (#[PR_NUMBER])
|
||||
* *Author:* @[username]
|
||||
* *Demo:* [Media Link or "No demo available"]
|
||||
* *Why users should care:* [One compelling sentence]
|
||||
* *Key Features:*
|
||||
* [Feature detail 1]
|
||||
* [Feature detail 2]
|
||||
|
||||
2. *[Feature Title]* (#[PR_NUMBER])
|
||||
* *Author:* @[username]
|
||||
* *Demo:* [Media Link]
|
||||
* *Why users should care:* [One compelling sentence]
|
||||
* *Key Features:*
|
||||
* [Feature detail 1]
|
||||
* [Feature detail 2]
|
||||
EOF
|
||||
# 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"
|
||||
else
|
||||
echo "✅ No GTM notification needed for this release"
|
||||
echo "📄 No gtm-summary file created - no marketing-worthy features"
|
||||
fi
|
||||
```
|
||||
|
||||
**CRITICAL Formatting Requirements:**
|
||||
- Use single asterisk (*) for emphasis, NOT double (**)
|
||||
- Use underscore (_) for italics
|
||||
- Use 4 spaces for indentation (not tabs)
|
||||
- Convert author names to @username format (e.g., "John Smith" → "@john")
|
||||
- No section headers (#), no code language specifications
|
||||
- Always include "Disclaimer: the below is AI-generated"
|
||||
- Keep content minimal - no testing instructions, additional sections, etc.
|
||||
|
||||
### Step 6: Version Preview
|
||||
|
||||
**Version Preview:**
|
||||
@@ -272,22 +228,17 @@ echo "Last stable release: $LAST_STABLE"
|
||||
|
||||
### Step 7: Security and Dependency Audit
|
||||
|
||||
1. Run pnpm security audit:
|
||||
1. Run security audit:
|
||||
```bash
|
||||
pnpm audit --audit-level moderate
|
||||
pnpm licenses ls --summary
|
||||
npm audit --audit-level moderate
|
||||
```
|
||||
2. Check for known vulnerabilities in dependencies
|
||||
3. Run comprehensive dependency health check:
|
||||
```bash
|
||||
pnpm doctor
|
||||
```
|
||||
4. Scan for hardcoded secrets or credentials:
|
||||
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"
|
||||
```
|
||||
5. Verify no sensitive data in recent commits
|
||||
6. **SECURITY REVIEW**: Address any critical findings before proceeding?
|
||||
4. Verify no sensitive data in recent commits
|
||||
5. **SECURITY REVIEW**: Address any critical findings before proceeding?
|
||||
|
||||
### Step 8: Pre-Release Testing
|
||||
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
# Setup Repository
|
||||
|
||||
Bootstrap the ComfyUI Frontend monorepo with all necessary dependencies and verification checks.
|
||||
|
||||
## Overview
|
||||
|
||||
This command will:
|
||||
1. Install pnpm package manager (if not present)
|
||||
2. Install all project dependencies
|
||||
3. Verify the project builds successfully
|
||||
4. Run unit tests to ensure functionality
|
||||
5. Start development server to verify frontend boots correctly
|
||||
|
||||
## Prerequisites Check
|
||||
|
||||
First, let's verify the environment:
|
||||
|
||||
```bash
|
||||
# Check Node.js version (should be >= 24)
|
||||
node --version
|
||||
|
||||
# Check if we're in a git repository
|
||||
git status
|
||||
```
|
||||
|
||||
## Step 1: Install pnpm
|
||||
|
||||
```bash
|
||||
# Check if pnpm is already installed
|
||||
pnpm --version 2>/dev/null || {
|
||||
echo "Installing pnpm..."
|
||||
npm install -g pnpm
|
||||
}
|
||||
|
||||
# Verify pnpm installation
|
||||
pnpm --version
|
||||
```
|
||||
|
||||
## Step 2: Install Dependencies
|
||||
|
||||
```bash
|
||||
# Install all dependencies using pnpm
|
||||
echo "Installing project dependencies..."
|
||||
pnpm install
|
||||
|
||||
# Verify node_modules exists and has packages
|
||||
ls -la node_modules | head -5
|
||||
```
|
||||
|
||||
## Step 3: Verify Build
|
||||
|
||||
```bash
|
||||
# Run TypeScript type checking
|
||||
echo "Running TypeScript checks..."
|
||||
pnpm typecheck
|
||||
|
||||
# Build the project
|
||||
echo "Building project..."
|
||||
pnpm build
|
||||
|
||||
# Verify dist folder was created
|
||||
ls -la dist/
|
||||
```
|
||||
|
||||
## Step 4: Run Unit Tests
|
||||
|
||||
```bash
|
||||
# Run unit tests
|
||||
echo "Running unit tests..."
|
||||
pnpm test:unit
|
||||
|
||||
# If tests fail, show the output and stop
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Unit tests failed. Please fix failing tests before continuing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Unit tests passed successfully"
|
||||
```
|
||||
|
||||
## Step 5: Verify Development Server
|
||||
|
||||
```bash
|
||||
# Start development server in background
|
||||
echo "Starting development server..."
|
||||
pnpm dev &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to start (check for port 5173 or similar)
|
||||
echo "Waiting for server to start..."
|
||||
sleep 10
|
||||
|
||||
# Check if server is running
|
||||
if curl -s http://localhost:5173 > /dev/null 2>&1; then
|
||||
echo "✅ Development server started successfully at http://localhost:5173"
|
||||
|
||||
# Kill the background server
|
||||
kill $SERVER_PID
|
||||
wait $SERVER_PID 2>/dev/null
|
||||
else
|
||||
echo "❌ Development server failed to start or is not accessible"
|
||||
kill $SERVER_PID 2>/dev/null
|
||||
wait $SERVER_PID 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Step 6: Final Verification
|
||||
|
||||
```bash
|
||||
# Run linting to ensure code quality
|
||||
echo "Running linter..."
|
||||
pnpm lint
|
||||
|
||||
# Show project status
|
||||
echo ""
|
||||
echo "🎉 Repository setup complete!"
|
||||
echo ""
|
||||
echo "Available commands:"
|
||||
echo " pnpm dev - Start development server"
|
||||
echo " pnpm build - Build for production"
|
||||
echo " pnpm test:unit - Run unit tests"
|
||||
echo " pnpm test:component - Run component tests"
|
||||
echo " pnpm typecheck - Run TypeScript checks"
|
||||
echo " pnpm lint - Run ESLint"
|
||||
echo " pnpm format - Format code with Prettier"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Run 'pnpm dev' to start developing"
|
||||
echo "2. Open http://localhost:5173 in your browser"
|
||||
echo "3. Check README.md for additional setup instructions"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If any step fails:
|
||||
|
||||
1. **pnpm installation fails**: Try using `curl -fsSL https://get.pnpm.io/install.sh | sh -`
|
||||
2. **Dependencies fail to install**: Try clearing cache with `pnpm store prune` and retry
|
||||
3. **Build fails**: Check for TypeScript errors and fix them first
|
||||
4. **Tests fail**: Review test output and fix failing tests
|
||||
5. **Dev server fails**: Check if port 5173 is already in use
|
||||
|
||||
## Manual Verification Steps
|
||||
|
||||
After running the setup, manually verify:
|
||||
|
||||
1. **Dependencies installed**: `ls node_modules | wc -l` should show many packages
|
||||
2. **Build artifacts**: `ls dist/` should show built files
|
||||
3. **Server accessible**: Open http://localhost:5173 in browser
|
||||
4. **Hot reload works**: Edit a file and see changes reflect
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
- Node.js >= 24
|
||||
- Git repository
|
||||
- Internet connection for package downloads
|
||||
- Available ports (typically 5173 for dev server)
|
||||
60
.github/workflows/chromatic.yaml
vendored
60
.github/workflows/chromatic.yaml
vendored
@@ -12,6 +12,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
# Only run for PRs from version-bump-* branches or manual triggers
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'version-bump-')
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -29,6 +32,29 @@ jobs:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Get current time
|
||||
id: current-time
|
||||
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Comment PR - Build Started
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: edumserrano/find-create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-includes: '<!-- STORYBOOK_BUILD_STATUS -->'
|
||||
comment-author: 'github-actions[bot]'
|
||||
edit-mode: append
|
||||
body: |
|
||||
<!-- STORYBOOK_BUILD_STATUS -->
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
🔄 **Building Storybook and running visual tests...**
|
||||
|
||||
⏳ Build started at: ${{ steps.current-time.outputs.time }} UTC
|
||||
|
||||
---
|
||||
*This comment will be updated when the build completes*
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
@@ -55,3 +81,37 @@ jobs:
|
||||
autoAcceptChanges: 'main' # Auto-accept changes on main branch
|
||||
exitOnceUploaded: true # Don't wait for UI tests to complete
|
||||
|
||||
- name: Get completion time
|
||||
id: completion-time
|
||||
if: always()
|
||||
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Comment PR - Build Complete
|
||||
if: github.event_name == 'pull_request' && always()
|
||||
continue-on-error: true
|
||||
uses: edumserrano/find-create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-includes: '<!-- STORYBOOK_BUILD_STATUS -->'
|
||||
comment-author: 'github-actions[bot]'
|
||||
edit-mode: replace
|
||||
body: |
|
||||
<!-- STORYBOOK_BUILD_STATUS -->
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
${{ steps.chromatic.outcome == 'success' && '✅' || '❌' }} **${{ steps.chromatic.outcome == 'success' && 'Build completed successfully!' || 'Build failed!' }}**
|
||||
|
||||
⏰ Completed at: ${{ steps.completion-time.outputs.time }} UTC
|
||||
|
||||
### 📊 Build Summary
|
||||
- **Components**: ${{ steps.chromatic.outputs.componentCount || '0' }}
|
||||
- **Stories**: ${{ steps.chromatic.outputs.testCount || '0' }}
|
||||
- **Visual changes**: ${{ steps.chromatic.outputs.changeCount || '0' }}
|
||||
- **Errors**: ${{ steps.chromatic.outputs.errorCount || '0' }}
|
||||
|
||||
### 🔗 Links
|
||||
${{ steps.chromatic.outputs.buildUrl && format('- [📸 View Chromatic Build]({0})', steps.chromatic.outputs.buildUrl) || '' }}
|
||||
${{ steps.chromatic.outputs.storybookUrl && format('- [📖 Preview Storybook]({0})', steps.chromatic.outputs.storybookUrl) || '' }}
|
||||
|
||||
---
|
||||
${{ steps.chromatic.outcome == 'success' && '🎉 Your Storybook is ready for review!' || '⚠️ Please check the workflow logs for error details.' }}
|
||||
|
||||
9
.github/workflows/i18n.yaml
vendored
9
.github/workflows/i18n.yaml
vendored
@@ -25,13 +25,6 @@ jobs:
|
||||
key: i18n-tools-cache-${{ runner.os }}-${{ hashFiles('ComfyUI_frontend/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
i18n-tools-cache-${{ runner.os }}-
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('ComfyUI_frontend/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
@@ -41,7 +34,7 @@ jobs:
|
||||
run: pnpm dev:electron &
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Update en.json
|
||||
run: pnpm collect-i18n
|
||||
run: pnpm collect-i18n -- scripts/collect-i18n-general.ts
|
||||
env:
|
||||
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
163
.github/workflows/pr-playwright-comment.yaml
vendored
163
.github/workflows/pr-playwright-comment.yaml
vendored
@@ -1,163 +0,0 @@
|
||||
name: PR Playwright Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Tests CI']
|
||||
types: [requested, completed]
|
||||
|
||||
env:
|
||||
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
|
||||
|
||||
jobs:
|
||||
comment-summary:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Get PR number
|
||||
id: pr
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: pullRequests } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
|
||||
});
|
||||
|
||||
if (pullRequests.length === 0) {
|
||||
console.log('No open PR found for this branch');
|
||||
return null;
|
||||
}
|
||||
|
||||
return pullRequests[0].number;
|
||||
|
||||
- name: Log when no PR found
|
||||
if: steps.pr.outputs.result == 'null'
|
||||
run: |
|
||||
echo "⚠️ No open PR found for branch: ${{ github.event.workflow_run.head_branch }}"
|
||||
echo "Workflow run ID: ${{ github.event.workflow_run.id }}"
|
||||
echo "Repository: ${{ github.event.workflow_run.repository.full_name }}"
|
||||
echo "Event: ${{ github.event.workflow_run.event }}"
|
||||
|
||||
- name: Generate comment body for start
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
|
||||
id: comment-body-start
|
||||
run: |
|
||||
echo "<!-- PLAYWRIGHT_TEST_STATUS -->" > comment.md
|
||||
echo "## 🎭 Playwright Test Results" >> comment.md
|
||||
echo "" >> comment.md
|
||||
echo "<img alt='comfy-loading-gif' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px' style='vertical-align: middle; margin-right: 4px;' /> **Tests are starting...** " >> comment.md
|
||||
echo "" >> comment.md
|
||||
echo "⏰ Started at: ${{ steps.completion-time.outputs.time }} UTC" >> comment.md
|
||||
echo "" >> comment.md
|
||||
echo "### 🚀 Running Tests" >> comment.md
|
||||
echo "- 🧪 **chromium**: Running tests..." >> comment.md
|
||||
echo "- 🧪 **chromium-0.5x**: Running tests..." >> comment.md
|
||||
echo "- 🧪 **chromium-2x**: Running tests..." >> comment.md
|
||||
echo "- 🧪 **mobile-chrome**: Running tests..." >> comment.md
|
||||
echo "" >> comment.md
|
||||
echo "---" >> comment.md
|
||||
echo "⏱️ Please wait while tests are running across all browsers..." >> comment.md
|
||||
|
||||
- name: Download all deployment info
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
pattern: deployment-info-*
|
||||
merge-multiple: true
|
||||
path: deployment-info
|
||||
|
||||
- name: Get completion time
|
||||
id: completion-time
|
||||
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate comment body for completion
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
id: comment-body-completed
|
||||
run: |
|
||||
echo "<!-- PLAYWRIGHT_TEST_STATUS -->" > comment.md
|
||||
echo "## 🎭 Playwright Test Results" >> comment.md
|
||||
echo "" >> comment.md
|
||||
|
||||
# Check if all tests passed
|
||||
ALL_PASSED=true
|
||||
for file in deployment-info/*.txt; do
|
||||
if [ -f "$file" ]; then
|
||||
browser=$(basename "$file" .txt)
|
||||
info=$(cat "$file")
|
||||
exit_code=$(echo "$info" | cut -d'|' -f2)
|
||||
if [ "$exit_code" != "0" ]; then
|
||||
ALL_PASSED=false
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_PASSED" = "true" ]; then
|
||||
echo "✅ **All tests passed across all browsers!**" >> comment.md
|
||||
else
|
||||
echo "❌ **Some tests failed!**" >> comment.md
|
||||
fi
|
||||
|
||||
echo "" >> comment.md
|
||||
echo "⏰ Completed at: ${{ steps.completion-time.outputs.time }} UTC" >> comment.md
|
||||
echo "" >> comment.md
|
||||
echo "### 📊 Test Reports by Browser" >> comment.md
|
||||
|
||||
for file in deployment-info/*.txt; do
|
||||
if [ -f "$file" ]; then
|
||||
browser=$(basename "$file" .txt)
|
||||
info=$(cat "$file")
|
||||
exit_code=$(echo "$info" | cut -d'|' -f2)
|
||||
url=$(echo "$info" | cut -d'|' -f3)
|
||||
|
||||
# Validate URLs before using them in comments
|
||||
sanitized_url=$(echo "$url" | grep -E '^https://[a-z0-9.-]+\.pages\.dev(/.*)?$' || echo "INVALID_URL")
|
||||
if [ "$sanitized_url" = "INVALID_URL" ]; then
|
||||
echo "Invalid deployment URL detected: $url"
|
||||
url="#" # Use safe fallback
|
||||
fi
|
||||
|
||||
if [ "$exit_code" = "0" ]; then
|
||||
status="✅"
|
||||
else
|
||||
status="❌"
|
||||
fi
|
||||
|
||||
echo "- $status **$browser**: [View Report]($url)" >> comment.md
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> comment.md
|
||||
echo "---" >> comment.md
|
||||
if [ "$ALL_PASSED" = "true" ]; then
|
||||
echo "🎉 Your tests are passing across all browsers!" >> comment.md
|
||||
else
|
||||
echo "⚠️ Please check the test reports for details on failures." >> comment.md
|
||||
fi
|
||||
|
||||
- name: Comment PR - Tests Started
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
|
||||
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
|
||||
with:
|
||||
issue-number: ${{ steps.pr.outputs.result }}
|
||||
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
|
||||
comment-author: 'github-actions[bot]'
|
||||
edit-mode: replace
|
||||
body-path: comment.md
|
||||
|
||||
- name: Comment PR - Tests Complete
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
|
||||
with:
|
||||
issue-number: ${{ steps.pr.outputs.result }}
|
||||
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
|
||||
comment-author: 'github-actions[bot]'
|
||||
edit-mode: replace
|
||||
body-path: comment.md
|
||||
101
.github/workflows/pr-playwright-deploy.yaml
vendored
101
.github/workflows/pr-playwright-deploy.yaml
vendored
@@ -1,101 +0,0 @@
|
||||
name: PR Playwright Deploy
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Tests CI"]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
deploy-reports:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request'
|
||||
permissions:
|
||||
actions: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [chromium, chromium-2x, chromium-0.5x, mobile-chrome]
|
||||
steps:
|
||||
- name: Get PR info
|
||||
id: pr-info
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: pullRequests } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
|
||||
});
|
||||
|
||||
if (pullRequests.length === 0) {
|
||||
console.log('No open PR found for this branch');
|
||||
return { number: null, sanitized_branch: null };
|
||||
}
|
||||
|
||||
const pr = pullRequests[0];
|
||||
const branchName = context.payload.workflow_run.head_branch;
|
||||
const sanitizedBranch = branchName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-').replace(/^-|-$/g, '');
|
||||
|
||||
return {
|
||||
number: pr.number,
|
||||
sanitized_branch: sanitizedBranch
|
||||
};
|
||||
|
||||
- name: Set project name
|
||||
if: fromJSON(steps.pr-info.outputs.result).number != null
|
||||
id: project-name
|
||||
run: |
|
||||
if [ "${{ matrix.browser }}" = "chromium-0.5x" ]; then
|
||||
echo "name=comfyui-playwright-chromium-0-5x" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "name=comfyui-playwright-${{ matrix.browser }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "branch=${{ fromJSON(steps.pr-info.outputs.result).sanitized_branch }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download playwright report
|
||||
if: fromJSON(steps.pr-info.outputs.result).number != null
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
name: playwright-report-${{ matrix.browser }}
|
||||
path: playwright-report
|
||||
|
||||
- name: Install Wrangler
|
||||
if: fromJSON(steps.pr-info.outputs.result).number != null
|
||||
run: npm install -g wrangler
|
||||
|
||||
- name: Deploy to Cloudflare Pages (${{ matrix.browser }})
|
||||
if: fromJSON(steps.pr-info.outputs.result).number != null
|
||||
id: cloudflare-deploy
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Retry logic for wrangler deploy (3 attempts)
|
||||
RETRY_COUNT=0
|
||||
MAX_RETRIES=3
|
||||
SUCCESS=false
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ $SUCCESS = false ]; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "Deployment attempt $RETRY_COUNT of $MAX_RETRIES..."
|
||||
|
||||
if npx wrangler pages deploy playwright-report --project-name=${{ steps.project-name.outputs.name }} --branch=${{ steps.project-name.outputs.branch }}; then
|
||||
SUCCESS=true
|
||||
echo "Deployment successful on attempt $RETRY_COUNT"
|
||||
else
|
||||
echo "Deployment failed on attempt $RETRY_COUNT"
|
||||
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
|
||||
echo "Retrying in 10 seconds..."
|
||||
sleep 10
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $SUCCESS = false ]; then
|
||||
echo "All deployment attempts failed"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
126
.github/workflows/pr-storybook-comment.yaml
vendored
126
.github/workflows/pr-storybook-comment.yaml
vendored
@@ -1,126 +0,0 @@
|
||||
name: PR Storybook Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Chromatic']
|
||||
types: [requested, completed]
|
||||
|
||||
jobs:
|
||||
comment-storybook:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
&& github.event.workflow_run.event == 'pull_request'
|
||||
&& startsWith(github.event.workflow_run.head_branch, 'version-bump-')
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Get PR number
|
||||
id: pr
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: pullRequests } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
head: `${context.repo.owner}:${context.payload.workflow_run.head_branch}`,
|
||||
});
|
||||
|
||||
if (pullRequests.length === 0) {
|
||||
console.log('No open PR found for this branch');
|
||||
return null;
|
||||
}
|
||||
|
||||
return pullRequests[0].number;
|
||||
|
||||
- name: Log when no PR found
|
||||
if: steps.pr.outputs.result == 'null'
|
||||
run: |
|
||||
echo "⚠️ No open PR found for branch: ${{ github.event.workflow_run.head_branch }}"
|
||||
echo "Workflow run ID: ${{ github.event.workflow_run.id }}"
|
||||
echo "Repository: ${{ github.event.workflow_run.repository.full_name }}"
|
||||
echo "Event: ${{ github.event.workflow_run.event }}"
|
||||
|
||||
- name: Get workflow run details
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
id: workflow-run
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const run = await github.rest.actions.getWorkflowRun({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
|
||||
return {
|
||||
conclusion: run.data.conclusion,
|
||||
html_url: run.data.html_url
|
||||
};
|
||||
|
||||
- name: Get completion time
|
||||
id: completion-time
|
||||
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Comment PR - Storybook Started
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
|
||||
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
|
||||
with:
|
||||
issue-number: ${{ steps.pr.outputs.result }}
|
||||
body-includes: '<!-- STORYBOOK_BUILD_STATUS -->'
|
||||
comment-author: 'github-actions[bot]'
|
||||
edit-mode: replace
|
||||
body: |
|
||||
<!-- STORYBOOK_BUILD_STATUS -->
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
<img alt='comfy-loading-gif' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px' style='vertical-align: middle; margin-right: 4px;' /> **Build is starting...**
|
||||
|
||||
⏰ Started at: ${{ steps.completion-time.outputs.time }} UTC
|
||||
|
||||
### 🚀 Building Storybook
|
||||
- 📦 Installing dependencies...
|
||||
- 🔧 Building Storybook components...
|
||||
- 🎨 Running Chromatic visual tests...
|
||||
|
||||
---
|
||||
⏱️ Please wait while the Storybook build is in progress...
|
||||
|
||||
- name: Comment PR - Storybook Complete
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
uses: edumserrano/find-create-or-update-comment@82880b65c8a3a6e4c70aa05a204995b6c9696f53 # v3.0.0
|
||||
with:
|
||||
issue-number: ${{ steps.pr.outputs.result }}
|
||||
body-includes: '<!-- STORYBOOK_BUILD_STATUS -->'
|
||||
comment-author: 'github-actions[bot]'
|
||||
edit-mode: replace
|
||||
body: |
|
||||
<!-- STORYBOOK_BUILD_STATUS -->
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
${{
|
||||
fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && '✅'
|
||||
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'skipped' && '⏭️'
|
||||
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'cancelled' && '🚫'
|
||||
|| '❌'
|
||||
}} **${{
|
||||
fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && 'Build completed successfully!'
|
||||
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'skipped' && 'Build skipped.'
|
||||
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'cancelled' && 'Build cancelled.'
|
||||
|| 'Build failed!'
|
||||
}}**
|
||||
|
||||
⏰ Completed at: ${{ steps.completion-time.outputs.time }} UTC
|
||||
|
||||
### 🔗 Links
|
||||
- [📊 View Workflow Run](${{ fromJSON(steps.workflow-run.outputs.result).html_url }})
|
||||
|
||||
---
|
||||
${{
|
||||
fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && '🎉 Your Storybook is ready for review!'
|
||||
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'skipped' && 'ℹ️ Chromatic was skipped for this PR.'
|
||||
|| fromJSON(steps.workflow-run.outputs.result).conclusion == 'cancelled' && 'ℹ️ The Chromatic run was cancelled.'
|
||||
|| '⚠️ Please check the workflow logs for error details.'
|
||||
}}
|
||||
7
.github/workflows/test-browser-exp.yaml
vendored
7
.github/workflows/test-browser-exp.yaml
vendored
@@ -11,13 +11,6 @@ jobs:
|
||||
if: github.event.label.name == 'New Browser Test Expectations'
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('ComfyUI_frontend/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
244
.github/workflows/test-ui.yaml
vendored
244
.github/workflows/test-ui.yaml
vendored
@@ -7,11 +7,15 @@ on:
|
||||
branches-ignore:
|
||||
[wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*]
|
||||
|
||||
env:
|
||||
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cache-key: ${{ steps.cache-key.outputs.key }}
|
||||
sanitized-branch: ${{ steps.branch-info.outputs.sanitized }}
|
||||
steps:
|
||||
- name: Checkout ComfyUI
|
||||
uses: actions/checkout@v4
|
||||
@@ -44,6 +48,29 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'ComfyUI_frontend/pnpm-lock.yaml'
|
||||
|
||||
- name: Get current time
|
||||
id: current-time
|
||||
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Comment PR - Tests Started
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: edumserrano/find-create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
|
||||
comment-author: 'github-actions[bot]'
|
||||
edit-mode: append
|
||||
body: |
|
||||
<!-- PLAYWRIGHT_TEST_STATUS -->
|
||||
|
||||
---
|
||||
|
||||
<img alt='claude-loading-gif' src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />
|
||||
<bold>[${{ steps.current-time.outputs.time }} UTC] Preparing browser tests across multiple browsers...</bold>
|
||||
|
||||
---
|
||||
*This comment will be updated when tests complete*
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
@@ -67,6 +94,14 @@ jobs:
|
||||
id: cache-key
|
||||
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate sanitized branch name
|
||||
id: branch-info
|
||||
run: |
|
||||
# Get branch name and sanitize it for Cloudflare branch names
|
||||
BRANCH_NAME="${{ github.head_ref || github.ref_name }}"
|
||||
SANITIZED_BRANCH=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
echo "sanitized=${SANITIZED_BRANCH}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Save cache
|
||||
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
with:
|
||||
@@ -79,6 +114,8 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -107,6 +144,32 @@ jobs:
|
||||
python-version: '3.10'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Get current time
|
||||
id: current-time
|
||||
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set project name
|
||||
id: project-name
|
||||
run: |
|
||||
if [ "${{ matrix.browser }}" = "chromium-0.5x" ]; then
|
||||
echo "name=comfyui-playwright-chromium-0-5x" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "name=comfyui-playwright-${{ matrix.browser }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "branch=${{ needs.setup.outputs.sanitized-branch }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Comment PR - Browser Test Started
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: edumserrano/find-create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
|
||||
comment-author: 'github-actions[bot]'
|
||||
edit-mode: append
|
||||
body: |
|
||||
<img alt='claude-loading-gif' src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />
|
||||
<bold>${{ matrix.browser }}</bold>: Running tests...
|
||||
|
||||
- name: Install requirements
|
||||
run: |
|
||||
@@ -135,6 +198,9 @@ jobs:
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Install Wrangler
|
||||
run: pnpm install -g wrangler
|
||||
|
||||
- name: Run Playwright tests (${{ matrix.browser }})
|
||||
id: playwright
|
||||
run: npx playwright test --project=${{ matrix.browser }} --reporter=html
|
||||
@@ -147,3 +213,181 @@ jobs:
|
||||
path: ComfyUI_frontend/playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- name: Deploy to Cloudflare Pages (${{ matrix.browser }})
|
||||
id: cloudflare-deploy
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Retry logic for wrangler deploy (3 attempts)
|
||||
RETRY_COUNT=0
|
||||
MAX_RETRIES=3
|
||||
SUCCESS=false
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ $SUCCESS = false ]; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "Deployment attempt $RETRY_COUNT of $MAX_RETRIES..."
|
||||
|
||||
if npx wrangler pages deploy ComfyUI_frontend/playwright-report --project-name=${{ steps.project-name.outputs.name }} --branch=${{ steps.project-name.outputs.branch }}; then
|
||||
SUCCESS=true
|
||||
echo "Deployment successful on attempt $RETRY_COUNT"
|
||||
else
|
||||
echo "Deployment failed on attempt $RETRY_COUNT"
|
||||
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
|
||||
echo "Retrying in 10 seconds..."
|
||||
sleep 10
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $SUCCESS = false ]; then
|
||||
echo "All deployment attempts failed"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
- name: Save deployment info for summary
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p deployment-info
|
||||
# Use step conclusion to determine test result
|
||||
if [ "${{ steps.playwright.conclusion }}" = "success" ]; then
|
||||
EXIT_CODE="0"
|
||||
else
|
||||
EXIT_CODE="1"
|
||||
fi
|
||||
DEPLOYMENT_URL="${{ steps.cloudflare-deploy.outputs.deployment-url || steps.cloudflare-deploy.outputs.url || format('https://{0}.{1}.pages.dev', steps.project-name.outputs.branch, steps.project-name.outputs.name) }}"
|
||||
echo "${{ matrix.browser }}|${EXIT_CODE}|${DEPLOYMENT_URL}" > deployment-info/${{ matrix.browser }}.txt
|
||||
|
||||
- name: Upload deployment info
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: deployment-info-${{ matrix.browser }}
|
||||
path: deployment-info/
|
||||
retention-days: 1
|
||||
|
||||
- name: Get completion time
|
||||
id: completion-time
|
||||
if: always()
|
||||
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Comment PR - Browser Test Complete
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: edumserrano/find-create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
|
||||
comment-author: 'github-actions[bot]'
|
||||
edit-mode: append
|
||||
body: |
|
||||
${{ steps.playwright.conclusion == 'success' && '✅' || '❌' }} **${{ matrix.browser }}**: ${{ steps.playwright.conclusion == 'success' && 'Tests passed!' || 'Tests failed!' }} [View Report](${{ steps.cloudflare-deploy.outputs.deployment-url || format('https://{0}.{1}.pages.dev', steps.project-name.outputs.branch, steps.project-name.outputs.name) }})
|
||||
|
||||
comment-summary:
|
||||
needs: playwright-tests
|
||||
runs-on: ubuntu-latest
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Download all deployment info
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: deployment-info-*
|
||||
merge-multiple: true
|
||||
path: deployment-info
|
||||
|
||||
- name: Get completion time
|
||||
id: completion-time
|
||||
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate comment body
|
||||
id: comment-body
|
||||
run: |
|
||||
echo "<!-- PLAYWRIGHT_TEST_STATUS -->" > comment.md
|
||||
echo "## 🎭 Playwright Test Results" >> comment.md
|
||||
echo "" >> comment.md
|
||||
|
||||
# Check if all tests passed
|
||||
ALL_PASSED=true
|
||||
for file in deployment-info/*.txt; do
|
||||
if [ -f "$file" ]; then
|
||||
browser=$(basename "$file" .txt)
|
||||
info=$(cat "$file")
|
||||
exit_code=$(echo "$info" | cut -d'|' -f2)
|
||||
if [ "$exit_code" != "0" ]; then
|
||||
ALL_PASSED=false
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_PASSED" = "true" ]; then
|
||||
echo "✅ **All tests passed across all browsers!**" >> comment.md
|
||||
else
|
||||
echo "❌ **Some tests failed!**" >> comment.md
|
||||
fi
|
||||
|
||||
echo "" >> comment.md
|
||||
echo "⏰ Completed at: ${{ steps.completion-time.outputs.time }} UTC" >> comment.md
|
||||
echo "" >> comment.md
|
||||
echo "### 📊 Test Reports by Browser" >> comment.md
|
||||
|
||||
for file in deployment-info/*.txt; do
|
||||
if [ -f "$file" ]; then
|
||||
browser=$(basename "$file" .txt)
|
||||
info=$(cat "$file")
|
||||
exit_code=$(echo "$info" | cut -d'|' -f2)
|
||||
url=$(echo "$info" | cut -d'|' -f3)
|
||||
|
||||
if [ "$exit_code" = "0" ]; then
|
||||
status="✅"
|
||||
else
|
||||
status="❌"
|
||||
fi
|
||||
|
||||
echo "- $status **$browser**: [View Report]($url)" >> comment.md
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> comment.md
|
||||
echo "---" >> comment.md
|
||||
if [ "$ALL_PASSED" = "true" ]; then
|
||||
echo "🎉 Your tests are passing across all browsers!" >> comment.md
|
||||
else
|
||||
echo "⚠️ Please check the test reports for details on failures." >> comment.md
|
||||
fi
|
||||
|
||||
- name: Comment PR - Tests Complete
|
||||
continue-on-error: true
|
||||
uses: edumserrano/find-create-or-update-comment@v3
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-includes: '<!-- PLAYWRIGHT_TEST_STATUS -->'
|
||||
comment-author: 'github-actions[bot]'
|
||||
edit-mode: replace
|
||||
body-path: comment.md
|
||||
|
||||
- name: Check test results and fail if needed
|
||||
run: |
|
||||
# Check if all tests passed and fail the job if not
|
||||
ALL_PASSED=true
|
||||
for file in deployment-info/*.txt; do
|
||||
if [ -f "$file" ]; then
|
||||
info=$(cat "$file")
|
||||
exit_code=$(echo "$info" | cut -d'|' -f2)
|
||||
if [ "$exit_code" != "0" ]; then
|
||||
ALL_PASSED=false
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_PASSED" = "false" ]; then
|
||||
echo "❌ Tests failed in one or more browsers. Failing the CI job."
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All tests passed across all browsers!"
|
||||
fi
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -22,7 +22,6 @@ dist-ssr
|
||||
*.local
|
||||
# Claude configuration
|
||||
.claude/*.local.json
|
||||
CLAUDE.local.md
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -77,12 +76,3 @@ vite.config.mts.timestamp-*.mjs
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
|
||||
|
||||
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
.cursor/rules/nx-rules.mdc
|
||||
.github/instructions/nx.instructions.md
|
||||
vite.config.*.timestamp*
|
||||
vitest.config.*.timestamp*
|
||||
|
||||
@@ -4,26 +4,17 @@
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Light theme default - with explicit color to override media queries */
|
||||
body:not(.dark-theme) {
|
||||
background-color: #fff !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
/* Override browser dark mode preference for light theme */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body:not(.dark-theme) {
|
||||
color: #000 !important;
|
||||
--fg-color: #000 !important;
|
||||
--bg-color: #fff !important;
|
||||
}
|
||||
/* Light theme default */
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Dark theme styles */
|
||||
body.dark-theme,
|
||||
.dark-theme body {
|
||||
background-color: #202020;
|
||||
color: #fff;
|
||||
background-color: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
/* Ensure Storybook canvas follows theme */
|
||||
@@ -33,33 +24,11 @@
|
||||
|
||||
.dark-theme .sb-show-main,
|
||||
.dark-theme .docs-story {
|
||||
background-color: #202020 !important;
|
||||
background-color: #0a0a0a !important;
|
||||
}
|
||||
|
||||
/* CSS Variables for theme consistency */
|
||||
body:not(.dark-theme) {
|
||||
--fg-color: #000;
|
||||
--bg-color: #fff;
|
||||
--content-bg: #e0e0e0;
|
||||
--content-fg: #000;
|
||||
--content-hover-bg: #adadad;
|
||||
--content-hover-fg: #000;
|
||||
}
|
||||
|
||||
body.dark-theme {
|
||||
--fg-color: #fff;
|
||||
--bg-color: #202020;
|
||||
--content-bg: #4e4e4e;
|
||||
--content-fg: #fff;
|
||||
--content-hover-bg: #222;
|
||||
--content-hover-fg: #fff;
|
||||
}
|
||||
|
||||
/* Override Storybook's problematic & selector styles */
|
||||
/* Reset only the specific properties that Storybook injects */
|
||||
#storybook-root li+li,
|
||||
#storybook-docs li+li {
|
||||
margin: inherit;
|
||||
padding: inherit;
|
||||
/* Fix for Storybook controls panel in dark mode */
|
||||
.dark-theme .docblock-argstable-body {
|
||||
color: #e5e5e5;
|
||||
}
|
||||
</style>
|
||||
50
CLAUDE.md
50
CLAUDE.md
@@ -1,52 +1,22 @@
|
||||
# ComfyUI Frontend Project Guidelines
|
||||
|
||||
## Repository Setup
|
||||
|
||||
For first-time setup, use the Claude command:
|
||||
```
|
||||
/setup_repo
|
||||
```
|
||||
This bootstraps the monorepo with dependencies, builds, tests, and dev server verification.
|
||||
|
||||
**Prerequisites:** Node.js >= 24, Git repository, available ports (5173, 6006)
|
||||
|
||||
## Quick Commands
|
||||
|
||||
- `pnpm`: See all available commands
|
||||
- `pnpm dev`: Start development server (port 5173, via nx)
|
||||
- `pnpm typecheck`: Type checking
|
||||
- `pnpm build`: Build for production (via nx)
|
||||
- `pnpm lint`: Linting (via nx)
|
||||
- `pnpm lint`: Linting
|
||||
- `pnpm format`: Prettier formatting
|
||||
- `pnpm test:component`: Run component tests with browser environment
|
||||
- `pnpm test:unit`: Run all unit tests
|
||||
- `pnpm test:browser`: Run E2E tests via Playwright
|
||||
- `pnpm test:unit -- tests-ui/tests/example.test.ts`: Run single test file
|
||||
- `pnpm storybook`: Start Storybook development server (port 6006)
|
||||
- `pnpm knip`: Detect unused code and dependencies
|
||||
|
||||
## Monorepo Architecture
|
||||
|
||||
The project now uses **Nx** for build orchestration and task management:
|
||||
|
||||
- **Task Orchestration**: Commands like `dev`, `build`, `lint`, and `test:browser` run via Nx
|
||||
- **Caching**: Nx provides intelligent caching for faster rebuilds
|
||||
- **Configuration**: Managed through `nx.json` with plugins for ESLint, Storybook, Vite, and Playwright
|
||||
- **Dependencies**: Nx handles dependency graph analysis and parallel execution
|
||||
|
||||
Key Nx features:
|
||||
- Build target caching and incremental builds
|
||||
- Parallel task execution across the monorepo
|
||||
- Plugin-based architecture for different tools
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **First-time setup**: Run `/setup_repo` Claude command
|
||||
2. Make code changes
|
||||
3. Run tests (see subdirectory CLAUDE.md files)
|
||||
4. Run typecheck, lint, format
|
||||
5. Check README updates
|
||||
6. Consider docs.comfy.org updates
|
||||
1. Make code changes
|
||||
2. Run tests (see subdirectory CLAUDE.md files)
|
||||
3. Run typecheck, lint, format
|
||||
4. Check README updates
|
||||
5. Consider docs.comfy.org updates
|
||||
|
||||
## Git Conventions
|
||||
|
||||
@@ -82,14 +52,6 @@ When referencing Comfy-Org repos:
|
||||
2. Use GitHub API for branches/PRs/metadata
|
||||
3. Curl GitHub website if needed
|
||||
|
||||
## Settings and Feature Flags
|
||||
|
||||
Extensive capabilities to adding settings and feature flags. Read documentation.
|
||||
|
||||
**Documentation:**
|
||||
- Settings system: `docs/SETTINGS.md`
|
||||
- Feature flags system: `docs/FEATURE_FLAGS.md`
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- NEVER use `any` type - use proper TypeScript types
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
|
||||
# Mask Editor extension
|
||||
/src/extensions/core/maskeditor.ts @brucew4yn3rp @trsommer @Comfy-Org/comfy_frontend_devs
|
||||
/src/extensions/core/maskeditor.ts @trsommer @Comfy-Org/comfy_frontend_devs
|
||||
|
||||
@@ -17,7 +17,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
|
||||
### Prerequisites & Technology Stack
|
||||
|
||||
- **Required Software**:
|
||||
- Node.js (v18 or later to build; v24 for vite dev server) and pnpm
|
||||
- Node.js (v16 or later; v24 strongly recommended) and pnpm
|
||||
- Git for version control
|
||||
- A running ComfyUI backend instance
|
||||
|
||||
|
||||
@@ -34,17 +34,23 @@ const getContentType = (filename: string, fileType: OutputFileType) => {
|
||||
}
|
||||
|
||||
const setQueueIndex = (task: TaskItem) => {
|
||||
task.prompt[0] = TaskHistory.queueIndex++
|
||||
task.prompt.priority = TaskHistory.queueIndex++
|
||||
}
|
||||
|
||||
const setPromptId = (task: TaskItem) => {
|
||||
task.prompt[1] = uuidv4()
|
||||
if (!task.prompt.prompt_id || task.prompt.prompt_id === 'prompt-id') {
|
||||
task.prompt.prompt_id = uuidv4()
|
||||
}
|
||||
}
|
||||
|
||||
export default class TaskHistory {
|
||||
static queueIndex = 0
|
||||
static readonly defaultTask: Readonly<HistoryTaskItem> = {
|
||||
prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []],
|
||||
prompt: {
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: uuidv4() }
|
||||
},
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
@@ -66,10 +72,37 @@ export default class TaskHistory {
|
||||
)
|
||||
|
||||
private async handleGetHistory(route: Route) {
|
||||
const url = route.request().url()
|
||||
|
||||
// Handle history_v2/:prompt_id endpoint
|
||||
const promptIdMatch = url.match(/history_v2\/([^?]+)/)
|
||||
if (promptIdMatch) {
|
||||
const promptId = promptIdMatch[1]
|
||||
const task = this.tasks.find((t) => t.prompt.prompt_id === promptId)
|
||||
const response: Record<string, any> = {}
|
||||
if (task) {
|
||||
response[promptId] = task
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
// Handle history_v2 list endpoint
|
||||
// Convert HistoryTaskItem to RawHistoryItem format expected by API
|
||||
const rawHistoryItems = this.tasks.map((task) => ({
|
||||
prompt_id: task.prompt.prompt_id,
|
||||
prompt: task.prompt,
|
||||
status: task.status,
|
||||
outputs: task.outputs,
|
||||
...(task.meta && { meta: task.meta })
|
||||
}))
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.tasks)
|
||||
body: JSON.stringify({ history: rawHistoryItems })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,7 +126,7 @@ export default class TaskHistory {
|
||||
|
||||
async setupRoutes() {
|
||||
return this.comfyPage.page.route(
|
||||
/.*\/api\/(view|history)(\?.*)?$/,
|
||||
/.*\/api\/(view|history_v2)(\/[^?]*)?(\?.*)?$/,
|
||||
async (route) => {
|
||||
const request = route.request()
|
||||
const method = request.method()
|
||||
|
||||
131
browser_tests/tests/historyApi.spec.ts
Normal file
131
browser_tests/tests/historyApi.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('History API v2', () => {
|
||||
const TEST_PROMPT_ID = 'test-prompt-id'
|
||||
const TEST_CLIENT_ID = 'test-client'
|
||||
|
||||
test('Can fetch history with new v2 format', async ({ comfyPage }) => {
|
||||
// Set up mocked history with tasks
|
||||
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
|
||||
|
||||
// Verify history_v2 API response format
|
||||
const result = await comfyPage.page.evaluate(async () => {
|
||||
try {
|
||||
const response = await window['app'].api.getHistory()
|
||||
return { success: true, data: response }
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch history:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toHaveProperty('History')
|
||||
expect(Array.isArray(result.data.History)).toBe(true)
|
||||
expect(result.data.History.length).toBeGreaterThan(0)
|
||||
|
||||
const historyItem = result.data.History[0]
|
||||
|
||||
// Verify the new prompt structure (object instead of array)
|
||||
expect(historyItem.prompt).toHaveProperty('priority')
|
||||
expect(historyItem.prompt).toHaveProperty('prompt_id')
|
||||
expect(historyItem.prompt).toHaveProperty('extra_data')
|
||||
expect(typeof historyItem.prompt.priority).toBe('number')
|
||||
expect(typeof historyItem.prompt.prompt_id).toBe('string')
|
||||
expect(historyItem.prompt.extra_data).toHaveProperty('client_id')
|
||||
})
|
||||
|
||||
test('Can load workflow from history using history_v2 endpoint', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Simple mock workflow for testing
|
||||
const mockWorkflow = {
|
||||
version: 0.4,
|
||||
nodes: [{ id: 1, type: 'TestNode', pos: [100, 100], size: [200, 100] }],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {}
|
||||
}
|
||||
|
||||
// Set up history with workflow data
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask(['example.webp'], 'images', {
|
||||
prompt: {
|
||||
priority: 0,
|
||||
prompt_id: TEST_PROMPT_ID,
|
||||
extra_data: {
|
||||
client_id: TEST_CLIENT_ID,
|
||||
extra_pnginfo: { workflow: mockWorkflow }
|
||||
}
|
||||
}
|
||||
})
|
||||
.setupRoutes()
|
||||
|
||||
// Load initial workflow to clear canvas
|
||||
await comfyPage.loadWorkflow('simple_slider')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Load workflow from history
|
||||
const loadResult = await comfyPage.page.evaluate(async (promptId) => {
|
||||
try {
|
||||
const workflow =
|
||||
await window['app'].api.getWorkflowFromHistory(promptId)
|
||||
if (workflow) {
|
||||
await window['app'].loadGraphData(workflow)
|
||||
return { success: true }
|
||||
}
|
||||
return { success: false, error: 'No workflow found' }
|
||||
} catch (error) {
|
||||
console.error('Failed to load workflow from history:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}, TEST_PROMPT_ID)
|
||||
|
||||
expect(loadResult.success).toBe(true)
|
||||
|
||||
// Verify workflow loaded correctly
|
||||
await comfyPage.nextFrame()
|
||||
const nodeInfo = await comfyPage.page.evaluate(() => {
|
||||
try {
|
||||
const graph = window['app'].graph
|
||||
return {
|
||||
success: true,
|
||||
nodeCount: graph.nodes?.length || 0,
|
||||
firstNodeType: graph.nodes?.[0]?.type || null
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
expect(nodeInfo.success).toBe(true)
|
||||
expect(nodeInfo.nodeCount).toBe(1)
|
||||
expect(nodeInfo.firstNodeType).toBe('TestNode')
|
||||
})
|
||||
|
||||
test('Handles missing workflow data gracefully', async ({ comfyPage }) => {
|
||||
// Set up empty history routes
|
||||
await comfyPage.setupHistory().setupRoutes()
|
||||
|
||||
// Test loading from history with invalid prompt_id
|
||||
const result = await comfyPage.page.evaluate(async () => {
|
||||
try {
|
||||
const workflow =
|
||||
await window['app'].api.getWorkflowFromHistory('invalid-id')
|
||||
return { success: true, workflow }
|
||||
} catch (error) {
|
||||
console.error('Expected error for invalid prompt_id:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Should handle gracefully without throwing
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.workflow).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -43,7 +43,7 @@ test.describe('Selection Toolbox', () => {
|
||||
const boundingBox = await toolboxContainer.boundingBox()
|
||||
expect(boundingBox).not.toBeNull()
|
||||
// Canvas-based positioning can vary, just verify toolbox appears in reasonable bounds
|
||||
expect(boundingBox!.x).toBeGreaterThan(-200) // Not too far off-screen left
|
||||
expect(boundingBox!.x).toBeGreaterThan(-100) // Not too far off-screen left
|
||||
expect(boundingBox!.x).toBeLessThan(1000) // Not too far off-screen right
|
||||
expect(boundingBox!.y).toBeGreaterThan(-100) // Not too far off-screen top
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 96 KiB |
@@ -187,6 +187,7 @@ test.describe('Workflows sidebar', () => {
|
||||
|
||||
test('Can save workflow as with same name', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow5.json')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow5.json'
|
||||
])
|
||||
|
||||
293
docs/SETTINGS.md
293
docs/SETTINGS.md
@@ -1,293 +0,0 @@
|
||||
# Settings System
|
||||
|
||||
## Overview
|
||||
|
||||
ComfyUI frontend uses a comprehensive settings system for user preferences with support for dynamic defaults, version-based rollouts, and environment-aware configuration.
|
||||
|
||||
### Settings Architecture
|
||||
- Settings are defined as `SettingParams` in `src/constants/coreSettings.ts`
|
||||
- Registered at app startup, loaded/saved via `useSettingStore` (Pinia)
|
||||
- Persisted per user via backend `/settings` endpoint
|
||||
- If a value hasn't been set by the user, the store returns the computed default
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:105-122
|
||||
function getDefaultValue<K extends keyof Settings>(
|
||||
key: K
|
||||
): Settings[K] | undefined {
|
||||
const param = getSettingById(key)
|
||||
if (param === undefined) return
|
||||
|
||||
const versionedDefault = getVersionedDefaultValue(key, param)
|
||||
if (versionedDefault) {
|
||||
return versionedDefault
|
||||
}
|
||||
|
||||
return typeof param.defaultValue === 'function'
|
||||
? param.defaultValue()
|
||||
: param.defaultValue
|
||||
}
|
||||
```
|
||||
|
||||
### Settings Registration Process
|
||||
|
||||
Settings are registered after server values are loaded:
|
||||
|
||||
```typescript
|
||||
// From src/components/graph/GraphCanvas.vue:311-315
|
||||
CORE_SETTINGS.forEach((setting) => {
|
||||
settingStore.addSetting(setting)
|
||||
})
|
||||
|
||||
await newUserService().initializeIfNewUser(settingStore)
|
||||
```
|
||||
|
||||
## Dynamic and Environment-Based Defaults
|
||||
|
||||
### Computed Defaults
|
||||
You can compute defaults dynamically using function defaults that access runtime context:
|
||||
|
||||
```typescript
|
||||
// From src/constants/coreSettings.ts:94-101
|
||||
{
|
||||
id: 'Comfy.Sidebar.Size',
|
||||
// Default to small if the window is less than 1536px(2xl) wide
|
||||
defaultValue: () => (window.innerWidth < 1536 ? 'small' : 'normal')
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// From src/constants/coreSettings.ts:306
|
||||
{
|
||||
id: 'Comfy.Locale',
|
||||
defaultValue: () => navigator.language.split('-')[0] || 'en'
|
||||
}
|
||||
```
|
||||
|
||||
### Version-Based Defaults
|
||||
You can vary defaults by installed frontend version using `defaultsByInstallVersion`:
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:129-150
|
||||
function getVersionedDefaultValue<K extends keyof Settings, TValue = Settings[K]>(
|
||||
key: K,
|
||||
param: SettingParams<TValue> | undefined
|
||||
): TValue | null {
|
||||
const defaultsByInstallVersion = param?.defaultsByInstallVersion
|
||||
if (defaultsByInstallVersion && key !== 'Comfy.InstalledVersion') {
|
||||
const installedVersion = get('Comfy.InstalledVersion')
|
||||
if (installedVersion) {
|
||||
const sortedVersions = Object.keys(defaultsByInstallVersion).sort(
|
||||
(a, b) => compareVersions(b, a)
|
||||
)
|
||||
for (const version of sortedVersions) {
|
||||
if (!isSemVer(version)) continue
|
||||
if (compareVersions(installedVersion, version) >= 0) {
|
||||
const versionedDefault = defaultsByInstallVersion[version]
|
||||
return typeof versionedDefault === 'function'
|
||||
? versionedDefault()
|
||||
: versionedDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
Example versioned defaults from codebase:
|
||||
|
||||
```typescript
|
||||
// From src/constants/coreSettings.ts:38-40
|
||||
{
|
||||
id: 'Comfy.Graph.LinkReleaseAction',
|
||||
defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU,
|
||||
defaultsByInstallVersion: {
|
||||
'1.24.1': LinkReleaseTriggerAction.SEARCH_BOX
|
||||
}
|
||||
}
|
||||
|
||||
// Another versioned default example
|
||||
{
|
||||
id: 'Comfy.Graph.LinkReleaseAction.Shift',
|
||||
defaultValue: LinkReleaseTriggerAction.SEARCH_BOX,
|
||||
defaultsByInstallVersion: {
|
||||
'1.24.1': LinkReleaseTriggerAction.CONTEXT_MENU
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Real Examples from Codebase
|
||||
|
||||
Here are actual settings showing different patterns:
|
||||
|
||||
```typescript
|
||||
// Number setting with validation
|
||||
{
|
||||
id: 'LiteGraph.Node.TooltipDelay',
|
||||
name: 'Tooltip Delay',
|
||||
type: 'number',
|
||||
attrs: {
|
||||
min: 100,
|
||||
max: 3000,
|
||||
step: 50
|
||||
},
|
||||
defaultValue: 500,
|
||||
versionAdded: '1.9.0'
|
||||
}
|
||||
|
||||
// Hidden system setting for tracking
|
||||
{
|
||||
id: 'Comfy.InstalledVersion',
|
||||
name: 'The frontend version that was running when the user first installed ComfyUI',
|
||||
type: 'hidden',
|
||||
defaultValue: null,
|
||||
versionAdded: '1.24.0'
|
||||
}
|
||||
|
||||
// Slider with complex tooltip
|
||||
{
|
||||
id: 'LiteGraph.Canvas.LowQualityRenderingZoomThreshold',
|
||||
name: 'Low quality rendering zoom threshold',
|
||||
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.',
|
||||
type: 'slider',
|
||||
attrs: {
|
||||
min: 0.1,
|
||||
max: 1.0,
|
||||
step: 0.05
|
||||
},
|
||||
defaultValue: 0.5
|
||||
}
|
||||
```
|
||||
|
||||
### New User Version Capture
|
||||
|
||||
The initial installed version is captured for new users to ensure versioned defaults remain stable:
|
||||
|
||||
```typescript
|
||||
// From src/services/newUserService.ts:49-53
|
||||
await settingStore.set(
|
||||
'Comfy.InstalledVersion',
|
||||
__COMFYUI_FRONTEND_VERSION__
|
||||
)
|
||||
```
|
||||
|
||||
## Practical Patterns for Environment-Based Defaults
|
||||
|
||||
### Dynamic Default Patterns
|
||||
```typescript
|
||||
// Device-based default
|
||||
{
|
||||
id: 'Comfy.Example.MobileDefault',
|
||||
type: 'boolean',
|
||||
defaultValue: () => /Mobile/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
// Environment-based default
|
||||
{
|
||||
id: 'Comfy.Example.DevMode',
|
||||
type: 'boolean',
|
||||
defaultValue: () => import.meta.env.DEV
|
||||
}
|
||||
|
||||
// Window size based
|
||||
{
|
||||
id: 'Comfy.Example.CompactUI',
|
||||
type: 'boolean',
|
||||
defaultValue: () => window.innerWidth < 1024
|
||||
}
|
||||
```
|
||||
|
||||
### Version-Based Rollout Pattern
|
||||
```typescript
|
||||
{
|
||||
id: 'Comfy.Example.NewFeature',
|
||||
type: 'combo',
|
||||
options: ['legacy', 'enhanced'],
|
||||
defaultValue: 'legacy',
|
||||
defaultsByInstallVersion: {
|
||||
'1.25.0': 'enhanced'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Settings Persistence and Access
|
||||
|
||||
### API Interaction
|
||||
Values are stored per user via the backend. The store writes through API and falls back to defaults when not set:
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:73-75
|
||||
onChange(settingsById.value[key], newValue, oldValue)
|
||||
settingValues.value[key] = newValue
|
||||
await api.storeSetting(key, newValue)
|
||||
```
|
||||
|
||||
### Usage in Components
|
||||
```typescript
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// Get setting value (returns computed default if not set by user)
|
||||
const value = settingStore.get('Comfy.SomeSetting')
|
||||
|
||||
// Update setting value
|
||||
await settingStore.set('Comfy.SomeSetting', newValue)
|
||||
```
|
||||
|
||||
|
||||
## Advanced Settings Features
|
||||
|
||||
### Migration and Backward Compatibility
|
||||
|
||||
Settings support migration from deprecated values:
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:68-69, 172-175
|
||||
const newValue = tryMigrateDeprecatedValue(
|
||||
settingsById.value[key],
|
||||
clonedValue
|
||||
)
|
||||
|
||||
// Migration happens during addSetting for existing values:
|
||||
if (settingValues.value[setting.id] !== undefined) {
|
||||
settingValues.value[setting.id] = tryMigrateDeprecatedValue(
|
||||
setting,
|
||||
settingValues.value[setting.id]
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### onChange Callbacks
|
||||
|
||||
Settings can define onChange callbacks that receive the setting definition, new value, and old value:
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:73, 177
|
||||
onChange(settingsById.value[key], newValue, oldValue) // During set()
|
||||
onChange(setting, get(setting.id), undefined) // During addSetting()
|
||||
```
|
||||
|
||||
### Settings UI and Categories
|
||||
|
||||
Settings are automatically grouped for UI based on their `category` or derived from `id`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'Comfy.Sidebar.Size',
|
||||
category: ['Appearance', 'Sidebar', 'Size'],
|
||||
// UI will group this under Appearance > Sidebar > Size
|
||||
}
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- Feature flag system: `docs/FEATURE_FLAGS.md`
|
||||
- Settings schema for backend: `src/schemas/apiSchema.ts` (zSettings)
|
||||
- Server configuration (separate from user settings): `src/constants/serverConfig.ts`
|
||||
|
||||
## Summary
|
||||
|
||||
- **Settings**: User preferences with dynamic/versioned defaults, persisted per user
|
||||
- **Environment Defaults**: Use function defaults to read runtime context (window, navigator, env)
|
||||
- **Version Rollouts**: Use `defaultsByInstallVersion` for gradual feature releases
|
||||
- **API Interaction**: Settings persist to `/settings` endpoint via `storeSetting()`
|
||||
@@ -1,82 +0,0 @@
|
||||
# Settings and Feature Flags Sequence Diagram
|
||||
|
||||
This diagram shows the flow of settings initialization, default resolution, persistence, and feature flags exchange.
|
||||
|
||||
This diagram accurately reflects the actual implementation in the ComfyUI frontend codebase.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as User
|
||||
participant Vue as Vue Component
|
||||
participant Store as SettingStore (Pinia)
|
||||
participant API as ComfyApi (WebSocket/REST)
|
||||
participant Backend as Backend
|
||||
participant NewUserSvc as NewUserService
|
||||
|
||||
Note over Vue,Store: App startup (GraphCanvas.vue)
|
||||
Vue->>Store: loadSettingValues()
|
||||
Store->>API: getSettings()
|
||||
API->>Backend: GET /settings
|
||||
Backend-->>API: settings map (per-user)
|
||||
API-->>Store: settings map
|
||||
Store-->>Vue: loaded
|
||||
|
||||
Vue->>Store: register CORE_SETTINGS (addSetting for each)
|
||||
loop For each setting registration
|
||||
Store->>Store: tryMigrateDeprecatedValue(existing value)
|
||||
Store->>Store: onChange(setting, currentValue, undefined)
|
||||
end
|
||||
|
||||
Note over Vue,NewUserSvc: New user detection
|
||||
Vue->>NewUserSvc: initializeIfNewUser(settingStore)
|
||||
NewUserSvc->>NewUserSvc: checkIsNewUser(settingStore)
|
||||
alt New user detected
|
||||
NewUserSvc->>Store: set("Comfy.InstalledVersion", __COMFYUI_FRONTEND_VERSION__)
|
||||
Store->>Store: tryMigrateDeprecatedValue(newValue)
|
||||
Store->>Store: onChange(setting, newValue, oldValue)
|
||||
Store->>API: storeSetting(key, newValue)
|
||||
API->>Backend: POST /settings/{id}
|
||||
else Existing user
|
||||
Note over NewUserSvc: Skip setting installed version
|
||||
end
|
||||
|
||||
Note over Vue,Store: Component reads a setting
|
||||
Vue->>Store: get(key)
|
||||
Store->>Store: exists(key)?
|
||||
alt User value exists
|
||||
Store-->>Vue: return stored user value
|
||||
else Not set by user
|
||||
Store->>Store: getVersionedDefaultValue(key)
|
||||
alt Versioned default matched (defaultsByInstallVersion)
|
||||
Store-->>Vue: return versioned default
|
||||
else No version match
|
||||
Store->>Store: evaluate defaultValue (function or constant)
|
||||
Note over Store: defaultValue can use window size,<br/>locale, env, etc.
|
||||
Store-->>Vue: return computed default
|
||||
end
|
||||
end
|
||||
|
||||
Note over User,Store: User updates a setting
|
||||
User->>Vue: changes setting in UI
|
||||
Vue->>Store: set(key, newValue)
|
||||
Store->>Store: tryMigrateDeprecatedValue(newValue)
|
||||
Store->>Store: check if newValue === oldValue (early return if same)
|
||||
Store->>Store: onChange(setting, newValue, oldValue)
|
||||
Store->>Store: update settingValues[key]
|
||||
Store->>API: storeSetting(key, newValue)
|
||||
API->>Backend: POST /settings/{id}
|
||||
Backend-->>API: 200 OK
|
||||
API-->>Store: ack
|
||||
|
||||
Note over API,Backend: Feature Flags WebSocket Exchange
|
||||
API->>Backend: WS connect
|
||||
API->>Backend: send { type: "feature_flags", data: clientFeatureFlags.json }
|
||||
Backend-->>API: WS send { type: "feature_flags", data: server flags }
|
||||
API->>API: store serverFeatureFlags = data
|
||||
|
||||
Note over Vue,API: Feature flag consumption in UI/logic
|
||||
Vue->>API: serverSupportsFeature(name)
|
||||
API-->>Vue: boolean (true only if flag === true)
|
||||
Vue->>API: getServerFeature(name, default)
|
||||
API-->>Vue: value or default
|
||||
```
|
||||
@@ -17,10 +17,9 @@ export default [
|
||||
'src/scripts/*',
|
||||
'src/extensions/core/*',
|
||||
'src/types/vue-shim.d.ts',
|
||||
// Generated files that don't need linting
|
||||
'src/types/comfyRegistryTypes.ts',
|
||||
'src/types/generatedManagerTypes.ts',
|
||||
'**/vite.config.*.timestamp*',
|
||||
'**/vitest.config.*.timestamp*'
|
||||
'src/types/generatedManagerTypes.ts'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -74,7 +74,7 @@ const config: KnipConfig = {
|
||||
// Workspace configuration for monorepo-like structure
|
||||
workspaces: {
|
||||
'.': {
|
||||
entry: ['src/main.ts', 'playwright.i18n.config.ts']
|
||||
entry: ['src/main.ts']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
40
nx.json
40
nx.json
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/nx/schemas/nx-schema.json",
|
||||
"plugins": [
|
||||
{
|
||||
"plugin": "@nx/eslint/plugin",
|
||||
"options": {
|
||||
"targetName": "lint"
|
||||
}
|
||||
},
|
||||
{
|
||||
"plugin": "@nx/storybook/plugin",
|
||||
"options": {
|
||||
"serveStorybookTargetName": "storybook",
|
||||
"buildStorybookTargetName": "build-storybook",
|
||||
"testStorybookTargetName": "test-storybook",
|
||||
"staticStorybookTargetName": "static-storybook"
|
||||
}
|
||||
},
|
||||
{
|
||||
"plugin": "@nx/vite/plugin",
|
||||
"options": {
|
||||
"buildTargetName": "build",
|
||||
"testTargetName": "test",
|
||||
"serveTargetName": "serve",
|
||||
"devTargetName": "dev",
|
||||
"previewTargetName": "preview",
|
||||
"serveStaticTargetName": "serve-static",
|
||||
"typecheckTargetName": "typecheck",
|
||||
"buildDepsTargetName": "build-deps",
|
||||
"watchDepsTargetName": "watch-deps"
|
||||
}
|
||||
},
|
||||
{
|
||||
"plugin": "@nx/playwright/plugin",
|
||||
"options": {
|
||||
"targetName": "e2e"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
40
package.json
40
package.json
@@ -8,22 +8,22 @@
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"dev": "nx serve",
|
||||
"dev:electron": "nx serve --config vite.electron.config.mts",
|
||||
"build": "pnpm typecheck && nx build",
|
||||
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"dev": "vite",
|
||||
"dev:electron": "vite --config vite.electron.config.mts",
|
||||
"build": "pnpm typecheck && vite build",
|
||||
"build:types": "vite build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"zipdist": "node scripts/zipdist.js",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache",
|
||||
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
|
||||
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}'",
|
||||
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
|
||||
"test:browser": "npx nx e2e",
|
||||
"test:unit": "nx run test tests-ui/tests",
|
||||
"test:component": "nx run test src/components/",
|
||||
"test:browser": "npx playwright test",
|
||||
"test:unit": "vitest run tests-ui/tests",
|
||||
"test:component": "vitest run src/components/",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "nx preview",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --cache",
|
||||
"lint:fix": "eslint src --cache --fix",
|
||||
"lint:no-cache": "eslint src",
|
||||
@@ -31,23 +31,18 @@
|
||||
"knip": "knip --cache",
|
||||
"knip:no-cache": "knip",
|
||||
"locale": "lobe-i18n locale",
|
||||
"collect-i18n": "npx playwright test --config=playwright.i18n.config.ts",
|
||||
"collect-i18n": "playwright test --config=playwright.i18n.config.ts",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
"storybook": "nx storybook -p 6006",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@executeautomation/playwright-mcp-server": "^1.0.6",
|
||||
"@executeautomation/playwright-mcp-server": "^1.0.5",
|
||||
"@iconify/json": "^2.2.245",
|
||||
"@iconify/tailwind": "^1.2.0",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
"@nx/eslint": "21.4.1",
|
||||
"@nx/playwright": "21.4.1",
|
||||
"@nx/storybook": "21.4.1",
|
||||
"@nx/vite": "21.4.1",
|
||||
"@nx/web": "21.4.1",
|
||||
"@lobehub/i18n-cli": "^1.20.0",
|
||||
"@pinia/testing": "^0.1.5",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@storybook/addon-docs": "^9.1.1",
|
||||
@@ -60,7 +55,6 @@
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/three": "^0.169.0",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vitest/ui": "^3.0.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"chalk": "^5.3.0",
|
||||
@@ -76,16 +70,14 @@
|
||||
"happy-dom": "^15.11.0",
|
||||
"husky": "^9.0.11",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"ink": "^6.2.2",
|
||||
"jiti": "2.4.2",
|
||||
"ink": "^4.2.0",
|
||||
"knip": "^5.62.0",
|
||||
"lint-staged": "^15.2.7",
|
||||
"lucide-vue-next": "^0.540.0",
|
||||
"nx": "21.4.1",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^3.3.2",
|
||||
"react": "^19.1.1",
|
||||
"react-reconciler": "^0.32.0",
|
||||
"react": "^18.3.1",
|
||||
"react-reconciler": "^0.29.2",
|
||||
"storybook": "^9.1.1",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tsx": "^4.15.6",
|
||||
@@ -98,7 +90,7 @@
|
||||
"vite-plugin-dts": "^4.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-vue-devtools": "^7.7.6",
|
||||
"vitest": "^3.2.4",
|
||||
"vitest": "^2.0.0",
|
||||
"vue-tsc": "^2.1.10",
|
||||
"zip-dir": "^2.0.0",
|
||||
"zod-to-json-schema": "^3.24.1"
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './browser_tests',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
reporter: 'html',
|
||||
// /* // Toggle for [LOCAL] testing.
|
||||
/* Retry on CI only - increased for better flaky test handling */
|
||||
retries: process.env.CI ? 3 : 0,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://127.0.0.1:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry'
|
||||
},
|
||||
/*/ // [LOCAL]
|
||||
// VERY HELPFUL: Skip screenshot tests locally
|
||||
// grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/,
|
||||
timeout: 30_000, // Longer timeout for breakpoints
|
||||
retries: 0, // No retries while debugging. Increase if writing new tests. that may be flaky.
|
||||
workers: 4, // Single worker for easier debugging. Increase to match CPU cores if you want to run a lot of tests in parallel.
|
||||
|
||||
use: {
|
||||
trace: 'on', // Always capture traces (CI uses 'on-first-retry')
|
||||
video: 'on' // Always record video (CI uses 'retain-on-failure')
|
||||
},
|
||||
//*/
|
||||
|
||||
/* Path to global setup file. Exported function runs once before all the tests */
|
||||
globalSetup: './browser_tests/globalSetup.ts',
|
||||
/* Path to global teardown file. Exported function runs once after all the tests */
|
||||
globalTeardown: './browser_tests/globalTeardown.ts',
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
import { PlaywrightTestConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: './scripts',
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
@@ -9,4 +9,6 @@ export default defineConfig({
|
||||
reporter: 'list',
|
||||
timeout: 60000,
|
||||
testMatch: /collect-i18n-.*\.ts/
|
||||
})
|
||||
}
|
||||
|
||||
export default config
|
||||
|
||||
4653
pnpm-lock.yaml
generated
4653
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -12,5 +12,4 @@ onlyBuiltDependencies:
|
||||
- '@playwright/browser-firefox'
|
||||
- '@playwright/browser-webkit'
|
||||
- esbuild
|
||||
- nx
|
||||
- oxc-resolver
|
||||
|
||||
@@ -24,22 +24,22 @@ export const Basic: Story = {
|
||||
<MoreButton>
|
||||
<template #default="{ close }">
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
type="secondary"
|
||||
label="Settings"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<Download :size="16" />
|
||||
<Download />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="transparent"
|
||||
type="primary"
|
||||
label="Profile"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<ScrollText :size="16" />
|
||||
<ScrollText />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
|
||||
@@ -32,12 +32,16 @@
|
||||
</Message>
|
||||
|
||||
<!-- Form -->
|
||||
<SignInForm v-if="isSignIn" @submit="signInWithEmail" />
|
||||
<SignInForm
|
||||
v-if="isSignIn"
|
||||
:auth-error="authError"
|
||||
@submit="signInWithEmail"
|
||||
/>
|
||||
<template v-else>
|
||||
<Message v-if="userIsInChina" severity="warn" class="mb-4">
|
||||
{{ t('auth.signup.regionRestrictionChina') }}
|
||||
</Message>
|
||||
<SignUpForm v-else @submit="signUpWithEmail" />
|
||||
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
|
||||
</template>
|
||||
|
||||
<!-- Divider -->
|
||||
@@ -149,6 +153,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
|
||||
import { SignInData, SignUpData } from '@/schemas/signInSchema'
|
||||
import { translateAuthError } from '@/utils/authErrorTranslation'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
|
||||
import ApiKeyForm from './signin/ApiKeyForm.vue'
|
||||
@@ -164,32 +169,58 @@ const authActions = useFirebaseAuthActions()
|
||||
const isSecureContext = window.isSecureContext
|
||||
const isSignIn = ref(true)
|
||||
const showApiKeyForm = ref(false)
|
||||
const authError = ref('')
|
||||
|
||||
const toggleState = () => {
|
||||
isSignIn.value = !isSignIn.value
|
||||
showApiKeyForm.value = false
|
||||
authError.value = ''
|
||||
}
|
||||
|
||||
// Custom error handler for inline display
|
||||
const inlineErrorHandler = (error: unknown) => {
|
||||
// Set inline error with auth error translation
|
||||
authError.value = translateAuthError(error)
|
||||
// Also show toast (original behavior)
|
||||
authActions.reportError(error)
|
||||
}
|
||||
|
||||
const signInWithGoogle = async () => {
|
||||
if (await authActions.signInWithGoogle()) {
|
||||
authError.value = ''
|
||||
if (await authActions.signInWithGoogle(inlineErrorHandler)()) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithGithub = async () => {
|
||||
if (await authActions.signInWithGithub()) {
|
||||
authError.value = ''
|
||||
if (await authActions.signInWithGithub(inlineErrorHandler)()) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithEmail = async (values: SignInData) => {
|
||||
if (await authActions.signInWithEmail(values.email, values.password)) {
|
||||
authError.value = ''
|
||||
if (
|
||||
await authActions.signInWithEmail(
|
||||
values.email,
|
||||
values.password,
|
||||
inlineErrorHandler
|
||||
)()
|
||||
) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signUpWithEmail = async (values: SignUpData) => {
|
||||
if (await authActions.signUpWithEmail(values.email, values.password)) {
|
||||
authError.value = ''
|
||||
if (
|
||||
await authActions.signUpWithEmail(
|
||||
values.email,
|
||||
values.password,
|
||||
inlineErrorHandler
|
||||
)()
|
||||
) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,23 +57,14 @@
|
||||
class="w-8 h-8 mt-4"
|
||||
style="--pc-spinner-color: #000"
|
||||
/>
|
||||
<div v-else class="mt-4 flex flex-col gap-2">
|
||||
<Button
|
||||
class="w-32"
|
||||
severity="secondary"
|
||||
:label="$t('auth.signOut.signOut')"
|
||||
icon="pi pi-sign-out"
|
||||
@click="handleSignOut"
|
||||
/>
|
||||
<Button
|
||||
v-if="!isApiKeyLogin"
|
||||
class="w-32"
|
||||
severity="danger"
|
||||
:label="$t('auth.deleteAccount.deleteAccount')"
|
||||
icon="pi pi-trash"
|
||||
@click="handleDeleteAccount"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
class="mt-4 w-32"
|
||||
severity="secondary"
|
||||
:label="$t('auth.signOut.signOut')"
|
||||
icon="pi pi-sign-out"
|
||||
@click="handleSignOut"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Login Section -->
|
||||
@@ -109,7 +100,6 @@ const dialogService = useDialogService()
|
||||
const {
|
||||
loading,
|
||||
isLoggedIn,
|
||||
isApiKeyLogin,
|
||||
isEmailProvider,
|
||||
userDisplayName,
|
||||
userEmail,
|
||||
@@ -117,7 +107,6 @@ const {
|
||||
providerName,
|
||||
providerIcon,
|
||||
handleSignOut,
|
||||
handleSignIn,
|
||||
handleDeleteAccount
|
||||
handleSignIn
|
||||
} = useCurrentUser()
|
||||
</script>
|
||||
|
||||
@@ -59,6 +59,11 @@
|
||||
}}</small>
|
||||
</div>
|
||||
|
||||
<!-- Auth Error Message -->
|
||||
<Message v-if="authError" severity="error">
|
||||
{{ authError }}
|
||||
</Message>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<ProgressSpinner v-if="loading" class="w-8 h-8" />
|
||||
<Button
|
||||
@@ -75,6 +80,7 @@ import { Form, FormSubmitEvent } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import Password from 'primevue/password'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
@@ -92,6 +98,10 @@ const toast = useToast()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
authError?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [values: SignInData]
|
||||
}>()
|
||||
|
||||
@@ -49,6 +49,11 @@
|
||||
}}</small>
|
||||
</FormField>
|
||||
|
||||
<!-- Auth Error Message -->
|
||||
<Message v-if="authError" severity="error">
|
||||
{{ authError }}
|
||||
</Message>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -64,6 +69,7 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { type SignUpData, signUpSchema } from '@/schemas/signInSchema'
|
||||
@@ -72,6 +78,10 @@ import PasswordFields from './PasswordFields.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
authError?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [values: SignUpData]
|
||||
}>()
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
// Global variable from vite build defined in global.d.ts
|
||||
// eslint-disable-next-line no-undef
|
||||
const isStaging = !__USE_PROD_CONFIG__
|
||||
import { isProductionEnvironment } from '@/config/environment'
|
||||
|
||||
const isStaging = !isProductionEnvironment()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<template>
|
||||
<div
|
||||
ref="toolboxRef"
|
||||
style="transform: translate(var(--tb-x), var(--tb-y))"
|
||||
class="fixed left-0 top-0 z-40"
|
||||
>
|
||||
<Transition name="slide-up">
|
||||
<Transition name="slide-up">
|
||||
<!-- Wrapping panel in div to get correct ref because panel ref is not of raw dom el -->
|
||||
<div
|
||||
v-show="visible"
|
||||
ref="toolboxRef"
|
||||
style="
|
||||
transform: translate(calc(var(--tb-x) - 50%), calc(var(--tb-y) - 120%));
|
||||
"
|
||||
class="selection-toolbox fixed left-0 top-0 z-40"
|
||||
>
|
||||
<Panel
|
||||
v-if="visible"
|
||||
class="rounded-lg selection-toolbox"
|
||||
class="rounded-lg"
|
||||
:pt="{
|
||||
header: 'hidden',
|
||||
content: 'p-0 flex flex-row'
|
||||
@@ -30,8 +33,8 @@
|
||||
/>
|
||||
<HelpButton />
|
||||
</Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -81,31 +84,22 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selection-toolbox {
|
||||
transform: translateX(-50%) translateY(-120%);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
transform: translateX(-50%) translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-50%) translateY(-125%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%) translateY(-120%);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.slide-up-enter-active {
|
||||
animation: slideUp 125ms ease-out;
|
||||
opacity: 1;
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-up-leave-active {
|
||||
animation: slideUp 25ms ease-out reverse;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(0);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import type { MultiSelectProps } from 'primevue/multiselect'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
|
||||
// Combine our component props with PrimeVue MultiSelect props
|
||||
// Since we use v-bind="$attrs", all PrimeVue props are available
|
||||
interface ExtendedProps extends Partial<MultiSelectProps> {
|
||||
// Our custom props
|
||||
label?: string
|
||||
showSearchBox?: boolean
|
||||
showSelectedCount?: boolean
|
||||
showClearButton?: boolean
|
||||
searchPlaceholder?: string
|
||||
// Override modelValue type to match our Option type
|
||||
modelValue?: Array<{ name: string; value: string }>
|
||||
}
|
||||
|
||||
const meta: Meta<ExtendedProps> = {
|
||||
const meta: Meta<typeof MultiSelect> = {
|
||||
title: 'Components/Input/MultiSelect',
|
||||
component: MultiSelect,
|
||||
tags: ['autodocs'],
|
||||
@@ -27,35 +13,7 @@ const meta: Meta<ExtendedProps> = {
|
||||
},
|
||||
options: {
|
||||
control: 'object'
|
||||
},
|
||||
showSearchBox: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle searchBar visibility'
|
||||
},
|
||||
showSelectedCount: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle selected count visibility'
|
||||
},
|
||||
showClearButton: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle clear button visibility'
|
||||
},
|
||||
searchPlaceholder: {
|
||||
control: 'text'
|
||||
}
|
||||
},
|
||||
args: {
|
||||
label: 'Select',
|
||||
options: [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
],
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +25,7 @@ export const Default: Story = {
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref([])
|
||||
const options = args.options || [
|
||||
const options = [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
@@ -80,11 +38,8 @@ export const Default: Story = {
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
:label="args.label"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
label="Select Frameworks"
|
||||
v-bind="args"
|
||||
/>
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
@@ -95,10 +50,10 @@ export const Default: Story = {
|
||||
}
|
||||
|
||||
export const WithPreselectedValues: Story = {
|
||||
render: (args) => ({
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const options = args.options || [
|
||||
const options = [
|
||||
{ name: 'JavaScript', value: 'js' },
|
||||
{ name: 'TypeScript', value: 'ts' },
|
||||
{ name: 'Python', value: 'python' },
|
||||
@@ -106,43 +61,25 @@ export const WithPreselectedValues: Story = {
|
||||
{ name: 'Rust', value: 'rust' }
|
||||
]
|
||||
const selected = ref([options[0], options[1]])
|
||||
return { selected, options, args }
|
||||
return { selected, options }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
:label="args.label"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
label="Select Languages"
|
||||
/>
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
label: 'Select Languages',
|
||||
options: [
|
||||
{ name: 'JavaScript', value: 'js' },
|
||||
{ name: 'TypeScript', value: 'ts' },
|
||||
{ name: 'Python', value: 'python' },
|
||||
{ name: 'Go', value: 'go' },
|
||||
{ name: 'Rust', value: 'rust' }
|
||||
],
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const MultipleSelectors: Story = {
|
||||
render: (args) => ({
|
||||
render: () => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const frameworkOptions = ref([
|
||||
@@ -177,8 +114,7 @@ export const MultipleSelectors: Story = {
|
||||
tagOptions,
|
||||
selectedFrameworks,
|
||||
selectedProjects,
|
||||
selectedTags,
|
||||
args
|
||||
selectedTags
|
||||
}
|
||||
},
|
||||
template: `
|
||||
@@ -188,34 +124,22 @@ export const MultipleSelectors: Story = {
|
||||
v-model="selectedFrameworks"
|
||||
:options="frameworkOptions"
|
||||
label="Select Frameworks"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
:options="projectOptions"
|
||||
label="Select Projects"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedTags"
|
||||
:options="tagOptions"
|
||||
label="Select Tags"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<h4 class="font-medium mt-0">Current Selection:</h4>
|
||||
<div class="flex flex-col text-sm">
|
||||
<h4 class="font-medium mb-2">Current Selection:</h4>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<p>Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<p>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
@@ -223,54 +147,5 @@ export const MultipleSelectors: Story = {
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: {
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithSearchBox: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true
|
||||
}
|
||||
}
|
||||
|
||||
export const WithSelectedCount: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSelectedCount: true
|
||||
}
|
||||
}
|
||||
|
||||
export const WithClearButton: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showClearButton: true
|
||||
}
|
||||
}
|
||||
|
||||
export const AllHeaderFeatures: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true,
|
||||
showSelectedCount: true,
|
||||
showClearButton: true
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomSearchPlaceholder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true,
|
||||
searchPlaceholder: 'Filter packages...'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,104 +1,93 @@
|
||||
<template>
|
||||
<!--
|
||||
Note: Unlike SingleSelect, we don't need an explicit options prop because:
|
||||
1. Our value template only shows a static label (not dynamic based on selection)
|
||||
2. We display a count badge instead of actual selected labels
|
||||
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
|
||||
|
||||
option-label="name" is required because our option template directly accesses option.name
|
||||
max-selected-labels="0" is required to show count badge instead of selected item labels
|
||||
-->
|
||||
<MultiSelect
|
||||
v-model="selectedItems"
|
||||
v-bind="$attrs"
|
||||
option-label="name"
|
||||
unstyled
|
||||
:max-selected-labels="0"
|
||||
:pt="pt"
|
||||
>
|
||||
<template
|
||||
v-if="showSearchBox || showSelectedCount || showClearButton"
|
||||
#header
|
||||
<div class="relative inline-block">
|
||||
<MultiSelect
|
||||
v-model="selectedItems"
|
||||
:options="options"
|
||||
option-label="name"
|
||||
unstyled
|
||||
:placeholder="label"
|
||||
:max-selected-labels="0"
|
||||
:pt="pt"
|
||||
>
|
||||
<div class="p-2 flex flex-col pb-0">
|
||||
<SearchBox
|
||||
v-if="showSearchBox"
|
||||
v-model="searchQuery"
|
||||
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
|
||||
:show-order="true"
|
||||
:place-holder="searchPlaceholder"
|
||||
/>
|
||||
<div
|
||||
v-if="showSelectedCount || showClearButton"
|
||||
class="mt-2 flex items-center justify-between"
|
||||
>
|
||||
<span
|
||||
v-if="showSelectedCount"
|
||||
class="text-sm text-neutral-400 dark-theme:text-zinc-500 px-1"
|
||||
>
|
||||
{{
|
||||
selectedCount > 0
|
||||
? $t('g.itemsSelected', { selectedCount })
|
||||
: $t('g.itemSelected', { selectedCount })
|
||||
}}
|
||||
</span>
|
||||
<TextButton
|
||||
v-if="showClearButton"
|
||||
:label="$t('g.clearAll')"
|
||||
type="transparent"
|
||||
size="fit-content"
|
||||
class="text-sm !text-blue-500 !dark-theme:text-blue-600"
|
||||
@click.stop="selectedItems = []"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trigger value (keep text scale identical) -->
|
||||
<template #value>
|
||||
<span class="text-sm text-zinc-700 dark-theme:text-gray-200">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedCount > 0"
|
||||
class="pointer-events-none absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-blue-400 dark-theme:bg-blue-500 text-xs font-semibold text-white"
|
||||
<template
|
||||
v-if="hasSearchBox || showSelectedCount || hasClearButton"
|
||||
#header
|
||||
>
|
||||
{{ selectedCount }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Chevron size identical to current -->
|
||||
<template #dropdownicon>
|
||||
<i-lucide:chevron-down class="text-lg text-neutral-400" />
|
||||
</template>
|
||||
|
||||
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
|
||||
<template #option="slotProps">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-4 w-4 p-0.5 flex-shrink-0 items-center justify-center rounded transition-all duration-200"
|
||||
:class="
|
||||
slotProps.selected
|
||||
? 'border-[3px] border-blue-400 bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
|
||||
: 'border-[1px] border-neutral-300 dark-theme:border-zinc-600 bg-neutral-100 dark-theme:bg-zinc-700'
|
||||
"
|
||||
>
|
||||
<i-lucide:check
|
||||
v-if="slotProps.selected"
|
||||
class="text-xs text-bold text-white"
|
||||
<div class="p-2 flex flex-col gap-y-4 pb-0">
|
||||
<SearchBox
|
||||
v-if="hasSearchBox"
|
||||
v-model="searchQuery"
|
||||
:has-border="true"
|
||||
:place-holder="searchPlaceholder"
|
||||
/>
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
v-if="showSelectedCount"
|
||||
class="text-sm text-neutral-400 dark-theme:text-zinc-500 px-1"
|
||||
>
|
||||
{{
|
||||
selectedCount > 0
|
||||
? $t('g.itemsSelected', { selectedCount })
|
||||
: $t('g.itemSelected', { selectedCount })
|
||||
}}
|
||||
</span>
|
||||
<TextButton
|
||||
v-if="hasClearButton"
|
||||
:label="$t('g.clearAll')"
|
||||
type="transparent"
|
||||
size="fit-content"
|
||||
class="text-sm !text-blue-500 !dark-theme:text-blue-600"
|
||||
@click.stop="selectedItems = []"
|
||||
/>
|
||||
</div>
|
||||
<div class="h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
|
||||
</div>
|
||||
<Button class="border-none outline-none bg-transparent" unstyled>{{
|
||||
slotProps.option.name
|
||||
}}</Button>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</template>
|
||||
|
||||
<!-- Trigger value (keep text scale identical) -->
|
||||
<template #value>
|
||||
<span class="text-sm text-zinc-700 dark-theme:text-gray-200">
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Chevron size identical to current -->
|
||||
<template #dropdownicon>
|
||||
<i-lucide:chevron-down class="text-lg text-neutral-400" />
|
||||
</template>
|
||||
|
||||
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
|
||||
<template #option="slotProps">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-4 w-4 p-0.5 flex-shrink-0 items-center justify-center rounded border-[3px] transition-all duration-200"
|
||||
:class="
|
||||
slotProps.selected
|
||||
? 'border-blue-400 bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
|
||||
: 'border-neutral-300 dark-theme:border-zinc-600 bg-neutral-100 dark-theme:bg-zinc-700'
|
||||
"
|
||||
>
|
||||
<i-lucide:check
|
||||
v-if="slotProps.selected"
|
||||
class="text-xs text-bold text-white"
|
||||
/>
|
||||
</div>
|
||||
<span>{{ slotProps.option.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<!-- Selected count badge -->
|
||||
<div
|
||||
v-if="selectedCount > 0"
|
||||
class="pointer-events-none absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-blue-400 dark-theme:bg-blue-500 text-xs font-semibold text-white"
|
||||
>
|
||||
{{ selectedCount }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import MultiSelect, {
|
||||
MultiSelectPassThroughMethodOptions
|
||||
} from 'primevue/multiselect'
|
||||
@@ -110,29 +99,26 @@ import TextButton from '../button/TextButton.vue'
|
||||
|
||||
type Option = { name: string; value: string }
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
interface Props {
|
||||
/** Input label shown on the trigger button */
|
||||
label?: string
|
||||
/** Static options for the multiselect (when not using async search) */
|
||||
options: Option[]
|
||||
/** Show search box in the panel header */
|
||||
showSearchBox?: boolean
|
||||
hasSearchBox?: boolean
|
||||
/** Show selected count text in the panel header */
|
||||
showSelectedCount?: boolean
|
||||
/** Show "Clear all" action in the panel header */
|
||||
showClearButton?: boolean
|
||||
hasClearButton?: boolean
|
||||
/** Placeholder for the search input */
|
||||
searchPlaceholder?: string
|
||||
// Note: options prop is intentionally omitted.
|
||||
// It's passed via $attrs to maximize PrimeVue API compatibility
|
||||
}
|
||||
const {
|
||||
label,
|
||||
showSearchBox = false,
|
||||
options,
|
||||
hasSearchBox = false,
|
||||
showSelectedCount = false,
|
||||
showClearButton = false,
|
||||
hasClearButton = false,
|
||||
searchPlaceholder = 'Search...'
|
||||
} = defineProps<Props>()
|
||||
|
||||
@@ -145,7 +131,7 @@ const selectedCount = computed(() => selectedItems.value.length)
|
||||
const pt = computed(() => ({
|
||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: [
|
||||
'relative inline-flex cursor-pointer select-none',
|
||||
'relative inline-flex cursor-pointer select-none w-full',
|
||||
'rounded-lg bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'border-[2.5px] border-solid',
|
||||
@@ -167,7 +153,7 @@ const pt = computed(() => ({
|
||||
},
|
||||
header: () => ({
|
||||
class:
|
||||
showSearchBox || showSelectedCount || showClearButton ? 'block' : 'hidden'
|
||||
hasSearchBox || showSelectedCount || hasClearButton ? 'block' : 'hidden'
|
||||
}),
|
||||
// Overlay & list visuals unchanged
|
||||
overlay:
|
||||
@@ -175,17 +161,9 @@ const pt = computed(() => ({
|
||||
list: {
|
||||
class: 'flex flex-col gap-1 p-0 list-none border-none text-xs'
|
||||
},
|
||||
// Option row hover and focus tone
|
||||
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: [
|
||||
'flex gap-1 items-center p-2',
|
||||
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
|
||||
// Add focus/highlight state for keyboard navigation
|
||||
{
|
||||
'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context?.focused
|
||||
}
|
||||
]
|
||||
}),
|
||||
// Option row hover tone identical
|
||||
option:
|
||||
'flex gap-1 items-center p-2 hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
|
||||
// Hide built-in checkboxes entirely via PT (no :deep)
|
||||
pcHeaderCheckbox: {
|
||||
root: { class: 'hidden' },
|
||||
|
||||
@@ -10,15 +10,7 @@ const meta: Meta<typeof SearchBox> = {
|
||||
argTypes: {
|
||||
placeHolder: {
|
||||
control: 'text'
|
||||
},
|
||||
showBorder: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border prop'
|
||||
}
|
||||
},
|
||||
args: {
|
||||
placeHolder: 'Search...',
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,23 +25,9 @@ export const Default: Story = {
|
||||
return { searchText, args }
|
||||
},
|
||||
template: `
|
||||
<div style="max-width: 320px;">
|
||||
<SearchBox v-bind="args" v-model="searchText" />
|
||||
<div>
|
||||
<SearchBox v-model:="searchQuery" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
showBorder: true
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,20 +15,19 @@
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { placeHolder, showBorder = false } = defineProps<{
|
||||
const { placeHolder, hasBorder = false } = defineProps<{
|
||||
placeHolder?: string
|
||||
showBorder?: boolean
|
||||
hasBorder?: boolean
|
||||
}>()
|
||||
// defineModel without arguments uses 'modelValue' as the prop name
|
||||
const searchQuery = defineModel<string>()
|
||||
const searchQuery = defineModel<string>('')
|
||||
|
||||
const wrapperStyle = computed(() => {
|
||||
return showBorder
|
||||
return hasBorder
|
||||
? 'flex w-full items-center rounded gap-2 bg-white dark-theme:bg-zinc-800 p-1 border border-solid border-zinc-200 dark-theme:border-zinc-700'
|
||||
: 'flex w-full items-center rounded px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800'
|
||||
})
|
||||
|
||||
const iconColorStyle = computed(() => {
|
||||
return !showBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700'
|
||||
return !hasBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -4,24 +4,12 @@ import { ref } from 'vue'
|
||||
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
|
||||
// SingleSelect already includes options prop, so no need to extend
|
||||
const meta: Meta<typeof SingleSelect> = {
|
||||
title: 'Components/Input/SingleSelect',
|
||||
component: SingleSelect,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
options: { control: 'object' }
|
||||
},
|
||||
args: {
|
||||
label: 'Sorting Type',
|
||||
options: [
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
{ name: 'Newest', value: 'newest' },
|
||||
{ name: 'Oldest', value: 'oldest' },
|
||||
{ name: 'A → Z', value: 'az' },
|
||||
{ name: 'Z → A', value: 'za' }
|
||||
]
|
||||
label: { control: 'text' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,18 +29,19 @@ export const Default: Story = {
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>(null)
|
||||
const options = args.options || sampleOptions
|
||||
const options = sampleOptions
|
||||
return { selected, options, args }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<SingleSelect v-model="selected" :options="options" :label="args.label" />
|
||||
<SingleSelect v-model="selected" :options="options" :label="args.label || 'Sorting Type'" />
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}),
|
||||
args: { label: 'Sorting Type' }
|
||||
}
|
||||
|
||||
export const WithIcon: Story = {
|
||||
|
||||
@@ -1,73 +1,58 @@
|
||||
<template>
|
||||
<!--
|
||||
Note: We explicitly pass options here (not just via $attrs) because:
|
||||
1. Our custom value template needs options to look up labels from values
|
||||
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
|
||||
3. We need to maintain the icon slot functionality in the value template
|
||||
|
||||
option-label="name" is required because our option template directly accesses option.name
|
||||
-->
|
||||
<Select
|
||||
v-model="selectedItem"
|
||||
v-bind="$attrs"
|
||||
:options="options"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
unstyled
|
||||
:pt="pt"
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<slot name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
class="text-zinc-700 dark-theme:text-gray-200"
|
||||
>
|
||||
{{ getLabel(slotProps.value) }}
|
||||
</span>
|
||||
<span v-else class="text-zinc-700 dark-theme:text-gray-200">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<Select
|
||||
v-model="selectedItem"
|
||||
:options="options"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
unstyled
|
||||
:placeholder="label"
|
||||
:pt="pt"
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<slot name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
class="text-zinc-700 dark-theme:text-gray-200"
|
||||
>
|
||||
{{ getLabel(slotProps.value) }}
|
||||
</span>
|
||||
<span v-else class="text-zinc-700 dark-theme:text-gray-200">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trigger caret -->
|
||||
<template #dropdownicon>
|
||||
<i-lucide:chevron-down
|
||||
class="text-base text-neutral-400 dark-theme:text-gray-300"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Option row -->
|
||||
<template #option="{ option, selected }">
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<span class="truncate">{{ option.name }}</span>
|
||||
<i-lucide:check
|
||||
v-if="selected"
|
||||
class="text-neutral-900 dark-theme:text-white"
|
||||
<!-- Trigger caret -->
|
||||
<template #dropdownicon>
|
||||
<i-lucide:chevron-down
|
||||
class="text-base text-neutral-400 dark-theme:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<!-- Option row -->
|
||||
<template #option="{ option, selected }">
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<span class="truncate">{{ option.name }}</span>
|
||||
<i-lucide:check
|
||||
v-if="selected"
|
||||
class="text-neutral-900 dark-theme:text-white"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select, { SelectPassThroughMethodOptions } from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { label, options } = defineProps<{
|
||||
label?: string
|
||||
/**
|
||||
* Required for displaying the selected item's label.
|
||||
* Cannot rely on $attrs alone because we need to access options
|
||||
* in getLabel() to map values to their display names.
|
||||
*/
|
||||
options?: {
|
||||
options: {
|
||||
name: string
|
||||
value: string
|
||||
}[]
|
||||
@@ -75,14 +60,8 @@ const { label, options } = defineProps<{
|
||||
|
||||
const selectedItem = defineModel<string | null>({ required: true })
|
||||
|
||||
/**
|
||||
* Maps a value to its display label.
|
||||
* Necessary because PrimeVue's value slot doesn't provide the selected item's label,
|
||||
* only the raw value. We need this to show the correct text when an item is selected.
|
||||
*/
|
||||
const getLabel = (val: string | null | undefined) => {
|
||||
if (val == null) return label ?? ''
|
||||
if (!options) return label ?? ''
|
||||
const found = options.find((o) => o.value === val)
|
||||
return found ? found.name : label ?? ''
|
||||
}
|
||||
@@ -98,7 +77,7 @@ const pt = computed(() => ({
|
||||
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
|
||||
class: [
|
||||
// container
|
||||
'relative inline-flex cursor-pointer select-none items-center',
|
||||
'relative inline-flex w-full cursor-pointer select-none items-center',
|
||||
// trigger surface
|
||||
'rounded-md',
|
||||
'bg-transparent text-neutral dark-theme:text-white',
|
||||
@@ -136,9 +115,7 @@ const pt = computed(() => ({
|
||||
'flex items-center justify-between gap-3 px-3 py-2',
|
||||
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
|
||||
// Selected state + check icon
|
||||
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected },
|
||||
// Add focus state for keyboard navigation
|
||||
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.focused }
|
||||
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected }
|
||||
]
|
||||
}),
|
||||
optionLabel: {
|
||||
|
||||
@@ -106,8 +106,8 @@ import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import { ComfyNode } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import {
|
||||
ResultItemImpl,
|
||||
@@ -126,6 +126,7 @@ const toast = useToast()
|
||||
const queueStore = useQueueStore()
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Expanded view: show all outputs in a flat list.
|
||||
@@ -208,8 +209,16 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
{
|
||||
label: t('g.loadWorkflow'),
|
||||
icon: 'pi pi-file-export',
|
||||
command: () => menuTargetTask.value?.loadWorkflow(app),
|
||||
disabled: !menuTargetTask.value?.workflow
|
||||
command: () => {
|
||||
if (menuTargetTask.value) {
|
||||
void workflowService.loadTaskWorkflow(menuTargetTask.value)
|
||||
}
|
||||
},
|
||||
disabled: !(
|
||||
menuTargetTask.value?.workflow ||
|
||||
(menuTargetTask.value?.isHistory &&
|
||||
menuTargetTask.value?.prompt.prompt_id)
|
||||
)
|
||||
},
|
||||
{
|
||||
label: t('g.goToNode'),
|
||||
|
||||
@@ -41,13 +41,11 @@ afterAll(() => {
|
||||
})
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
const mockHandleSignOut = vi.fn()
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
userPhotoUrl: 'https://example.com/avatar.jpg',
|
||||
userDisplayName: 'Test User',
|
||||
userEmail: 'test@example.com',
|
||||
handleSignOut: mockHandleSignOut
|
||||
userEmail: 'test@example.com'
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -157,8 +155,8 @@ describe('CurrentUserPopover', () => {
|
||||
// Click the logout button
|
||||
await logoutButton.trigger('click')
|
||||
|
||||
// Verify handleSignOut was called
|
||||
expect(mockHandleSignOut).toHaveBeenCalled()
|
||||
// Verify logout was called
|
||||
expect(mockLogout).toHaveBeenCalled()
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
|
||||
@@ -88,8 +88,7 @@ const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const { userDisplayName, userEmail, userPhotoUrl } = useCurrentUser()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
@@ -104,7 +103,7 @@ const handleTopUp = () => {
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await handleSignOut()
|
||||
await authActions.logout()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
|
||||
<SearchBox v-model:="searchQuery" class="max-w-[384px]" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
@@ -59,13 +59,12 @@
|
||||
<div class="relative px-6 pt-2 pb-4 flex gap-2">
|
||||
<MultiSelect
|
||||
v-model="selectedFrameworks"
|
||||
v-model:search-query="searchText"
|
||||
class="w-[250px]"
|
||||
:has-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:has-clear-button="true"
|
||||
label="Select Frameworks"
|
||||
:options="frameworkOptions"
|
||||
:show-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:show-clear-button="true"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
@@ -136,7 +135,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { provide, ref, watch } from 'vue'
|
||||
import { provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
@@ -202,18 +201,9 @@ const { onClose } = defineProps<{
|
||||
provide(OnCloseKey, onClose)
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
const searchText = ref<string>('')
|
||||
const selectedFrameworks = ref([])
|
||||
const selectedProjects = ref([])
|
||||
const selectedSort = ref<string>('popular')
|
||||
|
||||
const selectedNavItem = ref<string | null>('installed')
|
||||
|
||||
watch(searchText, (newQuery) => {
|
||||
console.log('searchText:', searchText.value, newQuery)
|
||||
})
|
||||
|
||||
watch(searchQuery, (newQuery) => {
|
||||
console.log('searchQuery:', searchQuery.value, newQuery)
|
||||
})
|
||||
</script>
|
||||
@@ -240,9 +240,6 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
v-model="selectedFrameworks"
|
||||
label="Select Frameworks"
|
||||
:options="frameworkOptions"
|
||||
:has-search-box="true"
|
||||
:show-selected-count="true"
|
||||
:has-clear-button="true"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { t } from '@/i18n'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
@@ -11,8 +8,6 @@ export const useCurrentUser = () => {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const commandStore = useCommandStore()
|
||||
const apiKeyStore = useApiKeyAuthStore()
|
||||
const dialogService = useDialogService()
|
||||
const { deleteAccount } = useFirebaseAuthActions()
|
||||
|
||||
const firebaseUser = computed(() => authStore.currentUser)
|
||||
const isApiKeyLogin = computed(() => apiKeyStore.isAuthenticated)
|
||||
@@ -90,18 +85,6 @@ export const useCurrentUser = () => {
|
||||
await commandStore.execute('Comfy.User.OpenSignInDialog')
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = await dialogService.confirm({
|
||||
title: t('auth.deleteAccount.confirmTitle'),
|
||||
message: t('auth.deleteAccount.confirmMessage'),
|
||||
type: 'delete'
|
||||
})
|
||||
|
||||
if (confirmed) {
|
||||
await deleteAccount()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading: authStore.loading,
|
||||
isLoggedIn,
|
||||
@@ -113,7 +96,6 @@ export const useCurrentUser = () => {
|
||||
providerName,
|
||||
providerIcon,
|
||||
handleSignOut,
|
||||
handleSignIn,
|
||||
handleDeleteAccount
|
||||
handleSignIn
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,27 +100,33 @@ export const useFirebaseAuthActions = () => {
|
||||
return await authStore.fetchBalance()
|
||||
}, reportError)
|
||||
|
||||
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {
|
||||
return await authStore.loginWithGoogle()
|
||||
}, reportError)
|
||||
const signInWithGoogle = (errorHandler = reportError) =>
|
||||
wrapWithErrorHandlingAsync(async () => {
|
||||
return await authStore.loginWithGoogle()
|
||||
}, errorHandler)
|
||||
|
||||
const signInWithGithub = wrapWithErrorHandlingAsync(async () => {
|
||||
return await authStore.loginWithGithub()
|
||||
}, reportError)
|
||||
const signInWithGithub = (errorHandler = reportError) =>
|
||||
wrapWithErrorHandlingAsync(async () => {
|
||||
return await authStore.loginWithGithub()
|
||||
}, errorHandler)
|
||||
|
||||
const signInWithEmail = wrapWithErrorHandlingAsync(
|
||||
async (email: string, password: string) => {
|
||||
const signInWithEmail = (
|
||||
email: string,
|
||||
password: string,
|
||||
errorHandler = reportError
|
||||
) =>
|
||||
wrapWithErrorHandlingAsync(async () => {
|
||||
return await authStore.login(email, password)
|
||||
},
|
||||
reportError
|
||||
)
|
||||
}, errorHandler)
|
||||
|
||||
const signUpWithEmail = wrapWithErrorHandlingAsync(
|
||||
async (email: string, password: string) => {
|
||||
const signUpWithEmail = (
|
||||
email: string,
|
||||
password: string,
|
||||
errorHandler = reportError
|
||||
) =>
|
||||
wrapWithErrorHandlingAsync(async () => {
|
||||
return await authStore.register(email, password)
|
||||
},
|
||||
reportError
|
||||
)
|
||||
}, errorHandler)
|
||||
|
||||
const updatePassword = wrapWithErrorHandlingAsync(
|
||||
async (newPassword: string) => {
|
||||
@@ -135,16 +141,6 @@ export const useFirebaseAuthActions = () => {
|
||||
reportError
|
||||
)
|
||||
|
||||
const deleteAccount = wrapWithErrorHandlingAsync(async () => {
|
||||
await authStore.deleteAccount()
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: t('auth.deleteAccount.success'),
|
||||
detail: t('auth.deleteAccount.successDetail'),
|
||||
life: 5000
|
||||
})
|
||||
}, reportError)
|
||||
|
||||
return {
|
||||
logout,
|
||||
sendPasswordReset,
|
||||
@@ -156,7 +152,7 @@ export const useFirebaseAuthActions = () => {
|
||||
signInWithEmail,
|
||||
signUpWithEmail,
|
||||
updatePassword,
|
||||
deleteAccount,
|
||||
accessError
|
||||
accessError,
|
||||
reportError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
@@ -179,12 +179,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
const numImagesWidget = node.widgets?.find(
|
||||
(w) => w.name === 'num_images'
|
||||
) as IComboWidget
|
||||
const characterInput = node.inputs?.find(
|
||||
(i) => i.name === 'character_image'
|
||||
) as INodeInputSlot
|
||||
const hasCharacter =
|
||||
typeof characterInput?.link !== 'undefined' &&
|
||||
characterInput.link != null
|
||||
|
||||
if (!renderingSpeedWidget)
|
||||
return '$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
|
||||
@@ -194,23 +188,11 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
const renderingSpeed = String(renderingSpeedWidget.value)
|
||||
if (renderingSpeed.toLowerCase().includes('quality')) {
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.2
|
||||
} else {
|
||||
basePrice = 0.09
|
||||
}
|
||||
} else if (renderingSpeed.toLowerCase().includes('default')) {
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.15
|
||||
} else {
|
||||
basePrice = 0.06
|
||||
}
|
||||
basePrice = 0.09
|
||||
} else if (renderingSpeed.toLowerCase().includes('balanced')) {
|
||||
basePrice = 0.06
|
||||
} else if (renderingSpeed.toLowerCase().includes('turbo')) {
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.1
|
||||
} else {
|
||||
basePrice = 0.03
|
||||
}
|
||||
basePrice = 0.03
|
||||
}
|
||||
|
||||
const totalCost = (basePrice * numImages).toFixed(2)
|
||||
@@ -413,12 +395,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
const modeValue = String(modeWidget.value)
|
||||
|
||||
// Same pricing matrix as KlingTextToVideoNode
|
||||
if (modeValue.includes('v2-1')) {
|
||||
if (modeValue.includes('10s')) {
|
||||
return '$0.98/Run' // pro, 10s
|
||||
}
|
||||
return '$0.49/Run' // pro, 5s default
|
||||
} else if (modeValue.includes('v2-master')) {
|
||||
if (modeValue.includes('v2-master')) {
|
||||
if (modeValue.includes('10s')) {
|
||||
return '$2.80/Run'
|
||||
}
|
||||
@@ -1485,7 +1462,7 @@ export const useNodePricing = () => {
|
||||
OpenAIGPTImage1: ['quality', 'n'],
|
||||
IdeogramV1: ['num_images', 'turbo'],
|
||||
IdeogramV2: ['num_images', 'turbo'],
|
||||
IdeogramV3: ['rendering_speed', 'num_images', 'character_image'],
|
||||
IdeogramV3: ['rendering_speed', 'num_images'],
|
||||
FluxProKontextProNode: [],
|
||||
FluxProKontextMaxNode: [],
|
||||
VeoVideoGenerationNode: ['duration_seconds'],
|
||||
|
||||
@@ -75,29 +75,6 @@ export const useComputedWithWidgetWatch = (
|
||||
}
|
||||
})
|
||||
})
|
||||
if (widgetNames && widgetNames.length > widgetsToObserve.length) {
|
||||
//Inputs have been included
|
||||
const indexesToObserve = widgetNames
|
||||
.map((name) =>
|
||||
widgetsToObserve.some((w) => w.name == name)
|
||||
? -1
|
||||
: node.inputs.findIndex((i) => i.name == name)
|
||||
)
|
||||
.filter((i) => i >= 0)
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
(_type: unknown, index: number, isConnected: boolean) => {
|
||||
if (!indexesToObserve.includes(index)) return
|
||||
widgetValues.value = {
|
||||
...widgetValues.value,
|
||||
[indexesToObserve[index]]: isConnected
|
||||
}
|
||||
if (triggerCanvasRedraw) {
|
||||
node.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a function that creates a computed that responds to widget changes.
|
||||
|
||||
@@ -446,9 +446,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
)
|
||||
group.resizeTo(canvas.selectedItems, padding)
|
||||
canvas.graph?.add(group)
|
||||
|
||||
group.recomputeInsideNodes()
|
||||
|
||||
useTitleEditorStore().titleEditorTarget = group
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import SampleModelSelector from '@/components/widget/SampleModelSelector.vue'
|
||||
import ModelSelector from '@/components/widget/ModelSelector.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -15,7 +15,7 @@ export const useModelSelectorDialog = () => {
|
||||
function show() {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: SampleModelSelector,
|
||||
component: ModelSelector,
|
||||
props: {
|
||||
onClose: hide
|
||||
}
|
||||
|
||||
@@ -18,9 +18,268 @@ import {
|
||||
type ComfyWidgetConstructorV2,
|
||||
addValueControlWidgets
|
||||
} from '@/scripts/widgets'
|
||||
import { fileNameMappingService } from '@/services/fileNameMappingService'
|
||||
|
||||
import { useRemoteWidget } from './useRemoteWidget'
|
||||
|
||||
// Common file extensions that indicate file inputs
|
||||
const FILE_EXTENSIONS = [
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.tiff',
|
||||
'.svg',
|
||||
'.safetensors',
|
||||
'.ckpt',
|
||||
'.pt',
|
||||
'.pth',
|
||||
'.bin'
|
||||
]
|
||||
|
||||
/**
|
||||
* Check if options contain filename-like values
|
||||
*/
|
||||
function hasFilenameOptions(options: any[]): boolean {
|
||||
return options.some((opt: any) => {
|
||||
if (typeof opt !== 'string') return false
|
||||
// Check for common file extensions
|
||||
const hasExtension = FILE_EXTENSIONS.some((ext) =>
|
||||
opt.toLowerCase().endsWith(ext)
|
||||
)
|
||||
// Check for hash-like filenames (ComfyUI hashed files)
|
||||
const isHashLike = /^[a-f0-9]{8,}\./i.test(opt)
|
||||
return hasExtension || isHashLike
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filename mapping to a widget using a simplified approach
|
||||
*/
|
||||
function applyFilenameMappingToWidget(
|
||||
widget: IComboWidget,
|
||||
node: LGraphNode,
|
||||
inputSpec: ComboInputSpec
|
||||
) {
|
||||
// Simple approach: just override _displayValue for text display
|
||||
// Leave all widget functionality intact
|
||||
console.debug(
|
||||
`[FilenameMapping] STARTING applyFilenameMappingToWidget for:`,
|
||||
{
|
||||
inputName: inputSpec.name,
|
||||
widgetName: widget.name,
|
||||
currentOptions: widget.options,
|
||||
currentValues: Array.isArray(widget.options?.values)
|
||||
? widget.options.values.slice(0, 3)
|
||||
: widget.options?.values || 'none'
|
||||
}
|
||||
)
|
||||
|
||||
// Override serializeValue to ensure hash is used for API
|
||||
;(widget as any).serializeValue = function () {
|
||||
// Always return the actual widget value (hash) for serialization
|
||||
return widget.value
|
||||
}
|
||||
|
||||
// Override _displayValue to show human-readable names
|
||||
Object.defineProperty(widget, '_displayValue', {
|
||||
get() {
|
||||
if ((widget as any).computedDisabled) return ''
|
||||
|
||||
// Get current hash value
|
||||
const hashValue = widget.value
|
||||
if (typeof hashValue !== 'string') return String(hashValue)
|
||||
|
||||
// Try to get human-readable name from cache (deduplicated for display)
|
||||
const mapping = fileNameMappingService.getCachedMapping('input', true)
|
||||
const humanName = mapping[hashValue]
|
||||
|
||||
// Return human name for display, fallback to hash
|
||||
return humanName || hashValue
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
// Also override the options.values to show human names in dropdown
|
||||
const originalOptions = widget.options as any
|
||||
|
||||
// Store original values array - maintain the same array reference
|
||||
const rawValues = Array.isArray(originalOptions.values)
|
||||
? originalOptions.values
|
||||
: []
|
||||
|
||||
console.debug('[FilenameMapping] Initial raw values:', rawValues)
|
||||
|
||||
// Create a computed property that returns mapped values
|
||||
Object.defineProperty(widget.options, 'values', {
|
||||
get() {
|
||||
if (!Array.isArray(rawValues)) return rawValues
|
||||
|
||||
// Map values to human-readable names (deduplicated for dropdown display)
|
||||
const mapping = fileNameMappingService.getCachedMapping('input', true)
|
||||
const mapped = rawValues.map((value: any) => {
|
||||
if (typeof value === 'string') {
|
||||
const humanName = mapping[value]
|
||||
if (humanName) {
|
||||
console.debug(`[FilenameMapping] Mapped ${value} -> ${humanName}`)
|
||||
return humanName
|
||||
}
|
||||
}
|
||||
return value
|
||||
})
|
||||
console.debug('[FilenameMapping] Returning mapped values:', mapped)
|
||||
return mapped
|
||||
},
|
||||
set(newValues) {
|
||||
// Update raw values array in place to maintain reference
|
||||
rawValues.length = 0
|
||||
if (Array.isArray(newValues)) {
|
||||
rawValues.push(...newValues)
|
||||
}
|
||||
console.debug('[FilenameMapping] Values set to:', rawValues)
|
||||
// Trigger UI update
|
||||
node.setDirtyCanvas?.(true, true)
|
||||
node.graph?.setDirtyCanvas?.(true, true)
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
|
||||
// Add helper methods for managing the raw values
|
||||
;(widget as any).getRawValues = function () {
|
||||
return rawValues
|
||||
}
|
||||
|
||||
// Add a method to force refresh the dropdown
|
||||
;(widget as any).refreshMappings = function () {
|
||||
console.debug('[FilenameMapping] Force refreshing dropdown')
|
||||
// Force litegraph to re-read the values
|
||||
const currentValues = widget.options.values
|
||||
console.debug('[FilenameMapping] Current mapped values:', currentValues)
|
||||
// Trigger UI update
|
||||
node.setDirtyCanvas?.(true, true)
|
||||
node.graph?.setDirtyCanvas?.(true, true)
|
||||
}
|
||||
|
||||
// Override incrementValue and decrementValue for arrow key navigation
|
||||
;(widget as any).incrementValue = function (options: any) {
|
||||
// Get the current human-readable value (deduplicated)
|
||||
const mapping = fileNameMappingService.getCachedMapping('input', true)
|
||||
const currentHumanName = mapping[widget.value] || widget.value
|
||||
|
||||
// Get the values array (which contains human names through our proxy)
|
||||
const rawValues = widget.options?.values
|
||||
if (!rawValues || typeof rawValues === 'function') return
|
||||
|
||||
const values = Array.isArray(rawValues)
|
||||
? rawValues
|
||||
: Object.values(rawValues)
|
||||
const currentIndex = values.indexOf(currentHumanName as any)
|
||||
|
||||
if (currentIndex >= 0 && currentIndex < values.length - 1) {
|
||||
// Get next value and set it (setValue will handle conversion)
|
||||
const nextValue = values[currentIndex + 1]
|
||||
;(widget as any).setValue(nextValue, options)
|
||||
}
|
||||
}
|
||||
;(widget as any).decrementValue = function (options: any) {
|
||||
// Get the current human-readable value (deduplicated)
|
||||
const mapping = fileNameMappingService.getCachedMapping('input', true)
|
||||
const currentHumanName = mapping[widget.value] || widget.value
|
||||
|
||||
// Get the values array (which contains human names through our proxy)
|
||||
const rawValues = widget.options?.values
|
||||
if (!rawValues || typeof rawValues === 'function') return
|
||||
|
||||
const values = Array.isArray(rawValues)
|
||||
? rawValues
|
||||
: Object.values(rawValues)
|
||||
const currentIndex = values.indexOf(currentHumanName as any)
|
||||
|
||||
if (currentIndex > 0) {
|
||||
// Get previous value and set it (setValue will handle conversion)
|
||||
const prevValue = values[currentIndex - 1]
|
||||
;(widget as any).setValue(prevValue, options)
|
||||
}
|
||||
}
|
||||
|
||||
// Override setValue to handle human name selection from dropdown
|
||||
const originalSetValue = (widget as any).setValue
|
||||
;(widget as any).setValue = function (selectedValue: any, options?: any) {
|
||||
if (typeof selectedValue === 'string') {
|
||||
// Check if this is a human-readable name that needs reverse mapping
|
||||
// Use deduplicated reverse mapping to handle suffixed names
|
||||
const reverseMapping = fileNameMappingService.getCachedReverseMapping(
|
||||
'input',
|
||||
true
|
||||
)
|
||||
const hashValue = reverseMapping[selectedValue] || selectedValue
|
||||
|
||||
// Set the hash value
|
||||
widget.value = hashValue
|
||||
|
||||
// Call original setValue with hash value if it exists
|
||||
if (originalSetValue) {
|
||||
originalSetValue.call(widget, hashValue, options)
|
||||
}
|
||||
|
||||
// Trigger callback with hash value
|
||||
if (widget.callback) {
|
||||
widget.callback.call(widget, hashValue)
|
||||
}
|
||||
} else {
|
||||
widget.value = selectedValue
|
||||
if (originalSetValue) {
|
||||
originalSetValue.call(widget, selectedValue, options)
|
||||
}
|
||||
if (widget.callback) {
|
||||
widget.callback.call(widget, selectedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override callback to handle human name selection
|
||||
const originalCallback = widget.callback
|
||||
widget.callback = function (selectedValue: any) {
|
||||
if (typeof selectedValue === 'string') {
|
||||
// Check if this is a human-readable name that needs reverse mapping
|
||||
// Use deduplicated reverse mapping to handle suffixed names
|
||||
const reverseMapping = fileNameMappingService.getCachedReverseMapping(
|
||||
'input',
|
||||
true
|
||||
)
|
||||
const hashValue = reverseMapping[selectedValue] || selectedValue
|
||||
|
||||
// Set the hash value
|
||||
widget.value = hashValue
|
||||
|
||||
// Call original callback with hash value
|
||||
if (originalCallback) {
|
||||
originalCallback.call(widget, hashValue)
|
||||
}
|
||||
} else {
|
||||
widget.value = selectedValue
|
||||
if (originalCallback) {
|
||||
originalCallback.call(widget, selectedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger async load of mappings and update display when ready
|
||||
fileNameMappingService
|
||||
.getMapping('input')
|
||||
.then(() => {
|
||||
// Mappings loaded, trigger redraw to update display
|
||||
node.setDirtyCanvas?.(true, true)
|
||||
node.graph?.setDirtyCanvas?.(true, true)
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - will show hash values as fallback
|
||||
})
|
||||
}
|
||||
|
||||
const getDefaultValue = (inputSpec: ComboInputSpec) => {
|
||||
if (inputSpec.default) return inputSpec.default
|
||||
if (inputSpec.options?.length) return inputSpec.options[0]
|
||||
@@ -91,6 +350,31 @@ const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
)
|
||||
}
|
||||
|
||||
// For non-remote combo widgets, check if they contain filenames and apply mapping
|
||||
if (!inputSpec.remote && inputSpec.options) {
|
||||
// Check if options contain filename-like values
|
||||
const hasFilenames = hasFilenameOptions(inputSpec.options)
|
||||
|
||||
console.debug(
|
||||
'[FilenameMapping] Checking combo widget for filename mapping:',
|
||||
{
|
||||
inputName: inputSpec.name,
|
||||
hasFilenames,
|
||||
optionsCount: inputSpec.options.length,
|
||||
sampleOptions: inputSpec.options.slice(0, 3)
|
||||
}
|
||||
)
|
||||
|
||||
if (hasFilenames) {
|
||||
// Apply filename mapping for display
|
||||
console.debug(
|
||||
'[FilenameMapping] Applying filename mapping to widget:',
|
||||
inputSpec.name
|
||||
)
|
||||
applyFilenameMappingToWidget(widget, node, inputSpec)
|
||||
}
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { fileNameMappingService } from '@/services/fileNameMappingService'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { isImageUploadInput } from '@/types/nodeDefAugmentation'
|
||||
import { createAnnotatedPath } from '@/utils/formatUtil'
|
||||
@@ -76,11 +77,49 @@ export const useImageUploadWidget = () => {
|
||||
fileFilter,
|
||||
accept,
|
||||
folder,
|
||||
onUploadComplete: (output) => {
|
||||
output.forEach((path) => addToComboValues(fileComboWidget, path))
|
||||
onUploadComplete: async (output) => {
|
||||
console.debug('[ImageUpload] Upload complete, output:', output)
|
||||
|
||||
// CRITICAL: Refresh mappings FIRST before updating dropdown
|
||||
// This ensures new hash→human mappings are available when dropdown renders
|
||||
try {
|
||||
await fileNameMappingService.refreshMapping('input')
|
||||
console.debug(
|
||||
'[ImageUpload] Filename mappings refreshed, updating dropdown'
|
||||
)
|
||||
} catch (error) {
|
||||
console.debug(
|
||||
'[ImageUpload] Failed to refresh filename mappings:',
|
||||
error
|
||||
)
|
||||
// Continue anyway - will show hash values as fallback
|
||||
}
|
||||
|
||||
// Now add the files to dropdown - addToComboValues will trigger refreshMappings
|
||||
output.forEach((path) => {
|
||||
console.debug('[ImageUpload] Adding to combo values:', path)
|
||||
addToComboValues(fileComboWidget, path)
|
||||
})
|
||||
|
||||
// Set the widget value to the newly uploaded files
|
||||
// Use the last uploaded file for single selection widgets
|
||||
const selectedValue = allow_batch ? output : output[output.length - 1]
|
||||
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
fileComboWidget.value = output
|
||||
fileComboWidget.callback?.(output)
|
||||
fileComboWidget.value = selectedValue
|
||||
fileComboWidget.callback?.(selectedValue)
|
||||
|
||||
// Force one more refresh to ensure UI is in sync
|
||||
if (typeof (fileComboWidget as any).refreshMappings === 'function') {
|
||||
console.debug('[ImageUpload] Final refreshMappings call for UI sync')
|
||||
;(fileComboWidget as any).refreshMappings()
|
||||
}
|
||||
|
||||
// Trigger UI update to show human-readable names
|
||||
node.setDirtyCanvas?.(true, true)
|
||||
node.graph?.setDirtyCanvas?.(true, true)
|
||||
|
||||
console.debug('[ImageUpload] Upload handling complete')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const MAX_RETRIES = 5
|
||||
const TIMEOUT = 4096
|
||||
@@ -58,10 +59,21 @@ const fetchData = async (
|
||||
controller: AbortController
|
||||
) => {
|
||||
const { route, response_key, query_params, timeout = TIMEOUT } = config
|
||||
|
||||
// Get auth header from Firebase
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (authHeader) {
|
||||
Object.assign(headers, authHeader)
|
||||
}
|
||||
|
||||
const res = await axios.get(route, {
|
||||
params: query_params,
|
||||
signal: controller.signal,
|
||||
timeout
|
||||
timeout,
|
||||
headers
|
||||
})
|
||||
return response_key ? res.data[response_key] : res.data
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export const COMFY_API_BASE_URL = __USE_PROD_CONFIG__
|
||||
import { isProductionEnvironment } from './environment'
|
||||
|
||||
export const COMFY_API_BASE_URL = isProductionEnvironment()
|
||||
? 'https://api.comfy.org'
|
||||
: 'https://stagingapi.comfy.org'
|
||||
|
||||
export const COMFY_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
|
||||
export const COMFY_PLATFORM_BASE_URL = isProductionEnvironment()
|
||||
? 'https://platform.comfy.org'
|
||||
: 'https://stagingplatform.comfy.org'
|
||||
|
||||
18
src/config/environment.ts
Normal file
18
src/config/environment.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Runtime environment configuration that determines if we're in production or staging
|
||||
* based on the hostname. Replaces the build-time __USE_PROD_CONFIG__ constant.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if the application is running in production environment
|
||||
* @returns true if hostname is cloud.comfy.org (production), false otherwise (staging)
|
||||
*/
|
||||
export function isProductionEnvironment(): boolean {
|
||||
// In SSR/Node.js environments or during build, use the environment variable
|
||||
if (typeof window === 'undefined') {
|
||||
return process.env.USE_PROD_CONFIG === 'true'
|
||||
}
|
||||
|
||||
// In browser, check the hostname
|
||||
return window.location.hostname === 'cloud.comfy.org'
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FirebaseOptions } from 'firebase/app'
|
||||
|
||||
import { isProductionEnvironment } from './environment'
|
||||
|
||||
const DEV_CONFIG: FirebaseOptions = {
|
||||
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
|
||||
authDomain: 'dreamboothy-dev.firebaseapp.com',
|
||||
@@ -23,6 +25,6 @@ const PROD_CONFIG: FirebaseOptions = {
|
||||
}
|
||||
|
||||
// To test with prod config while using dev server, set USE_PROD_CONFIG=true in .env
|
||||
export const FIREBASE_CONFIG: FirebaseOptions = __USE_PROD_CONFIG__
|
||||
export const FIREBASE_CONFIG: FirebaseOptions = isProductionEnvironment()
|
||||
? PROD_CONFIG
|
||||
: DEV_CONFIG
|
||||
|
||||
@@ -13,7 +13,6 @@ export const CORE_MENU_COMMANDS = [
|
||||
],
|
||||
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
|
||||
[['Edit'], ['Comfy.OpenClipspace']],
|
||||
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
|
||||
[
|
||||
['Help'],
|
||||
[
|
||||
|
||||
@@ -42,8 +42,6 @@ app.registerExtension({
|
||||
this.graph.add(group)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.graph.change()
|
||||
|
||||
group.recomputeInsideNodes()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import QuickLRU from '@alloc/quick-lru'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import _ from 'es-toolkit/compat'
|
||||
|
||||
@@ -10,7 +9,6 @@ import { ComfyApp } from '../../scripts/app'
|
||||
import { $el, ComfyDialog } from '../../scripts/ui'
|
||||
import { getStorageValue, setStorageValue } from '../../scripts/utils'
|
||||
import { hexToRgb } from '../../utils/colorUtil'
|
||||
import { parseToRgb } from '../../utils/colorUtil'
|
||||
import { ClipspaceDialog } from './clipspace'
|
||||
import {
|
||||
imageLayerFilenamesByTimestamp,
|
||||
@@ -813,7 +811,7 @@ interface Offset {
|
||||
y: number
|
||||
}
|
||||
|
||||
interface Brush {
|
||||
export interface Brush {
|
||||
type: BrushShape
|
||||
size: number
|
||||
opacity: number
|
||||
@@ -2051,16 +2049,9 @@ class BrushTool {
|
||||
rgbCtx: CanvasRenderingContext2D | null = null
|
||||
initialDraw: boolean = true
|
||||
|
||||
private static brushTextureCache = new QuickLRU<string, HTMLCanvasElement>({
|
||||
maxSize: 8 // Reasonable limit for brush texture variations?
|
||||
})
|
||||
|
||||
brushStrokeCanvas: HTMLCanvasElement | null = null
|
||||
brushStrokeCtx: CanvasRenderingContext2D | null = null
|
||||
|
||||
private static readonly SMOOTHING_MAX_STEPS = 30
|
||||
private static readonly SMOOTHING_MIN_STEPS = 2
|
||||
|
||||
//brush adjustment
|
||||
isBrushAdjusting: boolean = false
|
||||
brushPreviewGradient: HTMLElement | null = null
|
||||
@@ -2263,10 +2254,6 @@ class BrushTool {
|
||||
}
|
||||
}
|
||||
|
||||
private clampSmoothingPrecision(value: number): number {
|
||||
return Math.min(Math.max(value, 1), 100)
|
||||
}
|
||||
|
||||
private drawWithBetterSmoothing(point: Point) {
|
||||
// Add current point to the smoothing array
|
||||
if (!this.smoothingCordsArray) {
|
||||
@@ -2298,21 +2285,9 @@ class BrushTool {
|
||||
totalLength += Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
const maxSteps = BrushTool.SMOOTHING_MAX_STEPS
|
||||
const minSteps = BrushTool.SMOOTHING_MIN_STEPS
|
||||
|
||||
const smoothing = this.clampSmoothingPrecision(
|
||||
this.brushSettings.smoothingPrecision
|
||||
)
|
||||
const normalizedSmoothing = (smoothing - 1) / 99 // Convert to 0-1 range
|
||||
|
||||
// Optionality to use exponential curve
|
||||
const stepNr = Math.round(
|
||||
Math.round(minSteps + (maxSteps - minSteps) * normalizedSmoothing)
|
||||
)
|
||||
|
||||
// Calculate step distance capped by brush size
|
||||
const distanceBetweenPoints = totalLength / stepNr
|
||||
const distanceBetweenPoints =
|
||||
(this.brushSettings.size / this.brushSettings.smoothingPrecision) * 6
|
||||
const stepNr = Math.ceil(totalLength / distanceBetweenPoints)
|
||||
|
||||
let interpolatedPoints = points
|
||||
|
||||
@@ -2460,205 +2435,101 @@ class BrushTool {
|
||||
const hardness = brushSettings.hardness
|
||||
const x = point.x
|
||||
const y = point.y
|
||||
// Extend the gradient radius beyond the brush size
|
||||
const extendedSize = size * (2 - hardness)
|
||||
|
||||
const brushRadius = size
|
||||
const isErasing = maskCtx.globalCompositeOperation === 'destination-out'
|
||||
const currentTool = await this.messageBroker.pull('currentTool')
|
||||
|
||||
// Helper function to get or create cached brush texture
|
||||
const getCachedBrushTexture = (
|
||||
radius: number,
|
||||
hardness: number,
|
||||
color: string,
|
||||
opacity: number
|
||||
): HTMLCanvasElement => {
|
||||
const cacheKey = `${radius}_${hardness}_${color}_${opacity}`
|
||||
|
||||
if (BrushTool.brushTextureCache.has(cacheKey)) {
|
||||
return BrushTool.brushTextureCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
const tempCanvas = document.createElement('canvas')
|
||||
const tempCtx = tempCanvas.getContext('2d')!
|
||||
const size = radius * 2
|
||||
tempCanvas.width = size
|
||||
tempCanvas.height = size
|
||||
|
||||
const centerX = size / 2
|
||||
const centerY = size / 2
|
||||
const hardRadius = radius * hardness
|
||||
|
||||
const imageData = tempCtx.createImageData(size, size)
|
||||
const data = imageData.data
|
||||
const { r, g, b } = parseToRgb(color)
|
||||
|
||||
// Pre-calculate values to avoid repeated computations
|
||||
const fadeRange = radius - hardRadius
|
||||
|
||||
for (let y = 0; y < size; y++) {
|
||||
const dy = y - centerY
|
||||
for (let x = 0; x < size; x++) {
|
||||
const dx = x - centerX
|
||||
const index = (y * size + x) * 4
|
||||
|
||||
// Calculate square distance (Chebyshev distance)
|
||||
const distFromEdge = Math.max(Math.abs(dx), Math.abs(dy))
|
||||
|
||||
let pixelOpacity = 0
|
||||
if (distFromEdge <= hardRadius) {
|
||||
pixelOpacity = opacity
|
||||
} else if (distFromEdge <= radius) {
|
||||
const fadeProgress = (distFromEdge - hardRadius) / fadeRange
|
||||
pixelOpacity = opacity * (1 - fadeProgress)
|
||||
}
|
||||
|
||||
data[index] = r
|
||||
data[index + 1] = g
|
||||
data[index + 2] = b
|
||||
data[index + 3] = pixelOpacity * 255
|
||||
}
|
||||
}
|
||||
|
||||
tempCtx.putImageData(imageData, 0, 0)
|
||||
|
||||
// Cache the texture
|
||||
BrushTool.brushTextureCache.set(cacheKey, tempCanvas)
|
||||
|
||||
return tempCanvas
|
||||
}
|
||||
|
||||
// RGB brush logic
|
||||
// handle paint pen
|
||||
if (
|
||||
this.activeLayer === 'rgb' &&
|
||||
(currentTool === Tools.Eraser || currentTool === Tools.PaintPen)
|
||||
) {
|
||||
const rgbaColor = this.formatRgba(this.rgbColor, opacity)
|
||||
|
||||
if (brushType === BrushShape.Rect && hardness < 1) {
|
||||
const brushTexture = getCachedBrushTexture(
|
||||
brushRadius,
|
||||
hardness,
|
||||
rgbaColor,
|
||||
opacity
|
||||
)
|
||||
rgbCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
|
||||
return
|
||||
}
|
||||
|
||||
// For max hardness, use solid fill to avoid anti-aliasing
|
||||
let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, extendedSize)
|
||||
if (hardness === 1) {
|
||||
rgbCtx.fillStyle = rgbaColor
|
||||
rgbCtx.beginPath()
|
||||
if (brushType === BrushShape.Rect) {
|
||||
rgbCtx.rect(
|
||||
x - brushRadius,
|
||||
y - brushRadius,
|
||||
brushRadius * 2,
|
||||
brushRadius * 2
|
||||
)
|
||||
} else {
|
||||
rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||
}
|
||||
rgbCtx.fill()
|
||||
return
|
||||
gradient.addColorStop(0, rgbaColor)
|
||||
gradient.addColorStop(
|
||||
1,
|
||||
this.formatRgba(this.rgbColor, brushSettingsSliderOpacity)
|
||||
)
|
||||
} else {
|
||||
gradient.addColorStop(0, rgbaColor)
|
||||
gradient.addColorStop(hardness, rgbaColor)
|
||||
gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0))
|
||||
}
|
||||
|
||||
// For soft brushes, use gradient
|
||||
let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
|
||||
gradient.addColorStop(0, rgbaColor)
|
||||
gradient.addColorStop(
|
||||
hardness,
|
||||
this.formatRgba(this.rgbColor, opacity * 0.5)
|
||||
)
|
||||
gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0))
|
||||
|
||||
rgbCtx.fillStyle = gradient
|
||||
rgbCtx.beginPath()
|
||||
if (brushType === BrushShape.Rect) {
|
||||
rgbCtx.rect(
|
||||
x - brushRadius,
|
||||
y - brushRadius,
|
||||
brushRadius * 2,
|
||||
brushRadius * 2
|
||||
x - extendedSize,
|
||||
y - extendedSize,
|
||||
extendedSize * 2,
|
||||
extendedSize * 2
|
||||
)
|
||||
} else {
|
||||
rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||
rgbCtx.arc(x, y, extendedSize, 0, Math.PI * 2, false)
|
||||
}
|
||||
rgbCtx.fill()
|
||||
return
|
||||
}
|
||||
|
||||
// Mask brush logic
|
||||
if (brushType === BrushShape.Rect && hardness < 1) {
|
||||
const baseColor = isErasing
|
||||
? `rgba(255, 255, 255, ${opacity})`
|
||||
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
|
||||
const brushTexture = getCachedBrushTexture(
|
||||
brushRadius,
|
||||
hardness,
|
||||
baseColor,
|
||||
opacity
|
||||
)
|
||||
maskCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
|
||||
return
|
||||
}
|
||||
|
||||
// For max hardness, use solid fill to avoid anti-aliasing
|
||||
let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, extendedSize)
|
||||
if (hardness === 1) {
|
||||
const solidColor = isErasing
|
||||
? `rgba(255, 255, 255, ${opacity})`
|
||||
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
|
||||
maskCtx.fillStyle = solidColor
|
||||
maskCtx.beginPath()
|
||||
if (brushType === BrushShape.Rect) {
|
||||
maskCtx.rect(
|
||||
x - brushRadius,
|
||||
y - brushRadius,
|
||||
brushRadius * 2,
|
||||
brushRadius * 2
|
||||
)
|
||||
} else {
|
||||
maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||
}
|
||||
maskCtx.fill()
|
||||
return
|
||||
}
|
||||
|
||||
// For soft brushes, use gradient
|
||||
let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
|
||||
|
||||
if (isErasing) {
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
|
||||
gradient.addColorStop(hardness, `rgba(255, 255, 255, ${opacity * 0.5})`)
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
|
||||
} else {
|
||||
gradient.addColorStop(
|
||||
0,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
)
|
||||
gradient.addColorStop(
|
||||
hardness,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity * 0.5})`
|
||||
isErasing
|
||||
? `rgba(255, 255, 255, ${opacity})`
|
||||
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
)
|
||||
gradient.addColorStop(
|
||||
1,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)`
|
||||
isErasing
|
||||
? `rgba(255, 255, 255, ${opacity})`
|
||||
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
)
|
||||
} else {
|
||||
let softness = 1 - hardness
|
||||
let innerStop = Math.max(0, hardness - softness)
|
||||
let outerStop = size / extendedSize
|
||||
|
||||
if (isErasing) {
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
|
||||
gradient.addColorStop(innerStop, `rgba(255, 255, 255, ${opacity})`)
|
||||
gradient.addColorStop(outerStop, `rgba(255, 255, 255, ${opacity / 2})`)
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
|
||||
} else {
|
||||
gradient.addColorStop(
|
||||
0,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
)
|
||||
gradient.addColorStop(
|
||||
innerStop,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
|
||||
)
|
||||
gradient.addColorStop(
|
||||
outerStop,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity / 2})`
|
||||
)
|
||||
gradient.addColorStop(
|
||||
1,
|
||||
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
maskCtx.fillStyle = gradient
|
||||
maskCtx.beginPath()
|
||||
if (brushType === BrushShape.Rect) {
|
||||
maskCtx.rect(
|
||||
x - brushRadius,
|
||||
y - brushRadius,
|
||||
brushRadius * 2,
|
||||
brushRadius * 2
|
||||
x - extendedSize,
|
||||
y - extendedSize,
|
||||
extendedSize * 2,
|
||||
extendedSize * 2
|
||||
)
|
||||
} else {
|
||||
maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
|
||||
maskCtx.arc(x, y, extendedSize, 0, Math.PI * 2, false)
|
||||
}
|
||||
maskCtx.fill()
|
||||
}
|
||||
@@ -4314,35 +4185,30 @@ class UIManager {
|
||||
const centerY = cursorPoint.y + pan_offset.y
|
||||
const brush = this.brush
|
||||
const hardness = brushSettings.hardness
|
||||
|
||||
// Now that brush size is constant, preview is simple
|
||||
const brushRadius = brushSettings.size * zoom_ratio
|
||||
const previewSize = brushRadius * 2
|
||||
const extendedSize = brushSettings.size * (2 - hardness) * 2 * zoom_ratio
|
||||
|
||||
this.brushSizeSlider.value = String(brushSettings.size)
|
||||
this.brushHardnessSlider.value = String(hardness)
|
||||
|
||||
brush.style.width = previewSize + 'px'
|
||||
brush.style.height = previewSize + 'px'
|
||||
brush.style.left = centerX - brushRadius + 'px'
|
||||
brush.style.top = centerY - brushRadius + 'px'
|
||||
brush.style.width = extendedSize + 'px'
|
||||
brush.style.height = extendedSize + 'px'
|
||||
brush.style.left = centerX - extendedSize / 2 + 'px'
|
||||
brush.style.top = centerY - extendedSize / 2 + 'px'
|
||||
|
||||
if (hardness === 1) {
|
||||
this.brushPreviewGradient.style.background = 'rgba(255, 0, 0, 0.5)'
|
||||
return
|
||||
}
|
||||
|
||||
// Simplified gradient - hardness controls where the fade starts
|
||||
const midStop = hardness * 100
|
||||
const outerStop = 100
|
||||
const opacityStop = hardness / 4 + 0.25
|
||||
|
||||
this.brushPreviewGradient.style.background = `
|
||||
radial-gradient(
|
||||
circle,
|
||||
rgba(255, 0, 0, 0.5) 0%,
|
||||
rgba(255, 0, 0, 0.25) ${midStop}%,
|
||||
rgba(255, 0, 0, 0) ${outerStop}%
|
||||
)
|
||||
radial-gradient(
|
||||
circle,
|
||||
rgba(255, 0, 0, 0.5) 0%,
|
||||
rgba(255, 0, 0, ${opacityStop}) ${hardness * 100}%,
|
||||
rgba(255, 0, 0, 0) 100%
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { DOMWidget } from '@/scripts/domWidget'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import { fileNameMappingService } from '@/services/fileNameMappingService'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { NodeLocatorId } from '@/types'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
@@ -66,10 +67,26 @@ async function uploadFile(
|
||||
|
||||
if (resp.status === 200) {
|
||||
const data = await resp.json()
|
||||
// Add the file to the dropdown list and update the widget value
|
||||
// Build the file path
|
||||
let path = data.name
|
||||
if (data.subfolder) path = data.subfolder + '/' + path
|
||||
|
||||
// CRITICAL: Refresh mappings FIRST before updating dropdown
|
||||
// This ensures new hash→human mappings are available when dropdown renders
|
||||
try {
|
||||
await fileNameMappingService.refreshMapping('input')
|
||||
console.debug(
|
||||
'[AudioUpload] Filename mappings refreshed, updating dropdown'
|
||||
)
|
||||
} catch (error) {
|
||||
console.debug(
|
||||
'[AudioUpload] Failed to refresh filename mappings:',
|
||||
error
|
||||
)
|
||||
// Continue anyway - will show hash values as fallback
|
||||
}
|
||||
|
||||
// Now add the file to the dropdown list - any filename proxy will use fresh mappings
|
||||
// @ts-expect-error fixme ts strict error
|
||||
if (!audioWidget.options.values.includes(path)) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
|
||||
@@ -141,8 +141,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
*/
|
||||
resolveInput(
|
||||
slot: number,
|
||||
visited = new Set<string>(),
|
||||
type?: ISlotType
|
||||
visited = new Set<string>()
|
||||
): ResolvedInput | undefined {
|
||||
const uniqueId = `${this.subgraphNode?.subgraph.id}:${this.node.id}[I]${slot}`
|
||||
if (visited.has(uniqueId)) {
|
||||
@@ -233,11 +232,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
`No output node DTO found for id [${outputNodeExecutionId}]`
|
||||
)
|
||||
|
||||
return outputNodeDto.resolveOutput(
|
||||
link.origin_slot,
|
||||
type ?? input.type,
|
||||
visited
|
||||
)
|
||||
return outputNodeDto.resolveOutput(link.origin_slot, input.type, visited)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,7 +284,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
|
||||
// Upstreamed: Other virtual nodes are bypassed using the same input/output index (slots must match)
|
||||
if (node.isVirtualNode) {
|
||||
if (this.inputs.at(slot)) return this.resolveInput(slot, visited, type)
|
||||
if (this.inputs.at(slot)) return this.resolveInput(slot, visited)
|
||||
|
||||
// Fallback check for nodes performing link redirection
|
||||
const virtualLink = this.node.getInputLink(slot)
|
||||
|
||||
@@ -334,9 +334,12 @@ describe('LinkConnector Integration', () => {
|
||||
} = graph.getNodeById(nodeId)!
|
||||
|
||||
expect(input.link).toBeNull()
|
||||
// @ts-expect-error toBeOneOf not in type definitions
|
||||
expect(output.links?.length).toBeOneOf([0, undefined])
|
||||
|
||||
// @ts-expect-error toBeOneOf not in type definitions
|
||||
expect(input._floatingLinks?.size).toBeOneOf([0, undefined])
|
||||
// @ts-expect-error toBeOneOf not in type definitions
|
||||
expect(output._floatingLinks?.size).toBeOneOf([0, undefined])
|
||||
}
|
||||
})
|
||||
@@ -534,9 +537,12 @@ describe('LinkConnector Integration', () => {
|
||||
} = graph.getNodeById(nodeId)!
|
||||
|
||||
expect(input.link).toBeNull()
|
||||
// @ts-expect-error toBeOneOf not in type definitions
|
||||
expect(output.links?.length).toBeOneOf([0, undefined])
|
||||
|
||||
// @ts-expect-error toBeOneOf not in type definitions
|
||||
expect(input._floatingLinks?.size).toBeOneOf([0, undefined])
|
||||
// @ts-expect-error toBeOneOf not in type definitions
|
||||
expect(output._floatingLinks?.size).toBeOneOf([0, undefined])
|
||||
}
|
||||
})
|
||||
@@ -850,9 +856,12 @@ describe('LinkConnector Integration', () => {
|
||||
} = graph.getNodeById(nodeId)!
|
||||
|
||||
expect(input.link).toBeNull()
|
||||
// @ts-expect-error toBeOneOf not in type definitions
|
||||
expect(output.links?.length).toBeOneOf([0, undefined])
|
||||
|
||||
// @ts-expect-error toBeOneOf not in type definitions
|
||||
expect(input._floatingLinks?.size).toBeOneOf([0, undefined])
|
||||
// @ts-expect-error toBeOneOf not in type definitions
|
||||
expect(output._floatingLinks?.size).toBeOneOf([0, undefined])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
"title": "مفتاح API",
|
||||
"whitelistInfo": "حول المواقع غير المدرجة في القائمة البيضاء"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"cancel": "إلغاء",
|
||||
"confirm": "حذف الحساب",
|
||||
"confirmMessage": "هل أنت متأكد أنك تريد حذف حسابك؟ لا يمكن التراجع عن هذا الإجراء وسيتم حذف جميع بياناتك نهائيًا.",
|
||||
"confirmTitle": "حذف الحساب",
|
||||
"deleteAccount": "حذف الحساب",
|
||||
"success": "تم حذف الحساب",
|
||||
"successDetail": "تم حذف حسابك بنجاح."
|
||||
},
|
||||
"login": {
|
||||
"andText": "و",
|
||||
"confirmPasswordLabel": "تأكيد كلمة المرور",
|
||||
|
||||
@@ -1602,14 +1602,19 @@
|
||||
"success": "Password Updated",
|
||||
"successDetail": "Your password has been updated successfully"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"deleteAccount": "Delete Account",
|
||||
"confirmTitle": "Delete Account",
|
||||
"confirmMessage": "Are you sure you want to delete your account? This action cannot be undone and will permanently remove all your data.",
|
||||
"confirm": "Delete Account",
|
||||
"cancel": "Cancel",
|
||||
"success": "Account Deleted",
|
||||
"successDetail": "Your account has been successfully deleted."
|
||||
"errors": {
|
||||
"auth/invalid-email": "Please enter a valid email address.",
|
||||
"auth/user-disabled": "This account has been disabled. Please contact support.",
|
||||
"auth/user-not-found": "No account found with this email. Would you like to create a new account?",
|
||||
"auth/wrong-password": "The password you entered is incorrect. Please try again.",
|
||||
"auth/email-already-in-use": "An account with this email already exists. Try signing in instead.",
|
||||
"auth/weak-password": "Password is too weak. Please use a stronger password with at least 6 characters.",
|
||||
"auth/too-many-requests": "Too many login attempts. Please wait a moment and try again.",
|
||||
"auth/operation-not-allowed": "This sign-in method is not currently supported.",
|
||||
"auth/invalid-credential": "Invalid login credentials. Please check your email and password.",
|
||||
"auth/network-request-failed": "Network error. Please check your connection and try again.",
|
||||
"auth/popup-closed-by-user": "Sign-in was cancelled. Please try again.",
|
||||
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again."
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
"title": "Clave API",
|
||||
"whitelistInfo": "Acerca de los sitios no incluidos en la lista blanca"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Eliminar cuenta",
|
||||
"confirmMessage": "¿Estás seguro de que deseas eliminar tu cuenta? Esta acción no se puede deshacer y eliminará permanentemente todos tus datos.",
|
||||
"confirmTitle": "Eliminar cuenta",
|
||||
"deleteAccount": "Eliminar cuenta",
|
||||
"success": "Cuenta eliminada",
|
||||
"successDetail": "Tu cuenta ha sido eliminada exitosamente."
|
||||
},
|
||||
"login": {
|
||||
"andText": "y",
|
||||
"confirmPasswordLabel": "Confirmar contraseña",
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
"title": "Clé API",
|
||||
"whitelistInfo": "À propos des sites non autorisés"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer le compte",
|
||||
"confirmMessage": "Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible et supprimera définitivement toutes vos données.",
|
||||
"confirmTitle": "Supprimer le compte",
|
||||
"deleteAccount": "Supprimer le compte",
|
||||
"success": "Compte supprimé",
|
||||
"successDetail": "Votre compte a été supprimé avec succès."
|
||||
},
|
||||
"login": {
|
||||
"andText": "et",
|
||||
"confirmPasswordLabel": "Confirmer le mot de passe",
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
"title": "APIキー",
|
||||
"whitelistInfo": "ホワイトリストに登録されていないサイトについて"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "アカウントを削除",
|
||||
"confirmMessage": "本当にアカウントを削除しますか?この操作は元に戻せず、すべてのデータが完全に削除されます。",
|
||||
"confirmTitle": "アカウントを削除",
|
||||
"deleteAccount": "アカウントを削除",
|
||||
"success": "アカウントが削除されました",
|
||||
"successDetail": "アカウントは正常に削除されました。"
|
||||
},
|
||||
"login": {
|
||||
"andText": "および",
|
||||
"confirmPasswordLabel": "パスワードの確認",
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
"title": "API 키",
|
||||
"whitelistInfo": "비허용 사이트에 대하여"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"cancel": "취소",
|
||||
"confirm": "계정 삭제",
|
||||
"confirmMessage": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없으며 모든 데이터가 영구적으로 삭제됩니다.",
|
||||
"confirmTitle": "계정 삭제",
|
||||
"deleteAccount": "계정 삭제",
|
||||
"success": "계정이 삭제되었습니다",
|
||||
"successDetail": "계정이 성공적으로 삭제되었습니다."
|
||||
},
|
||||
"login": {
|
||||
"andText": "및",
|
||||
"confirmPasswordLabel": "비밀번호 확인",
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
"title": "API-ключ",
|
||||
"whitelistInfo": "О не включённых в белый список сайтах"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Удалить аккаунт",
|
||||
"confirmMessage": "Вы уверены, что хотите удалить свой аккаунт? Это действие необратимо и приведёт к безвозвратному удалению всех ваших данных.",
|
||||
"confirmTitle": "Удалить аккаунт",
|
||||
"deleteAccount": "Удалить аккаунт",
|
||||
"success": "Аккаунт удалён",
|
||||
"successDetail": "Ваш аккаунт был успешно удалён."
|
||||
},
|
||||
"login": {
|
||||
"andText": "и",
|
||||
"confirmPasswordLabel": "Подтвердите пароль",
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
"title": "API 金鑰",
|
||||
"whitelistInfo": "關於未列入白名單的網站"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"cancel": "取消",
|
||||
"confirm": "刪除帳號",
|
||||
"confirmMessage": "您確定要刪除您的帳號嗎?此操作無法復原,且將永久移除您所有的資料。",
|
||||
"confirmTitle": "刪除帳號",
|
||||
"deleteAccount": "刪除帳號",
|
||||
"success": "帳號已刪除",
|
||||
"successDetail": "您的帳號已成功刪除。"
|
||||
},
|
||||
"login": {
|
||||
"andText": "以及",
|
||||
"confirmPasswordLabel": "確認密碼",
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"label": "适应视图到选中节点"
|
||||
},
|
||||
"Comfy_Canvas_Lock": {
|
||||
"label": "锁定画布"
|
||||
"label": "鎖定畫布"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "下移选中的节点"
|
||||
@@ -93,7 +93,7 @@
|
||||
"label": "固定/取消固定选中项"
|
||||
},
|
||||
"Comfy_Canvas_Unlock": {
|
||||
"label": "解锁画布"
|
||||
"label": "解鎖畫布"
|
||||
},
|
||||
"Comfy_Canvas_ZoomIn": {
|
||||
"label": "放大"
|
||||
@@ -111,7 +111,7 @@
|
||||
"label": "联系支持"
|
||||
},
|
||||
"Comfy_Dev_ShowModelSelector": {
|
||||
"label": "显示模型选择器(开发)"
|
||||
"label": "顯示模型選擇器(開發)"
|
||||
},
|
||||
"Comfy_DuplicateWorkflow": {
|
||||
"label": "复制当前工作流"
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
"title": "API 密钥",
|
||||
"whitelistInfo": "关于非白名单网站"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"cancel": "取消",
|
||||
"confirm": "删除账户",
|
||||
"confirmMessage": "您确定要删除您的账户吗?此操作无法撤销,并且会永久删除您的所有数据。",
|
||||
"confirmTitle": "删除账户",
|
||||
"deleteAccount": "删除账户",
|
||||
"success": "账户已删除",
|
||||
"successDetail": "您的账户已成功删除。"
|
||||
},
|
||||
"login": {
|
||||
"andText": "和",
|
||||
"confirmPasswordLabel": "确认密码",
|
||||
@@ -322,7 +313,7 @@
|
||||
"feedback": "反馈",
|
||||
"filter": "过滤",
|
||||
"findIssues": "查找问题",
|
||||
"frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不相容。",
|
||||
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
|
||||
"frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 或更高版本。",
|
||||
"goToNode": "转到节点",
|
||||
"help": "帮助",
|
||||
@@ -419,17 +410,17 @@
|
||||
},
|
||||
"graphCanvasMenu": {
|
||||
"fitView": "适应视图",
|
||||
"focusMode": "专注模式",
|
||||
"hand": "拖拽",
|
||||
"hideLinks": "隐藏链接",
|
||||
"focusMode": "專注模式",
|
||||
"hand": "拖曳",
|
||||
"hideLinks": "隱藏連結",
|
||||
"panMode": "平移模式",
|
||||
"resetView": "重置视图",
|
||||
"select": "选择",
|
||||
"select": "選取",
|
||||
"selectMode": "选择模式",
|
||||
"showLinks": "显示链接",
|
||||
"showLinks": "顯示連結",
|
||||
"toggleMinimap": "切换小地图",
|
||||
"zoomIn": "放大",
|
||||
"zoomOptions": "缩放选项",
|
||||
"zoomOptions": "縮放選項",
|
||||
"zoomOut": "缩小"
|
||||
},
|
||||
"groupNode": {
|
||||
@@ -815,7 +806,7 @@
|
||||
"Increase Brush Size in MaskEditor": "在 MaskEditor 中增大笔刷大小",
|
||||
"Interrupt": "中断",
|
||||
"Load Default Workflow": "加载默认工作流",
|
||||
"Lock Canvas": "锁定画布",
|
||||
"Lock Canvas": "鎖定畫布",
|
||||
"Manage group nodes": "管理组节点",
|
||||
"Manager": "管理器",
|
||||
"Minimap": "小地图",
|
||||
@@ -856,8 +847,8 @@
|
||||
"Restart": "重启",
|
||||
"Save": "保存",
|
||||
"Save As": "另存为",
|
||||
"Show Keybindings Dialog": "显示快捷键对话框",
|
||||
"Show Model Selector (Dev)": "显示模型选择器(开发用)",
|
||||
"Show Keybindings Dialog": "顯示快捷鍵對話框",
|
||||
"Show Model Selector (Dev)": "顯示模型選擇器(開發用)",
|
||||
"Show Settings Dialog": "显示设置对话框",
|
||||
"Sign Out": "退出登录",
|
||||
"Toggle Essential Bottom Panel": "切换基础底部面板",
|
||||
@@ -870,7 +861,7 @@
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
|
||||
"Undo": "撤销",
|
||||
"Ungroup selected group nodes": "解散选中组节点",
|
||||
"Unlock Canvas": "解除锁定画布",
|
||||
"Unlock Canvas": "解除鎖定畫布",
|
||||
"Unpack the selected Subgraph": "解包选中子图",
|
||||
"Workflows": "工作流",
|
||||
"Zoom In": "放大画面",
|
||||
@@ -1693,8 +1684,8 @@
|
||||
},
|
||||
"versionMismatchWarning": {
|
||||
"dismiss": "关闭",
|
||||
"frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不相容。",
|
||||
"frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 版或更高版本。",
|
||||
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
|
||||
"frontendOutdated": "前端版本 {frontendVersion} 已过时。後端需要 {requiredVersion} 版或更高版本。",
|
||||
"title": "版本相容性警告",
|
||||
"updateFrontend": "更新前端"
|
||||
},
|
||||
@@ -1712,9 +1703,9 @@
|
||||
"saveWorkflow": "保存工作流"
|
||||
},
|
||||
"zoomControls": {
|
||||
"hideMinimap": "隐藏小地图",
|
||||
"label": "缩放控制",
|
||||
"showMinimap": "显示小地图",
|
||||
"zoomToFit": "适合画面"
|
||||
"hideMinimap": "隱藏小地圖",
|
||||
"label": "縮放控制",
|
||||
"showMinimap": "顯示小地圖",
|
||||
"zoomToFit": "適合畫面"
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,8 @@ Sentry.init({
|
||||
dsn: __SENTRY_DSN__,
|
||||
enabled: __SENTRY_ENABLED__,
|
||||
release: __COMFYUI_FRONTEND_VERSION__,
|
||||
integrations: [],
|
||||
autoSessionTracking: false,
|
||||
defaultIntegrations: false,
|
||||
normalizeDepth: 8,
|
||||
tracesSampleRate: 0
|
||||
tracesSampleRate: 1.0
|
||||
})
|
||||
app.directive('tooltip', Tooltip)
|
||||
app
|
||||
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
createWebHistory
|
||||
} from 'vue-router'
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
|
||||
|
||||
import { useUserStore } from './stores/userStore'
|
||||
import { isElectron } from './utils/envUtil'
|
||||
|
||||
const isFileProtocol = window.location.protocol === 'file:'
|
||||
const basePath = isElectron() ? '/' : window.location.pathname
|
||||
|
||||
@@ -130,4 +131,41 @@ const router = createRouter({
|
||||
}
|
||||
})
|
||||
|
||||
// Global authentication guard
|
||||
router.beforeEach(async (_to, _from, next) => {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
|
||||
// Wait for Firebase auth to initialize
|
||||
if (!authStore.isInitialized) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const unwatch = authStore.$subscribe((_, state) => {
|
||||
if (state.isInitialized) {
|
||||
unwatch()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user is authenticated (Firebase or API key)
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
|
||||
if (!authHeader) {
|
||||
// User is not authenticated, show sign-in dialog
|
||||
const dialogService = useDialogService()
|
||||
const loginSuccess = await dialogService.showSignInDialog()
|
||||
|
||||
if (loginSuccess) {
|
||||
// After successful login, proceed to the intended route
|
||||
next()
|
||||
} else {
|
||||
// User cancelled login, stay on current page or redirect to home
|
||||
next(false)
|
||||
}
|
||||
} else {
|
||||
// User is authenticated, proceed
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -112,6 +112,11 @@ const zDisplayComponentWsMessage = z.object({
|
||||
props: z.record(z.string(), z.any()).optional()
|
||||
})
|
||||
|
||||
const zNotificationWsMessage = z.object({
|
||||
value: z.string(),
|
||||
id: z.string().optional()
|
||||
})
|
||||
|
||||
const zTerminalSize = z.object({
|
||||
cols: z.number(),
|
||||
row: z.number()
|
||||
@@ -153,15 +158,9 @@ export type DisplayComponentWsMessage = z.infer<
|
||||
export type NodeProgressState = z.infer<typeof zNodeProgressState>
|
||||
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
|
||||
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
|
||||
export type NotificationWsMessage = z.infer<typeof zNotificationWsMessage>
|
||||
// End of ws messages
|
||||
|
||||
const zPromptInputItem = z.object({
|
||||
inputs: z.record(z.string(), z.any()),
|
||||
class_type: zNodeType
|
||||
})
|
||||
|
||||
const zPromptInputs = z.record(zPromptInputItem)
|
||||
|
||||
const zExtraPngInfo = z
|
||||
.object({
|
||||
workflow: zComfyWorkflow
|
||||
@@ -173,7 +172,6 @@ const zExtraData = z.object({
|
||||
extra_pnginfo: zExtraPngInfo.optional(),
|
||||
client_id: z.string()
|
||||
})
|
||||
const zOutputsToExecute = z.array(zNodeId)
|
||||
|
||||
const zExecutionStartMessage = z.tuple([
|
||||
z.literal('execution_start'),
|
||||
@@ -214,13 +212,11 @@ const zStatus = z.object({
|
||||
messages: z.array(zStatusMessage)
|
||||
})
|
||||
|
||||
const zTaskPrompt = z.tuple([
|
||||
zQueueIndex,
|
||||
zPromptId,
|
||||
zPromptInputs,
|
||||
zExtraData,
|
||||
zOutputsToExecute
|
||||
])
|
||||
const zTaskPrompt = z.object({
|
||||
priority: zQueueIndex,
|
||||
prompt_id: zPromptId,
|
||||
extra_data: zExtraData
|
||||
})
|
||||
|
||||
const zRunningTaskItem = z.object({
|
||||
taskType: z.literal('Running'),
|
||||
@@ -256,6 +252,20 @@ const zHistoryTaskItem = z.object({
|
||||
meta: zTaskMeta.optional()
|
||||
})
|
||||
|
||||
// Raw history item from backend (without taskType)
|
||||
const zRawHistoryItem = z.object({
|
||||
prompt_id: zPromptId,
|
||||
prompt: zTaskPrompt,
|
||||
status: zStatus.optional(),
|
||||
outputs: zTaskOutput,
|
||||
meta: zTaskMeta.optional()
|
||||
})
|
||||
|
||||
// New API response format: { history: [{prompt_id: "...", ...}, ...] }
|
||||
const zHistoryResponse = z.object({
|
||||
history: z.array(zRawHistoryItem)
|
||||
})
|
||||
|
||||
const zTaskItem = z.union([
|
||||
zRunningTaskItem,
|
||||
zPendingTaskItem,
|
||||
@@ -278,6 +288,8 @@ export type RunningTaskItem = z.infer<typeof zRunningTaskItem>
|
||||
export type PendingTaskItem = z.infer<typeof zPendingTaskItem>
|
||||
// `/history`
|
||||
export type HistoryTaskItem = z.infer<typeof zHistoryTaskItem>
|
||||
export type RawHistoryItem = z.infer<typeof zRawHistoryItem>
|
||||
export type HistoryResponse = z.infer<typeof zHistoryResponse>
|
||||
export type TaskItem = z.infer<typeof zTaskItem>
|
||||
|
||||
export function validateTaskItem(taskItem: unknown) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
|
||||
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json'
|
||||
import type {
|
||||
@@ -13,9 +14,11 @@ import type {
|
||||
ExecutionSuccessWsMessage,
|
||||
ExtensionsResponse,
|
||||
FeatureFlagsWsMessage,
|
||||
HistoryResponse,
|
||||
HistoryTaskItem,
|
||||
LogsRawResponse,
|
||||
LogsWsMessage,
|
||||
NotificationWsMessage,
|
||||
PendingTaskItem,
|
||||
ProgressStateWsMessage,
|
||||
ProgressTextWsMessage,
|
||||
@@ -26,6 +29,7 @@ import type {
|
||||
StatusWsMessage,
|
||||
StatusWsMessageStatus,
|
||||
SystemStats,
|
||||
TaskPrompt,
|
||||
User,
|
||||
UserDataFullInfo
|
||||
} from '@/schemas/apiSchema'
|
||||
@@ -35,6 +39,8 @@ import type {
|
||||
NodeId
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { WorkflowTemplates } from '@/types/workflowTemplateTypes'
|
||||
|
||||
@@ -130,6 +136,7 @@ interface BackendApiCalls {
|
||||
progress_state: ProgressStateWsMessage
|
||||
display_component: DisplayComponentWsMessage
|
||||
feature_flags: FeatureFlagsWsMessage
|
||||
notification: NotificationWsMessage
|
||||
}
|
||||
|
||||
/** Dictionary of all api calls */
|
||||
@@ -272,6 +279,81 @@ export class ComfyApi extends EventTarget {
|
||||
*/
|
||||
serverFeatureFlags: Record<string, unknown> = {}
|
||||
|
||||
/**
|
||||
* Map of notification toasts by ID
|
||||
*/
|
||||
#notificationToasts = new Map<string, any>()
|
||||
|
||||
/**
|
||||
* Map of timers for auto-hiding notifications by ID
|
||||
*/
|
||||
#notificationTimers = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* Handle notification messages (with optional ID for multiple parallel notifications)
|
||||
*/
|
||||
#handleNotification(value: string, id?: string) {
|
||||
try {
|
||||
const toastStore = useToastStore()
|
||||
const notificationId = id || 'default'
|
||||
|
||||
console.log(`Updating notification (${notificationId}):`, value)
|
||||
|
||||
// Get existing toast for this ID
|
||||
const existingToast = this.#notificationToasts.get(notificationId)
|
||||
|
||||
if (existingToast) {
|
||||
// Update existing toast by removing and re-adding with new content
|
||||
console.log(`Updating existing notification toast: ${notificationId}`)
|
||||
toastStore.remove(existingToast)
|
||||
|
||||
// Update the detail text
|
||||
existingToast.detail = value
|
||||
toastStore.add(existingToast)
|
||||
} else {
|
||||
// Create new persistent notification toast
|
||||
console.log(`Creating new notification toast: ${notificationId}`)
|
||||
const newToast = {
|
||||
severity: 'info' as const,
|
||||
summary: 'Notification',
|
||||
detail: value,
|
||||
closable: true
|
||||
// No 'life' property means it won't auto-hide
|
||||
}
|
||||
this.#notificationToasts.set(notificationId, newToast)
|
||||
toastStore.add(newToast)
|
||||
}
|
||||
|
||||
// Clear existing timer for this ID and set new one
|
||||
const existingTimer = this.#notificationTimers.get(notificationId)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const toast = this.#notificationToasts.get(notificationId)
|
||||
if (toast) {
|
||||
console.log(`Auto-hiding notification toast: ${notificationId}`)
|
||||
toastStore.remove(toast)
|
||||
this.#notificationToasts.delete(notificationId)
|
||||
this.#notificationTimers.delete(notificationId)
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
this.#notificationTimers.set(notificationId, timer)
|
||||
console.log('Toast updated successfully')
|
||||
} catch (error) {
|
||||
console.error('Error handling notification:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced notification handler to avoid rapid toast updates
|
||||
*/
|
||||
#debouncedNotificationHandler = debounce((value: string, id?: string) => {
|
||||
this.#handleNotification(value, id)
|
||||
}, 300) // 300ms debounce delay
|
||||
|
||||
/**
|
||||
* The auth token for the comfy org account if the user is logged in.
|
||||
* This is only used for {@link queuePrompt} now. It is not directly
|
||||
@@ -311,7 +393,27 @@ export class ComfyApi extends EventTarget {
|
||||
return this.api_base + route
|
||||
}
|
||||
|
||||
fetchApi(route: string, options?: RequestInit) {
|
||||
/**
|
||||
* Waits for Firebase auth to be initialized before proceeding
|
||||
*/
|
||||
async #waitForAuthInitialization(): Promise<void> {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
|
||||
if (authStore.isInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const unwatch = authStore.$subscribe((_, state) => {
|
||||
if (state.isInitialized) {
|
||||
unwatch()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fetchApi(route: string, options?: RequestInit) {
|
||||
if (!options) {
|
||||
options = {}
|
||||
}
|
||||
@@ -322,6 +424,30 @@ export class ComfyApi extends EventTarget {
|
||||
options.cache = 'no-cache'
|
||||
}
|
||||
|
||||
// Wait for Firebase auth to be initialized before making any API request
|
||||
await this.#waitForAuthInitialization()
|
||||
|
||||
// Add Firebase JWT token if user is logged in
|
||||
try {
|
||||
const authHeader = await useFirebaseAuthStore().getAuthHeader()
|
||||
if (authHeader) {
|
||||
if (Array.isArray(options.headers)) {
|
||||
for (const [key, value] of Object.entries(authHeader)) {
|
||||
options.headers.push([key, value])
|
||||
}
|
||||
} else if (options.headers instanceof Headers) {
|
||||
for (const [key, value] of Object.entries(authHeader)) {
|
||||
options.headers.set(key, value)
|
||||
}
|
||||
} else {
|
||||
Object.assign(options.headers, authHeader)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore auth errors to avoid breaking API calls
|
||||
console.warn('Failed to get auth header:', error)
|
||||
}
|
||||
|
||||
if (Array.isArray(options.headers)) {
|
||||
options.headers.push(['Comfy-User', this.user])
|
||||
} else if (options.headers instanceof Headers) {
|
||||
@@ -551,6 +677,16 @@ export class ComfyApi extends EventTarget {
|
||||
this.serverFeatureFlags
|
||||
)
|
||||
break
|
||||
case 'notification':
|
||||
// Display notification in toast with debouncing
|
||||
console.log(
|
||||
'Received notification message:',
|
||||
msg.data.value,
|
||||
msg.data.id ? `(ID: ${msg.data.id})` : ''
|
||||
)
|
||||
this.#debouncedNotificationHandler(msg.data.value, msg.data.id)
|
||||
this.dispatchCustomEvent(msg.type, msg.data)
|
||||
break
|
||||
default:
|
||||
if (this.#registered.has(msg.type)) {
|
||||
// Fallback for custom types - calls super direct.
|
||||
@@ -576,6 +712,35 @@ export class ComfyApi extends EventTarget {
|
||||
this.#createSocket()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test method to simulate a notification message (for development/testing)
|
||||
*/
|
||||
testNotification(message: string = 'Test notification message', id?: string) {
|
||||
console.log(
|
||||
'Testing notification with message:',
|
||||
message,
|
||||
id ? `(ID: ${id})` : ''
|
||||
)
|
||||
const mockEvent = {
|
||||
data: JSON.stringify({
|
||||
type: 'notification',
|
||||
data: { value: message, id }
|
||||
})
|
||||
}
|
||||
|
||||
// Simulate the websocket message handler
|
||||
const msg = JSON.parse(mockEvent.data)
|
||||
if (msg.type === 'notification') {
|
||||
console.log(
|
||||
'Received notification message:',
|
||||
msg.data.value,
|
||||
msg.data.id ? `(ID: ${msg.data.id})` : ''
|
||||
)
|
||||
this.#debouncedNotificationHandler(msg.data.value, msg.data.id)
|
||||
this.dispatchCustomEvent(msg.type, msg.data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of extension urls
|
||||
*/
|
||||
@@ -739,6 +904,28 @@ export class ComfyApi extends EventTarget {
|
||||
return this.getHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses queue prompt data from array or object format
|
||||
* @param rawPrompt The raw prompt data from the API
|
||||
* @returns Normalized TaskPrompt object
|
||||
*/
|
||||
private parseQueuePrompt(rawPrompt: any): TaskPrompt {
|
||||
if (Array.isArray(rawPrompt)) {
|
||||
// Queue format: [priority, prompt_id, workflow, outputs]
|
||||
const [priority, prompt_id, workflow] = rawPrompt
|
||||
return {
|
||||
priority,
|
||||
prompt_id,
|
||||
extra_data: workflow?.extra_data || {
|
||||
client_id: '',
|
||||
extra_pnginfo: workflow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rawPrompt as TaskPrompt
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current state of the queue
|
||||
* @returns The currently running and queued items
|
||||
@@ -752,15 +939,17 @@ export class ComfyApi extends EventTarget {
|
||||
const data = await res.json()
|
||||
return {
|
||||
// Running action uses a different endpoint for cancelling
|
||||
Running: data.queue_running.map((prompt: Record<number, any>) => ({
|
||||
Running: data.queue_running.map((prompt: any) => ({
|
||||
taskType: 'Running',
|
||||
prompt,
|
||||
// prompt[1] is the prompt id
|
||||
remove: { name: 'Cancel', cb: () => api.interrupt(prompt[1]) }
|
||||
prompt: this.parseQueuePrompt(prompt),
|
||||
remove: {
|
||||
name: 'Cancel',
|
||||
cb: () => api.interrupt(this.parseQueuePrompt(prompt).prompt_id)
|
||||
}
|
||||
})),
|
||||
Pending: data.queue_pending.map((prompt: Record<number, any>) => ({
|
||||
Pending: data.queue_pending.map((prompt: any) => ({
|
||||
taskType: 'Pending',
|
||||
prompt
|
||||
prompt: this.parseQueuePrompt(prompt)
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -777,13 +966,17 @@ export class ComfyApi extends EventTarget {
|
||||
max_items: number = 200
|
||||
): Promise<{ History: HistoryTaskItem[] }> {
|
||||
try {
|
||||
const res = await this.fetchApi(`/history?max_items=${max_items}`)
|
||||
const json: Promise<HistoryTaskItem[]> = await res.json()
|
||||
const res = await this.fetchApi(`/history_v2?max_items=${max_items}`)
|
||||
const json: HistoryResponse = await res.json()
|
||||
|
||||
// Extract history data from new format: { history: [{prompt_id: "...", ...}, ...] }
|
||||
return {
|
||||
History: Object.values(json).map((item) => ({
|
||||
...item,
|
||||
taskType: 'History'
|
||||
}))
|
||||
History: json.history.map(
|
||||
(item): HistoryTaskItem => ({
|
||||
...item,
|
||||
taskType: 'History'
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -791,6 +984,33 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets workflow data for a specific prompt from history
|
||||
* @param prompt_id The prompt ID to fetch workflow for
|
||||
* @returns Workflow data for the specific prompt
|
||||
*/
|
||||
async getWorkflowFromHistory(
|
||||
prompt_id: string
|
||||
): Promise<ComfyWorkflowJSON | null> {
|
||||
try {
|
||||
const res = await this.fetchApi(`/history_v2/${prompt_id}`)
|
||||
const json = await res.json()
|
||||
|
||||
// The /history_v2/{prompt_id} endpoint returns data for a specific prompt
|
||||
// The response format is: { prompt_id: { prompt: {priority, prompt_id, extra_data}, outputs: {...}, status: {...} } }
|
||||
const historyItem = json[prompt_id]
|
||||
if (!historyItem) return null
|
||||
|
||||
// Extract workflow from the prompt object
|
||||
// prompt.extra_data contains extra_pnginfo.workflow
|
||||
const workflow = historyItem.prompt?.extra_data?.extra_pnginfo?.workflow
|
||||
return workflow || null
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch workflow for prompt ${prompt_id}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets system & device stats
|
||||
* @returns System stats such as python version, OS, per device info
|
||||
@@ -1055,6 +1275,28 @@ export class ComfyApi extends EventTarget {
|
||||
getServerFeatures(): Record<string, unknown> {
|
||||
return { ...this.serverFeatureFlags }
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts analytics event to cloud analytics service
|
||||
* @param eventName The name of the analytics event
|
||||
* @param eventData The event data (any JSON-serializable object)
|
||||
* @returns Promise resolving to the response
|
||||
*/
|
||||
async postCloudAnalytics(
|
||||
eventName: string,
|
||||
eventData: any
|
||||
): Promise<Response> {
|
||||
return this.fetchApi(this.internalURL('/cloud_analytics'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
event_name: eventName,
|
||||
event_data: eventData
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ComfyApi()
|
||||
|
||||
@@ -324,6 +324,14 @@ export class ComfyApp {
|
||||
return '&rand=' + Math.random()
|
||||
}
|
||||
|
||||
getClientIdParam() {
|
||||
const clientId = window.name
|
||||
if (clientId) {
|
||||
return '&client_id=' + clientId
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
static onClipspaceEditorSave() {
|
||||
if (ComfyApp.clipspace_return_node) {
|
||||
ComfyApp.pasteFromClipspace(ComfyApp.clipspace_return_node)
|
||||
@@ -989,6 +997,10 @@ export class ComfyApp {
|
||||
if (!templateData?.templates) {
|
||||
return
|
||||
}
|
||||
api.postCloudAnalytics('load_workflow', {
|
||||
source: 'template',
|
||||
sourceData: { templateData }
|
||||
})
|
||||
|
||||
const old = localStorage.getItem('litegrapheditor_clipboard')
|
||||
|
||||
@@ -1269,6 +1281,12 @@ export class ComfyApp {
|
||||
const paths = await api.getFolderPaths()
|
||||
this.#showMissingModelsError(missingModels, paths)
|
||||
}
|
||||
api.postCloudAnalytics('load_workflow', {
|
||||
source: 'graph',
|
||||
graph: this.graph.asSerialisable(),
|
||||
missingNodeTypes,
|
||||
missingModels
|
||||
})
|
||||
await useExtensionService().invokeExtensionsAsync(
|
||||
'afterConfigureGraph',
|
||||
missingNodeTypes
|
||||
@@ -1577,6 +1595,11 @@ export class ComfyApp {
|
||||
const missingNodeTypes = Object.values(apiData).filter(
|
||||
(n) => !LiteGraph.registered_node_types[n.class_type]
|
||||
)
|
||||
api.postCloudAnalytics('load_workflow', {
|
||||
source: 'api_json',
|
||||
missingNodeTypes,
|
||||
apiJson: apiData
|
||||
})
|
||||
if (missingNodeTypes.length) {
|
||||
this.#showMissingNodesError(missingNodeTypes.map((t) => t.class_type))
|
||||
return
|
||||
|
||||
@@ -264,15 +264,21 @@ class ComfyList {
|
||||
? item.remove
|
||||
: {
|
||||
name: 'Delete',
|
||||
cb: () => api.deleteItem(this.#type, item.prompt[1])
|
||||
cb: () =>
|
||||
api.deleteItem(
|
||||
this.#type,
|
||||
Array.isArray(item.prompt)
|
||||
? item.prompt[1]
|
||||
: item.prompt.prompt_id
|
||||
)
|
||||
}
|
||||
return $el('div', { textContent: item.prompt[0] + ': ' }, [
|
||||
return $el('div', { textContent: item.prompt.priority + ': ' }, [
|
||||
$el('button', {
|
||||
textContent: 'Load',
|
||||
onclick: async () => {
|
||||
await app.loadGraphData(
|
||||
// @ts-expect-error fixme ts strict error
|
||||
item.prompt[3].extra_pnginfo.workflow,
|
||||
item.prompt.extra_data.extra_pnginfo.workflow,
|
||||
true,
|
||||
false
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ This directory contains the service layer for the ComfyUI frontend application.
|
||||
|
||||
## Overview
|
||||
|
||||
Services in ComfyUI provide organized modules that implement the application's functionality and logic. They handle operations such as API communication, workflow management, user settings, and other essential features.
|
||||
Services in ComfyUI provide organized modules that implement the application's functionality and logic. They handle operations such as API communication, workflow management, user settings, and other essential features.
|
||||
|
||||
The term "business logic" in this context refers to the code that implements the core functionality and behavior of the application - the rules, processes, and operations that make ComfyUI work as expected, separate from the UI display code.
|
||||
|
||||
@@ -57,25 +57,21 @@ While services can interact with both UI components and stores (centralized stat
|
||||
|
||||
## Core Services
|
||||
|
||||
The following table lists ALL services in the system as of 2025-09-01:
|
||||
The following table lists ALL services in the system as of 2025-01-30:
|
||||
|
||||
### Main Services
|
||||
|
||||
| Service | Description | Category |
|
||||
|---------|-------------|----------|
|
||||
| audioService.ts | Manages audio recording and WAV encoding functionality | Media |
|
||||
| autoQueueService.ts | Manages automatic queue execution | Execution |
|
||||
| colorPaletteService.ts | Handles color palette management and customization | UI |
|
||||
| comfyManagerService.ts | Manages ComfyUI application packages and updates | Manager |
|
||||
| comfyRegistryService.ts | Handles registration and discovery of ComfyUI extensions | Registry |
|
||||
| customerEventsService.ts | Handles customer event tracking and audit logs | Analytics |
|
||||
| dialogService.ts | Provides dialog and modal management | UI |
|
||||
| extensionService.ts | Manages extension registration and lifecycle | Extensions |
|
||||
| keybindingService.ts | Handles keyboard shortcuts and keybindings | Input |
|
||||
| litegraphService.ts | Provides utilities for working with the LiteGraph library | Graph |
|
||||
| load3dService.ts | Manages 3D model loading and visualization | 3D |
|
||||
| mediaCacheService.ts | Manages media file caching with blob storage and cleanup | Media |
|
||||
| newUserService.ts | Handles new user initialization and onboarding | System |
|
||||
| nodeHelpService.ts | Provides node documentation and help | Nodes |
|
||||
| nodeOrganizationService.ts | Handles node organization and categorization | Nodes |
|
||||
| nodeSearchService.ts | Implements node search functionality | Search |
|
||||
@@ -109,82 +105,47 @@ For complex services with state management and multiple methods, class-based ser
|
||||
```typescript
|
||||
export class NodeSearchService {
|
||||
// Service state
|
||||
public readonly nodeFuseSearch: FuseSearch<ComfyNodeDefImpl>
|
||||
public readonly inputTypeFilter: FuseFilter<ComfyNodeDefImpl, string>
|
||||
public readonly outputTypeFilter: FuseFilter<ComfyNodeDefImpl, string>
|
||||
public readonly nodeCategoryFilter: FuseFilter<ComfyNodeDefImpl, string>
|
||||
public readonly nodeSourceFilter: FuseFilter<ComfyNodeDefImpl, string>
|
||||
private readonly nodeFuseSearch: FuseSearch<ComfyNodeDefImpl>
|
||||
private readonly filters: Record<string, FuseFilter<ComfyNodeDefImpl, string>>
|
||||
|
||||
constructor(data: ComfyNodeDefImpl[]) {
|
||||
// Initialize search index
|
||||
this.nodeFuseSearch = new FuseSearch(data, {
|
||||
fuseOptions: {
|
||||
keys: ['name', 'display_name'],
|
||||
includeScore: true,
|
||||
threshold: 0.3,
|
||||
shouldSort: false,
|
||||
useExtendedSearch: true
|
||||
},
|
||||
createIndex: true,
|
||||
advancedScoring: true
|
||||
})
|
||||
|
||||
// Setup individual filters
|
||||
const fuseOptions = { includeScore: true, threshold: 0.3, shouldSort: true }
|
||||
this.inputTypeFilter = new FuseFilter<ComfyNodeDefImpl, string>(data, {
|
||||
id: 'input',
|
||||
name: 'Input Type',
|
||||
invokeSequence: 'i',
|
||||
getItemOptions: (node) => Object.values(node.inputs).map((input) => input.type),
|
||||
fuseOptions
|
||||
})
|
||||
// Additional filters initialized similarly...
|
||||
// Initialize state
|
||||
this.nodeFuseSearch = new FuseSearch(data, { /* options */ })
|
||||
|
||||
// Setup filters
|
||||
this.filters = {
|
||||
inputType: new FuseFilter<ComfyNodeDefImpl, string>(/* options */),
|
||||
category: new FuseFilter<ComfyNodeDefImpl, string>(/* options */)
|
||||
}
|
||||
}
|
||||
|
||||
public searchNode(
|
||||
query: string,
|
||||
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[] = []
|
||||
): ComfyNodeDefImpl[] {
|
||||
const matchedNodes = this.nodeFuseSearch.search(query)
|
||||
return matchedNodes.filter((node) => {
|
||||
return filters.every((filterAndValue) => {
|
||||
const { filterDef, value } = filterAndValue
|
||||
return filterDef.matches(node, value, { wildcard: '*' })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
get nodeFilters(): FuseFilter<ComfyNodeDefImpl, string>[] {
|
||||
return [
|
||||
this.inputTypeFilter,
|
||||
this.outputTypeFilter,
|
||||
this.nodeCategoryFilter,
|
||||
this.nodeSourceFilter
|
||||
]
|
||||
public searchNode(query: string, filters: FuseFilterWithValue[] = []): ComfyNodeDefImpl[] {
|
||||
// Implementation
|
||||
return results
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Composable-style Services
|
||||
|
||||
For services that need to integrate with Vue's reactivity system or handle API interactions, we use composable-style services:
|
||||
For simpler services or those that need to integrate with Vue's reactivity system, we prefer using composable-style services:
|
||||
|
||||
```typescript
|
||||
export function useNodeSearchService(initialData: ComfyNodeDefImpl[]) {
|
||||
// State (reactive if needed)
|
||||
const data = ref(initialData)
|
||||
|
||||
|
||||
// Search functionality
|
||||
function searchNodes(query: string) {
|
||||
// Implementation
|
||||
return results
|
||||
}
|
||||
|
||||
|
||||
// Additional methods
|
||||
function refreshData(newData: ComfyNodeDefImpl[]) {
|
||||
data.value = newData
|
||||
}
|
||||
|
||||
|
||||
// Return public API
|
||||
return {
|
||||
searchNodes,
|
||||
@@ -193,35 +154,12 @@ export function useNodeSearchService(initialData: ComfyNodeDefImpl[]) {
|
||||
}
|
||||
```
|
||||
|
||||
### Service Pattern Comparison
|
||||
When deciding between these approaches, consider:
|
||||
|
||||
| Aspect | Class-Based Services | Composable-Style Services | Bootstrap Services | Shared State Services |
|
||||
|--------|---------------------|---------------------------|-------------------|---------------------|
|
||||
| **Count** | 4 services | 18+ services | 1 service | 1 service |
|
||||
| **Export Pattern** | `export class ServiceName` | `export function useServiceName()` | `export function setupX()` | `export function serviceFactory()` |
|
||||
| **Instantiation** | `new ServiceName(data)` | `useServiceName()` | Direct function call | Direct function call |
|
||||
| **Best For** | Complex data structures, search algorithms, expensive initialization | Vue integration, API calls, reactive state | One-time app initialization | Singleton-like shared state |
|
||||
| **State Management** | Encapsulated private/public properties | External stores + reactive refs | Event listeners, side effects | Module-level state |
|
||||
| **Vue Integration** | Manual integration needed | Native reactivity support | N/A | Varies |
|
||||
| **Examples** | `NodeSearchService`, `Load3dService` | `workflowService`, `dialogService` | `autoQueueService` | `newUserService` |
|
||||
|
||||
### Decision Criteria
|
||||
|
||||
When choosing between these approaches, consider:
|
||||
|
||||
1. **Data Structure Complexity**: Classes work well for services managing multiple related data structures (search indices, filters, complex state)
|
||||
2. **Initialization Cost**: Classes are ideal when expensive setup should happen once and be controlled by instantiation
|
||||
3. **Vue Integration**: Composables integrate seamlessly with Vue's reactivity system and stores
|
||||
4. **API Interactions**: Composables handle async operations and API calls more naturally
|
||||
5. **State Management**: Classes provide strong encapsulation; composables work better with external state management
|
||||
6. **Application Bootstrap**: Bootstrap services handle one-time app initialization, event listener setup, and side effects
|
||||
7. **Singleton Behavior**: Shared state services provide module-level state that persists across multiple function calls
|
||||
|
||||
**Current Usage Patterns:**
|
||||
- **Class-based services (4)**: Complex data processing, search algorithms, expensive initialization
|
||||
- **Composable-style services (18+)**: UI interactions, API calls, store integration, reactive state management
|
||||
- **Bootstrap services (1)**: One-time application initialization and event handler setup
|
||||
- **Shared state services (1)**: Singleton-like behavior with module-level state management
|
||||
1. **Stateful vs. Stateless**: For stateful services, classes often provide clearer encapsulation
|
||||
2. **Reactivity needs**: If the service needs to be reactive, composable-style services integrate better with Vue's reactivity system
|
||||
3. **Complexity**: For complex services with many methods and internal state, classes can provide better organization
|
||||
4. **Testing**: Both approaches can be tested effectively, but composables may be simpler to test with Vue Test Utils
|
||||
|
||||
### Service Template
|
||||
|
||||
@@ -234,7 +172,7 @@ Here's a template for creating a new composable-style service:
|
||||
export function useExampleService() {
|
||||
// Private state/functionality
|
||||
const cache = new Map()
|
||||
|
||||
|
||||
/**
|
||||
* Description of what this method does
|
||||
* @param param1 Description of parameter
|
||||
@@ -250,7 +188,7 @@ export function useExampleService() {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Return public API
|
||||
return {
|
||||
performOperation
|
||||
@@ -268,16 +206,16 @@ Services in ComfyUI frequently use the following design patterns:
|
||||
export function useCachedService() {
|
||||
const cache = new Map()
|
||||
const pendingRequests = new Map()
|
||||
|
||||
|
||||
async function fetchData(key: string) {
|
||||
// Check cache first
|
||||
if (cache.has(key)) return cache.get(key)
|
||||
|
||||
|
||||
// Check if request is already in progress
|
||||
if (pendingRequests.has(key)) {
|
||||
return pendingRequests.get(key)
|
||||
}
|
||||
|
||||
|
||||
// Perform new request
|
||||
const requestPromise = fetch(`/api/${key}`)
|
||||
.then(response => response.json())
|
||||
@@ -286,11 +224,11 @@ export function useCachedService() {
|
||||
pendingRequests.delete(key)
|
||||
return data
|
||||
})
|
||||
|
||||
|
||||
pendingRequests.set(key, requestPromise)
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
|
||||
return { fetchData }
|
||||
}
|
||||
```
|
||||
@@ -310,7 +248,7 @@ export function useNodeFactory() {
|
||||
throw new Error(`Unknown node type: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { createNode }
|
||||
}
|
||||
```
|
||||
@@ -329,243 +267,11 @@ export function useWorkflowService(
|
||||
const storagePath = await storageService.getPath(name)
|
||||
return apiService.saveData(storagePath, graphData)
|
||||
}
|
||||
|
||||
|
||||
return { saveWorkflow }
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Services
|
||||
|
||||
Services in ComfyUI can be tested effectively using different approaches depending on their implementation pattern.
|
||||
|
||||
### Testing Class-Based Services
|
||||
|
||||
**Setup Requirements:**
|
||||
```typescript
|
||||
// Manual instantiation required
|
||||
const mockData = [/* test data */]
|
||||
const service = new NodeSearchService(mockData)
|
||||
```
|
||||
|
||||
**Characteristics:**
|
||||
- Requires constructor argument preparation
|
||||
- State is encapsulated within the class instance
|
||||
- Direct method calls on the instance
|
||||
- Good isolation - each test gets a fresh instance
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
describe('NodeSearchService', () => {
|
||||
let service: NodeSearchService
|
||||
|
||||
beforeEach(() => {
|
||||
const mockNodes = [/* mock node definitions */]
|
||||
service = new NodeSearchService(mockNodes)
|
||||
})
|
||||
|
||||
test('should search nodes by query', () => {
|
||||
const results = service.searchNode('test query')
|
||||
expect(results).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('should apply filters correctly', () => {
|
||||
const filters = [{ filterDef: service.inputTypeFilter, value: 'IMAGE' }]
|
||||
const results = service.searchNode('*', filters)
|
||||
expect(results.every(node => /* has IMAGE input */)).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Testing Composable-Style Services
|
||||
|
||||
**Setup Requirements:**
|
||||
```typescript
|
||||
// Direct function call, no instantiation
|
||||
const { saveWorkflow, loadWorkflow } = useWorkflowService()
|
||||
```
|
||||
|
||||
**Characteristics:**
|
||||
- No instantiation needed
|
||||
- Integrates naturally with Vue Test Utils
|
||||
- Easy mocking of reactive dependencies
|
||||
- External store dependencies need mocking
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
describe('useWorkflowService', () => {
|
||||
beforeEach(() => {
|
||||
// Mock external dependencies
|
||||
vi.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn().mockReturnValue(true),
|
||||
set: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/toastStore', () => ({
|
||||
useToastStore: () => ({
|
||||
add: vi.fn()
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
test('should save workflow with prompt', async () => {
|
||||
const { saveWorkflow } = useWorkflowService()
|
||||
await saveWorkflow('test-workflow')
|
||||
|
||||
// Verify interactions with mocked dependencies
|
||||
expect(mockSettingStore.get).toHaveBeenCalledWith('Comfy.PromptFilename')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Testing Bootstrap Services
|
||||
|
||||
**Focus on Setup Behavior:**
|
||||
```typescript
|
||||
describe('autoQueueService', () => {
|
||||
beforeEach(() => {
|
||||
// Mock global dependencies
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
queuePrompt: vi.fn()
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
test('should setup event listeners', () => {
|
||||
setupAutoQueueHandler()
|
||||
|
||||
expect(mockApi.addEventListener).toHaveBeenCalledWith('graphChanged', expect.any(Function))
|
||||
})
|
||||
|
||||
test('should handle graph changes when auto-queue enabled', () => {
|
||||
setupAutoQueueHandler()
|
||||
|
||||
// Simulate graph change event
|
||||
const graphChangeHandler = mockApi.addEventListener.mock.calls[0][1]
|
||||
graphChangeHandler()
|
||||
|
||||
expect(mockApp.queuePrompt).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Testing Shared State Services
|
||||
|
||||
**Focus on Shared State Behavior:**
|
||||
```typescript
|
||||
describe('newUserService', () => {
|
||||
beforeEach(() => {
|
||||
// Reset module state between tests
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
test('should return consistent API across calls', () => {
|
||||
const service1 = newUserService()
|
||||
const service2 = newUserService()
|
||||
|
||||
// Same functions returned (shared behavior)
|
||||
expect(service1.isNewUser).toBeDefined()
|
||||
expect(service2.isNewUser).toBeDefined()
|
||||
})
|
||||
|
||||
test('should share state between service instances', async () => {
|
||||
const service1 = newUserService()
|
||||
const service2 = newUserService()
|
||||
|
||||
// Initialize through one instance
|
||||
const mockSettingStore = { set: vi.fn() }
|
||||
await service1.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
// State should be shared
|
||||
expect(service2.isNewUser()).toBe(true) // or false, depending on mock
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Common Testing Patterns
|
||||
|
||||
**Mocking External Dependencies:**
|
||||
```typescript
|
||||
// Mock stores
|
||||
vi.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn(),
|
||||
set: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock API calls
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
get: vi.fn().mockResolvedValue({ data: 'mock' }),
|
||||
post: vi.fn().mockResolvedValue({ success: true })
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock Vue composables
|
||||
vi.mock('vue', () => ({
|
||||
ref: vi.fn((val) => ({ value: val })),
|
||||
reactive: vi.fn((obj) => obj)
|
||||
}))
|
||||
```
|
||||
|
||||
**Async Testing:**
|
||||
```typescript
|
||||
test('should handle async operations', async () => {
|
||||
const service = useMyService()
|
||||
const result = await service.performAsyncOperation()
|
||||
expect(result).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should handle concurrent requests', async () => {
|
||||
const service = useMyService()
|
||||
const promises = [
|
||||
service.loadData('key1'),
|
||||
service.loadData('key2')
|
||||
]
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
expect(results).toHaveLength(2)
|
||||
})
|
||||
```
|
||||
|
||||
**Error Handling:**
|
||||
```typescript
|
||||
test('should handle service errors gracefully', async () => {
|
||||
const service = useMyService()
|
||||
|
||||
// Mock API to throw error
|
||||
mockApi.get.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await expect(service.fetchData()).rejects.toThrow('Network error')
|
||||
})
|
||||
|
||||
test('should provide meaningful error messages', async () => {
|
||||
const service = useMyService()
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation()
|
||||
|
||||
await service.handleError('test error')
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('test error'))
|
||||
})
|
||||
```
|
||||
|
||||
### Testing Best Practices
|
||||
|
||||
1. **Isolate Dependencies**: Always mock external dependencies (stores, APIs, DOM)
|
||||
2. **Reset State**: Use `beforeEach` to ensure clean test state
|
||||
3. **Test Error Paths**: Don't just test happy paths - test error scenarios
|
||||
4. **Mock Timers**: Use `vi.useFakeTimers()` for time-dependent services
|
||||
5. **Test Async Properly**: Use `async/await` and proper promise handling
|
||||
|
||||
For more detailed information about the service layer pattern and its applications, refer to:
|
||||
- [Service Layer Pattern](https://en.wikipedia.org/wiki/Service_layer_pattern)
|
||||
- [Service-Orientation](https://en.wikipedia.org/wiki/Service-orientation)
|
||||
401
src/services/fileNameMappingService.ts
Normal file
401
src/services/fileNameMappingService.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
export type FileType = 'input' | 'output' | 'temp'
|
||||
|
||||
export interface FileNameMapping {
|
||||
[hashFilename: string]: string // hash -> human readable name
|
||||
}
|
||||
|
||||
export interface CacheEntry {
|
||||
data: FileNameMapping
|
||||
dedupData?: FileNameMapping // Deduplicated mapping with unique display names
|
||||
timestamp: number
|
||||
error?: Error | null
|
||||
fetchPromise?: Promise<FileNameMapping>
|
||||
failed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for fetching and caching filename mappings from the backend.
|
||||
* Maps SHA256 hash filenames to their original human-readable names.
|
||||
*/
|
||||
export class FileNameMappingService {
|
||||
private cache = new Map<FileType, CacheEntry>()
|
||||
private readonly TTL = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
/**
|
||||
* Get filename mapping for the specified file type.
|
||||
* @param fileType - The type of files to get mappings for
|
||||
* @returns Promise resolving to the filename mapping
|
||||
*/
|
||||
async getMapping(fileType: FileType = 'input'): Promise<FileNameMapping> {
|
||||
const cached = this.cache.get(fileType)
|
||||
|
||||
// Return cached data if valid and not expired
|
||||
if (cached && !this.isExpired(cached) && !cached.failed) {
|
||||
return cached.data
|
||||
}
|
||||
|
||||
// Return cached data if we're already fetching or if previous fetch failed recently
|
||||
if (cached?.fetchPromise || (cached?.failed && !this.shouldRetry(cached))) {
|
||||
return cached?.data ?? {}
|
||||
}
|
||||
|
||||
// Fetch new data
|
||||
return this.fetchMapping(fileType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable filename from hash filename.
|
||||
* @param hashFilename - The SHA256 hash filename
|
||||
* @param fileType - The type of file
|
||||
* @returns Promise resolving to human-readable name or original if not found
|
||||
*/
|
||||
async getHumanReadableName(
|
||||
hashFilename: string,
|
||||
fileType: FileType = 'input'
|
||||
): Promise<string> {
|
||||
try {
|
||||
const mapping = await this.getMapping(fileType)
|
||||
return mapping[hashFilename] ?? hashFilename
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to get human readable name for ${hashFilename}:`,
|
||||
error
|
||||
)
|
||||
return hashFilename
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filename mapping to an array of hash filenames.
|
||||
* @param hashFilenames - Array of SHA256 hash filenames
|
||||
* @param fileType - The type of files
|
||||
* @returns Promise resolving to array of human-readable names
|
||||
*/
|
||||
async applyMappingToArray(
|
||||
hashFilenames: string[],
|
||||
fileType: FileType = 'input'
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const mapping = await this.getMapping(fileType)
|
||||
return hashFilenames.map((filename) => mapping[filename] ?? filename)
|
||||
} catch (error) {
|
||||
console.warn('Failed to apply filename mapping:', error)
|
||||
return hashFilenames
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached mapping synchronously (returns empty object if not cached).
|
||||
* @param fileType - The file type to get cached mapping for
|
||||
* @param deduplicated - Whether to return deduplicated names for display
|
||||
* @returns The cached mapping or empty object
|
||||
*/
|
||||
getCachedMapping(
|
||||
fileType: FileType = 'input',
|
||||
deduplicated: boolean = false
|
||||
): FileNameMapping {
|
||||
const cached = this.cache.get(fileType)
|
||||
if (cached && !this.isExpired(cached) && !cached.failed) {
|
||||
// Return deduplicated mapping if requested and available
|
||||
if (deduplicated && cached.dedupData) {
|
||||
return cached.dedupData
|
||||
}
|
||||
const result = cached.data
|
||||
console.debug(
|
||||
`[FileNameMapping] getCachedMapping returning cached data:`,
|
||||
{
|
||||
fileType,
|
||||
deduplicated,
|
||||
mappingCount: Object.keys(result).length,
|
||||
sampleMappings: Object.entries(result).slice(0, 3)
|
||||
}
|
||||
)
|
||||
return result
|
||||
}
|
||||
console.debug(
|
||||
`[FileNameMapping] getCachedMapping returning empty object for ${fileType} (cache miss)`
|
||||
)
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reverse mapping (human-readable name to hash) synchronously.
|
||||
* @param fileType - The file type to get reverse mapping for
|
||||
* @param deduplicated - Whether to use deduplicated names
|
||||
* @returns The reverse mapping object
|
||||
*/
|
||||
getCachedReverseMapping(
|
||||
fileType: FileType = 'input',
|
||||
deduplicated: boolean = false
|
||||
): Record<string, string> {
|
||||
const mapping = this.getCachedMapping(fileType, deduplicated)
|
||||
const reverseMapping: Record<string, string> = {}
|
||||
|
||||
// Build reverse mapping: humanName -> hashName
|
||||
for (const [hash, humanName] of Object.entries(mapping)) {
|
||||
reverseMapping[humanName] = hash
|
||||
}
|
||||
|
||||
return reverseMapping
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a human-readable name back to its hash filename.
|
||||
* @param humanName - The human-readable filename
|
||||
* @param fileType - The file type
|
||||
* @returns The hash filename or the original if no mapping exists
|
||||
*/
|
||||
getHashFromHumanName(
|
||||
humanName: string,
|
||||
fileType: FileType = 'input'
|
||||
): string {
|
||||
const reverseMapping = this.getCachedReverseMapping(fileType)
|
||||
return reverseMapping[humanName] ?? humanName
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached mapping for a specific file type.
|
||||
* @param fileType - The file type to invalidate, or undefined to clear all
|
||||
*/
|
||||
invalidateCache(fileType?: FileType): void {
|
||||
if (fileType) {
|
||||
this.cache.delete(fileType)
|
||||
} else {
|
||||
this.cache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the mapping for a specific file type by clearing cache and fetching new data.
|
||||
* @param fileType - The file type to refresh
|
||||
* @returns Promise resolving to the new mapping
|
||||
*/
|
||||
async refreshMapping(fileType: FileType = 'input'): Promise<FileNameMapping> {
|
||||
console.debug(`[FileNameMapping] Refreshing mapping for ${fileType}`)
|
||||
this.invalidateCache(fileType)
|
||||
const freshMapping = await this.getMapping(fileType)
|
||||
console.debug(`[FileNameMapping] Fresh mapping fetched:`, {
|
||||
fileType,
|
||||
mappingCount: Object.keys(freshMapping).length,
|
||||
sampleMappings: Object.entries(freshMapping).slice(0, 3)
|
||||
})
|
||||
return freshMapping
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures mappings are loaded and cached for immediate synchronous access.
|
||||
* Use this to preload mappings before widget creation.
|
||||
* @param fileType - The file type to preload
|
||||
* @returns Promise that resolves when mappings are loaded
|
||||
*/
|
||||
async ensureMappingsLoaded(fileType: FileType = 'input'): Promise<void> {
|
||||
try {
|
||||
await this.getMapping(fileType)
|
||||
} catch (error) {
|
||||
// Errors are already handled in getMapping/performFetch
|
||||
// This ensures we don't break the app initialization
|
||||
console.debug(
|
||||
'[FileNameMappingService] Preload completed with fallback to empty mapping'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchMapping(fileType: FileType): Promise<FileNameMapping> {
|
||||
const cacheKey = fileType
|
||||
let entry = this.cache.get(cacheKey)
|
||||
|
||||
if (!entry) {
|
||||
entry = { data: {}, timestamp: 0 }
|
||||
this.cache.set(cacheKey, entry)
|
||||
}
|
||||
|
||||
// Prevent concurrent requests for the same fileType
|
||||
if (entry.fetchPromise) {
|
||||
return entry.fetchPromise
|
||||
}
|
||||
|
||||
// Set up fetch promise to prevent concurrent requests
|
||||
entry.fetchPromise = this.performFetch(fileType)
|
||||
|
||||
try {
|
||||
const data = await entry.fetchPromise
|
||||
|
||||
// Update cache with successful result
|
||||
entry.data = data
|
||||
entry.dedupData = this.deduplicateMapping(data)
|
||||
entry.timestamp = Date.now()
|
||||
entry.error = null
|
||||
entry.failed = false
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
// Should not happen as performFetch now returns empty mapping on error
|
||||
// But keep for safety
|
||||
entry.error = error instanceof Error ? error : new Error(String(error))
|
||||
entry.failed = true
|
||||
|
||||
console.debug(`[FileNameMappingService] Using fallback for ${fileType}`)
|
||||
return entry.data // Return existing data or empty object
|
||||
} finally {
|
||||
// Clear the promise after completion
|
||||
entry.fetchPromise = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async performFetch(fileType: FileType): Promise<FileNameMapping> {
|
||||
// Check if api is available
|
||||
if (!api || typeof api.fetchApi !== 'function') {
|
||||
console.warn(
|
||||
'[FileNameMappingService] API not available, returning empty mapping'
|
||||
)
|
||||
return {}
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await api.fetchApi(`/files/mappings`)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[FileNameMappingService] Network error fetching mappings:',
|
||||
error
|
||||
)
|
||||
return {} // Return empty mapping instead of throwing
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`[FileNameMappingService] Server returned ${response.status} ${response.statusText}, using empty mapping`
|
||||
)
|
||||
return {} // Graceful degradation
|
||||
}
|
||||
|
||||
let data: any
|
||||
try {
|
||||
// Check if response has json method
|
||||
if (typeof response.json !== 'function') {
|
||||
console.warn('[FileNameMappingService] Response has no json() method')
|
||||
return {}
|
||||
}
|
||||
data = await response.json()
|
||||
} catch (jsonError) {
|
||||
console.warn(
|
||||
'[FileNameMappingService] Failed to parse JSON response:',
|
||||
jsonError
|
||||
)
|
||||
return {} // Return empty mapping on parse error
|
||||
}
|
||||
|
||||
// Validate response structure
|
||||
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
||||
console.warn(
|
||||
'[FileNameMappingService] Invalid response format, expected object'
|
||||
)
|
||||
return {}
|
||||
}
|
||||
|
||||
// Validate and filter entries
|
||||
const validEntries: FileNameMapping = {}
|
||||
let invalidEntryCount = 0
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof key === 'string' && typeof value === 'string') {
|
||||
validEntries[key] = value
|
||||
} else {
|
||||
invalidEntryCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidEntryCount > 0) {
|
||||
console.debug(
|
||||
`[FileNameMappingService] Filtered out ${invalidEntryCount} invalid entries`
|
||||
)
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`[FileNameMappingService] Loaded ${Object.keys(validEntries).length} mappings for '${fileType}'`
|
||||
)
|
||||
|
||||
return validEntries
|
||||
}
|
||||
|
||||
private isExpired(entry: CacheEntry): boolean {
|
||||
return Date.now() - entry.timestamp > this.TTL
|
||||
}
|
||||
|
||||
private shouldRetry(entry: CacheEntry): boolean {
|
||||
// Allow retry after 30 seconds for failed requests
|
||||
return entry.timestamp > 0 && Date.now() - entry.timestamp > 30000
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate human-readable names when multiple hashes map to the same name.
|
||||
* Adds a suffix to duplicate names to make them unique.
|
||||
* @param mapping - The original hash -> human name mapping
|
||||
* @returns A new mapping with deduplicated human names
|
||||
*/
|
||||
private deduplicateMapping(mapping: FileNameMapping): FileNameMapping {
|
||||
const dedupMapping: FileNameMapping = {}
|
||||
const nameCount = new Map<string, number>()
|
||||
const nameToHashes = new Map<string, string[]>()
|
||||
|
||||
// First pass: count occurrences of each human name
|
||||
for (const [hash, humanName] of Object.entries(mapping)) {
|
||||
const count = nameCount.get(humanName) || 0
|
||||
nameCount.set(humanName, count + 1)
|
||||
|
||||
// Track which hashes map to this human name
|
||||
const hashes = nameToHashes.get(humanName) || []
|
||||
hashes.push(hash)
|
||||
nameToHashes.set(humanName, hashes)
|
||||
}
|
||||
|
||||
// Second pass: create deduplicated names
|
||||
const nameIndex = new Map<string, number>()
|
||||
|
||||
for (const [hash, humanName] of Object.entries(mapping)) {
|
||||
const count = nameCount.get(humanName) || 1
|
||||
|
||||
if (count === 1) {
|
||||
// No duplicates, use original name
|
||||
dedupMapping[hash] = humanName
|
||||
} else {
|
||||
// Has duplicates, add suffix
|
||||
const currentIndex = (nameIndex.get(humanName) || 0) + 1
|
||||
nameIndex.set(humanName, currentIndex)
|
||||
|
||||
// Extract file extension if present
|
||||
const lastDotIndex = humanName.lastIndexOf('.')
|
||||
let baseName = humanName
|
||||
let extension = ''
|
||||
|
||||
if (lastDotIndex > 0 && lastDotIndex < humanName.length - 1) {
|
||||
baseName = humanName.substring(0, lastDotIndex)
|
||||
extension = humanName.substring(lastDotIndex)
|
||||
}
|
||||
|
||||
// Add suffix: use first 8 chars of hash (without extension)
|
||||
// Remove extension from hash if present
|
||||
const hashWithoutExt = hash.includes('.')
|
||||
? hash.substring(0, hash.lastIndexOf('.'))
|
||||
: hash
|
||||
const hashSuffix = hashWithoutExt.substring(0, 8)
|
||||
dedupMapping[hash] = `${baseName}_${hashSuffix}${extension}`
|
||||
}
|
||||
}
|
||||
|
||||
console.debug('[FileNameMappingService] Deduplicated mapping:', {
|
||||
original: Object.keys(mapping).length,
|
||||
duplicates: Array.from(nameCount.entries()).filter(
|
||||
([_, count]) => count > 1
|
||||
),
|
||||
sample: Object.entries(dedupMapping).slice(0, 5)
|
||||
})
|
||||
|
||||
return dedupMapping
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const fileNameMappingService = new FileNameMappingService()
|
||||
@@ -5,10 +5,13 @@ import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail'
|
||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
|
||||
import { downloadBlob } from '@/scripts/utils'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { TaskItemImpl } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
@@ -16,6 +19,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { appendJsonExt, generateUUID } from '@/utils/formatUtil'
|
||||
|
||||
import { useDialogService } from './dialogService'
|
||||
import { useExtensionService } from './extensionService'
|
||||
|
||||
export const useWorkflowService = () => {
|
||||
const settingStore = useSettingStore()
|
||||
@@ -154,6 +158,47 @@ export const useWorkflowService = () => {
|
||||
await app.loadGraphData(blankGraph)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a workflow from a task item (queue/history)
|
||||
* For history items, fetches workflow data from /history_v2/{prompt_id}
|
||||
* @param task The task item to load the workflow from
|
||||
*/
|
||||
const loadTaskWorkflow = async (task: TaskItemImpl) => {
|
||||
let workflowData = task.workflow
|
||||
|
||||
// History items don't include workflow data - fetch from API
|
||||
if (task.isHistory) {
|
||||
const promptId = task.prompt.prompt_id
|
||||
if (promptId) {
|
||||
workflowData = (await api.getWorkflowFromHistory(promptId)) || undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (!workflowData) {
|
||||
return
|
||||
}
|
||||
|
||||
await app.loadGraphData(toRaw(workflowData))
|
||||
if (task.outputs) {
|
||||
const nodeOutputsStore = useNodeOutputStore()
|
||||
const rawOutputs = toRaw(task.outputs)
|
||||
|
||||
// Set outputs by execution ID to account for outputs inside of subgraphs
|
||||
for (const nodeExecutionId in rawOutputs) {
|
||||
nodeOutputsStore.setNodeOutputsByExecutionId(
|
||||
nodeExecutionId,
|
||||
rawOutputs[nodeExecutionId]
|
||||
)
|
||||
}
|
||||
|
||||
// Invoke extension (e.g., 3D nodes) hooks to allow them to update
|
||||
useExtensionService().invokeExtensions(
|
||||
'onNodeOutputsUpdated',
|
||||
app.nodeOutputs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the current workflow
|
||||
* This is used to refresh the node definitions update, e.g. when the locale changes.
|
||||
@@ -402,6 +447,7 @@ export const useWorkflowService = () => {
|
||||
saveWorkflow,
|
||||
loadDefaultWorkflow,
|
||||
loadBlankWorkflow,
|
||||
loadTaskWorkflow,
|
||||
reloadCurrentWorkflow,
|
||||
openWorkflow,
|
||||
closeWorkflow,
|
||||
|
||||
@@ -100,64 +100,57 @@ The following diagram illustrates the store architecture and data flow:
|
||||
|
||||
## Core Stores
|
||||
|
||||
The following table lists ALL 46 store instances in the system as of 2025-09-01:
|
||||
The following table lists ALL stores in the system as of 2025-01-30:
|
||||
|
||||
### Main Stores
|
||||
|
||||
| File | Store | Description | Category |
|
||||
|------|-------|-------------|----------|
|
||||
| aboutPanelStore.ts | useAboutPanelStore | Manages the About panel state and badges | UI |
|
||||
| apiKeyAuthStore.ts | useApiKeyAuthStore | Handles API key authentication | Auth |
|
||||
| comfyManagerStore.ts | useComfyManagerStore | Manages ComfyUI application state | Core |
|
||||
| comfyManagerStore.ts | useManagerProgressDialogStore | Manages manager progress dialog state | UI |
|
||||
| comfyRegistryStore.ts | useComfyRegistryStore | Handles extensions registry | Registry |
|
||||
| commandStore.ts | useCommandStore | Manages commands and command execution | Core |
|
||||
| dialogStore.ts | useDialogStore | Controls dialog/modal display and state | UI |
|
||||
| domWidgetStore.ts | useDomWidgetStore | Manages DOM widget state | Widgets |
|
||||
| electronDownloadStore.ts | useElectronDownloadStore | Handles Electron-specific download operations | Platform |
|
||||
| executionStore.ts | useExecutionStore | Tracks workflow execution state | Execution |
|
||||
| extensionStore.ts | useExtensionStore | Manages extension registration and state | Extensions |
|
||||
| firebaseAuthStore.ts | useFirebaseAuthStore | Handles Firebase authentication | Auth |
|
||||
| graphStore.ts | useTitleEditorStore | Manages title editing for nodes and groups | UI |
|
||||
| graphStore.ts | useCanvasStore | Manages the graph canvas state and interactions | Core |
|
||||
| helpCenterStore.ts | useHelpCenterStore | Manages help center visibility and state | UI |
|
||||
| imagePreviewStore.ts | useNodeOutputStore | Manages node outputs and execution results | Media |
|
||||
| keybindingStore.ts | useKeybindingStore | Manages keyboard shortcuts | Input |
|
||||
| maintenanceTaskStore.ts | useMaintenanceTaskStore | Handles system maintenance tasks | System |
|
||||
| menuItemStore.ts | useMenuItemStore | Handles menu items and their state | UI |
|
||||
| modelStore.ts | useModelStore | Manages AI models information | Models |
|
||||
| modelToNodeStore.ts | useModelToNodeStore | Maps models to compatible nodes | Models |
|
||||
| nodeBookmarkStore.ts | useNodeBookmarkStore | Manages node bookmarks and favorites | Nodes |
|
||||
| nodeDefStore.ts | useNodeDefStore | Manages node definitions and schemas | Nodes |
|
||||
| nodeDefStore.ts | useNodeFrequencyStore | Tracks node usage frequency | Nodes |
|
||||
| queueStore.ts | useQueueStore | Manages execution queue and task history | Execution |
|
||||
| queueStore.ts | useQueuePendingTaskCountStore | Tracks pending task counts | Execution |
|
||||
| queueStore.ts | useQueueSettingsStore | Manages queue execution settings | Execution |
|
||||
| releaseStore.ts | useReleaseStore | Manages application release information | System |
|
||||
| serverConfigStore.ts | useServerConfigStore | Handles server configuration | Config |
|
||||
| settingStore.ts | useSettingStore | Manages application settings | Config |
|
||||
| subgraphNavigationStore.ts | useSubgraphNavigationStore | Handles subgraph navigation state | Navigation |
|
||||
| systemStatsStore.ts | useSystemStatsStore | Tracks system performance statistics | System |
|
||||
| toastStore.ts | useToastStore | Manages toast notifications | UI |
|
||||
| userFileStore.ts | useUserFileStore | Manages user file operations | Files |
|
||||
| userStore.ts | useUserStore | Manages user data and preferences | User |
|
||||
| versionCompatibilityStore.ts | useVersionCompatibilityStore | Manages frontend/backend version compatibility warnings | Core |
|
||||
| widgetStore.ts | useWidgetStore | Manages widget configurations | Widgets |
|
||||
| workflowStore.ts | useWorkflowStore | Handles workflow data and operations | Workflows |
|
||||
| workflowStore.ts | useWorkflowBookmarkStore | Manages workflow bookmarks and favorites | Workflows |
|
||||
| workflowTemplatesStore.ts | useWorkflowTemplatesStore | Manages workflow templates | Workflows |
|
||||
| workspaceStore.ts | useWorkspaceStore | Manages overall workspace state | Workspace |
|
||||
| Store | Description | Category |
|
||||
|-------|-------------|----------|
|
||||
| aboutPanelStore.ts | Manages the About panel state and badges | UI |
|
||||
| apiKeyAuthStore.ts | Handles API key authentication | Auth |
|
||||
| comfyManagerStore.ts | Manages ComfyUI application state | Core |
|
||||
| comfyRegistryStore.ts | Handles extensions registry | Registry |
|
||||
| commandStore.ts | Manages commands and command execution | Core |
|
||||
| dialogStore.ts | Controls dialog/modal display and state | UI |
|
||||
| domWidgetStore.ts | Manages DOM widget state | Widgets |
|
||||
| electronDownloadStore.ts | Handles Electron-specific download operations | Platform |
|
||||
| executionStore.ts | Tracks workflow execution state | Execution |
|
||||
| extensionStore.ts | Manages extension registration and state | Extensions |
|
||||
| firebaseAuthStore.ts | Handles Firebase authentication | Auth |
|
||||
| graphStore.ts | Manages the graph canvas state | Core |
|
||||
| imagePreviewStore.ts | Controls image preview functionality | Media |
|
||||
| keybindingStore.ts | Manages keyboard shortcuts | Input |
|
||||
| maintenanceTaskStore.ts | Handles system maintenance tasks | System |
|
||||
| menuItemStore.ts | Handles menu items and their state | UI |
|
||||
| modelStore.ts | Manages AI models information | Models |
|
||||
| modelToNodeStore.ts | Maps models to compatible nodes | Models |
|
||||
| nodeBookmarkStore.ts | Manages node bookmarks and favorites | Nodes |
|
||||
| nodeDefStore.ts | Manages node definitions | Nodes |
|
||||
| queueStore.ts | Handles the execution queue | Execution |
|
||||
| releaseStore.ts | Manages application release information | System |
|
||||
| serverConfigStore.ts | Handles server configuration | Config |
|
||||
| settingStore.ts | Manages application settings | Config |
|
||||
| subgraphNavigationStore.ts | Handles subgraph navigation state | Navigation |
|
||||
| systemStatsStore.ts | Tracks system performance statistics | System |
|
||||
| toastStore.ts | Manages toast notifications | UI |
|
||||
| userFileStore.ts | Manages user file operations | Files |
|
||||
| userStore.ts | Manages user data and preferences | User |
|
||||
| versionCompatibilityStore.ts | Manages frontend/backend version compatibility warnings | Core |
|
||||
| widgetStore.ts | Manages widget configurations | Widgets |
|
||||
| workflowStore.ts | Handles workflow data and operations | Workflows |
|
||||
| workflowTemplatesStore.ts | Manages workflow templates | Workflows |
|
||||
| workspaceStore.ts | Manages overall workspace state | Workspace |
|
||||
|
||||
### Workspace Stores
|
||||
Located in `stores/workspace/`:
|
||||
|
||||
| File | Store | Description | Category |
|
||||
|------|-------|-------------|----------|
|
||||
| bottomPanelStore.ts | useBottomPanelStore | Controls bottom panel visibility and state | UI |
|
||||
| colorPaletteStore.ts | useColorPaletteStore | Manages color palette configurations | UI |
|
||||
| nodeHelpStore.ts | useNodeHelpStore | Handles node help and documentation display | UI |
|
||||
| searchBoxStore.ts | useSearchBoxStore | Manages search box functionality | UI |
|
||||
| sidebarTabStore.ts | useSidebarTabStore | Controls sidebar tab states and navigation | UI |
|
||||
| Store | Description |
|
||||
|-------|-------------|
|
||||
| bottomPanelStore.ts | Controls bottom panel visibility and state |
|
||||
| colorPaletteStore.ts | Manages color palette configurations |
|
||||
| nodeHelpStore.ts | Handles node help and documentation display |
|
||||
| searchBoxStore.ts | Manages search box functionality |
|
||||
| sidebarTabStore.ts | Controls sidebar tab states and navigation |
|
||||
|
||||
## Store Development Guidelines
|
||||
|
||||
@@ -196,7 +189,7 @@ export const useExampleStore = defineStore('example', () => {
|
||||
async function fetchItems() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/items')
|
||||
const data = await response.json()
|
||||
@@ -214,11 +207,11 @@ export const useExampleStore = defineStore('example', () => {
|
||||
items,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
|
||||
// Getters
|
||||
itemCount,
|
||||
hasError,
|
||||
|
||||
|
||||
// Actions
|
||||
addItem,
|
||||
fetchItems
|
||||
@@ -245,7 +238,7 @@ export const useDataStore = defineStore('data', () => {
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await api.getExtensions()
|
||||
const result = await api.getData()
|
||||
data.value = result
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
@@ -273,21 +266,21 @@ import { useOtherStore } from './otherStore'
|
||||
export const useComposedStore = defineStore('composed', () => {
|
||||
const otherStore = useOtherStore()
|
||||
const { someData } = storeToRefs(otherStore)
|
||||
|
||||
|
||||
// Local state
|
||||
const localState = ref(0)
|
||||
|
||||
|
||||
// Computed value based on other store
|
||||
const derivedValue = computed(() => {
|
||||
return computeFromOtherData(someData.value, localState.value)
|
||||
})
|
||||
|
||||
|
||||
// Action that uses another store
|
||||
async function complexAction() {
|
||||
await otherStore.someAction()
|
||||
localState.value += 1
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
localState,
|
||||
derivedValue,
|
||||
@@ -306,20 +299,20 @@ export const usePreferencesStore = defineStore('preferences', () => {
|
||||
// Load from localStorage if available
|
||||
const theme = ref(localStorage.getItem('theme') || 'light')
|
||||
const fontSize = ref(parseInt(localStorage.getItem('fontSize') || '14'))
|
||||
|
||||
|
||||
// Save to localStorage when changed
|
||||
watch(theme, (newTheme) => {
|
||||
localStorage.setItem('theme', newTheme)
|
||||
})
|
||||
|
||||
|
||||
watch(fontSize, (newSize) => {
|
||||
localStorage.setItem('fontSize', newSize.toString())
|
||||
})
|
||||
|
||||
|
||||
function setTheme(newTheme) {
|
||||
theme.value = newTheme
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
theme,
|
||||
fontSize,
|
||||
@@ -354,7 +347,7 @@ describe('useExampleStore', () => {
|
||||
// Create a fresh pinia instance and make it active
|
||||
setActivePinia(createPinia())
|
||||
store = useExampleStore()
|
||||
|
||||
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -370,14 +363,14 @@ describe('useExampleStore', () => {
|
||||
expect(store.items).toEqual(['test'])
|
||||
expect(store.itemCount).toBe(1)
|
||||
})
|
||||
|
||||
|
||||
it('should fetch items', async () => {
|
||||
// Setup mock response
|
||||
vi.mocked(api.getData).mockResolvedValue(['item1', 'item2'])
|
||||
|
||||
|
||||
// Call the action
|
||||
await store.fetchItems()
|
||||
|
||||
|
||||
// Verify state changes
|
||||
expect(store.isLoading).toBe(false)
|
||||
expect(store.items).toEqual(['item1', 'item2'])
|
||||
@@ -386,209 +379,4 @@ describe('useExampleStore', () => {
|
||||
})
|
||||
```
|
||||
|
||||
## ComfyUI-Specific Patterns
|
||||
|
||||
ComfyUI stores follow additional patterns specific to the application's architecture:
|
||||
|
||||
### WebSocket Message Handling
|
||||
|
||||
Many stores handle real-time updates via WebSocket messages. The execution store is a prime example:
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { ExecutionStartWsMessage, ProgressWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
export const useExecutionStore = defineStore('execution', () => {
|
||||
const activePromptId = ref<string | null>(null)
|
||||
const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
|
||||
|
||||
// Register WebSocket event listeners
|
||||
function registerEvents() {
|
||||
api.addEventListener('execution_start', handleExecutionStart)
|
||||
api.addEventListener('progress', handleProgress)
|
||||
api.addEventListener('execution_error', handleExecutionError)
|
||||
}
|
||||
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
activePromptId.value = e.detail.prompt_id
|
||||
// Handle execution start
|
||||
}
|
||||
|
||||
function handleProgress(e: CustomEvent<ProgressWsMessage>) {
|
||||
// Update node progress states
|
||||
const { value, max } = e.detail
|
||||
// ... progress handling logic
|
||||
}
|
||||
|
||||
// Clean up listeners when store is no longer needed
|
||||
function unregisterEvents() {
|
||||
api.removeEventListener('execution_start', handleExecutionStart)
|
||||
api.removeEventListener('progress', handleProgress)
|
||||
api.removeEventListener('execution_error', handleExecutionError)
|
||||
}
|
||||
|
||||
return {
|
||||
activePromptId,
|
||||
nodeProgressStates,
|
||||
registerEvents,
|
||||
unregisterEvents
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Store Communication Patterns
|
||||
|
||||
ComfyUI stores frequently reference each other to maintain consistency across the application:
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useCanvasStore } from './graphStore'
|
||||
import { useWorkflowStore } from './workflowStore'
|
||||
|
||||
export const useExecutionStore = defineStore('execution', () => {
|
||||
// Reference related stores
|
||||
const workflowStore = useWorkflowStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
// Computed values that depend on other stores
|
||||
const activeWorkflow = computed(() => workflowStore.activeWorkflow)
|
||||
|
||||
// Actions that coordinate between stores
|
||||
async function executeWorkflow() {
|
||||
const workflow = workflowStore.serialize()
|
||||
const canvas = canvasStore.canvas
|
||||
// Execute with coordination between stores
|
||||
}
|
||||
|
||||
return {
|
||||
activeWorkflow,
|
||||
executeWorkflow
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Extension Integration
|
||||
|
||||
Extensions can register with stores to extend functionality:
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
export const useExtensionStore = defineStore('extension', () => {
|
||||
const extensionByName = ref<Record<string, ComfyExtension>>({})
|
||||
|
||||
function registerExtension(extension: ComfyExtension) {
|
||||
if (!extension.name) {
|
||||
throw new Error("Extensions must have a 'name' property.")
|
||||
}
|
||||
|
||||
if (extensionByName.value[extension.name]) {
|
||||
console.warn(`Extension ${extension.name} already registered. Overwriting.`)
|
||||
}
|
||||
|
||||
extensionByName.value[extension.name] = extension
|
||||
|
||||
// Call extension setup if provided
|
||||
if (extension.setup) {
|
||||
extension.setup()
|
||||
}
|
||||
}
|
||||
|
||||
function isExtensionEnabled(extensionName: string): boolean {
|
||||
const extension = extensionByName.value[extensionName]
|
||||
return extension && !extension.disabled
|
||||
}
|
||||
|
||||
return {
|
||||
extensionByName: readonly(extensionByName),
|
||||
registerExtension,
|
||||
isExtensionEnabled
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Workspace Store Organization
|
||||
|
||||
ComfyUI uses a two-tier store organization:
|
||||
|
||||
1. **Main stores** (`src/stores/*.ts`) - Core application logic
|
||||
2. **Workspace stores** (`src/stores/workspace/*.ts`) - UI layout and workspace-specific state
|
||||
|
||||
The `useWorkspaceStore` acts as a coordinator, providing computed access to other stores:
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useWorkflowStore } from './workflowStore'
|
||||
import { useBottomPanelStore } from './workspace/bottomPanelStore'
|
||||
import { useSidebarTabStore } from './workspace/sidebarTabStore'
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
const focusMode = ref(false)
|
||||
|
||||
// Provide unified access to related stores
|
||||
const workflow = computed(() => useWorkflowStore())
|
||||
const sidebarTab = computed(() => useSidebarTabStore())
|
||||
const bottomPanel = computed(() => useBottomPanelStore())
|
||||
|
||||
// Workspace-level actions that coordinate multiple stores
|
||||
function toggleFocusMode() {
|
||||
focusMode.value = !focusMode.value
|
||||
// Hide/show panels based on focus mode
|
||||
if (focusMode.value) {
|
||||
bottomPanel.value.hide()
|
||||
sidebarTab.value.closeTabs()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
focusMode,
|
||||
workflow,
|
||||
sidebarTab,
|
||||
bottomPanel,
|
||||
toggleFocusMode
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Node and Workflow State Management
|
||||
|
||||
ComfyUI stores often work with node and workflow concepts:
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ComfyWorkflow, NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
|
||||
export const useWorkflowStore = defineStore('workflow', () => {
|
||||
const activeWorkflow = ref<ComfyWorkflow | null>(null)
|
||||
|
||||
// Convert node ID to a locator for cross-store communication
|
||||
function nodeIdToNodeLocatorId(nodeId: NodeId): NodeLocatorId {
|
||||
return createNodeLocatorId(activeWorkflow.value?.path, nodeId)
|
||||
}
|
||||
|
||||
// Serialize current workflow for execution
|
||||
function serialize(): ComfyApiWorkflow {
|
||||
if (!activeWorkflow.value) {
|
||||
throw new Error('No active workflow to serialize')
|
||||
}
|
||||
return convertToApiFormat(activeWorkflow.value)
|
||||
}
|
||||
|
||||
return {
|
||||
activeWorkflow,
|
||||
nodeIdToNodeLocatorId,
|
||||
serialize
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
These patterns ensure that ComfyUI stores work together effectively to manage the complex state of a node-based workflow editor.
|
||||
|
||||
For more information on Pinia, refer to the [Pinia documentation](https://pinia.vuejs.org/introduction.html).
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type UserCredential,
|
||||
browserLocalPersistence,
|
||||
createUserWithEmailAndPassword,
|
||||
deleteUser,
|
||||
onAuthStateChanged,
|
||||
sendPasswordResetEmail,
|
||||
setPersistence,
|
||||
@@ -87,6 +86,16 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
currentUser.value = user
|
||||
isInitialized.value = true
|
||||
|
||||
if (user && (window as any).mixpanel) {
|
||||
;(window as any).mixpanel
|
||||
.identify(user.uid)(window as any)
|
||||
.mixpanel.people.set({
|
||||
$email: user.email,
|
||||
$name: user.displayName,
|
||||
$created: user.metadata.creationTime
|
||||
})
|
||||
}
|
||||
|
||||
// Reset balance when auth state changes
|
||||
balance.value = null
|
||||
lastBalanceUpdateTime.value = null
|
||||
@@ -288,14 +297,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
await updatePassword(currentUser.value, newPassword)
|
||||
}
|
||||
|
||||
/** Delete the current user account */
|
||||
const _deleteAccount = async (): Promise<void> => {
|
||||
if (!currentUser.value) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
await deleteUser(currentUser.value)
|
||||
}
|
||||
|
||||
const addCredits = async (
|
||||
requestBodyContent: CreditPurchasePayload
|
||||
): Promise<CreditPurchaseResponse> => {
|
||||
@@ -394,7 +395,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
accessBillingPortal,
|
||||
sendPasswordReset,
|
||||
updatePassword: _updatePassword,
|
||||
deleteAccount: _deleteAccount,
|
||||
getAuthHeader
|
||||
}
|
||||
})
|
||||
|
||||
@@ -90,10 +90,13 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
|
||||
const rand = app.getRandParam()
|
||||
const previewParam = getPreviewParam(node, outputs)
|
||||
const clientIdParam = app.getClientIdParam()
|
||||
|
||||
return outputs.images.map((image) => {
|
||||
const imgUrlPart = new URLSearchParams(image)
|
||||
return api.apiURL(`/view?${imgUrlPart}${previewParam}${rand}`)
|
||||
return api.apiURL(
|
||||
`/view?${imgUrlPart}${previewParam}${rand}${clientIdParam}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,11 @@ export class ResultItemImpl {
|
||||
params.set('type', this.type)
|
||||
params.set('subfolder', this.subfolder)
|
||||
|
||||
const clientId = window.name
|
||||
if (clientId) {
|
||||
params.set('client_id', clientId)
|
||||
}
|
||||
|
||||
if (this.format) {
|
||||
params.set('format', this.format)
|
||||
}
|
||||
@@ -271,23 +276,15 @@ export class TaskItemImpl {
|
||||
}
|
||||
|
||||
get queueIndex() {
|
||||
return this.prompt[0]
|
||||
return this.prompt.priority
|
||||
}
|
||||
|
||||
get promptId() {
|
||||
return this.prompt[1]
|
||||
}
|
||||
|
||||
get promptInputs() {
|
||||
return this.prompt[2]
|
||||
return this.prompt.prompt_id
|
||||
}
|
||||
|
||||
get extraData() {
|
||||
return this.prompt[3]
|
||||
}
|
||||
|
||||
get outputsToExecute() {
|
||||
return this.prompt[4]
|
||||
return this.prompt.extra_data
|
||||
}
|
||||
|
||||
get extraPngInfo() {
|
||||
@@ -403,13 +400,11 @@ export class TaskItemImpl {
|
||||
(output: ResultItemImpl, i: number) =>
|
||||
new TaskItemImpl(
|
||||
this.taskType,
|
||||
[
|
||||
this.queueIndex,
|
||||
`${this.promptId}-${i}`,
|
||||
this.promptInputs,
|
||||
this.extraData,
|
||||
this.outputsToExecute
|
||||
],
|
||||
{
|
||||
priority: this.queueIndex,
|
||||
prompt_id: `${this.promptId}-${i}`,
|
||||
extra_data: this.extraData
|
||||
},
|
||||
this.status,
|
||||
{
|
||||
[output.nodeId]: {
|
||||
@@ -422,6 +417,13 @@ export class TaskItemImpl {
|
||||
}
|
||||
}
|
||||
|
||||
function executionStartTimestamp(taskItem: TaskItem) {
|
||||
const status = 'status' in taskItem ? taskItem.status : undefined
|
||||
const messages = status?.messages || []
|
||||
const message = messages.find((message) => message[0] === 'execution_start')
|
||||
return message ? message[1].timestamp : undefined
|
||||
}
|
||||
|
||||
export const useQueueStore = defineStore('queue', () => {
|
||||
const runningTasks = ref<TaskItemImpl[]>([])
|
||||
const pendingTasks = ref<TaskItemImpl[]>([])
|
||||
@@ -442,9 +444,12 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
tasks.value.flatMap((task: TaskItemImpl) => task.flatten())
|
||||
)
|
||||
|
||||
const lastHistoryQueueIndex = computed<number>(() =>
|
||||
historyTasks.value.length ? historyTasks.value[0].queueIndex : -1
|
||||
)
|
||||
const lastExecutionStartTimestamp = computed<number>(() => {
|
||||
const latestItemWithTimestamp = historyTasks.value.length
|
||||
? historyTasks.value.find((item) => item.executionStartTimestamp != null)
|
||||
: undefined
|
||||
return latestItemWithTimestamp?.executionStartTimestamp ?? -1
|
||||
})
|
||||
|
||||
const hasPendingTasks = computed<boolean>(() => pendingTasks.value.length > 0)
|
||||
|
||||
@@ -474,19 +479,34 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
pendingTasks.value = toClassAll(queue.Pending)
|
||||
|
||||
const allIndex = new Set<number>(
|
||||
history.History.map((item: TaskItem) => item.prompt[0])
|
||||
history.History.map((item: TaskItem) => item.prompt.priority)
|
||||
)
|
||||
const newHistoryItems = toClassAll(
|
||||
history.History.filter(
|
||||
(item) => item.prompt[0] > lastHistoryQueueIndex.value
|
||||
(item) =>
|
||||
(executionStartTimestamp(item) ?? Number.MAX_SAFE_INTEGER) >
|
||||
lastExecutionStartTimestamp.value
|
||||
)
|
||||
)
|
||||
const existingHistoryItems = historyTasks.value.filter((item) =>
|
||||
allIndex.has(item.queueIndex)
|
||||
)
|
||||
historyTasks.value = [...newHistoryItems, ...existingHistoryItems]
|
||||
const sortedTasks = [...newHistoryItems, ...existingHistoryItems]
|
||||
.slice(0, maxHistoryItems.value)
|
||||
.sort((a, b) => b.queueIndex - a.queueIndex)
|
||||
.sort((a, b) => {
|
||||
const aTime = a.executionStartTimestamp ?? 0
|
||||
const bTime = b.executionStartTimestamp ?? 0
|
||||
return bTime - aTime
|
||||
})
|
||||
const foundPromptIds = new Set()
|
||||
const deduplicatedTasks = sortedTasks.filter((item) => {
|
||||
if (!foundPromptIds.has(item.promptId)) {
|
||||
foundPromptIds.add(item.promptId)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
historyTasks.value = deduplicatedTasks
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -516,7 +536,6 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
|
||||
tasks,
|
||||
flatTasks,
|
||||
lastHistoryQueueIndex,
|
||||
hasPendingTasks,
|
||||
|
||||
update,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import axios, { type AxiosError } from 'axios'
|
||||
import { groupBy } from 'es-toolkit/compat'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
@@ -205,6 +206,12 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching workflow templates:', error)
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error as AxiosError
|
||||
if (axiosError.response?.data) {
|
||||
console.error('Template error details:', axiosError.response.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
27
src/utils/authErrorTranslation.ts
Normal file
27
src/utils/authErrorTranslation.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { FirebaseError } from 'firebase/app'
|
||||
|
||||
import { t, te } from '@/i18n'
|
||||
|
||||
/**
|
||||
* Translates authentication errors to user-friendly messages.
|
||||
* Handles Firebase errors with specific translations, and provides fallbacks for other error types.
|
||||
* @param error - Any error object from authentication flows
|
||||
* @returns User-friendly error message
|
||||
*/
|
||||
export function translateAuthError(error: unknown): string {
|
||||
if (error instanceof FirebaseError) {
|
||||
const translationKey = `auth.errors.${error.code}`
|
||||
|
||||
// Check if translation exists using te() function
|
||||
if (te(translationKey)) {
|
||||
return t(translationKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to original error message or generic error
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return t('g.unknownError')
|
||||
}
|
||||
@@ -59,59 +59,6 @@ export function hexToRgb(hex: string): RGB {
|
||||
return { r, g, b }
|
||||
}
|
||||
|
||||
export function parseToRgb(color: string): RGB {
|
||||
const format = identifyColorFormat(color)
|
||||
if (!format) return { r: 0, g: 0, b: 0 }
|
||||
|
||||
const hsla = parseToHSLA(color, format)
|
||||
if (!isHSLA(hsla)) return { r: 0, g: 0, b: 0 }
|
||||
|
||||
// Convert HSL to RGB
|
||||
const h = hsla.h / 360
|
||||
const s = hsla.s / 100
|
||||
const l = hsla.l / 100
|
||||
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s
|
||||
const x = c * (1 - Math.abs(((h * 6) % 2) - 1))
|
||||
const m = l - c / 2
|
||||
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0
|
||||
|
||||
if (h < 1 / 6) {
|
||||
r = c
|
||||
g = x
|
||||
b = 0
|
||||
} else if (h < 2 / 6) {
|
||||
r = x
|
||||
g = c
|
||||
b = 0
|
||||
} else if (h < 3 / 6) {
|
||||
r = 0
|
||||
g = c
|
||||
b = x
|
||||
} else if (h < 4 / 6) {
|
||||
r = 0
|
||||
g = x
|
||||
b = c
|
||||
} else if (h < 5 / 6) {
|
||||
r = x
|
||||
g = 0
|
||||
b = c
|
||||
} else {
|
||||
r = c
|
||||
g = 0
|
||||
b = x
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.round((r + m) * 255),
|
||||
g: Math.round((g + m) * 255),
|
||||
b: Math.round((b + m) * 255)
|
||||
}
|
||||
}
|
||||
|
||||
const identifyColorFormat = (color: string): ColorFormat | null => {
|
||||
if (!color) return null
|
||||
if (color.startsWith('#') && (color.length === 4 || color.length === 7))
|
||||
|
||||
@@ -43,10 +43,31 @@ export function isAudioNode(node: LGraphNode | undefined): boolean {
|
||||
export function addToComboValues(widget: IComboWidget, value: string) {
|
||||
if (!widget.options) widget.options = { values: [] }
|
||||
if (!widget.options.values) widget.options.values = []
|
||||
// @ts-expect-error Combo widget values may be a dictionary or legacy function type
|
||||
if (!widget.options.values.includes(value)) {
|
||||
|
||||
// Check if this widget has our filename mapping (has getRawValues method)
|
||||
const mappingWidget = widget as any
|
||||
if (
|
||||
mappingWidget.getRawValues &&
|
||||
typeof mappingWidget.getRawValues === 'function'
|
||||
) {
|
||||
// This is a filename mapping widget - work with raw values directly
|
||||
const rawValues = mappingWidget.getRawValues()
|
||||
if (!rawValues.includes(value)) {
|
||||
console.debug('[FilenameMapping] Adding to raw values:', value)
|
||||
rawValues.push(value)
|
||||
|
||||
// Trigger refresh
|
||||
if (mappingWidget.refreshMappings) {
|
||||
mappingWidget.refreshMappings()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular widget without mapping
|
||||
// @ts-expect-error Combo widget values may be a dictionary or legacy function type
|
||||
widget.options.values.push(value)
|
||||
if (!widget.options.values.includes(value)) {
|
||||
// @ts-expect-error Combo widget values may be a dictionary or legacy function type
|
||||
widget.options.values.push(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,7 @@ import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
function createMockNode(
|
||||
nodeTypeName: string,
|
||||
widgets: Array<{ name: string; value: any }> = [],
|
||||
isApiNode = true,
|
||||
inputs: Array<{
|
||||
name: string
|
||||
connected?: boolean
|
||||
useLinksArray?: boolean
|
||||
}> = []
|
||||
isApiNode = true
|
||||
): LGraphNode {
|
||||
const mockWidgets = widgets.map(({ name, value }) => ({
|
||||
name,
|
||||
@@ -21,16 +16,7 @@ function createMockNode(
|
||||
type: 'combo'
|
||||
})) as IComboWidget[]
|
||||
|
||||
const mockInputs =
|
||||
inputs.length > 0
|
||||
? inputs.map(({ name, connected, useLinksArray }) =>
|
||||
useLinksArray
|
||||
? { name, links: connected ? [1] : [] }
|
||||
: { name, link: connected ? 1 : null }
|
||||
)
|
||||
: undefined
|
||||
|
||||
const node: any = {
|
||||
return {
|
||||
id: Math.random().toString(),
|
||||
widgets: mockWidgets,
|
||||
constructor: {
|
||||
@@ -39,24 +25,7 @@ function createMockNode(
|
||||
api_node: isApiNode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mockInputs) {
|
||||
node.inputs = mockInputs
|
||||
// Provide the common helpers some frontend code may call
|
||||
node.findInputSlot = function (portName: string) {
|
||||
return this.inputs?.findIndex((i: any) => i.name === portName) ?? -1
|
||||
}
|
||||
node.isInputConnected = function (idx: number) {
|
||||
const port = this.inputs?.[idx]
|
||||
if (!port) return false
|
||||
if (typeof port.link !== 'undefined') return port.link != null
|
||||
if (Array.isArray(port.links)) return port.links.length > 0
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return node as LGraphNode
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('useNodePricing', () => {
|
||||
@@ -394,51 +363,34 @@ describe('useNodePricing', () => {
|
||||
})
|
||||
|
||||
describe('dynamic pricing - IdeogramV3', () => {
|
||||
it('should return correct prices for IdeogramV3 node', () => {
|
||||
it('should return $0.09 for Quality rendering speed', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('IdeogramV3', [
|
||||
{ name: 'rendering_speed', value: 'Quality' }
|
||||
])
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
rendering_speed: 'Quality',
|
||||
character_image: false,
|
||||
expected: '$0.09/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Quality',
|
||||
character_image: true,
|
||||
expected: '$0.20/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Default',
|
||||
character_image: false,
|
||||
expected: '$0.06/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Default',
|
||||
character_image: true,
|
||||
expected: '$0.15/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Turbo',
|
||||
character_image: false,
|
||||
expected: '$0.03/Run'
|
||||
},
|
||||
{
|
||||
rendering_speed: 'Turbo',
|
||||
character_image: true,
|
||||
expected: '$0.10/Run'
|
||||
}
|
||||
]
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.09/Run')
|
||||
})
|
||||
|
||||
testCases.forEach(({ rendering_speed, character_image, expected }) => {
|
||||
const node = createMockNode(
|
||||
'IdeogramV3',
|
||||
[{ name: 'rendering_speed', value: rendering_speed }],
|
||||
true,
|
||||
[{ name: 'character_image', connected: character_image }]
|
||||
)
|
||||
expect(getNodeDisplayPrice(node)).toBe(expected)
|
||||
})
|
||||
it('should return $0.06 for Balanced rendering speed', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('IdeogramV3', [
|
||||
{ name: 'rendering_speed', value: 'Balanced' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.06/Run')
|
||||
})
|
||||
|
||||
it('should return $0.03 for Turbo rendering speed', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('IdeogramV3', [
|
||||
{ name: 'rendering_speed', value: 'Turbo' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.03/Run')
|
||||
})
|
||||
|
||||
it('should return range when rendering_speed widget is missing', () => {
|
||||
@@ -983,11 +935,7 @@ describe('useNodePricing', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('IdeogramV3')
|
||||
expect(widgetNames).toEqual([
|
||||
'rendering_speed',
|
||||
'num_images',
|
||||
'character_image'
|
||||
])
|
||||
expect(widgetNames).toEqual(['rendering_speed', 'num_images'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,16 +2,189 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComboWidget } from '@/composables/widgets/useComboWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { fileNameMappingService } from '@/services/fileNameMappingService'
|
||||
|
||||
// Mock api to prevent app initialization
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
apiURL: vi.fn((path) => `/api${path}`),
|
||||
fileURL: vi.fn((path) => path)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/widgets', () => ({
|
||||
addValueControlWidgets: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/fileNameMappingService', () => ({
|
||||
fileNameMappingService: {
|
||||
getMapping: vi.fn().mockResolvedValue({}),
|
||||
getCachedMapping: vi.fn().mockReturnValue({}),
|
||||
getCachedReverseMapping: vi.fn().mockReturnValue({}),
|
||||
refreshMapping: vi.fn().mockResolvedValue({}),
|
||||
invalidateCache: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useComboWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('deduplication', () => {
|
||||
it('should display deduplicated names in dropdown', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = {
|
||||
name: 'image',
|
||||
value: 'hash1.png',
|
||||
options: {
|
||||
values: ['hash1.png', 'hash2.png', 'hash3.png']
|
||||
},
|
||||
callback: vi.fn()
|
||||
}
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue(mockWidget)
|
||||
}
|
||||
|
||||
// Mock deduplicated mapping
|
||||
vi.mocked(fileNameMappingService.getCachedMapping).mockImplementation(
|
||||
(_fileType, deduplicated) => {
|
||||
if (deduplicated) {
|
||||
return {
|
||||
'hash1.png': 'vacation_hash1.png',
|
||||
'hash2.png': 'vacation_hash2.png',
|
||||
'hash3.png': 'landscape.png'
|
||||
}
|
||||
}
|
||||
return {
|
||||
'hash1.png': 'vacation.png',
|
||||
'hash2.png': 'vacation.png',
|
||||
'hash3.png': 'landscape.png'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'image',
|
||||
options: ['hash1.png', 'hash2.png', 'hash3.png']
|
||||
}
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec)
|
||||
|
||||
// Check that dropdown values are deduplicated
|
||||
const dropdownValues = widget.options.values
|
||||
expect(dropdownValues).toEqual([
|
||||
'vacation_hash1.png',
|
||||
'vacation_hash2.png',
|
||||
'landscape.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('should correctly handle selection of deduplicated names', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = {
|
||||
name: 'image',
|
||||
value: 'hash1.png',
|
||||
options: {
|
||||
values: ['hash1.png', 'hash2.png']
|
||||
},
|
||||
callback: vi.fn()
|
||||
}
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue(mockWidget)
|
||||
}
|
||||
|
||||
// Mock deduplicated mappings
|
||||
vi.mocked(fileNameMappingService.getCachedMapping).mockImplementation(
|
||||
(_fileType, deduplicated) => {
|
||||
if (deduplicated) {
|
||||
return {
|
||||
'hash1.png': 'image_hash1.png',
|
||||
'hash2.png': 'image_hash2.png'
|
||||
}
|
||||
}
|
||||
return {
|
||||
'hash1.png': 'image.png',
|
||||
'hash2.png': 'image.png'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mocked(
|
||||
fileNameMappingService.getCachedReverseMapping
|
||||
).mockImplementation((_fileType, deduplicated) => {
|
||||
if (deduplicated) {
|
||||
return {
|
||||
'image_hash1.png': 'hash1.png',
|
||||
'image_hash2.png': 'hash2.png'
|
||||
} as Record<string, string>
|
||||
}
|
||||
return {
|
||||
'image.png': 'hash2.png' // Last one wins in non-dedup
|
||||
} as Record<string, string>
|
||||
})
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'image',
|
||||
options: ['hash1.png', 'hash2.png']
|
||||
}
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec)
|
||||
|
||||
// Select deduplicated name
|
||||
;(widget as any).setValue('image_hash1.png')
|
||||
|
||||
// Should set the correct hash value
|
||||
expect(widget.value).toBe('hash1.png')
|
||||
})
|
||||
|
||||
it('should display correct deduplicated name in _displayValue', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = {
|
||||
name: 'image',
|
||||
value: 'abc123.png',
|
||||
options: {
|
||||
values: ['abc123.png', 'def456.png']
|
||||
},
|
||||
callback: vi.fn()
|
||||
}
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue(mockWidget)
|
||||
}
|
||||
|
||||
// Mock deduplicated mapping
|
||||
vi.mocked(fileNameMappingService.getCachedMapping).mockImplementation(
|
||||
(_fileType, deduplicated) => {
|
||||
if (deduplicated) {
|
||||
return {
|
||||
'abc123.png': 'photo_abc123.png',
|
||||
'def456.png': 'photo_def456.png'
|
||||
}
|
||||
}
|
||||
return {
|
||||
'abc123.png': 'photo.png',
|
||||
'def456.png': 'photo.png'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'image',
|
||||
options: ['abc123.png', 'def456.png']
|
||||
}
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec)
|
||||
|
||||
// Check display value shows deduplicated name
|
||||
expect((widget as any)._displayValue).toBe('photo_abc123.png')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle undefined spec', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockNode = {
|
||||
@@ -36,4 +209,498 @@ describe('useComboWidget', () => {
|
||||
)
|
||||
expect(widget).toEqual({ options: {} })
|
||||
})
|
||||
|
||||
describe('filename mapping', () => {
|
||||
it('should apply filename mapping to widgets with file extensions', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = {
|
||||
name: 'image',
|
||||
value: 'abc123.png',
|
||||
options: {
|
||||
values: ['abc123.png', 'def456.jpg']
|
||||
},
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue(mockWidget),
|
||||
setDirtyCanvas: vi.fn(),
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'image',
|
||||
options: ['abc123.png', 'def456.jpg', 'xyz789.webp']
|
||||
}
|
||||
|
||||
// Setup mapping service mocks
|
||||
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
|
||||
'abc123.png': 'vacation_photo.png',
|
||||
'def456.jpg': 'profile_picture.jpg',
|
||||
'xyz789.webp': 'animated_logo.webp'
|
||||
})
|
||||
|
||||
vi.mocked(fileNameMappingService.getCachedReverseMapping).mockReturnValue(
|
||||
{
|
||||
'vacation_photo.png': 'abc123.png',
|
||||
'profile_picture.jpg': 'def456.jpg',
|
||||
'animated_logo.webp': 'xyz789.webp'
|
||||
}
|
||||
)
|
||||
|
||||
vi.mocked(fileNameMappingService.getMapping).mockResolvedValue({
|
||||
'abc123.png': 'vacation_photo.png',
|
||||
'def456.jpg': 'profile_picture.jpg',
|
||||
'xyz789.webp': 'animated_logo.webp'
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec)
|
||||
|
||||
// Widget should have mapping methods
|
||||
expect(widget).toBeDefined()
|
||||
expect(typeof (widget as any).refreshMappings).toBe('function')
|
||||
expect(typeof (widget as any).serializeValue).toBe('function')
|
||||
})
|
||||
|
||||
it('should display human-readable names in dropdown', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = {
|
||||
name: 'image',
|
||||
value: 'abc123.png',
|
||||
options: {
|
||||
values: ['abc123.png', 'def456.jpg']
|
||||
},
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue(mockWidget),
|
||||
setDirtyCanvas: vi.fn(),
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'image',
|
||||
options: ['abc123.png', 'def456.jpg']
|
||||
}
|
||||
|
||||
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
|
||||
'abc123.png': 'vacation_photo.png',
|
||||
'def456.jpg': 'profile_picture.jpg'
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec) as any
|
||||
|
||||
// Access options.values through the proxy
|
||||
const dropdownValues = widget.options.values
|
||||
|
||||
// Should return human-readable names
|
||||
expect(dropdownValues).toEqual([
|
||||
'vacation_photo.png',
|
||||
'profile_picture.jpg'
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle selection of human-readable name and convert to hash', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = {
|
||||
name: 'image',
|
||||
value: 'abc123.png',
|
||||
options: {
|
||||
values: ['abc123.png']
|
||||
},
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue(mockWidget),
|
||||
setDirtyCanvas: vi.fn(),
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'image',
|
||||
options: ['abc123.png']
|
||||
}
|
||||
|
||||
vi.mocked(fileNameMappingService.getCachedReverseMapping).mockReturnValue(
|
||||
{
|
||||
'vacation_photo.png': 'abc123.png'
|
||||
}
|
||||
)
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec) as any
|
||||
|
||||
// Simulate selecting human-readable name
|
||||
widget.callback('vacation_photo.png')
|
||||
|
||||
// Should store hash value
|
||||
expect(widget.value).toBe('abc123.png')
|
||||
})
|
||||
|
||||
it('should not apply mapping to non-file widgets', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = {
|
||||
name: 'mode',
|
||||
value: 'linear',
|
||||
options: {
|
||||
values: ['linear', 'cubic', 'nearest']
|
||||
},
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue(mockWidget)
|
||||
}
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'mode',
|
||||
options: ['linear', 'cubic', 'nearest']
|
||||
}
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec)
|
||||
|
||||
// Should not have mapping methods
|
||||
expect((widget as any).refreshMappings).toBeUndefined()
|
||||
expect((widget as any).serializeValue).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should show newly uploaded file in dropdown even without mapping', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = {
|
||||
name: 'image',
|
||||
value: 'abc123.png',
|
||||
options: {
|
||||
values: ['abc123.png']
|
||||
},
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue(mockWidget),
|
||||
setDirtyCanvas: vi.fn(),
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'image',
|
||||
options: ['abc123.png']
|
||||
}
|
||||
|
||||
// Start with mapping for existing file only
|
||||
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
|
||||
'abc123.png': 'vacation_photo.png'
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec) as any
|
||||
|
||||
// Simulate adding new file without mapping yet
|
||||
const newValues = [...mockWidget.options.values, 'new789.png']
|
||||
mockWidget.options.values = newValues
|
||||
|
||||
// Mapping still doesn't have the new file
|
||||
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
|
||||
'abc123.png': 'vacation_photo.png'
|
||||
})
|
||||
|
||||
// Force refresh
|
||||
widget.refreshMappings()
|
||||
|
||||
// Access updated dropdown values
|
||||
const dropdownValues = widget.options.values
|
||||
|
||||
// Should show human name for mapped file and hash for unmapped file
|
||||
expect(dropdownValues).toEqual(['vacation_photo.png', 'new789.png'])
|
||||
})
|
||||
|
||||
it('should handle dropdown update after new file upload', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = {
|
||||
name: 'image',
|
||||
value: 'abc123.png',
|
||||
options: {
|
||||
values: ['abc123.png']
|
||||
},
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue(mockWidget),
|
||||
setDirtyCanvas: vi.fn(),
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'image',
|
||||
options: ['abc123.png']
|
||||
}
|
||||
|
||||
// Initial mapping
|
||||
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
|
||||
'abc123.png': 'vacation_photo.png'
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec) as any
|
||||
|
||||
// The proxy should initially return mapped values
|
||||
expect(widget.options.values).toEqual(['vacation_photo.png'])
|
||||
|
||||
// Simulate adding new file by replacing the values array (as happens in practice)
|
||||
// This is how addToComboValues would modify it
|
||||
const newValues = [...mockWidget.options.values, 'new789.png']
|
||||
mockWidget.options.values = newValues
|
||||
|
||||
// Update mapping to include the new file
|
||||
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
|
||||
'abc123.png': 'vacation_photo.png',
|
||||
'new789.png': 'new_upload.png'
|
||||
})
|
||||
|
||||
// Force refresh of cached values
|
||||
widget.refreshMappings()
|
||||
|
||||
// Access updated dropdown values - proxy should recompute with new mapping
|
||||
const dropdownValues = widget.options.values
|
||||
|
||||
// Should include both mapped names
|
||||
expect(dropdownValues).toEqual(['vacation_photo.png', 'new_upload.png'])
|
||||
})
|
||||
|
||||
it('should display hash as fallback when no mapping exists', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = {
|
||||
name: 'image',
|
||||
value: 'unmapped123.png',
|
||||
options: {
|
||||
values: ['unmapped123.png']
|
||||
},
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue(mockWidget),
|
||||
setDirtyCanvas: vi.fn(),
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'image',
|
||||
options: ['unmapped123.png']
|
||||
}
|
||||
|
||||
// Return empty mapping
|
||||
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({})
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec) as any
|
||||
|
||||
// Access _displayValue
|
||||
const displayValue = widget._displayValue
|
||||
|
||||
// Should show hash when no mapping exists
|
||||
expect(displayValue).toBe('unmapped123.png')
|
||||
|
||||
// Dropdown should also show hash
|
||||
const dropdownValues = widget.options.values
|
||||
expect(dropdownValues).toEqual(['unmapped123.png'])
|
||||
})
|
||||
|
||||
it('should serialize widget value as hash for API calls', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = {
|
||||
name: 'image',
|
||||
value: 'abc123.png',
|
||||
options: {
|
||||
values: ['abc123.png']
|
||||
},
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue(mockWidget),
|
||||
setDirtyCanvas: vi.fn(),
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'image',
|
||||
options: ['abc123.png']
|
||||
}
|
||||
|
||||
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
|
||||
'abc123.png': 'vacation_photo.png'
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec) as any
|
||||
|
||||
// serializeValue should always return hash
|
||||
const serialized = widget.serializeValue()
|
||||
expect(serialized).toBe('abc123.png')
|
||||
})
|
||||
|
||||
it('should ensure widget.value always contains hash for API calls', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = {
|
||||
name: 'image',
|
||||
value: 'abc123.png',
|
||||
options: {
|
||||
values: ['abc123.png']
|
||||
},
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue(mockWidget),
|
||||
setDirtyCanvas: vi.fn(),
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'image',
|
||||
options: ['abc123.png']
|
||||
}
|
||||
|
||||
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
|
||||
'abc123.png': 'vacation.png'
|
||||
})
|
||||
|
||||
vi.mocked(fileNameMappingService.getCachedReverseMapping).mockReturnValue(
|
||||
{
|
||||
'vacation.png': 'abc123.png'
|
||||
}
|
||||
)
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec) as any
|
||||
|
||||
// Simulate user selecting from dropdown (human name)
|
||||
widget.setValue('vacation.png')
|
||||
|
||||
// Widget.value should contain the hash for API calls
|
||||
expect(widget.value).toBe('abc123.png')
|
||||
|
||||
// Callback should also convert human name to hash
|
||||
widget.callback('vacation.png')
|
||||
expect(widget.value).toBe('abc123.png')
|
||||
|
||||
// The value used for API calls should always be the hash
|
||||
// This is what would be used in /view?filename=...
|
||||
const apiValue = widget.value
|
||||
expect(apiValue).toBe('abc123.png')
|
||||
})
|
||||
|
||||
it('should handle arrow key navigation with filename mapping', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = {
|
||||
name: 'image',
|
||||
value: 'abc123.png',
|
||||
options: {
|
||||
values: ['abc123.png', 'def456.jpg', 'xyz789.webp']
|
||||
},
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue(mockWidget),
|
||||
setDirtyCanvas: vi.fn(),
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'image',
|
||||
options: ['abc123.png', 'def456.jpg', 'xyz789.webp']
|
||||
}
|
||||
|
||||
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
|
||||
'abc123.png': 'vacation.png',
|
||||
'def456.jpg': 'profile.jpg',
|
||||
'xyz789.webp': 'banner.webp'
|
||||
})
|
||||
|
||||
vi.mocked(fileNameMappingService.getCachedReverseMapping).mockReturnValue(
|
||||
{
|
||||
'vacation.png': 'abc123.png',
|
||||
'profile.jpg': 'def456.jpg',
|
||||
'banner.webp': 'xyz789.webp'
|
||||
}
|
||||
)
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec) as any
|
||||
|
||||
// Test increment (arrow right/up)
|
||||
widget.incrementValue({ canvas: { last_mouseclick: 0 } })
|
||||
|
||||
// Should move from abc123.png to def456.jpg
|
||||
expect(widget.value).toBe('def456.jpg')
|
||||
|
||||
// Test decrement (arrow left/down)
|
||||
widget.decrementValue({ canvas: { last_mouseclick: 0 } })
|
||||
|
||||
// Should move back to abc123.png
|
||||
expect(widget.value).toBe('abc123.png')
|
||||
})
|
||||
|
||||
it('should handle mixed file and non-file options', () => {
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = {
|
||||
name: 'source',
|
||||
value: 'abc123.png',
|
||||
options: {
|
||||
values: ['abc123.png', 'none', 'default']
|
||||
},
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
addWidget: vi.fn().mockReturnValue(mockWidget),
|
||||
setDirtyCanvas: vi.fn(),
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'source',
|
||||
options: ['abc123.png', 'none', 'default']
|
||||
}
|
||||
|
||||
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
|
||||
'abc123.png': 'background.png'
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode as any, inputSpec) as any
|
||||
|
||||
const dropdownValues = widget.options.values
|
||||
|
||||
// Should map file, but leave non-files unchanged
|
||||
expect(dropdownValues).toEqual(['background.png', 'none', 'default'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
248
tests-ui/tests/scripts/api.test.ts
Normal file
248
tests-ui/tests/scripts/api.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
HistoryResponse,
|
||||
RawHistoryItem
|
||||
} from '../../../src/schemas/apiSchema'
|
||||
import type { ComfyWorkflowJSON } from '../../../src/schemas/comfyWorkflowSchema'
|
||||
import { ComfyApi } from '../../../src/scripts/api'
|
||||
|
||||
describe('ComfyApi getHistory', () => {
|
||||
let api: ComfyApi
|
||||
|
||||
beforeEach(() => {
|
||||
api = new ComfyApi()
|
||||
})
|
||||
|
||||
const mockHistoryItem: RawHistoryItem = {
|
||||
prompt_id: 'test_prompt_id',
|
||||
prompt: {
|
||||
priority: 0,
|
||||
prompt_id: 'test_prompt_id',
|
||||
extra_data: {
|
||||
extra_pnginfo: {
|
||||
workflow: {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
}
|
||||
},
|
||||
client_id: 'test_client_id'
|
||||
}
|
||||
},
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('history v2 API format', () => {
|
||||
it('should handle history array format from /history_v2', async () => {
|
||||
const historyResponse: HistoryResponse = {
|
||||
history: [
|
||||
{ ...mockHistoryItem, prompt_id: 'prompt_id_1' },
|
||||
{ ...mockHistoryItem, prompt_id: 'prompt_id_2' }
|
||||
]
|
||||
}
|
||||
|
||||
// Mock fetchApi to return the v2 format
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue(historyResponse)
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getHistory(10)
|
||||
|
||||
expect(result.History).toHaveLength(2)
|
||||
expect(result.History[0]).toEqual({
|
||||
...mockHistoryItem,
|
||||
prompt_id: 'prompt_id_1',
|
||||
taskType: 'History'
|
||||
})
|
||||
expect(result.History[1]).toEqual({
|
||||
...mockHistoryItem,
|
||||
prompt_id: 'prompt_id_2',
|
||||
taskType: 'History'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty history array', async () => {
|
||||
const historyResponse: HistoryResponse = {
|
||||
history: []
|
||||
}
|
||||
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue(historyResponse)
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getHistory(10)
|
||||
|
||||
expect(result.History).toHaveLength(0)
|
||||
expect(result.History).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return empty history on error', async () => {
|
||||
const mockFetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getHistory()
|
||||
|
||||
expect(result.History).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('API call parameters', () => {
|
||||
it('should call fetchApi with correct v2 endpoint and parameters', async () => {
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ history: [] })
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
await api.getHistory(50)
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/history_v2?max_items=50')
|
||||
})
|
||||
|
||||
it('should use default max_items parameter with v2 endpoint', async () => {
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ history: [] })
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
await api.getHistory()
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/history_v2?max_items=200')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComfyApi getWorkflowFromHistory', () => {
|
||||
let api: ComfyApi
|
||||
|
||||
beforeEach(() => {
|
||||
api = new ComfyApi()
|
||||
})
|
||||
|
||||
const mockWorkflow: ComfyWorkflowJSON = {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
}
|
||||
|
||||
it('should fetch workflow data for a specific prompt', async () => {
|
||||
const promptId = 'test_prompt_id'
|
||||
const mockResponse = {
|
||||
[promptId]: {
|
||||
prompt: {
|
||||
priority: 0,
|
||||
prompt_id: promptId,
|
||||
extra_data: {
|
||||
extra_pnginfo: {
|
||||
workflow: mockWorkflow
|
||||
}
|
||||
}
|
||||
},
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue(mockResponse)
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getWorkflowFromHistory(promptId)
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(`/history_v2/${promptId}`)
|
||||
expect(result).toEqual(mockWorkflow)
|
||||
})
|
||||
|
||||
it('should return null when prompt_id is not found', async () => {
|
||||
const promptId = 'non_existent_prompt'
|
||||
const mockResponse = {}
|
||||
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue(mockResponse)
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getWorkflowFromHistory(promptId)
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(`/history_v2/${promptId}`)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when workflow data is missing', async () => {
|
||||
const promptId = 'test_prompt_id'
|
||||
const mockResponse = {
|
||||
[promptId]: {
|
||||
prompt: {
|
||||
priority: 0,
|
||||
prompt_id: promptId,
|
||||
extra_data: {}
|
||||
},
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue(mockResponse)
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getWorkflowFromHistory(promptId)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const promptId = 'test_prompt_id'
|
||||
const mockFetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getWorkflowFromHistory(promptId)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle malformed response gracefully', async () => {
|
||||
const promptId = 'test_prompt_id'
|
||||
const mockResponse = {
|
||||
[promptId]: null
|
||||
}
|
||||
|
||||
const mockFetchApi = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue(mockResponse)
|
||||
})
|
||||
api.fetchApi = mockFetchApi
|
||||
|
||||
const result = await api.getWorkflowFromHistory(promptId)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
571
tests-ui/tests/services/fileNameMappingService.test.ts
Normal file
571
tests-ui/tests/services/fileNameMappingService.test.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import {
|
||||
type FileNameMapping,
|
||||
FileNameMappingService
|
||||
} from '@/services/fileNameMappingService'
|
||||
|
||||
// Mock api module
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('FileNameMappingService', () => {
|
||||
let service: FileNameMappingService
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Create a new instance for each test to avoid cache pollution
|
||||
service = new FileNameMappingService()
|
||||
})
|
||||
|
||||
describe('deduplication', () => {
|
||||
it('should not modify unique names', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'abc123.png': 'vacation.png',
|
||||
'def456.jpg': 'profile.jpg',
|
||||
'ghi789.gif': 'animation.gif'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
await service.getMapping('input')
|
||||
const dedupMapping = service.getCachedMapping('input', true)
|
||||
|
||||
// All unique names should remain unchanged
|
||||
expect(dedupMapping['abc123.png']).toBe('vacation.png')
|
||||
expect(dedupMapping['def456.jpg']).toBe('profile.jpg')
|
||||
expect(dedupMapping['ghi789.gif']).toBe('animation.gif')
|
||||
})
|
||||
|
||||
it('should add hash suffix to duplicate names', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'abc123def456.png': 'vacation.png',
|
||||
'xyz789uvw012.png': 'vacation.png',
|
||||
'mno345pqr678.png': 'vacation.png'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
await service.getMapping('input')
|
||||
const dedupMapping = service.getCachedMapping('input', true)
|
||||
|
||||
// Check that all values are unique
|
||||
const values = Object.values(dedupMapping)
|
||||
const uniqueValues = new Set(values)
|
||||
expect(uniqueValues.size).toBe(values.length)
|
||||
|
||||
// Check that suffixes are added correctly
|
||||
expect(dedupMapping['abc123def456.png']).toBe('vacation_abc123de.png')
|
||||
expect(dedupMapping['xyz789uvw012.png']).toBe('vacation_xyz789uv.png')
|
||||
expect(dedupMapping['mno345pqr678.png']).toBe('vacation_mno345pq.png')
|
||||
})
|
||||
|
||||
it('should preserve file extensions when deduplicating', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'hash1234.safetensors': 'model.safetensors',
|
||||
'hash5678.safetensors': 'model.safetensors'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
await service.getMapping('input')
|
||||
const dedupMapping = service.getCachedMapping('input', true)
|
||||
|
||||
// Extensions should be preserved
|
||||
expect(dedupMapping['hash1234.safetensors']).toBe(
|
||||
'model_hash1234.safetensors'
|
||||
)
|
||||
expect(dedupMapping['hash5678.safetensors']).toBe(
|
||||
'model_hash5678.safetensors'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle files without extensions', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
abc123: 'README',
|
||||
def456: 'README',
|
||||
ghi789: 'LICENSE'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
await service.getMapping('input')
|
||||
const dedupMapping = service.getCachedMapping('input', true)
|
||||
|
||||
// Files without extensions should still get deduplicated
|
||||
expect(dedupMapping['abc123']).toBe('README_abc123')
|
||||
expect(dedupMapping['def456']).toBe('README_def456')
|
||||
expect(dedupMapping['ghi789']).toBe('LICENSE') // Unique, no suffix
|
||||
})
|
||||
|
||||
it('should build correct reverse mapping for deduplicated names', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'hash1.png': 'image.png',
|
||||
'hash2.png': 'image.png',
|
||||
'hash3.jpg': 'photo.jpg'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
await service.getMapping('input')
|
||||
const reverseMapping = service.getCachedReverseMapping('input', true)
|
||||
|
||||
// Reverse mapping should map deduplicated names back to hashes
|
||||
expect(reverseMapping['image_hash1.png']).toBe('hash1.png')
|
||||
expect(reverseMapping['image_hash2.png']).toBe('hash2.png')
|
||||
expect(reverseMapping['photo.jpg']).toBe('hash3.jpg')
|
||||
|
||||
// Should not have original duplicate names in reverse mapping
|
||||
expect(reverseMapping['image.png']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle mixed duplicate and unique names', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'a1.png': 'sunset.png',
|
||||
'b2.png': 'sunset.png',
|
||||
'c3.jpg': 'portrait.jpg',
|
||||
'd4.gif': 'animation.gif',
|
||||
'e5.png': 'sunset.png'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
await service.getMapping('input')
|
||||
const dedupMapping = service.getCachedMapping('input', true)
|
||||
|
||||
// Duplicates get suffixes
|
||||
expect(dedupMapping['a1.png']).toBe('sunset_a1.png')
|
||||
expect(dedupMapping['b2.png']).toBe('sunset_b2.png')
|
||||
expect(dedupMapping['e5.png']).toBe('sunset_e5.png')
|
||||
|
||||
// Unique names remain unchanged
|
||||
expect(dedupMapping['c3.jpg']).toBe('portrait.jpg')
|
||||
expect(dedupMapping['d4.gif']).toBe('animation.gif')
|
||||
})
|
||||
|
||||
it('should return non-deduplicated mapping when deduplicated=false', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'hash1.png': 'image.png',
|
||||
'hash2.png': 'image.png'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
await service.getMapping('input')
|
||||
|
||||
// Without deduplication flag
|
||||
const normalMapping = service.getCachedMapping('input', false)
|
||||
expect(normalMapping['hash1.png']).toBe('image.png')
|
||||
expect(normalMapping['hash2.png']).toBe('image.png')
|
||||
|
||||
// With deduplication flag
|
||||
const dedupMapping = service.getCachedMapping('input', true)
|
||||
expect(dedupMapping['hash1.png']).toBe('image_hash1.png')
|
||||
expect(dedupMapping['hash2.png']).toBe('image_hash2.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMapping', () => {
|
||||
it('should fetch mappings from API', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'abc123.png': 'vacation_photo.png',
|
||||
'def456.jpg': 'profile_picture.jpg'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
const result = await service.getMapping('input')
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith('/files/mappings')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('should cache mappings and not refetch within TTL', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'abc123.png': 'vacation_photo.png'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
// First call
|
||||
await service.getMapping('input')
|
||||
expect(api.fetchApi).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second call should use cache
|
||||
const result = await service.getMapping('input')
|
||||
expect(api.fetchApi).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('should return empty object on API failure', async () => {
|
||||
vi.mocked(api.fetchApi).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const result = await service.getMapping('input')
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should return empty object on non-200 response', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found'
|
||||
} as any)
|
||||
|
||||
const result = await service.getMapping('input')
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCachedMapping', () => {
|
||||
it('should return empty object if no cached data', () => {
|
||||
const result = service.getCachedMapping('input')
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should return cached data after successful fetch', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'abc123.png': 'vacation_photo.png'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
await service.getMapping('input')
|
||||
|
||||
const result = service.getCachedMapping('input')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCachedReverseMapping', () => {
|
||||
it('should return reverse mapping (human -> hash)', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'abc123.png': 'vacation_photo.png',
|
||||
'def456.jpg': 'profile_picture.jpg'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
await service.getMapping('input')
|
||||
|
||||
const reverseMapping = service.getCachedReverseMapping('input')
|
||||
expect(reverseMapping).toEqual({
|
||||
'vacation_photo.png': 'abc123.png',
|
||||
'profile_picture.jpg': 'def456.jpg'
|
||||
})
|
||||
})
|
||||
|
||||
it('should return empty object if no cached data', () => {
|
||||
const result = service.getCachedReverseMapping('input')
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHashFromHumanName', () => {
|
||||
it('should convert human name to hash', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'abc123.png': 'vacation_photo.png'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
await service.getMapping('input')
|
||||
|
||||
const hash = service.getHashFromHumanName('vacation_photo.png', 'input')
|
||||
expect(hash).toBe('abc123.png')
|
||||
})
|
||||
|
||||
it('should return original name if no mapping exists', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({})
|
||||
} as any)
|
||||
|
||||
await service.getMapping('input')
|
||||
|
||||
const result = service.getHashFromHumanName('unknown.png', 'input')
|
||||
expect(result).toBe('unknown.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHumanReadableName', () => {
|
||||
it('should convert hash to human-readable name', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'abc123.png': 'vacation_photo.png'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
const humanName = await service.getHumanReadableName(
|
||||
'abc123.png',
|
||||
'input'
|
||||
)
|
||||
expect(humanName).toBe('vacation_photo.png')
|
||||
})
|
||||
|
||||
it('should return hash if no mapping exists', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({})
|
||||
} as any)
|
||||
|
||||
const result = await service.getHumanReadableName('xyz789.png', 'input')
|
||||
expect(result).toBe('xyz789.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshMapping', () => {
|
||||
it('should invalidate cache and fetch fresh data', async () => {
|
||||
const mockData1: FileNameMapping = {
|
||||
'abc123.png': 'old_photo.png'
|
||||
}
|
||||
const mockData2: FileNameMapping = {
|
||||
'def456.png': 'new_photo.png'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData1
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData2
|
||||
} as any)
|
||||
|
||||
// First fetch
|
||||
await service.getMapping('input')
|
||||
expect(service.getCachedMapping('input')).toEqual(mockData1)
|
||||
|
||||
// Refresh should fetch new data
|
||||
const refreshedData = await service.refreshMapping('input')
|
||||
expect(api.fetchApi).toHaveBeenCalledTimes(2)
|
||||
expect(refreshedData).toEqual(mockData2)
|
||||
expect(service.getCachedMapping('input')).toEqual(mockData2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidateCache', () => {
|
||||
it('should clear cache for specific file type', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'abc123.png': 'photo.png'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
await service.getMapping('input')
|
||||
expect(service.getCachedMapping('input')).toEqual(mockData)
|
||||
|
||||
service.invalidateCache('input')
|
||||
expect(service.getCachedMapping('input')).toEqual({})
|
||||
})
|
||||
|
||||
it('should clear all caches when no type specified', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'abc123.png': 'photo.png'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
await service.getMapping('input')
|
||||
await service.getMapping('output')
|
||||
|
||||
service.invalidateCache()
|
||||
|
||||
expect(service.getCachedMapping('input')).toEqual({})
|
||||
expect(service.getCachedMapping('output')).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureMappingsLoaded', () => {
|
||||
it('should preload mappings for immediate synchronous access', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'abc123.png': 'photo.png'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
// Ensure mappings are loaded
|
||||
await service.ensureMappingsLoaded('input')
|
||||
|
||||
// Should be available synchronously
|
||||
const cached = service.getCachedMapping('input')
|
||||
expect(cached).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('should not throw on API failure', async () => {
|
||||
vi.mocked(api.fetchApi).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
// Should not throw
|
||||
await expect(service.ensureMappingsLoaded('input')).resolves.not.toThrow()
|
||||
|
||||
// Should have empty mapping
|
||||
expect(service.getCachedMapping('input')).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyMappingToArray', () => {
|
||||
it('should apply mapping to array of filenames', async () => {
|
||||
const mockData: FileNameMapping = {
|
||||
'abc123.png': 'vacation.png',
|
||||
'def456.jpg': 'profile.jpg'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
const result = await service.applyMappingToArray(
|
||||
['abc123.png', 'def456.jpg', 'unknown.gif'],
|
||||
'input'
|
||||
)
|
||||
|
||||
expect(result).toEqual(['vacation.png', 'profile.jpg', 'unknown.gif'])
|
||||
})
|
||||
|
||||
it('should return original array on API failure', async () => {
|
||||
vi.mocked(api.fetchApi).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const input = ['abc123.png', 'def456.jpg']
|
||||
const result = await service.applyMappingToArray(input, 'input')
|
||||
|
||||
expect(result).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle invalid JSON response gracefully', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new Error('Invalid JSON')
|
||||
}
|
||||
} as any)
|
||||
|
||||
const result = await service.getMapping('input')
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should filter out invalid entries from response', async () => {
|
||||
const mockData = {
|
||||
'valid.png': 'photo.png',
|
||||
invalid: 123, // Invalid value type - will be filtered
|
||||
123: 'number_key', // Numeric key becomes string "123" in JS
|
||||
'another_valid.jpg': 'image.jpg'
|
||||
}
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockData
|
||||
} as any)
|
||||
|
||||
const result = await service.getMapping('input')
|
||||
|
||||
// Should filter out non-string values but keep string keys (including coerced numeric keys)
|
||||
expect(result).toEqual({
|
||||
'valid.png': 'photo.png',
|
||||
'123': 'number_key', // Numeric key becomes string
|
||||
'another_valid.jpg': 'image.jpg'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle null or array responses', async () => {
|
||||
// Test null response
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => null
|
||||
} as any)
|
||||
|
||||
let result = await service.getMapping('input')
|
||||
expect(result).toEqual({})
|
||||
|
||||
// Test array response
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => []
|
||||
} as any)
|
||||
|
||||
result = await service.getMapping('output')
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,10 +3,94 @@ import { describe, expect, it } from 'vitest'
|
||||
import { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
describe('TaskItemImpl', () => {
|
||||
describe('prompt property accessors', () => {
|
||||
it('should correctly access queueIndex from priority', () => {
|
||||
const taskItem = new TaskItemImpl('Pending', {
|
||||
priority: 5,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
})
|
||||
|
||||
expect(taskItem.queueIndex).toBe(5)
|
||||
})
|
||||
|
||||
it('should correctly access promptId from prompt_id', () => {
|
||||
const taskItem = new TaskItemImpl('History', {
|
||||
priority: 0,
|
||||
prompt_id: 'unique-prompt-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
})
|
||||
|
||||
expect(taskItem.promptId).toBe('unique-prompt-id')
|
||||
})
|
||||
|
||||
it('should correctly access extraData', () => {
|
||||
const extraData = {
|
||||
client_id: 'client-id',
|
||||
extra_pnginfo: {
|
||||
workflow: {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
}
|
||||
}
|
||||
}
|
||||
const taskItem = new TaskItemImpl('Running', {
|
||||
priority: 1,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: extraData
|
||||
})
|
||||
|
||||
expect(taskItem.extraData).toEqual(extraData)
|
||||
})
|
||||
|
||||
it('should correctly access workflow from extraPngInfo', () => {
|
||||
const workflow = {
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
}
|
||||
const taskItem = new TaskItemImpl('History', {
|
||||
priority: 0,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: {
|
||||
client_id: 'client-id',
|
||||
extra_pnginfo: { workflow }
|
||||
}
|
||||
})
|
||||
|
||||
expect(taskItem.workflow).toEqual(workflow)
|
||||
})
|
||||
|
||||
it('should return undefined workflow when extraPngInfo is missing', () => {
|
||||
const taskItem = new TaskItemImpl('History', {
|
||||
priority: 0,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
})
|
||||
|
||||
expect(taskItem.workflow).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove animated property from outputs during construction', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
@@ -26,7 +110,11 @@ describe('TaskItemImpl', () => {
|
||||
it('should handle outputs without animated property', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
@@ -42,7 +130,11 @@ describe('TaskItemImpl', () => {
|
||||
it('should recognize webm video from core', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
@@ -64,7 +156,11 @@ describe('TaskItemImpl', () => {
|
||||
it('should recognize webm video from VHS', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
@@ -93,7 +189,11 @@ describe('TaskItemImpl', () => {
|
||||
it('should recognize mp4 video from core', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
@@ -128,7 +228,11 @@ describe('TaskItemImpl', () => {
|
||||
it(`should recognize ${extension} audio`, () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
[0, 'prompt-id', {}, { client_id: 'client-id' }, []],
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'prompt-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{ status_str: 'success', messages: [], completed: true },
|
||||
{
|
||||
'node-1': {
|
||||
@@ -153,4 +257,193 @@ describe('TaskItemImpl', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution timestamp properties', () => {
|
||||
it('should extract execution start timestamp from messages', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
[
|
||||
'execution_start',
|
||||
{ prompt_id: 'test-id', timestamp: 1234567890 }
|
||||
],
|
||||
[
|
||||
'execution_success',
|
||||
{ prompt_id: 'test-id', timestamp: 1234567900 }
|
||||
]
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
expect(taskItem.executionStartTimestamp).toBe(1234567890)
|
||||
})
|
||||
|
||||
it('should return undefined when no execution_start message exists', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
[
|
||||
'execution_success',
|
||||
{ prompt_id: 'test-id', timestamp: 1234567900 }
|
||||
]
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
expect(taskItem.executionStartTimestamp).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined when status has no messages', () => {
|
||||
const taskItem = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 0,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
}
|
||||
)
|
||||
|
||||
expect(taskItem.executionStartTimestamp).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined when status is undefined', () => {
|
||||
const taskItem = new TaskItemImpl('History', {
|
||||
priority: 0,
|
||||
prompt_id: 'test-id',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
})
|
||||
|
||||
expect(taskItem.executionStartTimestamp).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sorting by execution start time', () => {
|
||||
it('should sort history tasks by execution start timestamp descending', () => {
|
||||
const task1 = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 1,
|
||||
prompt_id: 'old-task',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
['execution_start', { prompt_id: 'old-task', timestamp: 1000 }]
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
const task2 = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 2,
|
||||
prompt_id: 'new-task',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
['execution_start', { prompt_id: 'new-task', timestamp: 3000 }]
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
const task3 = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 3,
|
||||
prompt_id: 'middle-task',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
['execution_start', { prompt_id: 'middle-task', timestamp: 2000 }]
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
const tasks = [task1, task2, task3]
|
||||
|
||||
// Sort using the same logic as queueStore
|
||||
tasks.sort((a, b) => {
|
||||
const aTime = a.executionStartTimestamp ?? 0
|
||||
const bTime = b.executionStartTimestamp ?? 0
|
||||
return bTime - aTime
|
||||
})
|
||||
|
||||
expect(tasks[0].promptId).toBe('new-task')
|
||||
expect(tasks[1].promptId).toBe('middle-task')
|
||||
expect(tasks[2].promptId).toBe('old-task')
|
||||
})
|
||||
|
||||
it('should place tasks without execution start timestamp at end', () => {
|
||||
const taskWithTime = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 1,
|
||||
prompt_id: 'with-time',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: [
|
||||
['execution_start', { prompt_id: 'with-time', timestamp: 2000 }]
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
const taskWithoutTime = new TaskItemImpl(
|
||||
'History',
|
||||
{
|
||||
priority: 2,
|
||||
prompt_id: 'without-time',
|
||||
extra_data: { client_id: 'client-id' }
|
||||
},
|
||||
{
|
||||
status_str: 'success',
|
||||
completed: true,
|
||||
messages: []
|
||||
}
|
||||
)
|
||||
|
||||
const tasks = [taskWithoutTime, taskWithTime]
|
||||
|
||||
// Sort using the same logic as queueStore
|
||||
tasks.sort((a, b) => {
|
||||
const aTime = a.executionStartTimestamp ?? 0
|
||||
const bTime = b.executionStartTimestamp ?? 0
|
||||
return bTime - aTime
|
||||
})
|
||||
|
||||
expect(tasks[0].promptId).toBe('with-time')
|
||||
expect(tasks[1].promptId).toBe('without-time')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user