Compare commits
21 Commits
remove-usa
...
fix/textar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
660ccb9236 | ||
|
|
1267c4b9b3 | ||
|
|
ddc2159bed | ||
|
|
5687b44422 | ||
|
|
26fa84ce1b | ||
|
|
e37bba7250 | ||
|
|
35f15d18b4 | ||
|
|
a82c984520 | ||
|
|
54f13930a4 | ||
|
|
c972dca61e | ||
|
|
cf8952e205 | ||
|
|
1dcaf5d0dc | ||
|
|
f707098f05 | ||
|
|
d2917be3a7 | ||
|
|
2639248867 | ||
|
|
b41f162607 | ||
|
|
3678e65bec | ||
|
|
16ddcfdbaf | ||
|
|
ef5198be25 | ||
|
|
38675e658f | ||
|
|
bd95150f82 |
118
.github/workflows/ci-oss-assets-validation.yaml
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
name: 'CI: OSS Assets Validation'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
push:
|
||||
branches: [main, dev*]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-fonts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project
|
||||
run: pnpm build
|
||||
env:
|
||||
DISTRIBUTION: localhost
|
||||
|
||||
- name: Check for proprietary fonts in dist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Checking dist for proprietary ABCROM fonts...'
|
||||
|
||||
if [ ! -d "dist" ] || [ -z "$(ls -A dist)" ]; then
|
||||
echo '❌ ERROR: dist/ directory missing or empty!'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for ABCROM font files
|
||||
if find dist/ -type f -iname '*abcrom*' \
|
||||
\( -name '*.woff' -o -name '*.woff2' -o -name '*.ttf' -o -name '*.otf' \) \
|
||||
-print -quit | grep -q .; then
|
||||
echo ''
|
||||
echo '❌ ERROR: Found proprietary ABCROM font files in dist!'
|
||||
echo ''
|
||||
find dist/ -type f -iname '*abcrom*' \
|
||||
\( -name '*.woff' -o -name '*.woff2' -o -name '*.ttf' -o -name '*.otf' \)
|
||||
echo ''
|
||||
echo 'ABCROM fonts are proprietary and should not ship to OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Use conditional font loading based on isCloud'
|
||||
echo '2. Ensure fonts are dynamically imported, not bundled'
|
||||
echo '3. Check vite config for font handling'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '✅ No proprietary fonts found in dist'
|
||||
|
||||
validate-licenses:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Validate production dependency licenses
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Checking production dependency licenses...'
|
||||
|
||||
# Use license-checker-rseidelsohn (actively maintained fork, handles monorepos)
|
||||
# Exclude internal @comfyorg packages from license check
|
||||
# Run in if condition to capture exit code
|
||||
if npx license-checker-rseidelsohn@4 \
|
||||
--production \
|
||||
--summary \
|
||||
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
|
||||
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
|
||||
echo ''
|
||||
echo '✅ All production dependency licenses are approved!'
|
||||
else
|
||||
echo ''
|
||||
echo '❌ ERROR: Found dependencies with non-approved licenses!'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Check the license of the problematic package'
|
||||
echo '2. Find an alternative package with an approved license'
|
||||
echo '3. If the license is safe and OSI-approved, add it to the --onlyAllow list'
|
||||
echo ''
|
||||
echo 'For more info on OSI-approved licenses:'
|
||||
echo 'https://opensource.org/licenses'
|
||||
exit 1
|
||||
fi
|
||||
6
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -6,9 +6,6 @@ on:
|
||||
workflows: ['CI: Tests E2E']
|
||||
types: [requested, completed]
|
||||
|
||||
env:
|
||||
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
|
||||
|
||||
jobs:
|
||||
deploy-and-comment-forked-pr:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -63,8 +60,7 @@ jobs:
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting" \
|
||||
"$(date -u '${{ env.DATE_FORMAT }}')"
|
||||
"starting"
|
||||
|
||||
- name: Download and Deploy Reports
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
|
||||
7
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -182,10 +182,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Get start time
|
||||
id: start-time
|
||||
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Post starting comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
@@ -194,8 +190,7 @@ jobs:
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"starting" \
|
||||
"${{ steps.start-time.outputs.time }}"
|
||||
"starting"
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
|
||||
@@ -6,9 +6,6 @@ on:
|
||||
workflows: ['CI: Tests Storybook']
|
||||
types: [requested, completed]
|
||||
|
||||
env:
|
||||
DATE_FORMAT: '+%m/%d/%Y, %I:%M:%S %p'
|
||||
|
||||
jobs:
|
||||
deploy-and-comment-forked-pr:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -63,8 +60,7 @@ jobs:
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting" \
|
||||
"$(date -u '${{ env.DATE_FORMAT }}')"
|
||||
"starting"
|
||||
|
||||
- name: Download and Deploy Storybook
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
|
||||
|
||||
3
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -24,8 +24,7 @@ jobs:
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"starting" \
|
||||
"$(date -u '+%m/%d/%Y, %I:%M:%S %p')"
|
||||
"starting"
|
||||
|
||||
# Build Storybook for all PRs (free Cloudflare deployment)
|
||||
storybook-build:
|
||||
|
||||
1
.gitignore
vendored
@@ -64,6 +64,7 @@ browser_tests/local/
|
||||
dist.zip
|
||||
|
||||
/temp/
|
||||
/tmp/
|
||||
|
||||
# Generated JSON Schemas
|
||||
/schemas/
|
||||
|
||||
183
browser_tests/assets/subgraphs/subgraph-duplicate-links.json
Normal file
@@ -0,0 +1,183 @@
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-aaaa-bbbb-cccc-ddddeeee0001",
|
||||
"pos": [600, 400],
|
||||
"size": [200, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-aaaa-bbbb-cccc-ddddeeee0001",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 5,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph With Duplicate Links",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [200, 400, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 400, 120, 60]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "out-latent-1",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"pos": [920, 420]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [400, 100],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [100, 200],
|
||||
"size": [200, 106],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [1, 3, 4, 5]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.38.14"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import { Topbar } from './components/Topbar'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from './helpers/ClipboardHelper'
|
||||
import { CommandHelper } from './helpers/CommandHelper'
|
||||
import { DebugHelper } from './helpers/DebugHelper'
|
||||
import { DragDropHelper } from './helpers/DragDropHelper'
|
||||
import { KeyboardHelper } from './helpers/KeyboardHelper'
|
||||
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
|
||||
@@ -174,7 +173,6 @@ export class ComfyPage {
|
||||
public readonly settingDialog: SettingDialog
|
||||
public readonly confirmDialog: ConfirmDialog
|
||||
public readonly vueNodes: VueNodeHelpers
|
||||
public readonly debug: DebugHelper
|
||||
public readonly subgraph: SubgraphHelper
|
||||
public readonly canvasOps: CanvasHelper
|
||||
public readonly nodeOps: NodeOperationsHelper
|
||||
@@ -219,7 +217,6 @@ export class ComfyPage {
|
||||
this.settingDialog = new SettingDialog(page, this)
|
||||
this.confirmDialog = new ConfirmDialog(page)
|
||||
this.vueNodes = new VueNodeHelpers(page)
|
||||
this.debug = new DebugHelper(page, this.canvas)
|
||||
this.subgraph = new SubgraphHelper(this)
|
||||
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
|
||||
this.nodeOps = new NodeOperationsHelper(this)
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import type { Locator, Page, TestInfo } from '@playwright/test'
|
||||
|
||||
import type { Position } from '../types'
|
||||
|
||||
export interface DebugScreenshotOptions {
|
||||
fullPage?: boolean
|
||||
element?: 'canvas' | 'page'
|
||||
markers?: Array<{ position: Position; id?: string }>
|
||||
}
|
||||
|
||||
export class DebugHelper {
|
||||
constructor(
|
||||
private page: Page,
|
||||
private canvas: Locator
|
||||
) {}
|
||||
|
||||
async addMarker(
|
||||
position: Position,
|
||||
id: string = 'debug-marker'
|
||||
): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
([pos, markerId]) => {
|
||||
const existing = document.getElementById(markerId)
|
||||
if (existing) existing.remove()
|
||||
|
||||
const marker = document.createElement('div')
|
||||
marker.id = markerId
|
||||
marker.style.position = 'fixed'
|
||||
marker.style.left = `${pos.x - 10}px`
|
||||
marker.style.top = `${pos.y - 10}px`
|
||||
marker.style.width = '20px'
|
||||
marker.style.height = '20px'
|
||||
marker.style.border = '2px solid red'
|
||||
marker.style.borderRadius = '50%'
|
||||
marker.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'
|
||||
marker.style.pointerEvents = 'none'
|
||||
marker.style.zIndex = '10000'
|
||||
document.body.appendChild(marker)
|
||||
},
|
||||
[position, id] as const
|
||||
)
|
||||
}
|
||||
|
||||
async removeMarkers(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
document
|
||||
.querySelectorAll('[id^="debug-marker"]')
|
||||
.forEach((el) => el.remove())
|
||||
})
|
||||
}
|
||||
|
||||
async attachScreenshot(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
options?: DebugScreenshotOptions
|
||||
): Promise<void> {
|
||||
if (options?.markers) {
|
||||
for (const marker of options.markers) {
|
||||
await this.addMarker(marker.position, marker.id)
|
||||
}
|
||||
}
|
||||
|
||||
let screenshot: Buffer
|
||||
const targetElement = options?.element || 'page'
|
||||
|
||||
if (targetElement === 'canvas') {
|
||||
screenshot = await this.canvas.screenshot()
|
||||
} else if (options?.fullPage) {
|
||||
screenshot = await this.page.screenshot({ fullPage: true })
|
||||
} else {
|
||||
screenshot = await this.page.screenshot()
|
||||
}
|
||||
|
||||
await testInfo.attach(name, {
|
||||
body: screenshot,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
if (options?.markers) {
|
||||
await this.removeMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
async saveCanvasScreenshot(filename: string): Promise<void> {
|
||||
await this.page.evaluate(async (filename) => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (!blob) {
|
||||
throw new Error('Failed to create blob from canvas')
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
resolve()
|
||||
}, 'image/png')
|
||||
})
|
||||
}, filename)
|
||||
}
|
||||
|
||||
async getCanvasDataURL(): Promise<string> {
|
||||
return await this.page.evaluate(() => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
return canvas.toDataURL('image/png')
|
||||
})
|
||||
}
|
||||
|
||||
async showCanvasOverlay(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
|
||||
const existingOverlay = document.getElementById('debug-canvas-overlay')
|
||||
if (existingOverlay) {
|
||||
existingOverlay.remove()
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'debug-canvas-overlay'
|
||||
overlay.style.position = 'fixed'
|
||||
overlay.style.top = '0'
|
||||
overlay.style.left = '0'
|
||||
overlay.style.zIndex = '9999'
|
||||
overlay.style.backgroundColor = 'white'
|
||||
overlay.style.padding = '10px'
|
||||
overlay.style.border = '2px solid red'
|
||||
|
||||
const img = document.createElement('img')
|
||||
img.src = canvas.toDataURL('image/png')
|
||||
img.style.maxWidth = '800px'
|
||||
img.style.maxHeight = '600px'
|
||||
overlay.appendChild(img)
|
||||
|
||||
document.body.appendChild(overlay)
|
||||
})
|
||||
}
|
||||
|
||||
async hideCanvasOverlay(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const overlay = document.getElementById('debug-canvas-overlay')
|
||||
if (overlay) {
|
||||
overlay.remove()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -375,6 +375,45 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Unpacking', () => {
|
||||
test('Unpacking subgraph with duplicate links does not create extra links', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-duplicate-links'
|
||||
)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) {
|
||||
return { error: 'No subgraph node found' }
|
||||
}
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
const linkCount = graph.links.size
|
||||
const nodes = graph.nodes
|
||||
const ksampler = nodes.find((n) => n.type === 'KSampler')
|
||||
if (!ksampler) return { error: 'No KSampler found after unpack' }
|
||||
|
||||
const linkedInputCount = ksampler.inputs.filter(
|
||||
(i) => i.link != null
|
||||
).length
|
||||
|
||||
return { linkCount, linkedInputCount, nodeCount: nodes.length }
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
// Should have exactly 1 link (EmptyLatentImage→KSampler)
|
||||
// not 4 (with 3 duplicates). The KSampler→output link is dropped
|
||||
// because the subgraph output has no downstream connection.
|
||||
expect(result.linkCount).toBe(1)
|
||||
// KSampler should have exactly 1 linked input (latent_image)
|
||||
expect(result.linkedInputCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Creation and Deletion', () => {
|
||||
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 105 KiB |
33
docs/WIDGET_SERIALIZATION.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Widget Serialization: `widget.serialize` vs `widget.options.serialize`
|
||||
|
||||
Two properties named `serialize` exist at different levels of a widget object. They control different serialization layers and are checked by completely different code paths.
|
||||
|
||||
**`widget.serialize`** — Controls **workflow persistence**. Checked by `LGraphNode.serialize()` and `configure()` when reading/writing `widgets_values` in the workflow JSON. When `false`, the widget is skipped in both serialization and deserialization. Used for UI-only widgets (image previews, progress text, audio players). Typed as `IBaseWidget.serialize` in `src/lib/litegraph/src/types/widgets.ts`.
|
||||
|
||||
**`widget.options.serialize`** — Controls **prompt/API serialization**. Checked by `executionUtil.ts` when building the API payload sent to the backend. When `false`, the widget is excluded from prompt inputs. Used for client-side-only controls (`control_after_generate`, combo filter lists) that the server doesn't need. Typed as `IWidgetOptions.serialize` in `src/lib/litegraph/src/types/widgets.ts`.
|
||||
|
||||
These correspond to the two data formats in `ComfyMetadata` embedded in output files (PNG, GLTF, WebM, AVIF, etc.): `widget.serialize` → `ComfyMetadataTags.WORKFLOW`, `widget.options.serialize` → `ComfyMetadataTags.PROMPT`.
|
||||
|
||||
## Permutation table
|
||||
|
||||
| `widget.serialize` | `widget.options.serialize` | In workflow? | In prompt? | Examples |
|
||||
| ------------------ | -------------------------- | ------------ | ---------- | -------------------------------------------------------------------- |
|
||||
| ✅ default | ✅ default | Yes | Yes | seed, cfg, sampler_name |
|
||||
| ✅ default | ❌ false | Yes | No | control_after_generate, combo filter list |
|
||||
| ❌ false | ✅ default | No | Yes | No current usage (would be a transient value computed at queue time) |
|
||||
| ❌ false | ❌ false | No | No | Image/video previews, audio players, progress text |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `addWidget('combo', name, value, cb, { serialize: false })` puts `serialize` into `widget.options`, **not** onto `widget` directly. These are different properties consumed by different systems.
|
||||
- `LGraphNode.serialize()` checks `widget.serialize === false` (line 967). It does **not** check `widget.options.serialize`. A widget with `options.serialize = false` is still included in `widgets_values`.
|
||||
- `LGraphNode.serialize()` only writes `widgets_values` if `this.widgets` is truthy. Nodes that create widgets dynamically (like `PrimitiveNode`) will have no `widgets_values` in serialized output if serialized before widget creation — even if `this.widgets_values` exists on the instance from a prior `configure()` call.
|
||||
- `widget.options.serialize` is typed as `IWidgetOptions.serialize` — both properties share the name `serialize` but live at different levels of the widget object.
|
||||
|
||||
## Code references
|
||||
|
||||
- `widget.serialize` checked: `src/lib/litegraph/src/LGraphNode.ts` serialize() and configure()
|
||||
- `widget.options.serialize` checked: `src/utils/executionUtil.ts`
|
||||
- `widget.options.serialize` set: `src/scripts/widgets.ts` addValueControlWidgets()
|
||||
- `widget.serialize` set: `src/composables/node/useNodeImage.ts`, `src/extensions/core/previewAny.ts`, etc.
|
||||
- Metadata types: `src/types/metadataTypes.ts`
|
||||
@@ -39,7 +39,9 @@ const config: KnipConfig = {
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts'
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Workflow files contain license names that knip misinterprets as binaries
|
||||
'.github/workflows/ci-oss-assets-validation.yaml'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.1",
|
||||
"version": "1.41.3",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -e
|
||||
|
||||
# Deploy Playwright test reports to Cloudflare Pages and comment on PR
|
||||
# Usage: ./pr-playwright-deploy-and-comment.sh <pr_number> <branch_name> <status> [start_time]
|
||||
# Usage: ./pr-playwright-deploy-and-comment.sh <pr_number> <branch_name> <status>
|
||||
|
||||
# Input validation
|
||||
# Validate PR number is numeric
|
||||
@@ -31,8 +31,6 @@ case "$STATUS" in
|
||||
;;
|
||||
esac
|
||||
|
||||
START_TIME="${4:-$(date -u '+%m/%d/%Y, %I:%M:%S %p')}"
|
||||
|
||||
# Required environment variables
|
||||
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
|
||||
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
|
||||
@@ -135,23 +133,8 @@ post_comment() {
|
||||
# Main execution
|
||||
if [ "$STATUS" = "starting" ]; then
|
||||
# Post concise starting comment
|
||||
comment=$(cat <<EOF
|
||||
$COMMENT_MARKER
|
||||
## 🎭 Playwright Tests: ⏳ Running...
|
||||
|
||||
Tests started at $START_TIME UTC
|
||||
|
||||
<details>
|
||||
<summary>📊 Browser Tests</summary>
|
||||
|
||||
- **chromium**: Running...
|
||||
- **chromium-0.5x**: Running...
|
||||
- **chromium-2x**: Running...
|
||||
- **mobile-chrome**: Running...
|
||||
|
||||
</details>
|
||||
EOF
|
||||
)
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎭 Playwright: ⏳ Running..."
|
||||
post_comment "$comment"
|
||||
|
||||
else
|
||||
@@ -300,7 +283,7 @@ else
|
||||
|
||||
# Generate compact single-line comment
|
||||
comment="$COMMENT_MARKER
|
||||
**Playwright:** $status_icon $total_passed passed, $total_failed failed$flaky_note"
|
||||
## 🎭 Playwright: $status_icon $total_passed passed, $total_failed failed$flaky_note"
|
||||
|
||||
# Extract and display failed tests from all browsers (flaky tests are treated as passing)
|
||||
if [ $total_failed -gt 0 ]; then
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -e
|
||||
|
||||
# Deploy Storybook to Cloudflare Pages and comment on PR
|
||||
# Usage: ./pr-storybook-deploy-and-comment.sh <pr_number> <branch_name> <status> [start_time]
|
||||
# Usage: ./pr-storybook-deploy-and-comment.sh <pr_number> <branch_name> <status>
|
||||
|
||||
# Input validation
|
||||
# Validate PR number is numeric
|
||||
@@ -31,7 +31,6 @@ case "$STATUS" in
|
||||
;;
|
||||
esac
|
||||
|
||||
START_TIME="${4:-$(date -u '+%m/%d/%Y, %I:%M:%S %p')}"
|
||||
|
||||
# Required environment variables
|
||||
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
|
||||
@@ -120,50 +119,9 @@ post_comment() {
|
||||
|
||||
# Main execution
|
||||
if [ "$STATUS" = "starting" ]; then
|
||||
# Check if this is a version-bump branch
|
||||
IS_VERSION_BUMP="false"
|
||||
if echo "$BRANCH_NAME" | grep -q "^version-bump-"; then
|
||||
IS_VERSION_BUMP="true"
|
||||
fi
|
||||
|
||||
# Post starting comment with appropriate message
|
||||
if [ "$IS_VERSION_BUMP" = "true" ]; then
|
||||
comment=$(cat <<EOF
|
||||
$COMMENT_MARKER
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Build is starting...**
|
||||
|
||||
⏰ Started at: $START_TIME UTC
|
||||
|
||||
### 🚀 Building Storybook
|
||||
- 📦 Installing dependencies...
|
||||
- 🔧 Building Storybook components...
|
||||
- 🎨 Running Chromatic visual tests...
|
||||
|
||||
---
|
||||
⏱️ Please wait while the Storybook build is in progress...
|
||||
EOF
|
||||
)
|
||||
else
|
||||
comment=$(cat <<EOF
|
||||
$COMMENT_MARKER
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
<img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> **Build is starting...**
|
||||
|
||||
⏰ Started at: $START_TIME UTC
|
||||
|
||||
### 🚀 Building Storybook
|
||||
- 📦 Installing dependencies...
|
||||
- 🔧 Building Storybook components...
|
||||
- 🌐 Preparing deployment to Cloudflare Pages...
|
||||
|
||||
---
|
||||
⏱️ Please wait while the Storybook build is in progress...
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
# Post starting comment
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎨 Storybook: <img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> Building..."
|
||||
post_comment "$comment"
|
||||
|
||||
elif [ "$STATUS" = "completed" ]; then
|
||||
@@ -192,56 +150,57 @@ elif [ "$STATUS" = "completed" ]; then
|
||||
WORKFLOW_CONCLUSION="${WORKFLOW_CONCLUSION:-success}"
|
||||
WORKFLOW_URL="${WORKFLOW_URL:-}"
|
||||
|
||||
# Generate completion comment based on conclusion
|
||||
# Generate compact header based on conclusion
|
||||
if [ "$WORKFLOW_CONCLUSION" = "success" ]; then
|
||||
status_icon="✅"
|
||||
status_text="Build completed successfully!"
|
||||
footer_text="🎉 Your Storybook is ready for review!"
|
||||
status_text="Built"
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "skipped" ]; then
|
||||
status_icon="⏭️"
|
||||
status_text="Build skipped."
|
||||
footer_text="ℹ️ Chromatic was skipped for this PR."
|
||||
status_text="Skipped"
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "cancelled" ]; then
|
||||
status_icon="🚫"
|
||||
status_text="Build cancelled."
|
||||
footer_text="ℹ️ The Chromatic run was cancelled."
|
||||
status_text="Cancelled"
|
||||
else
|
||||
status_icon="❌"
|
||||
status_text="Build failed!"
|
||||
footer_text="⚠️ Please check the workflow logs for error details."
|
||||
status_text="Failed"
|
||||
fi
|
||||
|
||||
comment="$COMMENT_MARKER
|
||||
## 🎨 Storybook Build Status
|
||||
|
||||
$status_icon **$status_text**
|
||||
# Build compact header with optional storybook link
|
||||
header="## 🎨 Storybook: $status_icon $status_text"
|
||||
if [ "$deployment_url" != "Not deployed" ] && [ "$deployment_url" != "Deployment failed" ] && [ "$WORKFLOW_CONCLUSION" = "success" ]; then
|
||||
header="$header — $deployment_url"
|
||||
fi
|
||||
|
||||
# Build details section
|
||||
details="<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
|
||||
|
||||
### 🔗 Links
|
||||
**Links**
|
||||
- [📊 View Workflow Run]($WORKFLOW_URL)"
|
||||
|
||||
# Add deployment status
|
||||
|
||||
if [ "$deployment_url" != "Not deployed" ]; then
|
||||
if [ "$deployment_url" = "Deployment failed" ]; then
|
||||
comment="$comment
|
||||
details="$details
|
||||
- ❌ Storybook deployment failed"
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "success" ]; then
|
||||
comment="$comment
|
||||
- 🎨 $deployment_url"
|
||||
else
|
||||
comment="$comment
|
||||
- ⚠️ Build failed - $deployment_url"
|
||||
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
|
||||
details="$details
|
||||
- ⚠️ Build failed — $deployment_url"
|
||||
fi
|
||||
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
|
||||
comment="$comment
|
||||
details="$details
|
||||
- ⏭️ Storybook deployment skipped (build did not succeed)"
|
||||
fi
|
||||
|
||||
comment="$comment
|
||||
|
||||
---
|
||||
$footer_text"
|
||||
details="$details
|
||||
|
||||
</details>"
|
||||
|
||||
comment="$COMMENT_MARKER
|
||||
$header
|
||||
|
||||
$details"
|
||||
|
||||
post_comment "$comment"
|
||||
fi
|
||||
189
src/components/common/VirtualGrid.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import VirtualGrid from './VirtualGrid.vue'
|
||||
|
||||
type TestItem = { key: string; name: string }
|
||||
|
||||
let mockedWidth: Ref<number>
|
||||
let mockedHeight: Ref<number>
|
||||
let mockedScrollY: Ref<number>
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
|
||||
return {
|
||||
...actual,
|
||||
useElementSize: () => ({ width: mockedWidth, height: mockedHeight }),
|
||||
useScroll: () => ({ y: mockedScrollY })
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockedWidth = ref(400)
|
||||
mockedHeight = ref(200)
|
||||
mockedScrollY = ref(0)
|
||||
})
|
||||
|
||||
function createItems(count: number): TestItem[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
key: `item-${i}`,
|
||||
name: `Item ${i}`
|
||||
}))
|
||||
}
|
||||
|
||||
describe('VirtualGrid', () => {
|
||||
const defaultGridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '1rem'
|
||||
}
|
||||
|
||||
it('renders items within the visible range', async () => {
|
||||
const items = createItems(100)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 100,
|
||||
defaultItemWidth: 100,
|
||||
maxColumns: 4,
|
||||
bufferRows: 1
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const renderedItems = wrapper.findAll('.test-item')
|
||||
expect(renderedItems.length).toBeGreaterThan(0)
|
||||
expect(renderedItems.length).toBeLessThan(items.length)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('provides correct index in slot props', async () => {
|
||||
const items = createItems(20)
|
||||
const receivedIndices: number[] = []
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 50,
|
||||
defaultItemWidth: 100,
|
||||
maxColumns: 1,
|
||||
bufferRows: 0
|
||||
},
|
||||
slots: {
|
||||
item: ({ index }: { index: number }) => {
|
||||
receivedIndices.push(index)
|
||||
return null
|
||||
}
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(receivedIndices.length).toBeGreaterThan(0)
|
||||
expect(receivedIndices[0]).toBe(0)
|
||||
for (let i = 1; i < receivedIndices.length; i++) {
|
||||
expect(receivedIndices[i]).toBe(receivedIndices[i - 1] + 1)
|
||||
}
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('respects maxColumns prop', async () => {
|
||||
const items = createItems(10)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
maxColumns: 2
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const gridElement = wrapper.find('[style*="display: grid"]')
|
||||
expect(gridElement.exists()).toBe(true)
|
||||
|
||||
const gridEl = gridElement.element as HTMLElement
|
||||
expect(gridEl.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders empty when no items provided', async () => {
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items: [],
|
||||
gridStyle: defaultGridStyle
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const renderedItems = wrapper.findAll('.test-item')
|
||||
expect(renderedItems.length).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('forces cols to maxColumns when maxColumns is finite', async () => {
|
||||
mockedWidth.value = 100
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const items = createItems(20)
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 50,
|
||||
defaultItemWidth: 200,
|
||||
maxColumns: 4,
|
||||
bufferRows: 0
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const renderedItems = wrapper.findAll('.test-item')
|
||||
expect(renderedItems.length).toBeGreaterThan(0)
|
||||
expect(renderedItems.length % 4).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,16 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
|
||||
class="h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable] scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
|
||||
>
|
||||
<div :style="topSpacerStyle" />
|
||||
<div :style="mergedGridStyle">
|
||||
<div
|
||||
v-for="item in renderedItems"
|
||||
v-for="(item, i) in renderedItems"
|
||||
:key="item.key"
|
||||
class="transition-[width] duration-150 ease-out"
|
||||
data-virtual-grid-item
|
||||
>
|
||||
<slot name="item" :item="item" />
|
||||
<slot name="item" :item :index="state.start + i" />
|
||||
</div>
|
||||
</div>
|
||||
<div :style="bottomSpacerStyle" />
|
||||
@@ -66,9 +65,10 @@ const { y: scrollY } = useScroll(container, {
|
||||
eventListenerOptions: { passive: true }
|
||||
})
|
||||
|
||||
const cols = computed(() =>
|
||||
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
|
||||
)
|
||||
const cols = computed(() => {
|
||||
if (maxColumns !== Infinity) return maxColumns
|
||||
return Math.floor(width.value / itemWidth.value) || 1
|
||||
})
|
||||
|
||||
const mergedGridStyle = computed<CSSProperties>(() => {
|
||||
if (maxColumns === Infinity) return gridStyle
|
||||
@@ -101,8 +101,9 @@ const renderedItems = computed(() =>
|
||||
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
|
||||
)
|
||||
|
||||
function rowsToHeight(rows: number): string {
|
||||
return `${(rows / cols.value) * itemHeight.value}px`
|
||||
function rowsToHeight(itemsCount: number): string {
|
||||
const rows = Math.ceil(itemsCount / cols.value)
|
||||
return `${rows * itemHeight.value}px`
|
||||
}
|
||||
const topSpacerStyle = computed<CSSProperties>(() => ({
|
||||
height: rowsToHeight(state.value.start)
|
||||
@@ -118,11 +119,10 @@ whenever(
|
||||
}
|
||||
)
|
||||
|
||||
const updateItemSize = () => {
|
||||
function updateItemSize(): void {
|
||||
if (container.value) {
|
||||
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
|
||||
|
||||
// Don't update item size if the first item is not rendered yet
|
||||
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
|
||||
|
||||
if (itemHeight.value !== firstItem.clientHeight) {
|
||||
|
||||
85
src/components/queue/QueueOverlayExpanded.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
vi.mock('@/composables/queue/useJobMenu', () => ({
|
||||
useJobMenu: () => ({ jobMenuEntries: [] })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandlingAsync: <T extends (...args: never[]) => unknown>(
|
||||
fn: T
|
||||
) => fn
|
||||
})
|
||||
}))
|
||||
|
||||
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
|
||||
|
||||
const QueueOverlayHeaderStub = {
|
||||
template: '<div />'
|
||||
}
|
||||
|
||||
const JobFiltersBarStub = {
|
||||
template: '<div />'
|
||||
}
|
||||
|
||||
const JobAssetsListStub = {
|
||||
name: 'JobAssetsList',
|
||||
template: '<div class="job-assets-list-stub" />'
|
||||
}
|
||||
|
||||
const JobContextMenuStub = {
|
||||
template: '<div />'
|
||||
}
|
||||
|
||||
const createJob = (): JobListItem => ({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'pending'
|
||||
})
|
||||
|
||||
const mountComponent = () =>
|
||||
mount(QueueOverlayExpanded, {
|
||||
props: {
|
||||
headerTitle: 'Jobs',
|
||||
queuedCount: 1,
|
||||
selectedJobTab: 'All',
|
||||
selectedWorkflowFilter: 'all',
|
||||
selectedSortMode: 'mostRecent',
|
||||
displayedJobGroups: [],
|
||||
hasFailedJobs: false
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
QueueOverlayHeader: QueueOverlayHeaderStub,
|
||||
JobFiltersBar: JobFiltersBarStub,
|
||||
JobAssetsList: JobAssetsListStub,
|
||||
JobContextMenu: JobContextMenuStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('QueueOverlayExpanded', () => {
|
||||
it('renders JobAssetsList', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.job-assets-list-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('re-emits list item actions from JobAssetsList', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const job = createJob()
|
||||
const jobAssetsList = wrapper.findComponent({ name: 'JobAssetsList' })
|
||||
|
||||
jobAssetsList.vm.$emit('cancel-item', job)
|
||||
jobAssetsList.vm.$emit('delete-item', job)
|
||||
jobAssetsList.vm.$emit('view-item', job)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('cancelItem')?.[0]).toEqual([job])
|
||||
expect(wrapper.emitted('deleteItem')?.[0]).toEqual([job])
|
||||
expect(wrapper.emitted('viewItem')?.[0]).toEqual([job])
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,6 @@
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<QueueOverlayHeader
|
||||
:header-title="headerTitle"
|
||||
:show-concurrent-indicator="showConcurrentIndicator"
|
||||
:concurrent-workflow-count="concurrentWorkflowCount"
|
||||
:queued-count="queuedCount"
|
||||
@clear-history="$emit('clearHistory')"
|
||||
@clear-queued="$emit('clearQueued')"
|
||||
@@ -23,7 +21,7 @@
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<JobGroupsList
|
||||
<JobAssetsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
@cancel-item="onCancelItemEvent"
|
||||
@delete-item="onDeleteItemEvent"
|
||||
@@ -55,13 +53,11 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
|
||||
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
||||
import JobContextMenu from './job/JobContextMenu.vue'
|
||||
import JobAssetsList from './job/JobAssetsList.vue'
|
||||
import JobFiltersBar from './job/JobFiltersBar.vue'
|
||||
import JobGroupsList from './job/JobGroupsList.vue'
|
||||
|
||||
defineProps<{
|
||||
headerTitle: string
|
||||
showConcurrentIndicator: boolean
|
||||
concurrentWorkflowCount: number
|
||||
queuedCount: number
|
||||
selectedJobTab: JobTab
|
||||
selectedWorkflowFilter: 'all' | 'current'
|
||||
|
||||
@@ -51,9 +51,10 @@ const i18n = createI18n({
|
||||
g: { more: 'More' },
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
running: 'running',
|
||||
queuedSuffix: 'queued',
|
||||
clearQueued: 'Clear queued',
|
||||
clearQueueTooltip: 'Clear queue',
|
||||
clearAllJobsTooltip: 'Cancel all running jobs',
|
||||
moreOptions: 'More options',
|
||||
clearHistory: 'Clear history',
|
||||
dockedJobHistory: 'Docked Job History'
|
||||
@@ -67,8 +68,6 @@ const mountHeader = (props = {}) =>
|
||||
mount(QueueOverlayHeader, {
|
||||
props: {
|
||||
headerTitle: 'Job queue',
|
||||
showConcurrentIndicator: true,
|
||||
concurrentWorkflowCount: 2,
|
||||
queuedCount: 3,
|
||||
...props
|
||||
},
|
||||
@@ -84,40 +83,28 @@ describe('QueueOverlayHeader', () => {
|
||||
mockSetSetting.mockClear()
|
||||
})
|
||||
|
||||
it('renders header title and concurrent indicator when enabled', () => {
|
||||
const wrapper = mountHeader({ concurrentWorkflowCount: 3 })
|
||||
|
||||
it('renders header title', () => {
|
||||
const wrapper = mountHeader()
|
||||
expect(wrapper.text()).toContain('Job queue')
|
||||
const indicator = wrapper.find('.inline-flex.items-center.gap-1')
|
||||
expect(indicator.exists()).toBe(true)
|
||||
expect(indicator.text()).toContain('3')
|
||||
expect(indicator.text()).toContain('running')
|
||||
})
|
||||
|
||||
it('hides concurrent indicator when flag is false', () => {
|
||||
const wrapper = mountHeader({ showConcurrentIndicator: false })
|
||||
|
||||
expect(wrapper.text()).toContain('Job queue')
|
||||
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows queued summary and emits clear queued', async () => {
|
||||
it('shows clear queue text and emits clear queued', async () => {
|
||||
const wrapper = mountHeader({ queuedCount: 4 })
|
||||
|
||||
expect(wrapper.text()).toContain('4')
|
||||
expect(wrapper.text()).toContain('queued')
|
||||
expect(wrapper.text()).toContain('Clear queue')
|
||||
expect(wrapper.text()).not.toContain('4 queued')
|
||||
|
||||
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
|
||||
await clearQueuedButton.trigger('click')
|
||||
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides clear queued button when queued count is zero', () => {
|
||||
it('disables clear queued button when queued count is zero', () => {
|
||||
const wrapper = mountHeader({ queuedCount: 0 })
|
||||
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
|
||||
|
||||
expect(wrapper.find('button[aria-label="Clear queued"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
expect(clearQueuedButton.attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('Clear queue')
|
||||
})
|
||||
|
||||
it('emits clear history from the menu', async () => {
|
||||
|
||||
@@ -4,34 +4,19 @@
|
||||
>
|
||||
<div class="min-w-0 flex-1 px-2 text-[14px] font-normal text-text-primary">
|
||||
<span>{{ headerTitle }}</span>
|
||||
<span
|
||||
v-if="showConcurrentIndicator"
|
||||
class="ml-4 inline-flex items-center gap-1 text-blue-100"
|
||||
>
|
||||
<span class="inline-block size-2 rounded-full bg-blue-100" />
|
||||
<span>
|
||||
<span class="font-bold">{{ concurrentWorkflowCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.running')
|
||||
}}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex h-6 items-center gap-2 text-[12px] leading-none text-text-primary"
|
||||
>
|
||||
<span class="opacity-90">
|
||||
<span class="font-bold">{{ queuedCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</span>
|
||||
<span :class="{ 'opacity-50': queuedCount === 0 }">{{
|
||||
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
|
||||
}}</span>
|
||||
<Button
|
||||
v-if="queuedCount > 0"
|
||||
v-tooltip.top="clearAllJobsTooltip"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
:disabled="queuedCount === 0"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
@@ -51,8 +36,6 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
defineProps<{
|
||||
headerTitle: string
|
||||
showConcurrentIndicator: boolean
|
||||
concurrentWorkflowCount: number
|
||||
queuedCount: number
|
||||
}>()
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
v-model:selected-sort-mode="selectedSortMode"
|
||||
class="flex-1 min-h-0"
|
||||
:header-title="headerTitle"
|
||||
:show-concurrent-indicator="showConcurrentIndicator"
|
||||
:concurrent-workflow-count="concurrentWorkflowCount"
|
||||
:queued-count="queuedCount"
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
@@ -183,13 +181,6 @@ const headerTitle = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const concurrentWorkflowCount = computed(
|
||||
() => executionStore.runningWorkflowCount
|
||||
)
|
||||
const showConcurrentIndicator = computed(
|
||||
() => concurrentWorkflowCount.value > 1
|
||||
)
|
||||
|
||||
const {
|
||||
selectedJobTab,
|
||||
selectedWorkflowFilter,
|
||||
|
||||
@@ -53,6 +53,7 @@ import NodeSearchCategoryTreeNode, {
|
||||
CATEGORY_UNSELECTED_CLASS
|
||||
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
|
||||
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
@@ -64,6 +65,7 @@ const selectedCategory = defineModel<string>('selectedCategory', {
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const { flags } = useFeatureFlags()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const topCategories = computed(() => [
|
||||
@@ -79,7 +81,7 @@ const hasEssentialNodes = computed(() =>
|
||||
|
||||
const sourceCategories = computed(() => {
|
||||
const categories = []
|
||||
if (hasEssentialNodes.value) {
|
||||
if (flags.nodeLibraryEssentialsEnabled && hasEssentialNodes.value) {
|
||||
categories.push({ id: 'essentials', label: t('g.essentials') })
|
||||
}
|
||||
categories.push({ id: 'custom', label: t('g.custom') })
|
||||
|
||||
@@ -154,6 +154,7 @@ import {
|
||||
render
|
||||
} from 'vue'
|
||||
|
||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
@@ -276,7 +277,9 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.leaf ? node.data.display_name : node.label,
|
||||
label: node.leaf
|
||||
? (resolveEssentialsDisplayName(node.data) ?? node.data.display_name)
|
||||
: node.label,
|
||||
leaf: node.leaf,
|
||||
data: node.data,
|
||||
getIcon() {
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
:value="tab.value"
|
||||
:class="
|
||||
cn(
|
||||
'select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
|
||||
'flex-1 text-center select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
|
||||
'text-sm text-foreground transition-colors',
|
||||
selectedTab === tab.value
|
||||
? 'bg-comfy-input font-bold'
|
||||
@@ -70,7 +70,9 @@
|
||||
<!-- Tab content (scrollable) -->
|
||||
<TabsRoot v-model="selectedTab" class="h-full">
|
||||
<EssentialNodesPanel
|
||||
v-if="selectedTab === 'essentials'"
|
||||
v-if="
|
||||
flags.nodeLibraryEssentialsEnabled && selectedTab === 'essentials'
|
||||
"
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="renderedEssentialRoot"
|
||||
@node-click="handleNodeClick"
|
||||
@@ -109,10 +111,12 @@ import {
|
||||
TabsRoot,
|
||||
TabsTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||
import SearchBox from '@/components/common/SearchBoxV2.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { usePerTabState } from '@/composables/usePerTabState'
|
||||
import {
|
||||
@@ -136,11 +140,22 @@ import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
|
||||
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
|
||||
import SidebarTabTemplate from './SidebarTabTemplate.vue'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const selectedTab = useLocalStorage<TabId>(
|
||||
'Comfy.NodeLibrary.Tab',
|
||||
DEFAULT_TAB_ID
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
if (
|
||||
!flags.nodeLibraryEssentialsEnabled &&
|
||||
selectedTab.value === 'essentials'
|
||||
) {
|
||||
selectedTab.value = DEFAULT_TAB_ID
|
||||
}
|
||||
})
|
||||
|
||||
const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
|
||||
'Comfy.NodeLibrary.SortByTab',
|
||||
{
|
||||
@@ -216,16 +231,23 @@ function findFirstLeaf(node: TreeNode): TreeNode | undefined {
|
||||
}
|
||||
|
||||
function fillNodeInfo(
|
||||
node: TreeNode
|
||||
node: TreeNode,
|
||||
{ useEssentialsLabels = false }: { useEssentialsLabels?: boolean } = {}
|
||||
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
|
||||
const children = node.children?.map(fillNodeInfo)
|
||||
const children = node.children?.map((child) =>
|
||||
fillNodeInfo(child, { useEssentialsLabels })
|
||||
)
|
||||
const totalLeaves = node.leaf
|
||||
? 1
|
||||
: (children?.reduce((acc, child) => acc + child.totalLeaves, 0) ?? 0)
|
||||
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.leaf ? node.data?.display_name : node.label,
|
||||
label: node.leaf
|
||||
? useEssentialsLabels
|
||||
? (resolveEssentialsDisplayName(node.data) ?? node.data?.display_name)
|
||||
: node.data?.display_name
|
||||
: node.label,
|
||||
leaf: node.leaf,
|
||||
data: node.data,
|
||||
icon: node.leaf ? 'icon-[comfy--node]' : getFolderIcon(node),
|
||||
@@ -260,7 +282,7 @@ const essentialSections = computed(() => {
|
||||
const renderedEssentialRoot = computed(() => {
|
||||
const section = essentialSections.value[0]
|
||||
return section
|
||||
? fillNodeInfo(applySorting(section.tree))
|
||||
? fillNodeInfo(applySorting(section.tree), { useEssentialsLabels: true })
|
||||
: fillNodeInfo({ key: 'root', label: '', children: [] })
|
||||
})
|
||||
|
||||
@@ -324,11 +346,21 @@ async function handleSearch() {
|
||||
expandedKeys.value = allKeys
|
||||
}
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ value: 'essentials', label: t('sideToolbar.nodeLibraryTab.essentials') },
|
||||
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
|
||||
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
|
||||
])
|
||||
const tabs = computed(() => {
|
||||
const baseTabs: Array<{ value: TabId; label: string }> = [
|
||||
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
|
||||
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
|
||||
]
|
||||
return flags.nodeLibraryEssentialsEnabled
|
||||
? [
|
||||
{
|
||||
value: 'essentials' as TabId,
|
||||
label: t('sideToolbar.nodeLibraryTab.essentials')
|
||||
},
|
||||
...baseTabs
|
||||
]
|
||||
: baseTabs
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
searchBoxRef.value?.focus()
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('EssentialNodeCard', () => {
|
||||
|
||||
return {
|
||||
key: 'test-key',
|
||||
label: 'Test Node',
|
||||
label: data.display_name,
|
||||
icon: 'icon-[comfy--node]',
|
||||
type: 'node',
|
||||
totalLeaves: 1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="group relative flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border aspect-square"
|
||||
:data-node-name="node.data?.display_name"
|
||||
:data-node-name="node.label"
|
||||
draggable="true"
|
||||
@click="handleClick"
|
||||
@dragstart="handleDragStart"
|
||||
@@ -16,7 +16,7 @@
|
||||
<TextTickerMultiLine
|
||||
class="shrink-0 h-8 w-full text-xs font-bold text-foreground leading-4"
|
||||
>
|
||||
{{ node.data?.display_name }}
|
||||
{{ node.label }}
|
||||
</TextTickerMultiLine>
|
||||
</div>
|
||||
|
||||
|
||||
54
src/components/ui/textarea/Textarea.stories.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Textarea from './Textarea.vue'
|
||||
|
||||
const meta: Meta<typeof Textarea> = {
|
||||
title: 'UI/Textarea',
|
||||
component: Textarea,
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Textarea>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { Textarea },
|
||||
setup() {
|
||||
const value = ref('Hello world')
|
||||
return { value }
|
||||
},
|
||||
template:
|
||||
'<Textarea v-model="value" placeholder="Type something..." class="max-w-sm" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => ({
|
||||
components: { Textarea },
|
||||
template:
|
||||
'<Textarea model-value="Disabled textarea" disabled class="max-w-sm" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithLabel: Story = {
|
||||
render: () => ({
|
||||
components: { Textarea },
|
||||
setup() {
|
||||
const value = ref('Content that sits below the label')
|
||||
return { value }
|
||||
},
|
||||
template: `
|
||||
<div class="relative max-w-sm rounded-lg bg-component-node-widget-background">
|
||||
<label class="pointer-events-none absolute left-3 top-1.5 text-xxs text-muted-foreground z-10">
|
||||
Prompt
|
||||
</label>
|
||||
<Textarea
|
||||
v-model="value"
|
||||
class="size-full resize-none border-none bg-transparent pt-5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
24
src/components/ui/textarea/Textarea.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restAttrs } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string | number>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
v-bind="restAttrs"
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
|
||||
|
||||
const { mockGetSetting, mockActiveJobsCount } = vi.hoisted(() => ({
|
||||
const { mockGetSetting, mockUnseenAddedAssetsCount } = vi.hoisted(() => ({
|
||||
mockGetSetting: vi.fn(),
|
||||
mockActiveJobsCount: { value: 0 }
|
||||
mockUnseenAddedAssetsCount: { value: 0 }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -17,16 +17,16 @@ vi.mock('@/components/sidebar/tabs/AssetsSidebarTab.vue', () => ({
|
||||
default: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => ({
|
||||
activeJobsCount: mockActiveJobsCount.value
|
||||
vi.mock('@/stores/workspace/assetsSidebarBadgeStore', () => ({
|
||||
useAssetsSidebarBadgeStore: () => ({
|
||||
unseenAddedAssetsCount: mockUnseenAddedAssetsCount.value
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useAssetsSidebarTab', () => {
|
||||
it('hides icon badge when QPO V2 is disabled', () => {
|
||||
mockGetSetting.mockReturnValue(false)
|
||||
mockActiveJobsCount.value = 3
|
||||
mockUnseenAddedAssetsCount.value = 3
|
||||
|
||||
const sidebarTab = useAssetsSidebarTab()
|
||||
|
||||
@@ -34,9 +34,9 @@ describe('useAssetsSidebarTab', () => {
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows active job count when QPO V2 is enabled', () => {
|
||||
it('shows unseen added assets count when QPO V2 is enabled', () => {
|
||||
mockGetSetting.mockReturnValue(true)
|
||||
mockActiveJobsCount.value = 3
|
||||
mockUnseenAddedAssetsCount.value = 3
|
||||
|
||||
const sidebarTab = useAssetsSidebarTab()
|
||||
|
||||
@@ -44,9 +44,9 @@ describe('useAssetsSidebarTab', () => {
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBe('3')
|
||||
})
|
||||
|
||||
it('hides badge when no active jobs', () => {
|
||||
it('hides badge when there are no unseen added assets', () => {
|
||||
mockGetSetting.mockReturnValue(true)
|
||||
mockActiveJobsCount.value = 0
|
||||
mockUnseenAddedAssetsCount.value = 0
|
||||
|
||||
const sidebarTab = useAssetsSidebarTab()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { markRaw } from 'vue'
|
||||
|
||||
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useAssetsSidebarBadgeStore } from '@/stores/workspace/assetsSidebarBadgeStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useAssetsSidebarTab = (): SidebarTabExtension => {
|
||||
@@ -21,8 +21,8 @@ export const useAssetsSidebarTab = (): SidebarTabExtension => {
|
||||
return null
|
||||
}
|
||||
|
||||
const queueStore = useQueueStore()
|
||||
const count = queueStore.activeJobsCount
|
||||
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
|
||||
const count = assetsSidebarBadgeStore.unseenAddedAssetsCount
|
||||
return count > 0 ? count.toString() : null
|
||||
}
|
||||
}
|
||||
|
||||
59
src/composables/sidebarTabs/useJobHistorySidebarTab.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useJobHistorySidebarTab } from '@/composables/sidebarTabs/useJobHistorySidebarTab'
|
||||
|
||||
const { mockActiveJobsCount, mockActiveSidebarTabId } = vi.hoisted(() => ({
|
||||
mockActiveJobsCount: { value: 0 },
|
||||
mockActiveSidebarTabId: { value: null as string | null }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/JobHistorySidebarTab.vue', () => ({
|
||||
default: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => ({
|
||||
activeJobsCount: mockActiveJobsCount.value
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
useSidebarTabStore: () => ({
|
||||
activeSidebarTabId: mockActiveSidebarTabId.value
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useJobHistorySidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveSidebarTabId.value = null
|
||||
mockActiveJobsCount.value = 0
|
||||
})
|
||||
|
||||
it('shows active jobs count while the panel is closed', () => {
|
||||
mockActiveSidebarTabId.value = 'assets'
|
||||
mockActiveJobsCount.value = 3
|
||||
|
||||
const sidebarTab = useJobHistorySidebarTab()
|
||||
|
||||
expect(typeof sidebarTab.iconBadge).toBe('function')
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBe('3')
|
||||
})
|
||||
|
||||
it('hides badge while the job history panel is open', () => {
|
||||
mockActiveSidebarTabId.value = 'job-history'
|
||||
mockActiveJobsCount.value = 3
|
||||
|
||||
const sidebarTab = useJobHistorySidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
|
||||
it('hides badge when there are no active jobs', () => {
|
||||
mockActiveSidebarTabId.value = null
|
||||
mockActiveJobsCount.value = 0
|
||||
|
||||
const sidebarTab = useJobHistorySidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import JobHistorySidebarTab from '@/components/sidebar/tabs/JobHistorySidebarTab.vue'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useJobHistorySidebarTab = (): SidebarTabExtension => {
|
||||
@@ -11,6 +13,16 @@ export const useJobHistorySidebarTab = (): SidebarTabExtension => {
|
||||
tooltip: 'queue.jobHistory',
|
||||
label: 'queue.jobHistory',
|
||||
component: markRaw(JobHistorySidebarTab),
|
||||
type: 'vue'
|
||||
type: 'vue',
|
||||
iconBadge: () => {
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
if (sidebarTabStore.activeSidebarTabId === 'job-history') {
|
||||
return null
|
||||
}
|
||||
|
||||
const queueStore = useQueueStore()
|
||||
const count = queueStore.activeJobsCount
|
||||
return count > 0 ? count.toString() : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isReactive, isReadonly } from 'vue'
|
||||
|
||||
import {
|
||||
@@ -175,4 +175,49 @@ describe('useFeatureFlags', () => {
|
||||
expect(flags.linearToggleEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dev override via localStorage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('resolveFlag returns localStorage override over remoteConfig and server value', () => {
|
||||
vi.mocked(api.getServerFeature).mockReturnValue(false)
|
||||
localStorage.setItem('ff:model_upload_button_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.modelUploadButtonEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('resolveFlag falls through to server when no override is set', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.ASSET_RENAME_ENABLED) return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.assetRenameEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('direct server flags delegate override to api.getServerFeature', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation((path) => {
|
||||
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
|
||||
return 'overridden'
|
||||
return undefined
|
||||
})
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.supportsPreviewMetadata).toBe('overridden')
|
||||
})
|
||||
|
||||
it('teamWorkspacesEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:team_workspaces_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
|
||||
/**
|
||||
* Known server feature flags (top-level, not extensions)
|
||||
@@ -21,7 +22,21 @@ export enum ServerFeatureFlag {
|
||||
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
||||
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
|
||||
USER_SECRETS_ENABLED = 'user_secrets_enabled',
|
||||
NODE_REPLACEMENTS = 'node_replacements'
|
||||
NODE_REPLACEMENTS = 'node_replacements',
|
||||
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a feature flag value with dev override > remoteConfig > serverFeature priority.
|
||||
*/
|
||||
function resolveFlag<T>(
|
||||
flagKey: string,
|
||||
remoteConfigValue: T | undefined,
|
||||
defaultValue: T
|
||||
): T {
|
||||
const override = getDevOverride<T>(flagKey)
|
||||
if (override !== undefined) return override
|
||||
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,38 +54,40 @@ export function useFeatureFlags() {
|
||||
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
|
||||
},
|
||||
get modelUploadButtonEnabled() {
|
||||
return (
|
||||
remoteConfig.value.model_upload_button_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
|
||||
false
|
||||
)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
|
||||
remoteConfig.value.model_upload_button_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get assetRenameEnabled() {
|
||||
return (
|
||||
remoteConfig.value.asset_rename_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ASSET_RENAME_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.ASSET_RENAME_ENABLED,
|
||||
remoteConfig.value.asset_rename_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get privateModelsEnabled() {
|
||||
return (
|
||||
remoteConfig.value.private_models_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.PRIVATE_MODELS_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.PRIVATE_MODELS_ENABLED,
|
||||
remoteConfig.value.private_models_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get onboardingSurveyEnabled() {
|
||||
return (
|
||||
remoteConfig.value.onboarding_survey_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED,
|
||||
remoteConfig.value.onboarding_survey_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get linearToggleEnabled() {
|
||||
if (isNightly) return true
|
||||
|
||||
return (
|
||||
remoteConfig.value.linear_toggle_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.LINEAR_TOGGLE_ENABLED,
|
||||
remoteConfig.value.linear_toggle_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
/**
|
||||
@@ -80,11 +97,12 @@ export function useFeatureFlags() {
|
||||
* and prevents race conditions during initialization.
|
||||
*/
|
||||
get teamWorkspacesEnabled() {
|
||||
if (!isCloud) return false
|
||||
const override = getDevOverride<boolean>(
|
||||
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED
|
||||
)
|
||||
if (override !== undefined) return override
|
||||
|
||||
// Only return true if authenticated config has been loaded.
|
||||
// This prevents race conditions where code checks this flag before
|
||||
// WorkspaceAuthGate has refreshed the config with auth.
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value) return false
|
||||
|
||||
return (
|
||||
@@ -93,13 +111,25 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
return (
|
||||
remoteConfig.value.user_secrets_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false)
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.USER_SECRETS_ENABLED,
|
||||
remoteConfig.value.user_secrets_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get nodeReplacementsEnabled() {
|
||||
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
|
||||
},
|
||||
get nodeLibraryEssentialsEnabled() {
|
||||
if (isNightly || import.meta.env.DEV) return true
|
||||
|
||||
return (
|
||||
remoteConfig.value.node_library_essentials_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
105
src/constants/essentialsDisplayNames.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: vi.fn((key: string) => key)
|
||||
}))
|
||||
|
||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||
|
||||
describe('resolveEssentialsDisplayName', () => {
|
||||
describe('exact name matches', () => {
|
||||
it.each([
|
||||
['LoadImage', 'essentials.loadImage'],
|
||||
['SaveImage', 'essentials.saveImage'],
|
||||
['PrimitiveStringMultiline', 'essentials.text'],
|
||||
['ImageScale', 'essentials.resizeImage'],
|
||||
['LoraLoader', 'essentials.loadStyleLora'],
|
||||
['OpenAIChatNode', 'essentials.textGenerationLLM'],
|
||||
['RecraftRemoveBackgroundNode', 'essentials.removeBackground'],
|
||||
['ImageCompare', 'essentials.imageCompare'],
|
||||
['StabilityTextToAudio', 'essentials.musicGeneration'],
|
||||
['BatchImagesNode', 'essentials.batchImage'],
|
||||
['Video Slice', 'essentials.extractFrame'],
|
||||
['KlingLipSyncAudioToVideoNode', 'essentials.lipsync'],
|
||||
['KlingLipSyncTextToVideoNode', 'essentials.lipsync']
|
||||
])('%s -> %s', (name, expected) => {
|
||||
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('3D API node alternatives', () => {
|
||||
it.each([
|
||||
['TencentTextToModelNode', 'essentials.textTo3DModel'],
|
||||
['MeshyTextToModelNode', 'essentials.textTo3DModel'],
|
||||
['TripoTextToModelNode', 'essentials.textTo3DModel'],
|
||||
['TencentImageToModelNode', 'essentials.imageTo3DModel'],
|
||||
['MeshyImageToModelNode', 'essentials.imageTo3DModel'],
|
||||
['TripoImageToModelNode', 'essentials.imageTo3DModel']
|
||||
])('%s -> %s', (name, expected) => {
|
||||
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blueprint prefix matches', () => {
|
||||
it.each([
|
||||
[
|
||||
'SubgraphBlueprint.text_to_image_flux_schnell.json',
|
||||
'essentials.textToImage'
|
||||
],
|
||||
['SubgraphBlueprint.text_to_image_sd15.json', 'essentials.textToImage'],
|
||||
[
|
||||
'SubgraphBlueprint.image_edit_something.json',
|
||||
'essentials.imageToImage'
|
||||
],
|
||||
['SubgraphBlueprint.pose_to_image_v2.json', 'essentials.poseToImage'],
|
||||
[
|
||||
'SubgraphBlueprint.canny_to_image_z_image_turbo.json',
|
||||
'essentials.cannyToImage'
|
||||
],
|
||||
[
|
||||
'SubgraphBlueprint.depth_to_image_z_image_turbo.json',
|
||||
'essentials.depthToImage'
|
||||
],
|
||||
['SubgraphBlueprint.text_to_video_ltx.json', 'essentials.textToVideo'],
|
||||
['SubgraphBlueprint.image_to_video_wan.json', 'essentials.imageToVideo'],
|
||||
[
|
||||
'SubgraphBlueprint.pose_to_video_ltx_2_0.json',
|
||||
'essentials.poseToVideo'
|
||||
],
|
||||
[
|
||||
'SubgraphBlueprint.canny_to_video_ltx_2_0.json',
|
||||
'essentials.cannyToVideo'
|
||||
],
|
||||
[
|
||||
'SubgraphBlueprint.depth_to_video_ltx_2_0.json',
|
||||
'essentials.depthToVideo'
|
||||
],
|
||||
[
|
||||
'SubgraphBlueprint.image_inpainting_qwen_image_instantx.json',
|
||||
'essentials.inpaintImage'
|
||||
],
|
||||
[
|
||||
'SubgraphBlueprint.image_outpainting_qwen_image_instantx.json',
|
||||
'essentials.outpaintImage'
|
||||
]
|
||||
])('%s -> %s', (name, expected) => {
|
||||
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmapped nodes', () => {
|
||||
it('returns undefined for unknown node names', () => {
|
||||
expect(resolveEssentialsDisplayName({ name: 'SomeRandomNode' })).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined for unknown blueprint prefixes', () => {
|
||||
expect(
|
||||
resolveEssentialsDisplayName({
|
||||
name: 'SubgraphBlueprint.unknown_workflow.json'
|
||||
})
|
||||
).toBe(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
108
src/constants/essentialsDisplayNames.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
const BLUEPRINT_PREFIX = 'SubgraphBlueprint.'
|
||||
|
||||
/**
|
||||
* Static mapping of node names to their Essentials tab display name i18n keys.
|
||||
*/
|
||||
const EXACT_NAME_MAP: Record<string, string> = {
|
||||
// Basics
|
||||
LoadImage: 'essentials.loadImage',
|
||||
SaveImage: 'essentials.saveImage',
|
||||
LoadVideo: 'essentials.loadVideo',
|
||||
SaveVideo: 'essentials.saveVideo',
|
||||
Load3D: 'essentials.load3DModel',
|
||||
SaveGLB: 'essentials.save3DModel',
|
||||
PrimitiveStringMultiline: 'essentials.text',
|
||||
|
||||
// Image Tools
|
||||
BatchImagesNode: 'essentials.batchImage',
|
||||
ImageCrop: 'essentials.cropImage',
|
||||
ImageScale: 'essentials.resizeImage',
|
||||
ImageRotate: 'essentials.rotate',
|
||||
ImageInvert: 'essentials.invert',
|
||||
Canny: 'essentials.canny',
|
||||
RecraftRemoveBackgroundNode: 'essentials.removeBackground',
|
||||
ImageCompare: 'essentials.imageCompare',
|
||||
|
||||
// Video Tools
|
||||
'Video Slice': 'essentials.extractFrame',
|
||||
|
||||
// Image Generation
|
||||
LoraLoader: 'essentials.loadStyleLora',
|
||||
|
||||
// Video Generation
|
||||
KlingLipSyncAudioToVideoNode: 'essentials.lipsync',
|
||||
KlingLipSyncTextToVideoNode: 'essentials.lipsync',
|
||||
|
||||
// Text Generation
|
||||
OpenAIChatNode: 'essentials.textGenerationLLM',
|
||||
|
||||
// 3D
|
||||
TencentTextToModelNode: 'essentials.textTo3DModel',
|
||||
TencentImageToModelNode: 'essentials.imageTo3DModel',
|
||||
MeshyTextToModelNode: 'essentials.textTo3DModel',
|
||||
MeshyImageToModelNode: 'essentials.imageTo3DModel',
|
||||
TripoTextToModelNode: 'essentials.textTo3DModel',
|
||||
TripoImageToModelNode: 'essentials.imageTo3DModel',
|
||||
|
||||
// Audio
|
||||
StabilityTextToAudio: 'essentials.musicGeneration',
|
||||
LoadAudio: 'essentials.loadAudio',
|
||||
SaveAudio: 'essentials.saveAudio'
|
||||
}
|
||||
|
||||
/**
|
||||
* Blueprint prefix patterns mapped to display name i18n keys.
|
||||
* Entries are matched by checking if the blueprint filename
|
||||
* (after removing the SubgraphBlueprint. prefix) starts with the key.
|
||||
* Ordered longest-first so more specific prefixes match before shorter ones.
|
||||
*/
|
||||
const BLUEPRINT_PREFIX_MAP: [prefix: string, displayNameKey: string][] = [
|
||||
// Image Generation
|
||||
['image_inpainting_', 'essentials.inpaintImage'],
|
||||
['image_outpainting_', 'essentials.outpaintImage'],
|
||||
['image_edit', 'essentials.imageToImage'],
|
||||
['text_to_image', 'essentials.textToImage'],
|
||||
['pose_to_image', 'essentials.poseToImage'],
|
||||
['canny_to_image', 'essentials.cannyToImage'],
|
||||
['depth_to_image', 'essentials.depthToImage'],
|
||||
|
||||
// Video Generation
|
||||
['text_to_video', 'essentials.textToVideo'],
|
||||
['image_to_video', 'essentials.imageToVideo'],
|
||||
['pose_to_video', 'essentials.poseToVideo'],
|
||||
['canny_to_video', 'essentials.cannyToVideo'],
|
||||
['depth_to_video', 'essentials.depthToVideo']
|
||||
]
|
||||
|
||||
function resolveBlueprintDisplayName(
|
||||
blueprintName: string
|
||||
): string | undefined {
|
||||
for (const [prefix, displayNameKey] of BLUEPRINT_PREFIX_MAP) {
|
||||
if (blueprintName.startsWith(prefix)) {
|
||||
return t(displayNameKey)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Essentials tab display name for a given node definition.
|
||||
* Returns `undefined` if the node has no Essentials display name mapping.
|
||||
*/
|
||||
export function resolveEssentialsDisplayName(
|
||||
nodeDef: Pick<ComfyNodeDefImpl, 'name'> | undefined
|
||||
): string | undefined {
|
||||
if (!nodeDef) return undefined
|
||||
const { name } = nodeDef
|
||||
|
||||
if (name.startsWith(BLUEPRINT_PREFIX)) {
|
||||
const blueprintName = name.slice(BLUEPRINT_PREFIX.length)
|
||||
return resolveBlueprintDisplayName(blueprintName)
|
||||
}
|
||||
|
||||
const key = EXACT_NAME_MAP[name]
|
||||
return key ? t(key) : undefined
|
||||
}
|
||||
38
src/constants/toolkitNodes.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Toolkit (Essentials) node detection constants.
|
||||
*
|
||||
* Used by telemetry to track toolkit node adoption and popularity.
|
||||
* Only novel nodes — basic nodes (LoadImage, SaveImage, etc.) are excluded.
|
||||
*
|
||||
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
|
||||
*/
|
||||
|
||||
/**
|
||||
* Canonical node type names for individual toolkit nodes.
|
||||
*/
|
||||
export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
|
||||
// Image Tools
|
||||
'ImageCrop',
|
||||
'ImageRotate',
|
||||
'ImageBlur',
|
||||
'ImageInvert',
|
||||
'ImageCompare',
|
||||
'Canny',
|
||||
|
||||
// Video Tools
|
||||
'Video Slice',
|
||||
|
||||
// API Nodes
|
||||
'RecraftRemoveBackgroundNode',
|
||||
'RecraftVectorizeImageNode',
|
||||
'KlingOmniProEditVideoNode'
|
||||
])
|
||||
|
||||
/**
|
||||
* python_module values that identify toolkit blueprint nodes.
|
||||
* Essentials blueprints are registered with node_pack 'comfy_essentials',
|
||||
* which maps to python_module on the node def.
|
||||
*/
|
||||
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
|
||||
'comfy_essentials'
|
||||
])
|
||||
@@ -6,6 +6,10 @@
|
||||
- Avoid repetition where possible, but not at expense of legibility
|
||||
- Prefer running single tests, not the whole suite, for performance
|
||||
|
||||
## Widget Serialization
|
||||
|
||||
See `docs/WIDGET_SERIALIZATION.md` for the distinction between `widget.serialize` (workflow persistence) and `widget.options.serialize` (API prompt). These are different properties checked by different code paths — a common source of confusion.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Prefer single line `if` syntax for concise expressions
|
||||
|
||||
@@ -484,3 +484,110 @@ describe('ensureGlobalIdUniqueness', () => {
|
||||
expect(subNode.id).toBe(subId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Unpacking', () => {
|
||||
class TestNode extends LGraphNode {
|
||||
constructor(title?: string) {
|
||||
super(title ?? 'TestNode')
|
||||
this.addInput('input_0', 'number')
|
||||
this.addOutput('output_0', 'number')
|
||||
}
|
||||
}
|
||||
|
||||
class MultiInputNode extends LGraphNode {
|
||||
constructor(title?: string) {
|
||||
super(title ?? 'MultiInputNode')
|
||||
this.addInput('input_0', 'number')
|
||||
this.addInput('input_1', 'number')
|
||||
this.addOutput('output_0', 'number')
|
||||
}
|
||||
}
|
||||
|
||||
function registerTestNodes() {
|
||||
LiteGraph.registerNodeType('test/TestNode', TestNode)
|
||||
LiteGraph.registerNodeType('test/MultiInputNode', MultiInputNode)
|
||||
}
|
||||
|
||||
function createSubgraphOnGraph(rootGraph: LGraph) {
|
||||
return rootGraph.createSubgraph(createTestSubgraphData())
|
||||
}
|
||||
|
||||
it('deduplicates links when unpacking subgraph with duplicate links', () => {
|
||||
registerTestNodes()
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createSubgraphOnGraph(rootGraph)
|
||||
|
||||
const sourceNode = LiteGraph.createNode('test/TestNode', 'Source')!
|
||||
const targetNode = LiteGraph.createNode('test/TestNode', 'Target')!
|
||||
subgraph.add(sourceNode)
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create a legitimate link
|
||||
sourceNode.connect(0, targetNode, 0)
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
|
||||
// Manually add duplicate links (simulating the bug)
|
||||
const existingLink = subgraph._links.values().next().value!
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dupLink = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
subgraph._links.set(dupLink.id, dupLink)
|
||||
sourceNode.outputs[0].links!.push(dupLink.id)
|
||||
}
|
||||
expect(subgraph._links.size).toBe(4)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.unpackSubgraph(subgraphNode)
|
||||
|
||||
// After unpacking, there should be exactly 1 link (not 4)
|
||||
expect(rootGraph.links.size).toBe(1)
|
||||
})
|
||||
|
||||
it('preserves correct link connections when unpacking with duplicate links', () => {
|
||||
registerTestNodes()
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createSubgraphOnGraph(rootGraph)
|
||||
|
||||
const sourceNode = LiteGraph.createNode('test/MultiInputNode', 'Source')!
|
||||
const targetNode = LiteGraph.createNode('test/MultiInputNode', 'Target')!
|
||||
subgraph.add(sourceNode)
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Connect source output 0 → target input 0
|
||||
sourceNode.connect(0, targetNode, 0)
|
||||
|
||||
// Add duplicate links to the same connection
|
||||
const existingLink = subgraph._links.values().next().value!
|
||||
const dupLink = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
subgraph._links.set(dupLink.id, dupLink)
|
||||
sourceNode.outputs[0].links!.push(dupLink.id)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.unpackSubgraph(subgraphNode)
|
||||
|
||||
// Verify only 1 link exists
|
||||
expect(rootGraph.links.size).toBe(1)
|
||||
|
||||
// Verify target input 1 does NOT have a link (no spurious connection)
|
||||
const unpackedTarget = rootGraph.nodes.find((n) => n.title === 'Target')!
|
||||
expect(unpackedTarget.inputs[0].link).not.toBeNull()
|
||||
expect(unpackedTarget.inputs[1].link).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1929,15 +1929,20 @@ export class LGraph
|
||||
node.id = this.last_node_id
|
||||
n_info.id = this.last_node_id
|
||||
|
||||
// Strip links from serialized data before configure to prevent
|
||||
// onConnectionsChange from resolving subgraph-internal link IDs
|
||||
// against the parent graph's link map (which may contain unrelated
|
||||
// links with the same numeric IDs).
|
||||
for (const input of n_info.inputs ?? []) {
|
||||
input.link = null
|
||||
}
|
||||
for (const output of n_info.outputs ?? []) {
|
||||
output.links = []
|
||||
}
|
||||
|
||||
this.add(node, true)
|
||||
node.configure(n_info)
|
||||
node.setPos(node.pos[0] + offsetX, node.pos[1] + offsetY)
|
||||
for (const input of node.inputs) {
|
||||
input.link = null
|
||||
}
|
||||
for (const output of node.outputs) {
|
||||
output.links = []
|
||||
}
|
||||
toSelect.push(node)
|
||||
}
|
||||
const groups = structuredClone(
|
||||
@@ -2043,8 +2048,19 @@ export class LGraph
|
||||
}
|
||||
this.remove(subgraphNode)
|
||||
this.subgraphs.delete(subgraphNode.subgraph.id)
|
||||
|
||||
// Deduplicate links by (oid, oslot, tid, tslot) to prevent repeated
|
||||
// disconnect/reconnect cycles on widget inputs that can shift slot indices.
|
||||
const seenLinks = new Set<string>()
|
||||
const dedupedNewLinks = newLinks.filter((link) => {
|
||||
const key = `${link.oid}:${link.oslot}:${link.tid}:${link.tslot}`
|
||||
if (seenLinks.has(key)) return false
|
||||
seenLinks.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
const linkIdMap = new Map<LinkId, LinkId[]>()
|
||||
for (const newLink of newLinks) {
|
||||
for (const newLink of dedupedNewLinks) {
|
||||
let created: LLink | null | undefined
|
||||
if (newLink.oid == SUBGRAPH_INPUT_ID) {
|
||||
if (!(this instanceof Subgraph)) {
|
||||
@@ -2102,7 +2118,7 @@ export class LGraph
|
||||
toSelect.push(migratedReroute)
|
||||
}
|
||||
//iterate over newly created links to update reroute parentIds
|
||||
for (const newLink of newLinks) {
|
||||
for (const newLink of dedupedNewLinks) {
|
||||
const linkInstance = this.links.get(newLink.id)
|
||||
if (!linkInstance) {
|
||||
continue
|
||||
@@ -2657,6 +2673,8 @@ export class Subgraph
|
||||
|
||||
/** The display name of the subgraph. */
|
||||
name: string = 'Unnamed Subgraph'
|
||||
/** Optional description shown as tooltip when hovering over the subgraph node. */
|
||||
description?: string
|
||||
|
||||
readonly inputNode = new SubgraphInputNode(this)
|
||||
readonly outputNode = new SubgraphOutputNode(this)
|
||||
@@ -2707,9 +2725,10 @@ export class Subgraph
|
||||
| (ISerialisedGraph & ExportedSubgraph)
|
||||
| (SerialisableGraph & ExportedSubgraph)
|
||||
): void {
|
||||
const { name, inputs, outputs, widgets } = data
|
||||
const { name, description, inputs, outputs, widgets } = data
|
||||
|
||||
this.name = name
|
||||
this.description = description
|
||||
if (inputs) {
|
||||
this.inputs.length = 0
|
||||
for (const input of inputs) {
|
||||
@@ -2920,6 +2939,7 @@ export class Subgraph
|
||||
revision: this.revision,
|
||||
config: this.config,
|
||||
name: this.name,
|
||||
...(this.description && { description: this.description }),
|
||||
inputNode: this.inputNode.asSerialisable(),
|
||||
outputNode: this.outputNode.asSerialisable(),
|
||||
inputs: this.inputs.map((x) => x.asSerialisable()),
|
||||
|
||||
@@ -76,7 +76,6 @@ describe.skip('SubgraphSerialization - Basic Serialization', () => {
|
||||
// Verify core properties
|
||||
expect(restored.id).toBe(original.id)
|
||||
expect(restored.name).toBe(original.name)
|
||||
// @ts-expect-error description property not in type definition
|
||||
expect(restored.description).toBe(original.description)
|
||||
|
||||
// Verify I/O structure
|
||||
|
||||
@@ -139,6 +139,8 @@ export interface ExportedSubgraph extends SerialisableGraph {
|
||||
name: string
|
||||
/** Optional category for organizing subgraph blueprints in the node library. */
|
||||
category?: string
|
||||
/** Optional description shown as tooltip when hovering over the subgraph node. */
|
||||
description?: string
|
||||
inputNode: ExportedSubgraphIONode
|
||||
outputNode: ExportedSubgraphIONode
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
|
||||
@@ -47,6 +47,19 @@ export interface IWidgetOptions<TValues = unknown> {
|
||||
/** Used as a temporary override for determining the asset type in vue mode*/
|
||||
nodeType?: string
|
||||
|
||||
/**
|
||||
* Whether the widget value should be included in the API prompt sent to
|
||||
* the backend for execution. Checked by {@link executionUtil} when
|
||||
* building the prompt payload.
|
||||
*
|
||||
* This is distinct from {@link IBaseWidget.serialize}, which controls
|
||||
* whether the value is persisted in the workflow JSON file.
|
||||
*
|
||||
* @default true
|
||||
* @see IBaseWidget.serialize — workflow persistence
|
||||
*/
|
||||
serialize?: boolean
|
||||
|
||||
values?: TValues
|
||||
/** Optional function to format values for display (e.g., hash → human-readable name) */
|
||||
getOptionLabel?: (value?: string | null) => string
|
||||
@@ -349,8 +362,15 @@ export interface IBaseWidget<
|
||||
vueTrack?: () => void
|
||||
|
||||
/**
|
||||
* Whether the widget value should be serialized on node serialization.
|
||||
* Whether the widget value is persisted in the workflow JSON
|
||||
* (`widgets_values`). Checked by {@link LGraphNode.serialize} and
|
||||
* {@link LGraphNode.configure}.
|
||||
*
|
||||
* This is distinct from {@link IWidgetOptions.serialize}, which controls
|
||||
* whether the value is included in the API prompt sent for execution.
|
||||
*
|
||||
* @default true
|
||||
* @see IWidgetOptions.serialize — API prompt inclusion
|
||||
*/
|
||||
serialize?: boolean
|
||||
|
||||
|
||||
@@ -735,6 +735,44 @@
|
||||
"errorCount": "{count} أخطاء | {count} خطأ | {count} أخطاء",
|
||||
"seeErrors": "عرض الأخطاء"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "معالجة صور دفعة واحدة",
|
||||
"canny": "كانّي",
|
||||
"cannyToImage": "تحويل كانّي إلى صورة",
|
||||
"cannyToVideo": "تحويل كانّي إلى فيديو",
|
||||
"cropImage": "قص الصورة",
|
||||
"depthToImage": "تحويل العمق إلى صورة",
|
||||
"depthToVideo": "تحويل العمق إلى فيديو",
|
||||
"extractFrame": "استخراج إطار",
|
||||
"imageCompare": "مقارنة الصور",
|
||||
"imageTo3DModel": "تحويل الصورة إلى نموذج ثلاثي الأبعاد",
|
||||
"imageToImage": "تحويل صورة إلى صورة",
|
||||
"imageToVideo": "تحويل صورة إلى فيديو",
|
||||
"inpaintImage": "ترميم الصورة",
|
||||
"invert": "عكس",
|
||||
"lipsync": "مزامنة الشفاه",
|
||||
"load3DModel": "تحميل نموذج ثلاثي الأبعاد",
|
||||
"loadAudio": "تحميل صوت",
|
||||
"loadImage": "تحميل صورة",
|
||||
"loadStyleLora": "تحميل نمط (LoRA)",
|
||||
"loadVideo": "تحميل فيديو",
|
||||
"musicGeneration": "توليد موسيقى",
|
||||
"outpaintImage": "توسيع الصورة",
|
||||
"poseToImage": "تحويل وضعية إلى صورة",
|
||||
"poseToVideo": "تحويل وضعية إلى فيديو",
|
||||
"removeBackground": "إزالة الخلفية",
|
||||
"resizeImage": "تغيير حجم الصورة",
|
||||
"rotate": "تدوير",
|
||||
"save3DModel": "حفظ نموذج ثلاثي الأبعاد",
|
||||
"saveAudio": "حفظ صوت",
|
||||
"saveImage": "حفظ صورة",
|
||||
"saveVideo": "حفظ فيديو",
|
||||
"text": "نص",
|
||||
"textGenerationLLM": "توليد نص (LLM)",
|
||||
"textTo3DModel": "تحويل النص إلى نموذج ثلاثي الأبعاد",
|
||||
"textToImage": "تحويل نص إلى صورة",
|
||||
"textToVideo": "تحويل نص إلى فيديو"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "اكتملت جميع عمليات التصدير",
|
||||
"downloadExport": "تحميل التصدير",
|
||||
@@ -2947,6 +2985,7 @@
|
||||
"node2only": "فقط Node 2.0",
|
||||
"selectModel": "اختر نموذج",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "تم الوصول إلى الحد الأقصى للاختيار",
|
||||
"placeholder": "اختر...",
|
||||
"placeholderAudio": "اختر صوت...",
|
||||
"placeholderImage": "اختر صورة...",
|
||||
|
||||
@@ -1604,7 +1604,6 @@
|
||||
"MiniMax": "MiniMax",
|
||||
"model_specific": "model_specific",
|
||||
"Moonvalley Marey": "Moonvalley Marey",
|
||||
"": "",
|
||||
"OpenAI": "OpenAI",
|
||||
"Sora": "Sora",
|
||||
"cond pair": "cond pair",
|
||||
@@ -1626,6 +1625,7 @@
|
||||
"style_model": "style_model",
|
||||
"Tencent": "Tencent",
|
||||
"textgen": "textgen",
|
||||
"": "",
|
||||
"Topaz": "Topaz",
|
||||
"Tripo": "Tripo",
|
||||
"Veo": "Veo",
|
||||
@@ -2459,7 +2459,8 @@
|
||||
"placeholderVideo": "Select video...",
|
||||
"placeholderMesh": "Select mesh...",
|
||||
"placeholderModel": "Select model...",
|
||||
"placeholderUnknown": "Select media..."
|
||||
"placeholderUnknown": "Select media...",
|
||||
"maxSelectionReached": "Maximum selection limit reached"
|
||||
},
|
||||
"valueControl": {
|
||||
"header": {
|
||||
@@ -3151,5 +3152,43 @@
|
||||
"duplicateName": "A secret with this name already exists",
|
||||
"duplicateProvider": "A secret for this provider already exists"
|
||||
}
|
||||
},
|
||||
"essentials": {
|
||||
"loadImage": "Load Image",
|
||||
"saveImage": "Save Image",
|
||||
"loadVideo": "Load Video",
|
||||
"saveVideo": "Save Video",
|
||||
"load3DModel": "Load 3D model",
|
||||
"save3DModel": "Save 3D Model",
|
||||
"text": "Text",
|
||||
"batchImage": "Batch Image",
|
||||
"cropImage": "Crop Image",
|
||||
"resizeImage": "Resize Image",
|
||||
"rotate": "Rotate",
|
||||
"invert": "Invert",
|
||||
"canny": "Canny",
|
||||
"removeBackground": "Remove Background",
|
||||
"imageCompare": "Image compare",
|
||||
"extractFrame": "Extract frame",
|
||||
"loadStyleLora": "Load style (LoRA)",
|
||||
"lipsync": "Lipsync",
|
||||
"textGenerationLLM": "Text generation (LLM)",
|
||||
"textTo3DModel": "Text to 3D model",
|
||||
"imageTo3DModel": "Image to 3D Model",
|
||||
"musicGeneration": "Music generation",
|
||||
"loadAudio": "Load Audio",
|
||||
"saveAudio": "Save Audio",
|
||||
"inpaintImage": "Inpaint image",
|
||||
"outpaintImage": "Outpaint image",
|
||||
"imageToImage": "Image to image",
|
||||
"textToImage": "Text to image",
|
||||
"poseToImage": "Pose to image",
|
||||
"cannyToImage": "Canny to image",
|
||||
"depthToImage": "Depth to image",
|
||||
"textToVideo": "Text to video",
|
||||
"imageToVideo": "Image to video",
|
||||
"poseToVideo": "Pose to video",
|
||||
"cannyToVideo": "Canny to video",
|
||||
"depthToVideo": "Depth to video"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,6 +735,44 @@
|
||||
"errorCount": "{count} ERRORES | {count} ERROR | {count} ERRORES",
|
||||
"seeErrors": "Ver errores"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Procesar imágenes por lotes",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Canny a imagen",
|
||||
"cannyToVideo": "Canny a video",
|
||||
"cropImage": "Recortar imagen",
|
||||
"depthToImage": "Profundidad a imagen",
|
||||
"depthToVideo": "Profundidad a video",
|
||||
"extractFrame": "Extraer fotograma",
|
||||
"imageCompare": "Comparar imágenes",
|
||||
"imageTo3DModel": "Imagen a modelo 3D",
|
||||
"imageToImage": "Imagen a imagen",
|
||||
"imageToVideo": "Imagen a video",
|
||||
"inpaintImage": "Rellenar imagen",
|
||||
"invert": "Invertir",
|
||||
"lipsync": "Lipsync",
|
||||
"load3DModel": "Cargar modelo 3D",
|
||||
"loadAudio": "Cargar audio",
|
||||
"loadImage": "Cargar imagen",
|
||||
"loadStyleLora": "Cargar estilo (LoRA)",
|
||||
"loadVideo": "Cargar video",
|
||||
"musicGeneration": "Generación de música",
|
||||
"outpaintImage": "Expandir imagen",
|
||||
"poseToImage": "Pose a imagen",
|
||||
"poseToVideo": "Pose a video",
|
||||
"removeBackground": "Eliminar fondo",
|
||||
"resizeImage": "Redimensionar imagen",
|
||||
"rotate": "Rotar",
|
||||
"save3DModel": "Guardar modelo 3D",
|
||||
"saveAudio": "Guardar audio",
|
||||
"saveImage": "Guardar imagen",
|
||||
"saveVideo": "Guardar video",
|
||||
"text": "Texto",
|
||||
"textGenerationLLM": "Generación de texto (LLM)",
|
||||
"textTo3DModel": "Texto a modelo 3D",
|
||||
"textToImage": "Texto a imagen",
|
||||
"textToVideo": "Texto a video"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "Todas las exportaciones completadas",
|
||||
"downloadExport": "Descargar exportación",
|
||||
@@ -2947,6 +2985,7 @@
|
||||
"node2only": "Solo Node 2.0",
|
||||
"selectModel": "Seleccionar modelo",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "Se alcanzó el límite máximo de selección",
|
||||
"placeholder": "Seleccionar...",
|
||||
"placeholderAudio": "Seleccionar audio...",
|
||||
"placeholderImage": "Seleccionar imagen...",
|
||||
|
||||
@@ -735,6 +735,44 @@
|
||||
"errorCount": "{count} خطا",
|
||||
"seeErrors": "مشاهده خطاها"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "پردازش دستهای تصویر",
|
||||
"canny": "لبهیابی Canny",
|
||||
"cannyToImage": "تبدیل Canny به تصویر",
|
||||
"cannyToVideo": "تبدیل Canny به ویدیو",
|
||||
"cropImage": "برش تصویر",
|
||||
"depthToImage": "تبدیل عمق به تصویر",
|
||||
"depthToVideo": "تبدیل عمق به ویدیو",
|
||||
"extractFrame": "استخراج فریم",
|
||||
"imageCompare": "مقایسه تصویر",
|
||||
"imageTo3DModel": "تبدیل تصویر به مدل سهبعدی",
|
||||
"imageToImage": "تبدیل تصویر به تصویر",
|
||||
"imageToVideo": "تبدیل تصویر به ویدیو",
|
||||
"inpaintImage": "بازسازی تصویر (Inpaint)",
|
||||
"invert": "معکوس",
|
||||
"lipsync": "همگامسازی لب",
|
||||
"load3DModel": "بارگذاری مدل سهبعدی",
|
||||
"loadAudio": "بارگذاری صوت",
|
||||
"loadImage": "بارگذاری تصویر",
|
||||
"loadStyleLora": "بارگذاری سبک (LoRA)",
|
||||
"loadVideo": "بارگذاری ویدیو",
|
||||
"musicGeneration": "تولید موسیقی",
|
||||
"outpaintImage": "گسترش تصویر (Outpaint)",
|
||||
"poseToImage": "تبدیل ژست به تصویر",
|
||||
"poseToVideo": "تبدیل ژست به ویدیو",
|
||||
"removeBackground": "حذف پسزمینه",
|
||||
"resizeImage": "تغییر اندازه تصویر",
|
||||
"rotate": "چرخش",
|
||||
"save3DModel": "ذخیره مدل سهبعدی",
|
||||
"saveAudio": "ذخیره صوت",
|
||||
"saveImage": "ذخیره تصویر",
|
||||
"saveVideo": "ذخیره ویدیو",
|
||||
"text": "متن",
|
||||
"textGenerationLLM": "تولید متن (LLM)",
|
||||
"textTo3DModel": "تبدیل متن به مدل سهبعدی",
|
||||
"textToImage": "تبدیل متن به تصویر",
|
||||
"textToVideo": "تبدیل متن به ویدیو"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "همه خروجیها تکمیل شد",
|
||||
"downloadExport": "دانلود خروجی",
|
||||
@@ -2959,6 +2997,7 @@
|
||||
"node2only": "فقط Node 2.0",
|
||||
"selectModel": "انتخاب مدل",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "حداکثر تعداد انتخاب مجاز رسید",
|
||||
"placeholder": "انتخاب...",
|
||||
"placeholderAudio": "انتخاب صوت...",
|
||||
"placeholderImage": "انتخاب تصویر...",
|
||||
|
||||
@@ -735,6 +735,44 @@
|
||||
"errorCount": "{count} ERREURS | {count} ERREUR | {count} ERREURS",
|
||||
"seeErrors": "Voir les erreurs"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Traitement par lot d'images",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Canny vers image",
|
||||
"cannyToVideo": "Canny vers vidéo",
|
||||
"cropImage": "Rogner l'image",
|
||||
"depthToImage": "Profondeur vers image",
|
||||
"depthToVideo": "Profondeur vers vidéo",
|
||||
"extractFrame": "Extraire une image",
|
||||
"imageCompare": "Comparer les images",
|
||||
"imageTo3DModel": "Image vers modèle 3D",
|
||||
"imageToImage": "Image vers image",
|
||||
"imageToVideo": "Image vers vidéo",
|
||||
"inpaintImage": "Inpainting d'image",
|
||||
"invert": "Inverser",
|
||||
"lipsync": "Synchronisation labiale",
|
||||
"load3DModel": "Charger un modèle 3D",
|
||||
"loadAudio": "Charger un audio",
|
||||
"loadImage": "Charger une image",
|
||||
"loadStyleLora": "Charger un style (LoRA)",
|
||||
"loadVideo": "Charger une vidéo",
|
||||
"musicGeneration": "Génération de musique",
|
||||
"outpaintImage": "Outpainting d'image",
|
||||
"poseToImage": "Pose vers image",
|
||||
"poseToVideo": "Pose vers vidéo",
|
||||
"removeBackground": "Supprimer l'arrière-plan",
|
||||
"resizeImage": "Redimensionner l'image",
|
||||
"rotate": "Faire pivoter",
|
||||
"save3DModel": "Enregistrer le modèle 3D",
|
||||
"saveAudio": "Enregistrer l'audio",
|
||||
"saveImage": "Enregistrer l'image",
|
||||
"saveVideo": "Enregistrer la vidéo",
|
||||
"text": "Texte",
|
||||
"textGenerationLLM": "Génération de texte (LLM)",
|
||||
"textTo3DModel": "Texte vers modèle 3D",
|
||||
"textToImage": "Texte vers image",
|
||||
"textToVideo": "Texte vers vidéo"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "Toutes les exportations sont terminées",
|
||||
"downloadExport": "Télécharger l’export",
|
||||
@@ -2947,6 +2985,7 @@
|
||||
"node2only": "Node 2.0 uniquement",
|
||||
"selectModel": "Sélectionner un modèle",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "Limite maximale de sélection atteinte",
|
||||
"placeholder": "Sélectionner...",
|
||||
"placeholderAudio": "Sélectionner un audio...",
|
||||
"placeholderImage": "Sélectionner une image...",
|
||||
|
||||
@@ -735,6 +735,44 @@
|
||||
"errorCount": "{count} 件のエラー",
|
||||
"seeErrors": "エラーを表示"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "バッチ画像処理",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Cannyから画像へ",
|
||||
"cannyToVideo": "Cannyから動画へ",
|
||||
"cropImage": "画像を切り抜く",
|
||||
"depthToImage": "深度から画像へ",
|
||||
"depthToVideo": "深度から動画へ",
|
||||
"extractFrame": "フレームを抽出",
|
||||
"imageCompare": "画像比較",
|
||||
"imageTo3DModel": "画像から3Dモデル",
|
||||
"imageToImage": "画像から画像へ",
|
||||
"imageToVideo": "画像から動画へ",
|
||||
"inpaintImage": "画像のインペイント",
|
||||
"invert": "反転",
|
||||
"lipsync": "リップシンク",
|
||||
"load3DModel": "3Dモデルを読み込む",
|
||||
"loadAudio": "音声を読み込む",
|
||||
"loadImage": "画像を読み込む",
|
||||
"loadStyleLora": "スタイルを読み込む(LoRA)",
|
||||
"loadVideo": "動画を読み込む",
|
||||
"musicGeneration": "音楽生成",
|
||||
"outpaintImage": "画像のアウトペイント",
|
||||
"poseToImage": "ポーズから画像へ",
|
||||
"poseToVideo": "ポーズから動画へ",
|
||||
"removeBackground": "背景を削除",
|
||||
"resizeImage": "画像のサイズ変更",
|
||||
"rotate": "回転",
|
||||
"save3DModel": "3Dモデルを保存",
|
||||
"saveAudio": "音声を保存",
|
||||
"saveImage": "画像を保存",
|
||||
"saveVideo": "動画を保存",
|
||||
"text": "テキスト",
|
||||
"textGenerationLLM": "テキスト生成(LLM)",
|
||||
"textTo3DModel": "テキストから3Dモデル",
|
||||
"textToImage": "テキストから画像へ",
|
||||
"textToVideo": "テキストから動画へ"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "すべてのエクスポートが完了しました",
|
||||
"downloadExport": "エクスポートをダウンロード",
|
||||
@@ -2947,6 +2985,7 @@
|
||||
"node2only": "Node 2.0専用",
|
||||
"selectModel": "モデルを選択",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "選択の上限に達しました",
|
||||
"placeholder": "選択...",
|
||||
"placeholderAudio": "音声を選択...",
|
||||
"placeholderImage": "画像を選択...",
|
||||
|
||||
@@ -735,6 +735,44 @@
|
||||
"errorCount": "{count}개 오류",
|
||||
"seeErrors": "오류 보기"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "이미지 일괄 처리",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Canny → 이미지",
|
||||
"cannyToVideo": "Canny → 비디오",
|
||||
"cropImage": "이미지 자르기",
|
||||
"depthToImage": "깊이 → 이미지",
|
||||
"depthToVideo": "깊이 → 비디오",
|
||||
"extractFrame": "프레임 추출",
|
||||
"imageCompare": "이미지 비교",
|
||||
"imageTo3DModel": "이미지 → 3D 모델",
|
||||
"imageToImage": "이미지 → 이미지",
|
||||
"imageToVideo": "이미지 → 비디오",
|
||||
"inpaintImage": "이미지 인페인팅",
|
||||
"invert": "반전",
|
||||
"lipsync": "립싱크",
|
||||
"load3DModel": "3D 모델 불러오기",
|
||||
"loadAudio": "오디오 불러오기",
|
||||
"loadImage": "이미지 불러오기",
|
||||
"loadStyleLora": "스타일 불러오기 (LoRA)",
|
||||
"loadVideo": "비디오 불러오기",
|
||||
"musicGeneration": "음악 생성",
|
||||
"outpaintImage": "이미지 아웃페인팅",
|
||||
"poseToImage": "포즈 → 이미지",
|
||||
"poseToVideo": "포즈 → 비디오",
|
||||
"removeBackground": "배경 제거",
|
||||
"resizeImage": "이미지 크기 조정",
|
||||
"rotate": "회전",
|
||||
"save3DModel": "3D 모델 저장",
|
||||
"saveAudio": "오디오 저장",
|
||||
"saveImage": "이미지 저장",
|
||||
"saveVideo": "비디오 저장",
|
||||
"text": "텍스트",
|
||||
"textGenerationLLM": "텍스트 생성 (LLM)",
|
||||
"textTo3DModel": "텍스트 → 3D 모델",
|
||||
"textToImage": "텍스트 → 이미지",
|
||||
"textToVideo": "텍스트 → 비디오"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "모든 내보내기 완료",
|
||||
"downloadExport": "내보내기 다운로드",
|
||||
@@ -2947,6 +2985,7 @@
|
||||
"node2only": "Node 2.0 전용",
|
||||
"selectModel": "모델 선택",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "최대 선택 한도에 도달했습니다",
|
||||
"placeholder": "선택...",
|
||||
"placeholderAudio": "오디오 선택...",
|
||||
"placeholderImage": "이미지 선택...",
|
||||
|
||||
@@ -735,6 +735,44 @@
|
||||
"errorCount": "{count} ERROS | {count} ERRO | {count} ERROS",
|
||||
"seeErrors": "Ver erros"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Imagem em lote",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Canny para imagem",
|
||||
"cannyToVideo": "Canny para vídeo",
|
||||
"cropImage": "Cortar imagem",
|
||||
"depthToImage": "Profundidade para imagem",
|
||||
"depthToVideo": "Profundidade para vídeo",
|
||||
"extractFrame": "Extrair quadro",
|
||||
"imageCompare": "Comparar imagens",
|
||||
"imageTo3DModel": "Imagem para modelo 3D",
|
||||
"imageToImage": "Imagem para imagem",
|
||||
"imageToVideo": "Imagem para vídeo",
|
||||
"inpaintImage": "Inpaint na imagem",
|
||||
"invert": "Inverter",
|
||||
"lipsync": "Lipsync",
|
||||
"load3DModel": "Carregar modelo 3D",
|
||||
"loadAudio": "Carregar áudio",
|
||||
"loadImage": "Carregar imagem",
|
||||
"loadStyleLora": "Carregar estilo (LoRA)",
|
||||
"loadVideo": "Carregar vídeo",
|
||||
"musicGeneration": "Geração de música",
|
||||
"outpaintImage": "Outpaint na imagem",
|
||||
"poseToImage": "Pose para imagem",
|
||||
"poseToVideo": "Pose para vídeo",
|
||||
"removeBackground": "Remover fundo",
|
||||
"resizeImage": "Redimensionar imagem",
|
||||
"rotate": "Girar",
|
||||
"save3DModel": "Salvar modelo 3D",
|
||||
"saveAudio": "Salvar áudio",
|
||||
"saveImage": "Salvar imagem",
|
||||
"saveVideo": "Salvar vídeo",
|
||||
"text": "Texto",
|
||||
"textGenerationLLM": "Geração de texto (LLM)",
|
||||
"textTo3DModel": "Texto para modelo 3D",
|
||||
"textToImage": "Texto para imagem",
|
||||
"textToVideo": "Texto para vídeo"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "Todas as exportações concluídas",
|
||||
"downloadExport": "Baixar exportação",
|
||||
@@ -2959,6 +2997,7 @@
|
||||
"node2only": "Apenas Node 2.0",
|
||||
"selectModel": "Selecionar modelo",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "Limite máximo de seleção atingido",
|
||||
"placeholder": "Selecionar...",
|
||||
"placeholderAudio": "Selecionar áudio...",
|
||||
"placeholderImage": "Selecionar imagem...",
|
||||
|
||||
@@ -735,6 +735,44 @@
|
||||
"errorCount": "{count} ОШИБОК | {count} ОШИБКА | {count} ОШИБКИ",
|
||||
"seeErrors": "Посмотреть ошибки"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Пакетная обработка изображений",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Canny в изображение",
|
||||
"cannyToVideo": "Canny в видео",
|
||||
"cropImage": "Обрезать изображение",
|
||||
"depthToImage": "Глубина в изображение",
|
||||
"depthToVideo": "Глубина в видео",
|
||||
"extractFrame": "Извлечь кадр",
|
||||
"imageCompare": "Сравнение изображений",
|
||||
"imageTo3DModel": "Изображение в 3D-модель",
|
||||
"imageToImage": "Изображение в изображение",
|
||||
"imageToVideo": "Изображение в видео",
|
||||
"inpaintImage": "Инпейтинг изображения",
|
||||
"invert": "Инвертировать",
|
||||
"lipsync": "Синхронизация губ",
|
||||
"load3DModel": "Загрузить 3D-модель",
|
||||
"loadAudio": "Загрузить аудио",
|
||||
"loadImage": "Загрузить изображение",
|
||||
"loadStyleLora": "Загрузить стиль (LoRA)",
|
||||
"loadVideo": "Загрузить видео",
|
||||
"musicGeneration": "Генерация музыки",
|
||||
"outpaintImage": "Аутпейтинг изображения",
|
||||
"poseToImage": "Поза в изображение",
|
||||
"poseToVideo": "Поза в видео",
|
||||
"removeBackground": "Удалить фон",
|
||||
"resizeImage": "Изменить размер изображения",
|
||||
"rotate": "Повернуть",
|
||||
"save3DModel": "Сохранить 3D-модель",
|
||||
"saveAudio": "Сохранить аудио",
|
||||
"saveImage": "Сохранить изображение",
|
||||
"saveVideo": "Сохранить видео",
|
||||
"text": "Текст",
|
||||
"textGenerationLLM": "Генерация текста (LLM)",
|
||||
"textTo3DModel": "Текст в 3D-модель",
|
||||
"textToImage": "Текст в изображение",
|
||||
"textToVideo": "Текст в видео"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "Все экспорты завершены",
|
||||
"downloadExport": "Скачать экспорт",
|
||||
@@ -2947,6 +2985,7 @@
|
||||
"node2only": "Только Node 2.0",
|
||||
"selectModel": "Выбрать модель",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "Достигнут максимальный лимит выбора",
|
||||
"placeholder": "Выбрать...",
|
||||
"placeholderAudio": "Выбрать аудио...",
|
||||
"placeholderImage": "Выбрать изображение...",
|
||||
|
||||
@@ -735,6 +735,44 @@
|
||||
"errorCount": "{count} HATA | {count} HATA | {count} HATA",
|
||||
"seeErrors": "Hataları Gör"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Toplu Görüntü",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Canny'den Görüntüye",
|
||||
"cannyToVideo": "Canny'den Videoya",
|
||||
"cropImage": "Görüntüyü Kırp",
|
||||
"depthToImage": "Derinlikten Görüntüye",
|
||||
"depthToVideo": "Derinlikten Videoya",
|
||||
"extractFrame": "Kareyi Çıkar",
|
||||
"imageCompare": "Görüntü Karşılaştır",
|
||||
"imageTo3DModel": "Görüntüden 3D Modele",
|
||||
"imageToImage": "Görüntüden Görüntüye",
|
||||
"imageToVideo": "Görüntüden Videoya",
|
||||
"inpaintImage": "Görüntüyü Tamamla",
|
||||
"invert": "Ters Çevir",
|
||||
"lipsync": "Dudak Senkronizasyonu",
|
||||
"load3DModel": "3D Model Yükle",
|
||||
"loadAudio": "Ses Yükle",
|
||||
"loadImage": "Görüntü Yükle",
|
||||
"loadStyleLora": "Stil Yükle (LoRA)",
|
||||
"loadVideo": "Video Yükle",
|
||||
"musicGeneration": "Müzik Üretimi",
|
||||
"outpaintImage": "Görüntüyü Genişlet",
|
||||
"poseToImage": "Pozdan Görüntüye",
|
||||
"poseToVideo": "Pozdan Videoya",
|
||||
"removeBackground": "Arka Planı Kaldır",
|
||||
"resizeImage": "Görüntüyü Yeniden Boyutlandır",
|
||||
"rotate": "Döndür",
|
||||
"save3DModel": "3D Modeli Kaydet",
|
||||
"saveAudio": "Sesi Kaydet",
|
||||
"saveImage": "Görüntüyü Kaydet",
|
||||
"saveVideo": "Videoyu Kaydet",
|
||||
"text": "Metin",
|
||||
"textGenerationLLM": "Metin Üretimi (LLM)",
|
||||
"textTo3DModel": "Metinden 3D Modele",
|
||||
"textToImage": "Metinden Görüntüye",
|
||||
"textToVideo": "Metinden Videoya"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "Tüm dışa aktarmalar tamamlandı",
|
||||
"downloadExport": "Dışa aktarmayı indir",
|
||||
@@ -2947,6 +2985,7 @@
|
||||
"node2only": "Yalnızca Node 2.0",
|
||||
"selectModel": "Model seç",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "Maksimum seçim sınırına ulaşıldı",
|
||||
"placeholder": "Seç...",
|
||||
"placeholderAudio": "Ses seç...",
|
||||
"placeholderImage": "Görsel seç...",
|
||||
|
||||
@@ -735,6 +735,44 @@
|
||||
"errorCount": "{count} 個錯誤",
|
||||
"seeErrors": "查看錯誤"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "批次圖片",
|
||||
"canny": "Canny 邊緣",
|
||||
"cannyToImage": "Canny 邊緣轉圖片",
|
||||
"cannyToVideo": "Canny 邊緣轉影片",
|
||||
"cropImage": "裁切圖片",
|
||||
"depthToImage": "深度轉圖片",
|
||||
"depthToVideo": "深度轉影片",
|
||||
"extractFrame": "擷取影格",
|
||||
"imageCompare": "圖片比較",
|
||||
"imageTo3DModel": "圖片轉 3D 模型",
|
||||
"imageToImage": "圖片轉圖片",
|
||||
"imageToVideo": "圖片轉影片",
|
||||
"inpaintImage": "圖片修補",
|
||||
"invert": "反相",
|
||||
"lipsync": "唇形同步",
|
||||
"load3DModel": "載入 3D 模型",
|
||||
"loadAudio": "載入音訊",
|
||||
"loadImage": "載入圖片",
|
||||
"loadStyleLora": "載入風格 (LoRA)",
|
||||
"loadVideo": "載入影片",
|
||||
"musicGeneration": "音樂生成",
|
||||
"outpaintImage": "圖片擴展",
|
||||
"poseToImage": "姿勢轉圖片",
|
||||
"poseToVideo": "姿勢轉影片",
|
||||
"removeBackground": "去背",
|
||||
"resizeImage": "調整圖片尺寸",
|
||||
"rotate": "旋轉",
|
||||
"save3DModel": "儲存 3D 模型",
|
||||
"saveAudio": "儲存音訊",
|
||||
"saveImage": "儲存圖片",
|
||||
"saveVideo": "儲存影片",
|
||||
"text": "文字",
|
||||
"textGenerationLLM": "文字生成 (LLM)",
|
||||
"textTo3DModel": "文字轉 3D 模型",
|
||||
"textToImage": "文字轉圖片",
|
||||
"textToVideo": "文字轉影片"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "所有匯出已完成",
|
||||
"downloadExport": "下載匯出檔",
|
||||
@@ -2947,6 +2985,7 @@
|
||||
"node2only": "僅限 Node 2.0",
|
||||
"selectModel": "選擇模型",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "已達到最大選取限制",
|
||||
"placeholder": "選擇...",
|
||||
"placeholderAudio": "選擇音訊...",
|
||||
"placeholderImage": "選擇圖片...",
|
||||
|
||||
@@ -735,6 +735,44 @@
|
||||
"errorCount": "{count}个错误",
|
||||
"seeErrors": "查看错误"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "批量图像",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Canny转图像",
|
||||
"cannyToVideo": "Canny转视频",
|
||||
"cropImage": "裁剪图像",
|
||||
"depthToImage": "深度转图像",
|
||||
"depthToVideo": "深度转视频",
|
||||
"extractFrame": "提取帧",
|
||||
"imageCompare": "图像对比",
|
||||
"imageTo3DModel": "图像转3D模型",
|
||||
"imageToImage": "图像转图像",
|
||||
"imageToVideo": "图像转视频",
|
||||
"inpaintImage": "图像修复",
|
||||
"invert": "反转",
|
||||
"lipsync": "唇形同步",
|
||||
"load3DModel": "加载3D模型",
|
||||
"loadAudio": "加载音频",
|
||||
"loadImage": "加载图像",
|
||||
"loadStyleLora": "加载风格(LoRA)",
|
||||
"loadVideo": "加载视频",
|
||||
"musicGeneration": "音乐生成",
|
||||
"outpaintImage": "图像扩展",
|
||||
"poseToImage": "姿态转图像",
|
||||
"poseToVideo": "姿态转视频",
|
||||
"removeBackground": "移除背景",
|
||||
"resizeImage": "调整图像大小",
|
||||
"rotate": "旋转",
|
||||
"save3DModel": "保存3D模型",
|
||||
"saveAudio": "保存音频",
|
||||
"saveImage": "保存图像",
|
||||
"saveVideo": "保存视频",
|
||||
"text": "文本",
|
||||
"textGenerationLLM": "文本生成(LLM)",
|
||||
"textTo3DModel": "文本转3D模型",
|
||||
"textToImage": "文本转图像",
|
||||
"textToVideo": "文本转视频"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "全部导出完成",
|
||||
"downloadExport": "下载导出文件",
|
||||
@@ -2959,6 +2997,7 @@
|
||||
"node2only": "仅限 Node 2.0",
|
||||
"selectModel": "选择模型",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "已达到最大选择数量",
|
||||
"placeholder": "请选择...",
|
||||
"placeholderAudio": "请选择音频...",
|
||||
"placeholderImage": "请选择图片...",
|
||||
|
||||
@@ -49,7 +49,6 @@ describe('MediaVideoTop', () => {
|
||||
expect(wrapper.find('video').exists()).toBe(true)
|
||||
expect(wrapper.find('source').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits playback events and hides paused overlay while playing', async () => {
|
||||
const wrapper = mount(MediaVideoTop, {
|
||||
props: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -35,12 +36,32 @@ vi.mock('vue-i18n', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockShowDialog = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
showDialog: vi.fn()
|
||||
showDialog: mockShowDialog
|
||||
})
|
||||
}))
|
||||
|
||||
const mockInvalidateModelsForCategory = vi.hoisted(() => vi.fn())
|
||||
const mockSetAssetDeleting = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateHistory = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateInputs = vi.hoisted(() => vi.fn())
|
||||
const mockHasCategory = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: () => ({
|
||||
setAssetDeleting: mockSetAssetDeleting,
|
||||
updateHistory: mockUpdateHistory,
|
||||
updateInputs: mockUpdateInputs,
|
||||
invalidateModelsForCategory: mockInvalidateModelsForCategory,
|
||||
hasCategory: mockHasCategory
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: () => ({
|
||||
copyToClipboard: vi.fn()
|
||||
@@ -93,14 +114,33 @@ vi.mock('@/utils/typeGuardUtil', () => ({
|
||||
isResultItemType: vi.fn().mockReturnValue(true)
|
||||
}))
|
||||
|
||||
const mockGetAssetType = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/assets/utils/assetTypeUtil', () => ({
|
||||
getAssetType: vi.fn().mockReturnValue('input')
|
||||
getAssetType: mockGetAssetType
|
||||
}))
|
||||
|
||||
vi.mock('../schemas/assetMetadataSchema', () => ({
|
||||
getOutputAssetMetadata: vi.fn().mockReturnValue(null)
|
||||
}))
|
||||
|
||||
const mockDeleteAsset = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../services/assetService', () => ({
|
||||
assetService: {
|
||||
deleteAsset: mockDeleteAsset
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
deleteItem: vi.fn(),
|
||||
apiURL: vi.fn((path: string) => `http://localhost:8188/api${path}`),
|
||||
internalURL: vi.fn((path: string) => `http://localhost:8188${path}`),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
user: 'test-user'
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'test-asset-id',
|
||||
@@ -115,7 +155,7 @@ function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
describe('useMediaAssetActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
setActivePinia(createPinia())
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
capturedFilenames.values = []
|
||||
mockIsCloud.value = false
|
||||
@@ -218,4 +258,114 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAssets - model cache invalidation', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
mockGetAssetType.mockReturnValue('input')
|
||||
mockDeleteAsset.mockResolvedValue(undefined)
|
||||
mockInvalidateModelsForCategory.mockClear()
|
||||
mockSetAssetDeleting.mockClear()
|
||||
mockUpdateHistory.mockClear()
|
||||
mockUpdateInputs.mockClear()
|
||||
mockHasCategory.mockClear()
|
||||
// By default, hasCategory returns true for model categories
|
||||
mockHasCategory.mockImplementation(
|
||||
(tag: string) => tag === 'checkpoints' || tag === 'loras'
|
||||
)
|
||||
})
|
||||
|
||||
it('should invalidate model cache when deleting a model asset', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const modelAsset = createMockAsset({
|
||||
id: 'checkpoint-1',
|
||||
name: 'model.safetensors',
|
||||
tags: ['models', 'checkpoints']
|
||||
})
|
||||
|
||||
mockShowDialog.mockImplementation(
|
||||
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
||||
void props.onConfirm()
|
||||
}
|
||||
)
|
||||
|
||||
await actions.deleteAssets(modelAsset)
|
||||
|
||||
// Only 'checkpoints' exists in cache; 'models' is excluded
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
|
||||
'checkpoints'
|
||||
)
|
||||
})
|
||||
|
||||
it('should invalidate multiple categories for multiple assets', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const assets = [
|
||||
createMockAsset({ id: '1', tags: ['models', 'checkpoints'] }),
|
||||
createMockAsset({ id: '2', tags: ['models', 'loras'] })
|
||||
]
|
||||
|
||||
mockShowDialog.mockImplementation(
|
||||
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
||||
void props.onConfirm()
|
||||
}
|
||||
)
|
||||
|
||||
await actions.deleteAssets(assets)
|
||||
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
|
||||
'checkpoints'
|
||||
)
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith('loras')
|
||||
})
|
||||
|
||||
it('should not invalidate model cache for non-model assets', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const inputAsset = createMockAsset({
|
||||
id: 'input-1',
|
||||
name: 'image.png',
|
||||
tags: ['input']
|
||||
})
|
||||
|
||||
mockShowDialog.mockImplementation(
|
||||
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
||||
void props.onConfirm()
|
||||
}
|
||||
)
|
||||
|
||||
await actions.deleteAssets(inputAsset)
|
||||
|
||||
// 'input' tag is excluded, so no cache invalidation
|
||||
expect(mockInvalidateModelsForCategory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should only invalidate categories that exist in cache', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
// hasCategory returns false for 'unknown-category'
|
||||
mockHasCategory.mockImplementation((tag: string) => tag === 'checkpoints')
|
||||
|
||||
const assets = [
|
||||
createMockAsset({ id: '1', tags: ['models', 'checkpoints'] }),
|
||||
createMockAsset({ id: '2', tags: ['models', 'unknown-category'] })
|
||||
]
|
||||
|
||||
mockShowDialog.mockImplementation(
|
||||
({ props }: { props: { onConfirm: () => Promise<void> } }) => {
|
||||
void props.onConfirm()
|
||||
}
|
||||
)
|
||||
|
||||
await actions.deleteAssets(assets)
|
||||
|
||||
// Only checkpoints should be invalidated (unknown-category not in cache)
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvalidateModelsForCategory).toHaveBeenCalledWith(
|
||||
'checkpoints'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,8 @@ import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import { assetService } from '../services/assetService'
|
||||
|
||||
const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
|
||||
|
||||
export function useMediaAssetActions() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
@@ -639,6 +641,22 @@ export function useMediaAssetActions() {
|
||||
await assetsStore.updateInputs()
|
||||
}
|
||||
|
||||
// Invalidate model caches for affected categories
|
||||
const modelCategories = new Set<string>()
|
||||
|
||||
for (const asset of assetArray) {
|
||||
for (const tag of asset.tags ?? []) {
|
||||
if (EXCLUDED_TAGS.has(tag)) continue
|
||||
if (assetsStore.hasCategory(tag)) {
|
||||
modelCategories.add(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const category of modelCategories) {
|
||||
assetsStore.invalidateModelsForCategory(category)
|
||||
}
|
||||
|
||||
// Show appropriate feedback based on results
|
||||
if (failed.length === 0) {
|
||||
toast.add({
|
||||
|
||||
@@ -43,4 +43,5 @@ export type RemoteConfig = {
|
||||
linear_toggle_enabled?: boolean
|
||||
team_workspaces_enabled?: boolean
|
||||
user_secrets_enabled?: boolean
|
||||
node_library_essentials_enabled?: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual('vue')
|
||||
return {
|
||||
...actual,
|
||||
watch: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
onUserResolved: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/topupTracker', () => ({
|
||||
checkForCompletedTopup: vi.fn(),
|
||||
clearTopupTracking: vi.fn(),
|
||||
startTopupTracking: vi.fn()
|
||||
}))
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockNodeDefsByName: {} as Record<string, unknown>,
|
||||
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[]
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
nodeDefsByName: hoisted.mockNodeDefsByName
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
knownTemplateNames: new Set()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
function mockNode(
|
||||
type: string,
|
||||
isSubgraph = false
|
||||
): Pick<LGraphNode, 'type' | 'isSubgraphNode'> {
|
||||
return {
|
||||
type,
|
||||
isSubgraphNode: (() => isSubgraph) as LGraphNode['isSubgraphNode']
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
reduceAllNodes: vi.fn((_graph, reducer, initial) => {
|
||||
let result = initial
|
||||
for (const node of hoisted.mockNodes) {
|
||||
result = reducer(result, node)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: {} }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: { value: null }
|
||||
}))
|
||||
|
||||
import { MixpanelTelemetryProvider } from './MixpanelTelemetryProvider'
|
||||
|
||||
describe('MixpanelTelemetryProvider.getExecutionContext', () => {
|
||||
let provider: MixpanelTelemetryProvider
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hoisted.mockNodes.length = 0
|
||||
for (const key of Object.keys(hoisted.mockNodeDefsByName)) {
|
||||
delete hoisted.mockNodeDefsByName[key]
|
||||
}
|
||||
provider = new MixpanelTelemetryProvider()
|
||||
})
|
||||
|
||||
it('returns has_toolkit_nodes false when no toolkit nodes are present', () => {
|
||||
hoisted.mockNodes.push(mockNode('KSampler'), mockNode('LoadImage'))
|
||||
hoisted.mockNodeDefsByName['KSampler'] = {
|
||||
name: 'KSampler',
|
||||
python_module: 'nodes'
|
||||
}
|
||||
hoisted.mockNodeDefsByName['LoadImage'] = {
|
||||
name: 'LoadImage',
|
||||
python_module: 'nodes'
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(false)
|
||||
expect(context.toolkit_node_names).toEqual([])
|
||||
expect(context.toolkit_node_count).toBe(0)
|
||||
})
|
||||
|
||||
it('detects individual toolkit nodes by type name', () => {
|
||||
hoisted.mockNodes.push(mockNode('Canny'), mockNode('KSampler'))
|
||||
hoisted.mockNodeDefsByName['Canny'] = {
|
||||
name: 'Canny',
|
||||
python_module: 'comfy_extras.nodes_canny'
|
||||
}
|
||||
hoisted.mockNodeDefsByName['KSampler'] = {
|
||||
name: 'KSampler',
|
||||
python_module: 'nodes'
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['Canny'])
|
||||
expect(context.toolkit_node_count).toBe(1)
|
||||
})
|
||||
|
||||
it('detects blueprint toolkit nodes via python_module', () => {
|
||||
const blueprintType = 'SubgraphBlueprint.text_to_image'
|
||||
hoisted.mockNodes.push(mockNode(blueprintType, true))
|
||||
hoisted.mockNodeDefsByName[blueprintType] = {
|
||||
name: blueprintType,
|
||||
python_module: 'comfy_essentials'
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual([blueprintType])
|
||||
expect(context.toolkit_node_count).toBe(1)
|
||||
})
|
||||
|
||||
it('deduplicates toolkit_node_names when same type appears multiple times', () => {
|
||||
hoisted.mockNodes.push(mockNode('Canny'), mockNode('Canny'))
|
||||
hoisted.mockNodeDefsByName['Canny'] = {
|
||||
name: 'Canny',
|
||||
python_module: 'comfy_extras.nodes_canny'
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
|
||||
expect(context.toolkit_node_names).toEqual(['Canny'])
|
||||
expect(context.toolkit_node_count).toBe(2)
|
||||
})
|
||||
|
||||
it('allows a node to appear in both api_node_names and toolkit_node_names', () => {
|
||||
hoisted.mockNodes.push(mockNode('RecraftRemoveBackgroundNode'))
|
||||
hoisted.mockNodeDefsByName['RecraftRemoveBackgroundNode'] = {
|
||||
name: 'RecraftRemoveBackgroundNode',
|
||||
python_module: 'comfy_extras.nodes_api',
|
||||
api_node: true
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
|
||||
expect(context.has_api_nodes).toBe(true)
|
||||
expect(context.api_node_names).toEqual(['RecraftRemoveBackgroundNode'])
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['RecraftRemoveBackgroundNode'])
|
||||
})
|
||||
|
||||
it('uses node.type as tracking name when nodeDef is missing', () => {
|
||||
hoisted.mockNodes.push(mockNode('ImageCrop'))
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['ImageCrop'])
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,10 @@ import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import {
|
||||
TOOLKIT_BLUEPRINT_MODULES,
|
||||
TOOLKIT_NODE_NAMES
|
||||
} from '@/constants/toolkitNodes'
|
||||
import {
|
||||
checkForCompletedTopup as checkTopupUtil,
|
||||
clearTopupTracking as clearTopupUtil,
|
||||
@@ -285,6 +289,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
subgraph_count: executionContext.subgraph_count,
|
||||
has_api_nodes: executionContext.has_api_nodes,
|
||||
api_node_names: executionContext.api_node_names,
|
||||
has_toolkit_nodes: executionContext.has_toolkit_nodes,
|
||||
toolkit_node_names: executionContext.toolkit_node_names,
|
||||
trigger_source: options?.trigger_source
|
||||
}
|
||||
|
||||
@@ -432,10 +438,13 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
type NodeMetrics = {
|
||||
custom_node_count: number
|
||||
api_node_count: number
|
||||
toolkit_node_count: number
|
||||
subgraph_count: number
|
||||
total_node_count: number
|
||||
has_api_nodes: boolean
|
||||
api_node_names: string[]
|
||||
has_toolkit_nodes: boolean
|
||||
toolkit_node_names: string[]
|
||||
}
|
||||
|
||||
const nodeCounts = reduceAllNodes<NodeMetrics>(
|
||||
@@ -458,8 +467,21 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
}
|
||||
|
||||
const isToolkitNode =
|
||||
TOOLKIT_NODE_NAMES.has(node.type) ||
|
||||
(nodeDef?.python_module !== undefined &&
|
||||
TOOLKIT_BLUEPRINT_MODULES.has(nodeDef.python_module))
|
||||
if (isToolkitNode) {
|
||||
metrics.has_toolkit_nodes = true
|
||||
const trackingName = nodeDef?.name ?? node.type
|
||||
if (!metrics.toolkit_node_names.includes(trackingName)) {
|
||||
metrics.toolkit_node_names.push(trackingName)
|
||||
}
|
||||
}
|
||||
|
||||
metrics.custom_node_count += isCustomNode ? 1 : 0
|
||||
metrics.api_node_count += isApiNode ? 1 : 0
|
||||
metrics.toolkit_node_count += isToolkitNode ? 1 : 0
|
||||
metrics.subgraph_count += isSubgraph ? 1 : 0
|
||||
metrics.total_node_count += 1
|
||||
|
||||
@@ -468,10 +490,13 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
{
|
||||
custom_node_count: 0,
|
||||
api_node_count: 0,
|
||||
toolkit_node_count: 0,
|
||||
subgraph_count: 0,
|
||||
total_node_count: 0,
|
||||
has_api_nodes: false,
|
||||
api_node_names: []
|
||||
api_node_names: [],
|
||||
has_toolkit_nodes: false,
|
||||
toolkit_node_names: []
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface RunButtonProperties {
|
||||
subgraph_count: number
|
||||
has_api_nodes: boolean
|
||||
api_node_names: string[]
|
||||
has_toolkit_nodes: boolean
|
||||
toolkit_node_names: string[]
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}
|
||||
|
||||
@@ -82,6 +84,9 @@ export interface ExecutionContext {
|
||||
total_node_count: number
|
||||
has_api_nodes: boolean
|
||||
api_node_names: string[]
|
||||
has_toolkit_nodes: boolean
|
||||
toolkit_node_names: string[]
|
||||
toolkit_node_count: number
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}
|
||||
|
||||
|
||||
@@ -396,6 +396,8 @@ interface SubgraphDefinitionBase<
|
||||
id: string
|
||||
revision: number
|
||||
name: string
|
||||
/** Optional description shown as tooltip when hovering over the subgraph node. */
|
||||
description?: string
|
||||
category?: string
|
||||
/** Custom metadata for the subgraph (description, searchAliases, etc.) */
|
||||
extra?: T extends ComfyWorkflow1BaseInput
|
||||
@@ -432,6 +434,8 @@ const zSubgraphDefinition = zComfyWorkflow1
|
||||
id: z.string().uuid(),
|
||||
revision: z.number(),
|
||||
name: z.string(),
|
||||
/** Optional description shown as tooltip when hovering over the subgraph node. */
|
||||
description: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
inputNode: zExportedSubgraphIONode,
|
||||
outputNode: zExportedSubgraphIONode,
|
||||
|
||||
@@ -192,19 +192,23 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
// Get value from store (falls back to undefined if not registered)
|
||||
const value = widgetState?.value as WidgetValue
|
||||
|
||||
// Build options from store state, with slot-linked override for disabled
|
||||
// Build options from store state, with slot-linked override for disabled.
|
||||
// Promoted widgets inside a subgraph are always linked to SubgraphInput,
|
||||
// but should remain interactive — skip the disabled override for them.
|
||||
const storeOptions = widgetState?.options ?? {}
|
||||
const widgetOptions = slotMetadata?.linked
|
||||
? { ...storeOptions, disabled: true }
|
||||
: storeOptions
|
||||
const isPromotedOnOwningNode =
|
||||
widgetState?.promoted && String(widgetState?.nodeId) === String(nodeId)
|
||||
const widgetOptions =
|
||||
slotMetadata?.linked && !isPromotedOnOwningNode
|
||||
? { ...storeOptions, disabled: true }
|
||||
: storeOptions
|
||||
|
||||
// Derive border style from store metadata
|
||||
const borderStyle =
|
||||
widgetState?.promoted && String(widgetState?.nodeId) === String(nodeId)
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: widget.options?.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined
|
||||
const borderStyle = isPromotedOnOwningNode
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
: widget.options?.advanced
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined
|
||||
|
||||
const simplified: SimplifiedWidget = {
|
||||
name: widget.name,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
@@ -24,18 +22,12 @@ function createMockWidget(
|
||||
function mountComponent(
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string,
|
||||
readonly = false,
|
||||
placeholder?: string
|
||||
) {
|
||||
return mount(WidgetTextarea, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { Textarea }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly,
|
||||
placeholder
|
||||
}
|
||||
})
|
||||
@@ -185,18 +177,39 @@ describe('WidgetTextarea Value Binding', () => {
|
||||
|
||||
it('uses provided placeholder when specified', () => {
|
||||
const widget = createMockWidget('test')
|
||||
const wrapper = mountComponent(
|
||||
widget,
|
||||
'test',
|
||||
false,
|
||||
'Custom placeholder'
|
||||
)
|
||||
const wrapper = mountComponent(widget, 'test', 'Custom placeholder')
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.attributes('placeholder')).toBe('Custom placeholder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Read-Only Behavior', () => {
|
||||
it('is readonly when options.read_only is true', () => {
|
||||
const widget = createMockWidget('test', { read_only: true })
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
expect(wrapper.find('textarea').attributes('readonly')).toBeDefined()
|
||||
})
|
||||
|
||||
it('is readonly when options.disabled is true', () => {
|
||||
const widget = createMockWidget('test', { disabled: true })
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
expect(wrapper.find('textarea').attributes('readonly')).toBeDefined()
|
||||
})
|
||||
|
||||
it('is editable when neither read_only nor disabled is set', () => {
|
||||
const widget = createMockWidget('test', {})
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
expect(wrapper.find('textarea').attributes('readonly')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is editable when disabled is explicitly false', () => {
|
||||
const widget = createMockWidget('test', { disabled: false })
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
expect(wrapper.find('textarea').attributes('readonly')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles very long text', async () => {
|
||||
const widget = createMockWidget('short')
|
||||
|
||||
@@ -1,37 +1,45 @@
|
||||
<template>
|
||||
<FloatLabel
|
||||
variant="in"
|
||||
:unstyled="hideLayoutField"
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg space-y-1 focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
||||
'relative rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
||||
widget.borderStyle
|
||||
)
|
||||
"
|
||||
>
|
||||
<label
|
||||
v-if="!hideLayoutField"
|
||||
:for="id"
|
||||
class="pointer-events-none absolute left-3 top-1.5 z-10 text-xxs text-muted-foreground"
|
||||
>
|
||||
{{ displayName }}
|
||||
</label>
|
||||
<Textarea
|
||||
v-bind="filteredProps"
|
||||
:id
|
||||
v-model="modelValue"
|
||||
:class="cn(WidgetInputBaseClass, 'size-full text-xs resize-none')"
|
||||
:class="
|
||||
cn(
|
||||
WidgetInputBaseClass,
|
||||
'size-full text-xs resize-none',
|
||||
!hideLayoutField && 'pt-5'
|
||||
)
|
||||
"
|
||||
:placeholder
|
||||
:readonly="isReadOnly"
|
||||
fluid
|
||||
data-capture-wheel="true"
|
||||
@pointerdown.capture.stop
|
||||
@pointermove.capture.stop
|
||||
@pointerup.capture.stop
|
||||
@contextmenu.capture.stop
|
||||
/>
|
||||
<label v-if="!hideLayoutField" :for="id">{{ displayName }}</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { computed, useId } from 'vue'
|
||||
|
||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { useHideLayoutField } from '@/types/widgetTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -161,7 +161,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
sel.clear()
|
||||
sel.add(item.id)
|
||||
} else {
|
||||
toastStore.addAlert(`Maximum selection limit reached`)
|
||||
toastStore.addAlert(t('widgets.uploadSelect.maxSelectionReached'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ const theButtonStyle = computed(() =>
|
||||
<div
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'flex text-base leading-none', {
|
||||
'opacity-50 cursor-not-allowed !outline-zinc-300/10': disabled
|
||||
'opacity-50 cursor-not-allowed outline-zinc-300/10': disabled
|
||||
})
|
||||
"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import FormDropdownMenu from './FormDropdownMenu.vue'
|
||||
import type { FormDropdownItem, LayoutMode } from './types'
|
||||
|
||||
function createItem(id: string, name: string): FormDropdownItem {
|
||||
return {
|
||||
id,
|
||||
preview_url: '',
|
||||
name,
|
||||
label: name
|
||||
}
|
||||
}
|
||||
|
||||
describe('FormDropdownMenu', () => {
|
||||
const defaultProps = {
|
||||
items: [createItem('1', 'Item 1'), createItem('2', 'Item 2')],
|
||||
isSelected: () => false,
|
||||
filterOptions: [],
|
||||
sortOptions: []
|
||||
}
|
||||
|
||||
it('renders empty state when no items', async () => {
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
items: []
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
},
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const emptyIcon = wrapper.find('.icon-\\[lucide--circle-off\\]')
|
||||
expect(emptyIcon.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders VirtualGrid when items exist', async () => {
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
|
||||
expect(virtualGrid.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('transforms items to include key property for VirtualGrid', async () => {
|
||||
const items = [createItem('1', 'Item 1'), createItem('2', 'Item 2')]
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
items
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
|
||||
const virtualItems = virtualGrid.props('items')
|
||||
|
||||
expect(virtualItems).toHaveLength(2)
|
||||
expect(virtualItems[0]).toHaveProperty('key', '1')
|
||||
expect(virtualItems[1]).toHaveProperty('key', '2')
|
||||
})
|
||||
|
||||
it('uses single column layout for list modes', async () => {
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
layoutMode: 'list' as LayoutMode
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
|
||||
expect(virtualGrid.props('maxColumns')).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import type { CSSProperties, MaybeRefOrGetter } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
|
||||
import type {
|
||||
FilterOption,
|
||||
@@ -30,7 +31,18 @@ interface Props {
|
||||
baseModelOptions?: FilterOption[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const {
|
||||
items,
|
||||
isSelected,
|
||||
filterOptions,
|
||||
sortOptions,
|
||||
searcher,
|
||||
updateKey,
|
||||
showOwnershipFilter,
|
||||
ownershipOptions,
|
||||
showBaseModelFilter,
|
||||
baseModelOptions
|
||||
} = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'item-click', item: FormDropdownItem, index: number): void
|
||||
}>()
|
||||
@@ -41,11 +53,48 @@ const sortSelected = defineModel<string>('sortSelected')
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected')
|
||||
const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
|
||||
|
||||
type LayoutConfig = {
|
||||
maxColumns: number
|
||||
itemHeight: number
|
||||
itemWidth: number
|
||||
gap: string
|
||||
}
|
||||
|
||||
const LAYOUT_CONFIGS: Record<LayoutMode, LayoutConfig> = {
|
||||
grid: { maxColumns: 4, itemHeight: 120, itemWidth: 89, gap: '1rem 0.5rem' },
|
||||
list: { maxColumns: 1, itemHeight: 64, itemWidth: 380, gap: '0.5rem' },
|
||||
'list-small': {
|
||||
maxColumns: 1,
|
||||
itemHeight: 40,
|
||||
itemWidth: 380,
|
||||
gap: '0.25rem'
|
||||
}
|
||||
}
|
||||
|
||||
const layoutConfig = computed<LayoutConfig>(
|
||||
() => LAYOUT_CONFIGS[layoutMode.value ?? 'grid']
|
||||
)
|
||||
|
||||
const gridStyle = computed<CSSProperties>(() => ({
|
||||
display: 'grid',
|
||||
gap: layoutConfig.value.gap,
|
||||
padding: '1rem',
|
||||
width: '100%'
|
||||
}))
|
||||
|
||||
type VirtualDropdownItem = FormDropdownItem & { key: string }
|
||||
const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
key: String(item.id)
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex max-h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
|
||||
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
|
||||
>
|
||||
<FormDropdownMenuFilter
|
||||
v-if="filterOptions.length > 0"
|
||||
@@ -66,34 +115,30 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
|
||||
:show-base-model-filter
|
||||
:base-model-options
|
||||
/>
|
||||
<div class="relative flex h-full mt-2 overflow-y-scroll">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'h-full max-h-full grid gap-x-2 gap-y-4 overflow-y-auto px-4 pt-4 pb-4 w-full',
|
||||
{
|
||||
'grid-cols-4': layoutMode === 'grid',
|
||||
'grid-cols-1 gap-y-2': layoutMode === 'list',
|
||||
'grid-cols-1 gap-y-1': layoutMode === 'list-small'
|
||||
}
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="pointer-events-none absolute inset-x-3 top-0 z-10 h-5" />
|
||||
<div
|
||||
v-if="items.length === 0"
|
||||
class="h-50 col-span-full flex items-center justify-center"
|
||||
>
|
||||
<i
|
||||
:title="$t('g.noItems')"
|
||||
:aria-label="$t('g.noItems')"
|
||||
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="items.length === 0"
|
||||
class="flex h-50 items-center justify-center"
|
||||
>
|
||||
<i
|
||||
:title="$t('g.noItems')"
|
||||
:aria-label="$t('g.noItems')"
|
||||
class="icon-[lucide--circle-off] size-30 text-muted-foreground/20"
|
||||
/>
|
||||
</div>
|
||||
<VirtualGrid
|
||||
v-else
|
||||
:key="layoutMode"
|
||||
:items="virtualItems"
|
||||
:grid-style
|
||||
:max-columns="layoutConfig.maxColumns"
|
||||
:default-item-height="layoutConfig.itemHeight"
|
||||
:default-item-width="layoutConfig.itemWidth"
|
||||
:buffer-rows="2"
|
||||
class="mt-2 min-h-0 flex-1"
|
||||
>
|
||||
<template #item="{ item, index }">
|
||||
<FormDropdownMenuItem
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
:index="index"
|
||||
:index
|
||||
:selected="isSelected(item, index)"
|
||||
:preview-url="item.preview_url ?? ''"
|
||||
:name="item.name"
|
||||
@@ -101,7 +146,7 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
|
||||
:layout="layoutMode"
|
||||
@click="emit('item-click', item, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { AssetKindKey } from './types'
|
||||
@@ -57,7 +56,7 @@ function handleVideoLoad(event: Event) {
|
||||
:class="
|
||||
cn(
|
||||
'flex gap-1 select-none group/item cursor-pointer bg-component-node-widget-background',
|
||||
'transition-all duration-150',
|
||||
'transition-[transform,box-shadow,background-color] duration-150',
|
||||
{
|
||||
'flex-col text-center': layout === 'grid',
|
||||
'flex-row text-left max-h-16 rounded-lg hover:scale-102 active:scale-98':
|
||||
@@ -79,7 +78,7 @@ function handleVideoLoad(event: Event) {
|
||||
cn(
|
||||
'relative',
|
||||
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-interface-stroke',
|
||||
'transition-all duration-150',
|
||||
'transition-[transform,box-shadow] duration-150',
|
||||
{
|
||||
'min-w-16 max-w-16 rounded-l-lg': layout === 'list',
|
||||
'rounded-sm group-hover/item:scale-108 group-active/item:scale-95':
|
||||
@@ -108,11 +107,12 @@ function handleVideoLoad(event: Event) {
|
||||
muted
|
||||
@loadeddata="handleVideoLoad"
|
||||
/>
|
||||
<LazyImage
|
||||
<img
|
||||
v-else-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
:alt="name"
|
||||
image-class="size-full object-cover"
|
||||
draggable="false"
|
||||
class="size-full object-cover"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -17,7 +17,11 @@ import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
|
||||
|
||||
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { cloudOnboardingRoutes } from './platform/cloud/onboarding/onboardingCloudRoutes'
|
||||
|
||||
const cloudOnboardingRoutes = isCloud
|
||||
? (await import('./platform/cloud/onboarding/onboardingCloudRoutes'))
|
||||
.cloudOnboardingRoutes
|
||||
: []
|
||||
|
||||
const isFileProtocol = window.location.protocol === 'file:'
|
||||
|
||||
|
||||
@@ -233,4 +233,37 @@ describe('API Feature Flags', () => {
|
||||
expect(flag.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dev override via localStorage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('getServerFeature returns localStorage override over server value', () => {
|
||||
api.serverFeatureFlags.value = { some_flag: false }
|
||||
localStorage.setItem('ff:some_flag', 'true')
|
||||
|
||||
expect(api.getServerFeature('some_flag')).toBe(true)
|
||||
})
|
||||
|
||||
it('serverSupportsFeature returns localStorage override over server value', () => {
|
||||
api.serverFeatureFlags.value = { some_flag: false }
|
||||
localStorage.setItem('ff:some_flag', 'true')
|
||||
|
||||
expect(api.serverSupportsFeature('some_flag')).toBe(true)
|
||||
})
|
||||
|
||||
it('getServerFeature falls through when no override is set', () => {
|
||||
api.serverFeatureFlags.value = { some_flag: 'server_value' }
|
||||
|
||||
expect(api.getServerFeature('some_flag')).toBe('server_value')
|
||||
})
|
||||
|
||||
it('getServerFeature override works with numeric values', () => {
|
||||
api.serverFeatureFlags.value = { max_upload_size: 100 }
|
||||
localStorage.setItem('ff:max_upload_size', '999')
|
||||
|
||||
expect(api.getServerFeature('max_upload_size')).toBe(999)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { trimEnd } from 'es-toolkit'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json' with { type: 'json' }
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
import type {
|
||||
ModelFile,
|
||||
ModelFolderInfo
|
||||
@@ -1299,6 +1300,8 @@ export class ComfyApi extends EventTarget {
|
||||
* @returns true if the feature is supported, false otherwise
|
||||
*/
|
||||
serverSupportsFeature(featureName: string): boolean {
|
||||
const override = getDevOverride<boolean>(featureName)
|
||||
if (override !== undefined) return override
|
||||
return get(this.serverFeatureFlags.value, featureName) === true
|
||||
}
|
||||
|
||||
@@ -1309,6 +1312,8 @@ export class ComfyApi extends EventTarget {
|
||||
* @returns The feature value or default
|
||||
*/
|
||||
getServerFeature<T = unknown>(featureName: string, defaultValue?: T): T {
|
||||
const override = getDevOverride<T>(featureName)
|
||||
if (override !== undefined) return override
|
||||
return get(this.serverFeatureFlags.value, featureName, defaultValue) as T
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export const useSubgraphService = () => {
|
||||
output_tooltips: [],
|
||||
name: id,
|
||||
display_name: name,
|
||||
description: `Subgraph node for ${name}`,
|
||||
description: exportedSubgraph.description || `Subgraph node for ${name}`,
|
||||
category: 'subgraph',
|
||||
output_node: false,
|
||||
python_module: 'nodes'
|
||||
|
||||
@@ -507,12 +507,12 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
mockIsCloud.value = false
|
||||
})
|
||||
|
||||
const createMockAsset = (id: string) => ({
|
||||
const createMockAsset = (id: string, tags: string[] = ['models']) => ({
|
||||
id,
|
||||
name: `asset-${id}`,
|
||||
size: 100,
|
||||
created_at: new Date().toISOString(),
|
||||
tags: ['models'],
|
||||
tags,
|
||||
preview_url: `http://test.com/${id}`
|
||||
})
|
||||
|
||||
@@ -751,4 +751,103 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
expect(store.getAssets('tag:models')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasCategory', () => {
|
||||
it('should return true for loaded categories', async () => {
|
||||
const store = useAssetsStore()
|
||||
const assets = [createMockAsset('asset-1')]
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
|
||||
await store.updateModelsForNodeType('CheckpointLoaderSimple')
|
||||
|
||||
expect(store.hasCategory('checkpoints')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for tag-based category when tag: prefix is not used', async () => {
|
||||
const store = useAssetsStore()
|
||||
const assets = [createMockAsset('asset-1')]
|
||||
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValue(assets)
|
||||
await store.updateModelsForTag('models')
|
||||
|
||||
// hasCategory('models') checks for both 'models' and 'tag:models'
|
||||
expect(store.hasCategory('models')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for unloaded categories', () => {
|
||||
const store = useAssetsStore()
|
||||
|
||||
expect(store.hasCategory('checkpoints')).toBe(false)
|
||||
expect(store.hasCategory('unknown-category')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false after category is invalidated', async () => {
|
||||
const store = useAssetsStore()
|
||||
const assets = [createMockAsset('asset-1')]
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
|
||||
await store.updateModelsForNodeType('CheckpointLoaderSimple')
|
||||
|
||||
expect(store.hasCategory('checkpoints')).toBe(true)
|
||||
|
||||
store.invalidateCategory('checkpoints')
|
||||
|
||||
expect(store.hasCategory('checkpoints')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidateModelsForCategory', () => {
|
||||
it('should clear cache for category and trigger refetch on next access', async () => {
|
||||
const store = useAssetsStore()
|
||||
const initialAssets = [createMockAsset('initial-1')]
|
||||
const refreshedAssets = [
|
||||
createMockAsset('refreshed-1'),
|
||||
createMockAsset('refreshed-2')
|
||||
]
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce(
|
||||
initialAssets
|
||||
)
|
||||
await store.updateModelsForNodeType('CheckpointLoaderSimple')
|
||||
expect(store.getAssets('CheckpointLoaderSimple')).toHaveLength(1)
|
||||
|
||||
store.invalidateModelsForCategory('checkpoints')
|
||||
|
||||
// Cache should be cleared
|
||||
expect(store.hasCategory('checkpoints')).toBe(false)
|
||||
expect(store.getAssets('CheckpointLoaderSimple')).toEqual([])
|
||||
|
||||
// Next fetch should get fresh data
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce(
|
||||
refreshedAssets
|
||||
)
|
||||
await store.updateModelsForNodeType('CheckpointLoaderSimple')
|
||||
expect(store.getAssets('CheckpointLoaderSimple')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should clear tag-based caches', async () => {
|
||||
const store = useAssetsStore()
|
||||
const tagAssets = [createMockAsset('tag-1'), createMockAsset('tag-2')]
|
||||
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValue(tagAssets)
|
||||
await store.updateModelsForTag('checkpoints')
|
||||
await store.updateModelsForTag('models')
|
||||
|
||||
expect(store.getAssets('tag:checkpoints')).toHaveLength(2)
|
||||
expect(store.getAssets('tag:models')).toHaveLength(2)
|
||||
|
||||
store.invalidateModelsForCategory('checkpoints')
|
||||
|
||||
expect(store.getAssets('tag:checkpoints')).toEqual([])
|
||||
expect(store.getAssets('tag:models')).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle unknown categories gracefully', () => {
|
||||
const store = useAssetsStore()
|
||||
|
||||
expect(() =>
|
||||
store.invalidateModelsForCategory('unknown-category')
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -375,6 +375,18 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
return modelStateByCategory.value.has(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a category exists in the cache.
|
||||
* Checks both direct category keys and tag-prefixed keys.
|
||||
* @param category The category to check (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function hasCategory(category: string): boolean {
|
||||
return (
|
||||
modelStateByCategory.value.has(category) ||
|
||||
modelStateByCategory.value.has(`tag:${category}`)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to fetch and cache assets for a category.
|
||||
* Loads first batch immediately, then progressively loads remaining batches.
|
||||
@@ -608,17 +620,30 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
|
||||
* Clears the category cache and tag-based caches so next access triggers refetch
|
||||
* @param category The model category to invalidate (e.g., 'checkpoints')
|
||||
*/
|
||||
function invalidateModelsForCategory(category: string): void {
|
||||
invalidateCategory(category)
|
||||
invalidateCategory(`tag:${category}`)
|
||||
invalidateCategory('tag:models')
|
||||
}
|
||||
|
||||
return {
|
||||
getAssets,
|
||||
isLoading,
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,11 +654,13 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
getError: () => undefined,
|
||||
hasMore: () => false,
|
||||
hasAssetKey: () => false,
|
||||
hasCategory: () => false,
|
||||
updateModelsForNodeType: async () => {},
|
||||
invalidateCategory: () => {},
|
||||
updateModelsForTag: async () => {},
|
||||
updateAssetMetadata: async () => {},
|
||||
updateAssetTags: async () => {}
|
||||
updateAssetTags: async () => {},
|
||||
invalidateModelsForCategory: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,11 +670,13 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory
|
||||
} = getModelState()
|
||||
|
||||
// Watch for completed downloads and refresh model caches
|
||||
@@ -718,12 +747,14 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
|
||||
// Model assets - actions
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory
|
||||
}
|
||||
})
|
||||
|
||||
@@ -535,6 +535,7 @@ describe('useQueueStore', () => {
|
||||
|
||||
await store.update()
|
||||
const initialTask = store.historyTasks[0]
|
||||
const initialHistoryTasks = store.historyTasks
|
||||
|
||||
// Same job with same outputs_count
|
||||
mockGetHistory.mockResolvedValue([{ ...job }])
|
||||
@@ -543,6 +544,8 @@ describe('useQueueStore', () => {
|
||||
|
||||
// Should reuse the same instance
|
||||
expect(store.historyTasks[0]).toBe(initialTask)
|
||||
// Should preserve array identity when history is unchanged
|
||||
expect(store.historyTasks).toBe(initialHistoryTasks)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -479,6 +479,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
const runningTasks = shallowRef<TaskItemImpl[]>([])
|
||||
const pendingTasks = shallowRef<TaskItemImpl[]>([])
|
||||
const historyTasks = shallowRef<TaskItemImpl[]>([])
|
||||
const hasFetchedHistorySnapshot = ref(false)
|
||||
const maxHistoryItems = ref(64)
|
||||
const isLoading = ref(false)
|
||||
|
||||
@@ -557,7 +558,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
currentHistory.map((impl) => [impl.jobId, impl])
|
||||
)
|
||||
|
||||
historyTasks.value = sortedHistory.map((job) => {
|
||||
const nextHistoryTasks = sortedHistory.map((job) => {
|
||||
const existing = existingByJobId.get(job.id)
|
||||
if (!existing) return new TaskItemImpl(job)
|
||||
// Recreate if outputs_count changed to ensure lazy loading works
|
||||
@@ -566,6 +567,15 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
}
|
||||
return existing
|
||||
})
|
||||
|
||||
const isHistoryUnchanged =
|
||||
nextHistoryTasks.length === currentHistory.length &&
|
||||
nextHistoryTasks.every((task, index) => task === currentHistory[index])
|
||||
|
||||
if (!isHistoryUnchanged) {
|
||||
historyTasks.value = nextHistoryTasks
|
||||
}
|
||||
hasFetchedHistorySnapshot.value = true
|
||||
} finally {
|
||||
// Only clear loading if this is the latest request.
|
||||
// A stale request completing (success or error) should not touch loading state
|
||||
@@ -595,6 +605,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
runningTasks,
|
||||
pendingTasks,
|
||||
historyTasks,
|
||||
hasFetchedHistorySnapshot,
|
||||
maxHistoryItems,
|
||||
isLoading,
|
||||
|
||||
|
||||
187
src/stores/workspace/assetsSidebarBadgeStore.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { useAssetsSidebarBadgeStore } from '@/stores/workspace/assetsSidebarBadgeStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
const createHistoryTask = ({
|
||||
id,
|
||||
outputsCount,
|
||||
hasPreview = true
|
||||
}: {
|
||||
id: string
|
||||
outputsCount?: number
|
||||
hasPreview?: boolean
|
||||
}) =>
|
||||
new TaskItemImpl({
|
||||
id,
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
priority: 1,
|
||||
outputs_count: outputsCount,
|
||||
preview_output: hasPreview
|
||||
? {
|
||||
filename: `${id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
: undefined
|
||||
} as JobListItem)
|
||||
|
||||
describe('useAssetsSidebarBadgeStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('does not count initial fetched history when store starts before hydration', async () => {
|
||||
const queueStore = useQueueStore()
|
||||
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', outputsCount: 2 })
|
||||
]
|
||||
await nextTick()
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
queueStore.hasFetchedHistorySnapshot = true
|
||||
await nextTick()
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
})
|
||||
|
||||
it('counts new history items after baseline hydration while assets tab is closed', async () => {
|
||||
const queueStore = useQueueStore()
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', outputsCount: 2 })
|
||||
]
|
||||
queueStore.hasFetchedHistorySnapshot = true
|
||||
|
||||
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-2', hasPreview: true }),
|
||||
...queueStore.historyTasks
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(1)
|
||||
})
|
||||
|
||||
it('does not count preview fallback when server outputsCount is zero', async () => {
|
||||
const queueStore = useQueueStore()
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', outputsCount: 2 })
|
||||
]
|
||||
queueStore.hasFetchedHistorySnapshot = true
|
||||
|
||||
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-2', outputsCount: 0, hasPreview: true }),
|
||||
...queueStore.historyTasks
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
})
|
||||
|
||||
it('adds only delta when a seen job gains more outputs', async () => {
|
||||
const queueStore = useQueueStore()
|
||||
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', hasPreview: true })
|
||||
]
|
||||
queueStore.hasFetchedHistorySnapshot = true
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', outputsCount: 3 })
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(2)
|
||||
})
|
||||
|
||||
it('treats a reappearing job as unseen after it aged out of history', async () => {
|
||||
const queueStore = useQueueStore()
|
||||
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', outputsCount: 2 })
|
||||
]
|
||||
queueStore.hasFetchedHistorySnapshot = true
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-2', outputsCount: 1 })
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(1)
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', outputsCount: 1 }),
|
||||
createHistoryTask({ id: 'job-2', outputsCount: 1 })
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(2)
|
||||
})
|
||||
|
||||
it('clears and suppresses count while assets tab is open', async () => {
|
||||
const queueStore = useQueueStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', outputsCount: 2 })
|
||||
]
|
||||
queueStore.hasFetchedHistorySnapshot = true
|
||||
await nextTick()
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-2', outputsCount: 4 }),
|
||||
...queueStore.historyTasks
|
||||
]
|
||||
await nextTick()
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(4)
|
||||
|
||||
sidebarTabStore.activeSidebarTabId = 'assets'
|
||||
await nextTick()
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-3', outputsCount: 4 }),
|
||||
...queueStore.historyTasks
|
||||
]
|
||||
await nextTick()
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
sidebarTabStore.activeSidebarTabId = 'node-library'
|
||||
await nextTick()
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-4', outputsCount: 1 }),
|
||||
...queueStore.historyTasks
|
||||
]
|
||||
await nextTick()
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(1)
|
||||
})
|
||||
})
|
||||
99
src/stores/workspace/assetsSidebarBadgeStore.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
const getAddedAssetCount = (task: TaskItemImpl): number => {
|
||||
if (typeof task.outputsCount === 'number') {
|
||||
return Math.max(task.outputsCount, 0)
|
||||
}
|
||||
|
||||
return task.previewOutput ? 1 : 0
|
||||
}
|
||||
|
||||
export const useAssetsSidebarBadgeStore = defineStore(
|
||||
'assetsSidebarBadge',
|
||||
() => {
|
||||
const queueStore = useQueueStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
|
||||
const unseenAddedAssetsCount = ref(0)
|
||||
const countedHistoryAssetsByJobId = ref(new Map<string, number>())
|
||||
const hasInitializedHistory = ref(false)
|
||||
|
||||
const markCurrentHistoryAsSeen = () => {
|
||||
countedHistoryAssetsByJobId.value = new Map(
|
||||
queueStore.historyTasks.map((task) => [
|
||||
task.jobId,
|
||||
getAddedAssetCount(task)
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
queueStore.historyTasks,
|
||||
queueStore.hasFetchedHistorySnapshot
|
||||
] as const,
|
||||
([historyTasks, hasFetchedHistorySnapshot]) => {
|
||||
if (!hasFetchedHistorySnapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasInitializedHistory.value) {
|
||||
hasInitializedHistory.value = true
|
||||
markCurrentHistoryAsSeen()
|
||||
return
|
||||
}
|
||||
|
||||
const isAssetsTabOpen = sidebarTabStore.activeSidebarTabId === 'assets'
|
||||
const previousCountedAssetsByJobId = countedHistoryAssetsByJobId.value
|
||||
const nextCountedAssetsByJobId = new Map<string, number>()
|
||||
|
||||
for (const task of historyTasks) {
|
||||
const jobId = task.jobId
|
||||
if (!jobId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const countedAssets = previousCountedAssetsByJobId.get(jobId) ?? 0
|
||||
const currentAssets = getAddedAssetCount(task)
|
||||
const hasSeenJob = previousCountedAssetsByJobId.has(jobId)
|
||||
|
||||
if (!isAssetsTabOpen && !hasSeenJob) {
|
||||
unseenAddedAssetsCount.value += currentAssets
|
||||
} else if (!isAssetsTabOpen && currentAssets > countedAssets) {
|
||||
unseenAddedAssetsCount.value += currentAssets - countedAssets
|
||||
}
|
||||
|
||||
nextCountedAssetsByJobId.set(
|
||||
jobId,
|
||||
Math.max(countedAssets, currentAssets)
|
||||
)
|
||||
}
|
||||
|
||||
countedHistoryAssetsByJobId.value = nextCountedAssetsByJobId
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sidebarTabStore.activeSidebarTabId,
|
||||
(activeSidebarTabId) => {
|
||||
if (activeSidebarTabId !== 'assets') {
|
||||
return
|
||||
}
|
||||
|
||||
unseenAddedAssetsCount.value = 0
|
||||
markCurrentHistoryAsSeen()
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
unseenAddedAssetsCount
|
||||
}
|
||||
}
|
||||
)
|
||||