mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 09:45:13 +00:00
Merge remote-tracking branch 'origin/main' into pysssss/asset-delete-progress
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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: {}
|
||||||
|
|||||||
12
AGENTS.md
12
AGENTS.md
@@ -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 |
@@ -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
134
pnpm-lock.yaml
generated
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(() =>
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
|||||||
30
src/components/common/StatusBadge.vue
Normal file
30
src/components/common/StatusBadge.vue
Normal 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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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="'→'"></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)
|
||||||
|
|
||||||
|
|||||||
292
src/components/honeyToast/HoneyToast.stories.ts
Normal file
292
src/components/honeyToast/HoneyToast.stories.ts
Normal 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>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
137
src/components/honeyToast/HoneyToast.test.ts
Normal file
137
src/components/honeyToast/HoneyToast.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
46
src/components/honeyToast/HoneyToast.vue
Normal file
46
src/components/honeyToast/HoneyToast.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
94
src/components/toast/ProgressToastItem.stories.ts
Normal file
94
src/components/toast/ProgressToastItem.stories.ts
Normal 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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/components/toast/ProgressToastItem.vue
Normal file
64
src/components/toast/ProgressToastItem.vue
Normal 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>
|
||||||
@@ -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
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
176
src/components/ui/stepper/FormattedNumberStepper.vue
Normal file
176
src/components/ui/stepper/FormattedNumberStepper.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
@@ -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']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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) */
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
250
src/platform/assets/components/ModelImportProgressDialog.vue
Normal file
250
src/platform/assets/components/ModelImportProgressDialog.vue
Normal 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>
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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>
|
|
||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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') }}
|
||||||
|
|||||||
@@ -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') }}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user