Compare commits

..

2 Commits

Author SHA1 Message Date
--list
3940cc5a9c feat(workspace): add store and composables with tests
- Add teamWorkspaceStore with state management and token refresh
- Add useWorkspaceUI composable for role-based permissions
- Add useInviteUrlLoader for invite URL handling
- Add useWorkspaceSwitch hook
- Include comprehensive test coverage (1400+ lines)
2026-01-20 11:57:41 -08:00
--list
a0dc6432fc feat(workspace): add foundation layer - API client and session manager
- Add workspaceApi.ts with all workspace endpoint methods
- Add sessionManager.ts for workspace token storage
- Update workspaceConstants.ts with storage keys
- Add INVITE namespace to preservedQueryNamespaces
2026-01-20 11:55:00 -08:00
171 changed files with 2872 additions and 6707 deletions

View File

@@ -122,7 +122,7 @@ echo " pnpm build - Build for production"
echo " pnpm test:unit - Run unit tests"
echo " pnpm typecheck - Run TypeScript checks"
echo " pnpm lint - Run ESLint"
echo " pnpm format - Format code with oxfmt"
echo " pnpm format - Format code with Prettier"
echo ""
echo "Next steps:"
echo "1. Run 'pnpm dev' to start developing"

View File

@@ -42,7 +42,7 @@ jobs:
- name: Run Stylelint with auto-fix
run: pnpm stylelint:fix
- name: Run oxfmt with auto-format
- name: Run Prettier with auto-format
run: pnpm format
- name: Check for changes
@@ -60,7 +60,7 @@ jobs:
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add .
git commit -m "[automated] Apply ESLint and Oxfmt fixes"
git commit -m "[automated] Apply ESLint and Prettier fixes"
git push
- name: Final validation
@@ -80,7 +80,7 @@ jobs:
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting'
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting'
})
- name: Comment on PR about manual fix needed

View File

@@ -1,7 +1,7 @@
// This file is intentionally kept in CommonJS format (.cjs)
// to resolve compatibility issues with dependencies that require CommonJS.
// Do not convert this file to ESModule format unless all dependencies support it.
const { defineConfig } = require('@lobehub/i18n-cli')
const { defineConfig } = require('@lobehub/i18n-cli');
module.exports = defineConfig({
modelName: 'gpt-4.1',
@@ -10,19 +10,7 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: [
'zh',
'zh-TW',
'ru',
'ja',
'ko',
'fr',
'es',
'ar',
'tr',
'pt-BR',
'fa'
],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.
@@ -38,4 +26,4 @@ module.exports = defineConfig({
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
- Maintain consistency with terminology used in Persian software and design applications.
`
})
});

View File

@@ -1,20 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"tabWidth": 2,
"semi": false,
"trailingComma": "none",
"printWidth": 80,
"ignorePatterns": [
"packages/registry-types/src/comfyRegistryTypes.ts",
"src/types/generatedManagerTypes.ts",
"**/*.md",
"**/*.json",
"**/*.css",
"**/*.yaml",
"**/*.yml",
"**/*.html",
"**/*.svg",
"**/*.xml"
]
}

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
packages/registry-types/src/comfyRegistryTypes.ts
src/types/generatedManagerTypes.ts

11
.prettierrc Normal file
View File

@@ -0,0 +1,11 @@
{
"singleQuote": true,
"tabWidth": 2,
"semi": false,
"trailingComma": "none",
"printWidth": 80,
"importOrder": ["^@core/(.*)$", "<THIRD_PARTY_MODULES>", "^@/(.*)$", "^[./]"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"plugins": ["@prettier/plugin-oxc", "@trivago/prettier-plugin-sort-imports"]
}

View File

@@ -1,22 +1,25 @@
{
"recommendations": [
"antfu.vite",
"austenc.tailwind-docs",
"bradlc.vscode-tailwindcss",
"davidanson.vscode-markdownlint",
"dbaeumer.vscode-eslint",
"donjayamanne.githistory",
"eamodio.gitlens",
"esbenp.prettier-vscode",
"figma.figma-vscode-extension",
"github.vscode-github-actions",
"github.vscode-pull-request-github",
"hbenl.vscode-test-explorer",
"kisstkondoros.vscode-codemetrics",
"lokalise.i18n-ally",
"ms-playwright.playwright",
"oxc.oxc-vscode",
"sonarsource.sonarlint-vscode",
"vitest.explorer",
"vue.volar",
"wix.vscode-import-cost"
"sonarsource.sonarlint-vscode",
"deque-systems.vscode-axe-linter",
"kisstkondoros.vscode-codemetrics",
"donjayamanne.githistory",
"wix.vscode-import-cost",
"prograhammer.tslint-vue",
"antfu.vite"
]
}

View File

@@ -27,10 +27,10 @@ See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob)
- Build output: `dist/`
- Configs
- `vite.config.mts`
- `vitest.config.ts`
- `playwright.config.ts`
- `eslint.config.ts`
- `.oxfmtrc.json`
- `.oxlintrc.json`
- `.prettierrc`
- etc.
## Monorepo Architecture
@@ -46,7 +46,7 @@ The project uses **Nx** for build orchestration and task management
- `pnpm test:unit`: Run Vitest unit tests
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`)
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
- `pnpm format` / `pnpm format:check`: oxfmt
- `pnpm format` / `pnpm format:check`: Prettier
- `pnpm typecheck`: Vue TSC type checking
- `pnpm storybook`: Start Storybook development server
@@ -72,7 +72,7 @@ The project uses **Nx** for build orchestration and task management
- Composition API only
- Tailwind 4 styling
- Avoid `<style>` blocks
- Style: (see `.oxfmtrc.json`)
- Style: (see `.prettierrc`)
- Indent 2 spaces
- single quotes
- no trailing semicolons

View File

@@ -79,15 +79,48 @@ export class SubgraphSlotReference {
const node =
type === 'input' ? currentGraph.inputNode : currentGraph.outputNode
const slots =
type === 'input' ? currentGraph.inputs : currentGraph.outputs
if (!node) {
throw new Error(`No ${type} node found in subgraph`)
}
// Calculate position for next available slot
// const nextSlotIndex = slots?.length || 0
// const slotHeight = 20
// const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight
// Find last slot position
const lastSlot = slots.at(-1)
let slotX: number
let slotY: number
if (lastSlot) {
// If there are existing slots, position the new one below the last one
const gapHeight = 20
slotX = lastSlot.pos[0]
slotY = lastSlot.pos[1] + gapHeight
} else {
// No existing slots - use slotAnchorX if available, otherwise calculate from node position
if (currentGraph.slotAnchorX !== undefined) {
// The actual slot X position seems to be slotAnchorX - 10
slotX = currentGraph.slotAnchorX - 10
} else {
// Fallback: calculate from node edge
slotX =
type === 'input'
? node.pos[0] + node.size[0] - 10 // Right edge for input node
: node.pos[0] + 10 // Left edge for output node
}
// For Y position when no slots exist, use middle of node
slotY = node.pos[1] + node.size[1] / 2
}
// Convert from offset to canvas coordinates
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
node.emptySlot.pos[0],
node.emptySlot.pos[1]
slotX,
slotY
])
return canvasPos
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -189,7 +189,9 @@ test.describe('Templates', () => {
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
)
const nav = comfyPage.page.locator('header', { hasText: 'Templates' })
const nav = comfyPage.page
.locator('header')
.filter({ hasText: 'Templates' })
await comfyPage.templates.waitForMinimumCardCount(1)
await expect(templateGrid).toBeVisible()
@@ -199,8 +201,7 @@ test.describe('Templates', () => {
await comfyPage.page.setViewportSize(mobileSize)
await comfyPage.templates.waitForMinimumCardCount(1)
await expect(templateGrid).toBeVisible()
// Nav header is clipped by overflow-hidden parent at mobile size
await expect(nav).not.toBeInViewport()
await expect(nav).not.toBeVisible() // Nav should collapse at mobile size
const tabletSize = { width: 1024, height: 800 }
await comfyPage.page.setViewportSize(tabletSize)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -76,7 +76,6 @@ function getModuleName(id: string): string {
export function comfyAPIPlugin(isDev: boolean): Plugin {
return {
name: 'comfy-api-plugin',
apply: 'build',
transform(code: string, id: string) {
if (isDev) return null

View File

@@ -5,7 +5,7 @@
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "bundler",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,

View File

@@ -30,10 +30,6 @@ describe('MyStore', () => {
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
## i18n in Component Tests
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
## Mock Patterns
### Reset all mocks at once

View File

@@ -4,7 +4,9 @@ import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import { importX } from 'eslint-plugin-import-x'
import oxlint from 'eslint-plugin-oxlint'
// eslint-config-prettier disables ESLint rules that conflict with formatters (oxfmt)
// WORKAROUND: eslint-plugin-prettier causes segfault on Node.js 24 + Windows
// See: https://github.com/nodejs/node/issues/58690
// Prettier is still run separately in lint-staged, so this is safe to disable
import eslintConfigPrettier from 'eslint-config-prettier'
import { configs as storybookConfigs } from 'eslint-plugin-storybook'
import unusedImports from 'eslint-plugin-unused-imports'
@@ -109,7 +111,7 @@ export default defineConfig([
tseslintConfigs.recommended,
// Difference in typecheck on CI vs Local
pluginVue.configs['flat/recommended'],
// Disables ESLint rules that conflict with formatters
// Use eslint-config-prettier instead of eslint-plugin-prettier to avoid Node 24 segfault
eslintConfigPrettier,
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types
storybookConfigs['flat/recommended'],

25
lint-staged.config.mjs Normal file
View File

@@ -0,0 +1,25 @@
import path from 'node:path'
export default {
'tests-ui/**': () => 'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
'./**/*.js': (stagedFiles) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
...formatAndEslint(stagedFiles),
'pnpm typecheck'
]
}
function formatAndEslint(fileNames) {
// Convert absolute paths to relative paths for better ESLint resolution
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
return [
`pnpm exec prettier --cache --write ${joinedPaths}`,
`pnpm exec oxlint --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}

View File

@@ -1,9 +1,6 @@
import path from 'node:path'
export default {
'tests-ui/**': () =>
'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [
@@ -17,7 +14,7 @@ function formatAndEslint(fileNames: string[]) {
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
return [
`pnpm exec oxfmt --write ${joinedPaths}`,
`pnpm exec prettier --cache --write ${joinedPaths}`,
`pnpm exec oxlint --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.38.9",
"version": "1.38.7",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -22,8 +22,10 @@
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
"dev": "nx serve",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check": "oxfmt --check",
"format": "oxfmt --write",
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
"json-schema": "tsx scripts/generate-json-schema.ts",
"knip:no-cache": "knip",
"knip": "knip --cache",
@@ -61,12 +63,14 @@
"@nx/vite": "catalog:",
"@pinia/testing": "catalog:",
"@playwright/test": "catalog:",
"@prettier/plugin-oxc": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@storybook/addon-docs": "catalog:",
"@storybook/addon-mcp": "catalog:",
"@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:",
"@tailwindcss/vite": "catalog:",
"@trivago/prettier-plugin-sort-imports": "catalog:",
"@types/fs-extra": "catalog:",
"@types/jsdom": "catalog:",
"@types/node": "catalog:",
@@ -97,11 +101,11 @@
"markdown-table": "catalog:",
"mixpanel-browser": "catalog:",
"nx": "catalog:",
"oxfmt": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
"picocolors": "catalog:",
"postcss-html": "catalog:",
"prettier": "catalog:",
"pretty-bytes": "catalog:",
"rollup-plugin-visualizer": "catalog:",
"storybook": "catalog:",

323
pnpm-lock.yaml generated
View File

@@ -48,6 +48,9 @@ catalogs:
'@playwright/test':
specifier: ^1.57.0
version: 1.57.0
'@prettier/plugin-oxc':
specifier: ^0.1.3
version: 0.1.3
'@primeuix/forms':
specifier: 0.0.2
version: 0.0.2
@@ -93,6 +96,9 @@ catalogs:
'@tailwindcss/vite':
specifier: ^4.1.12
version: 4.1.12
'@trivago/prettier-plugin-sort-imports':
specifier: ^5.2.0
version: 5.2.2
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
@@ -201,9 +207,6 @@ catalogs:
nx:
specifier: 22.2.6
version: 22.2.6
oxfmt:
specifier: ^0.26.0
version: 0.26.0
oxlint:
specifier: ^1.33.0
version: 1.33.0
@@ -219,6 +222,9 @@ catalogs:
postcss-html:
specifier: ^1.8.0
version: 1.8.0
prettier:
specifier: ^3.7.4
version: 3.7.4
pretty-bytes:
specifier: ^7.1.0
version: 7.1.0
@@ -534,6 +540,9 @@ importers:
'@playwright/test':
specifier: 'catalog:'
version: 1.57.0
'@prettier/plugin-oxc':
specifier: 'catalog:'
version: 0.1.3
'@sentry/vite-plugin':
specifier: 'catalog:'
version: 4.6.0
@@ -552,6 +561,9 @@ importers:
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.1.12(vite@8.0.0-beta.8(@types/node@24.10.4)(esbuild@0.27.1)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@trivago/prettier-plugin-sort-imports':
specifier: 'catalog:'
version: 5.2.2(@vue/compiler-sfc@3.5.25)(prettier@3.7.4)
'@types/fs-extra':
specifier: 'catalog:'
version: 11.0.4
@@ -642,9 +654,6 @@ importers:
nx:
specifier: 'catalog:'
version: 22.2.6
oxfmt:
specifier: 'catalog:'
version: 0.26.0
oxlint:
specifier: 'catalog:'
version: 1.33.0(oxlint-tsgolint@0.9.1)
@@ -657,6 +666,9 @@ importers:
postcss-html:
specifier: 'catalog:'
version: 1.8.0
prettier:
specifier: 'catalog:'
version: 3.7.4
pretty-bytes:
specifier: 'catalog:'
version: 7.1.0
@@ -2505,6 +2517,95 @@ packages:
'@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
'@oxc-parser/binding-android-arm64@0.99.0':
resolution: {integrity: sha512-V4jhmKXgQQdRnm73F+r3ZY4pUEsijQeSraFeaCGng7abSNJGs76X6l82wHnmjLGFAeY00LWtjcELs7ZmbJ9+lA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxc-parser/binding-darwin-arm64@0.99.0':
resolution: {integrity: sha512-Rp41nf9zD5FyLZciS9l1GfK8PhYqrD5kEGxyTOA2esTLeAy37rZxetG2E3xteEolAkeb2WDkVrlxPtibeAncMg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxc-parser/binding-darwin-x64@0.99.0':
resolution: {integrity: sha512-WVonp40fPPxo5Gs0POTI57iEFv485TvNKOHMwZRhigwZRhZY2accEAkYIhei9eswF4HN5B44Wybkz7Gd1Qr/5Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxc-parser/binding-freebsd-x64@0.99.0':
resolution: {integrity: sha512-H30bjOOttPmG54gAqu6+HzbLEzuNOYO2jZYrIq4At+NtLJwvNhXz28Hf5iEAFZIH/4hMpLkM4VN7uc+5UlNW3Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxc-parser/binding-linux-arm-gnueabihf@0.99.0':
resolution: {integrity: sha512-0Z/Th0SYqzSRDPs6tk5lQdW0i73UCupnim3dgq2oW0//UdLonV/5wIZCArfKGC7w9y4h8TxgXpgtIyD1kKzzlQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm-musleabihf@0.99.0':
resolution: {integrity: sha512-xo0wqNd5bpbzQVNpAIFbHk1xa+SaS/FGBABCd942SRTnrpxl6GeDj/s1BFaGcTl8MlwlKVMwOcyKrw/2Kdfquw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm64-gnu@0.99.0':
resolution: {integrity: sha512-u26I6LKoLTPTd4Fcpr0aoAtjnGf5/ulMllo+QUiBhupgbVCAlaj4RyXH/mvcjcsl2bVBv9E/gYJZz2JjxQWXBA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@oxc-parser/binding-linux-arm64-musl@0.99.0':
resolution: {integrity: sha512-qhftDo2D37SqCEl3ZTa367NqWSZNb1Ddp34CTmShLKFrnKdNiUn55RdokLnHtf1AL5ssaQlYDwBECX7XiBWOhw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@oxc-parser/binding-linux-riscv64-gnu@0.99.0':
resolution: {integrity: sha512-zxn/xkf519f12FKkpL5XwJipsylfSSnm36h6c1zBDTz4fbIDMGyIhHfWfwM7uUmHo9Aqw1pLxFpY39Etv398+Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
'@oxc-parser/binding-linux-s390x-gnu@0.99.0':
resolution: {integrity: sha512-Y1eSDKDS5E4IVC7Oxw+NbYAKRmJPMJTIjW+9xOWwteDHkFqpocKe0USxog+Q1uhzalD9M0p9eXWEWdGQCMDBMQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
'@oxc-parser/binding-linux-x64-gnu@0.99.0':
resolution: {integrity: sha512-YVJMfk5cFWB8i2/nIrbk6n15bFkMHqWnMIWkVx7r2KwpTxHyFMfu2IpeVKo1ITDSmt5nBrGdLHD36QRlu2nDLg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@oxc-parser/binding-linux-x64-musl@0.99.0':
resolution: {integrity: sha512-2+SDPrie5f90A1b9EirtVggOgsqtsYU5raZwkDYKyS1uvJzjqHCDhG/f4TwQxHmIc5YkczdQfwvN91lwmjsKYQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@oxc-parser/binding-wasm32-wasi@0.99.0':
resolution: {integrity: sha512-DKA4j0QerUWSMADziLM5sAyM7V53Fj95CV9SjP77bPfEfT7MnvFKnneaRMqPK1cpzjAGiQF52OBUIKyk0dwOQA==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@oxc-parser/binding-win32-arm64-msvc@0.99.0':
resolution: {integrity: sha512-EaB3AvsxqdNUhh9FOoAxRZ2L4PCRwDlDb//QXItwyOJrX7XS+uGK9B1KEUV4FZ/7rDhHsWieLt5e07wl2Ti5AQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxc-parser/binding-win32-x64-msvc@0.99.0':
resolution: {integrity: sha512-sJN1Q8h7ggFOyDn0zsHaXbP/MklAVUvhrbq0LA46Qum686P3SZQHjbATqJn9yaVEvaSKXCshgl0vQ1gWkGgpcQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxc-project/runtime@0.108.0':
resolution: {integrity: sha512-J1cESY4anMO4i9KtCPmCfQAzAR00Uw4SWsDPFP10CIwDMugkh34UrTKByuYKuPaHy0XAk8LlJiZJq2OLMfbuIQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2512,6 +2613,9 @@ packages:
'@oxc-project/types@0.108.0':
resolution: {integrity: sha512-7lf13b2IA/kZO6xgnIZA88sq3vwrxWk+2vxf6cc+omwYCRTiA5e63Beqf3fz/v8jEviChWWmFYBwzfSeyrsj7Q==}
'@oxc-project/types@0.99.0':
resolution: {integrity: sha512-LLDEhXB7g1m5J+woRSgfKsFPS3LhR9xRhTeIoEBm5WrkwMxn6eZ0Ld0c0K5eHB57ChZX6I3uSmmLjZ8pcjlRcw==}
'@oxc-resolver/binding-android-arm-eabi@11.15.0':
resolution: {integrity: sha512-Q+lWuFfq7whNelNJIP1dhXaVz4zO9Tu77GcQHyxDWh3MaCoO2Bisphgzmsh4ZoUe2zIchQh6OvQL99GlWHg9Tw==}
cpu: [arm]
@@ -2612,46 +2716,6 @@ packages:
cpu: [x64]
os: [win32]
'@oxfmt/darwin-arm64@0.26.0':
resolution: {integrity: sha512-AAGc+8CffkiWeVgtWf4dPfQwHEE5c/j/8NWH7VGVxxJRCZFdmWcqCXprvL2H6qZFewvDLrFbuSPRCqYCpYGaTQ==}
cpu: [arm64]
os: [darwin]
'@oxfmt/darwin-x64@0.26.0':
resolution: {integrity: sha512-xFx5ijCTjw577wJvFlZEMmKDnp3HSCcbYdCsLRmC5i3TZZiDe9DEYh3P46uqhzj8BkEw1Vm1ZCWdl48aEYAzvQ==}
cpu: [x64]
os: [darwin]
'@oxfmt/linux-arm64-gnu@0.26.0':
resolution: {integrity: sha512-GubkQeQT5d3B/Jx/IiR7NMkSmXrCZcVI0BPh1i7mpFi8HgD1hQ/LbhiBKAMsMqs5bbugdQOgBEl8bOhe8JhW1g==}
cpu: [arm64]
os: [linux]
'@oxfmt/linux-arm64-musl@0.26.0':
resolution: {integrity: sha512-OEypUwK69bFPj+aa3/LYCnlIUPgoOLu//WNcriwpnWNmt47808Ht7RJSg+MNK8a7pSZHpXJ5/E6CRK/OTwFdaQ==}
cpu: [arm64]
os: [linux]
'@oxfmt/linux-x64-gnu@0.26.0':
resolution: {integrity: sha512-xO6iEW2bC6ZHyOTPmPWrg/nM6xgzyRPaS84rATy6F8d79wz69LdRdJ3l/PXlkqhi7XoxhvX4ExysA0Nf10ZZEQ==}
cpu: [x64]
os: [linux]
'@oxfmt/linux-x64-musl@0.26.0':
resolution: {integrity: sha512-Z3KuZFC+MIuAyFCXBHY71kCsdRq1ulbsbzTe71v+hrEv7zVBn6yzql+/AZcgfIaKzWO9OXNuz5WWLWDmVALwow==}
cpu: [x64]
os: [linux]
'@oxfmt/win32-arm64@0.26.0':
resolution: {integrity: sha512-3zRbqwVWK1mDhRhTknlQFpRFL9GhEB5GfU6U7wawnuEwpvi39q91kJ+SRJvJnhyPCARkjZBd1V8XnweN5IFd1g==}
cpu: [arm64]
os: [win32]
'@oxfmt/win32-x64@0.26.0':
resolution: {integrity: sha512-m8TfIljU22i9UEIkD+slGPifTFeaCwIUfxszN3E6ABWP1KQbtwSw9Ak0TdoikibvukF/dtbeyG3WW63jv9DnEg==}
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.9.1':
resolution: {integrity: sha512-vk+8kChWqN+F+QUOvp4/6jDTlDCzXPgYGkxdi6EOUSOmCP1ix0uYOlIi/ytH2imXmC8YfPgLR/1BhqbsuDKuew==}
cpu: [arm64]
@@ -2760,6 +2824,10 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@prettier/plugin-oxc@0.1.3':
resolution: {integrity: sha512-aABz3zIRilpWMekbt1FL1JVBQrQLR8L4Td2SRctECrWSsXGTNn/G1BqNSKCdbvQS1LWstAXfqcXzDki7GAAJyg==}
engines: {node: '>=14'}
'@primeuix/forms@0.0.2':
resolution: {integrity: sha512-DpecPQd/Qf/kav4LKCaIeGuT3AkwhJzuHCkLANTVlN/zBvo8KIj3OZHsCkm0zlIMVVnaJdtx1ULNlRQdudef+A==}
engines: {node: '>=12.11.0'}
@@ -3503,6 +3571,22 @@ packages:
'@tmcp/auth':
optional: true
'@trivago/prettier-plugin-sort-imports@5.2.2':
resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==}
engines: {node: '>18.12'}
peerDependencies:
'@vue/compiler-sfc': 3.x
prettier: 2.x - 3.x
prettier-plugin-svelte: 3.x
svelte: 4.x || 5.x
peerDependenciesMeta:
'@vue/compiler-sfc':
optional: true
prettier-plugin-svelte:
optional: true
svelte:
optional: true
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
@@ -5955,6 +6039,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
javascript-natural-sort@0.7.1:
resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
jest-diff@30.2.0:
resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -6802,14 +6889,13 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
oxc-parser@0.99.0:
resolution: {integrity: sha512-MpS1lbd2vR0NZn1v0drpgu7RUFu3x9Rd0kxExObZc2+F+DIrV0BOMval/RO3BYGwssIOerII6iS8EbbpCCZQpQ==}
engines: {node: ^20.19.0 || >=22.12.0}
oxc-resolver@11.15.0:
resolution: {integrity: sha512-Hk2J8QMYwmIO9XTCUiOH00+Xk2/+aBxRUnhrSlANDyCnLYc32R1WSIq1sU2yEdlqd53FfMpPEpnBYIKQMzliJw==}
oxfmt@0.26.0:
resolution: {integrity: sha512-UDD1wFNwfeorMm2ZY0xy1KRAAvJ5NjKBfbDmiMwGP7baEHTq65cYpC0aPP+BGHc8weXUbSZaK8MdGyvuRUvS4Q==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
oxlint-tsgolint@0.9.1:
resolution: {integrity: sha512-w1lIvUDkkiAPFyo268SFGrdh1LQ3Lcs1XShES7I4X75TliQA0os5XJ5hNZ4lYsSevqcofgEtq4xq7rBumv69iQ==}
hasBin: true
@@ -7710,10 +7796,6 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinypool@2.0.0:
resolution: {integrity: sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg==}
engines: {node: ^20.0.0 || >=22.0.0}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
@@ -10595,10 +10677,59 @@ snapshots:
'@one-ini/wasm@0.1.1': {}
'@oxc-parser/binding-android-arm64@0.99.0':
optional: true
'@oxc-parser/binding-darwin-arm64@0.99.0':
optional: true
'@oxc-parser/binding-darwin-x64@0.99.0':
optional: true
'@oxc-parser/binding-freebsd-x64@0.99.0':
optional: true
'@oxc-parser/binding-linux-arm-gnueabihf@0.99.0':
optional: true
'@oxc-parser/binding-linux-arm-musleabihf@0.99.0':
optional: true
'@oxc-parser/binding-linux-arm64-gnu@0.99.0':
optional: true
'@oxc-parser/binding-linux-arm64-musl@0.99.0':
optional: true
'@oxc-parser/binding-linux-riscv64-gnu@0.99.0':
optional: true
'@oxc-parser/binding-linux-s390x-gnu@0.99.0':
optional: true
'@oxc-parser/binding-linux-x64-gnu@0.99.0':
optional: true
'@oxc-parser/binding-linux-x64-musl@0.99.0':
optional: true
'@oxc-parser/binding-wasm32-wasi@0.99.0':
dependencies:
'@napi-rs/wasm-runtime': 1.1.1
optional: true
'@oxc-parser/binding-win32-arm64-msvc@0.99.0':
optional: true
'@oxc-parser/binding-win32-x64-msvc@0.99.0':
optional: true
'@oxc-project/runtime@0.108.0': {}
'@oxc-project/types@0.108.0': {}
'@oxc-project/types@0.99.0': {}
'@oxc-resolver/binding-android-arm-eabi@11.15.0':
optional: true
@@ -10661,30 +10792,6 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.15.0':
optional: true
'@oxfmt/darwin-arm64@0.26.0':
optional: true
'@oxfmt/darwin-x64@0.26.0':
optional: true
'@oxfmt/linux-arm64-gnu@0.26.0':
optional: true
'@oxfmt/linux-arm64-musl@0.26.0':
optional: true
'@oxfmt/linux-x64-gnu@0.26.0':
optional: true
'@oxfmt/linux-x64-musl@0.26.0':
optional: true
'@oxfmt/win32-arm64@0.26.0':
optional: true
'@oxfmt/win32-x64@0.26.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.9.1':
optional: true
@@ -10759,6 +10866,10 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@prettier/plugin-oxc@0.1.3':
dependencies:
oxc-parser: 0.99.0
'@primeuix/forms@0.0.2':
dependencies:
'@primeuix/utils': 0.3.2
@@ -11474,6 +11585,20 @@ snapshots:
esm-env: 1.2.2
tmcp: 1.19.0(typescript@5.9.3)
'@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.25)(prettier@3.7.4)':
dependencies:
'@babel/generator': 7.28.5
'@babel/parser': 7.28.5
'@babel/traverse': 7.28.5
'@babel/types': 7.28.5
javascript-natural-sort: 0.7.1
lodash: 4.17.21
prettier: 3.7.4
optionalDependencies:
'@vue/compiler-sfc': 3.5.25
transitivePeerDependencies:
- supports-color
'@tweenjs/tween.js@23.1.3': {}
'@tybys/wasm-util@0.10.1':
@@ -14306,6 +14431,8 @@ snapshots:
filelist: 1.0.4
minimatch: 3.1.2
javascript-natural-sort@0.7.1: {}
jest-diff@30.2.0:
dependencies:
'@jest/diff-sequences': 30.0.1
@@ -15404,6 +15531,26 @@ snapshots:
safe-push-apply: 1.0.0
optional: true
oxc-parser@0.99.0:
dependencies:
'@oxc-project/types': 0.99.0
optionalDependencies:
'@oxc-parser/binding-android-arm64': 0.99.0
'@oxc-parser/binding-darwin-arm64': 0.99.0
'@oxc-parser/binding-darwin-x64': 0.99.0
'@oxc-parser/binding-freebsd-x64': 0.99.0
'@oxc-parser/binding-linux-arm-gnueabihf': 0.99.0
'@oxc-parser/binding-linux-arm-musleabihf': 0.99.0
'@oxc-parser/binding-linux-arm64-gnu': 0.99.0
'@oxc-parser/binding-linux-arm64-musl': 0.99.0
'@oxc-parser/binding-linux-riscv64-gnu': 0.99.0
'@oxc-parser/binding-linux-s390x-gnu': 0.99.0
'@oxc-parser/binding-linux-x64-gnu': 0.99.0
'@oxc-parser/binding-linux-x64-musl': 0.99.0
'@oxc-parser/binding-wasm32-wasi': 0.99.0
'@oxc-parser/binding-win32-arm64-msvc': 0.99.0
'@oxc-parser/binding-win32-x64-msvc': 0.99.0
oxc-resolver@11.15.0:
optionalDependencies:
'@oxc-resolver/binding-android-arm-eabi': 11.15.0
@@ -15427,19 +15574,6 @@ snapshots:
'@oxc-resolver/binding-win32-ia32-msvc': 11.15.0
'@oxc-resolver/binding-win32-x64-msvc': 11.15.0
oxfmt@0.26.0:
dependencies:
tinypool: 2.0.0
optionalDependencies:
'@oxfmt/darwin-arm64': 0.26.0
'@oxfmt/darwin-x64': 0.26.0
'@oxfmt/linux-arm64-gnu': 0.26.0
'@oxfmt/linux-arm64-musl': 0.26.0
'@oxfmt/linux-x64-gnu': 0.26.0
'@oxfmt/linux-x64-musl': 0.26.0
'@oxfmt/win32-arm64': 0.26.0
'@oxfmt/win32-x64': 0.26.0
oxlint-tsgolint@0.9.1:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.9.1
@@ -15620,8 +15754,7 @@ snapshots:
prelude-ls@1.2.1: {}
prettier@3.7.4:
optional: true
prettier@3.7.4: {}
pretty-bytes@7.1.0: {}
@@ -16584,8 +16717,6 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tinypool@2.0.0: {}
tinyrainbow@2.0.0: {}
tinyrainbow@3.0.3: {}

View File

@@ -17,6 +17,7 @@ catalog:
'@nx/vite': 22.2.6
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.57.0
'@prettier/plugin-oxc': ^0.1.3
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
'@primeuix/utils': ^0.3.2
@@ -32,6 +33,7 @@ catalog:
'@storybook/vue3': ^10.1.9
'@storybook/vue3-vite': ^10.1.9
'@tailwindcss/vite': ^4.1.12
'@trivago/prettier-plugin-sort-imports': ^5.2.0
'@types/fs-extra': ^11.0.4
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
@@ -68,12 +70,12 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.2.6
oxfmt: ^0.26.0
oxlint: ^1.33.0
oxlint-tsgolint: ^0.9.1
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0
prettier: ^3.7.4
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5

View File

@@ -1,17 +1,12 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick } from 'vue'
import { computed } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import type {
JobListItem,
JobStatus
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { isElectron } from '@/utils/envUtil'
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
@@ -41,8 +36,7 @@ function createWrapper() {
sideToolbar: {
queueProgressOverlay: {
viewJobHistory: 'View job history',
expandCollapsedQueue: 'Expand collapsed queue',
activeJobsShort: '{count} active | {count} active'
expandCollapsedQueue: 'Expand collapsed queue'
}
}
}
@@ -65,19 +59,6 @@ function createWrapper() {
})
}
function createJob(id: string, status: JobStatus): JobListItem {
return {
id,
status,
create_time: 0,
priority: 0
}
}
function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl(createJob(id, status))
}
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
@@ -119,19 +100,4 @@ describe('TopMenuSection', () => {
})
})
})
it('shows the active jobs label with the current count', async () => {
const wrapper = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
queueStore.runningTasks = [
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
]
await nextTick()
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
expect(queueButton.text()).toContain('3 active')
})
})

View File

@@ -44,17 +44,19 @@
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
size="icon"
:aria-pressed="isQueueOverlayExpanded"
class="px-3"
data-testid="queue-overlay-toggle"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }}
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground"
>
{{ queuedCount }}
</span>
</Button>
<CurrentUserButton
@@ -115,26 +117,18 @@ const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t, n } = useI18n()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const queueUIStore = useQueueUIStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobsShort',
{ count: n(count) },
count
)
})
const queuedCount = computed(() => queueStore.pendingTasks.length)
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)

View File

@@ -51,7 +51,7 @@ describe('EditableText', () => {
isEditing: true
})
await wrapper.findComponent(InputText).setValue('New Text')
await wrapper.findComponent(InputText).trigger('keydown.enter')
await wrapper.findComponent(InputText).trigger('keyup.enter')
// Blur event should have been triggered
expect(wrapper.findComponent(InputText).element).not.toBe(
document.activeElement
@@ -79,7 +79,7 @@ describe('EditableText', () => {
await wrapper.findComponent(InputText).setValue('Modified Text')
// Press escape
await wrapper.findComponent(InputText).trigger('keydown.escape')
await wrapper.findComponent(InputText).trigger('keyup.escape')
// Should emit cancel event
expect(wrapper.emitted('cancel')).toBeTruthy()
@@ -103,7 +103,7 @@ describe('EditableText', () => {
await wrapper.findComponent(InputText).setValue('Modified Text')
// Press escape (which triggers blur internally)
await wrapper.findComponent(InputText).trigger('keydown.escape')
await wrapper.findComponent(InputText).trigger('keyup.escape')
// Manually trigger blur to simulate the blur that happens after escape
await wrapper.findComponent(InputText).trigger('blur')
@@ -120,7 +120,7 @@ describe('EditableText', () => {
isEditing: true
})
await enterWrapper.findComponent(InputText).setValue('Saved Text')
await enterWrapper.findComponent(InputText).trigger('keydown.enter')
await enterWrapper.findComponent(InputText).trigger('keyup.enter')
// Trigger blur that happens after enter
await enterWrapper.findComponent(InputText).trigger('blur')
expect(enterWrapper.emitted('edit')).toBeTruthy()
@@ -133,7 +133,7 @@ describe('EditableText', () => {
isEditing: true
})
await escapeWrapper.findComponent(InputText).setValue('Cancelled Text')
await escapeWrapper.findComponent(InputText).trigger('keydown.escape')
await escapeWrapper.findComponent(InputText).trigger('keyup.escape')
expect(escapeWrapper.emitted('cancel')).toBeTruthy()
expect(escapeWrapper.emitted('edit')).toBeFalsy()
})

View File

@@ -3,7 +3,7 @@
<span v-if="!isEditing">
{{ modelValue }}
</span>
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
<!-- Avoid double triggering finishEditing event when keyup.enter is triggered -->
<InputText
v-else
ref="inputRef"
@@ -18,8 +18,8 @@
...inputAttrs
}
}"
@keydown.enter.capture.stop="blurInputElement"
@keydown.escape.capture.stop="cancelEditing"
@keyup.enter.capture.stop="blurInputElement"
@keyup.escape.stop="cancelEditing"
@click.stop
@contextmenu.stop
@pointerdown.stop.capture

View File

@@ -1,95 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StatusBadge from './StatusBadge.vue'
const meta = {
title: 'Common/StatusBadge',
component: StatusBadge,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
severity: {
control: 'select',
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
},
variant: {
control: 'select',
options: ['label', 'dot', 'circle']
}
},
args: {
label: 'Status',
severity: 'default'
}
} satisfies Meta<typeof StatusBadge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Failed: Story = {
args: {
label: 'Failed',
severity: 'danger'
}
}
export const Finished: Story = {
args: {
label: 'Finished',
severity: 'contrast'
}
}
export const Dot: Story = {
args: {
label: undefined,
variant: 'dot',
severity: 'danger'
}
}
export const Circle: Story = {
args: {
label: '3',
variant: 'circle'
}
}
export const AllSeverities: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-2">
<StatusBadge label="Default" severity="default" />
<StatusBadge label="Secondary" severity="secondary" />
<StatusBadge label="Warn" severity="warn" />
<StatusBadge label="Danger" severity="danger" />
<StatusBadge label="Contrast" severity="contrast" />
</div>
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-4">
<div class="flex flex-col items-center gap-1">
<StatusBadge label="Label" variant="label" />
<span class="text-xs text-muted">label</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge variant="dot" severity="danger" />
<span class="text-xs text-muted">dot</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge label="5" variant="circle" />
<span class="text-xs text-muted">circle</span>
</div>
</div>
`
})
}

View File

@@ -1,27 +1,30 @@
<script setup lang="ts">
import { statusBadgeVariants } from './statusBadge.variants'
import type { StatusBadgeVariants } from './statusBadge.variants'
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
const {
label,
severity = 'default',
variant
} = defineProps<{
label?: string | number
severity?: StatusBadgeVariants['severity']
variant?: StatusBadgeVariants['variant']
const { label, severity = 'default' } = defineProps<{
label: string
severity?: Severity
}>()
function badgeClasses(sev: Severity): string {
const baseClasses =
'inline-flex h-3.5 items-center justify-center rounded-full px-1 text-xxxs font-semibold uppercase'
switch (sev) {
case 'danger':
return `${baseClasses} bg-destructive-background text-white`
case 'contrast':
return `${baseClasses} bg-base-foreground text-base-background`
case 'warn':
return `${baseClasses} bg-warning-background text-base-background`
case 'secondary':
return `${baseClasses} bg-secondary-background text-base-foreground`
default:
return `${baseClasses} bg-primary-background text-base-foreground`
}
}
</script>
<template>
<span
:class="
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
"
>
{{ label }}
</span>
<span :class="badgeClasses(severity)">{{ label }}</span>
</template>

View File

@@ -1,20 +1,16 @@
<template>
<div
ref="container"
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
>
<div :style="topSpacerStyle" />
<div :style="mergedGridStyle">
<div
v-for="item in renderedItems"
:key="item.key"
class="transition-[width] duration-150 ease-out"
data-virtual-grid-item
>
<div ref="container" class="scroll-container">
<div :style="{ height: `${(state.start / cols) * itemHeight}px` }" />
<div :style="gridStyle">
<div v-for="item in renderedItems" :key="item.key" data-virtual-grid-item>
<slot name="item" :item="item" />
</div>
</div>
<div :style="bottomSpacerStyle" />
<div
:style="{
height: `${((items.length - state.end) / cols) * itemHeight}px`
}"
/>
</div>
</template>
@@ -32,22 +28,19 @@ type GridState = {
const {
items,
gridStyle,
bufferRows = 1,
scrollThrottle = 64,
resizeDebounce = 64,
defaultItemHeight = 200,
defaultItemWidth = 200,
maxColumns = Infinity
defaultItemWidth = 200
} = defineProps<{
items: (T & { key: string })[]
gridStyle: CSSProperties
gridStyle: Partial<CSSProperties>
bufferRows?: number
scrollThrottle?: number
resizeDebounce?: number
defaultItemHeight?: number
defaultItemWidth?: number
maxColumns?: number
}>()
const emit = defineEmits<{
@@ -66,18 +59,7 @@ const { y: scrollY } = useScroll(container, {
eventListenerOptions: { passive: true }
})
const cols = computed(() =>
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
)
const mergedGridStyle = computed<CSSProperties>(() => {
if (maxColumns === Infinity) return gridStyle
return {
...gridStyle,
gridTemplateColumns: `repeat(${maxColumns}, minmax(0, 1fr))`
}
})
const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1)
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
const isValidGrid = computed(() => height.value && width.value && items?.length)
@@ -101,16 +83,6 @@ 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`
}
const topSpacerStyle = computed<CSSProperties>(() => ({
height: rowsToHeight(state.value.start)
}))
const bottomSpacerStyle = computed<CSSProperties>(() => ({
height: rowsToHeight(items.length - state.value.end)
}))
whenever(
() => state.value.isNearEnd,
() => {
@@ -137,6 +109,15 @@ const onResize = debounce(updateItemSize, resizeDebounce)
watch([width, height], onResize, { flush: 'post' })
whenever(() => items, updateItemSize, { flush: 'post' })
onBeforeUnmount(() => {
onResize.cancel()
onResize.cancel() // Clear pending debounced calls
})
</script>
<style scoped>
.scroll-container {
height: 100%;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--dialog-surface) transparent;
}
</style>

View File

@@ -1,26 +0,0 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const statusBadgeVariants = cva({
base: 'inline-flex items-center justify-center rounded-full',
variants: {
severity: {
default: 'bg-primary-background text-base-foreground',
secondary: 'bg-secondary-background text-base-foreground',
warn: 'bg-warning-background text-base-background',
danger: 'bg-destructive-background text-white',
contrast: 'bg-base-foreground text-base-background'
},
variant: {
label: 'h-3.5 px-1 text-xxxs font-semibold uppercase',
dot: 'size-2',
circle: 'size-3.5 text-xxxs font-semibold'
}
},
defaultVariants: {
severity: 'default',
variant: 'label'
}
})
export type StatusBadgeVariants = VariantProps<typeof statusBadgeVariants>

View File

@@ -200,13 +200,7 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
if (item.state === 'running' || item.state === 'initialization') {
// Running/initializing jobs: interrupt execution
// Cloud backend uses deleteItem, local uses interrupt
if (isCloud) {
await api.deleteItem('queue', promptId)
} else {
await api.interrupt(promptId)
}
executionStore.clearInitializationByPromptId(promptId)
await api.interrupt(promptId)
await queueStore.update()
} else if (item.state === 'pending') {
// Pending jobs: remove from queue
@@ -274,15 +268,7 @@ const inspectJobAsset = wrapWithErrorHandlingAsync(
)
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
// Capture pending promptIds before clearing
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
// Clear initialization state for removed prompts
executionStore.clearInitializationByPromptIds(pendingPromptIds)
})
const interruptAll = wrapWithErrorHandlingAsync(async () => {
@@ -298,14 +284,10 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
// on cloud to ensure we cancel the workflow the user clicked.
if (isCloud) {
await Promise.all(promptIds.map((id) => api.deleteItem('queue', id)))
executionStore.clearInitializationByPromptIds(promptIds)
await queueStore.update()
return
}
await Promise.all(promptIds.map((id) => api.interrupt(id)))
executionStore.clearInitializationByPromptIds(promptIds)
await queueStore.update()
})
const showClearHistoryDialog = () => {

View File

@@ -5,32 +5,25 @@ import { cn } from '@/utils/tailwindUtil'
import TransitionCollapse from './TransitionCollapse.vue'
const {
disabled,
label,
enableEmptyState,
tooltip,
class: className
} = defineProps<{
const props = defineProps<{
disabled?: boolean
label?: string
enableEmptyState?: boolean
tooltip?: string
class?: string
}>()
const isCollapse = defineModel<boolean>('collapse', { default: false })
const isExpanded = computed(() => !isCollapse.value && !disabled)
const isExpanded = computed(() => !isCollapse.value && !props.disabled)
const tooltipConfig = computed(() => {
if (!tooltip) return undefined
return { value: tooltip, showDelay: 1000 }
if (!props.tooltip) return undefined
return { value: props.tooltip, showDelay: 1000 }
})
</script>
<template>
<div :class="cn('flex flex-col bg-comfy-menu-bg', className)">
<div class="flex flex-col bg-comfy-menu-bg">
<div
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl bg-inherit"
>

View File

@@ -25,31 +25,13 @@ const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes.map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden || w.options?.advanced)
)
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
.map((widget) => ({ node, widget }))
return { widgets: shownWidgets, node }
})
})
const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes
.map((node) => {
const { widgets = [] } = node
const advancedWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
)
.map((widget) => ({ node, widget }))
return { widgets: advancedWidgets, node }
})
.filter(({ widgets }) => widgets.length > 0)
})
const isMultipleNodesSelected = computed(
() => widgetsSectionDataList.value.length > 1
)
@@ -74,12 +56,6 @@ const label = computed(() => {
: t('rightSidePanel.inputsNone')
: undefined // SectionWidgets display node titles by default
})
const advancedLabel = computed(() => {
return !mustShowNodeTitle && !isMultipleNodesSelected.value
? t('rightSidePanel.advancedInputs')
: undefined // SectionWidgets display node titles by default
})
</script>
<template>
@@ -117,16 +93,4 @@ const advancedLabel = computed(() => {
class="border-b border-interface-stroke"
/>
</TransitionGroup>
<template v-if="advancedWidgetsSectionDataList.length > 0 && !isSearching">
<SectionWidgets
v-for="{ widgets, node } in advancedWidgetsSectionDataList"
:key="`advanced-${node.id}`"
:collapse="true"
:node
:label="advancedLabel"
:widgets
:show-locate-button="isMultipleNodesSelected"
class="border-b border-interface-stroke"
/>
</template>
</template>

View File

@@ -43,7 +43,7 @@ const favoritedWidgetsStore = useFavoritedWidgetsStore()
const isEditing = ref(false)
const widgetComponent = computed(() => {
const component = getComponent(widget.type)
const component = getComponent(widget.type, widget.name)
return component || WidgetLegacy
})

View File

@@ -5,7 +5,6 @@
:label="$t('menu.help')"
:tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''"
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
:is-small="isSmall"
@click="toggleHelpCenter"
/>
@@ -22,3 +21,24 @@ defineProps<{
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
</script>
<style scoped>
:deep(.p-badge) {
background: #ff3b30;
color: #ff3b30;
min-width: 8px;
height: 8px;
padding: 0;
border-radius: 9999px;
font-size: 0;
margin-top: 4px;
margin-right: 4px;
border: none;
outline: none;
box-shadow: none;
}
:deep(.p-badge.p-badge-dot) {
width: 8px !important;
}
</style>

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import OverlayBadge from 'primevue/overlaybadge'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -32,7 +33,8 @@ describe('SidebarIcon', () => {
return mount(SidebarIcon, {
global: {
plugins: [PrimeVue, i18n],
directives: { tooltip: Tooltip }
directives: { tooltip: Tooltip },
components: { OverlayBadge }
},
props: { ...exampleProps, ...props },
...options
@@ -52,9 +54,9 @@ describe('SidebarIcon', () => {
it('creates badge when iconBadge prop is set', () => {
const badge = '2'
const wrapper = mountSidebarIcon({ iconBadge: badge })
const badgeEl = wrapper.find('.sidebar-icon-badge')
const badgeEl = wrapper.findComponent(OverlayBadge)
expect(badgeEl.exists()).toBe(true)
expect(badgeEl.text()).toEqual(badge)
expect(badgeEl.find('.p-badge').text()).toEqual(badge)
})
it('shows tooltip on hover', async () => {

View File

@@ -17,28 +17,22 @@
>
<div class="side-bar-button-content">
<slot name="icon">
<div class="sidebar-icon-wrapper relative">
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<i
v-if="typeof icon === 'string'"
:class="icon + ' side-bar-button-icon'"
/>
<component
:is="icon"
v-else-if="typeof icon === 'object'"
class="side-bar-button-icon"
/>
<span
v-if="shouldShowBadge"
:class="
cn(
'sidebar-icon-badge absolute min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground',
badgeClass || '-top-1 -right-1'
)
"
>
{{ overlayValue }}
</span>
</div>
<component :is="icon" v-else class="side-bar-button-icon" />
</OverlayBadge>
<i
v-else-if="typeof icon === 'string'"
:class="icon + ' side-bar-button-icon'"
/>
<component
:is="icon"
v-else-if="typeof icon === 'object'"
class="side-bar-button-icon"
/>
</slot>
<span v-if="label && !isSmall" class="side-bar-button-label">{{
t(label)
@@ -48,6 +42,7 @@
</template>
<script setup lang="ts">
import OverlayBadge from 'primevue/overlaybadge'
import { computed } from 'vue'
import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -62,7 +57,6 @@ const {
tooltip = '',
tooltipSuffix = '',
iconBadge = '',
badgeClass = '',
label = '',
isSmall = false
} = defineProps<{
@@ -71,7 +65,6 @@ const {
tooltip?: string
tooltipSuffix?: string
iconBadge?: string | (() => string | null)
badgeClass?: string
label?: string
isSmall?: boolean
}>()

View File

@@ -1,109 +0,0 @@
<template>
<div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
<ActiveJobCard v-for="job in activeJobItems" :key="job.id" :job="job" />
</div>
<!-- Assets Header -->
<div
v-if="assets.length"
:class="cn('px-2 2xl:px-4', activeJobItems.length && 'mt-2')"
>
<div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
{{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
</div>
</div>
<!-- Assets Grid -->
<VirtualGrid
class="flex-1"
:items="assetItems"
:grid-style="gridStyle"
@approach-end="emit('approach-end')"
>
<template #item="{ item }">
<MediaAssetCard
:asset="item.asset"
:selected="isSelected(item.asset.id)"
:show-output-count="showOutputCount(item.asset)"
:output-count="getOutputCount(item.asset)"
@click="emit('select-asset', item.asset)"
@context-menu="emit('context-menu', $event, item.asset)"
@zoom="emit('zoom', item.asset)"
@output-count-click="emit('output-count-click', item.asset)"
/>
</template>
</VirtualGrid>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ActiveJobCard from '@/components/sidebar/tabs/assets/ActiveJobCard.vue'
import { useJobList } from '@/composables/queue/useJobList'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isActiveJobState } from '@/utils/queueUtil'
import { cn } from '@/utils/tailwindUtil'
const {
assets,
isSelected,
assetType = 'output',
showOutputCount,
getOutputCount
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
assetType?: 'input' | 'output'
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
}>()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
(e: 'zoom', asset: AssetItem): void
(e: 'output-count-click', asset: AssetItem): void
}>()
const { t } = useI18n()
const { jobItems } = useJobList()
type AssetGridItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state))
)
const assetItems = computed<AssetGridItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,
asset
}))
)
const gridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0 0.5rem',
gap: '0.5rem'
}
</script>

View File

@@ -2,7 +2,7 @@
<div class="flex h-full flex-col">
<div
v-if="activeJobItems.length"
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
class="flex max-h-[50%] flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
v-for="job in activeJobItems"
@@ -44,15 +44,9 @@
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
{{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
{{ t('sideToolbar.generatedAssetsHeader') }}
</div>
</div>
@@ -114,7 +108,7 @@ import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import { isActiveJobState } from '@/utils/queueUtil'
import type { JobState } from '@/types/queue'
import {
formatDuration,
formatSize,
@@ -124,14 +118,9 @@ import {
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
const {
assets,
isSelected,
assetType = 'output'
} = defineProps<{
const { assets, isSelected } = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
assetType?: 'input' | 'output'
}>()
const emit = defineEmits<{
@@ -172,6 +161,12 @@ const listGridStyle = {
gap: '0.5rem'
}
function isActiveJobState(state: JobState): boolean {
return (
state === 'pending' || state === 'initialization' || state === 'running'
)
}
function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name)
}

View File

@@ -100,24 +100,34 @@
v-if="isListView"
:assets="displayAssets"
:is-selected="isSelected"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
<AssetsSidebarGridView
<VirtualGrid
v-else
:assets="displayAssets"
:is-selected="isSelected"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
:items="mediaAssetsWithKey"
:grid-style="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0 0.5rem',
gap: '0.5rem'
}"
@approach-end="handleApproachEnd"
@zoom="handleZoomClick"
@output-count-click="enterFolderView"
/>
>
<template #item="{ item }">
<MediaAssetCard
:asset="item"
:selected="isSelected(item.id)"
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
@click="handleAssetSelect(item)"
@context-menu="handleAssetContextMenu"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
/>
</template>
</VirtualGrid>
</div>
</template>
<template #footer>
@@ -202,7 +212,6 @@
<script setup lang="ts">
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
@@ -210,14 +219,15 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
@@ -233,7 +243,6 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { getJobDetail } from '@/services/jobOutputCache'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -247,8 +256,6 @@ interface JobOutputItem {
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const { activeJobsCount } = storeToRefs(queueStore)
const executionStore = useExecutionStore()
const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output')
@@ -294,6 +301,9 @@ const formattedExecutionTime = computed(() => {
})
const queuedCount = computed(() => queueStore.pendingTasks.length)
const activeJobsCount = computed(
() => queueStore.pendingTasks.length + queueStore.runningTasks.length
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
@@ -394,14 +404,14 @@ const showLoadingState = computed(
() =>
loading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
(!isListView.value || activeJobsCount.value === 0)
)
const showEmptyState = computed(
() =>
!loading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
(!isListView.value || activeJobsCount.value === 0)
)
watch(displayAssets, (newAssets) => {
@@ -443,6 +453,14 @@ const galleryItems = computed(() => {
})
})
// Add key property for VirtualGrid
const mediaAssetsWithKey = computed(() => {
return displayAssets.value.map((asset) => ({
...asset,
key: asset.id
}))
})
const refreshAssets = async () => {
await currentAssets.value.fetchMediaList()
if (error.value) {
@@ -492,13 +510,7 @@ const handleBulkDelete = async (assets: AssetItem[]) => {
}
const handleClearQueue = async () => {
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByPromptIds(pendingPromptIds)
}
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {

View File

@@ -1,111 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ActiveJobCard from './ActiveJobCard.vue'
import type { JobListItem } from '@/composables/queue/useJobList'
vi.mock('@/composables/useProgressBarBackground', () => ({
useProgressBarBackground: () => ({
progressBarPrimaryClass: 'bg-blue-500',
hasProgressPercent: (val: number | undefined) => typeof val === 'number',
progressPercentStyle: (val: number) => ({ width: `${val}%` })
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
activeJobStatus: 'Active job: {status}'
}
}
}
})
const createJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'test-job-1',
title: 'Running...',
meta: 'Step 5/10',
state: 'running',
progressTotalPercent: 50,
progressCurrentPercent: 75,
...overrides
})
const mountComponent = (job: JobListItem) =>
mount(ActiveJobCard, {
props: { job },
global: {
plugins: [i18n]
}
})
describe('ActiveJobCard', () => {
it('displays percentage and progress bar when job is running', () => {
const wrapper = mountComponent(
createJob({ state: 'running', progressTotalPercent: 65 })
)
expect(wrapper.text()).toContain('65%')
const progressBar = wrapper.find('.bg-blue-500')
expect(progressBar.exists()).toBe(true)
expect(progressBar.attributes('style')).toContain('width: 65%')
})
it('displays status text when job is pending', () => {
const wrapper = mountComponent(
createJob({
state: 'pending',
title: 'In queue...',
progressTotalPercent: undefined
})
)
expect(wrapper.text()).toContain('In queue...')
const progressBar = wrapper.find('.bg-blue-500')
expect(progressBar.exists()).toBe(false)
})
it('shows spinner for pending state', () => {
const wrapper = mountComponent(createJob({ state: 'pending' }))
const spinner = wrapper.find('.icon-\\[lucide--loader-circle\\]')
expect(spinner.exists()).toBe(true)
expect(spinner.classes()).toContain('animate-spin')
})
it('shows error icon for failed state', () => {
const wrapper = mountComponent(
createJob({ state: 'failed', title: 'Failed' })
)
const errorIcon = wrapper.find('.icon-\\[lucide--circle-alert\\]')
expect(errorIcon.exists()).toBe(true)
expect(wrapper.text()).toContain('Failed')
})
it('shows preview image when running with iconImageUrl', () => {
const wrapper = mountComponent(
createJob({
state: 'running',
iconImageUrl: 'https://example.com/preview.jpg'
})
)
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('https://example.com/preview.jpg')
})
it('has proper accessibility attributes', () => {
const wrapper = mountComponent(createJob({ title: 'Generating...' }))
const container = wrapper.find('[role="status"]')
expect(container.exists()).toBe(true)
expect(container.attributes('aria-label')).toBe('Active job: Generating...')
})
})

View File

@@ -1,85 +0,0 @@
<template>
<div
role="status"
:aria-label="t('sideToolbar.activeJobStatus', { status: statusText })"
class="flex flex-col gap-2 p-2 rounded-lg"
>
<!-- Thumbnail -->
<div class="relative aspect-square overflow-hidden rounded-lg">
<!-- Running state with preview image -->
<img
v-if="isRunning && job.iconImageUrl"
:src="job.iconImageUrl"
:alt="statusText"
class="size-full object-cover"
/>
<!-- Placeholder for queued/failed states or running without preview -->
<div
v-else
class="absolute inset-0 flex items-center justify-center bg-modal-card-placeholder-background"
>
<!-- Spinner for queued/initialization states -->
<i
v-if="isQueued"
class="icon-[lucide--loader-circle] size-8 animate-spin text-muted-foreground"
/>
<!-- Error icon for failed state -->
<i
v-else-if="isFailed"
class="icon-[lucide--circle-alert] size-8 text-red-500"
/>
<!-- Spinner for running without preview -->
<i
v-else
class="icon-[lucide--loader-circle] size-8 animate-spin text-muted-foreground"
/>
</div>
</div>
<!-- Footer: Progress bar or status text -->
<div class="flex gap-1.5 items-center h-5">
<!-- Running state: percentage + progress bar -->
<template v-if="isRunning && hasProgressPercent(progressPercent)">
<span class="shrink-0 text-sm text-muted-foreground">
{{ Math.round(progressPercent ?? 0) }}%
</span>
<div class="flex-1 relative h-1 rounded-sm bg-secondary-background">
<div
:class="progressBarPrimaryClass"
:style="progressPercentStyle(progressPercent)"
/>
</div>
</template>
<!-- Non-running states: status text only -->
<template v-else>
<div class="w-full truncate text-center text-sm text-muted-foreground">
{{ statusText }}
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
const { job } = defineProps<{ job: JobListItem }>()
const { t } = useI18n()
const { progressBarPrimaryClass, hasProgressPercent, progressPercentStyle } =
useProgressBarBackground()
const statusText = computed(() => job.title)
const progressPercent = computed(() => job.progressTotalPercent)
const isQueued = computed(
() => job.state === 'pending' || job.state === 'initialization'
)
const isRunning = computed(() => job.state === 'running')
const isFailed = computed(() => job.state === 'failed')
</script>

View File

@@ -1,19 +0,0 @@
# UI Component Guidelines
## Adding New Components
```bash
pnpm dlx shadcn-vue@latest add <component-name> --yes
```
After adding, create `ComponentName.stories.ts` with Default, Disabled, and variant stories.
## Reka UI Wrapper Components
- Use reactive props destructuring with rest: `const { class: className, ...restProps } = defineProps<Props>()`
- Use `useForwardProps(restProps)` for prop forwarding, or `computed()` if adding defaults
- Import siblings directly (`./Component.vue`), not from barrel (`'.'`)
- Use `cn()` for class merging with `className`
- Use Iconify icons: `<i class="icon-[lucide--check]" />`
- Use design tokens: `bg-secondary-background`, `text-muted-foreground`, `border-border-default`
- Tailwind 4 CSS variables use parentheses: `h-(--my-var)` not `h-[--my-var]`

View File

@@ -1,261 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Select from './Select.vue'
import SelectContent from './SelectContent.vue'
import SelectGroup from './SelectGroup.vue'
import SelectItem from './SelectItem.vue'
import SelectLabel from './SelectLabel.vue'
import SelectSeparator from './SelectSeparator.vue'
import SelectTrigger from './SelectTrigger.vue'
import SelectValue from './SelectValue.vue'
const meta = {
title: 'Components/Select',
component: Select,
tags: ['autodocs'],
argTypes: {
modelValue: {
control: 'text',
description: 'Selected value'
},
disabled: {
control: 'boolean',
description: 'When true, disables the select'
},
'onUpdate:modelValue': { action: 'update:modelValue' }
}
} satisfies Meta<typeof Select>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref(args.modelValue || '')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
<SelectItem value="grape">Grape</SelectItem>
<SelectItem value="orange">Orange</SelectItem>
</SelectContent>
</Select>
<div class="mt-4 text-sm text-muted-foreground">
Selected: {{ value || 'None' }}
</div>
`
}),
args: {
disabled: false
}
}
export const WithPlaceholder: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Choose an option..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
<SelectItem value="option3">Option 3</SelectItem>
</SelectContent>
</Select>
`
}),
args: {
disabled: false
}
}
export const Disabled: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('apple')
return { value, args }
},
template: `
<Select v-model="value" disabled>
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
</SelectContent>
</Select>
`
})
}
export const WithGroups: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a model type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Checkpoints</SelectLabel>
<SelectItem value="sd15">SD 1.5</SelectItem>
<SelectItem value="sdxl">SDXL</SelectItem>
<SelectItem value="flux">Flux</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>LoRAs</SelectLabel>
<SelectItem value="lora-style">Style LoRA</SelectItem>
<SelectItem value="lora-character">Character LoRA</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Other</SelectLabel>
<SelectItem value="vae">VAE</SelectItem>
<SelectItem value="embedding">Embedding</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div class="mt-4 text-sm text-muted-foreground">
Selected: {{ value || 'None' }}
</div>
`
}),
args: {
disabled: false
}
}
export const Scrollable: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
const items = Array.from({ length: 20 }, (_, i) => ({
value: `item-${i + 1}`,
label: `Option ${i + 1}`
}))
return { value, items, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in items"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</SelectItem>
</SelectContent>
</Select>
`
}),
args: {
disabled: false
}
}
export const CustomWidth: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<div class="space-y-4">
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-32">
<SelectValue placeholder="Small" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">A</SelectItem>
<SelectItem value="b">B</SelectItem>
<SelectItem value="c">C</SelectItem>
</SelectContent>
</Select>
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-full">
<SelectValue placeholder="Full width select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
<SelectItem value="option3">Option 3</SelectItem>
</SelectContent>
</Select>
</div>
`
}),
args: {
disabled: false
}
}

View File

@@ -1,16 +0,0 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
// eslint-disable-next-line vue/no-unused-properties
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot v-bind="forwarded">
<slot />
</SelectRoot>
</template>

View File

@@ -1,73 +0,0 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits
} from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import SelectScrollDownButton from './SelectScrollDownButton.vue'
import SelectScrollUpButton from './SelectScrollUpButton.vue'
defineOptions({
inheritAttrs: false
})
const {
position = 'popper',
class: className,
...restProps
} = defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = computed(() => ({
position,
...restProps
}))
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'relative z-3000 max-h-96 min-w-32 overflow-hidden',
'mt-2 rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'scrollbar-custom flex flex-col gap-0',
position === 'popper' &&
'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width)'
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import type { SelectGroupProps } from 'reka-ui'
import { SelectGroup } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectGroupProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectGroup :class="cn('w-full', className)" v-bind="restProps">
<slot />
</SelectGroup>
</template>

View File

@@ -1,37 +0,0 @@
<script setup lang="ts">
import type { SelectItemProps } from 'reka-ui'
import { SelectItem, SelectItemIndicator, SelectItemText } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectItemProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectItem
v-bind="restProps"
:class="
cn(
'relative flex w-full cursor-pointer select-none items-center justify-between',
'gap-3 rounded px-2 py-3 text-sm outline-none',
'hover:bg-secondary-background-hover',
'focus:bg-secondary-background-hover',
'data-[state=checked]:bg-secondary-background-selected',
'data-[state=checked]:hover:bg-secondary-background-selected',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)
"
>
<SelectItemText class="truncate">
<slot />
</SelectItemText>
<SelectItemIndicator class="flex shrink-0 items-center justify-center">
<i class="icon-[lucide--check] text-base-foreground" aria-hidden="true" />
</SelectItemIndicator>
</SelectItem>
</template>

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import { SelectLabel } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectLabelProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectLabel
v-bind="restProps"
:class="
cn(
'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground',
className
)
"
>
<slot />
</SelectLabel>
</template>

View File

@@ -1,27 +0,0 @@
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from 'reka-ui'
import { SelectScrollDownButton } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectScrollDownButton
v-bind="restProps"
:class="
cn(
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
className
)
"
>
<slot>
<i class="icon-[lucide--chevron-down]" />
</slot>
</SelectScrollDownButton>
</template>

View File

@@ -1,27 +0,0 @@
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from 'reka-ui'
import { SelectScrollUpButton } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectScrollUpButton
v-bind="restProps"
:class="
cn(
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
className
)
"
>
<slot>
<i class="icon-[lucide--chevron-up]" />
</slot>
</SelectScrollUpButton>
</template>

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import { SelectSeparator } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectSeparatorProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectSeparator
v-bind="restProps"
:class="cn('-mx-1 my-1 h-px bg-border-default', className)"
/>
</template>

View File

@@ -1,36 +0,0 @@
<script setup lang="ts">
import type { SelectTriggerProps } from 'reka-ui'
import { SelectIcon, SelectTrigger } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectTriggerProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectTrigger
v-bind="restProps"
:class="
cn(
'flex h-10 w-full cursor-pointer select-none items-center justify-between',
'rounded-lg px-4 py-2 text-sm',
'bg-secondary-background text-base-foreground',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'focus:border-node-component-border focus:outline-none',
'data-[placeholder]:text-muted-foreground',
'disabled:cursor-not-allowed disabled:opacity-60',
'[&>span]:truncate',
className
)
"
>
<slot />
<SelectIcon as-child>
<i class="icon-[lucide--chevron-down] shrink-0 text-muted-foreground" />
</SelectIcon>
</SelectTrigger>
</template>

View File

@@ -1,12 +0,0 @@
<script setup lang="ts">
import type { SelectValueProps } from 'reka-ui'
import { SelectValue } from 'reka-ui'
const props = defineProps<SelectValueProps>()
</script>
<template>
<SelectValue v-bind="props">
<slot />
</SelectValue>
</template>

View File

@@ -23,11 +23,6 @@ const showInput = computed(() => isEditing.value || isEmpty)
const { forwardRef, currentElement } = useForwardExpose()
const registerFocus = inject(tagsInputFocusKey, undefined)
function handleEscape() {
currentElement.value?.blur()
isEditing.value = false
}
onMounted(() => {
registerFocus?.(() => currentElement.value?.focus())
})
@@ -49,6 +44,5 @@ onUnmounted(() => {
className
)
"
@keydown.escape.stop="handleEscape"
/>
</template>

View File

@@ -1,128 +1,100 @@
<template>
<div
class="base-widget-layout rounded-2xl overflow-hidden relative"
@keydown.esc.capture="handleEscape"
>
<div
class="grid h-full w-full transition-[grid-template-columns] duration-300 ease-out"
:style="gridStyle"
<div class="base-widget-layout rounded-2xl overflow-hidden relative">
<Button
v-show="!isRightPanelOpen && hasRightPanel"
size="lg"
:class="
cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
})
"
@click="toggleRightPanel"
>
<nav
class="h-full overflow-hidden"
:inert="!showLeftPanel"
:aria-hidden="!showLeftPanel"
>
<div v-if="hasLeftPanel" class="h-full min-w-40 max-w-56">
<slot name="leftPanel" />
</div>
</nav>
<div class="flex flex-col bg-base-background overflow-hidden">
<header
v-if="$slots.header"
class="w-full h-18 px-6 flex items-center justify-between gap-2"
<i class="icon-[lucide--panel-right]" />
</Button>
<Button
size="lg"
class="absolute top-4 right-6 z-10 transition-opacity duration-200 w-10"
@click="closeDialog"
>
<i class="pi pi-times" />
</Button>
<div class="flex h-full w-full">
<Transition name="slide-panel">
<nav
v-if="$slots.leftPanel && showLeftPanel"
:class="[
PANEL_SIZES.width,
PANEL_SIZES.minWidth,
PANEL_SIZES.maxWidth
]"
>
<div class="flex flex-1 shrink-0 gap-2">
<Button
v-if="!notMobile"
size="icon"
:aria-label="
showLeftPanel ? t('g.hideLeftPanel') : t('g.showLeftPanel')
"
@click="toggleLeftPanel"
>
<i
:class="
cn(
showLeftPanel
? 'icon-[lucide--panel-left]'
: 'icon-[lucide--panel-left-close]'
)
"
/>
</Button>
<slot name="header" />
</div>
<slot name="header-right-area" />
<template v-if="!isRightPanelOpen">
<Button
v-if="hasRightPanel"
size="lg"
class="w-10 p-0"
:aria-label="t('g.showRightPanel')"
@click="toggleRightPanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<Button
size="lg"
class="w-10"
:aria-label="t('g.closeDialog')"
@click="closeDialog"
>
<i class="pi pi-times" />
</Button>
</template>
</header>
<slot name="leftPanel"></slot>
</nav>
</Transition>
<main class="flex min-h-0 flex-1 flex-col">
<slot name="contentFilter" />
<h2
v-if="!hasLeftPanel"
class="text-xxl m-0 px-6 pt-2 pb-6 capitalize"
>
{{ contentTitle }}
</h2>
<div
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
>
<slot name="content" />
</div>
</main>
</div>
<aside
v-if="hasRightPanel"
class="overflow-hidden"
:inert="!isRightPanelOpen"
:aria-hidden="!isRightPanelOpen"
>
<div
class="min-w-72 w-72 flex flex-col bg-modal-panel-background h-full"
>
<div class="flex-1 flex bg-base-background">
<div class="flex h-full w-full flex-col">
<header
data-component-id="RightPanelHeader"
class="flex h-18 shrink-0 items-center gap-2 px-6"
v-if="$slots.header"
class="w-full h-18 px-6 flex items-center justify-between gap-2"
>
<h2 v-if="rightPanelTitle" class="flex-1 text-base font-semibold">
{{ rightPanelTitle }}
</h2>
<div v-else class="flex-1">
<slot name="rightPanelHeaderTitle" />
<div class="flex flex-1 shrink-0 gap-2">
<Button v-if="!notMobile" size="icon" @click="toggleLeftPanel">
<i
:class="
cn(
showLeftPanel
? 'icon-[lucide--panel-left]'
: 'icon-[lucide--panel-left-close]'
)
"
/>
</Button>
<slot name="header"></slot>
</div>
<slot name="rightPanelHeaderActions" />
<Button
size="lg"
class="w-10 p-0"
:aria-label="t('g.hideRightPanel')"
@click="toggleRightPanel"
<slot name="header-right-area"></slot>
<div
:class="
cn(
'flex justify-end gap-2 w-0',
hasRightPanel && !isRightPanelOpen ? 'min-w-22' : 'min-w-10'
)
"
>
<i class="icon-[lucide--panel-right-close] size-4" />
</Button>
<Button
size="lg"
class="w-10 p-0"
:aria-label="t('g.closeDialog')"
@click="closeDialog"
>
<i class="pi pi-times" />
</Button>
<Button
v-if="isRightPanelOpen && hasRightPanel"
size="lg"
@click="toggleRightPanel"
>
<i class="icon-[lucide--panel-right-close]" />
</Button>
</div>
</header>
<div class="min-h-0 flex-1 overflow-y-auto">
<slot name="rightPanel" />
</div>
<main class="flex min-h-0 flex-1 flex-col">
<!-- Fallback title bar when no leftPanel is provided -->
<slot name="contentFilter"></slot>
<h2
v-if="!$slots.leftPanel"
class="text-xxl m-0 px-6 pt-2 pb-6 capitalize"
>
{{ contentTitle }}
</h2>
<div
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
>
<slot name="content"></slot>
</div>
</main>
</div>
</aside>
<aside
v-if="hasRightPanel && isRightPanelOpen"
class="w-1/4 min-w-40 max-w-80 pt-16 pb-8"
>
<slot name="rightPanel"></slot>
</aside>
</div>
</div>
</div>
</template>
@@ -130,29 +102,27 @@
<script setup lang="ts">
import { useBreakpoints } from '@vueuse/core'
import { computed, inject, ref, useSlots, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { OnCloseKey } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const { contentTitle, rightPanelTitle } = defineProps<{
const { contentTitle } = defineProps<{
contentTitle: string
rightPanelTitle?: string
}>()
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
default: false
})
const slots = useSlots()
const hasLeftPanel = computed(() => !!slots.leftPanel)
const hasRightPanel = computed(() => !!slots.rightPanel)
const BREAKPOINTS = { md: 880 }
const PANEL_SIZES = {
width: 'w-1/3',
minWidth: 'min-w-40',
maxWidth: 'max-w-56'
}
const slots = useSlots()
const closeDialog = inject(OnCloseKey, () => {})
const breakpoints = useBreakpoints(BREAKPOINTS)
@@ -161,6 +131,8 @@ const notMobile = breakpoints.greater('md')
const isLeftPanelOpen = ref<boolean>(true)
const mobileMenuOpen = ref<boolean>(false)
const hasRightPanel = computed(() => !!slots.rightPanel)
watch(notMobile, (isDesktop) => {
if (!isDesktop) {
mobileMenuOpen.value = false
@@ -174,12 +146,6 @@ const showLeftPanel = computed(() => {
return shouldShow
})
const gridStyle = computed(() => ({
gridTemplateColumns: hasRightPanel.value
? `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
: `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr`
}))
const toggleLeftPanel = () => {
if (notMobile.value) {
isLeftPanelOpen.value = !isLeftPanelOpen.value
@@ -191,23 +157,6 @@ const toggleLeftPanel = () => {
const toggleRightPanel = () => {
isRightPanelOpen.value = !isRightPanelOpen.value
}
function handleEscape(event: KeyboardEvent) {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
) {
return
}
if (isRightPanelOpen.value) {
event.stopPropagation()
isRightPanelOpen.value = false
}
}
</script>
<style scoped>
.base-widget-layout {
@@ -222,4 +171,28 @@ function handleEscape(event: KeyboardEvent) {
max-width: 1724px;
}
}
/* Fade transition for buttons */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Slide transition for left panel */
.slide-panel-enter-active,
.slide-panel-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
backface-visibility: hidden;
}
.slide-panel-enter-from,
.slide-panel-leave-to {
transform: translateX(-100%);
}
</style>

View File

@@ -5,7 +5,7 @@
disabled: !isOverflowing,
pt: { text: { class: 'whitespace-nowrap' } }
}"
class="flex cursor-pointer items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
class="flex cursor-pointer items-start gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
:class="
active
? 'bg-interface-menu-component-surface-selected'
@@ -15,32 +15,25 @@
@mouseenter="checkOverflow"
@click="onClick"
>
<NavIcon v-if="icon" :icon="icon" />
<div v-if="icon" class="pt-0.5">
<NavIcon :icon="icon" />
</div>
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />
<span ref="textRef" class="min-w-0 truncate">
<slot />
<slot></slot>
</span>
<StatusBadge
v-if="badge !== undefined"
:label="String(badge)"
severity="contrast"
variant="circle"
class="ml-auto"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import type { NavItemData } from '@/types/navTypes'
import NavIcon from './NavIcon.vue'
const { icon, badge, active, onClick } = defineProps<{
const { icon, active, onClick } = defineProps<{
icon: NavItemData['icon']
badge?: NavItemData['badge']
active?: boolean
onClick: () => void
}>()

View File

@@ -22,7 +22,6 @@
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:badge="subItem.badge"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
@@ -33,7 +32,6 @@
<div v-else class="flex flex-col gap-2">
<NavItem
:icon="item.icon"
:badge="item.badge"
:active="activeItem === item.id"
@click="activeItem = item.id"
>

View File

@@ -77,7 +77,6 @@ export interface VueNodeData {
outputs?: INodeOutputSlot[]
resizable?: boolean
shape?: number
showAdvanced?: boolean
subgraphId?: string | null
titleMode?: TitleMode
widgets?: SafeWidgetData[]
@@ -315,8 +314,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
resizable: node.resizable,
shape: node.shape,
showAdvanced: node.showAdvanced
shape: node.shape
}
}
@@ -400,9 +398,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
vueNodeData.set(id, extractVueNodeData(node))
const initializeVueNodeLayout = () => {
// Check if the node was removed mid-sequence
if (!nodeRefs.has(id)) return
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
@@ -432,7 +427,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
requestAnimationFrame(initializeVueNodeLayout)
initializeVueNodeLayout()
}
// Call original callback if provided
@@ -570,13 +565,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
? propertyEvent.newValue
: undefined
})
break
case 'showAdvanced':
vueNodeData.set(nodeId, {
...currentData,
showAdvanced: Boolean(propertyEvent.newValue)
})
break
}
}
},

View File

@@ -305,40 +305,24 @@ describe('useJobList', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('sorts all tasks by create time', async () => {
it('sorts all tasks by priority descending', async () => {
queueStoreMock.pendingTasks = [
createTask({
promptId: 'p',
queueIndex: 1,
mockState: 'pending',
createTime: 3000
})
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
]
queueStoreMock.runningTasks = [
createTask({
promptId: 'r',
queueIndex: 5,
mockState: 'running',
createTime: 2000
})
createTask({ promptId: 'r', queueIndex: 5, mockState: 'running' })
]
queueStoreMock.historyTasks = [
createTask({
promptId: 'h',
queueIndex: 3,
mockState: 'completed',
createTime: 1000,
executionEndTimestamp: 5000
})
createTask({ promptId: 'h', queueIndex: 3, mockState: 'completed' })
]
const { allTasksSorted } = initComposable()
await flush()
expect(allTasksSorted.value.map((task) => task.promptId)).toEqual([
'p',
'r',
'h'
'h',
'p'
])
})

View File

@@ -197,18 +197,13 @@ export function useJobList() {
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
const selectedSortMode = ref<JobSortMode>('mostRecent')
const mostRecentTimestamp = (task: TaskItemImpl) => task.createTime ?? 0
const allTasksSorted = computed<TaskItemImpl[]>(() => {
const all = [
...queueStore.pendingTasks,
...queueStore.runningTasks,
...queueStore.historyTasks
]
return all.sort((a, b) => {
const delta = mostRecentTimestamp(b) - mostRecentTimestamp(a)
return delta === 0 ? 0 : delta
})
return all.sort((a, b) => b.queueIndex - a.queueIndex)
})
const tasksWithJobState = computed<TaskWithState[]>(() =>

View File

@@ -5,10 +5,6 @@ import type { Ref } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const downloadFileMock = vi.fn()
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: (...args: any[]) => downloadFileMock(...args)
@@ -59,8 +55,7 @@ const workflowStoreMock = {
createTemporary: vi.fn()
}
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStoreMock,
ComfyWorkflow: class {}
useWorkflowStore: () => workflowStoreMock
}))
const interruptMock = vi.fn()
@@ -109,13 +104,6 @@ vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => queueStoreMock
}))
const executionStoreMock = {
clearInitializationByPromptId: vi.fn()
}
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStoreMock
}))
const getJobWorkflowMock = vi.fn()
vi.mock('@/services/jobOutputCache', () => ({
getJobWorkflow: (...args: any[]) => getJobWorkflowMock(...args)

View File

@@ -6,7 +6,6 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { st, t } from '@/i18n'
import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -16,7 +15,6 @@ import { downloadBlob } from '@/scripts/utils'
import { useDialogService } from '@/services/dialogService'
import { getJobWorkflow } from '@/services/jobOutputCache'
import { useLitegraphService } from '@/services/litegraphService'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
@@ -46,7 +44,6 @@ export function useJobMenu(
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const { copyToClipboard } = useCopyToClipboard()
const litegraphService = useLitegraphService()
const nodeDefStore = useNodeDefStore()
@@ -75,15 +72,10 @@ export function useJobMenu(
const target = resolveItem(item)
if (!target) return
if (target.state === 'running' || target.state === 'initialization') {
if (isCloud) {
await api.deleteItem('queue', target.id)
} else {
await api.interrupt(target.id)
}
await api.interrupt(target.id)
} else if (target.state === 'pending') {
await api.deleteItem('queue', target.id)
}
executionStore.clearInitializationByPromptId(target.id)
await queueStore.update()
}

View File

@@ -1,7 +1,6 @@
import { markRaw } from 'vue'
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
import { useQueueStore } from '@/stores/queueStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useAssetsSidebarTab = (): SidebarTabExtension => {
@@ -12,12 +11,6 @@ export const useAssetsSidebarTab = (): SidebarTabExtension => {
tooltip: 'sideToolbar.assets',
label: 'sideToolbar.labels.assets',
component: markRaw(AssetsSidebarTab),
type: 'vue',
iconBadge: () => {
const queueStore = useQueueStore()
return queueStore.pendingTasks.length > 0
? queueStore.pendingTasks.length.toString()
: null
}
type: 'vue'
}
}

View File

@@ -123,7 +123,8 @@ export const useContextMenuTranslation = () => {
}
// for capture translation text of input and widget
const extraInfo = (options.extra || options.parentMenu?.options?.extra) as
const extraInfo = (options.extra ||
options.parentMenu?.options?.extra) as
| { inputs?: INodeInputSlot[]; widgets?: IWidget[] }
| undefined
// widgets and inputs

View File

@@ -1,6 +1,5 @@
import { computed, reactive, readonly } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
@@ -96,8 +95,6 @@ export function useFeatureFlags() {
)
},
get teamWorkspacesEnabled() {
if (!isCloud) return false
return (
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)

View File

@@ -1,4 +1,4 @@
import { isCloud, isNightly } from '@/platform/distribution/types'
import { isCloud } from '@/platform/distribution/types'
import './clipspace'
import './contextMenuFilter'
@@ -38,8 +38,3 @@ if (isCloud) {
await import('./cloudSubscription')
}
}
// Nightly-only extensions
if (isNightly && !isCloud) {
await import('./nightlyBadges')
}

View File

@@ -1,17 +0,0 @@
import { t } from '@/i18n'
import { useExtensionService } from '@/services/extensionService'
import type { TopbarBadge } from '@/types/comfy'
const badges: TopbarBadge[] = [
{
text: t('nightly.badge.label'),
label: t('g.nightly'),
variant: 'warning',
tooltip: t('nightly.badge.tooltip')
}
]
useExtensionService().registerExtension({
name: 'Comfy.Nightly.Badges',
topbarBadges: badges
})

View File

@@ -75,9 +75,7 @@ useExtensionService().registerExtension({
for (const previewWidget of previewWidgets) {
const text = message.text ?? ''
previewWidget.value = Array.isArray(text)
? (text?.join('\n\n') ?? '')
: text
previewWidget.value = Array.isArray(text) ? (text[0] ?? '') : text
}
}
}

View File

@@ -1,5 +1,6 @@
import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
@@ -24,17 +25,6 @@ import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
function updateUIWidget(
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
url: string = ''
) {
audioUIWidget.element.src = url
audioUIWidget.value = url
audioUIWidget.callback?.(url)
if (url) audioUIWidget.element.classList.remove('empty-audio-widget')
else audioUIWidget.element.classList.add('empty-audio-widget')
}
async function uploadFile(
audioWidget: IStringWidget,
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
@@ -65,10 +55,10 @@ async function uploadFile(
}
if (updateNode) {
updateUIWidget(
audioUIWidget,
api.apiURL(getResourceURL(...splitFilePath(path)))
audioUIWidget.element.src = api.apiURL(
getResourceURL(...splitFilePath(path))
)
audioWidget.value = path
// Manually trigger the callback to update VueNodes
audioWidget.callback?.(path)
@@ -128,18 +118,26 @@ app.registerExtension({
const audios = output.audio
if (!audios?.length) return
const audio = audios[0]
const resourceUrl = getResourceURL(
audio.subfolder ?? '',
audio.filename ?? '',
audio.type
audioUIWidget.element.src = api.apiURL(
getResourceURL(
audio.subfolder ?? '',
audio.filename ?? '',
audio.type
)
)
updateUIWidget(audioUIWidget, api.apiURL(resourceUrl))
audioUIWidget.element.classList.remove('empty-audio-widget')
}
}
let value = ''
audioUIWidget.options.getValue = () => value
audioUIWidget.options.setValue = (v) => (value = v)
audioUIWidget.onRemove = useChainCallback(
audioUIWidget.onRemove,
() => {
if (!audioUIWidget.element) return
audioUIWidget.element.pause()
audioUIWidget.element.src = ''
audioUIWidget.element.remove()
}
)
return { widget: audioUIWidget }
}
@@ -158,12 +156,10 @@ app.registerExtension({
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement, string>
const audio = output.audio[0]
const resourceUrl = getResourceURL(
audio.subfolder ?? '',
audio.filename ?? '',
audio.type
audioUIWidget.element.src = api.apiURL(
getResourceURL(audio.subfolder ?? '', audio.filename ?? '', audio.type)
)
updateUIWidget(audioUIWidget, api.apiURL(resourceUrl))
audioUIWidget.element.classList.remove('empty-audio-widget')
}
}
})
@@ -187,18 +183,18 @@ app.registerExtension({
const audioUIWidget = node.widgets.find(
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement, string>
audioUIWidget.options.canvasOnly = true
const onAudioWidgetUpdate = () => {
updateUIWidget(
audioUIWidget,
api.apiURL(
getResourceURL(...splitFilePath(audioWidget.value ?? ''))
)
if (typeof audioWidget.value !== 'string') return
audioUIWidget.element.src = api.apiURL(
getResourceURL(...splitFilePath(audioWidget.value))
)
}
// Initially load default audio file to audioUIWidget.
onAudioWidgetUpdate()
if (audioWidget.value) {
onAudioWidgetUpdate()
}
audioWidget.callback = onAudioWidgetUpdate
// Load saved audio file widget values if restoring from workflow
@@ -206,7 +202,9 @@ app.registerExtension({
node.onGraphConfigured = function () {
// @ts-expect-error fixme ts strict error
onGraphConfigured?.apply(this, arguments)
onAudioWidgetUpdate()
if (audioWidget.value) {
onAudioWidgetUpdate()
}
}
const handleUpload = async (files: File[]) => {
@@ -330,7 +328,7 @@ app.registerExtension({
URL.revokeObjectURL(audioUIWidget.element.src)
}
updateUIWidget(audioUIWidget, URL.createObjectURL(audioBlob))
audioUIWidget.element.src = URL.createObjectURL(audioBlob)
isRecording = false

View File

@@ -1350,12 +1350,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
})
function inner_clicked(
this: ContextMenuDivElement,
this: ContextMenu<string>,
v?: string | IContextMenuValue<string>
) {
if (!node || typeof v === 'string' || !v?.value) return
const rect = this.getBoundingClientRect()
const rect = this.root.getBoundingClientRect()
canvas.showEditPropertyValue(node, v.value, {
position: [rect.left, rect.top]
})

View File

@@ -13,9 +13,7 @@ const DEFAULT_TRACKED_PROPERTIES: string[] = [
'flags.pinned',
'mode',
'color',
'bgcolor',
'shape',
'showAdvanced'
'bgcolor'
]
/**
* Manages node properties with optional change tracking and instrumentation.

View File

@@ -24,7 +24,6 @@
"assets": "الأصول",
"baseModels": "النماذج الأساسية",
"browseAssets": "تصفح الأصول",
"byType": "حسب النوع",
"checkpoints": "نقاط التحقق",
"civitaiLinkExample": "{example} {link}",
"civitaiLinkExampleStrong": "مثال:",
@@ -46,10 +45,6 @@
"failed": "فشل التنزيل",
"inProgress": "جاري تنزيل {assetName}..."
},
"emptyImported": {
"canImport": "لا توجد نماذج مستوردة بعد. انقر على \"استيراد نموذج\" لإضافة نموذجك الخاص.",
"restricted": "النماذج الشخصية متاحة فقط لمستوى Creator وما فوق."
},
"errorFileTooLarge": "الملف يتجاوز الحد الأقصى المسموح به للحجم",
"errorFormatNotAllowed": "يسمح فقط بصيغة SafeTensor",
"errorModelTypeNotSupported": "نوع النموذج هذا غير مدعوم",
@@ -66,7 +61,6 @@
"finish": "إنهاء",
"genericLinkPlaceholder": "الصق الرابط هنا",
"importAnother": "استيراد آخر",
"imported": "مستوردة",
"jobId": "معرّف المهمة",
"loadingModels": "جارٍ تحميل {type}...",
"maxFileSize": "الحد الأقصى لحجم الملف: {size}",
@@ -76,29 +70,6 @@
"threeDModelPlaceholder": "نموذج ثلاثي الأبعاد"
},
"modelAssociatedWithLink": "النموذج المرتبط بالرابط الذي قدمته:",
"modelInfo": {
"addBaseModel": "أضف نموذجًا أساسيًا...",
"addTag": "أضف وسمًا...",
"additionalTags": "وسوم إضافية",
"baseModelUnknown": "النموذج الأساسي غير معروف",
"basicInfo": "معلومات أساسية",
"compatibleBaseModels": "نماذج أساسية متوافقة",
"description": "الوصف",
"descriptionNotSet": "لم يتم تعيين وصف",
"descriptionPlaceholder": "أضف وصفًا لهذا النموذج...",
"displayName": "اسم العرض",
"fileName": "اسم الملف",
"modelDescription": "وصف النموذج",
"modelTagging": "تصنيف النموذج",
"modelType": "نوع النموذج",
"noAdditionalTags": "لا توجد وسوم إضافية",
"selectModelPrompt": "اختر نموذجًا لعرض معلوماته",
"selectModelType": "اختر نوع النموذج...",
"source": "المصدر",
"title": "معلومات النموذج",
"triggerPhrases": "عبارات التفعيل",
"viewOnSource": "عرض على {source}"
},
"modelName": "اسم النموذج",
"modelNamePlaceholder": "أدخل اسمًا لهذا النموذج",
"modelTypeSelectorLabel": "ما نوع هذا النموذج؟",
@@ -713,7 +684,6 @@
"clearAll": "مسح الكل",
"clearFilters": "مسح الفلاتر",
"close": "إغلاق",
"closeDialog": "إغلاق الحوار",
"color": "اللون",
"comfy": "Comfy",
"comfyOrgLogoAlt": "شعار ComfyOrg",
@@ -792,8 +762,6 @@
"goToNode": "الانتقال إلى العقدة",
"graphNavigation": "التنقل في الرسم البياني",
"halfSpeed": "0.5x",
"hideLeftPanel": "إخفاء اللوحة اليسرى",
"hideRightPanel": "إخفاء اللوحة اليمنى",
"icon": "أيقونة",
"imageFailedToLoad": "فشل تحميل الصورة",
"imagePreview": "معاينة الصورة - استخدم مفاتيح الأسهم للتنقل بين الصور",
@@ -835,7 +803,6 @@
"name": "الاسم",
"newFolder": "مجلد جديد",
"next": "التالي",
"nightly": "NIGHTLY",
"no": "لا",
"noAudioRecorded": "لم يتم تسجيل أي صوت",
"noItems": "لا توجد عناصر",
@@ -850,7 +817,6 @@
"nodeSlotsError": "خطأ في فتحات العقدة",
"nodeWidgetsError": "خطأ في عناصر واجهة العقدة",
"nodes": "العُقَد",
"nodesCount": "{count} عقدة | {count} عقدة | {count} عقدة",
"nodesRunning": "العُقَد قيد التشغيل",
"none": "لا شيء",
"nothingToCopy": "لا يوجد ما يمكن نسخه",
@@ -925,9 +891,7 @@
"selectedFile": "الملف المحدد",
"setAsBackground": "تعيين كخلفية",
"settings": "الإعدادات",
"showLeftPanel": "إظهار اللوحة اليسرى",
"showReport": "عرض التقرير",
"showRightPanel": "إظهار اللوحة اليمنى",
"singleSelectDropdown": "قائمة منسدلة اختيار واحد",
"sort": "فرز",
"source": "المصدر",
@@ -950,7 +914,6 @@
"updating": "جارٍ التحديث",
"upload": "رفع",
"usageHint": "تلميح الاستخدام",
"use": "استخدم",
"user": "المستخدم",
"versionMismatchWarning": "تحذير توافق الإصدارات",
"versionMismatchWarningMessage": "{warning}: {detail} زر https://docs.comfy.org/installation/update_comfyui#common-update-issues للحصول على تعليمات التحديث.",
@@ -1654,18 +1617,11 @@
"title": "سير العمل هذا يحتوي على عقد مفقودة"
}
},
"nightly": {
"badge": {
"label": "إصدار معاينة",
"tooltip": "أنت تستخدم إصدارًا ليليًا من ComfyUI. يرجى استخدام زر الملاحظات لمشاركة آرائك حول هذه الميزات."
}
},
"nodeCategories": {
"": "",
"3d": "ثلاثي الأبعاد",
"3d_models": "نماذج ثلاثية الأبعاد",
"BFL": "BFL",
"Bria": "Bria",
"ByteDance": "بايت دانس",
"Gemini": "جيميني",
"Ideogram": "إيديوغرام",
@@ -1687,7 +1643,6 @@
"Veo": "Veo",
"Vidu": "فيدو",
"Wan": "وان",
"WaveSpeed": "WaveSpeed",
"_for_testing": "_للاختبار",
"advanced": "متقدم",
"animation": "الرسوم المتحركة",
@@ -2174,14 +2129,12 @@
"viewControls": "عناصر تحكم العرض"
},
"sideToolbar": {
"activeJobStatus": "المهمة النشطة: {status}",
"assets": "الأصول",
"backToAssets": "العودة إلى جميع الأصول",
"browseTemplates": "تصفح القوالب المثال",
"downloads": "التنزيلات",
"generatedAssetsHeader": "الأصول المُولدة",
"helpCenter": "مركز المساعدة",
"importedAssetsHeader": "الأصول المستوردة",
"labels": {
"assets": "الأصول",
"console": "وحدة التحكم",
@@ -2226,7 +2179,6 @@
"queue": "قائمة الانتظار",
"queueProgressOverlay": {
"activeJobs": "{count} مهمة نشطة | {count} مهام نشطة",
"activeJobsShort": "{count} نشط | {count} نشط",
"activeJobsSuffix": "مهام نشطة",
"cancelJobTooltip": "إلغاء المهمة",
"clearHistory": "مسح سجل قائمة الانتظار",

View File

@@ -328,68 +328,6 @@
}
}
},
"BriaImageEditNode": {
"description": "حرر الصور باستخدام أحدث نموذج من Bria",
"display_name": "تحرير صورة Bria",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"guidance_scale": {
"name": "مقياس التوجيه",
"tooltip": "القيمة الأعلى تجعل الصورة تتبع التوجيه بشكل أدق."
},
"image": {
"name": "الصورة"
},
"mask": {
"name": "القناع",
"tooltip": "إذا لم يتم تحديده، سيتم تطبيق التحرير على الصورة بالكامل."
},
"model": {
"name": "النموذج"
},
"moderation": {
"name": "الإشراف",
"tooltip": "إعدادات الإشراف"
},
"moderation_prompt_content_moderation": {
"name": "إشراف محتوى التوجيه"
},
"moderation_visual_input_moderation": {
"name": "إشراف الإدخال البصري"
},
"moderation_visual_output_moderation": {
"name": "إشراف الإخراج البصري"
},
"negative_prompt": {
"name": "توجيه سلبي"
},
"prompt": {
"name": "التوجيه",
"tooltip": "تعليمات لتحرير الصورة"
},
"seed": {
"name": "البذرة"
},
"steps": {
"name": "الخطوات"
},
"structured_prompt": {
"name": "توجيه منظم",
"tooltip": "سلسلة نصية تحتوي على توجيه التحرير المنظم بصيغة JSON. استخدم هذا بدلاً من التوجيه المعتاد للتحكم الدقيق والبرمجي."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "توجيه منظم",
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "إنشاء فيديو باستخدام المطالبة النصية والإطار الأول والأخير.",
"display_name": "تحويل الإطار الأول-الأخير من ByteDance إلى فيديو",
@@ -13512,40 +13450,6 @@
}
}
},
"TextEncodeZImageOmni": {
"display_name": "TextEncodeZImageOmni",
"inputs": {
"auto_resize_images": {
"name": "تغيير حجم الصور تلقائياً"
},
"clip": {
"name": "clip"
},
"image1": {
"name": "الصورة ١"
},
"image2": {
"name": "الصورة ٢"
},
"image3": {
"name": "الصورة ٣"
},
"image_encoder": {
"name": "مُرمّز الصورة"
},
"prompt": {
"name": "التوجيه"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TextToLowercase": {
"display_name": "تحويل النص إلى أحرف صغيرة",
"inputs": {
@@ -16233,43 +16137,6 @@
}
}
},
"WavespeedFlashVSRNode": {
"description": "مُرقّي فيديو سريع وعالي الجودة يعزز الدقة ويعيد الوضوح للمقاطع منخفضة الدقة أو الضبابية.",
"display_name": "ترقية فيديو FlashVSR",
"inputs": {
"target_resolution": {
"name": "الدقة المستهدفة"
},
"video": {
"name": "الفيديو"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WavespeedImageUpscaleNode": {
"description": "عزز دقة وجودة الصورة، وارفع الصور إلى دقة 4K أو 8K للحصول على نتائج حادة ومفصلة.",
"display_name": "ترقية صورة WaveSpeed",
"inputs": {
"image": {
"name": "الصورة"
},
"model": {
"name": "النموذج"
},
"target_resolution": {
"name": "الدقة المستهدفة"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WebcamCapture": {
"display_name": "التقاط كاميرا ويب",
"inputs": {

View File

@@ -100,11 +100,6 @@
"no": "No",
"cancel": "Cancel",
"close": "Close",
"closeDialog": "Close dialog",
"showLeftPanel": "Show left panel",
"hideLeftPanel": "Hide left panel",
"showRightPanel": "Show right panel",
"hideRightPanel": "Hide right panel",
"or": "or",
"pressKeysForNewBinding": "Press keys for new binding",
"defaultBanner": "default banner",
@@ -163,7 +158,6 @@
"choose_file_to_upload": "choose file to upload",
"capture": "capture",
"nodes": "Nodes",
"nodesCount": "{count} nodes | {count} node | {count} nodes",
"community": "Community",
"all": "All",
"versionMismatchWarning": "Version Compatibility Warning",
@@ -184,7 +178,6 @@
"source": "Source",
"filter": "Filter",
"apply": "Apply",
"use": "Use",
"enabled": "Enabled",
"installed": "Installed",
"restart": "Restart",
@@ -276,7 +269,6 @@
"1x": "1x",
"2x": "2x",
"beta": "BETA",
"nightly": "NIGHTLY",
"profile": "Profile",
"noItems": "No items"
},
@@ -710,8 +702,6 @@
"noImportedFiles": "No imported files found",
"noGeneratedFiles": "No generated files found",
"generatedAssetsHeader": "Generated assets",
"importedAssetsHeader": "Imported assets",
"activeJobStatus": "Active job: {status}",
"noFilesFoundMessage": "Upload files or generate content to see them here",
"browseTemplates": "Browse example templates",
"openWorkflow": "Open workflow in local file system",
@@ -761,7 +751,6 @@
"sortJobs": "Sort jobs",
"sortBy": "Sort by",
"activeJobs": "{count} active job | {count} active jobs",
"activeJobsShort": "{count} active | {count} active",
"activeJobsSuffix": "active jobs",
"jobQueue": "Job Queue",
"expandCollapsedQueue": "Expand job queue",
@@ -1437,7 +1426,6 @@
"latent": "latent",
"mask": "mask",
"api node": "api node",
"Bria": "Bria",
"video": "video",
"ByteDance": "ByteDance",
"preprocessors": "preprocessors",
@@ -1518,7 +1506,6 @@
"": "",
"camera": "camera",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
"zimage": "zimage"
},
"dataTypes": {
@@ -2319,12 +2306,6 @@
"assetBrowser": {
"allCategory": "All {category}",
"allModels": "All Models",
"byType": "By type",
"emptyImported": {
"canImport": "No imported models yet. Click \"Import Model\" to add your own.",
"restricted": "Personal models are only available at Creator tier and above."
},
"imported": "Imported",
"assetCollection": "Asset collection",
"assets": "Assets",
"baseModels": "Base models",
@@ -2415,29 +2396,6 @@
"assetCard": "{name} - {type} asset",
"loadingAsset": "Loading asset"
},
"modelInfo": {
"title": "Model Info",
"selectModelPrompt": "Select a model to see its information",
"basicInfo": "Basic Info",
"displayName": "Display Name",
"fileName": "File Name",
"source": "Source",
"viewOnSource": "View on {source}",
"modelTagging": "Model Tagging",
"modelType": "Model Type",
"selectModelType": "Select model type...",
"compatibleBaseModels": "Compatible Base Models",
"addBaseModel": "Add base model...",
"baseModelUnknown": "Base model unknown",
"additionalTags": "Additional Tags",
"addTag": "Add tag...",
"noAdditionalTags": "No additional tags",
"modelDescription": "Model Description",
"triggerPhrases": "Trigger Phrases",
"description": "Description",
"descriptionNotSet": "No description set",
"descriptionPlaceholder": "Add a description for this model..."
},
"media": {
"threeDModelPlaceholder": "3D Model",
"audioPlaceholder": "Audio"
@@ -2660,11 +2618,5 @@
"workspaceNotFound": "Workspace not found",
"tokenExchangeFailed": "Failed to authenticate with workspace: {error}"
}
},
"nightly": {
"badge": {
"label": "Preview Version",
"tooltip": "You are using a nightly version of ComfyUI. Please use the feedback button to share your thoughts about these features."
}
}
}

View File

@@ -328,68 +328,6 @@
}
}
},
"BriaImageEditNode": {
"display_name": "Bria Image Edit",
"description": "Edit images using Bria latest model",
"inputs": {
"model": {
"name": "model"
},
"image": {
"name": "image"
},
"prompt": {
"name": "prompt",
"tooltip": "Instruction to edit image"
},
"negative_prompt": {
"name": "negative_prompt"
},
"structured_prompt": {
"name": "structured_prompt",
"tooltip": "A string containing the structured edit prompt in JSON format. Use this instead of usual prompt for precise, programmatic control."
},
"seed": {
"name": "seed"
},
"guidance_scale": {
"name": "guidance_scale",
"tooltip": "Higher value makes the image follow the prompt more closely."
},
"steps": {
"name": "steps"
},
"moderation": {
"name": "moderation",
"tooltip": "Moderation settings"
},
"mask": {
"name": "mask",
"tooltip": "If omitted, the edit applies to the entire image."
},
"control_after_generate": {
"name": "control after generate"
},
"moderation_prompt_content_moderation": {
"name": "prompt_content_moderation"
},
"moderation_visual_input_moderation": {
"name": "visual_input_moderation"
},
"moderation_visual_output_moderation": {
"name": "visual_output_moderation"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "structured_prompt",
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"display_name": "ByteDance First-Last-Frame to Video",
"description": "Generate video using prompt and first and last frames.",
@@ -13531,40 +13469,6 @@
}
}
},
"TextEncodeZImageOmni": {
"display_name": "TextEncodeZImageOmni",
"inputs": {
"clip": {
"name": "clip"
},
"prompt": {
"name": "prompt"
},
"auto_resize_images": {
"name": "auto_resize_images"
},
"image_encoder": {
"name": "image_encoder"
},
"vae": {
"name": "vae"
},
"image1": {
"name": "image1"
},
"image2": {
"name": "image2"
},
"image3": {
"name": "image3"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TextToLowercase": {
"display_name": "Text to Lowercase",
"inputs": {
@@ -16295,43 +16199,6 @@
}
}
},
"WavespeedFlashVSRNode": {
"display_name": "FlashVSR Video Upscale",
"description": "Fast, high-quality video upscaler that boosts resolution and restores clarity for low-resolution or blurry footage.",
"inputs": {
"video": {
"name": "video"
},
"target_resolution": {
"name": "target_resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WavespeedImageUpscaleNode": {
"display_name": "WaveSpeed Image Upscale",
"description": "Boost image resolution and quality, upscaling photos to 4K or 8K for sharp, detailed results.",
"inputs": {
"model": {
"name": "model"
},
"image": {
"name": "image"
},
"target_resolution": {
"name": "target_resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WebcamCapture": {
"display_name": "Webcam Capture",
"inputs": {

View File

@@ -24,7 +24,6 @@
"assets": "Recursos",
"baseModels": "Modelos base",
"browseAssets": "Explorar recursos",
"byType": "Por tipo",
"checkpoints": "Checkpoints",
"civitaiLinkExample": "{example} {link}",
"civitaiLinkExampleStrong": "Ejemplo:",
@@ -46,10 +45,6 @@
"failed": "La descarga falló",
"inProgress": "Descargando {assetName}..."
},
"emptyImported": {
"canImport": "Aún no hay modelos importados. Haz clic en \"Importar modelo\" para añadir el tuyo.",
"restricted": "Los modelos personales solo están disponibles en el nivel Creador o superior."
},
"errorFileTooLarge": "El archivo excede el tamaño máximo permitido",
"errorFormatNotAllowed": "Solo se permite el formato SafeTensor",
"errorModelTypeNotSupported": "Este tipo de modelo no es compatible",
@@ -66,7 +61,6 @@
"finish": "Finalizar",
"genericLinkPlaceholder": "Pega el enlace aquí",
"importAnother": "Importar otro",
"imported": "Importado",
"jobId": "ID de tarea",
"loadingModels": "Cargando {type}...",
"maxFileSize": "Tamaño máximo de archivo: {size}",
@@ -76,29 +70,6 @@
"threeDModelPlaceholder": "Modelo 3D"
},
"modelAssociatedWithLink": "El modelo asociado con el enlace que proporcionaste:",
"modelInfo": {
"addBaseModel": "Agregar modelo base...",
"addTag": "Agregar etiqueta...",
"additionalTags": "Etiquetas adicionales",
"baseModelUnknown": "Modelo base desconocido",
"basicInfo": "Información básica",
"compatibleBaseModels": "Modelos base compatibles",
"description": "Descripción",
"descriptionNotSet": "Sin descripción",
"descriptionPlaceholder": "Agrega una descripción para este modelo...",
"displayName": "Nombre para mostrar",
"fileName": "Nombre de archivo",
"modelDescription": "Descripción del modelo",
"modelTagging": "Etiquetado del modelo",
"modelType": "Tipo de modelo",
"noAdditionalTags": "Sin etiquetas adicionales",
"selectModelPrompt": "Selecciona un modelo para ver su información",
"selectModelType": "Selecciona el tipo de modelo...",
"source": "Fuente",
"title": "Información del modelo",
"triggerPhrases": "Frases de activación",
"viewOnSource": "Ver en {source}"
},
"modelName": "Nombre del modelo",
"modelNamePlaceholder": "Introduce un nombre para este modelo",
"modelTypeSelectorLabel": "¿Qué tipo de modelo es este?",
@@ -713,7 +684,6 @@
"clearAll": "Borrar todo",
"clearFilters": "Borrar filtros",
"close": "Cerrar",
"closeDialog": "Cerrar diálogo",
"color": "Color",
"comfy": "Comfy",
"comfyOrgLogoAlt": "Logo de ComfyOrg",
@@ -792,8 +762,6 @@
"goToNode": "Ir al nodo",
"graphNavigation": "Navegación de gráficos",
"halfSpeed": "0.5x",
"hideLeftPanel": "Ocultar panel izquierdo",
"hideRightPanel": "Ocultar panel derecho",
"icon": "Icono",
"imageFailedToLoad": "Falló la carga de la imagen",
"imagePreview": "Vista previa de imagen - Usa las teclas de flecha para navegar entre imágenes",
@@ -835,7 +803,6 @@
"name": "Nombre",
"newFolder": "Nueva carpeta",
"next": "Siguiente",
"nightly": "NIGHTLY",
"no": "No",
"noAudioRecorded": "No se grabó audio",
"noItems": "Sin elementos",
@@ -850,7 +817,6 @@
"nodeSlotsError": "Error de Ranuras del Nodo",
"nodeWidgetsError": "Error de Widgets del Nodo",
"nodes": "Nodos",
"nodesCount": "{count} nodos | {count} nodo | {count} nodos",
"nodesRunning": "nodos en ejecución",
"none": "Ninguno",
"nothingToCopy": "Nada para copiar",
@@ -925,9 +891,7 @@
"selectedFile": "Archivo seleccionado",
"setAsBackground": "Establecer como fondo",
"settings": "Configuraciones",
"showLeftPanel": "Mostrar panel izquierdo",
"showReport": "Mostrar informe",
"showRightPanel": "Mostrar panel derecho",
"singleSelectDropdown": "Menú desplegable de selección única",
"sort": "Ordenar",
"source": "Fuente",
@@ -950,7 +914,6 @@
"updating": "Actualizando",
"upload": "Subir",
"usageHint": "Sugerencia de uso",
"use": "Usar",
"user": "Usuario",
"versionMismatchWarning": "Advertencia de compatibilidad de versión",
"versionMismatchWarningMessage": "{warning}: {detail} Visita https://docs.comfy.org/installation/update_comfyui#common-update-issues para obtener instrucciones de actualización.",
@@ -1654,18 +1617,11 @@
"title": "Este flujo de trabajo tiene nodos faltantes"
}
},
"nightly": {
"badge": {
"label": "Versión preliminar",
"tooltip": "Estás usando una versión nightly de ComfyUI. Por favor, utiliza el botón de comentarios para compartir tus opiniones sobre estas funciones."
}
},
"nodeCategories": {
"": "",
"3d": "3d",
"3d_models": "modelos_3d",
"BFL": "BFL",
"Bria": "Bria",
"ByteDance": "ByteDance",
"Gemini": "Gemini",
"Ideogram": "Ideogram",
@@ -1687,7 +1643,6 @@
"Veo": "Veo",
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
"_for_testing": "_para_pruebas",
"advanced": "avanzado",
"animation": "animación",
@@ -2174,14 +2129,12 @@
"viewControls": "Controles de vista"
},
"sideToolbar": {
"activeJobStatus": "Trabajo activo: {status}",
"assets": "Recursos",
"backToAssets": "Volver a todos los recursos",
"browseTemplates": "Explorar plantillas de ejemplo",
"downloads": "Descargas",
"generatedAssetsHeader": "Recursos generados",
"helpCenter": "Centro de ayuda",
"importedAssetsHeader": "Recursos importados",
"labels": {
"assets": "Recursos",
"console": "Consola",
@@ -2226,7 +2179,6 @@
"queue": "Cola",
"queueProgressOverlay": {
"activeJobs": "{count} trabajo activo | {count} trabajos activos",
"activeJobsShort": "{count} activo(s) | {count} activo(s)",
"activeJobsSuffix": "trabajos activos",
"cancelJobTooltip": "Cancelar trabajo",
"clearHistory": "Limpiar historial de la cola de trabajos",

View File

@@ -328,68 +328,6 @@
}
}
},
"BriaImageEditNode": {
"description": "Edita imágenes usando el modelo más reciente de Bria",
"display_name": "Edición de Imagen Bria",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"guidance_scale": {
"name": "escala_de_guía",
"tooltip": "Un valor más alto hace que la imagen siga la instrucción más de cerca."
},
"image": {
"name": "imagen"
},
"mask": {
"name": "máscara",
"tooltip": "Si se omite, la edición se aplica a toda la imagen."
},
"model": {
"name": "modelo"
},
"moderation": {
"name": "moderación",
"tooltip": "Configuración de moderación"
},
"moderation_prompt_content_moderation": {
"name": "moderación_de_contenido_de_instrucción"
},
"moderation_visual_input_moderation": {
"name": "moderación_visual_de_entrada"
},
"moderation_visual_output_moderation": {
"name": "moderación_visual_de_salida"
},
"negative_prompt": {
"name": "instrucción_negativa"
},
"prompt": {
"name": "instrucción",
"tooltip": "Instrucción para editar la imagen"
},
"seed": {
"name": "semilla"
},
"steps": {
"name": "pasos"
},
"structured_prompt": {
"name": "instrucción_estructurada",
"tooltip": "Una cadena que contiene la instrucción de edición estructurada en formato JSON. Usa esto en lugar de la instrucción habitual para un control preciso y programático."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "instrucción_estructurada",
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "Generar video usando prompt y primer y último fotograma.",
"display_name": "ByteDance Primer-Último-Fotograma a Video",
@@ -13512,40 +13450,6 @@
}
}
},
"TextEncodeZImageOmni": {
"display_name": "TextEncodeZImageOmni",
"inputs": {
"auto_resize_images": {
"name": "auto_redimensionar_imágenes"
},
"clip": {
"name": "clip"
},
"image1": {
"name": "imagen1"
},
"image2": {
"name": "imagen2"
},
"image3": {
"name": "imagen3"
},
"image_encoder": {
"name": "codificador_de_imagen"
},
"prompt": {
"name": "instrucción"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TextToLowercase": {
"display_name": "Convertir texto a minúsculas",
"inputs": {
@@ -16233,43 +16137,6 @@
}
}
},
"WavespeedFlashVSRNode": {
"description": "Escalador de video rápido y de alta calidad que aumenta la resolución y restaura la claridad de videos de baja resolución o borrosos.",
"display_name": "FlashVSR Escalado de Video",
"inputs": {
"target_resolution": {
"name": "resolución_objetivo"
},
"video": {
"name": "video"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WavespeedImageUpscaleNode": {
"description": "Aumenta la resolución y calidad de la imagen, escalando fotos a 4K u 8K para obtener resultados nítidos y detallados.",
"display_name": "WaveSpeed Escalado de Imagen",
"inputs": {
"image": {
"name": "imagen"
},
"model": {
"name": "modelo"
},
"target_resolution": {
"name": "resolución_objetivo"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WebcamCapture": {
"display_name": "Captura de Webcam",
"inputs": {

View File

@@ -24,7 +24,6 @@
"assets": "دارایی‌ها",
"baseModels": "مدل‌های پایه",
"browseAssets": "مرور دارایی‌ها",
"byType": "بر اساس نوع",
"checkpoints": "چک‌پوینت‌ها",
"civitaiLinkExample": "{example} {link}",
"civitaiLinkExampleStrong": "مثال:",
@@ -46,10 +45,6 @@
"failed": "دانلود ناموفق بود",
"inProgress": "در حال دانلود {assetName}..."
},
"emptyImported": {
"canImport": "هنوز مدلی وارد نشده است. برای افزودن مدل خود، روی «وارد کردن مدل» کلیک کنید.",
"restricted": "مدل‌های شخصی فقط برای سطح Creator و بالاتر در دسترس هستند."
},
"errorFileTooLarge": "فایل از حداکثر اندازه مجاز بزرگ‌تر است",
"errorFormatNotAllowed": "فقط فرمت SafeTensor مجاز است",
"errorModelTypeNotSupported": "این نوع مدل پشتیبانی نمی‌شود",
@@ -66,7 +61,6 @@
"finish": "پایان",
"genericLinkPlaceholder": "لینک را اینجا وارد کنید",
"importAnother": "وارد کردن مورد دیگر",
"imported": "وارد شده",
"jobId": "شناسه کار: {jobId}",
"loadingModels": "در حال بارگذاری {type}...",
"maxFileSize": "حداکثر اندازه فایل: {size}",
@@ -76,29 +70,6 @@
"threeDModelPlaceholder": "مدل سه‌بعدی"
},
"modelAssociatedWithLink": "مدل مرتبط با لینکی که وارد کردید:",
"modelInfo": {
"addBaseModel": "افزودن مدل پایه...",
"addTag": "افزودن برچسب...",
"additionalTags": "برچسب‌های اضافی",
"baseModelUnknown": "مدل پایه نامشخص",
"basicInfo": "اطلاعات پایه",
"compatibleBaseModels": "مدل‌های پایه سازگار",
"description": "توضیحات",
"descriptionNotSet": "توضیحی تنظیم نشده است",
"descriptionPlaceholder": "یک توضیح برای این مدل اضافه کنید...",
"displayName": "نام نمایشی",
"fileName": "نام فایل",
"modelDescription": "توضیحات مدل",
"modelTagging": "برچسب‌گذاری مدل",
"modelType": "نوع مدل",
"noAdditionalTags": "برچسب اضافی وجود ندارد",
"selectModelPrompt": "یک مدل را برای مشاهده اطلاعات آن انتخاب کنید",
"selectModelType": "انتخاب نوع مدل...",
"source": "منبع",
"title": "اطلاعات مدل",
"triggerPhrases": "عبارات فعال‌ساز",
"viewOnSource": "مشاهده در {source}"
},
"modelName": "نام مدل",
"modelNamePlaceholder": "یک نام برای این مدل وارد کنید",
"modelTypeSelectorLabel": "نوع مدل چیست؟",
@@ -713,7 +684,6 @@
"clearAll": "پاک‌سازی همه",
"clearFilters": "پاک‌سازی فیلترها",
"close": "بستن",
"closeDialog": "بستن پنجره",
"color": "رنگ",
"comfy": "Comfy",
"comfyOrgLogoAlt": "لوگوی ComfyOrg",
@@ -792,8 +762,6 @@
"goToNode": "رفتن به node",
"graphNavigation": "ناوبری گراف",
"halfSpeed": "۰.۵x",
"hideLeftPanel": "پنهان کردن پنل چپ",
"hideRightPanel": "پنهان کردن پنل راست",
"icon": "آیکون",
"imageFailedToLoad": "بارگذاری تصویر ناموفق بود",
"imagePreview": "پیش‌نمایش تصویر - برای جابجایی بین تصاویر از کلیدهای جهت‌دار استفاده کنید",
@@ -835,7 +803,6 @@
"name": "نام",
"newFolder": "پوشه جدید",
"next": "بعدی",
"nightly": "نسخه شبانه",
"no": "خیر",
"noAudioRecorded": "هیچ صدایی ضبط نشد",
"noItems": "هیچ موردی وجود ندارد",
@@ -850,7 +817,6 @@
"nodeSlotsError": "خطا در slotهای node",
"nodeWidgetsError": "خطا در ابزارک‌های node",
"nodes": "nodeها",
"nodesCount": "{count} نود | {count} نود | {count} نود",
"nodesRunning": "nodeها در حال اجرا هستند",
"none": "هیچ‌کدام",
"nothingToCopy": "موردی برای کپی وجود ندارد",
@@ -925,9 +891,7 @@
"selectedFile": "فایل انتخاب‌شده",
"setAsBackground": "تنظیم به عنوان پس‌زمینه",
"settings": "تنظیمات",
"showLeftPanel": "نمایش پنل چپ",
"showReport": "نمایش گزارش",
"showRightPanel": "نمایش پنل راست",
"singleSelectDropdown": "لیست کشویی تک‌انتخابی",
"sort": "مرتب‌سازی",
"source": "منبع",
@@ -950,7 +914,6 @@
"updating": "در حال به‌روزرسانی {id}",
"upload": "بارگذاری",
"usageHint": "راهنمای استفاده",
"use": "استفاده",
"user": "کاربر",
"versionMismatchWarning": "هشدار ناسازگاری نسخه",
"versionMismatchWarningMessage": "{warning}: {detail} برای راهنمای به‌روزرسانی به https://docs.comfy.org/installation/update_comfyui#common-update-issues مراجعه کنید.",
@@ -1654,18 +1617,11 @@
"title": "این workflow دارای nodeهای مفقود است"
}
},
"nightly": {
"badge": {
"label": "نسخه پیش‌نمایش",
"tooltip": "شما در حال استفاده از نسخه شبانه ComfyUI هستید. لطفاً با استفاده از دکمه بازخورد، نظرات خود را درباره این قابلیت‌ها به اشتراک بگذارید."
}
},
"nodeCategories": {
"": "",
"3d": "سه‌بعدی",
"3d_models": "مدل‌های سه‌بعدی",
"BFL": "BFL",
"Bria": "Bria",
"ByteDance": "ByteDance",
"Gemini": "Gemini",
"Ideogram": "Ideogram",
@@ -1687,7 +1643,6 @@
"Veo": "Veo",
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
"_for_testing": "_for_testing",
"advanced": "پیشرفته",
"animation": "انیمیشن",
@@ -2174,14 +2129,12 @@
"viewControls": "کنترل‌های نمایش"
},
"sideToolbar": {
"activeJobStatus": "وضعیت کار فعال: {status}",
"assets": "دارایی‌ها",
"backToAssets": "بازگشت به همه دارایی‌ها",
"browseTemplates": "مرور قالب‌های نمونه",
"downloads": "دانلودها",
"generatedAssetsHeader": "دارایی‌های تولیدشده",
"helpCenter": "مرکز راهنما",
"importedAssetsHeader": "دارایی‌های واردشده",
"labels": {
"assets": "دارایی‌ها",
"console": "کنسول",
@@ -2237,7 +2190,6 @@
"queue": "صف",
"queueProgressOverlay": {
"activeJobs": "{count} کار فعال",
"activeJobsShort": "{count} فعال | {count} فعال",
"activeJobsSuffix": "کار فعال",
"cancelJobTooltip": "لغو کار",
"clearHistory": "پاک‌سازی تاریخچه صف کار",

View File

@@ -328,68 +328,6 @@
}
}
},
"BriaImageEditNode": {
"description": "ویرایش تصاویر با استفاده از جدیدترین مدل Bria",
"display_name": "ویرایش تصویر Bria",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"guidance_scale": {
"name": "مقیاس راهنما",
"tooltip": "مقدار بالاتر باعث می‌شود تصویر بیشتر از پرامپت پیروی کند."
},
"image": {
"name": "تصویر"
},
"mask": {
"name": "ماسک",
"tooltip": "در صورت عدم انتخاب، ویرایش بر کل تصویر اعمال می‌شود."
},
"model": {
"name": "مدل"
},
"moderation": {
"name": "تنظیمات نظارت",
"tooltip": "تنظیمات نظارت"
},
"moderation_prompt_content_moderation": {
"name": "نظارت بر محتوای پرامپت"
},
"moderation_visual_input_moderation": {
"name": "نظارت بر ورودی تصویری"
},
"moderation_visual_output_moderation": {
"name": "نظارت بر خروجی تصویری"
},
"negative_prompt": {
"name": "پرامپت منفی"
},
"prompt": {
"name": "پرامپت",
"tooltip": "دستورالعمل برای ویرایش تصویر"
},
"seed": {
"name": "بذر"
},
"steps": {
"name": "گام‌ها"
},
"structured_prompt": {
"name": "پرامپت ساختاریافته",
"tooltip": "یک رشته شامل پرامپت ویرایش ساختاریافته در قالب JSON. برای کنترل دقیق و برنامه‌نویسی شده، به جای پرامپت معمولی از این گزینه استفاده کنید."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "پرامپت ساختاریافته",
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "تولید ویدیو با استفاده از پرامپت و اولین و آخرین فریم.",
"display_name": "تبدیل اولین و آخرین فریم به ویدیو ByteDance",
@@ -13539,40 +13477,6 @@
}
}
},
"TextEncodeZImageOmni": {
"display_name": "TextEncodeZImageOmni",
"inputs": {
"auto_resize_images": {
"name": "تغییر اندازه خودکار تصاویر"
},
"clip": {
"name": "clip"
},
"image1": {
"name": "تصویر ۱"
},
"image2": {
"name": "تصویر ۲"
},
"image3": {
"name": "تصویر ۳"
},
"image_encoder": {
"name": "رمزگذار تصویر"
},
"prompt": {
"name": "پرامپت"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TextToLowercase": {
"display_name": "تبدیل متن به حروف کوچک",
"inputs": {
@@ -16264,43 +16168,6 @@
}
}
},
"WavespeedFlashVSRNode": {
"description": "افزایش‌دهنده سریع و با کیفیت ویدیو که وضوح را افزایش داده و شفافیت را برای ویدیوهای کم‌کیفیت یا تار بازمی‌گرداند.",
"display_name": "افزایش کیفیت ویدیو FlashVSR",
"inputs": {
"target_resolution": {
"name": "وضوح هدف"
},
"video": {
"name": "ویدیو"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WavespeedImageUpscaleNode": {
"description": "افزایش وضوح و کیفیت تصویر، ارتقاء عکس‌ها به ۴K یا ۸K برای نتایج شفاف و با جزئیات.",
"display_name": "افزایش کیفیت تصویر WaveSpeed",
"inputs": {
"image": {
"name": "تصویر"
},
"model": {
"name": "مدل"
},
"target_resolution": {
"name": "وضوح هدف"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WebcamCapture": {
"display_name": "دریافت از وب‌کم",
"inputs": {

Some files were not shown because too many files have changed in this diff Show More