Compare commits

..

12 Commits

Author SHA1 Message Date
Luke Mino-Altherr
3a87e4c601 styling and fixes 2025-11-13 18:42:50 -08:00
Luke Mino-Altherr
3fc1d1663b Add components for asset modal 2025-11-13 18:41:28 -08:00
Luke Mino-Altherr
5ece3d6f2e WIP 2025-11-13 18:41:28 -08:00
Luke Mino-Altherr
17ceb75dce WIP 2025-11-13 18:41:28 -08:00
Jin Yi
bd6825a274 [style] Fix missing node modal styling (#6672)
## Summary
- Fix background and border colors in the missing nodes modal to use
semantic theme values
- Replace `ContentDivider` component with Tailwind border utilities for
cleaner code
- Update widget background from `bg-component-node-widget-background` to
`bg-secondary-background`
- Update text color from `text-text-secondary` to
`text-muted-foreground`
- Add root-level dialog styling to ensure proper background and border
colors

## Test plan
- [x] Open a workflow with missing nodes
- [x] Verify the missing nodes modal displays with correct background
colors
- [x] Verify border colors match the design system
- [x] Verify text is readable with proper contrast

Before
<img width="658" height="669" alt="before"
src="https://github.com/user-attachments/assets/1ad390ce-bffe-434f-90df-1b624bbb9d3b"
/>

After
<img width="749" height="647" alt="after"
src="https://github.com/user-attachments/assets/c8dccb44-99b8-4387-9e91-490769205979"
/>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6672-style-Fix-missing-node-modal-styling-2aa6d73d365081aea0f5eee35bc27ea7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-14 02:11:58 +00:00
Christian Byrne
f490b81be5 add telemetry event for subscription cancellation (#6684)
emits event after going to dashboard and returning to page and having
subscription status change from subscribed to not subscribed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6684-add-telemetry-event-for-subscription-cancellation-2aa6d73d365081009770de6d1db2b701)
by [Unito](https://www.unito.io)
2025-11-13 18:41:08 -07:00
Alexander Brown
ddbd26c062 Style: Fix slot colors to pull values from the theme (#6688)
## Summary

Pull colors for the slots from the Theme.

## Screenshots

| Before | After |
| ------ | ----- |
| <img width="798" height="383" alt="image"
src="https://github.com/user-attachments/assets/6c9cad2c-87db-41e2-92b9-d5d14f60d55c"
/> | <img width="964" height="407" alt="image"
src="https://github.com/user-attachments/assets/932d6e61-2eb3-462b-9b64-f0d4ce1804db"
/> |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6688-Style-Fix-slot-colors-to-pull-values-from-the-theme-2ab6d73d3650818d9a73ecc9ab26d0e8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-13 17:26:01 -08:00
Alexander Brown
adfd2e514e [Style] Compact Modern Nodes (#6687)
## Summary

Simple and clean is the way that we're making the nodes tonight.

## Changes

- **What**: Smaller minimum widths for nodes and labels
- **What**: Smaller font for the labels
- **What**: Removed outlines for widgets
- **What**: Fixes a text/background issue with buttons on widgets
- **What**: Smaller header
- **What**: Less padding within the node itself

## Review Focus

Check out the new styles and how they align with the Designs.

## Screenshots

| Before | After |
| --- | --- |
| <img width="542" height="486" alt="image"
src="https://github.com/user-attachments/assets/41fe9801-7a43-49ac-87fc-36d3b2ee82fb"
/> | <img width="411" height="388" alt="image"
src="https://github.com/user-attachments/assets/a7c21120-bf67-4039-86b3-c348bcc4341b"
/> |

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6687-Style-Compact-Modern-Nodes-2aa6d73d365081c48db3c5491c556dc9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-13 16:32:38 -08:00
Jin Yi
f0f554392d feat: Add pagination support for media assets history (#6373)
## Summary
- Implement pagination for media assets history to handle large datasets
efficiently
- Add infinite scroll support with approach-end event handler  
- Support offset parameter in history API for both V1 and V2 endpoints

## Changes
- Add offset parameter support to `api.getHistory()` method
- Update history fetchers (V1/V2) to include offset in API requests
- Implement `loadMoreHistory()` in assetsStore with pagination state
management
- Add `loadMore`, `hasMore`, and `isLoadingMore` to IAssetsProvider
interface
- Add approach-end handler in AssetsSidebarTab for infinite scroll
- Set BATCH_SIZE to 200 for efficient loading

## Implementation Improvements
Simplified offset-based pagination by removing unnecessary
reconciliation logic:
- Remove `reconcileHistory`, `taskItemsMap`, `lastKnownQueueIndex`
(offset is sufficient)
- Replace `assetItemsByPromptId` Map → `loadedIds` Set (store IDs only)
- Replace `findInsertionIndex` binary search → push + sort (faster for
batch operations)
- Replace `loadingPromise` → `isLoadingMore` boolean (simpler state
management)
- Fix memory leak by cleaning up Set together with array slice

## Test Plan
- [x] TypeScript compilation passes
- [x] ESLint and Prettier formatting applied
- [x] Test infinite scroll in media assets tab
- [x] Verify network requests include correct offset parameter
- [x] Confirm no duplicate items when loading more

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-13 11:15:44 -08:00
Christian Byrne
e639577685 ci: add backport labels automatically when a new minor version is released (#6615)
add the `core/x.yy` and `cloud/x.yy` labels (used for backporting)
automatically when a minor version is released (and the previous version
is made into RC).

By "add labels" I mean add them into the repo's list of available labels
that can be used in the UI.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6615-ci-add-backport-labels-automatically-when-a-new-minor-version-is-released-2a36d73d365081ed8c56ef650b665078)
by [Unito](https://www.unito.io)
2025-11-13 10:50:28 -08:00
Christian Byrne
596add9f63 fix: npm link in release notification comment (#6683)
Changes to correct URL syntax for npm package link (types package).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6683-fix-npm-link-in-release-notification-comment-2aa6d73d365081729c54efabc76a833a)
by [Unito](https://www.unito.io)
2025-11-13 11:25:23 -07:00
Christian Byrne
5e4965d131 ci: add yamllint (#6682)
adds yaml linting to CI and applies rules to existing yaml files.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6682-ci-add-yamllint-2aa6d73d365081b4b67ae9d9cc86760f)
by [Unito](https://www.unito.io)
2025-11-13 11:10:48 -07:00
90 changed files with 2277 additions and 596 deletions

View File

@@ -36,9 +36,9 @@ body:
3. Click Queue Prompt
4. See error
value: |
1.
2.
3.
1.
2.
3.
validations:
required: true

View File

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

View File

@@ -105,4 +105,4 @@ jobs:
labels: Manager
delete-branch: true
add-paths: |
src/types/generatedManagerTypes.ts
src/types/generatedManagerTypes.ts

View File

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

View File

@@ -6,7 +6,7 @@ on:
paths:
- 'tools/devtools/**'
push:
branches: [ main ]
branches: [main]
paths:
- 'tools/devtools/**'

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -92,4 +92,3 @@ jobs:
base: ${{ github.event.inputs.branch }}
labels: |
Release

10
.yamllint Normal file
View File

@@ -0,0 +1,10 @@
extends: default
ignore: |
node_modules/
dist/
rules:
line-length: disable
document-start: disable
truthy: disable

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 82 KiB

14
scripts/cicd/check-yaml.sh Executable file
View 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[@]}"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: [] }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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