Compare commits
13 Commits
DynamicGro
...
ci/bump-cu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a81ceda4c | ||
|
|
31accd9657 | ||
|
|
2b9b652df3 | ||
|
|
bf7d02f328 | ||
|
|
747f76db76 | ||
|
|
386460afef | ||
|
|
5cf647d183 | ||
|
|
fe1fc8baa6 | ||
|
|
3e4dd59e5f | ||
|
|
e25e0f2e16 | ||
|
|
2ee91c30ee | ||
|
|
854770d305 | ||
|
|
2e4c9c6fdc |
197
.github/workflows/backport-auto-merge.yaml
vendored
Normal file
@@ -0,0 +1,197 @@
|
||||
---
|
||||
name: Backport Auto-Merge
|
||||
|
||||
# Completes the merge of backport PRs once they are approved and their required
|
||||
# checks pass.
|
||||
#
|
||||
# Background: pr-backport.yaml opens each backport PR (labelled `backport`) and
|
||||
# calls `gh pr merge --auto`, which relies on the repo-level "Allow auto-merge"
|
||||
# setting. That setting is off, so `--auto` is a silent no-op and backport PRs
|
||||
# sit unmerged until a human clicks merge. This workflow performs the merge
|
||||
# directly (a plain `gh pr merge --squash`, which does not depend on that
|
||||
# setting) once GitHub itself reports the PR as ready to merge.
|
||||
#
|
||||
# Safety: branch protection on core/** and cloud/** is the hard gate — it
|
||||
# unconditionally requires an approval + the required status checks and cannot
|
||||
# be bypassed, and GitHub's merge API re-enforces it at merge time. This
|
||||
# workflow can only ever complete a merge that already satisfies those rules;
|
||||
# the eligibility check below only avoids pointless merge attempts.
|
||||
#
|
||||
# The merge uses PR_GH_TOKEN (not the default GITHUB_TOKEN) on purpose: a merge
|
||||
# performed by the default token does not emit events that trigger other
|
||||
# workflows, which would silently starve cloud-backport-tag.yaml (it runs on the
|
||||
# backport PR's `pull_request: closed` event to create the release tag).
|
||||
|
||||
on:
|
||||
# Fires when someone approves — if the required checks are already green, the
|
||||
# PR merges immediately.
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
# Primary catch for the "approved first, checks went green later" case, plus a
|
||||
# general backstop. A `check_suite`/`workflow_run` trigger would react faster to
|
||||
# checks completing, but GitHub suppresses `check_suite` events for its own
|
||||
# Actions suites (so it wouldn't fire for this repo's CI), and `workflow_run` is
|
||||
# a secrets-bearing "dangerous" trigger we don't want on a public repo for a
|
||||
# non-latency-critical task. Backports wait hours today, so a short sweep is a
|
||||
# large improvement and needs neither.
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
|
||||
# Only constrains the default github.token (used for read-only PR lookups below).
|
||||
# It does NOT constrain PR_GH_TOKEN, whose authority is fixed by its own scopes.
|
||||
permissions:
|
||||
contents: read # read-only; required for gh api / gh pr list to resolve candidates
|
||||
pull-requests: read # read-only; required for gh pr view eligibility checks
|
||||
|
||||
# Serialize runs that act on the same PR (review events keyed by PR number; all
|
||||
# scheduled sweeps share one key). Cross-key overlaps are still possible but
|
||||
# harmless: the merge loop treats an already-merged PR as success (idempotent).
|
||||
concurrency:
|
||||
group: backport-auto-merge-${{ github.event.pull_request.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
name: Merge eligible backport PRs
|
||||
# Skip review events that can't possibly make a PR mergeable — non-approval
|
||||
# reviews, or reviews on non-backport PRs (most reviews in the repo) — before
|
||||
# spending any API call. Schedule sweeps always proceed. The per-PR
|
||||
# eligibility checks in the job still re-verify the label and decision from
|
||||
# live state.
|
||||
if: github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && contains(github.event.pull_request.labels.*.name, 'backport'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # read-only PR/commit lookups via the default token
|
||||
pull-requests: read # read-only PR metadata via the default token
|
||||
steps:
|
||||
- name: Collect candidate backport PRs
|
||||
id: candidates
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_FROM_REVIEW: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
numbers=""
|
||||
case "$EVENT_NAME" in
|
||||
pull_request_review)
|
||||
numbers="$PR_FROM_REVIEW"
|
||||
;;
|
||||
schedule)
|
||||
# Sweep every open backport PR.
|
||||
numbers=$(gh pr list --repo "$GH_REPO" --state open --label backport \
|
||||
--limit 100 --json number --jq '.[].number')
|
||||
;;
|
||||
esac
|
||||
# De-duplicate and emit space-separated, digit-only tokens.
|
||||
numbers=$(echo "$numbers" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true)
|
||||
echo "numbers=${numbers}" >> "$GITHUB_OUTPUT"
|
||||
echo "Candidate PRs: '${numbers:-<none>}'"
|
||||
|
||||
- name: Merge eligible backport PRs
|
||||
if: steps.candidates.outputs.numbers != ''
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
# Read with the default token; merge with PR_GH_TOKEN so the merge emits
|
||||
# the events that downstream workflows (cloud-backport-tag.yaml) rely on.
|
||||
READ_TOKEN: ${{ github.token }}
|
||||
MERGE_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
CANDIDATES: ${{ steps.candidates.outputs.numbers }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
is_merged() {
|
||||
[ "$(GH_TOKEN="$READ_TOKEN" gh pr view "$1" --repo "$GH_REPO" --json merged --jq '.merged' 2>/dev/null || echo false)" = "true" ]
|
||||
}
|
||||
|
||||
for pr in $CANDIDATES; do
|
||||
echo "::group::PR #${pr}"
|
||||
|
||||
info=$(GH_TOKEN="$READ_TOKEN" gh pr view "$pr" --repo "$GH_REPO" \
|
||||
--json number,state,isDraft,labels,baseRefName,reviewDecision,mergeStateStatus 2>/dev/null || echo '')
|
||||
if [ -z "$info" ]; then
|
||||
echo "Could not read PR #${pr} — skipping."; echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
state=$(echo "$info" | jq -r '.state')
|
||||
is_draft=$(echo "$info" | jq -r '.isDraft')
|
||||
is_backport=$(echo "$info" | jq -r '[.labels[].name] | any(. == "backport")')
|
||||
base=$(echo "$info" | jq -r '.baseRefName')
|
||||
review=$(echo "$info" | jq -r '.reviewDecision')
|
||||
merge_state=$(echo "$info" | jq -r '.mergeStateStatus')
|
||||
|
||||
# Only ever act on open, non-draft, backport-labelled PRs targeting a
|
||||
# protected release branch.
|
||||
if [ "$state" != "OPEN" ] || [ "$is_draft" != "false" ] || [ "$is_backport" != "true" ]; then
|
||||
echo "Not an actionable backport PR (state=$state draft=$is_draft backport=$is_backport) — skipping."
|
||||
echo "::endgroup::"; continue
|
||||
fi
|
||||
case "$base" in
|
||||
cloud/*|core/*) : ;;
|
||||
*) echo "Base '$base' is not a release branch — skipping."; echo "::endgroup::"; continue ;;
|
||||
esac
|
||||
|
||||
# Ready = approved AND GitHub says it's mergeable with required checks green.
|
||||
# CLEAN = approved, all required checks green, mergeable, no conflict.
|
||||
# UNSTABLE = same, but a NON-required check is pending/failing — GitHub
|
||||
# still allows the merge, so we do too (matches what a human
|
||||
# clicking "Squash and merge" can do; required checks are the
|
||||
# only merge gate per the ruleset). Requiring CLEAN alone would
|
||||
# stick forever behind flaky/slow non-required checks.
|
||||
# Any other state (BLOCKED/DIRTY/BEHIND/UNKNOWN/...) => not ready; re-checked
|
||||
# by a later event or the next sweep.
|
||||
if [ "$review" != "APPROVED" ] || { [ "$merge_state" != "CLEAN" ] && [ "$merge_state" != "UNSTABLE" ]; }; then
|
||||
echo "Not yet ready (reviewDecision=$review mergeStateStatus=$merge_state) — will re-check later."
|
||||
echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
echo "PR #${pr} is ready — attempting squash merge."
|
||||
attempt=0
|
||||
max=3
|
||||
merged=false
|
||||
while [ "$attempt" -lt "$max" ]; do
|
||||
attempt=$((attempt + 1))
|
||||
# A concurrent run (or a human) may have merged it already.
|
||||
if is_merged "$pr"; then merged=true; break; fi
|
||||
if out=$(GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --repo "$GH_REPO" --squash 2>&1); then
|
||||
merged=true; break
|
||||
fi
|
||||
echo "Merge attempt ${attempt}/${max} failed: ${out}"
|
||||
# No sleep after the final attempt.
|
||||
[ "$attempt" -lt "$max" ] && sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
# Final reconciliation: a failed merge command may just mean a concurrent
|
||||
# run won the race — don't post a false failure if the PR is in fact merged.
|
||||
if [ "$merged" != "true" ] && is_merged "$pr"; then merged=true; fi
|
||||
|
||||
if [ "$merged" = "true" ]; then
|
||||
echo "PR #${pr} merged."
|
||||
else
|
||||
echo "::warning::PR #${pr} looked ready but did not merge after ${max} attempts."
|
||||
# Avoid spamming a persistently-stuck PR: only re-warn if the last
|
||||
# warning (identified by its marker) is more than an hour old.
|
||||
marker='<!-- backport-auto-merge:merge-failed -->'
|
||||
# `gh api --paginate` emits one JSON array per page; `--jq` would run
|
||||
# per page (missing the true latest across pages), so slurp all pages
|
||||
# into one array first and filter with a separate jq pass.
|
||||
last_warned=$(GH_TOKEN="$READ_TOKEN" gh api "repos/${GH_REPO}/issues/${pr}/comments" --paginate 2>/dev/null \
|
||||
| jq -s "[.[][] | select(.body | contains(\"${marker}\"))] | sort_by(.created_at) | last | .created_at // empty") || last_warned=''
|
||||
stale=true
|
||||
if [ -n "$last_warned" ]; then
|
||||
last_epoch=$(date -d "$last_warned" +%s 2>/dev/null || echo 0)
|
||||
now_epoch=$(date -u +%s)
|
||||
[ $((now_epoch - last_epoch)) -lt 3600 ] && stale=false
|
||||
fi
|
||||
if [ "$stale" = "true" ]; then
|
||||
body=$(printf '%s\n\n%s' \
|
||||
"This backport PR is approved and its required checks are green, but automatic merge failed after ${max} attempts. Please merge manually or investigate (possible branch-protection mismatch)." \
|
||||
"$marker")
|
||||
GH_TOKEN="$MERGE_TOKEN" gh pr comment "$pr" --repo "$GH_REPO" --body "$body" || true
|
||||
else
|
||||
echo "Already warned within the last hour — skipping duplicate comment."
|
||||
fi
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
42
.github/workflows/cla.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
@@ -17,13 +18,45 @@ jobs:
|
||||
cla-assistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA already verified before merge queue
|
||||
if: github.event_name == 'merge_group'
|
||||
run: echo "CLA is checked on the pull request before it enters merge queue."
|
||||
|
||||
# The CLA action normally requires every commit author in a PR to sign.
|
||||
# We only want the PR author to sign, so we allowlist all other committers
|
||||
# by computing them from the PR's commits and excluding the PR author.
|
||||
- name: Build author-only allowlist
|
||||
id: allowlist
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
))
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
|
||||
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
|
||||
run: |
|
||||
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
|
||||
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
|
||||
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
|
||||
if [ -n "$others" ]; then
|
||||
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowlist=${BASE_ALLOWLIST}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: CLA Assistant
|
||||
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
|
||||
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
))
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -39,9 +72,10 @@ jobs:
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: main
|
||||
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# Only the PR author must sign: bots plus every non-author committer
|
||||
# are allowlisted via the "Build author-only allowlist" step above.
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
|
||||
allowlist: ${{ steps.allowlist.outputs.allowlist }}
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
|
||||
4
.github/workflows/pr-cursor-review.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
|
||||
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
|
||||
# from the same commit as the workflow definition.
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@21973fa117bf17d9fefa751bb5245acba18374a6 # github-workflows main (21973fa)
|
||||
with:
|
||||
# Overriding diff_excludes replaces the reusable default wholesale, so
|
||||
# this restates the generated/vendored defaults and adds this repo's heavy
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
:!**/*-snapshots/**
|
||||
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
|
||||
# Load the prompts/scripts from the same ref as `uses:`.
|
||||
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
|
||||
workflows_ref: 21973fa117bf17d9fefa751bb5245acba18374a6
|
||||
secrets:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
# Optional — enables start/complete Slack DMs to the triggerer.
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 938 B |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 1.2 KiB |
10
apps/website/public/icons/ai-models/openai.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1483_15836)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.373 184.704V136.491C196.373 132.437 197.909 129.387 201.451 127.36L298.368 71.552C311.573 63.936 327.296 60.3947 343.531 60.3947C404.416 60.3947 442.987 107.584 442.987 157.803C442.987 161.365 442.987 165.419 442.475 169.472L341.995 110.613C339.266 108.876 336.099 107.954 332.864 107.954C329.629 107.954 326.462 108.876 323.733 110.613L196.373 184.704ZM422.699 372.437V257.28C422.699 250.176 419.648 245.12 413.547 241.557L286.187 167.467L327.787 143.616C329.294 142.624 331.059 142.095 332.864 142.095C334.669 142.095 336.434 142.624 337.941 143.616L434.859 199.445C462.784 215.659 481.557 250.176 481.557 283.669C481.557 322.24 458.731 357.76 422.677 372.48L422.699 372.437ZM166.443 270.997L124.843 246.635C121.28 244.608 119.744 241.557 119.744 237.504V125.845C119.744 71.552 161.344 30.4427 217.685 30.4427C239.019 30.4427 258.795 37.5467 275.541 50.24L175.573 108.096C169.493 111.637 166.443 116.715 166.443 123.819V270.976V270.997ZM256 322.731L196.373 289.237V218.197L256 184.704L315.627 218.197V289.237L256 322.731ZM294.315 476.971C272.981 476.971 253.205 469.888 236.459 457.195L336.427 399.339C342.507 395.797 345.557 390.72 345.557 383.616V236.459L387.669 260.821C391.232 262.848 392.747 265.899 392.747 269.952V381.589C392.747 435.883 350.635 476.971 294.315 476.971ZM174.059 363.84L77.12 308.011C49.216 291.776 30.4427 257.28 30.4427 223.787C30.3769 204.756 35.9917 186.138 46.5684 170.317C57.1451 154.495 72.2025 142.19 89.8133 134.976V250.667C89.8133 257.771 92.864 262.848 98.944 266.411L225.813 339.989L184.213 363.84C182.707 364.835 180.941 365.365 179.136 365.365C177.331 365.365 175.565 364.835 174.059 363.84ZM168.469 447.04C111.125 447.04 69.0133 403.925 69.0133 350.635C69.0133 346.581 69.5253 342.528 70.016 338.475L169.984 396.288C176.085 399.851 182.165 399.851 188.245 396.288L315.605 322.731V370.944C315.605 374.997 314.112 378.048 310.549 380.075L213.632 435.883C200.427 443.499 184.704 447.04 168.469 447.04ZM294.315 507.413C323.553 507.416 351.895 497.319 374.547 478.831C397.198 460.343 412.768 434.598 418.624 405.952C475.456 391.232 512 337.92 512 283.648C512 248.128 496.789 213.632 469.376 188.757C471.915 178.091 473.429 167.445 473.429 156.8C473.429 84.2453 414.571 29.9307 346.581 29.9307C332.885 29.9307 319.701 31.9573 306.475 36.544C282.795 13.2354 250.933 0.118797 217.707 1.37049e-07C188.465 -0.00135846 160.121 10.0985 137.469 28.5908C114.817 47.0831 99.2486 72.8325 93.3973 101.483C36.544 116.203 0 169.493 0 223.787C0 259.328 15.2107 293.824 42.624 318.677C40.0853 329.344 38.5707 340.011 38.5707 350.656C38.5707 423.211 97.4293 477.504 165.419 477.504C179.115 477.504 192.299 475.477 205.525 470.912C229.208 494.23 261.08 507.347 294.315 507.456V507.413Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1483_15836">
|
||||
<rect width="512" height="512" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
28
apps/website/src/components/common/CardArrow.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { hover = 'self', class: className } = defineProps<{
|
||||
hover?: 'self' | 'group'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-10 items-center justify-center rounded-2xl bg-white/20 text-white backdrop-blur-sm transition-colors',
|
||||
hover === 'group'
|
||||
? 'group-hover:bg-primary-comfy-yellow group-hover:text-primary-comfy-ink'
|
||||
: 'hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink',
|
||||
className
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronRight class="size-5" :stroke-width="2" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { title, description, cta, href, bg } = defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
@@ -28,11 +30,9 @@ const { title, description, cta, href, bg } = defineProps<{
|
||||
<p class="text-sm text-white/70">
|
||||
{{ description }}
|
||||
</p>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-4 inline-block rounded-xl px-4 py-2 text-xs font-bold tracking-wide"
|
||||
>
|
||||
<Button as="span" variant="default" size="sm" class="mt-4">
|
||||
{{ cta }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
import GlassCard from '../../common/GlassCard.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -27,7 +29,7 @@ const cards = [
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
|
||||
class="text-3.5xl/tight mx-auto max-w-3xl text-center font-light text-primary-comfy-canvas lg:text-5xl/tight"
|
||||
>
|
||||
{{ headingParts[0]
|
||||
}}<span class="text-white">{{
|
||||
@@ -37,10 +39,11 @@ const cards = [
|
||||
</h2>
|
||||
|
||||
<GlassCard class="mt-12 grid grid-cols-1 gap-6 lg:mt-20 lg:grid-cols-2">
|
||||
<div
|
||||
<a
|
||||
v-for="card in cards"
|
||||
:key="card.labelKey"
|
||||
class="bg-primary-comfy-ink rounded-4.5xl overflow-hidden"
|
||||
:href="externalLinks.cloud"
|
||||
class="group rounded-4.5xl block overflow-hidden bg-primary-comfy-ink"
|
||||
>
|
||||
<img
|
||||
:src="card.image"
|
||||
@@ -51,23 +54,27 @@ const cards = [
|
||||
/>
|
||||
|
||||
<div class="mt-8 p-6">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow hover="group" class="shrink-0" />
|
||||
</div>
|
||||
|
||||
<h3
|
||||
class="text-primary-comfy-canvas mt-8 text-3xl/tight font-light whitespace-pre-line"
|
||||
class="mt-8 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</h3>
|
||||
|
||||
<p class="text-primary-comfy-canvas mt-8 text-base/normal">
|
||||
<p class="mt-8 text-base/normal text-primary-comfy-canvas">
|
||||
{{ t(card.descriptionKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</GlassCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -17,18 +17,18 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
>
|
||||
<div class="max-w-2xl">
|
||||
<h2
|
||||
class="text-primary-comfy-ink text-2xl/tight font-medium lg:text-3xl/tight"
|
||||
class="text-2xl/tight font-medium text-primary-comfy-ink lg:text-3xl/tight"
|
||||
>
|
||||
{{ t('cloud.pricing.title', locale) }}
|
||||
</h2>
|
||||
|
||||
<p class="text-primary-comfy-ink mt-4 text-base">
|
||||
<p class="mt-4 text-base text-primary-comfy-ink">
|
||||
{{ t('cloud.pricing.description', locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="SHOW_FREE_TIER"
|
||||
class="text-primary-comfy-ink mt-4 text-base font-bold"
|
||||
class="mt-4 text-base font-bold text-primary-comfy-ink"
|
||||
>
|
||||
{{ t('cloud.pricing.tagline', locale) }}
|
||||
</p>
|
||||
@@ -36,7 +36,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
<a
|
||||
:href="getRoutes(locale).cloudPricing"
|
||||
class="bg-primary-comfy-ink text-primary-comfy-yellow shrink-0 rounded-2xl px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
class="text-primary-comfy-yellow shrink-0 rounded-2xl bg-primary-comfy-ink px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('cloud.pricing.cta', locale) }}
|
||||
</a>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Locale } from '../../../i18n/translations'
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import BrandButton from '../../common/BrandButton.vue'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
|
||||
type ModelCard = {
|
||||
titleKey:
|
||||
@@ -14,11 +15,10 @@ type ModelCard = {
|
||||
| 'cloud.aiModels.card.seedance20'
|
||||
| 'cloud.aiModels.card.qwenImageEdit'
|
||||
| 'cloud.aiModels.card.wan22TextToVideo'
|
||||
| 'cloud.aiModels.card.gptImage2'
|
||||
imageSrc: string
|
||||
badgeIcon: string
|
||||
badgeClass: string
|
||||
layoutClass: string
|
||||
objectPosition?: string
|
||||
}
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -32,48 +32,45 @@ const modelCards: ModelCard[] = [
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/seedance-20.webm',
|
||||
badgeIcon: '/icons/ai-models/bytedance.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.nanoBananaPro',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/nano-banana-pro.webp',
|
||||
badgeIcon: '/icons/ai-models/gemini.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]',
|
||||
objectPosition: 'center 20%'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.grokImagine',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/grok-video.webm',
|
||||
badgeIcon: '/icons/ai-models/grok.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.qwenImageEdit',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/qwen-image-edit.webp',
|
||||
badgeIcon: '/icons/ai-models/qwen.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.wan22TextToVideo',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/wan-22.webm',
|
||||
badgeIcon: '/icons/ai-models/wan.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.gptImage2',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/gpt-image-2.webm',
|
||||
badgeIcon: '/icons/ai-models/openai.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
}
|
||||
]
|
||||
|
||||
function getCardClass(layoutClass: string): string {
|
||||
return cn(
|
||||
layoutClass,
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-4xl bg-black/40 lg:h-auto'
|
||||
)
|
||||
}
|
||||
const cardClass =
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-3xl bg-black/40 lg:col-span-4 lg:aspect-square lg:h-auto'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -100,23 +97,18 @@ function getCardClass(layoutClass: string): string {
|
||||
</p>
|
||||
|
||||
<div class="mt-16 w-full lg:mt-24">
|
||||
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
|
||||
<div class="rounded-4xl bg-white/8 p-2 lg:p-1.5">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
|
||||
<a
|
||||
v-for="card in modelCards"
|
||||
:key="card.titleKey"
|
||||
:href="externalLinks.workflows"
|
||||
:class="getCardClass(card.layoutClass)"
|
||||
:class="cardClass"
|
||||
>
|
||||
<video
|
||||
v-if="card.imageSrc.endsWith('.webm')"
|
||||
:src="card.imageSrc"
|
||||
:aria-label="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
autoplay
|
||||
loop
|
||||
@@ -134,11 +126,6 @@ function getCardClass(layoutClass: string): string {
|
||||
v-else
|
||||
:src="card.imageSrc"
|
||||
:alt="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@@ -168,10 +155,14 @@ function getCardClass(layoutClass: string): string {
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-primary-warm-white absolute inset-x-6 bottom-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
class="text-primary-warm-white absolute right-20 bottom-6 left-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow
|
||||
class="absolute right-5 bottom-5 lg:right-6 lg:bottom-6"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -932,9 +932,9 @@ const translations = {
|
||||
'zh-CN': '所有模型。\n商业许可保证。'
|
||||
},
|
||||
'cloud.reason.2.description': {
|
||||
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
|
||||
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D, GPT Image 2 and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
|
||||
'zh-CN':
|
||||
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
|
||||
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D、GPT Image 2 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
|
||||
},
|
||||
'cloud.reason.2.badge.onlyOn': {
|
||||
en: 'ONLY ON',
|
||||
@@ -996,6 +996,10 @@ const translations = {
|
||||
en: 'Wan 2.2',
|
||||
'zh-CN': 'Wan 2.2'
|
||||
},
|
||||
'cloud.aiModels.card.gptImage2': {
|
||||
en: 'GPT Image 2',
|
||||
'zh-CN': 'GPT Image 2'
|
||||
},
|
||||
'cloud.aiModels.ctaDesktop': {
|
||||
en: 'EXPLORE WORKFLOWS WITH THE LATEST MODELS',
|
||||
'zh-CN': '探索最新模型工作流'
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
@utility ppformula-text-center {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.19em;
|
||||
top: 0.1em;
|
||||
}
|
||||
|
||||
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
validateComfyNodeDef,
|
||||
zDynamicGroupInputSpec
|
||||
} from '../schemas/nodeDefSchema'
|
||||
import { validateComfyNodeDef } from '../schemas/nodeDefSchema'
|
||||
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
|
||||
|
||||
const EXAMPLE_NODE_DEF: ComfyNodeDef = {
|
||||
@@ -68,42 +65,3 @@ describe('validateNodeDef', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('zDynamicGroupInputSpec', () => {
|
||||
const template = { required: { a: ['STRING', {}] } }
|
||||
|
||||
it('rejects min greater than max', () => {
|
||||
expect(
|
||||
zDynamicGroupInputSpec.safeParse([
|
||||
'COMFY_DYNAMICGROUP_V3',
|
||||
{ template, min: 60, max: 50 }
|
||||
]).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts min equal to max', () => {
|
||||
expect(
|
||||
zDynamicGroupInputSpec.safeParse([
|
||||
'COMFY_DYNAMICGROUP_V3',
|
||||
{ template, min: 3, max: 3 }
|
||||
]).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('applies default min and max', () => {
|
||||
const parsed = zDynamicGroupInputSpec.parse([
|
||||
'COMFY_DYNAMICGROUP_V3',
|
||||
{ template }
|
||||
])
|
||||
expect(parsed[1].min).toBe(0)
|
||||
expect(parsed[1].max).toBe(50)
|
||||
})
|
||||
|
||||
it('accepts an optional group_name', () => {
|
||||
const parsed = zDynamicGroupInputSpec.parse([
|
||||
'COMFY_DYNAMICGROUP_V3',
|
||||
{ template, group_name: 'Lora' }
|
||||
])
|
||||
expect(parsed[1].group_name).toBe('Lora')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -344,26 +344,6 @@ export const zDynamicComboInputSpec = z.tuple([
|
||||
})
|
||||
])
|
||||
|
||||
export const zDynamicGroupInputSpec = z.tuple([
|
||||
z.literal('COMFY_DYNAMICGROUP_V3'),
|
||||
zBaseInputOptions
|
||||
.extend({
|
||||
template: zComfyInputsSpec,
|
||||
min: z.number().int().nonnegative().optional().default(0),
|
||||
max: z.number().int().positive().max(100).optional().default(50),
|
||||
group_name: z.string().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.min > data.max) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'min must be less than or equal to max',
|
||||
path: ['min']
|
||||
})
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
export const zMatchTypeOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
type: z.literal('COMFY_MATCHTYPE_V3'),
|
||||
|
||||
@@ -79,7 +79,6 @@ export interface SafeWidgetData {
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
removable?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
@@ -207,8 +206,7 @@ function extractWidgetDisplayOptions(
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only,
|
||||
removable: widget.options.removable
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { resolveInputType } from './dynamicTypes'
|
||||
|
||||
describe('resolveInputType', () => {
|
||||
it('resolves field types from a dynamic group template', () => {
|
||||
const spec = transformInputSpecV1ToV2(
|
||||
[
|
||||
'COMFY_DYNAMICGROUP_V3',
|
||||
{
|
||||
template: {
|
||||
required: { image: ['IMAGE', {}] },
|
||||
optional: { text: ['STRING', {}] }
|
||||
}
|
||||
}
|
||||
] as InputSpec,
|
||||
{ name: 'loras', isOptional: false }
|
||||
)
|
||||
|
||||
expect(resolveInputType(spec)).toEqual(['IMAGE', 'STRING'])
|
||||
})
|
||||
|
||||
it('resolves nested combo types inside a dynamic group template', () => {
|
||||
const spec = transformInputSpecV1ToV2(
|
||||
[
|
||||
'COMFY_DYNAMICGROUP_V3',
|
||||
{
|
||||
template: {
|
||||
required: {
|
||||
mode: [['a', 'b'], {}]
|
||||
}
|
||||
}
|
||||
}
|
||||
] as InputSpec,
|
||||
{ name: 'loras', isOptional: false }
|
||||
)
|
||||
|
||||
expect(resolveInputType(spec)).toEqual(['COMBO'])
|
||||
})
|
||||
|
||||
it('returns an empty list for an invalid dynamic group spec', () => {
|
||||
const spec = transformInputSpecV1ToV2(
|
||||
['COMFY_DYNAMICGROUP_V3', { template: { required: {} } }] as InputSpec,
|
||||
{ name: 'loras', isOptional: false }
|
||||
)
|
||||
spec.type = 'COMFY_DYNAMICGROUP_V3'
|
||||
spec.template = undefined as never
|
||||
|
||||
expect(resolveInputType(spec)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,5 @@
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicGroupInputSpec,
|
||||
zMatchTypeOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { zAutogrowOptions, zMatchTypeOptions } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
@@ -12,7 +8,6 @@ const dynamicTypeResolvers: Record<
|
||||
(inputSpec: InputSpecV2) => string[]
|
||||
> = {
|
||||
COMFY_AUTOGROW_V3: resolveAutogrowType,
|
||||
COMFY_DYNAMICGROUP_V3: resolveDynamicGroupType,
|
||||
COMFY_MATCHTYPE_V3: (input) =>
|
||||
zMatchTypeOptions
|
||||
.safeParse(input)
|
||||
@@ -25,21 +20,6 @@ export function resolveInputType(input: InputSpecV2): string[] {
|
||||
: input.type.split(',')
|
||||
}
|
||||
|
||||
function resolveDynamicGroupType(rawSpec: InputSpecV2): string[] {
|
||||
const parsed = zDynamicGroupInputSpec.safeParse([rawSpec.type, rawSpec])
|
||||
const template = parsed.data?.[1]?.template
|
||||
if (!template) return []
|
||||
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
|
||||
template.required,
|
||||
template.optional
|
||||
]
|
||||
return inputTypes.flatMap((inputType) =>
|
||||
Object.entries(inputType ?? {}).flatMap(([name, v]) =>
|
||||
resolveInputType(transformInputSpecV1ToV2(v, { name }))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveAutogrowType(rawSpec: InputSpecV2): string[] {
|
||||
const { input } = zAutogrowOptions.safeParse(rawSpec).data?.template ?? {}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { DynamicGroupNode } from '@/core/graph/widgets/dynamicWidgets'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
@@ -49,33 +47,6 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
|
||||
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
|
||||
)
|
||||
}
|
||||
function addDynamicGroup(
|
||||
node: LGraphNode,
|
||||
template: object,
|
||||
{
|
||||
min,
|
||||
max,
|
||||
name = 'g',
|
||||
group_name
|
||||
}: {
|
||||
min?: number
|
||||
max?: number
|
||||
name?: string
|
||||
group_name?: string
|
||||
} = {}
|
||||
) {
|
||||
const options: Record<string, unknown> = { template }
|
||||
if (min !== undefined) options.min = min
|
||||
if (max !== undefined) options.max = max
|
||||
if (group_name !== undefined) options.group_name = group_name
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(['COMFY_DYNAMICGROUP_V3', options] as InputSpec, {
|
||||
name,
|
||||
isOptional: false
|
||||
})
|
||||
)
|
||||
}
|
||||
function addAutogrow(node: LGraphNode, template: unknown) {
|
||||
addNodeInput(
|
||||
node,
|
||||
@@ -316,173 +287,3 @@ describe('Autogrow', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
describe('Dynamic Groups', () => {
|
||||
const stringTemplate = { required: { a: ['STRING', {}] } }
|
||||
const widgetNames = (node: LGraphNode) => node.widgets!.map((w) => w.name)
|
||||
const inputNames = (node: LGraphNode) => node.inputs.map((i) => i.name)
|
||||
const widgetNamed = (node: LGraphNode, name: string) =>
|
||||
node.widgets!.find((w) => w.name === name)!
|
||||
|
||||
test('renders min rows on creation', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 2, max: 5 })
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
|
||||
expect(inputNames(node)).toStrictEqual([])
|
||||
})
|
||||
|
||||
test('add row appends a new row up to max', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 2 })
|
||||
expect(widgetNames(node)).toStrictEqual(['g'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
|
||||
|
||||
// At max, further adds are ignored.
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('controller disabled option set at max', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 1 })
|
||||
expect(widgetNamed(node, 'g').options?.disabled).toBe(false)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(widgetNamed(node, 'g').options?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
test('remove row renumbers later rows', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
const state = (
|
||||
node as Parameters<typeof widgetNamed>[0] & {
|
||||
comfyDynamic: {
|
||||
dynamicGroup: Record<
|
||||
string,
|
||||
{ addRow: () => void; removeRow: (r: number) => void }
|
||||
>
|
||||
}
|
||||
}
|
||||
).comfyDynamic.dynamicGroup['g']
|
||||
state.addRow()
|
||||
state.addRow()
|
||||
state.addRow()
|
||||
|
||||
const row0Field = widgetNamed(node, 'g.0.a')
|
||||
const row2Field = widgetNamed(node, 'g.2.a')
|
||||
|
||||
state.removeRow(1)
|
||||
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
|
||||
// Row 0 is untouched; the former row 2 shifts down into row 1.
|
||||
expect(widgetNamed(node, 'g.0.a')).toBe(row0Field)
|
||||
expect(widgetNamed(node, 'g.1.a')).toBe(row2Field)
|
||||
})
|
||||
|
||||
test('remove row disconnects linked sockets and renumbers inputs', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
const state = (
|
||||
node as Parameters<typeof widgetNamed>[0] & {
|
||||
comfyDynamic: {
|
||||
dynamicGroup: Record<
|
||||
string,
|
||||
{ addRow: () => void; removeRow: (r: number) => void }
|
||||
>
|
||||
}
|
||||
}
|
||||
).comfyDynamic.dynamicGroup['g']
|
||||
state.addRow()
|
||||
state.addRow()
|
||||
state.addRow()
|
||||
|
||||
const graph = new LGraph()
|
||||
graph.add(node)
|
||||
node.addInput('g.1.a', 'STRING')
|
||||
const row1Index = node.inputs.findIndex((i) => i.name === 'g.1.a')
|
||||
connectInput(node, row1Index, graph)
|
||||
const linkId = node.inputs[row1Index].link!
|
||||
node.addInput('g.2.a', 'STRING')
|
||||
|
||||
state.removeRow(1)
|
||||
|
||||
expect(graph.links[linkId]).toBeUndefined()
|
||||
expect(inputNames(node)).toStrictEqual(['g.1.a'])
|
||||
expect(node.inputs[0].link).toBeNull()
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('rows below min cannot be removed', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 1, max: 5 })
|
||||
const state = (
|
||||
node as Parameters<typeof widgetNamed>[0] & {
|
||||
comfyDynamic: {
|
||||
dynamicGroup: Record<string, { removeRow: (r: number) => void }>
|
||||
}
|
||||
}
|
||||
).comfyDynamic.dynamicGroup['g']
|
||||
|
||||
// Row 0 is at the min boundary — removing it is a no-op.
|
||||
state.removeRow(0)
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a'])
|
||||
})
|
||||
|
||||
test('controller value setter rebuilds rows within min and max', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 1, max: 4 })
|
||||
const controller = widgetNamed(node, 'g')
|
||||
|
||||
controller.value = 3
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a', 'g.2.a'])
|
||||
expect(controller.value).toBe(3)
|
||||
|
||||
controller.value = 99
|
||||
expect(widgetNames(node)).toStrictEqual([
|
||||
'g',
|
||||
'g.0.a',
|
||||
'g.1.a',
|
||||
'g.2.a',
|
||||
'g.3.a'
|
||||
])
|
||||
expect(controller.value).toBe(4)
|
||||
|
||||
controller.value = 0
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a'])
|
||||
expect(controller.value).toBe(1)
|
||||
})
|
||||
|
||||
test('stores group_name on dynamic group state', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, {
|
||||
min: 1,
|
||||
max: 3,
|
||||
name: 'loras',
|
||||
group_name: 'Lora'
|
||||
})
|
||||
const state = (node as unknown as DynamicGroupNode).comfyDynamic
|
||||
.dynamicGroup.loras
|
||||
|
||||
expect(state.groupName).toBe('Lora')
|
||||
})
|
||||
|
||||
test('remove row renames linked input widget metadata', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
const state = (node as unknown as DynamicGroupNode).comfyDynamic
|
||||
.dynamicGroup['g']
|
||||
state.addRow()
|
||||
state.addRow()
|
||||
|
||||
const row2Input = node.addInput('g.2.a', 'STRING')
|
||||
row2Input.widget = { name: 'g.2.a' }
|
||||
|
||||
state.removeRow(1)
|
||||
|
||||
expect(row2Input.name).toBe('g.1.a')
|
||||
expect(row2Input.widget?.name).toBe('g.1.a')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,13 +13,11 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { commonType } from '@/lib/litegraph/src/utils/type'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicComboInputSpec,
|
||||
zDynamicGroupInputSpec,
|
||||
zMatchTypeOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -30,18 +28,6 @@ import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
type DynamicGroupState = {
|
||||
min: number
|
||||
max: number
|
||||
groupName?: string
|
||||
inputSpecs: InputSpecV2[]
|
||||
addRow: () => void
|
||||
removeRow: (row: number) => void
|
||||
}
|
||||
export type DynamicGroupNode = LGraphNode & {
|
||||
comfyDynamic: { dynamicGroup: Record<string, DynamicGroupState> }
|
||||
}
|
||||
|
||||
type MatchTypeNode = LGraphNode &
|
||||
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
|
||||
comfyDynamic: { matchType: Record<string, Record<string, string>> }
|
||||
@@ -228,229 +214,7 @@ function dynamicComboWidget(
|
||||
return { widget, minWidth, minHeight }
|
||||
}
|
||||
|
||||
function withComfyDynamicGroup(
|
||||
node: LGraphNode
|
||||
): asserts node is DynamicGroupNode {
|
||||
if (node.comfyDynamic?.dynamicGroup) return
|
||||
node.comfyDynamic ??= {}
|
||||
node.comfyDynamic.dynamicGroup = {}
|
||||
}
|
||||
|
||||
const fieldName = (group: string, row: number, field: string) =>
|
||||
`${group}.${row}.${field}`
|
||||
|
||||
/** Rename a field that sits above the removed row, shifting its index down. */
|
||||
function shiftedFieldName(
|
||||
group: string,
|
||||
name: string,
|
||||
removedRow: number
|
||||
): string | undefined {
|
||||
const prefix = `${group}.`
|
||||
if (!name.startsWith(prefix)) return undefined
|
||||
const rest = name.slice(prefix.length)
|
||||
const dot = rest.indexOf('.')
|
||||
if (dot === -1) return undefined
|
||||
const row = Number(rest.slice(0, dot))
|
||||
if (!Number.isInteger(row) || row <= removedRow) return undefined
|
||||
return fieldName(group, row - 1, rest.slice(dot + 1))
|
||||
}
|
||||
|
||||
const isGroupField = (group: string, name: string) =>
|
||||
name.startsWith(`${group}.`)
|
||||
|
||||
const belongsToRow = (group: string, name: string, row: number): boolean =>
|
||||
name.startsWith(`${group}.${row}.`)
|
||||
|
||||
function countGroupRows(group: string, node: LGraphNode): number {
|
||||
const rows = new Set<number>()
|
||||
for (const w of node.widgets ?? []) {
|
||||
if (!isGroupField(group, w.name)) continue
|
||||
const rest = w.name.slice(group.length + 1)
|
||||
const dot = rest.indexOf('.')
|
||||
if (dot !== -1) {
|
||||
const row = Number(rest.slice(0, dot))
|
||||
if (Number.isInteger(row)) rows.add(row)
|
||||
}
|
||||
}
|
||||
return rows.size
|
||||
}
|
||||
|
||||
/** Build field widgets for a single row, returning them detached from the node. */
|
||||
function createRow(
|
||||
group: string,
|
||||
row: number,
|
||||
state: DynamicGroupState,
|
||||
node: DynamicGroupNode
|
||||
): IBaseWidget[] {
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
const startLen = node.widgets!.length
|
||||
|
||||
for (const spec of state.inputSpecs)
|
||||
addNodeInput(node, {
|
||||
...spec,
|
||||
name: fieldName(group, row, spec.name),
|
||||
display_name: spec.display_name ?? spec.name,
|
||||
hidden: true,
|
||||
socketless: true
|
||||
})
|
||||
|
||||
return node.widgets!.splice(startLen)
|
||||
}
|
||||
|
||||
function insertRowAfterGroup(
|
||||
group: string,
|
||||
node: LGraphNode,
|
||||
rowWidgets: IBaseWidget[]
|
||||
): void {
|
||||
const lastIdx = node.widgets!.findLastIndex(
|
||||
(w) => w.name === group || isGroupField(group, w.name)
|
||||
)
|
||||
node.widgets!.splice(lastIdx + 1, 0, ...rowWidgets)
|
||||
}
|
||||
|
||||
function removeGroupInputs(
|
||||
node: DynamicGroupNode,
|
||||
predicate: (name: string) => boolean
|
||||
): void {
|
||||
for (let i = node.inputs.length - 1; i >= 0; i--) {
|
||||
if (predicate(node.inputs[i].name)) node.removeInput(i)
|
||||
}
|
||||
}
|
||||
|
||||
function syncController(group: string, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
const controller = node.widgets?.find((w) => w.name === group)
|
||||
if (!state || !controller) return
|
||||
controller.options ??= {}
|
||||
controller.options.disabled = countGroupRows(group, node) >= state.max
|
||||
// Route through setSize (not `size[1] = …`) so the layout store and the Vue
|
||||
// node's min-height floor are updated; a direct buffer write bypasses the
|
||||
// size setter and leaves the node unable to shrink after rows are removed.
|
||||
node.setSize([node.size[0], node.computeSize()[1]])
|
||||
}
|
||||
|
||||
function addRow(group: string, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state) return
|
||||
node.widgets ??= []
|
||||
const row = countGroupRows(group, node)
|
||||
if (row >= state.max) return
|
||||
insertRowAfterGroup(group, node, createRow(group, row, state, node))
|
||||
syncController(group, node)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function removeRow(group: string, row: number, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state || row < state.min) return
|
||||
|
||||
for (const w of remove(node.widgets!, (w) =>
|
||||
belongsToRow(group, w.name, row)
|
||||
))
|
||||
w.onRemove?.()
|
||||
removeGroupInputs(node, (name) => belongsToRow(group, name, row))
|
||||
|
||||
for (const w of node.widgets ?? []) {
|
||||
const shifted = shiftedFieldName(group, w.name, row)
|
||||
if (shifted !== undefined) w.name = shifted
|
||||
}
|
||||
for (const inp of node.inputs) {
|
||||
const shifted = shiftedFieldName(group, inp.name, row)
|
||||
if (shifted === undefined) continue
|
||||
inp.name = shifted
|
||||
if (inp.widget) inp.widget.name = shifted
|
||||
}
|
||||
|
||||
syncController(group, node)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
/** Rebuild the group from scratch to hold exactly `count` rows. */
|
||||
function rebuildRows(group: string, count: number, node: DynamicGroupNode) {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state) return
|
||||
node.widgets ??= []
|
||||
|
||||
const isRowMember = (name: string) => isGroupField(group, name)
|
||||
for (const w of remove(node.widgets, (w) => isRowMember(w.name)))
|
||||
w.onRemove?.()
|
||||
removeGroupInputs(node, isRowMember)
|
||||
|
||||
const insertAt = node.widgets.findIndex((w) => w.name === group) + 1
|
||||
const rowWidgets: IBaseWidget[] = []
|
||||
for (let row = 0; row < count; row++)
|
||||
rowWidgets.push(...createRow(group, row, state, node))
|
||||
node.widgets.splice(insertAt, 0, ...rowWidgets)
|
||||
}
|
||||
|
||||
function dynamicGroupWidget(
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
untypedInputData: InputSpec,
|
||||
_appArg: ComfyApp
|
||||
) {
|
||||
const parseResult = zDynamicGroupInputSpec.safeParse(untypedInputData)
|
||||
if (!parseResult.success) throw new Error('invalid DynamicGroup spec')
|
||||
const [, { template, min, max, group_name: groupName }] = parseResult.data
|
||||
|
||||
const toSpecs = (
|
||||
inputs: Record<string, InputSpec> | undefined,
|
||||
isOptional: boolean
|
||||
) =>
|
||||
Object.entries(inputs ?? {}).map(([name, spec]) =>
|
||||
transformInputSpecV1ToV2(spec, { name, isOptional })
|
||||
)
|
||||
const inputSpecs = [
|
||||
...toSpecs(template.required, false),
|
||||
...toSpecs(template.optional, true)
|
||||
]
|
||||
|
||||
withComfyDynamicGroup(node)
|
||||
const typedNode = node as DynamicGroupNode
|
||||
typedNode.comfyDynamic.dynamicGroup[inputName] = {
|
||||
min,
|
||||
max,
|
||||
groupName,
|
||||
inputSpecs,
|
||||
addRow: () => addRow(inputName, typedNode),
|
||||
removeRow: (row: number) => removeRow(inputName, row, typedNode)
|
||||
}
|
||||
|
||||
node.widgets ??= []
|
||||
const controller = node.addCustomWidget({
|
||||
name: inputName,
|
||||
type: 'dynamic_group',
|
||||
value: min,
|
||||
y: 0,
|
||||
serialize: true,
|
||||
callback: () => addRow(inputName, typedNode),
|
||||
options: { socketless: true, disabled: false, min, max }
|
||||
})
|
||||
|
||||
Object.defineProperty(controller, 'value', {
|
||||
get() {
|
||||
return countGroupRows(inputName, typedNode)
|
||||
},
|
||||
set(count: unknown) {
|
||||
if (typeof count !== 'number') return
|
||||
const state = typedNode.comfyDynamic.dynamicGroup[inputName]
|
||||
if (!state) return
|
||||
const clamped = Math.min(Math.max(count, state.min), state.max)
|
||||
rebuildRows(inputName, clamped, typedNode)
|
||||
syncController(inputName, typedNode)
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
controller.value = min
|
||||
|
||||
return { widget: controller }
|
||||
}
|
||||
|
||||
export const dynamicWidgets = {
|
||||
COMFY_DYNAMICCOMBO_V3: dynamicComboWidget,
|
||||
COMFY_DYNAMICGROUP_V3: dynamicGroupWidget
|
||||
}
|
||||
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
|
||||
const dynamicInputs: Record<
|
||||
string,
|
||||
(node: LGraphNode, inputSpec: InputSpecV2) => void
|
||||
|
||||
@@ -75,7 +75,6 @@ export interface IWidgetOptions<TValues = unknown> {
|
||||
|
||||
// Vue widget options
|
||||
disabled?: boolean
|
||||
removable?: boolean
|
||||
useGrouping?: boolean
|
||||
placeholder?: string
|
||||
showThumbnails?: boolean
|
||||
|
||||
@@ -911,8 +911,8 @@
|
||||
"nodes": "Nodes",
|
||||
"models": "Models",
|
||||
"assets": "Assets",
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
"console": "Console",
|
||||
"menu": "Menu",
|
||||
"imported": "Imported",
|
||||
@@ -2253,12 +2253,6 @@
|
||||
"slots": "Node Slots Error",
|
||||
"widgets": "Node Widgets Error"
|
||||
},
|
||||
"dynamicGroup": {
|
||||
"addGroup": "Add {group_name}",
|
||||
"removeGroup": "Remove {group_name}",
|
||||
"group": "{group_name} #{index}",
|
||||
"defaultGroupName": "Group"
|
||||
},
|
||||
"oauth": {
|
||||
"consent": {
|
||||
"allow": "Continue",
|
||||
@@ -3093,7 +3087,7 @@
|
||||
"share": "Share"
|
||||
},
|
||||
"shortcuts": {
|
||||
"shortcuts": "Shortcuts",
|
||||
"shortcuts": "Shortcuts",
|
||||
"essentials": "Essential",
|
||||
"viewControls": "View Controls",
|
||||
"manageShortcuts": "Manage Shortcuts",
|
||||
|
||||
@@ -110,17 +110,18 @@
|
||||
>
|
||||
<span class="flex items-center gap-1 text-text-primary">
|
||||
{{ $t('subscription.additionalCredits') }}
|
||||
<button
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: $t('subscription.additionalCreditsTooltip'),
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('subscription.additionalCreditsInfo')"
|
||||
class="flex items-center text-muted"
|
||||
class="text-muted"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</button>
|
||||
</Button>
|
||||
<span
|
||||
v-if="isSpendingAdditional"
|
||||
class="flex h-3.5 items-center rounded-full bg-base-foreground px-1 text-2xs/none font-semibold text-base-background uppercase"
|
||||
|
||||
@@ -30,6 +30,90 @@ describe('preservedQueryManager', () => {
|
||||
expect(sessionStorage.getItem('Comfy.PreservedQuery.template')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('merges newly captured keys into the payload when merge is set', () => {
|
||||
capturePreservedQuery(
|
||||
NAMESPACE,
|
||||
{ template: 'flux' },
|
||||
['template', 'source', 'mode'],
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
capturePreservedQuery(
|
||||
NAMESPACE,
|
||||
{ source: 'custom' },
|
||||
['template', 'source', 'mode'],
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
const merged = mergePreservedQueryIntoQuery(NAMESPACE)
|
||||
expect(merged).toEqual({ template: 'flux', source: 'custom' })
|
||||
})
|
||||
|
||||
it('replaces the whole payload on capture by default', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux', source: 'custom' }, [
|
||||
'template',
|
||||
'source',
|
||||
'mode'
|
||||
])
|
||||
|
||||
capturePreservedQuery(NAMESPACE, { template: 'sdxl' }, [
|
||||
'template',
|
||||
'source',
|
||||
'mode'
|
||||
])
|
||||
|
||||
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toEqual({
|
||||
template: 'sdxl'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the payload untouched when a default capture has no valid values', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
|
||||
|
||||
capturePreservedQuery(NAMESPACE, { template: '' }, ['template'])
|
||||
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBe('flux')
|
||||
})
|
||||
|
||||
it('captures the first non-empty string element of an array-valued param', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: ['', 'flux', 'sdxl'] }, [
|
||||
'template'
|
||||
])
|
||||
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBe('flux')
|
||||
})
|
||||
|
||||
it('does not stash empty, null, or all-junk array values', () => {
|
||||
capturePreservedQuery(
|
||||
NAMESPACE,
|
||||
{ template: '', source: null, mode: ['', null] },
|
||||
['template', 'source', 'mode']
|
||||
)
|
||||
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBeUndefined()
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'source')).toBeUndefined()
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'mode')).toBeUndefined()
|
||||
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes a preserved key on empty value when merge is set', () => {
|
||||
capturePreservedQuery(
|
||||
NAMESPACE,
|
||||
{ template: 'flux', source: 'custom' },
|
||||
['template', 'source'],
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
capturePreservedQuery(NAMESPACE, { template: '' }, ['template', 'source'], {
|
||||
merge: true
|
||||
})
|
||||
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBeUndefined()
|
||||
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toEqual({
|
||||
source: 'custom'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a preserved query param by key', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
|
||||
|
||||
@@ -78,6 +162,16 @@ describe('preservedQueryManager', () => {
|
||||
expect(merged).toBeUndefined()
|
||||
})
|
||||
|
||||
it('overwrites an array-valued live query key with the stashed string', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
|
||||
|
||||
const merged = mergePreservedQueryIntoQuery(NAMESPACE, {
|
||||
template: ['existing', 'other']
|
||||
})
|
||||
|
||||
expect(merged).toEqual({ template: 'flux' })
|
||||
})
|
||||
|
||||
it('clears cached payload', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ const STORAGE_PREFIX = 'Comfy.PreservedQuery.'
|
||||
const preservedQueries = new Map<string, Record<string, string>>()
|
||||
|
||||
const readQueryParam = (value: unknown): string | undefined => {
|
||||
return typeof value === 'string' ? value : undefined
|
||||
if (typeof value === 'string') return value
|
||||
if (!Array.isArray(value)) return undefined
|
||||
return value.find(
|
||||
(entry: unknown): entry is string =>
|
||||
typeof entry === 'string' && entry !== ''
|
||||
)
|
||||
}
|
||||
|
||||
const getStorageKey = (namespace: string) => `${STORAGE_PREFIX}${namespace}`
|
||||
@@ -65,25 +70,65 @@ export const hydratePreservedQuery = (namespace: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* By default each capture replaces the namespace stash with the values present
|
||||
* in the given query. With `merge`, values are merged into the existing stash
|
||||
* and a key supplied with an empty value clears its stashed entry — for
|
||||
* namespaces where the stash, not the URL, is the surviving carrier.
|
||||
*/
|
||||
export const capturePreservedQuery = (
|
||||
namespace: string,
|
||||
query: LocationQuery,
|
||||
keys: string[]
|
||||
keys: string[],
|
||||
{ merge = false }: { merge?: boolean } = {}
|
||||
) => {
|
||||
const payload: Record<string, string> = {}
|
||||
|
||||
keys.forEach((key) => {
|
||||
const value = readQueryParam(query[key])
|
||||
if (value) {
|
||||
payload[key] = value
|
||||
if (!merge) {
|
||||
const payload: Record<string, string> = {}
|
||||
keys.forEach((key) => {
|
||||
const value = readQueryParam(query[key])
|
||||
if (value) {
|
||||
payload[key] = value
|
||||
}
|
||||
})
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
preservedQueries.set(namespace, payload)
|
||||
writeToStorage(namespace, payload)
|
||||
return
|
||||
}
|
||||
|
||||
preservedQueries.set(namespace, payload)
|
||||
hydratePreservedQuery(namespace)
|
||||
const payload: Record<string, string> = {
|
||||
...(preservedQueries.get(namespace) ?? {})
|
||||
}
|
||||
let changed = false
|
||||
|
||||
keys.forEach((key) => {
|
||||
if (!Object.hasOwn(query, key)) return
|
||||
|
||||
const value = readQueryParam(query[key])
|
||||
if (value) {
|
||||
payload[key] = value
|
||||
changed = true
|
||||
return
|
||||
}
|
||||
|
||||
if (key in payload) {
|
||||
delete payload[key]
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
|
||||
if (!changed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
preservedQueries.delete(namespace)
|
||||
} else {
|
||||
preservedQueries.set(namespace, payload)
|
||||
}
|
||||
writeToStorage(namespace, payload)
|
||||
}
|
||||
|
||||
|
||||
196
src/platform/navigation/preservedQueryTracker.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Router, RouterHistory } from 'vue-router'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
clearPreservedQuery,
|
||||
getPreservedQueryParam
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
|
||||
|
||||
const STRIPPED_NAMESPACE = 'test_strip'
|
||||
const SECOND_STRIPPED_NAMESPACE = 'test_strip_b'
|
||||
const PLAIN_NAMESPACE = 'test_plain'
|
||||
|
||||
const strippedDefinition = {
|
||||
namespace: STRIPPED_NAMESPACE,
|
||||
keys: ['one_time_code'],
|
||||
stripAfterCapture: true
|
||||
}
|
||||
|
||||
const plainDefinition = {
|
||||
namespace: PLAIN_NAMESPACE,
|
||||
keys: ['plain_code', 'plain_source']
|
||||
}
|
||||
|
||||
function createTestRouter(
|
||||
history: RouterHistory = createMemoryHistory()
|
||||
): Router {
|
||||
return createRouter({
|
||||
history,
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
|
||||
})
|
||||
}
|
||||
|
||||
describe('installPreservedQueryTracker', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
clearPreservedQuery(STRIPPED_NAMESPACE)
|
||||
clearPreservedQuery(SECOND_STRIPPED_NAMESPACE)
|
||||
clearPreservedQuery(PLAIN_NAMESPACE)
|
||||
})
|
||||
|
||||
it('strips marked keys from the URL while preserving other query and hash', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/?one_time_code=otc_abc123&keep=a+b#frag')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/?keep=a+b#frag')
|
||||
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
|
||||
'otc_abc123'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps params of non-strip namespaces in the URL and still captures them', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [plainDefinition])
|
||||
|
||||
await router.push('/?plain_code=alpha&plain_source=beta')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/?plain_code=alpha&plain_source=beta'
|
||||
)
|
||||
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_code')).toBe('alpha')
|
||||
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_source')).toBe('beta')
|
||||
})
|
||||
|
||||
it('replaces a non-strip namespace stash on later captures', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [plainDefinition])
|
||||
|
||||
await router.push('/?plain_code=alpha&plain_source=beta')
|
||||
await router.push('/?plain_code=gamma')
|
||||
|
||||
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_code')).toBe('gamma')
|
||||
expect(
|
||||
getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_source')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('navigates exactly once when no strip-marked keys are present', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
let completedNavigations = 0
|
||||
router.afterEach(() => {
|
||||
completedNavigations++
|
||||
})
|
||||
|
||||
await router.push('/?keep=1')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/?keep=1')
|
||||
expect(completedNavigations).toBe(1)
|
||||
})
|
||||
|
||||
it('scrubs empty and null values from the URL without stashing them', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/?one_time_code=')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
expect(
|
||||
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
|
||||
).toBeUndefined()
|
||||
|
||||
await router.push('/?one_time_code')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
expect(
|
||||
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears a stale stripped value when the URL supplies an empty value', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/?one_time_code=otc_abc123')
|
||||
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
|
||||
'otc_abc123'
|
||||
)
|
||||
|
||||
await router.push('/?one_time_code=')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
expect(
|
||||
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stashes the first value of a repeated param and cleans the URL', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/?one_time_code=otc_A&one_time_code=otc_B')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
|
||||
'otc_A'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips keys of multiple marked namespaces in a single redirect', async () => {
|
||||
const router = createTestRouter()
|
||||
let navigationAttempts = 0
|
||||
router.beforeEach((_to, _from, next) => {
|
||||
navigationAttempts++
|
||||
next()
|
||||
})
|
||||
installPreservedQueryTracker(router, [
|
||||
strippedDefinition,
|
||||
{
|
||||
namespace: SECOND_STRIPPED_NAMESPACE,
|
||||
keys: ['second_code'],
|
||||
stripAfterCapture: true
|
||||
}
|
||||
])
|
||||
|
||||
await router.push('/?one_time_code=otc_x&second_code=sc_y&keep=1')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/?keep=1')
|
||||
expect(navigationAttempts).toBe(2)
|
||||
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
|
||||
'otc_x'
|
||||
)
|
||||
expect(
|
||||
getPreservedQueryParam(SECOND_STRIPPED_NAMESPACE, 'second_code')
|
||||
).toBe('sc_y')
|
||||
})
|
||||
|
||||
it('keeps the prior history entry reachable after the strip redirect', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/start')
|
||||
await router.push('/?one_time_code=otc_abc123')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
|
||||
router.go(-1)
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.fullPath).toBe('/start')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps replace navigation from adding a back target', async () => {
|
||||
const history = createMemoryHistory()
|
||||
const router = createTestRouter(history)
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/start')
|
||||
await router.replace('/?one_time_code=otc_abc123')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
|
||||
router.go(-1)
|
||||
|
||||
expect(history.location).toBe('/')
|
||||
})
|
||||
})
|
||||
@@ -5,25 +5,48 @@ import {
|
||||
hydratePreservedQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
|
||||
interface PreservedQueryDefinition {
|
||||
namespace: string
|
||||
keys: string[]
|
||||
/**
|
||||
* When set, keys present in the query are removed from the client-side URL
|
||||
* before navigation completes. Later guards, afterEach hooks, and views must
|
||||
* read a strip-marked key from the preserved-query stash instead of
|
||||
* route.query or fullPath. Because the stash is the only carrier after
|
||||
* stripping, captures for the namespace merge into the existing stash and an
|
||||
* explicitly empty value clears the stashed key; non-strip namespaces keep
|
||||
* replace-on-capture semantics.
|
||||
*/
|
||||
stripAfterCapture?: boolean
|
||||
}
|
||||
|
||||
export const installPreservedQueryTracker = (
|
||||
router: Router,
|
||||
definitions: Array<{ namespace: string; keys: string[] }>
|
||||
definitions: PreservedQueryDefinition[]
|
||||
) => {
|
||||
const trackedDefinitions = definitions.map((definition) => ({
|
||||
...definition
|
||||
}))
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const queryKeys = new Set(Object.keys(to.query))
|
||||
const keysToStrip = new Set<string>()
|
||||
|
||||
trackedDefinitions.forEach(({ namespace, keys }) => {
|
||||
definitions.forEach(({ namespace, keys, stripAfterCapture }) => {
|
||||
hydratePreservedQuery(namespace)
|
||||
const shouldCapture = keys.some((key) => queryKeys.has(key))
|
||||
if (shouldCapture) {
|
||||
capturePreservedQuery(namespace, to.query, keys)
|
||||
const presentKeys = keys.filter((key) => queryKeys.has(key))
|
||||
if (presentKeys.length === 0) return
|
||||
capturePreservedQuery(namespace, to.query, keys, {
|
||||
merge: stripAfterCapture
|
||||
})
|
||||
if (stripAfterCapture) {
|
||||
presentKeys.forEach((key) => keysToStrip.add(key))
|
||||
}
|
||||
})
|
||||
|
||||
next()
|
||||
if (keysToStrip.size === 0) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const cleanedQuery = { ...to.query }
|
||||
keysToStrip.forEach((key) => delete cleanedQuery[key])
|
||||
next({ path: to.path, query: cleanedQuery, hash: to.hash })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -530,8 +530,6 @@ export function useSlotLinkInteraction({
|
||||
|
||||
raf.flush()
|
||||
|
||||
raf.flush()
|
||||
|
||||
if (!state.source) {
|
||||
cleanupInteraction()
|
||||
app.canvas?.setDirty(true, true)
|
||||
@@ -579,24 +577,18 @@ export function useSlotLinkInteraction({
|
||||
const graph = app.canvas?.graph ?? null
|
||||
const context = { adapter, graph, session: dragContext }
|
||||
|
||||
const attemptSnapped = () => tryConnectToCandidate(snappedCandidate)
|
||||
|
||||
const domSlotCandidate = resolveSlotTargetCandidate(target, context)
|
||||
const attemptDomSlot = () => tryConnectToCandidate(domSlotCandidate)
|
||||
|
||||
const nodeSurfaceSlotCandidate = resolveNodeSurfaceSlotCandidate(
|
||||
target,
|
||||
context
|
||||
)
|
||||
const attemptNodeSurface = () =>
|
||||
tryConnectToCandidate(nodeSurfaceSlotCandidate)
|
||||
const attemptReroute = () => tryConnectViaRerouteAtPointer()
|
||||
|
||||
if (attemptSnapped()) return true
|
||||
if (attemptDomSlot()) return true
|
||||
if (attemptNodeSurface()) return true
|
||||
if (attemptReroute()) return true
|
||||
return false
|
||||
return (
|
||||
tryConnectToCandidate(snappedCandidate) ||
|
||||
tryConnectToCandidate(domSlotCandidate) ||
|
||||
tryConnectToCandidate(nodeSurfaceSlotCandidate) ||
|
||||
tryConnectViaRerouteAtPointer()
|
||||
)
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { DynamicGroupNode } from '@/core/graph/widgets/dynamicWidgets'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { HasInitialMinSize } from '@/services/litegraphService'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetDynamicGroup from './WidgetDynamicGroup.vue'
|
||||
|
||||
const appMocks = vi.hoisted(() => ({
|
||||
graph: null as LGraph | null
|
||||
}))
|
||||
|
||||
const FieldStub = vi.hoisted(() => ({
|
||||
name: 'FieldStub',
|
||||
props: {
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
widget: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<input data-testid="field" :aria-label="widget.name" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get graph() {
|
||||
return appMocks.graph
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
|
||||
() => ({
|
||||
getComponent: () => FieldStub
|
||||
})
|
||||
)
|
||||
|
||||
const ButtonStub = {
|
||||
name: 'Button',
|
||||
props: { disabled: Boolean },
|
||||
template: '<button type="button" :disabled="disabled"><slot /></button>'
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
dynamicGroup: {
|
||||
addGroup: 'Add {group_name}',
|
||||
removeGroup: 'Remove {group_name}',
|
||||
group: '{group_name} #{index}',
|
||||
defaultGroupName: 'Group'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function fieldWidget(name: string, value = ''): IBaseWidget {
|
||||
return {
|
||||
name,
|
||||
type: 'string',
|
||||
value,
|
||||
options: {},
|
||||
y: 0
|
||||
}
|
||||
}
|
||||
|
||||
function createDynamicGroupNode({
|
||||
min = 1,
|
||||
max = 3,
|
||||
groupName = 'Lora',
|
||||
fieldsPerRow = ['text'],
|
||||
rows = [0]
|
||||
}: {
|
||||
min?: number
|
||||
max?: number
|
||||
groupName?: string
|
||||
fieldsPerRow?: string[]
|
||||
rows?: number[]
|
||||
} = {}): DynamicGroupNode {
|
||||
const node = new LGraphNode('test') as DynamicGroupNode &
|
||||
Partial<HasInitialMinSize>
|
||||
node._initialMinSize = { width: 1, height: 1 }
|
||||
node.widgets = [
|
||||
{
|
||||
name: 'loras',
|
||||
type: 'dynamic_group',
|
||||
value: rows.length,
|
||||
options: { min, max },
|
||||
y: 0
|
||||
},
|
||||
...rows.flatMap((row) =>
|
||||
fieldsPerRow.map((field) => fieldWidget(`loras.${row}.${field}`))
|
||||
)
|
||||
]
|
||||
|
||||
const state = {
|
||||
min,
|
||||
max,
|
||||
groupName,
|
||||
inputSpecs: [],
|
||||
addRow: vi.fn(),
|
||||
removeRow: vi.fn()
|
||||
}
|
||||
node.comfyDynamic = { dynamicGroup: { loras: state } }
|
||||
|
||||
const graph = new LGraph()
|
||||
graph.add(node)
|
||||
appMocks.graph = graph
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
function mountWidgetDynamicGroup(node: DynamicGroupNode) {
|
||||
const state = node.comfyDynamic.dynamicGroup.loras
|
||||
const widget: SimplifiedWidget<number> = {
|
||||
name: 'loras',
|
||||
type: 'dynamic_group',
|
||||
value: node.widgets!.filter((w) => w.name.startsWith('loras.')).length,
|
||||
options: { min: state.min, max: state.max }
|
||||
}
|
||||
|
||||
return render(WidgetDynamicGroup, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { Button: ButtonStub }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
nodeId: toNodeId(String(node.id)),
|
||||
nodeType: 'testnode'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('WidgetDynamicGroup', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
appMocks.graph = null
|
||||
})
|
||||
|
||||
it('renders one row per field widget with the configured group name', () => {
|
||||
mountWidgetDynamicGroup(createDynamicGroupNode({ min: 2, rows: [0, 1] }))
|
||||
|
||||
expect(screen.getByText('Lora #1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Lora #2')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('field')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders multiple fields per row', () => {
|
||||
mountWidgetDynamicGroup(
|
||||
createDynamicGroupNode({
|
||||
rows: [0, 1],
|
||||
fieldsPerRow: ['text', 'strength']
|
||||
})
|
||||
)
|
||||
|
||||
expect(screen.getAllByTestId('field')).toHaveLength(4)
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: 'loras.0.text' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: 'loras.1.strength' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls addRow when the add button is clicked', async () => {
|
||||
const node = createDynamicGroupNode({ min: 1, max: 3 })
|
||||
const user = userEvent.setup()
|
||||
|
||||
mountWidgetDynamicGroup(node)
|
||||
await user.click(screen.getByRole('button', { name: 'Add Lora' }))
|
||||
|
||||
expect(node.comfyDynamic.dynamicGroup.loras.addRow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls removeRow with the correct row index', async () => {
|
||||
const node = createDynamicGroupNode({ min: 0, max: 3, rows: [0, 1, 2] })
|
||||
const user = userEvent.setup()
|
||||
|
||||
mountWidgetDynamicGroup(node)
|
||||
const removeButtons = screen.getAllByRole('button', { name: 'Remove Lora' })
|
||||
await user.click(removeButtons[2]!)
|
||||
|
||||
expect(node.comfyDynamic.dynamicGroup.loras.removeRow).toHaveBeenCalledWith(
|
||||
2
|
||||
)
|
||||
})
|
||||
|
||||
it('only shows remove buttons for rows above the minimum', () => {
|
||||
mountWidgetDynamicGroup(createDynamicGroupNode({ min: 2, rows: [0, 1, 2] }))
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: 'Remove Lora' })
|
||||
expect(removeButtons).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides all remove buttons when row count equals min', () => {
|
||||
mountWidgetDynamicGroup(createDynamicGroupNode({ min: 1, rows: [0] }))
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Remove Lora' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables the add button when the group is at max capacity', () => {
|
||||
mountWidgetDynamicGroup(
|
||||
createDynamicGroupNode({ min: 0, max: 2, rows: [0, 1] })
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add Lora' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables the add button when below max capacity', () => {
|
||||
mountWidgetDynamicGroup(
|
||||
createDynamicGroupNode({ min: 0, max: 3, rows: [0, 1] })
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add Lora' })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('updates a field widget value when edited', async () => {
|
||||
const node = createDynamicGroupNode({ rows: [0] })
|
||||
const rowWidget = node.widgets!.find((w) => w.name === 'loras.0.text')!
|
||||
const user = userEvent.setup()
|
||||
|
||||
mountWidgetDynamicGroup(node)
|
||||
|
||||
const field = screen.getByTestId('field')
|
||||
await user.clear(field)
|
||||
await user.type(field, 'my-lora')
|
||||
|
||||
expect(rowWidget.value).toBe('my-lora')
|
||||
})
|
||||
|
||||
it('uses the default group name when groupName is not configured', () => {
|
||||
const node = createDynamicGroupNode({ rows: [0] })
|
||||
node.comfyDynamic.dynamicGroup.loras.groupName = undefined
|
||||
|
||||
mountWidgetDynamicGroup(node)
|
||||
|
||||
expect(screen.getByText('Group #1')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Add Group' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when the node is not on the graph', () => {
|
||||
const node = createDynamicGroupNode({ rows: [0, 1] })
|
||||
appMocks.graph = null
|
||||
|
||||
mountWidgetDynamicGroup(node)
|
||||
|
||||
expect(screen.queryAllByTestId('field')).toHaveLength(0)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Add Group' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,190 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="col-span-2 grid grid-cols-[minmax(80px,min-content)_minmax(125px,1fr)] gap-x-2 gap-y-1"
|
||||
>
|
||||
<template v-for="row in rowIndices" :key="row">
|
||||
<div
|
||||
class="col-span-2 mt-1 flex items-center justify-between border-t border-node-component-surface pt-1"
|
||||
>
|
||||
<span
|
||||
class="truncate text-xs font-medium text-node-component-slot-text"
|
||||
>
|
||||
{{
|
||||
t('dynamicGroup.group', { group_name: groupName, index: row + 1 })
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
v-if="row >= minRows"
|
||||
v-tooltip.top="
|
||||
t('dynamicGroup.removeGroup', { group_name: groupName })
|
||||
"
|
||||
type="button"
|
||||
class="mr-1.75 flex cursor-pointer appearance-none border-0 bg-transparent p-0 text-node-component-slot-text/40 transition-colors duration-150 hover:text-danger-100 focus-visible:outline-none"
|
||||
:aria-label="t('dynamicGroup.removeGroup', { group_name: groupName })"
|
||||
@click="onRemoveRow(row)"
|
||||
>
|
||||
<span
|
||||
class="icon-[material-symbols--close] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<component
|
||||
:is="fw.component"
|
||||
v-for="fw in rowWidgets(row)"
|
||||
:key="fw.name"
|
||||
:model-value="fw.value"
|
||||
:widget="fw.simplified"
|
||||
:node-id="nodeId"
|
||||
:node-type="nodeType"
|
||||
class="col-span-2"
|
||||
@update:model-value="fw.onUpdate"
|
||||
/>
|
||||
</template>
|
||||
<Button
|
||||
:disabled="addDisabled"
|
||||
class="col-span-2 mt-1 border-0 bg-component-node-widget-background text-node-component-slot-text"
|
||||
size="sm"
|
||||
variant="textonly"
|
||||
@click="onAddRow"
|
||||
>
|
||||
<span
|
||||
class="mr-1 icon-[material-symbols--add] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('dynamicGroup.addGroup', { group_name: groupName }) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { DynamicGroupNode } from '@/core/graph/widgets/dynamicWidgets'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { getComponent } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
import type { WidgetState } from '@/types/widgetState'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const { widget, nodeId, nodeType } = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
nodeId: string
|
||||
nodeType?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const group = widget.name
|
||||
|
||||
const node = computed(
|
||||
() => app.graph?.getNodeById(toNodeId(nodeId)) as DynamicGroupNode | undefined
|
||||
)
|
||||
|
||||
const groupState = computed(
|
||||
() => node.value?.comfyDynamic?.dynamicGroup?.[group]
|
||||
)
|
||||
|
||||
const minRows = computed(() => groupState.value?.min ?? 0)
|
||||
const groupName = computed(
|
||||
() => groupState.value?.groupName ?? t('dynamicGroup.defaultGroupName')
|
||||
)
|
||||
|
||||
interface FieldWidgetView {
|
||||
name: string
|
||||
row: number
|
||||
component: Component
|
||||
simplified: SimplifiedWidget
|
||||
value: WidgetValue
|
||||
onUpdate: (value: WidgetValue) => void
|
||||
}
|
||||
|
||||
function resolveWidgetState(w: IBaseWidget): WidgetState | undefined {
|
||||
if (w.widgetId) return widgetValueStore.getWidget(w.widgetId)
|
||||
const graphId = node.value?.graph?.rootGraph?.id
|
||||
if (!graphId) return undefined
|
||||
const localId = stripGraphPrefix(String(nodeId))
|
||||
if (!localId) return undefined
|
||||
return widgetValueStore.getWidget(widgetId(graphId, localId, w.name))
|
||||
}
|
||||
|
||||
function toFieldView(
|
||||
n: DynamicGroupNode,
|
||||
w: IBaseWidget,
|
||||
row: number,
|
||||
fieldName: string
|
||||
): FieldWidgetView {
|
||||
const state = resolveWidgetState(w)
|
||||
const value = state?.value ?? w.value
|
||||
const simplified: SimplifiedWidget = {
|
||||
name: w.name,
|
||||
type: state?.type ?? w.type,
|
||||
value,
|
||||
label: state?.label ?? w.label ?? fieldName,
|
||||
options: state?.options ?? w.options,
|
||||
spec: nodeDefStore.getInputSpecForWidget(n, w.name)
|
||||
}
|
||||
return {
|
||||
name: w.name,
|
||||
row,
|
||||
component: getComponent(w.type) ?? WidgetLegacy,
|
||||
simplified,
|
||||
value,
|
||||
onUpdate: (next: WidgetValue) => {
|
||||
if (state) state.value = next
|
||||
w.value = next ?? undefined
|
||||
w.callback?.(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fieldWidgets = computed<FieldWidgetView[]>(() => {
|
||||
const n = node.value
|
||||
if (!n?.widgets) return []
|
||||
const prefix = `${group}.`
|
||||
const views: FieldWidgetView[] = []
|
||||
for (const w of n.widgets) {
|
||||
if (!w.name.startsWith(prefix)) continue
|
||||
const rest = w.name.slice(prefix.length)
|
||||
const dot = rest.indexOf('.')
|
||||
if (dot === -1) continue
|
||||
const row = Number(rest.slice(0, dot))
|
||||
if (!Number.isInteger(row)) continue
|
||||
views.push(toFieldView(n, w, row, rest.slice(dot + 1)))
|
||||
}
|
||||
return views
|
||||
})
|
||||
|
||||
const rowIndices = computed(() =>
|
||||
[...new Set(fieldWidgets.value.map((fw) => fw.row))].sort((a, b) => a - b)
|
||||
)
|
||||
|
||||
const addDisabled = computed(
|
||||
() => rowIndices.value.length >= (groupState.value?.max ?? Infinity)
|
||||
)
|
||||
|
||||
function rowWidgets(row: number): FieldWidgetView[] {
|
||||
return fieldWidgets.value.filter((fw) => fw.row === row)
|
||||
}
|
||||
|
||||
function onAddRow() {
|
||||
groupState.value?.addRow()
|
||||
}
|
||||
|
||||
function onRemoveRow(row: number) {
|
||||
groupState.value?.removeRow(row)
|
||||
}
|
||||
</script>
|
||||
@@ -75,10 +75,6 @@ const WidgetBoundingBoxes = defineAsyncComponent(
|
||||
const WidgetColors = defineAsyncComponent(
|
||||
() => import('@/components/palette/WidgetColors.vue')
|
||||
)
|
||||
const WidgetDynamicGroup = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/renderer/extensions/vueNodes/widgets/components/WidgetDynamicGroup.vue')
|
||||
)
|
||||
|
||||
export const FOR_TESTING = {
|
||||
WidgetButton,
|
||||
@@ -245,14 +241,6 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
aliases: ['COLORS'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'dynamic_group',
|
||||
{
|
||||
component: WidgetDynamicGroup,
|
||||
aliases: [],
|
||||
essential: false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
@@ -304,7 +304,6 @@ export const useLitegraphService = () => {
|
||||
hidden: inputSpec.hidden
|
||||
})
|
||||
if (inputSpec.hidden !== undefined) widget.hidden = inputSpec.hidden
|
||||
if (inputSpec.socketless) widget.options.socketless = true
|
||||
if (dynamic) widget.tooltip = inputSpec.tooltip
|
||||
}
|
||||
|
||||
|
||||