mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
15 Commits
v1.26.8
...
feature/mu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9936fc8d5 | ||
|
|
55408ca9b1 | ||
|
|
953cf56c4f | ||
|
|
feaabd7c2a | ||
|
|
e83335dd9f | ||
|
|
2945adb513 | ||
|
|
0ab9af78d1 | ||
|
|
3de318f3df | ||
|
|
6513d39651 | ||
|
|
355efe2200 | ||
|
|
79d6572bc0 | ||
|
|
deec7dc22b | ||
|
|
2aa9bb8284 | ||
|
|
2ec38694fc | ||
|
|
c4e42e5125 |
7
.github/workflows/i18n.yaml
vendored
7
.github/workflows/i18n.yaml
vendored
@@ -25,13 +25,6 @@ jobs:
|
||||
key: i18n-tools-cache-${{ runner.os }}-${{ hashFiles('ComfyUI_frontend/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
i18n-tools-cache-${{ runner.os }}-
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('ComfyUI_frontend/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
2
.github/workflows/lint-and-format.yaml
vendored
2
.github/workflows/lint-and-format.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
|
||||
163
.github/workflows/pr-playwright-comment.yaml
vendored
Normal file
163
.github/workflows/pr-playwright-comment.yaml
vendored
Normal file
@@ -0,0 +1,163 @@
|
||||
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
|
||||
187
.github/workflows/pr-playwright-deploy.yaml
vendored
187
.github/workflows/pr-playwright-deploy.yaml
vendored
@@ -1,17 +1,14 @@
|
||||
name: PR Playwright Deploy and Comment
|
||||
name: PR Playwright Deploy
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Tests CI"]
|
||||
types: [requested, completed]
|
||||
|
||||
env:
|
||||
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
deploy-reports:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'completed'
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request'
|
||||
permissions:
|
||||
actions: read
|
||||
strategy:
|
||||
@@ -101,180 +98,4 @@ jobs:
|
||||
fi
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
comment-tests-starting:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'requested'
|
||||
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: Get completion time
|
||||
id: completion-time
|
||||
run: echo "time=$(date -u '${{ env.DATE_FORMAT }}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate comment body for start
|
||||
if: steps.pr.outputs.result != 'null'
|
||||
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: Comment PR - Tests Started
|
||||
if: steps.pr.outputs.result != 'null'
|
||||
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
|
||||
|
||||
comment-tests-completed:
|
||||
runs-on: ubuntu-latest
|
||||
needs: deploy-reports
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request' && github.event.action == 'completed' && always()
|
||||
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: Download all deployment info
|
||||
if: steps.pr.outputs.result != 'null'
|
||||
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'
|
||||
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 Complete
|
||||
if: steps.pr.outputs.result != 'null'
|
||||
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
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
24
.github/workflows/pr-storybook-comment.yaml
vendored
24
.github/workflows/pr-storybook-comment.yaml
vendored
@@ -8,10 +8,7 @@ on:
|
||||
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-')
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend' && github.event.workflow_run.event == 'pull_request'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
@@ -100,17 +97,7 @@ jobs:
|
||||
<!-- 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!'
|
||||
}}**
|
||||
${{ fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && '✅' || '❌' }} **${{ fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && 'Build completed successfully!' || 'Build failed!' }}**
|
||||
|
||||
⏰ Completed at: ${{ steps.completion-time.outputs.time }} UTC
|
||||
|
||||
@@ -118,9 +105,4 @@ jobs:
|
||||
- [📊 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.'
|
||||
}}
|
||||
${{ fromJSON(steps.workflow-run.outputs.result).conclusion == 'success' && '🎉 Your Storybook is ready for review!' || '⚠️ Please check the workflow logs for error details.' }}
|
||||
|
||||
7
.github/workflows/test-browser-exp.yaml
vendored
7
.github/workflows/test-browser-exp.yaml
vendored
@@ -11,13 +11,6 @@ jobs:
|
||||
if: github.event.label.name == 'New Browser Test Expectations'
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('ComfyUI_frontend/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
38
CLAUDE.md
38
CLAUDE.md
@@ -82,44 +82,6 @@ When referencing Comfy-Org repos:
|
||||
2. Use GitHub API for branches/PRs/metadata
|
||||
3. Curl GitHub website if needed
|
||||
|
||||
## Settings and Feature Flags Quick Reference
|
||||
|
||||
### Settings Usage
|
||||
```typescript
|
||||
const settingStore = useSettingStore()
|
||||
const value = settingStore.get('Comfy.SomeSetting') // Get setting
|
||||
await settingStore.set('Comfy.SomeSetting', newValue) // Update setting
|
||||
```
|
||||
|
||||
### Dynamic Defaults
|
||||
```typescript
|
||||
{
|
||||
id: 'Comfy.Example.Setting',
|
||||
defaultValue: () => window.innerWidth < 1024 ? 'small' : 'large' // Runtime context
|
||||
}
|
||||
```
|
||||
|
||||
### Version-Based Defaults
|
||||
```typescript
|
||||
{
|
||||
id: 'Comfy.Example.Feature',
|
||||
defaultValue: 'legacy',
|
||||
defaultsByInstallVersion: { '1.25.0': 'enhanced' } // Gradual rollout
|
||||
}
|
||||
```
|
||||
|
||||
### Feature Flags
|
||||
```typescript
|
||||
if (api.serverSupportsFeature('feature_name')) { // Check capability
|
||||
// Use enhanced feature
|
||||
}
|
||||
const value = api.getServerFeature('config_name', defaultValue) // Get config
|
||||
```
|
||||
|
||||
**Documentation:**
|
||||
- Settings system: `docs/SETTINGS.md`
|
||||
- Feature flags system: `docs/FEATURE_FLAGS.md`
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- NEVER use `any` type - use proper TypeScript types
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -59,6 +59,18 @@ test.describe('Execution error', () => {
|
||||
const executionError = comfyPage.page.locator('.comfy-error-report')
|
||||
await expect(executionError).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can display Issue Report form', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.queueButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.getByLabel('Help Fix This').click()
|
||||
const issueReportForm = comfyPage.page.getByText(
|
||||
'Submit Error Report (Optional)'
|
||||
)
|
||||
await expect(issueReportForm).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Missing models warning', () => {
|
||||
@@ -291,16 +303,37 @@ test.describe('Settings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Support', () => {
|
||||
test('Should open external zendesk link', async ({ comfyPage }) => {
|
||||
test.describe('Feedback dialog', () => {
|
||||
test('Should open from topmenu help command', async ({ comfyPage }) => {
|
||||
// Open feedback dialog from top menu
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
const pagePromise = comfyPage.page.context().waitForEvent('page')
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Help', 'Support'])
|
||||
const newPage = await pagePromise
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Help', 'Feedback'])
|
||||
|
||||
await newPage.waitForLoadState('networkidle')
|
||||
await expect(newPage).toHaveURL(/.*support\.comfy\.org.*/)
|
||||
await newPage.close()
|
||||
// Verify feedback dialog content is visible
|
||||
const feedbackHeader = comfyPage.page.getByRole('heading', {
|
||||
name: 'Feedback'
|
||||
})
|
||||
await expect(feedbackHeader).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should close when close button clicked', async ({ comfyPage }) => {
|
||||
// Open feedback dialog
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Help', 'Feedback'])
|
||||
|
||||
const feedbackHeader = comfyPage.page.getByRole('heading', {
|
||||
name: 'Feedback'
|
||||
})
|
||||
|
||||
// Close feedback dialog
|
||||
await comfyPage.page
|
||||
.getByLabel('', { exact: true })
|
||||
.getByLabel('Close')
|
||||
.click()
|
||||
await feedbackHeader.waitFor({ state: 'hidden' })
|
||||
|
||||
// Verify dialog is closed
|
||||
await expect(feedbackHeader).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
293
docs/SETTINGS.md
293
docs/SETTINGS.md
@@ -1,293 +0,0 @@
|
||||
# Settings System
|
||||
|
||||
## Overview
|
||||
|
||||
ComfyUI frontend uses a comprehensive settings system for user preferences with support for dynamic defaults, version-based rollouts, and environment-aware configuration.
|
||||
|
||||
### Settings Architecture
|
||||
- Settings are defined as `SettingParams` in `src/constants/coreSettings.ts`
|
||||
- Registered at app startup, loaded/saved via `useSettingStore` (Pinia)
|
||||
- Persisted per user via backend `/settings` endpoint
|
||||
- If a value hasn't been set by the user, the store returns the computed default
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:105-122
|
||||
function getDefaultValue<K extends keyof Settings>(
|
||||
key: K
|
||||
): Settings[K] | undefined {
|
||||
const param = getSettingById(key)
|
||||
if (param === undefined) return
|
||||
|
||||
const versionedDefault = getVersionedDefaultValue(key, param)
|
||||
if (versionedDefault) {
|
||||
return versionedDefault
|
||||
}
|
||||
|
||||
return typeof param.defaultValue === 'function'
|
||||
? param.defaultValue()
|
||||
: param.defaultValue
|
||||
}
|
||||
```
|
||||
|
||||
### Settings Registration Process
|
||||
|
||||
Settings are registered after server values are loaded:
|
||||
|
||||
```typescript
|
||||
// From src/components/graph/GraphCanvas.vue:311-315
|
||||
CORE_SETTINGS.forEach((setting) => {
|
||||
settingStore.addSetting(setting)
|
||||
})
|
||||
|
||||
await newUserService().initializeIfNewUser(settingStore)
|
||||
```
|
||||
|
||||
## Dynamic and Environment-Based Defaults
|
||||
|
||||
### Computed Defaults
|
||||
You can compute defaults dynamically using function defaults that access runtime context:
|
||||
|
||||
```typescript
|
||||
// From src/constants/coreSettings.ts:94-101
|
||||
{
|
||||
id: 'Comfy.Sidebar.Size',
|
||||
// Default to small if the window is less than 1536px(2xl) wide
|
||||
defaultValue: () => (window.innerWidth < 1536 ? 'small' : 'normal')
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// From src/constants/coreSettings.ts:306
|
||||
{
|
||||
id: 'Comfy.Locale',
|
||||
defaultValue: () => navigator.language.split('-')[0] || 'en'
|
||||
}
|
||||
```
|
||||
|
||||
### Version-Based Defaults
|
||||
You can vary defaults by installed frontend version using `defaultsByInstallVersion`:
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:129-150
|
||||
function getVersionedDefaultValue<K extends keyof Settings, TValue = Settings[K]>(
|
||||
key: K,
|
||||
param: SettingParams<TValue> | undefined
|
||||
): TValue | null {
|
||||
const defaultsByInstallVersion = param?.defaultsByInstallVersion
|
||||
if (defaultsByInstallVersion && key !== 'Comfy.InstalledVersion') {
|
||||
const installedVersion = get('Comfy.InstalledVersion')
|
||||
if (installedVersion) {
|
||||
const sortedVersions = Object.keys(defaultsByInstallVersion).sort(
|
||||
(a, b) => compareVersions(b, a)
|
||||
)
|
||||
for (const version of sortedVersions) {
|
||||
if (!isSemVer(version)) continue
|
||||
if (compareVersions(installedVersion, version) >= 0) {
|
||||
const versionedDefault = defaultsByInstallVersion[version]
|
||||
return typeof versionedDefault === 'function'
|
||||
? versionedDefault()
|
||||
: versionedDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
Example versioned defaults from codebase:
|
||||
|
||||
```typescript
|
||||
// From src/constants/coreSettings.ts:38-40
|
||||
{
|
||||
id: 'Comfy.Graph.LinkReleaseAction',
|
||||
defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU,
|
||||
defaultsByInstallVersion: {
|
||||
'1.24.1': LinkReleaseTriggerAction.SEARCH_BOX
|
||||
}
|
||||
}
|
||||
|
||||
// Another versioned default example
|
||||
{
|
||||
id: 'Comfy.Graph.LinkReleaseAction.Shift',
|
||||
defaultValue: LinkReleaseTriggerAction.SEARCH_BOX,
|
||||
defaultsByInstallVersion: {
|
||||
'1.24.1': LinkReleaseTriggerAction.CONTEXT_MENU
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Real Examples from Codebase
|
||||
|
||||
Here are actual settings showing different patterns:
|
||||
|
||||
```typescript
|
||||
// Number setting with validation
|
||||
{
|
||||
id: 'LiteGraph.Node.TooltipDelay',
|
||||
name: 'Tooltip Delay',
|
||||
type: 'number',
|
||||
attrs: {
|
||||
min: 100,
|
||||
max: 3000,
|
||||
step: 50
|
||||
},
|
||||
defaultValue: 500,
|
||||
versionAdded: '1.9.0'
|
||||
}
|
||||
|
||||
// Hidden system setting for tracking
|
||||
{
|
||||
id: 'Comfy.InstalledVersion',
|
||||
name: 'The frontend version that was running when the user first installed ComfyUI',
|
||||
type: 'hidden',
|
||||
defaultValue: null,
|
||||
versionAdded: '1.24.0'
|
||||
}
|
||||
|
||||
// Slider with complex tooltip
|
||||
{
|
||||
id: 'LiteGraph.Canvas.LowQualityRenderingZoomThreshold',
|
||||
name: 'Low quality rendering zoom threshold',
|
||||
tooltip: 'Zoom level threshold for performance mode. Lower values (0.1) = quality at all zoom levels. Higher values (1.0) = performance mode even when zoomed in.',
|
||||
type: 'slider',
|
||||
attrs: {
|
||||
min: 0.1,
|
||||
max: 1.0,
|
||||
step: 0.05
|
||||
},
|
||||
defaultValue: 0.5
|
||||
}
|
||||
```
|
||||
|
||||
### New User Version Capture
|
||||
|
||||
The initial installed version is captured for new users to ensure versioned defaults remain stable:
|
||||
|
||||
```typescript
|
||||
// From src/services/newUserService.ts:49-53
|
||||
await settingStore.set(
|
||||
'Comfy.InstalledVersion',
|
||||
__COMFYUI_FRONTEND_VERSION__
|
||||
)
|
||||
```
|
||||
|
||||
## Practical Patterns for Environment-Based Defaults
|
||||
|
||||
### Dynamic Default Patterns
|
||||
```typescript
|
||||
// Device-based default
|
||||
{
|
||||
id: 'Comfy.Example.MobileDefault',
|
||||
type: 'boolean',
|
||||
defaultValue: () => /Mobile/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
// Environment-based default
|
||||
{
|
||||
id: 'Comfy.Example.DevMode',
|
||||
type: 'boolean',
|
||||
defaultValue: () => import.meta.env.DEV
|
||||
}
|
||||
|
||||
// Window size based
|
||||
{
|
||||
id: 'Comfy.Example.CompactUI',
|
||||
type: 'boolean',
|
||||
defaultValue: () => window.innerWidth < 1024
|
||||
}
|
||||
```
|
||||
|
||||
### Version-Based Rollout Pattern
|
||||
```typescript
|
||||
{
|
||||
id: 'Comfy.Example.NewFeature',
|
||||
type: 'combo',
|
||||
options: ['legacy', 'enhanced'],
|
||||
defaultValue: 'legacy',
|
||||
defaultsByInstallVersion: {
|
||||
'1.25.0': 'enhanced'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Settings Persistence and Access
|
||||
|
||||
### API Interaction
|
||||
Values are stored per user via the backend. The store writes through API and falls back to defaults when not set:
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:73-75
|
||||
onChange(settingsById.value[key], newValue, oldValue)
|
||||
settingValues.value[key] = newValue
|
||||
await api.storeSetting(key, newValue)
|
||||
```
|
||||
|
||||
### Usage in Components
|
||||
```typescript
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// Get setting value (returns computed default if not set by user)
|
||||
const value = settingStore.get('Comfy.SomeSetting')
|
||||
|
||||
// Update setting value
|
||||
await settingStore.set('Comfy.SomeSetting', newValue)
|
||||
```
|
||||
|
||||
|
||||
## Advanced Settings Features
|
||||
|
||||
### Migration and Backward Compatibility
|
||||
|
||||
Settings support migration from deprecated values:
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:68-69, 172-175
|
||||
const newValue = tryMigrateDeprecatedValue(
|
||||
settingsById.value[key],
|
||||
clonedValue
|
||||
)
|
||||
|
||||
// Migration happens during addSetting for existing values:
|
||||
if (settingValues.value[setting.id] !== undefined) {
|
||||
settingValues.value[setting.id] = tryMigrateDeprecatedValue(
|
||||
setting,
|
||||
settingValues.value[setting.id]
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### onChange Callbacks
|
||||
|
||||
Settings can define onChange callbacks that receive the setting definition, new value, and old value:
|
||||
|
||||
```typescript
|
||||
// From src/stores/settingStore.ts:73, 177
|
||||
onChange(settingsById.value[key], newValue, oldValue) // During set()
|
||||
onChange(setting, get(setting.id), undefined) // During addSetting()
|
||||
```
|
||||
|
||||
### Settings UI and Categories
|
||||
|
||||
Settings are automatically grouped for UI based on their `category` or derived from `id`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'Comfy.Sidebar.Size',
|
||||
category: ['Appearance', 'Sidebar', 'Size'],
|
||||
// UI will group this under Appearance > Sidebar > Size
|
||||
}
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- Feature flag system: `docs/FEATURE_FLAGS.md`
|
||||
- Settings schema for backend: `src/schemas/apiSchema.ts` (zSettings)
|
||||
- Server configuration (separate from user settings): `src/constants/serverConfig.ts`
|
||||
|
||||
## Summary
|
||||
|
||||
- **Settings**: User preferences with dynamic/versioned defaults, persisted per user
|
||||
- **Environment Defaults**: Use function defaults to read runtime context (window, navigator, env)
|
||||
- **Version Rollouts**: Use `defaultsByInstallVersion` for gradual feature releases
|
||||
- **API Interaction**: Settings persist to `/settings` endpoint via `storeSetting()`
|
||||
@@ -1,82 +0,0 @@
|
||||
# Settings and Feature Flags Sequence Diagram
|
||||
|
||||
This diagram shows the flow of settings initialization, default resolution, persistence, and feature flags exchange.
|
||||
|
||||
This diagram accurately reflects the actual implementation in the ComfyUI frontend codebase.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as User
|
||||
participant Vue as Vue Component
|
||||
participant Store as SettingStore (Pinia)
|
||||
participant API as ComfyApi (WebSocket/REST)
|
||||
participant Backend as Backend
|
||||
participant NewUserSvc as NewUserService
|
||||
|
||||
Note over Vue,Store: App startup (GraphCanvas.vue)
|
||||
Vue->>Store: loadSettingValues()
|
||||
Store->>API: getSettings()
|
||||
API->>Backend: GET /settings
|
||||
Backend-->>API: settings map (per-user)
|
||||
API-->>Store: settings map
|
||||
Store-->>Vue: loaded
|
||||
|
||||
Vue->>Store: register CORE_SETTINGS (addSetting for each)
|
||||
loop For each setting registration
|
||||
Store->>Store: tryMigrateDeprecatedValue(existing value)
|
||||
Store->>Store: onChange(setting, currentValue, undefined)
|
||||
end
|
||||
|
||||
Note over Vue,NewUserSvc: New user detection
|
||||
Vue->>NewUserSvc: initializeIfNewUser(settingStore)
|
||||
NewUserSvc->>NewUserSvc: checkIsNewUser(settingStore)
|
||||
alt New user detected
|
||||
NewUserSvc->>Store: set("Comfy.InstalledVersion", __COMFYUI_FRONTEND_VERSION__)
|
||||
Store->>Store: tryMigrateDeprecatedValue(newValue)
|
||||
Store->>Store: onChange(setting, newValue, oldValue)
|
||||
Store->>API: storeSetting(key, newValue)
|
||||
API->>Backend: POST /settings/{id}
|
||||
else Existing user
|
||||
Note over NewUserSvc: Skip setting installed version
|
||||
end
|
||||
|
||||
Note over Vue,Store: Component reads a setting
|
||||
Vue->>Store: get(key)
|
||||
Store->>Store: exists(key)?
|
||||
alt User value exists
|
||||
Store-->>Vue: return stored user value
|
||||
else Not set by user
|
||||
Store->>Store: getVersionedDefaultValue(key)
|
||||
alt Versioned default matched (defaultsByInstallVersion)
|
||||
Store-->>Vue: return versioned default
|
||||
else No version match
|
||||
Store->>Store: evaluate defaultValue (function or constant)
|
||||
Note over Store: defaultValue can use window size,<br/>locale, env, etc.
|
||||
Store-->>Vue: return computed default
|
||||
end
|
||||
end
|
||||
|
||||
Note over User,Store: User updates a setting
|
||||
User->>Vue: changes setting in UI
|
||||
Vue->>Store: set(key, newValue)
|
||||
Store->>Store: tryMigrateDeprecatedValue(newValue)
|
||||
Store->>Store: check if newValue === oldValue (early return if same)
|
||||
Store->>Store: onChange(setting, newValue, oldValue)
|
||||
Store->>Store: update settingValues[key]
|
||||
Store->>API: storeSetting(key, newValue)
|
||||
API->>Backend: POST /settings/{id}
|
||||
Backend-->>API: 200 OK
|
||||
API-->>Store: ack
|
||||
|
||||
Note over API,Backend: Feature Flags WebSocket Exchange
|
||||
API->>Backend: WS connect
|
||||
API->>Backend: send { type: "feature_flags", data: clientFeatureFlags.json }
|
||||
Backend-->>API: WS send { type: "feature_flags", data: server flags }
|
||||
API->>API: store serverFeatureFlags = data
|
||||
|
||||
Note over Vue,API: Feature flag consumption in UI/logic
|
||||
Vue->>API: serverSupportsFeature(name)
|
||||
API-->>Vue: boolean (true only if flag === true)
|
||||
Vue->>API: getServerFeature(name, default)
|
||||
API-->>Vue: value or default
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.26.8",
|
||||
"version": "1.26.7",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -107,7 +107,6 @@ const rename = async (
|
||||
}
|
||||
}
|
||||
|
||||
const isRoot = props.item.key === 'root'
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
@@ -121,27 +120,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
command: async () => {
|
||||
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('menuLabels.Save'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.SaveWorkflow')
|
||||
},
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('menuLabels.Save As'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.SaveWorkflowAs')
|
||||
},
|
||||
visible: isRoot
|
||||
visible: props.item.key === 'root'
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
@@ -155,7 +134,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
visible: props.item.key === 'root'
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
@@ -163,7 +142,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
command: async () => {
|
||||
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: isRoot
|
||||
visible: props.item.key === 'root'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -21,9 +21,16 @@
|
||||
@click="showReport"
|
||||
/>
|
||||
<Button
|
||||
v-show="!reportOpen"
|
||||
v-show="!sendReportOpen"
|
||||
text
|
||||
:label="$t('issueReport.helpFix')"
|
||||
@click="showSendReport"
|
||||
/>
|
||||
<Button
|
||||
v-if="authStore.currentUser"
|
||||
v-show="!reportOpen"
|
||||
text
|
||||
:label="$t('issueReport.contactSupportTitle')"
|
||||
@click="showContactSupport"
|
||||
/>
|
||||
</div>
|
||||
@@ -34,6 +41,16 @@
|
||||
</ScrollPanel>
|
||||
<Divider />
|
||||
</template>
|
||||
<ReportIssuePanel
|
||||
v-if="sendReportOpen"
|
||||
:title="$t('issueReport.submitErrorReport')"
|
||||
:error-type="error.reportType ?? 'unknownError'"
|
||||
:extra-fields="[stackTraceField]"
|
||||
:tags="{
|
||||
exceptionMessage: error.exceptionMessage,
|
||||
nodeType: error.nodeType ?? 'UNKNOWN'
|
||||
}"
|
||||
/>
|
||||
<div class="flex gap-4 justify-end">
|
||||
<FindIssueButton
|
||||
:error-message="error.exceptionMessage"
|
||||
@@ -64,12 +81,18 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import type { ReportField } from '@/types/issueReportTypes'
|
||||
import {
|
||||
type ErrorReportData,
|
||||
generateErrorReport
|
||||
} from '@/utils/errorReportUtil'
|
||||
|
||||
import ReportIssuePanel from './error/ReportIssuePanel.vue'
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
|
||||
const { error } = defineProps<{
|
||||
error: Omit<ErrorReportData, 'workflow' | 'systemStats' | 'serverLogs'> & {
|
||||
/**
|
||||
@@ -91,6 +114,10 @@ const reportOpen = ref(false)
|
||||
const showReport = () => {
|
||||
reportOpen.value = true
|
||||
}
|
||||
const sendReportOpen = ref(false)
|
||||
const showSendReport = () => {
|
||||
sendReportOpen.value = true
|
||||
}
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
@@ -99,6 +126,15 @@ const title = computed<string>(
|
||||
() => error.nodeType ?? error.exceptionType ?? t('errorDialog.defaultTitle')
|
||||
)
|
||||
|
||||
const stackTraceField = computed<ReportField>(() => {
|
||||
return {
|
||||
label: t('issueReport.stackTrace'),
|
||||
value: 'StackTrace',
|
||||
optIn: true,
|
||||
getData: () => error.traceback
|
||||
}
|
||||
})
|
||||
|
||||
const showContactSupport = async () => {
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
33
src/components/dialog/content/IssueReportDialogContent.vue
Normal file
33
src/components/dialog/content/IssueReportDialogContent.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="p-2 h-full" aria-labelledby="issue-report-title">
|
||||
<Panel
|
||||
:pt="{
|
||||
root: 'border-none',
|
||||
content: 'p-0'
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<header class="flex flex-col items-center w-full">
|
||||
<h2 id="issue-report-title" class="text-4xl">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<span v-if="subtitle" class="text-muted mt-0">{{ subtitle }}</span>
|
||||
</header>
|
||||
</template>
|
||||
<ReportIssuePanel v-bind="panelProps" :pt="{ root: 'border-none' }" />
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Panel from 'primevue/panel'
|
||||
|
||||
import ReportIssuePanel from '@/components/dialog/content/error/ReportIssuePanel.vue'
|
||||
import type { IssueReportPanelProps } from '@/types/issueReportTypes'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
panelProps: IssueReportPanelProps
|
||||
}>()
|
||||
</script>
|
||||
338
src/components/dialog/content/error/ReportIssuePanel.spec.ts
Normal file
338
src/components/dialog/content/error/ReportIssuePanel.spec.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { Form } from '@primevue/forms'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMesages from '@/locales/en/main.json'
|
||||
import { IssueReportPanelProps } from '@/types/issueReportTypes'
|
||||
|
||||
import ReportIssuePanel from './ReportIssuePanel.vue'
|
||||
|
||||
const DEFAULT_FIELDS = ['Workflow', 'Logs', 'Settings', 'SystemStats']
|
||||
const CUSTOM_FIELDS = [
|
||||
{
|
||||
label: 'Custom Field',
|
||||
value: 'CustomField',
|
||||
optIn: true,
|
||||
getData: () => 'mock data'
|
||||
}
|
||||
]
|
||||
|
||||
async function getSubmittedContext() {
|
||||
const { captureMessage } = (await import('@sentry/core')) as any
|
||||
return captureMessage.mock.calls[0][1]
|
||||
}
|
||||
|
||||
async function submitForm(wrapper: any) {
|
||||
await wrapper.findComponent(Form).trigger('submit')
|
||||
return getSubmittedContext()
|
||||
}
|
||||
|
||||
async function findAndUpdateCheckbox(
|
||||
wrapper: any,
|
||||
value: string,
|
||||
checked = true
|
||||
) {
|
||||
const checkbox = wrapper
|
||||
.findAllComponents(Checkbox)
|
||||
.find((c: any) => c.props('value') === value)
|
||||
if (!checkbox) throw new Error(`Checkbox with value "${value}" not found`)
|
||||
|
||||
await checkbox.vm.$emit('update:modelValue', checked)
|
||||
return checkbox
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: enMesages
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: vi.fn(() => ({
|
||||
add: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getLogs: vi.fn().mockResolvedValue('mock logs'),
|
||||
getSystemStats: vi.fn().mockResolvedValue('mock stats'),
|
||||
getSettings: vi.fn().mockResolvedValue('mock settings'),
|
||||
fetchApi: vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
text: vi.fn().mockResolvedValue('')
|
||||
}),
|
||||
apiURL: vi.fn().mockReturnValue('https://test.com')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
asSerialisable: vi.fn().mockReturnValue({})
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@sentry/core', () => ({
|
||||
captureMessage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@primevue/forms', () => ({
|
||||
Form: {
|
||||
name: 'Form',
|
||||
template:
|
||||
'<form @submit.prevent="onSubmit"><slot :values="formValues" /></form>',
|
||||
props: ['resolver'],
|
||||
data() {
|
||||
return {
|
||||
formValues: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.$emit('submit', {
|
||||
valid: true,
|
||||
// @ts-expect-error fixme ts strict error
|
||||
values: this.formValues
|
||||
})
|
||||
},
|
||||
updateFieldValue(name: string, value: any) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.formValues[name] = value
|
||||
}
|
||||
}
|
||||
},
|
||||
FormField: {
|
||||
name: 'FormField',
|
||||
template:
|
||||
'<div><slot :modelValue="modelValue" @update:modelValue="updateValue" /></div>',
|
||||
props: ['name'],
|
||||
data() {
|
||||
return {
|
||||
modelValue: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
updateValue(value) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.modelValue = value
|
||||
// @ts-expect-error fixme ts strict error
|
||||
let parent = this.$parent
|
||||
while (parent && parent.$options.name !== 'Form') {
|
||||
parent = parent.$parent
|
||||
}
|
||||
if (parent) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
parent.updateFieldValue(this.name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
currentUser: {
|
||||
email: 'test@example.com'
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('ReportIssuePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
const mountComponent = (props: IssueReportPanelProps, options = {}): any => {
|
||||
return mount(ReportIssuePanel, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, createPinia()],
|
||||
directives: { tooltip: Tooltip }
|
||||
},
|
||||
props,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
it('renders the panel with all required components', () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
expect(wrapper.find('.p-panel').exists()).toBe(true)
|
||||
expect(wrapper.findAllComponents(Checkbox).length).toBe(6)
|
||||
expect(wrapper.findComponent(InputText).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(Textarea).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('updates selection when checkboxes are selected', async () => {
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error'
|
||||
})
|
||||
|
||||
const checkboxes = wrapper.findAllComponents(Checkbox)
|
||||
|
||||
for (const field of DEFAULT_FIELDS) {
|
||||
const checkbox = checkboxes.find(
|
||||
// @ts-expect-error fixme ts strict error
|
||||
(checkbox) => checkbox.props('value') === field
|
||||
)
|
||||
expect(checkbox).toBeDefined()
|
||||
|
||||
await checkbox?.vm.$emit('update:modelValue', [field])
|
||||
expect(wrapper.vm.selection).toContain(field)
|
||||
}
|
||||
})
|
||||
|
||||
it('updates contactInfo when input is changed', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const input = wrapper.findComponent(InputText)
|
||||
|
||||
await input.vm.$emit('update:modelValue', 'test@example.com')
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.user.email).toBe('test@example.com')
|
||||
})
|
||||
|
||||
it('updates additional details when textarea is changed', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const textarea = wrapper.findComponent(Textarea)
|
||||
|
||||
await textarea.vm.$emit('update:modelValue', 'This is a test detail.')
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.extra.details).toBe('This is a test detail.')
|
||||
})
|
||||
|
||||
it('set contact preferences back to false if email is removed', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const input = wrapper.findComponent(InputText)
|
||||
|
||||
// Set a valid email, enabling the contact preferences to be changed
|
||||
await input.vm.$emit('update:modelValue', 'name@example.com')
|
||||
|
||||
// Enable both contact preferences
|
||||
for (const pref of ['followUp', 'notifyOnResolution']) {
|
||||
await findAndUpdateCheckbox(wrapper, pref)
|
||||
}
|
||||
|
||||
// Change the email back to empty
|
||||
await input.vm.$emit('update:modelValue', '')
|
||||
const context = await submitForm(wrapper)
|
||||
|
||||
// Check that the contact preferences are back to false automatically
|
||||
expect(context.tags.followUp).toBe(false)
|
||||
expect(context.tags.notifyOnResolution).toBe(false)
|
||||
})
|
||||
|
||||
it('renders with overridden default fields', () => {
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error',
|
||||
defaultFields: ['Settings']
|
||||
})
|
||||
|
||||
// Filter out the contact preferences checkboxes
|
||||
const fieldCheckboxes = wrapper.findAllComponents(Checkbox).filter(
|
||||
// @ts-expect-error fixme ts strict error
|
||||
(checkbox) =>
|
||||
!['followUp', 'notifyOnResolution'].includes(checkbox.props('value'))
|
||||
)
|
||||
expect(fieldCheckboxes.length).toBe(1)
|
||||
expect(fieldCheckboxes.at(0)?.props('value')).toBe('Settings')
|
||||
})
|
||||
|
||||
it('renders additional fields when extraFields prop is provided', () => {
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error',
|
||||
extraFields: CUSTOM_FIELDS
|
||||
})
|
||||
const customCheckbox = wrapper
|
||||
.findAllComponents(Checkbox)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
.find((checkbox) => checkbox.props('value') === 'CustomField')
|
||||
expect(customCheckbox).toBeDefined()
|
||||
})
|
||||
|
||||
it('allows custom fields to be selected', async () => {
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error',
|
||||
extraFields: CUSTOM_FIELDS
|
||||
})
|
||||
|
||||
await findAndUpdateCheckbox(wrapper, 'CustomField')
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.extra.CustomField).toBe('mock data')
|
||||
})
|
||||
|
||||
it('does not submit unchecked fields', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const textarea = wrapper.findComponent(Textarea)
|
||||
|
||||
// Set details but don't check any field checkboxes
|
||||
await textarea.vm.$emit(
|
||||
'update:modelValue',
|
||||
'Report with only text but no fields selected'
|
||||
)
|
||||
const context = await submitForm(wrapper)
|
||||
|
||||
// Verify none of the optional fields were included
|
||||
for (const field of DEFAULT_FIELDS) {
|
||||
expect(context.extra[field]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
checkbox: 'Logs',
|
||||
apiMethod: 'getLogs',
|
||||
expectedKey: 'Logs',
|
||||
mockValue: 'mock logs'
|
||||
},
|
||||
{
|
||||
checkbox: 'SystemStats',
|
||||
apiMethod: 'getSystemStats',
|
||||
expectedKey: 'SystemStats',
|
||||
mockValue: 'mock stats'
|
||||
},
|
||||
{
|
||||
checkbox: 'Settings',
|
||||
apiMethod: 'getSettings',
|
||||
expectedKey: 'Settings',
|
||||
mockValue: 'mock settings'
|
||||
}
|
||||
])(
|
||||
'submits $checkbox data when checkbox is selected',
|
||||
async ({ checkbox, apiMethod, expectedKey, mockValue }) => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
|
||||
const { api } = (await import('@/scripts/api')) as any
|
||||
vi.spyOn(api, apiMethod).mockResolvedValue(mockValue)
|
||||
|
||||
await findAndUpdateCheckbox(wrapper, checkbox)
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.extra[expectedKey]).toBe(mockValue)
|
||||
}
|
||||
)
|
||||
|
||||
it('submits workflow when the Workflow checkbox is selected', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
|
||||
const { app } = (await import('@/scripts/app')) as any
|
||||
const mockWorkflow = { nodes: [], edges: [] }
|
||||
vi.spyOn(app.graph, 'asSerialisable').mockReturnValue(mockWorkflow)
|
||||
|
||||
await findAndUpdateCheckbox(wrapper, 'Workflow')
|
||||
const context = await submitForm(wrapper)
|
||||
|
||||
expect(context.extra.Workflow).toEqual(mockWorkflow)
|
||||
})
|
||||
})
|
||||
348
src/components/dialog/content/error/ReportIssuePanel.vue
Normal file
348
src/components/dialog/content/error/ReportIssuePanel.vue
Normal file
@@ -0,0 +1,348 @@
|
||||
<template>
|
||||
<Form
|
||||
v-slot="$form"
|
||||
:resolver="zodResolver(issueReportSchema)"
|
||||
@submit="submit"
|
||||
>
|
||||
<Panel :pt="$attrs.pt as any">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-4">
|
||||
<Button
|
||||
v-tooltip="!submitted ? $t('g.reportIssueTooltip') : undefined"
|
||||
:label="submitted ? $t('g.reportSent') : $t('g.reportIssue')"
|
||||
:severity="submitted ? 'secondary' : 'primary'"
|
||||
:icon="submitted ? 'pi pi-check' : 'pi pi-send'"
|
||||
:disabled="submitted"
|
||||
type="submit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 mt-2 border border-round surface-border shadow-1">
|
||||
<div class="flex flex-col gap-6">
|
||||
<FormField
|
||||
v-slot="$field"
|
||||
name="contactInfo"
|
||||
:initial-value="authStore.currentUser?.email"
|
||||
>
|
||||
<div class="self-stretch inline-flex justify-start items-center">
|
||||
<label for="contactInfo" class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.email')
|
||||
}}</label>
|
||||
</div>
|
||||
<InputText
|
||||
id="contactInfo"
|
||||
v-bind="$field"
|
||||
class="w-full"
|
||||
:placeholder="$t('issueReport.provideEmail')"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error && $field.touched && $field.value !== ''"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ t('issueReport.validation.invalidEmail') }}
|
||||
</Message>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="$field" name="helpType">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2.5"
|
||||
>
|
||||
<label for="helpType" class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.whatDoYouNeedHelpWith')
|
||||
}}</label>
|
||||
</div>
|
||||
<Dropdown
|
||||
v-bind="$field"
|
||||
v-model="$field.value"
|
||||
:options="helpTypes"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:placeholder="$t('issueReport.selectIssue')"
|
||||
class="w-full"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ t('issueReport.validation.selectIssueType') }}
|
||||
</Message>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2.5"
|
||||
>
|
||||
<span class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.whatCanWeInclude')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-row gap-3">
|
||||
<div v-for="field in fields" :key="field.value">
|
||||
<FormField
|
||||
v-if="field.optIn"
|
||||
v-slot="$field"
|
||||
:name="field.value"
|
||||
class="flex space-x-1"
|
||||
>
|
||||
<Checkbox
|
||||
v-bind="$field"
|
||||
v-model="selection"
|
||||
:input-id="field.value"
|
||||
:value="field.value"
|
||||
/>
|
||||
<label :for="field.value">{{ field.label }}</label>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<FormField v-slot="$field" name="details">
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2.5"
|
||||
>
|
||||
<label for="details" class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.describeTheProblem')
|
||||
}}</label>
|
||||
</div>
|
||||
<Textarea
|
||||
v-bind="$field"
|
||||
id="details"
|
||||
class="w-full"
|
||||
rows="5"
|
||||
:placeholder="$t('issueReport.provideAdditionalDetails')"
|
||||
:aria-label="$t('issueReport.provideAdditionalDetails')"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error && $field.touched"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{
|
||||
$field.value
|
||||
? t('issueReport.validation.maxLength')
|
||||
: t('issueReport.validation.descriptionRequired')
|
||||
}}
|
||||
</Message>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 mt-2">
|
||||
<div v-for="checkbox in contactCheckboxes" :key="checkbox.value">
|
||||
<FormField
|
||||
v-slot="$field"
|
||||
:name="checkbox.value"
|
||||
class="flex space-x-1"
|
||||
>
|
||||
<Checkbox
|
||||
v-bind="$field"
|
||||
v-model="contactPrefs"
|
||||
:input-id="checkbox.value"
|
||||
:value="checkbox.value"
|
||||
:disabled="
|
||||
$form.contactInfo?.error || !$form.contactInfo?.value
|
||||
"
|
||||
/>
|
||||
<label :for="checkbox.value">{{ checkbox.label }}</label>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Form, FormField, type FormSubmitEvent } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import type { CaptureContext, User } from '@sentry/core'
|
||||
import { captureMessage } from '@sentry/core'
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { cloneDeep } from 'es-toolkit/compat'
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import Panel from 'primevue/panel'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
type IssueReportFormData,
|
||||
issueReportSchema
|
||||
} from '@/schemas/issueReportSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type {
|
||||
DefaultField,
|
||||
IssueReportPanelProps,
|
||||
ReportField
|
||||
} from '@/types/issueReportTypes'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { generateUUID } from '@/utils/formatUtil'
|
||||
|
||||
const DEFAULT_ISSUE_NAME = 'User reported issue'
|
||||
|
||||
const props = defineProps<IssueReportPanelProps>()
|
||||
const { defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'] } =
|
||||
props
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
|
||||
const selection = ref<string[]>([])
|
||||
const contactPrefs = ref<string[]>([])
|
||||
const submitted = ref(false)
|
||||
|
||||
const contactCheckboxes = [
|
||||
{ label: t('issueReport.contactFollowUp'), value: 'followUp' },
|
||||
{ label: t('issueReport.notifyResolve'), value: 'notifyOnResolution' }
|
||||
]
|
||||
|
||||
const helpTypes = [
|
||||
{
|
||||
label: t('issueReport.helpTypes.billingPayments'),
|
||||
value: 'billingPayments'
|
||||
},
|
||||
{
|
||||
label: t('issueReport.helpTypes.loginAccessIssues'),
|
||||
value: 'loginAccessIssues'
|
||||
},
|
||||
{ label: t('issueReport.helpTypes.giveFeedback'), value: 'giveFeedback' },
|
||||
{ label: t('issueReport.helpTypes.bugReport'), value: 'bugReport' },
|
||||
{ label: t('issueReport.helpTypes.somethingElse'), value: 'somethingElse' }
|
||||
]
|
||||
|
||||
const defaultFieldsConfig: ReportField[] = [
|
||||
{
|
||||
label: t('issueReport.systemStats'),
|
||||
value: 'SystemStats',
|
||||
getData: () => api.getSystemStats(),
|
||||
optIn: true
|
||||
},
|
||||
{
|
||||
label: t('g.workflow'),
|
||||
value: 'Workflow',
|
||||
getData: () => cloneDeep(app.graph.asSerialisable()),
|
||||
optIn: true
|
||||
},
|
||||
{
|
||||
label: t('g.logs'),
|
||||
value: 'Logs',
|
||||
getData: () => api.getLogs(),
|
||||
optIn: true
|
||||
},
|
||||
{
|
||||
label: t('g.settings'),
|
||||
value: 'Settings',
|
||||
getData: () => api.getSettings(),
|
||||
optIn: true
|
||||
}
|
||||
]
|
||||
|
||||
const fields = computed(() => [
|
||||
...defaultFieldsConfig.filter(({ value }) =>
|
||||
defaultFields.includes(value as DefaultField)
|
||||
),
|
||||
...(props.extraFields ?? [])
|
||||
])
|
||||
|
||||
const createUser = (formData: IssueReportFormData): User => ({
|
||||
email: formData.contactInfo || undefined
|
||||
})
|
||||
|
||||
const createExtraData = async (formData: IssueReportFormData) => {
|
||||
const result: Record<string, unknown> = {}
|
||||
const isChecked = (fieldValue: string) => formData[fieldValue]
|
||||
|
||||
await Promise.all(
|
||||
fields.value
|
||||
.filter((field) => !field.optIn || isChecked(field.value))
|
||||
.map(async (field) => {
|
||||
try {
|
||||
result[field.value] = await field.getData()
|
||||
} catch (error) {
|
||||
console.error(`Failed to collect ${field.value}:`, error)
|
||||
result[field.value] = { error: String(error) }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const createCaptureContext = async (
|
||||
formData: IssueReportFormData
|
||||
): Promise<CaptureContext> => {
|
||||
return {
|
||||
user: createUser(formData),
|
||||
level: 'error',
|
||||
tags: {
|
||||
errorType: props.errorType,
|
||||
helpType: formData.helpType,
|
||||
followUp: formData.contactInfo ? formData.followUp : false,
|
||||
notifyOnResolution: formData.contactInfo
|
||||
? formData.notifyOnResolution
|
||||
: false,
|
||||
isElectron: isElectron(),
|
||||
..._.mapValues(props.tags, (tag) => _.trim(tag).replace(/[\n\r\t]/g, ' '))
|
||||
},
|
||||
extra: {
|
||||
details: formData.details,
|
||||
...(await createExtraData(formData))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const generateUniqueTicketId = (type: string) => `${type}-${generateUUID()}`
|
||||
|
||||
const submit = async (event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
try {
|
||||
const captureContext = await createCaptureContext(event.values)
|
||||
|
||||
// If it's billing or access issue, generate unique id to be used by customer service ticketing
|
||||
const isValidContactInfo = event.values.contactInfo?.length
|
||||
const isCustomerServiceIssue =
|
||||
isValidContactInfo &&
|
||||
['billingPayments', 'loginAccessIssues'].includes(
|
||||
event.values.helpType || ''
|
||||
)
|
||||
const issueName = isCustomerServiceIssue
|
||||
? `ticket-${generateUniqueTicketId(event.values.helpType || '')}`
|
||||
: DEFAULT_ISSUE_NAME
|
||||
captureMessage(issueName, captureContext)
|
||||
submitted.value = true
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.reportSent'),
|
||||
life: 3000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -112,12 +112,12 @@ import Divider from 'primevue/divider'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
@@ -128,10 +128,10 @@ interface CreditHistoryItemData {
|
||||
isPositive: boolean
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
@@ -160,8 +160,15 @@ const handleCreditsHistoryClick = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
const handleMessageSupport = () => {
|
||||
dialogService.showIssueReportDialog({
|
||||
title: t('issueReport.contactSupportTitle'),
|
||||
subtitle: t('issueReport.contactSupportDescription'),
|
||||
panelProps: {
|
||||
errorType: 'BillingSupport',
|
||||
defaultFields: ['Workflow', 'Logs', 'SystemStats', 'Settings']
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
|
||||
@@ -277,7 +277,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
icon: 'pi pi-question-circle',
|
||||
label: t('helpCenter.helpFeedback'),
|
||||
action: () => {
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
void commandStore.execute('Comfy.Feedback')
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
@@ -179,12 +179,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
const numImagesWidget = node.widgets?.find(
|
||||
(w) => w.name === 'num_images'
|
||||
) as IComboWidget
|
||||
const characterInput = node.inputs?.find(
|
||||
(i) => i.name === 'character_image'
|
||||
) as INodeInputSlot
|
||||
const hasCharacter =
|
||||
typeof characterInput?.link !== 'undefined' &&
|
||||
characterInput.link != null
|
||||
|
||||
if (!renderingSpeedWidget)
|
||||
return '$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
|
||||
@@ -194,23 +188,11 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
const renderingSpeed = String(renderingSpeedWidget.value)
|
||||
if (renderingSpeed.toLowerCase().includes('quality')) {
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.2
|
||||
} else {
|
||||
basePrice = 0.09
|
||||
}
|
||||
} else if (renderingSpeed.toLowerCase().includes('default')) {
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.15
|
||||
} else {
|
||||
basePrice = 0.06
|
||||
}
|
||||
basePrice = 0.09
|
||||
} else if (renderingSpeed.toLowerCase().includes('balanced')) {
|
||||
basePrice = 0.06
|
||||
} else if (renderingSpeed.toLowerCase().includes('turbo')) {
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.1
|
||||
} else {
|
||||
basePrice = 0.03
|
||||
}
|
||||
basePrice = 0.03
|
||||
}
|
||||
|
||||
const totalCost = (basePrice * numImages).toFixed(2)
|
||||
@@ -413,12 +395,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
const modeValue = String(modeWidget.value)
|
||||
|
||||
// Same pricing matrix as KlingTextToVideoNode
|
||||
if (modeValue.includes('v2-1')) {
|
||||
if (modeValue.includes('10s')) {
|
||||
return '$0.98/Run' // pro, 10s
|
||||
}
|
||||
return '$0.49/Run' // pro, 5s default
|
||||
} else if (modeValue.includes('v2-master')) {
|
||||
if (modeValue.includes('v2-master')) {
|
||||
if (modeValue.includes('10s')) {
|
||||
return '$2.80/Run'
|
||||
}
|
||||
@@ -1485,7 +1462,7 @@ export const useNodePricing = () => {
|
||||
OpenAIGPTImage1: ['quality', 'n'],
|
||||
IdeogramV1: ['num_images', 'turbo'],
|
||||
IdeogramV2: ['num_images', 'turbo'],
|
||||
IdeogramV3: ['rendering_speed', 'num_images', 'character_image'],
|
||||
IdeogramV3: ['rendering_speed', 'num_images'],
|
||||
FluxProKontextProNode: [],
|
||||
FluxProKontextMaxNode: [],
|
||||
VeoVideoGenerationNode: ['duration_seconds'],
|
||||
|
||||
@@ -75,29 +75,6 @@ export const useComputedWithWidgetWatch = (
|
||||
}
|
||||
})
|
||||
})
|
||||
if (widgetNames && widgetNames.length > widgetsToObserve.length) {
|
||||
//Inputs have been included
|
||||
const indexesToObserve = widgetNames
|
||||
.map((name) =>
|
||||
widgetsToObserve.some((w) => w.name == name)
|
||||
? -1
|
||||
: node.inputs.findIndex((i) => i.name == name)
|
||||
)
|
||||
.filter((i) => i >= 0)
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
(_type: unknown, index: number, isConnected: boolean) => {
|
||||
if (!indexesToObserve.includes(index)) return
|
||||
widgetValues.value = {
|
||||
...widgetValues.value,
|
||||
[indexesToObserve[index]]: isConnected
|
||||
}
|
||||
if (triggerCanvasRedraw) {
|
||||
node.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a function that creates a computed that responds to widget changes.
|
||||
|
||||
@@ -679,13 +679,36 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
await workflowService.closeWorkflow(workflowStore.activeWorkflow)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Feedback',
|
||||
icon: 'pi pi-megaphone',
|
||||
label: 'Give Feedback',
|
||||
versionAdded: '1.8.2',
|
||||
function: () => {
|
||||
dialogService.showIssueReportDialog({
|
||||
title: t('g.feedback'),
|
||||
subtitle: t('issueReport.feedbackTitle'),
|
||||
panelProps: {
|
||||
errorType: 'Feedback',
|
||||
defaultFields: ['SystemStats', 'Settings']
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ContactSupport',
|
||||
icon: 'pi pi-question',
|
||||
label: 'Contact Support',
|
||||
versionAdded: '1.17.8',
|
||||
function: () => {
|
||||
window.open('https://support.comfy.org/', '_blank')
|
||||
dialogService.showIssueReportDialog({
|
||||
title: t('issueReport.contactSupportTitle'),
|
||||
subtitle: t('issueReport.contactSupportDescription'),
|
||||
panelProps: {
|
||||
errorType: 'ContactSupport',
|
||||
defaultFields: ['Workflow', 'Logs', 'SystemStats', 'Settings']
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,5 +23,8 @@ export const CORE_MENU_COMMANDS = [
|
||||
'Comfy.Help.OpenComfyUIForum'
|
||||
]
|
||||
],
|
||||
[['Help'], ['Comfy.Help.AboutComfyUI', 'Comfy.ContactSupport']]
|
||||
[
|
||||
['Help'],
|
||||
['Comfy.Help.AboutComfyUI', 'Comfy.Feedback', 'Comfy.ContactSupport']
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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%
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -551,7 +551,37 @@
|
||||
"updateConsent": "لقد وافقت سابقًا على الإبلاغ عن الأعطال. نحن الآن نتتبع إحصائيات مبنية على الأحداث للمساعدة في تحديد الأخطاء وتحسين التطبيق. لا يتم جمع معلومات شخصية قابلة للتعريف."
|
||||
},
|
||||
"issueReport": {
|
||||
"helpFix": "المساعدة في الإصلاح"
|
||||
"contactFollowUp": "اتصل بي للمتابعة",
|
||||
"contactSupportDescription": "يرجى ملء النموذج أدناه مع تقريرك",
|
||||
"contactSupportTitle": "الاتصال بالدعم",
|
||||
"describeTheProblem": "صف المشكلة",
|
||||
"email": "البريد الإلكتروني",
|
||||
"feedbackTitle": "ساعدنا في تحسين ComfyUI من خلال تقديم الملاحظات",
|
||||
"helpFix": "المساعدة في الإصلاح",
|
||||
"helpTypes": {
|
||||
"billingPayments": "الفوترة / المدفوعات",
|
||||
"bugReport": "تقرير خطأ",
|
||||
"giveFeedback": "إرسال ملاحظات",
|
||||
"loginAccessIssues": "مشكلة في تسجيل الدخول / الوصول",
|
||||
"somethingElse": "أمر آخر"
|
||||
},
|
||||
"notifyResolve": "أعلمني عند الحل",
|
||||
"provideAdditionalDetails": "أضف تفاصيل إضافية",
|
||||
"provideEmail": "زودنا ببريدك الإلكتروني (اختياري)",
|
||||
"rating": "التقييم",
|
||||
"selectIssue": "اختر المشكلة",
|
||||
"stackTrace": "أثر التكديس",
|
||||
"submitErrorReport": "إرسال تقرير الخطأ (اختياري)",
|
||||
"systemStats": "إحصائيات النظام",
|
||||
"validation": {
|
||||
"descriptionRequired": "الوصف مطلوب",
|
||||
"helpTypeRequired": "نوع المساعدة مطلوب",
|
||||
"invalidEmail": "يرجى إدخال بريد إلكتروني صالح",
|
||||
"maxLength": "الرسالة طويلة جداً",
|
||||
"selectIssueType": "يرجى اختيار نوع المشكلة"
|
||||
},
|
||||
"whatCanWeInclude": "حدد ما يجب تضمينه في التقرير",
|
||||
"whatDoYouNeedHelpWith": "بماذا تحتاج المساعدة؟"
|
||||
},
|
||||
"load3d": {
|
||||
"applyingTexture": "جارٍ تطبيق الخامة...",
|
||||
|
||||
@@ -210,7 +210,37 @@
|
||||
}
|
||||
},
|
||||
"issueReport": {
|
||||
"helpFix": "Help Fix This"
|
||||
"submitErrorReport": "Submit Error Report (Optional)",
|
||||
"provideEmail": "Give us your email (optional)",
|
||||
"provideAdditionalDetails": "Provide additional details",
|
||||
"stackTrace": "Stack Trace",
|
||||
"systemStats": "System Stats",
|
||||
"contactFollowUp": "Contact me for follow up",
|
||||
"notifyResolve": "Notify me when resolved",
|
||||
"helpFix": "Help Fix This",
|
||||
"rating": "Rating",
|
||||
"feedbackTitle": "Help us improve ComfyUI by providing feedback",
|
||||
"contactSupportTitle": "Contact Support",
|
||||
"contactSupportDescription": "Please fill in the form below with your report",
|
||||
"selectIssue": "Select the issue",
|
||||
"whatDoYouNeedHelpWith": "What do you need help with?",
|
||||
"whatCanWeInclude": "Specify what to include in the report",
|
||||
"describeTheProblem": "Describe the problem",
|
||||
"email": "Email",
|
||||
"helpTypes": {
|
||||
"billingPayments": "Billing / Payments",
|
||||
"loginAccessIssues": "Login / Access Issues",
|
||||
"giveFeedback": "Give Feedback",
|
||||
"bugReport": "Bug Report",
|
||||
"somethingElse": "Something Else"
|
||||
},
|
||||
"validation": {
|
||||
"maxLength": "Message too long",
|
||||
"invalidEmail": "Please enter a valid email address",
|
||||
"selectIssueType": "Please select an issue type",
|
||||
"descriptionRequired": "Description is required",
|
||||
"helpTypeRequired": "Help type is required"
|
||||
}
|
||||
},
|
||||
"color": {
|
||||
"noColor": "No Color",
|
||||
|
||||
@@ -551,7 +551,37 @@
|
||||
"updateConsent": "Anteriormente optaste por reportar fallos. Ahora estamos rastreando métricas basadas en eventos para ayudar a identificar errores y mejorar la aplicación. No se recoge ninguna información personal identificable."
|
||||
},
|
||||
"issueReport": {
|
||||
"helpFix": "Ayuda a Solucionar Esto"
|
||||
"contactFollowUp": "Contáctame para seguimiento",
|
||||
"contactSupportDescription": "Por favor, complete el siguiente formulario con su reporte",
|
||||
"contactSupportTitle": "Contactar Soporte",
|
||||
"describeTheProblem": "Describa el problema",
|
||||
"email": "Correo electrónico",
|
||||
"feedbackTitle": "Ayúdanos a mejorar ComfyUI proporcionando comentarios",
|
||||
"helpFix": "Ayuda a Solucionar Esto",
|
||||
"helpTypes": {
|
||||
"billingPayments": "Facturación / Pagos",
|
||||
"bugReport": "Reporte de error",
|
||||
"giveFeedback": "Enviar comentarios",
|
||||
"loginAccessIssues": "Problemas de inicio de sesión / acceso",
|
||||
"somethingElse": "Otro"
|
||||
},
|
||||
"notifyResolve": "Notifícame cuando se resuelva",
|
||||
"provideAdditionalDetails": "Proporciona detalles adicionales (opcional)",
|
||||
"provideEmail": "Danos tu correo electrónico (opcional)",
|
||||
"rating": "Calificación",
|
||||
"selectIssue": "Seleccione el problema",
|
||||
"stackTrace": "Rastreo de Pila",
|
||||
"submitErrorReport": "Enviar Reporte de Error (Opcional)",
|
||||
"systemStats": "Estadísticas del Sistema",
|
||||
"validation": {
|
||||
"descriptionRequired": "Se requiere una descripción",
|
||||
"helpTypeRequired": "Se requiere el tipo de ayuda",
|
||||
"invalidEmail": "Por favor ingresa una dirección de correo electrónico válida",
|
||||
"maxLength": "Mensaje demasiado largo",
|
||||
"selectIssueType": "Por favor, seleccione un tipo de problema"
|
||||
},
|
||||
"whatCanWeInclude": "Especifique qué incluir en el reporte",
|
||||
"whatDoYouNeedHelpWith": "¿Con qué necesita ayuda?"
|
||||
},
|
||||
"load3d": {
|
||||
"applyingTexture": "Aplicando textura...",
|
||||
|
||||
@@ -551,7 +551,37 @@
|
||||
"updateConsent": "Vous avez précédemment accepté de signaler les plantages. Nous suivons maintenant des métriques basées sur les événements pour aider à identifier les bugs et améliorer l'application. Aucune information personnelle identifiable n'est collectée."
|
||||
},
|
||||
"issueReport": {
|
||||
"helpFix": "Aidez à résoudre cela"
|
||||
"contactFollowUp": "Contactez-moi pour un suivi",
|
||||
"contactSupportDescription": "Veuillez remplir le formulaire ci-dessous avec votre signalement",
|
||||
"contactSupportTitle": "Contacter le support",
|
||||
"describeTheProblem": "Décrivez le problème",
|
||||
"email": "E-mail",
|
||||
"feedbackTitle": "Aidez-nous à améliorer ComfyUI en fournissant des commentaires",
|
||||
"helpFix": "Aidez à résoudre cela",
|
||||
"helpTypes": {
|
||||
"billingPayments": "Facturation / Paiements",
|
||||
"bugReport": "Signaler un bug",
|
||||
"giveFeedback": "Donner un avis",
|
||||
"loginAccessIssues": "Problèmes de connexion / d'accès",
|
||||
"somethingElse": "Autre chose"
|
||||
},
|
||||
"notifyResolve": "Prévenez-moi lorsque résolu",
|
||||
"provideAdditionalDetails": "Fournir des détails supplémentaires (facultatif)",
|
||||
"provideEmail": "Donnez-nous votre email (Facultatif)",
|
||||
"rating": "Évaluation",
|
||||
"selectIssue": "Sélectionnez le problème",
|
||||
"stackTrace": "Trace de la pile",
|
||||
"submitErrorReport": "Soumettre un rapport d'erreur (Facultatif)",
|
||||
"systemStats": "Statistiques du système",
|
||||
"validation": {
|
||||
"descriptionRequired": "La description est requise",
|
||||
"helpTypeRequired": "Le type d'aide est requis",
|
||||
"invalidEmail": "Veuillez entrer une adresse e-mail valide",
|
||||
"maxLength": "Message trop long",
|
||||
"selectIssueType": "Veuillez sélectionner un type de problème"
|
||||
},
|
||||
"whatCanWeInclude": "Précisez ce qu'il faut inclure dans le rapport",
|
||||
"whatDoYouNeedHelpWith": "Avec quoi avez-vous besoin d'aide ?"
|
||||
},
|
||||
"load3d": {
|
||||
"applyingTexture": "Application de la texture...",
|
||||
|
||||
@@ -551,7 +551,37 @@
|
||||
"updateConsent": "以前、クラッシュレポートを報告することに同意していました。現在、バグの特定とアプリの改善を助けるためにイベントベースのメトリクスを追跡しています。個人を特定できる情報は収集されません。"
|
||||
},
|
||||
"issueReport": {
|
||||
"helpFix": "これを修正するのを助ける"
|
||||
"contactFollowUp": "フォローアップのために私に連絡する",
|
||||
"contactSupportDescription": "下記のフォームにご報告内容をご記入ください",
|
||||
"contactSupportTitle": "サポートに連絡",
|
||||
"describeTheProblem": "問題の内容を記述してください",
|
||||
"email": "メールアドレス",
|
||||
"feedbackTitle": "フィードバックを提供してComfyUIの改善にご協力ください",
|
||||
"helpFix": "これを修正するのを助ける",
|
||||
"helpTypes": {
|
||||
"billingPayments": "請求/支払い",
|
||||
"bugReport": "バグ報告",
|
||||
"giveFeedback": "フィードバックを送る",
|
||||
"loginAccessIssues": "ログイン/アクセスの問題",
|
||||
"somethingElse": "その他"
|
||||
},
|
||||
"notifyResolve": "解決したときに通知する",
|
||||
"provideAdditionalDetails": "追加の詳細を提供する(オプション)",
|
||||
"provideEmail": "あなたのメールアドレスを教えてください(オプション)",
|
||||
"rating": "評価",
|
||||
"selectIssue": "問題を選択してください",
|
||||
"stackTrace": "スタックトレース",
|
||||
"submitErrorReport": "エラーレポートを提出する(オプション)",
|
||||
"systemStats": "システム統計",
|
||||
"validation": {
|
||||
"descriptionRequired": "説明は必須です",
|
||||
"helpTypeRequired": "ヘルプの種類は必須です",
|
||||
"invalidEmail": "有効なメールアドレスを入力してください",
|
||||
"maxLength": "メッセージが長すぎます",
|
||||
"selectIssueType": "問題の種類を選択してください"
|
||||
},
|
||||
"whatCanWeInclude": "レポートに含める内容を指定してください",
|
||||
"whatDoYouNeedHelpWith": "どのようなサポートが必要ですか?"
|
||||
},
|
||||
"load3d": {
|
||||
"applyingTexture": "テクスチャを適用中...",
|
||||
|
||||
@@ -551,7 +551,37 @@
|
||||
"updateConsent": "이전에 충돌 보고에 동의하셨습니다. 이제 버그를 식별하고 앱을 개선하기 위해 이벤트 기반 통계 정보의 추적을 시작합니다. 개인을 식별할 수 있는 정보는 수집되지 않습니다."
|
||||
},
|
||||
"issueReport": {
|
||||
"helpFix": "이 문제 해결에 도움을 주세요"
|
||||
"contactFollowUp": "추적 조사를 위해 연락해 주세요",
|
||||
"contactSupportDescription": "아래 양식에 보고 내용을 작성해 주세요",
|
||||
"contactSupportTitle": "지원팀에 문의하기",
|
||||
"describeTheProblem": "문제를 설명해 주세요",
|
||||
"email": "이메일",
|
||||
"feedbackTitle": "피드백을 제공함으로써 ComfyUI를 개선하는 데 도움을 주십시오",
|
||||
"helpFix": "이 문제 해결에 도움을 주세요",
|
||||
"helpTypes": {
|
||||
"billingPayments": "결제 / 지불",
|
||||
"bugReport": "버그 신고",
|
||||
"giveFeedback": "피드백 제공",
|
||||
"loginAccessIssues": "로그인 / 접근 문제",
|
||||
"somethingElse": "기타"
|
||||
},
|
||||
"notifyResolve": "해결되었을 때 알려주세요",
|
||||
"provideAdditionalDetails": "추가 세부 사항 제공 (선택 사항)",
|
||||
"provideEmail": "이메일을 알려주세요 (선택 사항)",
|
||||
"rating": "평가",
|
||||
"selectIssue": "문제를 선택하세요",
|
||||
"stackTrace": "스택 추적",
|
||||
"submitErrorReport": "오류 보고서 제출 (선택 사항)",
|
||||
"systemStats": "시스템 통계",
|
||||
"validation": {
|
||||
"descriptionRequired": "설명은 필수입니다",
|
||||
"helpTypeRequired": "도움 유형은 필수입니다",
|
||||
"invalidEmail": "유효한 이메일 주소를 입력해 주세요",
|
||||
"maxLength": "메시지가 너무 깁니다",
|
||||
"selectIssueType": "문제 유형을 선택해 주세요"
|
||||
},
|
||||
"whatCanWeInclude": "보고서에 포함할 내용을 지정하세요",
|
||||
"whatDoYouNeedHelpWith": "어떤 도움이 필요하신가요?"
|
||||
},
|
||||
"load3d": {
|
||||
"applyingTexture": "텍스처 적용 중...",
|
||||
|
||||
@@ -551,7 +551,37 @@
|
||||
"updateConsent": "Вы ранее согласились на отчётность об ошибках. Теперь мы отслеживаем метрики событий, чтобы помочь выявить ошибки и улучшить приложение. Личная идентифицируемая информация не собирается."
|
||||
},
|
||||
"issueReport": {
|
||||
"helpFix": "Помочь исправить это"
|
||||
"contactFollowUp": "Свяжитесь со мной для уточнения",
|
||||
"contactSupportDescription": "Пожалуйста, заполните форму ниже для отправки вашего отчёта",
|
||||
"contactSupportTitle": "Связаться с поддержкой",
|
||||
"describeTheProblem": "Опишите проблему",
|
||||
"email": "Электронная почта",
|
||||
"feedbackTitle": "Помогите нам улучшить ComfyUI, оставив отзыв",
|
||||
"helpFix": "Помочь исправить это",
|
||||
"helpTypes": {
|
||||
"billingPayments": "Оплата / Платежи",
|
||||
"bugReport": "Сообщить об ошибке",
|
||||
"giveFeedback": "Оставить отзыв",
|
||||
"loginAccessIssues": "Проблемы со входом / доступом",
|
||||
"somethingElse": "Другое"
|
||||
},
|
||||
"notifyResolve": "Уведомить меня, когда проблема будет решена",
|
||||
"provideAdditionalDetails": "Предоставьте дополнительные сведения (необязательно)",
|
||||
"provideEmail": "Укажите вашу электронную почту (необязательно)",
|
||||
"rating": "Рейтинг",
|
||||
"selectIssue": "Выберите проблему",
|
||||
"stackTrace": "Трассировка стека",
|
||||
"submitErrorReport": "Отправить отчёт об ошибке (необязательно)",
|
||||
"systemStats": "Статистика системы",
|
||||
"validation": {
|
||||
"descriptionRequired": "Описание обязательно",
|
||||
"helpTypeRequired": "Тип помощи обязателен",
|
||||
"invalidEmail": "Пожалуйста, введите действительный адрес электронной почты",
|
||||
"maxLength": "Сообщение слишком длинное",
|
||||
"selectIssueType": "Пожалуйста, выберите тип проблемы"
|
||||
},
|
||||
"whatCanWeInclude": "Уточните, что включить в отчёт",
|
||||
"whatDoYouNeedHelpWith": "С чем вам нужна помощь?"
|
||||
},
|
||||
"load3d": {
|
||||
"applyingTexture": "Применение текстуры...",
|
||||
|
||||
@@ -551,7 +551,37 @@
|
||||
"updateConsent": "您先前已選擇回報當機。現在我們會追蹤事件型統計資料,以協助找出錯誤並改進應用程式。不會收集任何可識別個人身分的資訊。"
|
||||
},
|
||||
"issueReport": {
|
||||
"helpFix": "協助修復此問題"
|
||||
"contactFollowUp": "需要聯絡我以便後續追蹤",
|
||||
"contactSupportDescription": "請填寫下列表單並提交您的報告",
|
||||
"contactSupportTitle": "聯絡客服支援",
|
||||
"describeTheProblem": "請描述問題",
|
||||
"email": "電子郵件",
|
||||
"feedbackTitle": "協助我們改進 ComfyUI,請提供您的回饋",
|
||||
"helpFix": "協助修復此問題",
|
||||
"helpTypes": {
|
||||
"billingPayments": "帳單/付款問題",
|
||||
"bugReport": "錯誤回報",
|
||||
"giveFeedback": "提供回饋",
|
||||
"loginAccessIssues": "登入/存取問題",
|
||||
"somethingElse": "其他"
|
||||
},
|
||||
"notifyResolve": "問題解決時通知我",
|
||||
"provideAdditionalDetails": "提供更多細節",
|
||||
"provideEmail": "請提供您的電子郵件(選填)",
|
||||
"rating": "評分",
|
||||
"selectIssue": "請選擇問題",
|
||||
"stackTrace": "堆疊追蹤",
|
||||
"submitErrorReport": "提交錯誤報告(選填)",
|
||||
"systemStats": "系統狀態",
|
||||
"validation": {
|
||||
"descriptionRequired": "請填寫問題描述",
|
||||
"helpTypeRequired": "請選擇協助類型",
|
||||
"invalidEmail": "請輸入有效的電子郵件地址",
|
||||
"maxLength": "訊息過長",
|
||||
"selectIssueType": "請選擇問題類型"
|
||||
},
|
||||
"whatCanWeInclude": "請說明報告中要包含哪些內容",
|
||||
"whatDoYouNeedHelpWith": "您需要什麼協助?"
|
||||
},
|
||||
"load3d": {
|
||||
"applyingTexture": "正在套用材質貼圖...",
|
||||
|
||||
@@ -551,7 +551,37 @@
|
||||
"updateConsent": "您之前选择了报告崩溃。我们现在正在跟踪基于事件的度量,以帮助识别错误并改进应用程序。我们不收集任何个人可识别信息。"
|
||||
},
|
||||
"issueReport": {
|
||||
"helpFix": "帮助修复这个"
|
||||
"contactFollowUp": "跟进联系我",
|
||||
"contactSupportDescription": "请填写下方表格提交您的报告",
|
||||
"contactSupportTitle": "联系支持",
|
||||
"describeTheProblem": "描述问题",
|
||||
"email": "电子邮箱",
|
||||
"feedbackTitle": "通过提供反馈帮助我们改进ComfyUI",
|
||||
"helpFix": "帮助修复这个",
|
||||
"helpTypes": {
|
||||
"billingPayments": "账单 / 支付",
|
||||
"bugReport": "错误报告",
|
||||
"giveFeedback": "提交反馈",
|
||||
"loginAccessIssues": "登录 / 访问问题",
|
||||
"somethingElse": "其他"
|
||||
},
|
||||
"notifyResolve": "解决时通知我",
|
||||
"provideAdditionalDetails": "提供额外的详细信息(可选)",
|
||||
"provideEmail": "提供您的电子邮件(可选)",
|
||||
"rating": "评分",
|
||||
"selectIssue": "选择问题",
|
||||
"stackTrace": "堆栈跟踪",
|
||||
"submitErrorReport": "提交错误报告(可选)",
|
||||
"systemStats": "系统状态",
|
||||
"validation": {
|
||||
"descriptionRequired": "描述为必填项",
|
||||
"helpTypeRequired": "帮助类型为必选项",
|
||||
"invalidEmail": "请输入有效的电子邮件地址",
|
||||
"maxLength": "消息过长",
|
||||
"selectIssueType": "请选择一个问题类型"
|
||||
},
|
||||
"whatCanWeInclude": "请说明报告中需要包含的内容",
|
||||
"whatDoYouNeedHelpWith": "您需要什么帮助?"
|
||||
},
|
||||
"load3d": {
|
||||
"applyingTexture": "应用纹理中...",
|
||||
|
||||
28
src/schemas/issueReportSchema.ts
Normal file
28
src/schemas/issueReportSchema.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const checkboxField = z.boolean().optional()
|
||||
export const issueReportSchema = z
|
||||
.object({
|
||||
contactInfo: z.string().email().max(320).optional().or(z.literal('')),
|
||||
details: z
|
||||
.string()
|
||||
.min(1, { message: t('validation.descriptionRequired') })
|
||||
.max(5_000, { message: t('validation.maxLength', { length: 5_000 }) })
|
||||
.optional(),
|
||||
helpType: z.string().optional()
|
||||
})
|
||||
.catchall(checkboxField)
|
||||
.refine((data) => Object.values(data).some((value) => value), {
|
||||
path: ['details', 'helpType']
|
||||
})
|
||||
.refine((data) => data.helpType !== undefined && data.helpType !== '', {
|
||||
message: t('issueReport.validation.helpTypeRequired'),
|
||||
path: ['helpType']
|
||||
})
|
||||
.refine((data) => data.details !== undefined && data.details !== '', {
|
||||
message: t('issueReport.validation.descriptionRequired'),
|
||||
path: ['details']
|
||||
})
|
||||
export type IssueReportFormData = z.infer<typeof issueReportSchema>
|
||||
@@ -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)
|
||||
@@ -4,6 +4,7 @@ import { Component } from 'vue'
|
||||
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
|
||||
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
|
||||
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
|
||||
import IssueReportDialogContent from '@/components/dialog/content/IssueReportDialogContent.vue'
|
||||
import LoadWorkflowWarning from '@/components/dialog/content/LoadWorkflowWarning.vue'
|
||||
import ManagerProgressDialogContent from '@/components/dialog/content/ManagerProgressDialogContent.vue'
|
||||
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
|
||||
@@ -123,6 +124,16 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
function showIssueReportDialog(
|
||||
props: InstanceType<typeof IssueReportDialogContent>['$props']
|
||||
) {
|
||||
dialogStore.showDialog({
|
||||
key: 'global-issue-report',
|
||||
component: IssueReportDialogContent,
|
||||
props
|
||||
})
|
||||
}
|
||||
|
||||
function showManagerDialog(
|
||||
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
|
||||
) {
|
||||
@@ -459,6 +470,7 @@ export const useDialogService = () => {
|
||||
showAboutDialog,
|
||||
showExecutionErrorDialog,
|
||||
showTemplateWorkflowsDialog,
|
||||
showIssueReportDialog,
|
||||
showManagerDialog,
|
||||
showManagerProgressDialog,
|
||||
showErrorDialog,
|
||||
|
||||
@@ -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'])
|
||||
|
||||
51
src/types/issueReportTypes.ts
Normal file
51
src/types/issueReportTypes.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type DefaultField = 'Workflow' | 'Logs' | 'SystemStats' | 'Settings'
|
||||
|
||||
export interface ReportField {
|
||||
/**
|
||||
* The label of the field, shown next to the checkbox if the field is opt-in.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* A unique identifier for the field, used internally as the key for this field's value.
|
||||
*/
|
||||
value: string
|
||||
|
||||
/**
|
||||
* The data associated with this field, sent as part of the report.
|
||||
*/
|
||||
getData: () => unknown
|
||||
|
||||
/**
|
||||
* Indicates whether the field requires explicit opt-in from the user
|
||||
* before its data is included in the report.
|
||||
*/
|
||||
optIn: boolean
|
||||
}
|
||||
|
||||
export interface IssueReportPanelProps {
|
||||
/**
|
||||
* The type of error being reported. This is used to categorize the error.
|
||||
*/
|
||||
errorType: string
|
||||
|
||||
/**
|
||||
* Which of the default fields to include in the report.
|
||||
*/
|
||||
defaultFields?: DefaultField[]
|
||||
|
||||
/**
|
||||
* Additional fields to include in the report.
|
||||
*/
|
||||
extraFields?: ReportField[]
|
||||
|
||||
/**
|
||||
* Tags that will be added to the report. Tags are used to further categorize the error.
|
||||
*/
|
||||
tags?: Record<string, string>
|
||||
|
||||
/**
|
||||
* The title displayed in the dialog.
|
||||
*/
|
||||
title?: string
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user