Compare commits

..

25 Commits

Author SHA1 Message Date
Richard Yu
f3e5b281dd wip2 2025-09-02 21:12:16 -07:00
Richard Yu
d83af149b0 wip 2025-09-02 16:56:40 -07:00
Jennifer Weber
4899c9d25b translations for human friendly auth errors 2025-08-29 21:53:23 -07:00
Jennifer Weber
0bd3c1271d Small fixes after rebase 2025-08-29 11:10:21 -07:00
Jennifer Weber
6eb91e4aed Show signin and signup errors on form 2025-08-29 02:32:06 -07:00
Jennifer Weber
3b3071c975 Fix for maintining the new item optimization in queue store 2025-08-29 02:32:06 -07:00
Jennifer Weber
68f0275a83 Fix for history items sometimes not appearing again
New items from the history endpoint were being ignored due to the sorting based on priority, and left out of the merge
Fixed by removing that optimization so they all go through merge.
2025-08-29 02:32:06 -07:00
Jennifer Weber
a0d66bb0d7 Fix for depulicating tasks in queuestore by promptId to take into account sorting differences 2025-08-29 02:32:06 -07:00
Jennifer Weber
1292ae0f14 Add error log when templates are not found 2025-08-29 02:32:03 -07:00
Christian Byrne
8da2b304ef allow updating outputs on custom nodes and inside subgraphs (#4963) 2025-08-29 02:30:34 -07:00
Jennifer Weber
0950da0b43 Update logic for dev server url after cloud https changes
default to staging http for now
env var can be overrden for local in the .env file
2025-08-29 02:30:34 -07:00
Deep Roy
86e2b1fc61 Add analytics for workflow loading (#4966)
Needs to land after https://github.com/Comfy-Org/cloud/pull/398

## Description

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

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

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

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

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

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

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

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

View File

@@ -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

View File

@@ -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)

View File

@@ -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.' }}

View File

@@ -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

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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.'
}}

View File

@@ -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

View File

@@ -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
View File

@@ -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*

1
.nvmrc
View File

@@ -1 +0,0 @@
24

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

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

View File

@@ -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

View File

@@ -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'
])

View File

@@ -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()`

View File

@@ -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
```

View File

@@ -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'
]
},
{

View File

@@ -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
View File

@@ -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"
}
}
]
}

View File

@@ -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"

View File

@@ -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',

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -12,5 +12,4 @@ onlyBuiltDependencies:
- '@playwright/browser-firefox'
- '@playwright/browser-webkit'
- esbuild
- nx
- oxc-resolver

View File

@@ -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>

View File

@@ -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()
}
}

View File

@@ -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>

View File

@@ -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]
}>()

View File

@@ -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]
}>()

View File

@@ -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>

View File

@@ -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>

View File

@@ -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...'
}
})
}

View File

@@ -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' },

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -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 = {

View File

@@ -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: {

View File

@@ -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'),

View File

@@ -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()

View File

@@ -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')
}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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'],

View File

@@ -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.

View File

@@ -446,9 +446,6 @@ export function useCoreCommands(): ComfyCommand[] {
)
group.resizeTo(canvas.selectedItems, padding)
canvas.graph?.add(group)
group.recomputeInsideNodes()
useTitleEditorStore().titleEditorTarget = group
}
},

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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')
}
})

View File

@@ -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
}

View File

@@ -1,7 +1,9 @@
export const COMFY_API_BASE_URL = __USE_PROD_CONFIG__
import { isProductionEnvironment } from './environment'
export const COMFY_API_BASE_URL = isProductionEnvironment()
? 'https://api.comfy.org'
: 'https://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
View File

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

View File

@@ -1,5 +1,7 @@
import { 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

View File

@@ -13,7 +13,6 @@ export const CORE_MENU_COMMANDS = [
],
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
[['Edit'], ['Comfy.OpenClipspace']],
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
[
['Help'],
[

View File

@@ -42,8 +42,6 @@ app.registerExtension({
this.graph.add(group)
// @ts-expect-error fixme ts strict error
this.graph.change()
group.recomputeInsideNodes()
}
})
}

View File

@@ -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%
)
`
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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])
}
})

View File

@@ -27,15 +27,6 @@
"title": "مفتاح API",
"whitelistInfo": "حول المواقع غير المدرجة في القائمة البيضاء"
},
"deleteAccount": {
"cancel": "إلغاء",
"confirm": "حذف الحساب",
"confirmMessage": "هل أنت متأكد أنك تريد حذف حسابك؟ لا يمكن التراجع عن هذا الإجراء وسيتم حذف جميع بياناتك نهائيًا.",
"confirmTitle": "حذف الحساب",
"deleteAccount": "حذف الحساب",
"success": "تم حذف الحساب",
"successDetail": "تم حذف حسابك بنجاح."
},
"login": {
"andText": "و",
"confirmPasswordLabel": "تأكيد كلمة المرور",

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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",

View File

@@ -27,15 +27,6 @@
"title": "APIキー",
"whitelistInfo": "ホワイトリストに登録されていないサイトについて"
},
"deleteAccount": {
"cancel": "キャンセル",
"confirm": "アカウントを削除",
"confirmMessage": "本当にアカウントを削除しますか?この操作は元に戻せず、すべてのデータが完全に削除されます。",
"confirmTitle": "アカウントを削除",
"deleteAccount": "アカウントを削除",
"success": "アカウントが削除されました",
"successDetail": "アカウントは正常に削除されました。"
},
"login": {
"andText": "および",
"confirmPasswordLabel": "パスワードの確認",

View File

@@ -27,15 +27,6 @@
"title": "API 키",
"whitelistInfo": "비허용 사이트에 대하여"
},
"deleteAccount": {
"cancel": "취소",
"confirm": "계정 삭제",
"confirmMessage": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없으며 모든 데이터가 영구적으로 삭제됩니다.",
"confirmTitle": "계정 삭제",
"deleteAccount": "계정 삭제",
"success": "계정이 삭제되었습니다",
"successDetail": "계정이 성공적으로 삭제되었습니다."
},
"login": {
"andText": "및",
"confirmPasswordLabel": "비밀번호 확인",

View File

@@ -27,15 +27,6 @@
"title": "API-ключ",
"whitelistInfo": "О не включённых в белый список сайтах"
},
"deleteAccount": {
"cancel": "Отмена",
"confirm": "Удалить аккаунт",
"confirmMessage": "Вы уверены, что хотите удалить свой аккаунт? Это действие необратимо и приведёт к безвозвратному удалению всех ваших данных.",
"confirmTitle": "Удалить аккаунт",
"deleteAccount": "Удалить аккаунт",
"success": "Аккаунт удалён",
"successDetail": "Ваш аккаунт был успешно удалён."
},
"login": {
"andText": "и",
"confirmPasswordLabel": "Подтвердите пароль",

View File

@@ -27,15 +27,6 @@
"title": "API 金鑰",
"whitelistInfo": "關於未列入白名單的網站"
},
"deleteAccount": {
"cancel": "取消",
"confirm": "刪除帳號",
"confirmMessage": "您確定要刪除您的帳號嗎?此操作無法復原,且將永久移除您所有的資料。",
"confirmTitle": "刪除帳號",
"deleteAccount": "刪除帳號",
"success": "帳號已刪除",
"successDetail": "您的帳號已成功刪除。"
},
"login": {
"andText": "以及",
"confirmPasswordLabel": "確認密碼",

View File

@@ -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": "复制当前工作流"

View File

@@ -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": "適合畫面"
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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

View File

@@ -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
)

View File

@@ -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)

View 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()

View File

@@ -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,

View File

@@ -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).

View File

@@ -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
}
})

View File

@@ -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}`
)
})
}

View File

@@ -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,

View File

@@ -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)
}
}
}
}

View 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')
}

View File

@@ -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))

View File

@@ -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)
}
}
}

View File

@@ -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'])
})
})

View File

@@ -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'])
})
})
})

View 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()
})
})

View 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({})
})
})
})

View File

@@ -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