123 Commits

Author SHA1 Message Date
Koro
4f9a437725 Improvements in previews reading (#204)
* Improve model preview handling and optimize file processing

* increate the version
2025-09-05 16:45:51 +08:00
Hayden
815a483cf0 Prepare release 2.8.1 2025-09-03 15:14:07 +08:00
Hayden
ae37765017 fix: Add error message tag (#203) 2025-09-03 15:13:08 +08:00
Hayden
ebef300279 fix: Validate existence of entry path in model preview generation (#202) 2025-09-03 15:06:50 +08:00
Hayden
38cd328e57 Prepare release 2.8.0 2025-08-15 10:13:10 +08:00
Kevin Lewis
71a200ed5c add support for video previews (#197)
* add support for video previews

* fix two cases where video previews did not show
2025-08-15 10:12:13 +08:00
Hayden
c96a164f68 Prepare release 2.7.0 2025-08-11 16:59:45 +08:00
Hayden
0ae0716272 fix: Ensure downloadable files are available for model versions without model files (#199) 2025-08-11 10:51:18 +08:00
Hayden
b692270f87 perf: Reconstruct the i18n directory structure (#198) 2025-08-11 10:38:40 +08:00
Hayden
a9675a5d83 feat: Add model upload functionality (#194) 2025-08-11 09:10:39 +08:00
Hayden
ac4a168f13 feat: support download multiple actual files (#196) 2025-08-11 09:10:20 +08:00
Hayden
8b9f3a0e65 191 windows path is wrong when dragging to create lora node from manager (#195)
* fix: match system path style

* fix: only match dragToAddModelNode
2025-08-11 09:09:51 +08:00
Hayden
8d7e32eaf6 Prepare release 2.6.3 2025-04-29 17:32:13 +08:00
Hayden
e964f26798 Secure storage API keys (#181)
* Migrate api key to private.key

* Optimize API Key setting
2025-04-29 17:30:36 +08:00
Hayden
3cfbb5ac0e 178 bug borderline race condition (#179)
* Add assets files

* Prepare release 2.6.2
2025-04-21 17:17:28 +08:00
Hayden
4472357537 176 bug the version selection panel didnt show up (#177)
* Remove custom z-index

* Prepare release 2.6.1
2025-04-21 10:54:00 +08:00
hayden
aabf3f99b3 Add browser version collection 2025-04-21 10:34:11 +08:00
hayden
6bd6b19c1d format code 2025-04-21 09:54:30 +08:00
Hayden
411219df7d Update publish.yml 2025-04-10 13:15:13 +08:00
Hayden
cc29349aee Prepare release 2.6.0 2025-04-10 13:08:46 +08:00
Hayden
f639e3c795 Support extension gguf (#175) 2025-04-10 13:07:54 +08:00
Hayden
5251eeaa93 Refactor scan infomation featurn (#174)
* feat: add scanning setting panel

* feat: implement the back-end interface

* feat: add i18n-zh

* chore: remove never used code
2025-04-10 13:07:33 +08:00
hayden
3bfc6c28af chore(deps): update vue 2025-04-08 10:32:57 +08:00
hayden
c91eff16ae Remove build sourcemap 2025-04-08 10:20:11 +08:00
hayden
2d638a3451 Add issue template 2025-04-08 10:16:53 +08:00
hayden
280b6ed7c0 Delete action permission restrictions 2025-04-08 10:15:35 +08:00
Hayden
7de73ae09c Cancel Release 2025-03-28 20:49:15 +08:00
Hayden
0fdea64c79 Update publish.yml 2025-03-28 20:47:24 +08:00
Hayden
2b9327e6ca Update publish.yml 2025-03-28 20:34:51 +08:00
Hayden
c33b4e0333 Update publish.yml 2025-03-28 20:25:02 +08:00
Hayden
6dcaed7764 Update publish.yml 2025-03-28 20:22:11 +08:00
Hayden
ab4e0d38e1 Error file size (#170)
* Information providing wrong file size

* prepare release 2.5.5
2025-03-28 17:23:41 +08:00
Robin Huang
581d2c14fc chore(publish): update GitHub Actions workflow for conditional execution and permissions (#168)
- Added permissions to allow writing to issues.
- Introduced conditional execution for the publish-node job based on repository owner.
- Updated checkout action to version 4.

Co-authored-by: snomiao <snomiao+comfy-pr@gmail.com>
2025-03-28 11:29:10 +08:00
Hayden
811f1bc352 Support optional in py3.9 (#165)
* fix: support optional in py3.9

* prepare release 2.5.4
2025-03-14 17:02:54 +08:00
Hayden
5342b7ec92 fix: miss property (#163) 2025-03-06 10:34:30 +08:00
Hayden
30e1714397 159 python version compatible (#160)
* fix: double quotes nest in f-strings

* prepare release 2.5.3
2025-03-04 15:17:20 +08:00
Hayden
384a106917 pref: optimize dialog property (#158) 2025-03-03 17:02:03 +08:00
Hayden
7378a7deae Feat optimize preview (#156)
* pref: change code structure

* feat(information): support gif preview

* feat(information): support video preview
2025-03-03 14:50:06 +08:00
Hayden
1975e2056d 152 cant click through some nested dirs in tree view (#157)
* fix: basename error

* prepare release 2.5.2
2025-03-03 14:36:13 +08:00
Hayden
8877c1599b prepare release 2.5.1 2025-02-24 11:09:08 +08:00
Hayden
965905305e fix: find subfolder incorrect (#154) 2025-02-24 11:07:43 +08:00
Hayden
312138f981 fix: auto open root folder (#151) 2025-02-22 18:30:29 +08:00
Hayden
76df8cd3cb prepare release 2.5.0 2025-02-22 18:14:38 +08:00
Hayden
df17eae0a2 fix: dialog cover tooltip (#150) 2025-02-22 18:10:43 +08:00
Hayden
7df89c7265 feat: add tooltip for model card and folder path (#149) 2025-02-22 18:10:28 +08:00
Hayden
450072e49d refactor(explorer): optimize openFolder (#148) 2025-02-22 18:10:11 +08:00
Hayden
759865e8ea feat: support search sub folder (#147) 2025-02-22 18:09:59 +08:00
Hayden
304978a7b8 prepare release 2.4.0 2025-02-19 16:16:14 +08:00
Hayden
704f35a1a8 feat: add context menu (#143) 2025-02-19 16:15:19 +08:00
Hayden
ce42960d57 fix(download): miss parameter (#142) 2025-02-19 16:11:15 +08:00
Hayden
05fa31f2c5 fix: download module error (#141) 2025-02-19 14:37:27 +08:00
Hayden
ea26ec5098 fix: dialog cover confirm and toast (#140) 2025-02-19 14:12:53 +08:00
Hayden
3d01c2dfda fix: content error in create download (#139) 2025-02-19 13:47:44 +08:00
Hayden
59552841e7 fix: add error tip (#137) 2025-02-18 16:44:53 +08:00
Hayden
ad6045f286 fix(Input): valid none value (#136) 2025-02-18 16:21:38 +08:00
Hayden
86c11e5343 [New Feature] sub directories support (#135)
* feat: add close all dialog

* feat: add new ui toggle setting

* feat: add tree display ui

* feat: add search and sort

* feat: change model data structure

* pref: Optimize model data structure

* feat: set sub folder by choose
2025-02-18 16:03:07 +08:00
Hayden
37be9a0b0d prepare release 2.3.4 2025-02-18 16:01:49 +08:00
Hayden
fcea052dde fix: resolve path (#132) 2025-02-10 17:00:08 +08:00
Hayden
9e95e7bd74 style: optimize style (#131) 2025-02-10 16:42:53 +08:00
Hayden
7e58d0a82d fix(setting): no modified value saved (#130)
* fix: save setting value

* prepare release 2.3.3
2025-02-10 13:51:45 +08:00
Hayden
55a4eff01b prepare releas 2.3.2 2025-02-10 12:42:29 +08:00
Hayden
45cf18299f feat: optimize resize card size (#129) 2025-02-10 12:41:00 +08:00
Hayden
c7898c47f1 fix: unpack folder_names_and_paths error (#128) 2025-02-10 10:59:36 +08:00
Hayden
17ab373b9c fix: change model size type to float (#126) 2025-02-06 12:02:00 +08:00
boeto
f6368fe20b fix: model preview path (#120) 2025-02-04 20:27:00 +08:00
Hayden
92f2d5ab9e Fix unable to install (#119)
* fix: release without requirements.txt

* prepare release 2.3.1
2025-02-04 11:12:15 +08:00
boeto
130c75f5bf fix huggingface download with tokens (#116) 2025-02-03 20:30:07 +08:00
Hayden
921dabc057 pref: optimize scan cost (#117) 2025-02-03 20:19:02 +08:00
Hayden
ac21c8015d style: optimize toolbar layout (#115) 2025-02-03 16:52:37 +08:00
Hayden
123b46fa88 prepare release 2.3.0 2025-02-03 16:41:24 +08:00
Hayden
6a77554932 Feat resize model card (#104)
* feat: Use setting definition card size

* refactor: Optimize computed value of the list items

- Add useContainerResize hooks
- Remove v-resize
- Change cols to computed

* refactor(ModelCard): Optimize style

- Control the display of button or name in difference sizes
- Add name tooltip when hiding name

* feat: Add i18n

* pref: optimize style code structure

* feat: add quick resize card size

* feat: add custom size tooltip

* feat: optimize card tool button display judgment
2025-02-03 16:40:33 +08:00
Hayden
faf4c15865 Pref hooks (#113)
* pref: replace useContainerResize

* pref: replace useContainerScroll
2025-02-02 19:54:23 +08:00
Hayden
f079d8bde5 feat: add scroll thumb draggable (#112)
add dependencies @vueuse/core
2025-02-02 19:44:44 +08:00
Hayden
56a2deb4eb pref: optimize virtual scroll (#111) 2025-02-02 16:42:25 +08:00
Hayden
448ea4b1ba pref: use hooks instead of directive (#108)
- remove v-resize
- add useContainerResize
- remove v-container
- add useContainerQueries
- add useContainerScroll
2025-02-01 11:56:17 +08:00
Hayden
e5d9950429 fix: overlay zIndex (#109) 2025-02-01 11:55:58 +08:00
Hayden
e7e2f4ce78 fix: set base-z-index (#107) 2025-01-31 11:53:01 +08:00
Hayden
0575124d35 Refactor code structure (#106)
* refactor: rename searcher.py to information.py

* refactor: move the routes into each sub-modules

* refactor: move services's func into sub-modules
2025-01-30 21:06:24 +08:00
Hayden
4df226be82 feat: add deprecated decorator (#105) 2025-01-30 10:04:56 +08:00
Hayden
1ba80fab2e prepare release 2.2.3 2025-01-16 10:24:42 +08:00
Hayden
b9e637049a fix: Inability to Scroll model dir list (#101) 2025-01-16 10:23:51 +08:00
Hayden
bfccc6f04f fix: can't change or delete preview (#100) 2025-01-15 16:48:41 +08:00
Hayden
89c249542a fix: cant't close create task dialog (#98) 2025-01-15 16:11:21 +08:00
Hayden
136bc0ecd5 feat(dialog): Optimize dialog closing logic (#97)
- Add optional parameters to the close function to support parameterless calling
- When the dialog parameter is not provided, automatically close the dialog box on the top of the stack
2025-01-15 16:03:46 +08:00
Hayden
8653af1f14 fix: misplaced preview buttons (#96) 2025-01-14 16:05:11 +08:00
Hayden
354b5c840a feat: allow multi create task dialog (#95) 2025-01-14 11:27:05 +08:00
Hayden
be383ac6e1 fix: potential bug after adding excluded directories (#94)
* Revert "fix: missing parameter (#93)"

This reverts commit c2406a1fd1.

* Revert "feat: add exclude scan model types (#92)"

This reverts commit 40a1a7f43a.

* feat: add exclude scan model types

* fix: potential bug after adding excluded directories
2025-01-14 11:04:41 +08:00
Hayden
c2406a1fd1 fix: missing parameter (#93) 2025-01-13 15:58:11 +08:00
Hayden
4132b2d8c4 prepare release 2.2.0 2025-01-13 15:16:31 +08:00
Hayden
40a1a7f43a feat: add exclude scan model types (#92) 2025-01-13 15:15:32 +08:00
Hayden
14bb6f194d Fix: i18n settings (#91)
* fix(i18n): Getting language configuration exception

* feat(i18n): Change settings display
2025-01-13 11:58:17 +08:00
Hayden
97b26549ce feat: Remove migration functionality (#89) 2025-01-10 17:11:15 +08:00
Hayden
e75275dfff fix: Container queries occasionally fail (#88)
- Use js dynamic calculation instead of container query
- Remove @tailwindcss/container-queries
2025-01-10 16:04:49 +08:00
Robin Huang
c1e89eb177 chore(licence-update): Update PyProject Toml - License (#87)
Co-authored-by: snomiao <snomiao+comfy-pr@gmail.com>
2025-01-09 10:16:53 +08:00
Hayden
bfedcb2a7d prepare release 2.1.6 2024-12-08 15:33:09 +08:00
Hayden
1d01ce009f Fixed infinite Load (#79) 2024-12-08 15:31:50 +08:00
Hayden
5c017137b0 Fixed the loading could not be closed correctly (#77)
* Fix hide loading before show it

* Release hotfix
2024-12-06 22:23:11 +08:00
Hayden
00d23ff74f prepare release 2.1.4
Optimize models request API
2024-12-03 14:14:11 +08:00
Hayden
dc46f498be Split model get list (#74)
Get the model list separately by model type and defer the request.
2024-12-03 14:05:18 +08:00
Hayden
6d67b00b17 Fix publishing failed (#69) 2024-11-29 09:40:25 +08:00
Hayden
cda24405b5 prepare release 2.1.3 2024-11-28 13:03:06 +08:00
Hayden
6fa90be8c4 Fix preview path (#66) 2024-11-28 13:02:36 +08:00
Hayden
5a28789af7 prepare release 2.1.2 2024-11-28 12:07:45 +08:00
Hayden
dada903b2b Feature scan extra folders (#65)
* scan extra folders

Other extension may be add models folder in folder_paths

* Fix scanned non-model files

Model file suffix specified
2024-11-28 12:04:23 +08:00
Hayden
e8916307aa Skip hidden model files (#64) 2024-11-28 12:01:55 +08:00
Hayden
8b6c6ebdea Fix some minor bug (#62)
* Fix print info

* Delete empty line
2024-11-25 15:58:18 +08:00
Hayden
1796b101c5 Fix missing preview (#56)
* fix: can't scan .preview.ext file

* realse fix
2024-11-22 17:44:46 +08:00
Hayden
bd874e5ff3 prepare release 2.1.0
- feature scan model info
2024-11-21 22:07:40 +08:00
Hayden
6a64f3050a chore: update installation explanation (#54) 2024-11-21 22:05:56 +08:00
Hayden
659637c6e0 Feature scan info (#53)
* pref: migrate fetch model info to end back

* fix(download): can't select model type

* feat: add scan model info

* feat: add trigger button in setting

* feat: add printing logs

* chore: add explanation of scan model info
2024-11-21 22:04:39 +08:00
hayden
6ae7e1835f pref: add debug printer 2024-11-15 21:52:45 +08:00
hayden
4038e240f0 pref: optimize styles
Reduce the possibility of style pollution.
2024-11-11 14:21:52 +08:00
hayden
254ad8c597 pref: optimize parameter transmission 2024-11-11 12:08:01 +08:00
hayden
dfae915b77 pref: optimize print logging 2024-11-11 11:51:22 +08:00
hayden
f57ffc9e7a chore: add check action 2024-11-11 11:40:11 +08:00
hayden
6904aca24c chore: format code 2024-11-11 11:39:32 +08:00
Hayden
e36af38375 prepare release 2.0.3 2024-11-11 11:13:24 +08:00
Hayden
d4922f59d3 Merge pull request #50 from hayden-fr/feature-optimize-ui
Feature optimize UI
2024-11-11 11:11:30 +08:00
Hayden
f2e17744ae Merge pull request #47 from hayden-fr/feature-multi-user
feat: adapt to multi user
2024-11-11 11:11:09 +08:00
hayden
3b25d3e347 pref: optimize the timing of scrollbar reset 2024-11-08 12:42:00 +08:00
hayden
3a0676b29f pref(download): keep model content status 2024-11-08 11:49:18 +08:00
hayden
a1e5761dbc feat: adapt to multi user 2024-11-08 11:13:01 +08:00
hayden
ae518b541a chore(download): add todo notes 2024-11-07 09:42:37 +08:00
78 changed files with 5677 additions and 2360 deletions

86
.github/ISSUE_TEMPLATE/bug-report.yaml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: Bug Report
description: 'Something is not behaving as expected.'
title: '[Bug]: '
body:
- type: markdown
attributes:
value: |
Before submitting a **Bug Report**, please ensure the following:
- **1:** You are running the latest version of ComfyUI-Model-Manager.
- **2:** You have looked at the existing bug reports and made sure this isn't already reported.
- **3:** You confirmed that the bug is not caused by other custom nodes.
- type: textarea
id: environment
attributes:
label: Environment
description: 'Describe as detailed as possible what your current usage environment is. local? cloud? etc...'
value: |
[Operating System]:
[Python Version]:
[ComfyUI Version]:
[ComfyUI Frontend Version]:
[ComfyUI-Model-Manager Version]:
[Browser Version]:
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: 'What you expected to happen.'
validations:
required: true
- type: textarea
attributes:
label: Actual Behavior
description: 'What actually happened. Please include a screenshot / video clip of the issue if possible.'
validations:
required: true
- type: textarea
attributes:
label: Steps to Reproduce
description: "Describe how to reproduce the issue. Please be sure to attach a workflow JSON or PNG, ideally one that doesn't require custom nodes to test. If the bug open happens when certain custom nodes are used, most likely that custom node is what has the bug rather than ComfyUI, in which case it should be reported to the node's author."
validations:
required: true
- type: textarea
attributes:
label: Debug Logs
description: 'Please copy the output from your terminal logs here.'
render: powershell
validations:
required: true
- type: textarea
attributes:
label: Browser Logs
description: 'Please copy the output from your browser logs here. You can access this by pressing F12 to toggle the developer tools, then navigating to the Console tab.'
validations:
required: true
- type: textarea
attributes:
label: Setting JSON
description: 'Please upload the setting file here. The setting file is located at `user/default/comfy.settings.json`'
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers do you use to access the UI ?
multiple: true
options:
- Mozilla Firefox
- Google Chrome
- Brave
- Apple Safari
- Microsoft Edge
- Android
- iOS
- Other
- type: textarea
attributes:
label: Other
description: 'Any other additional information you think might be helpful.'
validations:
required: false

View File

@@ -0,0 +1,39 @@
name: Feature Request
description: Suggest an idea for this project
title: '[Feature Request]: '
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the feature you want, and that it's not implemented in a recent build/commit.
options:
- label: I have searched the existing issues and checked the recent builds/commits
required: true
- type: markdown
attributes:
value: |
*Please fill this form with as much information as possible, provide screenshots and/or illustrations of the feature if possible*
- type: textarea
id: feature
attributes:
label: What would your feature do ?
description: Tell us about your feature in a very clear and simple way, and what problem it would solve
validations:
required: true
- type: textarea
id: workflow
attributes:
label: Proposed workflow
description: Please provide us with step by step information on how you'd like the feature to be accessed and used
value: |
1. Go to ....
2. Press ....
3. ...
validations:
required: true
- type: textarea
id: misc
attributes:
label: Additional information
description: Add any other context or screenshots about the feature request here.

39
.github/workflows/eslint.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: ESLint
on:
push:
paths:
- '**/*.vue'
- '**/*.ts'
- '**/*.tsx'
- '**/*.js'
pull_request:
paths:
- '**/*.vue'
- '**/*.ts'
- '**/*.tsx'
- '**/*.js'
jobs:
eslint:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Lint code
run: pnpm run lint

40
.github/workflows/format.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Prettier Check
on:
push:
paths:
- '**/*.vue'
- '**/*.ts'
- '**/*.tsx'
- '**/*.js'
pull_request:
paths:
- '**/*.vue'
- '**/*.ts'
- '**/*.tsx'
- '**/*.js'
jobs:
prettier:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run Prettier check
run: pnpm exec prettier --check './**/*.{js,ts,tsx,vue}'

View File

@@ -11,6 +11,7 @@ jobs:
publish-node: publish-node:
name: Release and Publish Custom Node to registry name: Release and Publish Custom Node to registry
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'hayden-fr' }}
steps: steps:
- name: Check out code - name: Check out code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -60,7 +61,7 @@ jobs:
run: | run: |
pnpm install pnpm install
pnpm run build pnpm run build
tar -czf dist.tar.gz py/ web/ __init__.py LICENSE pyproject.toml tar -czf dist.tar.gz assets/ py/ web/ __init__.py LICENSE pyproject.toml requirements.txt
- name: Create release draft - name: Create release draft
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2

29
.github/workflows/pylint.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Python Linting
on:
push:
paths:
- '**/*.py'
pull_request:
paths:
- '**/*.py'
jobs:
pylint:
name: Run Pylint
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.x
- name: Install Pylint
run: pip install pylint
- name: Run Pylint
run: pylint --rcfile=.pylintrc $(find . -type f -name "*.py")

3
.gitignore vendored
View File

@@ -197,3 +197,6 @@ web/
# config # config
config/ config/
# private info
private.key

3
.pylintrc Normal file
View File

@@ -0,0 +1,3 @@
[MESSAGES CONTROL]
disable=all
enable=eval-used

View File

@@ -1,5 +1,6 @@
{ {
"recommendations": [ "recommendations": [
"esbenp.prettier-vscode" "esbenp.prettier-vscode",
"lokalise.i18n-ally"
] ]
} }

10
.vscode/settings.json vendored
View File

@@ -23,7 +23,6 @@
"inputgroup", "inputgroup",
"inputgroupaddon", "inputgroupaddon",
"iconfield", "iconfield",
"inputicon",
"inputtext", "inputtext",
"overlaybadge", "overlaybadge",
"usetoast", "usetoast",
@@ -44,5 +43,10 @@
"editor.quickSuggestions": { "editor.quickSuggestions": {
"strings": "on" "strings": "on"
}, },
"css.lint.unknownAtRules": "ignore" "css.lint.unknownAtRules": "ignore",
} "i18n-ally.localesPaths": [
"src/locales"
],
"i18n-ally.sourceLanguage": "en",
"i18n-ally.keystyle": "nested"
}

View File

@@ -4,15 +4,13 @@ Download, browse and delete models in ComfyUI.
Designed to support desktop, mobile and multi-screen devices. Designed to support desktop, mobile and multi-screen devices.
# Usage # Installation
```bash There are three installation methods, choose one
cd /path/to/ComfyUI/custom_nodes
git clone https://github.com/hayden-fr/ComfyUI-Model-Manager.git 1. Clone the repository: `git clone https://github.com/hayden-fr/ComfyUI-Model-Manager.git` to your ComfyUI `custom_nodes` folder
cd /path/to/ComfyUI/custom_nodes/ComfyUI-Model-Manager 2. Download the [latest release](https://github.com/hayden-fr/ComfyUI-Model-Manager/releases/latest/download/dist.tar.gz) and extract it to your ComfyUI `custom_nodes` folder
npm install 3. Use comfy cli: `comfy node registry-install comfyui-model-manager`
npm run build
```
## Features ## Features
@@ -61,3 +59,9 @@ npm run build
- Read, edit and save notes. (Saved as a `.md` file beside the model). - Read, edit and save notes. (Saved as a `.md` file beside the model).
- Change or remove a model's preview image. - Change or remove a model's preview image.
- View training tags and use the random tag generator to generate prompt ideas. (Inspired by the one in A1111.) - View training tags and use the random tag generator to generate prompt ideas. (Inspired by the one in A1111.)
### Scan Model Information
<img src="demo/scan-model-info.png" alt="Model Manager Demo Screenshot" style="max-width: 100%; max-height: 300px"/>
- Scan models and try to download information & preview.

View File

@@ -1,199 +1,54 @@
import os
import folder_paths import folder_paths
# NOTE: This is an experiment
# Add .gguf extension to supported_pt_extensions
folder_paths.supported_pt_extensions.add(".gguf")
import os
from .py import config from .py import config
from .py import utils from .py import utils
extension_uri = utils.normalize_path(os.path.dirname(__file__))
# Install requirements
requirements_path = utils.join_path(extension_uri, "requirements.txt")
with open(requirements_path, "r", encoding="utf-8") as f:
requirements = f.readlines()
requirements = [x.strip() for x in requirements]
requirements = [x for x in requirements if not x.startswith("#")]
uninstalled_package = [p for p in requirements if not utils.is_installed(p)]
if len(uninstalled_package) > 0:
utils.print_info(f"Install dependencies...")
for p in uninstalled_package:
utils.pip_install(p)
# Init config settings # Init config settings
config.extension_uri = utils.normalize_path(os.path.dirname(__file__)) config.extension_uri = extension_uri
utils.resolve_model_base_paths()
# Try to download web distribution
version = utils.get_current_version() version = utils.get_current_version()
utils.download_web_distribution(version) utils.download_web_distribution(version)
import logging # Add api routes
from aiohttp import web from .py import manager
import traceback from .py import download
from .py import services from .py import information
from .py import upload
routes = config.routes routes = config.routes
manager.ModelManager().add_routes(routes)
@routes.get("/model-manager/ws") download.ModelDownload().add_routes(routes)
async def socket_handler(request): information.Information().add_routes(routes)
""" upload.ModelUploader().add_routes(routes)
Handle websocket connection.
"""
ws = await services.connect_websocket(request)
return ws
@routes.get("/model-manager/base-folders")
async def get_model_paths(request):
"""
Returns the base folders for models.
"""
model_base_paths = config.model_base_paths
return web.json_response({"success": True, "data": model_base_paths})
@routes.post("/model-manager/model")
async def create_model(request):
"""
Create a new model.
request body: x-www-form-urlencoded
- type: model type.
- pathIndex: index of the model folders.
- fullname: filename that relative to the model folder.
- previewFile: preview file.
- description: description.
- downloadPlatform: download platform.
- downloadUrl: download url.
- hash: a JSON string containing the hash value of the downloaded model.
"""
post = await request.post()
try:
task_id = await services.create_model_download_task(post)
return web.json_response({"success": True, "data": {"taskId": task_id}})
except Exception as e:
error_msg = f"Create model download task failed: {str(e)}"
logging.error(error_msg)
logging.debug(traceback.format_exc())
return web.json_response({"success": False, "error": error_msg})
@routes.get("/model-manager/models")
async def read_models(request):
"""
Scan all models and read their information.
"""
try:
result = services.scan_models()
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Read models failed: {str(e)}"
logging.error(error_msg)
logging.debug(traceback.format_exc())
return web.json_response({"success": False, "error": error_msg})
@routes.get("/model-manager/model/{type}/{index}/{filename:.*}")
async def read_model_info(request):
"""
Get the information of the specified model.
"""
model_type = request.match_info.get("type", None)
index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None)
try:
model_path = utils.get_valid_full_path(model_type, index, filename)
result = services.get_model_info(model_path)
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Read model info failed: {str(e)}"
logging.error(error_msg)
logging.debug(traceback.format_exc())
return web.json_response({"success": False, "error": error_msg})
@routes.put("/model-manager/model/{type}/{index}/{filename:.*}")
async def update_model(request):
"""
Update model information.
request body: x-www-form-urlencoded
- previewFile: preview file.
- description: description.
- type: model type.
- pathIndex: index of the model folders.
- fullname: filename that relative to the model folder.
All fields are optional, but type, pathIndex and fullname must appear together.
"""
model_type = request.match_info.get("type", None)
index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None)
post: dict = await request.post()
try:
model_path = utils.get_valid_full_path(model_type, index, filename)
if model_path is None:
raise RuntimeError(f"File {filename} not found")
services.update_model(model_path, post)
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Update model failed: {str(e)}"
logging.error(error_msg)
logging.debug(traceback.format_exc())
return web.json_response({"success": False, "error": error_msg})
@routes.delete("/model-manager/model/{type}/{index}/{filename:.*}")
async def delete_model(request):
"""
Delete model.
"""
model_type = request.match_info.get("type", None)
index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None)
try:
model_path = utils.get_valid_full_path(model_type, index, filename)
if model_path is None:
raise RuntimeError(f"File {filename} not found")
services.remove_model(model_path)
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Delete model failed: {str(e)}"
logging.error(error_msg)
logging.debug(traceback.format_exc())
return web.json_response({"success": False, "error": error_msg})
@routes.get("/model-manager/preview/{type}/{index}/{filename:.*}")
async def read_model_preview(request):
"""
Get the file stream of the specified image.
If the file does not exist, no-preview.png is returned.
:param type: The type of the model. eg.checkpoints, loras, vae, etc.
:param index: The index of the model folders.
:param filename: The filename of the image.
"""
model_type = request.match_info.get("type", None)
index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None)
extension_uri = config.extension_uri
try:
folders = folder_paths.get_folder_paths(model_type)
base_path = folders[index]
abs_path = utils.join_path(base_path, filename)
except:
abs_path = extension_uri
if not os.path.isfile(abs_path):
abs_path = utils.join_path(extension_uri, "assets", "no-preview.png")
return web.FileResponse(abs_path)
@routes.get("/model-manager/preview/download/{filename}")
async def read_download_preview(request):
filename = request.match_info.get("filename", None)
extension_uri = config.extension_uri
download_path = utils.get_download_path()
preview_path = utils.join_path(download_path, filename)
if not os.path.isfile(preview_path):
preview_path = utils.join_path(extension_uri, "assets", "no-preview.png")
return web.FileResponse(preview_path)
WEB_DIRECTORY = "web" WEB_DIRECTORY = "web"

BIN
demo/scan-model-info.png Executable file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -1,18 +1,15 @@
import globals from 'globals'
import pluginJs from '@eslint/js' import pluginJs from '@eslint/js'
import tsEslint from 'typescript-eslint'
import pluginVue from 'eslint-plugin-vue' import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import tsEslint from 'typescript-eslint'
/** @type {import('eslint').Linter.Config[]} */
export default [ export default [
{ {
files: ['src/**/*.{js,mjs,cjs,ts,vue}'], files: ['src/**/*.{js,mjs,cjs,ts,vue}'],
}, },
{ {
ignores: [ ignores: ['src/scripts/*', 'src/types/shims.d.ts', 'src/utils/legacy.ts'],
'src/scripts/*',
'src/extensions/core/*',
'src/types/vue-shim.d.ts',
],
}, },
{ languageOptions: { globals: globals.browser } }, { languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended, pluginJs.configs.recommended,
@@ -25,8 +22,6 @@ export default [
{ {
rules: { rules: {
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off',
}, },
}, },
] ]

View File

@@ -6,18 +6,18 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint src",
"prepare": "husky" "prepare": "husky"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
"@types/lodash": "^4.17.9", "@types/lodash": "^4.17.9",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^22.5.5", "@types/node": "^22.5.5",
"@types/turndown": "^5.0.5",
"@vitejs/plugin-vue": "^5.1.4", "@vitejs/plugin-vue": "^5.1.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.10.0", "eslint": "^9.10.0",
"eslint-plugin-vue": "^9.28.0", "eslint-plugin-vue": "^9.28.0",
"globals": "^15.12.0",
"husky": "^9.1.6", "husky": "^9.1.6",
"less": "^4.2.0", "less": "^4.2.0",
"lint-staged": "^15.2.10", "lint-staged": "^15.2.10",
@@ -27,25 +27,25 @@
"prettier-plugin-tailwindcss": "^0.6.8", "prettier-plugin-tailwindcss": "^0.6.8",
"tailwindcss": "^3.4.12", "tailwindcss": "^3.4.12",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"typescript-eslint": "^8.6.0", "typescript-eslint": "^8.13.0",
"vite": "^5.4.6" "vite": "^5.4.6",
"vue-tsc": "^2.1.10"
}, },
"dependencies": { "dependencies": {
"@primevue/themes": "^4.0.7", "@primevue/themes": "^4.0.7",
"@vueuse/core": "^11.3.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-metadata-block": "^1.0.6", "markdown-it-metadata-block": "^1.0.6",
"primevue": "^4.0.7", "primevue": "^4.0.7",
"turndown": "^7.2.0", "vue": "^3.5.6",
"vue": "^3.4.31", "vue-i18n": "^9.14.0",
"vue-i18n": "^9.13.1",
"yaml": "^2.6.0" "yaml": "^2.6.0"
}, },
"lint-staged": { "lint-staged": {
"./**/*.{js,ts,tsx,vue}": [ "./**/*.{js,ts,tsx,vue}": [
"prettier --write", "prettier --write"
"git add"
] ]
} }
} }

349
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@primevue/themes': '@primevue/themes':
specifier: ^4.0.7 specifier: ^4.0.7
version: 4.0.7 version: 4.0.7
'@vueuse/core':
specifier: ^11.3.0
version: 11.3.0(vue@3.5.6(typescript@5.6.2))
dayjs: dayjs:
specifier: ^1.11.13 specifier: ^1.11.13
version: 1.11.13 version: 1.11.13
@@ -26,22 +29,16 @@ importers:
primevue: primevue:
specifier: ^4.0.7 specifier: ^4.0.7
version: 4.0.7(vue@3.5.6(typescript@5.6.2)) version: 4.0.7(vue@3.5.6(typescript@5.6.2))
turndown:
specifier: ^7.2.0
version: 7.2.0
vue: vue:
specifier: ^3.4.31 specifier: ^3.5.6
version: 3.5.6(typescript@5.6.2) version: 3.5.6(typescript@5.6.2)
vue-i18n: vue-i18n:
specifier: ^9.13.1 specifier: ^9.14.0
version: 9.14.0(vue@3.5.6(typescript@5.6.2)) version: 9.14.0(vue@3.5.6(typescript@5.6.2))
yaml: yaml:
specifier: ^2.6.0 specifier: ^2.6.0
version: 2.6.0 version: 2.6.0
devDependencies: devDependencies:
'@tailwindcss/container-queries':
specifier: ^0.1.1
version: 0.1.1(tailwindcss@3.4.12)
'@types/lodash': '@types/lodash':
specifier: ^4.17.9 specifier: ^4.17.9
version: 4.17.9 version: 4.17.9
@@ -51,9 +48,6 @@ importers:
'@types/node': '@types/node':
specifier: ^22.5.5 specifier: ^22.5.5
version: 22.5.5 version: 22.5.5
'@types/turndown':
specifier: ^5.0.5
version: 5.0.5
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^5.1.4 specifier: ^5.1.4
version: 5.1.4(vite@5.4.6(@types/node@22.5.5)(less@4.2.0))(vue@3.5.6(typescript@5.6.2)) version: 5.1.4(vite@5.4.6(@types/node@22.5.5)(less@4.2.0))(vue@3.5.6(typescript@5.6.2))
@@ -66,6 +60,9 @@ importers:
eslint-plugin-vue: eslint-plugin-vue:
specifier: ^9.28.0 specifier: ^9.28.0
version: 9.28.0(eslint@9.10.0(jiti@1.21.6)) version: 9.28.0(eslint@9.10.0(jiti@1.21.6))
globals:
specifier: ^15.12.0
version: 15.12.0
husky: husky:
specifier: ^9.1.6 specifier: ^9.1.6
version: 9.1.6 version: 9.1.6
@@ -83,10 +80,10 @@ importers:
version: 3.3.3 version: 3.3.3
prettier-plugin-organize-imports: prettier-plugin-organize-imports:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0(prettier@3.3.3)(typescript@5.6.2) version: 4.1.0(prettier@3.3.3)(typescript@5.6.2)(vue-tsc@2.1.10(typescript@5.6.2))
prettier-plugin-tailwindcss: prettier-plugin-tailwindcss:
specifier: ^0.6.8 specifier: ^0.6.8
version: 0.6.8(prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2))(prettier@3.3.3) version: 0.6.8(prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2)(vue-tsc@2.1.10(typescript@5.6.2)))(prettier@3.3.3)
tailwindcss: tailwindcss:
specifier: ^3.4.12 specifier: ^3.4.12
version: 3.4.12 version: 3.4.12
@@ -94,11 +91,14 @@ importers:
specifier: ^5.6.2 specifier: ^5.6.2
version: 5.6.2 version: 5.6.2
typescript-eslint: typescript-eslint:
specifier: ^8.6.0 specifier: ^8.13.0
version: 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) version: 8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)
vite: vite:
specifier: ^5.4.6 specifier: ^5.4.6
version: 5.4.6(@types/node@22.5.5)(less@4.2.0) version: 5.4.6(@types/node@22.5.5)(less@4.2.0)
vue-tsc:
specifier: ^2.1.10
version: 2.1.10(typescript@5.6.2)
packages: packages:
@@ -267,10 +267,20 @@ packages:
peerDependencies: peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/eslint-utils@4.4.1':
resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/regexpp@4.11.1': '@eslint-community/regexpp@4.11.1':
resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint-community/regexpp@4.12.1':
resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint/config-array@0.18.0': '@eslint/config-array@0.18.0':
resolution: {integrity: sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==} resolution: {integrity: sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -333,9 +343,6 @@ packages:
'@jridgewell/trace-mapping@0.3.25': '@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@mixmark-io/domino@2.2.0':
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -463,11 +470,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@tailwindcss/container-queries@0.1.1':
resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
peerDependencies:
tailwindcss: '>=3.2.0'
'@types/estree@1.0.5': '@types/estree@1.0.5':
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
@@ -486,11 +488,11 @@ packages:
'@types/node@22.5.5': '@types/node@22.5.5':
resolution: {integrity: sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==} resolution: {integrity: sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==}
'@types/turndown@5.0.5': '@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==} resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@typescript-eslint/eslint-plugin@8.6.0': '@typescript-eslint/eslint-plugin@8.13.0':
resolution: {integrity: sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==} resolution: {integrity: sha512-nQtBLiZYMUPkclSeC3id+x4uVd1SGtHuElTxL++SfP47jR0zfkZBJHc+gL4qPsgTuypz0k8Y2GheaDYn6Gy3rg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
'@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0
@@ -500,8 +502,8 @@ packages:
typescript: typescript:
optional: true optional: true
'@typescript-eslint/parser@8.6.0': '@typescript-eslint/parser@8.13.0':
resolution: {integrity: sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==} resolution: {integrity: sha512-w0xp+xGg8u/nONcGw1UXAr6cjCPU1w0XVyBs6Zqaj5eLmxkKQAByTdV/uGgNN5tVvN/kKpoQlP2cL7R+ajZZIQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
@@ -510,12 +512,12 @@ packages:
typescript: typescript:
optional: true optional: true
'@typescript-eslint/scope-manager@8.6.0': '@typescript-eslint/scope-manager@8.13.0':
resolution: {integrity: sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==} resolution: {integrity: sha512-XsGWww0odcUT0gJoBZ1DeulY1+jkaHUciUq4jKNv4cpInbvvrtDoyBH9rE/n2V29wQJPk8iCH1wipra9BhmiMA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/type-utils@8.6.0': '@typescript-eslint/type-utils@8.13.0':
resolution: {integrity: sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==} resolution: {integrity: sha512-Rqnn6xXTR316fP4D2pohZenJnp+NwQ1mo7/JM+J1LWZENSLkJI8ID8QNtlvFeb0HnFSK94D6q0cnMX6SbE5/vA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '*' typescript: '*'
@@ -523,12 +525,12 @@ packages:
typescript: typescript:
optional: true optional: true
'@typescript-eslint/types@8.6.0': '@typescript-eslint/types@8.13.0':
resolution: {integrity: sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==} resolution: {integrity: sha512-4cyFErJetFLckcThRUFdReWJjVsPCqyBlJTi6IDEpc1GWCIIZRFxVppjWLIMcQhNGhdWJJRYFHpHoDWvMlDzng==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.6.0': '@typescript-eslint/typescript-estree@8.13.0':
resolution: {integrity: sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==} resolution: {integrity: sha512-v7SCIGmVsRK2Cy/LTLGN22uea6SaUIlpBcO/gnMGT/7zPtxp90bphcGf4fyrCQl3ZtiBKqVTG32hb668oIYy1g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '*' typescript: '*'
@@ -536,14 +538,14 @@ packages:
typescript: typescript:
optional: true optional: true
'@typescript-eslint/utils@8.6.0': '@typescript-eslint/utils@8.13.0':
resolution: {integrity: sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==} resolution: {integrity: sha512-A1EeYOND6Uv250nybnLZapeXpYMl8tkzYUxqmoKAWnI4sei3ihf2XdZVd+vVOmHGcp3t+P7yRrNsyyiXTvShFQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
'@typescript-eslint/visitor-keys@8.6.0': '@typescript-eslint/visitor-keys@8.13.0':
resolution: {integrity: sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==} resolution: {integrity: sha512-7N/+lztJqH4Mrf0lb10R/CbI1EaAMMGyF5y0oJvFoAhafwgiRA7TXyd8TFn8FC8k5y2dTsYogg238qavRGNnlw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitejs/plugin-vue@5.1.4': '@vitejs/plugin-vue@5.1.4':
@@ -553,6 +555,15 @@ packages:
vite: ^5.0.0 vite: ^5.0.0
vue: ^3.2.25 vue: ^3.2.25
'@volar/language-core@2.4.10':
resolution: {integrity: sha512-hG3Z13+nJmGaT+fnQzAkS0hjJRa2FCeqZt6Bd+oGNhUkQ+mTFsDETg5rqUTxyzIh5pSOGY7FHCWUS8G82AzLCA==}
'@volar/source-map@2.4.10':
resolution: {integrity: sha512-OCV+b5ihV0RF3A7vEvNyHPi4G4kFa6ukPmyVocmqm5QzOd8r5yAtiNvaPEjl8dNvgC/lj4JPryeeHLdXd62rWA==}
'@volar/typescript@2.4.10':
resolution: {integrity: sha512-F8ZtBMhSXyYKuBfGpYwqA5rsONnOwAVvjyE7KPYJ7wgZqo2roASqNWUnianOomJX5u1cxeRooHV59N0PhvEOgw==}
'@vue/compiler-core@3.5.6': '@vue/compiler-core@3.5.6':
resolution: {integrity: sha512-r+gNu6K4lrvaQLQGmf+1gc41p3FO2OUJyWmNqaIITaJU6YFiV5PtQSFZt8jfztYyARwqhoCayjprC7KMvT3nRA==} resolution: {integrity: sha512-r+gNu6K4lrvaQLQGmf+1gc41p3FO2OUJyWmNqaIITaJU6YFiV5PtQSFZt8jfztYyARwqhoCayjprC7KMvT3nRA==}
@@ -565,9 +576,20 @@ packages:
'@vue/compiler-ssr@3.5.6': '@vue/compiler-ssr@3.5.6':
resolution: {integrity: sha512-VpWbaZrEOCqnmqjE83xdwegtr5qO/2OPUC6veWgvNqTJ3bYysz6vY3VqMuOijubuUYPRpG3OOKIh9TD0Stxb9A==} resolution: {integrity: sha512-VpWbaZrEOCqnmqjE83xdwegtr5qO/2OPUC6veWgvNqTJ3bYysz6vY3VqMuOijubuUYPRpG3OOKIh9TD0Stxb9A==}
'@vue/compiler-vue2@2.7.16':
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
'@vue/devtools-api@6.6.4': '@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/language-core@2.1.10':
resolution: {integrity: sha512-DAI289d0K3AB5TUG3xDp9OuQ71CnrujQwJrQnfuZDwo6eGNf0UoRlPuaVNO+Zrn65PC3j0oB2i7mNmVPggeGeQ==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
'@vue/reactivity@3.5.6': '@vue/reactivity@3.5.6':
resolution: {integrity: sha512-shZ+KtBoHna5GyUxWfoFVBCVd7k56m6lGhk5e+J9AKjheHF6yob5eukssHRI+rzvHBiU1sWs/1ZhNbLExc5oYQ==} resolution: {integrity: sha512-shZ+KtBoHna5GyUxWfoFVBCVd7k56m6lGhk5e+J9AKjheHF6yob5eukssHRI+rzvHBiU1sWs/1ZhNbLExc5oYQ==}
@@ -585,6 +607,15 @@ packages:
'@vue/shared@3.5.6': '@vue/shared@3.5.6':
resolution: {integrity: sha512-eidH0HInnL39z6wAt6SFIwBrvGOpDWsDxlw3rCgo1B+CQ1781WzQUSU3YjxgdkcJo9Q8S6LmXTkvI+cLHGkQfA==} resolution: {integrity: sha512-eidH0HInnL39z6wAt6SFIwBrvGOpDWsDxlw3rCgo1B+CQ1781WzQUSU3YjxgdkcJo9Q8S6LmXTkvI+cLHGkQfA==}
'@vueuse/core@11.3.0':
resolution: {integrity: sha512-7OC4Rl1f9G8IT6rUfi9JrKiXy4bfmHhZ5x2Ceojy0jnd3mHNEvV4JaRygH362ror6/NZ+Nl+n13LPzGiPN8cKA==}
'@vueuse/metadata@11.3.0':
resolution: {integrity: sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g==}
'@vueuse/shared@11.3.0':
resolution: {integrity: sha512-P8gSSWQeucH5821ek2mn/ciCk+MS/zoRKqdQIM3bHq6p7GXDAJLmnRRKmF5F65sAVJIfzQlwR3aDzwCn10s8hA==}
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@@ -598,6 +629,9 @@ packages:
ajv@6.12.6: ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
alien-signals@0.2.1:
resolution: {integrity: sha512-FlEQrDJe9r2RI4cDlnK2zYqJezvx1uJaWEuwxsnlFqnPwvJbgitNBRumWrLDv8lA+7cCikpMxfJD2TTHiaTklA==}
ansi-escapes@7.0.0: ansi-escapes@7.0.0:
resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -671,8 +705,8 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
caniuse-lite@1.0.30001662: caniuse-lite@1.0.30001712:
resolution: {integrity: sha512-sgMUVwLmGseH8ZIrm1d51UbrhqMCH3jvS7gF/M6byuHOnKyLOBL7W8yz5V02OHwgLGA36o/AFhWzzh4uc5aqTA==} resolution: {integrity: sha512-MBqPpGYYdQ7/hfKiet9SCI+nmN5/hp4ZzveOJubl5DTAMa5oggjAuoi0Z4onBpKPFI2ePGnQuQIzF3VxDjDJig==}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -733,6 +767,9 @@ packages:
dayjs@1.11.13: dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
debug@4.3.7: debug@4.3.7:
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -935,6 +972,10 @@ packages:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
globals@15.12.0:
resolution: {integrity: sha512-1+gLErljJFhbOVyaetcwJiJ4+eLe45S2E7P5UiZ9xGfeq3ATQf5DOv9G7MH3gGbKQLkzmNh2DxfZwLdw+j6oTQ==}
engines: {node: '>=18'}
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -949,6 +990,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
human-signals@5.0.0: human-signals@5.0.0:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'} engines: {node: '>=16.17.0'}
@@ -1152,6 +1197,9 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
mz@2.7.0: mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@@ -1225,6 +1273,9 @@ packages:
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-exists@4.0.0: path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1546,8 +1597,8 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
ts-api-utils@1.3.0: ts-api-utils@1.4.0:
resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} resolution: {integrity: sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
peerDependencies: peerDependencies:
typescript: '>=4.2.0' typescript: '>=4.2.0'
@@ -1558,9 +1609,6 @@ packages:
tslib@2.7.0: tslib@2.7.0:
resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
turndown@7.2.0:
resolution: {integrity: sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -1569,8 +1617,8 @@ packages:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
typescript-eslint@8.6.0: typescript-eslint@8.13.0:
resolution: {integrity: sha512-eEhhlxCEpCd4helh3AO1hk0UP2MvbRi9CtIAJTVPQjuSXOOO2jsEacNi4UdcJzZJbeuVg1gMhtZ8UYb+NFYPrA==} resolution: {integrity: sha512-vIMpDRJrQd70au2G8w34mPps0ezFSPMEX4pXkTzUkrNbRX+36ais2ksGWN0esZL+ZMaFJEneOBHzCgSqle7DHw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '*' typescript: '*'
@@ -1632,6 +1680,20 @@ packages:
terser: terser:
optional: true optional: true
vscode-uri@3.0.8:
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-eslint-parser@9.4.3: vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0} engines: {node: ^14.17.0 || >=16.0.0}
@@ -1644,6 +1706,12 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.0.0 vue: ^3.0.0
vue-tsc@2.1.10:
resolution: {integrity: sha512-RBNSfaaRHcN5uqVqJSZh++Gy/YUzryuv9u1aFWhsammDJXNtUiJMNoJ747lZcQ68wUQFx6E73y4FY3D8E7FGMA==}
hasBin: true
peerDependencies:
typescript: '>=5.0.0'
vue@3.5.6: vue@3.5.6:
resolution: {integrity: sha512-zv+20E2VIYbcJOzJPUWp03NOGFhMmpCKOfSxVTmCYyYFFko48H9tmuQFzYj7tu4qX1AeXlp9DmhIP89/sSxxhw==} resolution: {integrity: sha512-zv+20E2VIYbcJOzJPUWp03NOGFhMmpCKOfSxVTmCYyYFFko48H9tmuQFzYj7tu4qX1AeXlp9DmhIP89/sSxxhw==}
peerDependencies: peerDependencies:
@@ -1783,8 +1851,15 @@ snapshots:
eslint: 9.10.0(jiti@1.21.6) eslint: 9.10.0(jiti@1.21.6)
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.4.1(eslint@9.10.0(jiti@1.21.6))':
dependencies:
eslint: 9.10.0(jiti@1.21.6)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.11.1': {} '@eslint-community/regexpp@4.11.1': {}
'@eslint-community/regexpp@4.12.1': {}
'@eslint/config-array@0.18.0': '@eslint/config-array@0.18.0':
dependencies: dependencies:
'@eslint/object-schema': 2.1.4 '@eslint/object-schema': 2.1.4
@@ -1857,8 +1932,6 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
'@mixmark-io/domino@2.2.0': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -1945,10 +2018,6 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.22.0': '@rollup/rollup-win32-x64-msvc@4.22.0':
optional: true optional: true
'@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.12)':
dependencies:
tailwindcss: 3.4.12
'@types/estree@1.0.5': {} '@types/estree@1.0.5': {}
'@types/linkify-it@5.0.0': {} '@types/linkify-it@5.0.0': {}
@@ -1966,32 +2035,32 @@ snapshots:
dependencies: dependencies:
undici-types: 6.19.8 undici-types: 6.19.8
'@types/turndown@5.0.5': {} '@types/web-bluetooth@0.0.20': {}
'@typescript-eslint/eslint-plugin@8.6.0(@typescript-eslint/parser@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)': '@typescript-eslint/eslint-plugin@8.13.0(@typescript-eslint/parser@8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.11.1 '@eslint-community/regexpp': 4.12.1
'@typescript-eslint/parser': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/parser': 8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)
'@typescript-eslint/scope-manager': 8.6.0 '@typescript-eslint/scope-manager': 8.13.0
'@typescript-eslint/type-utils': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/type-utils': 8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)
'@typescript-eslint/utils': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/utils': 8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)
'@typescript-eslint/visitor-keys': 8.6.0 '@typescript-eslint/visitor-keys': 8.13.0
eslint: 9.10.0(jiti@1.21.6) eslint: 9.10.0(jiti@1.21.6)
graphemer: 1.4.0 graphemer: 1.4.0
ignore: 5.3.2 ignore: 5.3.2
natural-compare: 1.4.0 natural-compare: 1.4.0
ts-api-utils: 1.3.0(typescript@5.6.2) ts-api-utils: 1.4.0(typescript@5.6.2)
optionalDependencies: optionalDependencies:
typescript: 5.6.2 typescript: 5.6.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)': '@typescript-eslint/parser@8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.6.0 '@typescript-eslint/scope-manager': 8.13.0
'@typescript-eslint/types': 8.6.0 '@typescript-eslint/types': 8.13.0
'@typescript-eslint/typescript-estree': 8.6.0(typescript@5.6.2) '@typescript-eslint/typescript-estree': 8.13.0(typescript@5.6.2)
'@typescript-eslint/visitor-keys': 8.6.0 '@typescript-eslint/visitor-keys': 8.13.0
debug: 4.3.7 debug: 4.3.7
eslint: 9.10.0(jiti@1.21.6) eslint: 9.10.0(jiti@1.21.6)
optionalDependencies: optionalDependencies:
@@ -1999,54 +2068,54 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/scope-manager@8.6.0': '@typescript-eslint/scope-manager@8.13.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.6.0 '@typescript-eslint/types': 8.13.0
'@typescript-eslint/visitor-keys': 8.6.0 '@typescript-eslint/visitor-keys': 8.13.0
'@typescript-eslint/type-utils@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)': '@typescript-eslint/type-utils@8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)':
dependencies: dependencies:
'@typescript-eslint/typescript-estree': 8.6.0(typescript@5.6.2) '@typescript-eslint/typescript-estree': 8.13.0(typescript@5.6.2)
'@typescript-eslint/utils': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/utils': 8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)
debug: 4.3.7 debug: 4.3.7
ts-api-utils: 1.3.0(typescript@5.6.2) ts-api-utils: 1.4.0(typescript@5.6.2)
optionalDependencies: optionalDependencies:
typescript: 5.6.2 typescript: 5.6.2
transitivePeerDependencies: transitivePeerDependencies:
- eslint - eslint
- supports-color - supports-color
'@typescript-eslint/types@8.6.0': {} '@typescript-eslint/types@8.13.0': {}
'@typescript-eslint/typescript-estree@8.6.0(typescript@5.6.2)': '@typescript-eslint/typescript-estree@8.13.0(typescript@5.6.2)':
dependencies: dependencies:
'@typescript-eslint/types': 8.6.0 '@typescript-eslint/types': 8.13.0
'@typescript-eslint/visitor-keys': 8.6.0 '@typescript-eslint/visitor-keys': 8.13.0
debug: 4.3.7 debug: 4.3.7
fast-glob: 3.3.2 fast-glob: 3.3.2
is-glob: 4.0.3 is-glob: 4.0.3
minimatch: 9.0.5 minimatch: 9.0.5
semver: 7.6.3 semver: 7.6.3
ts-api-utils: 1.3.0(typescript@5.6.2) ts-api-utils: 1.4.0(typescript@5.6.2)
optionalDependencies: optionalDependencies:
typescript: 5.6.2 typescript: 5.6.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)': '@typescript-eslint/utils@8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0(jiti@1.21.6)) '@eslint-community/eslint-utils': 4.4.1(eslint@9.10.0(jiti@1.21.6))
'@typescript-eslint/scope-manager': 8.6.0 '@typescript-eslint/scope-manager': 8.13.0
'@typescript-eslint/types': 8.6.0 '@typescript-eslint/types': 8.13.0
'@typescript-eslint/typescript-estree': 8.6.0(typescript@5.6.2) '@typescript-eslint/typescript-estree': 8.13.0(typescript@5.6.2)
eslint: 9.10.0(jiti@1.21.6) eslint: 9.10.0(jiti@1.21.6)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
- typescript - typescript
'@typescript-eslint/visitor-keys@8.6.0': '@typescript-eslint/visitor-keys@8.13.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.6.0 '@typescript-eslint/types': 8.13.0
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@vitejs/plugin-vue@5.1.4(vite@5.4.6(@types/node@22.5.5)(less@4.2.0))(vue@3.5.6(typescript@5.6.2))': '@vitejs/plugin-vue@5.1.4(vite@5.4.6(@types/node@22.5.5)(less@4.2.0))(vue@3.5.6(typescript@5.6.2))':
@@ -2054,6 +2123,18 @@ snapshots:
vite: 5.4.6(@types/node@22.5.5)(less@4.2.0) vite: 5.4.6(@types/node@22.5.5)(less@4.2.0)
vue: 3.5.6(typescript@5.6.2) vue: 3.5.6(typescript@5.6.2)
'@volar/language-core@2.4.10':
dependencies:
'@volar/source-map': 2.4.10
'@volar/source-map@2.4.10': {}
'@volar/typescript@2.4.10':
dependencies:
'@volar/language-core': 2.4.10
path-browserify: 1.0.1
vscode-uri: 3.0.8
'@vue/compiler-core@3.5.6': '@vue/compiler-core@3.5.6':
dependencies: dependencies:
'@babel/parser': 7.25.6 '@babel/parser': 7.25.6
@@ -2084,8 +2165,26 @@ snapshots:
'@vue/compiler-dom': 3.5.6 '@vue/compiler-dom': 3.5.6
'@vue/shared': 3.5.6 '@vue/shared': 3.5.6
'@vue/compiler-vue2@2.7.16':
dependencies:
de-indent: 1.0.2
he: 1.2.0
'@vue/devtools-api@6.6.4': {} '@vue/devtools-api@6.6.4': {}
'@vue/language-core@2.1.10(typescript@5.6.2)':
dependencies:
'@volar/language-core': 2.4.10
'@vue/compiler-dom': 3.5.6
'@vue/compiler-vue2': 2.7.16
'@vue/shared': 3.5.6
alien-signals: 0.2.1
minimatch: 9.0.5
muggle-string: 0.4.1
path-browserify: 1.0.1
optionalDependencies:
typescript: 5.6.2
'@vue/reactivity@3.5.6': '@vue/reactivity@3.5.6':
dependencies: dependencies:
'@vue/shared': 3.5.6 '@vue/shared': 3.5.6
@@ -2110,6 +2209,25 @@ snapshots:
'@vue/shared@3.5.6': {} '@vue/shared@3.5.6': {}
'@vueuse/core@11.3.0(vue@3.5.6(typescript@5.6.2))':
dependencies:
'@types/web-bluetooth': 0.0.20
'@vueuse/metadata': 11.3.0
'@vueuse/shared': 11.3.0(vue@3.5.6(typescript@5.6.2))
vue-demi: 0.14.10(vue@3.5.6(typescript@5.6.2))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/metadata@11.3.0': {}
'@vueuse/shared@11.3.0(vue@3.5.6(typescript@5.6.2))':
dependencies:
vue-demi: 0.14.10(vue@3.5.6(typescript@5.6.2))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
acorn-jsx@5.3.2(acorn@8.12.1): acorn-jsx@5.3.2(acorn@8.12.1):
dependencies: dependencies:
acorn: 8.12.1 acorn: 8.12.1
@@ -2123,6 +2241,8 @@ snapshots:
json-schema-traverse: 0.4.1 json-schema-traverse: 0.4.1
uri-js: 4.4.1 uri-js: 4.4.1
alien-signals@0.2.1: {}
ansi-escapes@7.0.0: ansi-escapes@7.0.0:
dependencies: dependencies:
environment: 1.1.0 environment: 1.1.0
@@ -2151,7 +2271,7 @@ snapshots:
autoprefixer@10.4.20(postcss@8.4.47): autoprefixer@10.4.20(postcss@8.4.47):
dependencies: dependencies:
browserslist: 4.23.3 browserslist: 4.23.3
caniuse-lite: 1.0.30001662 caniuse-lite: 1.0.30001712
fraction.js: 4.3.7 fraction.js: 4.3.7
normalize-range: 0.1.2 normalize-range: 0.1.2
picocolors: 1.1.0 picocolors: 1.1.0
@@ -2179,7 +2299,7 @@ snapshots:
browserslist@4.23.3: browserslist@4.23.3:
dependencies: dependencies:
caniuse-lite: 1.0.30001662 caniuse-lite: 1.0.30001712
electron-to-chromium: 1.5.25 electron-to-chromium: 1.5.25
node-releases: 2.0.18 node-releases: 2.0.18
update-browserslist-db: 1.1.0(browserslist@4.23.3) update-browserslist-db: 1.1.0(browserslist@4.23.3)
@@ -2188,7 +2308,7 @@ snapshots:
camelcase-css@2.0.1: {} camelcase-css@2.0.1: {}
caniuse-lite@1.0.30001662: {} caniuse-lite@1.0.30001712: {}
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
@@ -2248,6 +2368,8 @@ snapshots:
dayjs@1.11.13: {} dayjs@1.11.13: {}
de-indent@1.0.2: {}
debug@4.3.7: debug@4.3.7:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@@ -2493,6 +2615,8 @@ snapshots:
globals@14.0.0: {} globals@14.0.0: {}
globals@15.12.0: {}
graceful-fs@4.2.11: graceful-fs@4.2.11:
optional: true optional: true
@@ -2504,6 +2628,8 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
he@1.2.0: {}
human-signals@5.0.0: {} human-signals@5.0.0: {}
husky@9.1.6: {} husky@9.1.6: {}
@@ -2701,6 +2827,8 @@ snapshots:
ms@2.1.3: {} ms@2.1.3: {}
muggle-string@0.4.1: {}
mz@2.7.0: mz@2.7.0:
dependencies: dependencies:
any-promise: 1.3.0 any-promise: 1.3.0
@@ -2768,6 +2896,8 @@ snapshots:
parse-node-version@1.0.1: {} parse-node-version@1.0.1: {}
path-browserify@1.0.1: {}
path-exists@4.0.0: {} path-exists@4.0.0: {}
path-key@3.1.1: {} path-key@3.1.1: {}
@@ -2833,16 +2963,18 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2): prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2)(vue-tsc@2.1.10(typescript@5.6.2)):
dependencies: dependencies:
prettier: 3.3.3 prettier: 3.3.3
typescript: 5.6.2 typescript: 5.6.2
optionalDependencies:
vue-tsc: 2.1.10(typescript@5.6.2)
prettier-plugin-tailwindcss@0.6.8(prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2))(prettier@3.3.3): prettier-plugin-tailwindcss@0.6.8(prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2)(vue-tsc@2.1.10(typescript@5.6.2)))(prettier@3.3.3):
dependencies: dependencies:
prettier: 3.3.3 prettier: 3.3.3
optionalDependencies: optionalDependencies:
prettier-plugin-organize-imports: 4.1.0(prettier@3.3.3)(typescript@5.6.2) prettier-plugin-organize-imports: 4.1.0(prettier@3.3.3)(typescript@5.6.2)(vue-tsc@2.1.10(typescript@5.6.2))
prettier@3.3.3: {} prettier@3.3.3: {}
@@ -3040,7 +3172,7 @@ snapshots:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
ts-api-utils@1.3.0(typescript@5.6.2): ts-api-utils@1.4.0(typescript@5.6.2):
dependencies: dependencies:
typescript: 5.6.2 typescript: 5.6.2
@@ -3048,21 +3180,17 @@ snapshots:
tslib@2.7.0: {} tslib@2.7.0: {}
turndown@7.2.0:
dependencies:
'@mixmark-io/domino': 2.2.0
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-fest@0.20.2: {} type-fest@0.20.2: {}
typescript-eslint@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2): typescript-eslint@8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.6.0(@typescript-eslint/parser@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/eslint-plugin': 8.13.0(@typescript-eslint/parser@8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)
'@typescript-eslint/parser': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/parser': 8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)
'@typescript-eslint/utils': 8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/utils': 8.13.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)
optionalDependencies: optionalDependencies:
typescript: 5.6.2 typescript: 5.6.2
transitivePeerDependencies: transitivePeerDependencies:
@@ -3097,6 +3225,12 @@ snapshots:
fsevents: 2.3.3 fsevents: 2.3.3
less: 4.2.0 less: 4.2.0
vscode-uri@3.0.8: {}
vue-demi@0.14.10(vue@3.5.6(typescript@5.6.2)):
dependencies:
vue: 3.5.6(typescript@5.6.2)
vue-eslint-parser@9.4.3(eslint@9.10.0(jiti@1.21.6)): vue-eslint-parser@9.4.3(eslint@9.10.0(jiti@1.21.6)):
dependencies: dependencies:
debug: 4.3.7 debug: 4.3.7
@@ -3117,6 +3251,13 @@ snapshots:
'@vue/devtools-api': 6.6.4 '@vue/devtools-api': 6.6.4
vue: 3.5.6(typescript@5.6.2) vue: 3.5.6(typescript@5.6.2)
vue-tsc@2.1.10(typescript@5.6.2):
dependencies:
'@volar/typescript': 2.4.10
'@vue/language-core': 2.1.10(typescript@5.6.2)
semver: 7.6.3
typescript: 5.6.2
vue@3.5.6(typescript@5.6.2): vue@3.5.6(typescript@5.6.2):
dependencies: dependencies:
'@vue/compiler-dom': 3.5.6 '@vue/compiler-dom': 3.5.6

View File

@@ -1,5 +1,6 @@
extension_tag = "ComfyUI Model Manager"
extension_uri: str = None extension_uri: str = None
model_base_paths: dict[str, list[str]] = {}
setting_key = { setting_key = {
@@ -10,6 +11,9 @@ setting_key = {
"download": { "download": {
"max_task_count": "ModelManager.Download.MaxTaskCount", "max_task_count": "ModelManager.Download.MaxTaskCount",
}, },
"scan": {
"include_hidden_files": "ModelManager.Scan.IncludeHiddenFiles"
},
} }
user_agent = "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" user_agent = "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
@@ -19,15 +23,3 @@ from server import PromptServer
serverInstance = PromptServer.instance serverInstance = PromptServer.instance
routes = serverInstance.routes routes = serverInstance.routes
class FakeRequest:
def __init__(self):
self.headers = {}
class CustomException(BaseException):
def __init__(self, type: str, message: str = None) -> None:
self.type = type
self.message = message
super().__init__(message)

View File

@@ -1,15 +1,20 @@
import os import os
import uuid import uuid
import time import time
import logging
import requests import requests
import base64
import folder_paths import folder_paths
import traceback
from typing import Callable, Awaitable, Any, Literal, Union, Optional from typing import Callable, Awaitable, Any, Literal, Union, Optional
from dataclasses import dataclass from dataclasses import dataclass
from aiohttp import web
from . import config from . import config
from . import utils from . import utils
from . import socket
from . import thread from . import thread
@@ -27,6 +32,34 @@ class TaskStatus:
bps: float = 0 bps: float = 0
error: Optional[str] = None error: Optional[str] = None
def __init__(self, **kwargs):
self.taskId = kwargs.get("taskId", None)
self.type = kwargs.get("type", None)
self.fullname = kwargs.get("fullname", None)
self.preview = kwargs.get("preview", None)
self.status = kwargs.get("status", "pause")
self.platform = kwargs.get("platform", None)
self.downloadedSize = kwargs.get("downloadedSize", 0)
self.totalSize = kwargs.get("totalSize", 0)
self.progress = kwargs.get("progress", 0)
self.bps = kwargs.get("bps", 0)
self.error = kwargs.get("error", None)
def to_dict(self):
return {
"taskId": self.taskId,
"type": self.type,
"fullname": self.fullname,
"preview": self.preview,
"status": self.status,
"platform": self.platform,
"downloadedSize": self.downloadedSize,
"totalSize": self.totalSize,
"progress": self.progress,
"bps": self.bps,
"error": self.error,
}
@dataclass @dataclass
class TaskContent: class TaskContent:
@@ -39,65 +72,221 @@ class TaskContent:
sizeBytes: float sizeBytes: float
hashes: Optional[dict[str, str]] = None hashes: Optional[dict[str, str]] = None
def __init__(self, **kwargs):
self.type = kwargs.get("type", None)
self.pathIndex = int(kwargs.get("pathIndex", 0))
self.fullname = kwargs.get("fullname", None)
self.description = kwargs.get("description", None)
self.downloadPlatform = kwargs.get("downloadPlatform", None)
self.downloadUrl = kwargs.get("downloadUrl", None)
self.sizeBytes = float(kwargs.get("sizeBytes", 0))
self.hashes = kwargs.get("hashes", None)
download_model_task_status: dict[str, TaskStatus] = {} def to_dict(self):
download_thread_pool = thread.DownloadThreadPool() return {
"type": self.type,
"pathIndex": self.pathIndex,
"fullname": self.fullname,
"description": self.description,
"downloadPlatform": self.downloadPlatform,
"downloadUrl": self.downloadUrl,
"sizeBytes": self.sizeBytes,
"hashes": self.hashes,
}
def set_task_content(task_id: str, task_content: Union[TaskContent, dict]): class ApiKey:
download_path = utils.get_download_path()
task_file_path = utils.join_path(download_path, f"{task_id}.task") __store: dict[str, str] = {}
utils.save_dict_pickle_file(task_file_path, utils.unpack_dataclass(task_content))
def __init__(self):
self.__cache_file = os.path.join(config.extension_uri, "private.key")
def init(self, request):
# Try to migrate api key from user setting
if not os.path.exists(self.__cache_file):
self.__store = {
"civitai": utils.get_setting_value(request, "api_key.civitai"),
"huggingface": utils.get_setting_value(request, "api_key.huggingface"),
}
self.__update__()
# Remove api key from user setting
utils.set_setting_value(request, "api_key.civitai", None)
utils.set_setting_value(request, "api_key.huggingface", None)
self.__store = utils.load_dict_pickle_file(self.__cache_file)
# Desensitization returns
result: dict[str, str] = {}
for key in self.__store:
v = self.__store[key]
if v is not None:
result[key] = v[:4] + "****" + v[-4:]
return result
def get_value(self, key: str):
return self.__store.get(key, None)
def set_value(self, key: str, value: str):
self.__store[key] = value
self.__update__()
def __update__(self):
utils.save_dict_pickle_file(self.__cache_file, self.__store)
def get_task_content(task_id: str): class ModelDownload:
download_path = utils.get_download_path() def __init__(self):
task_file = utils.join_path(download_path, f"{task_id}.task") self.api_key = ApiKey()
if not os.path.isfile(task_file):
raise RuntimeError(f"Task {task_id} not found")
task_content = utils.load_dict_pickle_file(task_file)
task_content["pathIndex"] = int(task_content.get("pathIndex", 0))
task_content["sizeBytes"] = float(task_content.get("sizeBytes", 0))
return TaskContent(**task_content)
def add_routes(self, routes):
@routes.post("/model-manager/download/init")
async def init_download(request):
"""
Init download setting.
"""
result = self.api_key.init(request)
return web.json_response({"success": True, "data": result})
def get_task_status(task_id: str): @routes.post("/model-manager/download/setting")
task_status = download_model_task_status.get(task_id, None) async def set_download_setting(request):
"""
Set download setting.
"""
json_data = await request.json()
key = json_data.get("key", None)
value = json_data.get("value", None)
value = base64.b64decode(value).decode("utf-8") if value is not None else None
self.api_key.set_value(key, value)
return web.json_response({"success": True})
if task_status is None: @routes.get("/model-manager/download/task")
async def scan_download_tasks(request):
"""
Read download task list.
"""
try:
result = await self.scan_model_download_task_list()
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Read download task list failed: {e}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.put("/model-manager/download/{task_id}")
async def resume_download_task(request):
"""
Toggle download task status.
"""
try:
task_id = request.match_info.get("task_id", None)
if task_id is None:
raise web.HTTPBadRequest(reason="Invalid task id")
json_data = await request.json()
status = json_data.get("status", None)
if status == "pause":
await self.pause_model_download_task(task_id)
elif status == "resume":
await self.download_model(task_id, request)
else:
raise web.HTTPBadRequest(reason="Invalid status")
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Resume download task failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.delete("/model-manager/download/{task_id}")
async def delete_model_download_task(request):
"""
Delete download task.
"""
task_id = request.match_info.get("task_id", None)
try:
await self.delete_model_download_task(task_id)
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Delete download task failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.post("/model-manager/model")
async def create_model(request):
"""
Create a new model.
request body: x-www-form-urlencoded
- type: model type.
- pathIndex: index of the model folders.
- fullname: filename that relative to the model folder.
- previewFile: preview file.
- description: description.
- downloadPlatform: download platform.
- downloadUrl: download url.
- hash: a JSON string containing the hash value of the downloaded model.
"""
task_data = await request.post()
task_data = dict(task_data)
try:
task_id = await self.create_model_download_task(task_data, request)
return web.json_response({"success": True, "data": {"taskId": task_id}})
except Exception as e:
error_msg = f"Create model download task failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
download_model_task_status: dict[str, TaskStatus] = {}
download_thread_pool = thread.DownloadThreadPool()
def set_task_content(self, task_id: str, task_content: Union[TaskContent, dict]):
download_path = utils.get_download_path() download_path = utils.get_download_path()
task_content = get_task_content(task_id) task_file_path = utils.join_path(download_path, f"{task_id}.task")
download_file = utils.join_path(download_path, f"{task_id}.download") utils.save_dict_pickle_file(task_file_path, task_content)
download_size = 0
if os.path.exists(download_file):
download_size = os.path.getsize(download_file)
total_size = task_content.sizeBytes def get_task_content(self, task_id: str):
task_status = TaskStatus( download_path = utils.get_download_path()
taskId=task_id, task_file = utils.join_path(download_path, f"{task_id}.task")
type=task_content.type, if not os.path.isfile(task_file):
fullname=task_content.fullname, raise RuntimeError(f"Task {task_id} not found")
preview=utils.get_model_preview_name(download_file), task_content = utils.load_dict_pickle_file(task_file)
platform=task_content.downloadPlatform, if isinstance(task_content, TaskContent):
downloadedSize=download_size, return task_content
totalSize=task_content.sizeBytes, return TaskContent(**task_content)
progress=download_size / total_size * 100 if total_size > 0 else 0,
)
download_model_task_status[task_id] = task_status def get_task_status(self, task_id: str):
task_status = self.download_model_task_status.get(task_id, None)
return task_status if task_status is None:
download_path = utils.get_download_path()
task_content = self.get_task_content(task_id)
download_file = utils.join_path(download_path, f"{task_id}.download")
download_size = 0
if os.path.exists(download_file):
download_size = os.path.getsize(download_file)
total_size = task_content.sizeBytes
task_status = TaskStatus(
taskId=task_id,
type=task_content.type,
fullname=task_content.fullname,
preview=utils.get_model_preview_name(download_file),
platform=task_content.downloadPlatform,
downloadedSize=download_size,
totalSize=task_content.sizeBytes,
progress=download_size / total_size * 100 if total_size > 0 else 0,
)
def delete_task_status(task_id: str): self.download_model_task_status[task_id] = task_status
download_model_task_status.pop(task_id, None)
return task_status
async def scan_model_download_task_list(sid: str): def delete_task_status(self, task_id: str):
""" self.download_model_task_status.pop(task_id, None)
Scan the download directory and send the task list to the client.
""" async def scan_model_download_task_list(self):
try: """
Scan the download directory and send the task list to the client.
"""
download_dir = utils.get_download_path() download_dir = utils.get_download_path()
task_files = utils.search_files(download_dir) task_files = utils.search_files(download_dir)
task_files = folder_paths.filter_files_extensions(task_files, [".task"]) task_files = folder_paths.filter_files_extensions(task_files, [".task"])
@@ -109,254 +298,252 @@ async def scan_model_download_task_list(sid: str):
task_list: list[dict] = [] task_list: list[dict] = []
for task_file in task_files: for task_file in task_files:
task_id = task_file.replace(".task", "") task_id = task_file.replace(".task", "")
task_status = get_task_status(task_id) task_status = self.get_task_status(task_id)
task_list.append(task_status) task_list.append(task_status.to_dict())
await socket.send_json("downloadTaskList", task_list, sid) return task_list
except Exception as e:
error_msg = f"Refresh task list failed: {e}"
await socket.send_json("error", error_msg, sid)
logging.error(error_msg)
async def create_model_download_task(self, task_data: dict, request):
"""
Creates a download task for the given data.
"""
model_type = task_data.get("type", None)
path_index = int(task_data.get("pathIndex", None))
fullname = task_data.get("fullname", None)
async def create_model_download_task(post: dict): model_path = utils.get_full_path(model_type, path_index, fullname)
""" # Check if the model path is valid
Creates a download task for the given post. if os.path.exists(model_path):
""" raise RuntimeError(f"File already exists: {model_path}")
model_type = post.get("type", None)
path_index = int(post.get("pathIndex", None))
fullname = post.get("fullname", None)
model_path = utils.get_full_path(model_type, path_index, fullname) download_path = utils.get_download_path()
# Check if the model path is valid
if os.path.exists(model_path):
raise RuntimeError(f"File already exists: {model_path}")
download_path = utils.get_download_path() task_id = uuid.uuid4().hex
task_path = utils.join_path(download_path, f"{task_id}.task")
task_id = uuid.uuid4().hex if os.path.exists(task_path):
task_path = utils.join_path(download_path, f"{task_id}.task") raise RuntimeError(f"Task {task_id} already exists")
if os.path.exists(task_path): download_platform = task_data.get("downloadPlatform", None)
raise RuntimeError(f"Task {task_id} already exists")
try:
previewFile = post.pop("previewFile", None)
utils.save_model_preview_image(task_path, previewFile)
set_task_content(task_id, post)
task_status = TaskStatus(
taskId=task_id,
type=model_type,
fullname=fullname,
preview=utils.get_model_preview_name(task_path),
platform=post.get("downloadPlatform", None),
totalSize=float(post.get("sizeBytes", 0)),
)
download_model_task_status[task_id] = task_status
await socket.send_json("createDownloadTask", task_status)
except Exception as e:
await delete_model_download_task(task_id)
raise RuntimeError(str(e)) from e
await download_model(task_id)
return task_id
async def pause_model_download_task(task_id: str):
task_status = get_task_status(task_id=task_id)
task_status.status = "pause"
async def delete_model_download_task(task_id: str):
task_status = get_task_status(task_id)
is_running = task_status.status == "doing"
task_status.status = "waiting"
await socket.send_json("deleteDownloadTask", task_id)
# Pause the task
if is_running:
task_status.status = "pause"
time.sleep(1)
download_dir = utils.get_download_path()
task_file_list = os.listdir(download_dir)
for task_file in task_file_list:
task_file_target = os.path.splitext(task_file)[0]
if task_file_target == task_id:
delete_task_status(task_id)
os.remove(utils.join_path(download_dir, task_file))
await socket.send_json("deleteDownloadTask", task_id)
async def download_model(task_id: str):
async def download_task(task_id: str):
async def report_progress(task_status: TaskStatus):
await socket.send_json("updateDownloadTask", task_status)
try: try:
# When starting a task from the queue, the task may not exist preview_file = task_data.pop("previewFile", None)
task_status = get_task_status(task_id) utils.save_model_preview(task_path, preview_file, download_platform)
except: self.set_task_content(task_id, task_data)
return task_status = TaskStatus(
taskId=task_id,
# Update task status type=model_type,
task_status.status = "doing" fullname=fullname,
await socket.send_json("updateDownloadTask", task_status) preview=utils.get_model_preview_name(task_path),
platform=download_platform,
try: totalSize=float(task_data.get("sizeBytes", 0)),
# Set download request headers
headers = {"User-Agent": config.user_agent}
download_platform = task_status.platform
if download_platform == "civitai":
api_key = utils.get_setting_value("api_key.civitai")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
elif download_platform == "huggingface":
api_key = utils.get_setting_value("api_key.huggingface")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
progress_interval = 1.0
await download_model_file(
task_id=task_id,
headers=headers,
progress_callback=report_progress,
interval=progress_interval,
) )
self.download_model_task_status[task_id] = task_status
await utils.send_json("create_download_task", task_status.to_dict())
except Exception as e:
await self.delete_model_download_task(task_id)
raise RuntimeError(str(e)) from e
await self.download_model(task_id, request)
return task_id
async def pause_model_download_task(self, task_id: str):
task_status = self.get_task_status(task_id=task_id)
task_status.status = "pause"
async def delete_model_download_task(self, task_id: str):
task_status = self.get_task_status(task_id)
is_running = task_status.status == "doing"
task_status.status = "waiting"
await utils.send_json("delete_download_task", task_id)
# Pause the task
if is_running:
task_status.status = "pause"
time.sleep(1)
download_dir = utils.get_download_path()
task_file_list = os.listdir(download_dir)
for task_file in task_file_list:
task_file_target = os.path.splitext(task_file)[0]
if task_file_target == task_id:
self.delete_task_status(task_id)
os.remove(utils.join_path(download_dir, task_file))
await utils.send_json("delete_download_task", task_id)
async def download_model(self, task_id: str, request):
async def download_task(task_id: str):
async def report_progress(task_status: TaskStatus):
await utils.send_json("update_download_task", task_status.to_dict())
try:
# When starting a task from the queue, the task may not exist
task_status = self.get_task_status(task_id)
except:
return
# Update task status
task_status.status = "doing"
await utils.send_json("update_download_task", task_status.to_dict())
try:
# Set download request headers
headers = {"User-Agent": config.user_agent}
download_platform = task_status.platform
if download_platform == "civitai":
api_key = self.api_key.get_value("civitai")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
elif download_platform == "huggingface":
api_key = self.api_key.get_value("huggingface")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
progress_interval = 1.0
await self.download_model_file(
task_id=task_id,
headers=headers,
progress_callback=report_progress,
interval=progress_interval,
)
except Exception as e:
task_status.status = "pause"
task_status.error = str(e)
await utils.send_json("update_download_task", task_status.to_dict())
task_status.error = None
utils.print_error(str(e))
try:
status = self.download_thread_pool.submit(download_task, task_id)
if status == "Waiting":
task_status = self.get_task_status(task_id)
task_status.status = "waiting"
await utils.send_json("update_download_task", task_status.to_dict())
except Exception as e: except Exception as e:
task_status.status = "pause" task_status.status = "pause"
task_status.error = str(e) task_status.error = str(e)
await socket.send_json("updateDownloadTask", task_status) await utils.send_json("update_download_task", task_status.to_dict())
task_status.error = None task_status.error = None
logging.error(str(e)) utils.print_error(str(e))
try: async def download_model_file(
status = download_thread_pool.submit(download_task, task_id) self,
if status == "Waiting": task_id: str,
task_status = get_task_status(task_id) headers: dict,
task_status.status = "waiting" progress_callback: Callable[[TaskStatus], Awaitable[Any]],
await socket.send_json("updateDownloadTask", task_status) interval: float = 1.0,
except Exception as e: ):
task_status.status = "pause"
task_status.error = str(e)
await socket.send_json("updateDownloadTask", task_status)
task_status.error = None
logging.error(traceback.format_exc())
async def download_complete():
"""
Restore the model information from the task file
and move the model file to the target directory.
"""
model_type = task_content.type
path_index = task_content.pathIndex
fullname = task_content.fullname
# Write description file
description = task_content.description
description_file = utils.join_path(download_path, f"{task_id}.md")
with open(description_file, "w", encoding="utf-8", newline="") as f:
f.write(description)
async def download_model_file( model_path = utils.get_full_path(model_type, path_index, fullname)
task_id: str,
headers: dict,
progress_callback: Callable[[TaskStatus], Awaitable[Any]],
interval: float = 1.0,
):
async def download_complete(): utils.rename_model(download_tmp_file, model_path)
"""
Restore the model information from the task file
and move the model file to the target directory.
"""
model_type = task_content.type
path_index = task_content.pathIndex
fullname = task_content.fullname
# Write description file
description = task_content.description
description_file = utils.join_path(download_path, f"{task_id}.md")
with open(description_file, "w", encoding="utf-8", newline="") as f:
f.write(description)
model_path = utils.get_full_path(model_type, path_index, fullname) time.sleep(1)
task_file = utils.join_path(download_path, f"{task_id}.task")
os.remove(task_file)
await utils.send_json("complete_download_task", task_id)
utils.rename_model(download_tmp_file, model_path) async def update_progress():
nonlocal last_update_time
nonlocal last_downloaded_size
progress = (downloaded_size / total_size) * 100 if total_size > 0 else 0
task_status.downloadedSize = downloaded_size
task_status.progress = progress
task_status.bps = downloaded_size - last_downloaded_size
await progress_callback(task_status)
last_update_time = time.time()
last_downloaded_size = downloaded_size
time.sleep(1) task_status = self.get_task_status(task_id)
task_file = utils.join_path(download_path, f"{task_id}.task") task_content = self.get_task_content(task_id)
os.remove(task_file)
await socket.send_json("completeDownloadTask", task_id) # Check download uri
model_url = task_content.downloadUrl
if not model_url:
raise RuntimeError("No downloadUrl found")
download_path = utils.get_download_path()
download_tmp_file = utils.join_path(download_path, f"{task_id}.download")
downloaded_size = 0
if os.path.isfile(download_tmp_file):
downloaded_size = os.path.getsize(download_tmp_file)
headers["Range"] = f"bytes={downloaded_size}-"
total_size = task_content.sizeBytes
if total_size > 0 and downloaded_size == total_size:
await download_complete()
return
async def update_progress():
nonlocal last_update_time
nonlocal last_downloaded_size
progress = (downloaded_size / total_size) * 100 if total_size > 0 else 0
task_status.downloadedSize = downloaded_size
task_status.progress = progress
task_status.bps = downloaded_size - last_downloaded_size
await progress_callback(task_status)
last_update_time = time.time() last_update_time = time.time()
last_downloaded_size = downloaded_size last_downloaded_size = downloaded_size
task_status = get_task_status(task_id) response = requests.get(
task_content = get_task_content(task_id) url=model_url,
headers=headers,
# Check download uri stream=True,
model_url = task_content.downloadUrl allow_redirects=True,
if not model_url:
raise RuntimeError("No downloadUrl found")
download_path = utils.get_download_path()
download_tmp_file = utils.join_path(download_path, f"{task_id}.download")
downloaded_size = 0
if os.path.isfile(download_tmp_file):
downloaded_size = os.path.getsize(download_tmp_file)
headers["Range"] = f"bytes={downloaded_size}-"
total_size = task_content.sizeBytes
if total_size > 0 and downloaded_size == total_size:
await download_complete()
return
last_update_time = time.time()
last_downloaded_size = downloaded_size
response = requests.get(
url=model_url,
headers=headers,
stream=True,
allow_redirects=True,
)
if response.status_code not in (200, 206):
raise RuntimeError(
f"Failed to download {task_content.fullname}, status code: {response.status_code}"
) )
# Some models require logging in before they can be downloaded. if response.status_code not in (200, 206):
# If no token is carried, it will be redirected to the login page. raise RuntimeError(f"Failed to download {task_content.fullname}, status code: {response.status_code}")
content_type = response.headers.get("content-type")
if content_type and content_type.startswith("text/html"):
raise RuntimeError(
f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first."
)
# When parsing model information from HuggingFace API, # Some models require logging in before they can be downloaded.
# the file size was not found and needs to be obtained from the response header. # If no token is carried, it will be redirected to the login page.
if total_size == 0: content_type = response.headers.get("content-type")
total_size = int(response.headers.get("content-length", 0)) if content_type and content_type.startswith("text/html"):
task_content.sizeBytes = total_size # TODO More checks
task_status.totalSize = total_size # In addition to requiring login to download, there may be other restrictions.
set_task_content(task_id, task_content) # The currently one situation is early access??? issues#43
await socket.send_json("updateDownloadTask", task_content) # Due to the lack of test data, lets put it aside for now.
# If it cannot be downloaded, a redirect will definitely occur.
# Maybe consider getting the redirect url from response.history to make a judgment.
# Here we also need to consider how different websites are processed.
raise RuntimeError(f"{task_content.fullname} needs to be logged in to download. Please set the API-Key first.")
with open(download_tmp_file, "ab") as f: # When parsing model information from HuggingFace API,
for chunk in response.iter_content(chunk_size=8192): # the file size was not found and needs to be obtained from the response header.
if task_status.status == "pause": # Fixed issue #169. Some model information from Civitai, providing the wrong file size
break response_total_size = float(response.headers.get("content-length", 0))
if total_size == 0 or total_size != response_total_size:
total_size = response_total_size
task_content.sizeBytes = total_size
task_status.totalSize = total_size
self.set_task_content(task_id, task_content)
await utils.send_json("update_download_task", task_content.to_dict())
f.write(chunk) with open(download_tmp_file, "ab") as f:
downloaded_size += len(chunk) for chunk in response.iter_content(chunk_size=8192):
if task_status.status == "pause":
break
if time.time() - last_update_time >= interval: f.write(chunk)
await update_progress() downloaded_size += len(chunk)
await update_progress() if time.time() - last_update_time >= interval:
await update_progress()
if total_size > 0 and downloaded_size == total_size: await update_progress()
await download_complete()
else: if total_size > 0 and downloaded_size == total_size:
task_status.status = "pause" await download_complete()
await socket.send_json("updateDownloadTask", task_status) else:
task_status.status = "pause"
await utils.send_json("update_download_task", task_status.to_dict())

584
py/information.py Normal file
View File

@@ -0,0 +1,584 @@
import os
import re
import uuid
import math
import yaml
import requests
import markdownify
import folder_paths
from aiohttp import web
from abc import ABC, abstractmethod
from urllib.parse import urlparse, parse_qs
from PIL import Image
from io import BytesIO
from . import utils
from . import config
from . import thread
class ModelSearcher(ABC):
"""
Abstract class for model searcher.
"""
@abstractmethod
def search_by_url(self, url: str) -> list[dict]:
pass
@abstractmethod
def search_by_hash(self, hash: str) -> dict:
pass
class UnknownWebsiteSearcher(ModelSearcher):
def search_by_url(self, url: str):
raise RuntimeError(f"Unknown Website, please input a URL from huggingface.co or civitai.com.")
def search_by_hash(self, hash: str):
raise RuntimeError(f"Unknown Website, unable to search with hash value.")
class CivitaiModelSearcher(ModelSearcher):
def search_by_url(self, url: str):
parsed_url = urlparse(url)
pathname = parsed_url.path
match = re.match(r"^/models/(\d*)", pathname)
model_id = match.group(1) if match else None
query_params = parse_qs(parsed_url.query)
version_id = query_params.get("modelVersionId", [None])[0]
if not model_id:
return []
response = requests.get(f"https://civitai.com/api/v1/models/{model_id}")
response.raise_for_status()
res_data: dict = response.json()
model_versions: list[dict] = res_data["modelVersions"]
if version_id:
model_versions = utils.filter_with(model_versions, {"id": int(version_id)})
models: list[dict] = []
for version in model_versions:
version_files: list[dict] = version.get("files", [])
model_files = utils.filter_with(version_files, {"type": "Model"})
# issue: https://github.com/hayden-fr/ComfyUI-Model-Manager/issues/188
# Some Embeddings do not have Model file, but Negative
# Make sure there are at least downloadable files
model_files = version_files if len(model_files) == 0 else model_files
shortname = version.get("name", None) if len(model_files) > 0 else None
for file in model_files:
name = file.get("name", None)
extension = os.path.splitext(name)[1]
basename = os.path.splitext(name)[0]
metadata_info = {
"website": "Civitai",
"modelPage": f"https://civitai.com/models/{model_id}?modelVersionId={version.get('id')}",
"author": res_data.get("creator", {}).get("username", None),
"baseModel": version.get("baseModel"),
"hashes": file.get("hashes"),
"metadata": file.get("metadata"),
"preview": [i["url"] for i in version["images"]],
}
description_parts: list[str] = []
description_parts.append("---")
description_parts.append(yaml.dump(metadata_info).strip())
description_parts.append("---")
description_parts.append("")
description_parts.append(f"# Trigger Words")
description_parts.append("")
description_parts.append(", ".join(version.get("trainedWords", ["No trigger words"])))
description_parts.append("")
description_parts.append(f"# About this version")
description_parts.append("")
description_parts.append(markdownify.markdownify(version.get("description", "<p>No description about this version</p>")).strip())
description_parts.append("")
description_parts.append(f"# {res_data.get('name')}")
description_parts.append("")
description_parts.append(markdownify.markdownify(res_data.get("description", "<p>No description about this model</p>")).strip())
description_parts.append("")
model = {
"id": version.get("id"),
"shortname": shortname or basename,
"basename": basename,
"extension": extension,
"preview": metadata_info.get("preview"),
"sizeBytes": file.get("sizeKB", 0) * 1024,
"type": self._resolve_model_type(res_data.get("type", "")),
"pathIndex": 0,
"subFolder": "",
"description": "\n".join(description_parts),
"metadata": file.get("metadata"),
"downloadPlatform": "civitai",
"downloadUrl": file.get("downloadUrl"),
"hashes": file.get("hashes"),
"files": version_files if len(version_files) > 1 else None,
}
models.append(model)
return models
def search_by_hash(self, hash: str):
if not hash:
raise RuntimeError(f"Hash value is empty.")
response = requests.get(f"https://civitai.com/api/v1/model-versions/by-hash/{hash}")
response.raise_for_status()
version: dict = response.json()
model_id = version.get("modelId")
version_id = version.get("id")
model_page = f"https://civitai.com/models/{model_id}?modelVersionId={version_id}"
models = self.search_by_url(model_page)
for model in models:
sha256 = model.get("hashes", {}).get("SHA256")
if sha256 == hash:
return model
return models[0]
def _resolve_model_type(self, model_type: str):
map_legacy = {
"TextualInversion": "embeddings",
"LoCon": "loras",
"DoRA": "loras",
"Controlnet": "controlnet",
"Upscaler": "upscale_models",
"VAE": "vae",
"unknown": "",
}
return map_legacy.get(model_type, f"{model_type.lower()}s")
class HuggingfaceModelSearcher(ModelSearcher):
def search_by_url(self, url: str):
parsed_url = urlparse(url)
pathname = parsed_url.path
space, name, *rest_paths = pathname.strip("/").split("/")
model_id = f"{space}/{name}"
rest_pathname = "/".join(rest_paths)
response = requests.get(f"https://huggingface.co/api/models/{model_id}")
response.raise_for_status()
res_data: dict = response.json()
sibling_files: list[str] = [x.get("rfilename") for x in res_data.get("siblings", [])]
model_files = utils.filter_with(
utils.filter_with(sibling_files, self._match_model_files()),
self._match_tree_files(rest_pathname),
)
image_files = utils.filter_with(
utils.filter_with(sibling_files, self._match_image_files()),
self._match_tree_files(rest_pathname),
)
image_files = [f"https://huggingface.co/{model_id}/resolve/main/{filename}" for filename in image_files]
models: list[dict] = []
for filename in model_files:
fullname = os.path.basename(filename)
extension = os.path.splitext(fullname)[1]
basename = os.path.splitext(fullname)[0]
description_parts: list[str] = []
metadata_info = {
"website": "HuggingFace",
"modelPage": f"https://huggingface.co/{model_id}",
"author": res_data.get("author", None),
"preview": image_files,
}
description_parts: list[str] = []
description_parts.append("---")
description_parts.append(yaml.dump(metadata_info).strip())
description_parts.append("---")
description_parts.append("")
description_parts.append(f"# Trigger Words")
description_parts.append("")
description_parts.append("No trigger words")
description_parts.append("")
description_parts.append(f"# About this version")
description_parts.append("")
description_parts.append("No description about this version")
description_parts.append("")
description_parts.append(f"# {res_data.get('name')}")
description_parts.append("")
description_parts.append("No description about this model")
description_parts.append("")
model = {
"id": filename,
"shortname": filename,
"basename": basename,
"extension": extension,
"preview": image_files,
"sizeBytes": 0,
"type": "",
"pathIndex": 0,
"subFolder": "",
"description": "\n".join(description_parts),
"metadata": {},
"downloadPlatform": "huggingface",
"downloadUrl": f"https://huggingface.co/{model_id}/resolve/main/{filename}?download=true",
}
models.append(model)
return models
def search_by_hash(self, hash: str):
raise RuntimeError("Hash search is not supported by Huggingface.")
def _match_model_files(self):
extension = [
".bin",
".ckpt",
".gguf",
".onnx",
".pt",
".pth",
".safetensors",
]
def _filter_model_files(file: str):
return any(file.endswith(ext) for ext in extension)
return _filter_model_files
def _match_image_files(self):
extension = [
".png",
".webp",
".jpeg",
".jpg",
".jfif",
".gif",
".apng",
]
def _filter_image_files(file: str):
return any(file.endswith(ext) for ext in extension)
return _filter_image_files
def _match_tree_files(self, pathname: str):
target, *paths = pathname.split("/")
def _filter_tree_files(file: str):
if not target:
return True
if target != "tree" and target != "blob":
return True
prefix_path = "/".join(paths)
return file.startswith(prefix_path)
return _filter_tree_files
class Information:
def add_routes(self, routes):
@routes.get("/model-manager/model-info")
async def fetch_model_info(request):
"""
Fetch model information from network with model page.
"""
try:
model_page = request.query.get("model-page", None)
result = self.fetch_model_info(model_page)
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Fetch model info failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.get("/model-manager/model-info/scan")
async def get_model_info_download_task(request):
"""
Get model information download task list.
"""
try:
result = self.get_scan_model_info_task_list()
if result is not None:
await self.download_model_info(request)
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Get model info download task list failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.post("/model-manager/model-info/scan")
async def create_model_info_download_task(request):
"""
Create a task to download model information.
- scanMode: The alternatives are diff and full.
- mode: The alternatives are diff and full.
- path: Scanning root path.
"""
post = await utils.get_request_body(request)
try:
# TODO scanMode is deprecated, use mode instead.
scan_mode = post.get("scanMode", "diff")
scan_mode = post.get("mode", scan_mode)
scan_path = post.get("path", None)
result = await self.create_scan_model_info_task(scan_mode, scan_path, request)
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Download model info failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.get("/model-manager/preview/{type}/{index}/{filename:.*}")
async def read_model_preview(request):
"""
Get the file stream of the specified preview
If the file does not exist, no-preview.png is returned.
:param type: The type of the model. eg.checkpoints, loras, vae, etc.
:param index: The index of the model folders.
:param filename: The filename of the preview.
"""
model_type = request.match_info.get("type", None)
index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None)
extension_uri = config.extension_uri
try:
folders = folder_paths.get_folder_paths(model_type)
base_path = folders[index]
abs_path = utils.join_path(base_path, filename)
preview_name = utils.get_model_preview_name(abs_path)
if preview_name:
dir_name = os.path.dirname(abs_path)
abs_path = utils.join_path(dir_name, preview_name)
except:
abs_path = extension_uri
if not os.path.isfile(abs_path):
abs_path = utils.join_path(extension_uri, "assets", "no-preview.png")
# Determine content type from the actual file
content_type = utils.resolve_file_content_type(abs_path)
if content_type == "video":
# Serve video files directly
return web.FileResponse(abs_path)
else:
# Serve image files (WebP or fallback images)
image_data = self.get_image_preview_data(abs_path)
return web.Response(body=image_data.getvalue(), content_type="image/webp")
@routes.get("/model-manager/preview/download/{filename}")
async def read_download_preview(request):
filename = request.match_info.get("filename", None)
extension_uri = config.extension_uri
download_path = utils.get_download_path()
preview_path = utils.join_path(download_path, filename)
if not os.path.isfile(preview_path):
preview_path = utils.join_path(extension_uri, "assets", "no-preview.png")
return web.FileResponse(preview_path)
def get_image_preview_data(self, filename: str):
with Image.open(filename) as img:
max_size = 1024
original_format = img.format
exif_data = img.info.get("exif")
icc_profile = img.info.get("icc_profile")
if getattr(img, "is_animated", False) and img.n_frames > 1:
total_frames = img.n_frames
step = max(1, math.ceil(total_frames / 30))
frames, durations = [], []
for frame_idx in range(0, total_frames, step):
img.seek(frame_idx)
frame = img.copy()
frame.thumbnail((max_size, max_size), Image.Resampling.NEAREST)
frames.append(frame)
durations.append(img.info.get("duration", 100) * step)
save_args = {
"format": "WEBP",
"save_all": True,
"append_images": frames[1:],
"duration": durations,
"loop": 0,
"quality": 80,
"method": 0,
"allow_mixed": False,
}
if exif_data:
save_args["exif"] = exif_data
if icc_profile:
save_args["icc_profile"] = icc_profile
img_byte_arr = BytesIO()
frames[0].save(img_byte_arr, **save_args)
img_byte_arr.seek(0)
return img_byte_arr
img.thumbnail((max_size, max_size), Image.Resampling.BICUBIC)
img_byte_arr = BytesIO()
save_args = {"format": "WEBP", "quality": 80}
if exif_data:
save_args["exif"] = exif_data
if icc_profile:
save_args["icc_profile"] = icc_profile
img.save(img_byte_arr, **save_args)
img_byte_arr.seek(0)
return img_byte_arr
def fetch_model_info(self, model_page: str):
if not model_page:
return []
model_searcher = self.get_model_searcher_by_url(model_page)
result = model_searcher.search_by_url(model_page)
return result
def get_scan_information_task_filepath(self):
download_dir = utils.get_download_path()
return utils.join_path(download_dir, "scan_information.task")
def get_scan_model_info_task_list(self):
scan_info_task_file = self.get_scan_information_task_filepath()
if os.path.isfile(scan_info_task_file):
return utils.load_dict_pickle_file(scan_info_task_file)
return None
async def create_scan_model_info_task(self, scan_mode: str, scan_path: str | None, request):
scan_info_task_file = self.get_scan_information_task_filepath()
scan_info_task_content = {"mode": scan_mode}
scan_models: dict[str, bool] = {}
scan_paths: list[str] = []
if scan_path is None:
model_base_paths = utils.resolve_model_base_paths()
for model_type in model_base_paths:
folders, *others = folder_paths.folder_names_and_paths[model_type]
for path_index, base_path in enumerate(folders):
scan_paths.append(base_path)
else:
scan_paths = [scan_path]
for base_path in scan_paths:
files = utils.recursive_search_files(base_path, request)
models = folder_paths.filter_files_extensions(files, folder_paths.supported_pt_extensions)
for fullname in models:
fullname = utils.normalize_path(fullname)
abs_model_path = utils.join_path(base_path, fullname)
utils.print_debug(f"Found model: {abs_model_path}")
scan_models[abs_model_path] = False
scan_info_task_content["models"] = scan_models
utils.save_dict_pickle_file(scan_info_task_file, scan_info_task_content)
await self.download_model_info(request)
return scan_info_task_content
download_thread_pool = thread.DownloadThreadPool()
async def download_model_info(self, request):
async def download_information_task(task_id: str):
scan_info_task_file = self.get_scan_information_task_filepath()
scan_info_task_content = utils.load_dict_pickle_file(scan_info_task_file)
scan_mode = scan_info_task_content.get("mode", "diff")
scan_models: dict[str, bool] = scan_info_task_content.get("models", {})
for key, value in scan_models.items():
if value is True:
continue
abs_model_path = key
base_path = os.path.dirname(abs_model_path)
image_name = utils.get_model_preview_name(abs_model_path)
abs_image_path = utils.join_path(base_path, image_name)
has_preview = os.path.isfile(abs_image_path)
description_name = utils.get_model_description_name(abs_model_path)
abs_description_path = utils.join_path(base_path, description_name) if description_name else None
has_description = os.path.isfile(abs_description_path) if abs_description_path else False
try:
utils.print_info(f"Checking model {abs_model_path}")
utils.print_debug(f"Scan mode: {scan_mode}")
utils.print_debug(f"Has preview: {has_preview}")
utils.print_debug(f"Has description: {has_description}")
if scan_mode == "full" or not has_preview or not has_description:
utils.print_debug(f"Calculate sha256 for {abs_model_path}")
hash_value = utils.calculate_sha256(abs_model_path)
utils.print_info(f"Searching model info by hash {hash_value}")
model_info = CivitaiModelSearcher().search_by_hash(hash_value)
preview_url_list = model_info.get("preview", [])
preview_url = preview_url_list[0] if preview_url_list else None
if preview_url:
utils.print_debug(f"Save preview to {abs_model_path}")
utils.save_model_preview(abs_model_path, preview_url)
description = model_info.get("description", None)
if description:
utils.save_model_description(abs_model_path, description)
scan_models[abs_model_path] = True
scan_info_task_content["models"] = scan_models
utils.save_dict_pickle_file(scan_info_task_file, scan_info_task_content)
utils.print_debug(f"Send update scan information task to frontend.")
await utils.send_json("update_scan_information_task", scan_info_task_content)
except Exception as e:
utils.print_error(f"Failed to download model info for {abs_model_path}: {e}")
os.remove(scan_info_task_file)
utils.print_info("Completed scan model information.")
try:
task_id = uuid.uuid4().hex
self.download_thread_pool.submit(download_information_task, task_id)
except Exception as e:
utils.print_debug(str(e))
def get_model_searcher_by_url(self, url: str) -> ModelSearcher:
parsed_url = urlparse(url)
host_name = parsed_url.hostname
if host_name == "civitai.com":
return CivitaiModelSearcher()
elif host_name == "huggingface.co":
return HuggingfaceModelSearcher()
return UnknownWebsiteSearcher()

246
py/manager.py Normal file
View File

@@ -0,0 +1,246 @@
import os
import folder_paths
from aiohttp import web
from concurrent.futures import ThreadPoolExecutor, as_completed
from . import utils
class ModelManager:
def add_routes(self, routes):
@routes.get("/model-manager/base-folders")
@utils.deprecated(reason="Use `/model-manager/models` instead.")
async def get_model_paths(request):
"""
Returns the base folders for models.
"""
model_base_paths = utils.resolve_model_base_paths()
return web.json_response({"success": True, "data": model_base_paths})
@routes.get("/model-manager/models")
async def get_folders(request):
"""
Returns the base folders for models.
"""
try:
result = utils.resolve_model_base_paths()
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Read models failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.get("/model-manager/models/{folder}")
async def get_folder_models(request):
try:
folder = request.match_info.get("folder", None)
results = self.scan_models(folder, request)
return web.json_response({"success": True, "data": results})
except Exception as e:
error_msg = f"Read models failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.get("/model-manager/model/{type}/{index}/{filename:.*}")
async def get_model_info(request):
"""
Get the information of the specified model.
"""
model_type = request.match_info.get("type", None)
path_index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None)
try:
model_path = utils.get_valid_full_path(model_type, path_index, filename)
result = self.get_model_info(model_path)
return web.json_response({"success": True, "data": result})
except Exception as e:
error_msg = f"Read model info failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.put("/model-manager/model/{type}/{index}/{filename:.*}")
async def update_model(request):
"""
Update model information.
request body: x-www-form-urlencoded
- previewFile: preview file.
- description: description.
- type: model type.
- pathIndex: index of the model folders.
- fullname: filename that relative to the model folder.
All fields are optional, but type, pathIndex and fullname must appear together.
"""
model_type = request.match_info.get("type", None)
path_index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None)
model_data = await request.post()
model_data = dict(model_data)
try:
model_path = utils.get_valid_full_path(model_type, path_index, filename)
if model_path is None:
raise RuntimeError(f"File {filename} not found")
self.update_model(model_path, model_data)
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Update model failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.delete("/model-manager/model/{type}/{index}/{filename:.*}")
async def delete_model(request):
"""
Delete model.
"""
model_type = request.match_info.get("type", None)
path_index = int(request.match_info.get("index", None))
filename = request.match_info.get("filename", None)
try:
model_path = utils.get_valid_full_path(model_type, path_index, filename)
if model_path is None:
raise RuntimeError(f"File {filename} not found")
self.remove_model(model_path)
return web.json_response({"success": True})
except Exception as e:
error_msg = f"Delete model failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
def scan_models(self, folder: str, request):
result = []
include_hidden_files = utils.get_setting_value(request, "scan.include_hidden_files", False)
folders, *others = folder_paths.folder_names_and_paths[folder]
def get_file_info(entry: os.DirEntry[str], base_path: str, path_index: int):
prefix_path = utils.normalize_path(base_path)
if not prefix_path.endswith("/"):
prefix_path = f"{prefix_path}/"
is_file = entry.is_file()
relative_path = utils.normalize_path(entry.path).replace(prefix_path, "")
sub_folder = os.path.dirname(relative_path)
filename = os.path.basename(relative_path)
basename = os.path.splitext(filename)[0] if is_file else filename
extension = os.path.splitext(filename)[1] if is_file else ""
model_preview = None
if is_file:
preview_name = utils.get_model_preview_name(entry.path)
preview_ext = f".{preview_name.split('.')[-1]}"
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, preview_ext)}"
if not os.path.exists(entry.path):
utils.print_error(f"{entry.path} is not file or directory.")
return None
stat = entry.stat()
return {
"type": folder,
"subFolder": sub_folder,
"isFolder": not is_file,
"basename": basename,
"extension": extension,
"pathIndex": path_index,
"sizeBytes": stat.st_size if is_file else 0,
"preview": model_preview,
"createdAt": round(stat.st_ctime_ns / 1000000),
"updatedAt": round(stat.st_mtime_ns / 1000000),
}
def get_all_files_entry(directory: str):
entries: list[os.DirEntry[str]] = []
with os.scandir(directory) as it:
for entry in it:
if not include_hidden_files and entry.name.startswith("."):
continue
if entry.is_file():
extension = os.path.splitext(entry.name)[1]
if extension in folder_paths.supported_pt_extensions:
entries.append(entry)
else:
entries.append(entry)
entries.extend(get_all_files_entry(entry.path))
return entries
BATCH_SIZE = 200
MAX_WORKERS = min(4, os.cpu_count() or 1)
for path_index, base_path in enumerate(folders):
if not os.path.exists(base_path):
continue
file_entries = get_all_files_entry(base_path)
for i in range(0, len(file_entries), BATCH_SIZE):
batch = file_entries[i:i + BATCH_SIZE]
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = {executor.submit(get_file_info, entry, base_path, path_index): entry for entry in batch}
for future in as_completed(futures):
file_info = future.result()
if file_info is not None:
result.append(file_info)
return result
def get_model_info(self, model_path: str):
directory = os.path.dirname(model_path)
metadata = utils.get_model_metadata(model_path)
description_file = utils.get_model_description_name(model_path)
description_file = utils.join_path(directory, description_file)
description = None
if os.path.isfile(description_file):
with open(description_file, "r", encoding="utf-8", newline="") as f:
description = f.read()
return {
"metadata": metadata,
"description": description,
}
def update_model(self, model_path: str, model_data: dict):
if "previewFile" in model_data:
previewFile = model_data["previewFile"]
# Always remove existing preview files first in case the file extension has changed
utils.remove_model_preview(model_path)
# Nothing else to do if the preview file was being removed
if not (type(previewFile) is str and previewFile == "undefined"):
utils.save_model_preview(model_path, previewFile)
if "description" in model_data:
description = model_data["description"]
utils.save_model_description(model_path, description)
if "type" in model_data and "pathIndex" in model_data and "fullname" in model_data:
model_type = model_data.get("type", None)
path_index = int(model_data.get("pathIndex", None))
fullname = model_data.get("fullname", None)
if model_type is None or path_index is None or fullname is None:
raise RuntimeError("Invalid type or pathIndex or fullname")
# get new path
new_model_path = utils.get_full_path(model_type, path_index, fullname)
utils.rename_model(model_path, new_model_path)
def remove_model(self, model_path: str):
model_dirname = os.path.dirname(model_path)
os.remove(model_path)
model_previews = utils.get_model_all_previews(model_path)
for preview in model_previews:
os.remove(utils.join_path(model_dirname, preview))
model_descriptions = utils.get_model_all_descriptions(model_path)
for description in model_descriptions:
os.remove(utils.join_path(model_dirname, description))

View File

@@ -1,140 +0,0 @@
import os
import logging
import traceback
import folder_paths
from typing import Any
from multidict import MultiDictProxy
from . import config
from . import utils
from . import socket
from . import download
async def connect_websocket(request):
async def message_handler(event_type: str, detail: Any, sid: str):
try:
if event_type == "downloadTaskList":
await download.scan_model_download_task_list(sid=sid)
if event_type == "resumeDownloadTask":
await download.download_model(task_id=detail)
if event_type == "pauseDownloadTask":
await download.pause_model_download_task(task_id=detail)
if event_type == "deleteDownloadTask":
await download.delete_model_download_task(task_id=detail)
except Exception:
logging.error(traceback.format_exc())
ws = await socket.create_websocket_handler(request, handler=message_handler)
return ws
def scan_models():
result = []
model_base_paths = config.model_base_paths
for model_type in model_base_paths:
folders, extensions = folder_paths.folder_names_and_paths[model_type]
for path_index, base_path in enumerate(folders):
files = utils.recursive_search_files(base_path)
models = folder_paths.filter_files_extensions(files, extensions)
images = folder_paths.filter_files_content_types(files, ["image"])
image_dict = utils.file_list_to_name_dict(images)
for fullname in models:
fullname = utils.normalize_path(fullname)
basename = os.path.splitext(fullname)[0]
extension = os.path.splitext(fullname)[1]
abs_path = utils.join_path(base_path, fullname)
file_stats = os.stat(abs_path)
# Resolve preview
image_name = image_dict.get(basename, "no-preview.png")
abs_image_path = utils.join_path(base_path, image_name)
if os.path.isfile(abs_image_path):
image_state = os.stat(abs_image_path)
image_timestamp = round(image_state.st_mtime_ns / 1000000)
image_name = f"{image_name}?ts={image_timestamp}"
model_preview = (
f"/model-manager/preview/{model_type}/{path_index}/{image_name}"
)
model_info = {
"fullname": fullname,
"basename": basename,
"extension": extension,
"type": model_type,
"pathIndex": path_index,
"sizeBytes": file_stats.st_size,
"preview": model_preview,
"createdAt": round(file_stats.st_ctime_ns / 1000000),
"updatedAt": round(file_stats.st_mtime_ns / 1000000),
}
result.append(model_info)
return result
def get_model_info(model_path: str):
directory = os.path.dirname(model_path)
metadata = utils.get_model_metadata(model_path)
description_file = utils.get_model_description_name(model_path)
description_file = utils.join_path(directory, description_file)
description = None
if os.path.isfile(description_file):
with open(description_file, "r", encoding="utf-8", newline="") as f:
description = f.read()
return {
"metadata": metadata,
"description": description,
}
def update_model(model_path: str, post: MultiDictProxy):
if "previewFile" in post:
previewFile = post["previewFile"]
utils.save_model_preview_image(model_path, previewFile)
if "description" in post:
description = post["description"]
utils.save_model_description(model_path, description)
if "type" in post and "pathIndex" in post and "fullname" in post:
model_type = post.get("type", None)
path_index = int(post.get("pathIndex", None))
fullname = post.get("fullname", None)
if model_type is None or path_index is None or fullname is None:
raise RuntimeError("Invalid type or pathIndex or fullname")
# get new path
new_model_path = utils.get_full_path(model_type, path_index, fullname)
utils.rename_model(model_path, new_model_path)
def remove_model(model_path: str):
model_dirname = os.path.dirname(model_path)
os.remove(model_path)
model_previews = utils.get_model_all_images(model_path)
for preview in model_previews:
os.remove(utils.join_path(model_dirname, preview))
model_descriptions = utils.get_model_all_descriptions(model_path)
for description in model_descriptions:
os.remove(utils.join_path(model_dirname, description))
async def create_model_download_task(post):
dict_post = dict(post)
return await download.create_model_download_task(dict_post)

View File

@@ -1,63 +0,0 @@
import aiohttp
import logging
import uuid
import json
from aiohttp import web
from typing import Any, Callable, Awaitable
from . import utils
__sockets: dict[str, web.WebSocketResponse] = {}
async def create_websocket_handler(
request, handler: Callable[[str, Any, str], Awaitable[Any]]
):
ws = web.WebSocketResponse()
await ws.prepare(request)
sid = request.rel_url.query.get("clientId", "")
if sid:
# Reusing existing session, remove old
__sockets.pop(sid, None)
else:
sid = uuid.uuid4().hex
__sockets[sid] = ws
try:
async for msg in ws:
if msg.type == aiohttp.WSMsgType.ERROR:
logging.warning(
"ws connection closed with exception %s" % ws.exception()
)
if msg.type == aiohttp.WSMsgType.TEXT:
data = json.loads(msg.data)
await handler(data.get("type"), data.get("detail"), sid)
finally:
__sockets.pop(sid, None)
return ws
async def send_json(event: str, data: Any, sid: str = None):
detail = utils.unpack_dataclass(data)
message = {"type": event, "data": detail}
if sid is None:
socket_list = list(__sockets.values())
for ws in socket_list:
await __send_socket_catch_exception(ws.send_json, message)
elif sid in __sockets:
await __send_socket_catch_exception(__sockets[sid].send_json, message)
async def __send_socket_catch_exception(function, message):
try:
await function(message)
except (
aiohttp.ClientError,
aiohttp.ClientPayloadError,
ConnectionResetError,
BrokenPipeError,
ConnectionError,
) as err:
logging.warning("send error: {}".format(err))

View File

@@ -1,7 +1,7 @@
import asyncio import asyncio
import threading import threading
import queue import queue
import logging
from . import utils from . import utils
@@ -13,14 +13,7 @@ class DownloadThreadPool:
self._lock = threading.Lock() self._lock = threading.Lock()
default_max_workers = 5 default_max_workers = 5
max_workers: int = utils.get_setting_value( max_workers: int = default_max_workers
"download.max_task_count", default_max_workers
)
if max_workers <= 0:
max_workers = default_max_workers
utils.set_setting_value("download.max_task_count", max_workers)
self.max_worker = max_workers self.max_worker = max_workers
def submit(self, task, task_id): def submit(self, task, task_id):
@@ -58,7 +51,7 @@ class DownloadThreadPool:
with self._lock: with self._lock:
self.running_tasks.remove(task_id) self.running_tasks.remove(task_id)
except Exception as e: except Exception as e:
logging.error(f"worker run error: {str(e)}") utils.print_error(f"worker run error: {str(e)}")
with self._lock: with self._lock:
self.workers_count -= 1 self.workers_count -= 1

79
py/upload.py Normal file
View File

@@ -0,0 +1,79 @@
import os
import time
import folder_paths
from aiohttp import web
from . import utils
class ModelUploader:
def add_routes(self, routes):
@routes.get("/model-manager/supported-extensions")
async def fetch_model_exts(request):
"""
Get model exts
"""
try:
supported_extensions = list(folder_paths.supported_pt_extensions)
return web.json_response({"success": True, "data": supported_extensions})
except Exception as e:
error_msg = f"Get model supported extension failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
@routes.post("/model-manager/upload")
async def upload_model(request):
"""
Upload model
"""
try:
reader = await request.multipart()
await self.upload_model(reader)
utils.print_info(f"Upload model success")
return web.json_response({"success": True, "data": None})
except Exception as e:
error_msg = f"Upload model failed: {str(e)}"
utils.print_error(error_msg)
return web.json_response({"success": False, "error": error_msg})
async def upload_model(self, reader):
uploaded_size = 0
last_update_time = time.time()
interval = 1.0
while True:
part = await reader.next()
if part is None:
break
name = part.name
if name == "folder":
file_folder = await part.text()
if name == "file":
filename = part.filename
filepath = f"{file_folder}/{filename}"
tmp_filepath = f"{file_folder}/{filename}.tmp"
with open(tmp_filepath, "wb") as f:
while True:
chunk = await part.read_chunk()
if not chunk:
break
f.write(chunk)
uploaded_size += len(chunk)
if time.time() - last_update_time >= interval:
update_upload_progress = {
"uploaded_size": uploaded_size,
}
await utils.send_json("update_upload_progress", update_upload_progress)
update_upload_progress = {
"uploaded_size": uploaded_size,
}
await utils.send_json("update_upload_progress", update_upload_progress)
os.rename(tmp_filepath, filepath)

View File

@@ -5,15 +5,87 @@ import shutil
import tarfile import tarfile
import logging import logging
import requests import requests
import traceback
import configparser import configparser
import functools
import mimetypes
import comfy.utils import comfy.utils
import folder_paths import folder_paths
from aiohttp import web from aiohttp import web
from typing import Any from typing import Any, Optional
from . import config from . import config
# Media file extensions
VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.m4v', '.ogv']
IMAGE_EXTENSIONS = ['.webp', '.png', '.jpg', '.jpeg', '.gif', '.bmp']
# Preview extensions in priority order (videos first, then images)
PREVIEW_EXTENSIONS = ['.webm', '.mp4', '.webp', '.png', '.jpg', '.jpeg', '.gif', '.bmp']
# Content type mappings
VIDEO_CONTENT_TYPE_MAP = {
'video/mp4': '.mp4',
'video/webm': '.webm',
'video/quicktime': '.mov',
'video/x-msvideo': '.avi',
'video/x-matroska': '.mkv',
'video/x-flv': '.flv',
'video/x-ms-wmv': '.wmv',
'video/ogg': '.ogv',
}
def print_info(msg, *args, **kwargs):
logging.info(f"[{config.extension_tag}] {msg}", *args, **kwargs)
def print_warning(msg, *args, **kwargs):
logging.warning(f"[{config.extension_tag}][WARNING] {msg}", *args, **kwargs)
def print_error(msg, *args, **kwargs):
logging.error(f"[{config.extension_tag}][ERROR] {msg}", *args, **kwargs)
logging.debug(traceback.format_exc())
def print_debug(msg, *args, **kwargs):
logging.debug(f"[{config.extension_tag}] {msg}", *args, **kwargs)
def deprecated(reason: str):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print_warning(f"{func.__name__} is deprecated: {reason}")
return func(*args, **kwargs)
return wrapper
return decorator
def _matches(predicate: dict):
def _filter(obj: dict):
return all(obj.get(key, None) == value for key, value in predicate.items())
return _filter
def filter_with(list: list, predicate):
if isinstance(predicate, dict):
predicate = _matches(predicate)
return [item for item in list if predicate(item)]
async def get_request_body(request) -> dict:
try:
return await request.json()
except:
return {}
def normalize_path(path: str): def normalize_path(path: str):
normpath = os.path.normpath(path) normpath = os.path.normpath(path)
@@ -52,8 +124,8 @@ def download_web_distribution(version: str):
return return
try: try:
logging.info(f"current version {version}, web version {web_version}") print_info(f"current version {version}, web version {web_version}")
logging.info("Downloading web distribution...") print_info("Downloading web distribution...")
download_url = f"https://github.com/hayden-fr/ComfyUI-Model-Manager/releases/download/v{version}/dist.tar.gz" download_url = f"https://github.com/hayden-fr/ComfyUI-Model-Manager/releases/download/v{version}/dist.tar.gz"
response = requests.get(download_url, stream=True) response = requests.get(download_url, stream=True)
response.raise_for_status() response.raise_for_status()
@@ -66,40 +138,56 @@ def download_web_distribution(version: str):
if os.path.exists(web_path): if os.path.exists(web_path):
shutil.rmtree(web_path) shutil.rmtree(web_path)
logging.info("Extracting web distribution...") print_info("Extracting web distribution...")
with tarfile.open(temp_file, "r:gz") as tar: with tarfile.open(temp_file, "r:gz") as tar:
members = [ members = [member for member in tar.getmembers() if member.name.startswith("web/")]
member for member in tar.getmembers() if member.name.startswith("web/")
]
tar.extractall(path=config.extension_uri, members=members) tar.extractall(path=config.extension_uri, members=members)
os.remove(temp_file) os.remove(temp_file)
logging.info("Web distribution downloaded successfully.") print_info("Web distribution downloaded successfully.")
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logging.error(f"Failed to download web distribution: {e}") print_error(f"Failed to download web distribution: {e}")
except tarfile.TarError as e: except tarfile.TarError as e:
logging.error(f"Failed to extract web distribution: {e}") print_error(f"Failed to extract web distribution: {e}")
except Exception as e: except Exception as e:
logging.error(f"An unexpected error occurred: {e}") print_error(f"An unexpected error occurred: {e}")
def resolve_model_base_paths(): def resolve_model_base_paths() -> dict[str, list[str]]:
"""
Resolve model base paths.
eg. { "checkpoints": ["path/to/checkpoints"] }
"""
folders = list(folder_paths.folder_names_and_paths.keys()) folders = list(folder_paths.folder_names_and_paths.keys())
config.model_base_paths = {} model_base_paths = {}
folder_black_list = ["configs", "custom_nodes"]
for folder in folders: for folder in folders:
if folder == "configs": if folder in folder_black_list:
continue
if folder == "custom_nodes":
continue continue
folders = folder_paths.get_folder_paths(folder) folders = folder_paths.get_folder_paths(folder)
config.model_base_paths[folder] = [normalize_path(f) for f in folders] model_base_paths[folder] = [normalize_path(f) for f in folders]
return model_base_paths
def resolve_file_content_type(filename: str):
extension_mimetypes_cache = folder_paths.extension_mimetypes_cache
extension = filename.split(".")[-1]
if extension not in extension_mimetypes_cache:
mime_type, _ = mimetypes.guess_type(filename, strict=False)
if not mime_type:
return None
content_type = mime_type.split("/")[0]
extension_mimetypes_cache[extension] = content_type
else:
content_type = extension_mimetypes_cache[extension]
return content_type
def get_full_path(model_type: str, path_index: int, filename: str): def get_full_path(model_type: str, path_index: int, filename: str):
""" """
Get the absolute path in the model type through string concatenation. Get the absolute path in the model type through string concatenation.
""" """
folders = config.model_base_paths.get(model_type, []) folders = resolve_model_base_paths().get(model_type, [])
if not path_index < len(folders): if not path_index < len(folders):
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}") raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
base_path = folders[path_index] base_path = folders[path_index]
@@ -111,7 +199,7 @@ def get_valid_full_path(model_type: str, path_index: int, filename: str):
""" """
Like get_full_path but it will check whether the file is valid. Like get_full_path but it will check whether the file is valid.
""" """
folders = config.model_base_paths.get(model_type, []) folders = resolve_model_base_paths().get(model_type, [])
if not path_index < len(folders): if not path_index < len(folders):
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}") raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
base_path = folders[path_index] base_path = folders[path_index]
@@ -119,9 +207,7 @@ def get_valid_full_path(model_type: str, path_index: int, filename: str):
if os.path.isfile(full_path): if os.path.isfile(full_path):
return full_path return full_path
elif os.path.islink(full_path): elif os.path.islink(full_path):
raise RuntimeError( raise RuntimeError(f"WARNING path {full_path} exists but doesn't link anywhere, skipping.")
f"WARNING path {full_path} exists but doesn't link anywhere, skipping."
)
def get_download_path(): def get_download_path():
@@ -131,11 +217,29 @@ def get_download_path():
return download_path return download_path
def recursive_search_files(directory: str): def recursive_search_files(directory: str, request):
files, folder_all = folder_paths.recursive_search( if not os.path.isdir(directory):
directory, excluded_dir_names=[".git"] return []
)
return [normalize_path(f) for f in files] excluded_dir_names = [".git"]
result = []
include_hidden_files = get_setting_value(request, "scan.include_hidden_files", False)
for dirpath, subdirs, filenames in os.walk(directory, followlinks=True, topdown=True):
subdirs[:] = [d for d in subdirs if d not in excluded_dir_names]
if not include_hidden_files:
subdirs[:] = [d for d in subdirs if not d.startswith(".")]
filenames[:] = [f for f in filenames if not f.startswith(".")]
for file_name in filenames:
try:
relative_path = os.path.relpath(os.path.join(dirpath, file_name), directory)
result.append(relative_path)
except:
logging.warning(f"Warning: Unable to access {file_name}. Skipping this file.")
continue
return [normalize_path(f) for f in result]
def search_files(directory: str): def search_files(directory: str):
@@ -167,62 +271,145 @@ def get_model_metadata(filename: str):
return {} return {}
def get_model_all_images(model_path: str): def _check_preview_variants(base_dirname: str, basename: str, extensions: list[str]) -> list[str]:
"""Check for preview files with given extensions and return found files"""
found = []
for ext in extensions:
# Direct match (basename.ext)
preview_file = f"{basename}{ext}"
if os.path.isfile(join_path(base_dirname, preview_file)):
found.append(preview_file)
# Preview variant (basename.preview.ext)
preview_file = f"{basename}.preview{ext}"
if os.path.isfile(join_path(base_dirname, preview_file)):
found.append(preview_file)
return found
def _get_preview_path(model_path: str, extension: str) -> str:
"""Generate preview file path with given extension"""
basename = os.path.splitext(model_path)[0]
return f"{basename}{extension}"
def get_model_all_previews(model_path: str) -> list[str]:
"""Get all preview files for a model"""
base_dirname = os.path.dirname(model_path) base_dirname = os.path.dirname(model_path)
files = search_files(base_dirname)
files = folder_paths.filter_files_content_types(files, ["image"])
basename = os.path.splitext(os.path.basename(model_path))[0] basename = os.path.splitext(os.path.basename(model_path))[0]
output: list[str] = [] return _check_preview_variants(base_dirname, basename, PREVIEW_EXTENSIONS)
for file in files:
file_basename = os.path.splitext(file)[0]
if file_basename == basename:
output.append(file)
if file_basename == f"{basename}.preview":
output.append(file)
return output
def get_model_preview_name(model_path: str): def get_model_preview_name(model_path: str) -> str:
images = get_model_all_images(model_path) """Get the first available preview file or 'no-preview.png' if none found"""
return images[0] if len(images) > 0 else "no-preview.png"
def save_model_preview_image(model_path: str, image_file: Any):
if not isinstance(image_file, web.FileField):
raise RuntimeError("Invalid image file")
content_type: str = image_file.content_type
if not content_type.startswith("image/"):
raise RuntimeError(f"FileTypeError: expected image, got {content_type}")
base_dirname = os.path.dirname(model_path) base_dirname = os.path.dirname(model_path)
# remove old preview images
old_preview_images = get_model_all_images(model_path)
a1111_civitai_helper_image = False
for image in old_preview_images:
if os.path.splitext(image)[1].endswith(".preview"):
a1111_civitai_helper_image = True
image_path = join_path(base_dirname, image)
os.remove(image_path)
# save new preview image
basename = os.path.splitext(os.path.basename(model_path))[0] basename = os.path.splitext(os.path.basename(model_path))[0]
extension = f".{content_type.split('/')[1]}"
new_preview_path = join_path(base_dirname, f"{basename}{extension}") for ext in PREVIEW_EXTENSIONS:
# Check direct match first
preview_name = f"{basename}{ext}"
if os.path.isfile(join_path(base_dirname, preview_name)):
return preview_name
# Check preview variant
preview_name = f"{basename}.preview{ext}"
if os.path.isfile(join_path(base_dirname, preview_name)):
return preview_name
return "no-preview.png"
with open(new_preview_path, "wb") as f:
f.write(image_file.file.read())
# TODO Is it possible to abandon the current rules and adopt the rules of a1111 civitai_helper? from PIL import Image
if a1111_civitai_helper_image: from io import BytesIO
"""
Keep preview image of a1111_civitai_helper
""" def remove_model_preview(model_path: str):
new_preview_path = join_path(base_dirname, f"{basename}.preview{extension}") """Remove all preview files for a model"""
with open(new_preview_path, "wb") as f: base_dirname = os.path.dirname(model_path)
f.write(image_file.file.read()) basename = os.path.splitext(os.path.basename(model_path))[0]
previews = _check_preview_variants(base_dirname, basename, PREVIEW_EXTENSIONS)
for preview in previews:
preview_path = join_path(base_dirname, preview)
if os.path.exists(preview_path):
os.remove(preview_path)
def save_model_preview(model_path: str, file_or_url: Any, platform: Optional[str] = None):
"""Save a preview file for a model. Images -> WebP, videos -> original format"""
# Download file if it is a URL
if type(file_or_url) is str:
url = file_or_url
try:
response = requests.get(url)
response.raise_for_status()
# Determine content type from response headers or URL extension
content_type = response.headers.get('content-type', '')
if not content_type:
# Fallback to URL extension detection
content_type = resolve_file_content_type(url) or ''
content = response.content
if content_type.startswith("video/"):
# Save video in original format
# Try to get extension from URL or content-type
ext = _get_video_extension_from_url(url) or _get_extension_from_content_type(content_type) or '.mp4'
preview_path = _get_preview_path(model_path, ext)
with open(preview_path, 'wb') as f:
f.write(content)
else:
# Default to image processing for unknown or image types
preview_path = _get_preview_path(model_path, ".webp")
image = Image.open(BytesIO(content))
image.save(preview_path, "WEBP")
except Exception as e:
print_error(f"Failed to download preview: {e}")
# Handle uploaded file
else:
file_obj = file_or_url
if not isinstance(file_obj, web.FileField):
raise RuntimeError("Invalid file")
content_type: str = file_obj.content_type
filename: str = getattr(file_obj, 'filename', '')
if content_type.startswith("video/"):
# Save video in original format for now, consider transcoding to webm to follow the pattern for images converting to webp
ext = os.path.splitext(filename.lower())[1] or '.mp4'
preview_path = _get_preview_path(model_path, ext)
file_obj.file.seek(0)
content = file_obj.file.read()
with open(preview_path, 'wb') as f:
f.write(content)
elif content_type.startswith("image/"):
# Convert image to webp
preview_path = _get_preview_path(model_path, ".webp")
image = Image.open(file_obj.file)
image.save(preview_path, "WEBP")
else:
raise RuntimeError(f"FileTypeError: expected image or video, got {content_type}")
def _get_video_extension_from_url(url: str) -> Optional[str]:
"""Extract video extension from URL."""
from urllib.parse import urlparse
path = urlparse(url).path.lower()
for ext in VIDEO_EXTENSIONS:
if path.endswith(ext):
return ext
return None
def _get_extension_from_content_type(content_type: str) -> Optional[str]:
"""Map content-type to file extension."""
return VIDEO_CONTENT_TYPE_MAP.get(content_type.lower())
def get_model_all_descriptions(model_path: str): def get_model_all_descriptions(model_path: str):
@@ -251,12 +438,6 @@ def save_model_description(model_path: str, content: Any):
base_dirname = os.path.dirname(model_path) base_dirname = os.path.dirname(model_path)
# remove old descriptions
old_descriptions = get_model_all_descriptions(model_path)
for desc in old_descriptions:
description_path = join_path(base_dirname, desc)
os.remove(description_path)
# save new description # save new description
basename = os.path.splitext(os.path.basename(model_path))[0] basename = os.path.splitext(os.path.basename(model_path))[0]
extension = ".md" extension = ".md"
@@ -286,7 +467,7 @@ def rename_model(model_path: str, new_model_path: str):
shutil.move(model_path, new_model_path) shutil.move(model_path, new_model_path)
# move preview # move preview
previews = get_model_all_images(model_path) previews = get_model_all_previews(model_path)
for preview in previews: for preview in previews:
preview_path = join_path(model_dirname, preview) preview_path = join_path(model_dirname, preview)
preview_name = os.path.splitext(preview)[0] preview_name = os.path.splitext(preview)[0]
@@ -334,30 +515,56 @@ def resolve_setting_key(key: str) -> str:
return setting_id return setting_id
def set_setting_value(key: str, value: Any): def set_setting_value(request: web.Request, key: str, value: Any):
setting_id = resolve_setting_key(key) setting_id = resolve_setting_key(key)
fake_request = config.FakeRequest() settings = config.serverInstance.user_manager.settings.get_settings(request)
settings = config.serverInstance.user_manager.settings.get_settings(fake_request)
settings[setting_id] = value settings[setting_id] = value
config.serverInstance.user_manager.settings.save_settings(fake_request, settings) config.serverInstance.user_manager.settings.save_settings(request, settings)
def get_setting_value(key: str, default: Any = None) -> Any: def get_setting_value(request: web.Request, key: str, default: Any = None) -> Any:
setting_id = resolve_setting_key(key) setting_id = resolve_setting_key(key)
fake_request = config.FakeRequest() settings = config.serverInstance.user_manager.settings.get_settings(request)
settings = config.serverInstance.user_manager.settings.get_settings(fake_request)
return settings.get(setting_id, default) return settings.get(setting_id, default)
from dataclasses import asdict, is_dataclass async def send_json(event: str, data: Any, sid: str = None):
await config.serverInstance.send_json(event, data, sid)
def unpack_dataclass(data: Any): import sys
if isinstance(data, dict): import subprocess
return {key: unpack_dataclass(value) for key, value in data.items()} import importlib.util
elif isinstance(data, list): import importlib.metadata
return [unpack_dataclass(x) for x in data]
elif is_dataclass(data):
return asdict(data) def is_installed(package_name: str):
else: try:
return data dist = importlib.metadata.distribution(package_name)
except importlib.metadata.PackageNotFoundError:
try:
spec = importlib.util.find_spec(package_name)
except ModuleNotFoundError:
return False
return spec is not None
return dist is not None
def pip_install(package_name: str):
subprocess.run([sys.executable, "-m", "pip", "install", package_name], check=True)
import hashlib
def calculate_sha256(path, buffer_size=1024 * 1024):
sha256 = hashlib.sha256()
with open(path, "rb") as f:
while True:
data = f.read(buffer_size)
if not data:
break
sha256.update(data)
return sha256.hexdigest()

View File

@@ -1,8 +1,9 @@
[project] [project]
name = "comfyui-model-manager" name = "comfyui-model-manager"
description = "Manage models: browsing, download and delete." description = "Manage models: browsing, download and delete."
version = "2.0.2" version = "2.8.2"
license = "LICENSE" license = { file = "LICENSE" }
dependencies = ["markdownify"]
[project.urls] [project.urls]
Repository = "https://github.com/hayden-fr/ComfyUI-Model-Manager" Repository = "https://github.com/hayden-fr/ComfyUI-Model-Manager"
@@ -12,3 +13,6 @@ Repository = "https://github.com/hayden-fr/ComfyUI-Model-Manager"
PublisherId = "hayden" PublisherId = "hayden"
DisplayName = "ComfyUI-Model-Manager" DisplayName = "ComfyUI-Model-Manager"
Icon = "" Icon = ""
[tool.black]
line-length = 160

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
markdownify

View File

@@ -6,25 +6,30 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import DialogManager from 'components/DialogManager.vue'
import DialogDownload from 'components/DialogDownload.vue' import DialogDownload from 'components/DialogDownload.vue'
import GlobalToast from 'components/GlobalToast.vue' import DialogExplorer from 'components/DialogExplorer.vue'
import GlobalLoading from 'components/GlobalLoading.vue' import DialogManager from 'components/DialogManager.vue'
import DialogScanning from 'components/DialogScanning.vue'
import DialogUpload from 'components/DialogUpload.vue'
import GlobalDialogStack from 'components/GlobalDialogStack.vue' import GlobalDialogStack from 'components/GlobalDialogStack.vue'
import GlobalConfirm from 'primevue/confirmdialog' import GlobalLoading from 'components/GlobalLoading.vue'
import { $el, app, ComfyButton } from 'scripts/comfyAPI' import GlobalToast from 'components/GlobalToast.vue'
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useStoreProvider } from 'hooks/store' import { useStoreProvider } from 'hooks/store'
import { useToast } from 'hooks/toast' import { useToast } from 'hooks/toast'
import GlobalConfirm from 'primevue/confirmdialog'
import { $el, app, ComfyButton } from 'scripts/comfyAPI'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t } = useI18n()
const { dialog, models, config, download } = useStoreProvider() const { dialog, models, config, download } = useStoreProvider()
const { toast } = useToast() const { toast } = useToast()
const firstOpenManager = ref(true)
onMounted(() => { onMounted(() => {
const refreshModelsAndConfig = async () => { const refreshModelsAndConfig = async () => {
await Promise.all([models.refresh(), config.refresh()]) await Promise.all([models.refresh(true)])
toast.add({ toast.add({
severity: 'success', severity: 'success',
summary: 'Refreshed Models', summary: 'Refreshed Models',
@@ -32,6 +37,19 @@ onMounted(() => {
}) })
} }
const openModelScanning = () => {
dialog.open({
key: 'model-information-scanning',
title: t('batchScanModelInformation'),
content: DialogScanning,
modal: true,
defaultSize: {
width: 680,
height: 490,
},
})
}
const openDownloadDialog = () => { const openDownloadDialog = () => {
dialog.open({ dialog.open({
key: 'model-manager-download-list', key: 'model-manager-download-list',
@@ -39,6 +57,7 @@ onMounted(() => {
content: DialogDownload, content: DialogDownload,
headerButtons: [ headerButtons: [
{ {
key: 'refresh',
icon: 'pi pi-refresh', icon: 'pi pi-refresh',
command: () => download.refresh(), command: () => download.refresh(),
}, },
@@ -46,23 +65,55 @@ onMounted(() => {
}) })
} }
const openUploadDialog = () => {
dialog.open({
key: 'model-manager-upload',
title: t('uploadModel'),
content: DialogUpload,
headerButtons: [
{
key: 'refresh',
icon: 'pi pi-refresh',
command: refreshModelsAndConfig,
},
],
})
}
const openManagerDialog = () => { const openManagerDialog = () => {
const { cardWidth, gutter, aspect } = config const { cardWidth, gutter, aspect, flat } = config
if (firstOpenManager.value) {
models.refresh(true)
firstOpenManager.value = false
}
dialog.open({ dialog.open({
key: 'model-manager', key: 'model-manager',
title: t('modelManager'), title: t('modelManager'),
content: DialogManager, content: flat.value ? DialogManager : DialogExplorer,
keepAlive: true, keepAlive: true,
headerButtons: [ headerButtons: [
{ {
key: 'scanning',
icon: 'mdi mdi-folder-search-outline text-lg',
command: openModelScanning,
},
{
key: 'refresh',
icon: 'pi pi-refresh', icon: 'pi pi-refresh',
command: refreshModelsAndConfig, command: refreshModelsAndConfig,
}, },
{ {
key: 'download',
icon: 'pi pi-download', icon: 'pi pi-download',
command: openDownloadDialog, command: openDownloadDialog,
}, },
{
key: 'upload',
icon: 'pi pi-upload',
command: openUploadDialog,
},
], ],
minWidth: cardWidth * 2 + gutter + 42, minWidth: cardWidth * 2 + gutter + 42,
minHeight: (cardWidth / aspect) * 0.5 + 162, minHeight: (cardWidth / aspect) * 0.5 + 162,

View File

@@ -8,7 +8,7 @@
> >
<template #suffix> <template #suffix>
<span <span
class="pi pi-search pi-inputicon" class="pi pi-search text-base opacity-60"
@click="searchModelsByUrl" @click="searchModelsByUrl"
></span> ></span>
</template> </template>
@@ -28,21 +28,31 @@
<ResponseScroll class="-mx-5 h-full"> <ResponseScroll class="-mx-5 h-full">
<div class="px-5"> <div class="px-5">
<ModelContent <KeepAlive>
v-if="currentModel" <ModelContent
:key="currentModel.id" v-if="currentModel"
:model="currentModel" :key="`${currentModel.id}-${currentModel.currentFileId}`"
:editable="true" :model="currentModel"
@submit="createDownTask" :editable="true"
> @submit="createDownTask"
<template #action> >
<Button <template #action>
icon="pi pi-download" <div v-if="currentModel.files" class="flex-1">
:label="$t('download')" <ResponseSelect
type="submit" :model-value="currentModel.currentFileId"
></Button> :items="currentModel.selectionFiles"
</template> :type="isMobile ? 'drop' : 'button'"
</ModelContent> >
</ResponseSelect>
</div>
<Button
icon="pi pi-download"
:label="$t('download')"
type="submit"
></Button>
</template>
</ModelContent>
</KeepAlive>
<div v-show="data.length === 0"> <div v-show="data.length === 0">
<div class="flex flex-col items-center gap-4 py-8"> <div class="flex flex-col items-center gap-4 py-8">
@@ -58,15 +68,17 @@
<script setup lang="ts"> <script setup lang="ts">
import ModelContent from 'components/ModelContent.vue' import ModelContent from 'components/ModelContent.vue'
import ResponseInput from 'components/ResponseInput.vue' import ResponseInput from 'components/ResponseInput.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import ResponseScroll from 'components/ResponseScroll.vue' import ResponseScroll from 'components/ResponseScroll.vue'
import Button from 'primevue/button' import ResponseSelect from 'components/ResponseSelect.vue'
import { useConfig } from 'hooks/config' import { useConfig } from 'hooks/config'
import { useDialog } from 'hooks/dialog' import { useDialog } from 'hooks/dialog'
import { useModelSearch } from 'hooks/download' import { useModelSearch } from 'hooks/download'
import { useLoading } from 'hooks/loading' import { useLoading } from 'hooks/loading'
import { genModelFullName } from 'hooks/model'
import { request } from 'hooks/request' import { request } from 'hooks/request'
import { useToast } from 'hooks/toast' import { useToast } from 'hooks/toast'
import Button from 'primevue/button'
import { VersionModel, WithResolved } from 'types/typings'
import { previewUrlToFile } from 'utils/common' import { previewUrlToFile } from 'utils/common'
import { ref } from 'vue' import { ref } from 'vue'
@@ -85,42 +97,55 @@ const searchModelsByUrl = async () => {
} }
} }
const createDownTask = async (data: VersionModel) => { const createDownTask = async (data: WithResolved<VersionModel>) => {
const formData = new FormData()
loading.show() loading.show()
// set base info
formData.append('type', data.type) const formData = new FormData()
formData.append('pathIndex', data.pathIndex.toString()) for (const key in data) {
formData.append('fullname', data.fullname) if (Object.prototype.hasOwnProperty.call(data, key)) {
// set preview let value = data[key]
const previewFile = await previewUrlToFile(data.preview as string).catch(
() => { // set preview file
loading.hide() if (key === 'preview') {
toast.add({ if (value) {
severity: 'error', const previewFile = await previewUrlToFile(value).catch(() => {
summary: 'Error', loading.hide()
detail: 'Failed to download preview', toast.add({
life: 15000, severity: 'error',
}) summary: 'Error',
throw new Error('Failed to download preview') detail: 'Failed to download preview',
}, life: 5000,
) })
formData.append('previewFile', previewFile) throw new Error('Failed to download preview')
// set description })
formData.append('description', data.description) formData.append('previewFile', previewFile)
// set model download info } else {
formData.append('downloadPlatform', data.downloadPlatform) formData.append('previewFile', value)
formData.append('downloadUrl', data.downloadUrl) }
formData.append('sizeBytes', data.sizeBytes.toString()) continue
formData.append('hashes', JSON.stringify(data.hashes)) }
if (typeof value === 'object') {
value = JSON.stringify(value)
}
if (typeof value === 'number') {
value = value.toString()
}
formData.append(key, value)
}
}
const fullname = genModelFullName(data as VersionModel)
formData.append('fullname', fullname)
await request('/model', { await request('/model', {
method: 'POST', method: 'POST',
body: formData, body: formData,
}) })
.then(() => { .then(() => {
dialog.close({ key: 'model-manager-create-task' }) dialog.close()
}) })
.catch((e) => { .catch((e) => {
toast.add({ toast.add({

View File

@@ -1,9 +1,9 @@
<template> <template>
<div class="flex h-full flex-col gap-4"> <div class="flex h-full flex-col gap-4">
<div class="whitespace-nowrap px-4 @container"> <div ref="container" class="whitespace-nowrap px-4">
<div class="flex gap-4 @sm:justify-end"> <div :class="['flex gap-4', $sm('justify-end')]">
<Button <Button
class="w-full @sm:w-auto" :class="[$sm('w-auto', 'w-full')]"
:label="$t('createDownloadTask')" :label="$t('createDownloadTask')"
@click="openCreateTask" @click="openCreateTask"
></Button> ></Button>
@@ -20,7 +20,10 @@
> >
<div class="flex gap-4 overflow-hidden whitespace-nowrap"> <div class="flex gap-4 overflow-hidden whitespace-nowrap">
<div class="h-18 preview-aspect"> <div class="h-18 preview-aspect">
<img :src="item.preview" /> <div v-if="isVideoUrl(item.preview)" class="h-full w-full">
<PreviewVideo :src="item.preview" />
</div>
<img v-else :src="item.preview" />
</div> </div>
<div class="flex flex-1 flex-col gap-3 overflow-hidden"> <div class="flex flex-1 flex-col gap-3 overflow-hidden">
@@ -72,10 +75,14 @@
<script setup lang="ts"> <script setup lang="ts">
import DialogCreateTask from 'components/DialogCreateTask.vue' import DialogCreateTask from 'components/DialogCreateTask.vue'
import PreviewVideo from 'components/PreviewVideo.vue'
import ResponseScroll from 'components/ResponseScroll.vue' import ResponseScroll from 'components/ResponseScroll.vue'
import Button from 'primevue/button' import { useContainerQueries } from 'hooks/container'
import { useDownload } from 'hooks/download'
import { useDialog } from 'hooks/dialog' import { useDialog } from 'hooks/dialog'
import { useDownload } from 'hooks/download'
import Button from 'primevue/button'
import { isVideoUrl } from 'utils/media'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { data } = useDownload() const { data } = useDownload()
@@ -85,9 +92,12 @@ const dialog = useDialog()
const openCreateTask = () => { const openCreateTask = () => {
dialog.open({ dialog.open({
key: 'model-manager-create-task', key: `model-manager-create-task-${Date.now()}`,
title: t('parseModelUrl'), title: t('parseModelUrl'),
content: DialogCreateTask, content: DialogCreateTask,
}) })
} }
const container = ref<HTMLElement | null>(null)
const { $sm } = useContainerQueries(container)
</script> </script>

View File

@@ -0,0 +1,337 @@
<template>
<div
class="flex h-full w-full select-none flex-col overflow-hidden"
@contextmenu.prevent="nonContextMenu"
>
<div class="flex w-full gap-4 overflow-hidden px-4 pb-4">
<div :class="['flex gap-4 overflow-hidden', showToolbar || 'flex-1']">
<div class="flex overflow-hidden">
<Button
icon="pi pi-arrow-up"
text
rounded
severity="secondary"
:disabled="folderPaths.length < 2"
@click="handleGoBackParentFolder"
></Button>
</div>
<ResponseBreadcrumb
v-show="!showToolbar"
class="h-10 flex-1"
:items="folderPaths"
></ResponseBreadcrumb>
</div>
<div :class="['flex gap-4', showToolbar && 'flex-1']">
<ResponseInput
v-model="searchContent"
:placeholder="$t('searchModels')"
></ResponseInput>
<div
v-show="showToolbar"
class="flex flex-1 items-center justify-end gap-2"
>
<ResponseSelect
v-model="sortOrder"
:items="sortOrderOptions"
></ResponseSelect>
<ResponseSelect
v-model="cardSizeFlag"
:items="cardSizeOptions"
></ResponseSelect>
</div>
<Button
:icon="`mdi mdi-menu-${showToolbar ? 'close' : 'open'}`"
text
severity="secondary"
@click="toggleToolbar"
></Button>
</div>
</div>
<div
ref="contentContainer"
class="relative flex-1 overflow-hidden px-2"
@contextmenu.stop.prevent=""
>
<ResponseScroll :items="renderedList" :item-size="itemSize">
<template #item="{ item }">
<div
class="grid h-full justify-center"
:style="{
gridTemplateColumns: `repeat(auto-fit, ${cardSize.width}px)`,
columnGap: `${gutter.x}px`,
rowGap: `${gutter.y}px`,
}"
>
<ModelCard
v-for="rowItem in item.row"
:model="rowItem"
:key="genModelKey(rowItem)"
:style="{
width: `${cardSize.width}px`,
height: `${cardSize.height}px`,
}"
v-tooltip.top="{
value: getFullPath(rowItem),
disabled: folderPaths.length < 2,
autoHide: false,
showDelay: 800,
hideDelay: 300,
pt: { root: { style: { zIndex: 2100, maxWidth: '32rem' } } },
}"
@dblclick="openItem(rowItem, $event)"
@contextmenu.stop.prevent="openItemContext(rowItem, $event)"
></ModelCard>
<div class="col-span-full"></div>
</div>
</template>
</ResponseScroll>
</div>
<div class="flex justify-between px-4 py-2 text-sm">
<div></div>
<div></div>
</div>
<ContextMenu ref="menu" :model="contextItems"></ContextMenu>
<ConfirmDialog group="confirm-name">
<template #container="{ acceptCallback: accept, rejectCallback: reject }">
<div class="flex w-90 flex-col items-end rounded px-4 pb-4 pt-8">
<InputText
class="w-full"
type="text"
v-model="confirmName"
v-focus
@keyup.enter="accept"
></InputText>
<div class="mt-6 flex items-center gap-2">
<Button :label="$t('cancel')" @click="reject" outlined></Button>
<Button :label="$t('confirm')" @click="accept"></Button>
</div>
</div>
</template>
</ConfirmDialog>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import ModelCard from 'components/ModelCard.vue'
import ResponseBreadcrumb from 'components/ResponseBreadcrumb.vue'
import ResponseInput from 'components/ResponseInput.vue'
import ResponseScroll from 'components/ResponseScroll.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import { useConfig } from 'hooks/config'
import { type ModelTreeNode, useModelExplorer } from 'hooks/explorer'
import { chunk } from 'lodash'
import Button from 'primevue/button'
import ConfirmDialog from 'primevue/confirmdialog'
import ContextMenu from 'primevue/contextmenu'
import InputText from 'primevue/inputtext'
import type { MenuItem } from 'primevue/menuitem'
import { genModelKey } from 'utils/model'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const gutter = {
x: 4,
y: 32,
}
const {
dataTreeList,
folderPaths,
findFolder,
openFolder,
openModelDetail,
getFullPath,
} = useModelExplorer()
const { cardSize, cardSizeMap, cardSizeFlag, dialog: settings } = useConfig()
const showToolbar = ref(false)
const toggleToolbar = () => {
showToolbar.value = !showToolbar.value
}
const contentContainer = ref<HTMLElement | null>(null)
const contentSize = useElementSize(contentContainer)
const itemSize = computed(() => {
return cardSize.value.height + gutter.y
})
const cols = computed(() => {
const containerWidth = contentSize.width.value + gutter.x
const itemWidth = cardSize.value.width + gutter.x
return Math.floor(containerWidth / itemWidth)
})
const searchContent = ref<string>()
const sortOrder = ref('name')
const sortOrderOptions = ref(
['name', 'size', 'created', 'modified'].map((key) => {
return {
label: t(`sort.${key}`),
value: key,
icon: key === 'name' ? 'pi pi-sort-alpha-down' : 'pi pi-sort-amount-down',
command: () => {
sortOrder.value = key
},
}
}),
)
const currentDataList = computed(() => {
let renderedList = dataTreeList.value
for (const folderItem of folderPaths.value) {
const found = findFolder(renderedList, {
basename: folderItem.name,
pathIndex: folderItem.pathIndex,
})
renderedList = found?.children || []
}
const filter = searchContent.value?.toLowerCase().trim() ?? ''
if (filter) {
const filterItems: ModelTreeNode[] = []
const searchList = [...renderedList]
while (searchList.length) {
const item = searchList.pop()!
const children = (item as any).children ?? []
searchList.push(...children)
const matchSubFolder = `${item.subFolder}/`.toLowerCase().includes(filter)
const matchName = item.basename.toLowerCase().includes(filter)
if (matchSubFolder || matchName) {
filterItems.push(item)
}
}
renderedList = filterItems
}
if (folderPaths.value.length > 1) {
const folderItems: ModelTreeNode[] = []
const modelItems: ModelTreeNode[] = []
for (const item of renderedList) {
if (item.isFolder) {
folderItems.push(item)
} else {
modelItems.push(item)
}
}
folderItems.sort((a, b) => {
return a.basename.localeCompare(b.basename)
})
modelItems.sort((a, b) => {
const sortFieldMap = {
name: 'basename',
size: 'sizeBytes',
created: 'createdAt',
modified: 'updatedAt',
}
const sortField = sortFieldMap[sortOrder.value]
const aValue = a[sortField]
const bValue = b[sortField]
const result =
typeof aValue === 'string'
? aValue.localeCompare(bValue)
: aValue - bValue
return result
})
renderedList = [...folderItems, ...modelItems]
}
return renderedList
})
const renderedList = computed(() => {
return chunk(currentDataList.value, cols.value).map((row) => {
return { key: row.map((o) => o.basename).join('#'), row }
})
})
const cardSizeOptions = computed(() => {
const customSize = 'size.custom'
const customOptionMap = {
...cardSizeMap.value,
[customSize]: 'custom',
}
return Object.keys(customOptionMap).map((key) => {
return {
label: t(key),
value: key,
command: () => {
if (key === customSize) {
settings.showCardSizeSetting()
} else {
cardSizeFlag.value = key
}
},
}
})
})
const menu = ref()
const contextItems = ref<MenuItem[]>([])
const confirmName = ref('')
const openItem = (item: ModelTreeNode, e: Event) => {
menu.value.hide(e)
if (item.isFolder) {
searchContent.value = undefined
openFolder(item)
} else {
openModelDetail(item)
}
}
const openItemContext = (item: ModelTreeNode, e: Event) => {
if (folderPaths.value.length < 2) {
return
}
contextItems.value = [
{
label: t('open'),
icon: 'pi pi-folder-open',
command: () => {
openItem(item, e)
},
},
]
menu.value?.show(e)
}
const nonContextMenu = (e: Event) => {
menu.value.hide(e)
}
const vFocus = {
mounted: (el: HTMLInputElement) => el.focus(),
}
const handleGoBackParentFolder = () => {
folderPaths.value.pop()
}
</script>

View File

@@ -1,64 +1,110 @@
<template> <template>
<div <div
class="flex h-full flex-col gap-4 overflow-hidden @container/content" ref="contentContainer"
:style="{ class="flex h-full flex-col gap-4 overflow-hidden"
['--card-width']: `${cardWidth}px`,
['--gutter']: `${gutter}px`,
}"
v-resize="onContainerResize"
> >
<div <div
:class="[ class="grid grid-cols-1 justify-center gap-4 px-8"
'grid grid-cols-1 justify-center gap-4 px-8', :style="$content_lg(contentStyle)"
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
'@lg/content:gap-[var(--gutter)]',
'@lg/content:px-4',
]"
> >
<div class="col-span-full @container/toolbar"> <div ref="toolbarContainer" class="col-span-full">
<div :class="['flex flex-col gap-4', '@2xl/toolbar:flex-row']"> <div :class="['flex gap-4', $toolbar_2xl('flex-row', 'flex-col')]">
<ResponseInput <div class="flex-1">
v-model="searchContent" <ResponseInput
:placeholder="$t('searchModels')" v-model="searchContent"
:allow-clear="true" :placeholder="$t('searchModels')"
suffix-icon="pi pi-search" :allow-clear="true"
></ResponseInput> suffix-icon="pi pi-search"
></ResponseInput>
</div>
<div class="flex items-center justify-between gap-4 overflow-hidden"> <div class="flex items-center justify-between gap-4 overflow-hidden">
<ResponseSelect <ResponseSelect
class="flex-1"
v-model="currentType" v-model="currentType"
:items="typeOptions" :items="typeOptions"
:type="isMobile ? 'drop' : 'button'"
></ResponseSelect> ></ResponseSelect>
<ResponseSelect <ResponseSelect
class="flex-1"
v-model="sortOrder" v-model="sortOrder"
:items="sortOrderOptions" :items="sortOrderOptions"
></ResponseSelect> ></ResponseSelect>
<ResponseSelect
class="flex-1"
v-model="cardSizeFlag"
:items="cardSizeOptions"
></ResponseSelect>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<ResponseScroll <ResponseScroll :items="list" :itemSize="itemSize" class="h-full flex-1">
:items="list"
:itemSize="itemSize"
:row-key="(item) => item.map(genModelKey).join(',')"
class="h-full flex-1"
>
<template #item="{ item }"> <template #item="{ item }">
<div <div
:class="[ class="grid grid-cols-1 justify-center gap-8 px-8"
'grid grid-cols-1 justify-center gap-8 px-8', :style="contentStyle"
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
'@lg/content:gap-[var(--gutter)]',
'@lg/content:px-4',
]"
> >
<ModelCard <ModelCard
v-for="model in item" v-for="model in item.row"
:key="genModelKey(model)" :key="genModelKey(model)"
:model="model" :model="model"
></ModelCard> :style="{
width: `${cardSize.width}px`,
height: `${cardSize.height}px`,
}"
class="group/card cursor-pointer !p-0"
@click="openModelDetail(model)"
v-tooltip.top="{
value: getFullPath(model),
autoHide: false,
showDelay: 800,
hideDelay: 300,
pt: { root: { style: { zIndex: 2100, maxWidth: '32rem' } } },
}"
>
<template #name>
<div
v-show="showModelName"
class="absolute top-0 h-full w-full p-2"
>
<div class="flex h-full flex-col justify-end text-lg">
<div class="line-clamp-3 break-all font-bold text-shadow">
{{ model.basename }}
</div>
</div>
</div>
</template>
<template #extra>
<div
v-show="showModeAction"
class="pointer-events-none absolute right-2 top-2 opacity-0 duration-300 group-hover/card:opacity-100"
>
<div class="flex flex-col gap-2">
<Button
icon="pi pi-plus"
severity="secondary"
rounded
@click.stop="addModelNode(model)"
></Button>
<Button
icon="pi pi-copy"
severity="secondary"
rounded
@click.stop="copyModelNode(model)"
></Button>
<Button
v-show="model.preview"
icon="pi pi-file-import"
severity="secondary"
rounded
@click.stop="loadPreviewWorkflow(model)"
></Button>
</div>
</div>
</template>
</ModelCard>
<div class="col-span-full"></div> <div class="col-span-full"></div>
</div> </div>
</template> </template>
@@ -74,28 +120,59 @@
</template> </template>
<script setup lang="ts" name="manager-dialog"> <script setup lang="ts" name="manager-dialog">
import { useConfig } from 'hooks/config' import { useElementSize } from '@vueuse/core'
import { useModels } from 'hooks/model'
import ModelCard from 'components/ModelCard.vue' import ModelCard from 'components/ModelCard.vue'
import ResponseInput from 'components/ResponseInput.vue' import ResponseInput from 'components/ResponseInput.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import ResponseScroll from 'components/ResponseScroll.vue' import ResponseScroll from 'components/ResponseScroll.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import { configSetting, useConfig } from 'hooks/config'
import { useContainerQueries } from 'hooks/container'
import { useModelNodeAction, useModels } from 'hooks/model'
import { chunk } from 'lodash'
import Button from 'primevue/button'
import { app } from 'scripts/comfyAPI'
import { Model } from 'types/typings'
import { genModelKey } from 'utils/model'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { chunk } from 'lodash'
import { defineResizeCallback } from 'hooks/resize'
import { genModelKey } from 'utils/model'
const { isMobile, cardWidth, gutter, aspect, modelFolders } = useConfig() const {
isMobile,
gutter,
cardSize,
cardSizeMap,
cardSizeFlag,
dialog: settings,
} = useConfig()
const { data } = useModels() const { data, folders, openModelDetail, getFullPath } = useModels()
const { t } = useI18n() const { t } = useI18n()
const toolbarContainer = ref<HTMLElement | null>(null)
const { $2xl: $toolbar_2xl } = useContainerQueries(toolbarContainer)
const contentContainer = ref<HTMLElement | null>(null)
const { $lg: $content_lg } = useContainerQueries(contentContainer)
const searchContent = ref<string>() const searchContent = ref<string>()
const currentType = ref('all') const allType = 'All'
const currentType = ref(allType)
const typeOptions = computed(() => { const typeOptions = computed(() => {
return ['all', ...Object.keys(modelFolders.value)].map((type) => { const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
configSetting.excludeScanTypes,
)
const customBlackList =
excludeScanTypes
?.split(',')
.map((type) => type.trim())
.filter(Boolean) ?? []
return [
allType,
...Object.keys(folders.value).filter(
(folder) => !customBlackList.includes(folder),
),
].map((type) => {
return { return {
label: type, label: type,
value: type, value: type,
@@ -121,35 +198,49 @@ const sortOrderOptions = ref(
) )
const itemSize = computed(() => { const itemSize = computed(() => {
let itemWidth = cardWidth let itemHeight = cardSize.value.height
let itemGutter = gutter let itemGutter = gutter
if (isMobile.value) { if (isMobile.value) {
const baseSize = 16 const baseSize = 16
itemWidth = window.innerWidth - baseSize * 2 * 2 itemHeight = window.innerWidth - baseSize * 2 * 2
itemGutter = baseSize * 2 itemGutter = baseSize * 2
} }
return itemWidth / aspect + itemGutter return itemHeight + itemGutter
}) })
const colSpan = ref(1) const { width } = useElementSize(contentContainer)
const colSpanWidth = ref(cardWidth)
const cols = computed(() => {
if (isMobile.value) {
return 1
}
const containerWidth = width.value
const itemWidth = cardSize.value.width
return Math.floor((containerWidth - gutter) / (itemWidth + gutter))
})
const list = computed(() => { const list = computed(() => {
const filterList = data.value.filter((model) => { const mergedList = Object.values(data.value).flat()
const showAllModel = currentType.value === 'all' const pureModels = mergedList.filter((item) => {
return !item.isFolder
const matchType = showAllModel || model.type === currentType.value
const matchName = model.fullname
.toLowerCase()
.includes(searchContent.value?.toLowerCase() || '')
return matchType && matchName
}) })
let sortStrategy = (a: Model, b: Model) => 0 const filterList = pureModels.filter((model) => {
const showAllModel = currentType.value === allType
const matchType = showAllModel || model.type === currentType.value
const filter = searchContent.value?.toLowerCase() ?? ''
const matchSubFolder = model.subFolder.toLowerCase().includes(filter)
const matchName = model.basename.toLowerCase().includes(filter)
return matchType && (matchSubFolder || matchName)
})
let sortStrategy: (a: Model, b: Model) => number = () => 0
switch (sortOrder.value) { switch (sortOrder.value) {
case 'name': case 'name':
sortStrategy = (a, b) => a.fullname.localeCompare(b.fullname) sortStrategy = (a, b) => a.basename.localeCompare(b.basename)
break break
case 'size': case 'size':
sortStrategy = (a, b) => b.sizeBytes - a.sizeBytes sortStrategy = (a, b) => b.sizeBytes - a.sizeBytes
@@ -166,17 +257,49 @@ const list = computed(() => {
const sortedList = filterList.sort(sortStrategy) const sortedList = filterList.sort(sortStrategy)
return chunk(sortedList, colSpan.value) return chunk(sortedList, cols.value).map((row) => {
return { key: row.map(genModelKey).join(','), row }
})
}) })
const onContainerResize = defineResizeCallback((entries) => { const contentStyle = computed(() => ({
const entry = entries[0] gridTemplateColumns: `repeat(auto-fit, ${cardSize.value.width}px)`,
if (isMobile.value) { gap: `${gutter}px`,
colSpan.value = 1 paddingLeft: `1rem`,
} else { paddingRight: `1rem`,
const containerWidth = entry.contentRect.width }))
colSpan.value = Math.floor((containerWidth - gutter) / (cardWidth + gutter))
colSpanWidth.value = colSpan.value * (cardWidth + gutter) - gutter const cardSizeOptions = computed(() => {
const customSize = 'size.custom'
const customOptionMap = {
...cardSizeMap.value,
[customSize]: 'custom',
} }
return Object.keys(customOptionMap).map((key) => {
return {
label: t(key),
value: key,
command: () => {
if (key === customSize) {
settings.showCardSizeSetting()
} else {
cardSizeFlag.value = key
}
},
}
})
}) })
const showModelName = computed(() => {
return cardSize.value.width > 120 && cardSize.value.height > 160
})
const showModeAction = computed(() => {
return cardSize.value.width > 120 && cardSize.value.height > 160
})
const { addModelNode, copyModelNode, loadPreviewWorkflow } =
useModelNodeAction()
</script> </script>

View File

@@ -18,12 +18,18 @@
icon="pi pi-eye" icon="pi pi-eye"
@click="openModelPage(metadata.modelPage)" @click="openModelPage(metadata.modelPage)"
></Button> ></Button>
<Button icon="pi pi-plus" @click.stop="addModelNode"></Button> <Button
<Button icon="pi pi-copy" @click.stop="copyModelNode"></Button> icon="pi pi-plus"
@click.stop="addModelNode(model)"
></Button>
<Button
icon="pi pi-copy"
@click.stop="copyModelNode(model)"
></Button>
<Button <Button
v-show="model.preview" v-show="model.preview"
icon="pi pi-file-import" icon="pi pi-file-import"
@click.stop="loadPreviewWorkflow" @click.stop="loadPreviewWorkflow(model)"
></Button> ></Button>
<Button <Button
icon="pi pi-pen-to-square" icon="pi pi-pen-to-square"
@@ -44,9 +50,10 @@
<script setup lang="ts"> <script setup lang="ts">
import ModelContent from 'components/ModelContent.vue' import ModelContent from 'components/ModelContent.vue'
import ResponseScroll from 'components/ResponseScroll.vue' import ResponseScroll from 'components/ResponseScroll.vue'
import Button from 'primevue/button' import { genModelUrl, useModelNodeAction, useModels } from 'hooks/model'
import { useModelNodeAction, useModels } from 'hooks/model'
import { useRequest } from 'hooks/request' import { useRequest } from 'hooks/request'
import Button from 'primevue/button'
import { BaseModel, Model, WithResolved } from 'types/typings'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
interface Props { interface Props {
@@ -58,7 +65,7 @@ const { remove, update } = useModels()
const editable = ref(false) const editable = ref(false)
const modelDetailUrl = `/model/${props.model.type}/${props.model.pathIndex}/${props.model.fullname}` const modelDetailUrl = genModelUrl(props.model)
const { data: extraInfo } = useRequest(modelDetailUrl, { const { data: extraInfo } = useRequest(modelDetailUrl, {
method: 'GET', method: 'GET',
}) })
@@ -71,7 +78,7 @@ const handleCancel = () => {
editable.value = false editable.value = false
} }
const handleSave = async (data: BaseModel) => { const handleSave = async (data: WithResolved<BaseModel>) => {
await update(modelContent.value, data) await update(modelContent.value, data)
editable.value = false editable.value = false
} }
@@ -84,7 +91,6 @@ const openModelPage = (url: string) => {
window.open(url, '_blank') window.open(url, '_blank')
} }
const { addModelNode, copyModelNode, loadPreviewWorkflow } = useModelNodeAction( const { addModelNode, copyModelNode, loadPreviewWorkflow } =
props.model, useModelNodeAction()
)
</script> </script>

View File

@@ -0,0 +1,271 @@
<template>
<div class="h-full px-4">
<div v-show="batchScanningStep === 0" class="h-full">
<div class="flex h-full items-center px-8">
<div class="h-20 w-full opacity-60">
<ProgressBar mode="indeterminate" style="height: 6px"></ProgressBar>
</div>
</div>
</div>
<Stepper
v-show="batchScanningStep === 1"
v-model:value="stepValue"
class="flex h-full flex-col"
linear
>
<StepList>
<Step value="1">{{ $t('selectModelType') }}</Step>
<Step value="2">{{ $t('selectSubdirectory') }}</Step>
<Step value="3">{{ $t('scanModelInformation') }}</Step>
</StepList>
<StepPanels class="flex-1 overflow-hidden">
<StepPanel value="1" class="h-full">
<div class="flex h-full flex-col overflow-hidden">
<ResponseScroll>
<div class="flex flex-wrap gap-4">
<Button
v-for="item in typeOptions"
:key="item.value"
:label="item.label"
@click="item.command"
></Button>
</div>
</ResponseScroll>
</div>
</StepPanel>
<StepPanel value="2" class="h-full">
<div class="flex h-full flex-col overflow-hidden">
<ResponseScroll class="flex-1">
<Tree
class="h-full"
v-model:selection-keys="selectedKey"
:value="pathOptions"
selectionMode="single"
:pt:nodeLabel:class="'text-ellipsis overflow-hidden'"
></Tree>
</ResponseScroll>
<div class="flex justify-between pt-6">
<Button
:label="$t('back')"
severity="secondary"
icon="pi pi-arrow-left"
@click="handleBackTypeSelect"
></Button>
<Button
:label="$t('next')"
icon="pi pi-arrow-right"
icon-pos="right"
:disabled="!enabledScan"
@click="handleConfirmSubdir"
></Button>
</div>
</div>
</StepPanel>
<StepPanel value="3" class="h-full">
<div class="overflow-hidden break-words py-8">
<div class="overflow-hidden px-8">
<div v-show="currentType === allType" class="text-center">
{{ $t('selectedAllPaths') }}
</div>
<div v-show="currentType !== allType" class="text-center">
<div class="pb-2">
{{ $t('selectedSpecialPath') }}
</div>
<div class="leading-5 opacity-60">
{{ selectedModelFolder }}
</div>
</div>
</div>
</div>
<div class="flex items-center justify-center gap-4">
<Button
v-for="item in scanActions"
:key="item.value"
:label="item.label"
:icon="item.icon"
@click="item.command.call(item)"
></Button>
</div>
</StepPanel>
</StepPanels>
</Stepper>
<div v-show="batchScanningStep === 2" class="h-full">
<div class="flex h-full items-center px-8">
<div class="h-20 w-full">
<div v-show="scanProgress > -1">
<ProgressBar :value="scanProgress">
{{ scanCompleteCount }} / {{ scanTotalCount }}
</ProgressBar>
</div>
<div v-show="scanProgress === -1" class="text-center">
<Button
severity="secondary"
:label="$t('back')"
icon="pi pi-arrow-left"
@click="handleBackTypeSelect"
></Button>
<span class="pl-2">{{ $t('noModelsInCurrentPath') }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ResponseScroll from 'components/ResponseScroll.vue'
import { configSetting } from 'hooks/config'
import { useModelFolder, useModels } from 'hooks/model'
import { request } from 'hooks/request'
import Button from 'primevue/button'
import ProgressBar from 'primevue/progressbar'
import Step from 'primevue/step'
import StepList from 'primevue/steplist'
import StepPanel from 'primevue/steppanel'
import StepPanels from 'primevue/steppanels'
import Stepper from 'primevue/stepper'
import Tree from 'primevue/tree'
import { api, app } from 'scripts/comfyAPI'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const stepValue = ref('1')
const { folders } = useModels()
const allType = 'All'
const currentType = ref<string>()
const typeOptions = computed(() => {
const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
configSetting.excludeScanTypes,
)
const customBlackList =
excludeScanTypes
?.split(',')
.map((type) => type.trim())
.filter(Boolean) ?? []
return [
allType,
...Object.keys(folders.value).filter(
(folder) => !customBlackList.includes(folder),
),
].map((type) => {
return {
label: type,
value: type,
command: () => {
currentType.value = type
stepValue.value = currentType.value === allType ? '3' : '2'
},
}
})
})
const { pathOptions } = useModelFolder({ type: currentType })
const selectedModelFolder = ref<string>()
const selectedKey = computed({
get: () => {
const key = selectedModelFolder.value
return key ? { [key]: true } : {}
},
set: (val) => {
const key = Object.keys(val)[0]
selectedModelFolder.value = key
},
})
const enabledScan = computed(() => {
return currentType.value === allType || !!selectedModelFolder.value
})
const handleBackTypeSelect = () => {
selectedModelFolder.value = undefined
currentType.value = undefined
stepValue.value = '1'
batchScanningStep.value = 1
}
const handleConfirmSubdir = () => {
stepValue.value = '3'
}
const batchScanningStep = ref(0)
const scanModelsList = ref<Record<string, boolean>>({})
const scanTotalCount = computed(() => {
return Object.keys(scanModelsList.value).length
})
const scanCompleteCount = computed(() => {
return Object.keys(scanModelsList.value).filter(
(key) => scanModelsList.value[key],
).length
})
const scanProgress = computed(() => {
if (scanTotalCount.value === 0) {
return -1
}
const progress = scanCompleteCount.value / scanTotalCount.value
return Number(progress.toFixed(4)) * 100
})
const handleScanModelInformation = async function () {
batchScanningStep.value = 0
const mode = this.value
const path = selectedModelFolder.value
try {
const result = await request('/model-info/scan', {
method: 'POST',
body: JSON.stringify({ mode, path }),
})
scanModelsList.value = result?.models ?? {}
batchScanningStep.value = 2
} catch {
batchScanningStep.value = 1
}
}
const scanActions = ref([
{
value: 'back',
label: t('back'),
icon: 'pi pi-arrow-left',
command: () => {
stepValue.value = currentType.value === allType ? '1' : '2'
},
},
{
value: 'full',
label: t('scanFullInformation'),
command: handleScanModelInformation,
},
{
value: 'diff',
label: t('scanMissInformation'),
command: handleScanModelInformation,
},
])
const refreshTaskContent = async () => {
const result = await request('/model-info/scan')
const listContent = result?.models ?? {}
scanModelsList.value = listContent
batchScanningStep.value = Object.keys(listContent).length ? 2 : 1
}
onMounted(() => {
refreshTaskContent()
api.addEventListener('update_scan_information_task', (event) => {
const content = event.detail
scanModelsList.value = content.models
})
})
</script>

View File

@@ -0,0 +1,274 @@
<template>
<div class="h-full px-4">
<!-- <div v-show="batchScanningStep === 0" class="h-full">
<div class="flex h-full items-center px-8">
<div class="h-20 w-full opacity-60">
<ProgressBar mode="indeterminate" style="height: 6px"></ProgressBar>
</div>
</div>
</div> -->
<Stepper v-model:value="stepValue" class="flex h-full flex-col" linear>
<StepList>
<Step :value="1">{{ $t('selectModelType') }}</Step>
<Step :value="2">{{ $t('selectSubdirectory') }}</Step>
<Step :value="3">{{ $t('chooseFile') }}</Step>
</StepList>
<StepPanels class="flex-1 overflow-hidden">
<StepPanel :value="1" class="h-full">
<div class="flex h-full flex-col overflow-hidden">
<ResponseScroll>
<div class="flex flex-wrap gap-4">
<Button
v-for="item in typeOptions"
:key="item.value"
:label="item.label"
@click="item.command"
></Button>
</div>
</ResponseScroll>
</div>
</StepPanel>
<StepPanel :value="2" class="h-full">
<div class="flex h-full flex-col overflow-hidden">
<ResponseScroll class="flex-1">
<Tree
class="h-full"
v-model:selection-keys="selectedKey"
:value="pathOptions"
selectionMode="single"
:pt:nodeLabel:class="'text-ellipsis overflow-hidden'"
></Tree>
</ResponseScroll>
<div class="flex justify-between pt-6">
<Button
:label="$t('back')"
severity="secondary"
icon="pi pi-arrow-left"
@click="handleBackTypeSelect"
></Button>
<Button
:label="$t('next')"
icon="pi pi-arrow-right"
icon-pos="right"
:disabled="!enabledUpload"
@click="handleConfirmSubdir"
></Button>
</div>
</div>
</StepPanel>
<StepPanel :value="3" class="h-full">
<div class="flex h-full flex-col items-center justify-center">
<template v-if="showUploadProgress">
<div class="w-4/5">
<ProgressBar
:value="uploadProgress"
:pt:value:style="{ transition: 'width .1s linear' }"
></ProgressBar>
</div>
</template>
<template v-else>
<div class="overflow-hidden break-words py-8">
<div class="overflow-hidden px-8">
<div class="text-center">
<div class="pb-2">
{{ $t('selectedSpecialPath') }}
</div>
<div class="leading-5 opacity-60">
{{ selectedModelFolder }}
</div>
</div>
</div>
</div>
<div class="flex items-center justify-center gap-4">
<Button
v-for="item in uploadActions"
:key="item.value"
:label="item.label"
:icon="item.icon"
@click="item.command.call(item)"
></Button>
</div>
</template>
<div class="h-1/4"></div>
</div>
</StepPanel>
</StepPanels>
</Stepper>
</div>
</template>
<script setup lang="ts">
import ResponseScroll from 'components/ResponseScroll.vue'
import { configSetting } from 'hooks/config'
import { useModelFolder, useModels } from 'hooks/model'
import { request } from 'hooks/request'
import { useToast } from 'hooks/toast'
import Button from 'primevue/button'
import ProgressBar from 'primevue/progressbar'
import Step from 'primevue/step'
import StepList from 'primevue/steplist'
import StepPanel from 'primevue/steppanel'
import StepPanels from 'primevue/steppanels'
import Stepper from 'primevue/stepper'
import Tree from 'primevue/tree'
import { api, app } from 'scripts/comfyAPI'
import { computed, onMounted, onUnmounted, ref, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { toast } = useToast()
const stepValue = ref(1)
const { folders } = useModels()
const currentType = ref<string>()
const typeOptions = computed(() => {
const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
configSetting.excludeScanTypes,
)
const customBlackList =
excludeScanTypes
?.split(',')
.map((type) => type.trim())
.filter(Boolean) ?? []
return Object.keys(folders.value)
.filter((folder) => !customBlackList.includes(folder))
.map((type) => {
return {
label: type,
value: type,
command: () => {
currentType.value = type
stepValue.value++
},
}
})
})
const { pathOptions } = useModelFolder({ type: currentType })
const selectedModelFolder = ref<string>()
const selectedKey = computed({
get: () => {
const key = selectedModelFolder.value
return key ? { [key]: true } : {}
},
set: (val) => {
const key = Object.keys(val)[0]
selectedModelFolder.value = key
},
})
const enabledUpload = computed(() => {
return !!selectedModelFolder.value
})
const handleBackTypeSelect = () => {
selectedModelFolder.value = undefined
currentType.value = undefined
stepValue.value--
}
const handleConfirmSubdir = () => {
stepValue.value++
}
const uploadTotalSize = ref<number>()
const uploadSize = ref<number>()
const uploadProgress = computed(() => {
const total = toValue(uploadTotalSize)
const size = toValue(uploadSize)
if (typeof total === 'number' && typeof size === 'number') {
return Math.floor((size / total) * 100)
}
return undefined
})
const showUploadProgress = computed(() => {
return typeof uploadProgress.value !== 'undefined'
})
const uploadActions = ref([
{
value: 'back',
label: t('back'),
icon: 'pi pi-arrow-left',
command: () => {
stepValue.value--
},
},
{
value: 'full',
label: t('chooseFile'),
command: () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = supportedExtensions.value.join(',')
input.onchange = async () => {
const files = input.files
const file = files?.item(0)
if (!file) {
return
}
try {
uploadTotalSize.value = file.size
uploadSize.value = 0
const body = new FormData()
body.append('folder', toValue(selectedModelFolder)!)
body.append('file', file)
await request('/upload', {
method: 'POST',
body: body,
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Error',
detail: error.message,
life: 5000,
})
}
}
input.click()
},
},
])
const supportedExtensions = ref([])
const fetchSupportedExtensions = async () => {
try {
const result = await request('/supported-extensions')
supportedExtensions.value = result ?? []
} catch (error) {
toast.add({
severity: 'error',
summary: 'Error',
detail: error.message,
life: 5000,
})
}
}
const update_process = (event: CustomEvent) => {
const detail = event.detail
uploadSize.value = detail.uploaded_size
}
onMounted(() => {
fetchSupportedExtensions()
api.addEventListener('update_upload_progress', update_process)
})
onUnmounted(() => {
api.removeEventListener('update_upload_progress', update_process)
})
</script>

View File

@@ -1,17 +1,11 @@
<template> <template>
<ResponseDialog <ResponseDialog
v-for="(item, index) in stack" v-for="(item, index) in stack"
v-model:visible="item.visible"
:key="item.key" :key="item.key"
:keep-alive="item.keepAlive" v-model:visible="item.visible"
:default-size="item.defaultSize" v-bind="omitProps(item)"
:default-mobile-size="item.defaultMobileSize" :auto-z-index="false"
:resize-allow="item.resizeAllow" :pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
:min-width="item.minWidth"
:max-width="item.maxWidth"
:min-height="item.minHeight"
:max-height="item.maxHeight"
:z-index="index"
:pt:root:onMousedown="() => rise(item)" :pt:root:onMousedown="() => rise(item)"
@hide="() => close(item)" @hide="() => close(item)"
> >
@@ -21,6 +15,7 @@
<div class="p-dialog-header-actions"> <div class="p-dialog-header-actions">
<Button <Button
v-for="action in item.headerButtons" v-for="action in item.headerButtons"
:key="action.key"
severity="secondary" severity="secondary"
:text="true" :text="true"
:rounded="true" :rounded="true"
@@ -35,12 +30,34 @@
<component :is="item.content" v-bind="item.contentProps"></component> <component :is="item.content" v-bind="item.contentProps"></component>
</template> </template>
</ResponseDialog> </ResponseDialog>
<Dialog :visible="true" :pt:mask:style="{ display: 'none' }"></Dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Button from 'primevue/button'
import ResponseDialog from 'components/ResponseDialog.vue' import ResponseDialog from 'components/ResponseDialog.vue'
import { useDialog } from 'hooks/dialog' import { type DialogItem, useDialog } from 'hooks/dialog'
import { omit } from 'lodash'
import Button from 'primevue/button'
import { usePrimeVue } from 'primevue/config'
import Dialog from 'primevue/dialog'
import { computed } from 'vue'
const { stack, rise, close } = useDialog() const { stack, rise, close } = useDialog()
const { config } = usePrimeVue()
const baseZIndex = computed(() => {
return config.zIndex?.modal ?? 1100
})
const omitProps = (item: DialogItem) => {
return omit(item, [
'key',
'visible',
'title',
'headerButtons',
'content',
'contentProps',
])
}
</script> </script>

View File

@@ -7,16 +7,63 @@
</template> </template>
</ResponseSelect> </ResponseSelect>
<ResponseSelect class="w-full" v-model="pathIndex" :items="pathOptions"> <div class="flex gap-2 overflow-hidden">
</ResponseSelect> <div class="flex-1 overflow-hidden rounded bg-gray-500/30">
<div class="flex h-full items-center justify-end">
<span class="overflow-hidden text-ellipsis whitespace-nowrap px-2">
{{ renderedModelFolder }}
</span>
</div>
</div>
<Button
icon="pi pi-folder"
:disabled="!type"
@click="handleSelectFolder"
></Button>
<Dialog
v-model:visible="folderSelectVisible"
:header="$t('folder')"
:auto-z-index="false"
:pt:mask:style="{ zIndex }"
:pt:root:style="{ height: '50vh', maxWidth: '50vw' }"
pt:content:class="flex-1"
>
<div class="flex h-full flex-col overflow-hidden">
<div class="flex-1 overflow-hidden">
<ResponseScroll>
<Tree
class="h-full"
v-model:selection-keys="modelFolder"
:value="pathOptions"
selectionMode="single"
:pt:nodeLabel:class="'text-ellipsis overflow-hidden'"
></Tree>
</ResponseScroll>
</div>
<div class="flex justify-end gap-2">
<Button
:label="$t('cancel')"
severity="secondary"
@click="handleCancelSelectFolder"
></Button>
<Button
:label="$t('select')"
@click="handleConfirmSelectFolder"
></Button>
</div>
</div>
</Dialog>
</div>
<ResponseInput <ResponseInput
v-model.trim="basename" v-model.trim.valid="basename"
class="-mr-2 text-right" class="-mr-2 text-right"
update-trigger="blur" update-trigger="blur"
:validate="validateBasename"
> >
<template #suffix> <template #suffix>
<span class="pi-inputicon"> <span class="text-base opacity-60">
{{ extension }} {{ extension }}
</span> </span>
</template> </template>
@@ -29,11 +76,25 @@
<col /> <col />
</colgroup> </colgroup>
<tbody> <tbody>
<tr v-for="item in information" class="h-8 whitespace-nowrap border-b"> <tr
v-for="item in information"
:key="item.key"
class="h-8 whitespace-nowrap border-b"
>
<td class="border-r bg-gray-300 px-4 dark:bg-gray-800"> <td class="border-r bg-gray-300 px-4 dark:bg-gray-800">
{{ $t(`info.${item.key}`) }} {{ $t(`info.${item.key}`) }}
</td> </td>
<td class="overflow-hidden text-ellipsis break-all px-4"> <td
class="overflow-hidden text-ellipsis break-all px-4"
v-tooltip.top="{
value: item.display,
disabled: !['pathIndex', 'basename'].includes(item.key),
autoHide: false,
showDelay: 800,
hideDelay: 300,
pt: { root: { style: { zIndex: 2100, maxWidth: '32rem' } } },
}"
>
{{ item.display }} {{ item.display }}
</td> </td>
</tr> </tr>
@@ -44,16 +105,34 @@
<script setup lang="ts"> <script setup lang="ts">
import ResponseInput from 'components/ResponseInput.vue' import ResponseInput from 'components/ResponseInput.vue'
import ResponseScroll from 'components/ResponseScroll.vue'
import ResponseSelect from 'components/ResponseSelect.vue' import ResponseSelect from 'components/ResponseSelect.vue'
import { useConfig } from 'hooks/config' import { useDialog } from 'hooks/dialog'
import { useModelBaseInfo } from 'hooks/model' import { useModelBaseInfo, useModelFolder } from 'hooks/model'
import { computed } from 'vue' import { useToast } from 'hooks/toast'
import Button from 'primevue/button'
import { usePrimeVue } from 'primevue/config'
import Dialog from 'primevue/dialog'
import Tree from 'primevue/tree'
import { computed, ref, watch } from 'vue'
const editable = defineModel<boolean>('editable') const editable = defineModel<boolean>('editable')
const { modelFolders } = useConfig() const { toast } = useToast()
const { baseInfo, pathIndex, basename, extension, type } = useModelBaseInfo() const {
baseInfo,
pathIndex,
subFolder,
basename,
extension,
type,
modelFolders,
} = useModelBaseInfo()
watch(type, () => {
subFolder.value = ''
})
const typeOptions = computed(() => { const typeOptions = computed(() => {
return Object.keys(modelFolders.value).map((curr) => { return Object.keys(modelFolders.value).map((curr) => {
@@ -68,25 +147,104 @@ const typeOptions = computed(() => {
}) })
}) })
const pathOptions = computed(() => {
return (modelFolders.value[type.value] ?? []).map((folder, index) => {
return {
value: index,
label: folder,
command: () => {
pathIndex.value = index
},
}
})
})
const information = computed(() => { const information = computed(() => {
return Object.values(baseInfo.value).filter((row) => { return Object.values(baseInfo.value).filter((row) => {
if (editable.value) { if (editable.value) {
const hiddenKeys = ['fullname', 'pathIndex'] const hiddenKeys = ['basename', 'pathIndex']
return !hiddenKeys.includes(row.key) return !hiddenKeys.includes(row.key)
} }
return true return true
}) })
}) })
const validateBasename = (val: string | undefined) => {
if (!val) {
toast.add({
severity: 'error',
detail: 'basename is required',
life: 3000,
})
return false
}
const invalidChart = /[\\/:*?"<>|]/
if (invalidChart.test(val)) {
toast.add({
severity: 'error',
detail: 'basename is invalid, \\/:*?"<>|',
life: 3000,
})
return false
}
return true
}
const folderSelectVisible = ref(false)
const { stack } = useDialog()
const { config } = usePrimeVue()
const zIndex = computed(() => {
const baseZIndex = config.zIndex?.modal ?? 1100
return baseZIndex + stack.value.length + 1
})
const handleSelectFolder = () => {
if (!type.value) {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Please select model type first',
life: 5000,
})
return
}
folderSelectVisible.value = true
}
const { pathOptions } = useModelFolder({ type })
const selectedModelFolder = ref<string>()
const modelFolder = computed({
get: () => {
const folderPath = baseInfo.value.pathIndex.display
const selectedKey = selectedModelFolder.value ?? folderPath
return { [selectedKey]: true }
},
set: (val) => {
const folderPath = Object.keys(val)[0]
selectedModelFolder.value = folderPath
},
})
const renderedModelFolder = computed(() => {
return baseInfo.value.pathIndex?.display
})
const handleCancelSelectFolder = () => {
selectedModelFolder.value = undefined
folderSelectVisible.value = false
}
const handleConfirmSelectFolder = () => {
const folderPath = Object.keys(modelFolder.value)[0]
const folders = modelFolders.value[type.value]
pathIndex.value = folders.findIndex((item) => folderPath.includes(item))
if (pathIndex.value < 0) {
toast.add({
severity: 'error',
detail: 'Folder not found',
life: 3000,
})
return
}
const prefixPath = folders[pathIndex.value]
subFolder.value = folderPath.replace(prefixPath, '')
if (subFolder.value.startsWith('/')) {
subFolder.value = subFolder.value.replace('/', '')
}
selectedModelFolder.value = undefined
folderSelectVisible.value = false
}
</script> </script>

View File

@@ -1,103 +1,102 @@
<template> <template>
<div <div
class="group/card relative w-full cursor-pointer select-none preview-aspect" ref="container"
@click.stop="openDetailDialog" class="relative h-full select-none rounded-lg hover:bg-gray-500/40"
> >
<div class="h-full overflow-hidden rounded-lg"> <div data-card-main class="flex h-full w-full flex-col">
<div class="h-full bg-gray-500 duration-300 group-hover/card:scale-110"> <div data-card-preview class="flex-1 overflow-hidden">
<img class="h-full w-full object-cover" :src="preview" /> <div v-if="model.isFolder" class="h-full w-full">
<svg
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
>
<path
d="M853.333333 256H469.333333l-85.333333-85.333333H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v170.666667h853.333334v-85.333334c0-46.933333-38.4-85.333333-85.333334-85.333333z"
fill="#FFA000"
></path>
<path
d="M853.333333 256H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v426.666667c0 46.933333 38.4 85.333333 85.333334 85.333333h682.666666c46.933333 0 85.333333-38.4 85.333334-85.333333V341.333333c0-46.933333-38.4-85.333333-85.333334-85.333333z"
fill="#FFCA28"
></path>
</svg>
</div>
<div
v-else-if="isVideoUrl(preview)"
class="h-full w-full p-1 hover:p-0"
>
<PreviewVideo :src="preview" />
</div>
<div v-else class="h-full w-full p-1 hover:p-0">
<img class="h-full w-full rounded-lg object-cover" :src="preview" />
</div>
</div> </div>
<slot name="name">
<div class="flex justify-center overflow-hidden px-1">
<span class="overflow-hidden text-ellipsis whitespace-nowrap">
{{ model.basename }}
</span>
</div>
</slot>
</div> </div>
<div <div
v-if="!model.isFolder"
data-draggable-overlay data-draggable-overlay
class="absolute left-0 top-0 h-full w-full" class="absolute left-0 top-0 h-full w-full"
draggable="true" draggable="true"
@dragend.stop="dragToAddModelNode" @dragend.stop="dragToAddModelNode(model, $event)"
></div> ></div>
<div class="pointer-events-none absolute left-0 top-0 h-full w-full p-4"> <div
<div class="relative h-full w-full text-white"> v-if="!model.isFolder"
<div class="absolute bottom-0 left-0"> data-mode-type
<div class="drop-shadow-[0px_2px_2px_rgba(0,0,0,0.75)]"> class="pointer-events-none absolute left-2 top-2"
<div class="line-clamp-3 break-all text-2xl font-bold @lg:text-lg"> :style="{
{{ model.basename }} transform: `scale(${typeLabelScale})`,
</div> transformOrigin: 'left top',
</div> }"
</div> >
<div class="rounded-full bg-black/50 px-3 py-1">
<div class="absolute left-0 top-0 w-full"> <span>{{ model.type }}</span>
<div class="flex flex-row items-start justify-between">
<div class="flex items-center rounded-full bg-black/30 px-3 py-2">
<div class="font-bold @lg:text-xs">
{{ model.type }}
</div>
</div>
<div class="opacity-0 duration-300 group-hover/card:opacity-100">
<div class="flex flex-col gap-4 *:pointer-events-auto">
<Button
icon="pi pi-plus"
severity="secondary"
rounded
@click.stop="addModelNode"
></Button>
<Button
icon="pi pi-copy"
severity="secondary"
rounded
@click.stop="copyModelNode"
></Button>
<Button
v-show="model.preview"
icon="pi pi-file-import"
severity="secondary"
rounded
@click.stop="loadPreviewWorkflow"
></Button>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<slot name="extra"></slot>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import DialogModelDetail from 'components/DialogModelDetail.vue' import { useElementSize } from '@vueuse/core'
import Button from 'primevue/button' import PreviewVideo from 'components/PreviewVideo.vue'
import { genModelKey } from 'utils/model'
import { computed } from 'vue'
import { useModelNodeAction } from 'hooks/model' import { useModelNodeAction } from 'hooks/model'
import { useDialog } from 'hooks/dialog' import { BaseModel } from 'types/typings'
import { isVideoUrl } from 'utils/media'
import { computed, ref } from 'vue'
interface Props { interface Props {
model: Model model: BaseModel
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const dialog = useDialog()
const openDetailDialog = () => {
const basename = props.model.fullname.split('/').pop()!
const filename = basename.replace(props.model.extension, '')
dialog.open({
key: genModelKey(props.model),
title: filename,
content: DialogModelDetail,
contentProps: { model: props.model },
})
}
const preview = computed(() => const preview = computed(() =>
Array.isArray(props.model.preview) Array.isArray(props.model.preview)
? props.model.preview[0] ? props.model.preview[0]
: props.model.preview, : props.model.preview,
) )
const { addModelNode, dragToAddModelNode, copyModelNode, loadPreviewWorkflow } = const container = ref<HTMLElement | null>(null)
useModelNodeAction(props.model)
const { width } = useElementSize(container)
const typeLabelScale = computed(() => {
return width.value / 200
})
const { dragToAddModelNode } = useModelNodeAction()
</script> </script>

View File

@@ -1,18 +1,23 @@
<template> <template>
<form <form
class="@container" ref="container"
@submit.prevent="handleSubmit" @submit.prevent="handleSubmit"
@reset.prevent="handleReset" @reset.prevent="handleReset"
> >
<div class="mx-auto w-full max-w-[50rem]"> <div class="mx-auto w-full max-w-[50rem]">
<div class="relative flex flex-col gap-4 overflow-hidden @xl:flex-row"> <div
:class="[
'relative flex gap-4 overflow-hidden',
$xl('flex-row', 'flex-col'),
]"
>
<ModelPreview <ModelPreview
class="shrink-0" class="shrink-0"
v-model:editable="editable" v-model:editable="editable"
></ModelPreview> ></ModelPreview>
<div class="flex flex-col gap-4 overflow-hidden"> <div class="flex flex-col gap-4 overflow-hidden">
<div class="flex items-center justify-end gap-4"> <div class="flex h-10 items-center justify-end gap-4">
<slot name="action" :metadata="formInstance.metadata.value"></slot> <slot name="action" :metadata="formInstance.metadata.value"></slot>
</div> </div>
@@ -39,15 +44,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ModelPreview from 'components/ModelPreview.vue'
import ModelBaseInfo from 'components/ModelBaseInfo.vue' import ModelBaseInfo from 'components/ModelBaseInfo.vue'
import ModelDescription from 'components/ModelDescription.vue' import ModelDescription from 'components/ModelDescription.vue'
import ModelMetadata from 'components/ModelMetadata.vue' import ModelMetadata from 'components/ModelMetadata.vue'
import Tab from 'primevue/tab' import ModelPreview from 'components/ModelPreview.vue'
import Tabs from 'primevue/tabs' import { useContainerQueries } from 'hooks/container'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import { import {
useModelBaseInfoEditor, useModelBaseInfoEditor,
useModelDescriptionEditor, useModelDescriptionEditor,
@@ -55,8 +56,14 @@ import {
useModelMetadataEditor, useModelMetadataEditor,
useModelPreviewEditor, useModelPreviewEditor,
} from 'hooks/model' } from 'hooks/model'
import { toRaw, watch } from 'vue'
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { BaseModel, WithResolved } from 'types/typings'
import { ref, toRaw, watch } from 'vue'
interface Props { interface Props {
model: BaseModel model: BaseModel
@@ -66,7 +73,7 @@ const props = defineProps<Props>()
const editable = defineModel<boolean>('editable') const editable = defineModel<boolean>('editable')
const emits = defineEmits<{ const emits = defineEmits<{
submit: [formData: BaseModel] submit: [formData: WithResolved<BaseModel>]
reset: [] reset: []
}>() }>()
@@ -93,4 +100,7 @@ watch(
handleReset() handleReset()
}, },
) )
const container = ref<HTMLElement | null>(null)
const { $xl } = useContainerQueries(container)
</script> </script>

View File

@@ -26,7 +26,7 @@
<div class="relative"> <div class="relative">
<div <div
v-if="renderedDescription" v-if="renderedDescription"
class="markdown-it" :class="$style['markdown-body']"
v-html="renderedDescription" v-html="renderedDescription"
></div> ></div>
<div v-else class="flex flex-col items-center gap-2 py-5"> <div v-else class="flex flex-col items-center gap-2 py-5">
@@ -89,3 +89,146 @@ const exitEditMode = () => {
active.value = false active.value = false
} }
</script> </script>
<style lang="less" module>
.markdown-body {
font-family: theme('fontFamily.sans');
font-size: theme('fontSize.base');
line-height: theme('lineHeight.relaxed');
word-break: break-word;
margin: 0;
&::before {
display: table;
content: '';
}
&::after {
display: table;
content: '';
clear: both;
}
> *:first-child {
margin-top: 0 !important;
}
> *:last-child {
margin-bottom: 0 !important;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 1.5em;
margin-bottom: 1em;
font-weight: 600;
line-height: 1.25;
}
h1 {
font-size: 2em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--p-surface-700);
}
h2 {
font-size: 1.5em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--p-surface-700);
}
h3 {
font-size: 1.25em;
}
h4 {
font-size: 1em;
}
h5 {
font-size: 0.875em;
}
h6 {
font-size: 0.85em;
color: var(--p-surface-500);
}
a {
color: #1e8bc3;
text-decoration: none;
word-break: break-all;
}
a:hover {
text-decoration: underline;
}
p,
blockquote,
ul,
ol,
dl,
table,
pre,
details {
margin-top: 0;
margin-bottom: 1em;
}
p img {
width: 100%;
height: 100%;
object-fit: cover;
}
ul,
ol {
padding-left: 2em;
}
li {
margin: 0.5em 0;
}
blockquote {
padding: 0px 1em;
border-left: 0.25em solid var(--p-surface-500);
color: var(--p-surface-500);
margin: 1em 0;
}
blockquote > *:first-child {
margin-top: 0;
}
blockquote > *:last-child {
margin-bottom: 0;
}
pre {
font-size: 85%;
border-radius: 6px;
padding: 8px 16px;
overflow-x: auto;
background: var(--p-dialog-background);
filter: invert(10%);
}
pre code,
pre tt {
display: inline;
padding: 0;
margin: 0;
overflow: visible;
line-height: inherit;
word-wrap: normal;
background-color: transparent;
border: 0;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<table v-if="dataSource.length" class="w-full border-collapse border"> <table v-if="dataSource.length" class="w-full border-collapse border">
<tbody> <tbody>
<tr v-for="item in dataSource" class="h-8 border-b"> <tr v-for="item in dataSource" :key="item.key" class="h-8 border-b">
<td class="border-r bg-gray-300 px-4 dark:bg-gray-800"> <td class="border-r bg-gray-300 px-4 dark:bg-gray-800">
{{ item.key }} {{ item.key }}
</td> </td>

View File

@@ -1,17 +1,28 @@
<template> <template>
<div <div class="flex flex-col gap-4">
class="flex flex-col gap-4"
:style="{ ['--preview-width']: `${cardWidth}px` }"
>
<div> <div>
<div <div
:class="[ class="relative mx-auto w-full overflow-hidden rounded-lg preview-aspect"
'relative mx-auto w-full', :style="$sm({ width: `${cardWidth}px` })"
'@sm:w-[var(--preview-width)]',
'overflow-hidden rounded-lg preview-aspect',
]"
> >
<ResponseImage :src="preview" :error="noPreviewContent"></ResponseImage> <div
v-if="
preview &&
isVideoUrl(
preview,
currentType === 'local' ? localContentType : undefined,
)
"
class="h-full w-full p-1 hover:p-0"
>
<PreviewVideo :src="preview" />
</div>
<ResponseImage
v-else
:src="preview"
:error="noPreviewContent"
></ResponseImage>
<Carousel <Carousel
v-if="defaultContent.length > 1" v-if="defaultContent.length > 1"
@@ -37,7 +48,14 @@
}" }"
> >
<template #item="slotProps"> <template #item="slotProps">
<div
v-if="isVideoUrl(slotProps.data)"
class="h-full w-full p-1 hover:p-0"
>
<PreviewVideo :src="slotProps.data" />
</div>
<ResponseImage <ResponseImage
v-else
:src="slotProps.data" :src="slotProps.data"
:error="noPreviewContent" :error="noPreviewContent"
></ResponseImage> ></ResponseImage>
@@ -50,9 +68,8 @@
<div class="h-10"></div> <div class="h-10"></div>
<div <div
:class="[ :class="[
'flex h-10 items-center gap-4', 'absolute flex h-10 items-center gap-4',
'absolute left-1/2 -translate-x-1/2', $xl('left-0 translate-x-0', 'left-1/2 -translate-x-1/2'),
'@xl:left-0 @xl:translate-x-0',
]" ]"
> >
<Button <Button
@@ -88,13 +105,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import PreviewVideo from 'components/PreviewVideo.vue'
import ResponseFileUpload from 'components/ResponseFileUpload.vue'
import ResponseImage from 'components/ResponseImage.vue' import ResponseImage from 'components/ResponseImage.vue'
import ResponseInput from 'components/ResponseInput.vue' import ResponseInput from 'components/ResponseInput.vue'
import ResponseFileUpload from 'components/ResponseFileUpload.vue' import { useConfig } from 'hooks/config'
import { useContainerQueries } from 'hooks/container'
import { useModelPreview } from 'hooks/model'
import Button from 'primevue/button' import Button from 'primevue/button'
import Carousel from 'primevue/carousel' import Carousel from 'primevue/carousel'
import { useModelPreview } from 'hooks/model' import { isVideoUrl } from 'utils/media'
import { useConfig } from 'hooks/config'
const editable = defineModel<boolean>('editable') const editable = defineModel<boolean>('editable')
const { cardWidth } = useConfig() const { cardWidth } = useConfig()
@@ -108,5 +128,8 @@ const {
networkContent, networkContent,
updateLocalContent, updateLocalContent,
noPreviewContent, noPreviewContent,
localContentType,
} = useModelPreview() } = useModelPreview()
const { $sm, $xl } = useContainerQueries()
</script> </script>

View File

@@ -0,0 +1,25 @@
<template>
<video
class="h-full w-full object-cover"
playsinline
autoplay
loop
muted
disablepictureinpicture
:preload="preload"
>
<source :src="src" type="video/mp4" />
<source :src="src" type="video/webm" />
</video>
</template>
<script setup lang="ts">
interface Props {
src: string
preload?: 'none' | 'metadata' | 'auto'
}
withDefaults(defineProps<Props>(), {
preload: 'metadata',
})
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div ref="container" class="breadcrumb-container">
<div v-if="firstItem" class="breadcrumb-item">
<span class="breadcrumb-label" @click="firstItem.onClick">
<i v-if="firstItem.icon" :class="firstItem.icon"></i>
<i v-else class="breadcrumb-name">{{ firstItem.name }}</i>
</span>
<ResponseSelect
v-if="!!firstItem.children?.length"
:items="firstItem.children"
>
<template #target="{ toggle, overlayVisible }">
<span class="breadcrumb-split" @click="toggle">
<i
class="pi pi-angle-right transition-all"
:style="{ transform: overlayVisible ? 'rotate(90deg)' : '' }"
></i>
</span>
</template>
</ResponseSelect>
</div>
<div v-if="!!renderedItems.collapsed.length" class="breadcrumb-item">
<ResponseSelect :items="renderedItems.collapsed">
<template #target="{ toggle }">
<span class="breadcrumb-split" @click="toggle">
<i class="pi pi-ellipsis-h"></i>
</span>
</template>
</ResponseSelect>
</div>
<div
v-for="(item, index) in renderedItems.tail"
:key="`${index}-${item.name}`"
class="breadcrumb-item"
>
<span class="breadcrumb-label" @click="item.onClick">
<i v-if="item.icon" :class="item.icon"></i>
<i v-else class="breadcrumb-name">{{ item.name }}</i>
</span>
<ResponseSelect v-if="!!item.children?.length" :items="item.children">
<template #target="{ toggle, overlayVisible }">
<span class="breadcrumb-split" @click="toggle">
<i
class="pi pi-angle-right transition-all"
:style="{ transform: overlayVisible ? 'rotate(90deg)' : '' }"
></i>
</span>
</template>
</ResponseSelect>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import ResponseSelect from 'components/ResponseSelect.vue'
import { SelectOptions } from 'types/typings'
import { computed, ref } from 'vue'
interface BreadcrumbItem {
name: string
icon?: string
onClick?: () => void
children?: SelectOptions[]
}
interface Props {
items: BreadcrumbItem[]
}
const props = defineProps<Props>()
const container = ref<HTMLElement | null>(null)
const { width } = useElementSize(container)
const firstItem = computed<BreadcrumbItem | null>(() => {
return props.items[0]
})
const renderedItems = computed(() => {
const [, ...items] = props.items
const lastItem = items.pop()
items.reverse()
const separatorWidth = 32
const calculateItemWidth = (item: BreadcrumbItem | undefined) => {
if (!item) {
return 0
}
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')!
context.font = '16px Arial'
const text = item.name
return context.measureText(text).width + 16 + separatorWidth
}
const firstItemEL = container.value?.querySelector('div')
const firstItemWidth = firstItemEL?.getBoundingClientRect().width ?? 0
const lastItemWidth = calculateItemWidth(lastItem)
const collapseWidth = separatorWidth
let totalWidth = firstItemWidth + collapseWidth + lastItemWidth
const containerWidth = width.value - 18
const collapsed: SelectOptions[] = []
const tail: BreadcrumbItem[] = []
for (const item of items) {
const itemWidth = calculateItemWidth(item)
totalWidth += itemWidth
if (totalWidth < containerWidth) {
tail.unshift(item)
} else {
collapsed.unshift({
value: item.name,
label: item.name,
command: () => {
item.onClick?.()
},
})
}
}
if (lastItem) {
tail.push(lastItem)
}
return { collapsed, tail }
})
</script>
<style scoped>
.breadcrumb-container {
@apply flex overflow-hidden rounded-lg bg-gray-500/30 px-2 py-1;
}
.breadcrumb-item {
@apply flex h-full overflow-hidden rounded border border-transparent hover:border-gray-500/30;
}
.breadcrumb-item:nth-of-type(-n + 2) {
@apply flex-shrink-0;
}
.breadcrumb-label {
@apply flex h-full min-w-8 items-center overflow-hidden px-2 hover:bg-gray-500/30;
}
.breadcrumb-name {
@apply overflow-hidden text-ellipsis whitespace-nowrap not-italic;
}
.breadcrumb-split {
@apply flex aspect-square h-full min-w-8 items-center justify-center hover:bg-gray-500/30;
}
</style>

View File

@@ -3,13 +3,14 @@
ref="dialogRef" ref="dialogRef"
:visible="true" :visible="true"
@update:visible="updateVisible" @update:visible="updateVisible"
:modal="modal"
:close-on-escape="false" :close-on-escape="false"
:maximizable="!isMobile" :maximizable="!isMobile"
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center" maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center" minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
:pt:mask:class="['group', { open: visible }]" :pt:mask:class="['group', { open: visible }]"
pt:root:class="max-h-full group-[:not(.open)]:!hidden" :pt:root:class="['max-h-full group-[:not(.open)]:!hidden', $style.dialog]"
pt:content:class="px-0 flex-1" pt:content:class="p-0 flex-1"
:base-z-index="1000" :base-z-index="1000"
:auto-z-index="isNil(zIndex)" :auto-z-index="isNil(zIndex)"
:pt:mask:style="isNil(zIndex) ? {} : { zIndex: 1000 + zIndex }" :pt:mask:style="isNil(zIndex) ? {} : { zIndex: 1000 + zIndex }"
@@ -75,9 +76,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Dialog from 'primevue/dialog'
import { clamp, isNil } from 'lodash'
import { useConfig } from 'hooks/config' import { useConfig } from 'hooks/config'
import { clamp, isNil } from 'lodash'
import Dialog from 'primevue/dialog'
import { ContainerPosition, ContainerSize } from 'types/typings'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
interface Props { interface Props {
@@ -90,6 +92,7 @@ interface Props {
minHeight?: number minHeight?: number
maxHeight?: number maxHeight?: number
zIndex?: number zIndex?: number
modal?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -332,3 +335,16 @@ defineExpose({
updateContainerPosition, updateContainerPosition,
}) })
</script> </script>
<style lang="css" module>
@layer tailwind-utilities {
:where(.dialog) {
*,
*::before,
*::after {
box-sizing: border-box;
border: 0 solid var(--p-surface-500);
}
}
}
</style>

View File

@@ -19,6 +19,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SelectEvent, SelectFile } from 'types/typings'
const emits = defineEmits<{ const emits = defineEmits<{
select: [event: SelectEvent] select: [event: SelectEvent]
}>() }>()
@@ -44,7 +46,7 @@ const handleDropFile = (event: DragEvent) => {
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
const input = document.createElement('input') const input = document.createElement('input')
input.type = 'file' input.type = 'file'
input.accept = 'image/*' input.accept = 'image/*,video/*'
input.onchange = () => { input.onchange = () => {
const files = input.files const files = input.files
if (files) { if (files) {

View File

@@ -1,12 +1,20 @@
<template> <template>
<div class="p-component p-inputtext flex items-center gap-2"> <div
:class="[
'p-component p-inputtext flex items-center gap-2 border',
'focus-within:border-[--p-inputtext-focus-border-color]',
]"
>
<slot name="prefix"> <slot name="prefix">
<span v-if="prefixIcon" :class="[prefixIcon, 'pi-inputicon']"></span> <span
v-if="prefixIcon"
:class="[prefixIcon, 'text-base opacity-60']"
></span>
</slot> </slot>
<input <input
ref="inputRef" ref="inputRef"
v-model="innerValue" v-model="inputValue"
class="flex-1 border-none bg-transparent text-base outline-none" class="flex-1 border-none bg-transparent text-base outline-none"
type="text" type="text"
:placeholder="placeholder" :placeholder="placeholder"
@@ -18,17 +26,20 @@
<span <span
v-if="allowClear" v-if="allowClear"
v-show="content" v-show="content"
class="pi pi-times pi-inputicon" class="pi pi-times text-base opacity-60"
@click="clearContent" @click="clearContent"
></span> ></span>
<slot name="suffix"> <slot name="suffix">
<span v-if="suffixIcon" :class="[suffixIcon, 'pi-inputicon']"></span> <span
v-if="suffixIcon"
:class="[suffixIcon, 'text-base opacity-60']"
></span>
</slot> </slot>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref } from 'vue' import { computed, ref } from 'vue'
interface Props { interface Props {
prefixIcon?: string prefixIcon?: string
@@ -36,22 +47,40 @@ interface Props {
placeholder?: string placeholder?: string
allowClear?: boolean allowClear?: boolean
updateTrigger?: string updateTrigger?: string
validate?: (value: string | undefined) => boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const [content, modifiers] = defineModel<string, 'trim'>() const [content, modifiers] = defineModel<string, 'trim' | 'valid'>()
const inputRef = ref() const inputRef = ref()
const innerValue = ref(content) const innerValue = ref<string>()
const inputValue = computed({
get: () => {
return innerValue.value ?? content.value
},
set: (val) => {
innerValue.value = val
},
})
const trigger = computed(() => props.updateTrigger ?? 'change') const trigger = computed(() => props.updateTrigger ?? 'change')
const updateContent = () => { const updateContent = () => {
let value = innerValue.value let value = inputValue.value
if (modifiers.trim) { if (modifiers.trim) {
value = innerValue.value?.trim() value = value?.trim()
} }
if (modifiers.valid) {
const isValid = props.validate?.(value) ?? true
if (!isValid) {
innerValue.value = content.value
return
}
}
innerValue.value = undefined
content.value = value content.value = value
inputRef.value.value = value inputRef.value.value = value
} }
@@ -65,18 +94,3 @@ const clearContent = () => {
inputRef.value?.focus() inputRef.value?.focus()
} }
</script> </script>
<style>
.p-inputtext:focus-within {
border-color: var(--p-inputtext-focus-border-color);
box-shadow: var(--p-inputtext-focus-ring-shadow);
outline: var(--p-inputtext-focus-ring-width)
var(--p-inputtext-focus-ring-style) var(--p-inputtext-focus-ring-color);
outline-offset: var(--p-inputtext-focus-ring-offset);
}
.p-inputtext .pi-inputicon {
font-size: 1rem;
opacity: 0.6;
}
</style>

View File

@@ -1,315 +1,140 @@
<template> <template>
<div data-scroll-area class="group/scroll relative overflow-hidden"> <div class="group/scroll relative h-full overflow-hidden">
<div <div ref="viewport" class="h-full w-full overflow-auto scrollbar-none">
ref="viewport" <div ref="content">
data-scroll-viewport
class="h-full w-full overflow-auto scrollbar-none"
:style="{ contain: items ? 'strict' : undefined }"
@scroll="onContentScroll"
v-resize="onContainerResize"
>
<div data-scroll-content class="relative min-w-full">
<slot name="default"> <slot name="default">
<div <slot v-if="renderedItems.length === 0" name="empty">
v-for="(item, index) in loadedItems"
:key="genRowKey(item, index)"
:style="{ height: `${itemSize}px` }"
>
<slot name="item" :item="item"></slot>
</div>
<slot v-if="loadedItems.length === 0" name="empty">
<div class="absolute w-full py-20 text-center">No Data</div> <div class="absolute w-full py-20 text-center">No Data</div>
</slot> </slot>
<div :style="{ height: `${headHeight}px` }"></div>
<div>
<div
v-for="item in renderedItems"
:key="item.key"
:style="{ height: `${itemSize}px` }"
data-virtual-item
>
<slot name="item" :item="item"></slot>
</div>
</div>
<div :style="{ height: `${tailHeight}px` }"></div>
</slot> </slot>
</div> </div>
<div
data-scroll-space
class="pointer-events-none absolute left-0 top-0 h-px w-px"
:style="spaceStyle"
></div>
</div> </div>
<div <div ref="scroll" class="absolute right-0 top-0 h-full w-2">
v-for="scroll in scrollbars"
:key="scroll.direction"
v-show="scroll.visible"
v-bind="{ [`data-scroll-bar-${scroll.direction}`]: '' }"
:class="[
'pointer-events-none absolute z-auto h-full w-full rounded-full',
'data-[scroll-bar-horizontal]:bottom-0 data-[scroll-bar-horizontal]:left-0 data-[scroll-bar-horizontal]:h-2',
'data-[scroll-bar-vertical]:right-0 data-[scroll-bar-vertical]:top-0 data-[scroll-bar-vertical]:w-2',
]"
>
<div <div
v-bind="{ ['data-scroll-thumb']: scroll.direction }" ref="thumb"
:class="[ :class="[
'pointer-events-auto absolute h-full w-full rounded-full', 'absolute w-full cursor-pointer rounded-full bg-gray-500',
'cursor-pointer bg-black dark:bg-white', 'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-30',
'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-10',
]" ]"
:style="{ :style="{
[scrollbarAttrs[scroll.direction].size]: `${scroll.size}px`, height: `${thumbSize}px`,
[scrollbarAttrs[scroll.direction].offset]: `${scroll.offset}px`, top: `${thumbOffset}px`,
opacity: isDragging ? 0.1 : '', opacity: isDragging ? '0.3' : undefined,
}" }"
@mousedown="startDragThumb"
></div> ></div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts" generic="T"> <script setup lang="ts" generic="T extends { key: string }">
import { nextTick, onUnmounted, ref, watch } from 'vue' import { useDraggable, useElementSize, useScroll } from '@vueuse/core'
import { clamp, throttle } from 'lodash' import { clamp } from 'lodash'
import { computed, ref } from 'vue'
interface ScrollAreaProps { interface ScrollAreaProps {
items?: T[][] items?: T[]
itemSize?: number itemSize?: number
scrollbar?: boolean
rowKey?: string | ((item: T[]) => string)
} }
const props = withDefaults(defineProps<ScrollAreaProps>(), { const props = defineProps<ScrollAreaProps>()
scrollbar: true,
})
const emit = defineEmits(['scroll', 'resize'])
type ScrollbarDirection = 'horizontal' | 'vertical' const viewport = ref<HTMLElement | null>(null)
const content = ref<HTMLElement | null>(null)
interface Scrollbar { const { height: viewportHeight } = useElementSize(viewport)
direction: ScrollbarDirection const { height: contentHeight } = useElementSize(content)
visible: boolean const { y: scrollY } = useScroll(viewport)
size: number
offset: number
}
interface ScrollbarAttribute { const itemSize = computed(() => props.itemSize || 0)
clientSize: string
scrollOffset: string
pagePosition: string
offset: string
size: string
}
const scrollbarAttrs: Record<ScrollbarDirection, ScrollbarAttribute> = { const viewRows = computed(() =>
horizontal: { Math.ceil(viewportHeight.value / itemSize.value),
clientSize: 'clientWidth',
scrollOffset: 'scrollLeft',
pagePosition: 'pageX',
offset: 'left',
size: 'width',
},
vertical: {
clientSize: 'clientHeight',
scrollOffset: 'scrollTop',
pagePosition: 'pageY',
offset: 'top',
size: 'height',
},
}
const scrollbars = ref<Record<ScrollbarDirection, Scrollbar>>({
horizontal: {
direction: 'horizontal',
visible: props.scrollbar,
size: 0,
offset: 0,
},
vertical: {
direction: 'vertical',
visible: props.scrollbar,
size: 0,
offset: 0,
},
})
const isDragging = ref(false)
const spaceStyle = ref({})
const loadedItems = ref<T[][]>([])
const genRowKey = (item: any | any[], index: number) => {
if (typeof props.rowKey === 'function') {
return props.rowKey(item)
}
return item[props.rowKey ?? 'key'] ?? index
}
const setSpacerSize = () => {
const items = props.items
if (items) {
const itemSize = props.itemSize ?? 0
spaceStyle.value = { height: `${itemSize * items.length}px` }
} else {
spaceStyle.value = {}
}
}
const getContainerContent = (raw?: boolean): HTMLElement => {
const container = viewport.value as HTMLElement
if (props.items && !raw) {
return container.querySelector('[data-scroll-space]')!
}
return container.querySelector('[data-scroll-content]')!
}
const init = () => {
const container = viewport.value as HTMLElement
container.scrollTop = 0
getContainerContent().style.transform = ''
}
const calculateLoadItems = () => {
let visibleItems: any[] = []
if (props.items) {
const container = viewport.value as HTMLElement
const content = getContainerContent(true)
const resolveVisibleItems = (items: any[], attr: ScrollbarAttribute) => {
const containerSize = container[attr.clientSize]
const itemSize = props.itemSize!
const viewCount = Math.ceil(containerSize / itemSize)
let start = Math.floor(container[attr.scrollOffset] / itemSize)
const offset = start * itemSize
let end = start + viewCount
end = Math.min(end + viewCount, items.length)
content.style.transform = `translateY(${offset}px)`
return items.slice(start, end)
}
visibleItems = resolveVisibleItems(props.items, scrollbarAttrs.vertical)
}
loadedItems.value = visibleItems
}
const calculateScrollThumbSize = () => {
const container = viewport.value as HTMLElement
const content = getContainerContent()
const resolveScrollbarSize = (item: Scrollbar, attr: ScrollbarAttribute) => {
const containerSize: number = container[attr.clientSize]
const contentSize: number = content[attr.clientSize]
item.visible = props.scrollbar && contentSize > containerSize
item.size = Math.max(Math.pow(containerSize, 2) / contentSize, 16)
}
nextTick(() => {
resolveScrollbarSize(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
resolveScrollbarSize(scrollbars.value.vertical, scrollbarAttrs.vertical)
})
}
const onContainerResize: ResizeObserverCallback = throttle((entries) => {
emit('resize', entries)
if (isDragging.value) return
calculateScrollThumbSize()
})
const onContentScroll = throttle((event: Event) => {
emit('scroll', event)
if (isDragging.value) return
const container = event.target as HTMLDivElement
const content = getContainerContent()
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
const containerSize = container[attr.clientSize]
const contentSize = content[attr.clientSize]
const scrollOffset = container[attr.scrollOffset]
item.offset =
(scrollOffset / (contentSize - containerSize)) *
(containerSize - item.size)
}
resolveOffset(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
resolveOffset(scrollbars.value.vertical, scrollbarAttrs.vertical)
calculateLoadItems()
})
const viewport = ref<HTMLElement>()
const draggingDirection = ref<ScrollbarDirection>()
const prevDraggingEvent = ref<MouseEvent>()
const moveThumb = throttle((event: MouseEvent) => {
if (isDragging.value) {
const container = viewport.value!
const content = getContainerContent()
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
const containerSize = container[attr.clientSize]
const contentSize = content[attr.clientSize]
// Resolve thumb position
const prevPagePos = prevDraggingEvent.value![attr.pagePosition]
const currPagePos = event[attr.pagePosition]
const offset = currPagePos - prevPagePos
item.offset = clamp(item.offset + offset, 0, containerSize - item.size)
// Resolve scroll position
const scrollOffset = containerSize - item.size
const offsetSize = contentSize - containerSize
container[attr.scrollOffset] = (item.offset / scrollOffset) * offsetSize
}
const scrollDirection = draggingDirection.value!
resolveOffset(
scrollbars.value[scrollDirection],
scrollbarAttrs[scrollDirection],
)
prevDraggingEvent.value = event
calculateLoadItems()
}
})
const stopMoveThumb = () => {
isDragging.value = false
draggingDirection.value = undefined
prevDraggingEvent.value = undefined
document.removeEventListener('mousemove', moveThumb)
document.removeEventListener('mouseup', stopMoveThumb)
document.body.style.userSelect = ''
document.body.style.cursor = ''
}
const startDragThumb = (event: MouseEvent) => {
isDragging.value = true
const target = event.target as HTMLElement
draggingDirection.value = <any>target.getAttribute('data-scroll-thumb')
prevDraggingEvent.value = event
document.addEventListener('mousemove', moveThumb)
document.addEventListener('mouseup', stopMoveThumb)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'default'
}
watch(
() => props.items,
() => {
init()
setSpacerSize()
calculateScrollThumbSize()
calculateLoadItems()
},
) )
const offsetRows = computed(() => Math.floor(scrollY.value / itemSize.value))
onUnmounted(() => { const items = computed(() => {
stopMoveThumb() return props.items ?? []
}) })
defineExpose({ const state = computed(() => {
viewport, const bufferRows = viewRows.value
const fromRow = offsetRows.value - bufferRows
const toRow = offsetRows.value + bufferRows + viewRows.value
const itemCount = items.value.length
return {
start: clamp(fromRow, 0, itemCount),
end: clamp(toRow, fromRow, itemCount),
}
})
const renderedItems = computed(() => {
const { start, end } = state.value
return props.items?.slice(start, end) ?? []
})
const headHeight = computed(() => {
return state.value.start * itemSize.value
})
const tailHeight = computed(() => {
return (items.value.length - state.value.end) * itemSize.value
})
const thumbSize = computed(() => {
if (viewportHeight.value >= contentHeight.value) {
return 0
}
const thumbHeight = Math.pow(viewportHeight.value, 2) / contentHeight.value
return Math.max(thumbHeight, 16)
})
const thumbOffset = computed({
get: () => {
return (
(scrollY.value / (contentHeight.value - viewportHeight.value)) *
(viewportHeight.value - thumbSize.value)
)
},
set: (offset) => {
scrollY.value =
(offset / (viewportHeight.value - thumbSize.value)) *
(contentHeight.value - viewportHeight.value)
},
})
const scroll = ref<HTMLElement | null>(null)
const thumb = ref<HTMLElement | null>(null)
const { isDragging } = useDraggable(thumb, {
axis: 'y',
containerElement: scroll,
onStart: () => {
document.body.style.userSelect = 'none'
},
onMove: (position) => {
thumbOffset.value = position.y
},
onEnd: () => {
document.body.style.userSelect = ''
},
}) })
</script> </script>

View File

@@ -2,7 +2,14 @@
<slot <slot
v-if="type === 'drop'" v-if="type === 'drop'"
name="target" name="target"
v-bind="{ toggle, prefixIcon, suffixIcon, currentLabel, current }" v-bind="{
toggle,
prefixIcon,
suffixIcon,
currentLabel,
current,
overlayVisible,
}"
> >
<div :class="['-my-1 py-1', $attrs.class]" @click="toggle"> <div :class="['-my-1 py-1', $attrs.class]" @click="toggle">
<Button <Button
@@ -27,18 +34,15 @@
</slot> </slot>
<div v-else class="relative flex-1 overflow-hidden"> <div v-else class="relative flex-1 overflow-hidden">
<div <div ref="scrollArea" class="h-full w-full overflow-auto scrollbar-none">
ref="scrollArea"
class="h-full w-full overflow-auto scrollbar-none"
v-resize="checkScrollPosition"
@scroll="checkScrollPosition"
>
<div ref="contentArea" class="table max-w-full"> <div ref="contentArea" class="table max-w-full">
<div <div
v-show="showControlButton && scrollPosition !== 'left'" v-show="showControlButton && scrollPosition !== 'left'"
:class="[ :class="[
'pointer-events-none absolute left-0 top-1/2 z-10', 'pointer-events-none absolute z-10 flex h-full items-center',
'-translate-y-1/2 bg-gradient-to-r from-current to-transparent pr-16', 'top-1/2 [transform:translateY(-50%)]',
'left-0 pr-16',
'[background-image:linear-gradient(to_right,currentColor,transparent)]',
]" ]"
style="color: var(--p-dialog-background)" style="color: var(--p-dialog-background)"
> >
@@ -67,8 +71,10 @@
<div <div
v-show="showControlButton && scrollPosition !== 'right'" v-show="showControlButton && scrollPosition !== 'right'"
:class="[ :class="[
'pointer-events-none absolute right-0 top-1/2 z-10', 'pointer-events-none absolute z-10 flex h-full items-center',
'-translate-y-1/2 bg-gradient-to-l from-current to-transparent pl-16', 'top-1/2 [transform:translateY(-50%)]',
'right-0 pl-16',
'[background-image:linear-gradient(to_left,currentColor,transparent)]',
]" ]"
style="color: var(--p-dialog-background)" style="color: var(--p-dialog-background)"
> >
@@ -126,7 +132,13 @@
<slot v-else name="desktop"> <slot v-else name="desktop">
<slot name="container"> <slot name="container">
<slot name="desktop:container"> <slot name="desktop:container">
<Menu ref="menu" :model="items" :popup="true" :base-z-index="1000"> <Menu
ref="menu"
:model="items"
:popup="true"
:base-z-index="1000"
:pt:root:style="{ maxHeight: '300px', overflowX: 'hidden' }"
>
<template #item="{ item }"> <template #item="{ item }">
<slot name="item" :item="item"> <slot name="item" :item="item">
<slot name="desktop:container:item" :item="item"> <slot name="desktop:container:item" :item="item">
@@ -146,11 +158,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import { useConfig } from 'hooks/config' import { useConfig } from 'hooks/config'
import Button, { ButtonProps } from 'primevue/button' import Button, { ButtonProps } from 'primevue/button'
import Drawer from 'primevue/drawer' import Drawer from 'primevue/drawer'
import Menu from 'primevue/menu' import Menu from 'primevue/menu'
import { computed, ref } from 'vue' import { SelectOptions } from 'types/typings'
import { computed, ref, watch } from 'vue'
const current = defineModel() const current = defineModel()
@@ -190,8 +204,12 @@ const toggle = (event: MouseEvent) => {
} }
} }
const overlayVisible = computed(() => {
return isMobile.value ? visible.value : (menu.value?.overlayVisible ?? false)
})
// Select Button Type // Select Button Type
const scrollArea = ref() const scrollArea = ref<HTMLElement | null>(null)
const contentArea = ref() const contentArea = ref()
type ScrollPosition = 'left' | 'right' type ScrollPosition = 'left' | 'right'
@@ -231,4 +249,16 @@ const checkScrollPosition = () => {
scrollPosition.value = position scrollPosition.value = position
showControlButton.value = contentWidth > containerWidth showControlButton.value = contentWidth > containerWidth
} }
const { width, height } = useElementSize(scrollArea)
watch([width, height], () => {
checkScrollPosition()
})
useScroll(scrollArea, {
onScroll: () => {
checkScrollPosition()
},
})
</script> </script>

View File

@@ -0,0 +1,69 @@
<template>
<div class="p-4">
<InputText
class="w-full"
v-model="content"
placeholder="Set New API Key"
autocomplete="off"
></InputText>
<div class="mt-4 flex items-center justify-between">
<div>
<span v-show="showError" class="text-red-400">
API Key Not Allow Empty
</span>
</div>
<Button label="Save" autofocus @click="saveKeybinding"></Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useDialog } from 'hooks/dialog'
import { request } from 'hooks/request'
import { useToast } from 'hooks/toast'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import { ref, toValue } from 'vue'
interface Props {
keyField: string
setter: (val: string) => void
}
const props = defineProps<Props>()
const { close } = useDialog()
const { toast } = useToast()
const content = ref<string>()
const showError = ref<boolean>(false)
const saveKeybinding = async () => {
const value = toValue(content)
if (!value) {
showError.value = true
return
}
showError.value = false
const key = toValue(props.keyField)
try {
const encodeValue = value ? btoa(value) : null
await request('/download/setting', {
method: 'POST',
body: JSON.stringify({ key, value: encodeValue }),
})
const desString = value ? value.slice(0, 4) + '****' + value.slice(-4) : ''
props.setter(desString)
close()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Error',
detail: error.message,
life: 3000,
})
}
}
</script>

View File

@@ -0,0 +1,110 @@
<template>
<div class="flex h-full flex-col">
<div class="flex-1 px-4">
<DataTable :value="sizeList">
<Column field="name" :header="$t('name')">
<template #body="{ data, field }">
{{ $t(data[field]) }}
</template>
</Column>
<Column field="width" :header="$t('width')" class="min-w-36">
<template #body="{ data, field }">
<span class="flex items-center gap-4">
<Slider
v-model="data[field]"
class="flex-1"
v-bind="sizeStint"
></Slider>
<span>{{ data[field] }}</span>
</span>
</template>
</Column>
<Column field="height" :header="$t('height')" class="min-w-36">
<template #body="{ data, field }">
<span class="flex items-center gap-4">
<Slider
v-model="data[field]"
class="flex-1"
v-bind="sizeStint"
></Slider>
<span>{{ data[field] }}</span>
</span>
</template>
</Column>
</DataTable>
</div>
<div class="flex justify-between px-4">
<div></div>
<div class="flex gap-2">
<Button
icon="pi pi-refresh"
:label="$t('reset')"
@click="handleReset"
></Button>
<Button :label="$t('cancel')" @click="handleCancelEditor"></Button>
<Button :label="$t('save')" @click="handleSaveSizeMap"></Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useConfig } from 'hooks/config'
import { useDialog } from 'hooks/dialog'
import Button from 'primevue/button'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Slider from 'primevue/slider'
import { onMounted, ref } from 'vue'
const { cardSizeMap, defaultCardSizeMap } = useConfig()
const dialog = useDialog()
const sizeList = ref()
const sizeStint = {
step: 10,
min: 80,
max: 320,
}
const resolveSizeMap = (sizeMap: Record<string, string>) => {
return Object.entries(sizeMap).map(([key, value]) => {
const [width, height] = value.split('x')
return {
id: key,
name: key,
width: parseInt(width),
height: parseInt(height),
}
})
}
const resolveSizeList = (
sizeList: { name: string; width: number; height: number }[],
) => {
return Object.fromEntries(
sizeList.map(({ name, width, height }) => {
return [name, [width, height].join('x')]
}),
)
}
onMounted(() => {
sizeList.value = resolveSizeMap(cardSizeMap.value)
})
const handleReset = () => {
sizeList.value = resolveSizeMap(defaultCardSizeMap)
}
const handleCancelEditor = () => {
sizeList.value = resolveSizeMap(cardSizeMap.value)
dialog.close()
}
const handleSaveSizeMap = () => {
cardSizeMap.value = resolveSizeList(sizeList.value)
dialog.close()
}
</script>

View File

@@ -1,16 +1,18 @@
import { useRequest } from 'hooks/request' import SettingApiKey from 'components/SettingApiKey.vue'
import SettingCardSize from 'components/SettingCardSize.vue'
import { request } from 'hooks/request'
import { defineStore } from 'hooks/store' import { defineStore } from 'hooks/store'
import { app } from 'scripts/comfyAPI' import { useToast } from 'hooks/toast'
import { onMounted, onUnmounted, ref } from 'vue' import { $el, app } from 'scripts/comfyAPI'
import { computed, onMounted, onUnmounted, readonly, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
export const useConfig = defineStore('config', (store) => {
const { t } = useI18n()
export const useConfig = defineStore('config', () => {
const mobileDeviceBreakPoint = 759 const mobileDeviceBreakPoint = 759
const isMobile = ref(window.innerWidth < mobileDeviceBreakPoint) const isMobile = ref(window.innerWidth < mobileDeviceBreakPoint)
type ModelFolder = Record<string, string[]>
const { data: modelFolders, refresh: refreshModelFolders } =
useRequest<ModelFolder>('/base-folders')
const checkDeviceType = () => { const checkDeviceType = () => {
isMobile.value = window.innerWidth < mobileDeviceBreakPoint isMobile.value = window.innerWidth < mobileDeviceBreakPoint
} }
@@ -23,20 +25,64 @@ export const useConfig = defineStore('config', () => {
window.removeEventListener('resize', checkDeviceType) window.removeEventListener('resize', checkDeviceType)
}) })
const refresh = async () => { const flatLayout = ref(false)
return Promise.all([refreshModelFolders()])
} const defaultCardSizeMap = readonly({
'size.extraLarge': '240x320',
'size.large': '180x240',
'size.medium': '120x160',
'size.small': '80x120',
})
const cardSizeMap = ref<Record<string, string>>({ ...defaultCardSizeMap })
const cardSizeFlag = ref('size.extraLarge')
const cardSize = computed(() => {
const size = cardSizeMap.value[cardSizeFlag.value]
const [width = '120', height = '240'] = size.split('x')
return {
width: parseInt(width),
height: parseInt(height),
}
})
const config = { const config = {
isMobile, isMobile,
gutter: 16, gutter: 16,
defaultCardSizeMap: defaultCardSizeMap,
cardSizeMap: cardSizeMap,
cardSizeFlag: cardSizeFlag,
cardSize: cardSize,
cardWidth: 240, cardWidth: 240,
aspect: 7 / 9, aspect: 7 / 9,
modelFolders, dialog: {
refresh, showCardSizeSetting: () => {
store.dialog.open({
key: 'setting.cardSize',
title: t('setting.cardSize'),
content: SettingCardSize,
defaultSize: {
width: 500,
height: 390,
},
})
},
},
flat: flatLayout,
apiKeyInfo: ref<Record<string, string>>({}),
} }
useAddConfigSettings(config) watch(cardSizeFlag, (val) => {
app.ui?.settings.setSettingValue('ModelManager.UI.CardSize', val)
})
watch(cardSizeMap, (val) => {
app.ui?.settings.setSettingValue(
'ModelManager.UI.CardSizeMap',
JSON.stringify(val),
)
})
useAddConfigSettings(store)
return config return config
}) })
@@ -49,21 +95,159 @@ declare module 'hooks/store' {
} }
} }
function useAddConfigSettings(config: Config) { export const configSetting = {
excludeScanTypes: 'ModelManager.Scan.excludeScanTypes',
}
function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
const { t } = useI18n()
const { confirm } = useToast()
const iconButton = (opt: {
icon: string
onClick: () => void | Promise<void>
}) => {
return $el(
'span.h-4.cursor-pointer',
{ onclick: opt.onClick },
$el(`i.${opt.icon.replace(/\s/g, '.')}`),
)
}
const setApiKey = async (key: string, setter: (val: string) => void) => {
store.dialog.open({
key: `setting.api_key.${key}`,
title: t(`setting.api_key.${key}`),
content: SettingApiKey,
modal: true,
defaultSize: {
width: 500,
height: 200,
},
contentProps: {
keyField: key,
setter: setter,
},
})
}
const removeApiKey = async (key: string) => {
await new Promise((resolve, reject) => {
confirm.require({
message: t('deleteAsk'),
header: 'Danger',
icon: 'pi pi-info-circle',
accept: () => resolve(true),
reject: reject,
})
})
await request('/download/setting', {
method: 'POST',
body: JSON.stringify({ key, value: null }),
})
}
const renderApiKey = (key: string) => {
return () => {
const apiKey = store.config.apiKeyInfo.value[key] || 'None'
const apiKeyDisplayEl = $el('div.text-sm.text-gray-500.flex-1', {
textContent: apiKey,
})
const setter = (val: string) => {
store.config.apiKeyInfo.value[key] = val
apiKeyDisplayEl.textContent = val || 'None'
}
return $el('div.flex.gap-4', [
apiKeyDisplayEl,
iconButton({
icon: 'pi pi-pencil text-blue-400',
onClick: () => {
setApiKey(key, setter)
},
}),
iconButton({
icon: 'pi pi-trash text-red-400',
onClick: async () => {
const value = store.config.apiKeyInfo.value[key]
if (value) {
await removeApiKey(key)
setter('')
}
},
}),
])
}
}
onMounted(() => { onMounted(() => {
// API keys // API keys
app.ui?.settings.addSetting({ app.ui?.settings.addSetting({
id: 'ModelManager.APIKey.HuggingFace', id: 'ModelManager.APIKey.HuggingFace',
category: [t('modelManager'), t('setting.apiKey'), 'HuggingFace'],
name: 'HuggingFace API Key', name: 'HuggingFace API Key',
type: 'text',
defaultValue: undefined, defaultValue: undefined,
type: renderApiKey('huggingface'),
}) })
app.ui?.settings.addSetting({ app.ui?.settings.addSetting({
id: 'ModelManager.APIKey.Civitai', id: 'ModelManager.APIKey.Civitai',
category: [t('modelManager'), t('setting.apiKey'), 'Civitai'],
name: 'Civitai API Key', name: 'Civitai API Key',
type: 'text',
defaultValue: undefined, defaultValue: undefined,
type: renderApiKey('civitai'),
})
const defaultCardSize = store.config.defaultCardSizeMap
app.ui?.settings.addSetting({
id: 'ModelManager.UI.CardSize',
category: [t('modelManager'), t('setting.ui'), 'CardSize'],
name: t('setting.cardSize'),
defaultValue: 'size.extraLarge',
type: 'hidden',
onChange: (val) => {
store.config.cardSizeFlag.value = val
},
})
app.ui?.settings.addSetting({
id: 'ModelManager.UI.CardSizeMap',
category: [t('modelManager'), t('setting.ui'), 'CardSizeMap'],
name: t('setting.cardSize'),
defaultValue: JSON.stringify(defaultCardSize),
type: 'hidden',
onChange(value) {
store.config.cardSizeMap.value = JSON.parse(value)
},
})
app.ui?.settings.addSetting({
id: 'ModelManager.UI.Flat',
category: [t('modelManager'), t('setting.ui'), 'Flat'],
name: t('setting.useFlatUI'),
type: 'boolean',
defaultValue: false,
onChange(value) {
store.dialog.closeAll()
store.config.flat.value = value
},
})
app.ui?.settings.addSetting({
id: configSetting.excludeScanTypes,
category: [t('modelManager'), t('setting.scan'), 'ExcludeScanTypes'],
name: t('setting.excludeScanTypes'),
defaultValue: undefined,
type: 'text',
})
app.ui?.settings.addSetting({
id: 'ModelManager.Scan.IncludeHiddenFiles',
category: [t('modelManager'), t('setting.scan'), 'IncludeHiddenFiles'],
name: t('setting.includeHiddenFiles'),
defaultValue: false,
type: 'boolean',
}) })
}) })
} }

41
src/hooks/container.ts Normal file
View File

@@ -0,0 +1,41 @@
import { useElementSize } from '@vueuse/core'
import { type InjectionKey, type Ref, inject, provide, toRef } from 'vue'
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize)
const containerKey = Symbol('container') as InjectionKey<
Ref<HTMLElement | null>
>
export const useContainerQueries = (
el?: HTMLElement | null | Ref<HTMLElement | null>,
) => {
const container = inject(containerKey, el ? toRef(el) : toRef(document.body))
provide(containerKey, container)
const { width } = useElementSize(container)
/**
* @param size unit rem
*/
const generator = (size: number) => {
return (content: any, defaultContent: any = undefined) => {
return width.value > size * rem ? content : defaultContent
}
}
return {
$xs: generator(20),
$sm: generator(24),
$md: generator(28),
$lg: generator(32),
$xl: generator(36),
$2xl: generator(42),
$3xl: generator(48),
$4xl: generator(54),
$5xl: generator(60),
$6xl: generator(66),
$7xl: generator(72),
}
}

View File

@@ -1,12 +1,14 @@
import { defineStore } from 'hooks/store' import { defineStore } from 'hooks/store'
import { ContainerSize } from 'types/typings'
import { Component, markRaw, ref } from 'vue' import { Component, markRaw, ref } from 'vue'
interface HeaderButton { interface HeaderButton {
key: string
icon: string icon: string
command: () => void command: () => void
} }
interface DialogItem { export interface DialogItem {
key: string key: string
title: string title: string
content: Component content: Component
@@ -20,6 +22,7 @@ interface DialogItem {
maxWidth?: number maxWidth?: number
minHeight?: number minHeight?: number
maxHeight?: number maxHeight?: number
modal?: boolean
} }
export const useDialog = defineStore('dialog', () => { export const useDialog = defineStore('dialog', () => {
@@ -47,7 +50,12 @@ export const useDialog = defineStore('dialog', () => {
} }
} }
const close = (dialog: { key: string }) => { const close = (dialog?: { key: string }) => {
if (!dialog) {
stack.value.pop()
return
}
const item = stack.value.find((item) => item.key === dialog.key) const item = stack.value.find((item) => item.key === dialog.key)
if (item?.keepAlive) { if (item?.keepAlive) {
item.visible = false item.visible = false
@@ -56,7 +64,11 @@ export const useDialog = defineStore('dialog', () => {
} }
} }
return { stack, open, close, rise } const closeAll = () => {
stack.value = []
}
return { stack, open, close, closeAll, rise }
}) })
declare module 'hooks/store' { declare module 'hooks/store' {

View File

@@ -1,22 +1,27 @@
import { useLoading } from 'hooks/loading' import { useLoading } from 'hooks/loading'
import { MarkdownTool, useMarkdown } from 'hooks/markdown' import { request } from 'hooks/request'
import { socket } from 'hooks/socket'
import { defineStore } from 'hooks/store' import { defineStore } from 'hooks/store'
import { useToast } from 'hooks/toast' import { useToast } from 'hooks/toast'
import { upperFirst } from 'lodash'
import { api } from 'scripts/comfyAPI'
import {
DownloadTask,
DownloadTaskOptions,
SelectOptions,
VersionModel,
VersionModelFile,
} from 'types/typings'
import { bytesToSize } from 'utils/common' import { bytesToSize } from 'utils/common'
import { onBeforeMount, onMounted, ref, watch } from 'vue' import { onBeforeMount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import yaml from 'yaml'
export const useDownload = defineStore('download', (store) => { export const useDownload = defineStore('download', (store) => {
const { toast, confirm } = useToast() const { toast, confirm, wrapperToastError } = useToast()
const { t } = useI18n() const { t } = useI18n()
const taskList = ref<DownloadTask[]>([]) const taskList = ref<DownloadTask[]>([])
const refresh = () => {
socket.send('downloadTaskList', null)
}
const createTaskItem = (item: DownloadTaskOptions) => { const createTaskItem = (item: DownloadTaskOptions) => {
const { downloadedSize, totalSize, bps, ...rest } = item const { downloadedSize, totalSize, bps, ...rest } = item
@@ -26,10 +31,24 @@ export const useDownload = defineStore('download', (store) => {
downloadProgress: `${bytesToSize(downloadedSize)} / ${bytesToSize(totalSize)}`, downloadProgress: `${bytesToSize(downloadedSize)} / ${bytesToSize(totalSize)}`,
downloadSpeed: `${bytesToSize(bps)}/s`, downloadSpeed: `${bytesToSize(bps)}/s`,
pauseTask() { pauseTask() {
socket.send('pauseDownloadTask', item.taskId) wrapperToastError(async () =>
request(`/download/${item.taskId}`, {
method: 'PUT',
body: JSON.stringify({
status: 'pause',
}),
}),
)()
}, },
resumeTask: () => { resumeTask: () => {
socket.send('resumeDownloadTask', item.taskId) wrapperToastError(async () =>
request(`/download/${item.taskId}`, {
method: 'PUT',
body: JSON.stringify({
status: 'resume',
}),
}),
)()
}, },
deleteTask: () => { deleteTask: () => {
confirm.require({ confirm.require({
@@ -46,7 +65,11 @@ export const useDownload = defineStore('download', (store) => {
severity: 'danger', severity: 'danger',
}, },
accept: () => { accept: () => {
socket.send('deleteDownloadTask', item.taskId) wrapperToastError(async () =>
request(`/download/${item.taskId}`, {
method: 'DELETE',
}),
)()
}, },
reject: () => {}, reject: () => {},
}) })
@@ -56,12 +79,28 @@ export const useDownload = defineStore('download', (store) => {
return task return task
} }
const refresh = wrapperToastError(async () => {
return request('/download/task').then((resData: DownloadTaskOptions[]) => {
taskList.value = resData.map((item) => createTaskItem(item))
return taskList.value
})
})
// Initial download settings
// Migrate API keys from user settings to private key
const init = async () => {
const res = await request('/download/init', { method: 'POST' })
store.config.apiKeyInfo.value = res
}
onBeforeMount(() => { onBeforeMount(() => {
socket.addEventListener('reconnected', () => { init()
api.addEventListener('reconnected', () => {
refresh() refresh()
}) })
socket.addEventListener('downloadTaskList', (event) => { api.addEventListener('fetch_download_task_list', (event) => {
const data = event.detail as DownloadTaskOptions[] const data = event.detail as DownloadTaskOptions[]
taskList.value = data.map((item) => { taskList.value = data.map((item) => {
@@ -69,12 +108,12 @@ export const useDownload = defineStore('download', (store) => {
}) })
}) })
socket.addEventListener('createDownloadTask', (event) => { api.addEventListener('create_download_task', (event) => {
const item = event.detail as DownloadTaskOptions const item = event.detail as DownloadTaskOptions
taskList.value.unshift(createTaskItem(item)) taskList.value.unshift(createTaskItem(item))
}) })
socket.addEventListener('updateDownloadTask', (event) => { api.addEventListener('update_download_task', (event) => {
const item = event.detail as DownloadTaskOptions const item = event.detail as DownloadTaskOptions
for (const task of taskList.value) { for (const task of taskList.value) {
@@ -93,12 +132,12 @@ export const useDownload = defineStore('download', (store) => {
} }
}) })
socket.addEventListener('deleteDownloadTask', (event) => { api.addEventListener('delete_download_task', (event) => {
const taskId = event.detail as string const taskId = event.detail as string
taskList.value = taskList.value.filter((item) => item.taskId !== taskId) taskList.value = taskList.value.filter((item) => item.taskId !== taskId)
}) })
socket.addEventListener('completeDownloadTask', (event) => { api.addEventListener('complete_download_task', (event) => {
const taskId = event.detail as string const taskId = event.detail as string
const task = taskList.value.find((item) => item.taskId === taskId) const task = taskList.value.find((item) => item.taskId === taskId)
taskList.value = taskList.value.filter((item) => item.taskId !== taskId) taskList.value = taskList.value.filter((item) => item.taskId !== taskId)
@@ -125,287 +164,80 @@ declare module 'hooks/store' {
} }
} }
abstract class ModelSearch { type WithSelection<T> = SelectOptions & { item: T }
constructor(readonly md: MarkdownTool) {}
abstract search(pathname: string): Promise<VersionModel[]> type FileSelectionVersionModel = VersionModel & {
} currentFileId?: number
selectionFiles?: WithSelection<VersionModelFile>[]
class Civitai extends ModelSearch {
async search(searchUrl: string): Promise<VersionModel[]> {
const { pathname, searchParams } = new URL(searchUrl)
const [, modelId] = pathname.match(/^\/models\/(\d*)/) ?? []
const versionId = searchParams.get('modelVersionId')
if (!modelId) {
return Promise.resolve([])
}
return fetch(`https://civitai.com/api/v1/models/${modelId}`)
.then((response) => response.json())
.then((resData) => {
const modelVersions: any[] = resData.modelVersions.filter(
(version: any) => {
if (versionId) {
return version.id == versionId
}
return true
},
)
const models: VersionModel[] = []
for (const version of modelVersions) {
const modelFiles: any[] = version.files.filter(
(file: any) => file.type === 'Model',
)
const shortname = modelFiles.length > 0 ? version.name : undefined
for (const file of modelFiles) {
const fullname = file.name
const extension = `.${fullname.split('.').pop()}`
const basename = fullname.replace(extension, '')
models.push({
id: file.id,
shortname: shortname ?? basename,
fullname: fullname,
basename: basename,
extension: extension,
preview: version.images.map((i: any) => i.url),
sizeBytes: file.sizeKB * 1024,
type: this.resolveType(resData.type),
pathIndex: 0,
description: [
'---',
...[
`website: Civitai`,
`modelPage: https://civitai.com/models/${modelId}?modelVersionId=${version.id}`,
`author: ${resData.creator?.username}`,
version.baseModel && `baseModel: ${version.baseModel}`,
file.hashes && `hashes:`,
...Object.entries(file.hashes ?? {}).map(
([key, value]) => ` ${key}: ${value}`,
),
file.metadata && `metadata:`,
...Object.entries(file.metadata ?? {}).map(
([key, value]) => ` ${key}: ${value}`,
),
].filter(Boolean),
'---',
'',
'# Trigger Words',
`\n${(version.trainedWords ?? ['No trigger words']).join(', ')}\n`,
'# About this version',
this.resolveDescription(
version.description,
'\nNo description about this version\n',
),
`# ${resData.name}`,
this.resolveDescription(
resData.description,
'No description about this model',
),
].join('\n'),
metadata: file.metadata,
downloadPlatform: 'civitai',
downloadUrl: file.downloadUrl,
hashes: file.hashes,
})
}
}
return models
})
}
private resolveType(type: string) {
const mapLegacy = {
TextualInversion: 'embeddings',
LoCon: 'loras',
DoRA: 'loras',
Controlnet: 'controlnet',
Upscaler: 'upscale_models',
VAE: 'vae',
}
return mapLegacy[type] ?? `${type.toLowerCase()}s`
}
private resolveDescription(content: string, defaultContent: string) {
const mdContent = this.md.parse(content ?? '').trim()
return mdContent || defaultContent
}
}
class Huggingface extends ModelSearch {
async search(searchUrl: string): Promise<VersionModel[]> {
const { pathname } = new URL(searchUrl)
const [, space, name, ...restPaths] = pathname.split('/')
if (!space || !name) {
return Promise.resolve([])
}
const modelId = `${space}/${name}`
const restPathname = restPaths.join('/')
return fetch(`https://huggingface.co/api/models/${modelId}`)
.then((response) => response.json())
.then((resData) => {
const siblingFiles: string[] = resData.siblings.map(
(item: any) => item.rfilename,
)
const modelFiles: string[] = this.filterTreeFiles(
this.filterModelFiles(siblingFiles),
restPathname,
)
const images: string[] = this.filterTreeFiles(
this.filterImageFiles(siblingFiles),
restPathname,
).map((filename) => {
return `https://huggingface.co/${modelId}/resolve/main/${filename}`
})
const models: VersionModel[] = []
for (const filename of modelFiles) {
const fullname = filename.split('/').pop()!
const extension = `.${fullname.split('.').pop()}`
const basename = fullname.replace(extension, '')
models.push({
id: filename,
shortname: filename,
fullname: fullname,
basename: basename,
extension: extension,
preview: images,
sizeBytes: 0,
type: 'unknown',
pathIndex: 0,
description: [
'---',
...[
`website: HuggingFace`,
`modelPage: https://huggingface.co/${modelId}`,
`author: ${resData.author}`,
].filter(Boolean),
'---',
'',
'# Trigger Words',
'\nNo trigger words\n',
'# About this version',
'\nNo description about this version\n',
`# ${resData.modelId}`,
'\nNo description about this model\n',
].join('\n'),
metadata: {},
downloadPlatform: 'huggingface',
downloadUrl: `https://huggingface.co/${modelId}/resolve/main/${filename}?download=true`,
})
}
return models
})
}
private filterTreeFiles(files: string[], pathname: string) {
const [target, , ...paths] = pathname.split('/')
if (!target) return files
if (target !== 'tree' && target !== 'blob') return files
const pathPrefix = paths.join('/')
return files.filter((file) => {
return file.startsWith(pathPrefix)
})
}
private filterModelFiles(files: string[]) {
const extension = [
'.bin',
'.ckpt',
'.gguf',
'.onnx',
'.pt',
'.pth',
'.safetensors',
]
return files.filter((file) => {
const ext = file.split('.').pop()
return ext ? extension.includes(`.${ext}`) : false
})
}
private filterImageFiles(files: string[]) {
const extension = [
'.png',
'.webp',
'.jpeg',
'.jpg',
'.jfif',
'.gif',
'.apng',
]
return files.filter((file) => {
const ext = file.split('.').pop()
return ext ? extension.includes(`.${ext}`) : false
})
}
}
class UnknownWebsite extends ModelSearch {
async search(searchUrl: string): Promise<VersionModel[]> {
return Promise.reject(
new Error(
'Unknown Website, please input a URL from huggingface.co or civitai.com.',
),
)
}
} }
export const useModelSearch = () => { export const useModelSearch = () => {
const loading = useLoading() const loading = useLoading()
const md = useMarkdown()
const { toast } = useToast() const { toast } = useToast()
const data = ref<(SelectOptions & { item: VersionModel })[]>([]) const data = ref<WithSelection<FileSelectionVersionModel>[]>([])
const current = ref<string | number>() const current = ref<string | number>()
const currentModel = ref<BaseModel>() const currentModel = ref<FileSelectionVersionModel>()
const genFileSelectionItem = (
item: VersionModel,
): FileSelectionVersionModel => {
const fileSelectionItem: FileSelectionVersionModel = { ...item }
fileSelectionItem.selectionFiles = fileSelectionItem.files
?.sort((file) => (file.type === 'Model' ? -1 : 1))
.map((file) => {
const parts = file.name.split('.')
const extension = `.${parts.pop()}`
const basename = parts.join('.')
const regexp = /---\n([\s\S]*?)\n---/
const yamlMetadataMatch = item.description.match(regexp)
const yamlMetadata = yaml.parse(yamlMetadataMatch?.[1] || '')
yamlMetadata.hashes = file.hashes
yamlMetadata.metadata = file.metadata
const yamlContent = `---\n${yaml.stringify(yamlMetadata)}---`
const description = item.description.replace(regexp, yamlContent)
return {
label: file.type === 'Model' ? upperFirst(item.type) : file.type,
value: file.id,
item: file,
command() {
if (currentModel.value) {
currentModel.value.basename = basename
currentModel.value.extension = extension
currentModel.value.sizeBytes = file.sizeKB * 1024
currentModel.value.metadata = file.metadata
currentModel.value.downloadUrl = file.downloadUrl
currentModel.value.hashes = file.hashes
currentModel.value.description = description
currentModel.value.currentFileId = file.id
}
},
}
})
fileSelectionItem.currentFileId = item.files?.[0]?.id
return fileSelectionItem
}
const handleSearchByUrl = async (url: string) => { const handleSearchByUrl = async (url: string) => {
if (!url) { if (!url) {
return Promise.resolve([]) return Promise.resolve([])
} }
let instance: ModelSearch = new UnknownWebsite(md)
const { hostname } = new URL(url ?? '')
if (hostname === 'civitai.com') {
instance = new Civitai(md)
}
if (hostname === 'huggingface.co') {
instance = new Huggingface(md)
}
loading.show() loading.show()
return instance return request(`/model-info?model-page=${encodeURIComponent(url)}`, {})
.search(url) .then((resData: VersionModel[]) => {
.then((resData) => { data.value = resData.map((item) => {
data.value = resData.map((item) => ({ const resolvedItem = genFileSelectionItem(item)
label: item.shortname, return {
value: item.id, label: item.shortname,
item, value: item.id,
command() { item: resolvedItem,
current.value = item.id command() {
}, current.value = item.id
})) },
}
})
current.value = data.value[0]?.value current.value = data.value[0]?.value
currentModel.value = data.value[0]?.item currentModel.value = data.value[0]?.item

170
src/hooks/explorer.ts Normal file
View File

@@ -0,0 +1,170 @@
import { genModelFullName, useModels } from 'hooks/model'
import { cloneDeep, filter, find } from 'lodash'
import { BaseModel, Model, SelectOptions } from 'types/typings'
import { computed, ref, watch } from 'vue'
export interface FolderPathItem {
name: string
pathIndex: number
icon?: string
onClick: () => void
children: SelectOptions[]
}
export type ModelFolder = BaseModel & {
children: ModelTreeNode[]
}
export type ModelItem = Model
export type ModelTreeNode = BaseModel & {
children?: ModelTreeNode[]
}
export type TreeItemNode = ModelTreeNode & {
onDbClick: () => void
onContextMenu: () => void
}
export const useModelExplorer = () => {
const { data, folders, initialized, ...modelRest } = useModels()
const folderPaths = ref<FolderPathItem[]>([])
const genFolderItem = (
basename: string,
folder?: string,
subFolder?: string,
): ModelFolder => {
return {
id: basename,
basename: basename,
subFolder: subFolder ?? '',
pathIndex: 0,
sizeBytes: 0,
extension: '',
description: '',
metadata: {},
preview: '',
type: folder ?? '',
isFolder: true,
children: [],
}
}
const dataTreeList = computed<ModelTreeNode[]>(() => {
const rootChildren: ModelTreeNode[] = []
for (const folder in folders.value) {
if (Object.prototype.hasOwnProperty.call(folders.value, folder)) {
const folderItem = genFolderItem(folder)
const folderModels = cloneDeep(data.value[folder]) ?? []
const pathMap: Record<string, ModelTreeNode> = Object.fromEntries(
folderModels.map((item) => [
`${item.pathIndex}-${genModelFullName(item)}`,
item,
]),
)
for (const item of folderModels) {
const key = genModelFullName(item)
const parentKey = key.split('/').slice(0, -1).join('/')
if (parentKey === '') {
folderItem.children.push(item)
continue
}
const parentItem = pathMap[`${item.pathIndex}-${parentKey}`]
if (parentItem) {
parentItem.children ??= []
parentItem.children.push(item)
}
}
rootChildren.push(folderItem)
}
}
const root: ModelTreeNode = genFolderItem('root')
root.children = rootChildren
return [root]
})
function findFolder(
list: ModelTreeNode[],
feature: { basename: string; pathIndex: number },
) {
return find(list, { ...feature, isFolder: true }) as ModelFolder | undefined
}
function findFolders(list: ModelTreeNode[]) {
return filter(list, { isFolder: true }) as ModelFolder[]
}
async function openFolder(item: BaseModel) {
const folderItems: FolderPathItem[] = []
const folder = item.type
const subFolderParts = item.subFolder.split('/').filter(Boolean)
const pathParts: string[] = []
if (folder) {
pathParts.push(folder, ...subFolderParts)
}
pathParts.push(item.basename)
if (pathParts[0] !== 'root') {
pathParts.unshift('root')
}
let levelFolders = findFolders(dataTreeList.value)
for (const [index, part] of pathParts.entries()) {
const pathIndex = index < 2 ? 0 : item.pathIndex
const currentFolder = findFolder(levelFolders, {
basename: part,
pathIndex: pathIndex,
})
if (!currentFolder) {
break
}
levelFolders = findFolders(currentFolder.children ?? [])
folderItems.push({
name: currentFolder.basename,
pathIndex: pathIndex,
icon: index === 0 ? 'pi pi-desktop' : '',
onClick: () => {
openFolder(currentFolder)
},
children: levelFolders.map((child) => {
const name = child.basename
return {
value: name,
label: name,
command: () => openFolder(child),
}
}),
})
}
folderPaths.value = folderItems
}
watch(initialized, (val) => {
if (val) {
openFolder(dataTreeList.value[0])
}
})
return {
folders,
folderPaths,
dataTreeList,
...modelRest,
findFolder: findFolder,
findFolders: findFolders,
openFolder: openFolder,
}
}

View File

@@ -31,20 +31,26 @@ export const useGlobalLoading = defineStore('loading', () => {
return { loading } return { loading }
}) })
export const useLoading = () => { declare module 'hooks/store' {
const timer = ref<NodeJS.Timeout>() interface StoreProvider {
loading: ReturnType<typeof useGlobalLoading>
}
}
const show = () => { export const useLoading = () => {
timer.value = setTimeout(() => { const targetTimer = ref<Record<string, NodeJS.Timeout | undefined>>({})
timer.value = undefined
const show = (target: string = '_default') => {
targetTimer.value[target] = setTimeout(() => {
targetTimer.value[target] = undefined
globalLoading.show() globalLoading.show()
}, 200) }, 200)
} }
const hide = () => { const hide = (target: string = '_default') => {
if (timer.value) { if (targetTimer.value[target]) {
clearTimeout(timer.value) clearTimeout(targetTimer.value[target])
timer.value = undefined targetTimer.value[target] = undefined
} else { } else {
globalLoading.hide() globalLoading.hide()
} }

View File

@@ -1,6 +1,5 @@
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import metadata_block from 'markdown-it-metadata-block' import metadata_block from 'markdown-it-metadata-block'
import TurndownService from 'turndown'
import yaml from 'yaml' import yaml from 'yaml'
interface MarkdownOptions { interface MarkdownOptions {
@@ -31,19 +30,7 @@ export const useMarkdown = (opts?: MarkdownOptions) => {
return self.renderToken(tokens, idx, options) return self.renderToken(tokens, idx, options)
} }
const turndown = new TurndownService({ return { render: md.render.bind(md) }
headingStyle: 'atx',
bulletListMarker: '-',
})
turndown.addRule('paragraph', {
filter: 'p',
replacement: function (content) {
return `\n\n${content}`
},
})
return { render: md.render.bind(md), parse: turndown.turndown.bind(turndown) }
} }
export type MarkdownTool = ReturnType<typeof useMarkdown> export type MarkdownTool = ReturnType<typeof useMarkdown>

View File

@@ -1,66 +1,149 @@
import { useConfig } from 'hooks/config' import DialogModelDetail from 'components/DialogModelDetail.vue'
import { useLoading } from 'hooks/loading' import { useLoading } from 'hooks/loading'
import { useMarkdown } from 'hooks/markdown' import { useMarkdown } from 'hooks/markdown'
import { request, useRequest } from 'hooks/request' import { request } from 'hooks/request'
import { defineStore } from 'hooks/store' import { defineStore } from 'hooks/store'
import { useToast } from 'hooks/toast' import { useToast } from 'hooks/toast'
import { cloneDeep } from 'lodash' import { castArray, cloneDeep } from 'lodash'
import { app } from 'scripts/comfyAPI' import { TreeNode } from 'primevue/treenode'
import { api, app } from 'scripts/comfyAPI'
import { BaseModel, Model, SelectEvent, WithResolved } from 'types/typings'
import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common' import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common'
import { ModelGrid } from 'utils/legacy' import { ModelGrid } from 'utils/legacy'
import { genModelKey, resolveModelTypeLoader } from 'utils/model' import { genModelKey, resolveModelTypeLoader } from 'utils/model'
import { import {
computed, computed,
inject, inject,
InjectionKey, type InjectionKey,
MaybeRefOrGetter,
onMounted, onMounted,
provide, provide,
type Ref,
ref, ref,
toRaw, toRaw,
toValue,
unref, unref,
} from 'vue' } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { configSetting } from './config'
const systemStat = ref()
type ModelFolder = Record<string, string[]>
const modelFolderProvideKey = Symbol('modelFolder') as InjectionKey<
Ref<ModelFolder>
>
export const genModelFullName = (model: BaseModel, splitter = '/') => {
return [model.subFolder, `${model.basename}${model.extension}`]
.filter(Boolean)
.join(splitter)
}
export const genModelUrl = (model: BaseModel) => {
const fullname = genModelFullName(model)
return `/model/${model.type}/${model.pathIndex}/${fullname}`
}
export const useModels = defineStore('models', (store) => { export const useModels = defineStore('models', (store) => {
const { data, refresh } = useRequest<Model[]>('/models', { defaultValue: [] })
const { toast, confirm } = useToast() const { toast, confirm } = useToast()
const { t } = useI18n() const { t } = useI18n()
const loading = useLoading() const loading = useLoading()
const updateModel = async (model: BaseModel, data: BaseModel) => { const folders = ref<ModelFolder>({})
const formData = new FormData() const initialized = ref(false)
const refreshFolders = async () => {
return request('/models').then((resData) => {
folders.value = resData
initialized.value = true
})
}
provide(modelFolderProvideKey, folders)
const models = ref<Record<string, Model[]>>({})
const refreshModels = async (folder: string) => {
loading.show(folder)
return request(`/models/${folder}`)
.then((resData) => {
models.value[folder] = resData
return resData
})
.finally(() => {
loading.hide(folder)
})
}
const refreshAllModels = async (force = false) => {
const forceRefresh = force ? refreshFolders() : Promise.resolve()
models.value = {}
const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
configSetting.excludeScanTypes,
)
const customBlackList =
excludeScanTypes
?.split(',')
.map((type) => type.trim())
.filter(Boolean) ?? []
await forceRefresh.then(() =>
Promise.allSettled(
Object.keys(folders.value)
.filter((folder) => !customBlackList.includes(folder))
.map(refreshModels),
),
)
}
const updateModel = async (
model: BaseModel,
data: WithResolved<BaseModel>,
) => {
const updateData = new FormData()
let oldKey: string | null = null let oldKey: string | null = null
let needUpdate = false
// Check current preview // Check current preview
if (model.preview !== data.preview) { if (model.preview !== data.preview) {
const previewFile = await previewUrlToFile(data.preview as string) const preview = data.preview
formData.append('previewFile', previewFile) if (preview) {
const previewFile = await previewUrlToFile(data.preview as string)
updateData.set('previewFile', previewFile)
} else {
updateData.set('previewFile', 'undefined')
}
needUpdate = true
} }
// Check current description // Check current description
if (model.description !== data.description) { if (model.description !== data.description) {
formData.append('description', data.description) updateData.set('description', data.description)
needUpdate = true
} }
// Check current name and pathIndex // Check current name and pathIndex
if ( if (
model.fullname !== data.fullname || model.subFolder !== data.subFolder ||
model.pathIndex !== data.pathIndex model.pathIndex !== data.pathIndex
) { ) {
oldKey = genModelKey(model) oldKey = genModelKey(model)
formData.append('type', data.type) updateData.set('type', data.type)
formData.append('pathIndex', data.pathIndex.toString()) updateData.set('pathIndex', data.pathIndex.toString())
formData.append('fullname', data.fullname) updateData.set('fullname', genModelFullName(data as BaseModel))
needUpdate = true
} }
if (formData.keys().next().done) { if (!needUpdate) {
return return
} }
loading.show() loading.show()
await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
await request(genModelUrl(model), {
method: 'PUT', method: 'PUT',
body: formData, body: updateData,
}) })
.catch((err) => { .catch((err) => {
const error_message = err.message ?? err.error const error_message = err.message ?? err.error
@@ -80,7 +163,7 @@ export const useModels = defineStore('models', (store) => {
store.dialog.close({ key: oldKey }) store.dialog.close({ key: oldKey })
} }
refresh() refreshModels(data.type)
} }
const deleteModel = async (model: BaseModel) => { const deleteModel = async (model: BaseModel) => {
@@ -101,18 +184,18 @@ export const useModels = defineStore('models', (store) => {
accept: () => { accept: () => {
const dialogKey = genModelKey(model) const dialogKey = genModelKey(model)
loading.show() loading.show()
request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, { request(genModelUrl(model), {
method: 'DELETE', method: 'DELETE',
}) })
.then(() => { .then(() => {
toast.add({ toast.add({
severity: 'success', severity: 'success',
summary: 'Success', summary: 'Success',
detail: `${model.fullname} Deleted`, detail: `${model.basename} Deleted`,
life: 2000, life: 2000,
}) })
store.dialog.close({ key: dialogKey }) store.dialog.close({ key: dialogKey })
return refresh() return refreshModels(model.type)
}) })
.then(() => { .then(() => {
resolve(void 0) resolve(void 0)
@@ -136,7 +219,39 @@ export const useModels = defineStore('models', (store) => {
}) })
} }
return { data, refresh, remove: deleteModel, update: updateModel } function openModelDetail(model: BaseModel) {
const filename = model.basename.replace(model.extension, '')
store.dialog.open({
key: genModelKey(model),
title: filename,
content: DialogModelDetail,
contentProps: { model: model },
})
}
function getFullPath(model: BaseModel) {
const fullname = genModelFullName(model)
const prefixPath = folders.value[model.type]?.[model.pathIndex]
return [prefixPath, fullname].filter(Boolean).join('/')
}
onMounted(() => {
api.getSystemStats().then((res) => {
systemStat.value = res
})
})
return {
initialized: initialized,
folders: folders,
data: models,
refresh: refreshAllModels,
remove: deleteModel,
update: updateModel,
openModelDetail: openModelDetail,
getFullPath: getFullPath,
}
}) })
declare module 'hooks/store' { declare module 'hooks/store' {
@@ -164,15 +279,15 @@ export const useModelFormData = (getFormData: () => BaseModel) => {
} }
} }
type SubmitCallback = (data: BaseModel) => void type SubmitCallback = (data: WithResolved<BaseModel>) => void
const submitCallback = ref<SubmitCallback[]>([]) const submitCallback = ref<SubmitCallback[]>([])
const registerSubmit = (callback: SubmitCallback) => { const registerSubmit = (callback: SubmitCallback) => {
submitCallback.value.push(callback) submitCallback.value.push(callback)
} }
const submit = () => { const submit = (): WithResolved<BaseModel> => {
const data = cloneDeep(toRaw(unref(formData))) const data: any = cloneDeep(toRaw(unref(formData)))
for (const callback of submitCallback.value) { for (const callback of submitCallback.value) {
callback(data) callback(data)
} }
@@ -204,7 +319,10 @@ const baseInfoKey = Symbol('baseInfo') as InjectionKey<
export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => { export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
const { formData: model, modelData } = formInstance const { formData: model, modelData } = formInstance
const { modelFolders } = useConfig() const provideModelFolders = inject(modelFolderProvideKey)
const modelFolders = computed<ModelFolder>(() => {
return provideModelFolders?.value ?? {}
})
const type = computed({ const type = computed({
get: () => { get: () => {
@@ -224,16 +342,25 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
}, },
}) })
const subFolder = computed({
get: () => {
return model.value.subFolder
},
set: (val) => {
model.value.subFolder = val
},
})
const extension = computed(() => { const extension = computed(() => {
return model.value.extension return model.value.extension
}) })
const basename = computed({ const basename = computed({
get: () => { get: () => {
return model.value.fullname.replace(model.value.extension, '') return model.value.basename
}, },
set: (val) => { set: (val) => {
model.value.fullname = `${val ?? ''}${model.value.extension}` model.value.basename = val
}, },
}) })
@@ -245,27 +372,35 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
interface FieldsItem { interface FieldsItem {
key: keyof Model key: keyof Model
formatter: (val: any) => string formatter: (val: any) => string | undefined | null
} }
const baseInfo = computed(() => { const baseInfo = computed(() => {
const fields: FieldsItem[] = [ const fields: FieldsItem[] = [
{ {
key: 'type', key: 'type',
formatter: () => modelData.value.type, formatter: () =>
modelData.value.type in modelFolders.value
? modelData.value.type
: undefined,
}, },
{ {
key: 'pathIndex', key: 'pathIndex',
formatter: () => { formatter: () => {
const modelType = modelData.value.type const modelType = model.value.type
const pathIndex = modelData.value.pathIndex const pathIndex = model.value.pathIndex
if (!modelType) {
return undefined
}
const folders = modelFolders.value[modelType] ?? [] const folders = modelFolders.value[modelType] ?? []
return `${folders[pathIndex]}` return [`${folders[pathIndex]}`, model.value.subFolder]
.filter(Boolean)
.join('/')
}, },
}, },
{ {
key: 'fullname', key: 'basename',
formatter: (val) => val, formatter: (val) => `${val}${model.value.extension}`,
}, },
{ {
key: 'sizeBytes', key: 'sizeBytes',
@@ -300,7 +435,9 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
baseInfo, baseInfo,
basename, basename,
extension, extension,
subFolder,
pathIndex, pathIndex,
modelFolders,
} }
provide(baseInfoKey, result) provide(baseInfoKey, result)
@@ -312,6 +449,74 @@ export const useModelBaseInfo = () => {
return inject(baseInfoKey)! return inject(baseInfoKey)!
} }
export const useModelFolder = (
option: {
type?: MaybeRefOrGetter<string | undefined>
} = {},
) => {
const { data: models, folders: modelFolders } = useModels()
const pathOptions = computed(() => {
const type = toValue(option.type)
if (!type) {
return []
}
const folderItems = cloneDeep(models.value[type]) ?? []
const pureFolders = folderItems.filter((item) => item.isFolder)
pureFolders.sort((a, b) => a.basename.localeCompare(b.basename))
const folders = modelFolders.value[type] ?? []
const root: TreeNode[] = []
for (const [index, folder] of folders.entries()) {
const pathIndexItem: TreeNode = {
key: folder,
label: folder,
children: [],
}
const items = pureFolders
.filter((item) => item.pathIndex === index)
.map((item) => {
const node: TreeNode = {
key: `${folder}/${genModelFullName(item)}`,
label: item.basename,
data: item,
}
return node
})
const itemMap = Object.fromEntries(items.map((item) => [item.key, item]))
for (const item of items) {
const key = item.key
const parentKey = key.split('/').slice(0, -1).join('/')
if (parentKey === folder) {
pathIndexItem.children!.push(item)
continue
}
const parentItem = itemMap[parentKey]
if (parentItem) {
parentItem.children ??= []
parentItem.children.push(item)
}
}
root.push(pathIndexItem)
}
return root
})
return {
pathOptions,
}
}
/** /**
* Editable preview image. * Editable preview image.
* *
@@ -335,9 +540,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
* Default images * Default images
*/ */
const defaultContent = computed(() => { const defaultContent = computed(() => {
return Array.isArray(model.value.preview) return model.value.preview ? castArray(model.value.preview) : []
? model.value.preview
: [model.value.preview]
}) })
const defaultContentPage = ref(0) const defaultContentPage = ref(0)
@@ -350,16 +553,19 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
* Local file url * Local file url
*/ */
const localContent = ref<string>() const localContent = ref<string>()
const localContentType = ref<string>()
const updateLocalContent = async (event: SelectEvent) => { const updateLocalContent = async (event: SelectEvent) => {
const { files } = event const { files } = event
localContent.value = files[0].objectURL localContent.value = files[0].objectURL
localContentType.value = files[0].type
} }
/** /**
* No preview * No preview
*/ */
const noPreviewContent = computed(() => { const noPreviewContent = computed(() => {
return `/model-manager/preview/${model.value.type}/0/no-preview.png` const folder = model.value.type || 'unknown'
return `/model-manager/preview/${folder}/0/no-preview.png`
}) })
const preview = computed(() => { const preview = computed(() => {
@@ -376,7 +582,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
content = localContent.value content = localContent.value
break break
default: default:
content = noPreviewContent.value content = undefined
break break
} }
@@ -389,10 +595,11 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
defaultContentPage.value = 0 defaultContentPage.value = 0
networkContent.value = undefined networkContent.value = undefined
localContent.value = undefined localContent.value = undefined
localContentType.value = undefined
}) })
registerSubmit((data) => { registerSubmit((data) => {
data.preview = preview.value ?? noPreviewContent.value data.preview = preview.value
}) })
}) })
@@ -407,6 +614,7 @@ export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
networkContent, networkContent,
// local file // local file
localContent, localContent,
localContentType,
updateLocalContent, updateLocalContent,
// no preview // no preview
noPreviewContent, noPreviewContent,
@@ -482,11 +690,11 @@ export const useModelMetadata = () => {
return inject(metadataKey)! return inject(metadataKey)!
} }
export const useModelNodeAction = (model: BaseModel) => { export const useModelNodeAction = () => {
const { t } = useI18n() const { t } = useI18n()
const { toast, wrapperToastError } = useToast() const { toast, wrapperToastError } = useToast()
const createNode = (options: Record<string, any> = {}) => { const createNode = (model: BaseModel, options: Record<string, any> = {}) => {
const nodeType = resolveModelTypeLoader(model.type) const nodeType = resolveModelTypeLoader(model.type)
if (!nodeType) { if (!nodeType) {
throw new Error(t('unSupportedModelType', [model.type])) throw new Error(t('unSupportedModelType', [model.type]))
@@ -495,50 +703,53 @@ export const useModelNodeAction = (model: BaseModel) => {
const node = window.LiteGraph.createNode(nodeType, null, options) const node = window.LiteGraph.createNode(nodeType, null, options)
const widgetIndex = node.widgets.findIndex((w) => w.type === 'combo') const widgetIndex = node.widgets.findIndex((w) => w.type === 'combo')
if (widgetIndex > -1) { if (widgetIndex > -1) {
node.widgets[widgetIndex].value = model.fullname node.widgets[widgetIndex].value = genModelFullName(model)
} }
return node return node
} }
const dragToAddModelNode = wrapperToastError((event: DragEvent) => { const dragToAddModelNode = wrapperToastError(
// const target = document.elementFromPoint(event.clientX, event.clientY) (model: BaseModel, event: DragEvent) => {
// if ( // const target = document.elementFromPoint(event.clientX, event.clientY)
// target?.tagName.toLocaleLowerCase() === 'canvas' && // if (
// target.id === 'graph-canvas' // target?.tagName.toLocaleLowerCase() === 'canvas' &&
// ) { // target.id === 'graph-canvas'
// const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY]) // ) {
// const node = createNode({ pos }) // const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY])
// app.graph.add(node) // const node = createNode({ pos })
// app.canvas.selectNode(node) // app.graph.add(node)
// } // app.canvas.selectNode(node)
// // }
// Use the legacy method instead //
const removeEmbeddingExtension = true // Use the legacy method instead
const strictDragToAdd = false const removeEmbeddingExtension = true
const strictDragToAdd = false
const splitter = systemStat.value?.system.os === 'nt' ? '\\' : '/'
ModelGrid.dragAddModel( ModelGrid.dragAddModel(
event, event,
model.type, model.type,
model.fullname, genModelFullName(model, splitter),
removeEmbeddingExtension, removeEmbeddingExtension,
strictDragToAdd, strictDragToAdd,
) )
}) },
)
const addModelNode = wrapperToastError(() => { const addModelNode = wrapperToastError((model: BaseModel) => {
const selectedNodes = app.canvas.selected_nodes const selectedNodes = app.canvas.selected_nodes
const firstSelectedNode = Object.values(selectedNodes)[0] const firstSelectedNode = Object.values(selectedNodes)[0]
const offset = 25 const offset = 25
const pos = firstSelectedNode const pos = firstSelectedNode
? [firstSelectedNode.pos[0] + offset, firstSelectedNode.pos[1] + offset] ? [firstSelectedNode.pos[0] + offset, firstSelectedNode.pos[1] + offset]
: app.canvas.canvas_mouse : app.canvas.canvas_mouse
const node = createNode({ pos }) const node = createNode(model, { pos })
app.graph.add(node) app.graph.add(node)
app.canvas.selectNode(node) app.canvas.selectNode(node)
}) })
const copyModelNode = wrapperToastError(() => { const copyModelNode = wrapperToastError((model: BaseModel) => {
const node = createNode() const node = createNode(model)
app.canvas.copyToClipboard([node]) app.canvas.copyToClipboard([node])
toast.add({ toast.add({
severity: 'success', severity: 'success',
@@ -548,13 +759,13 @@ export const useModelNodeAction = (model: BaseModel) => {
}) })
}) })
const loadPreviewWorkflow = wrapperToastError(async () => { const loadPreviewWorkflow = wrapperToastError(async (model: BaseModel) => {
const previewUrl = model.preview as string const previewUrl = model.preview as string
const response = await fetch(previewUrl) const response = await fetch(previewUrl)
const data = await response.blob() const data = await response.blob()
const type = data.type const type = data.type
const extension = type.split('/').pop() const extension = type.split('/').pop()
const file = new File([data], `${model.fullname}.${extension}`, { type }) const file = new File([data], `${model.basename}.${extension}`, { type })
app.handleFile(file) app.handleFile(file)
}) })

View File

@@ -1,22 +0,0 @@
import { throttle } from 'lodash'
import { Directive } from 'vue'
export const resizeDirective: Directive<HTMLElement, ResizeObserverCallback> = {
mounted: (el, binding) => {
const callback = binding.value ?? (() => {})
const observer = new ResizeObserver(callback)
observer.observe(el)
el['observer'] = observer
},
unmounted: (el) => {
const observer = el['observer']
observer.disconnect()
},
}
export const defineResizeCallback = (
callback: ResizeObserverCallback,
wait?: number,
) => {
return throttle(callback, wait ?? 100)
}

View File

@@ -1,82 +0,0 @@
import { globalToast } from 'hooks/toast'
import { readonly } from 'vue'
class WebSocketEvent extends EventTarget {
private socket: WebSocket | null
constructor() {
super()
this.createSocket()
}
private createSocket(isReconnect?: boolean) {
const api_host = location.host
const api_base = location.pathname.split('/').slice(0, -1).join('/')
let opened = false
let existingSession = window.name
if (existingSession) {
existingSession = '?clientId=' + existingSession
}
this.socket = readonly(
new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${api_host}${api_base}/model-manager/ws${existingSession}`,
),
)
this.socket.addEventListener('open', () => {
opened = true
if (isReconnect) {
this.dispatchEvent(new CustomEvent('reconnected'))
}
})
this.socket.addEventListener('error', () => {
if (this.socket) this.socket.close()
})
this.socket.addEventListener('close', (event) => {
setTimeout(() => {
this.socket = null
this.createSocket(true)
}, 300)
if (opened) {
this.dispatchEvent(new CustomEvent('status', { detail: null }))
this.dispatchEvent(new CustomEvent('reconnecting'))
}
})
this.socket.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'error') {
globalToast.value?.add({
severity: 'error',
summary: 'Error',
detail: msg.data,
life: 15000,
})
} else {
this.dispatchEvent(new CustomEvent(msg.type, { detail: msg.data }))
}
} catch (error) {
console.error(error)
}
})
}
addEventListener = (
type: string,
callback: CustomEventListener | null,
options?: AddEventListenerOptions | boolean,
) => {
super.addEventListener(type, callback, options)
}
send(type: string, data: any) {
this.socket?.send(JSON.stringify({ type, detail: data }))
}
}
export const socket = new WebSocketEvent()

View File

@@ -13,7 +13,7 @@ export const useStoreProvider = () => {
return storeEvent return storeEvent
} }
const storeKeys = new Map<string, Symbol>() const storeKeys = new Map<string, symbol>()
const getStoreKey = (key: string) => { const getStoreKey = (key: string) => {
let storeKey = storeKeys.get(key) let storeKey = storeKeys.get(key)

View File

@@ -12,7 +12,7 @@ export const useToast = () => {
globalToast.value = toast globalToast.value = toast
const wrapperToastError = <T extends Function>(callback: T): T => { const wrapperToastError = <T extends CallableFunction>(callback: T): T => {
const showToast = (error: Error) => { const showToast = (error: Error) => {
toast.add({ toast.add({
severity: 'error', severity: 'error',

View File

@@ -1,91 +1,21 @@
import { app } from 'scripts/comfyAPI'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import en from './locales/en.json'
import zh from './locales/zh.json'
const messages = { const messages = {
en: { en: en,
model: 'Model', zh: zh,
modelManager: 'Model Manager',
openModelManager: 'Open Model Manager',
searchModels: 'Search models',
modelCopied: 'Model Copied',
download: 'Download',
downloadList: 'Download List',
downloadTask: 'Download Task',
createDownloadTask: 'Create Download Task',
parseModelUrl: 'Parse Model URL',
pleaseInputModelUrl: 'Input a URL from civitai.com or huggingface.co',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
deleteAsk: 'Confirm delete this {0}?',
modelType: 'Model Type',
default: 'Default',
network: 'Network',
local: 'Local',
none: 'None',
uploadFile: 'Upload File',
tapToChange: 'Tap description to change content',
sort: {
name: 'Name',
size: 'Largest',
created: 'Latest created',
modified: 'Latest modified',
},
info: {
type: 'Model Type',
pathIndex: 'Directory',
fullname: 'File Name',
sizeBytes: 'File Size',
createdAt: 'Created At',
updatedAt: 'Updated At',
},
},
zh: {
model: '模型',
modelManager: '模型管理器',
openModelManager: '打开模型管理器',
searchModels: '搜索模型',
modelCopied: '模型节点已拷贝',
download: '下载',
downloadList: '下载列表',
downloadTask: '下载任务',
createDownloadTask: '创建下载任务',
parseModelUrl: '解析模型URL',
pleaseInputModelUrl: '输入 civitai.com 或 huggingface.co 的 URL',
cancel: '取消',
save: '保存',
delete: '删除',
deleteAsk: '确定要删除此{0}',
modelType: '模型类型',
default: '默认',
network: '网络',
local: '本地',
none: '无',
uploadFile: '上传文件',
tapToChange: '点击描述可更改内容',
sort: {
name: '名称',
size: '最大',
created: '最新创建',
modified: '最新修改',
},
info: {
type: '类型',
pathIndex: '目录',
fullname: '文件名',
sizeBytes: '文件大小',
createdAt: '创建时间',
updatedAt: '更新时间',
},
},
} }
const getLocalLanguage = () => { const getLocalLanguage = () => {
const local = const local =
localStorage.getItem('Comfy.Settings.Comfy.Locale') || app.ui?.settings.getSettingValue<string>('Comfy.Locale') ||
navigator.language.split('-')[0] || navigator.language.split('-')[0] ||
'en' 'en'
return local.replace(/['"]/g, '') return local
} }
export const i18n = createI18n({ export const i18n = createI18n({

77
src/locales/en.json Normal file
View File

@@ -0,0 +1,77 @@
{
"model": "Model",
"modelManager": "Model Manager",
"openModelManager": "Open Model Manager",
"searchModels": "Search models",
"modelCopied": "Model Copied",
"download": "Download",
"downloadList": "Download List",
"downloadTask": "Download Task",
"createDownloadTask": "Create Download Task",
"parseModelUrl": "Parse Model URL",
"pleaseInputModelUrl": "Input a URL from civitai.com or huggingface.co",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"deleteAsk": "Confirm delete this {0}?",
"modelType": "Model Type",
"default": "Default",
"network": "Network",
"local": "Local",
"none": "None",
"uploadFile": "Upload File",
"tapToChange": "Tap description to change content",
"name": "Name",
"width": "Width",
"height": "Height",
"reset": "Reset",
"back": "Back",
"next": "Next",
"batchScanModelInformation": "Batch scan model information",
"modelInformationScanning": "Scanning model information",
"selectModelType": "Select model type",
"selectSubdirectory": "Select subdirectory",
"scanModelInformation": "Scan model information",
"selectedAllPaths": "Selected all model paths",
"selectedSpecialPath": "Selected special path",
"scanMissInformation": "Download missing information",
"scanFullInformation": "Override full information",
"noModelsInCurrentPath": "There are no models available in the current path",
"uploadModel": "Upload Model",
"chooseFile": "Choose File",
"sort": {
"name": "Name",
"size": "Largest",
"created": "Latest created",
"modified": "Latest modified"
},
"size": {
"extraLarge": "Extra Large Icons",
"large": "Large Icons",
"medium": "Medium Icons",
"small": "Small Icons",
"custom": "Custom Size",
"customTip": "Set in `Settings > Model Manager > UI`"
},
"info": {
"type": "Model Type",
"pathIndex": "Directory",
"basename": "File Name",
"sizeBytes": "File Size",
"createdAt": "Created At",
"updatedAt": "Updated At"
},
"setting": {
"apiKey": "API Key",
"cardHeight": "Card Height",
"cardWidth": "Card Width",
"scan": "Scan",
"scanMissing": "Download missing information or preview",
"scanAll": "Override all models' information and preview",
"includeHiddenFiles": "Include hidden files(start with .)",
"excludeScanTypes": "Exclude scan types (separate with commas)",
"ui": "UI",
"cardSize": "Card Size",
"useFlatUI": "Flat Layout"
}
}

77
src/locales/zh.json Normal file
View File

@@ -0,0 +1,77 @@
{
"model": "模型",
"modelManager": "模型管理器",
"openModelManager": "打开模型管理器",
"searchModels": "搜索模型",
"modelCopied": "模型节点已拷贝",
"download": "下载",
"downloadList": "下载列表",
"downloadTask": "下载任务",
"createDownloadTask": "创建下载任务",
"parseModelUrl": "解析模型URL",
"pleaseInputModelUrl": "输入 civitai.com 或 huggingface.co 的 URL",
"cancel": "取消",
"save": "保存",
"delete": "删除",
"deleteAsk": "确定要删除此{0}",
"modelType": "模型类型",
"default": "默认",
"network": "网络",
"local": "本地",
"none": "无",
"uploadFile": "上传文件",
"tapToChange": "点击描述可更改内容",
"name": "名称",
"width": "宽度",
"height": "高度",
"reset": "重置",
"back": "返回",
"next": "下一步",
"batchScanModelInformation": "批量扫描模型信息",
"modelInformationScanning": "扫描模型信息",
"selectModelType": "选择模型类型",
"selectSubdirectory": "选择子目录",
"scanModelInformation": "扫描模型信息",
"selectedAllPaths": "已选所有模型路径",
"selectedSpecialPath": "已选指定路径",
"scanMissInformation": "下载缺失信息",
"scanFullInformation": "覆盖所有信息",
"noModelsInCurrentPath": "当前路径中没有可用的模型",
"uploadModel": "上传模型",
"chooseFile": "选择文件",
"sort": {
"name": "名称",
"size": "最大",
"created": "最新创建",
"modified": "最新修改"
},
"size": {
"extraLarge": "超大图标",
"large": "大图标",
"medium": "中等图标",
"small": "小图标",
"custom": "自定义尺寸",
"customTip": "在 `设置 > 模型管理器 > 外观` 中设置"
},
"info": {
"type": "类型",
"pathIndex": "目录",
"basename": "文件名",
"sizeBytes": "文件大小",
"createdAt": "创建时间",
"updatedAt": "更新时间"
},
"setting": {
"apiKey": "密钥",
"cardHeight": "卡片高度",
"cardWidth": "卡片宽度",
"scan": "扫描",
"scanMissing": "下载缺失的信息或预览图片",
"scanAll": "覆盖所有模型信息和预览图片",
"includeHiddenFiles": "包含隐藏文件(以 . 开头的文件或文件夹)",
"excludeScanTypes": "排除扫描类型(使用英文逗号隔开)",
"ui": "外观",
"cardSize": "卡片尺寸",
"useFlatUI": "展平布局"
}
}

View File

@@ -1,6 +1,5 @@
import { definePreset } from '@primevue/themes' import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura' import Aura from '@primevue/themes/aura'
import { resizeDirective } from 'hooks/resize'
import PrimeVue from 'primevue/config' import PrimeVue from 'primevue/config'
import ConfirmationService from 'primevue/confirmationservice' import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice' import ToastService from 'primevue/toastservice'
@@ -20,7 +19,6 @@ const ComfyUIPreset = definePreset(Aura, {
function createVueApp(rootContainer: string | HTMLElement) { function createVueApp(rootContainer: string | HTMLElement) {
const app = createApp(App) const app = createApp(App)
app.directive('tooltip', Tooltip) app.directive('tooltip', Tooltip)
app.directive('resize', resizeDirective)
app app
.use(PrimeVue, { .use(PrimeVue, {
theme: { theme: {

View File

@@ -5,3 +5,4 @@ export const $el = window.comfyAPI.ui.$el
export const ComfyApp = window.comfyAPI.app.ComfyApp export const ComfyApp = window.comfyAPI.app.ComfyApp
export const ComfyButton = window.comfyAPI.button.ComfyButton export const ComfyButton = window.comfyAPI.button.ComfyButton
export const ComfyDialog = window.comfyAPI.dialog.ComfyDialog

View File

@@ -3,220 +3,4 @@
@layer tailwind-utilities { @layer tailwind-utilities {
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
*.border,
*.border-x,
*.border-y,
*.border-l,
*.border-t,
*.border-r,
*.border-b {
border-style: solid;
}
table,
th,
tr,
td {
border-width: 0px;
}
}
.comfy-modal {
z-index: 3000;
}
.markdown-it {
font-family: theme('fontFamily.sans');
font-size: theme('fontSize.base');
line-height: theme('lineHeight.relaxed');
word-break: break-word;
margin: 0;
&::before {
display: table;
content: '';
}
&::after {
display: table;
content: '';
clear: both;
}
> *:first-child {
margin-top: 0 !important;
}
> *:last-child {
margin-bottom: 0 !important;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 1.5em;
margin-bottom: 1em;
font-weight: 600;
line-height: 1.25;
}
h1 {
font-size: 2em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--p-surface-700);
}
h2 {
font-size: 1.5em;
padding-bottom: 0.3em;
border-bottom: 1px solid var(--p-surface-700);
}
h3 {
font-size: 1.25em;
}
h4 {
font-size: 1em;
}
h5 {
font-size: 0.875em;
}
h6 {
font-size: 0.85em;
color: var(--p-surface-500);
}
a {
color: #1e8bc3;
text-decoration: none;
word-break: break-all;
}
a:hover {
text-decoration: underline;
}
p,
blockquote,
ul,
ol,
dl,
table,
pre,
details {
margin-top: 0;
margin-bottom: 1em;
}
p img {
width: 100%;
height: 100%;
object-fit: cover;
}
ul,
ol {
padding-left: 2em;
}
li {
margin: 0.5em 0;
}
blockquote {
padding: 0px 1em;
border-left: 0.25em solid var(--p-surface-500);
color: var(--p-surface-500);
margin: 1em 0;
}
blockquote > *:first-child {
margin-top: 0;
}
blockquote > *:last-child {
margin-bottom: 0;
}
pre {
font-size: 85%;
border-radius: 6px;
padding: 8px 16px;
overflow-x: auto;
background: var(--p-dialog-background);
filter: invert(10%);
}
pre code,
pre tt {
display: inline;
padding: 0;
margin: 0;
overflow: visible;
line-height: inherit;
word-wrap: normal;
background-color: transparent;
border: 0;
}
} }

25
src/types/global.d.ts vendored
View File

@@ -1,6 +1,10 @@
declare namespace ComfyAPI { declare namespace ComfyAPI {
namespace api { namespace api {
class ComfyApi { class ComfyApiEvent {
getSystemStats: () => Promise<any>
}
class ComfyApi extends ComfyApiEvent {
socket: WebSocket socket: WebSocket
fetchApi: (route: string, options?: RequestInit) => Promise<Response> fetchApi: (route: string, options?: RequestInit) => Promise<Response>
addEventListener: ( addEventListener: (
@@ -8,6 +12,11 @@ declare namespace ComfyAPI {
callback: (event: CustomEvent) => void, callback: (event: CustomEvent) => void,
options?: AddEventListenerOptions, options?: AddEventListenerOptions,
) => void ) => void
removeEventListener: (
type: string,
callback: (event: CustomEvent) => void,
options?: AddEventListenerOptions,
) => void
} }
const api: ComfyApi const api: ComfyApi
@@ -112,6 +121,7 @@ declare namespace ComfyAPI {
settings: ComfySettingsDialog settings: ComfySettingsDialog
menuHamburger?: HTMLDivElement menuHamburger?: HTMLDivElement
menuContainer?: HTMLDivElement menuContainer?: HTMLDivElement
dialog: dialog.ComfyDialog
} }
type SettingInputType = type SettingInputType =
@@ -154,8 +164,10 @@ declare namespace ComfyAPI {
deprecated?: boolean deprecated?: boolean
} }
class ComfySettingsDialog { class ComfySettingsDialog extends dialog.ComfyDialog {
addSetting: (params: SettingParams) => { value: any } addSetting: (params: SettingParams) => { value: any }
getSettingValue: <T>(id: string, defaultValue?: T) => T
setSettingValue: <T>(id: string, value: T) => void
} }
} }
@@ -197,6 +209,15 @@ declare namespace ComfyAPI {
constructor(...buttons: (HTMLElement | ComfyButton)[]): ComfyButtonGroup constructor(...buttons: (HTMLElement | ComfyButton)[]): ComfyButtonGroup
} }
} }
namespace dialog {
class ComfyDialog {
constructor(type = 'div', buttons: HTMLElement[] = null)
element: HTMLElement
close(): void
show(html: string | HTMLElement | HTMLElement[]): void
}
}
} }
declare namespace lightGraph { declare namespace lightGraph {

View File

@@ -1,11 +1,5 @@
export {} export {}
declare module 'vue' {
interface ComponentCustomProperties {
vResize: (typeof import('hooks/resize'))['resizeDirective']
}
}
declare module 'hooks/store' { declare module 'hooks/store' {
interface StoreProvider {} interface StoreProvider {}
} }

View File

@@ -1,50 +1,67 @@
type ContainerSize = { width: number; height: number } export type ContainerSize = { width: number; height: number }
type ContainerPosition = { left: number; top: number } export type ContainerPosition = { left: number; top: number }
interface BaseModel { export interface BaseModel {
id: number | string id: number | string
fullname: string
basename: string basename: string
extension: string extension: string
sizeBytes: number sizeBytes: number
type: string type: string
subFolder: string
pathIndex: number pathIndex: number
isFolder: boolean
preview: string | string[] preview: string | string[]
description: string description: string
metadata: Record<string, string> metadata: Record<string, string>
} }
interface Model extends BaseModel { export interface Model extends BaseModel {
createdAt: number createdAt: number
updatedAt: number updatedAt: number
children?: Model[]
} }
interface VersionModel extends BaseModel { export interface VersionModelFile {
id: number
sizeKB: number
name: string
type: string
metadata: Record<string, string>
hashes: Record<string, string>
downloadUrl: string
}
export interface VersionModel extends BaseModel {
shortname: string shortname: string
downloadPlatform: string downloadPlatform: string
downloadUrl: string downloadUrl: string
hashes?: Record<string, string> hashes?: Record<string, string>
files?: VersionModelFile[]
} }
type PassThrough<T = void> = T | object | undefined export type WithResolved<T> = Omit<T, 'preview'> & {
preview: string | undefined
}
interface SelectOptions { export type PassThrough<T = void> = T | object | undefined
export interface SelectOptions {
label: string label: string
value: any value: any
icon?: string icon?: string
command: () => void command: () => void
} }
interface SelectFile extends File { export interface SelectFile extends File {
objectURL: string objectURL: string
} }
interface SelectEvent { export interface SelectEvent {
files: SelectFile[] files: SelectFile[]
originalEvent: Event originalEvent: Event
} }
interface DownloadTaskOptions { export interface DownloadTaskOptions {
taskId: string taskId: string
type: string type: string
fullname: string fullname: string
@@ -57,7 +74,7 @@ interface DownloadTaskOptions {
error?: string error?: string
} }
interface DownloadTask export interface DownloadTask
extends Omit< extends Omit<
DownloadTaskOptions, DownloadTaskOptions,
'downloadedSize' | 'totalSize' | 'bps' | 'error' 'downloadedSize' | 'totalSize' | 'bps' | 'error'
@@ -69,4 +86,4 @@ interface DownloadTask
deleteTask: () => void deleteTask: () => void
} }
type CustomEventListener = (event: CustomEvent) => void export type CustomEventListener = (event: CustomEvent) => void

View File

@@ -1,4 +1,3 @@
// @ts-nocheck
import { app } from 'scripts/comfyAPI' import { app } from 'scripts/comfyAPI'
const LiteGraph = window.LiteGraph const LiteGraph = window.LiteGraph

53
src/utils/media.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Media file utility functions
*/
const VIDEO_EXTENSIONS = [
'.mp4',
'.webm',
'.mov',
'.avi',
'.mkv',
'.flv',
'.wmv',
'.m4v',
'.ogv',
]
const VIDEO_HOST_PATTERNS = [
'/video', // Civitai video URLs often end with /video
'type=video', // URLs with video type parameter
'format=video', // URLs with video format parameter
'video.civitai.com', // Civitai video domain
]
/**
* Detect if a URL points to a video based on extension or URL patterns
* @param url - The URL to check
* @param localContentType - Optional MIME type for local files
*/
export const isVideoUrl = (url: string, localContentType?: string): boolean => {
if (!url) return false
// For local files with known MIME type
if (localContentType && localContentType.startsWith('video/')) {
return true
}
const urlLower = url.toLowerCase()
// First check if URL ends with a video extension
for (const ext of VIDEO_EXTENSIONS) {
if (urlLower.endsWith(ext)) {
return true
}
}
// Check if URL contains a video extension anywhere (for complex URLs like Civitai)
if (VIDEO_EXTENSIONS.some((ext) => urlLower.includes(ext))) {
return true
}
// Check for specific video hosting patterns
return VIDEO_HOST_PATTERNS.some((pattern) => urlLower.includes(pattern))
}

View File

@@ -1,3 +1,5 @@
import { BaseModel } from 'types/typings'
const loader = { const loader = {
checkpoints: 'CheckpointLoaderSimple', checkpoints: 'CheckpointLoaderSimple',
loras: 'LoraLoader', loras: 'LoraLoader',
@@ -23,5 +25,5 @@ export const resolveModelTypeLoader = (type: string) => {
} }
export const genModelKey = (model: BaseModel) => { export const genModelKey = (model: BaseModel) => {
return `${model.type}:${model.pathIndex}:${model.fullname}` return `${model.type}:${model.pathIndex}:${model.subFolder}:${model.basename}${model.extension}`
} }

View File

@@ -1,4 +1,3 @@
import container from '@tailwindcss/container-queries'
import plugin from 'tailwindcss/plugin' import plugin from 'tailwindcss/plugin'
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
@@ -8,9 +7,11 @@ export default {
darkMode: ['selector', '.dark-theme'], darkMode: ['selector', '.dark-theme'],
plugins: [ plugins: [
container,
plugin(({ addUtilities }) => { plugin(({ addUtilities }) => {
addUtilities({ addUtilities({
'.text-shadow': {
'text-shadow': '2px 2px 4px rgba(0, 0, 0, 0.5)',
},
'.scrollbar-none': { '.scrollbar-none': {
'scrollbar-width': 'none', 'scrollbar-width': 'none',
}, },

View File

@@ -11,6 +11,7 @@
"moduleResolution": "Node", "moduleResolution": "Node",
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"resolveJsonModule": true,
/* Linting */ /* Linting */
"strict": false, "strict": false,
@@ -20,21 +21,13 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"downlevelIteration": true, "downlevelIteration": true,
/* AllowJs during migration phase */
"allowJs": true,
"baseUrl": ".",
"outDir": "./web",
"rootDir": "./",
"paths": { "paths": {
"components/*": ["src/components/*"], "components/*": ["./src/components/*"],
"hooks/*": ["src/hooks/*"], "hooks/*": ["./src/hooks/*"],
"scripts/*": ["src/scripts/*"], "scripts/*": ["./src/scripts/*"],
"types/*": ["src/types/*"], "types/*": ["./src/types/*"],
"utils/*": ["src/utils/*"], "utils/*": ["./src/utils/*"]
} }
}, },
"include": [ "include": ["./src/**/*"]
"src/**/*", }
"src/**/*.vue",
]
}

View File

@@ -114,7 +114,7 @@ export default defineConfig({
outDir: 'web', outDir: 'web',
minify: 'esbuild', minify: 'esbuild',
target: 'es2022', target: 'es2022',
sourcemap: true, sourcemap: false,
rollupOptions: { rollupOptions: {
// Disabling tree-shaking // Disabling tree-shaking
// Prevent vite remove unused exports // Prevent vite remove unused exports