Compare commits
1 Commits
fix/codera
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed9a379c6 |
@@ -1,150 +0,0 @@
|
||||
---
|
||||
name: backport-management
|
||||
description: Manages cherry-pick backports across stable release branches. Discovers candidates from Slack/git, analyzes dependencies, resolves conflicts via worktree, and logs results. Use when asked to backport, cherry-pick to stable, manage release branches, do stable branch maintenance, or run a backport session.
|
||||
---
|
||||
|
||||
# Backport Management
|
||||
|
||||
Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release branches.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
|
||||
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
|
||||
3. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
4. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
|
||||
5. **Verify** — After each wave, verify branch integrity before proceeding
|
||||
6. **Log & Report** — Generate session report with mermaid diagram (`reference/logging.md`)
|
||||
|
||||
## System Context
|
||||
|
||||
| Item | Value |
|
||||
| -------------- | ------------------------------------------------- |
|
||||
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
|
||||
| Merge strategy | Squash merge (`gh pr merge --squash --admin`) |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
|
||||
## Branch Scope Rules
|
||||
|
||||
**Critical: Match PRs to the correct target branches.**
|
||||
|
||||
| Branch prefix | Scope | Example |
|
||||
| ------------- | ------------------------------ | ----------------------------------------- |
|
||||
| `cloud/*` | Cloud-hosted ComfyUI only | App mode, cloud auth, cloud-specific UI |
|
||||
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
|
||||
|
||||
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** Cloud-only changes (app mode, cloud auth, cloud billing UI, cloud-specific API calls) are irrelevant to local users and waste effort. Before backporting any PR to a `core/*` branch, check:
|
||||
|
||||
- Does the PR title/description mention "app mode", "cloud", or cloud-specific features?
|
||||
- Does the PR only touch files like `appModeStore.ts`, cloud auth, or cloud-specific components?
|
||||
- If yes → skip for `core/*` branches (may still apply to `cloud/*` branches)
|
||||
|
||||
## ⚠️ Gotchas (Learn from Past Sessions)
|
||||
|
||||
### Use `gh api` for Labels — NOT `gh pr edit`
|
||||
|
||||
`gh pr edit --add-label` triggers Projects Classic deprecation errors. Always use:
|
||||
|
||||
```bash
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH"
|
||||
```
|
||||
|
||||
### Automation Over-Reports Conflicts
|
||||
|
||||
The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-pick -m 1` with git auto-merge handles many cases the automation can't. Always attempt manual cherry-pick before skipping.
|
||||
|
||||
### Never Skip Based on Conflict File Count
|
||||
|
||||
12 or 27 conflicting files can be trivial (snapshots, new files). **Categorize conflicts first**, then decide. See Conflict Triage below.
|
||||
|
||||
## Conflict Triage
|
||||
|
||||
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
|
||||
|
||||
| Type | Symptom | Resolution |
|
||||
| ---------------------------- | ------------------------------------ | --------------------------------------------------------------- |
|
||||
| **Binary snapshots (PNGs)** | `.png` files in conflict list | `git checkout --theirs $FILE && git add $FILE` — always trivial |
|
||||
| **Modify/delete (new file)** | PR introduces files not on target | `git add $FILE` — keep the new file |
|
||||
| **Modify/delete (removed)** | Target removed files the PR modifies | `git rm $FILE` — file no longer relevant |
|
||||
| **Content conflicts** | Marker-based (`<<<<<<<`) | Accept theirs via python regex (see below) |
|
||||
| **Add/add** | Both sides added same file | Accept theirs, verify no logic conflict |
|
||||
| **Locale/JSON files** | i18n key additions | Accept theirs, validate JSON after |
|
||||
|
||||
```python
|
||||
# Accept theirs for content conflicts
|
||||
import re
|
||||
pattern = r'<<<<<<< HEAD\n(.*?)=======\n(.*?)>>>>>>> [^\n]+\n?'
|
||||
content = re.sub(pattern, r'\2', content, flags=re.DOTALL)
|
||||
```
|
||||
|
||||
### Escalation Triggers (Flag for Human)
|
||||
|
||||
- **Package.json/lockfile changes** → skip on stable (transitive dep regression risk)
|
||||
- **Core type definition changes** → requires human judgment
|
||||
- **Business logic conflicts** (not just imports/exports) → requires domain knowledge
|
||||
- **Admin-merged conflict resolutions** → get human review of the resolution before continuing the wave
|
||||
|
||||
## Auto-Skip Categories
|
||||
|
||||
Skip these without discussion:
|
||||
|
||||
- **Dep refresh PRs** — Risk of transitive dep regressions on stable. Cherry-pick individual CVE fixes instead.
|
||||
- **CI/tooling changes** — Not user-facing
|
||||
- **Test-only / lint rule changes** — Not user-facing
|
||||
- **Revert pairs** — If PR A reverted by PR B, skip both. If fixed version (PR C) exists, backport only C.
|
||||
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
|
||||
- **Cloud-only PRs on core/\* branches** — App mode, cloud auth, cloud billing. These only affect cloud-hosted ComfyUI.
|
||||
|
||||
## Wave Verification
|
||||
|
||||
After merging each wave of PRs to a target branch, verify branch integrity before proceeding:
|
||||
|
||||
```bash
|
||||
# Fetch latest state of target branch
|
||||
git fetch origin TARGET_BRANCH
|
||||
|
||||
# Quick smoke check: does the branch build?
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
|
||||
## Continuous Backporting Recommendation
|
||||
|
||||
Large backport sessions (50+ PRs) are expensive and error-prone. Prefer continuous backporting:
|
||||
|
||||
- Backport bug fixes as they merge to main (same day or next day)
|
||||
- Use the automation labels immediately after merge
|
||||
- Reserve session-style bulk backporting for catching up after gaps
|
||||
- When a release branch is created, immediately start the continuous process
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Label-Driven Automation (default path)
|
||||
|
||||
```bash
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH"
|
||||
# Wait 3 min, check: gh pr list --base TARGET_BRANCH --state open
|
||||
```
|
||||
|
||||
### Manual Worktree Cherry-Pick (conflict fallback)
|
||||
|
||||
```bash
|
||||
git worktree add /tmp/backport-$BRANCH origin/$BRANCH
|
||||
cd /tmp/backport-$BRANCH
|
||||
git checkout -b backport-$PR-to-$BRANCH origin/$BRANCH
|
||||
git cherry-pick -m 1 $MERGE_SHA
|
||||
# Resolve conflicts, push, create PR, merge
|
||||
```
|
||||
|
||||
### PR Title Convention
|
||||
|
||||
```
|
||||
[backport TARGET_BRANCH] Original Title (#ORIGINAL_PR)
|
||||
```
|
||||
@@ -1,68 +0,0 @@
|
||||
# Analysis & Decision Framework
|
||||
|
||||
## Categorization
|
||||
|
||||
| Category | Criteria | Action |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| **MUST** | User-facing bug, crash, data corruption, security. Clear breakage that users will hit. | Backport (with deps if needed) |
|
||||
| **SHOULD** | UX improvement, minor bug, small dep chain. No user-visible breakage if skipped, but improves experience. | Backport if clean cherry-pick; defer if conflict resolution is non-trivial |
|
||||
| **SKIP** | CI/tooling, test-only, lint rules, cosmetic, dep refresh | Skip with documented reason |
|
||||
| **NEEDS DISCUSSION** | Large dep chain, unclear risk/benefit, touches core types | Flag for human |
|
||||
|
||||
### MUST vs SHOULD Decision Guide
|
||||
|
||||
When unsure, ask: "If a user on this stable branch reports this issue, would we consider it a bug?"
|
||||
|
||||
- **Yes** → MUST. The fix addresses broken behavior.
|
||||
- **No, but it's noticeably better** → SHOULD. The fix is a quality-of-life improvement.
|
||||
- **No, and it's cosmetic or internal** → SKIP.
|
||||
|
||||
For SHOULD items with conflicts: if conflict resolution requires more than trivial accept-theirs patterns (content conflicts in business logic, not just imports), downgrade to SKIP or escalate to NEEDS DISCUSSION.
|
||||
|
||||
## Branch Scope Filtering
|
||||
|
||||
**Before categorizing, filter by branch scope:**
|
||||
|
||||
| Target branch | Skip if PR is... |
|
||||
| ------------- | ------------------------------------------------------------------- |
|
||||
| `core/*` | Cloud-only (app mode, cloud auth, cloud billing, cloud-specific UI) |
|
||||
| `cloud/*` | Local-only features not present on cloud branch |
|
||||
|
||||
Cloud-only PRs backported to `core/*` are wasted effort — `core/*` branches serve local/self-hosted users who never see cloud features. Check PR titles, descriptions, and files changed for cloud-specific indicators.
|
||||
|
||||
## Features Not on Stable Branches
|
||||
|
||||
Check before backporting — these don't exist on older branches:
|
||||
|
||||
- **Painter** (`src/extensions/core/painter.ts`) — not on core/1.40
|
||||
- **GLSLShader** — not on core/1.40
|
||||
- **App builder** — check per branch
|
||||
- **appModeStore.ts** — not on core/1.40
|
||||
|
||||
## Dep Refresh PRs
|
||||
|
||||
Always SKIP on stable branches. Risk of transitive dependency regressions outweighs audit cleanup benefit. If a specific CVE fix is needed, cherry-pick that individual fix instead.
|
||||
|
||||
## Revert Pairs
|
||||
|
||||
If PR A is reverted by PR B:
|
||||
|
||||
- Skip BOTH A and B
|
||||
- If a fixed version exists (PR C), backport only C
|
||||
|
||||
## Dependency Analysis
|
||||
|
||||
```bash
|
||||
# Find other PRs that touched the same files
|
||||
gh pr view $PR --json files --jq '.files[].path' | while read f; do
|
||||
git log --oneline origin/TARGET..$MERGE_SHA -- "$f"
|
||||
done
|
||||
```
|
||||
|
||||
## Human Review Checkpoint
|
||||
|
||||
Present decisions.md before execution. Include:
|
||||
|
||||
1. All MUST/SHOULD/SKIP categorizations with rationale
|
||||
2. Questions for human (feature existence, scope, deps)
|
||||
3. Estimated effort per branch
|
||||
@@ -1,42 +0,0 @@
|
||||
# Discovery — Candidate Collection
|
||||
|
||||
## Source 1: Slack Backport-Checker Bot
|
||||
|
||||
Use `slackdump` skill to export `#frontend-releases` channel (C09K9TPU2G7):
|
||||
|
||||
```bash
|
||||
slackdump export -o ~/slack-exports/frontend-releases.zip C09K9TPU2G7
|
||||
```
|
||||
|
||||
Parse bot messages for PRs flagged "Might need backport" per release version.
|
||||
|
||||
## Source 2: Git Log Gap Analysis
|
||||
|
||||
```bash
|
||||
# Count gap
|
||||
git log --oneline origin/TARGET..origin/main | wc -l
|
||||
|
||||
# List gap commits
|
||||
git log --oneline origin/TARGET..origin/main
|
||||
|
||||
# Check if a PR is already on target
|
||||
git log --oneline origin/TARGET --grep="#PR_NUMBER"
|
||||
|
||||
# Check for existing backport PRs
|
||||
gh pr list --base TARGET --state all --search "backport PR_NUMBER"
|
||||
```
|
||||
|
||||
## Source 3: GitHub PR Details
|
||||
|
||||
```bash
|
||||
# Get merge commit SHA
|
||||
gh pr view $PR --json mergeCommit,title --jq '"Title: \(.title)\nMerge: \(.mergeCommit.oid)"'
|
||||
|
||||
# Get files changed
|
||||
gh pr view $PR --json files --jq '.files[].path'
|
||||
```
|
||||
|
||||
## Output: candidate_list.md
|
||||
|
||||
Table per target branch:
|
||||
| PR# | Title | Category | Flagged by Bot? | Decision |
|
||||
@@ -1,150 +0,0 @@
|
||||
# Execution Workflow
|
||||
|
||||
## Per-Branch Execution Order
|
||||
|
||||
1. Smallest gap first (validation run)
|
||||
2. Medium gap next (quick win)
|
||||
3. Largest gap last (main effort)
|
||||
|
||||
## Step 1: Label-Driven Automation (Batch)
|
||||
|
||||
```bash
|
||||
# Add labels to all candidates for a target branch
|
||||
for pr in $PR_LIST; do
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$pr/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH" --silent
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Wait 3 minutes for automation
|
||||
sleep 180
|
||||
|
||||
# Check which got auto-PRs
|
||||
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
|
||||
```
|
||||
|
||||
## Step 2: Review & Merge Clean Auto-PRs
|
||||
|
||||
```bash
|
||||
for pr in $AUTO_PRS; do
|
||||
# Check size
|
||||
gh pr view $pr --json title,additions,deletions,changedFiles \
|
||||
--jq '"Files: \(.changedFiles), +\(.additions)/-\(.deletions)"'
|
||||
# Admin merge
|
||||
gh pr merge $pr --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
```
|
||||
|
||||
## Step 3: Manual Worktree for Conflicts
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/backport-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/backport-TARGET
|
||||
|
||||
for PR in ${CONFLICT_PRS[@]}; do
|
||||
# Refresh target ref so each branch is based on current HEAD
|
||||
git fetch origin TARGET_BRANCH
|
||||
git checkout origin/TARGET_BRANCH
|
||||
|
||||
git checkout -b backport-$PR-to-TARGET origin/TARGET_BRANCH
|
||||
git cherry-pick -m 1 $MERGE_SHA
|
||||
|
||||
# If conflict — NEVER skip based on file count alone!
|
||||
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add
|
||||
# See SKILL.md Conflict Triage table for resolution per type.
|
||||
|
||||
# Resolve all conflicts, then:
|
||||
git add .
|
||||
GIT_EDITOR=true git cherry-pick --continue
|
||||
|
||||
git push origin backport-$PR-to-TARGET
|
||||
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
|
||||
--title "[backport TARGET] TITLE (#$PR)" \
|
||||
--body "Backport of #$PR..." | grep -oP '\d+$')
|
||||
gh pr merge $NEW_PR --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# Cleanup
|
||||
cd -
|
||||
git worktree remove /tmp/backport-TARGET --force
|
||||
```
|
||||
|
||||
**⚠️ Human review for conflict resolutions:** When admin-merging a PR where you manually resolved conflicts (especially content conflicts beyond trivial accept-theirs), pause and present the resolution diff to the human for review before merging. Trivial resolutions (binary snapshots, modify/delete, locale key additions) can proceed without review.
|
||||
|
||||
## Step 4: Wave Verification
|
||||
|
||||
After completing all PRs in a wave for a target branch:
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If verification fails, stop and fix before proceeding to the next wave. Do not compound problems across waves.
|
||||
|
||||
## Conflict Resolution Patterns
|
||||
|
||||
### 1. Content Conflicts (accept theirs)
|
||||
|
||||
```python
|
||||
import re
|
||||
pattern = r'<<<<<<< HEAD\n(.*?)=======\n(.*?)>>>>>>> [^\n]+\n?'
|
||||
content = re.sub(pattern, r'\2', content, flags=re.DOTALL)
|
||||
```
|
||||
|
||||
### 2. Modify/Delete (two cases!)
|
||||
|
||||
```bash
|
||||
# Case A: PR introduces NEW files not on target → keep them
|
||||
git add $FILE
|
||||
|
||||
# Case B: Target REMOVED files the PR modifies → drop them
|
||||
git rm $FILE
|
||||
```
|
||||
|
||||
### 3. Binary Files (snapshots)
|
||||
|
||||
```bash
|
||||
git checkout --theirs $FILE && git add $FILE
|
||||
```
|
||||
|
||||
### 4. Locale Files
|
||||
|
||||
Usually adding new i18n keys — accept theirs, validate JSON:
|
||||
|
||||
```bash
|
||||
python3 -c "import json; json.load(open('src/locales/en/main.json'))" && echo "Valid"
|
||||
```
|
||||
|
||||
## Merge Conflicts After Other Merges
|
||||
|
||||
When merging multiple PRs to the same branch, later PRs may conflict with earlier merges:
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git rebase origin/TARGET_BRANCH
|
||||
# Resolve new conflicts
|
||||
git push --force origin backport-$PR-to-TARGET
|
||||
sleep 20 # Wait for GitHub to recompute merge state
|
||||
gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. **Automation reports more conflicts than reality** — `cherry-pick -m 1` with git auto-merge handles many "conflicts" the automation can't
|
||||
2. **Never skip based on conflict file count** — 12 or 27 conflicts can be trivial (snapshots, new files). Categorize first: binary PNGs, modify/delete, content, add/add.
|
||||
3. **Modify/delete goes BOTH ways** — if the PR introduces new files (not on target), `git add` them. If target deleted files the PR modifies, `git rm`.
|
||||
4. **Binary snapshot PNGs** — always `git checkout --theirs && git add`. Never skip a PR just because it has many snapshot conflicts.
|
||||
5. **Batch label additions need 2s delay** between API calls to avoid rate limits
|
||||
6. **Merging 6+ PRs rapidly** can cause later PRs to become unmergeable — wait 20-30s for GitHub to recompute merge state
|
||||
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
|
||||
8. **Always validate JSON** after resolving locale file conflicts
|
||||
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
|
||||
10. **Verify after each wave** — run `pnpm typecheck` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.
|
||||
@@ -1,96 +0,0 @@
|
||||
# Logging & Session Reports
|
||||
|
||||
## During Execution
|
||||
|
||||
Maintain `execution-log.md` with per-branch tables:
|
||||
|
||||
```markdown
|
||||
| PR# | Title | Status | Backport PR | Notes |
|
||||
| ----- | ----- | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
```
|
||||
|
||||
## Wave Verification Log
|
||||
|
||||
Track verification results per wave:
|
||||
|
||||
```markdown
|
||||
## Wave N Verification — TARGET_BRANCH
|
||||
|
||||
- PRs merged: #A, #B, #C
|
||||
- Typecheck: ✅ Pass / ❌ Fail
|
||||
- Issues found: (if any)
|
||||
- Human review needed: (list any non-trivial conflict resolutions)
|
||||
```
|
||||
|
||||
## Session Report Template
|
||||
|
||||
```markdown
|
||||
# Backport Session Report
|
||||
|
||||
## Summary
|
||||
|
||||
| Branch | Candidates | Merged | Skipped | Deferred | Rate |
|
||||
| ------ | ---------- | ------ | ------- | -------- | ---- |
|
||||
|
||||
## Deferred Items (Needs Human)
|
||||
|
||||
| PR# | Title | Branch | Issue |
|
||||
|
||||
## Conflict Resolutions Requiring Review
|
||||
|
||||
| PR# | Branch | Conflict Type | Resolution Summary |
|
||||
|
||||
## Automation Performance
|
||||
|
||||
| Metric | Value |
|
||||
| --------------------------- | ----- |
|
||||
| Auto success rate | X% |
|
||||
| Manual resolution rate | X% |
|
||||
| Overall clean rate | X% |
|
||||
| Wave verification pass rate | X% |
|
||||
|
||||
## Process Recommendations
|
||||
|
||||
- Were there clusters of related PRs that should have been backported together?
|
||||
- Any PRs that should have been backported sooner (continuous backporting candidates)?
|
||||
- Feature branches that need tracking for future sessions?
|
||||
```
|
||||
|
||||
## Final Deliverable: Visual Summary
|
||||
|
||||
At session end, generate a **mermaid diagram** showing all backported PRs organized by target branch and category (MUST/SHOULD), plus a summary table. Present this to the user as the final output.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph branch1["☁️ cloud/X.XX — N PRs"]
|
||||
C1["#XXXX title"]
|
||||
C2["#XXXX title"]
|
||||
end
|
||||
|
||||
subgraph branch2must["🔴 core/X.XX MUST — N PRs"]
|
||||
M1["#XXXX title"]
|
||||
end
|
||||
|
||||
subgraph branch2should["🟡 core/X.XX SHOULD — N PRs"]
|
||||
S1["#XXXX-#XXXX N auto-merged"]
|
||||
S2["#XXXX-#XXXX N manual picks"]
|
||||
end
|
||||
|
||||
classDef cloudStyle fill:#1a3a5c,stroke:#4da6ff,color:#e0f0ff
|
||||
classDef coreStyle fill:#1a4a2e,stroke:#4dff88,color:#e0ffe8
|
||||
classDef mustStyle fill:#5c1a1a,stroke:#ff4d4d,color:#ffe0e0
|
||||
classDef shouldStyle fill:#4a3a1a,stroke:#ffcc4d,color:#fff5e0
|
||||
```
|
||||
|
||||
Use the `mermaid` tool to render this diagram and present it alongside the summary table as the session's final deliverable.
|
||||
|
||||
## Files to Track
|
||||
|
||||
- `candidate_list.md` — all candidates per branch
|
||||
- `decisions.md` — MUST/SHOULD/SKIP with rationale
|
||||
- `wave-plan.md` — execution order
|
||||
- `execution-log.md` — real-time status
|
||||
- `backport-session-report.md` — final summary
|
||||
|
||||
All in `~/temp/backport-session/`.
|
||||
25
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -4,8 +4,6 @@ name: 'CI: Tests Storybook'
|
||||
on:
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
# Post starting comment for non-forked PRs
|
||||
@@ -140,29 +138,6 @@ jobs:
|
||||
"${{ github.head_ref }}" \
|
||||
"completed"
|
||||
|
||||
# Deploy Storybook to production URL on main branch push
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build Storybook
|
||||
run: pnpm build-storybook
|
||||
|
||||
- name: Deploy to Cloudflare Pages (production)
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
npx wrangler@^4.0.0 pages deploy storybook-static \
|
||||
--project-name=comfy-storybook \
|
||||
--branch=main
|
||||
|
||||
# Update comment with Chromatic URLs for version-bump branches
|
||||
update-comment-with-chromatic:
|
||||
needs: [chromatic-deployment, deploy-and-comment]
|
||||
|
||||
1
.gitignore
vendored
@@ -26,7 +26,6 @@ dist-ssr
|
||||
.claude/*.local.json
|
||||
.claude/*.local.md
|
||||
.claude/*.local.txt
|
||||
.claude/worktrees
|
||||
CLAUDE.local.md
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
@@ -1,91 +0,0 @@
|
||||
# Change Tracker (Undo/Redo System)
|
||||
|
||||
The `ChangeTracker` class (`src/scripts/changeTracker.ts`) manages undo/redo
|
||||
history by comparing serialized graph snapshots.
|
||||
|
||||
## How It Works
|
||||
|
||||
`checkState()` is the core method. It:
|
||||
|
||||
1. Serializes the current graph via `app.rootGraph.serialize()`
|
||||
2. Deep-compares the result against the last known `activeState`
|
||||
3. If different, pushes `activeState` onto `undoQueue` and replaces it
|
||||
|
||||
**It is not reactive.** Changes to the graph (widget values, node positions,
|
||||
links, etc.) are only captured when `checkState()` is explicitly triggered.
|
||||
|
||||
## Automatic Triggers
|
||||
|
||||
These are set up once in `ChangeTracker.init()`:
|
||||
|
||||
| Trigger | Event / Hook | What It Catches |
|
||||
| ----------------------------------- | -------------------------------------------------- | --------------------------------------------------- |
|
||||
| Keyboard (non-modifier, non-repeat) | `window` `keydown` | Shortcuts, typing in canvas |
|
||||
| Modifier key release | `window` `keyup` | Releasing Ctrl/Shift/Alt/Meta |
|
||||
| Mouse click | `window` `mouseup` | General clicks on native DOM |
|
||||
| Canvas mouse up | `LGraphCanvas.processMouseUp` override | LiteGraph canvas interactions |
|
||||
| Number/string dialog | `LGraphCanvas.prompt` override | Dialog popups for editing widgets |
|
||||
| Context menu close | `LiteGraph.ContextMenu.close` override | COMBO widget menus in LiteGraph |
|
||||
| Active input element | `bindInput` (change/input/blur on focused element) | Native HTML input edits |
|
||||
| Prompt queued | `api` `promptQueued` event | Dynamic widget changes on queue |
|
||||
| Graph cleared | `api` `graphCleared` event | Full graph clear |
|
||||
| Transaction end | `litegraph:canvas` `after-change` event | Batched operations via `beforeChange`/`afterChange` |
|
||||
|
||||
## When You Must Call `checkState()` Manually
|
||||
|
||||
The automatic triggers above are designed around LiteGraph's native DOM
|
||||
rendering. They **do not cover**:
|
||||
|
||||
- **Vue-rendered widgets** — Vue handles events internally without triggering
|
||||
native DOM events that the tracker listens to (e.g., `mouseup` on a Vue
|
||||
dropdown doesn't bubble the same way as a native LiteGraph widget click)
|
||||
- **Programmatic graph mutations** — Any code that modifies the graph outside
|
||||
of user interaction (e.g., applying a template, pasting nodes, aligning)
|
||||
- **Async operations** — File uploads, API calls that change widget values
|
||||
after the initial user gesture
|
||||
|
||||
### Pattern for Manual Calls
|
||||
|
||||
```typescript
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
// After mutating the graph:
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
```
|
||||
|
||||
### Existing Manual Call Sites
|
||||
|
||||
These locations already call `checkState()` explicitly:
|
||||
|
||||
- `WidgetSelectDropdown.vue` — After dropdown selection and file upload
|
||||
- `ColorPickerButton.vue` — After changing node colors
|
||||
- `NodeSearchBoxPopover.vue` — After adding a node from search
|
||||
- `useAppSetDefaultView.ts` — After setting default view
|
||||
- `useSelectionOperations.ts` — After align, copy, paste, duplicate, group
|
||||
- `useSelectedNodeActions.ts` — After pin, bypass, collapse
|
||||
- `useGroupMenuOptions.ts` — After group operations
|
||||
- `useSubgraphOperations.ts` — After subgraph enter/exit
|
||||
- `useCanvasRefresh.ts` — After canvas refresh
|
||||
- `useCoreCommands.ts` — After metadata/subgraph commands
|
||||
- `workflowService.ts` — After workflow service operations
|
||||
|
||||
## Transaction Guards
|
||||
|
||||
For operations that make multiple changes that should be a single undo entry:
|
||||
|
||||
```typescript
|
||||
changeTracker.beforeChange()
|
||||
// ... multiple graph mutations ...
|
||||
changeTracker.afterChange() // calls checkState() when nesting count hits 0
|
||||
```
|
||||
|
||||
The `litegraph:canvas` custom event also supports this with `before-change` /
|
||||
`after-change` sub-types.
|
||||
|
||||
## Key Invariants
|
||||
|
||||
- `checkState()` is a no-op during `loadGraphData` (guarded by
|
||||
`isLoadingGraph`) to prevent cross-workflow corruption
|
||||
- `checkState()` is a no-op when `changeCount > 0` (inside a transaction)
|
||||
- `undoQueue` is capped at 50 entries (`MAX_HISTORY`)
|
||||
- `graphEqual` ignores node order and `ds` (pan/zoom) when comparing
|
||||
1
global.d.ts
vendored
@@ -35,7 +35,6 @@ interface Window {
|
||||
mixpanel_token?: string
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_debug?: boolean
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -43,9 +43,6 @@ export function useAppSetDefaultView() {
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
closeDialog()
|
||||
showAppliedDialog(openAsApp)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
<!-- Node -->
|
||||
<div
|
||||
v-if="item.value.type === 'node'"
|
||||
v-bind="$attrs"
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
draggable="true"
|
||||
@@ -49,7 +48,6 @@
|
||||
<!-- Folder -->
|
||||
<div
|
||||
v-else
|
||||
v-bind="$attrs"
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
@@ -100,10 +98,6 @@ import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const ROW_CLASS =
|
||||
'group/tree-node flex cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CanvasModeSelector from '@/components/graph/CanvasModeSelector.vue'
|
||||
|
||||
const mockExecute = vi.fn()
|
||||
const mockGetCommand = vi.fn().mockReturnValue({
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['V']
|
||||
}
|
||||
}
|
||||
})
|
||||
const mockFormatKeySequence = vi.fn().mockReturnValue('V')
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: mockExecute,
|
||||
getCommand: mockGetCommand,
|
||||
formatKeySequence: mockFormatKeySequence
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: { read_only: false }
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
graphCanvasMenu: {
|
||||
select: 'Select',
|
||||
hand: 'Hand',
|
||||
canvasMode: 'Canvas Mode'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mockPopoverHide = vi.fn()
|
||||
|
||||
function createWrapper() {
|
||||
return mount(CanvasModeSelector, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Popover: {
|
||||
template: '<div><slot /></div>',
|
||||
methods: {
|
||||
toggle: vi.fn(),
|
||||
hide: mockPopoverHide
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('CanvasModeSelector', () => {
|
||||
it('should render menu with menuitemradio roles and aria-checked', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menu = wrapper.find('[role="menu"]')
|
||||
expect(menu.exists()).toBe(true)
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
expect(menuItems).toHaveLength(2)
|
||||
|
||||
// Select mode is active (read_only: false), so select is checked
|
||||
expect(menuItems[0].attributes('aria-checked')).toBe('true')
|
||||
expect(menuItems[1].attributes('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('should render menu items as buttons with aria-labels', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
menuItems.forEach((btn) => {
|
||||
expect(btn.element.tagName).toBe('BUTTON')
|
||||
expect(btn.attributes('type')).toBe('button')
|
||||
})
|
||||
expect(menuItems[0].attributes('aria-label')).toBe('Select')
|
||||
expect(menuItems[1].attributes('aria-label')).toBe('Hand')
|
||||
})
|
||||
|
||||
it('should use roving tabindex based on active mode', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
// Select is active (read_only: false) → tabindex 0
|
||||
expect(menuItems[0].attributes('tabindex')).toBe('0')
|
||||
// Hand is inactive → tabindex -1
|
||||
expect(menuItems[1].attributes('tabindex')).toBe('-1')
|
||||
})
|
||||
|
||||
it('should mark icons as aria-hidden', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const icons = wrapper.findAll('[role="menuitemradio"] i')
|
||||
icons.forEach((icon) => {
|
||||
expect(icon.attributes('aria-hidden')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose trigger button with aria-haspopup and aria-expanded', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const trigger = wrapper.find('[aria-haspopup="menu"]')
|
||||
expect(trigger.exists()).toBe(true)
|
||||
expect(trigger.attributes('aria-label')).toBe('Canvas Mode')
|
||||
expect(trigger.attributes('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('should call focus on next item when ArrowDown is pressed', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
const secondItemEl = menuItems[1].element as HTMLElement
|
||||
const focusSpy = vi.spyOn(secondItemEl, 'focus')
|
||||
|
||||
await menuItems[0].trigger('keydown', { key: 'ArrowDown' })
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call focus on previous item when ArrowUp is pressed', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
const firstItemEl = menuItems[0].element as HTMLElement
|
||||
const focusSpy = vi.spyOn(firstItemEl, 'focus')
|
||||
|
||||
await menuItems[1].trigger('keydown', { key: 'ArrowUp' })
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close popover on Escape and restore focus to trigger', async () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
const trigger = wrapper.find('[aria-haspopup="menu"]')
|
||||
const triggerEl = trigger.element as HTMLElement
|
||||
const focusSpy = vi.spyOn(triggerEl, 'focus')
|
||||
|
||||
await menuItems[0].trigger('keydown', { key: 'Escape' })
|
||||
expect(mockPopoverHide).toHaveBeenCalled()
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -4,21 +4,15 @@
|
||||
variant="secondary"
|
||||
class="group h-8 rounded-none! bg-comfy-menu-bg p-0 transition-none! hover:rounded-lg! hover:bg-interface-button-hover-surface!"
|
||||
:style="buttonStyles"
|
||||
:aria-label="$t('graphCanvasMenu.canvasMode')"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="isOpen"
|
||||
@click="toggle"
|
||||
>
|
||||
<div class="flex items-center gap-1 pr-0.5">
|
||||
<div
|
||||
class="rounded-lg bg-interface-panel-selected-surface p-2 group-hover:bg-interface-button-hover-surface"
|
||||
>
|
||||
<i :class="currentModeIcon" class="block size-4" aria-hidden="true" />
|
||||
<i :class="currentModeIcon" class="block size-4" />
|
||||
</div>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] block size-4 pr-1.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down] block size-4 pr-1.5" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -30,54 +24,31 @@
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="popoverPt"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
>
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="flex flex-col gap-1"
|
||||
role="menu"
|
||||
:aria-label="$t('graphCanvasMenu.canvasMode')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
:aria-checked="!isCanvasReadOnly"
|
||||
:tabindex="!isCanvasReadOnly ? 0 : -1"
|
||||
class="flex w-full cursor-pointer items-center justify-between rounded-sm border-none bg-transparent px-3 py-2 text-sm text-text-primary outline-none hover:bg-node-component-surface-hovered focus-visible:bg-node-component-surface-hovered"
|
||||
:aria-label="$t('graphCanvasMenu.select')"
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
@click="setMode('select')"
|
||||
@keydown.arrow-down.prevent="focusNextItem"
|
||||
@keydown.arrow-up.prevent="focusPrevItem"
|
||||
@keydown.escape.prevent="closeAndRestoreFocus"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--mouse-pointer-2] size-4" aria-hidden="true" />
|
||||
<i class="icon-[lucide--mouse-pointer-2] size-4" />
|
||||
<span>{{ $t('graphCanvasMenu.select') }}</span>
|
||||
</div>
|
||||
<span class="text-[9px] text-text-primary">{{
|
||||
unlockCommandText
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
:aria-checked="isCanvasReadOnly"
|
||||
:tabindex="isCanvasReadOnly ? 0 : -1"
|
||||
class="flex w-full cursor-pointer items-center justify-between rounded-sm border-none bg-transparent px-3 py-2 text-sm text-text-primary outline-none hover:bg-node-component-surface-hovered focus-visible:bg-node-component-surface-hovered"
|
||||
:aria-label="$t('graphCanvasMenu.hand')"
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded-sm px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
|
||||
@click="setMode('hand')"
|
||||
@keydown.arrow-down.prevent="focusNextItem"
|
||||
@keydown.arrow-up.prevent="focusPrevItem"
|
||||
@keydown.escape.prevent="closeAndRestoreFocus"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--hand] size-4" aria-hidden="true" />
|
||||
<i class="icon-[lucide--hand] size-4" />
|
||||
<span>{{ $t('graphCanvasMenu.hand') }}</span>
|
||||
</div>
|
||||
<span class="text-[9px] text-text-primary">{{ lockCommandText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -85,7 +56,7 @@
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -98,8 +69,6 @@ interface Props {
|
||||
defineProps<Props>()
|
||||
const buttonRef = ref<ComponentPublicInstance | null>(null)
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -137,43 +106,6 @@ const setMode = (mode: 'select' | 'hand') => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
async function onPopoverShow() {
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
const checkedItem = menuRef.value?.querySelector<HTMLElement>(
|
||||
'[aria-checked="true"]'
|
||||
)
|
||||
checkedItem?.focus()
|
||||
}
|
||||
|
||||
function onPopoverHide() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function closeAndRestoreFocus() {
|
||||
popover.value?.hide()
|
||||
const el = buttonRef.value?.$el || buttonRef.value
|
||||
;(el as HTMLElement)?.focus()
|
||||
}
|
||||
|
||||
function focusNextItem(event: KeyboardEvent) {
|
||||
const items = getMenuItems(event)
|
||||
const index = items.indexOf(event.target as HTMLElement)
|
||||
items[(index + 1) % items.length]?.focus()
|
||||
}
|
||||
|
||||
function focusPrevItem(event: KeyboardEvent) {
|
||||
const items = getMenuItems(event)
|
||||
const index = items.indexOf(event.target as HTMLElement)
|
||||
items[(index - 1 + items.length) % items.length]?.focus()
|
||||
}
|
||||
|
||||
function getMenuItems(event: KeyboardEvent): HTMLElement[] {
|
||||
const menu = (event.target as HTMLElement).closest('[role="menu"]')
|
||||
if (!menu) return []
|
||||
return Array.from(menu.querySelectorAll('[role="menuitemradio"]'))
|
||||
}
|
||||
|
||||
const popoverPt = computed(() => ({
|
||||
root: {
|
||||
class: 'absolute z-50 -translate-y-2'
|
||||
|
||||
@@ -538,7 +538,7 @@ onMounted(async () => {
|
||||
|
||||
// Restore saved workflow and workflow tabs state
|
||||
await workflowPersistence.initializeWorkflow()
|
||||
await workflowPersistence.restoreWorkflowTabsState()
|
||||
workflowPersistence.restoreWorkflowTabsState()
|
||||
|
||||
const sharedWorkflowLoadStatus =
|
||||
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
></div>
|
||||
|
||||
<ButtonGroup
|
||||
role="toolbar"
|
||||
:aria-label="t('graphCanvasMenu.canvasToolbar')"
|
||||
class="absolute right-0 bottom-0 z-1200 flex-row gap-1 border border-interface-stroke bg-comfy-menu-bg p-2"
|
||||
:style="{
|
||||
...stringifiedMinimapStyles.buttonGroupStyles
|
||||
@@ -32,7 +30,7 @@
|
||||
class="size-8 bg-comfy-menu-bg p-0 hover:bg-interface-button-hover-surface!"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
|
||||
>
|
||||
<i class="icon-[lucide--focus] size-4" aria-hidden="true" />
|
||||
<i class="icon-[lucide--focus] size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -46,7 +44,7 @@
|
||||
>
|
||||
<span class="inline-flex items-center gap-1 px-2 text-xs">
|
||||
<span>{{ canvasStore.appScalePercentage }}%</span>
|
||||
<i class="icon-[lucide--chevron-down] size-4" aria-hidden="true" />
|
||||
<i class="icon-[lucide--chevron-down] size-4" />
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -61,7 +59,7 @@
|
||||
:class="minimapButtonClass"
|
||||
@click="onMinimapToggleClick"
|
||||
>
|
||||
<i class="icon-[lucide--map] size-4" aria-hidden="true" />
|
||||
<i class="icon-[lucide--map] size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -80,7 +78,7 @@
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
@click="onLinkVisibilityToggleClick"
|
||||
>
|
||||
<i class="icon-[lucide--route-off] size-4" aria-hidden="true" />
|
||||
<i class="icon-[lucide--route-off] size-4" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
@@ -39,12 +39,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useElementBounding, useEventListener, useRafFn } from '@vueuse/core'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
|
||||
import { useCanvasMenuPositionSync } from '@/composables/graph/useCanvasMenuPositionSync'
|
||||
import {
|
||||
registerNodeOptionsInstance,
|
||||
useMoreOptionsMenu
|
||||
@@ -53,6 +52,7 @@ import type {
|
||||
MenuOption,
|
||||
SubMenuOption
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
|
||||
|
||||
@@ -67,11 +67,67 @@ const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
|
||||
const isOpen = ref(false)
|
||||
|
||||
const { menuOptions, bump } = useMoreOptionsMenu()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { setWorldPositionFromEvent } = useCanvasMenuPositionSync(
|
||||
contextMenu,
|
||||
isOpen
|
||||
)
|
||||
// World position (canvas coordinates) where menu was opened
|
||||
const worldPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
// Get canvas bounding rect reactively
|
||||
const lgCanvas = canvasStore.getCanvas()
|
||||
const { left: canvasLeft, top: canvasTop } = useElementBounding(lgCanvas.canvas)
|
||||
|
||||
// Track last canvas transform to detect actual changes
|
||||
let lastScale = 0
|
||||
let lastOffsetX = 0
|
||||
let lastOffsetY = 0
|
||||
|
||||
// Update menu position based on canvas transform
|
||||
const updateMenuPosition = () => {
|
||||
if (!isOpen.value) return
|
||||
|
||||
const menuInstance = contextMenu.value as unknown as {
|
||||
container?: HTMLElement
|
||||
}
|
||||
const menuEl = menuInstance?.container
|
||||
if (!menuEl) return
|
||||
|
||||
const { scale, offset } = lgCanvas.ds
|
||||
|
||||
// Only update if canvas transform actually changed
|
||||
if (
|
||||
scale === lastScale &&
|
||||
offset[0] === lastOffsetX &&
|
||||
offset[1] === lastOffsetY
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
lastScale = scale
|
||||
lastOffsetX = offset[0]
|
||||
lastOffsetY = offset[1]
|
||||
|
||||
// Convert world position to screen position
|
||||
const screenX = (worldPosition.value.x + offset[0]) * scale + canvasLeft.value
|
||||
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasTop.value
|
||||
|
||||
// Update menu position
|
||||
menuEl.style.left = `${screenX}px`
|
||||
menuEl.style.top = `${screenY}px`
|
||||
}
|
||||
|
||||
// Sync with canvas transform using requestAnimationFrame
|
||||
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
|
||||
immediate: false
|
||||
})
|
||||
|
||||
// Start/stop syncing based on menu visibility
|
||||
watchEffect(() => {
|
||||
if (isOpen.value) {
|
||||
startSync()
|
||||
} else {
|
||||
stopSync()
|
||||
}
|
||||
})
|
||||
|
||||
// Close on touch outside to handle mobile devices where click might be swallowed
|
||||
useEventListener(
|
||||
@@ -151,7 +207,25 @@ const menuItems = computed<ExtendedMenuItem[]>(() =>
|
||||
// Show context menu
|
||||
function show(event: MouseEvent) {
|
||||
bump()
|
||||
setWorldPositionFromEvent(event)
|
||||
|
||||
// Convert screen position to world coordinates
|
||||
// Screen position relative to canvas = event position - canvas offset
|
||||
const screenX = event.clientX - canvasLeft.value
|
||||
const screenY = event.clientY - canvasTop.value
|
||||
|
||||
// Convert to world coordinates using canvas transform
|
||||
const { scale, offset } = lgCanvas.ds
|
||||
worldPosition.value = {
|
||||
x: screenX / scale - offset[0],
|
||||
y: screenY / scale - offset[1]
|
||||
}
|
||||
|
||||
// Initialize last* values to current transform to prevent updateMenuPosition
|
||||
// from overwriting PrimeVue's flip-adjusted position on the first RAF tick
|
||||
lastScale = scale
|
||||
lastOffsetX = offset[0]
|
||||
lastOffsetY = offset[1]
|
||||
|
||||
isOpen.value = true
|
||||
contextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -37,7 +36,6 @@ import TabErrors from './errors/TabErrors.vue'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
@@ -45,8 +43,6 @@ const { t } = useI18n()
|
||||
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
|
||||
storeToRefs(executionErrorStore)
|
||||
|
||||
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
|
||||
@@ -122,21 +118,12 @@ const hasMissingNodeSelected = computed(
|
||||
)
|
||||
)
|
||||
|
||||
const hasMissingModelSelected = computed(
|
||||
() =>
|
||||
hasSelection.value &&
|
||||
selectedNodes.value.some((node) =>
|
||||
activeMissingModelGraphIds.value.has(String(node.id))
|
||||
)
|
||||
)
|
||||
|
||||
const hasRelevantErrors = computed(() => {
|
||||
if (!hasSelection.value) return hasAnyError.value
|
||||
return (
|
||||
hasDirectNodeError.value ||
|
||||
hasContainerInternalError.value ||
|
||||
hasMissingNodeSelected.value ||
|
||||
hasMissingModelSelected.value
|
||||
hasMissingNodeSelected.value
|
||||
)
|
||||
})
|
||||
|
||||
@@ -327,11 +314,7 @@ function handleTitleCancel() {
|
||||
:value="tab.value"
|
||||
>
|
||||
{{ tab.label() }}
|
||||
<i
|
||||
v-if="tab.icon"
|
||||
aria-hidden="true"
|
||||
:class="cn(tab.icon, 'size-4')"
|
||||
/>
|
||||
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
|
||||
</Tab>
|
||||
</TabList>
|
||||
</nav>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<div class="min-w-0 flex-1 overflow-y-auto">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
@@ -32,7 +32,11 @@
|
||||
:key="group.title"
|
||||
:collapse="isSectionCollapsed(group.title) && !isSearching"
|
||||
class="border-b border-interface-stroke"
|
||||
:size="getGroupSize(group)"
|
||||
:size="
|
||||
group.type === 'missing_node' || group.type === 'swap_nodes'
|
||||
? 'lg'
|
||||
: 'default'
|
||||
"
|
||||
@update:collapse="setSectionCollapsed(group.title, $event)"
|
||||
>
|
||||
<template #label>
|
||||
@@ -126,14 +130,6 @@
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-else-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-model="handleLocateModel"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
@@ -191,14 +187,12 @@ import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/f
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
||||
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -217,15 +211,6 @@ const { replaceGroup, replaceAllGroups } = useNodeReplacement()
|
||||
const searchQuery = ref('')
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
const fullSizeGroupTypes = new Set([
|
||||
'missing_node',
|
||||
'swap_nodes',
|
||||
'missing_model'
|
||||
])
|
||||
function getGroupSize(group: ErrorGroup) {
|
||||
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
||||
}
|
||||
|
||||
const showNodeIdBadge = computed(
|
||||
() =>
|
||||
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
|
||||
@@ -241,7 +226,6 @@ const {
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
@@ -299,10 +283,6 @@ function handleLocateMissingNode(nodeId: string) {
|
||||
focusNode(nodeId, missingNodeCache.value)
|
||||
}
|
||||
|
||||
function handleLocateModel(nodeId: string) {
|
||||
focusNode(nodeId)
|
||||
}
|
||||
|
||||
function handleOpenManagerInfo(packId: string) {
|
||||
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
|
||||
if (isKnownToRegistry) {
|
||||
|
||||
@@ -23,4 +23,3 @@ export type ErrorGroup =
|
||||
}
|
||||
| { type: 'missing_node'; title: string; priority: number }
|
||||
| { type: 'swap_nodes'; title: string; priority: number }
|
||||
| { type: 'missing_model'; title: string; priority: number }
|
||||
|
||||
@@ -47,13 +47,6 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
|
||||
isGroupNode: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/missingModel/composables/useMissingModelInteractions',
|
||||
() => ({
|
||||
clearMissingModelState: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
@@ -527,115 +520,4 @@ describe('useErrorGroups', () => {
|
||||
expect(typeof groups.collapseState).toBe('object')
|
||||
})
|
||||
})
|
||||
|
||||
describe('missingModelGroups', () => {
|
||||
function makeModel(
|
||||
name: string,
|
||||
opts: {
|
||||
nodeId?: string | number
|
||||
widgetName?: string
|
||||
directory?: string
|
||||
isAssetSupported?: boolean
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
name,
|
||||
nodeId: opts.nodeId ?? '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: opts.widgetName ?? 'ckpt_name',
|
||||
isAssetSupported: opts.isAssetSupported ?? false,
|
||||
isMissing: true as const,
|
||||
directory: opts.directory
|
||||
}
|
||||
}
|
||||
|
||||
it('returns empty array when no missing models', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.missingModelGroups.value).toEqual([])
|
||||
})
|
||||
|
||||
it('groups asset-supported models by directory', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingModels([
|
||||
makeModel('model_a.safetensors', {
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true
|
||||
}),
|
||||
makeModel('model_b.safetensors', {
|
||||
nodeId: '2',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true
|
||||
}),
|
||||
makeModel('lora_a.safetensors', {
|
||||
nodeId: '3',
|
||||
directory: 'loras',
|
||||
isAssetSupported: true
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.missingModelGroups.value).toHaveLength(2)
|
||||
const ckptGroup = groups.missingModelGroups.value.find(
|
||||
(g) => g.directory === 'checkpoints'
|
||||
)
|
||||
expect(ckptGroup?.models).toHaveLength(2)
|
||||
expect(ckptGroup?.isAssetSupported).toBe(true)
|
||||
})
|
||||
|
||||
it('puts unsupported models in a separate group', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingModels([
|
||||
makeModel('model_a.safetensors', {
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true
|
||||
}),
|
||||
makeModel('custom_model.safetensors', {
|
||||
nodeId: '2',
|
||||
isAssetSupported: false
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.missingModelGroups.value).toHaveLength(2)
|
||||
const unsupported = groups.missingModelGroups.value.find(
|
||||
(g) => !g.isAssetSupported
|
||||
)
|
||||
expect(unsupported?.models).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('merges same-named models into one view model with multiple referencingNodes', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingModels([
|
||||
makeModel('shared_model.safetensors', {
|
||||
nodeId: '1',
|
||||
widgetName: 'ckpt_name',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true
|
||||
}),
|
||||
makeModel('shared_model.safetensors', {
|
||||
nodeId: '2',
|
||||
widgetName: 'ckpt_name',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true
|
||||
})
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
expect(groups.missingModelGroups.value).toHaveLength(1)
|
||||
const model = groups.missingModelGroups.value[0].models[0]
|
||||
expect(model.name).toBe('shared_model.safetensors')
|
||||
expect(model.referencingNodes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('includes missing_model group in allErrorGroups', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingModels([makeModel('model_a.safetensors')])
|
||||
await nextTick()
|
||||
|
||||
const modelGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'missing_model'
|
||||
)
|
||||
expect(modelGroup).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { MaybeRefOrGetter } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -24,11 +23,6 @@ import { st } from '@/i18n'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelGroup
|
||||
} from '@/platform/missingModel/types'
|
||||
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
|
||||
import {
|
||||
isNodeExecutionId,
|
||||
compareExecutionId
|
||||
@@ -45,9 +39,6 @@ const KNOWN_PROMPT_ERROR_TYPES = new Set([
|
||||
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
|
||||
const RESOLVING = '__RESOLVING__'
|
||||
|
||||
/** Sentinel key for grouping non-asset-supported missing models. */
|
||||
const UNSUPPORTED = Symbol('unsupported')
|
||||
|
||||
export interface MissingPackGroup {
|
||||
packId: string | null
|
||||
nodeTypes: MissingNodeType[]
|
||||
@@ -240,7 +231,6 @@ export function useErrorGroups(
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
@@ -569,60 +559,6 @@ export function useErrorGroups(
|
||||
return groups.sort((a, b) => a.priority - b.priority)
|
||||
}
|
||||
|
||||
/** Groups missing models. Asset-supported models group by directory; others go into a separate group.
|
||||
* Within each group, candidates with the same model name are merged into a single view model. */
|
||||
const missingModelGroups = computed<MissingModelGroup[]>(() => {
|
||||
const candidates = missingModelStore.missingModelCandidates
|
||||
if (!candidates?.length) return []
|
||||
|
||||
type GroupKey = string | null | typeof UNSUPPORTED
|
||||
const map = new Map<
|
||||
GroupKey,
|
||||
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
|
||||
>()
|
||||
|
||||
for (const c of candidates) {
|
||||
const groupKey: GroupKey = c.isAssetSupported
|
||||
? c.directory || null
|
||||
: UNSUPPORTED
|
||||
|
||||
const existing = map.get(groupKey)
|
||||
if (existing) {
|
||||
existing.candidates.push(c)
|
||||
} else {
|
||||
map.set(groupKey, {
|
||||
candidates: [c],
|
||||
isAssetSupported: c.isAssetSupported
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.entries())
|
||||
.sort(([dirA], [dirB]) => {
|
||||
if (dirA === UNSUPPORTED) return 1
|
||||
if (dirB === UNSUPPORTED) return -1
|
||||
if (dirA === null) return 1
|
||||
if (dirB === null) return -1
|
||||
return dirA.localeCompare(dirB)
|
||||
})
|
||||
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
|
||||
directory: typeof key === 'string' ? key : null,
|
||||
models: groupCandidatesByName(groupCandidates),
|
||||
isAssetSupported
|
||||
}))
|
||||
})
|
||||
|
||||
function buildMissingModelGroups(): ErrorGroup[] {
|
||||
if (!missingModelGroups.value.length) return []
|
||||
return [
|
||||
{
|
||||
type: 'missing_model' as const,
|
||||
title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${missingModelGroups.value.reduce((count, group) => count + group.models.length, 0)})`,
|
||||
priority: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
@@ -630,11 +566,7 @@ export function useErrorGroups(
|
||||
processNodeErrors(groupsMap)
|
||||
processExecutionError(groupsMap)
|
||||
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...toSortedGroups(groupsMap)
|
||||
]
|
||||
return [...buildMissingNodeGroups(), ...toSortedGroups(groupsMap)]
|
||||
})
|
||||
|
||||
const tabErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
@@ -648,11 +580,7 @@ export function useErrorGroups(
|
||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||
: toSortedGroups(groupsMap)
|
||||
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...executionGroups
|
||||
]
|
||||
return [...buildMissingNodeGroups(), ...executionGroups]
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
@@ -687,7 +615,6 @@ export function useErrorGroups(
|
||||
missingNodeCache,
|
||||
groupedErrorMessages,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
swapNodeGroups
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,15 +11,13 @@
|
||||
}"
|
||||
@click="onLogoMenuClick($event)"
|
||||
>
|
||||
<div class="grid place-items-center-safe gap-0.5">
|
||||
<i
|
||||
class="col-span-full row-span-full icon-[lucide--chevron-down] size-3 translate-x-4 text-muted-foreground"
|
||||
/>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<ComfyLogo
|
||||
alt="ComfyUI Logo"
|
||||
class="comfyui-logo col-span-full row-span-full size-4.5"
|
||||
class="comfyui-logo h-[18px] w-[18px]"
|
||||
mode="fill"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down] size-3 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="prepend" />
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport
|
||||
:class="
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useElementBounding, useRafFn } from '@vueuse/core'
|
||||
import type ContextMenu from 'primevue/contextmenu'
|
||||
import type { Ref } from 'vue'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
/**
|
||||
* Keeps a PrimeVue ContextMenu anchored to a world-space position while the
|
||||
* canvas is panned/zoomed. Performs dirty-checking each RAF tick so the DOM
|
||||
* is only touched when the transform actually changes.
|
||||
*/
|
||||
export function useCanvasMenuPositionSync(
|
||||
contextMenu: Ref<InstanceType<typeof ContextMenu> | undefined>,
|
||||
isOpen: Ref<boolean>
|
||||
) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const lgCanvas = canvasStore.getCanvas()
|
||||
const { left: canvasLeft, top: canvasTop } = useElementBounding(
|
||||
lgCanvas.canvas
|
||||
)
|
||||
|
||||
const worldPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
let lastScale = 0
|
||||
let lastOffsetX = 0
|
||||
let lastOffsetY = 0
|
||||
|
||||
function updateMenuPosition() {
|
||||
if (!isOpen.value) return
|
||||
|
||||
const menuInstance = contextMenu.value as unknown as {
|
||||
container?: HTMLElement
|
||||
}
|
||||
const menuEl = menuInstance?.container
|
||||
if (!menuEl) return
|
||||
|
||||
const { scale, offset } = lgCanvas.ds
|
||||
|
||||
if (
|
||||
scale === lastScale &&
|
||||
offset[0] === lastOffsetX &&
|
||||
offset[1] === lastOffsetY
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
lastScale = scale
|
||||
lastOffsetX = offset[0]
|
||||
lastOffsetY = offset[1]
|
||||
|
||||
const screenX =
|
||||
(worldPosition.value.x + offset[0]) * scale + canvasLeft.value
|
||||
const screenY =
|
||||
(worldPosition.value.y + offset[1]) * scale + canvasTop.value
|
||||
|
||||
menuEl.style.left = `${screenX}px`
|
||||
menuEl.style.top = `${screenY}px`
|
||||
}
|
||||
|
||||
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
|
||||
immediate: false
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (isOpen.value) {
|
||||
startSync()
|
||||
} else {
|
||||
stopSync()
|
||||
}
|
||||
})
|
||||
|
||||
/** Convert a mouse event's screen position to world coordinates and store it. */
|
||||
function setWorldPositionFromEvent(event: MouseEvent) {
|
||||
const screenX = event.clientX - canvasLeft.value
|
||||
const screenY = event.clientY - canvasTop.value
|
||||
const { scale, offset } = lgCanvas.ds
|
||||
|
||||
worldPosition.value = {
|
||||
x: screenX / scale - offset[0],
|
||||
y: screenY / scale - offset[1]
|
||||
}
|
||||
|
||||
lastScale = scale
|
||||
lastOffsetX = offset[0]
|
||||
lastOffsetY = offset[1]
|
||||
}
|
||||
|
||||
return { setWorldPositionFromEvent }
|
||||
}
|
||||
@@ -324,8 +324,7 @@ function safeWidgetMapper(
|
||||
}
|
||||
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
slotMetadata: slotInfo,
|
||||
slotName: name !== widget.name ? widget.name : undefined,
|
||||
tooltip: widget.tooltip
|
||||
slotName: name !== widget.name ? widget.name : undefined
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -357,8 +357,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize viewer in standalone mode (for asset preview).
|
||||
* Creates the Load3d instance once; subsequent calls reuse it.
|
||||
* Initialize viewer in standalone mode (for asset preview)
|
||||
*/
|
||||
const initializeStandaloneViewer = async (
|
||||
containerRef: HTMLElement,
|
||||
@@ -367,11 +366,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
if (!containerRef) return
|
||||
|
||||
try {
|
||||
if (load3d) {
|
||||
await loadStandaloneModel(modelUrl)
|
||||
return
|
||||
}
|
||||
|
||||
isStandaloneMode.value = true
|
||||
|
||||
load3d = new Load3d(containerRef, {
|
||||
@@ -398,23 +392,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
setupAnimationEvents()
|
||||
} catch (error) {
|
||||
console.error('Error initializing standalone 3D viewer:', error)
|
||||
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a new model into an existing standalone viewer,
|
||||
* reusing the same WebGLRenderer.
|
||||
*/
|
||||
const loadStandaloneModel = async (modelUrl: string) => {
|
||||
if (!load3d) return
|
||||
|
||||
try {
|
||||
await load3d.loadModel(modelUrl)
|
||||
isSplatModel.value = load3d.isSplatModel()
|
||||
isPlyModel.value = load3d.isPlyModel()
|
||||
} catch (error) {
|
||||
console.error('Error loading model in standalone viewer:', error)
|
||||
useToastStore().addAlert('Failed to load 3D model')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
|
||||
`${namePrefix}.${depth}.${inputIndex}`,
|
||||
Array.isArray(input)
|
||||
? ['COMFY_DYNAMICCOMBO_V3', { options: getSpec(input, depth + 1) }]
|
||||
: [input, { tooltip: `${groupIndex}` }]
|
||||
: [input, {}]
|
||||
])
|
||||
return {
|
||||
key: `${groupIndex}`,
|
||||
@@ -106,13 +106,6 @@ describe('Dynamic Combos', () => {
|
||||
expect(node.inputs[1].name).toBe('0.0.0.0')
|
||||
expect(node.inputs[3].name).toBe('2.2.0.0')
|
||||
})
|
||||
test('Dynamically added widgets have tooltips', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
expect.soft(node.widgets[1].tooltip).toBe('0')
|
||||
node.widgets[0].value = '1'
|
||||
expect.soft(node.widgets[1].tooltip).toBe('1')
|
||||
})
|
||||
})
|
||||
describe('Autogrow', () => {
|
||||
const inputsSpec = { required: { image: ['IMAGE', {}] } }
|
||||
|
||||
@@ -35,10 +35,6 @@
|
||||
"videoPreview": "Video preview - Use arrow keys to navigate between videos",
|
||||
"galleryImage": "Gallery image",
|
||||
"galleryThumbnail": "Gallery thumbnail",
|
||||
"previousImage": "Previous image",
|
||||
"nextImage": "Next image",
|
||||
"switchToGridView": "Switch to grid view",
|
||||
"switchToSingleView": "Switch to single view",
|
||||
"errorLoadingImage": "Error loading image",
|
||||
"errorLoadingVideo": "Error loading video",
|
||||
"failedToDownloadImage": "Failed to download image",
|
||||
@@ -1056,8 +1052,6 @@
|
||||
"logoProviderSeparator": " & "
|
||||
},
|
||||
"graphCanvasMenu": {
|
||||
"canvasMode": "Canvas Mode",
|
||||
"canvasToolbar": "Canvas Toolbar",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"resetView": "Reset View",
|
||||
@@ -1961,7 +1955,6 @@
|
||||
"exportSuccess": "Successfully exported model as {format}",
|
||||
"fileLoadError": "Unable to find workflow in {fileName}",
|
||||
"dropFileError": "Unable to process dropped item: {error}",
|
||||
"missingModelVerificationFailed": "Failed to verify missing models. Some models may not be shown in the Errors tab.",
|
||||
"interrupted": "Execution has been interrupted",
|
||||
"pendingTasksDeleted": "Pending tasks deleted",
|
||||
"nothingToGroup": "Nothing to group",
|
||||
@@ -3393,33 +3386,6 @@
|
||||
"viewInManager": "View in Manager",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand"
|
||||
},
|
||||
"missingModels": {
|
||||
"urlPlaceholder": "Paste Model URL (Civitai or Hugging Face)",
|
||||
"or": "OR",
|
||||
"useFromLibrary": "Use from Library",
|
||||
"usingFromLibrary": "Using from Library",
|
||||
"unsupportedUrl": "Only Civitai and Hugging Face URLs are supported.",
|
||||
"metadataFetchFailed": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"imported": "Imported",
|
||||
"importFailed": "Import failed",
|
||||
"typeMismatch": "This model seems to be a \"{detectedType}\". Are you sure?",
|
||||
"importAnyway": "Import Anyway",
|
||||
"alreadyExistsInCategory": "This model already exists in \"{category}\"",
|
||||
"customNodeDownloadDisabled": "Cloud environment does not support model imports for custom nodes in this section. Please use standard loader nodes or substitute with a model from the library below.",
|
||||
"importNotSupported": "Import Not Supported",
|
||||
"copyModelName": "Copy model name",
|
||||
"confirmSelection": "Confirm selection",
|
||||
"locateNode": "Locate node on canvas",
|
||||
"cancelSelection": "Cancel selection",
|
||||
"clearUrl": "Clear URL",
|
||||
"expandNodes": "Show referencing nodes",
|
||||
"collapseNodes": "Hide referencing nodes",
|
||||
"unknownCategory": "Unknown",
|
||||
"missingModelsTitle": "Missing Models",
|
||||
"assetLoadTimeout": "Model detection timed out. Try reloading the workflow."
|
||||
}
|
||||
},
|
||||
"errorOverlay": {
|
||||
|
||||
@@ -348,8 +348,8 @@
|
||||
"tooltip": "The maximum number of tasks that show in the queue history."
|
||||
},
|
||||
"Comfy_Queue_QPOV2": {
|
||||
"name": "Docked job history/queue panel",
|
||||
"tooltip": "Replaces the floating job queue panel with an equivalent job queue embedded in the job history side panel. You can disable this to return to the floating panel layout."
|
||||
"name": "Use the unified job queue in the Assets side panel",
|
||||
"tooltip": "Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout."
|
||||
},
|
||||
"Comfy_QueueButton_BatchCountLimit": {
|
||||
"name": "Batch count limit",
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
MissingModelGroup,
|
||||
MissingModelViewModel
|
||||
} from '@/platform/missingModel/types'
|
||||
|
||||
vi.mock('./MissingModelRow.vue', () => ({
|
||||
default: {
|
||||
name: 'MissingModelRow',
|
||||
template: '<div class="model-row" />',
|
||||
props: ['model', 'directory', 'showNodeIdBadge', 'isAssetSupported'],
|
||||
emits: ['locate-model']
|
||||
}
|
||||
}))
|
||||
|
||||
import MissingModelCard from './MissingModelCard.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
rightSidePanel: {
|
||||
missingModels: {
|
||||
importNotSupported: 'Import Not Supported',
|
||||
customNodeDownloadDisabled:
|
||||
'Cloud environment does not support model imports for custom nodes.',
|
||||
unknownCategory: 'Unknown Category'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function makeViewModel(
|
||||
name: string,
|
||||
nodeId: string = '1'
|
||||
): MissingModelViewModel {
|
||||
return {
|
||||
name,
|
||||
representative: {
|
||||
name,
|
||||
nodeId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId, widgetName: 'ckpt_name' }]
|
||||
}
|
||||
}
|
||||
|
||||
function makeGroup(
|
||||
opts: {
|
||||
directory?: string | null
|
||||
isAssetSupported?: boolean
|
||||
modelNames?: string[]
|
||||
} = {}
|
||||
): MissingModelGroup {
|
||||
const names = opts.modelNames ?? ['model.safetensors']
|
||||
return {
|
||||
directory: 'directory' in opts ? (opts.directory ?? null) : 'checkpoints',
|
||||
isAssetSupported: opts.isAssetSupported ?? true,
|
||||
models: names.map((n, i) => makeViewModel(n, String(i + 1)))
|
||||
}
|
||||
}
|
||||
|
||||
function mountCard(
|
||||
props: Partial<{
|
||||
missingModelGroups: MissingModelGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}> = {}
|
||||
) {
|
||||
return mount(MissingModelCard, {
|
||||
props: {
|
||||
missingModelGroups: [makeGroup()],
|
||||
showNodeIdBadge: false,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('MissingModelCard', () => {
|
||||
describe('Rendering & Props', () => {
|
||||
it('renders directory name in category header', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ directory: 'loras' })]
|
||||
})
|
||||
expect(wrapper.text()).toContain('loras')
|
||||
})
|
||||
|
||||
it('renders translated unknown category when directory is null', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ directory: null })]
|
||||
})
|
||||
expect(wrapper.text()).toContain('Unknown Category')
|
||||
})
|
||||
|
||||
it('renders model count in category header', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({ modelNames: ['a.safetensors', 'b.safetensors'] })
|
||||
]
|
||||
})
|
||||
expect(wrapper.text()).toContain('(2)')
|
||||
})
|
||||
|
||||
it('renders correct number of MissingModelRow components', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({
|
||||
modelNames: ['a.safetensors', 'b.safetensors', 'c.safetensors']
|
||||
})
|
||||
]
|
||||
})
|
||||
expect(
|
||||
wrapper.findAllComponents({ name: 'MissingModelRow' })
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders multiple groups', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [
|
||||
makeGroup({ directory: 'checkpoints' }),
|
||||
makeGroup({ directory: 'loras' })
|
||||
]
|
||||
})
|
||||
expect(wrapper.text()).toContain('checkpoints')
|
||||
expect(wrapper.text()).toContain('loras')
|
||||
})
|
||||
|
||||
it('renders zero rows when missingModelGroups is empty', () => {
|
||||
const wrapper = mountCard({ missingModelGroups: [] })
|
||||
expect(
|
||||
wrapper.findAllComponents({ name: 'MissingModelRow' })
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('passes props correctly to MissingModelRow children', () => {
|
||||
const wrapper = mountCard({ showNodeIdBadge: true })
|
||||
const row = wrapper.findComponent({ name: 'MissingModelRow' })
|
||||
expect(row.props('showNodeIdBadge')).toBe(true)
|
||||
expect(row.props('isAssetSupported')).toBe(true)
|
||||
expect(row.props('directory')).toBe('checkpoints')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Asset Unsupported Group', () => {
|
||||
it('shows "Import Not Supported" header for unsupported groups', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ isAssetSupported: false })]
|
||||
})
|
||||
expect(wrapper.text()).toContain('Import Not Supported')
|
||||
})
|
||||
|
||||
it('shows info notice for unsupported groups', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ isAssetSupported: false })]
|
||||
})
|
||||
expect(wrapper.text()).toContain(
|
||||
'Cloud environment does not support model imports'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides info notice for supported groups', () => {
|
||||
const wrapper = mountCard({
|
||||
missingModelGroups: [makeGroup({ isAssetSupported: true })]
|
||||
})
|
||||
expect(wrapper.text()).not.toContain(
|
||||
'Cloud environment does not support model imports'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Handling', () => {
|
||||
it('emits locateModel when child emits locate-model', async () => {
|
||||
const wrapper = mountCard()
|
||||
const row = wrapper.findComponent({ name: 'MissingModelRow' })
|
||||
await row.vm.$emit('locate-model', '42')
|
||||
expect(wrapper.emitted('locateModel')).toBeTruthy()
|
||||
expect(wrapper.emitted('locateModel')?.[0]).toEqual(['42'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2">
|
||||
<!-- Category groups (by directory) -->
|
||||
<div
|
||||
v-for="group in missingModelGroups"
|
||||
:key="`${group.isAssetSupported ? 'supported' : 'unsupported'}::${group.directory ?? '__unknown__'}`"
|
||||
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
|
||||
>
|
||||
<!-- Category header -->
|
||||
<div class="flex h-8 w-full items-center">
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium"
|
||||
:class="
|
||||
!group.isAssetSupported || group.directory === null
|
||||
? 'text-warning-background'
|
||||
: 'text-destructive-background-hover'
|
||||
"
|
||||
>
|
||||
<span v-if="!group.isAssetSupported" class="text-warning-background">
|
||||
{{ t('rightSidePanel.missingModels.importNotSupported') }}
|
||||
({{ group.models.length }})
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
group.directory ??
|
||||
t('rightSidePanel.missingModels.unknownCategory')
|
||||
}}
|
||||
({{ group.models.length }})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Asset unsupported group notice -->
|
||||
<div
|
||||
v-if="!group.isAssetSupported"
|
||||
class="flex items-start gap-1.5 px-0.5 py-1 pl-2"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 icon-[lucide--info] size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-xs/tight text-muted-foreground">
|
||||
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Model rows -->
|
||||
<div class="flex flex-col gap-1 overflow-hidden pl-2">
|
||||
<MissingModelRow
|
||||
v-for="model in group.models"
|
||||
:key="model.name"
|
||||
:model="model"
|
||||
:directory="group.directory"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:is-asset-supported="group.isAssetSupported"
|
||||
@locate-model="emit('locateModel', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
|
||||
|
||||
const { missingModelGroups, showNodeIdBadge } = defineProps<{
|
||||
missingModelGroups: MissingModelGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateModel: [nodeId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,113 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
|
||||
<span class="text-xs font-bold text-muted-foreground">
|
||||
{{ t('rightSidePanel.missingModels.or') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
:model-value="modelValue"
|
||||
:disabled="options.length === 0"
|
||||
@update:model-value="handleSelect"
|
||||
>
|
||||
<SelectTrigger
|
||||
size="md"
|
||||
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
|
||||
>
|
||||
<SelectValue
|
||||
:placeholder="t('rightSidePanel.missingModels.useFromLibrary')"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<template v-if="options.length > 4" #prepend>
|
||||
<div class="px-1 pb-1.5">
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
v-model="filterQuery"
|
||||
type="text"
|
||||
:aria-label="t('g.searchPlaceholder', { subject: '' })"
|
||||
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('g.searchPlaceholder', { subject: '' })"
|
||||
@keydown.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SelectItem
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ option.name }}
|
||||
</SelectItem>
|
||||
<div
|
||||
v-if="filteredOptions.length === 0"
|
||||
role="status"
|
||||
class="px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('g.noResultsFound') }}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFuse } from '@vueuse/integrations/useFuse'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
|
||||
const { options, showDivider = false } = defineProps<{
|
||||
modelValue: string | undefined
|
||||
options: { name: string; value: string }[]
|
||||
showDivider?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const filterQuery = ref('')
|
||||
|
||||
watch(
|
||||
() => options.length,
|
||||
(len) => {
|
||||
if (len <= 4) filterQuery.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
const { results: fuseResults } = useFuse(filterQuery, () => options, {
|
||||
fuseOptions: {
|
||||
keys: ['name'],
|
||||
threshold: 0.4,
|
||||
ignoreLocation: true
|
||||
},
|
||||
matchAllWhenSearchEmpty: true
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
|
||||
|
||||
function handleSelect(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
filterQuery.value = ''
|
||||
emit('select', value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,224 +0,0 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col pb-3">
|
||||
<!-- Model header -->
|
||||
<div class="flex h-8 w-full items-center gap-2">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="text-foreground icon-[lucide--file-check] size-4 shrink-0"
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 items-center">
|
||||
<p
|
||||
class="text-foreground min-w-0 truncate text-sm font-medium"
|
||||
:title="model.name"
|
||||
>
|
||||
{{ model.name }} ({{ model.referencingNodes.length }})
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 hover:bg-transparent"
|
||||
:aria-label="t('rightSidePanel.missingModels.copyModelName')"
|
||||
:title="t('rightSidePanel.missingModels.copyModelName')"
|
||||
@click="copyToClipboard(model.name)"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--copy] size-3.5 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.confirmSelection')"
|
||||
:disabled="!canConfirm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 rounded-lg transition-colors',
|
||||
canConfirm ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
|
||||
)
|
||||
"
|
||||
@click="handleLibrarySelect"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--check] size-4"
|
||||
:class="canConfirm ? 'text-primary' : 'text-foreground'"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="model.referencingNodes.length > 0"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="
|
||||
expanded
|
||||
? t('rightSidePanel.missingModels.collapseNodes')
|
||||
: t('rightSidePanel.missingModels.expandNodes')
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
||||
expanded && 'rotate-180'
|
||||
)
|
||||
"
|
||||
@click="toggleModelExpand(modelKey)"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Referencing nodes -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
|
||||
>
|
||||
<div
|
||||
v-for="ref in model.referencingNodes"
|
||||
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
|
||||
class="flex h-7 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="showNodeIdBadge"
|
||||
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
#{{ ref.nodeId }}
|
||||
</span>
|
||||
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{{ getNodeDisplayLabel(ref.nodeId, model.representative.nodeType) }}
|
||||
</p>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.locateNode')"
|
||||
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('locateModel', String(ref.nodeId))"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Status card -->
|
||||
<TransitionCollapse>
|
||||
<MissingModelStatusCard
|
||||
v-if="selectedLibraryModel[modelKey]"
|
||||
:model-name="selectedLibraryModel[modelKey]"
|
||||
:is-download-active="isDownloadActive"
|
||||
:download-status="downloadStatus"
|
||||
:category-mismatch="importCategoryMismatch[modelKey]"
|
||||
@cancel="cancelLibrarySelect(modelKey)"
|
||||
/>
|
||||
</TransitionCollapse>
|
||||
|
||||
<!-- Input area -->
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="!selectedLibraryModel[modelKey]"
|
||||
class="mt-1 flex flex-col gap-2"
|
||||
>
|
||||
<template v-if="isAssetSupported">
|
||||
<MissingModelUrlInput
|
||||
:model-key="modelKey"
|
||||
:directory="directory"
|
||||
:type-mismatch="typeMismatch"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<TransitionCollapse>
|
||||
<MissingModelLibrarySelect
|
||||
v-if="!urlInputs[modelKey]"
|
||||
:model-value="getComboValue(model.representative)"
|
||||
:options="comboOptions"
|
||||
:show-divider="model.representative.isAssetSupported"
|
||||
@select="handleComboSelect(modelKey, $event)"
|
||||
/>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import MissingModelStatusCard from '@/platform/missingModel/components/MissingModelStatusCard.vue'
|
||||
import MissingModelUrlInput from '@/platform/missingModel/components/MissingModelUrlInput.vue'
|
||||
import MissingModelLibrarySelect from '@/platform/missingModel/components/MissingModelLibrarySelect.vue'
|
||||
import type { MissingModelViewModel } from '@/platform/missingModel/types'
|
||||
|
||||
import {
|
||||
useMissingModelInteractions,
|
||||
getModelStateKey,
|
||||
getNodeDisplayLabel,
|
||||
getComboValue
|
||||
} from '@/platform/missingModel/composables/useMissingModelInteractions'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
|
||||
const { model, directory, isAssetSupported } = defineProps<{
|
||||
model: MissingModelViewModel
|
||||
directory: string | null
|
||||
showNodeIdBadge: boolean
|
||||
isAssetSupported: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateModel: [nodeId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
const modelKey = computed(() =>
|
||||
getModelStateKey(model.name, directory, isAssetSupported)
|
||||
)
|
||||
|
||||
const downloadStatus = computed(() => getDownloadStatus(modelKey.value))
|
||||
const comboOptions = computed(() => getComboOptions(model.representative))
|
||||
const canConfirm = computed(() => isSelectionConfirmable(modelKey.value))
|
||||
const expanded = computed(() => isModelExpanded(modelKey.value))
|
||||
const typeMismatch = computed(() => getTypeMismatch(modelKey.value, directory))
|
||||
const isDownloadActive = computed(
|
||||
() =>
|
||||
downloadStatus.value?.status === 'running' ||
|
||||
downloadStatus.value?.status === 'created'
|
||||
)
|
||||
|
||||
const store = useMissingModelStore()
|
||||
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
|
||||
storeToRefs(store)
|
||||
|
||||
const {
|
||||
toggleModelExpand,
|
||||
isModelExpanded,
|
||||
getComboOptions,
|
||||
handleComboSelect,
|
||||
isSelectionConfirmable,
|
||||
cancelLibrarySelect,
|
||||
confirmLibrarySelect,
|
||||
getTypeMismatch,
|
||||
getDownloadStatus
|
||||
} = useMissingModelInteractions()
|
||||
|
||||
function handleLibrarySelect() {
|
||||
confirmLibrarySelect(
|
||||
modelKey.value,
|
||||
model.name,
|
||||
model.referencingNodes,
|
||||
directory
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,108 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
aria-live="polite"
|
||||
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
|
||||
>
|
||||
<!-- Progress bar fill -->
|
||||
<div
|
||||
v-if="isDownloadActive"
|
||||
class="absolute inset-y-0 left-0 bg-primary/10 transition-all duration-200 ease-linear"
|
||||
:style="{ width: (downloadStatus?.progress ?? 0) * 100 + '%' }"
|
||||
/>
|
||||
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<i
|
||||
v-if="categoryMismatch"
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 icon-[lucide--triangle-alert] size-5 text-warning-background"
|
||||
/>
|
||||
<i
|
||||
v-else-if="downloadStatus?.status === 'failed'"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--circle-alert] size-5 text-destructive-background"
|
||||
/>
|
||||
<i
|
||||
v-else-if="downloadStatus?.status === 'completed'"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--check-circle] size-5 text-success-background"
|
||||
/>
|
||||
<i
|
||||
v-else-if="isDownloadActive"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--file-check] size-5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<span class="text-foreground truncate text-xs/tight font-medium">
|
||||
{{ modelName }}
|
||||
</span>
|
||||
<span class="mt-0.5 text-xs/tight text-muted-foreground">
|
||||
<template v-if="categoryMismatch">
|
||||
{{
|
||||
t('rightSidePanel.missingModels.alreadyExistsInCategory', {
|
||||
category: categoryMismatch
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="isDownloadActive">
|
||||
{{ t('rightSidePanel.missingModels.importing') }}
|
||||
{{ Math.round((downloadStatus?.progress ?? 0) * 100) }}%
|
||||
</template>
|
||||
<template v-else-if="downloadStatus?.status === 'completed'">
|
||||
{{ t('rightSidePanel.missingModels.imported') }}
|
||||
</template>
|
||||
<template v-else-if="downloadStatus?.status === 'failed'">
|
||||
{{
|
||||
downloadStatus?.error ||
|
||||
t('rightSidePanel.missingModels.importFailed')
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('rightSidePanel.missingModels.usingFromLibrary') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.cancelSelection')"
|
||||
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
|
||||
const {
|
||||
modelName,
|
||||
isDownloadActive,
|
||||
downloadStatus = null,
|
||||
categoryMismatch = null
|
||||
} = defineProps<{
|
||||
modelName: string
|
||||
isDownloadActive: boolean
|
||||
downloadStatus?: AssetDownload | null
|
||||
categoryMismatch?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,156 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 items-center rounded-lg border border-transparent bg-secondary-background px-3 transition-colors focus-within:border-interface-stroke',
|
||||
!canImportModels && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
v-bind="upgradePromptAttrs"
|
||||
@click="!canImportModels && showUploadDialog()"
|
||||
>
|
||||
<label :for="`url-input-${modelKey}`" class="sr-only">
|
||||
{{ t('rightSidePanel.missingModels.urlPlaceholder') }}
|
||||
</label>
|
||||
<input
|
||||
:id="`url-input-${modelKey}`"
|
||||
type="text"
|
||||
:value="urlInputs[modelKey] ?? ''"
|
||||
:readonly="!canImportModels"
|
||||
:placeholder="t('rightSidePanel.missingModels.urlPlaceholder')"
|
||||
:class="
|
||||
cn(
|
||||
'text-foreground w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground',
|
||||
!canImportModels && 'pointer-events-none opacity-60'
|
||||
)
|
||||
"
|
||||
@input="
|
||||
handleUrlInput(modelKey, ($event.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
v-if="urlInputs[modelKey]"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingModels.clearUrl')"
|
||||
class="ml-1 shrink-0"
|
||||
@click.stop="handleUrlInput(modelKey, '')"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--x] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TransitionCollapse>
|
||||
<div v-if="urlMetadata[modelKey]" class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 px-0.5 pt-0.5">
|
||||
<span class="text-foreground min-w-0 truncate text-xs font-bold">
|
||||
{{ urlMetadata[modelKey]?.filename }}
|
||||
</span>
|
||||
<span
|
||||
v-if="(urlMetadata[modelKey]?.content_length ?? 0) > 0"
|
||||
class="shrink-0 rounded-sm bg-secondary-background-selected px-1.5 py-0.5 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{{ formatSize(urlMetadata[modelKey]?.content_length ?? 0) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="typeMismatch" class="flex items-start gap-1.5 px-0.5">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 icon-[lucide--triangle-alert] size-3 shrink-0 text-warning-background"
|
||||
/>
|
||||
<span class="text-xs/tight text-warning-background">
|
||||
{{
|
||||
t('rightSidePanel.missingModels.typeMismatch', {
|
||||
detectedType: typeMismatch
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pt-0.5">
|
||||
<Button
|
||||
variant="primary"
|
||||
class="h-9 w-full justify-center gap-2 text-sm font-semibold"
|
||||
:loading="urlImporting[modelKey]"
|
||||
@click="handleImport(modelKey, directory)"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--download] size-4" />
|
||||
{{
|
||||
typeMismatch
|
||||
? t('rightSidePanel.missingModels.importAnyway')
|
||||
: t('rightSidePanel.missingModels.import')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="urlFetching[modelKey]"
|
||||
aria-live="polite"
|
||||
class="flex items-center justify-center py-2"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<span class="sr-only">{{ t('g.loading') }}</span>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
|
||||
<TransitionCollapse>
|
||||
<div v-if="urlErrors[modelKey]" class="px-0.5" role="alert">
|
||||
<span class="text-xs text-destructive-background-hover">
|
||||
{{ urlErrors[modelKey] }}
|
||||
</span>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingModelInteractions } from '@/platform/missingModel/composables/useMissingModelInteractions'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
||||
|
||||
const { modelKey, directory, typeMismatch } = defineProps<{
|
||||
modelKey: string
|
||||
directory: string | null
|
||||
typeMismatch: string | null
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { flags } = useFeatureFlags()
|
||||
const canImportModels = computed(() => flags.privateModelsEnabled)
|
||||
const { showUploadDialog } = useModelUpload()
|
||||
|
||||
const store = useMissingModelStore()
|
||||
const { urlInputs, urlMetadata, urlFetching, urlErrors, urlImporting } =
|
||||
storeToRefs(store)
|
||||
|
||||
const { handleUrlInput, handleImport } = useMissingModelInteractions()
|
||||
|
||||
const upgradePromptAttrs = computed(() =>
|
||||
canImportModels.value
|
||||
? {}
|
||||
: {
|
||||
role: 'button',
|
||||
tabindex: 0,
|
||||
onKeydown: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
showUploadDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -1,516 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
|
||||
const mockGetNodeByExecutionId = vi.fn()
|
||||
const mockResolveNodeDisplayName = vi.fn()
|
||||
const mockValidateSourceUrl = vi.fn()
|
||||
const mockGetAssetMetadata = vi.fn()
|
||||
const mockGetAssetDisplayName = vi.fn((a: { name: string }) => a.name)
|
||||
const mockGetAssetFilename = vi.fn((a: { name: string }) => a.name)
|
||||
const mockGetAssets = vi.fn()
|
||||
const mockUpdateModelsForNodeType = vi.fn()
|
||||
const mockGetAllNodeProviders = vi.fn()
|
||||
const mockDownloadList = vi.fn(
|
||||
(): Array<{ taskId: string; status: string }> => []
|
||||
)
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((_key: string, fallback: string) => fallback)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId: (...args: unknown[]) =>
|
||||
mockGetNodeByExecutionId(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeTitleUtil', () => ({
|
||||
resolveNodeDisplayName: (...args: unknown[]) =>
|
||||
mockResolveNodeDisplayName(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({
|
||||
getAssets: mockGetAssets,
|
||||
updateModelsForNodeType: mockUpdateModelsForNodeType,
|
||||
invalidateModelsForCategory: vi.fn(),
|
||||
updateModelsForTag: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/assetDownloadStore', () => ({
|
||||
useAssetDownloadStore: () => ({
|
||||
get downloadList() {
|
||||
return mockDownloadList()
|
||||
},
|
||||
trackDownload: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({
|
||||
getAllNodeProviders: mockGetAllNodeProviders
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetMetadata: (...args: unknown[]) => mockGetAssetMetadata(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/assetMetadataUtils', () => ({
|
||||
getAssetDisplayName: (a: { name: string }) => mockGetAssetDisplayName(a),
|
||||
getAssetFilename: (a: { name: string }) => mockGetAssetFilename(a)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({
|
||||
civitaiImportSource: {
|
||||
type: 'civitai',
|
||||
name: 'Civitai',
|
||||
hostnames: ['civitai.com']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/importSources/huggingfaceImportSource', () => ({
|
||||
huggingfaceImportSource: {
|
||||
type: 'huggingface',
|
||||
name: 'Hugging Face',
|
||||
hostnames: ['huggingface.co']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/importSourceUtil', () => ({
|
||||
validateSourceUrl: (...args: unknown[]) => mockValidateSourceUrl(...args)
|
||||
}))
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import {
|
||||
getComboValue,
|
||||
getModelStateKey,
|
||||
getNodeDisplayLabel,
|
||||
useMissingModelInteractions
|
||||
} from './useMissingModelInteractions'
|
||||
|
||||
function makeCandidate(
|
||||
overrides: Partial<MissingModelCandidate> = {}
|
||||
): MissingModelCandidate {
|
||||
return {
|
||||
name: 'model.safetensors',
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
isMissing: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMissingModelInteractions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.resetAllMocks()
|
||||
mockGetAssetDisplayName.mockImplementation((a: { name: string }) => a.name)
|
||||
mockGetAssetFilename.mockImplementation((a: { name: string }) => a.name)
|
||||
mockDownloadList.mockImplementation(
|
||||
(): Array<{ taskId: string; status: string }> => []
|
||||
)
|
||||
;(app as { rootGraph: unknown }).rootGraph = null
|
||||
})
|
||||
|
||||
describe('getModelStateKey', () => {
|
||||
it('returns key with supported prefix when asset is supported', () => {
|
||||
expect(getModelStateKey('model.safetensors', 'checkpoints', true)).toBe(
|
||||
'supported::checkpoints::model.safetensors'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns key with unsupported prefix when asset is not supported', () => {
|
||||
expect(getModelStateKey('model.safetensors', 'loras', false)).toBe(
|
||||
'unsupported::loras::model.safetensors'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles null directory', () => {
|
||||
expect(getModelStateKey('model.safetensors', null, true)).toBe(
|
||||
'supported::::model.safetensors'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeDisplayLabel', () => {
|
||||
it('returns fallback when graph is null', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = null
|
||||
expect(getNodeDisplayLabel('1', 'Node #1')).toBe('Node #1')
|
||||
})
|
||||
|
||||
it('calls resolveNodeDisplayName when graph is available', () => {
|
||||
const mockGraph = {}
|
||||
const mockNode = { id: 1 }
|
||||
;(app as { rootGraph: unknown }).rootGraph = mockGraph
|
||||
mockGetNodeByExecutionId.mockReturnValue(mockNode)
|
||||
mockResolveNodeDisplayName.mockReturnValue('My Checkpoint')
|
||||
|
||||
const result = getNodeDisplayLabel('1', 'Node #1')
|
||||
|
||||
expect(mockGetNodeByExecutionId).toHaveBeenCalledWith(mockGraph, '1')
|
||||
expect(result).toBe('My Checkpoint')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getComboValue', () => {
|
||||
it('returns undefined when node is not found', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = {}
|
||||
mockGetNodeByExecutionId.mockReturnValue(null)
|
||||
|
||||
const result = getComboValue(makeCandidate())
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when widget is not found', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = {}
|
||||
mockGetNodeByExecutionId.mockReturnValue({
|
||||
widgets: [{ name: 'other_widget', value: 'test' }]
|
||||
})
|
||||
|
||||
const result = getComboValue(makeCandidate())
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns string value directly', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = {}
|
||||
mockGetNodeByExecutionId.mockReturnValue({
|
||||
widgets: [{ name: 'ckpt_name', value: 'v1-5.safetensors' }]
|
||||
})
|
||||
|
||||
expect(getComboValue(makeCandidate())).toBe('v1-5.safetensors')
|
||||
})
|
||||
|
||||
it('returns stringified number value', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = {}
|
||||
mockGetNodeByExecutionId.mockReturnValue({
|
||||
widgets: [{ name: 'ckpt_name', value: 42 }]
|
||||
})
|
||||
|
||||
expect(getComboValue(makeCandidate())).toBe('42')
|
||||
})
|
||||
|
||||
it('returns undefined for unexpected types', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = {}
|
||||
mockGetNodeByExecutionId.mockReturnValue({
|
||||
widgets: [{ name: 'ckpt_name', value: { complex: true } }]
|
||||
})
|
||||
|
||||
expect(getComboValue(makeCandidate())).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when nodeId is null', () => {
|
||||
const result = getComboValue(makeCandidate({ nodeId: undefined }))
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleModelExpand / isModelExpanded', () => {
|
||||
it('starts collapsed by default', () => {
|
||||
const { isModelExpanded } = useMissingModelInteractions()
|
||||
expect(isModelExpanded('key1')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles to expanded', () => {
|
||||
const { toggleModelExpand, isModelExpanded } =
|
||||
useMissingModelInteractions()
|
||||
toggleModelExpand('key1')
|
||||
expect(isModelExpanded('key1')).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles back to collapsed', () => {
|
||||
const { toggleModelExpand, isModelExpanded } =
|
||||
useMissingModelInteractions()
|
||||
toggleModelExpand('key1')
|
||||
toggleModelExpand('key1')
|
||||
expect(isModelExpanded('key1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleComboSelect', () => {
|
||||
it('sets selectedLibraryModel in store', () => {
|
||||
const store = useMissingModelStore()
|
||||
const { handleComboSelect } = useMissingModelInteractions()
|
||||
|
||||
handleComboSelect('key1', 'model_v2.safetensors')
|
||||
expect(store.selectedLibraryModel['key1']).toBe('model_v2.safetensors')
|
||||
})
|
||||
|
||||
it('does not set value when undefined', () => {
|
||||
const store = useMissingModelStore()
|
||||
const { handleComboSelect } = useMissingModelInteractions()
|
||||
|
||||
handleComboSelect('key1', undefined)
|
||||
expect(store.selectedLibraryModel['key1']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSelectionConfirmable', () => {
|
||||
it('returns false when no selection exists', () => {
|
||||
const { isSelectionConfirmable } = useMissingModelInteractions()
|
||||
expect(isSelectionConfirmable('key1')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when download is running', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'model.safetensors'
|
||||
store.importTaskIds['key1'] = 'task-123'
|
||||
mockDownloadList.mockReturnValue([
|
||||
{ taskId: 'task-123', status: 'running' }
|
||||
])
|
||||
|
||||
const { isSelectionConfirmable } = useMissingModelInteractions()
|
||||
expect(isSelectionConfirmable('key1')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when importCategoryMismatch exists', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'model.safetensors'
|
||||
store.importCategoryMismatch['key1'] = 'loras'
|
||||
|
||||
const { isSelectionConfirmable } = useMissingModelInteractions()
|
||||
expect(isSelectionConfirmable('key1')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when selection is ready with no active download', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'model.safetensors'
|
||||
mockDownloadList.mockReturnValue([])
|
||||
|
||||
const { isSelectionConfirmable } = useMissingModelInteractions()
|
||||
expect(isSelectionConfirmable('key1')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelLibrarySelect', () => {
|
||||
it('clears selectedLibraryModel and importCategoryMismatch', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'model.safetensors'
|
||||
store.importCategoryMismatch['key1'] = 'loras'
|
||||
|
||||
const { cancelLibrarySelect } = useMissingModelInteractions()
|
||||
cancelLibrarySelect('key1')
|
||||
|
||||
expect(store.selectedLibraryModel['key1']).toBeUndefined()
|
||||
expect(store.importCategoryMismatch['key1']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmLibrarySelect', () => {
|
||||
it('updates widget values on referencing nodes and removes missing model', () => {
|
||||
const mockGraph = {}
|
||||
;(app as { rootGraph: unknown }).rootGraph = mockGraph
|
||||
|
||||
const widget1 = { name: 'ckpt_name', value: 'old_model.safetensors' }
|
||||
const widget2 = { name: 'ckpt_name', value: 'old_model.safetensors' }
|
||||
const node1 = { widgets: [widget1] }
|
||||
const node2 = { widgets: [widget2] }
|
||||
|
||||
mockGetNodeByExecutionId.mockImplementation(
|
||||
(_graph: unknown, id: string) => {
|
||||
if (id === '10') return node1
|
||||
if (id === '20') return node2
|
||||
return null
|
||||
}
|
||||
)
|
||||
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'new_model.safetensors'
|
||||
store.setMissingModels([
|
||||
makeCandidate({ name: 'old_model.safetensors', nodeId: '10' }),
|
||||
makeCandidate({ name: 'old_model.safetensors', nodeId: '20' })
|
||||
])
|
||||
|
||||
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
|
||||
|
||||
const { confirmLibrarySelect } = useMissingModelInteractions()
|
||||
confirmLibrarySelect(
|
||||
'key1',
|
||||
'old_model.safetensors',
|
||||
[
|
||||
{ nodeId: '10', widgetName: 'ckpt_name' },
|
||||
{ nodeId: '20', widgetName: 'ckpt_name' }
|
||||
],
|
||||
null
|
||||
)
|
||||
|
||||
expect(widget1.value).toBe('new_model.safetensors')
|
||||
expect(widget2.value).toBe('new_model.safetensors')
|
||||
expect(removeSpy).toHaveBeenCalledWith(
|
||||
'old_model.safetensors',
|
||||
new Set(['10', '20'])
|
||||
)
|
||||
expect(store.selectedLibraryModel['key1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does nothing when no selection exists', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = {}
|
||||
const store = useMissingModelStore()
|
||||
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
|
||||
|
||||
const { confirmLibrarySelect } = useMissingModelInteractions()
|
||||
confirmLibrarySelect('key1', 'model.safetensors', [], null)
|
||||
|
||||
expect(removeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when graph is null', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = null
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'new.safetensors'
|
||||
const removeSpy = vi.spyOn(store, 'removeMissingModelByNameOnNodes')
|
||||
|
||||
const { confirmLibrarySelect } = useMissingModelInteractions()
|
||||
confirmLibrarySelect('key1', 'model.safetensors', [], null)
|
||||
|
||||
expect(removeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes model cache when directory is provided', () => {
|
||||
;(app as { rootGraph: unknown }).rootGraph = {}
|
||||
mockGetNodeByExecutionId.mockReturnValue(null)
|
||||
mockGetAllNodeProviders.mockReturnValue([
|
||||
{ nodeDef: { name: 'CheckpointLoaderSimple' } }
|
||||
])
|
||||
|
||||
const store = useMissingModelStore()
|
||||
store.selectedLibraryModel['key1'] = 'new.safetensors'
|
||||
|
||||
const { confirmLibrarySelect } = useMissingModelInteractions()
|
||||
confirmLibrarySelect('key1', 'model.safetensors', [], 'checkpoints')
|
||||
|
||||
expect(mockGetAllNodeProviders).toHaveBeenCalledWith('checkpoints')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUrlInput', () => {
|
||||
it('clears previous state on new input', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.urlMetadata['key1'] = { name: 'old' } as never
|
||||
store.urlErrors['key1'] = 'old error'
|
||||
store.urlFetching['key1'] = true
|
||||
|
||||
const { handleUrlInput } = useMissingModelInteractions()
|
||||
handleUrlInput('key1', 'https://civitai.com/models/123')
|
||||
|
||||
expect(store.urlInputs['key1']).toBe('https://civitai.com/models/123')
|
||||
expect(store.urlMetadata['key1']).toBeUndefined()
|
||||
expect(store.urlErrors['key1']).toBeUndefined()
|
||||
expect(store.urlFetching['key1']).toBe(false)
|
||||
})
|
||||
|
||||
it('does not set debounce timer for empty input', () => {
|
||||
const store = useMissingModelStore()
|
||||
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
|
||||
|
||||
const { handleUrlInput } = useMissingModelInteractions()
|
||||
handleUrlInput('key1', ' ')
|
||||
|
||||
expect(setTimerSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets debounce timer for non-empty input', () => {
|
||||
const store = useMissingModelStore()
|
||||
const setTimerSpy = vi.spyOn(store, 'setDebounceTimer')
|
||||
|
||||
const { handleUrlInput } = useMissingModelInteractions()
|
||||
handleUrlInput('key1', 'https://civitai.com/models/123')
|
||||
|
||||
expect(setTimerSpy).toHaveBeenCalledWith(
|
||||
'key1',
|
||||
expect.any(Function),
|
||||
800
|
||||
)
|
||||
})
|
||||
|
||||
it('clears previous debounce timer', () => {
|
||||
const store = useMissingModelStore()
|
||||
const clearTimerSpy = vi.spyOn(store, 'clearDebounceTimer')
|
||||
|
||||
const { handleUrlInput } = useMissingModelInteractions()
|
||||
handleUrlInput('key1', 'https://civitai.com/models/123')
|
||||
|
||||
expect(clearTimerSpy).toHaveBeenCalledWith('key1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTypeMismatch', () => {
|
||||
it('returns null when groupDirectory is null', () => {
|
||||
const { getTypeMismatch } = useMissingModelInteractions()
|
||||
expect(getTypeMismatch('key1', null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no metadata exists', () => {
|
||||
const { getTypeMismatch } = useMissingModelInteractions()
|
||||
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when metadata has no tags', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.urlMetadata['key1'] = { name: 'model', tags: [] } as never
|
||||
|
||||
const { getTypeMismatch } = useMissingModelInteractions()
|
||||
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when detected type matches directory', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.urlMetadata['key1'] = {
|
||||
name: 'model',
|
||||
tags: ['checkpoints']
|
||||
} as never
|
||||
|
||||
const { getTypeMismatch } = useMissingModelInteractions()
|
||||
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns detected type when it differs from directory', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.urlMetadata['key1'] = {
|
||||
name: 'model',
|
||||
tags: ['loras']
|
||||
} as never
|
||||
|
||||
const { getTypeMismatch } = useMissingModelInteractions()
|
||||
expect(getTypeMismatch('key1', 'checkpoints')).toBe('loras')
|
||||
})
|
||||
|
||||
it('returns null when tags contain no recognized model type', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.urlMetadata['key1'] = {
|
||||
name: 'model',
|
||||
tags: ['other', 'random']
|
||||
} as never
|
||||
|
||||
const { getTypeMismatch } = useMissingModelInteractions()
|
||||
expect(getTypeMismatch('key1', 'checkpoints')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,393 +0,0 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { st } from '@/i18n'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
getAssetDisplayName,
|
||||
getAssetFilename
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
|
||||
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
|
||||
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelViewModel
|
||||
} from '@/platform/missingModel/types'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
const importSources = [civitaiImportSource, huggingfaceImportSource]
|
||||
|
||||
const MODEL_TYPE_TAGS = [
|
||||
'checkpoints',
|
||||
'loras',
|
||||
'vae',
|
||||
'text_encoders',
|
||||
'diffusion_models'
|
||||
] as const
|
||||
|
||||
const URL_DEBOUNCE_MS = 800
|
||||
|
||||
export function getModelStateKey(
|
||||
modelName: string,
|
||||
directory: string | null,
|
||||
isAssetSupported: boolean
|
||||
): string {
|
||||
const prefix = isAssetSupported ? 'supported' : 'unsupported'
|
||||
return `${prefix}::${directory ?? ''}::${modelName}`
|
||||
}
|
||||
|
||||
export function getNodeDisplayLabel(
|
||||
nodeId: string | number,
|
||||
fallback: string
|
||||
): string {
|
||||
const graph = app.rootGraph
|
||||
if (!graph) return fallback
|
||||
const node = getNodeByExecutionId(graph, String(nodeId))
|
||||
return resolveNodeDisplayName(node, {
|
||||
emptyLabel: fallback,
|
||||
untitledLabel: fallback,
|
||||
st
|
||||
})
|
||||
}
|
||||
|
||||
function getModelComboWidget(
|
||||
model: MissingModelCandidate
|
||||
): { node: LGraphNode; widget: IBaseWidget } | null {
|
||||
if (model.nodeId == null) return null
|
||||
|
||||
const graph = app.rootGraph
|
||||
if (!graph) return null
|
||||
const node = getNodeByExecutionId(graph, String(model.nodeId))
|
||||
if (!node) return null
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === model.widgetName)
|
||||
if (!widget) return null
|
||||
|
||||
return { node, widget }
|
||||
}
|
||||
|
||||
export function getComboValue(
|
||||
model: MissingModelCandidate
|
||||
): string | undefined {
|
||||
const result = getModelComboWidget(model)
|
||||
if (!result) return undefined
|
||||
const val = result.widget.value
|
||||
if (typeof val === 'string') return val
|
||||
if (typeof val === 'number') return String(val)
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function useMissingModelInteractions() {
|
||||
const { t } = useI18n()
|
||||
const store = useMissingModelStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
|
||||
const _requestTokens: Record<string, symbol> = {}
|
||||
|
||||
function toggleModelExpand(key: string) {
|
||||
store.modelExpandState[key] = !isModelExpanded(key)
|
||||
}
|
||||
|
||||
function isModelExpanded(key: string): boolean {
|
||||
return store.modelExpandState[key] ?? false
|
||||
}
|
||||
|
||||
function getComboOptions(
|
||||
model: MissingModelCandidate
|
||||
): { name: string; value: string }[] {
|
||||
if (model.isAssetSupported && model.nodeType) {
|
||||
const assets = assetsStore.getAssets(model.nodeType) ?? []
|
||||
return assets.map((asset) => ({
|
||||
name: getAssetDisplayName(asset),
|
||||
value: getAssetFilename(asset)
|
||||
}))
|
||||
}
|
||||
|
||||
const result = getModelComboWidget(model)
|
||||
if (!result) return []
|
||||
const values = result.widget.options?.values
|
||||
if (!Array.isArray(values)) return []
|
||||
return values.map((v) => ({ name: String(v), value: String(v) }))
|
||||
}
|
||||
|
||||
function handleComboSelect(key: string, value: string | undefined) {
|
||||
if (value) {
|
||||
store.selectedLibraryModel[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
function isSelectionConfirmable(key: string): boolean {
|
||||
if (!store.selectedLibraryModel[key]) return false
|
||||
if (store.importCategoryMismatch[key]) return false
|
||||
|
||||
const status = getDownloadStatus(key)
|
||||
if (
|
||||
status &&
|
||||
(status.status === 'running' || status.status === 'created')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function cancelLibrarySelect(key: string) {
|
||||
delete store.selectedLibraryModel[key]
|
||||
delete store.importCategoryMismatch[key]
|
||||
}
|
||||
|
||||
/** Apply selected model to referencing nodes, removing only that model from the error list. */
|
||||
function confirmLibrarySelect(
|
||||
key: string,
|
||||
modelName: string,
|
||||
referencingNodes: MissingModelViewModel['referencingNodes'],
|
||||
directory: string | null
|
||||
) {
|
||||
const value = store.selectedLibraryModel[key]
|
||||
if (!value) return
|
||||
|
||||
const graph = app.rootGraph
|
||||
if (!graph) return
|
||||
|
||||
if (directory) {
|
||||
const providers = modelToNodeStore.getAllNodeProviders(directory)
|
||||
void Promise.allSettled(
|
||||
providers.map((provider) =>
|
||||
assetsStore.updateModelsForNodeType(provider.nodeDef.name)
|
||||
)
|
||||
).then((results) => {
|
||||
for (const r of results) {
|
||||
if (r.status === 'rejected') {
|
||||
console.warn(
|
||||
'[Missing Model] Failed to refresh model cache:',
|
||||
r.reason
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const ref of referencingNodes) {
|
||||
const node = getNodeByExecutionId(graph, String(ref.nodeId))
|
||||
if (node) {
|
||||
const widget = node.widgets?.find((w) => w.name === ref.widgetName)
|
||||
if (widget) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
}
|
||||
node.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
delete store.selectedLibraryModel[key]
|
||||
const nodeIdSet = new Set(referencingNodes.map((ref) => String(ref.nodeId)))
|
||||
store.removeMissingModelByNameOnNodes(modelName, nodeIdSet)
|
||||
}
|
||||
|
||||
function handleUrlInput(key: string, value: string) {
|
||||
store.urlInputs[key] = value
|
||||
|
||||
delete store.urlMetadata[key]
|
||||
delete store.urlErrors[key]
|
||||
delete store.importCategoryMismatch[key]
|
||||
store.urlFetching[key] = false
|
||||
|
||||
store.clearDebounceTimer(key)
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
store.setDebounceTimer(
|
||||
key,
|
||||
() => {
|
||||
void fetchUrlMetadata(key, trimmed)
|
||||
},
|
||||
URL_DEBOUNCE_MS
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchUrlMetadata(key: string, url: string) {
|
||||
const source = importSources.find((s) => validateSourceUrl(url, s))
|
||||
if (!source) {
|
||||
store.urlErrors[key] = t('rightSidePanel.missingModels.unsupportedUrl')
|
||||
return
|
||||
}
|
||||
|
||||
const token = Symbol()
|
||||
_requestTokens[key] = token
|
||||
|
||||
store.urlFetching[key] = true
|
||||
delete store.urlErrors[key]
|
||||
|
||||
try {
|
||||
const metadata = await assetService.getAssetMetadata(url)
|
||||
|
||||
if (_requestTokens[key] !== token) return
|
||||
|
||||
if (metadata.filename) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(metadata.filename)
|
||||
const basename = decoded.split(/[/\\]/).pop() ?? decoded
|
||||
if (!basename.includes('..')) {
|
||||
metadata.filename = basename
|
||||
}
|
||||
} catch {
|
||||
/* keep original */
|
||||
}
|
||||
}
|
||||
|
||||
store.urlMetadata[key] = metadata
|
||||
} catch (error) {
|
||||
if (_requestTokens[key] !== token) return
|
||||
|
||||
store.urlErrors[key] =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('rightSidePanel.missingModels.metadataFetchFailed')
|
||||
} finally {
|
||||
if (_requestTokens[key] === token) {
|
||||
store.urlFetching[key] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeMismatch(
|
||||
key: string,
|
||||
groupDirectory: string | null
|
||||
): string | null {
|
||||
if (!groupDirectory) return null
|
||||
|
||||
const metadata = store.urlMetadata[key]
|
||||
if (!metadata?.tags?.length) return null
|
||||
|
||||
const detectedType = metadata.tags.find((tag) =>
|
||||
MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
|
||||
)
|
||||
if (!detectedType) return null
|
||||
|
||||
if (detectedType !== groupDirectory) {
|
||||
return detectedType
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getDownloadStatus(key: string) {
|
||||
const taskId = store.importTaskIds[key]
|
||||
if (!taskId) return null
|
||||
return (
|
||||
assetDownloadStore.downloadList.find((d) => d.taskId === taskId) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function handleAsyncPending(
|
||||
key: string,
|
||||
taskId: string,
|
||||
modelType: string | undefined,
|
||||
filename: string
|
||||
) {
|
||||
store.importTaskIds[key] = taskId
|
||||
if (modelType) {
|
||||
assetDownloadStore.trackDownload(taskId, modelType, filename)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAsyncCompleted(modelType: string | undefined) {
|
||||
if (modelType) {
|
||||
assetsStore.invalidateModelsForCategory(modelType)
|
||||
void assetsStore.updateModelsForTag(modelType)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSyncResult(
|
||||
key: string,
|
||||
tags: string[],
|
||||
modelType: string | undefined
|
||||
) {
|
||||
const existingCategory = tags.find((tag) =>
|
||||
MODEL_TYPE_TAGS.includes(tag as (typeof MODEL_TYPE_TAGS)[number])
|
||||
)
|
||||
if (existingCategory && modelType && existingCategory !== modelType) {
|
||||
store.importCategoryMismatch[key] = existingCategory
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(key: string, groupDirectory: string | null) {
|
||||
const metadata = store.urlMetadata[key]
|
||||
if (!metadata) return
|
||||
|
||||
const url = store.urlInputs[key]?.trim()
|
||||
if (!url) return
|
||||
|
||||
const source = importSources.find((s) => validateSourceUrl(url, s))
|
||||
if (!source) return
|
||||
|
||||
const token = Symbol()
|
||||
_requestTokens[key] = token
|
||||
|
||||
store.urlImporting[key] = true
|
||||
delete store.urlErrors[key]
|
||||
delete store.importCategoryMismatch[key]
|
||||
|
||||
try {
|
||||
const modelType = groupDirectory || undefined
|
||||
const tags = modelType ? ['models', modelType] : ['models']
|
||||
const filename = metadata.filename || metadata.name || 'model'
|
||||
|
||||
const result = await assetService.uploadAssetAsync({
|
||||
source_url: url,
|
||||
tags,
|
||||
user_metadata: {
|
||||
source: source.type,
|
||||
source_url: url,
|
||||
model_type: modelType
|
||||
}
|
||||
})
|
||||
|
||||
if (_requestTokens[key] !== token) return
|
||||
|
||||
if (result.type === 'async' && result.task.status !== 'completed') {
|
||||
handleAsyncPending(key, result.task.task_id, modelType, filename)
|
||||
} else if (result.type === 'async') {
|
||||
handleAsyncCompleted(modelType)
|
||||
} else if (result.type === 'sync') {
|
||||
handleSyncResult(key, result.asset.tags ?? [], modelType)
|
||||
}
|
||||
|
||||
store.selectedLibraryModel[key] = filename
|
||||
} catch (error) {
|
||||
if (_requestTokens[key] !== token) return
|
||||
|
||||
store.urlErrors[key] =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('rightSidePanel.missingModels.importFailed')
|
||||
} finally {
|
||||
if (_requestTokens[key] === token) {
|
||||
store.urlImporting[key] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toggleModelExpand,
|
||||
isModelExpanded,
|
||||
getComboOptions,
|
||||
handleComboSelect,
|
||||
isSelectionConfirmable,
|
||||
cancelLibrarySelect,
|
||||
confirmLibrarySelect,
|
||||
handleUrlInput,
|
||||
getTypeMismatch,
|
||||
getDownloadStatus,
|
||||
handleImport
|
||||
}
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { flattenWorkflowNodes } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelViewModel,
|
||||
EmbeddedModelWithSource
|
||||
} from './types'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type {
|
||||
IAssetWidget,
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
||||
return widget.type === 'combo'
|
||||
}
|
||||
|
||||
function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
|
||||
return widget.type === 'asset'
|
||||
}
|
||||
|
||||
export const MODEL_FILE_EXTENSIONS = new Set([
|
||||
'.safetensors',
|
||||
'.ckpt',
|
||||
'.pt',
|
||||
'.pth',
|
||||
'.bin',
|
||||
'.sft',
|
||||
'.onnx',
|
||||
'.gguf'
|
||||
])
|
||||
|
||||
export function isModelFileName(name: string): boolean {
|
||||
const lower = name.toLowerCase()
|
||||
for (const ext of MODEL_FILE_EXTENSIONS) {
|
||||
if (lower.endsWith(ext)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveComboOptions(widget: IComboWidget): string[] {
|
||||
const values = widget.options.values
|
||||
if (!values) return []
|
||||
if (typeof values === 'function') return values(widget)
|
||||
if (Array.isArray(values)) return values
|
||||
return Object.keys(values)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan COMBO and asset widgets on configured graph nodes for model-like values.
|
||||
* Must be called after `graph.configure()` so widget name/value mappings are accurate.
|
||||
*
|
||||
* Non-asset-supported nodes: `isMissing` resolved immediately via widget options.
|
||||
* Asset-supported nodes: `isMissing` left `undefined` for async verification.
|
||||
*/
|
||||
export function scanAllModelCandidates(
|
||||
rootGraph: LGraph,
|
||||
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
|
||||
getDirectory?: (nodeType: string) => string | undefined
|
||||
): MissingModelCandidate[] {
|
||||
if (!rootGraph) return []
|
||||
|
||||
const allNodes = collectAllNodes(rootGraph)
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
|
||||
for (const node of allNodes) {
|
||||
if (!node.widgets?.length) continue
|
||||
|
||||
const executionId = getExecutionIdByNode(rootGraph, node)
|
||||
if (!executionId) continue
|
||||
|
||||
for (const widget of node.widgets) {
|
||||
let candidate: MissingModelCandidate | null = null
|
||||
|
||||
if (isAssetWidget(widget)) {
|
||||
candidate = scanAssetWidget(node, widget, executionId, getDirectory)
|
||||
} else if (isComboWidget(widget)) {
|
||||
candidate = scanComboWidget(
|
||||
node,
|
||||
widget,
|
||||
executionId,
|
||||
isAssetSupported,
|
||||
getDirectory
|
||||
)
|
||||
}
|
||||
|
||||
if (candidate) candidates.push(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
function scanAssetWidget(
|
||||
node: { type: string },
|
||||
widget: IAssetWidget,
|
||||
executionId: string,
|
||||
getDirectory: ((nodeType: string) => string | undefined) | undefined
|
||||
): MissingModelCandidate | null {
|
||||
const value = widget.value
|
||||
if (!value.trim()) return null
|
||||
if (!isModelFileName(value)) return null
|
||||
|
||||
return {
|
||||
nodeId: executionId as NodeId,
|
||||
nodeType: node.type,
|
||||
widgetName: widget.name,
|
||||
isAssetSupported: true,
|
||||
name: value,
|
||||
directory: getDirectory?.(node.type),
|
||||
isMissing: undefined
|
||||
}
|
||||
}
|
||||
|
||||
function scanComboWidget(
|
||||
node: { type: string },
|
||||
widget: IComboWidget,
|
||||
executionId: string,
|
||||
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
|
||||
getDirectory: ((nodeType: string) => string | undefined) | undefined
|
||||
): MissingModelCandidate | null {
|
||||
const value = widget.value
|
||||
if (typeof value !== 'string' || !value.trim()) return null
|
||||
if (!isModelFileName(value)) return null
|
||||
|
||||
const nodeIsAssetSupported = isAssetSupported(node.type, widget.name)
|
||||
const options = resolveComboOptions(widget)
|
||||
const inOptions = options.includes(value)
|
||||
|
||||
return {
|
||||
nodeId: executionId as NodeId,
|
||||
nodeType: node.type,
|
||||
widgetName: widget.name,
|
||||
isAssetSupported: nodeIsAssetSupported,
|
||||
name: value,
|
||||
directory: getDirectory?.(node.type),
|
||||
isMissing: nodeIsAssetSupported ? undefined : !inOptions
|
||||
}
|
||||
}
|
||||
|
||||
export async function enrichWithEmbeddedMetadata(
|
||||
candidates: readonly MissingModelCandidate[],
|
||||
graphData: ComfyWorkflowJSON,
|
||||
checkModelInstalled: (name: string, directory: string) => Promise<boolean>,
|
||||
isAssetSupported?: (nodeType: string, widgetName: string) => boolean
|
||||
): Promise<MissingModelCandidate[]> {
|
||||
const allNodes = flattenWorkflowNodes(graphData)
|
||||
const embeddedModels = collectEmbeddedModelsWithSource(allNodes, graphData)
|
||||
|
||||
const enriched = candidates.map((c) => ({ ...c }))
|
||||
const candidatesByKey = new Map<string, MissingModelCandidate[]>()
|
||||
for (const c of enriched) {
|
||||
const dirKey = `${c.name}::${c.directory ?? ''}`
|
||||
const dirList = candidatesByKey.get(dirKey)
|
||||
if (dirList) dirList.push(c)
|
||||
else candidatesByKey.set(dirKey, [c])
|
||||
|
||||
const nameKey = c.name
|
||||
const nameList = candidatesByKey.get(nameKey)
|
||||
if (nameList) nameList.push(c)
|
||||
else candidatesByKey.set(nameKey, [c])
|
||||
}
|
||||
|
||||
const deduped: EmbeddedModelWithSource[] = []
|
||||
const enrichedKeys = new Set<string>()
|
||||
for (const model of embeddedModels) {
|
||||
const dedupeKey = `${model.name}::${model.directory}`
|
||||
if (enrichedKeys.has(dedupeKey)) continue
|
||||
enrichedKeys.add(dedupeKey)
|
||||
deduped.push(model)
|
||||
}
|
||||
|
||||
const unmatched: EmbeddedModelWithSource[] = []
|
||||
for (const model of deduped) {
|
||||
const dirKey = `${model.name}::${model.directory}`
|
||||
const exact = candidatesByKey.get(dirKey)
|
||||
const fallback = candidatesByKey.get(model.name)
|
||||
const existing = exact?.length ? exact : fallback
|
||||
if (existing) {
|
||||
for (const c of existing) {
|
||||
if (c.directory && c.directory !== model.directory) continue
|
||||
c.directory ??= model.directory
|
||||
c.url ??= model.url
|
||||
c.hash ??= model.hash
|
||||
c.hashType ??= model.hash_type
|
||||
}
|
||||
} else {
|
||||
unmatched.push(model)
|
||||
}
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
unmatched.map(async (model) => {
|
||||
const installed = await checkModelInstalled(model.name, model.directory)
|
||||
if (installed) return null
|
||||
|
||||
const nodeIsAssetSupported = isAssetSupported
|
||||
? isAssetSupported(model.sourceNodeType, model.sourceWidgetName)
|
||||
: false
|
||||
|
||||
return {
|
||||
nodeId: model.sourceNodeId,
|
||||
nodeType: model.sourceNodeType,
|
||||
widgetName: model.sourceWidgetName,
|
||||
isAssetSupported: nodeIsAssetSupported,
|
||||
name: model.name,
|
||||
directory: model.directory,
|
||||
url: model.url,
|
||||
hash: model.hash,
|
||||
hashType: model.hash_type,
|
||||
isMissing: nodeIsAssetSupported ? undefined : true
|
||||
} satisfies MissingModelCandidate
|
||||
})
|
||||
)
|
||||
|
||||
for (const r of settled) {
|
||||
if (r.status === 'rejected') {
|
||||
console.warn(
|
||||
'[Missing Model Pipeline] checkModelInstalled failed:',
|
||||
r.reason
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (r.value) enriched.push(r.value)
|
||||
}
|
||||
|
||||
return enriched
|
||||
}
|
||||
|
||||
function collectEmbeddedModelsWithSource(
|
||||
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
||||
graphData: ComfyWorkflowJSON
|
||||
): EmbeddedModelWithSource[] {
|
||||
const result: EmbeddedModelWithSource[] = []
|
||||
|
||||
for (const node of allNodes) {
|
||||
const selected = getSelectedModelsMetadata(
|
||||
node as Parameters<typeof getSelectedModelsMetadata>[0]
|
||||
)
|
||||
if (!selected?.length) continue
|
||||
|
||||
for (const model of selected) {
|
||||
result.push({
|
||||
...model,
|
||||
sourceNodeId: node.id,
|
||||
sourceNodeType: node.type,
|
||||
sourceWidgetName: findWidgetNameForModel(node, model.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow-level model entries have no originating node; sourceNodeId
|
||||
// remains undefined and empty-string node type/widget are handled by
|
||||
// groupCandidatesByName (no nodeId → no referencing node entry).
|
||||
if (graphData.models?.length) {
|
||||
for (const model of graphData.models) {
|
||||
result.push({
|
||||
...model,
|
||||
sourceNodeType: '',
|
||||
sourceWidgetName: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function findWidgetNameForModel(
|
||||
node: ReturnType<typeof flattenWorkflowNodes>[number],
|
||||
modelName: string
|
||||
): string {
|
||||
if (Array.isArray(node.widgets_values) || !node.widgets_values) return ''
|
||||
const wv = node.widgets_values as Record<string, unknown>
|
||||
for (const [key, val] of Object.entries(wv)) {
|
||||
if (val === modelName) return key
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
interface AssetVerifier {
|
||||
updateModelsForNodeType: (nodeType: string) => Promise<void>
|
||||
getAssets: (nodeType: string) => AssetItem[] | undefined
|
||||
}
|
||||
|
||||
export async function verifyAssetSupportedCandidates(
|
||||
candidates: MissingModelCandidate[],
|
||||
signal?: AbortSignal,
|
||||
assetsStore?: AssetVerifier
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) return
|
||||
|
||||
const pendingNodeTypes = new Set<string>()
|
||||
for (const c of candidates) {
|
||||
if (c.isAssetSupported && c.isMissing === undefined) {
|
||||
pendingNodeTypes.add(c.nodeType)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingNodeTypes.size === 0) return
|
||||
|
||||
const store =
|
||||
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
|
||||
|
||||
const failedNodeTypes = new Set<string>()
|
||||
await Promise.allSettled(
|
||||
[...pendingNodeTypes].map(async (nodeType) => {
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
await store.updateModelsForNodeType(nodeType)
|
||||
} catch (err) {
|
||||
failedNodeTypes.add(nodeType)
|
||||
console.warn(
|
||||
`[Missing Model Pipeline] Failed to load assets for ${nodeType}:`,
|
||||
err
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (signal?.aborted) return
|
||||
|
||||
for (const c of candidates) {
|
||||
if (!c.isAssetSupported || c.isMissing !== undefined) continue
|
||||
if (failedNodeTypes.has(c.nodeType)) continue
|
||||
|
||||
const assets = store.getAssets(c.nodeType) ?? []
|
||||
c.isMissing = !isAssetInstalled(c, assets)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function isAssetInstalled(
|
||||
candidate: MissingModelCandidate,
|
||||
assets: AssetItem[]
|
||||
): boolean {
|
||||
if (candidate.hash && candidate.hashType) {
|
||||
const candidateHash = `${candidate.hashType}:${candidate.hash}`
|
||||
if (assets.some((a) => a.asset_hash === candidateHash)) return true
|
||||
}
|
||||
|
||||
const normalizedName = normalizePath(candidate.name)
|
||||
return assets.some((a) => {
|
||||
const f = normalizePath(getAssetFilename(a))
|
||||
return f === normalizedName || f.endsWith('/' + normalizedName)
|
||||
})
|
||||
}
|
||||
|
||||
export function groupCandidatesByName(
|
||||
candidates: MissingModelCandidate[]
|
||||
): MissingModelViewModel[] {
|
||||
const map = new Map<string, MissingModelViewModel>()
|
||||
for (const c of candidates) {
|
||||
const existing = map.get(c.name)
|
||||
if (existing) {
|
||||
if (c.nodeId) {
|
||||
existing.referencingNodes.push({
|
||||
nodeId: c.nodeId,
|
||||
widgetName: c.widgetName
|
||||
})
|
||||
}
|
||||
} else {
|
||||
map.set(c.name, {
|
||||
name: c.name,
|
||||
representative: c,
|
||||
referencingNodes: c.nodeId
|
||||
? [{ nodeId: c.nodeId, widgetName: c.widgetName }]
|
||||
: []
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: vi.fn((_key: string, fallback: string) => fallback)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
import { useMissingModelStore } from './missingModelStore'
|
||||
|
||||
function makeModelCandidate(
|
||||
name: string,
|
||||
opts: {
|
||||
nodeId?: string | number
|
||||
nodeType?: string
|
||||
widgetName?: string
|
||||
isAssetSupported?: boolean
|
||||
} = {}
|
||||
): MissingModelCandidate {
|
||||
return {
|
||||
name,
|
||||
nodeId: opts.nodeId ?? '1',
|
||||
nodeType: opts.nodeType ?? 'CheckpointLoaderSimple',
|
||||
widgetName: opts.widgetName ?? 'ckpt_name',
|
||||
isAssetSupported: opts.isAssetSupported ?? false,
|
||||
isMissing: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('missingModelStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('setMissingModels', () => {
|
||||
it('sets missingModelCandidates with provided models', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([makeModelCandidate('model_a.safetensors')])
|
||||
|
||||
expect(store.missingModelCandidates).not.toBeNull()
|
||||
expect(store.missingModelCandidates).toHaveLength(1)
|
||||
expect(store.hasMissingModels).toBe(true)
|
||||
})
|
||||
|
||||
it('clears missingModelCandidates when given empty array', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([makeModelCandidate('model_a.safetensors')])
|
||||
expect(store.missingModelCandidates).not.toBeNull()
|
||||
|
||||
store.setMissingModels([])
|
||||
expect(store.missingModelCandidates).toBeNull()
|
||||
expect(store.hasMissingModels).toBe(false)
|
||||
})
|
||||
|
||||
it('includes model count in missingModelCount', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors'),
|
||||
makeModelCandidate('model_b.safetensors', { nodeId: '2' })
|
||||
])
|
||||
|
||||
expect(store.missingModelCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasMissingModelOnNode', () => {
|
||||
it('returns true when node has missing model', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
|
||||
])
|
||||
|
||||
expect(store.hasMissingModelOnNode('5')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when node has no missing model', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
|
||||
])
|
||||
|
||||
expect(store.hasMissingModelOnNode('99')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no models are missing', () => {
|
||||
const store = useMissingModelStore()
|
||||
expect(store.hasMissingModelOnNode('1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMissingModelByNameOnNodes', () => {
|
||||
it('removes only the named model from specified nodes', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', {
|
||||
nodeId: '1',
|
||||
widgetName: 'ckpt_name'
|
||||
}),
|
||||
makeModelCandidate('model_b.safetensors', {
|
||||
nodeId: '1',
|
||||
widgetName: 'vae_name'
|
||||
}),
|
||||
makeModelCandidate('model_a.safetensors', {
|
||||
nodeId: '2',
|
||||
widgetName: 'ckpt_name'
|
||||
})
|
||||
])
|
||||
|
||||
store.removeMissingModelByNameOnNodes(
|
||||
'model_a.safetensors',
|
||||
new Set(['1'])
|
||||
)
|
||||
|
||||
expect(store.missingModelCandidates).toHaveLength(2)
|
||||
expect(store.missingModelCandidates![0].name).toBe('model_b.safetensors')
|
||||
expect(store.missingModelCandidates![1].name).toBe('model_a.safetensors')
|
||||
expect(String(store.missingModelCandidates![1].nodeId)).toBe('2')
|
||||
})
|
||||
|
||||
it('sets missingModelCandidates to null when all removed', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
|
||||
])
|
||||
|
||||
store.removeMissingModelByNameOnNodes(
|
||||
'model_a.safetensors',
|
||||
new Set(['1'])
|
||||
)
|
||||
|
||||
expect(store.missingModelCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMissingModels', () => {
|
||||
it('clears missingModelCandidates and interaction state', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
|
||||
])
|
||||
store.urlInputs['test-key'] = 'https://example.com'
|
||||
store.selectedLibraryModel['test-key'] = 'some-model'
|
||||
expect(store.missingModelCandidates).not.toBeNull()
|
||||
|
||||
store.clearMissingModels()
|
||||
|
||||
expect(store.missingModelCandidates).toBeNull()
|
||||
expect(store.hasMissingModels).toBe(false)
|
||||
expect(store.urlInputs).toEqual({})
|
||||
expect(store.selectedLibraryModel).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWidgetMissingModel', () => {
|
||||
it('returns true when specific widget has missing model', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', {
|
||||
nodeId: '5',
|
||||
widgetName: 'ckpt_name'
|
||||
})
|
||||
])
|
||||
|
||||
expect(store.isWidgetMissingModel('5', 'ckpt_name')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for different widget on same node', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', {
|
||||
nodeId: '5',
|
||||
widgetName: 'ckpt_name'
|
||||
})
|
||||
])
|
||||
|
||||
expect(store.isWidgetMissingModel('5', 'lora_name')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no models are missing', () => {
|
||||
const store = useMissingModelStore()
|
||||
expect(store.isWidgetMissingModel('1', 'ckpt_name')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,201 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, onScopeDispose, ref } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* Missing model error state and interaction state.
|
||||
* Separated from executionErrorStore to keep domain boundaries clean.
|
||||
* The executionErrorStore composes from this store for aggregate error flags.
|
||||
*/
|
||||
export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const missingModelCandidates = ref<MissingModelCandidate[] | null>(null)
|
||||
|
||||
const hasMissingModels = computed(
|
||||
() => !!missingModelCandidates.value?.length
|
||||
)
|
||||
|
||||
const missingModelCount = computed(
|
||||
() => missingModelCandidates.value?.length ?? 0
|
||||
)
|
||||
|
||||
const missingModelNodeIds = computed<Set<string>>(() => {
|
||||
const ids = new Set<string>()
|
||||
if (!missingModelCandidates.value) return ids
|
||||
for (const m of missingModelCandidates.value) {
|
||||
if (m.nodeId != null) ids.add(String(m.nodeId))
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
const missingModelWidgetKeys = computed<Set<string>>(() => {
|
||||
const keys = new Set<string>()
|
||||
if (!missingModelCandidates.value) return keys
|
||||
for (const m of missingModelCandidates.value) {
|
||||
keys.add(`${String(m.nodeId)}::${m.widgetName}`)
|
||||
}
|
||||
return keys
|
||||
})
|
||||
|
||||
/**
|
||||
* Set of all execution ID prefixes derived from missing model node IDs,
|
||||
* including the missing model nodes themselves.
|
||||
*
|
||||
* Example: missing model on node "65:70:63" → Set { "65", "65:70", "65:70:63" }
|
||||
*/
|
||||
const missingModelAncestorExecutionIds = computed<Set<NodeExecutionId>>(
|
||||
() => {
|
||||
const ids = new Set<NodeExecutionId>()
|
||||
for (const nodeId of missingModelNodeIds.value) {
|
||||
for (const id of getAncestorExecutionIds(nodeId)) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
)
|
||||
|
||||
const activeMissingModelGraphIds = computed<Set<string>>(() => {
|
||||
if (!app.rootGraph) return new Set()
|
||||
return getActiveGraphNodeIds(
|
||||
app.rootGraph,
|
||||
canvasStore.currentGraph ?? app.rootGraph,
|
||||
missingModelAncestorExecutionIds.value
|
||||
)
|
||||
})
|
||||
|
||||
// Persists across component re-mounts so that download progress,
|
||||
// URL inputs, etc. survive tab switches within the right-side panel.
|
||||
const modelExpandState = ref<Record<string, boolean>>({})
|
||||
const selectedLibraryModel = ref<Record<string, string>>({})
|
||||
const importCategoryMismatch = ref<Record<string, string>>({})
|
||||
const importTaskIds = ref<Record<string, string>>({})
|
||||
const urlInputs = ref<Record<string, string>>({})
|
||||
const urlMetadata = ref<Record<string, AssetMetadata | null>>({})
|
||||
const urlFetching = ref<Record<string, boolean>>({})
|
||||
const urlErrors = ref<Record<string, string>>({})
|
||||
const urlImporting = ref<Record<string, boolean>>({})
|
||||
|
||||
const _urlDebounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
|
||||
|
||||
let _verificationAbortController: AbortController | null = null
|
||||
|
||||
onScopeDispose(cancelDebounceTimers)
|
||||
|
||||
function createVerificationAbortController(): AbortController {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = new AbortController()
|
||||
return _verificationAbortController
|
||||
}
|
||||
|
||||
function setMissingModels(models: MissingModelCandidate[]) {
|
||||
missingModelCandidates.value = models.length ? models : null
|
||||
}
|
||||
|
||||
function removeMissingModelByNameOnNodes(
|
||||
modelName: string,
|
||||
nodeIds: Set<string>
|
||||
) {
|
||||
if (!missingModelCandidates.value) return
|
||||
missingModelCandidates.value = missingModelCandidates.value.filter(
|
||||
(m) =>
|
||||
m.name !== modelName ||
|
||||
m.nodeId == null ||
|
||||
!nodeIds.has(String(m.nodeId))
|
||||
)
|
||||
if (!missingModelCandidates.value.length)
|
||||
missingModelCandidates.value = null
|
||||
}
|
||||
|
||||
function hasMissingModelOnNode(nodeLocatorId: string): boolean {
|
||||
return missingModelNodeIds.value.has(nodeLocatorId)
|
||||
}
|
||||
|
||||
function isWidgetMissingModel(nodeId: string, widgetName: string): boolean {
|
||||
return missingModelWidgetKeys.value.has(`${nodeId}::${widgetName}`)
|
||||
}
|
||||
|
||||
function isContainerWithMissingModel(node: LGraphNode): boolean {
|
||||
return activeMissingModelGraphIds.value.has(String(node.id))
|
||||
}
|
||||
|
||||
function cancelDebounceTimers() {
|
||||
for (const key of Object.keys(_urlDebounceTimers)) {
|
||||
clearTimeout(_urlDebounceTimers[key])
|
||||
delete _urlDebounceTimers[key]
|
||||
}
|
||||
}
|
||||
|
||||
function setDebounceTimer(
|
||||
key: string,
|
||||
callback: () => void,
|
||||
delayMs: number
|
||||
) {
|
||||
if (_urlDebounceTimers[key]) {
|
||||
clearTimeout(_urlDebounceTimers[key])
|
||||
}
|
||||
_urlDebounceTimers[key] = setTimeout(callback, delayMs)
|
||||
}
|
||||
|
||||
function clearDebounceTimer(key: string) {
|
||||
if (_urlDebounceTimers[key]) {
|
||||
clearTimeout(_urlDebounceTimers[key])
|
||||
delete _urlDebounceTimers[key]
|
||||
}
|
||||
}
|
||||
|
||||
function clearMissingModels() {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = null
|
||||
missingModelCandidates.value = null
|
||||
cancelDebounceTimers()
|
||||
modelExpandState.value = {}
|
||||
selectedLibraryModel.value = {}
|
||||
importCategoryMismatch.value = {}
|
||||
importTaskIds.value = {}
|
||||
urlInputs.value = {}
|
||||
urlMetadata.value = {}
|
||||
urlFetching.value = {}
|
||||
urlErrors.value = {}
|
||||
urlImporting.value = {}
|
||||
}
|
||||
|
||||
return {
|
||||
missingModelCandidates,
|
||||
hasMissingModels,
|
||||
missingModelCount,
|
||||
missingModelNodeIds,
|
||||
activeMissingModelGraphIds,
|
||||
|
||||
setMissingModels,
|
||||
removeMissingModelByNameOnNodes,
|
||||
clearMissingModels,
|
||||
createVerificationAbortController,
|
||||
|
||||
hasMissingModelOnNode,
|
||||
isWidgetMissingModel,
|
||||
isContainerWithMissingModel,
|
||||
|
||||
modelExpandState,
|
||||
selectedLibraryModel,
|
||||
importTaskIds,
|
||||
importCategoryMismatch,
|
||||
urlInputs,
|
||||
urlMetadata,
|
||||
urlFetching,
|
||||
urlErrors,
|
||||
urlImporting,
|
||||
|
||||
setDebounceTimer,
|
||||
clearDebounceTimer
|
||||
}
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import type {
|
||||
ModelFile,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
/**
|
||||
* A single (node, widget, model) binding detected by the missing model pipeline.
|
||||
* The same model name may appear multiple times across different nodes.
|
||||
*/
|
||||
export interface MissingModelCandidate {
|
||||
/** Undefined for workflow-level models not tied to a specific node. */
|
||||
nodeId?: NodeId
|
||||
nodeType: string
|
||||
widgetName: string
|
||||
isAssetSupported: boolean
|
||||
|
||||
name: string
|
||||
directory?: string
|
||||
url?: string
|
||||
hash?: string
|
||||
hashType?: string
|
||||
|
||||
/**
|
||||
* - `true` — confirmed missing
|
||||
* - `false` — confirmed installed
|
||||
* - `undefined` — pending async verification (asset-supported nodes only)
|
||||
*/
|
||||
isMissing: boolean | undefined
|
||||
}
|
||||
|
||||
export interface EmbeddedModelWithSource extends ModelFile {
|
||||
/** Undefined for workflow-level models not tied to a specific node. */
|
||||
sourceNodeId?: NodeId
|
||||
sourceNodeType: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
/** View model grouping multiple candidate references under a single model name. */
|
||||
export interface MissingModelViewModel {
|
||||
name: string
|
||||
representative: MissingModelCandidate
|
||||
referencingNodes: Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
}>
|
||||
}
|
||||
|
||||
/** A category group of missing models sharing the same directory. */
|
||||
export interface MissingModelGroup {
|
||||
directory: string | null
|
||||
models: MissingModelViewModel[]
|
||||
isAssetSupported: boolean
|
||||
}
|
||||
@@ -150,41 +150,6 @@ describe('fetchJobs', () => {
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('parses batch containing text-only preview outputs', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve(
|
||||
createMockResponse([
|
||||
createMockJob('image-job', 'completed', {
|
||||
preview_output: {
|
||||
filename: 'output.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
}),
|
||||
createMockJob('text-job', 'completed', {
|
||||
preview_output: {
|
||||
content: 'some generated text',
|
||||
nodeId: '5',
|
||||
mediaType: 'text'
|
||||
}
|
||||
}),
|
||||
createMockJob('no-preview-job', 'completed')
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
const result = await fetchHistory(mockFetch)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].id).toBe('image-job')
|
||||
expect(result[1].id).toBe('text-job')
|
||||
expect(result[2].id).toBe('no-preview-job')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchQueue', () => {
|
||||
|
||||
@@ -18,16 +18,14 @@ const zJobStatus = z.enum([
|
||||
'cancelled'
|
||||
])
|
||||
|
||||
const zPreviewOutput = z
|
||||
.object({
|
||||
filename: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: resultItemType.optional(),
|
||||
nodeId: z.string(),
|
||||
mediaType: z.string(),
|
||||
display_name: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
const zPreviewOutput = z.object({
|
||||
filename: z.string(),
|
||||
subfolder: z.string(),
|
||||
type: resultItemType,
|
||||
nodeId: z.string(),
|
||||
mediaType: z.string(),
|
||||
display_name: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Execution error from Jobs API.
|
||||
|
||||
@@ -31,7 +31,6 @@ export type RemoteConfig = {
|
||||
mixpanel_token?: string
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_debug?: boolean
|
||||
subscription_required?: boolean
|
||||
server_health_alert?: ServerHealthAlert
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -1201,10 +1201,10 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
{
|
||||
id: 'Comfy.Queue.QPOV2',
|
||||
category: ['Comfy', 'Queue', 'Layout'],
|
||||
name: 'Docked job history/queue panel',
|
||||
name: 'Use the unified job queue in the Assets side panel',
|
||||
type: 'boolean',
|
||||
tooltip:
|
||||
'Replaces the floating job queue panel with an equivalent job queue embedded in the job history side panel. You can disable this to return to the floating panel layout.',
|
||||
'Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout.',
|
||||
defaultValue: false,
|
||||
experimental: true
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { AuditLog } from '@/services/customerEventsService'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
@@ -28,8 +27,7 @@ import type {
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
WorkflowImportMetadata
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
@@ -159,14 +157,6 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackWorkflowOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackWorkflowSaved?.(metadata))
|
||||
}
|
||||
|
||||
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
|
||||
this.dispatch((provider) => provider.trackDefaultViewSet?.(metadata))
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.dispatch((provider) => provider.trackEnterLinear?.(metadata))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
CreditTopupMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
@@ -41,8 +40,7 @@ import type {
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
WorkflowImportMetadata
|
||||
} from '../../types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
@@ -361,14 +359,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
|
||||
}
|
||||
|
||||
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
|
||||
}
|
||||
|
||||
@@ -80,30 +80,27 @@ describe('PostHogTelemetryProvider', () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({
|
||||
api_host: 'https://t.comfy.org',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie'
|
||||
})
|
||||
)
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith('phc_test_token', {
|
||||
api_host: 'https://ph.comfy.org',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: false
|
||||
})
|
||||
})
|
||||
|
||||
it('enables debug mode when posthog_debug is true in config', async () => {
|
||||
it('uses custom api_host from config when provided', async () => {
|
||||
window.__CONFIG__ = {
|
||||
posthog_project_token: 'phc_test_token',
|
||||
posthog_debug: true
|
||||
posthog_api_host: 'https://custom.host.com'
|
||||
} as typeof window.__CONFIG__
|
||||
new PostHogTelemetryProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({ debug: true })
|
||||
expect.objectContaining({ api_host: 'https://custom.host.com' })
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ExecutionContext,
|
||||
@@ -35,8 +34,7 @@ import type {
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata,
|
||||
WorkflowSavedMetadata
|
||||
WorkflowImportMetadata
|
||||
} from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
@@ -103,15 +101,12 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.posthog = posthogModule.default
|
||||
this.posthog!.init(apiKey, {
|
||||
api_host:
|
||||
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
window.__CONFIG__?.posthog_api_host || 'https://ph.comfy.org',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug:
|
||||
window.__CONFIG__?.posthog_debug ??
|
||||
import.meta.env.VITE_POSTHOG_DEBUG === 'true'
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true'
|
||||
})
|
||||
this.isInitialized = true
|
||||
this.flushEventQueue()
|
||||
@@ -349,14 +344,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
|
||||
}
|
||||
|
||||
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
|
||||
}
|
||||
|
||||
@@ -149,15 +149,6 @@ export interface EnterLinearMetadata {
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface WorkflowSavedMetadata {
|
||||
is_app: boolean
|
||||
is_new: boolean
|
||||
}
|
||||
|
||||
export interface DefaultViewSetMetadata {
|
||||
default_view: 'app' | 'graph'
|
||||
}
|
||||
|
||||
type ShareFlowStep =
|
||||
| 'dialog_opened'
|
||||
| 'save_prompted'
|
||||
@@ -386,8 +377,6 @@ export interface TelemetryProvider {
|
||||
// Workflow management events
|
||||
trackWorkflowImported?(metadata: WorkflowImportMetadata): void
|
||||
trackWorkflowOpened?(metadata: WorkflowImportMetadata): void
|
||||
trackWorkflowSaved?(metadata: WorkflowSavedMetadata): void
|
||||
trackDefaultViewSet?(metadata: DefaultViewSetMetadata): void
|
||||
trackEnterLinear?(metadata: EnterLinearMetadata): void
|
||||
trackShareFlow?(metadata: ShareFlowMetadata): void
|
||||
|
||||
@@ -501,8 +490,6 @@ export const TelemetryEvents = {
|
||||
|
||||
// Workflow Creation
|
||||
WORKFLOW_CREATED: 'app:workflow_created',
|
||||
WORKFLOW_SAVED: 'app:workflow_saved',
|
||||
DEFAULT_VIEW_SET: 'app:default_view_set',
|
||||
|
||||
// Execution Lifecycle
|
||||
EXECUTION_START: 'execution_start',
|
||||
@@ -553,6 +540,4 @@ export type TelemetryEventProperties =
|
||||
| WorkflowCreatedMetadata
|
||||
| EnterLinearMetadata
|
||||
| ShareFlowMetadata
|
||||
| WorkflowSavedMetadata
|
||||
| DefaultViewSetMetadata
|
||||
| SubscriptionMetadata
|
||||
|
||||
@@ -149,8 +149,6 @@ export const useWorkflowService = () => {
|
||||
await openWorkflow(tempWorkflow)
|
||||
await workflowStore.saveWorkflow(tempWorkflow)
|
||||
}
|
||||
|
||||
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: true })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -191,7 +189,6 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,16 +544,15 @@ export const useWorkflowService = () => {
|
||||
if (settingStore.get('Comfy.Workflow.ShowMissingNodesWarning')) {
|
||||
missingNodesDialog.show({ missingNodeTypes })
|
||||
}
|
||||
|
||||
executionErrorStore.surfaceMissingNodes(missingNodeTypes)
|
||||
}
|
||||
|
||||
// Missing models are NOT surfaced to the Errors tab here.
|
||||
// On Cloud, the dedicated pipeline in app.ts handles detection and
|
||||
// surfacing via surfaceMissingModels(). OSS uses only this dialog.
|
||||
if (missingModels) {
|
||||
if (settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')) {
|
||||
missingModelsDialog.show(missingModels)
|
||||
}
|
||||
if (
|
||||
missingModels &&
|
||||
settingStore.get('Comfy.Workflow.ShowMissingModelsWarning')
|
||||
) {
|
||||
missingModelsDialog.show(missingModels)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as I18n from 'vue-i18n'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
|
||||
import { useWorkflowPersistenceV2 } from './useWorkflowPersistenceV2'
|
||||
|
||||
const settingMocks = vi.hoisted(() => ({
|
||||
persistRef: null as { value: boolean } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
settingMocks.persistRef = ref(true)
|
||||
return {
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Workflow.Persist')
|
||||
return settingMocks.persistRef!.value
|
||||
return undefined
|
||||
}),
|
||||
set: vi.fn()
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const mockToastAdd = vi.fn()
|
||||
vi.mock('primevue', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader',
|
||||
() => ({
|
||||
useSharedWorkflowUrlLoader: () => ({
|
||||
loadSharedWorkflowFromUrl: vi.fn().mockResolvedValue('not-present')
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof I18n>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const openWorkflowMock = vi.fn()
|
||||
const loadBlankWorkflowMock = vi.fn()
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
openWorkflow: openWorkflowMock,
|
||||
loadBlankWorkflow: loadBlankWorkflowMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
|
||||
() => ({
|
||||
useTemplateUrlLoader: () => ({
|
||||
loadTemplateFromUrl: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
query: {}
|
||||
}),
|
||||
useRouter: () => ({
|
||||
replace: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
onUserLogout: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/navigation/preservedQueryManager', () => ({
|
||||
hydratePreservedQuery: vi.fn(),
|
||||
mergePreservedQueryIntoQuery: vi.fn(() => null)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
|
||||
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template' }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('../migration/migrateV1toV2', () => ({
|
||||
migrateV1toV2: vi.fn()
|
||||
}))
|
||||
|
||||
type GraphChangedHandler = (() => void) | null
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
graphChangedHandler: null as GraphChangedHandler,
|
||||
currentGraph: {} as Record<string, unknown>
|
||||
}
|
||||
const serializeMock = vi.fn(() => state.currentGraph)
|
||||
const loadGraphDataMock = vi.fn()
|
||||
const apiMock = {
|
||||
clientId: 'test-client',
|
||||
initialClientId: 'test-client',
|
||||
addEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'graphChanged') {
|
||||
state.graphChangedHandler = handler
|
||||
}
|
||||
}),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
return { state, serializeMock, loadGraphDataMock, apiMock }
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
serialize: () => mocks.serializeMock()
|
||||
},
|
||||
rootGraph: {
|
||||
serialize: () => mocks.serializeMock()
|
||||
},
|
||||
loadGraphData: (...args: unknown[]) => mocks.loadGraphDataMock(...args),
|
||||
canvas: {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: mocks.apiMock
|
||||
}))
|
||||
|
||||
describe('useWorkflowPersistenceV2', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
settingMocks.persistRef!.value = true
|
||||
mocks.state.graphChangedHandler = null
|
||||
mocks.state.currentGraph = { initial: true }
|
||||
mocks.serializeMock.mockImplementation(() => mocks.state.currentGraph)
|
||||
mocks.loadGraphDataMock.mockReset()
|
||||
mocks.apiMock.clientId = 'test-client'
|
||||
mocks.apiMock.initialClientId = 'test-client'
|
||||
mocks.apiMock.addEventListener.mockImplementation(
|
||||
(event: string, handler: () => void) => {
|
||||
if (event === 'graphChanged') {
|
||||
mocks.state.graphChangedHandler = handler
|
||||
}
|
||||
}
|
||||
)
|
||||
mocks.apiMock.removeEventListener.mockImplementation(() => {})
|
||||
openWorkflowMock.mockReset()
|
||||
loadBlankWorkflowMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function writeTabState(paths: string[], activeIndex: number) {
|
||||
const pointer = {
|
||||
workspaceId: 'personal',
|
||||
paths,
|
||||
activeIndex
|
||||
}
|
||||
sessionStorage.setItem(
|
||||
`Comfy.Workflow.OpenPaths:test-client`,
|
||||
JSON.stringify(pointer)
|
||||
)
|
||||
}
|
||||
|
||||
function writeActivePath(path: string) {
|
||||
const pointer = {
|
||||
workspaceId: 'personal',
|
||||
path
|
||||
}
|
||||
sessionStorage.setItem(
|
||||
`Comfy.Workflow.ActivePath:test-client`,
|
||||
JSON.stringify(pointer)
|
||||
)
|
||||
}
|
||||
|
||||
describe('loadPreviousWorkflowFromStorage', () => {
|
||||
it('loads saved workflow when draft is missing for session path', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const savedWorkflow = workflowStore.createTemporary('SavedWorkflow.json')
|
||||
|
||||
// Set session path to the saved workflow but do NOT create a draft
|
||||
writeActivePath(savedWorkflow.path)
|
||||
|
||||
const { initializeWorkflow } = useWorkflowPersistenceV2()
|
||||
await initializeWorkflow()
|
||||
|
||||
// Should call workflowService.openWorkflow with the saved workflow
|
||||
expect(openWorkflowMock).toHaveBeenCalledWith(savedWorkflow)
|
||||
// Should NOT fall through to loadGraphData (fallbackToLatestDraft)
|
||||
expect(mocks.loadGraphDataMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefers draft over saved workflow when draft exists', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
const workflow = workflowStore.createTemporary('DraftWorkflow.json')
|
||||
const draftData = JSON.stringify({ nodes: [], title: 'draft' })
|
||||
draftStore.saveDraft(workflow.path, draftData, {
|
||||
name: 'DraftWorkflow.json',
|
||||
isTemporary: true
|
||||
})
|
||||
writeActivePath(workflow.path)
|
||||
|
||||
mocks.loadGraphDataMock.mockResolvedValue(undefined)
|
||||
|
||||
const { initializeWorkflow } = useWorkflowPersistenceV2()
|
||||
await initializeWorkflow()
|
||||
|
||||
// Should load draft via loadGraphData, not via workflowService.openWorkflow
|
||||
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
|
||||
expect(openWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to latest draft only when no session path exists', async () => {
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
// No session path set, but a draft exists
|
||||
const draftData = JSON.stringify({ nodes: [], title: 'latest' })
|
||||
draftStore.saveDraft('workflows/Other.json', draftData, {
|
||||
name: 'Other.json',
|
||||
isTemporary: true
|
||||
})
|
||||
|
||||
mocks.loadGraphDataMock.mockResolvedValue(undefined)
|
||||
|
||||
const { initializeWorkflow } = useWorkflowPersistenceV2()
|
||||
await initializeWorkflow()
|
||||
|
||||
// Should load via fallbackToLatestDraft
|
||||
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
|
||||
expect(openWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreWorkflowTabsState', () => {
|
||||
it('activates the correct workflow at storedActiveIndex', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
// Create two temporary workflows with drafts
|
||||
const workflowA = workflowStore.createTemporary('WorkflowA.json')
|
||||
const workflowB = workflowStore.createTemporary('WorkflowB.json')
|
||||
|
||||
draftStore.saveDraft(workflowA.path, JSON.stringify({ title: 'A' }), {
|
||||
name: 'WorkflowA.json',
|
||||
isTemporary: true
|
||||
})
|
||||
draftStore.saveDraft(workflowB.path, JSON.stringify({ title: 'B' }), {
|
||||
name: 'WorkflowB.json',
|
||||
isTemporary: true
|
||||
})
|
||||
|
||||
// storedActiveIndex = 1 → WorkflowB should be activated
|
||||
writeTabState([workflowA.path, workflowB.path], 1)
|
||||
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
expect(openWorkflowMock).toHaveBeenCalledWith(workflowB)
|
||||
})
|
||||
|
||||
it('activates first tab when storedActiveIndex is 0', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
const workflowA = workflowStore.createTemporary('WorkflowA.json')
|
||||
const workflowB = workflowStore.createTemporary('WorkflowB.json')
|
||||
|
||||
draftStore.saveDraft(workflowA.path, JSON.stringify({ title: 'A' }), {
|
||||
name: 'WorkflowA.json',
|
||||
isTemporary: true
|
||||
})
|
||||
draftStore.saveDraft(workflowB.path, JSON.stringify({ title: 'B' }), {
|
||||
name: 'WorkflowB.json',
|
||||
isTemporary: true
|
||||
})
|
||||
|
||||
writeTabState([workflowA.path, workflowB.path], 0)
|
||||
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
expect(openWorkflowMock).toHaveBeenCalledWith(workflowA)
|
||||
})
|
||||
|
||||
it('does not call openWorkflow when no restorable state', async () => {
|
||||
// No tab state written to sessionStorage
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
expect(openWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores temporary workflows and adds them to tabs', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
// Save a draft for a workflow that doesn't exist in the store yet
|
||||
const path = 'workflows/Unsaved.json'
|
||||
draftStore.saveDraft(path, JSON.stringify({ title: 'Unsaved' }), {
|
||||
name: 'Unsaved.json',
|
||||
isTemporary: true
|
||||
})
|
||||
|
||||
writeTabState([path], 0)
|
||||
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
const restored = workflowStore.getWorkflowByPath(path)
|
||||
expect(restored).toBeTruthy()
|
||||
expect(restored?.isTemporary).toBe(true)
|
||||
expect(workflowStore.openWorkflows.map((w) => w?.path)).toContain(path)
|
||||
})
|
||||
|
||||
it('skips activation when persistence is disabled', async () => {
|
||||
settingMocks.persistRef!.value = false
|
||||
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
expect(openWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -132,28 +132,19 @@ export function useWorkflowPersistenceV2() {
|
||||
const debouncedPersist = debounce(persistCurrentWorkflow, PERSIST_DEBOUNCE_MS)
|
||||
|
||||
const loadPreviousWorkflowFromStorage = async () => {
|
||||
// 1. Try session pointer (for tab restoration)
|
||||
const sessionPath = tabState.getActivePath()
|
||||
|
||||
// 1. Try draft for session path
|
||||
if (
|
||||
sessionPath &&
|
||||
(await draftStore.loadPersistedWorkflow({
|
||||
workflowName: null,
|
||||
preferredPath: sessionPath
|
||||
}))
|
||||
)
|
||||
) {
|
||||
return true
|
||||
|
||||
// 2. Try saved workflow by path (draft may not exist for saved+unmodified workflows)
|
||||
if (sessionPath) {
|
||||
const saved = workflowStore.getWorkflowByPath(sessionPath)
|
||||
if (saved) {
|
||||
await useWorkflowService().openWorkflow(saved)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fall back to most recent draft
|
||||
// 2. Fall back to most recent draft
|
||||
return await draftStore.loadPersistedWorkflow({
|
||||
workflowName: null,
|
||||
fallbackToLatestDraft: true
|
||||
@@ -251,7 +242,7 @@ export function useWorkflowPersistenceV2() {
|
||||
}
|
||||
})
|
||||
|
||||
const restoreWorkflowTabsState = async () => {
|
||||
const restoreWorkflowTabsState = () => {
|
||||
if (!workflowPersistenceEnabled.value) {
|
||||
tabStateRestored = true
|
||||
return
|
||||
@@ -263,11 +254,10 @@ export function useWorkflowPersistenceV2() {
|
||||
const storedWorkflows = storedTabState?.paths ?? []
|
||||
const storedActiveIndex = storedTabState?.activeIndex ?? -1
|
||||
|
||||
tabStateRestored = true
|
||||
|
||||
const isRestorable = storedWorkflows.length > 0 && storedActiveIndex >= 0
|
||||
if (!isRestorable) {
|
||||
tabStateRestored = true
|
||||
return
|
||||
}
|
||||
if (!isRestorable) return
|
||||
|
||||
storedWorkflows.forEach((path: string) => {
|
||||
if (workflowStore.getWorkflowByPath(path)) return
|
||||
@@ -290,17 +280,6 @@ export function useWorkflowPersistenceV2() {
|
||||
left: storedWorkflows.slice(0, storedActiveIndex),
|
||||
right: storedWorkflows.slice(storedActiveIndex)
|
||||
})
|
||||
|
||||
tabStateRestored = true
|
||||
|
||||
// Activate the correct workflow at storedActiveIndex
|
||||
const activePath = storedWorkflows[storedActiveIndex]
|
||||
const workflow = activePath
|
||||
? workflowStore.getWorkflowByPath(activePath)
|
||||
: null
|
||||
if (workflow) {
|
||||
await useWorkflowService().openWorkflow(workflow)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,13 +3,9 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildSubgraphExecutionPaths,
|
||||
flattenWorkflowNodes,
|
||||
validateComfyWorkflow
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ComfyNode,
|
||||
ComfyWorkflowJSON
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { defaultGraph } from '@/scripts/defaultGraph'
|
||||
|
||||
const WORKFLOW_DIR = 'src/platform/workflow/validation/schemas/__fixtures__'
|
||||
@@ -278,48 +274,3 @@ describe('buildSubgraphExecutionPaths', () => {
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('flattenWorkflowNodes', () => {
|
||||
it('returns root nodes when no subgraphs exist', () => {
|
||||
const result = flattenWorkflowNodes({
|
||||
nodes: [node(1, 'KSampler'), node(2, 'CLIPLoader')]
|
||||
} as ComfyWorkflowJSON)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((n) => n.id)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('returns empty array when nodes is undefined', () => {
|
||||
const result = flattenWorkflowNodes({} as ComfyWorkflowJSON)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('includes subgraph nodes with prefixed IDs', () => {
|
||||
const result = flattenWorkflowNodes({
|
||||
nodes: [node(5, 'def-A')],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
subgraphDef('def-A', [node(10, 'Inner'), node(20, 'Inner2')])
|
||||
]
|
||||
}
|
||||
} as unknown as ComfyWorkflowJSON)
|
||||
|
||||
expect(result).toHaveLength(3) // 1 root + 2 subgraph
|
||||
expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:20'])
|
||||
})
|
||||
|
||||
it('prefixes nested subgraph nodes with full execution path', () => {
|
||||
const result = flattenWorkflowNodes({
|
||||
nodes: [node(5, 'def-A')],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
subgraphDef('def-A', [node(10, 'def-B')]),
|
||||
subgraphDef('def-B', [node(3, 'Leaf')])
|
||||
]
|
||||
}
|
||||
} as unknown as ComfyWorkflowJSON)
|
||||
|
||||
// root:5, def-A inner: 5:10, def-B inner: 5:10:3
|
||||
expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:10:3'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -592,56 +592,3 @@ export function buildSubgraphExecutionPaths(
|
||||
build(rootNodes, '')
|
||||
return pathMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all subgraph definitions from root and nested levels.
|
||||
*/
|
||||
function collectAllSubgraphDefs(rootDefs: unknown[]): SubgraphDefinition[] {
|
||||
const result: SubgraphDefinition[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
function collect(defs: unknown[]) {
|
||||
for (const def of defs) {
|
||||
if (!isSubgraphDefinition(def)) continue
|
||||
if (seen.has(def.id)) continue
|
||||
seen.add(def.id)
|
||||
result.push(def)
|
||||
if (def.definitions?.subgraphs?.length) {
|
||||
collect(def.definitions.subgraphs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect(rootDefs)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten all workflow nodes (root + subgraphs) into a single array.
|
||||
* Each node's `id` is prefixed with its execution path (e.g. node "3" inside container "11" → "11:3").
|
||||
*/
|
||||
export function flattenWorkflowNodes(
|
||||
graphData: ComfyWorkflowJSON
|
||||
): Readonly<ComfyNode>[] {
|
||||
const rootNodes = graphData.nodes ?? []
|
||||
const allDefs = collectAllSubgraphDefs(graphData.definitions?.subgraphs ?? [])
|
||||
const pathMap = buildSubgraphExecutionPaths(rootNodes, allDefs)
|
||||
|
||||
const allNodes: ComfyNode[] = [...rootNodes]
|
||||
|
||||
const subgraphDefMap = new Map(allDefs.map((s) => [s.id, s]))
|
||||
for (const [defId, paths] of pathMap.entries()) {
|
||||
const def = subgraphDefMap.get(defId)
|
||||
if (!def?.nodes) continue
|
||||
for (const prefix of paths) {
|
||||
for (const node of def.nodes) {
|
||||
allNodes.push({
|
||||
...node,
|
||||
id: `${prefix}:${node.id}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allNodes
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import DropZone from './DropZone.vue'
|
||||
|
||||
type StoryArgs = ComponentPropsAndSlots<typeof DropZone>
|
||||
|
||||
const defaultLabel = 'Click to browse or drag an image'
|
||||
const defaultIconClass = 'icon-[lucide--image]'
|
||||
|
||||
function createFileInput(onFile: (file: File) => void) {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/*'
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files?.[0]
|
||||
if (file) onFile(file)
|
||||
})
|
||||
return input
|
||||
}
|
||||
|
||||
function fileToObjectUrl(file: File): string {
|
||||
return URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
function extractDroppedImageFile(e: DragEvent): File | undefined {
|
||||
return Array.from(e.dataTransfer?.files ?? []).find((f) =>
|
||||
f.type.startsWith('image/')
|
||||
)
|
||||
}
|
||||
|
||||
const renderStory = (args: StoryArgs) => ({
|
||||
components: { DropZone },
|
||||
setup() {
|
||||
const imageUrl = ref<string | undefined>(undefined)
|
||||
const hovered = ref(false)
|
||||
|
||||
function handleFile(file: File) {
|
||||
imageUrl.value = fileToObjectUrl(file)
|
||||
}
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!e.dataTransfer?.items) return false
|
||||
return Array.from(e.dataTransfer.items).some(
|
||||
(item) => item.kind === 'file' && item.type.startsWith('image/')
|
||||
)
|
||||
}
|
||||
|
||||
const onDragDrop = (e: DragEvent) => {
|
||||
const file = extractDroppedImageFile(e)
|
||||
if (file) handleFile(file)
|
||||
return !!file
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
createFileInput(handleFile).click()
|
||||
}
|
||||
|
||||
const dropIndicator = ref({
|
||||
...args.dropIndicator,
|
||||
onClick
|
||||
})
|
||||
|
||||
return { args, onDragOver, onDragDrop, dropIndicator, imageUrl, hovered }
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
@mouseenter="hovered = true"
|
||||
@mouseleave="hovered = false"
|
||||
>
|
||||
<DropZone
|
||||
v-bind="args"
|
||||
:on-drag-over="onDragOver"
|
||||
:on-drag-drop="onDragDrop"
|
||||
:force-hovered="hovered"
|
||||
:drop-indicator="{
|
||||
...dropIndicator,
|
||||
imageUrl: imageUrl ?? dropIndicator.imageUrl
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Components/FileUpload',
|
||||
component: DropZone,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Linear mode drag-and-drop target with a file upload indicator. Click to browse or drag an image file to upload.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
onDragOver: { table: { disable: true } },
|
||||
onDragDrop: { table: { disable: true } },
|
||||
dropIndicator: { control: false },
|
||||
forceHovered: { table: { disable: true } }
|
||||
},
|
||||
args: {
|
||||
dropIndicator: {
|
||||
label: defaultLabel,
|
||||
iconClass: defaultIconClass
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: `
|
||||
<div class="w-[440px] rounded-xl bg-component-node-background p-4">
|
||||
<story />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: renderStory
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useDropZone } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
onDragOver,
|
||||
onDragDrop,
|
||||
dropIndicator,
|
||||
forceHovered = false
|
||||
} = defineProps<{
|
||||
const props = defineProps<{
|
||||
onDragOver?: (e: DragEvent) => boolean
|
||||
onDragDrop?: (e: DragEvent) => Promise<boolean> | boolean
|
||||
dropIndicator?: {
|
||||
@@ -18,7 +13,6 @@ const {
|
||||
label?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
}
|
||||
forceHovered?: boolean
|
||||
}>()
|
||||
|
||||
const dropZoneRef = ref<HTMLElement | null>(null)
|
||||
@@ -29,83 +23,53 @@ const { isOverDropZone } = useDropZone(dropZoneRef, {
|
||||
// Stop propagation to prevent global handlers from creating a new node
|
||||
event?.stopPropagation()
|
||||
|
||||
if (onDragDrop && event) {
|
||||
onDragDrop(event)
|
||||
if (props.onDragDrop && event) {
|
||||
props.onDragDrop(event)
|
||||
}
|
||||
canAcceptDrop.value = false
|
||||
},
|
||||
onOver: (_, event) => {
|
||||
if (onDragOver && event) {
|
||||
canAcceptDrop.value = onDragOver(event)
|
||||
if (props.onDragOver && event) {
|
||||
canAcceptDrop.value = props.onDragOver(event)
|
||||
}
|
||||
},
|
||||
onLeave: () => {
|
||||
canAcceptDrop.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const isHovered = computed(
|
||||
() => forceHovered || (canAcceptDrop.value && isOverDropZone.value)
|
||||
)
|
||||
const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="onDragOver && onDragDrop"
|
||||
ref="dropZoneRef"
|
||||
data-slot="drop-zone"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg transition-colors',
|
||||
isHovered && 'bg-component-node-widget-background-hovered'
|
||||
'rounded-lg ring-primary-500 ring-inset',
|
||||
canAcceptDrop && isOverDropZone && 'bg-primary-500/10 ring-4'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<component
|
||||
:is="indicatorTag"
|
||||
<div
|
||||
v-if="dropIndicator"
|
||||
:type="dropIndicator?.onClick ? 'button' : undefined"
|
||||
:aria-label="dropIndicator?.onClick ? dropIndicator.label : undefined"
|
||||
data-slot="drop-zone-indicator"
|
||||
:class="
|
||||
cn(
|
||||
'm-3 block w-[calc(100%-1.5rem)] appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
|
||||
'm-3 flex h-25 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-subtle py-2',
|
||||
dropIndicator?.onClick && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
@click.prevent="dropIndicator?.onClick?.($event)"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-23 w-full flex-col items-center justify-center gap-2 rounded-[7px] p-6 text-center text-sm/tight transition-colors',
|
||||
isHovered &&
|
||||
!dropIndicator?.imageUrl &&
|
||||
'border border-dashed border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
|
||||
)
|
||||
"
|
||||
>
|
||||
<img
|
||||
v-if="dropIndicator?.imageUrl"
|
||||
class="max-h-23 rounded-md object-contain"
|
||||
:alt="dropIndicator?.label ?? ''"
|
||||
:src="dropIndicator?.imageUrl"
|
||||
/>
|
||||
<template v-else>
|
||||
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
|
||||
<i
|
||||
v-if="dropIndicator.iconClass"
|
||||
:class="
|
||||
cn(
|
||||
'size-4 text-component-node-foreground-secondary',
|
||||
dropIndicator.iconClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</component>
|
||||
<img
|
||||
v-if="dropIndicator?.imageUrl"
|
||||
class="h-23"
|
||||
:src="dropIndicator?.imageUrl"
|
||||
/>
|
||||
<template v-else>
|
||||
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
|
||||
<i v-if="dropIndicator.iconClass" :class="dropIndicator.iconClass" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
@@ -64,7 +64,6 @@ useEventListener(
|
||||
)
|
||||
|
||||
const mappedSelections = computed(() => {
|
||||
void graphNodes.value
|
||||
let unprocessedInputs = appModeStore.selectedInputs.flatMap(
|
||||
([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import { ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
@@ -19,10 +19,6 @@ watch([containerRef, () => modelUrl], async () => {
|
||||
await viewer.value.initializeStandaloneViewer(containerRef.value, modelUrl)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
viewer.value.cleanup()
|
||||
})
|
||||
|
||||
//TODO: refactor to add control buttons
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -365,7 +365,6 @@ describe('linearOutputStore', () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
@@ -388,14 +387,12 @@ describe('linearOutputStore', () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
// Direct transition: job-1 → job-2 (no null in between)
|
||||
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-2'
|
||||
await nextTick()
|
||||
|
||||
@@ -634,7 +631,7 @@ describe('linearOutputStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves in-progress items when leaving app mode', async () => {
|
||||
it('resets state when leaving app mode', async () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
@@ -646,65 +643,9 @@ describe('linearOutputStore', () => {
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(store.inProgressItems.length).toBeGreaterThan(0)
|
||||
expect(store.selectedId).toBe('slot:some-id')
|
||||
})
|
||||
|
||||
it('completes stale tracked job when re-entering app mode', async () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
// Switch away — job finishes while we're gone
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
activeJobIdRef.value = null
|
||||
await nextTick()
|
||||
|
||||
// Switch back — store should reconcile the stale tracked job
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(store.pendingResolve.has('job-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('recovers latent preview when re-entering app mode', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
// First node executes, consuming the skeleton
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
expect(store.inProgressItems[0].state).toBe('image')
|
||||
|
||||
// Switch away — latent preview arrives for next node while gone
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
previewsRef.value = {
|
||||
'job-1': { url: 'blob:preview-while-away', nodeId: 'node-2' }
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
// Switch back — should recover the latent preview
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
const latentItems = store.inProgressItems.filter(
|
||||
(i) => i.state === 'latent'
|
||||
)
|
||||
expect(latentItems).toHaveLength(1)
|
||||
expect(latentItems[0].latentPreviewUrl).toBe('blob:preview-while-away')
|
||||
vi.useRealTimers()
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
expect(store.selectedId).toBeNull()
|
||||
expect(store.pendingResolve.size).toBe(0)
|
||||
})
|
||||
|
||||
it('does not show in-progress items from another workflow', () => {
|
||||
@@ -844,472 +785,4 @@ describe('linearOutputStore', () => {
|
||||
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('workflow switching during generation', () => {
|
||||
async function setup() {
|
||||
vi.useFakeTimers()
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
return { store, nextTick }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('preserves images and latent previews across tab switch', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: start job, produce 1 image + 1 latent
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
store.onLatentPreview('job-a', 'blob:node2-latent', '2')
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
const imagesBefore = store.inProgressItems.filter(
|
||||
(i) => i.state === 'image'
|
||||
)
|
||||
const latentsBefore = store.inProgressItems.filter(
|
||||
(i) => i.state === 'latent'
|
||||
)
|
||||
expect(imagesBefore).toHaveLength(1)
|
||||
expect(latentsBefore).toHaveLength(1)
|
||||
|
||||
// Switch to workflow B (graph mode)
|
||||
activeWorkflowPathRef.value = 'workflows/graph-b.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// Items still in store (not reset)
|
||||
expect(store.inProgressItems.filter((i) => i.state === 'image')).toEqual(
|
||||
imagesBefore
|
||||
)
|
||||
expect(store.inProgressItems.filter((i) => i.state === 'latent')).toEqual(
|
||||
latentsBefore
|
||||
)
|
||||
|
||||
// Switch back to workflow A
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
// Items visible via activeWorkflowInProgressItems
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(2)
|
||||
expect(
|
||||
store.activeWorkflowInProgressItems.some((i) => i.state === 'image')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('captures outputs produced while viewing another tab', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: start job, produce 1 image
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(1)
|
||||
|
||||
// Switch away
|
||||
activeWorkflowPathRef.value = 'workflows/graph-b.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// While away: node 2 executes (event missed — listener removed)
|
||||
// Node 3 starts sending latent previews (watcher guarded)
|
||||
previewsRef.value = {
|
||||
'job-a': { url: 'blob:node3-latent', nodeId: '3' }
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
// Switch back
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
// Original image preserved + latent preview recovered
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'latent')
|
||||
).toHaveLength(1)
|
||||
expect(store.inProgressItems[0].latentPreviewUrl).toBe(
|
||||
'blob:node3-latent'
|
||||
)
|
||||
})
|
||||
|
||||
it('scopes items to correct workflow after switching back', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: start job
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Switch to workflow B
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
|
||||
// Workflow A items should NOT appear for workflow B
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
||||
|
||||
// Switch back to workflow A
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
|
||||
// Workflow A items should reappear
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
|
||||
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-a')
|
||||
expect(store.activeWorkflowInProgressItems[0].state).toBe('image')
|
||||
})
|
||||
|
||||
it('completes job A while away and starts tracking job B on return', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: start and partially generate
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Switch to workflow B (graph mode)
|
||||
activeWorkflowPathRef.value = 'workflows/graph-b.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// While away: job A finishes, job B starts
|
||||
setJobWorkflowPath('job-b', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-b'
|
||||
await nextTick()
|
||||
|
||||
// Switch back to workflow A
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Job A should have been completed (pending resolve)
|
||||
expect(store.pendingResolve.has('job-a')).toBe(true)
|
||||
|
||||
// Job B should have been started
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-b')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles job finishing while away with no new job', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Switch away
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// Job finishes, no new job
|
||||
activeJobIdRef.value = null
|
||||
await nextTick()
|
||||
|
||||
// Switch back
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Job A completed, images pending resolve
|
||||
expect(store.pendingResolve.has('job-a')).toBe(true)
|
||||
// No new skeleton should have been created (no active job)
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'skeleton')
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not leak workflow A items into workflow B view', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: two images
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
store.onNodeExecuted(
|
||||
'job-a',
|
||||
makeExecutedDetail(
|
||||
'job-a',
|
||||
[{ filename: 'b.png', subfolder: '', type: 'output' }],
|
||||
'2'
|
||||
)
|
||||
)
|
||||
|
||||
// Items exist in the global list
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(2)
|
||||
|
||||
// Switch to workflow B (also app mode)
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
|
||||
// Workflow B should see nothing from job-a
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
||||
|
||||
// Global list still has them
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cleans up stale tracked job when leaving app mode after job finishes', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Job finishes while still in app mode
|
||||
store.onJobComplete('job-a')
|
||||
expect(store.pendingResolve.has('job-a')).toBe(true)
|
||||
|
||||
// Now switch away — pendingResolve items stay (still-running case
|
||||
// doesn't apply, but items are kept for history absorption)
|
||||
activeJobIdRef.value = null
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// Items preserved (pendingResolve is not cleared on exit)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-a')).toBe(true)
|
||||
})
|
||||
|
||||
it('evicts prior pendingResolve entries when a new job completes', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
// Job 1: produce image, complete
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
expect(store.pendingResolve.has('job-1')).toBe(true)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-1')).toBe(true)
|
||||
|
||||
// Job 2: produce image, complete — should evict job-1
|
||||
store.onJobStart('job-2')
|
||||
store.onNodeExecuted('job-2', makeExecutedDetail('job-2'))
|
||||
store.onJobComplete('job-2')
|
||||
|
||||
expect(store.pendingResolve.has('job-1')).toBe(false)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-1')).toBe(false)
|
||||
// Job 2 is now pending resolve
|
||||
expect(store.pendingResolve.has('job-2')).toBe(true)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('cleans up finished tracked job on exit when job ended while in app mode', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Job ends, new job starts — all while in app mode
|
||||
setJobWorkflowPath('job-b', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-b'
|
||||
await nextTick()
|
||||
|
||||
// job-a completed via activeJobId watcher, now pending resolve
|
||||
expect(store.pendingResolve.has('job-a')).toBe(true)
|
||||
|
||||
// Switch away — tracked job is now job-b which is still active, so
|
||||
// the else-branch does NOT complete it (it's still running)
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// job-b items preserved (still running)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-b')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not leak items across many job cycles', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const jobId = `job-${i}`
|
||||
setJobWorkflowPath(jobId, 'workflows/test-workflow.json')
|
||||
store.onJobStart(jobId)
|
||||
store.onNodeExecuted(jobId, makeExecutedDetail(jobId))
|
||||
store.onJobComplete(jobId)
|
||||
}
|
||||
|
||||
// Only the last job should have items (pending resolve).
|
||||
// All prior jobs were evicted by subsequent onJobComplete calls.
|
||||
expect(store.pendingResolve.size).toBe(1)
|
||||
expect(store.pendingResolve.has('job-5')).toBe(true)
|
||||
expect(store.inProgressItems.every((i) => i.jobId === 'job-5')).toBe(true)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not adopt another workflow job when switching back', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Tab A: queue "cat"
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-cat', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-cat'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted(
|
||||
'job-cat',
|
||||
makeExecutedDetail('job-cat', undefined, '1')
|
||||
)
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
|
||||
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-cat')
|
||||
|
||||
// Switch to tab B (different workflow, also app mode): queue "dog"
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
setJobWorkflowPath('job-dog', 'workflows/app-b.json')
|
||||
activeJobIdRef.value = 'job-dog'
|
||||
await nextTick()
|
||||
|
||||
// Tab B should see dog, not cat
|
||||
expect(
|
||||
store.activeWorkflowInProgressItems.every((i) => i.jobId === 'job-dog')
|
||||
).toBe(true)
|
||||
|
||||
// Switch back to tab A
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Dog's executed events arrive while viewing tab A (listener is
|
||||
// active and activeJobId is still job-dog).
|
||||
const event = new CustomEvent('executed', {
|
||||
detail: makeExecutedDetail(
|
||||
'job-dog',
|
||||
[{ filename: 'dog.png', subfolder: '', type: 'output' }],
|
||||
'1'
|
||||
)
|
||||
})
|
||||
apiTarget.dispatchEvent(event)
|
||||
|
||||
// Dog's latent preview also arrives
|
||||
previewsRef.value = {
|
||||
'job-dog': { url: 'blob:dog-latent', nodeId: '2' }
|
||||
}
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
// Tab A must NOT show dog — only cat
|
||||
const tabAItems = store.activeWorkflowInProgressItems
|
||||
expect(tabAItems.every((i) => i.jobId === 'job-cat')).toBe(true)
|
||||
expect(tabAItems.some((i) => i.jobId === 'job-dog')).toBe(false)
|
||||
|
||||
// Selection must not have been yanked to a dog item
|
||||
expect(store.selectedId?.includes('job-dog') ?? false).toBe(false)
|
||||
|
||||
// Dog should still exist globally (scoped to tab B)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-dog')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not create skeleton for next job from another workflow', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Run dog on dog tab
|
||||
activeWorkflowPathRef.value = 'workflows/dog.json'
|
||||
setJobWorkflowPath('job-dog', 'workflows/dog.json')
|
||||
activeJobIdRef.value = 'job-dog'
|
||||
await nextTick()
|
||||
|
||||
// Swap to cat tab, queue cat (dog still running)
|
||||
activeWorkflowPathRef.value = 'workflows/cat.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
setJobWorkflowPath('job-cat', 'workflows/cat.json')
|
||||
|
||||
// Swap back to dog tab
|
||||
activeWorkflowPathRef.value = 'workflows/dog.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Dog finishes, cat starts (activeJobId transitions on dog tab)
|
||||
activeJobIdRef.value = 'job-cat'
|
||||
await nextTick()
|
||||
|
||||
// Dog tab must NOT show cat's skeleton
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
||||
// No skeleton for cat should have been created at all
|
||||
expect(
|
||||
store.inProgressItems.some(
|
||||
(i) => i.jobId === 'job-cat' && i.state === 'skeleton'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('processes new executed events after switching back', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Switch away and back
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Fire an executed event via the API — listener should be re-attached
|
||||
const event = new CustomEvent('executed', {
|
||||
detail: makeExecutedDetail(
|
||||
'job-a',
|
||||
[{ filename: 'c.png', subfolder: '', type: 'output' }],
|
||||
'3'
|
||||
)
|
||||
})
|
||||
apiTarget.dispatchEvent(event)
|
||||
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -167,14 +167,6 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
}
|
||||
|
||||
function onJobComplete(jobId: string) {
|
||||
// On any job complete, remove all pending resolve items.
|
||||
if (pendingResolve.value.size > 0) {
|
||||
for (const oldJobId of pendingResolve.value) {
|
||||
removeJobItems(oldJobId)
|
||||
}
|
||||
pendingResolve.value = new Set()
|
||||
}
|
||||
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
@@ -234,16 +226,11 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
isFollowing.value = true
|
||||
}
|
||||
|
||||
function isJobForActiveWorkflow(jobId: string): boolean {
|
||||
return (
|
||||
executionStore.jobIdToSessionWorkflowPath.get(jobId) ===
|
||||
workflowStore.activeWorkflow?.path
|
||||
)
|
||||
}
|
||||
|
||||
function autoSelect(slotId: string, jobId: string) {
|
||||
// Only auto-select if the job belongs to the active workflow
|
||||
if (!isJobForActiveWorkflow(jobId)) return
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (path && executionStore.jobIdToSessionWorkflowPath.get(jobId) !== path)
|
||||
return
|
||||
|
||||
const sel = selectedId.value
|
||||
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
|
||||
@@ -262,6 +249,20 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
onNodeExecuted(jobId, detail)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
}
|
||||
executedNodeIds.clear()
|
||||
inProgressItems.value = []
|
||||
selectedId.value = null
|
||||
isFollowing.value = true
|
||||
trackedJobId.value = null
|
||||
currentSkeletonId.value = null
|
||||
pendingResolve.value = new Set()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => executionStore.activeJobId,
|
||||
(jobId, oldJobId) => {
|
||||
@@ -269,10 +270,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
if (oldJobId && oldJobId !== jobId) {
|
||||
onJobComplete(oldJobId)
|
||||
}
|
||||
// Start tracking only if the job belongs to this workflow.
|
||||
// Jobs from other workflows are picked up by reconcileOnEnter
|
||||
// when the user switches to that workflow's tab.
|
||||
if (jobId && isJobForActiveWorkflow(jobId)) {
|
||||
if (jobId) {
|
||||
onJobStart(jobId)
|
||||
}
|
||||
}
|
||||
@@ -290,66 +288,14 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function reconcileOnEnter() {
|
||||
// Complete any tracked job that finished while we were away.
|
||||
// The activeJobId watcher couldn't fire onJobComplete because
|
||||
// isAppMode was false at the time.
|
||||
if (
|
||||
trackedJobId.value &&
|
||||
trackedJobId.value !== executionStore.activeJobId
|
||||
) {
|
||||
onJobComplete(trackedJobId.value)
|
||||
}
|
||||
// Start tracking the current job only if it belongs to this
|
||||
// workflow — otherwise we'd adopt another tab's job.
|
||||
if (
|
||||
executionStore.activeJobId &&
|
||||
trackedJobId.value !== executionStore.activeJobId &&
|
||||
isJobForActiveWorkflow(executionStore.activeJobId)
|
||||
) {
|
||||
onJobStart(executionStore.activeJobId)
|
||||
}
|
||||
|
||||
// Clear stale selection from another workflow's job.
|
||||
if (
|
||||
selectedId.value?.startsWith('slot:') &&
|
||||
trackedJobId.value &&
|
||||
!isJobForActiveWorkflow(trackedJobId.value)
|
||||
) {
|
||||
selectedId.value = null
|
||||
isFollowing.value = true
|
||||
}
|
||||
|
||||
// Re-apply the latest latent preview that may have arrived while
|
||||
// away, but only for a job belonging to the active workflow.
|
||||
const jobId = trackedJobId.value
|
||||
if (jobId && isJobForActiveWorkflow(jobId)) {
|
||||
const preview = jobPreviewStore.nodePreviewsByPromptId[jobId]
|
||||
if (preview) onLatentPreview(jobId, preview.url, preview.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOnLeave() {
|
||||
// If the tracked job already finished (no longer the active job),
|
||||
// complete it now to clean up skeletons/latents. If it's still
|
||||
// running, preserve all items for tab switching.
|
||||
if (
|
||||
trackedJobId.value &&
|
||||
trackedJobId.value !== executionStore.activeJobId
|
||||
) {
|
||||
onJobComplete(trackedJobId.value)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
isAppMode,
|
||||
(active, wasActive) => {
|
||||
if (active) {
|
||||
api.addEventListener('executed', handleExecuted)
|
||||
reconcileOnEnter()
|
||||
} else if (wasActive) {
|
||||
api.removeEventListener('executed', handleExecuted)
|
||||
cleanupOnLeave()
|
||||
reset()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -187,46 +187,6 @@ describe('useMinimapViewport', () => {
|
||||
expect(transform.height).toBeCloseTo(viewportHeight * 0.5) // 300 * 0.5 = 150
|
||||
})
|
||||
|
||||
it('should maintain strict reference equality for viewportTransform when canvas state is unchanged', () => {
|
||||
vi.mocked(calculateNodeBounds).mockReturnValue({
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 500,
|
||||
maxY: 400,
|
||||
width: 500,
|
||||
height: 400
|
||||
})
|
||||
|
||||
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
|
||||
vi.mocked(calculateMinimapScale).mockReturnValue(0.5)
|
||||
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
|
||||
|
||||
mockCanvas.ds.scale = 2
|
||||
mockCanvas.ds.offset = [-100, -50]
|
||||
|
||||
viewport.updateBounds()
|
||||
viewport.updateCanvasDimensions()
|
||||
viewport.updateViewport()
|
||||
|
||||
const initialTransform = viewport.viewportTransform.value
|
||||
|
||||
viewport.updateViewport()
|
||||
const transformAfterIdle = viewport.viewportTransform.value
|
||||
|
||||
expect(transformAfterIdle).toBe(initialTransform)
|
||||
|
||||
mockCanvas.ds.offset = [-150, -50]
|
||||
viewport.updateViewport()
|
||||
const transformAfterPan = viewport.viewportTransform.value
|
||||
|
||||
expect(transformAfterPan).not.toBe(initialTransform)
|
||||
expect(transformAfterPan.x).not.toBe(initialTransform.x)
|
||||
})
|
||||
|
||||
it('should center view on world coordinates', () => {
|
||||
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
|
||||
const graphRef = ref(mockGraph) as Ref<LGraph | null>
|
||||
|
||||
@@ -90,16 +90,12 @@ export function useMinimapViewport(
|
||||
const centerOffsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const centerOffsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
const x = (worldX - bounds.value.minX) * scale.value + centerOffsetX
|
||||
const y = (worldY - bounds.value.minY) * scale.value + centerOffsetY
|
||||
const w = viewportWidth * scale.value
|
||||
const h = viewportHeight * scale.value
|
||||
|
||||
const curr = viewportTransform.value
|
||||
if (curr.x === x && curr.y === y && curr.width === w && curr.height === h)
|
||||
return
|
||||
|
||||
viewportTransform.value = { x, y, width: w, height: h }
|
||||
viewportTransform.value = {
|
||||
x: (worldX - bounds.value.minX) * scale.value + centerOffsetX,
|
||||
y: (worldY - bounds.value.minY) * scale.value + centerOffsetY,
|
||||
width: viewportWidth * scale.value,
|
||||
height: viewportHeight * scale.value
|
||||
}
|
||||
}
|
||||
|
||||
const updateBounds = () => {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ImagePreview from './ImagePreview.vue'
|
||||
|
||||
const SAMPLE_URLS = [
|
||||
'https://picsum.photos/seed/preview1/800/600',
|
||||
'https://picsum.photos/seed/preview2/800/600',
|
||||
'https://picsum.photos/seed/preview3/800/600'
|
||||
]
|
||||
|
||||
const meta: Meta<typeof ImagePreview> = {
|
||||
title: 'Components/Display/ImagePreview',
|
||||
component: ImagePreview,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Node output image preview with navigation dots, keyboard controls, and hover action buttons (download, remove, edit/mask).'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="h-80 w-96 rounded-lg bg-component-node-background"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
imageUrls: [SAMPLE_URLS[0]]
|
||||
}
|
||||
}
|
||||
|
||||
export const MultipleImages: Story = {
|
||||
args: {
|
||||
imageUrls: SAMPLE_URLS
|
||||
}
|
||||
}
|
||||
|
||||
export const ErrorState: Story = {
|
||||
args: {
|
||||
imageUrls: ['https://invalid.example.com/no-image.png']
|
||||
}
|
||||
}
|
||||
|
||||
export const ManyImages: Story = {
|
||||
args: {
|
||||
imageUrls: Array.from(
|
||||
{ length: 8 },
|
||||
(_, i) => `https://picsum.photos/seed/many${i}/800/600`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ describe('ImagePreview', () => {
|
||||
it('shows navigation dots for multiple images', () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
expect(navigationDots).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('ImagePreview', () => {
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
expect(navigationDots).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -249,7 +249,7 @@ describe('ImagePreview', () => {
|
||||
)
|
||||
|
||||
// Click second navigation dot
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
@@ -259,22 +259,22 @@ describe('ImagePreview', () => {
|
||||
expect(imgElement.attributes('src')).toBe(defaultProps.imageUrls[1])
|
||||
})
|
||||
|
||||
it('marks active navigation dot with aria-current', async () => {
|
||||
it('applies correct classes to navigation dots based on current image', async () => {
|
||||
const wrapper = mountImagePreview()
|
||||
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
|
||||
// First dot should be active
|
||||
expect(navigationDots[0].attributes('aria-current')).toBe('true')
|
||||
expect(navigationDots[1].attributes('aria-current')).toBeUndefined()
|
||||
// First dot should be active (has bg-white class)
|
||||
expect(navigationDots[0].classes()).toContain('bg-base-foreground')
|
||||
expect(navigationDots[1].classes()).toContain('bg-base-foreground/50')
|
||||
|
||||
// Switch to second image
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Second dot should now be active
|
||||
expect(navigationDots[0].attributes('aria-current')).toBeUndefined()
|
||||
expect(navigationDots[1].attributes('aria-current')).toBe('true')
|
||||
expect(navigationDots[0].classes()).toContain('bg-base-foreground/50')
|
||||
expect(navigationDots[1].classes()).toContain('bg-base-foreground')
|
||||
})
|
||||
|
||||
it('loads image without errors', async () => {
|
||||
@@ -301,7 +301,7 @@ describe('ImagePreview', () => {
|
||||
expect(wrapper.find('img').attributes('alt')).toBe('Node output 1')
|
||||
|
||||
// Switch to second image
|
||||
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
|
||||
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
await navigationDots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
@@ -326,7 +326,7 @@ describe('ImagePreview', () => {
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||
|
||||
// Click second navigation dot to cycle
|
||||
const dots = wrapper.findAll('[aria-label*="View image"]')
|
||||
const dots = wrapper.findAll('.w-2.h-2.rounded-full')
|
||||
await dots[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- Image Wrapper -->
|
||||
<div
|
||||
ref="imageWrapperEl"
|
||||
class="relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
|
||||
class="relative flex min-h-0 w-full flex-1 overflow-hidden rounded-[5px] bg-transparent"
|
||||
tabindex="0"
|
||||
role="img"
|
||||
:aria-label="$t('g.imagePreview')"
|
||||
@@ -33,19 +33,14 @@
|
||||
</div>
|
||||
<!-- Loading State -->
|
||||
<div v-if="showLoader && !imageError" class="size-full">
|
||||
<Skeleton class="size-full rounded-sm" />
|
||||
<Skeleton border-radius="5px" width="100%" height="100%" />
|
||||
</div>
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-if="!imageError"
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute inset-0 block size-full object-contain transition-opacity',
|
||||
(isHovered || isFocused) && 'opacity-60'
|
||||
)
|
||||
"
|
||||
class="pointer-events-none absolute inset-0 block size-full object-contain"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
@@ -53,7 +48,7 @@
|
||||
<!-- Floating Action Buttons (appear on hover and focus) -->
|
||||
<div
|
||||
v-if="isHovered || isFocused"
|
||||
class="actions absolute top-2 right-2 flex gap-1"
|
||||
class="actions absolute top-2 right-2 flex gap-2.5"
|
||||
>
|
||||
<!-- Mask/Edit Button -->
|
||||
<button
|
||||
@@ -83,7 +78,7 @@
|
||||
:aria-label="$t('g.removeImage')"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<i class="icon-[lucide--circle-x] size-4" />
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,16 +119,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { useToast } from 'primevue'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
/** Array of image URLs to display */
|
||||
@@ -147,10 +141,9 @@ const props = defineProps<ImagePreviewProps>()
|
||||
const { t } = useI18n()
|
||||
const maskEditor = useMaskEditor()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const actionButtonClass =
|
||||
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
|
||||
'flex h-8 min-h-8 items-center justify-center gap-2.5 rounded-lg border-0 bg-button-surface px-2 py-2 text-button-surface-contrast shadow-sm transition-colors duration-200 hover:bg-button-hover-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-button-surface-contrast focus-visible:ring-offset-2 focus-visible:ring-offset-transparent cursor-pointer'
|
||||
|
||||
// Component state
|
||||
const currentIndex = ref(0)
|
||||
@@ -236,10 +229,11 @@ const handleDownload = () => {
|
||||
try {
|
||||
downloadFile(currentImageUrl.value)
|
||||
} catch (error) {
|
||||
toastStore.add({
|
||||
useToast().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
summary: 'Error',
|
||||
detail: t('g.failedToDownloadImage'),
|
||||
group: 'image-preview'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -286,13 +280,13 @@ const handleFocusOut = (event: FocusEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
function getNavigationDotClass(index: number) {
|
||||
return cn(
|
||||
'size-2 cursor-pointer rounded-full border-0 p-0 transition-all duration-200',
|
||||
const getNavigationDotClass = (index: number) => {
|
||||
return [
|
||||
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer p-0',
|
||||
index === currentIndex.value
|
||||
? 'bg-base-foreground'
|
||||
: 'bg-base-foreground/50 hover:bg-base-foreground/80'
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
|
||||
@@ -289,7 +289,6 @@ import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useN
|
||||
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
|
||||
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -340,7 +339,6 @@ const isSelected = computed(() => {
|
||||
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
|
||||
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const hasExecutionError = computed(
|
||||
() => executionErrorStore.lastExecutionErrorNodeId === nodeData.id
|
||||
)
|
||||
@@ -351,11 +349,9 @@ const hasAnyError = computed((): boolean => {
|
||||
nodeData.hasErrors ||
|
||||
error ||
|
||||
executionErrorStore.getNodeErrors(nodeLocatorId.value) ||
|
||||
missingModelStore.hasMissingModelOnNode(nodeLocatorId.value) ||
|
||||
(lgraphNode.value &&
|
||||
(executionErrorStore.isContainerWithInternalError(lgraphNode.value) ||
|
||||
executionErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
|
||||
missingModelStore.isContainerWithMissingModel(lgraphNode.value)))
|
||||
executionErrorStore.isContainerWithMissingNode(lgraphNode.value)))
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -115,7 +115,6 @@ import {
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -135,7 +134,6 @@ const canvasStore = useCanvasStore()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
const promotionStore = usePromotionStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
function handleWidgetPointerEvent(event: PointerEvent) {
|
||||
if (shouldHandleNodePointerEvents.value) return
|
||||
@@ -204,7 +202,6 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
const graphId = canvasStore.canvas?.graph?.rootGraph.id
|
||||
|
||||
const nodeId = nodeData.id
|
||||
const nodeIdStr = String(nodeId)
|
||||
const { widgets } = nodeData
|
||||
const result: ProcessedWidget[] = []
|
||||
|
||||
@@ -287,11 +284,9 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
handleContextMenu,
|
||||
hasLayoutSize: widget.hasLayoutSize ?? false,
|
||||
hasError:
|
||||
(nodeErrors?.errors?.some(
|
||||
nodeErrors?.errors?.some(
|
||||
(error) => error.extra_info?.input_name === widget.name
|
||||
) ??
|
||||
false) ||
|
||||
missingModelStore.isWidgetMissingModel(nodeIdStr, widget.name),
|
||||
) ?? false,
|
||||
hidden: widget.options?.hidden ?? false,
|
||||
id: String(bareWidgetId),
|
||||
name: widget.name,
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import DisplayCarousel from './DisplayCarousel.vue'
|
||||
import type { GalleryImage, GalleryValue } from './DisplayCarousel.vue'
|
||||
|
||||
const SAMPLE_IMAGES = [
|
||||
'https://picsum.photos/seed/comfy1/600/400',
|
||||
'https://picsum.photos/seed/comfy2/600/400',
|
||||
'https://picsum.photos/seed/comfy3/600/400',
|
||||
'https://picsum.photos/seed/comfy4/600/400',
|
||||
'https://picsum.photos/seed/comfy5/600/400'
|
||||
]
|
||||
|
||||
const SAMPLE_IMAGE_OBJECTS: GalleryImage[] = [
|
||||
{
|
||||
itemImageSrc: 'https://picsum.photos/seed/obj1/600/400',
|
||||
thumbnailImageSrc: 'https://picsum.photos/seed/obj1/120/80',
|
||||
alt: 'Mountain landscape'
|
||||
},
|
||||
{
|
||||
itemImageSrc: 'https://picsum.photos/seed/obj2/600/400',
|
||||
thumbnailImageSrc: 'https://picsum.photos/seed/obj2/120/80',
|
||||
alt: 'Ocean view'
|
||||
},
|
||||
{
|
||||
itemImageSrc: 'https://picsum.photos/seed/obj3/600/400',
|
||||
thumbnailImageSrc: 'https://picsum.photos/seed/obj3/120/80',
|
||||
alt: 'Forest path'
|
||||
}
|
||||
]
|
||||
|
||||
const meta: Meta<typeof DisplayCarousel> = {
|
||||
title: 'Components/Display/DisplayCarousel',
|
||||
component: DisplayCarousel,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Image gallery with Single (carousel) and Grid display modes. Hover to reveal a toggle button that switches between modes. Grid mode shows images in a responsive grid; clicking an image switches back to single mode focused on that image.'
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-80 rounded-xl bg-component-node-background p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([...SAMPLE_IMAGES])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const SingleImage: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([SAMPLE_IMAGES[0]])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithImageObjects: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([...SAMPLE_IMAGE_OBJECTS])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const GridFewImages: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([...SAMPLE_IMAGES.slice(0, 4)])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget, displayMode: ref('grid') }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const GridManyImages: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>(
|
||||
Array.from(
|
||||
{ length: 25 },
|
||||
(_, i) => `https://picsum.photos/seed/grid${i}/200/200`
|
||||
)
|
||||
)
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => ({
|
||||
components: { DisplayCarousel },
|
||||
setup() {
|
||||
const value = ref<GalleryValue>([])
|
||||
const widget: SimplifiedWidget<GalleryValue, Record<string, unknown>> = {
|
||||
name: 'gallery',
|
||||
type: 'array',
|
||||
value: []
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<DisplayCarousel :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import DisplayCarousel from './DisplayCarousel.vue'
|
||||
import type { GalleryImage, GalleryValue } from './DisplayCarousel.vue'
|
||||
import { createMockWidget } from './widgetTestUtils'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
galleryImage: 'Gallery image',
|
||||
galleryThumbnail: 'Gallery thumbnail',
|
||||
previousImage: 'Previous image',
|
||||
nextImage: 'Next image',
|
||||
switchToGridView: 'Switch to grid view',
|
||||
switchToSingleView: 'Switch to single view',
|
||||
viewImageOfTotal: 'View image {index} of {total}',
|
||||
editOrMaskImage: 'Edit or mask image',
|
||||
downloadImage: 'Download image',
|
||||
removeImage: 'Remove image'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const TEST_IMAGES_SMALL: readonly string[] = Object.freeze([
|
||||
'https://example.com/image0.jpg',
|
||||
'https://example.com/image1.jpg',
|
||||
'https://example.com/image2.jpg'
|
||||
])
|
||||
|
||||
const TEST_IMAGES_SINGLE: readonly string[] = Object.freeze([
|
||||
'https://example.com/single.jpg'
|
||||
])
|
||||
|
||||
const TEST_IMAGE_OBJECTS: readonly GalleryImage[] = Object.freeze([
|
||||
{
|
||||
itemImageSrc: 'https://example.com/image0.jpg',
|
||||
thumbnailImageSrc: 'https://example.com/thumb0.jpg',
|
||||
alt: 'Test image 0'
|
||||
},
|
||||
{
|
||||
itemImageSrc: 'https://example.com/image1.jpg',
|
||||
thumbnailImageSrc: 'https://example.com/thumb1.jpg',
|
||||
alt: 'Test image 1'
|
||||
}
|
||||
])
|
||||
|
||||
function createGalleriaWidget(
|
||||
value: GalleryValue = [],
|
||||
options: Record<string, unknown> = {}
|
||||
) {
|
||||
return createMockWidget<GalleryValue>({
|
||||
value,
|
||||
name: 'test_galleria',
|
||||
type: 'array',
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
widget: SimplifiedWidget<GalleryValue>,
|
||||
modelValue: GalleryValue
|
||||
) {
|
||||
return mount(DisplayCarousel, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n]
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createImageStrings(count: number): string[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => `https://example.com/image${i}.jpg`
|
||||
)
|
||||
}
|
||||
|
||||
function createGalleriaWrapper(
|
||||
images: GalleryValue,
|
||||
options: Record<string, unknown> = {}
|
||||
) {
|
||||
const widget = createGalleriaWidget(images, options)
|
||||
return mountComponent(widget, images)
|
||||
}
|
||||
|
||||
function findThumbnails(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.findAll('div').filter((div) => {
|
||||
return div.find('img').exists() && div.classes().includes('border-2')
|
||||
})
|
||||
}
|
||||
|
||||
function findImageContainer(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.find('[tabindex="0"]')
|
||||
}
|
||||
|
||||
describe('DisplayCarousel Single Mode', () => {
|
||||
describe('Component Rendering', () => {
|
||||
it('renders main image', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe(TEST_IMAGES_SMALL[0])
|
||||
})
|
||||
|
||||
it('displays empty gallery when no images provided', () => {
|
||||
const wrapper = createGalleriaWrapper([])
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('handles null value gracefully', () => {
|
||||
const widget = createGalleriaWidget([])
|
||||
const wrapper = mountComponent(widget, null as unknown as GalleryValue)
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('handles undefined value gracefully', () => {
|
||||
const widget = createGalleriaWidget([])
|
||||
const wrapper = mountComponent(
|
||||
widget,
|
||||
undefined as unknown as GalleryValue
|
||||
)
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('String Array Input', () => {
|
||||
it('converts string array to image objects and displays first', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('https://example.com/image0.jpg')
|
||||
})
|
||||
|
||||
it('handles single string image', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('https://example.com/single.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Object Array Input', () => {
|
||||
it('preserves image objects and displays first', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGE_OBJECTS])
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('https://example.com/image0.jpg')
|
||||
expect(img.attributes('alt')).toBe('Test image 0')
|
||||
})
|
||||
|
||||
it('handles mixed object properties with src fallback', () => {
|
||||
const images: GalleryImage[] = [
|
||||
{ src: 'https://example.com/image1.jpg', alt: 'First' }
|
||||
]
|
||||
const wrapper = createGalleriaWrapper(images)
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('https://example.com/image1.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Thumbnail Display', () => {
|
||||
it('shows thumbnails when multiple images present', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const thumbnailButtons = findThumbnails(wrapper)
|
||||
expect(thumbnailButtons).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('hides thumbnails for single image', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
|
||||
|
||||
const thumbnailButtons = findThumbnails(wrapper)
|
||||
expect(thumbnailButtons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('thumbnails are not interactive', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
const thumbnails = findThumbnails(wrapper)
|
||||
expect(thumbnails[0].element.tagName).not.toBe('BUTTON')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigation Buttons', () => {
|
||||
it('shows navigation buttons when multiple images present', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="Next image"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides navigation buttons for single image', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="Next image"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('respects widget option to hide navigation buttons', () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL], {
|
||||
showItemNavigators: false
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('navigates to next image on next click', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image1.jpg')
|
||||
})
|
||||
|
||||
it('navigates to previous image on prev click', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Go to second image first
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Go back
|
||||
await wrapper.find('[aria-label="Previous image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image0.jpg')
|
||||
})
|
||||
|
||||
it('wraps from first to last image on previous click', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
await wrapper.find('[aria-label="Previous image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image2.jpg')
|
||||
})
|
||||
|
||||
it('wraps from last to first image on next click', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Navigate to last image
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Next from last should wrap to first
|
||||
await wrapper.find('[aria-label="Next image"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image0.jpg')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DisplayCarousel Accessibility', () => {
|
||||
it('shows controls on focusin for keyboard users', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(wrapper.find('[aria-label="Edit or mask image"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('hides controls on focusout when focus leaves component', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
// Focus leaves the image container entirely
|
||||
await findImageContainer(wrapper).trigger('focusout', {
|
||||
relatedTarget: null
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DisplayCarousel Grid Mode', () => {
|
||||
it('switches to grid mode via toggle button on hover', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Trigger focus on image container to reveal toggle button
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
const toggleBtn = wrapper.find('[aria-label="Switch to grid view"]')
|
||||
expect(toggleBtn.exists()).toBe(true)
|
||||
|
||||
await toggleBtn.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Grid mode should show all images as grid items
|
||||
const gridImages = wrapper.findAll('img')
|
||||
expect(gridImages).toHaveLength(TEST_IMAGES_SMALL.length)
|
||||
})
|
||||
|
||||
it('does not show grid toggle for single image', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SINGLE])
|
||||
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[aria-label="Switch to grid view"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('switches back to single mode via toggle button', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Switch to grid via focus on image container
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
await wrapper.find('[aria-label="Switch to grid view"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Focus the grid container to reveal toggle
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
|
||||
// Switch back to single
|
||||
const singleToggle = wrapper.find('[aria-label="Switch to single view"]')
|
||||
expect(singleToggle.exists()).toBe(true)
|
||||
|
||||
await singleToggle.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Should be back in single mode with main image
|
||||
expect(wrapper.find('[aria-label="Previous image"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('clicking grid image switches to single mode focused on that image', async () => {
|
||||
const wrapper = createGalleriaWrapper([...TEST_IMAGES_SMALL])
|
||||
|
||||
// Switch to grid via focus on image container
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
await wrapper.find('[aria-label="Switch to grid view"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Click second grid image
|
||||
const gridButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((btn) => btn.find('img').exists())
|
||||
await gridButtons[1].trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Should be in single mode showing the second image
|
||||
const mainImg = wrapper.findAll('img')[0]
|
||||
expect(mainImg.attributes('src')).toBe('https://example.com/image1.jpg')
|
||||
})
|
||||
|
||||
it('reverts to single mode when images reduce to one', async () => {
|
||||
const images = ref<GalleryValue>([...TEST_IMAGES_SMALL])
|
||||
const widget = createGalleriaWidget([...TEST_IMAGES_SMALL])
|
||||
const wrapper = mount(DisplayCarousel, {
|
||||
global: { plugins: [createTestingPinia({ createSpy: vi.fn }), i18n] },
|
||||
props: { widget, modelValue: images.value }
|
||||
})
|
||||
|
||||
// Switch to grid via focus on image container
|
||||
await findImageContainer(wrapper).trigger('focusin')
|
||||
await nextTick()
|
||||
await wrapper.find('[aria-label="Switch to grid view"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Reduce to single image
|
||||
await wrapper.setProps({ modelValue: [TEST_IMAGES_SMALL[0]] })
|
||||
await nextTick()
|
||||
|
||||
// Should revert to single mode (no grid toggle visible)
|
||||
expect(wrapper.find('[aria-label="Switch to single view"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DisplayCarousel Edge Cases', () => {
|
||||
it('handles empty array gracefully', () => {
|
||||
const wrapper = createGalleriaWrapper([])
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(findThumbnails(wrapper)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('filters out malformed image objects without valid src', () => {
|
||||
const malformedImages = [{}, { randomProp: 'value' }, null, undefined]
|
||||
const wrapper = createGalleriaWrapper(malformedImages as string[])
|
||||
|
||||
// All filtered out: null/undefined removed, then objects without src filtered
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('handles very large image arrays', () => {
|
||||
const largeImageArray = createImageStrings(100)
|
||||
const wrapper = createGalleriaWrapper(largeImageArray)
|
||||
|
||||
const thumbnailButtons = findThumbnails(wrapper)
|
||||
expect(thumbnailButtons).toHaveLength(100)
|
||||
})
|
||||
|
||||
it('handles mixed string and object arrays gracefully', () => {
|
||||
const mixedArray = [
|
||||
'https://example.com/string.jpg',
|
||||
{ itemImageSrc: 'https://example.com/object.jpg' },
|
||||
'https://example.com/another-string.jpg'
|
||||
]
|
||||
expect(() => createGalleriaWrapper(mixedArray as string[])).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles invalid URL strings without crashing', () => {
|
||||
const invalidUrls = ['not-a-url', 'http://', 'ftp://invalid']
|
||||
const wrapper = createGalleriaWrapper(invalidUrls)
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('filters out empty string URLs', () => {
|
||||
const wrapper = createGalleriaWrapper([''])
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,408 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex max-w-full flex-col rounded-lg bg-component-node-widget-background"
|
||||
>
|
||||
<!-- Single Mode -->
|
||||
<template v-if="displayMode === 'single'">
|
||||
<div class="flex flex-col gap-1 p-4">
|
||||
<!-- Main Image Container -->
|
||||
<div
|
||||
ref="imageContainerEl"
|
||||
class="relative flex cursor-pointer items-center justify-center"
|
||||
tabindex="0"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
@focusin="isFocused = true"
|
||||
@focusout="handleFocusOut"
|
||||
>
|
||||
<img
|
||||
v-if="activeItem"
|
||||
:src="getItemSrc(activeItem)"
|
||||
:alt="getItemAlt(activeItem, activeIndex)"
|
||||
:class="
|
||||
cn(
|
||||
'h-auto w-full rounded-sm object-contain transition-opacity',
|
||||
showControls && 'opacity-50'
|
||||
)
|
||||
"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
|
||||
<!-- Toggle to Grid (hover, top-left) -->
|
||||
<button
|
||||
v-if="showControls && galleryImages.length > 1"
|
||||
:class="toggleButtonClass"
|
||||
class="absolute top-2 left-2"
|
||||
:aria-label="t('g.switchToGridView')"
|
||||
@click="switchToGrid"
|
||||
>
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
</button>
|
||||
|
||||
<!-- Action Buttons (hover, top-right) -->
|
||||
<div
|
||||
v-if="showControls && activeItem"
|
||||
class="absolute top-2 right-2 flex gap-1"
|
||||
>
|
||||
<button
|
||||
:class="actionButtonClass"
|
||||
:aria-label="t('g.editOrMaskImage')"
|
||||
@click="handleEditMask"
|
||||
>
|
||||
<i-comfy:mask class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
:class="actionButtonClass"
|
||||
:aria-label="t('g.downloadImage')"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-down-to-line] size-4" />
|
||||
</button>
|
||||
<button
|
||||
:class="actionButtonClass"
|
||||
:aria-label="t('g.removeImage')"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Dimensions -->
|
||||
<p
|
||||
:class="
|
||||
cn(
|
||||
'text-center text-xs text-component-node-foreground-secondary',
|
||||
!imageDimensions && 'invisible'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ imageDimensions || '\u00A0' }}
|
||||
</p>
|
||||
|
||||
<!-- Thumbnail Strip with Navigation -->
|
||||
<div
|
||||
v-if="showMultipleImages || showNavButtons"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<!-- Previous Button -->
|
||||
<button
|
||||
v-if="showNavButtons"
|
||||
:class="navButtonClass"
|
||||
:aria-label="t('g.previousImage')"
|
||||
@click="goToPrevious"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-left] size-3.5" />
|
||||
</button>
|
||||
|
||||
<!-- Thumbnails -->
|
||||
<div
|
||||
v-if="showMultipleImages"
|
||||
class="flex min-w-0 flex-1 items-center gap-1 overflow-x-hidden scroll-smooth py-1"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in galleryImages"
|
||||
:key="getItemSrc(item)"
|
||||
:ref="(el) => setThumbnailRef(el as HTMLElement | null, index)"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 overflow-hidden rounded-lg p-1 transition-colors',
|
||||
index === activeIndex
|
||||
? 'border-2 border-base-foreground'
|
||||
: 'border-2 border-transparent'
|
||||
)
|
||||
"
|
||||
:aria-label="getItemAlt(item, index)"
|
||||
>
|
||||
<img
|
||||
:src="getItemThumbnail(item)"
|
||||
:alt="getItemAlt(item, index)"
|
||||
class="size-10 rounded-sm object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Button -->
|
||||
<button
|
||||
v-if="showNavButtons"
|
||||
:class="navButtonClass"
|
||||
:aria-label="t('g.nextImage')"
|
||||
@click="goToNext"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-right] size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Grid Mode -->
|
||||
<template v-else>
|
||||
<div class="p-4">
|
||||
<div
|
||||
ref="gridContainerEl"
|
||||
class="relative h-72 overflow-x-hidden overflow-y-auto rounded-sm bg-component-node-background"
|
||||
tabindex="0"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
@focusin="isFocused = true"
|
||||
@focusout="handleFocusOut"
|
||||
>
|
||||
<!-- Toggle to Single (hover, top-left) -->
|
||||
<button
|
||||
v-if="showControls"
|
||||
:class="toggleButtonClass"
|
||||
class="absolute top-2 left-2 z-10"
|
||||
:aria-label="t('g.switchToSingleView')"
|
||||
@click="switchToSingle"
|
||||
>
|
||||
<i class="icon-[lucide--square] size-4" />
|
||||
</button>
|
||||
|
||||
<div class="flex flex-wrap content-start gap-1">
|
||||
<button
|
||||
v-for="(item, index) in galleryImages"
|
||||
:key="getItemSrc(item)"
|
||||
class="size-14 shrink-0 cursor-pointer overflow-hidden border-0 p-0"
|
||||
:aria-label="getItemAlt(item, index)"
|
||||
@mouseenter="hoveredGridIndex = index"
|
||||
@mouseleave="hoveredGridIndex = -1"
|
||||
@click="selectFromGrid(index)"
|
||||
>
|
||||
<img
|
||||
:src="getItemThumbnail(item)"
|
||||
:alt="getItemAlt(item, index)"
|
||||
:class="
|
||||
cn(
|
||||
'size-full object-cover transition-opacity',
|
||||
hoveredGridIndex === index && 'opacity-50'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
export interface GalleryImage {
|
||||
itemImageSrc?: string
|
||||
thumbnailImageSrc?: string
|
||||
src?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export type GalleryValue = string[] | GalleryImage[]
|
||||
|
||||
type DisplayMode = 'single' | 'grid'
|
||||
|
||||
interface GalleryOptions {
|
||||
showItemNavigators?: boolean
|
||||
}
|
||||
|
||||
const value = defineModel<GalleryValue>({ required: true })
|
||||
|
||||
const { widget, nodeId } = defineProps<{
|
||||
widget: SimplifiedWidget<GalleryValue>
|
||||
nodeId?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const maskEditor = useMaskEditor()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const activeIndex = ref(0)
|
||||
const displayMode = ref<DisplayMode>('single')
|
||||
const isHovered = ref(false)
|
||||
const isFocused = ref(false)
|
||||
const hoveredGridIndex = ref(-1)
|
||||
const imageDimensions = ref<string | null>(null)
|
||||
const thumbnailRefs = ref<(HTMLElement | null)[]>([])
|
||||
const imageContainerEl = ref<HTMLDivElement>()
|
||||
const gridContainerEl = ref<HTMLDivElement>()
|
||||
|
||||
const showControls = computed(() => isHovered.value || isFocused.value)
|
||||
|
||||
const options = computed<GalleryOptions>(() => widget.options ?? {})
|
||||
|
||||
const galleryImages = computed<GalleryImage[]>(() => {
|
||||
if (!value.value || !Array.isArray(value.value)) return []
|
||||
|
||||
return value.value.flatMap((item) => {
|
||||
if (item === null || item === undefined) return []
|
||||
const image =
|
||||
typeof item === 'string'
|
||||
? { itemImageSrc: item, thumbnailImageSrc: item }
|
||||
: item
|
||||
return image.itemImageSrc || image.src ? [image] : []
|
||||
})
|
||||
})
|
||||
|
||||
const activeItem = computed(() => galleryImages.value[activeIndex.value])
|
||||
|
||||
const showMultipleImages = computed(() => galleryImages.value.length > 1)
|
||||
|
||||
const showNavButtons = computed(
|
||||
() => options.value.showItemNavigators !== false && showMultipleImages.value
|
||||
)
|
||||
|
||||
const actionButtonClass =
|
||||
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-md transition-colors hover:bg-base-foreground/90'
|
||||
|
||||
const toggleButtonClass = actionButtonClass
|
||||
|
||||
const navButtonClass =
|
||||
'flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg border-0 bg-secondary-background text-component-node-foreground-secondary transition-colors'
|
||||
|
||||
watch(galleryImages, (images) => {
|
||||
thumbnailRefs.value = thumbnailRefs.value.slice(0, images.length)
|
||||
imageDimensions.value = null
|
||||
if (images.length === 0) {
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
if (activeIndex.value >= images.length) {
|
||||
activeIndex.value = images.length - 1
|
||||
}
|
||||
if (images.length <= 1) {
|
||||
displayMode.value = 'single'
|
||||
}
|
||||
})
|
||||
|
||||
function getItemSrc(item: GalleryImage): string {
|
||||
return item.itemImageSrc || item.src || ''
|
||||
}
|
||||
|
||||
function getItemThumbnail(item: GalleryImage): string {
|
||||
return item.thumbnailImageSrc || item.itemImageSrc || item.src || ''
|
||||
}
|
||||
|
||||
function getItemAlt(item: GalleryImage, index: number): string {
|
||||
return (
|
||||
item.alt ||
|
||||
t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
total: galleryImages.value.length
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function handleFocusOut(event: FocusEvent) {
|
||||
const container =
|
||||
displayMode.value === 'single'
|
||||
? imageContainerEl.value
|
||||
: gridContainerEl.value
|
||||
if (!container?.contains(event.relatedTarget as Node)) {
|
||||
isFocused.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageLoad(event: Event) {
|
||||
if (!(event.target instanceof HTMLImageElement)) return
|
||||
const { naturalWidth, naturalHeight } = event.target
|
||||
if (naturalWidth && naturalHeight) {
|
||||
imageDimensions.value = `${naturalWidth} x ${naturalHeight}`
|
||||
}
|
||||
}
|
||||
|
||||
function setThumbnailRef(el: HTMLElement | null, index: number) {
|
||||
thumbnailRefs.value[index] = el
|
||||
}
|
||||
|
||||
function scrollToActive() {
|
||||
void nextTick(() => {
|
||||
const el = thumbnailRefs.value[activeIndex.value]
|
||||
if (el) {
|
||||
el.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'center'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goToPrevious() {
|
||||
activeIndex.value =
|
||||
activeIndex.value > 0
|
||||
? activeIndex.value - 1
|
||||
: galleryImages.value.length - 1
|
||||
imageDimensions.value = null
|
||||
scrollToActive()
|
||||
}
|
||||
|
||||
function goToNext() {
|
||||
activeIndex.value =
|
||||
activeIndex.value < galleryImages.value.length - 1
|
||||
? activeIndex.value + 1
|
||||
: 0
|
||||
imageDimensions.value = null
|
||||
scrollToActive()
|
||||
}
|
||||
|
||||
function switchToGrid() {
|
||||
isHovered.value = false
|
||||
displayMode.value = 'grid'
|
||||
}
|
||||
|
||||
function switchToSingle() {
|
||||
isHovered.value = false
|
||||
displayMode.value = 'single'
|
||||
}
|
||||
|
||||
function selectFromGrid(index: number) {
|
||||
activeIndex.value = index
|
||||
imageDimensions.value = null
|
||||
isHovered.value = false
|
||||
displayMode.value = 'single'
|
||||
scrollToActive()
|
||||
}
|
||||
|
||||
function handleEditMask() {
|
||||
if (!nodeId) return
|
||||
const node = app.rootGraph?.getNodeById(Number(nodeId))
|
||||
if (!node) return
|
||||
maskEditor.openMaskEditor(node)
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const src = activeItem.value ? getItemSrc(activeItem.value) : ''
|
||||
if (!src) return
|
||||
try {
|
||||
downloadFile(src)
|
||||
} catch {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
if (!nodeId) return
|
||||
const node = app.rootGraph?.getNodeById(Number(nodeId))
|
||||
nodeOutputStore.removeNodeOutputs(nodeId)
|
||||
if (node) {
|
||||
node.imgs = undefined
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
imageWidget.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -15,34 +15,7 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import { createMockWidget } from './widgetTestUtils'
|
||||
|
||||
const mockCheckState = vi.hoisted(() => vi.fn())
|
||||
const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] }))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
const actual = await vi.importActual(
|
||||
'@/platform/workflow/management/stores/workflowStore'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: {
|
||||
changeTracker: {
|
||||
checkState: mockCheckState
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn(),
|
||||
apiURL: vi.fn((url: string) => url),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData',
|
||||
() => ({
|
||||
@@ -483,69 +456,3 @@ describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
|
||||
expect(selectedSet.has('missing-missing_model.safetensors')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WidgetSelectDropdown undo tracking', () => {
|
||||
interface UndoTrackingInstance extends ComponentPublicInstance {
|
||||
updateSelectedItems: (selectedSet: Set<string>) => void
|
||||
handleFilesUpdate: (files: File[]) => Promise<void>
|
||||
}
|
||||
|
||||
const mountForUndo = (
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined
|
||||
): VueWrapper<UndoTrackingInstance> => {
|
||||
return mount(WidgetSelectDropdown, {
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
assetKind: 'image',
|
||||
allowUpload: true,
|
||||
uploadFolder: 'input'
|
||||
},
|
||||
global: {
|
||||
plugins: [PrimeVue, createTestingPinia(), i18n]
|
||||
}
|
||||
}) as unknown as VueWrapper<UndoTrackingInstance>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCheckState.mockClear()
|
||||
})
|
||||
|
||||
it('calls checkState after dropdown selection changes modelValue', () => {
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'img_001.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: ['img_001.png', 'photo_abc.jpg'] }
|
||||
})
|
||||
const wrapper = mountForUndo(widget, 'img_001.png')
|
||||
|
||||
wrapper.vm.updateSelectedItems(new Set(['input-1']))
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['photo_abc.jpg'])
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls checkState after file upload completes', async () => {
|
||||
const { api } = await import('@/scripts/api')
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
|
||||
} as Response)
|
||||
|
||||
const widget = createMockWidget<string | undefined>({
|
||||
value: 'img_001.png',
|
||||
name: 'test_image',
|
||||
type: 'combo',
|
||||
options: { values: ['img_001.png'] }
|
||||
})
|
||||
const wrapper = mountForUndo(widget, 'img_001.png')
|
||||
|
||||
const file = new File(['test'], 'uploaded.png', { type: 'image/png' })
|
||||
await wrapper.vm.handleFilesUpdate([file])
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['uploaded.png'])
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||