Compare commits
12 Commits
v1.32.5
...
luke-mino-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a87e4c601 | ||
|
|
3fc1d1663b | ||
|
|
5ece3d6f2e | ||
|
|
17ceb75dce | ||
|
|
bd6825a274 | ||
|
|
f490b81be5 | ||
|
|
ddbd26c062 | ||
|
|
adfd2e514e | ||
|
|
f0f554392d | ||
|
|
e639577685 | ||
|
|
596add9f63 | ||
|
|
5e4965d131 |
6
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -36,9 +36,9 @@ body:
|
||||
3. Click Queue Prompt
|
||||
4. See error
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -57,11 +57,11 @@ runs:
|
||||
package.json)
|
||||
LINKS_VALUE=$(printf '%s\n%s' \
|
||||
'PyPI|https://pypi.org/project/comfyui-frontend-package/{{version}}/' \
|
||||
'npm types|https://npm.im/@comfyorg/comfyui-frontend-types@{{version}}')
|
||||
'npm types|https://www.npmjs.com/package/@comfyorg/comfyui-frontend-types/v/{{version}}')
|
||||
;;
|
||||
apps/desktop-ui/package.json)
|
||||
MARKER='desktop-release-summary'
|
||||
LINKS_VALUE='npm desktop UI|https://npm.im/@comfyorg/desktop-ui@{{version}}'
|
||||
LINKS_VALUE='npm desktop UI|https://www.npmjs.com/package/@comfyorg/desktop-ui/v/{{version}}'
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -74,7 +74,7 @@ runs:
|
||||
echo "<!--$MARKER:$DIFF_PREFIX$NEW_VERSION-->"
|
||||
echo "$MESSAGE"
|
||||
echo ""
|
||||
echo "- $DIFF_LABEL: [$DIFF_PREFIX$PREV_VERSION...$DIFF_PREFIX$NEW_VERSION]($DIFF_URL)"
|
||||
echo "- $DIFF_LABEL: [\`$DIFF_PREFIX$PREV_VERSION...$DIFF_PREFIX$NEW_VERSION\`]($DIFF_URL)"
|
||||
|
||||
while IFS= read -r RAW_LINE; do
|
||||
LINE=$(echo "$RAW_LINE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
@@ -87,7 +87,7 @@ runs:
|
||||
URL_TEMPLATE=${LINE#*|}
|
||||
URL=${URL_TEMPLATE//\{\{version\}\}/$NEW_VERSION}
|
||||
URL=${URL//\{\{prev_version\}\}/$PREV_VERSION}
|
||||
echo "- $LABEL: $URL"
|
||||
echo "- $LABEL: [\`$NEW_VERSION\`]($URL)"
|
||||
done <<< "$LINKS_VALUE"
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -105,4 +105,4 @@ jobs:
|
||||
labels: Manager
|
||||
delete-branch: true
|
||||
add-paths: |
|
||||
src/types/generatedManagerTypes.ts
|
||||
src/types/generatedManagerTypes.ts
|
||||
|
||||
2
.github/workflows/ci-lint-format.yaml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit changes
|
||||
|
||||
2
.github/workflows/ci-python-validation.yaml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
paths:
|
||||
- 'tools/devtools/**'
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'tools/devtools/**'
|
||||
|
||||
|
||||
14
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
deploy-and-comment-forked-pr:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.head_repository != null &&
|
||||
github.event.workflow_run.repository != null &&
|
||||
@@ -43,14 +43,14 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
|
||||
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
|
||||
|
||||
|
||||
if (!pr) {
|
||||
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
|
||||
return pr.number;
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
pattern: playwright-report-*
|
||||
path: reports
|
||||
|
||||
|
||||
- name: Handle Test Completion
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
env:
|
||||
@@ -85,9 +85,9 @@ jobs:
|
||||
# Rename merged report if exists
|
||||
[ -d "reports/playwright-report-chromium-merged" ] && \
|
||||
mv reports/playwright-report-chromium-merged reports/playwright-report-chromium
|
||||
|
||||
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"completed"
|
||||
"completed"
|
||||
|
||||
2
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
with:
|
||||
include_build_step: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright # Setup Playwright and cache browsers
|
||||
uses: ./.github/actions/setup-playwright # Setup Playwright and cache browsers
|
||||
|
||||
# Save the entire workspace as cache for later test jobs to restore
|
||||
- name: Generate cache key
|
||||
|
||||
12
.github/workflows/ci-tests-storybook-forks.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
deploy-and-comment-forked-pr:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.head_repository != null &&
|
||||
github.event.workflow_run.repository != null &&
|
||||
@@ -43,14 +43,14 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
|
||||
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
|
||||
|
||||
|
||||
if (!pr) {
|
||||
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
|
||||
return pr.number;
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
name: storybook-static
|
||||
path: storybook-static
|
||||
|
||||
|
||||
- name: Handle Storybook Completion
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
env:
|
||||
@@ -88,4 +88,4 @@ jobs:
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"completed"
|
||||
"completed"
|
||||
|
||||
28
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -2,7 +2,7 @@ name: "CI: Tests Storybook"
|
||||
description: "Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages"
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
|
||||
- name: Post starting comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0 # Required for Chromatic baseline
|
||||
fetch-depth: 0 # Required for Chromatic baseline
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -111,9 +111,9 @@ jobs:
|
||||
with:
|
||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
buildScriptName: build-storybook
|
||||
autoAcceptChanges: 'main' # Auto-accept changes on main branch
|
||||
exitOnceUploaded: true # Don't wait for UI tests to complete
|
||||
onlyChanged: true # Only capture changed stories
|
||||
autoAcceptChanges: 'main' # Auto-accept changes on main branch
|
||||
exitOnceUploaded: true # Don't wait for UI tests to complete
|
||||
onlyChanged: true # Only capture changed stories
|
||||
|
||||
- name: Set job status
|
||||
id: job-status
|
||||
@@ -138,17 +138,17 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
|
||||
- name: Download Storybook build
|
||||
if: needs.storybook-build.outputs.conclusion == 'success'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-static
|
||||
path: storybook-static
|
||||
|
||||
|
||||
- name: Make deployment script executable
|
||||
run: chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
|
||||
|
||||
- name: Deploy Storybook and comment on PR
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -176,25 +176,25 @@ jobs:
|
||||
script: |
|
||||
const buildUrl = '${{ needs.chromatic-deployment.outputs.chromatic-build-url }}';
|
||||
const storybookUrl = '${{ needs.chromatic-deployment.outputs.chromatic-storybook-url }}';
|
||||
|
||||
|
||||
// Find the existing Storybook comment
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: ${{ github.event.pull_request.number }}
|
||||
});
|
||||
|
||||
const storybookComment = comments.find(comment =>
|
||||
|
||||
const storybookComment = comments.find(comment =>
|
||||
comment.body.includes('<!-- STORYBOOK_BUILD_STATUS -->')
|
||||
);
|
||||
|
||||
|
||||
if (storybookComment && buildUrl && storybookUrl) {
|
||||
// Append Chromatic info to existing comment
|
||||
const updatedBody = storybookComment.body.replace(
|
||||
/---\n(.*)$/s,
|
||||
`---\n### 🎨 Chromatic Visual Tests\n- 📊 [View Chromatic Build](${buildUrl})\n- 📚 [View Chromatic Storybook](${storybookUrl})\n\n$1`
|
||||
);
|
||||
|
||||
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
33
.github/workflows/ci-yaml-validation.yaml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: "CI: YAML Validation"
|
||||
description: "Validates YAML syntax and style using yamllint with relaxed rules"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '**/*.yml'
|
||||
- '**/*.yaml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**/*.yml'
|
||||
- '**/*.yaml'
|
||||
|
||||
jobs:
|
||||
yaml-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install yamllint
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install yamllint
|
||||
|
||||
- name: Validate YAML syntax and style
|
||||
run: ./scripts/cicd/check-yaml.sh
|
||||
82
.github/workflows/i18n-update-core.yaml
vendored
@@ -2,11 +2,11 @@ name: "i18n: Update Core"
|
||||
description: "Generates and updates translations for core ComfyUI components using OpenAI"
|
||||
|
||||
on:
|
||||
# Manual dispatch for urgent translation updates
|
||||
# Manual dispatch for urgent translation updates
|
||||
workflow_dispatch:
|
||||
# Only trigger on PRs to main/master - additional branch filtering in job condition
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
@@ -15,45 +15,45 @@ jobs:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'version-bump-'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Setup playwright environment
|
||||
- name: Setup ComfyUI Frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
- name: Setup ComfyUI Server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
# Setup playwright environment
|
||||
- name: Setup ComfyUI Frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
- name: Setup ComfyUI Server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
- name: Start dev server
|
||||
# Run electron dev server as it is a superset of the web dev server
|
||||
# We do want electron specific UIs to be translated.
|
||||
run: pnpm dev:electron &
|
||||
- name: Start dev server
|
||||
# Run electron dev server as it is a superset of the web dev server
|
||||
# We do want electron specific UIs to be translated.
|
||||
run: pnpm dev:electron &
|
||||
|
||||
# Update locales, collect new strings and update translations using OpenAI, then commit changes
|
||||
- name: Update en.json
|
||||
run: pnpm collect-i18n
|
||||
env:
|
||||
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||
- name: Update translations
|
||||
run: pnpm locale
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
- name: Commit updated locales
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@github.com'
|
||||
git fetch origin ${{ github.head_ref }}
|
||||
# Stash any local changes before checkout
|
||||
git stash -u
|
||||
git checkout -B ${{ github.head_ref }} origin/${{ github.head_ref }}
|
||||
# Apply the stashed changes if any
|
||||
git stash pop || true
|
||||
git add src/locales/
|
||||
git diff --staged --quiet || git commit -m "Update locales"
|
||||
git push origin HEAD:${{ github.head_ref }}
|
||||
# Update locales, collect new strings and update translations using OpenAI, then commit changes
|
||||
- name: Update en.json
|
||||
run: pnpm collect-i18n
|
||||
env:
|
||||
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||
- name: Update translations
|
||||
run: pnpm locale
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
- name: Commit updated locales
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@github.com'
|
||||
git fetch origin ${{ github.head_ref }}
|
||||
# Stash any local changes before checkout
|
||||
git stash -u
|
||||
git checkout -B ${{ github.head_ref }} origin/${{ github.head_ref }}
|
||||
# Apply the stashed changes if any
|
||||
git stash pop || true
|
||||
git add src/locales/
|
||||
git diff --staged --quiet || git commit -m "Update locales"
|
||||
git push origin HEAD:${{ github.head_ref }}
|
||||
|
||||
204
.github/workflows/i18n-update-custom-nodes.yaml
vendored
@@ -21,116 +21,116 @@ jobs:
|
||||
update-locales:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Setup playwright environment with custom node repository
|
||||
- name: Setup ComfyUI Server (without launching)
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: 'true'
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
# Setup playwright environment with custom node repository
|
||||
- name: Setup ComfyUI Server (without launching)
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: 'true'
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
# Install the custom node repository
|
||||
- name: Checkout custom node repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: ${{ inputs.owner }}/${{ inputs.repository }}
|
||||
path: 'ComfyUI/custom_nodes/${{ inputs.repository }}'
|
||||
- name: Install custom node Python requirements
|
||||
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
|
||||
run: |
|
||||
if [ -f "requirements.txt" ]; then
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
# Install the custom node repository
|
||||
- name: Checkout custom node repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: ${{ inputs.owner }}/${{ inputs.repository }}
|
||||
path: 'ComfyUI/custom_nodes/${{ inputs.repository }}'
|
||||
- name: Install custom node Python requirements
|
||||
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
|
||||
run: |
|
||||
if [ -f "requirements.txt" ]; then
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
# Start ComfyUI Server
|
||||
- name: Start ComfyUI Server
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../dist --custom-node-path ../ComfyUI/custom_nodes/${{ inputs.repository }} &
|
||||
wait-for-it --service
|
||||
# Start ComfyUI Server
|
||||
- name: Start ComfyUI Server
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../dist --custom-node-path ../ComfyUI/custom_nodes/${{ inputs.repository }} &
|
||||
wait-for-it --service
|
||||
|
||||
- name: Start dev server
|
||||
# Run electron dev server as it is a superset of the web dev server
|
||||
# We do want electron specific UIs to be translated.
|
||||
run: pnpm dev:electron &
|
||||
|
||||
- name: Capture base i18n
|
||||
run: pnpm exec tsx scripts/diff-i18n capture
|
||||
- name: Update en.json
|
||||
run: pnpm collect-i18n
|
||||
env:
|
||||
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||
- name: Update translations
|
||||
run: pnpm locale
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
- name: Diff base vs updated i18n
|
||||
run: pnpm exec tsx scripts/diff-i18n diff
|
||||
- name: Update i18n in custom node repository
|
||||
run: |
|
||||
LOCALE_DIR=ComfyUI/custom_nodes/${{ inputs.repository }}/locales/
|
||||
install -d "$LOCALE_DIR"
|
||||
cp -rf ComfyUI_frontend/temp/diff/* "$LOCALE_DIR"
|
||||
|
||||
# Git ops for pushing changes and creating PR
|
||||
- name: Check and create fork of custom node repository
|
||||
run: |
|
||||
# Try to fork the repository
|
||||
gh repo fork ${{ inputs.owner }}/${{ inputs.repository }} --clone=false || {
|
||||
echo "Fork failed - repository might already be forked"
|
||||
# Exit 0 to prevent the workflow from failing
|
||||
exit 0
|
||||
}
|
||||
- name: Start dev server
|
||||
# Run electron dev server as it is a superset of the web dev server
|
||||
# We do want electron specific UIs to be translated.
|
||||
run: pnpm dev:electron &
|
||||
|
||||
# Enable workflows on the forked repository
|
||||
gh api \
|
||||
--method PUT \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"/repos/${{ inputs.fork_owner }}/${{ inputs.repository }}/actions/permissions/workflow" \
|
||||
-F can_approve_pull_request_reviews=true \
|
||||
-F default_workflow_permissions="write" \
|
||||
-F enabled=true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
- name: Capture base i18n
|
||||
run: pnpm exec tsx scripts/diff-i18n capture
|
||||
- name: Update en.json
|
||||
run: pnpm collect-i18n
|
||||
env:
|
||||
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||
- name: Update translations
|
||||
run: pnpm locale
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
- name: Diff base vs updated i18n
|
||||
run: pnpm exec tsx scripts/diff-i18n diff
|
||||
- name: Update i18n in custom node repository
|
||||
run: |
|
||||
LOCALE_DIR=ComfyUI/custom_nodes/${{ inputs.repository }}/locales/
|
||||
install -d "$LOCALE_DIR"
|
||||
cp -rf ComfyUI_frontend/temp/diff/* "$LOCALE_DIR"
|
||||
|
||||
- name: Commit changes
|
||||
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@github.com'
|
||||
# Git ops for pushing changes and creating PR
|
||||
- name: Check and create fork of custom node repository
|
||||
run: |
|
||||
# Try to fork the repository
|
||||
gh repo fork ${{ inputs.owner }}/${{ inputs.repository }} --clone=false || {
|
||||
echo "Fork failed - repository might already be forked"
|
||||
# Exit 0 to prevent the workflow from failing
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Create and switch to new branch
|
||||
git checkout -b update-locales
|
||||
# Enable workflows on the forked repository
|
||||
gh api \
|
||||
--method PUT \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"/repos/${{ inputs.fork_owner }}/${{ inputs.repository }}/actions/permissions/workflow" \
|
||||
-F can_approve_pull_request_reviews=true \
|
||||
-F default_workflow_permissions="write" \
|
||||
-F enabled=true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
|
||||
# Stage and commit changes
|
||||
git add -A
|
||||
git commit -m "Update locales"
|
||||
- name: Commit changes
|
||||
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@github.com'
|
||||
|
||||
- name: Install SSH key For PUSH
|
||||
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
|
||||
with:
|
||||
# PR private key from action server
|
||||
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}
|
||||
# github public key to confirm it's github server
|
||||
known_hosts: github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==
|
||||
# Create and switch to new branch
|
||||
git checkout -b update-locales
|
||||
|
||||
- name: Push changes
|
||||
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
|
||||
run: |
|
||||
# Force push to create the branch
|
||||
echo "Pushing changes to ${{ inputs.fork_owner }}/${{ inputs.repository }}"
|
||||
git push -f git@github.com:${{ inputs.fork_owner }}/${{ inputs.repository }}.git update-locales
|
||||
# Stage and commit changes
|
||||
git add -A
|
||||
git commit -m "Update locales"
|
||||
|
||||
- name: Create PR
|
||||
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
|
||||
run: |
|
||||
# Create PR using gh cli
|
||||
gh pr create --title "Update locales for ${{ inputs.repository }}" --repo ${{ inputs.owner }}/${{ inputs.repository }} --head ${{ inputs.fork_owner }}:update-locales --body "Update locales for ${{ inputs.repository }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
- name: Install SSH key For PUSH
|
||||
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
|
||||
with:
|
||||
# PR private key from action server
|
||||
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}
|
||||
# github public key to confirm it's github server
|
||||
known_hosts: github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==
|
||||
|
||||
- name: Push changes
|
||||
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
|
||||
run: |
|
||||
# Force push to create the branch
|
||||
echo "Pushing changes to ${{ inputs.fork_owner }}/${{ inputs.repository }}"
|
||||
git push -f git@github.com:${{ inputs.fork_owner }}/${{ inputs.repository }}.git update-locales
|
||||
|
||||
- name: Create PR
|
||||
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
|
||||
run: |
|
||||
# Create PR using gh cli
|
||||
gh pr create --title "Update locales for ${{ inputs.repository }}" --repo ${{ inputs.owner }}/${{ inputs.repository }} --head ${{ inputs.fork_owner }}:update-locales --body "Update locales for ${{ inputs.repository }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
|
||||
74
.github/workflows/i18n-update-nodes.yaml
vendored
@@ -13,42 +13,42 @@ jobs:
|
||||
update-locales:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
# Setup playwright environment
|
||||
- name: Setup ComfyUI Server (and start)
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
# Setup playwright environment
|
||||
- name: Setup ComfyUI Server (and start)
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
- name: Start dev server
|
||||
# Run electron dev server as it is a superset of the web dev server
|
||||
# We do want electron specific UIs to be translated.
|
||||
run: pnpm dev:electron &
|
||||
- name: Update en.json
|
||||
run: pnpm collect-i18n -- scripts/collect-i18n-node-defs.ts
|
||||
env:
|
||||
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||
- name: Update translations
|
||||
run: pnpm locale
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: "Update locales for node definitions"
|
||||
title: "Update locales for node definitions"
|
||||
body: |
|
||||
Automated PR to update locales for node definitions
|
||||
- name: Start dev server
|
||||
# Run electron dev server as it is a superset of the web dev server
|
||||
# We do want electron specific UIs to be translated.
|
||||
run: pnpm dev:electron &
|
||||
- name: Update en.json
|
||||
run: pnpm collect-i18n -- scripts/collect-i18n-node-defs.ts
|
||||
env:
|
||||
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||
- name: Update translations
|
||||
run: pnpm locale
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: "Update locales for node definitions"
|
||||
title: "Update locales for node definitions"
|
||||
body: |
|
||||
Automated PR to update locales for node definitions
|
||||
|
||||
This PR was created automatically by the frontend update workflow.
|
||||
branch: update-locales-node-defs-${{ github.event.inputs.trigger_type }}-${{ github.run_id }}
|
||||
base: main
|
||||
labels: dependencies
|
||||
This PR was created automatically by the frontend update workflow.
|
||||
branch: update-locales-node-defs-${{ github.event.inputs.trigger_type }}-${{ github.run_id }}
|
||||
base: main
|
||||
labels: dependencies
|
||||
|
||||
12
.github/workflows/pr-backport.yaml
vendored
@@ -19,8 +19,8 @@ on:
|
||||
jobs:
|
||||
backport:
|
||||
if: >
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
contains(github.event.pull_request.labels.*.name, 'needs-backport')) ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -38,19 +38,19 @@ jobs:
|
||||
echo "::error::Invalid PR number format. Must be a positive integer."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Validate PR exists and is merged
|
||||
if ! gh pr view "${{ inputs.pr_number }}" --json merged >/dev/null 2>&1; then
|
||||
echo "::error::PR #${{ inputs.pr_number }} not found or inaccessible."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
MERGED=$(gh pr view "${{ inputs.pr_number }}" --json merged --jq '.merged')
|
||||
if [ "$MERGED" != "true" ]; then
|
||||
echo "::error::PR #${{ inputs.pr_number }} is not merged. Only merged PRs can be backported."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Validate PR has needs-backport label
|
||||
if ! gh pr view "${{ inputs.pr_number }}" --json labels --jq '.labels[].name' | grep -q "needs-backport"; then
|
||||
echo "::error::PR #${{ inputs.pr_number }} does not have 'needs-backport' label."
|
||||
@@ -330,7 +330,7 @@ jobs:
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
|
||||
fi
|
||||
|
||||
|
||||
for backport in ${{ steps.backport.outputs.success }}; do
|
||||
IFS=':' read -r target branch <<< "${backport}"
|
||||
|
||||
|
||||
@@ -127,26 +127,26 @@ jobs:
|
||||
echo "=========================================="
|
||||
echo "STAGING CHANGED SNAPSHOTS (Shard ${{ matrix.shardIndex }})"
|
||||
echo "=========================================="
|
||||
|
||||
|
||||
# Get list of changed snapshot files
|
||||
changed_files=$(git diff --name-only browser_tests/ 2>/dev/null | grep -E '\-snapshots/' || echo "")
|
||||
|
||||
|
||||
if [ -z "$changed_files" ]; then
|
||||
echo "No snapshot changes in this shard"
|
||||
echo "has-changes=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
echo "✓ Found changed files:"
|
||||
echo "$changed_files"
|
||||
file_count=$(echo "$changed_files" | wc -l)
|
||||
echo "Count: $file_count"
|
||||
echo "has-changes=true" >> $GITHUB_OUTPUT
|
||||
echo ""
|
||||
|
||||
|
||||
# Create staging directory
|
||||
mkdir -p /tmp/changed_snapshots_shard
|
||||
|
||||
|
||||
# Copy only changed files, preserving directory structure
|
||||
# Strip 'browser_tests/' prefix to avoid double nesting
|
||||
echo "Copying changed files to staging directory..."
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
cp "$file" "/tmp/changed_snapshots_shard/$file_without_prefix"
|
||||
echo " → $file_without_prefix"
|
||||
done <<< "$changed_files"
|
||||
|
||||
|
||||
echo ""
|
||||
echo "Staged files for upload:"
|
||||
find /tmp/changed_snapshots_shard -type f
|
||||
@@ -233,18 +233,18 @@ jobs:
|
||||
|
||||
shard_name=$(basename "$shard_dir")
|
||||
file_count=$(find "$shard_dir" -type f | wc -l)
|
||||
|
||||
|
||||
if [ "$file_count" -eq 0 ]; then
|
||||
echo " $shard_name: no files"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Processing $shard_name ($file_count file(s))..."
|
||||
|
||||
|
||||
# Copy files directly, preserving directory structure
|
||||
# Since files are already in correct structure (no browser_tests/ prefix), just copy them all
|
||||
cp -v -r "$shard_dir"* browser_tests/ 2>&1 | sed 's/^/ /'
|
||||
|
||||
|
||||
merged_count=$((merged_count + 1))
|
||||
echo " ✓ Merged"
|
||||
echo ""
|
||||
@@ -272,25 +272,25 @@ jobs:
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@github.com'
|
||||
|
||||
|
||||
if git diff --quiet browser_tests/; then
|
||||
echo "No changes to commit"
|
||||
echo "has-changes=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
echo "=========================================="
|
||||
echo "COMMITTING CHANGES"
|
||||
echo "=========================================="
|
||||
|
||||
|
||||
echo "has-changes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
git add browser_tests/
|
||||
git commit -m "[automated] Update test expectations"
|
||||
|
||||
|
||||
echo "Pushing to ${{ needs.setup.outputs.branch }}..."
|
||||
git push origin ${{ needs.setup.outputs.branch }}
|
||||
|
||||
|
||||
echo "✓ Commit and push successful"
|
||||
|
||||
- name: Add Done Reaction
|
||||
@@ -306,4 +306,4 @@ jobs:
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
run: gh pr edit ${{ needs.setup.outputs.pr-number }} --remove-label "New Browser Test Expectations"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
72
.github/workflows/release-branch-create.yaml
vendored
@@ -153,8 +153,78 @@ jobs:
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Post summary
|
||||
- name: Ensure release labels
|
||||
if: steps.check_version.outputs.is_minor_bump == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH_BASE="${{ steps.check_version.outputs.branch_base }}"
|
||||
|
||||
if [[ -z "$BRANCH_BASE" ]]; then
|
||||
echo "::error::Branch base not set; unable to manage labels"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
declare -A COLORS=(
|
||||
[core]="4361ee"
|
||||
[cloud]="4f6ef5"
|
||||
)
|
||||
|
||||
for PREFIX in core cloud; do
|
||||
LABEL="${PREFIX}/${BRANCH_BASE}"
|
||||
COLOR="${COLORS[$PREFIX]}"
|
||||
DESCRIPTION="Backport PRs for ${PREFIX} ${BRANCH_BASE}"
|
||||
|
||||
if gh label view "$LABEL" >/dev/null 2>&1; then
|
||||
gh label edit "$LABEL" \
|
||||
--color "$COLOR" \
|
||||
--description "$DESCRIPTION"
|
||||
echo "🔄 Updated label $LABEL"
|
||||
else
|
||||
gh label create "$LABEL" \
|
||||
--color "$COLOR" \
|
||||
--description "$DESCRIPTION"
|
||||
echo "✨ Created label $LABEL"
|
||||
fi
|
||||
done
|
||||
|
||||
MIN_LABELS_TO_KEEP=3
|
||||
MAX_LABELS_TO_FETCH=200
|
||||
|
||||
for PREFIX in core cloud; do
|
||||
mapfile -t LABELS < <(
|
||||
gh label list \
|
||||
--json name \
|
||||
--limit "$MAX_LABELS_TO_FETCH" \
|
||||
--jq '.[].name' |
|
||||
grep -E "^${PREFIX}/[0-9]+\.[0-9]+$" |
|
||||
sort -t/ -k2,2V
|
||||
)
|
||||
|
||||
TOTAL=${#LABELS[@]}
|
||||
|
||||
if (( TOTAL <= MIN_LABELS_TO_KEEP )); then
|
||||
echo "ℹ️ Nothing to prune for $PREFIX labels"
|
||||
continue
|
||||
fi
|
||||
|
||||
REMOVE_COUNT=$((TOTAL - MIN_LABELS_TO_KEEP))
|
||||
|
||||
if (( REMOVE_COUNT > 1 )); then
|
||||
REMOVE_COUNT=1
|
||||
fi
|
||||
|
||||
for ((i=0; i<REMOVE_COUNT; i++)); do
|
||||
OLD_LABEL="${LABELS[$i]}"
|
||||
gh label delete "$OLD_LABEL" --yes
|
||||
echo "🗑️ Removed old label $OLD_LABEL"
|
||||
done
|
||||
done
|
||||
|
||||
- name: Post summary
|
||||
if: always() && steps.check_version.outputs.is_minor_bump == 'true'
|
||||
run: |
|
||||
CURRENT_VERSION="${{ steps.check_version.outputs.current_version }}"
|
||||
RESULTS="${{ steps.create_branches.outputs.results }}"
|
||||
|
||||
@@ -92,4 +92,3 @@ jobs:
|
||||
base: ${{ github.event.inputs.branch }}
|
||||
labels: |
|
||||
Release
|
||||
|
||||
|
||||
10
.yamllint
Normal file
@@ -0,0 +1,10 @@
|
||||
extends: default
|
||||
|
||||
ignore: |
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
rules:
|
||||
line-length: disable
|
||||
document-start: disable
|
||||
truthy: disable
|
||||
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 82 KiB |
14
scripts/cicd/check-yaml.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(git rev-parse --show-toplevel)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
mapfile -t yaml_files < <(git ls-files '*.yml' '*.yaml')
|
||||
|
||||
if [[ ${#yaml_files[@]} -eq 0 ]]; then
|
||||
echo "No YAML files found to lint"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
yamllint --config-file .yamllint "${yaml_files[@]}"
|
||||
@@ -35,6 +35,7 @@ import { ValidationState } from '@/utils/validationUtil'
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
validateUrlFn?: (url: string) => Promise<boolean>
|
||||
disableValidation?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -101,6 +102,8 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
|
||||
}
|
||||
|
||||
const validateUrl = async (value: string) => {
|
||||
if (props.disableValidation) return
|
||||
|
||||
if (validationState.value === ValidationState.LOADING) return
|
||||
|
||||
const url = cleanInput(value)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="flex w-[490px] flex-col">
|
||||
<ContentDivider :width="1" />
|
||||
<div
|
||||
class="flex w-[490px] flex-col border-t-1 border-b-1 border-border-default"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col gap-4 p-4">
|
||||
<!-- Description -->
|
||||
<div>
|
||||
@@ -13,12 +14,12 @@
|
||||
|
||||
<!-- Missing Nodes List Wrapper -->
|
||||
<div
|
||||
class="flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-component-node-widget-background"
|
||||
class="flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
|
||||
>
|
||||
<div
|
||||
v-for="(node, i) in uniqueNodes"
|
||||
:key="i"
|
||||
class="flex min-h-8 items-center justify-between px-4 py-2 bg-component-node-widget-background text-text-secondary"
|
||||
class="flex min-h-8 items-center justify-between px-4 py-2 bg-secondary-background text-muted-foreground"
|
||||
>
|
||||
<span class="text-xs">
|
||||
{{ node.label }}
|
||||
@@ -33,14 +34,12 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ContentDivider :width="1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- h-0 to force scrollpanel to grow -->
|
||||
<ScrollPanel class="h-0 grow">
|
||||
<!-- min-h-0 to force scrollpanel to grow -->
|
||||
<ScrollPanel class="min-h-0 grow">
|
||||
<slot name="body" />
|
||||
</ScrollPanel>
|
||||
<div v-if="slots.footer">
|
||||
|
||||
@@ -41,9 +41,27 @@
|
||||
</TabList>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="displayAssets.length" class="relative size-full">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading">
|
||||
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="!displayAssets.length">
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-info-circle"
|
||||
:title="
|
||||
$t(
|
||||
activeTab === 'input'
|
||||
? 'sideToolbar.noImportedFiles'
|
||||
: 'sideToolbar.noGeneratedFiles'
|
||||
)
|
||||
"
|
||||
:message="$t('sideToolbar.noFilesFoundMessage')"
|
||||
/>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div v-else class="relative size-full">
|
||||
<VirtualGrid
|
||||
v-if="displayAssets.length"
|
||||
:items="mediaAssetsWithKey"
|
||||
:grid-style="{
|
||||
display: 'grid',
|
||||
@@ -51,6 +69,7 @@
|
||||
padding: '0.5rem',
|
||||
gap: '0.5rem'
|
||||
}"
|
||||
@approach-end="handleApproachEnd"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<MediaAssetCard
|
||||
@@ -66,24 +85,6 @@
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
<div v-else-if="loading">
|
||||
<ProgressSpinner
|
||||
class="absolute left-1/2 w-[50px] -translate-x-1/2"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-info-circle"
|
||||
:title="
|
||||
$t(
|
||||
activeTab === 'input'
|
||||
? 'sideToolbar.noImportedFiles'
|
||||
: 'sideToolbar.noGeneratedFiles'
|
||||
)
|
||||
"
|
||||
:message="$t('sideToolbar.noFilesFoundMessage')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
@@ -147,6 +148,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
@@ -291,6 +293,7 @@ watch(
|
||||
activeTab,
|
||||
() => {
|
||||
clearSelection()
|
||||
// Reset pagination state when tab changes
|
||||
void refreshAssets()
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -395,4 +398,15 @@ const handleDeleteSelected = async () => {
|
||||
await deleteMultipleAssets(selectedAssets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleApproachEnd = useDebounceFn(async () => {
|
||||
if (
|
||||
activeTab.value === 'output' &&
|
||||
!isInFolderView.value &&
|
||||
outputAssets.hasMore.value &&
|
||||
!outputAssets.isLoadingMore.value
|
||||
) {
|
||||
await outputAssets.loadMore()
|
||||
}
|
||||
}, 300)
|
||||
</script>
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
/**
|
||||
* Default colors for node slot types
|
||||
* Mirrors LiteGraph's slot_default_color_by_type
|
||||
*/
|
||||
const SLOT_TYPE_COLORS: Record<string, string> = {
|
||||
number: '#AAD',
|
||||
string: '#DCA',
|
||||
boolean: '#DAA',
|
||||
vec2: '#ADA',
|
||||
vec3: '#ADA',
|
||||
vec4: '#ADA',
|
||||
color: '#DDA',
|
||||
image: '#353',
|
||||
latent: '#858',
|
||||
conditioning: '#FFA',
|
||||
control_net: '#F8F',
|
||||
clip: '#FFD',
|
||||
vae: '#F82',
|
||||
model: '#B98',
|
||||
'*': '#AAA' // Default color
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color for a slot type
|
||||
*/
|
||||
export function getSlotColor(type?: string | number | null): string {
|
||||
if (!type) return SLOT_TYPE_COLORS['*']
|
||||
const typeStr = String(type).toLowerCase()
|
||||
return SLOT_TYPE_COLORS[typeStr] || SLOT_TYPE_COLORS['*']
|
||||
if (!type) return '#AAA'
|
||||
const typeStr = String(type).toUpperCase()
|
||||
return `var(--color-datatype-${typeStr})`
|
||||
}
|
||||
|
||||
@@ -1984,6 +1984,32 @@
|
||||
"noModelsInFolder": "No {type} available in this folder",
|
||||
"searchAssetsPlaceholder": "Type to search...",
|
||||
"uploadModel": "Upload model",
|
||||
"uploadModelFromCivitai": "Upload a model from Civitai",
|
||||
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
|
||||
"uploadModelDescription2": "Only links from https://civitai.com are supported at the moment",
|
||||
"uploadModelDescription3": "Max file size: 1 GB",
|
||||
"civitaiLinkLabel": "Civitai model download link",
|
||||
"civitaiLinkPlaceholder": "Paste link here",
|
||||
"civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor",
|
||||
"confirmModelDetails": "Confirm Model Details",
|
||||
"fileName": "File Name",
|
||||
"fileSize": "File Size",
|
||||
"modelName": "Model Name",
|
||||
"modelNamePlaceholder": "Enter a name for this model",
|
||||
"tags": "Tags",
|
||||
"tagsPlaceholder": "e.g., models, checkpoint",
|
||||
"tagsHelp": "Separate tags with commas",
|
||||
"upload": "Upload",
|
||||
"uploadingModel": "Uploading model...",
|
||||
"uploadSuccess": "Model uploaded successfully!",
|
||||
"uploadFailed": "Upload failed",
|
||||
"modelAssociatedWithLink": "The model associated with the link you provided:",
|
||||
"whatTypeOfModel": "What type of model is this?",
|
||||
"selectModelType": "Select model type",
|
||||
"notSureLeaveAsIs": "Not sure? Just leave this as is",
|
||||
"modelUploaded": "Model uploaded!",
|
||||
"findInLibrary": "Find it in the {type} section of the models library.",
|
||||
"finish": "Finish",
|
||||
"allModels": "All Models",
|
||||
"allCategory": "All {category}",
|
||||
"unknown": "Unknown",
|
||||
|
||||
@@ -73,11 +73,14 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
|
||||
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
@@ -92,6 +95,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'asset-select': [asset: AssetDisplayItem]
|
||||
@@ -189,6 +193,15 @@ const { flags } = useFeatureFlags()
|
||||
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
|
||||
|
||||
function handleUploadClick() {
|
||||
// Will be implemented in the future commit
|
||||
dialogStore.showDialog({
|
||||
key: 'upload-model',
|
||||
headerComponent: UploadModelDialogHeader,
|
||||
component: UploadModelDialog,
|
||||
props: {
|
||||
onUploadSuccess: async () => {
|
||||
await execute()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<!-- TBD: File size will be provided by backend history API -->
|
||||
<div
|
||||
v-if="asset.size"
|
||||
class="flex items-center gap-2 text-xs text-zinc-400"
|
||||
>
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<!-- TBD: File size will be provided by backend history API -->
|
||||
<div
|
||||
v-if="asset.size"
|
||||
class="flex items-center gap-2 text-xs text-zinc-400"
|
||||
>
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<div class="flex items-center text-xs text-zinc-400">
|
||||
<!-- TBD: File size will be provided by backend history API -->
|
||||
<div v-if="asset.size" class="flex items-center text-xs text-zinc-400">
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
<video
|
||||
ref="videoRef"
|
||||
:controls="shouldShowControls"
|
||||
preload="none"
|
||||
preload="metadata"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
:poster="asset.preview_url"
|
||||
class="relative size-full object-contain"
|
||||
@click.stop
|
||||
|
||||
62
src/platform/assets/components/UploadModelConfirmation.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Model Info Section -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ $t('assetBrowser.modelAssociatedWithLink') }}
|
||||
</p>
|
||||
<p class="text-sm mt-0">
|
||||
{{ metadata?.name || metadata?.filename }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Type Selection -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted">
|
||||
{{ $t('assetBrowser.whatTypeOfModel') }}
|
||||
</label>
|
||||
<SingleSelect
|
||||
v-model="selectedModelType"
|
||||
:label="$t('assetBrowser.whatTypeOfModel')"
|
||||
:options="modelTypes"
|
||||
/>
|
||||
<div class="flex items-center gap-2 text-sm text-muted">
|
||||
<i class="icon-[lucide--info]" />
|
||||
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
|
||||
interface ModelMetadata {
|
||||
content_length: number
|
||||
final_url: string
|
||||
content_type?: string
|
||||
filename?: string
|
||||
name?: string
|
||||
tags?: string[]
|
||||
preview_url?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
metadata: ModelMetadata | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { modelTypes } = useModelTypes()
|
||||
|
||||
const selectedModelType = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: string) => emit('update:modelValue', value)
|
||||
})
|
||||
</script>
|
||||
224
src/platform/assets/components/UploadModelDialog.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6">
|
||||
<!-- Step 1: Enter URL -->
|
||||
<UploadModelUrlInput v-if="currentStep === 1" v-model="wizardData.url" />
|
||||
|
||||
<!-- Step 2: Confirm Metadata -->
|
||||
<UploadModelConfirmation
|
||||
v-else-if="currentStep === 2"
|
||||
v-model="selectedModelType"
|
||||
:metadata="wizardData.metadata"
|
||||
/>
|
||||
|
||||
<!-- Step 3: Upload Progress -->
|
||||
<UploadModelProgress
|
||||
v-else-if="currentStep === 3"
|
||||
:status="uploadStatus"
|
||||
:error="uploadError"
|
||||
:metadata="wizardData.metadata"
|
||||
:model-type="selectedModelType"
|
||||
/>
|
||||
|
||||
<!-- Navigation Footer -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<TextButton
|
||||
v-if="currentStep !== 1 && currentStep !== 3"
|
||||
:label="$t('g.back')"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="isFetchingMetadata || isUploading"
|
||||
:on-click="goToPreviousStep"
|
||||
/>
|
||||
<span v-else />
|
||||
|
||||
<IconTextButton
|
||||
v-if="currentStep === 1"
|
||||
:label="$t('g.continue')"
|
||||
type="primary"
|
||||
size="md"
|
||||
:disabled="!canFetchMetadata || isFetchingMetadata"
|
||||
:on-click="handleFetchMetadata"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="isFetchingMetadata"
|
||||
class="icon-[lucide--loader-circle] animate-spin"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
v-else-if="currentStep === 2"
|
||||
:label="$t('assetBrowser.upload')"
|
||||
type="primary"
|
||||
size="md"
|
||||
:disabled="!canUploadModel || isUploading"
|
||||
:on-click="handleUploadModel"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="isUploading"
|
||||
class="icon-[lucide--loader-circle] animate-spin"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<TextButton
|
||||
v-else-if="currentStep === 3 && uploadStatus === 'success'"
|
||||
:label="$t('assetBrowser.finish')"
|
||||
type="primary"
|
||||
size="md"
|
||||
:on-click="handleClose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
|
||||
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
|
||||
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'upload-success': []
|
||||
}>()
|
||||
|
||||
const currentStep = ref(1)
|
||||
const isFetchingMetadata = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle')
|
||||
const uploadError = ref('')
|
||||
|
||||
const wizardData = ref<{
|
||||
url: string
|
||||
metadata: {
|
||||
content_length: number
|
||||
final_url: string
|
||||
content_type?: string
|
||||
filename?: string
|
||||
name?: string
|
||||
tags?: string[]
|
||||
preview_url?: string
|
||||
} | null
|
||||
name: string
|
||||
tags: string[]
|
||||
}>({
|
||||
url: '',
|
||||
metadata: null,
|
||||
name: '',
|
||||
tags: []
|
||||
})
|
||||
|
||||
const selectedModelType = ref<string>('loras')
|
||||
|
||||
const { modelTypes, fetchModelTypes } = useModelTypes()
|
||||
|
||||
// Validation
|
||||
const canFetchMetadata = computed(() => {
|
||||
return wizardData.value.url.trim().length > 0
|
||||
})
|
||||
|
||||
const canUploadModel = computed(() => {
|
||||
return !!selectedModelType.value
|
||||
})
|
||||
|
||||
async function handleFetchMetadata() {
|
||||
if (!canFetchMetadata.value) return
|
||||
|
||||
isFetchingMetadata.value = true
|
||||
try {
|
||||
const metadata = await assetService.getAssetMetadata(wizardData.value.url)
|
||||
wizardData.value.metadata = metadata
|
||||
|
||||
// Pre-fill name from metadata
|
||||
wizardData.value.name = metadata.filename || metadata.name || ''
|
||||
|
||||
// Pre-fill model type from metadata tags if available
|
||||
if (metadata.tags && metadata.tags.length > 0) {
|
||||
wizardData.value.tags = metadata.tags
|
||||
// Try to detect model type from tags
|
||||
const typeTag = metadata.tags.find((tag) =>
|
||||
modelTypes.value.some((type) => type.value === tag)
|
||||
)
|
||||
if (typeTag) {
|
||||
selectedModelType.value = typeTag
|
||||
}
|
||||
}
|
||||
|
||||
currentStep.value = 2
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve metadata:', error)
|
||||
uploadError.value =
|
||||
error instanceof Error ? error.message : 'Failed to retrieve metadata'
|
||||
// TODO: Show error toast to user
|
||||
} finally {
|
||||
isFetchingMetadata.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadModel() {
|
||||
if (!canUploadModel.value) return
|
||||
|
||||
isUploading.value = true
|
||||
uploadStatus.value = 'uploading'
|
||||
|
||||
try {
|
||||
const tags = ['models', selectedModelType.value]
|
||||
const filename =
|
||||
wizardData.value.metadata?.filename ||
|
||||
wizardData.value.metadata?.name ||
|
||||
'model'
|
||||
|
||||
await assetService.uploadAssetFromUrl({
|
||||
url: wizardData.value.url,
|
||||
name: filename,
|
||||
tags,
|
||||
user_metadata: {
|
||||
source: 'civitai',
|
||||
source_url: wizardData.value.url,
|
||||
model_type: selectedModelType.value
|
||||
}
|
||||
})
|
||||
|
||||
uploadStatus.value = 'success'
|
||||
currentStep.value = 3
|
||||
emit('upload-success')
|
||||
} catch (error) {
|
||||
console.error('Failed to upload asset:', error)
|
||||
uploadStatus.value = 'error'
|
||||
uploadError.value =
|
||||
error instanceof Error ? error.message : 'Failed to upload model'
|
||||
currentStep.value = 3
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPreviousStep() {
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value = currentStep.value - 1
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
dialogStore.closeDialog({ key: 'upload-model' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchModelTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-model-dialog {
|
||||
min-width: 600px;
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
12
src/platform/assets/components/UploadModelDialogHeader.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 px-4 py-2 font-bold">
|
||||
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
|
||||
<span
|
||||
class="rounded-full bg-white px-1.5 py-0 text-xxs font-medium uppercase text-black"
|
||||
>
|
||||
{{ $t('g.beta') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
78
src/platform/assets/components/UploadModelProgress.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col gap-6">
|
||||
<!-- Uploading State -->
|
||||
<div
|
||||
v-if="status === 'uploading'"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] animate-spin text-6xl text-primary"
|
||||
/>
|
||||
<div class="text-center">
|
||||
<p class="m-0 text-sm font-bold">
|
||||
{{ $t('assetBrowser.uploadingModel') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-else-if="status === 'success'" class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-muted m-0 font-bold">
|
||||
{{ $t('assetBrowser.modelUploaded') }} 🎉
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-row items-start p-8 bg-node-component-surface rounded-lg"
|
||||
>
|
||||
<div class="flex flex-col justify-center items-start gap-1 flex-1">
|
||||
<p class="text-sm m-0">
|
||||
{{ metadata?.name || metadata?.filename }}
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ modelType }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="status === 'error'"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<i class="icon-[lucide--x-circle] text-6xl text-red-500" />
|
||||
<div class="text-center">
|
||||
<p class="m-0 text-sm font-bold">
|
||||
{{ $t('assetBrowser.uploadFailed') }}
|
||||
</p>
|
||||
<p v-if="error" class="text-sm text-muted mb-0">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ModelMetadata {
|
||||
content_length: number
|
||||
final_url: string
|
||||
content_type?: string
|
||||
filename?: string
|
||||
name?: string
|
||||
tags?: string[]
|
||||
preview_url?: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
status: 'idle' | 'uploading' | 'success' | 'error'
|
||||
error?: string
|
||||
metadata: ModelMetadata | null
|
||||
modelType: string
|
||||
}>()
|
||||
</script>
|
||||
46
src/platform/assets/components/UploadModelUrlInput.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ $t('assetBrowser.uploadModelDescription1') }}
|
||||
</p>
|
||||
<ul class="list-disc space-y-1 pl-5 mt-0 text-sm text-muted">
|
||||
<li>{{ $t('assetBrowser.uploadModelDescription2') }}</li>
|
||||
<li>{{ $t('assetBrowser.uploadModelDescription3') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted mb-0">
|
||||
{{ $t('assetBrowser.civitaiLinkLabel') }}
|
||||
</label>
|
||||
<UrlInput
|
||||
v-model="url"
|
||||
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
|
||||
:disable-validation="true"
|
||||
/>
|
||||
<p class="text-xs text-muted">
|
||||
{{ $t('assetBrowser.civitaiLinkExample') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import UrlInput from '@/components/common/UrlInput.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const url = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: string) => emit('update:modelValue', value)
|
||||
})
|
||||
</script>
|
||||
@@ -26,4 +26,19 @@ export interface IAssetsProvider {
|
||||
* Refresh the media list (alias for fetchMediaList)
|
||||
*/
|
||||
refresh: () => Promise<AssetItem[]>
|
||||
|
||||
/**
|
||||
* Load more items (for pagination)
|
||||
*/
|
||||
loadMore: () => Promise<void>
|
||||
|
||||
/**
|
||||
* Whether there are more items to load
|
||||
*/
|
||||
hasMore: Ref<boolean>
|
||||
|
||||
/**
|
||||
* Whether currently loading more items
|
||||
*/
|
||||
isLoadingMore: Ref<boolean>
|
||||
}
|
||||
|
||||
@@ -36,11 +36,28 @@ export function useAssetsApi(directory: 'input' | 'output') {
|
||||
|
||||
const refresh = () => fetchMediaList()
|
||||
|
||||
const loadMore = async (): Promise<void> => {
|
||||
if (directory === 'output') {
|
||||
await assetsStore.loadMoreHistory()
|
||||
}
|
||||
}
|
||||
|
||||
const hasMore = computed(() => {
|
||||
return directory === 'output' ? assetsStore.hasMoreHistory : false
|
||||
})
|
||||
|
||||
const isLoadingMore = computed(() => {
|
||||
return directory === 'output' ? assetsStore.isLoadingMore : false
|
||||
})
|
||||
|
||||
return {
|
||||
media,
|
||||
loading,
|
||||
error,
|
||||
fetchMediaList,
|
||||
refresh
|
||||
refresh,
|
||||
loadMore,
|
||||
hasMore,
|
||||
isLoadingMore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,28 @@ export function useInternalFilesApi(directory: 'input' | 'output') {
|
||||
|
||||
const refresh = () => fetchMediaList()
|
||||
|
||||
const loadMore = async (): Promise<void> => {
|
||||
if (directory === 'output') {
|
||||
await assetsStore.loadMoreHistory()
|
||||
}
|
||||
}
|
||||
|
||||
const hasMore = computed(() => {
|
||||
return directory === 'output' ? assetsStore.hasMoreHistory : false
|
||||
})
|
||||
|
||||
const isLoadingMore = computed(() => {
|
||||
return directory === 'output' ? assetsStore.isLoadingMore : false
|
||||
})
|
||||
|
||||
return {
|
||||
media,
|
||||
loading,
|
||||
error,
|
||||
fetchMediaList,
|
||||
refresh
|
||||
refresh,
|
||||
loadMore,
|
||||
hasMore,
|
||||
isLoadingMore
|
||||
}
|
||||
}
|
||||
|
||||
94
src/platform/assets/composables/useModelTypes.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
|
||||
/**
|
||||
* Format folder name to display name
|
||||
* Converts "upscale_models" -> "Upscale Models"
|
||||
* Converts "loras" -> "LoRAs"
|
||||
*/
|
||||
function formatDisplayName(folderName: string): string {
|
||||
// Special cases for acronyms and proper nouns
|
||||
const specialCases: Record<string, string> = {
|
||||
loras: 'LoRAs',
|
||||
ipadapter: 'IP-Adapter',
|
||||
sams: 'SAMs',
|
||||
clip_vision: 'CLIP Vision',
|
||||
animatediff_motion_lora: 'AnimateDiff Motion LoRA',
|
||||
animatediff_models: 'AnimateDiff Models',
|
||||
vae: 'VAE',
|
||||
sam2: 'SAM 2',
|
||||
controlnet: 'ControlNet',
|
||||
gligen: 'GLIGEN'
|
||||
}
|
||||
|
||||
if (specialCases[folderName]) {
|
||||
return specialCases[folderName]
|
||||
}
|
||||
|
||||
return folderName
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
interface ModelTypeOption {
|
||||
name: string // Display name
|
||||
value: string // Actual tag value
|
||||
}
|
||||
|
||||
// Shared state across all instances
|
||||
const modelTypes = ref<ModelTypeOption[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let fetchPromise: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* Composable for fetching and managing model types from the API
|
||||
* Uses shared state to ensure data is only fetched once
|
||||
*/
|
||||
export function useModelTypes() {
|
||||
/**
|
||||
* Fetch model types from the API (only fetches once, subsequent calls reuse the same promise)
|
||||
*/
|
||||
async function fetchModelTypes() {
|
||||
// If already loaded, return immediately
|
||||
if (modelTypes.value.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// If currently loading, return the existing promise
|
||||
if (fetchPromise) {
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
fetchPromise = (async () => {
|
||||
try {
|
||||
const response = await assetService.getModelTypes()
|
||||
modelTypes.value = response.map((folder) => ({
|
||||
name: formatDisplayName(folder.name),
|
||||
value: folder.name
|
||||
}))
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch model types'
|
||||
console.error('Failed to fetch model types:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
fetchPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
return {
|
||||
modelTypes,
|
||||
isLoading,
|
||||
error,
|
||||
fetchModelTypes
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ const zAsset = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
asset_hash: z.string().nullish(),
|
||||
size: z.number(),
|
||||
size: z.number().optional(), // TBD: Will be provided by history API in the future
|
||||
mime_type: z.string().nullish(),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
preview_id: z.string().nullable().optional(),
|
||||
|
||||
@@ -249,6 +249,91 @@ function createAssetService() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves metadata from a download URL without downloading the file
|
||||
*
|
||||
* @param url - Download URL to retrieve metadata from (will be URL-encoded)
|
||||
* @returns Promise with metadata including content_length, final_url, filename, etc.
|
||||
* @throws Error if metadata retrieval fails
|
||||
*/
|
||||
async function getAssetMetadata(url: string): Promise<{
|
||||
content_length: number
|
||||
final_url: string
|
||||
content_type?: string
|
||||
filename?: string
|
||||
name?: string
|
||||
tags?: string[]
|
||||
}> {
|
||||
const encodedUrl = encodeURIComponent(url)
|
||||
const res = await api.fetchApi(
|
||||
`${ASSETS_ENDPOINT}/metadata?url=${encodedUrl}`
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => 'Unknown error')
|
||||
throw new Error(
|
||||
`Failed to retrieve metadata: Server returned ${res.status}. ${errorText}`
|
||||
)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an asset by providing a URL to download from
|
||||
*
|
||||
* @param params - Upload parameters
|
||||
* @param params.url - HTTP/HTTPS URL to download from
|
||||
* @param params.name - Display name (determines extension)
|
||||
* @param params.tags - Optional freeform tags
|
||||
* @param params.user_metadata - Optional custom metadata object
|
||||
* @param params.preview_id - Optional UUID for preview asset
|
||||
* @returns Promise<AssetItem & { created_new: boolean }> - Asset object with created_new flag
|
||||
* @throws Error if upload fails
|
||||
*/
|
||||
async function uploadAssetFromUrl(params: {
|
||||
url: string
|
||||
name: string
|
||||
tags?: string[]
|
||||
user_metadata?: Record<string, any>
|
||||
preview_id?: string
|
||||
}): Promise<AssetItem & { created_new: boolean }> {
|
||||
const res = await api.fetchApi(ASSETS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => 'Unknown error')
|
||||
throw new Error(
|
||||
`Failed to upload asset: Server returned ${res.status}. ${errorText}`
|
||||
)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets available model types from the server
|
||||
*
|
||||
* @returns Promise<ModelFolder[]> - List of model types with their folder mappings
|
||||
* @throws Error if request fails
|
||||
*/
|
||||
async function getModelTypes(): Promise<ModelFolder[]> {
|
||||
const res = await api.fetchApi('/experiment/models')
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch model types: Server returned ${res.status}`
|
||||
)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
@@ -256,7 +341,10 @@ function createAssetService() {
|
||||
getAssetsForNodeType,
|
||||
getAssetDetails,
|
||||
getAssetsByTag,
|
||||
deleteAsset
|
||||
deleteAsset,
|
||||
getAssetMetadata,
|
||||
uploadAssetFromUrl,
|
||||
getModelTypes
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
|
||||
|
||||
type CloudSubscriptionCheckoutResponse = {
|
||||
checkout_url: string
|
||||
}
|
||||
|
||||
type CloudSubscriptionStatusResponse = {
|
||||
export type CloudSubscriptionStatusResponse = {
|
||||
is_active: boolean
|
||||
subscription_id: string
|
||||
renewal_date: string | null
|
||||
@@ -28,6 +29,7 @@ type CloudSubscriptionStatusResponse = {
|
||||
|
||||
function useSubscriptionInternal() {
|
||||
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const isSubscribedOrIsNotCloud = computed(() => {
|
||||
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
|
||||
@@ -103,8 +105,21 @@ function useSubscriptionInternal() {
|
||||
void dialogService.showSubscriptionRequiredDialog()
|
||||
}
|
||||
|
||||
const shouldWatchCancellation = (): boolean =>
|
||||
Boolean(isCloud && window.__CONFIG__?.subscription_required)
|
||||
|
||||
const { startCancellationWatcher, stopCancellationWatcher } =
|
||||
useSubscriptionCancellationWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription: isSubscribedOrIsNotCloud,
|
||||
subscriptionStatus,
|
||||
telemetry,
|
||||
shouldWatchCancellation
|
||||
})
|
||||
|
||||
const manageSubscription = async () => {
|
||||
await accessBillingPortal()
|
||||
startCancellationWatcher()
|
||||
}
|
||||
|
||||
const requireActiveSubscription = async (): Promise<void> => {
|
||||
@@ -168,6 +183,7 @@ function useSubscriptionInternal() {
|
||||
await fetchSubscriptionStatus()
|
||||
} else {
|
||||
subscriptionStatus.value = null
|
||||
stopCancellationWatcher()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { onScopeDispose, ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { defaultWindow, useEventListener, useTimeoutFn } from '@vueuse/core'
|
||||
|
||||
import type { TelemetryProvider } from '@/platform/telemetry/types'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from './useSubscription'
|
||||
|
||||
const MAX_CANCELLATION_ATTEMPTS = 4
|
||||
const CANCELLATION_BASE_DELAY_MS = 5000
|
||||
const CANCELLATION_BACKOFF_MULTIPLIER = 3 // 5s, 15s, 45s, 135s intervals
|
||||
|
||||
type CancellationWatcherOptions = {
|
||||
fetchStatus: () => Promise<CloudSubscriptionStatusResponse | null | void>
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
subscriptionStatus: Ref<CloudSubscriptionStatusResponse | null>
|
||||
telemetry: Pick<TelemetryProvider, 'trackMonthlySubscriptionCancelled'> | null
|
||||
shouldWatchCancellation: () => boolean
|
||||
}
|
||||
|
||||
export function useSubscriptionCancellationWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry,
|
||||
shouldWatchCancellation
|
||||
}: CancellationWatcherOptions) {
|
||||
const watcherActive = ref(false)
|
||||
const cancellationAttempts = ref(0)
|
||||
const cancellationTracked = ref(false)
|
||||
const cancellationCheckInFlight = ref(false)
|
||||
const nextDelay = ref(CANCELLATION_BASE_DELAY_MS)
|
||||
let detachFocusListener: (() => void) | null = null
|
||||
|
||||
const { start: startTimer, stop: stopTimer } = useTimeoutFn(
|
||||
() => {
|
||||
void checkForCancellation()
|
||||
},
|
||||
nextDelay,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const stopCancellationWatcher = () => {
|
||||
watcherActive.value = false
|
||||
stopTimer()
|
||||
cancellationAttempts.value = 0
|
||||
cancellationCheckInFlight.value = false
|
||||
if (detachFocusListener) {
|
||||
detachFocusListener()
|
||||
detachFocusListener = null
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleNextCancellationCheck = () => {
|
||||
if (!watcherActive.value) return
|
||||
|
||||
if (cancellationAttempts.value >= MAX_CANCELLATION_ATTEMPTS) {
|
||||
stopCancellationWatcher()
|
||||
return
|
||||
}
|
||||
|
||||
nextDelay.value =
|
||||
CANCELLATION_BASE_DELAY_MS *
|
||||
CANCELLATION_BACKOFF_MULTIPLIER ** cancellationAttempts.value
|
||||
cancellationAttempts.value += 1
|
||||
startTimer()
|
||||
}
|
||||
|
||||
const checkForCancellation = async (triggeredFromFocus = false) => {
|
||||
if (!watcherActive.value || cancellationCheckInFlight.value) return
|
||||
|
||||
cancellationCheckInFlight.value = true
|
||||
try {
|
||||
await fetchStatus()
|
||||
|
||||
if (!isActiveSubscription.value) {
|
||||
if (!cancellationTracked.value) {
|
||||
cancellationTracked.value = true
|
||||
try {
|
||||
telemetry?.trackMonthlySubscriptionCancelled()
|
||||
} catch (telemetryError) {
|
||||
console.error(
|
||||
'[Subscription] Failed to track cancellation telemetry:',
|
||||
telemetryError
|
||||
)
|
||||
}
|
||||
}
|
||||
stopCancellationWatcher()
|
||||
return
|
||||
}
|
||||
|
||||
if (!triggeredFromFocus) {
|
||||
scheduleNextCancellationCheck()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Subscription] Error checking cancellation status:', error)
|
||||
scheduleNextCancellationCheck()
|
||||
} finally {
|
||||
cancellationCheckInFlight.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startCancellationWatcher = () => {
|
||||
if (!shouldWatchCancellation() || !subscriptionStatus.value?.is_active) {
|
||||
return
|
||||
}
|
||||
|
||||
stopCancellationWatcher()
|
||||
watcherActive.value = true
|
||||
cancellationTracked.value = false
|
||||
cancellationAttempts.value = 0
|
||||
if (!detachFocusListener && defaultWindow) {
|
||||
detachFocusListener = useEventListener(defaultWindow, 'focus', () => {
|
||||
if (!watcherActive.value) return
|
||||
void checkForCancellation(true)
|
||||
})
|
||||
}
|
||||
scheduleNextCancellationCheck()
|
||||
}
|
||||
|
||||
onScopeDispose(() => {
|
||||
stopCancellationWatcher()
|
||||
})
|
||||
|
||||
return {
|
||||
startCancellationWatcher,
|
||||
stopCancellationWatcher
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,28 @@ import type {
|
||||
* Fetches history from V1 API endpoint
|
||||
* @param api - API instance with fetchApi method
|
||||
* @param maxItems - Maximum number of history items to fetch
|
||||
* @param offset - Offset for pagination (must be non-negative integer)
|
||||
* @returns Promise resolving to V1 history response
|
||||
* @throws Error if offset is invalid (negative or non-integer)
|
||||
*/
|
||||
export async function fetchHistoryV1(
|
||||
fetchApi: (url: string) => Promise<Response>,
|
||||
maxItems: number = 200
|
||||
maxItems: number = 200,
|
||||
offset?: number
|
||||
): Promise<HistoryV1Response> {
|
||||
const res = await fetchApi(`/history?max_items=${maxItems}`)
|
||||
// Validate offset parameter
|
||||
if (offset !== undefined && (offset < 0 || !Number.isInteger(offset))) {
|
||||
throw new Error(
|
||||
`Invalid offset parameter: ${offset}. Must be a non-negative integer.`
|
||||
)
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ max_items: maxItems.toString() })
|
||||
if (offset !== undefined) {
|
||||
params.set('offset', offset.toString())
|
||||
}
|
||||
const url = `/history?${params.toString()}`
|
||||
const res = await fetchApi(url)
|
||||
const json: Record<
|
||||
string,
|
||||
Omit<HistoryTaskItem, 'taskType'>
|
||||
|
||||
@@ -14,13 +14,28 @@ import type { HistoryResponseV2 } from '../types/historyV2Types'
|
||||
* Fetches history from V2 API endpoint and adapts to V1 format
|
||||
* @param fetchApi - API instance with fetchApi method
|
||||
* @param maxItems - Maximum number of history items to fetch
|
||||
* @param offset - Offset for pagination (must be non-negative integer)
|
||||
* @returns Promise resolving to V1 history response (adapted from V2)
|
||||
* @throws Error if offset is invalid (negative or non-integer)
|
||||
*/
|
||||
export async function fetchHistoryV2(
|
||||
fetchApi: (url: string) => Promise<Response>,
|
||||
maxItems: number = 200
|
||||
maxItems: number = 200,
|
||||
offset?: number
|
||||
): Promise<HistoryV1Response> {
|
||||
const res = await fetchApi(`/history_v2?max_items=${maxItems}`)
|
||||
// Validate offset parameter
|
||||
if (offset !== undefined && (offset < 0 || !Number.isInteger(offset))) {
|
||||
throw new Error(
|
||||
`Invalid offset parameter: ${offset}. Must be a non-negative integer.`
|
||||
)
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ max_items: maxItems.toString() })
|
||||
if (offset !== undefined) {
|
||||
params.set('offset', offset.toString())
|
||||
}
|
||||
const url = `/history_v2?${params.toString()}`
|
||||
const res = await fetchApi(url)
|
||||
const rawData: HistoryResponseV2 = await res.json()
|
||||
const adaptedHistory = mapHistoryV2toHistory(rawData)
|
||||
return { History: adaptedHistory }
|
||||
|
||||
@@ -175,6 +175,14 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Track when a user completes a subscription cancellation flow.
|
||||
* Fired after we detect the backend reports `is_active: false` and the UI stops polling.
|
||||
*/
|
||||
trackMonthlySubscriptionCancelled(): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
|
||||
}
|
||||
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
||||
const metadata: CreditTopupMetadata = {
|
||||
credit_amount: amount
|
||||
|
||||
@@ -265,6 +265,7 @@ export interface TelemetryProvider {
|
||||
// Subscription flow events
|
||||
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
|
||||
trackMonthlySubscriptionSucceeded(): void
|
||||
trackMonthlySubscriptionCancelled(): void
|
||||
trackAddApiCreditButtonClicked(): void
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
|
||||
trackApiCreditTopupSucceeded(): void
|
||||
@@ -344,6 +345,7 @@ export const TelemetryEvents = {
|
||||
SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'app:subscription_required_modal_opened',
|
||||
SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked',
|
||||
MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded',
|
||||
MONTHLY_SUBSCRIPTION_CANCELLED: 'app:monthly_subscription_cancelled',
|
||||
ADD_API_CREDIT_BUTTON_CLICKED: 'app:add_api_credit_button_clicked',
|
||||
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
|
||||
'app:api_credit_topup_button_purchase_clicked',
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
/>
|
||||
|
||||
<!-- Slot Name -->
|
||||
<div class="relative">
|
||||
<div class="relative h-full flex items-center">
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
:class="
|
||||
cn('whitespace-nowrap text-sm font-normal lod-toggle', labelClasses)
|
||||
cn('whitespace-nowrap text-xs font-normal lod-toggle', labelClasses)
|
||||
"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
/>
|
||||
|
||||
<template v-if="!isCollapsed">
|
||||
<div class="relative mb-4">
|
||||
<div class="relative mb-1">
|
||||
<div :class="separatorClasses" />
|
||||
<!-- Progress bar for executing state -->
|
||||
<div
|
||||
@@ -101,7 +101,7 @@
|
||||
|
||||
<!-- Node Body - rendered based on LOD level and collapsed state -->
|
||||
<div
|
||||
class="flex min-h-min min-w-min flex-1 flex-col gap-4 pb-4"
|
||||
class="flex min-h-min min-w-min flex-1 flex-col gap-1 pb-2"
|
||||
:data-testid="`node-body-${nodeData.id}`"
|
||||
>
|
||||
<!-- Slots only rendered at full detail -->
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-header p-4 rounded-t-2xl w-full min-w-50',
|
||||
'lg-node-header py-2 pl-2 pr-3 text-sm rounded-t-2xl w-full min-w-50',
|
||||
'text-node-component-header',
|
||||
collapsed && 'rounded-2xl'
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div v-else :class="cn('flex justify-between', unifiedWrapperClass)">
|
||||
<div
|
||||
v-if="filteredInputs.length"
|
||||
:class="cn('flex flex-col gap-1', unifiedDotsClass)"
|
||||
:class="cn('flex flex-col', unifiedDotsClass)"
|
||||
>
|
||||
<InputSlot
|
||||
v-for="(input, index) in filteredInputs"
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<div
|
||||
v-if="nodeData?.outputs?.length"
|
||||
:class="cn('ml-auto flex flex-col gap-1', unifiedDotsClass)"
|
||||
:class="cn('ml-auto flex flex-col', unifiedDotsClass)"
|
||||
>
|
||||
<OutputSlot
|
||||
v-for="(output, index) in nodeData.outputs"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-widgets flex flex-col has-[.widget-expands]:flex-1 gap-2 pr-3',
|
||||
'lg-node-widgets flex flex-col has-[.widget-expands]:flex-1 gap-1 pr-3',
|
||||
shouldHandleNodePointerEvents
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-1 text-xs text-red-500">⚠️</div>
|
||||
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
|
||||
<div class="relative">
|
||||
<div class="relative h-full flex items-center">
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="lod-toggle text-sm font-normal whitespace-nowrap text-node-component-slot-text"
|
||||
class="lod-toggle text-xs font-normal whitespace-nowrap text-node-component-slot-text"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Output ${index}` }}
|
||||
</span>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt'
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
@@ -79,57 +78,50 @@ const buttonTooltip = computed(() => {
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const inputNumberPt = useNumberWidgetButtonPt({
|
||||
roundedLeft: true,
|
||||
roundedRight: true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<div v-tooltip="buttonTooltip">
|
||||
<InputNumber
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
button-layout="horizontal"
|
||||
size="small"
|
||||
:step="stepValue"
|
||||
:use-grouping="useGrouping"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
:aria-label="widget.name"
|
||||
:show-buttons="!buttonsDisabled"
|
||||
:pt="inputNumberPt"
|
||||
@update:model-value="onChange"
|
||||
>
|
||||
<template #incrementicon>
|
||||
<span
|
||||
class="pi pi-plus text-sm text-component-node-foreground-secondary"
|
||||
/>
|
||||
</template>
|
||||
<template #decrementicon>
|
||||
<span
|
||||
class="pi pi-minus text-sm text-component-node-foreground-secondary"
|
||||
/>
|
||||
</template>
|
||||
</InputNumber>
|
||||
</div>
|
||||
<InputNumber
|
||||
v-model="localValue"
|
||||
v-tooltip="buttonTooltip"
|
||||
v-bind="filteredProps"
|
||||
fluid
|
||||
button-layout="horizontal"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
:step="stepValue"
|
||||
:use-grouping="useGrouping"
|
||||
:class="cn(WidgetInputBaseClass, 'grow text-xs')"
|
||||
:aria-label="widget.name"
|
||||
:show-buttons="!buttonsDisabled"
|
||||
:pt="{
|
||||
root: {
|
||||
class: '[&>input]:bg-transparent [&>input]:border-0'
|
||||
},
|
||||
decrementButton: {
|
||||
class: 'w-8 border-0'
|
||||
},
|
||||
incrementButton: {
|
||||
class: 'w-8 border-0'
|
||||
}
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
>
|
||||
<template #incrementicon>
|
||||
<span class="pi pi-plus text-sm" />
|
||||
</template>
|
||||
<template #decrementicon>
|
||||
<span class="pi pi-minus text-sm" />
|
||||
</template>
|
||||
</InputNumber>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-inputnumber-input) {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--component-node-border);
|
||||
border-top: transparent;
|
||||
border-bottom: transparent;
|
||||
height: 1.625rem;
|
||||
margin: 1px 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.p-inputnumber-button.p-disabled .pi),
|
||||
:deep(.p-inputnumber-button.p-disabled .p-icon) {
|
||||
color: var(--color-node-icon-disabled) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<div
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'flex items-center gap-2 w-full pl-4 pr-2')
|
||||
"
|
||||
>
|
||||
<div :class="cn(WidgetInputBaseClass, 'flex items-center gap-2 pl-3 pr-2')">
|
||||
<Slider
|
||||
:model-value="[localValue]"
|
||||
v-bind="filteredProps"
|
||||
@@ -24,7 +20,6 @@
|
||||
size="small"
|
||||
pt:pc-input-text:root="min-w-full bg-transparent border-none text-center"
|
||||
class="w-16"
|
||||
:show-buttons="!buttonsDisabled"
|
||||
:pt="sliderNumberPt"
|
||||
@update:model-value="handleNumberInputUpdate"
|
||||
/>
|
||||
@@ -107,14 +102,6 @@ const stepValue = computed(() => {
|
||||
return 1 / Math.pow(10, precision.value)
|
||||
})
|
||||
|
||||
const buttonsDisabled = computed(() => {
|
||||
const currentValue = localValue.value ?? 0
|
||||
return (
|
||||
!Number.isFinite(currentValue) ||
|
||||
Math.abs(currentValue) > Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
})
|
||||
|
||||
const sliderNumberPt = useNumberWidgetButtonPt({
|
||||
roundedLeft: true,
|
||||
roundedRight: true
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdownIcon: 'text-component-node-foreground-secondary'
|
||||
dropdown: 'w-8'
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="onChange"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<WidgetLayoutField :widget>
|
||||
<ToggleSwitch
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
class="ml-auto block"
|
||||
:aria-label="widget.name"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
@@ -42,13 +43,3 @@ const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-toggleswitch .p-toggleswitch-slider) {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
:deep(.p-toggleswitch:hover .p-toggleswitch-slider) {
|
||||
border-color: currentColor;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,19 +11,19 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-[30px] min-w-105 items-center justify-between gap-2 overscroll-contain contain-size"
|
||||
>
|
||||
<div class="relative flex h-6 min-w-28 shrink-1 items-center">
|
||||
<div class="flex h-[30px] min-w-78 items-center justify-between gap-1">
|
||||
<div
|
||||
class="relative flex h-full basis-content min-w-20 flex-1 items-center"
|
||||
>
|
||||
<p
|
||||
v-if="widget.name"
|
||||
class="lod-toggle flex-1 truncate text-sm font-normal text-node-component-slot-text"
|
||||
class="lod-toggle flex-1 truncate text-xs font-normal text-node-component-slot-text"
|
||||
>
|
||||
{{ widget.label || widget.name }}
|
||||
</p>
|
||||
<LODFallback />
|
||||
</div>
|
||||
<div class="relative min-w-75 grow-1">
|
||||
<div class="relative min-w-56 basis-full grow">
|
||||
<div
|
||||
class="lod-toggle cursor-default"
|
||||
@pointerdown.stop="noop"
|
||||
|
||||
@@ -6,9 +6,6 @@ export const WidgetInputBaseClass = cn([
|
||||
'not-disabled:text-component-node-foreground',
|
||||
// Outline
|
||||
'border-none',
|
||||
'outline outline-offset-[-1px] outline-component-node-border',
|
||||
// Rounded
|
||||
'rounded-lg',
|
||||
// Hover
|
||||
'hover:bg-component-node-widget-background-hovered'
|
||||
'rounded-lg'
|
||||
])
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
const sharedButtonClasses =
|
||||
'!inline-flex !items-center !justify-center !border-0 bg-transparent text-inherit transition-colors duration-150 ease-in-out ' +
|
||||
'hover:bg-node-component-surface-hovered active:bg-node-component-surface-selected' +
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const sharedButtonClasses = cn(
|
||||
'inline-flex items-center justify-center border-0 bg-transparent text-inherit transition-colors duration-150 ease-in-out ',
|
||||
'hover:bg-node-component-surface-hovered active:bg-node-component-surface-selected',
|
||||
'disabled:bg-node-component-disabled disabled:text-node-icon-disabled disabled:cursor-not-allowed'
|
||||
)
|
||||
|
||||
export function useNumberWidgetButtonPt(options?: {
|
||||
roundedLeft?: boolean
|
||||
@@ -9,15 +12,15 @@ export function useNumberWidgetButtonPt(options?: {
|
||||
}) {
|
||||
const { roundedLeft = false, roundedRight = false } = options ?? {}
|
||||
|
||||
const increment = `${sharedButtonClasses}${roundedRight ? ' !rounded-r-lg' : ''}`
|
||||
const decrement = `${sharedButtonClasses}${roundedLeft ? ' !rounded-l-lg' : ''}`
|
||||
const increment = cn(sharedButtonClasses, roundedRight && 'rounded-r-lg')
|
||||
const decrement = cn(sharedButtonClasses, roundedLeft && 'rounded-l-lg')
|
||||
|
||||
return {
|
||||
incrementButton: {
|
||||
class: increment.trim()
|
||||
class: increment
|
||||
},
|
||||
decrementButton: {
|
||||
class: decrement.trim()
|
||||
class: decrement
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -899,10 +899,15 @@ export class ComfyApi extends EventTarget {
|
||||
* @returns Prompt history including node outputs
|
||||
*/
|
||||
async getHistory(
|
||||
max_items: number = 200
|
||||
max_items: number = 200,
|
||||
options?: { offset?: number }
|
||||
): Promise<{ History: HistoryTaskItem[] }> {
|
||||
try {
|
||||
return await fetchHistory(this.fetchApi.bind(this), max_items)
|
||||
return await fetchHistory(
|
||||
this.fetchApi.bind(this),
|
||||
max_items,
|
||||
options?.offset
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return { History: [] }
|
||||
|
||||
@@ -97,6 +97,26 @@ export const useColorPaletteService = () => {
|
||||
)
|
||||
}
|
||||
|
||||
function loadLinkColorPaletteForVueNodes(
|
||||
linkColorPalette: Colors['node_slot']
|
||||
) {
|
||||
if (!linkColorPalette) return
|
||||
const rootStyle = document.getElementById('vue-app')?.style
|
||||
if (!rootStyle) return
|
||||
|
||||
for (const dataType of nodeDefStore.nodeDataTypes) {
|
||||
const cssVar = `color-datatype-${dataType}`
|
||||
|
||||
const valueMaybe =
|
||||
linkColorPalette[dataType as unknown as keyof Colors['node_slot']]
|
||||
if (valueMaybe) {
|
||||
rootStyle.setProperty(`--${cssVar}`, valueMaybe)
|
||||
} else {
|
||||
rootStyle.removeProperty(`--${cssVar}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadLitegraphForVueNodes(
|
||||
palette: Colors['litegraph_base'],
|
||||
colorPaletteId: string
|
||||
@@ -229,6 +249,7 @@ export const useColorPaletteService = () => {
|
||||
completedPalette.colors.litegraph_base,
|
||||
colorPaletteId
|
||||
)
|
||||
loadLinkColorPaletteForVueNodes(completedPalette.colors.node_slot)
|
||||
loadComfyColorPalette(completedPalette.colors.comfy_base)
|
||||
app.canvas.setDirty(true, true)
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export const useDialogService = () => {
|
||||
dialogComponentProps: {
|
||||
closable: true,
|
||||
pt: {
|
||||
root: { class: 'bg-base-background border-border-default' },
|
||||
header: { class: '!p-0 !m-0' },
|
||||
content: { class: '!p-0 overflow-y-hidden' },
|
||||
footer: { class: '!p-0' },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, shallowReactive } from 'vue'
|
||||
|
||||
import { computed, shallowReactive, ref } from 'vue'
|
||||
import {
|
||||
mapInputFileToAssetItem,
|
||||
mapTaskOutputToAssetItem
|
||||
@@ -9,7 +8,7 @@ import {
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { HistoryTaskItem } from '@/schemas/apiSchema'
|
||||
import type { TaskItem } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { TaskItemImpl } from './queueStore'
|
||||
@@ -48,10 +47,15 @@ async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
|
||||
/**
|
||||
* Convert history task items to asset items
|
||||
*/
|
||||
function mapHistoryToAssets(historyItems: HistoryTaskItem[]): AssetItem[] {
|
||||
function mapHistoryToAssets(historyItems: TaskItem[]): AssetItem[] {
|
||||
const assetItems: AssetItem[] = []
|
||||
|
||||
for (const item of historyItems) {
|
||||
// Type guard for HistoryTaskItem which has status and outputs
|
||||
if (item.taskType !== 'History') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!item.outputs || !item.status || item.status?.status_str === 'error') {
|
||||
continue
|
||||
}
|
||||
@@ -85,16 +89,22 @@ function mapHistoryToAssets(historyItems: HistoryTaskItem[]): AssetItem[] {
|
||||
)
|
||||
}
|
||||
|
||||
export const useAssetsStore = defineStore('assets', () => {
|
||||
const maxHistoryItems = 200
|
||||
const BATCH_SIZE = 200
|
||||
const MAX_HISTORY_ITEMS = 1000 // Maximum items to keep in memory
|
||||
|
||||
const getFetchInputFiles = () => {
|
||||
if (isCloud) {
|
||||
return fetchInputFilesFromCloud
|
||||
}
|
||||
return fetchInputFilesFromAPI
|
||||
}
|
||||
const fetchInputFiles = getFetchInputFiles()
|
||||
export const useAssetsStore = defineStore('assets', () => {
|
||||
// Pagination state
|
||||
const historyOffset = ref(0)
|
||||
const hasMoreHistory = ref(true)
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
const allHistoryItems = ref<AssetItem[]>([])
|
||||
|
||||
const loadedIds = shallowReactive(new Set<string>())
|
||||
|
||||
const fetchInputFiles = isCloud
|
||||
? fetchInputFilesFromCloud
|
||||
: fetchInputFilesFromAPI
|
||||
|
||||
const {
|
||||
state: inputAssets,
|
||||
@@ -109,23 +119,119 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const fetchHistoryAssets = async (): Promise<AssetItem[]> => {
|
||||
const history = await api.getHistory(maxHistoryItems)
|
||||
return mapHistoryToAssets(history.History)
|
||||
/**
|
||||
* Fetch history assets with pagination support
|
||||
* @param loadMore - true for pagination (append), false for initial load (replace)
|
||||
*/
|
||||
const fetchHistoryAssets = async (loadMore = false): Promise<AssetItem[]> => {
|
||||
// Reset state for initial load
|
||||
if (!loadMore) {
|
||||
historyOffset.value = 0
|
||||
hasMoreHistory.value = true
|
||||
allHistoryItems.value = []
|
||||
loadedIds.clear()
|
||||
}
|
||||
|
||||
// Fetch from server with offset
|
||||
const history = await api.getHistory(BATCH_SIZE, {
|
||||
offset: historyOffset.value
|
||||
})
|
||||
|
||||
// Convert TaskItems to AssetItems
|
||||
const newAssets = mapHistoryToAssets(history.History)
|
||||
|
||||
if (loadMore) {
|
||||
// Filter out duplicates and insert in sorted order
|
||||
for (const asset of newAssets) {
|
||||
if (loadedIds.has(asset.id)) {
|
||||
continue // Skip duplicates
|
||||
}
|
||||
loadedIds.add(asset.id)
|
||||
|
||||
// Find insertion index to maintain sorted order (newest first)
|
||||
const assetTime = new Date(asset.created_at).getTime()
|
||||
const insertIndex = allHistoryItems.value.findIndex(
|
||||
(item) => new Date(item.created_at).getTime() < assetTime
|
||||
)
|
||||
|
||||
if (insertIndex === -1) {
|
||||
// Asset is oldest, append to end
|
||||
allHistoryItems.value.push(asset)
|
||||
} else {
|
||||
// Insert at the correct position
|
||||
allHistoryItems.value.splice(insertIndex, 0, asset)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Initial load: replace all
|
||||
allHistoryItems.value = newAssets
|
||||
newAssets.forEach((asset) => loadedIds.add(asset.id))
|
||||
}
|
||||
|
||||
// Update pagination state
|
||||
historyOffset.value += BATCH_SIZE
|
||||
hasMoreHistory.value = history.History.length === BATCH_SIZE
|
||||
|
||||
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
|
||||
const removed = allHistoryItems.value.slice(MAX_HISTORY_ITEMS)
|
||||
allHistoryItems.value = allHistoryItems.value.slice(0, MAX_HISTORY_ITEMS)
|
||||
|
||||
// Clean up Set
|
||||
removed.forEach((item) => loadedIds.delete(item.id))
|
||||
}
|
||||
|
||||
return allHistoryItems.value
|
||||
}
|
||||
|
||||
const {
|
||||
state: historyAssets,
|
||||
isLoading: historyLoading,
|
||||
error: historyError,
|
||||
execute: updateHistory
|
||||
} = useAsyncState(fetchHistoryAssets, [], {
|
||||
immediate: false,
|
||||
resetOnExecute: false,
|
||||
onError: (err) => {
|
||||
const historyAssets = ref<AssetItem[]>([])
|
||||
const historyLoading = ref(false)
|
||||
const historyError = ref<unknown>(null)
|
||||
|
||||
/**
|
||||
* Initial load of history assets
|
||||
*/
|
||||
const updateHistory = async () => {
|
||||
historyLoading.value = true
|
||||
historyError.value = null
|
||||
try {
|
||||
await fetchHistoryAssets(false)
|
||||
historyAssets.value = allHistoryItems.value
|
||||
} catch (err) {
|
||||
console.error('Error fetching history assets:', err)
|
||||
historyError.value = err
|
||||
// Keep existing data when error occurs
|
||||
if (!historyAssets.value.length) {
|
||||
historyAssets.value = []
|
||||
}
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more history items (infinite scroll)
|
||||
*/
|
||||
const loadMoreHistory = async () => {
|
||||
// Guard: prevent concurrent loads and check if more items available
|
||||
if (!hasMoreHistory.value || isLoadingMore.value) return
|
||||
|
||||
isLoadingMore.value = true
|
||||
historyError.value = null
|
||||
|
||||
try {
|
||||
await fetchHistoryAssets(true)
|
||||
historyAssets.value = allHistoryItems.value
|
||||
} catch (err) {
|
||||
console.error('Error loading more history:', err)
|
||||
historyError.value = err
|
||||
// Keep existing data when error occurs (consistent with updateHistory)
|
||||
if (!historyAssets.value.length) {
|
||||
historyAssets.value = []
|
||||
}
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of asset hash filename to asset item for O(1) lookup
|
||||
@@ -142,7 +248,6 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
})
|
||||
|
||||
/**
|
||||
* Get human-readable name for input asset filename
|
||||
* @param filename Hash-based filename (e.g., "72e786ff...efb7.png")
|
||||
* @returns Human-readable asset name or original filename if not found
|
||||
*/
|
||||
@@ -248,10 +353,13 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
historyLoading,
|
||||
inputError,
|
||||
historyError,
|
||||
hasMoreHistory,
|
||||
isLoadingMore,
|
||||
|
||||
// Actions
|
||||
updateInputs,
|
||||
updateHistory,
|
||||
loadMoreHistory,
|
||||
|
||||
// Input mapping helpers
|
||||
inputAssetsByFilename,
|
||||
|
||||
@@ -11,6 +11,10 @@ const mockShowSubscriptionRequiredDialog = vi.fn()
|
||||
const mockGetAuthHeader = vi.fn(() =>
|
||||
Promise.resolve({ Authorization: 'Bearer test-token' })
|
||||
)
|
||||
const mockTelemetry = {
|
||||
trackSubscription: vi.fn(),
|
||||
trackMonthlySubscriptionCancelled: vi.fn()
|
||||
}
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
@@ -20,7 +24,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => null)
|
||||
useTelemetry: vi.fn(() => mockTelemetry)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
@@ -72,6 +76,11 @@ describe('useSubscription', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsLoggedIn.value = false
|
||||
mockTelemetry.trackSubscription.mockReset()
|
||||
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
|
||||
window.__CONFIG__ = {
|
||||
subscription_required: true
|
||||
} as typeof window.__CONFIG__
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -321,5 +330,94 @@ describe('useSubscription', () => {
|
||||
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks cancellation after manage subscription when status flips', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockIsLoggedIn.value = true
|
||||
|
||||
const activeResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_active',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
}
|
||||
|
||||
const cancelledResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: false,
|
||||
subscription_id: 'sub_cancelled',
|
||||
renewal_date: '2025-11-16',
|
||||
end_date: '2025-12-01'
|
||||
})
|
||||
}
|
||||
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce(activeResponse as Response)
|
||||
.mockResolvedValueOnce(activeResponse as Response)
|
||||
.mockResolvedValueOnce(cancelledResponse as Response)
|
||||
|
||||
try {
|
||||
const { fetchStatus, manageSubscription } = useSubscription()
|
||||
|
||||
await fetchStatus()
|
||||
await manageSubscription()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
|
||||
expect(
|
||||
mockTelemetry.trackMonthlySubscriptionCancelled
|
||||
).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('handles rapid focus events during cancellation polling', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockIsLoggedIn.value = true
|
||||
|
||||
const activeResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_active',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
}
|
||||
|
||||
const cancelledResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: false,
|
||||
subscription_id: 'sub_cancelled',
|
||||
renewal_date: '2025-11-16',
|
||||
end_date: '2025-12-01'
|
||||
})
|
||||
}
|
||||
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce(activeResponse as Response)
|
||||
.mockResolvedValueOnce(activeResponse as Response)
|
||||
.mockResolvedValueOnce(cancelledResponse as Response)
|
||||
|
||||
try {
|
||||
const { fetchStatus, manageSubscription } = useSubscription()
|
||||
|
||||
await fetchStatus()
|
||||
await manageSubscription()
|
||||
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
mockTelemetry.trackMonthlySubscriptionCancelled
|
||||
).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, effectScope, ref } from 'vue'
|
||||
import type { EffectScope } from 'vue'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionCancellationWatcher } from '@/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher'
|
||||
|
||||
describe('useSubscriptionCancellationWatcher', () => {
|
||||
const trackMonthlySubscriptionCancelled = vi.fn()
|
||||
const telemetryMock: Pick<
|
||||
import('@/platform/telemetry/types').TelemetryProvider,
|
||||
'trackMonthlySubscriptionCancelled'
|
||||
> = {
|
||||
trackMonthlySubscriptionCancelled
|
||||
}
|
||||
|
||||
const baseStatus: CloudSubscriptionStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_id: 'sub_123',
|
||||
renewal_date: '2025-11-16'
|
||||
}
|
||||
|
||||
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(
|
||||
baseStatus
|
||||
)
|
||||
const isActive = ref(true)
|
||||
const isActiveSubscription = computed(() => isActive.value)
|
||||
|
||||
let shouldWatch = true
|
||||
const shouldWatchCancellation = () => shouldWatch
|
||||
|
||||
const activeScopes: EffectScope[] = []
|
||||
|
||||
const initWatcher = (
|
||||
options: Parameters<typeof useSubscriptionCancellationWatcher>[0]
|
||||
): ReturnType<typeof useSubscriptionCancellationWatcher> => {
|
||||
const scope = effectScope()
|
||||
let result: ReturnType<typeof useSubscriptionCancellationWatcher> | null =
|
||||
null
|
||||
scope.run(() => {
|
||||
result = useSubscriptionCancellationWatcher(options)
|
||||
})
|
||||
if (!result) {
|
||||
throw new Error('Failed to initialize cancellation watcher')
|
||||
}
|
||||
activeScopes.push(scope)
|
||||
return result
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
trackMonthlySubscriptionCancelled.mockReset()
|
||||
subscriptionStatus.value = { ...baseStatus }
|
||||
isActive.value = true
|
||||
shouldWatch = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
activeScopes.forEach((scope) => scope.stop())
|
||||
activeScopes.length = 0
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('polls with exponential backoff and fires telemetry once cancellation detected', async () => {
|
||||
const fetchStatus = vi.fn(async () => {
|
||||
if (fetchStatus.mock.calls.length === 2) {
|
||||
isActive.value = false
|
||||
subscriptionStatus.value = {
|
||||
is_active: false,
|
||||
subscription_id: 'sub_cancelled',
|
||||
renewal_date: '2025-11-16',
|
||||
end_date: '2025-12-01'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
})
|
||||
|
||||
startCancellationWatcher()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15000)
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
telemetryMock.trackMonthlySubscriptionCancelled
|
||||
).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('triggers a check immediately when window regains focus', async () => {
|
||||
const fetchStatus = vi.fn(async () => {
|
||||
isActive.value = false
|
||||
subscriptionStatus.value = {
|
||||
...baseStatus,
|
||||
is_active: false,
|
||||
end_date: '2025-12-01'
|
||||
}
|
||||
})
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
})
|
||||
|
||||
startCancellationWatcher()
|
||||
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
telemetryMock.trackMonthlySubscriptionCancelled
|
||||
).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops after max attempts when subscription stays active', async () => {
|
||||
const fetchStatus = vi.fn(async () => {})
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
})
|
||||
|
||||
startCancellationWatcher()
|
||||
|
||||
const delays = [5000, 15000, 45000, 135000]
|
||||
for (const delay of delays) {
|
||||
await vi.advanceTimersByTimeAsync(delay)
|
||||
}
|
||||
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(4)
|
||||
expect(trackMonthlySubscriptionCancelled).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200000)
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('does not start watcher when guard fails or subscription inactive', async () => {
|
||||
const fetchStatus = vi.fn()
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
})
|
||||
|
||||
shouldWatch = false
|
||||
startCancellationWatcher()
|
||||
await vi.advanceTimersByTimeAsync(60000)
|
||||
expect(fetchStatus).not.toHaveBeenCalled()
|
||||
|
||||
shouldWatch = true
|
||||
isActive.value = false
|
||||
subscriptionStatus.value = {
|
||||
...baseStatus,
|
||||
is_active: false
|
||||
}
|
||||
startCancellationWatcher()
|
||||
await vi.advanceTimersByTimeAsync(60000)
|
||||
expect(fetchStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,225 +1,519 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import type {
|
||||
HistoryTaskItem,
|
||||
TaskPrompt,
|
||||
TaskStatus,
|
||||
TaskOutput
|
||||
} from '@/schemas/apiSchema'
|
||||
|
||||
// Mock isCloud to be true for these tests
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
// Mock the api module
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getHistory: vi.fn(),
|
||||
internalURL: vi.fn((path) => `http://localhost:3000${path}`),
|
||||
user: 'test-user'
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock assetService
|
||||
const mockGetAssetsForNodeType = vi.hoisted(() => vi.fn())
|
||||
|
||||
// Mock the asset service
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetsForNodeType: mockGetAssetsForNodeType
|
||||
getAssetsByTag: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const HASH_FILENAME =
|
||||
'72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png'
|
||||
const HASH_FILENAME_2 =
|
||||
'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456.jpg'
|
||||
// Mock distribution type
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'test-id',
|
||||
name: 'test.png',
|
||||
asset_hash: 'test-hash',
|
||||
size: 1024,
|
||||
tags: [],
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides
|
||||
// Mock TaskItemImpl
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
TaskItemImpl: class {
|
||||
public flatOutputs: Array<{
|
||||
supportsPreview: boolean
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
url: string
|
||||
}>
|
||||
public previewOutput:
|
||||
| {
|
||||
supportsPreview: boolean
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
url: string
|
||||
}
|
||||
| undefined
|
||||
|
||||
constructor(
|
||||
public taskType: string,
|
||||
public prompt: TaskPrompt,
|
||||
public status: TaskStatus | undefined,
|
||||
public outputs: TaskOutput
|
||||
) {
|
||||
this.flatOutputs = this.outputs
|
||||
? [
|
||||
{
|
||||
supportsPreview: true,
|
||||
filename: 'test.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
url: 'http://test.com/test.png'
|
||||
}
|
||||
]
|
||||
: []
|
||||
this.previewOutput = this.flatOutputs[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock asset mappers - add unique timestamps
|
||||
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
|
||||
mapInputFileToAssetItem: vi.fn((name, index, type) => ({
|
||||
id: `${type}-${index}`,
|
||||
name,
|
||||
size: 0,
|
||||
created_at: new Date(Date.now() - index * 1000).toISOString(), // Unique timestamps
|
||||
tags: [type],
|
||||
preview_url: `http://test.com/${name}`
|
||||
})),
|
||||
mapTaskOutputToAssetItem: vi.fn((task, output) => {
|
||||
const index = parseInt(task.prompt[1].split('_')[1]) || 0
|
||||
return {
|
||||
id: task.prompt[1], // Use promptId as asset ID
|
||||
name: output.filename,
|
||||
size: 0,
|
||||
created_at: new Date(Date.now() - index * 1000).toISOString(), // Unique timestamps
|
||||
tags: ['output'],
|
||||
preview_url: output.url,
|
||||
user_metadata: {}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('assetsStore - Refactored (Option A)', () => {
|
||||
let store: ReturnType<typeof useAssetsStore>
|
||||
|
||||
// Helper function to create mock history items
|
||||
const createMockHistoryItem = (index: number): HistoryTaskItem => ({
|
||||
taskType: 'History' as const,
|
||||
prompt: [
|
||||
1000 + index, // queueIndex
|
||||
`prompt_${index}`, // promptId
|
||||
{}, // promptInputs
|
||||
{
|
||||
extra_pnginfo: {
|
||||
workflow: {
|
||||
last_node_id: 1,
|
||||
last_link_id: 1,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
version: 1
|
||||
}
|
||||
}
|
||||
}, // extraData
|
||||
[] // outputsToExecute
|
||||
],
|
||||
status: {
|
||||
status_str: 'success' as const,
|
||||
completed: true,
|
||||
messages: []
|
||||
},
|
||||
outputs: {
|
||||
'1': {
|
||||
images: [
|
||||
{
|
||||
filename: `output_${index}.png`,
|
||||
subfolder: '',
|
||||
type: 'output' as const
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('assetsStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
store = useAssetsStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('input asset mapping helpers', () => {
|
||||
it('should return name for valid asset_hash', () => {
|
||||
const store = useAssetsStore()
|
||||
describe('Initial Load', () => {
|
||||
it('should load initial history items', async () => {
|
||||
const mockHistory = Array.from({ length: 10 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValue({
|
||||
History: mockHistory
|
||||
})
|
||||
|
||||
store.inputAssets = [
|
||||
createMockAssetItem({
|
||||
name: 'Beautiful Sunset.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
}),
|
||||
createMockAssetItem({
|
||||
name: 'Mountain Vista.jpg',
|
||||
asset_hash: HASH_FILENAME_2
|
||||
})
|
||||
]
|
||||
await store.updateHistory()
|
||||
|
||||
expect(store.getInputName(HASH_FILENAME)).toBe('Beautiful Sunset.png')
|
||||
expect(store.getInputName(HASH_FILENAME_2)).toBe('Mountain Vista.jpg')
|
||||
expect(api.getHistory).toHaveBeenCalledWith(200, { offset: 0 })
|
||||
expect(store.historyAssets).toHaveLength(10)
|
||||
expect(store.hasMoreHistory).toBe(false) // Less than BATCH_SIZE
|
||||
expect(store.historyLoading).toBe(false)
|
||||
expect(store.historyError).toBe(null)
|
||||
})
|
||||
|
||||
it('should return original hash when no matching asset found', () => {
|
||||
const store = useAssetsStore()
|
||||
it('should set hasMoreHistory to true when batch is full', async () => {
|
||||
const mockHistory = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValue({
|
||||
History: mockHistory
|
||||
})
|
||||
|
||||
store.inputAssets = [
|
||||
createMockAssetItem({
|
||||
name: 'Beautiful Sunset.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
})
|
||||
]
|
||||
await store.updateHistory()
|
||||
|
||||
const unknownHash =
|
||||
'fffffffffffffffffffffffffffuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu.png'
|
||||
expect(store.getInputName(unknownHash)).toBe(unknownHash)
|
||||
expect(store.historyAssets).toHaveLength(200)
|
||||
expect(store.hasMoreHistory).toBe(true) // Exactly BATCH_SIZE
|
||||
})
|
||||
|
||||
it('should return hash as-is when no assets loaded', () => {
|
||||
const store = useAssetsStore()
|
||||
it('should handle errors during initial load', async () => {
|
||||
const error = new Error('Failed to fetch')
|
||||
vi.mocked(api.getHistory).mockRejectedValue(error)
|
||||
|
||||
store.inputAssets = []
|
||||
await store.updateHistory()
|
||||
|
||||
expect(store.getInputName(HASH_FILENAME)).toBe(HASH_FILENAME)
|
||||
})
|
||||
|
||||
it('should ignore assets without asset_hash', () => {
|
||||
const store = useAssetsStore()
|
||||
|
||||
store.inputAssets = [
|
||||
createMockAssetItem({
|
||||
name: 'Beautiful Sunset.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
}),
|
||||
createMockAssetItem({
|
||||
name: 'No Hash Asset.jpg',
|
||||
asset_hash: null
|
||||
})
|
||||
]
|
||||
|
||||
// Should find first asset
|
||||
expect(store.getInputName(HASH_FILENAME)).toBe('Beautiful Sunset.png')
|
||||
// Map should only contain one entry
|
||||
expect(store.inputAssetsByFilename.size).toBe(1)
|
||||
expect(store.historyAssets).toHaveLength(0)
|
||||
expect(store.historyError).toBe(error)
|
||||
expect(store.historyLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inputAssetsByFilename computed', () => {
|
||||
it('should create map keyed by asset_hash', () => {
|
||||
const store = useAssetsStore()
|
||||
|
||||
store.inputAssets = [
|
||||
createMockAssetItem({
|
||||
id: 'asset-123',
|
||||
name: 'Beautiful Sunset.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
}),
|
||||
createMockAssetItem({
|
||||
id: 'asset-456',
|
||||
name: 'Mountain Vista.jpg',
|
||||
asset_hash: HASH_FILENAME_2
|
||||
})
|
||||
]
|
||||
|
||||
const map = store.inputAssetsByFilename
|
||||
|
||||
expect(map.size).toBe(2)
|
||||
expect(map.get(HASH_FILENAME)).toMatchObject({
|
||||
id: 'asset-123',
|
||||
name: 'Beautiful Sunset.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
describe('Pagination', () => {
|
||||
it('should accumulate items when loading more', async () => {
|
||||
// First batch - full BATCH_SIZE
|
||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: firstBatch
|
||||
})
|
||||
expect(map.get(HASH_FILENAME_2)).toMatchObject({
|
||||
id: 'asset-456',
|
||||
name: 'Mountain Vista.jpg',
|
||||
asset_hash: HASH_FILENAME_2
|
||||
|
||||
await store.updateHistory()
|
||||
expect(store.historyAssets).toHaveLength(200)
|
||||
expect(store.hasMoreHistory).toBe(true)
|
||||
|
||||
// Second batch - different items
|
||||
const secondBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(200 + i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: secondBatch
|
||||
})
|
||||
|
||||
await store.loadMoreHistory()
|
||||
|
||||
expect(api.getHistory).toHaveBeenCalledWith(200, { offset: 200 })
|
||||
expect(store.historyAssets).toHaveLength(400) // Accumulated
|
||||
expect(store.hasMoreHistory).toBe(true)
|
||||
})
|
||||
|
||||
it('should exclude assets with null/undefined hash from map', () => {
|
||||
const store = useAssetsStore()
|
||||
it('should prevent duplicate items during pagination', async () => {
|
||||
// First batch - full BATCH_SIZE
|
||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: firstBatch
|
||||
})
|
||||
|
||||
store.inputAssets = [
|
||||
createMockAssetItem({
|
||||
name: 'Has Hash.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
}),
|
||||
createMockAssetItem({
|
||||
name: 'Null Hash.jpg',
|
||||
asset_hash: null
|
||||
}),
|
||||
createMockAssetItem({
|
||||
name: 'Undefined Hash.jpg',
|
||||
asset_hash: undefined
|
||||
})
|
||||
await store.updateHistory()
|
||||
expect(store.historyAssets).toHaveLength(200)
|
||||
|
||||
// Second batch with some duplicates
|
||||
const secondBatch = [
|
||||
createMockHistoryItem(2), // Duplicate
|
||||
createMockHistoryItem(5), // Duplicate
|
||||
...Array.from({ length: 198 }, (_, i) => createMockHistoryItem(200 + i)) // New
|
||||
]
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: secondBatch
|
||||
})
|
||||
|
||||
const map = store.inputAssetsByFilename
|
||||
await store.loadMoreHistory()
|
||||
|
||||
// Only asset with valid asset_hash should be in map
|
||||
expect(map.size).toBe(1)
|
||||
expect(map.has(HASH_FILENAME)).toBe(true)
|
||||
// Should only add new items (198 new, 2 duplicates filtered)
|
||||
expect(store.historyAssets).toHaveLength(398)
|
||||
|
||||
// Verify no duplicates
|
||||
const assetIds = store.historyAssets.map((a) => a.id)
|
||||
const uniqueAssetIds = new Set(assetIds)
|
||||
expect(uniqueAssetIds.size).toBe(store.historyAssets.length)
|
||||
})
|
||||
|
||||
it('should return empty map when no assets loaded', () => {
|
||||
const store = useAssetsStore()
|
||||
it('should stop loading when no more items', async () => {
|
||||
// First batch - less than BATCH_SIZE
|
||||
const firstBatch = Array.from({ length: 50 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: firstBatch
|
||||
})
|
||||
|
||||
store.inputAssets = []
|
||||
await store.updateHistory()
|
||||
expect(store.hasMoreHistory).toBe(false)
|
||||
|
||||
expect(store.inputAssetsByFilename.size).toBe(0)
|
||||
// Try to load more - should return early
|
||||
await store.loadMoreHistory()
|
||||
|
||||
// Should only have been called once (initial load)
|
||||
expect(api.getHistory).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle race conditions with concurrent loads', async () => {
|
||||
// Setup initial state with full batch
|
||||
const initialBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: initialBatch
|
||||
})
|
||||
await store.updateHistory()
|
||||
expect(store.hasMoreHistory).toBe(true)
|
||||
|
||||
// Clear mock to count only loadMore calls
|
||||
vi.mocked(api.getHistory).mockClear()
|
||||
|
||||
// Setup slow API response
|
||||
let resolveLoadMore: (value: { History: HistoryTaskItem[] }) => void
|
||||
const loadMorePromise = new Promise<{ History: HistoryTaskItem[] }>(
|
||||
(resolve) => {
|
||||
resolveLoadMore = resolve
|
||||
}
|
||||
)
|
||||
vi.mocked(api.getHistory).mockReturnValueOnce(loadMorePromise)
|
||||
|
||||
// Start first loadMore
|
||||
const firstLoad = store.loadMoreHistory()
|
||||
|
||||
// Try concurrent load - should be ignored
|
||||
const secondLoad = store.loadMoreHistory()
|
||||
|
||||
// Resolve
|
||||
const secondBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(200 + i)
|
||||
)
|
||||
resolveLoadMore!({ History: secondBatch })
|
||||
|
||||
await Promise.all([firstLoad, secondLoad])
|
||||
|
||||
// Only one API call
|
||||
expect(api.getHistory).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should respect MAX_HISTORY_ITEMS limit', async () => {
|
||||
const BATCH_COUNT = 6 // 6 × 200 = 1200 items
|
||||
|
||||
// Initial load
|
||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: firstBatch
|
||||
})
|
||||
await store.updateHistory()
|
||||
|
||||
// Load additional batches
|
||||
for (let batch = 1; batch < BATCH_COUNT; batch++) {
|
||||
const items = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(batch * 200 + i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: items
|
||||
})
|
||||
await store.loadMoreHistory()
|
||||
}
|
||||
|
||||
// Should be capped at MAX_HISTORY_ITEMS (1000)
|
||||
expect(store.historyAssets).toHaveLength(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('model assets caching', () => {
|
||||
beforeEach(() => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
describe('Sorting', () => {
|
||||
it('should maintain date sorting after pagination', async () => {
|
||||
// First batch
|
||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: firstBatch
|
||||
})
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
// Second batch
|
||||
const secondBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(200 + i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: secondBatch
|
||||
})
|
||||
|
||||
await store.loadMoreHistory()
|
||||
|
||||
// Verify sorting (newest first - lower index = newer)
|
||||
for (let i = 1; i < store.historyAssets.length; i++) {
|
||||
const prevDate = new Date(store.historyAssets[i - 1].created_at)
|
||||
const currDate = new Date(store.historyAssets[i].created_at)
|
||||
expect(prevDate.getTime()).toBeGreaterThanOrEqual(currDate.getTime())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should preserve existing data when loadMore fails', async () => {
|
||||
// First successful load - full batch
|
||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: firstBatch
|
||||
})
|
||||
|
||||
await store.updateHistory()
|
||||
expect(store.historyAssets).toHaveLength(200)
|
||||
|
||||
// Second load fails
|
||||
const error = new Error('Network error')
|
||||
vi.mocked(api.getHistory).mockRejectedValueOnce(error)
|
||||
|
||||
await store.loadMoreHistory()
|
||||
|
||||
// Should keep existing data
|
||||
expect(store.historyAssets).toHaveLength(200)
|
||||
expect(store.historyError).toBe(error)
|
||||
expect(store.isLoadingMore).toBe(false)
|
||||
})
|
||||
|
||||
it('should cache assets by node type', async () => {
|
||||
const store = useAssetsStore()
|
||||
const mockAssets: AssetItem[] = [
|
||||
createMockAssetItem({ id: '1', name: 'model_a.safetensors' }),
|
||||
createMockAssetItem({ id: '2', name: 'model_b.safetensors' })
|
||||
]
|
||||
mockGetAssetsForNodeType.mockResolvedValue(mockAssets)
|
||||
|
||||
await store.updateModelsForNodeType('CheckpointLoaderSimple')
|
||||
|
||||
expect(mockGetAssetsForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
it('should clear error state on successful retry', async () => {
|
||||
// First load succeeds
|
||||
const firstBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
)
|
||||
expect(store.modelAssetsByNodeType.get('CheckpointLoaderSimple')).toEqual(
|
||||
mockAssets
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: firstBatch
|
||||
})
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
// Second load fails
|
||||
const error = new Error('Network error')
|
||||
vi.mocked(api.getHistory).mockRejectedValueOnce(error)
|
||||
|
||||
await store.loadMoreHistory()
|
||||
expect(store.historyError).toBe(error)
|
||||
|
||||
// Third load succeeds
|
||||
const thirdBatch = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(200 + i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: thirdBatch
|
||||
})
|
||||
|
||||
await store.loadMoreHistory()
|
||||
|
||||
// Error should be cleared
|
||||
expect(store.historyError).toBe(null)
|
||||
expect(store.historyAssets).toHaveLength(400)
|
||||
})
|
||||
|
||||
it('should track loading state', async () => {
|
||||
const store = useAssetsStore()
|
||||
mockGetAssetsForNodeType.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve([]), 100))
|
||||
)
|
||||
it('should handle errors with proper loading state', async () => {
|
||||
const error = new Error('API error')
|
||||
vi.mocked(api.getHistory).mockRejectedValue(error)
|
||||
|
||||
const promise = store.updateModelsForNodeType('LoraLoader')
|
||||
await store.updateHistory()
|
||||
|
||||
expect(store.modelLoadingByNodeType.get('LoraLoader')).toBe(true)
|
||||
expect(store.historyLoading).toBe(false)
|
||||
expect(store.historyError).toBe(error)
|
||||
})
|
||||
})
|
||||
|
||||
await promise
|
||||
describe('Memory Management', () => {
|
||||
it('should cleanup when exceeding MAX_HISTORY_ITEMS', async () => {
|
||||
// Load 1200 items (exceeds 1000 limit)
|
||||
const batches = 6
|
||||
|
||||
expect(store.modelLoadingByNodeType.get('LoraLoader')).toBe(false)
|
||||
for (let batch = 0; batch < batches; batch++) {
|
||||
const items = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(batch * 200 + i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: items
|
||||
})
|
||||
|
||||
if (batch === 0) {
|
||||
await store.updateHistory()
|
||||
} else {
|
||||
await store.loadMoreHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// Should be limited to 1000
|
||||
expect(store.historyAssets).toHaveLength(1000)
|
||||
|
||||
// All items should be unique (Set cleanup works)
|
||||
const assetIds = store.historyAssets.map((a) => a.id)
|
||||
const uniqueAssetIds = new Set(assetIds)
|
||||
expect(uniqueAssetIds.size).toBe(1000)
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const store = useAssetsStore()
|
||||
const mockError = new Error('Network error')
|
||||
mockGetAssetsForNodeType.mockRejectedValue(mockError)
|
||||
it('should maintain correct state after cleanup', async () => {
|
||||
// Load items beyond limit
|
||||
for (let batch = 0; batch < 6; batch++) {
|
||||
const items = Array.from({ length: 200 }, (_, i) =>
|
||||
createMockHistoryItem(batch * 200 + i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValueOnce({
|
||||
History: items
|
||||
})
|
||||
|
||||
await store.updateModelsForNodeType('VAELoader')
|
||||
if (batch === 0) {
|
||||
await store.updateHistory()
|
||||
} else {
|
||||
await store.loadMoreHistory()
|
||||
}
|
||||
}
|
||||
|
||||
expect(store.modelErrorByNodeType.get('VAELoader')).toBe(mockError)
|
||||
expect(store.modelAssetsByNodeType.get('VAELoader')).toEqual([])
|
||||
expect(store.modelLoadingByNodeType.get('VAELoader')).toBe(false)
|
||||
expect(store.historyAssets).toHaveLength(1000)
|
||||
|
||||
// Should still maintain sorting
|
||||
for (let i = 1; i < store.historyAssets.length; i++) {
|
||||
const prevDate = new Date(store.historyAssets[i - 1].created_at)
|
||||
const currDate = new Date(store.historyAssets[i].created_at)
|
||||
expect(prevDate.getTime()).toBeGreaterThanOrEqual(currDate.getTime())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('jobDetailView Support', () => {
|
||||
it('should include outputCount and allOutputs in user_metadata', async () => {
|
||||
const mockHistory = Array.from({ length: 5 }, (_, i) =>
|
||||
createMockHistoryItem(i)
|
||||
)
|
||||
vi.mocked(api.getHistory).mockResolvedValue({
|
||||
History: mockHistory
|
||||
})
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
// Check first asset
|
||||
const asset = store.historyAssets[0]
|
||||
expect(asset.user_metadata).toBeDefined()
|
||||
expect(asset.user_metadata).toHaveProperty('outputCount')
|
||||
expect(asset.user_metadata).toHaveProperty('allOutputs')
|
||||
expect(Array.isArray(asset.user_metadata!.allOutputs)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||