Merge remote-tracking branch 'origin/main' into pysssss/asset-delete-progress

This commit is contained in:
pythongosssss
2026-01-09 15:33:06 +00:00
83 changed files with 2537 additions and 1670 deletions

View File

@@ -6,6 +6,7 @@ const { defineConfig } = require('@lobehub/i18n-cli');
module.exports = defineConfig({ module.exports = defineConfig({
modelName: 'gpt-4.1', modelName: 'gpt-4.1',
splitToken: 1024, splitToken: 1024,
saveImmediately: true,
entry: 'src/locales/en', entry: 'src/locales/en',
entryLocale: 'en', entryLocale: 'en',
output: 'src/locales', output: 'src/locales',

View File

@@ -7,7 +7,7 @@ import type { InlineConfig } from 'vite'
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: ['@storybook/addon-docs'], addons: ['@storybook/addon-docs', '@storybook/addon-mcp'],
framework: { framework: {
name: '@storybook/vue3-vite', name: '@storybook/vue3-vite',
options: {} options: {}

View File

@@ -63,6 +63,9 @@ The project uses **Nx** for build orchestration and task management
- Imports: - Imports:
- sorted/grouped by plugin - sorted/grouped by plugin
- run `pnpm format` before committing - run `pnpm format` before committing
- use separate `import type` statements, not inline `type` in mixed imports
-`import type { Foo } from './foo'` + `import { bar } from './foo'`
-`import { bar, type Foo } from './foo'`
- ESLint: - ESLint:
- Vue + TS rules - Vue + TS rules
- no floating promises - no floating promises
@@ -119,7 +122,10 @@ The project uses **Nx** for build orchestration and task management
- Prefer reactive props destructuring to `const props = defineProps<...>` - Prefer reactive props destructuring to `const props = defineProps<...>`
- Do not use `withDefaults` or runtime props declaration - Do not use `withDefaults` or runtime props declaration
- Do not import Vue macros unnecessarily - Do not import Vue macros unnecessarily
- Prefer `useModel` to separately defining a prop and emit - Prefer `defineModel` to separately defining a prop and emit for v-model bindings
- Define slots via template usage, not `defineSlots`
- Use same-name shorthand for slot prop bindings: `:isExpanded` instead of `:is-expanded="isExpanded"`
- Derive component types using `vue-component-type-helpers` (`ComponentProps`, `ComponentSlots`) instead of separate type files
- Be judicious with addition of new refs or other state - Be judicious with addition of new refs or other state
- If it's possible to accomplish the design goals with just a prop, don't add a `ref` - If it's possible to accomplish the design goals with just a prop, don't add a `ref`
- If it's possible to use the `ref` or prop directly, don't add a `computed` - If it's possible to use the `ref` or prop directly, don't add a `computed`
@@ -137,7 +143,7 @@ The project uses **Nx** for build orchestration and task management
8. Implement proper error handling 8. Implement proper error handling
9. Follow Vue 3 style guide and naming conventions 9. Follow Vue 3 style guide and naming conventions
10. Use Vite for fast development and building 10. Use Vite for fast development and building
11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json 11. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json. Use the plurals system in i18n instead of hardcoding pluralization in templates.
12. Avoid new usage of PrimeVue components 12. Avoid new usage of PrimeVue components
13. Write tests for all changes, especially bug fixes to catch future regressions 13. Write tests for all changes, especially bug fixes to catch future regressions
14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary 14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary
@@ -268,6 +274,8 @@ When referencing Comfy-Org repos:
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value - Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
- NEVER use `!important` or the `!` important prefix for tailwind classes - NEVER use `!important` or the `!` important prefix for tailwind classes
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead. - Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
## Agent-only rules ## Agent-only rules

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -1,7 +1,7 @@
{ {
"name": "@comfyorg/comfyui-frontend", "name": "@comfyorg/comfyui-frontend",
"private": true, "private": true,
"version": "1.37.5", "version": "1.37.6",
"type": "module", "type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org", "homepage": "https://comfy.org",
@@ -66,6 +66,7 @@
"@prettier/plugin-oxc": "catalog:", "@prettier/plugin-oxc": "catalog:",
"@sentry/vite-plugin": "catalog:", "@sentry/vite-plugin": "catalog:",
"@storybook/addon-docs": "catalog:", "@storybook/addon-docs": "catalog:",
"@storybook/addon-mcp": "catalog:",
"@storybook/vue3": "catalog:", "@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:", "@storybook/vue3-vite": "catalog:",
"@tailwindcss/vite": "catalog:", "@tailwindcss/vite": "catalog:",

134
pnpm-lock.yaml generated
View File

@@ -84,6 +84,9 @@ catalogs:
'@storybook/addon-docs': '@storybook/addon-docs':
specifier: ^10.1.9 specifier: ^10.1.9
version: 10.1.9 version: 10.1.9
'@storybook/addon-mcp':
specifier: 0.1.6
version: 0.1.6
'@storybook/vue3': '@storybook/vue3':
specifier: ^10.1.9 specifier: ^10.1.9
version: 10.1.9 version: 10.1.9
@@ -549,6 +552,9 @@ importers:
'@storybook/addon-docs': '@storybook/addon-docs':
specifier: 'catalog:' specifier: 'catalog:'
version: 10.1.9(@types/react@19.1.9)(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) version: 10.1.9(@types/react@19.1.9)(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@storybook/addon-mcp':
specifier: 'catalog:'
version: 0.1.6(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)
'@storybook/vue3': '@storybook/vue3':
specifier: 'catalog:' specifier: 'catalog:'
version: 10.1.9(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vue@3.5.13(typescript@5.9.3)) version: 10.1.9(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vue@3.5.13(typescript@5.9.3))
@@ -3148,6 +3154,11 @@ packages:
peerDependencies: peerDependencies:
storybook: ^10.1.9 storybook: ^10.1.9
'@storybook/addon-mcp@0.1.6':
resolution: {integrity: sha512-+EagCHqwIb9tg3DKskEsXpsqQVnMljxgR5Tt3Bu0ZpWweB1HdMy+ok128gzNfTZ3r+5ljksr0q66YCEkrQwdDA==}
peerDependencies:
storybook: ^9.1.16 || ^10.0.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0
'@storybook/builder-vite@10.1.9': '@storybook/builder-vite@10.1.9':
resolution: {integrity: sha512-rUILpjGV7gKfXrUeZzpNAer9PspB3LJI1d+gJHISx2Gs24bdneA3y/gu0fWw46ccOSIcwb91xoK5QxliJcWsWg==} resolution: {integrity: sha512-rUILpjGV7gKfXrUeZzpNAer9PspB3LJI1d+gJHISx2Gs24bdneA3y/gu0fWw46ccOSIcwb91xoK5QxliJcWsWg==}
peerDependencies: peerDependencies:
@@ -3181,6 +3192,9 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@storybook/mcp@0.1.1':
resolution: {integrity: sha512-+AivFDms1XkY2VUvZBBYy0co5qvRh20eYXYwhaDPQXX2Q4y96arSkWn22e/l3DQwA9Ywzv481vj4gl4zPrCQkg==}
'@storybook/react-dom-shim@10.1.9': '@storybook/react-dom-shim@10.1.9':
resolution: {integrity: sha512-gJsR6fI1gG4DSin6sQx8RmGDQF8Lije0cZbxHyVedNleBsveGXIPFUKFVi+pRNdwBPni1Z2g/gYyHzkOEqPD2w==} resolution: {integrity: sha512-gJsR6fI1gG4DSin6sQx8RmGDQF8Lije0cZbxHyVedNleBsveGXIPFUKFVi+pRNdwBPni1Z2g/gYyHzkOEqPD2w==}
peerDependencies: peerDependencies:
@@ -3453,6 +3467,26 @@ packages:
'@tiptap/starter-kit@2.10.4': '@tiptap/starter-kit@2.10.4':
resolution: {integrity: sha512-tu/WCs9Mkr5Nt8c3/uC4VvAbQlVX0OY7ygcqdzHGUeG9zP3twdW7o5xM3kyDKR2++sbVzqu5Ll5qNU+1JZvPGQ==} resolution: {integrity: sha512-tu/WCs9Mkr5Nt8c3/uC4VvAbQlVX0OY7ygcqdzHGUeG9zP3twdW7o5xM3kyDKR2++sbVzqu5Ll5qNU+1JZvPGQ==}
'@tmcp/adapter-valibot@0.1.5':
resolution: {integrity: sha512-9P2wrVYPngemNK0UvPb/opC722/jfd09QxXmme1TRp/wPsl98vpSk/MXt24BCMqBRv4Dvs0xxJH4KHDcjXW52Q==}
peerDependencies:
tmcp: ^1.17.0
valibot: ^1.1.0
'@tmcp/session-manager@0.2.1':
resolution: {integrity: sha512-DOGy9LfufXCy1wfpGHZ6qPSDQtRnTVwOb71+41ffovTqzLMZlK3iLK/LIsekHxIiku+iIAUiqEKN+DHbqEm8IA==}
peerDependencies:
tmcp: ^1.16.3
'@tmcp/transport-http@0.8.3':
resolution: {integrity: sha512-gnoBjDBd8/ppl4WRrNKPKHlioCxE8D0zTyNUOzqUjsg0s6GRsyB5iMirh9lC4QjQt0NEOrI+sIJdz+9ymf0MDA==}
peerDependencies:
'@tmcp/auth': ^0.3.3 || ^0.4.0
tmcp: ^1.18.0
peerDependenciesMeta:
'@tmcp/auth':
optional: true
'@trivago/prettier-plugin-sort-imports@5.2.2': '@trivago/prettier-plugin-sort-imports@5.2.2':
resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==} resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==}
engines: {node: '>18.12'} engines: {node: '>18.12'}
@@ -3786,6 +3820,11 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@valibot/to-json-schema@1.5.0':
resolution: {integrity: sha512-GE7DmSr1C2UCWPiV0upRH6mv0cCPsqYGs819fb6srCS1tWhyXrkGGe+zxUiwzn/L1BOfADH4sNjY/YHCuP8phQ==}
peerDependencies:
valibot: ^1.2.0
'@vitejs/plugin-vue@6.0.3': '@vitejs/plugin-vue@6.0.3':
resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==} resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -5170,6 +5209,9 @@ packages:
jiti: jiti:
optional: true optional: true
esm-env@1.2.2:
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
esm-resolve@1.0.11: esm-resolve@1.0.11:
resolution: {integrity: sha512-LxF0wfUQm3ldUDHkkV2MIbvvY0TgzIpJ420jHSV1Dm+IlplBEWiJTKWM61GtxUfvjV6iD4OtTYFGAGM2uuIUWg==} resolution: {integrity: sha512-LxF0wfUQm3ldUDHkkV2MIbvvY0TgzIpJ420jHSV1Dm+IlplBEWiJTKWM61GtxUfvjV6iD4OtTYFGAGM2uuIUWg==}
@@ -5978,6 +6020,9 @@ packages:
json-parse-even-better-errors@2.3.1: json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
json-rpc-2.0@1.7.1:
resolution: {integrity: sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==}
json-schema-traverse@0.4.1: json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -7386,6 +7431,9 @@ packages:
sprintf-js@1.0.3: sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
sqids@0.3.0:
resolution: {integrity: sha512-lOQK1ucVg+W6n3FhRwwSeUijxe93b51Bfz5PMRMihVf1iVkl82ePQG7V5vwrhzB11v0NtsR25PSZRGiSomJaJw==}
stable-hash-x@0.2.0: stable-hash-x@0.2.0:
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -7613,6 +7661,9 @@ packages:
resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==}
hasBin: true hasBin: true
tmcp@1.19.0:
resolution: {integrity: sha512-wOY449EdaWDo7wLZEOVjeH9fn/AqfFF4f+3pDerCI8xHpy2Z8msUjAF0Vkg01aEFIdFMmiNDiY4hu6E7jVX79w==}
tmp@0.2.5: tmp@0.2.5:
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
engines: {node: '>=14.14'} engines: {node: '>=14.14'}
@@ -7858,6 +7909,9 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
uri-template-matcher@1.1.2:
resolution: {integrity: sha512-uZc1h12jdO3m/R77SfTEOuo6VbMhgWznaawKpBjRGSJb7i91x5PgI37NQJtG+Cerxkk0yr1pylBY2qG1kQ+aEQ==}
use-sync-external-store@1.6.0: use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies: peerDependencies:
@@ -7873,6 +7927,14 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true hasBin: true
valibot@1.2.0:
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
typescript:
optional: true
vfile-message@4.0.3: vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -8019,6 +8081,9 @@ packages:
vue-component-type-helpers@3.2.1: vue-component-type-helpers@3.2.1:
resolution: {integrity: sha512-gKV7XOkQl4urSuLHNY1tnVQf7wVgtb/mKbRyxSLWGZUY9RK7aDPhBenTjm+i8ZFe0zC2PZeHMPtOZXZfyaFOzQ==} resolution: {integrity: sha512-gKV7XOkQl4urSuLHNY1tnVQf7wVgtb/mKbRyxSLWGZUY9RK7aDPhBenTjm+i8ZFe0zC2PZeHMPtOZXZfyaFOzQ==}
vue-component-type-helpers@3.2.2:
resolution: {integrity: sha512-x8C2nx5XlUNM0WirgfTkHjJGO/ABBxlANZDtHw2HclHtQnn+RFPTnbjMJn8jHZW4TlUam0asHcA14lf1C6Jb+A==}
vue-demi@0.14.10: vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -10949,6 +11014,18 @@ snapshots:
- vite - vite
- webpack - webpack
'@storybook/addon-mcp@0.1.6(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)':
dependencies:
'@storybook/mcp': 0.1.1(typescript@5.9.3)
'@tmcp/adapter-valibot': 0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))
'@tmcp/transport-http': 0.8.3(tmcp@1.19.0(typescript@5.9.3))
storybook: 10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
tmcp: 1.19.0(typescript@5.9.3)
valibot: 1.2.0(typescript@5.9.3)
transitivePeerDependencies:
- '@tmcp/auth'
- typescript
'@storybook/builder-vite@10.1.9(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))': '@storybook/builder-vite@10.1.9(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies: dependencies:
'@storybook/csf-plugin': 10.1.9(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) '@storybook/csf-plugin': 10.1.9(esbuild@0.27.1)(rollup@4.53.5)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
@@ -10978,6 +11055,16 @@ snapshots:
react: 19.2.3 react: 19.2.3
react-dom: 19.2.3(react@19.2.3) react-dom: 19.2.3(react@19.2.3)
'@storybook/mcp@0.1.1(typescript@5.9.3)':
dependencies:
'@tmcp/adapter-valibot': 0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))
'@tmcp/transport-http': 0.8.3(tmcp@1.19.0(typescript@5.9.3))
tmcp: 1.19.0(typescript@5.9.3)
valibot: 1.2.0(typescript@5.9.3)
transitivePeerDependencies:
- '@tmcp/auth'
- typescript
'@storybook/react-dom-shim@10.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))': '@storybook/react-dom-shim@10.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))':
dependencies: dependencies:
react: 19.2.3 react: 19.2.3
@@ -11007,7 +11094,7 @@ snapshots:
storybook: 10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) storybook: 10.1.9(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
type-fest: 2.19.0 type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.3) vue: 3.5.13(typescript@5.9.3)
vue-component-type-helpers: 3.2.1 vue-component-type-helpers: 3.2.2
'@swc/helpers@0.5.17': '@swc/helpers@0.5.17':
dependencies: dependencies:
@@ -11275,6 +11362,23 @@ snapshots:
'@tiptap/extension-text-style': 2.10.4(@tiptap/core@2.10.4(@tiptap/pm@2.10.4)) '@tiptap/extension-text-style': 2.10.4(@tiptap/core@2.10.4(@tiptap/pm@2.10.4))
'@tiptap/pm': 2.10.4 '@tiptap/pm': 2.10.4
'@tmcp/adapter-valibot@0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))':
dependencies:
'@standard-schema/spec': 1.1.0
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
tmcp: 1.19.0(typescript@5.9.3)
valibot: 1.2.0(typescript@5.9.3)
'@tmcp/session-manager@0.2.1(tmcp@1.19.0(typescript@5.9.3))':
dependencies:
tmcp: 1.19.0(typescript@5.9.3)
'@tmcp/transport-http@0.8.3(tmcp@1.19.0(typescript@5.9.3))':
dependencies:
'@tmcp/session-manager': 0.2.1(tmcp@1.19.0(typescript@5.9.3))
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)': '@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.25)(prettier@3.7.4)':
dependencies: dependencies:
'@babel/generator': 7.28.5 '@babel/generator': 7.28.5
@@ -11623,6 +11727,10 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1': '@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true optional: true
'@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3))':
dependencies:
valibot: 1.2.0(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))': '@vitejs/plugin-vue@6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53 '@rolldown/pluginutils': 1.0.0-beta.53
@@ -13303,6 +13411,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
esm-env@1.2.2: {}
esm-resolve@1.0.11: {} esm-resolve@1.0.11: {}
espree@10.4.0: espree@10.4.0:
@@ -14189,6 +14299,8 @@ snapshots:
json-parse-even-better-errors@2.3.1: {} json-parse-even-better-errors@2.3.1: {}
json-rpc-2.0@1.7.1: {}
json-schema-traverse@0.4.1: {} json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {} json-schema-traverse@1.0.0: {}
@@ -16055,6 +16167,8 @@ snapshots:
sprintf-js@1.0.3: {} sprintf-js@1.0.3: {}
sqids@0.3.0: {}
stable-hash-x@0.2.0: {} stable-hash-x@0.2.0: {}
stack-utils@2.0.6: stack-utils@2.0.6:
@@ -16347,6 +16461,16 @@ snapshots:
dependencies: dependencies:
tldts-core: 7.0.19 tldts-core: 7.0.19
tmcp@1.19.0(typescript@5.9.3):
dependencies:
'@standard-schema/spec': 1.1.0
json-rpc-2.0: 1.7.1
sqids: 0.3.0
uri-template-matcher: 1.1.2
valibot: 1.2.0(typescript@5.9.3)
transitivePeerDependencies:
- typescript
tmp@0.2.5: {} tmp@0.2.5: {}
to-regex-range@5.0.1: to-regex-range@5.0.1:
@@ -16644,6 +16768,8 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
uri-template-matcher@1.1.2: {}
use-sync-external-store@1.6.0(react@19.2.3): use-sync-external-store@1.6.0(react@19.2.3):
dependencies: dependencies:
react: 19.2.3 react: 19.2.3
@@ -16654,6 +16780,10 @@ snapshots:
uuid@11.1.0: {} uuid@11.1.0: {}
valibot@1.2.0(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
vfile-message@4.0.3: vfile-message@4.0.3:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
@@ -16914,6 +17044,8 @@ snapshots:
vue-component-type-helpers@3.2.1: {} vue-component-type-helpers@3.2.1: {}
vue-component-type-helpers@3.2.2: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)): vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
dependencies: dependencies:
vue: 3.5.13(typescript@5.9.3) vue: 3.5.13(typescript@5.9.3)

View File

@@ -29,6 +29,7 @@ catalog:
'@sentry/vue': ^10.32.1 '@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^0.1.10 '@sparkjsdev/spark': ^0.1.10
'@storybook/addon-docs': ^10.1.9 '@storybook/addon-docs': ^10.1.9
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.1.9 '@storybook/vue3': ^10.1.9
'@storybook/vue3-vite': ^10.1.9 '@storybook/vue3-vite': ^10.1.9
'@tailwindcss/vite': ^4.1.12 '@tailwindcss/vite': ^4.1.12

View File

@@ -20,9 +20,14 @@
variant="secondary" variant="secondary"
size="icon" size="icon"
:aria-label="t('menu.customNodesManager')" :aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager" @click="openCustomNodeManager"
> >
<i class="icon-[lucide--puzzle] size-4" /> <i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button> </Button>
</div> </div>
@@ -49,7 +54,7 @@
<i class="icon-[lucide--history] size-4" /> <i class="icon-[lucide--history] size-4" />
<span <span
v-if="queuedCount > 0" 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-white" 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 }} {{ queuedCount }}
</span> </span>
@@ -91,12 +96,14 @@ import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore' import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore' import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore' import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil' import { isElectron } from '@/utils/envUtil'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState' import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes' import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
@@ -111,6 +118,10 @@ const commandStore = useCommandStore()
const queueStore = useQueueStore() const queueStore = useQueueStore()
const queueUIStore = useQueueUIStore() const queueUIStore = useQueueUIStore()
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore) const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false) const isTopMenuHovered = ref(false)
const queuedCount = computed(() => queueStore.pendingTasks.length) const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() => const queueHistoryTooltipConfig = computed(() =>
@@ -120,6 +131,12 @@ const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager')) buildTooltipConfig(t('menu.customNodesManager'))
) )
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value
})
// Right side panel toggle // Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore) const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() => const rightSidePanelTooltipConfig = computed(() =>

View File

@@ -2,8 +2,8 @@
<div <div
:class=" :class="
cn( cn(
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer', 'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer bg-secondary-background',
backgroundClass || 'bg-secondary-background' backgroundClass
) )
" "
> >

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
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="badgeClasses(severity)">{{ label }}</span>
</template>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex w-full items-center justify-between p-4"> <div class="flex w-full items-center justify-between p-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<i class="icon-[lucide--triangle-alert] text-gold-600"></i> <i class="icon-[lucide--triangle-alert] text-warning-background"></i>
<p class="m-0 text-sm"> <p class="m-0 text-sm">
{{ {{
isCloud isCloud

View File

@@ -1,180 +1,259 @@
<template> <template>
<div class="flex w-112 flex-col gap-8 p-8"> <div
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- Header --> <!-- Header -->
<div class="flex flex-col gap-4"> <div class="flex py-8 items-center justify-between px-8">
<h1 class="text-2xl font-semibold text-base-foreground m-0"> <h2 class="text-lg font-bold text-base-foreground m-0">
{{ {{
isInsufficientCredits isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun') ? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits') : $t('credits.topUp.addMoreCredits')
}} }}
</h1> </h2>
<div v-if="isInsufficientCredits" class="flex flex-col gap-2"> <button
<p class="text-sm text-muted-foreground m-0 w-96"> class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
{{ $t('credits.topUp.insufficientWorkflowMessage') }} @click="() => handleClose()"
</p> >
</div> <i class="icon-[lucide--x] size-6" />
<div v-else class="flex flex-col gap-2"> </button>
<p class="text-sm text-muted-foreground m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
</div>
</div> </div>
<p
v-if="isInsufficientCredits"
class="text-sm text-muted-foreground m-0 px-8"
>
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
<!-- Current Balance Section --> <!-- Preset amount buttons -->
<div class="flex flex-col gap-4"> <div class="px-8">
<div class="flex items-baseline gap-2"> <h3 class="m-0 text-sm font-normal text-muted-foreground">
<UserCredit text-class="text-3xl font-bold" show-credits-only /> {{ $t('credits.topUp.selectAmount') }}
<span class="text-sm text-muted-foreground">{{ </h3>
$t('credits.creditsAvailable') <div class="flex gap-2 pt-3">
}}</span> <Button
</div> v-for="amount in PRESET_AMOUNTS"
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground"> :key="amount"
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }} :autofocus="amount === 50"
</div> variant="secondary"
</div> size="lg"
:class="
<!-- Credit Options Section --> cn(
<div class="flex flex-col gap-4"> 'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
<span class="text-sm text-muted-foreground"> selectedPreset === amount && 'bg-secondary-background-selected'
{{ $t('credits.topUp.howManyCredits') }} )
</span> "
<div class="flex flex-col gap-2"> @click="handlePresetClick(amount)"
<CreditTopUpOption
v-for="option in creditOptions"
:key="option.credits"
:credits="option.credits"
:description="option.description"
:selected="selectedCredits === option.credits"
@select="selectedCredits = option.credits"
/>
</div>
<div class="flex flex-row items-center gap-2 group pt-2">
<i
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
/>
<span
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
@click="togglePopover"
> >
{{ t('subscription.videoTemplateBasedCredits') }} ${{ amount }}
</span> </Button>
</div>
</div>
<!-- Amount (USD) / Credits -->
<div class="flex gap-2 px-8 pt-8">
<!-- You Pay -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youPay') }}
</div>
<FormattedNumberStepper
:model-value="payAmount"
:min="0"
:max="MAX_AMOUNT"
:step="getStepAmount"
@update:model-value="handlePayAmountChange"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<span class="shrink-0 text-base font-semibold text-base-foreground"
>$</span
>
</template>
</FormattedNumberStepper>
</div> </div>
<!-- Buy Button --> <!-- You Get -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youGet') }}
</div>
<FormattedNumberStepper
v-model="creditsModel"
:min="0"
:max="usdToCredits(MAX_AMOUNT)"
:step="getCreditsStepAmount"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
</div>
<!-- Warnings -->
<p
v-if="isBelowMin"
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center"
>
{{
$t('credits.topUp.minimumPurchase', {
amount: MIN_AMOUNT,
credits: usdToCredits(MIN_AMOUNT)
})
}}
</p>
<p
v-if="showCeilingWarning"
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center"
>
{{
$t('credits.topUp.maximumAmount', {
amount: formatNumber(MAX_AMOUNT)
})
}}
<span>{{ $t('credits.topUp.needMore') }}</span>
<a
href="https://www.comfy.org/cloud/enterprise"
target="_blank"
class="ml-1 text-inherit"
>{{ $t('credits.topUp.contactUs') }}</a
>
</p>
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
<Button <Button
:disabled="!selectedCredits || loading" :disabled="!isValidAmount || loading"
:loading="loading" :loading="loading"
variant="primary" variant="primary"
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')" size="lg"
class="h-10 justify-center"
@click="handleBuy" @click="handleBuy"
> >
{{ $t('credits.topUp.buy') }} {{ $t('credits.topUp.buyCredits') }}
</Button> </Button>
</div> <div class="flex items-center justify-center gap-1">
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class:
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
}
}"
>
<div class="flex flex-col gap-2">
<p class="text-sm text-base-foreground leading-normal">
{{ t('subscription.videoEstimateExplanation') }}
</p>
<a <a
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera" :href="pricingUrl"
target="_blank" target="_blank"
rel="noopener noreferrer" class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
> >
<span class="underline"> {{ $t('credits.topUp.viewPricing') }}
{{ t('subscription.videoEstimateTryTemplate') }} <i class="icon-[lucide--external-link] size-4" />
</span>
<span class="no-underline" v-html="'&rarr;'"></span>
</a> </a>
</div> </div>
</Popover> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Popover } from 'primevue'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { creditsToUsd } from '@/base/credits/comfyCredits' import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import UserCredit from '@/components/common/UserCredit.vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry' import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
interface CreditOption {
credits: number
description: string
}
const { isInsufficientCredits = false } = defineProps<{ const { isInsufficientCredits = false } = defineProps<{
isInsufficientCredits?: boolean isInsufficientCredits?: boolean
}>() }>()
const { formattedRenewalDate } = useSubscription()
const { t } = useI18n() const { t } = useI18n()
const authActions = useFirebaseAuthActions() const authActions = useFirebaseAuthActions()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const telemetry = useTelemetry() const telemetry = useTelemetry()
const toast = useToast() const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const selectedCredits = ref<number | null>(null) // Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
const MIN_AMOUNT = 5
const MAX_AMOUNT = 10000
// State
const selectedPreset = ref<number | null>(50)
const payAmount = ref(50)
const showCeilingWarning = ref(false)
const loading = ref(false) const loading = ref(false)
const popover = ref() // Computed
const pricingUrl = computed(() =>
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
)
const togglePopover = (event: Event) => { const creditsModel = computed({
popover.value.toggle(event) get: () => usdToCredits(payAmount.value),
set: (newCredits: number) => {
payAmount.value = Math.round(creditsToUsd(newCredits))
selectedPreset.value = null
}
})
const isValidAmount = computed(
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
)
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
// Utility functions
function formatNumber(num: number): string {
return num.toLocaleString('en-US')
} }
const creditOptions: CreditOption[] = [ // Step amount functions
{ function getStepAmount(currentAmount: number): number {
credits: 1055, // $5.00 if (currentAmount < 100) return 5
description: t('credits.topUp.videosEstimate', { count: 30 }) if (currentAmount < 1000) return 50
}, return 100
{ }
credits: 2110, // $10.00
description: t('credits.topUp.videosEstimate', { count: 60 })
},
{
credits: 4220, // $20.00
description: t('credits.topUp.videosEstimate', { count: 120 })
},
{
credits: 10550, // $50.00
description: t('credits.topUp.videosEstimate', { count: 301 })
}
]
const handleBuy = async () => { function getCreditsStepAmount(currentCredits: number): number {
if (!selectedCredits.value) return const usdAmount = creditsToUsd(currentCredits)
return usdToCredits(getStepAmount(usdAmount))
}
// Event handlers
function handlePayAmountChange(value: number) {
payAmount.value = value
selectedPreset.value = null
showCeilingWarning.value = false
}
function handlePresetClick(amount: number) {
showCeilingWarning.value = false
payAmount.value = amount
selectedPreset.value = amount
}
function handleClose(clearTracking = true) {
if (clearTracking) {
clearTopupTracking()
}
dialogStore.closeDialog({ key: 'top-up-credits' })
}
async function handleBuy() {
// Prevent double-clicks
if (loading.value || !isValidAmount.value) return
loading.value = true loading.value = true
try { try {
const usdAmount = creditsToUsd(selectedCredits.value) telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount) await authActions.purchaseCredits(payAmount.value)
await authActions.purchaseCredits(usdAmount)
// Close top-up dialog (keep tracking) and open subscription panel to show updated credits
handleClose(false)
dialogService.showSettingsDialog('subscription')
} catch (error) { } catch (error) {
console.error('Purchase failed:', error) console.error('Purchase failed:', error)

View File

@@ -0,0 +1,292 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
import Button from '@/components/ui/button/Button.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'
import HoneyToast from './HoneyToast.vue'
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
return {
taskId: 'task-1',
assetId: 'asset-1',
assetName: 'model-v1.safetensors',
bytesTotal: 1000000,
bytesDownloaded: 0,
progress: 0,
status: 'created',
...overrides
}
}
const meta: Meta<typeof HoneyToast> = {
title: 'Toast/HoneyToast',
component: HoneyToast,
parameters: {
layout: 'fullscreen'
},
decorators: [
() => ({
template: '<div class="h-screen bg-base-background p-8"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(false)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'completed',
progress: 1
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'running',
progress: 0.45
}),
createMockJob({
taskId: 'task-3',
assetName: 'vae-decoder.safetensors',
status: 'created'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">1 of 3</span>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Expanded: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(true)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'completed',
progress: 1
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'running',
progress: 0.45
}),
createMockJob({
taskId: 'task-3',
assetName: 'vae-decoder.safetensors',
status: 'created'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">1 of 3</span>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Completed: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(false)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
bytesDownloaded: 1000000,
progress: 1,
status: 'completed'
}),
createMockJob({
taskId: 'task-2',
assetId: 'asset-2',
assetName: 'lora-style.safetensors',
bytesTotal: 500000,
bytesDownloaded: 500000,
progress: 1,
status: 'completed'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
<span class="font-bold text-base-foreground">All downloads completed</span>
</div>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
<Button variant="muted-textonly" size="icon">
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const WithError: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(true)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'failed',
progress: 0.23
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'completed',
progress: 1
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--circle-alert] size-4 text-destructive-background" />
<span class="font-bold text-base-foreground">1 download failed</span>
</div>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
<Button variant="muted-textonly" size="icon">
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Hidden: Story = {
render: () => ({
components: { HoneyToast },
template: `
<div>
<p class="text-base-foreground">HoneyToast is hidden when visible=false. Nothing appears at the bottom.</p>
<HoneyToast :visible="false">
<template #default>
<div class="px-4 py-4">Content</div>
</template>
<template #footer>
<div class="h-12 px-4">Footer</div>
</template>
</HoneyToast>
</div>
`
})
}

View File

@@ -0,0 +1,137 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
import HoneyToast from './HoneyToast.vue'
describe('HoneyToast', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
})
function mountComponent(
props: { visible: boolean; expanded?: boolean } = { visible: true }
): VueWrapper {
return mount(HoneyToast, {
props,
slots: {
default: (slotProps: { isExpanded: boolean }) =>
h(
'div',
{ 'data-testid': 'content' },
slotProps.isExpanded ? 'expanded' : 'collapsed'
),
footer: (slotProps: { isExpanded: boolean; toggle: () => void }) =>
h(
'button',
{
'data-testid': 'toggle-btn',
onClick: slotProps.toggle
},
slotProps.isExpanded ? 'Collapse' : 'Expand'
)
},
attachTo: document.body
})
}
it('renders when visible is true', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeTruthy()
wrapper.unmount()
})
it('does not render when visible is false', async () => {
const wrapper = mountComponent({ visible: false })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeFalsy()
wrapper.unmount()
})
it('passes is-expanded=false to slots by default', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
wrapper.unmount()
})
it('applies collapsed max-height class when collapsed', async () => {
const wrapper = mountComponent({ visible: true, expanded: false })
await nextTick()
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
wrapper.unmount()
})
it('has aria-live="polite" for accessibility', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast?.getAttribute('aria-live')).toBe('polite')
wrapper.unmount()
})
it('supports v-model:expanded with reactive parent state', async () => {
const TestWrapper = defineComponent({
components: { HoneyToast },
setup() {
const expanded = ref(false)
return { expanded }
},
template: `
<HoneyToast :visible="true" v-model:expanded="expanded">
<template #default="slotProps">
<div data-testid="content">{{ slotProps.isExpanded ? 'expanded' : 'collapsed' }}</div>
</template>
<template #footer="slotProps">
<button data-testid="toggle-btn" @click="slotProps.toggle">
{{ slotProps.isExpanded ? 'Collapse' : 'Expand' }}
</button>
</template>
</HoneyToast>
`
})
const wrapper = mount(TestWrapper, { attachTo: document.body })
await nextTick()
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
const toggleBtn = document.body.querySelector(
'[data-testid="toggle-btn"]'
) as HTMLButtonElement
expect(toggleBtn?.textContent?.trim()).toBe('Expand')
toggleBtn?.click()
await nextTick()
expect(content?.textContent).toBe('expanded')
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
wrapper.unmount()
})
})

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const { visible } = defineProps<{
visible: boolean
}>()
const isExpanded = defineModel<boolean>('expanded', { default: false })
function toggle() {
isExpanded.value = !isExpanded.value
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="translate-y-full opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-full opacity-0"
>
<div
v-if="visible"
role="status"
aria-live="polite"
class="fixed inset-x-0 bottom-6 z-50 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
>
<div
:class="
cn(
'overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-[400px]' : 'max-h-0'
)
"
>
<slot :is-expanded />
</div>
<slot name="footer" :is-expanded :toggle />
</div>
</Transition>
</Teleport>
</template>

View File

@@ -160,7 +160,7 @@
> >
<i <i
v-if="slotProps.selected" v-if="slotProps.selected"
class="text-bold icon-[lucide--check] text-xs text-white" class="text-bold icon-[lucide--check] text-xs text-base-foreground"
/> />
</div> </div>
<span> <span>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-smoke-700/30" class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-backdrop/30"
@pointerdown.stop @pointerdown.stop
@pointermove.stop @pointermove.stop
@pointerup.stop @pointerup.stop
@@ -14,12 +14,12 @@
class="rounded-full" class="rounded-full"
@click="toggleMenu" @click="toggleMenu"
> >
<i class="pi pi-bars text-lg text-white" /> <i class="pi pi-bars text-lg text-base-foreground" />
</Button> </Button>
<div <div
v-show="isMenuOpen" v-show="isMenuOpen"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg" class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
> >
<div class="flex flex-col"> <div class="flex flex-col">
<Button <Button
@@ -29,13 +29,13 @@
:class=" :class="
cn( cn(
'flex w-full items-center justify-start', 'flex w-full items-center justify-start',
activeCategory === category && 'bg-smoke-600' activeCategory === category && 'bg-button-active-surface'
) )
" "
@click="selectCategory(category)" @click="selectCategory(category)"
> >
<i :class="getCategoryIcon(category)" /> <i :class="getCategoryIcon(category)" />
<span class="whitespace-nowrap text-white">{{ <span class="whitespace-nowrap text-base-foreground">{{
$t(categoryLabels[category]) $t(categoryLabels[category])
}}</span> }}</span>
</Button> </Button>
@@ -169,7 +169,7 @@ const getCategoryIcon = (category: string) => {
export: 'pi pi-download' export: 'pi pi-download'
} }
// @ts-expect-error fixme ts strict error // @ts-expect-error fixme ts strict error
return `${icons[category]} text-white text-lg` return `${icons[category]} text-base-foreground text-lg`
} }
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -2,11 +2,11 @@
<Transition name="fade"> <Transition name="fade">
<div <div
v-if="loading" v-if="loading"
class="bg-opacity-50 absolute inset-0 z-50 flex items-center justify-center bg-black" class="absolute inset-0 z-50 flex items-center justify-center bg-backdrop/50"
> >
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div class="spinner" /> <div class="spinner" />
<div class="mt-4 text-lg text-white"> <div class="mt-4 text-lg text-base-foreground">
{{ loadingMessage }} {{ loadingMessage }}
</div> </div>
</div> </div>

View File

@@ -15,7 +15,7 @@
:class="[ :class="[
'pi', 'pi',
playing ? 'pi-pause' : 'pi-play', playing ? 'pi-pause' : 'pi-play',
'text-lg text-white' 'text-lg text-base-foreground'
]" ]"
/> />
</Button> </Button>
@@ -46,7 +46,7 @@
class="flex-1" class="flex-1"
@update:model-value="handleSliderChange" @update:model-value="handleSliderChange"
/> />
<span class="min-w-16 text-xs text-white"> <span class="min-w-16 text-xs text-base-foreground">
{{ formatTime(currentTime) }} / {{ formatTime(animationDuration) }} {{ formatTime(currentTime) }} / {{ formatTime(animationDuration) }}
</span> </span>
</div> </div>

View File

@@ -11,7 +11,7 @@
:aria-label="$t('load3d.switchCamera')" :aria-label="$t('load3d.switchCamera')"
@click="switchCamera" @click="switchCamera"
> >
<i :class="['pi', 'pi-camera', 'text-lg text-white']" /> <i :class="['pi', 'pi-camera', 'text-lg text-base-foreground']" />
</Button> </Button>
<PopupSlider <PopupSlider
v-if="showFOVButton" v-if="showFOVButton"

View File

@@ -12,18 +12,18 @@
:aria-label="$t('load3d.exportModel')" :aria-label="$t('load3d.exportModel')"
@click="toggleExportFormats" @click="toggleExportFormats"
> >
<i class="pi pi-download text-lg text-white" /> <i class="pi pi-download text-lg text-base-foreground" />
</Button> </Button>
<div <div
v-show="showExportFormats" v-show="showExportFormats"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg" class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
> >
<div class="flex flex-col"> <div class="flex flex-col">
<Button <Button
v-for="format in exportFormats" v-for="format in exportFormats"
:key="format.value" :key="format.value"
variant="textonly" variant="textonly"
class="text-white" class="text-base-foreground"
@click="exportModel(format.value)" @click="exportModel(format.value)"
> >
{{ format.label }} {{ format.label }}

View File

@@ -12,7 +12,7 @@
:aria-label="$t('load3d.lightIntensity')" :aria-label="$t('load3d.lightIntensity')"
@click="toggleLightIntensity" @click="toggleLightIntensity"
> >
<i class="pi pi-sun text-lg text-white" /> <i class="pi pi-sun text-lg text-base-foreground" />
</Button> </Button>
<div <div
v-show="showLightIntensity" v-show="showLightIntensity"

View File

@@ -12,11 +12,11 @@
:aria-label="t('load3d.upDirection')" :aria-label="t('load3d.upDirection')"
@click="toggleUpDirection" @click="toggleUpDirection"
> >
<i class="pi pi-arrow-up text-lg text-white" /> <i class="pi pi-arrow-up text-lg text-base-foreground" />
</Button> </Button>
<div <div
v-show="showUpDirection" v-show="showUpDirection"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg" class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
> >
<div class="flex flex-col"> <div class="flex flex-col">
<Button <Button
@@ -24,7 +24,10 @@
:key="direction" :key="direction"
variant="textonly" variant="textonly"
:class=" :class="
cn('text-white', upDirection === direction && 'bg-blue-500') cn(
'text-base-foreground',
upDirection === direction && 'bg-blue-500'
)
" "
@click="selectUpDirection(direction)" @click="selectUpDirection(direction)"
> >
@@ -46,11 +49,11 @@
:aria-label="t('load3d.materialMode')" :aria-label="t('load3d.materialMode')"
@click="toggleMaterialMode" @click="toggleMaterialMode"
> >
<i class="pi pi-box text-lg text-white" /> <i class="pi pi-box text-lg text-base-foreground" />
</Button> </Button>
<div <div
v-show="showMaterialMode" v-show="showMaterialMode"
class="absolute top-0 left-12 rounded-lg bg-black/50 shadow-lg" class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
> >
<div class="flex flex-col"> <div class="flex flex-col">
<Button <Button
@@ -59,7 +62,7 @@
variant="textonly" variant="textonly"
:class=" :class="
cn( cn(
'whitespace-nowrap text-white', 'whitespace-nowrap text-base-foreground',
materialMode === mode && 'bg-blue-500' materialMode === mode && 'bg-blue-500'
) )
" "
@@ -83,7 +86,7 @@
:aria-label="t('load3d.showSkeleton')" :aria-label="t('load3d.showSkeleton')"
@click="showSkeleton = !showSkeleton" @click="showSkeleton = !showSkeleton"
> >
<i class="pi pi-sitemap text-lg text-white" /> <i class="pi pi-sitemap text-lg text-base-foreground" />
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -8,11 +8,11 @@
:aria-label="tooltipText" :aria-label="tooltipText"
@click="toggleSlider" @click="toggleSlider"
> >
<i :class="['pi', icon, 'text-lg text-white']" /> <i :class="['pi', icon, 'text-lg text-base-foreground']" />
</Button> </Button>
<div <div
v-show="showSlider" v-show="showSlider"
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg w-[150px]" class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface p-4 shadow-lg w-[150px]"
> >
<Slider <Slider
v-model="value" v-model="value"

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="relative rounded-lg bg-smoke-700/30"> <div class="relative rounded-lg bg-backdrop/30">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Button <Button
v-tooltip.right="{ v-tooltip.right="{
@@ -25,7 +25,7 @@
:class="[ :class="[
'pi', 'pi',
isRecording ? 'pi-circle-fill' : 'pi-video', isRecording ? 'pi-circle-fill' : 'pi-video',
'text-lg text-white' 'text-lg text-base-foreground'
]" ]"
/> />
</Button> </Button>
@@ -42,7 +42,7 @@
:aria-label="$t('load3d.exportRecording')" :aria-label="$t('load3d.exportRecording')"
@click="handleExportRecording" @click="handleExportRecording"
> >
<i class="pi pi-download text-lg text-white" /> <i class="pi pi-download text-lg text-base-foreground" />
</Button> </Button>
<Button <Button
@@ -57,12 +57,12 @@
:aria-label="$t('load3d.clearRecording')" :aria-label="$t('load3d.clearRecording')"
@click="handleClearRecording" @click="handleClearRecording"
> >
<i class="pi pi-trash text-lg text-white" /> <i class="pi pi-trash text-lg text-base-foreground" />
</Button> </Button>
<div <div
v-if="recordingDuration && recordingDuration > 0 && !isRecording" v-if="recordingDuration && recordingDuration > 0 && !isRecording"
class="mt-1 text-center text-xs text-white" class="mt-1 text-center text-xs text-base-foreground"
> >
{{ formatDuration(recordingDuration) }} {{ formatDuration(recordingDuration) }}
</div> </div>

View File

@@ -8,7 +8,7 @@
:aria-label="$t('load3d.showGrid')" :aria-label="$t('load3d.showGrid')"
@click="toggleGrid" @click="toggleGrid"
> >
<i class="pi pi-table text-lg text-white" /> <i class="pi pi-table text-lg text-base-foreground" />
</Button> </Button>
<div v-if="!hasBackgroundImage"> <div v-if="!hasBackgroundImage">
@@ -23,7 +23,7 @@
:aria-label="$t('load3d.backgroundColor')" :aria-label="$t('load3d.backgroundColor')"
@click="openColorPicker" @click="openColorPicker"
> >
<i class="pi pi-palette text-lg text-white" /> <i class="pi pi-palette text-lg text-base-foreground" />
<input <input
ref="colorPickerRef" ref="colorPickerRef"
type="color" type="color"
@@ -48,7 +48,7 @@
:aria-label="$t('load3d.uploadBackgroundImage')" :aria-label="$t('load3d.uploadBackgroundImage')"
@click="openImagePicker" @click="openImagePicker"
> >
<i class="pi pi-image text-lg text-white" /> <i class="pi pi-image text-lg text-base-foreground" />
<input <input
ref="imagePickerRef" ref="imagePickerRef"
type="file" type="file"
@@ -76,7 +76,7 @@
:aria-label="$t('load3d.panoramaMode')" :aria-label="$t('load3d.panoramaMode')"
@click="toggleBackgroundRenderMode" @click="toggleBackgroundRenderMode"
> >
<i class="pi pi-globe text-lg text-white" /> <i class="pi pi-globe text-lg text-base-foreground" />
</Button> </Button>
</div> </div>
@@ -98,7 +98,7 @@
:aria-label="$t('load3d.removeBackgroundImage')" :aria-label="$t('load3d.removeBackgroundImage')"
@click="removeBackgroundImage" @click="removeBackgroundImage"
> >
<i class="pi pi-times text-lg text-white" /> <i class="pi pi-times text-lg text-base-foreground" />
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="relative rounded-lg bg-smoke-700/30"> <div class="relative rounded-lg bg-backdrop/30">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Button <Button
v-tooltip.right="{ v-tooltip.right="{
@@ -12,7 +12,7 @@
:aria-label="t('load3d.openIn3DViewer')" :aria-label="t('load3d.openIn3DViewer')"
@click="openIn3DViewer" @click="openIn3DViewer"
> >
<i class="pi pi-expand text-lg text-white" /> <i class="pi pi-expand text-lg text-base-foreground" />
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -114,11 +114,15 @@
:output-count="getOutputCount(item)" :output-count="getOutputCount(item)"
:show-delete-button="shouldShowDeleteButton" :show-delete-button="shouldShowDeleteButton"
:open-context-menu-id="openContextMenuId" :open-context-menu-id="openContextMenuId"
:selected-assets="getSelectedAssets(displayAssets)"
:has-selection="hasSelection"
@click="handleAssetSelect(item)" @click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)" @zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)" @output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets" @asset-deleted="refreshAssets"
@context-menu-opened="openContextMenuId = item.id" @context-menu-opened="openContextMenuId = item.id"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
/> />
</template> </template>
</VirtualGrid> </VirtualGrid>
@@ -134,7 +138,6 @@
<div ref="selectionCountButtonRef" class="inline-flex w-48"> <div ref="selectionCountButtonRef" class="inline-flex w-48">
<Button <Button
variant="secondary" variant="secondary"
size="lg"
:class="cn(isCompact && 'text-left')" :class="cn(isCompact && 'text-left')"
@click="handleDeselectAll" @click="handleDeselectAll"
> >
@@ -243,11 +246,6 @@ const shouldShowDeleteButton = computed(() => {
return true return true
}) })
const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 1
}
const shouldShowOutputCount = (item: AssetItem): boolean => { const shouldShowOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) { if (activeTab.value !== 'output' || isInFolderView.value) {
return false return false
@@ -285,6 +283,8 @@ const {
hasSelection, hasSelection,
clearSelection, clearSelection,
getSelectedAssets, getSelectedAssets,
getOutputCount,
getTotalOutputCount,
activate: activateSelection, activate: activateSelection,
deactivate: deactivateSelection deactivate: deactivateSelection
} = useAssetSelection() } = useAssetSelection()
@@ -316,7 +316,7 @@ const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
// Total output count for all selected assets // Total output count for all selected assets
const totalOutputCount = computed(() => { const totalOutputCount = computed(() => {
const selectedAssets = getSelectedAssets(displayAssets.value) const selectedAssets = getSelectedAssets(displayAssets.value)
return selectedAssets.reduce((sum, asset) => sum + getOutputCount(asset), 0) return getTotalOutputCount(selectedAssets)
}) })
const currentAssets = computed(() => const currentAssets = computed(() =>
@@ -537,6 +537,16 @@ const handleDeleteSelected = async () => {
clearSelection() clearSelection()
} }
const handleBulkDownload = (assets: AssetItem[]) => {
downloadMultipleAssets(assets)
clearSelection()
}
const handleBulkDelete = async (assets: AssetItem[]) => {
await deleteMultipleAssets(assets)
clearSelection()
}
const handleClearQueue = async () => { const handleClearQueue = async () => {
await commandStore.execute('Comfy.ClearPendingTasks') await commandStore.execute('Comfy.ClearPendingTasks')
} }

View File

@@ -0,0 +1,94 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import ProgressToastItem from './ProgressToastItem.vue'
const meta: Meta<typeof ProgressToastItem> = {
title: 'Toast/ProgressToastItem',
component: ProgressToastItem,
parameters: {
layout: 'padded'
},
decorators: [
() => ({
template: '<div class="w-[400px] bg-base-background p-4"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
return {
taskId: 'task-1',
assetId: 'asset-1',
assetName: 'model-v1.safetensors',
bytesTotal: 1000000,
bytesDownloaded: 0,
progress: 0,
status: 'created',
...overrides
}
}
export const Pending: Story = {
args: {
job: createMockJob({
status: 'created',
assetName: 'sd-xl-base-1.0.safetensors'
})
}
}
export const Running: Story = {
args: {
job: createMockJob({
status: 'running',
progress: 0.45,
assetName: 'lora-detail-enhancer.safetensors'
})
}
}
export const RunningAlmostComplete: Story = {
args: {
job: createMockJob({
status: 'running',
progress: 0.92,
assetName: 'vae-ft-mse-840000.safetensors'
})
}
}
export const Completed: Story = {
args: {
job: createMockJob({
status: 'completed',
progress: 1,
assetName: 'controlnet-canny.safetensors'
})
}
}
export const Failed: Story = {
args: {
job: createMockJob({
status: 'failed',
progress: 0.23,
assetName: 'unreachable-model.safetensors'
})
}
}
export const LongFileName: Story = {
args: {
job: createMockJob({
status: 'running',
progress: 0.67,
assetName:
'very-long-model-name-with-lots-of-descriptive-text-v2.1-final-release.safetensors'
})
}
}

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import StatusBadge from '@/components/common/StatusBadge.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'
const { job } = defineProps<{
job: AssetDownload
}>()
const { t } = useI18n()
const progressPercent = computed(() => Math.round(job.progress * 100))
const isCompleted = computed(() => job.status === 'completed')
const isFailed = computed(() => job.status === 'failed')
const isRunning = computed(() => job.status === 'running')
const isPending = computed(() => job.status === 'created')
</script>
<template>
<div
:class="
cn(
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
isCompleted && 'opacity-50'
)
"
>
<div class="flex flex-col">
<span class="text-sm text-base-foreground">{{ job.assetName }}</span>
<span v-if="isRunning" class="text-xs text-muted-foreground"> </span>
</div>
<div class="flex items-center gap-2">
<template v-if="isFailed">
<i
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
/>
<StatusBadge :label="t('progressToast.failed')" severity="danger" />
</template>
<template v-else-if="isCompleted">
<StatusBadge :label="t('progressToast.finished')" severity="contrast" />
</template>
<template v-else-if="isRunning">
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-base-foreground"
/>
<span class="text-xs text-base-foreground">
{{ progressPercent }}%
</span>
</template>
<template v-else-if="isPending">
<span class="text-xs text-muted-foreground">
{{ t('progressToast.pending') }}
</span>
</template>
</div>
</div>
</template>

View File

@@ -161,7 +161,7 @@ describe('TopbarBadge', () => {
) )
expect(wrapper.find('.bg-gold-600').exists()).toBe(true) expect(wrapper.find('.bg-gold-600').exists()).toBe(true)
expect(wrapper.find('.text-gold-600').exists()).toBe(true) expect(wrapper.find('.text-warning-background').exists()).toBe(true)
}) })
it('uses default error icon for error variant', () => { it('uses default error icon for error variant', () => {
@@ -185,7 +185,9 @@ describe('TopbarBadge', () => {
'full' 'full'
) )
expect(wrapper.find('.pi-exclamation-triangle').exists()).toBe(true) expect(wrapper.find('.icon-\\[lucide--triangle-alert\\]').exists()).toBe(
true
)
}) })
}) })

View File

@@ -174,7 +174,7 @@ const textClasses = computed(() => {
case 'error': case 'error':
return 'text-danger-100' return 'text-danger-100'
case 'warning': case 'warning':
return 'text-gold-600' return 'text-warning-background'
case 'info': case 'info':
default: default:
return 'text-text-primary' return 'text-text-primary'
@@ -191,7 +191,7 @@ const iconClass = computed(() => {
case 'error': case 'error':
return 'pi pi-exclamation-circle' return 'pi pi-exclamation-circle'
case 'warning': case 'warning':
return 'pi pi-exclamation-triangle' return 'icon-[lucide--triangle-alert]'
case 'info': case 'info':
default: default:
return undefined return undefined

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva' import { cva } from 'cva'
export const buttonVariants = cva({ export const buttonVariants = cva({
base: 'inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
variants: { variants: {
variant: { variant: {
secondary: secondary:

View File

@@ -0,0 +1,176 @@
<template>
<div
:class="
cn(
'flex h-10 items-center rounded-lg bg-secondary-background text-secondary-foreground hover:bg-secondary-background-hover',
disabled && 'opacity-50 pointer-events-none'
)
"
>
<button
type="button"
class="flex h-full w-8 cursor-pointer items-center justify-center rounded-l-lg border-none bg-transparent text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-secondary-foreground disabled:opacity-30"
:disabled="disabled || modelValue <= min"
:aria-label="$t('g.decrement')"
@click="handleStep(-1)"
>
<i class="icon-[lucide--minus] size-4" />
</button>
<div
class="flex flex-1 items-center justify-center gap-0.5 overflow-hidden"
>
<slot name="prefix" />
<input
ref="inputRef"
v-model="inputValue"
type="text"
inputmode="numeric"
:style="{ width: `${inputWidth}ch` }"
class="min-w-0 rounded border-none bg-transparent text-center text-base-foreground font-medium text-lg focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:disabled="disabled"
@input="handleInputChange"
@blur="handleInputBlur"
@focus="handleInputFocus"
/>
<slot name="suffix" />
</div>
<button
type="button"
class="flex h-full w-8 cursor-pointer items-center justify-center rounded-r-lg border-none bg-transparent text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-secondary-foreground disabled:opacity-30"
:disabled="disabled || modelValue >= max"
:aria-label="$t('g.increment')"
@click="handleStep(1)"
>
<i class="icon-[lucide--plus] size-4" />
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const {
min = 0,
max = Infinity,
step = 1,
formatOptions = { useGrouping: true },
disabled = false
} = defineProps<{
min?: number
max?: number
step?: number | ((value: number) => number)
formatOptions?: Intl.NumberFormatOptions
disabled?: boolean
}>()
const emit = defineEmits<{
'max-reached': []
}>()
const modelValue = defineModel<number>({ required: true })
const inputRef = ref<HTMLInputElement | null>(null)
const inputValue = ref(formatNumber(modelValue.value))
const inputWidth = computed(() =>
Math.min(Math.max(inputValue.value.length, 1) + 0.5, 9)
)
watch(modelValue, (newValue) => {
if (document.activeElement !== inputRef.value) {
inputValue.value = formatNumber(newValue)
}
})
function formatNumber(num: number): string {
return num.toLocaleString('en-US', formatOptions)
}
function parseFormattedNumber(str: string): number {
const cleaned = str.replace(/[^0-9]/g, '')
return cleaned === '' ? 0 : parseInt(cleaned, 10)
}
function clamp(value: number, minVal: number, maxVal: number): number {
return Math.min(Math.max(value, minVal), maxVal)
}
function formatWithCursor(
value: string,
cursorPos: number
): { formatted: string; newCursor: number } {
const num = parseFormattedNumber(value)
const formatted = formatNumber(num)
const digitsBeforeCursor = value
.slice(0, cursorPos)
.replace(/[^0-9]/g, '').length
let digitCount = 0
let newCursor = 0
for (let i = 0; i < formatted.length; i++) {
if (/[0-9]/.test(formatted[i])) {
digitCount++
}
if (digitCount >= digitsBeforeCursor) {
newCursor = i + 1
break
}
}
if (digitCount < digitsBeforeCursor) {
newCursor = formatted.length
}
return { formatted, newCursor }
}
function getStepAmount(): number {
return typeof step === 'function' ? step(modelValue.value) : step
}
function handleInputChange(e: Event) {
const input = e.target as HTMLInputElement
const raw = input.value
const cursorPos = input.selectionStart ?? raw.length
const num = parseFormattedNumber(raw)
const clamped = Math.min(num, max)
const wasClamped = num > max
if (wasClamped) {
emit('max-reached')
}
modelValue.value = clamped
const { formatted, newCursor } = formatWithCursor(
wasClamped ? formatNumber(clamped) : raw,
wasClamped ? formatNumber(clamped).length : cursorPos
)
inputValue.value = formatted
requestAnimationFrame(() => {
inputRef.value?.setSelectionRange(newCursor, newCursor)
})
}
function handleInputBlur() {
const clamped = clamp(modelValue.value, min, max)
modelValue.value = clamped
inputValue.value = formatNumber(clamped)
}
function handleInputFocus(e: FocusEvent) {
;(e.target as HTMLInputElement).select()
}
function handleStep(direction: 1 | -1) {
const stepAmount = getStepAmount()
const newValue = clamp(modelValue.value + stepAmount * direction, min, max)
modelValue.value = newValue
inputValue.value = formatNumber(newValue)
}
</script>

View File

@@ -13,10 +13,11 @@
<i class="icon-[lucide--panel-right] text-sm" /> <i class="icon-[lucide--panel-right] text-sm" />
</Button> </Button>
<Button <Button
class="absolute top-4 right-6 z-10 transition-opacity duration-200" size="lg"
class="absolute top-4 right-6 z-10 transition-opacity duration-200 w-10"
@click="closeDialog" @click="closeDialog"
> >
<i class="pi pi-times text-sm"></i> <i class="pi pi-times" />
</Button> </Button>
<div class="flex h-full w-full"> <div class="flex h-full w-full">
<Transition name="slide-panel"> <Transition name="slide-panel">
@@ -80,7 +81,9 @@
> >
{{ contentTitle }} {{ contentTitle }}
</h2> </h2>
<div class="min-h-0 px-6 pt-0 pb-10 overflow-y-auto"> <div
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
>
<slot name="content"></slot> <slot name="content"></slot>
</div> </div>
</main> </main>

View File

@@ -9,7 +9,7 @@
role="button" role="button"
@click="onClick" @click="onClick"
> >
<div v-if="icon" class="py-0.5"> <div v-if="icon" class="pt-0.5">
<NavIcon :icon="icon" /> <NavIcon :icon="icon" />
</div> </div>
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" /> <i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />

View File

@@ -1664,31 +1664,41 @@ describe('useNodePricing', () => {
{ {
model: 'gemini-2.5-pro-preview-05-06', model: 'gemini-2.5-pro-preview-05-06',
expected: creditsListLabel([0.00125, 0.01], { expected: creditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'gemini-2.5-pro', model: 'gemini-2.5-pro',
expected: creditsListLabel([0.00125, 0.01], { expected: creditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'gemini-3-pro-preview', model: 'gemini-3-pro-preview',
expected: creditsListLabel([0.002, 0.012], { expected: creditsListLabel([0.002, 0.012], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'gemini-2.5-flash-preview-04-17', model: 'gemini-2.5-flash-preview-04-17',
expected: creditsListLabel([0.0003, 0.0025], { expected: creditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'gemini-2.5-flash', model: 'gemini-2.5-flash',
expected: creditsListLabel([0.0003, 0.0025], { expected: creditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ model: 'unknown-gemini-model', expected: 'Token-based' } { model: 'unknown-gemini-model', expected: 'Token-based' }
@@ -1702,16 +1712,6 @@ describe('useNodePricing', () => {
}) })
}) })
it('should return per-second pricing for Gemini Veo models', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('GeminiNode', [
{ name: 'model', value: 'veo-2.0-generate-001' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe(creditsLabel(0.5, { suffix: '/second' }))
})
it('should return fallback for GeminiNode without model widget', () => { it('should return fallback for GeminiNode without model widget', () => {
const { getNodeDisplayPrice } = useNodePricing() const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('GeminiNode', []) const node = createMockNode('GeminiNode', [])
@@ -1737,73 +1737,97 @@ describe('useNodePricing', () => {
{ {
model: 'o4-mini', model: 'o4-mini',
expected: creditsListLabel([0.0011, 0.0044], { expected: creditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'o1-pro', model: 'o1-pro',
expected: creditsListLabel([0.15, 0.6], { expected: creditsListLabel([0.15, 0.6], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'o1', model: 'o1',
expected: creditsListLabel([0.015, 0.06], { expected: creditsListLabel([0.015, 0.06], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'o3-mini', model: 'o3-mini',
expected: creditsListLabel([0.0011, 0.0044], { expected: creditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'o3', model: 'o3',
expected: creditsListLabel([0.01, 0.04], { expected: creditsListLabel([0.01, 0.04], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'gpt-4o', model: 'gpt-4o',
expected: creditsListLabel([0.0025, 0.01], { expected: creditsListLabel([0.0025, 0.01], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'gpt-4.1-nano', model: 'gpt-4.1-nano',
expected: creditsListLabel([0.0001, 0.0004], { expected: creditsListLabel([0.0001, 0.0004], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'gpt-4.1-mini', model: 'gpt-4.1-mini',
expected: creditsListLabel([0.0004, 0.0016], { expected: creditsListLabel([0.0004, 0.0016], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'gpt-4.1', model: 'gpt-4.1',
expected: creditsListLabel([0.002, 0.008], { expected: creditsListLabel([0.002, 0.008], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'gpt-5-nano', model: 'gpt-5-nano',
expected: creditsListLabel([0.00005, 0.0004], { expected: creditsListLabel([0.00005, 0.0004], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'gpt-5-mini', model: 'gpt-5-mini',
expected: creditsListLabel([0.00025, 0.002], { expected: creditsListLabel([0.00025, 0.002], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'gpt-5', model: 'gpt-5',
expected: creditsListLabel([0.00125, 0.01], { expected: creditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} }
] ]
@@ -1824,37 +1848,49 @@ describe('useNodePricing', () => {
{ {
model: 'gpt-4.1-nano-test', model: 'gpt-4.1-nano-test',
expected: creditsListLabel([0.0001, 0.0004], { expected: creditsListLabel([0.0001, 0.0004], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'gpt-4.1-mini-test', model: 'gpt-4.1-mini-test',
expected: creditsListLabel([0.0004, 0.0016], { expected: creditsListLabel([0.0004, 0.0016], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'gpt-4.1-test', model: 'gpt-4.1-test',
expected: creditsListLabel([0.002, 0.008], { expected: creditsListLabel([0.002, 0.008], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'o1-pro-test', model: 'o1-pro-test',
expected: creditsListLabel([0.15, 0.6], { expected: creditsListLabel([0.15, 0.6], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'o1-test', model: 'o1-test',
expected: creditsListLabel([0.015, 0.06], { expected: creditsListLabel([0.015, 0.06], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ {
model: 'o3-mini-test', model: 'o3-mini-test',
expected: creditsListLabel([0.0011, 0.0044], { expected: creditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
}, },
{ model: 'unknown-model', expected: 'Token-based' } { model: 'unknown-model', expected: 'Token-based' }

View File

@@ -1823,28 +1823,35 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const model = String(modelWidget.value) const model = String(modelWidget.value)
// Google Veo video generation if (model.includes('gemini-2.5-flash-preview-04-17')) {
if (model.includes('veo-2.0')) {
return formatCreditsLabel(0.5, { suffix: '/second' })
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
return formatCreditsListLabel([0.0003, 0.0025], { return formatCreditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('gemini-2.5-flash')) { } else if (model.includes('gemini-2.5-flash')) {
return formatCreditsListLabel([0.0003, 0.0025], { return formatCreditsListLabel([0.0003, 0.0025], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('gemini-2.5-pro-preview-05-06')) { } else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return formatCreditsListLabel([0.00125, 0.01], { return formatCreditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('gemini-2.5-pro')) { } else if (model.includes('gemini-2.5-pro')) {
return formatCreditsListLabel([0.00125, 0.01], { return formatCreditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('gemini-3-pro-preview')) { } else if (model.includes('gemini-3-pro-preview')) {
return formatCreditsListLabel([0.002, 0.012], { return formatCreditsListLabel([0.002, 0.012], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} }
// For other Gemini models, show token-based pricing info // For other Gemini models, show token-based pricing info
@@ -1899,51 +1906,75 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
// Specific pricing for exposed models based on official pricing data (converted to per 1K tokens) // Specific pricing for exposed models based on official pricing data (converted to per 1K tokens)
if (model.includes('o4-mini')) { if (model.includes('o4-mini')) {
return formatCreditsListLabel([0.0011, 0.0044], { return formatCreditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('o1-pro')) { } else if (model.includes('o1-pro')) {
return formatCreditsListLabel([0.15, 0.6], { return formatCreditsListLabel([0.15, 0.6], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('o1')) { } else if (model.includes('o1')) {
return formatCreditsListLabel([0.015, 0.06], { return formatCreditsListLabel([0.015, 0.06], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('o3-mini')) { } else if (model.includes('o3-mini')) {
return formatCreditsListLabel([0.0011, 0.0044], { return formatCreditsListLabel([0.0011, 0.0044], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('o3')) { } else if (model.includes('o3')) {
return formatCreditsListLabel([0.01, 0.04], { return formatCreditsListLabel([0.01, 0.04], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('gpt-4o')) { } else if (model.includes('gpt-4o')) {
return formatCreditsListLabel([0.0025, 0.01], { return formatCreditsListLabel([0.0025, 0.01], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('gpt-4.1-nano')) { } else if (model.includes('gpt-4.1-nano')) {
return formatCreditsListLabel([0.0001, 0.0004], { return formatCreditsListLabel([0.0001, 0.0004], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('gpt-4.1-mini')) { } else if (model.includes('gpt-4.1-mini')) {
return formatCreditsListLabel([0.0004, 0.0016], { return formatCreditsListLabel([0.0004, 0.0016], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('gpt-4.1')) { } else if (model.includes('gpt-4.1')) {
return formatCreditsListLabel([0.002, 0.008], { return formatCreditsListLabel([0.002, 0.008], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('gpt-5-nano')) { } else if (model.includes('gpt-5-nano')) {
return formatCreditsListLabel([0.00005, 0.0004], { return formatCreditsListLabel([0.00005, 0.0004], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('gpt-5-mini')) { } else if (model.includes('gpt-5-mini')) {
return formatCreditsListLabel([0.00025, 0.002], { return formatCreditsListLabel([0.00025, 0.002], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} else if (model.includes('gpt-5')) { } else if (model.includes('gpt-5')) {
return formatCreditsListLabel([0.00125, 0.01], { return formatCreditsListLabel([0.00125, 0.01], {
suffix: ' per 1K tokens' suffix: ' per 1K tokens',
approximate: true,
separator: '-'
}) })
} }
return 'Token-based' return 'Token-based'
@@ -2101,6 +2132,35 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
}, },
LtxvApiImageToVideo: { LtxvApiImageToVideo: {
displayPrice: ltxvPricingCalculator displayPrice: ltxvPricingCalculator
},
WanReferenceVideoApi: {
displayPrice: (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const sizeWidget = node.widgets?.find(
(w) => w.name === 'size'
) as IComboWidget
if (!durationWidget || !sizeWidget) {
return formatCreditsRangeLabel(0.7, 1.5, {
note: '(varies with size & duration)'
})
}
const seconds = parseFloat(String(durationWidget.value))
const sizeStr = String(sizeWidget.value).toLowerCase()
const rate = sizeStr.includes('1080p') ? 0.15 : 0.1
const inputMin = 2 * rate
const inputMax = 5 * rate
const outputPrice = seconds * rate
const minTotal = inputMin + outputPrice
const maxTotal = inputMax + outputPrice
return formatCreditsRangeLabel(minTotal, maxTotal)
}
} }
} }
@@ -2254,6 +2314,7 @@ export const useNodePricing = () => {
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'], ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
WanTextToVideoApi: ['duration', 'size'], WanTextToVideoApi: ['duration', 'size'],
WanImageToVideoApi: ['duration', 'resolution'], WanImageToVideoApi: ['duration', 'resolution'],
WanReferenceVideoApi: ['duration', 'size'],
LtxvApiTextToVideo: ['model', 'duration', 'resolution'], LtxvApiTextToVideo: ['model', 'duration', 'resolution'],
LtxvApiImageToVideo: ['model', 'duration', 'resolution'] LtxvApiImageToVideo: ['model', 'duration', 'resolution']
} }

View File

@@ -905,15 +905,6 @@ export function useCoreCommands(): ComfyCommand[] {
}) })
} }
}, },
{
id: 'Comfy.Manager.ToggleManagerProgressDialog',
icon: 'pi pi-spinner',
label: 'Toggle the Custom Nodes Manager Progress Bar',
versionAdded: '1.13.9',
function: () => {
dialogService.toggleManagerProgressDialog()
}
},
{ {
id: 'Comfy.User.OpenSignInDialog', id: 'Comfy.User.OpenSignInDialog',
icon: 'pi pi-user', icon: 'pi pi-user',

View File

@@ -28,6 +28,7 @@ import type {
} from '@/lib/litegraph/src/types/serialisation' } from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { UUID } from '@/lib/litegraph/src/utils/uuid' import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { AssetWidget } from '@/lib/litegraph/src/widgets/AssetWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap' import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { ExecutableNodeDTO } from './ExecutableNodeDTO' import { ExecutableNodeDTO } from './ExecutableNodeDTO'
@@ -333,6 +334,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode( const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(
this this
) )
if (widget instanceof AssetWidget)
promotedWidget.options.nodeType ??= widget.node.type
Object.assign(promotedWidget, { Object.assign(promotedWidget, {
get name() { get name() {

View File

@@ -27,6 +27,8 @@ export interface IWidgetOptions<TValues = unknown[]> {
socketless?: boolean socketless?: boolean
/** If `true`, the widget will not be rendered by the Vue renderer. */ /** If `true`, the widget will not be rendered by the Vue renderer. */
canvasOnly?: boolean canvasOnly?: boolean
/** Used as a temporary override for determining the asset type in vue mode*/
nodeType?: string
values?: TValues values?: TValues
/** Optional function to format values for display (e.g., hash → human-readable name) */ /** Optional function to format values for display (e.g., hash → human-readable name) */

View File

@@ -10,8 +10,10 @@
"downloadVideo": "Download video", "downloadVideo": "Download video",
"editOrMaskImage": "Edit or mask image", "editOrMaskImage": "Edit or mask image",
"editImage": "Edit image", "editImage": "Edit image",
"decrement": "Decrement",
"deleteImage": "Delete image", "deleteImage": "Delete image",
"deleteAudioFile": "Delete audio file", "deleteAudioFile": "Delete audio file",
"increment": "Increment",
"removeImage": "Remove image", "removeImage": "Remove image",
"removeVideo": "Remove video", "removeVideo": "Remove video",
"chart": "Chart", "chart": "Chart",
@@ -1918,12 +1920,25 @@
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.", "insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
"creditsDescription": "Credits are used to run workflows or partner nodes.", "creditsDescription": "Credits are used to run workflows or partner nodes.",
"howManyCredits": "How many credits would you like to add?", "howManyCredits": "How many credits would you like to add?",
"videosEstimate": "~{count} videos", "usdAmount": "${amount}",
"videosEstimate": "~{count} videos*",
"templateNote": "*Generated with Wan Fun Control template", "templateNote": "*Generated with Wan Fun Control template",
"buy": "Buy", "buy": "Buy",
"purchaseError": "Purchase Failed", "purchaseError": "Purchase Failed",
"purchaseErrorDetail": "Failed to purchase credits: {error}", "purchaseErrorDetail": "Failed to purchase credits: {error}",
"unknownError": "An unknown error occurred" "unknownError": "An unknown error occurred",
"viewPricing": "View pricing details",
"youPay": "Amount (USD)",
"youGet": "Credits",
"buyCredits": "Continue to payment",
"minimumPurchase": "${amount} minimum ({credits} credits)",
"maximumAmount": "${amount} max.",
"creditsPerDollar": "credits per dollar",
"amountToPayLabel": "Amount to pay in dollars",
"creditsToReceiveLabel": "Credits to receive",
"selectAmount": "Select amount",
"needMore": "Need more?",
"contactUs": "Contact us"
}, },
"creditsAvailable": "Credits available", "creditsAvailable": "Credits available",
"refreshes": "Refreshes {date}", "refreshes": "Refreshes {date}",
@@ -1960,9 +1975,9 @@
"monthlyBonusDescription": "Monthly credit bonus", "monthlyBonusDescription": "Monthly credit bonus",
"prepaidDescription": "Pre-paid credits", "prepaidDescription": "Pre-paid credits",
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.", "prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
"creditsRemainingThisMonth": "Credits remaining this month", "creditsRemainingThisMonth": "Included (Refills {date})",
"creditsRemainingThisYear": "Credits remaining this year", "creditsRemainingThisYear": "Included (Refills {date})",
"creditsYouveAdded": "Credits you've added", "creditsYouveAdded": "Additional",
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over", "monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
"viewMoreDetailsPlans": "View more details about plans & pricing", "viewMoreDetailsPlans": "View more details about plans & pricing",
"nextBillingCycle": "next billing cycle", "nextBillingCycle": "next billing cycle",
@@ -2017,7 +2032,7 @@
"subscribeTo": "Subscribe to {plan}", "subscribeTo": "Subscribe to {plan}",
"monthlyCreditsLabel": "Monthly credits", "monthlyCreditsLabel": "Monthly credits",
"yearlyCreditsLabel": "Total yearly credits", "yearlyCreditsLabel": "Total yearly credits",
"maxDurationLabel": "Max duration of each workflow run", "maxDurationLabel": "Max run duration",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)", "gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever", "addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs", "customLoRAsLabel": "Import your own LoRAs",
@@ -2395,9 +2410,12 @@
}, },
"selection": { "selection": {
"selectedCount": "Assets Selected: {count}", "selectedCount": "Assets Selected: {count}",
"multipleSelectedAssets": "Multiple assets selected",
"deselectAll": "Deselect all", "deselectAll": "Deselect all",
"downloadSelected": "Download", "downloadSelected": "Download",
"downloadSelectedAll": "Download all",
"deleteSelected": "Delete", "deleteSelected": "Delete",
"deleteSelectedAll": "Delete all",
"downloadStarted": "Downloading {count} files...", "downloadStarted": "Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file(s)", "downloadsStarted": "Started downloading {count} file(s)",
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully", "assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
@@ -2480,5 +2498,21 @@
"help": { "help": {
"recentReleases": "Recent releases", "recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu" "helpCenterMenu": "Help Center Menu"
},
"progressToast": {
"importingModels": "Importing Models",
"downloadingModel": "Downloading model...",
"downloadsFailed": "{count} downloads failed | {count} download failed | {count} downloads failed",
"allDownloadsCompleted": "All downloads completed",
"noImportsInQueue": "No {filter} in queue",
"failed": "Failed",
"finished": "Finished",
"pending": "Pending",
"progressCount": "{completed} of {total}",
"filter": {
"all": "All",
"completed": "Completed",
"failed": "Failed"
}
} }
} }

View File

@@ -6088,6 +6088,9 @@
}, },
"ckpt_name": { "ckpt_name": {
"name": "ckpt_name" "name": "ckpt_name"
},
"device": {
"name": "device"
} }
}, },
"outputs": { "outputs": {
@@ -15365,6 +15368,51 @@
} }
} }
}, },
"WanReferenceVideoApi": {
"display_name": "Wan Reference to Video",
"description": "Use the character and voice from input videos, combined with a prompt, to generate a new video that maintains character consistency.",
"inputs": {
"model": {
"name": "model"
},
"prompt": {
"name": "prompt",
"tooltip": "Prompt describing the elements and visual features. Supports English and Chinese. Use identifiers such as `character1` and `character2` to refer to the reference characters."
},
"negative_prompt": {
"name": "negative_prompt",
"tooltip": "Negative prompt describing what to avoid."
},
"reference_videos": {
"name": "reference_videos"
},
"size": {
"name": "size"
},
"duration": {
"name": "duration"
},
"seed": {
"name": "seed"
},
"shot_type": {
"name": "shot_type",
"tooltip": "Specifies the shot type for the generated video, that is, whether the video is a single continuous shot or multiple shots with cuts."
},
"watermark": {
"name": "watermark",
"tooltip": "Whether to add an AI-generated watermark to the result."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WanSoundImageToVideo": { "WanSoundImageToVideo": {
"display_name": "WanSoundImageToVideo", "display_name": "WanSoundImageToVideo",
"inputs": { "inputs": {

View File

@@ -5,7 +5,7 @@
:key="badge.label" :key="badge.label"
:class=" :class="
cn( cn(
'px-2 py-1 rounded text-xs font-bold uppercase tracking-wider text-modal-card-tag-foreground bg-modal-card-tag-background' 'px-2 py-1 rounded text-xs font-bold uppercase tracking-wider text-modal-card-tag-foreground bg-modal-card-tag-background break-all'
) )
" "
> >

View File

@@ -32,7 +32,7 @@
<Button <Button
v-if="isUploadButtonEnabled" v-if="isUploadButtonEnabled"
variant="primary" variant="primary"
:size="breakpoints.md ? 'md' : 'icon'" :size="breakpoints.md ? 'lg' : 'icon'"
data-attr="upload-model-button" data-attr="upload-model-button"
@click="showUploadDialog" @click="showUploadDialog"
> >

View File

@@ -48,7 +48,7 @@
@update:model-value="handleFilterChange" @update:model-value="handleFilterChange"
> >
<template #icon> <template #icon>
<i class="icon-[lucide--arrow-up-down] size-3" /> <i class="icon-[lucide--arrow-up-down]" />
</template> </template>
</SingleSelect> </SingleSelect>
</div> </div>

View File

@@ -1,28 +1,21 @@
<template> <template>
<div <div
data-component-id="AssetGrid" data-component-id="AssetGrid"
:class=" class="h-full"
cn('grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-4 p-2')
"
role="grid" role="grid"
:aria-label="$t('assetBrowser.assetCollection')" :aria-label="$t('assetBrowser.assetCollection')"
:aria-rowcount="-1" :aria-rowcount="-1"
:aria-colcount="-1" :aria-colcount="-1"
:aria-setsize="assets.length" :aria-setsize="assets.length"
> >
<!-- Loading state --> <div v-if="loading" class="flex h-full items-center justify-center py-20">
<div
v-if="loading"
class="col-span-full flex items-center justify-center py-20"
>
<i <i
class="icon-[lucide--loader] size-12 animate-spin text-muted-foreground" class="icon-[lucide--loader] size-12 animate-spin text-muted-foreground"
/> />
</div> </div>
<!-- Empty state -->
<div <div
v-else-if="assets.length === 0" v-else-if="assets.length === 0"
class="col-span-full flex flex-col items-center justify-center py-16 text-muted-foreground" class="flex h-full flex-col items-center justify-center py-16 text-muted-foreground"
> >
<i class="mb-4 icon-[lucide--search] size-10" /> <i class="mb-4 icon-[lucide--search] size-10" />
<h3 class="mb-2 text-lg font-medium"> <h3 class="mb-2 text-lg font-medium">
@@ -30,24 +23,33 @@
</h3> </h3>
<p class="text-sm">{{ $t('assetBrowser.tryAdjustingFilters') }}</p> <p class="text-sm">{{ $t('assetBrowser.tryAdjustingFilters') }}</p>
</div> </div>
<template v-else> <VirtualGrid
<AssetCard v-else
v-for="asset in assets" :items="assetsWithKey"
:key="asset.id" :grid-style="gridStyle"
:asset="asset" :default-item-height="320"
:interactive="true" :default-item-width="240"
@select="$emit('assetSelect', $event)" >
/> <template #item="{ item }">
</template> <AssetCard
:asset="item"
:interactive="true"
@select="$emit('assetSelect', $event)"
/>
</template>
</VirtualGrid>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { CSSProperties } from 'vue'
import { computed } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import AssetCard from '@/platform/assets/components/AssetCard.vue' import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { cn } from '@/utils/tailwindUtil'
defineProps<{ const { assets } = defineProps<{
assets: AssetDisplayItem[] assets: AssetDisplayItem[]
loading?: boolean loading?: boolean
}>() }>()
@@ -55,4 +57,15 @@ defineProps<{
defineEmits<{ defineEmits<{
assetSelect: [asset: AssetDisplayItem] assetSelect: [asset: AssetDisplayItem]
}>() }>()
const assetsWithKey = computed(() =>
assets.map((asset) => ({ ...asset, key: asset.id }))
)
const gridStyle: Partial<CSSProperties> = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(15rem, 1fr))',
gap: '1rem',
padding: '0.5rem'
}
</script> </script>

View File

@@ -139,9 +139,13 @@
:asset-type="assetType" :asset-type="assetType"
:file-kind="fileKind" :file-kind="fileKind"
:show-delete-button="showDeleteButton" :show-delete-button="showDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="hasSelection && (selectedAssets?.length ?? 0) > 1"
@zoom="handleZoomClick" @zoom="handleZoomClick"
@asset-deleted="emit('asset-deleted')" @asset-deleted="emit('asset-deleted')"
@asset-deleting="isDeleting = $event" @asset-deleting="isDeleting = $event"
@bulk-download="emit('bulk-download', $event)"
@bulk-delete="emit('bulk-delete', $event)"
/> />
</template> </template>
@@ -187,7 +191,9 @@ const {
showOutputCount, showOutputCount,
outputCount, outputCount,
showDeleteButton, showDeleteButton,
openContextMenuId openContextMenuId,
selectedAssets,
hasSelection
} = defineProps<{ } = defineProps<{
asset?: AssetItem asset?: AssetItem
loading?: boolean loading?: boolean
@@ -196,6 +202,8 @@ const {
outputCount?: number outputCount?: number
showDeleteButton?: boolean showDeleteButton?: boolean
openContextMenuId?: string | null openContextMenuId?: string | null
selectedAssets?: AssetItem[]
hasSelection?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -204,6 +212,8 @@ const emit = defineEmits<{
'output-count-click': [] 'output-count-click': []
'asset-deleted': [] 'asset-deleted': []
'context-menu-opened': [] 'context-menu-opened': []
'bulk-download': [assets: AssetItem[]]
'bulk-delete': [assets: AssetItem[]]
}>() }>()
const cardContainerRef = ref<HTMLElement>() const cardContainerRef = ref<HTMLElement>()

View File

@@ -15,11 +15,10 @@
<template #item="{ item, props }"> <template #item="{ item, props }">
<Button <Button
variant="secondary" variant="secondary"
size="sm"
class="w-full justify-start" class="w-full justify-start"
v-bind="props.action" v-bind="props.action"
> >
<i :class="item.icon" class="size-4" /> <i v-if="item.icon" :class="item.icon" class="size-4" />
<span>{{ <span>{{
typeof item.label === 'function' ? item.label() : (item.label ?? '') typeof item.label === 'function' ? item.label() : (item.label ?? '')
}}</span> }}</span>
@@ -45,17 +44,28 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema' import type { AssetItem } from '../schemas/assetSchema'
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema' import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
const { asset, assetType, fileKind, showDeleteButton } = defineProps<{ const {
asset,
assetType,
fileKind,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
asset: AssetItem asset: AssetItem
assetType: AssetContext['type'] assetType: AssetContext['type']
fileKind: MediaKind fileKind: MediaKind
showDeleteButton?: boolean showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
zoom: [] zoom: []
'asset-deleted': [] 'asset-deleted': []
'asset-deleting': [boolean] 'asset-deleting': [boolean]
'bulk-download': [assets: AssetItem[]]
'bulk-delete': [assets: AssetItem[]]
}>() }>()
const contextMenu = ref<InstanceType<typeof ContextMenu>>() const contextMenu = ref<InstanceType<typeof ContextMenu>>()
@@ -113,6 +123,45 @@ const contextMenuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [] const items: MenuItem[] = []
// Check if current asset is part of the selection
const isCurrentAssetSelected = selectedAssets?.some(
(selectedAsset) => selectedAsset.id === asset.id
)
// Bulk mode: Show selected count and bulk actions only if current asset is selected
if (
isBulkMode &&
selectedAssets &&
selectedAssets.length > 0 &&
isCurrentAssetSelected
) {
// Header item showing selected count
items.push({
label: t('mediaAsset.selection.multipleSelectedAssets'),
disabled: true
})
// Bulk Download
items.push({
label: t('mediaAsset.selection.downloadSelectedAll'),
icon: 'icon-[lucide--download]',
command: () => emit('bulk-download', selectedAssets)
})
// Bulk Delete (if allowed)
if (shouldShowDeleteButton.value) {
items.push({
label: t('mediaAsset.selection.deleteSelectedAll'),
icon: 'icon-[lucide--trash-2]',
command: () => emit('bulk-delete', selectedAssets)
})
}
return items
}
// Individual mode: Show all menu options
// Inspect (if not 3D) // Inspect (if not 3D)
if (fileKind !== '3D') { if (fileKind !== '3D') {
items.push({ items.push({

View File

@@ -0,0 +1,250 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const assetDownloadStore = useAssetDownloadStore()
const visible = computed(() => assetDownloadStore.hasDownloads)
const isExpanded = ref(false)
const activeFilter = ref<'all' | 'completed' | 'failed'>('all')
const filterPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
whenever(
() => !isExpanded.value,
() => filterPopoverRef.value?.hide()
)
const filterOptions = [
{ value: 'all', label: 'all' },
{ value: 'completed', label: 'completed' },
{ value: 'failed', label: 'failed' }
] as const
function onFilterClick(event: Event) {
filterPopoverRef.value?.toggle(event)
}
function setFilter(filter: typeof activeFilter.value) {
activeFilter.value = filter
filterPopoverRef.value?.hide()
}
const downloadJobs = computed(() => assetDownloadStore.downloadList)
const completedJobs = computed(() =>
assetDownloadStore.finishedDownloads.filter((d) => d.status === 'completed')
)
const failedJobs = computed(() =>
assetDownloadStore.finishedDownloads.filter((d) => d.status === 'failed')
)
const isInProgress = computed(() => assetDownloadStore.hasActiveDownloads)
const currentJobName = computed(() => {
const activeJob = downloadJobs.value.find((job) => job.status === 'running')
return activeJob?.assetName || t('progressToast.downloadingModel')
})
const completedCount = computed(
() => completedJobs.value.length + failedJobs.value.length
)
const totalCount = computed(() => downloadJobs.value.length)
const filteredJobs = computed(() => {
switch (activeFilter.value) {
case 'completed':
return completedJobs.value
case 'failed':
return failedJobs.value
default:
return downloadJobs.value
}
})
const activeFilterLabel = computed(() => {
const option = filterOptions.find((f) => f.value === activeFilter.value)
return option
? t(`progressToast.filter.${option.label}`)
: t('progressToast.filter.all')
})
function closeDialog() {
assetDownloadStore.clearFinishedDownloads()
isExpanded.value = false
}
</script>
<template>
<HoneyToast v-model:expanded="isExpanded" :visible>
<template #default>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h3 class="text-sm font-bold text-base-foreground">
{{ t('progressToast.importingModels') }}
</h3>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="md"
class="gap-1.5 px-2"
@click="onFilterClick"
>
<i class="icon-[lucide--list-filter] size-4" />
<span>{{ activeFilterLabel }}</span>
<i class="icon-[lucide--chevron-down] size-3" />
</Button>
<Popover
ref="filterPopoverRef"
append-to="body"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class:
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg'
}
}"
>
<div
class="flex min-w-30 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<Button
v-for="option in filterOptions"
:key="option.value"
variant="textonly"
size="sm"
:class="
cn(
'w-full justify-start bg-transparent',
activeFilter === option.value &&
'bg-secondary-background-selected'
)
"
@click="setFilter(option.value)"
>
{{ t(`progressToast.filter.${option.label}`) }}
</Button>
</div>
</Popover>
</div>
</div>
<div class="relative max-h-75 overflow-y-auto px-4 py-4">
<div
v-if="filteredJobs.length > 3"
class="absolute right-1 top-4 h-12 w-1 rounded-full bg-muted-foreground"
/>
<div class="flex flex-col gap-2">
<ProgressToastItem
v-for="job in filteredJobs"
:key="job.taskId"
:job="job"
/>
</div>
<div
v-if="filteredJobs.length === 0"
class="flex flex-col items-center justify-center py-6 text-center"
>
<span class="text-sm text-muted-foreground">
{{
t('progressToast.noImportsInQueue', {
filter: activeFilterLabel
})
}}
</span>
</div>
</div>
</template>
<template #footer="{ toggle }">
<div
class="flex h-12 items-center justify-between border-t border-border-default px-4"
>
<div class="flex items-center gap-2 text-sm">
<template v-if="isInProgress">
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
<span class="font-bold text-base-foreground">{{
currentJobName
}}</span>
</template>
<template v-else-if="failedJobs.length > 0">
<i
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
/>
<span class="font-bold text-base-foreground">
{{
t('progressToast.downloadsFailed', {
count: failedJobs.length
})
}}
</span>
</template>
<template v-else>
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
<span class="font-bold text-base-foreground">
{{ t('progressToast.allDownloadsCompleted') }}
</span>
</template>
</div>
<div class="flex items-center gap-2">
<span v-if="isInProgress" class="text-sm text-muted-foreground">
{{
t('progressToast.progressCount', {
completed: completedCount,
total: totalCount
})
}}
</span>
<div class="flex items-center">
<Button
variant="muted-textonly"
size="icon"
:aria-label="
isExpanded ? t('contextMenu.Collapse') : t('contextMenu.Expand')
"
@click.stop="toggle"
>
<i
:class="
cn(
'size-4',
isExpanded
? 'icon-[lucide--chevron-down]'
: 'icon-[lucide--chevron-up]'
)
"
/>
</Button>
<Button
v-if="!isInProgress"
variant="muted-textonly"
size="icon"
:aria-label="t('g.close')"
@click.stop="closeDialog"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
</template>

View File

@@ -98,6 +98,48 @@ describe('useAssetBrowser', () => {
expect(result.description).toBe('loras model') expect(result.description).toBe('loras model')
}) })
it('removes category prefix from badge labels', () => {
const apiAsset = createApiAsset({
tags: ['models', 'checkpoint/stable-diffusion-v1-5']
})
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
const result = filteredAssets.value[0]
expect(result.badges).toContainEqual({
label: 'stable-diffusion-v1-5',
type: 'type'
})
})
it('handles tags without slash for badges', () => {
const apiAsset = createApiAsset({
tags: ['models', 'checkpoints']
})
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
const result = filteredAssets.value[0]
expect(result.badges).toContainEqual({
label: 'checkpoints',
type: 'type'
})
})
it('handles tags with multiple slashes in badges', () => {
const apiAsset = createApiAsset({
tags: ['models', 'checkpoint/subfolder/model-name']
})
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
const result = filteredAssets.value[0]
expect(result.badges).toContainEqual({
label: 'subfolder/model-name',
type: 'type'
})
})
}) })
describe('Tag-Based Filtering', () => { describe('Tag-Based Filtering', () => {
@@ -533,5 +575,58 @@ describe('useAssetBrowser', () => {
selectedCategory.value = 'unknown' selectedCategory.value = 'unknown'
expect(contentTitle.value).toBe('Assets') expect(contentTitle.value).toBe('Assets')
}) })
it('groups models by top-level folder name', () => {
const assets = [
createApiAsset({
id: 'asset-1',
tags: ['models', 'Chatterbox/subfolder1/model1']
}),
createApiAsset({
id: 'asset-2',
tags: ['models', 'Chatterbox/subfolder2/model2']
}),
createApiAsset({
id: 'asset-3',
tags: ['models', 'Chatterbox/subfolder3/model3']
}),
createApiAsset({
id: 'asset-4',
tags: ['models', 'OtherFolder/subfolder1/model4']
})
]
const { availableCategories, selectedCategory, categoryFilteredAssets } =
useAssetBrowser(ref(assets))
// Should group all Chatterbox subfolders under single category
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
{
id: 'Chatterbox',
label: 'Chatterbox',
icon: 'icon-[lucide--package]'
},
{
id: 'OtherFolder',
label: 'OtherFolder',
icon: 'icon-[lucide--package]'
}
])
// When selecting Chatterbox category, should include all models from its subfolders
selectedCategory.value = 'Chatterbox'
expect(categoryFilteredAssets.value).toHaveLength(3)
expect(categoryFilteredAssets.value.map((a) => a.id)).toEqual([
'asset-1',
'asset-2',
'asset-3'
])
// When selecting OtherFolder category, should include only its models
selectedCategory.value = 'OtherFolder'
expect(categoryFilteredAssets.value).toHaveLength(1)
expect(categoryFilteredAssets.value[0].id).toBe('asset-4')
})
}) })
}) })

View File

@@ -15,7 +15,18 @@ export type OwnershipOption = 'all' | 'my-models' | 'public-models'
function filterByCategory(category: string) { function filterByCategory(category: string) {
return (asset: AssetItem) => { return (asset: AssetItem) => {
return category === 'all' || asset.tags.includes(category) if (category === 'all') return true
// Check if any tag matches the category (for exact matches)
if (asset.tags.includes(category)) return true
// Check if any tag's top-level folder matches the category
return asset.tags.some((tag) => {
if (typeof tag === 'string' && tag.includes('/')) {
return tag.split('/')[0] === category
}
return false
})
} }
} }
@@ -93,7 +104,12 @@ export function useAssetBrowser(
// Type badge from non-root tag // Type badge from non-root tag
if (typeTag) { if (typeTag) {
badges.push({ label: typeTag, type: 'type' }) // Remove category prefix from badge label (e.g. "checkpoint/model" → "model")
const badgeLabel = typeTag.includes('/')
? typeTag.substring(typeTag.indexOf('/') + 1)
: typeTag
badges.push({ label: badgeLabel, type: 'type' })
} }
// Base model badge from metadata // Base model badge from metadata
@@ -125,6 +141,7 @@ export function useAssetBrowser(
.filter((asset) => asset.tags[0] === 'models') .filter((asset) => asset.tags[0] === 'models')
.map((asset) => asset.tags[1]) .map((asset) => asset.tags[1])
.filter((tag): tag is string => typeof tag === 'string' && tag.length > 0) .filter((tag): tag is string => typeof tag === 'string' && tag.length > 0)
.map((tag) => tag.split('/')[0]) // Extract top-level folder name
const uniqueCategories = Array.from(new Set(categories)) const uniqueCategories = Array.from(new Set(categories))
.sort() .sort()

View File

@@ -88,6 +88,22 @@ export function useAssetSelection() {
return allAssets.filter((asset) => selectionStore.isSelected(asset.id)) return allAssets.filter((asset) => selectionStore.isSelected(asset.id))
} }
/**
* Get the output count for a single asset
* Same logic as in AssetsSidebarTab.vue
*/
function getOutputCount(item: AssetItem): number {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 1
}
/**
* Get the total output count for given assets
*/
function getTotalOutputCount(assets: AssetItem[]): number {
return assets.reduce((sum, asset) => sum + getOutputCount(asset), 0)
}
/** /**
* Activate key event listeners (when sidebar opens) * Activate key event listeners (when sidebar opens)
*/ */
@@ -116,6 +132,8 @@ export function useAssetSelection() {
selectAll, selectAll,
clearSelection: () => selectionStore.clearSelection(), clearSelection: () => selectionStore.clearSelection(),
getSelectedAssets, getSelectedAssets,
getOutputCount,
getTotalOutputCount,
reset: () => selectionStore.reset(), reset: () => selectionStore.reset(),
// Lifecycle management // Lifecycle management

View File

@@ -33,6 +33,9 @@ const mockSubscriptionData = {
const baseName = TIER_TO_NAME[mockSubscriptionTier.value] const baseName = TIER_TO_NAME[mockSubscriptionTier.value]
return mockIsYearlySubscription.value ? `${baseName} Yearly` : baseName return mockIsYearlySubscription.value ? `${baseName} Yearly` : baseName
}), }),
subscriptionStatus: computed(() => ({
renewal_date: '2024-12-31T00:00:00Z'
})),
isYearlySubscription: computed(() => mockIsYearlySubscription.value), isYearlySubscription: computed(() => mockIsYearlySubscription.value),
handleInvoiceHistory: vi.fn() handleInvoiceHistory: vi.fn()
} }

View File

@@ -84,8 +84,8 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-1 gap-6 pt-9 lg:grid-cols-2"> <div class="flex flex-col lg:flex-row gap-6 pt-9">
<div class="flex flex-col flex-1"> <div class="flex flex-col shrink-0">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div <div
:class=" :class="
@@ -98,11 +98,11 @@
<Button <Button
variant="muted-textonly" variant="muted-textonly"
size="icon-sm" size="icon-sm"
class="absolute top-0.5 right-0" class="absolute top-4 right-4"
:loading="isLoadingBalance" :loading="isLoadingBalance"
@click="handleRefresh" @click="handleRefresh"
> >
<i class="pi pi-sync text-text-secondary text-xs" /> <i class="pi pi-sync text-text-secondary text-sm" />
</Button> </Button>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@@ -120,60 +120,39 @@
</div> </div>
<!-- Credit Breakdown --> <!-- Credit Breakdown -->
<div class="flex flex-col gap-1"> <table class="text-sm text-muted">
<div class="flex items-center gap-4"> <tbody>
<Skeleton <tr>
v-if="isLoadingBalance" <td class="pr-4 font-bold text-left align-middle">
width="3rem" <Skeleton
height="1rem" v-if="isLoadingBalance"
/> width="5rem"
<div height="1rem"
v-else />
class="text-sm font-bold w-12 shrink-0 text-left text-muted" <span v-else>{{ includedCreditsDisplay }}</span>
> </td>
{{ monthlyBonusCredits }} <td class="align-middle" :title="creditsRemainingLabel">
</div>
<div class="flex items-center gap-1 min-w-0">
<div
class="text-sm truncate text-muted"
:title="creditsRemainingLabel"
>
{{ creditsRemainingLabel }} {{ creditsRemainingLabel }}
</div> </td>
</div> </tr>
</div> <tr>
<div class="flex items-center gap-4"> <td class="pr-4 font-bold text-left align-middle">
<Skeleton <Skeleton
v-if="isLoadingBalance" v-if="isLoadingBalance"
width="3rem" width="3rem"
height="1rem" height="1rem"
/> />
<div <span v-else>{{ prepaidCredits }}</span>
v-else </td>
class="text-sm font-bold w-12 shrink-0 text-left text-muted" <td
> class="align-middle"
{{ prepaidCredits }}
</div>
<div class="flex items-center gap-1 min-w-0">
<div
class="text-sm truncate text-muted"
:title="$t('subscription.creditsYouveAdded')" :title="$t('subscription.creditsYouveAdded')"
> >
{{ $t('subscription.creditsYouveAdded') }} {{ $t('subscription.creditsYouveAdded') }}
</div> </td>
<Button </tr>
v-tooltip="$t('subscription.prepaidCreditsInfo')" </tbody>
variant="muted-textonly" </table>
size="icon-sm"
class="h-4 w-4 shrink-0 rounded-full"
>
<i
class="pi pi-question-circle text-text-secondary text-xs"
/>
</Button>
</div>
</div>
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<a <a
@@ -197,7 +176,7 @@
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 flex-1"> <div class="flex flex-col gap-2">
<div class="text-sm text-text-primary"> <div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }} {{ $t('subscription.yourPlanIncludes') }}
</div> </div>
@@ -288,7 +267,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Skeleton from 'primevue/skeleton' import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel' import TabPanel from 'primevue/tabpanel'
import { computed } from 'vue' import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import CloudBadge from '@/components/topbar/CloudBadge.vue' import CloudBadge from '@/components/topbar/CloudBadge.vue'
@@ -320,6 +299,7 @@ const {
formattedEndDate, formattedEndDate,
subscriptionTier, subscriptionTier,
subscriptionTierName, subscriptionTierName,
subscriptionStatus,
isYearlySubscription, isYearlySubscription,
handleInvoiceHistory handleInvoiceHistory
} = useSubscription() } = useSubscription()
@@ -334,10 +314,34 @@ const tierKey = computed(() => {
const tierPrice = computed(() => const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value) getTierPrice(tierKey.value, isYearlySubscription.value)
) )
const refillsDate = computed(() => {
if (!subscriptionStatus.value?.renewal_date) return ''
const date = new Date(subscriptionStatus.value.renewal_date)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()).slice(-2)
return `${month}/${day}/${year}`
})
const creditsRemainingLabel = computed(() => const creditsRemainingLabel = computed(() =>
isYearlySubscription.value isYearlySubscription.value
? t('subscription.creditsRemainingThisYear') ? t('subscription.creditsRemainingThisYear', {
: t('subscription.creditsRemainingThisMonth') date: refillsDate.value
})
: t('subscription.creditsRemainingThisMonth', {
date: refillsDate.value
})
)
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)
const total = isYearlySubscription.value ? credits * 12 : credits
return n(total)
})
const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
) )
// Tier benefits for v-for loop // Tier benefits for v-for loop
@@ -354,14 +358,6 @@ const tierBenefits = computed((): Benefit[] => {
const key = tierKey.value const key = tierKey.value
const benefits: Benefit[] = [ const benefits: Benefit[] = [
{
key: 'monthlyCredits',
type: 'metric',
value: n(getTierCredits(key)),
label: isYearlySubscription.value
? t('subscription.yearlyCreditsLabel')
: t('subscription.monthlyCreditsLabel')
},
{ {
key: 'maxDuration', key: 'maxDuration',
type: 'metric', type: 'metric',
@@ -402,6 +398,35 @@ const {
handleLearnMoreClick handleLearnMoreClick
} = useSubscriptionActions() } = useSubscriptionActions()
// Focus-based polling: refresh balance when user returns from Stripe checkout
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
function handleWindowFocus() {
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
if (!timestampStr) return
const timestamp = parseInt(timestampStr, 10)
// Clear expired tracking (older than 5 minutes)
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
localStorage.removeItem(PENDING_TOPUP_KEY)
return
}
// Refresh and clear tracking to prevent repeated calls
void handleRefresh()
localStorage.removeItem(PENDING_TOPUP_KEY)
}
onMounted(() => {
window.addEventListener('focus', handleWindowFocus)
})
onBeforeUnmount(() => {
window.removeEventListener('focus', handleWindowFocus)
})
const handleOpenPartnerNodesInfo = () => { const handleOpenPartnerNodesInfo = () => {
window.open( window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }), buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),

View File

@@ -20,11 +20,16 @@
<!-- Error State --> <!-- Error State -->
<div <div
v-if="videoError" v-if="videoError"
class="flex size-full flex-col items-center justify-center bg-smoke-800/50 text-center text-white py-8" role="alert"
class="flex size-full flex-col items-center justify-center bg-muted-background text-center text-base-foreground py-8"
> >
<i class="mb-2 icon-[lucide--video-off] h-12 w-12 text-smoke-400" /> <i
<p class="text-sm text-smoke-300">{{ $t('g.videoFailedToLoad') }}</p> class="mb-2 icon-[lucide--video-off] h-12 w-12 text-base-foreground"
<p class="mt-1 text-xs text-smoke-400"> />
<p class="text-sm text-base-foreground">
{{ $t('g.videoFailedToLoad') }}
</p>
<p class="mt-1 text-xs text-base-foreground">
{{ getVideoFilename(currentVideoUrl) }} {{ getVideoFilename(currentVideoUrl) }}
</p> </p>
</div> </div>

View File

@@ -104,7 +104,7 @@ function handleMove(e: PointerEvent) {
> >
<canvas <canvas
ref="canvasEl" ref="canvasEl"
class="absolute mt-[-13px] w-full cursor-crosshair" class="absolute w-full cursor-crosshair"
@pointerdown="handleDown" @pointerdown="handleDown"
@pointerup="handleUp" @pointerup="handleUp"
@pointermove="handleMove" @pointermove="handleMove"

View File

@@ -60,8 +60,9 @@ const combinedProps = computed(() => ({
})) }))
const getAssetData = () => { const getAssetData = () => {
if (props.isAssetMode && props.nodeType) { const nodeType = props.widget.options?.nodeType ?? props.nodeType
return useAssetWidgetData(toRef(() => props.nodeType)) if (props.isAssetMode && nodeType) {
return useAssetWidgetData(toRef(nodeType))
} }
return null return null
} }

View File

@@ -25,9 +25,7 @@ import type {
DialogComponentProps, DialogComponentProps,
ShowDialogOptions ShowDialogOptions
} from '@/stores/dialogStore' } from '@/stores/dialogStore'
import ManagerProgressDialogContent from '@/workbench/extensions/manager/components/ManagerProgressDialogContent.vue'
import ManagerProgressFooter from '@/workbench/extensions/manager/components/ManagerProgressFooter.vue'
import ManagerProgressHeader from '@/workbench/extensions/manager/components/ManagerProgressHeader.vue'
import ManagerDialogContent from '@/workbench/extensions/manager/components/manager/ManagerDialogContent.vue' import ManagerDialogContent from '@/workbench/extensions/manager/components/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/workbench/extensions/manager/components/manager/ManagerHeader.vue' import ManagerHeader from '@/workbench/extensions/manager/components/manager/ManagerHeader.vue'
import ImportFailedNodeContent from '@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue' import ImportFailedNodeContent from '@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue'
@@ -232,30 +230,6 @@ export const useDialogService = () => {
}) })
} }
function showManagerProgressDialog(options?: {
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
}) {
return dialogStore.showDialog({
key: 'global-manager-progress-dialog',
component: ManagerProgressDialogContent,
headerComponent: ManagerProgressHeader,
footerComponent: ManagerProgressFooter,
props: options?.props,
priority: 2,
dialogComponentProps: {
closable: false,
modal: false,
position: 'bottom',
pt: {
root: { class: 'w-[80%] max-w-2xl mx-auto border-none' },
content: { class: 'p-0!' },
header: { class: 'p-0! border-none' },
footer: { class: 'p-0! border-none' }
}
}
})
}
/** /**
* Shows a dialog requiring sign in for API nodes * Shows a dialog requiring sign in for API nodes
* @returns Promise that resolves to true if user clicks login, false if cancelled * @returns Promise that resolves to true if user clicks login, false if cancelled
@@ -394,7 +368,8 @@ export const useDialogService = () => {
headless: true, headless: true,
pt: { pt: {
header: { class: 'p-0! hidden' }, header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0!' } content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl' }
} }
} }
}) })
@@ -443,16 +418,6 @@ export const useDialogService = () => {
} }
} }
function toggleManagerProgressDialog(
props?: ComponentAttrs<typeof ManagerProgressDialogContent>
) {
if (dialogStore.isDialogOpen('global-manager-progress-dialog')) {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
} else {
showManagerProgressDialog({ props })
}
}
function showLayoutDialog(options: { function showLayoutDialog(options: {
key: string key: string
component: Component component: Component
@@ -588,7 +553,6 @@ export const useDialogService = () => {
showAboutDialog, showAboutDialog,
showExecutionErrorDialog, showExecutionErrorDialog,
showManagerDialog, showManagerDialog,
showManagerProgressDialog,
showApiNodesSignInDialog, showApiNodesSignInDialog,
showSignInDialog, showSignInDialog,
showSubscriptionRequiredDialog, showSubscriptionRequiredDialog,
@@ -599,7 +563,6 @@ export const useDialogService = () => {
showErrorDialog, showErrorDialog,
confirm, confirm,
toggleManagerDialog, toggleManagerDialog,
toggleManagerProgressDialog,
showLayoutDialog, showLayoutDialog,
showImportFailedNodeDialog, showImportFailedNodeDialog,
showNodeConflictDialog showNodeConflictDialog

View File

@@ -109,7 +109,6 @@ The following table lists ALL 46 store instances in the system as of 2025-09-01:
| aboutPanelStore.ts | useAboutPanelStore | Manages the About panel state and badges | UI | | aboutPanelStore.ts | useAboutPanelStore | Manages the About panel state and badges | UI |
| apiKeyAuthStore.ts | useApiKeyAuthStore | Handles API key authentication | Auth | | apiKeyAuthStore.ts | useApiKeyAuthStore | Handles API key authentication | Auth |
| comfyManagerStore.ts | useComfyManagerStore | Manages ComfyUI application state | Core | | comfyManagerStore.ts | useComfyManagerStore | Manages ComfyUI application state | Core |
| comfyManagerStore.ts | useManagerProgressDialogStore | Manages manager progress dialog state | UI |
| comfyRegistryStore.ts | useComfyRegistryStore | Handles extensions registry | Registry | | comfyRegistryStore.ts | useComfyRegistryStore | Handles extensions registry | Registry |
| commandStore.ts | useCommandStore | Manages commands and command execution | Core | | commandStore.ts | useCommandStore | Manages commands and command execution | Core |
| dialogStore.ts | useDialogStore | Controls dialog/modal display and state | UI | | dialogStore.ts | useDialogStore | Controls dialog/modal display and state | UI |

View File

@@ -1,13 +1,10 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useEventListener } from '@vueuse/core'
import { st } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { AssetDownloadWsMessage } from '@/schemas/apiSchema' import type { AssetDownloadWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
interface AssetDownload { export interface AssetDownload {
taskId: string taskId: string
assetId: string assetId: string
assetName: string assetName: string
@@ -24,32 +21,35 @@ interface CompletedDownload {
timestamp: number timestamp: number
} }
const PROGRESS_TOAST_INTERVAL_MS = 5000
const PROCESSED_TASK_CLEANUP_MS = 60000 const PROCESSED_TASK_CLEANUP_MS = 60000
const MAX_COMPLETED_DOWNLOADS = 10 const MAX_COMPLETED_DOWNLOADS = 10
export const useAssetDownloadStore = defineStore('assetDownload', () => { export const useAssetDownloadStore = defineStore('assetDownload', () => {
const toastStore = useToastStore()
/** Map of task IDs to their download progress data */ /** Map of task IDs to their download progress data */
const activeDownloads = ref<Map<string, AssetDownload>>(new Map()) const downloads = ref<Map<string, AssetDownload>>(new Map())
/** Map of task IDs to model types, used to track which model type to refresh after download completes */ /** Map of task IDs to model types, used to track which model type to refresh after download completes */
const pendingModelTypes = new Map<string, string>() const pendingModelTypes = new Map<string, string>()
/** Map of task IDs to timestamps, used to throttle progress toast notifications */
const lastToastTime = new Map<string, number>()
/** Set of task IDs that have reached a terminal state (completed/failed), prevents duplicate processing */ /** Set of task IDs that have reached a terminal state (completed/failed), prevents duplicate processing */
const processedTaskIds = new Set<string>() const processedTaskIds = new Set<string>()
/** Reactive signal for completed downloads */ /** Reactive signal for completed downloads */
const completedDownloads = ref<CompletedDownload[]>([]) const completedDownloads = ref<CompletedDownload[]>([])
const hasActiveDownloads = computed(() => activeDownloads.value.size > 0) const downloadList = computed(() => Array.from(downloads.value.values()))
const downloadList = computed(() => const activeDownloads = computed(() =>
Array.from(activeDownloads.value.values()) downloadList.value.filter(
(d) => d.status === 'created' || d.status === 'running'
)
) )
const finishedDownloads = computed(() =>
downloadList.value.filter(
(d) => d.status === 'completed' || d.status === 'failed'
)
)
const hasActiveDownloads = computed(() => activeDownloads.value.length > 0)
const hasDownloads = computed(() => downloads.value.size > 0)
/** /**
* Associates a download task with its model type for later use when the download completes. * Associates a download task with its model type for later use when the download completes.
@@ -82,19 +82,17 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => {
error: data.error error: data.error
} }
downloads.value.set(data.task_id, download)
if (data.status === 'completed') { if (data.status === 'completed') {
activeDownloads.value.delete(data.task_id)
lastToastTime.delete(data.task_id)
const modelType = pendingModelTypes.get(data.task_id) const modelType = pendingModelTypes.get(data.task_id)
if (modelType) { if (modelType) {
// Emit completed download signal for other stores to react to
const newDownload: CompletedDownload = { const newDownload: CompletedDownload = {
taskId: data.task_id, taskId: data.task_id,
modelType, modelType,
timestamp: Date.now() timestamp: Date.now()
} }
// Keep only the last MAX_COMPLETED_DOWNLOADS items (FIFO)
const updated = [...completedDownloads.value, newDownload] const updated = [...completedDownloads.value, newDownload]
if (updated.length > MAX_COMPLETED_DOWNLOADS) { if (updated.length > MAX_COMPLETED_DOWNLOADS) {
updated.shift() updated.shift()
@@ -107,65 +105,31 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => {
() => processedTaskIds.delete(data.task_id), () => processedTaskIds.delete(data.task_id),
PROCESSED_TASK_CLEANUP_MS PROCESSED_TASK_CLEANUP_MS
) )
toastStore.add({
severity: 'success',
summary: st('assetBrowser.download.complete', 'Download complete'),
detail: data.asset_name,
life: 5000
})
} else if (data.status === 'failed') { } else if (data.status === 'failed') {
activeDownloads.value.delete(data.task_id)
lastToastTime.delete(data.task_id)
pendingModelTypes.delete(data.task_id) pendingModelTypes.delete(data.task_id)
setTimeout( setTimeout(
() => processedTaskIds.delete(data.task_id), () => processedTaskIds.delete(data.task_id),
PROCESSED_TASK_CLEANUP_MS PROCESSED_TASK_CLEANUP_MS
) )
toastStore.add({
severity: 'error',
summary: st('assetBrowser.download.failed', 'Download failed'),
detail: data.error || data.asset_name,
life: 8000
})
} else {
activeDownloads.value.set(data.task_id, download)
const now = Date.now()
const lastTime = lastToastTime.get(data.task_id) ?? 0
const shouldShowToast = now - lastTime >= PROGRESS_TOAST_INTERVAL_MS
if (shouldShowToast) {
lastToastTime.set(data.task_id, now)
const progressPercent = Math.round(data.progress * 100)
toastStore.add({
severity: 'info',
summary: st('assetBrowser.download.inProgress', 'Downloading...'),
detail: `${data.asset_name} (${progressPercent}%)`,
life: PROGRESS_TOAST_INTERVAL_MS,
closable: true
})
}
} }
} }
let stopListener: (() => void) | undefined api.addEventListener('asset_download', handleAssetDownload)
function setup() { function clearFinishedDownloads() {
stopListener = useEventListener(api, 'asset_download', handleAssetDownload) for (const download of finishedDownloads.value) {
} downloads.value.delete(download.taskId)
}
function teardown() {
stopListener?.()
stopListener = undefined
} }
return { return {
activeDownloads, activeDownloads,
finishedDownloads,
hasActiveDownloads, hasActiveDownloads,
hasDownloads,
downloadList, downloadList,
completedDownloads, completedDownloads,
trackDownload, trackDownload,
setup, clearFinishedDownloads
teardown
} }
}) })

View File

@@ -17,6 +17,8 @@
<GlobalToast /> <GlobalToast />
<RerouteMigrationToast /> <RerouteMigrationToast />
<ModelImportProgressDialog />
<ManagerProgressToast />
<UnloadWindowConfirmDialog v-if="!isElectron()" /> <UnloadWindowConfirmDialog v-if="!isElectron()" />
<MenuHamburger /> <MenuHamburger />
</template> </template>
@@ -49,6 +51,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { useProgressFavicon } from '@/composables/useProgressFavicon' import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig' import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
import { i18n, loadLocale } from '@/i18n' import { i18n, loadLocale } from '@/i18n'
import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry' import { useTelemetry } from '@/platform/telemetry'
@@ -60,7 +63,6 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { setupAutoQueueHandler } from '@/services/autoQueueService' import { setupAutoQueueHandler } from '@/services/autoQueueService'
import { useKeybindingService } from '@/services/keybindingService' import { useKeybindingService } from '@/services/keybindingService'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useAssetsStore } from '@/stores/assetsStore' import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
@@ -79,6 +81,7 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore' import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI, isElectron } from '@/utils/envUtil' import { electronAPI, isElectron } from '@/utils/envUtil'
import LinearView from '@/views/LinearView.vue' import LinearView from '@/views/LinearView.vue'
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
setupAutoQueueHandler() setupAutoQueueHandler()
useProgressFavicon() useProgressFavicon()
@@ -88,7 +91,6 @@ const { t } = useI18n()
const toast = useToast() const toast = useToast()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const executionStore = useExecutionStore() const executionStore = useExecutionStore()
const assetDownloadStore = useAssetDownloadStore()
const colorPaletteStore = useColorPaletteStore() const colorPaletteStore = useColorPaletteStore()
const queueStore = useQueueStore() const queueStore = useQueueStore()
const assetsStore = useAssetsStore() const assetsStore = useAssetsStore()
@@ -256,7 +258,6 @@ onMounted(() => {
api.addEventListener('reconnecting', onReconnecting) api.addEventListener('reconnecting', onReconnecting)
api.addEventListener('reconnected', onReconnected) api.addEventListener('reconnected', onReconnected)
executionStore.bindExecutionEvents() executionStore.bindExecutionEvents()
assetDownloadStore.setup()
try { try {
init() init()
@@ -273,7 +274,6 @@ onBeforeUnmount(() => {
api.removeEventListener('reconnecting', onReconnecting) api.removeEventListener('reconnecting', onReconnecting)
api.removeEventListener('reconnected', onReconnected) api.removeEventListener('reconnected', onReconnected)
executionStore.unbindExecutionEvents() executionStore.unbindExecutionEvents()
assetDownloadStore.teardown()
// Clean up page visibility listener // Clean up page visibility listener
if (visibilityListener) { if (visibilityListener) {

View File

@@ -1,186 +0,0 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import Panel from 'primevue/panel'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import ManagerProgressDialogContent from './ManagerProgressDialogContent.vue'
type ComponentInstance = InstanceType<typeof ManagerProgressDialogContent> & {
lastPanelRef: HTMLElement | null
onLogsAdded: () => void
handleScroll: (e: { target: HTMLElement }) => void
isUserScrolling: boolean
resetUserScrolling: () => void
collapsedPanels: Record<number, boolean>
togglePanel: (index: number) => void
}
const mockCollapse = vi.fn()
const defaultMockTaskLogs = [
{ taskName: 'Task 1', logs: ['Log 1', 'Log 2'] },
{ taskName: 'Task 2', logs: ['Log 3', 'Log 4'] }
]
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
taskLogs: [...defaultMockTaskLogs],
succeededTasksLogs: [...defaultMockTaskLogs],
failedTasksLogs: [...defaultMockTaskLogs],
managerQueue: { historyCount: 2 },
isLoading: false
})),
useManagerProgressDialogStore: vi.fn(() => ({
isExpanded: true,
activeTabIndex: 0,
getActiveTabIndex: vi.fn(() => 0),
setActiveTabIndex: vi.fn(),
toggle: vi.fn(),
collapse: mockCollapse,
expand: vi.fn()
}))
}))
describe('ManagerProgressDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollapse.mockReset()
})
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper<ComponentInstance> => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(ManagerProgressDialogContent, {
props: {
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Panel,
Button
}
}
}) as VueWrapper<ComponentInstance>
}
it('renders the correct number of panels', async () => {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findAllComponents(Panel).length).toBe(2)
})
it('expands the last panel by default', async () => {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.vm.collapsedPanels[1]).toBeFalsy()
})
it('toggles panel expansion when toggle method is called', async () => {
const wrapper = mountComponent()
await nextTick()
// Initial state - first panel should be collapsed
expect(wrapper.vm.collapsedPanels[0]).toBeFalsy()
wrapper.vm.togglePanel(0)
await nextTick()
// After toggle - first panel should be expanded
expect(wrapper.vm.collapsedPanels[0]).toBe(true)
wrapper.vm.togglePanel(0)
await nextTick()
expect(wrapper.vm.collapsedPanels[0]).toBeFalsy()
})
it('displays the correct status for each panel', async () => {
const wrapper = mountComponent()
await nextTick()
// Expand all panels to see status text
const panels = wrapper.findAllComponents(Panel)
for (let i = 0; i < panels.length; i++) {
if (!wrapper.vm.collapsedPanels[i]) {
wrapper.vm.togglePanel(i)
await nextTick()
}
}
const panelsText = wrapper
.findAllComponents(Panel)
.map((panel) => panel.text())
expect(panelsText[0]).toContain('Completed ✓')
expect(panelsText[1]).toContain('Completed ✓')
})
it('auto-scrolls to bottom when new logs are added', async () => {
const wrapper = mountComponent()
await nextTick()
const mockScrollElement = document.createElement('div')
Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 })
Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 })
Object.defineProperty(mockScrollElement, 'scrollTop', {
value: 0,
writable: true
})
wrapper.vm.lastPanelRef = mockScrollElement
wrapper.vm.onLogsAdded()
await nextTick()
// Check if scrollTop is set to scrollHeight (scrolled to bottom)
expect(mockScrollElement.scrollTop).toBe(200)
})
it('does not auto-scroll when user is manually scrolling', async () => {
const wrapper = mountComponent()
await nextTick()
const mockScrollElement = document.createElement('div')
Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 })
Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 })
Object.defineProperty(mockScrollElement, 'scrollTop', {
value: 50,
writable: true
})
wrapper.vm.lastPanelRef = mockScrollElement
wrapper.vm.handleScroll({ target: mockScrollElement })
await nextTick()
expect(wrapper.vm.isUserScrolling).toBe(true)
// Now trigger the log update
wrapper.vm.onLogsAdded()
await nextTick()
// Check that scrollTop is not changed (should still be 50)
expect(mockScrollElement.scrollTop).toBe(50)
})
it('calls collapse method when component is unmounted', async () => {
const wrapper = mountComponent()
await nextTick()
wrapper.unmount()
expect(mockCollapse).toHaveBeenCalled()
})
})

View File

@@ -1,182 +0,0 @@
<template>
<div
class="overflow-hidden transition-all duration-300"
:class="{
'max-h-[500px]': isExpanded,
'm-0 max-h-0 p-0': !isExpanded
}"
>
<div
ref="sectionsContainerRef"
class="scroll-container max-h-[450px] overflow-y-auto px-6 py-4"
:style="{
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(156, 163, 175, 0.5) transparent'
}"
:class="{
'max-h-[450px]': isExpanded,
'max-h-0': !isExpanded
}"
>
<div v-for="(log, index) in focusedLogs" :key="index">
<Panel
:expanded="collapsedPanels[index] === true"
toggleable
class="shadow-elevation-1 mt-2 rounded-lg"
>
<template #header>
<div class="flex w-full items-center justify-between py-2">
<div class="flex flex-col text-sm leading-normal font-medium">
<span>{{ log.taskName }}</span>
<span class="text-muted">
{{
isInProgress(index)
? $t('g.inProgress')
: $t('g.completed') + ' ✓'
}}
</span>
</div>
</div>
</template>
<template #toggleicon>
<Button
variant="textonly"
class="text-neutral-300"
@click="togglePanel(index)"
>
<i
:class="
collapsedPanels[index]
? 'pi pi-chevron-right'
: 'pi pi-chevron-down'
"
/>
</Button>
</template>
<div
:ref="
index === focusedLogs.length - 1
? (el) => (lastPanelRef = el as HTMLElement)
: undefined
"
class="h-64 overflow-y-auto rounded-lg bg-black"
:class="{
'h-64': index !== focusedLogs.length - 1,
grow: index === focusedLogs.length - 1
}"
@scroll="handleScroll"
>
<div class="h-full">
<div
v-for="(logLine, logIndex) in log.logs"
:key="logIndex"
class="text-muted"
>
<pre class="break-words whitespace-pre-wrap">{{ logLine }}</pre>
</div>
</div>
</div>
</Panel>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useScroll, whenever } from '@vueuse/core'
import Panel from 'primevue/panel'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
const comfyManagerStore = useComfyManagerStore()
const progressDialogContent = useManagerProgressDialogStore()
const isInProgress = (index: number) => {
const log = focusedLogs.value[index]
if (!log) return false
// Check if this task is in the running or pending queue
const taskQueue = comfyManagerStore.taskQueue
if (!taskQueue) return false
const allQueueTasks = [
...(taskQueue.running_queue || []),
...(taskQueue.pending_queue || [])
]
return allQueueTasks.some((task) => task.ui_id === log.taskId)
}
const focusedLogs = computed(() => {
if (progressDialogContent.getActiveTabIndex() === 0) {
return comfyManagerStore.succeededTasksLogs
}
return comfyManagerStore.failedTasksLogs
})
const isExpanded = computed(() => progressDialogContent.isExpanded)
const isCollapsed = computed(() => !isExpanded.value)
const collapsedPanels = ref<Record<number, boolean>>({})
const togglePanel = (index: number) => {
collapsedPanels.value[index] = !collapsedPanels.value[index]
}
const sectionsContainerRef = ref<HTMLElement | null>(null)
const { y: scrollY } = useScroll(sectionsContainerRef, {
eventListenerOptions: {
passive: true
}
})
const lastPanelRef = ref<HTMLElement | null>(null)
const isUserScrolling = ref(false)
const lastPanelLogs = computed(() => focusedLogs.value?.at(-1)?.logs)
const isAtBottom = (el: HTMLElement | null) => {
if (!el) return false
const threshold = 20
return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < threshold
}
const scrollLastPanelToBottom = () => {
if (!lastPanelRef.value || isUserScrolling.value) return
lastPanelRef.value.scrollTop = lastPanelRef.value.scrollHeight
}
const scrollContentToBottom = () => {
scrollY.value = sectionsContainerRef.value?.scrollHeight ?? 0
}
const resetUserScrolling = () => {
isUserScrolling.value = false
}
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
if (target !== lastPanelRef.value) return
isUserScrolling.value = !isAtBottom(target)
}
const onLogsAdded = () => {
// If user is scrolling manually, don't automatically scroll to bottom
if (isUserScrolling.value) return
scrollLastPanelToBottom()
}
whenever(lastPanelLogs, onLogsAdded, { flush: 'post', deep: true })
whenever(() => isExpanded.value, scrollContentToBottom)
whenever(isCollapsed, resetUserScrolling)
onMounted(() => {
scrollContentToBottom()
})
onBeforeUnmount(() => {
progressDialogContent.collapse()
})
</script>

View File

@@ -1,486 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import ManagerProgressFooter from '@/workbench/extensions/manager/components/ManagerProgressFooter.vue'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { TaskLog } from '@/workbench/extensions/manager/types/comfyManagerTypes'
// Mock modules
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore')
vi.mock('@/stores/dialogStore')
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/commandStore')
vi.mock('@/workbench/extensions/manager/services/comfyManagerService')
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: vi.fn(() => ({
conflictedPackages: { value: [] },
runFullConflictAnalysis: vi.fn().mockResolvedValue(undefined)
}))
})
)
// Mock useEventListener to capture the event handler
let reconnectHandler: (() => void) | null = null
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...actual,
useEventListener: vi.fn(
(_target: any, event: string, handler: any, _options: any) => {
if (event === 'reconnected') {
reconnectHandler = handler
}
}
)
}
})
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: vi.fn(() => ({
reloadCurrentWorkflow: vi.fn().mockResolvedValue(undefined)
}))
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({
completedActivePalette: {
light_theme: false
}
}))
}))
// Helper function to mount component with required setup
const mountComponent = (options: { captureError?: boolean } = {}) => {
const pinia = createPinia()
setActivePinia(pinia)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
close: 'Close',
progressCountOf: 'of'
},
contextMenu: {
Collapse: 'Collapse',
Expand: 'Expand'
},
manager: {
clickToFinishSetup: 'Click',
applyChanges: 'Apply Changes',
toFinishSetup: 'to finish setup',
restartingBackend: 'Restarting backend to apply changes...',
extensionsSuccessfullyInstalled:
'Extension(s) successfully installed and are ready to use!',
restartToApplyChanges: 'To apply changes, please restart ComfyUI',
installingDependencies: 'Installing dependencies...'
}
}
}
})
const config: any = {
global: {
plugins: [pinia, PrimeVue, i18n]
}
}
// Add error handler for tests that expect errors
if (options.captureError) {
config.global.config = {
errorHandler: () => {
// Suppress error in test
}
}
}
return mount(ManagerProgressFooter, config)
}
describe('ManagerProgressFooter', () => {
const mockTaskLogs: TaskLog[] = []
const mockComfyManagerStore = {
taskLogs: mockTaskLogs,
allTasksDone: true,
isProcessingTasks: false,
succeededTasksIds: [] as string[],
failedTasksIds: [] as string[],
taskHistory: {} as Record<string, any>,
taskQueue: null,
resetTaskState: vi.fn(),
clearLogs: vi.fn(),
setStale: vi.fn(),
// Add other required properties
isLoading: { value: false },
error: { value: null },
statusMessage: { value: 'DONE' },
installedPacks: {},
installedPacksIds: new Set(),
isPackInstalled: vi.fn(),
isPackEnabled: vi.fn(),
getInstalledPackVersion: vi.fn(),
refreshInstalledList: vi.fn(),
installPack: vi.fn(),
uninstallPack: vi.fn(),
updatePack: vi.fn(),
updateAllPacks: vi.fn(),
disablePack: vi.fn(),
enablePack: vi.fn()
}
const mockDialogStore = {
closeDialog: vi.fn(),
// Add other required properties
dialogStack: { value: [] },
showDialog: vi.fn(),
$id: 'dialog',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$dispose: vi.fn(),
$onAction: vi.fn()
}
const mockSettingStore = {
get: vi.fn().mockReturnValue(false),
set: vi.fn(),
// Add other required properties
settingValues: { value: {} },
settingsById: { value: {} },
exists: vi.fn(),
getDefaultValue: vi.fn(),
loadSettingValues: vi.fn(),
updateValue: vi.fn(),
$id: 'setting',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$dispose: vi.fn(),
$onAction: vi.fn()
}
const mockProgressDialogStore = {
isExpanded: false,
toggle: vi.fn(),
collapse: vi.fn(),
expand: vi.fn()
}
const mockCommandStore = {
execute: vi.fn().mockResolvedValue(undefined)
}
const mockComfyManagerService = {
rebootComfyUI: vi.fn().mockResolvedValue(null)
}
beforeEach(() => {
vi.clearAllMocks()
// Create new pinia instance for each test
const pinia = createPinia()
setActivePinia(pinia)
// Reset task logs
mockTaskLogs.length = 0
mockComfyManagerStore.taskLogs = mockTaskLogs
// Reset event handler
reconnectHandler = null
vi.mocked(useComfyManagerStore).mockReturnValue(
mockComfyManagerStore as any
)
vi.mocked(useDialogStore).mockReturnValue(mockDialogStore as any)
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useManagerProgressDialogStore).mockReturnValue(
mockProgressDialogStore as any
)
vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any)
vi.mocked(useComfyManagerService).mockReturnValue(
mockComfyManagerService as any
)
})
describe('State 1: Queue Running', () => {
it('should display loading spinner and progress counter when queue is running', async () => {
// Setup queue running state
mockComfyManagerStore.isProcessingTasks = true
mockComfyManagerStore.succeededTasksIds = ['1', '2']
mockComfyManagerStore.failedTasksIds = []
mockComfyManagerStore.taskHistory = {
'1': { taskName: 'Installing pack1' },
'2': { taskName: 'Installing pack2' },
'3': { taskName: 'Installing pack3' }
}
mockTaskLogs.push(
{ taskName: 'Installing pack1', taskId: '1', logs: [] },
{ taskName: 'Installing pack2', taskId: '2', logs: [] },
{ taskName: 'Installing pack3', taskId: '3', logs: [] }
)
const wrapper = mountComponent()
// Check loading spinner exists (DotSpinner component)
expect(wrapper.find('.inline-flex').exists()).toBe(true)
// Check current task name is displayed
expect(wrapper.text()).toContain('Installing pack3')
// Check progress counter (completed: 2 of 3)
expect(wrapper.text()).toMatch(/2.*of.*3/)
// Check expand/collapse button exists
const expandButton = wrapper.find('[aria-label="Expand"]')
expect(expandButton.exists()).toBe(true)
// Check Apply Changes button is NOT shown
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('should toggle expansion when expand button is clicked', async () => {
mockComfyManagerStore.isProcessingTasks = true
mockTaskLogs.push({ taskName: 'Installing', taskId: '1', logs: [] })
const wrapper = mountComponent()
const expandButton = wrapper.find('[aria-label="Expand"]')
await expandButton.trigger('click')
expect(mockProgressDialogStore.toggle).toHaveBeenCalled()
})
})
describe('State 2: Tasks Completed (Waiting for Restart)', () => {
it('should display check mark and Apply Changes button when all tasks are done', async () => {
// Setup tasks completed state
mockComfyManagerStore.isProcessingTasks = false
mockTaskLogs.push(
{ taskName: 'Installed pack1', taskId: '1', logs: [] },
{ taskName: 'Installed pack2', taskId: '2', logs: [] }
)
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Check check mark emoji
expect(wrapper.text()).toContain('✅')
// Check restart message
expect(wrapper.text()).toContain(
'To apply changes, please restart ComfyUI'
)
expect(wrapper.text()).toContain('Apply Changes')
// Check Apply Changes button exists
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
expect(applyButton).toBeTruthy()
// Check no progress counter
expect(wrapper.text()).not.toMatch(/\d+.*of.*\d+/)
})
})
describe('State 3: Restarting', () => {
it('should display restarting message and spinner during restart', async () => {
// Setup completed state first
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Click Apply Changes to trigger restart
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for state update
await nextTick()
// Check restarting message
expect(wrapper.text()).toContain('Restarting backend to apply changes...')
// Check loading spinner during restart
expect(wrapper.find('.inline-flex').exists()).toBe(true)
// Check Apply Changes button is hidden
expect(wrapper.text()).not.toContain('Apply Changes')
})
})
describe('State 4: Restart Completed', () => {
it('should display success message and auto-close after 3 seconds', async () => {
vi.useFakeTimers()
// Setup completed state
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Trigger restart
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for event listener to be set up
await nextTick()
// Trigger the reconnect handler directly
if (reconnectHandler) {
await reconnectHandler()
}
// Wait for restart completed state
await nextTick()
// Check success message
expect(wrapper.text()).toContain('🎉')
expect(wrapper.text()).toContain(
'Extension(s) successfully installed and are ready to use!'
)
// Check dialog closes after 3 seconds
vi.advanceTimersByTime(3000)
await nextTick()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-manager-progress-dialog'
})
expect(mockComfyManagerStore.resetTaskState).toHaveBeenCalled()
vi.useRealTimers()
})
})
describe('Common Features', () => {
it('should always display close button', async () => {
const wrapper = mountComponent()
const closeButton = wrapper.find('[aria-label="Close"]')
expect(closeButton.exists()).toBe(true)
})
it('should close dialog when close button is clicked', async () => {
const wrapper = mountComponent()
const closeButton = wrapper.find('[aria-label="Close"]')
await closeButton.trigger('click')
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-manager-progress-dialog'
})
})
})
describe('Toast Management', () => {
it('should suppress reconnection toasts during restart', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
mockSettingStore.get.mockReturnValue(false) // Original setting
const wrapper = mountComponent()
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Check toast setting was disabled
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
true
)
})
it('should restore toast settings after restart completes', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
mockSettingStore.get.mockReturnValue(false) // Original setting
const wrapper = mountComponent()
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for event listener to be set up
await nextTick()
// Trigger the reconnect handler directly
if (reconnectHandler) {
await reconnectHandler()
}
// Wait for settings restoration
await nextTick()
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
false // Restored to original
)
})
})
describe('Error Handling', () => {
it('should restore state and close dialog on restart error', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
// Mock restart to throw error
mockComfyManagerService.rebootComfyUI.mockRejectedValue(
new Error('Restart failed')
)
const wrapper = mountComponent({ captureError: true })
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
expect(applyButton).toBeTruthy()
// The component throws the error but Vue Test Utils catches it
// We need to check if the error handling logic was executed
await applyButton!.trigger('click').catch(() => {
// Error is expected, ignore it
})
// Wait for error handling
await nextTick()
// Check dialog was closed on error
expect(mockDialogStore.closeDialog).toHaveBeenCalled()
// Check toast settings were restored
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
false
)
// Check that the error handler was called
expect(mockComfyManagerService.rebootComfyUI).toHaveBeenCalled()
})
})
})

View File

@@ -1,195 +0,0 @@
<template>
<div
class="flex w-full items-center justify-between px-6 py-2 shadow-lg"
:class="{
'rounded-t-none': progressDialogContent.isExpanded,
'rounded-lg': !progressDialogContent.isExpanded
}"
>
<div class="flex items-center text-base leading-none">
<div class="flex items-center">
<template v-if="isInProgress">
<DotSpinner duration="1s" class="mr-2" />
<span>{{ currentTaskName }}</span>
</template>
<template v-else-if="isRestartCompleted">
<span class="mr-2">🎉</span>
<span>{{ currentTaskName }}</span>
</template>
<template v-else>
<span class="mr-2"></span>
<span>{{ $t('manager.restartToApplyChanges') }}</span>
</template>
</div>
</div>
<div class="flex items-center gap-4">
<span v-if="isInProgress" class="text-sm text-neutral-700">
{{ completedTasksCount }} {{ $t('g.progressCountOf') }}
{{ totalTasksCount }}
</span>
<div class="flex items-center">
<Button
v-if="!isInProgress && !isRestartCompleted"
variant="secondary"
class="mr-4 rounded-full border-2 border-base-foreground px-3 text-base-foreground hover:bg-secondary-background-hover"
@click="handleRestart"
>
{{ $t('manager.applyChanges') }}
</Button>
<Button
v-else-if="!isRestartCompleted"
variant="muted-textonly"
size="sm"
class="rounded-full font-bold"
:aria-label="
$t(
progressDialogContent.isExpanded
? 'contextMenu.Collapse'
: 'contextMenu.Expand'
)
"
@click.stop="progressDialogContent.toggle"
>
<i
:class="
progressDialogContent.isExpanded
? 'pi pi-chevron-up'
: 'pi pi-chevron-down'
"
/>
</Button>
<Button
variant="muted-textonly"
size="sm"
class="rounded-full font-bold"
:aria-label="$t('g.close')"
@click.stop="closeDialog"
>
<i class="pi pi-times" />
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { api } from '@/scripts/api'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
const { t } = useI18n()
const dialogStore = useDialogStore()
const progressDialogContent = useManagerProgressDialogStore()
const comfyManagerStore = useComfyManagerStore()
const settingStore = useSettingStore()
const { runFullConflictAnalysis } = useConflictDetection()
// State management for restart process
const isRestarting = ref<boolean>(false)
const isRestartCompleted = ref<boolean>(false)
const isInProgress = computed(
() => comfyManagerStore.isProcessingTasks || isRestarting.value
)
const completedTasksCount = computed(() => {
return (
comfyManagerStore.succeededTasksIds.length +
comfyManagerStore.failedTasksIds.length
)
})
const totalTasksCount = computed(() => {
const completedTasks = Object.keys(comfyManagerStore.taskHistory).length
const taskQueue = comfyManagerStore.taskQueue
const queuedTasks = taskQueue
? (taskQueue.running_queue?.length || 0) +
(taskQueue.pending_queue?.length || 0)
: 0
return completedTasks + queuedTasks
})
const closeDialog = () => {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
}
const fallbackTaskName = t('manager.installingDependencies')
const currentTaskName = computed(() => {
if (isRestarting.value) {
return t('manager.restartingBackend')
}
if (isRestartCompleted.value) {
return t('manager.extensionsSuccessfullyInstalled')
}
if (!comfyManagerStore.taskLogs.length) return fallbackTaskName
const task = comfyManagerStore.taskLogs.at(-1)
return task?.taskName ?? fallbackTaskName
})
const handleRestart = async () => {
// Store original toast setting value
const originalToastSetting = settingStore.get(
'Comfy.Toast.DisableReconnectingToast'
)
try {
await settingStore.set('Comfy.Toast.DisableReconnectingToast', true)
isRestarting.value = true
const onReconnect = async () => {
try {
comfyManagerStore.setStale()
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
// Run conflict detection in background after restart completion
void runFullConflictAnalysis()
} finally {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = true
setTimeout(() => {
closeDialog()
comfyManagerStore.resetTaskState()
}, 3000)
}
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
await useComfyManagerService().rebootComfyUI()
} catch (error) {
// If restart fails, restore settings and reset state
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = false
closeDialog() // Close dialog on error
throw error
}
}
</script>

View File

@@ -1,44 +0,0 @@
<template>
<div
v-if="progressDialogContent.isExpanded"
class="flex items-center px-4 py-2"
>
<TabMenu
v-model:active-index="activeTabIndex"
:model="tabs"
class="w-full border-none"
:pt="{
menu: { class: 'border-none' },
menuitem: { class: 'font-medium' },
action: { class: 'px-4 py-2' }
}"
/>
</div>
</template>
<script setup lang="ts">
import TabMenu from 'primevue/tabmenu'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
const progressDialogContent = useManagerProgressDialogStore()
const comfyManagerStore = useComfyManagerStore()
const activeTabIndex = computed({
get: () => progressDialogContent.getActiveTabIndex(),
set: (value) => progressDialogContent.setActiveTabIndex(value)
})
const { t } = useI18n()
const tabs = computed(() => [
{ label: t('manager.installationQueue') },
{
label: t('manager.failed', {
count: comfyManagerStore.failedTasksIds.length
})
}
])
</script>

View File

@@ -0,0 +1,353 @@
<script setup lang="ts">
import { useEventListener, useScroll, whenever } from '@vueuse/core'
import Panel from 'primevue/panel'
import TabMenu from 'primevue/tabmenu'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { api } from '@/scripts/api'
import { useCommandStore } from '@/stores/commandStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const settingStore = useSettingStore()
const { runFullConflictAnalysis } = useConflictDetection()
const isExpanded = ref(false)
const activeTabIndex = ref(0)
const tabs = computed(() => [
{ label: t('manager.installationQueue') },
{
label: t('manager.failed', {
count: comfyManagerStore.failedTasksIds.length
})
}
])
const focusedLogs = computed(() => {
if (activeTabIndex.value === 0) {
return comfyManagerStore.succeededTasksLogs
}
return comfyManagerStore.failedTasksLogs
})
const visible = computed(() => comfyManagerStore.taskLogs.length > 0)
const isRestarting = ref(false)
const isRestartCompleted = ref(false)
const isInProgress = computed(
() => comfyManagerStore.isProcessingTasks || isRestarting.value
)
const isTaskInProgress = (index: number) => {
const log = focusedLogs.value[index]
if (!log) return false
const taskQueue = comfyManagerStore.taskQueue
if (!taskQueue) return false
const allQueueTasks = [
...(taskQueue.running_queue || []),
...(taskQueue.pending_queue || [])
]
return allQueueTasks.some((task) => task.ui_id === log.taskId)
}
const completedTasksCount = computed(() => {
return (
comfyManagerStore.succeededTasksIds.length +
comfyManagerStore.failedTasksIds.length
)
})
const totalTasksCount = computed(() => {
const completedTasks = Object.keys(comfyManagerStore.taskHistory).length
const taskQueue = comfyManagerStore.taskQueue
const queuedTasks = taskQueue
? (taskQueue.running_queue?.length || 0) +
(taskQueue.pending_queue?.length || 0)
: 0
return completedTasks + queuedTasks
})
const currentTaskName = computed(() => {
if (isRestarting.value) {
return t('manager.restartingBackend')
}
if (isRestartCompleted.value) {
return t('manager.extensionsSuccessfullyInstalled')
}
if (!comfyManagerStore.taskLogs.length)
return t('manager.installingDependencies')
const task = comfyManagerStore.taskLogs.at(-1)
return task?.taskName ?? t('manager.installingDependencies')
})
const collapsedPanels = ref<Record<number, boolean>>({})
function togglePanel(index: number) {
collapsedPanels.value[index] = !collapsedPanels.value[index]
}
const sectionsContainerRef = ref<HTMLElement | null>(null)
const { y: scrollY } = useScroll(sectionsContainerRef, {
eventListenerOptions: { passive: true }
})
const lastPanelRef = ref<HTMLElement | null>(null)
const isUserScrolling = ref(false)
const lastPanelLogs = computed(() => focusedLogs.value?.at(-1)?.logs)
function isAtBottom(el: HTMLElement | null) {
if (!el) return false
const threshold = 20
return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < threshold
}
function scrollLastPanelToBottom() {
if (!lastPanelRef.value || isUserScrolling.value) return
lastPanelRef.value.scrollTop = lastPanelRef.value.scrollHeight
}
function scrollContentToBottom() {
scrollY.value = sectionsContainerRef.value?.scrollHeight ?? 0
}
function resetUserScrolling() {
isUserScrolling.value = false
}
function handleScroll(e: Event) {
const target = e.target as HTMLElement
if (target !== lastPanelRef.value) return
isUserScrolling.value = !isAtBottom(target)
}
function onLogsAdded() {
if (isUserScrolling.value) return
scrollLastPanelToBottom()
}
whenever(lastPanelLogs, onLogsAdded, { flush: 'post', deep: true })
whenever(() => isExpanded.value, scrollContentToBottom)
whenever(() => !isExpanded.value, resetUserScrolling)
function closeToast() {
comfyManagerStore.resetTaskState()
isExpanded.value = false
}
async function handleRestart() {
const originalToastSetting = settingStore.get(
'Comfy.Toast.DisableReconnectingToast'
)
try {
await settingStore.set('Comfy.Toast.DisableReconnectingToast', true)
isRestarting.value = true
const onReconnect = async () => {
try {
comfyManagerStore.setStale()
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
void runFullConflictAnalysis()
} finally {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = true
setTimeout(() => {
closeToast()
}, 3000)
}
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
await useComfyManagerService().rebootComfyUI()
} catch (error) {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = false
closeToast()
throw error
}
}
onMounted(() => {
scrollContentToBottom()
})
onBeforeUnmount(() => {
isExpanded.value = false
})
</script>
<template>
<HoneyToast v-model:expanded="isExpanded" :visible>
<template #default>
<div v-if="isExpanded" class="flex items-center px-4 py-2">
<TabMenu
v-model:active-index="activeTabIndex"
:model="tabs"
class="w-full border-none"
:pt="{
menu: { class: 'border-none' },
menuitem: { class: 'font-medium' },
action: { class: 'px-4 py-2' }
}"
/>
</div>
<div
ref="sectionsContainerRef"
class="scroll-container max-h-[450px] overflow-y-auto px-6 py-4"
:style="{
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(156, 163, 175, 0.5) transparent'
}"
>
<div v-for="(log, index) in focusedLogs" :key="index">
<Panel
:expanded="collapsedPanels[index] === true"
toggleable
class="shadow-elevation-1 mt-2 rounded-lg"
>
<template #header>
<div class="flex w-full items-center justify-between py-2">
<div class="flex flex-col text-sm leading-normal font-medium">
<span>{{ log.taskName }}</span>
<span class="text-muted">
{{
isTaskInProgress(index)
? t('g.inProgress')
: t('g.completed') + ' ✓'
}}
</span>
</div>
</div>
</template>
<template #toggleicon>
<Button
variant="textonly"
class="text-neutral-300"
@click="togglePanel(index)"
>
<i
:class="
collapsedPanels[index]
? 'pi pi-chevron-right'
: 'pi pi-chevron-down'
"
/>
</Button>
</template>
<div
:ref="
index === focusedLogs.length - 1
? (el) => (lastPanelRef = el as HTMLElement)
: undefined
"
class="h-64 overflow-y-auto rounded-lg bg-black"
:class="{
'h-64': index !== focusedLogs.length - 1,
grow: index === focusedLogs.length - 1
}"
@scroll="handleScroll"
>
<div class="h-full">
<div
v-for="(logLine, logIndex) in log.logs"
:key="logIndex"
class="text-muted"
>
<pre class="break-words whitespace-pre-wrap">{{
logLine
}}</pre>
</div>
</div>
</div>
</Panel>
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex w-full items-center justify-between px-6 py-2 shadow-lg">
<div class="flex items-center text-base leading-none">
<div class="flex items-center">
<template v-if="isInProgress">
<DotSpinner duration="1s" class="mr-2" />
<span>{{ currentTaskName }}</span>
</template>
<template v-else-if="isRestartCompleted">
<span class="mr-2">🎉</span>
<span>{{ currentTaskName }}</span>
</template>
<template v-else>
<span class="mr-2"></span>
<span>{{ t('manager.restartToApplyChanges') }}</span>
</template>
</div>
</div>
<div class="flex items-center gap-4">
<span v-if="isInProgress" class="text-sm text-muted-foreground">
{{ completedTasksCount }} {{ t('g.progressCountOf') }}
{{ totalTasksCount }}
</span>
<div class="flex items-center">
<Button
v-if="!isInProgress && !isRestartCompleted"
variant="secondary"
class="mr-4 rounded-full border-2 border-base-foreground px-3 text-base-foreground hover:bg-secondary-background-hover"
@click="handleRestart"
>
{{ t('manager.applyChanges') }}
</Button>
<Button
v-else-if="!isRestartCompleted"
variant="muted-textonly"
size="sm"
class="rounded-full font-bold"
:aria-label="
t(isExpanded ? 'contextMenu.Collapse' : 'contextMenu.Expand')
"
@click.stop="toggle"
>
<i
:class="isExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
/>
</Button>
<Button
variant="muted-textonly"
size="sm"
class="rounded-full font-bold"
:aria-label="t('g.close')"
@click.stop="closeToast"
>
<i class="pi pi-times" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
</template>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex w-full items-center justify-between p-4"> <div class="flex w-full items-center justify-between p-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<i class="icon-[lucide--triangle-alert] text-gold-600"></i> <i class="icon-[lucide--triangle-alert] text-warning-background" />
<p class="m-0 text-sm"> <p class="m-0 text-sm">
{{ $t('importFailed.title') }} {{ $t('importFailed.title') }}
</p> </p>

View File

@@ -38,7 +38,9 @@
v-if="shouldShowManagerBanner" v-if="shouldShowManagerBanner"
class="relative mt-3 mb-4 flex items-center gap-6 rounded-lg bg-yellow-500/20 p-4" class="relative mt-3 mb-4 flex items-center gap-6 rounded-lg bg-yellow-500/20 p-4"
> >
<i class="pi pi-exclamation-triangle text-lg text-yellow-600"></i> <i
class="icon-[lucide--triangle-alert] text-lg text-warning-background"
/>
<div class="flex flex-1 flex-col gap-2"> <div class="flex flex-1 flex-col gap-2">
<p class="m-0 text-sm font-bold"> <p class="m-0 text-sm font-bold">
{{ $t('manager.conflicts.warningBanner.title') }} {{ $t('manager.conflicts.warningBanner.title') }}

View File

@@ -2,7 +2,7 @@
<div class="flex h-12 w-full items-center justify-between pl-6"> <div class="flex h-12 w-full items-center justify-between pl-6">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Warning Icon --> <!-- Warning Icon -->
<i class="icon-[lucide--triangle-alert] text-gold-600"></i> <i class="icon-[lucide--triangle-alert] text-warning-background" />
<!-- Title --> <!-- Title -->
<p class="text-base font-bold"> <p class="text-base font-bold">
{{ $t('manager.conflicts.title') }} {{ $t('manager.conflicts.title') }}

View File

@@ -411,7 +411,7 @@ describe('PackVersionSelectorPopover', () => {
expect(mockCheckNodeCompatibility).toHaveBeenCalled() expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for incompatible versions // The warning icon should be shown for incompatible versions
const warningIcons = wrapper.findAll('.pi-exclamation-triangle') const warningIcons = wrapper.findAll('.icon-\\[lucide--triangle-alert\\]')
expect(warningIcons.length).toBeGreaterThan(0) expect(warningIcons.length).toBeGreaterThan(0)
}) })
@@ -536,7 +536,7 @@ describe('PackVersionSelectorPopover', () => {
expect(mockCheckNodeCompatibility).toHaveBeenCalled() expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for version incompatible packages // The warning icon should be shown for version incompatible packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle') const warningIcons = wrapper.findAll('.icon-\\[lucide--triangle-alert\\]')
expect(warningIcons.length).toBeGreaterThan(0) expect(warningIcons.length).toBeGreaterThan(0)
}) })
@@ -662,7 +662,7 @@ describe('PackVersionSelectorPopover', () => {
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
// The warning icon should be shown for banned packages in the dropdown options // The warning icon should be shown for banned packages in the dropdown options
const warningIcons = wrapper.findAll('.pi-exclamation-triangle') const warningIcons = wrapper.findAll('.icon-\\[lucide--triangle-alert\\]')
expect(warningIcons.length).toBeGreaterThan(0) expect(warningIcons.length).toBeGreaterThan(0)
}) })
@@ -705,7 +705,7 @@ describe('PackVersionSelectorPopover', () => {
expect(mockCheckNodeCompatibility).toHaveBeenCalled() expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for security pending packages // The warning icon should be shown for security pending packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle') const warningIcons = wrapper.findAll('.icon-\\[lucide--triangle-alert\\]')
expect(warningIcons.length).toBeGreaterThan(0) expect(warningIcons.length).toBeGreaterThan(0)
}) })
}) })

View File

@@ -45,7 +45,7 @@
value: slotProps.option.conflictMessage, value: slotProps.option.conflictMessage,
showDelay: 300 showDelay: 300
}" }"
class="pi pi-exclamation-triangle text-yellow-500" class="icon-[lucide--triangle-alert] text-warning-background"
/> />
<VerifiedIcon v-else :size="20" class="relative right-0.5" /> <VerifiedIcon v-else :size="20" class="relative right-0.5" />
</template> </template>

View File

@@ -192,9 +192,9 @@ describe('PackEnableToggle', () => {
const wrapper = mountComponent() const wrapper = mountComponent()
// Check if warning icon exists // Check if warning icon exists
const warningIcon = wrapper.find('.pi-exclamation-triangle') const warningIcon = wrapper.find('.icon-\\[lucide--triangle-alert\\]')
expect(warningIcon.exists()).toBe(true) expect(warningIcon.exists()).toBe(true)
expect(warningIcon.classes()).toContain('text-yellow-500') expect(warningIcon.classes()).toContain('text-warning-background')
}) })
it('should not show warning icon when package has no conflicts', () => { it('should not show warning icon when package has no conflicts', () => {
@@ -204,7 +204,7 @@ describe('PackEnableToggle', () => {
const wrapper = mountComponent() const wrapper = mountComponent()
// Check if warning icon does not exist // Check if warning icon does not exist
const warningIcon = wrapper.find('.pi-exclamation-triangle') const warningIcon = wrapper.find('.icon-\\[lucide--triangle-alert\\]')
expect(warningIcon.exists()).toBe(false) expect(warningIcon.exists()).toBe(false)
}) })
}) })

View File

@@ -9,7 +9,9 @@
class="flex h-6 w-6 cursor-pointer items-center justify-center" class="flex h-6 w-6 cursor-pointer items-center justify-center"
@click="showConflictModal(true)" @click="showConflictModal(true)"
> >
<i class="pi pi-exclamation-triangle text-xl text-yellow-500"></i> <i
class="icon-[lucide--triangle-alert] text-xl text-warning-background"
/>
</div> </div>
<ToggleSwitch <ToggleSwitch
v-if="!canToggleDirectly" v-if="!canToggleDirectly"
@@ -37,12 +39,11 @@ import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import type { components } from '@/types/comfyRegistryTypes' import type { components } from '@/types/comfyRegistryTypes'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment' import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useImportFailedDetection } from '@/workbench/extensions/manager/composables/useImportFailedDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore' import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes' import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
import { useImportFailedDetection } from '../../../composables/useImportFailedDetection'
const TOGGLE_DEBOUNCE_MS = 256 const TOGGLE_DEBOUNCE_MS = 256
const { nodePack } = defineProps<{ const { nodePack } = defineProps<{

View File

@@ -7,7 +7,7 @@
> >
<i <i
v-if="hasConflict && !isInstalling && !isLoading" v-if="hasConflict && !isInstalling && !isLoading"
class="pi pi-exclamation-triangle text-yellow-500" class="icon-[lucide--triangle-alert] text-warning-background"
/> />
<DotSpinner <DotSpinner
v-else-if="isLoading || isInstalling" v-else-if="isLoading || isInstalling"

View File

@@ -401,36 +401,29 @@ export function useConflictDetection() {
// Process import failures // Process import failures
for (const [packageId, failureInfo] of Object.entries(importFailInfo)) { for (const [packageId, failureInfo] of Object.entries(importFailInfo)) {
if (failureInfo && typeof failureInfo === 'object') { if (!failureInfo || typeof failureInfo !== 'object') continue
// Extract error information from Manager API response
const errorMsg = failureInfo.error || 'Unknown import error'
const traceback = failureInfo.traceback || ''
// Combine error and traceback for display const errorMsg = failureInfo.error || 'Unknown import error'
const fullErrorInfo = traceback || errorMsg const fullErrorInfo = failureInfo.traceback || errorMsg
results.push({ results.push({
package_id: packageId, package_id: packageId,
package_name: packageId, package_name: packageId,
has_conflict: true, has_conflict: true,
conflicts: [ conflicts: [
{
type: 'import_failed',
current_value: errorMsg,
required_value: fullErrorInfo
}
],
is_compatible: false
})
console.warn(
`[ConflictDetection] Python import failure detected for ${packageId}:`,
{ {
error: errorMsg, type: 'import_failed',
hasTraceback: !!traceback current_value: errorMsg,
required_value: fullErrorInfo
} }
) ],
} is_compatible: false
})
console.warn(
`[ConflictDetection] Python import failure detected for ${packageId}:`,
errorMsg
)
} }
return results return results

View File

@@ -4,13 +4,6 @@ import { ref } from 'vue'
import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue' import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue'
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes' import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
// Mock dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showManagerProgressDialog: vi.fn()
})
}))
// Mock the app API // Mock the app API
vi.mock('@/scripts/app', () => ({ vi.mock('@/scripts/app', () => ({
app: { app: {

View File

@@ -4,7 +4,6 @@ import type { Ref } from 'vue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { normalizePackKeys } from '@/utils/packUtils' import { normalizePackKeys } from '@/utils/packUtils'
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes' import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
@@ -27,8 +26,6 @@ export const useManagerQueue = (
taskQueue: Ref<ManagerTaskQueue>, taskQueue: Ref<ManagerTaskQueue>,
installedPacks: Ref<Record<string, any>> installedPacks: Ref<Record<string, any>>
) => { ) => {
const { showManagerProgressDialog } = useDialogService()
// Task queue state (read-only from server) // Task queue state (read-only from server)
const maxHistoryItems = ref(64) const maxHistoryItems = ref(64)
const isLoading = ref(false) const isLoading = ref(false)
@@ -113,15 +110,6 @@ export const useManagerQueue = (
(event: CustomEvent<ManagerWsTaskDoneMsg>) => { (event: CustomEvent<ManagerWsTaskDoneMsg>) => {
if (event?.type === MANAGER_WS_TASK_DONE_NAME && event.detail?.state) { if (event?.type === MANAGER_WS_TASK_DONE_NAME && event.detail?.state) {
updateTaskState(event.detail.state) updateTaskState(event.detail.state)
// If no more tasks are running/pending, hide the progress dialog
if (allTasksDone.value) {
setTimeout(() => {
if (allTasksDone.value) {
showManagerProgressDialog()
}
}, 1000) // Small delay to let users see completion
}
} }
} }
) )
@@ -133,9 +121,6 @@ export const useManagerQueue = (
(event: CustomEvent<ManagerWsTaskStartedMsg>) => { (event: CustomEvent<ManagerWsTaskStartedMsg>) => {
if (event?.type === MANAGER_WS_TASK_STARTED_NAME && event.detail?.state) { if (event?.type === MANAGER_WS_TASK_STARTED_NAME && event.detail?.state) {
updateTaskState(event.detail.state) updateTaskState(event.detail.state)
// Show progress dialog when a task starts
showManagerProgressDialog()
} }
} }
) )

View File

@@ -17,12 +17,6 @@ vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
useComfyManagerService: vi.fn() useComfyManagerService: vi.fn()
})) }))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showManagerProgressDialog: vi.fn()
})
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerQueue', () => { vi.mock('@/workbench/extensions/manager/composables/useManagerQueue', () => {
const enqueueTaskMock = vi.fn() const enqueueTaskMock = vi.fn()

View File

@@ -8,7 +8,7 @@ import { useCachedRequest } from '@/composables/useCachedRequest'
import { useServerLogs } from '@/composables/useServerLogs' import { useServerLogs } from '@/composables/useServerLogs'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { normalizePackKeys } from '@/utils/packUtils' import { normalizePackKeys } from '@/utils/packUtils'
import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue' import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService' import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
@@ -32,7 +32,6 @@ type UpdateAllPacksParams = components['schemas']['UpdateAllPacksParams']
export const useComfyManagerStore = defineStore('comfyManager', () => { export const useComfyManagerStore = defineStore('comfyManager', () => {
const { t } = useI18n() const { t } = useI18n()
const managerService = useComfyManagerService() const managerService = useComfyManagerService()
const { showManagerProgressDialog } = useDialogService()
const installedPacks = ref<InstalledPacksResponse>({}) const installedPacks = ref<InstalledPacksResponse>({})
const enabledPacksIds = ref<Set<string>>(new Set()) const enabledPacksIds = ref<Set<string>>(new Set())
@@ -204,8 +203,6 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
}) })
try { try {
// Show progress dialog immediately when task is queued
showManagerProgressDialog()
managerQueue.isProcessing.value = true managerQueue.isProcessing.value = true
// Prepare logging hook // Prepare logging hook
@@ -392,44 +389,3 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
enablePack enablePack
} }
}) })
/**
* Store for state of the manager progress dialog content.
* The dialog itself is managed by the dialog store. This store is used to
* manage the visibility of the dialog's content, header, footer.
*/
export const useManagerProgressDialogStore = defineStore(
'managerProgressDialog',
() => {
const isExpanded = ref(false)
const activeTabIndex = ref(0)
const setActiveTabIndex = (index: number) => {
activeTabIndex.value = index
}
const getActiveTabIndex = () => {
return activeTabIndex.value
}
const toggle = () => {
isExpanded.value = !isExpanded.value
}
const collapse = () => {
isExpanded.value = false
}
const expand = () => {
isExpanded.value = true
}
return {
isExpanded,
toggle,
collapse,
expand,
setActiveTabIndex,
getActiveTabIndex
}
}
)