Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e88a77f224 | ||
|
|
f3de2006ef | ||
|
|
0295dd6288 | ||
|
|
4f9a437725 | ||
|
|
815a483cf0 | ||
|
|
ae37765017 | ||
|
|
ebef300279 | ||
|
|
38cd328e57 | ||
|
|
71a200ed5c | ||
|
|
c96a164f68 | ||
|
|
0ae0716272 | ||
|
|
b692270f87 | ||
|
|
a9675a5d83 | ||
|
|
ac4a168f13 | ||
|
|
8b9f3a0e65 | ||
|
|
8d7e32eaf6 | ||
|
|
e964f26798 | ||
|
|
3cfbb5ac0e | ||
|
|
4472357537 | ||
|
|
aabf3f99b3 | ||
|
|
6bd6b19c1d | ||
|
|
411219df7d | ||
|
|
cc29349aee | ||
|
|
f639e3c795 | ||
|
|
5251eeaa93 | ||
|
|
3bfc6c28af | ||
|
|
c91eff16ae | ||
|
|
2d638a3451 | ||
|
|
280b6ed7c0 | ||
|
|
7de73ae09c | ||
|
|
0fdea64c79 | ||
|
|
2b9327e6ca | ||
|
|
c33b4e0333 | ||
|
|
6dcaed7764 | ||
|
|
ab4e0d38e1 | ||
|
|
581d2c14fc | ||
|
|
811f1bc352 | ||
|
|
5342b7ec92 | ||
|
|
30e1714397 | ||
|
|
384a106917 | ||
|
|
7378a7deae | ||
|
|
1975e2056d | ||
|
|
8877c1599b | ||
|
|
965905305e | ||
|
|
312138f981 | ||
|
|
76df8cd3cb | ||
|
|
df17eae0a2 | ||
|
|
7df89c7265 | ||
|
|
450072e49d | ||
|
|
759865e8ea | ||
|
|
304978a7b8 | ||
|
|
704f35a1a8 | ||
|
|
ce42960d57 | ||
|
|
05fa31f2c5 | ||
|
|
ea26ec5098 | ||
|
|
3d01c2dfda | ||
|
|
59552841e7 | ||
|
|
ad6045f286 | ||
|
|
86c11e5343 | ||
|
|
37be9a0b0d | ||
|
|
fcea052dde | ||
|
|
9e95e7bd74 | ||
|
|
7e58d0a82d | ||
|
|
55a4eff01b | ||
|
|
45cf18299f | ||
|
|
c7898c47f1 | ||
|
|
17ab373b9c | ||
|
|
f6368fe20b | ||
|
|
92f2d5ab9e | ||
|
|
130c75f5bf | ||
|
|
921dabc057 | ||
|
|
ac21c8015d | ||
|
|
123b46fa88 | ||
|
|
6a77554932 | ||
|
|
faf4c15865 | ||
|
|
f079d8bde5 | ||
|
|
56a2deb4eb | ||
|
|
448ea4b1ba | ||
|
|
e5d9950429 | ||
|
|
e7e2f4ce78 | ||
|
|
0575124d35 | ||
|
|
4df226be82 | ||
|
|
1ba80fab2e | ||
|
|
b9e637049a | ||
|
|
bfccc6f04f | ||
|
|
89c249542a | ||
|
|
136bc0ecd5 | ||
|
|
8653af1f14 | ||
|
|
354b5c840a | ||
|
|
be383ac6e1 | ||
|
|
c2406a1fd1 | ||
|
|
4132b2d8c4 | ||
|
|
40a1a7f43a | ||
|
|
14bb6f194d | ||
|
|
97b26549ce | ||
|
|
e75275dfff | ||
|
|
c1e89eb177 | ||
|
|
bfedcb2a7d | ||
|
|
1d01ce009f | ||
|
|
5c017137b0 | ||
|
|
00d23ff74f | ||
|
|
dc46f498be | ||
|
|
6d67b00b17 | ||
|
|
cda24405b5 | ||
|
|
6fa90be8c4 | ||
|
|
5a28789af7 | ||
|
|
dada903b2b | ||
|
|
e8916307aa | ||
|
|
8b6c6ebdea | ||
|
|
1796b101c5 | ||
|
|
bd874e5ff3 | ||
|
|
6a64f3050a | ||
|
|
659637c6e0 | ||
|
|
6ae7e1835f | ||
|
|
4038e240f0 | ||
|
|
254ad8c597 | ||
|
|
dfae915b77 | ||
|
|
f57ffc9e7a | ||
|
|
6904aca24c | ||
|
|
e36af38375 | ||
|
|
d4922f59d3 | ||
|
|
f2e17744ae | ||
|
|
3b25d3e347 | ||
|
|
3a0676b29f | ||
|
|
a1e5761dbc | ||
|
|
ae518b541a | ||
|
|
f22fbd46ad | ||
|
|
8c3a001657 | ||
|
|
d052d9dceb | ||
|
|
652721ac9a | ||
|
|
cfd2bdea4a | ||
|
|
b8cd3c28a5 | ||
|
|
153dbc0788 | ||
|
|
288f026d47 | ||
|
|
0a8c532506 | ||
|
|
8bfe601588 | ||
|
|
7a183464ae | ||
|
|
f9b0afcbf5 | ||
|
|
1f4c55ab89 | ||
|
|
da1ec3a52c | ||
|
|
79b106d986 | ||
|
|
4c1af63d0d | ||
|
|
5b6e00bfa6 | ||
|
|
599ac92a2b | ||
|
|
274a598602 | ||
|
|
2fce5cd4ec | ||
|
|
bab643ee3d | ||
|
|
26fa78e2b7 | ||
|
|
86d38911e9 | ||
|
|
d6ae5e4424 | ||
|
|
a17558663b | ||
|
|
181828c64b | ||
|
|
6934fbb331 | ||
|
|
14a31a8ca8 | ||
|
|
86e587eba2 | ||
|
|
92c55e04fd | ||
|
|
6b031a50bc | ||
|
|
c1747a79f3 | ||
|
|
d96aff80c2 |
@@ -1,12 +0,0 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
86
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
Normal 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
|
||||
39
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
Normal 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
@@ -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
@@ -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}'
|
||||
100
.github/workflows/publish.yml
vendored
@@ -1,21 +1,107 @@
|
||||
name: Publish to Comfy registry
|
||||
name: Release and Publish to Comfy registry
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
- 'pyproject.toml'
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
name: Release and Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'hayden-fr' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
run: |
|
||||
echo "version=$(cat pyproject.toml | grep 'version =' | cut -d'=' -f2 | xargs)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check if tag exists
|
||||
id: check-tag
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
script: |
|
||||
const tag = `v${{ steps.current_version.outputs.version }}`;
|
||||
try {
|
||||
await github.rest.repos.getReleaseByTag({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag
|
||||
});
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return false
|
||||
}
|
||||
|
||||
- name: Assert tag v${{ steps.current_version.outputs.version }} is not exist
|
||||
run: |
|
||||
if [ ${{ steps.check-tag.outputs.result }} == true ]; then
|
||||
echo "Tag exists, skipping release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- 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: Build and Package
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm run build
|
||||
tar -czf dist.tar.gz assets/ py/ web/ __init__.py LICENSE pyproject.toml requirements.txt
|
||||
|
||||
- name: Create release draft
|
||||
uses: softprops/action-gh-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
files: |
|
||||
dist.tar.gz
|
||||
name: ${{ steps.current_version.outputs.version }}
|
||||
tag_name: v${{ steps.current_version.outputs.version }}
|
||||
draft: true
|
||||
make_latest: true
|
||||
|
||||
- name: Prepare publish custom node to registry
|
||||
run: |
|
||||
find . -maxdepth 1 ! -name '.' ! -name 'dist.tar.gz' ! -name '.git' -exec rm -rf {} +
|
||||
tar -xzf dist.tar.gz
|
||||
rm -rf dist.tar.gz
|
||||
|
||||
# - name: Publish Custom Node
|
||||
# uses: Comfy-Org/publish-node-action@main
|
||||
# with:
|
||||
# ## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
# personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
#
|
||||
# Publish Custom Node
|
||||
# Copy from Comfy-Org/publish-node-action@main
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install comfy-cli
|
||||
shell: bash
|
||||
run: |
|
||||
pip install comfy-cli
|
||||
|
||||
- name: Publish Node
|
||||
shell: bash
|
||||
run: |
|
||||
comfy --skip-prompt --no-enable-telemetry env
|
||||
comfy node publish --token ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
|
||||
29
.github/workflows/pylint.yml
vendored
Normal 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")
|
||||
12
.gitignore
vendored
@@ -188,3 +188,15 @@ Icon
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# dist
|
||||
web/
|
||||
|
||||
# config
|
||||
config/
|
||||
|
||||
# private info
|
||||
private.key
|
||||
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
pnpm exec lint-staged
|
||||
13
.prettierrc
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf",
|
||||
"semi": false,
|
||||
"plugins": [
|
||||
"prettier-plugin-organize-imports",
|
||||
"prettier-plugin-tailwindcss"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
3
.vscode/extensions.json
vendored
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode"
|
||||
"esbenp.prettier-vscode",
|
||||
"lokalise.i18n-ally"
|
||||
]
|
||||
}
|
||||
68
.vscode/settings.json
vendored
@@ -1,20 +1,52 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"apng",
|
||||
"Civitai",
|
||||
"ckpt",
|
||||
"comfyui",
|
||||
"FYUIKMNVB",
|
||||
"gguf",
|
||||
"gligen",
|
||||
"jfif",
|
||||
"locon",
|
||||
"loras",
|
||||
"noimage",
|
||||
"onnx",
|
||||
"rfilename",
|
||||
"unet",
|
||||
"upscaler"
|
||||
],
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"cSpell.words": [
|
||||
"tailwindcss",
|
||||
"vnode",
|
||||
"unref",
|
||||
"civitai",
|
||||
"huggingface",
|
||||
"comfyui",
|
||||
"ckpt",
|
||||
"gligen",
|
||||
"loras",
|
||||
"safetensors",
|
||||
"unet",
|
||||
"controlnet",
|
||||
"hypernetwork",
|
||||
"hypernetworks",
|
||||
"photomaker",
|
||||
"upscaler",
|
||||
"comfyorg",
|
||||
"fullname",
|
||||
"primevue",
|
||||
"maximizable",
|
||||
"inputgroup",
|
||||
"inputgroupaddon",
|
||||
"iconfield",
|
||||
"inputtext",
|
||||
"overlaybadge",
|
||||
"usetoast",
|
||||
"toastservice",
|
||||
"useconfirm",
|
||||
"confirmationservice",
|
||||
"confirmdialog",
|
||||
"popupmenu",
|
||||
"inplace",
|
||||
"contentcontainer",
|
||||
"itemlist",
|
||||
"virtualscroller"
|
||||
],
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
},
|
||||
"editor.quickSuggestions": {
|
||||
"strings": "on"
|
||||
},
|
||||
"css.lint.unknownAtRules": "ignore",
|
||||
"i18n-ally.localesPaths": [
|
||||
"src/locales"
|
||||
],
|
||||
"i18n-ally.sourceLanguage": "en",
|
||||
"i18n-ally.keystyle": "nested"
|
||||
}
|
||||
48
README.md
@@ -4,64 +4,64 @@ Download, browse and delete models in ComfyUI.
|
||||
|
||||
Designed to support desktop, mobile and multi-screen devices.
|
||||
|
||||
<img src="demo/beta-menu-model-manager-button-settings-group.png" alt="Model Manager Demo Screenshot" width="65%"/>
|
||||
# Installation
|
||||
|
||||
<img src="demo/tab-models.png" alt="Model Manager Demo Screenshot" width="65%"/>
|
||||
There are three installation methods, choose one
|
||||
|
||||
1. Clone the repository: `git clone https://github.com/hayden-fr/ComfyUI-Model-Manager.git` to your ComfyUI `custom_nodes` folder
|
||||
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
|
||||
3. Use comfy cli: `comfy node registry-install comfyui-model-manager`
|
||||
|
||||
## Features
|
||||
|
||||
### Node Graph
|
||||
## Freely adjust size and position
|
||||
|
||||
<img src="demo/tab-model-drag-add.gif" alt="Model Manager Demo Screenshot" width="65%"/>
|
||||
<img src="demo/tab-models.gif" style="max-width: 100%; max-height: 300px" >
|
||||
|
||||
### Support Node Graph
|
||||
|
||||
<img src="demo/tab-model-node-graph.gif" style="max-width: 100%; max-height: 300px" >
|
||||
|
||||
- Drag a model thumbnail onto the graph to add a new node.
|
||||
- Drag a model thumbnail onto an existing node to set the input field.
|
||||
- If there are multiple valid possible fields, then the drag must be exact.
|
||||
- Drag an embedding thumbnail onto a text area, or highlight any number of nodes, to append it onto the end of the text.
|
||||
- Drag the preview image in a model's info view onto the graph to load the embedded workflow (if it exists).
|
||||
|
||||
<img src="demo/tab-model-preview-thumbnail-buttons-example.png" alt="Model Manager Demo Screenshot" width="65%"/>
|
||||
|
||||
- Press the "copy" button to copy a model to ComfyUI's clipboard or copy the embedding to the system clipboard. (Copying the embedding to the system clipboard requires a secure http connection.)
|
||||
- Press the "add" button to add the model to the ComfyUI graph or append the embedding to one or more selected nodes.
|
||||
- Press the "load workflow" button to try and load a workflow embedded in a model's preview image.
|
||||
|
||||
### Download Tab
|
||||
|
||||
<img src="demo/tab-download.png" alt="Model Manager Demo Screenshot" width="65%"/>
|
||||
<img src="demo/tab-download.png" style="max-width: 100%; max-height: 300px" >
|
||||
|
||||
- View multiple models associated with a url.
|
||||
- Select a save directory and input a filename.
|
||||
- Optionally set a model's preview image.
|
||||
- Optionally edit and save descriptions as a .txt note. (Default behavior can be set in the settings tab.)
|
||||
- Add Civitai and HuggingFace API tokens in `server_settings.yaml`.
|
||||
- Optionally edit and save descriptions as a .md note.
|
||||
- Add Civitai and HuggingFace API tokens in ComfyUI's settings.
|
||||
|
||||
<img src="demo/tab-settings.png" style="max-width: 100%; max-height: 150px" >
|
||||
|
||||
### Models Tab
|
||||
|
||||
<img src="demo/tab-models-dropdown.png" alt="Model Manager Demo Screenshot" width="65%"/>
|
||||
<img src="demo/tab-models.png" alt="Model Manager Demo Screenshot" style="max-width: 100%; max-height: 300px"/>
|
||||
|
||||
- Search in real-time for models using the search bar.
|
||||
- Use advance keyword search by typing `"multiple words in quotes"` or a minus sign before to `-exclude` a word or phrase.
|
||||
- Add `/` at the start of a search to view a dropdown list of subdirectories (for example, `/0/1.5/styles/clothing`).
|
||||
- Any directory paths in ComfyUI's `extra_model_paths.yaml` or directories added in `ComfyUI/models/` will automatically be detected.
|
||||
- Sort models by "Date Created", "Date Modified", "Name" and "File Size".
|
||||
- Sort models by "Name", "File Size", "Date Created" and "Date Modified".
|
||||
|
||||
### Model Info View
|
||||
|
||||
<img src="demo/tab-model-info-overview.png" alt="Model Manager Demo Screenshot" width="65%"/>
|
||||
<img src="demo/tab-model-info-overview.png" alt="Model Manager Demo Screenshot" style="max-width: 100%; max-height: 300px"/>
|
||||
|
||||
- View file info and metadata.
|
||||
- Rename, move or **permanently** remove a model and all of it's related files.
|
||||
- Read, edit and save notes. (Saved as a `.txt` file beside the model).
|
||||
- `Ctrl+s` or `⌘+S` to save a note when the textarea is in focus.
|
||||
- Autosave can be enabled in settings. (Note: Once the model info view is closed, the undo history is lost.)
|
||||
- Read, edit and save notes. (Saved as a `.md` file beside the model).
|
||||
- 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.)
|
||||
|
||||
### Settings Tab
|
||||
### Scan Model Information
|
||||
|
||||
<img src="demo/tab-settings.png" alt="Model Manager Demo Screenshot" width="65%"/>
|
||||
<img src="demo/scan-model-info.png" alt="Model Manager Demo Screenshot" style="max-width: 100%; max-height: 300px"/>
|
||||
|
||||
- Settings are saved to `ui_settings.yaml`.
|
||||
- Most settings should update immediately, but a few may require a page reload to take effect.
|
||||
- Press the "Fix Extensions" button to correct all image file extensions in the model directories. (Note: This may take a minute or so to complete.)
|
||||
- Scan models and try to download information & preview.
|
||||
|
||||
1244
__init__.py
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -1,72 +0,0 @@
|
||||
import yaml
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class Rule:
|
||||
key: any
|
||||
value_default: any
|
||||
value_type: type
|
||||
value_min: any # int | float | None
|
||||
value_max: any # int | float | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key,
|
||||
value_default,
|
||||
value_type: type,
|
||||
value_min: any = None, # int | float | None
|
||||
value_max: any = None, # int | float | None
|
||||
):
|
||||
self.key = key
|
||||
self.value_default = value_default
|
||||
self.value_type = value_type
|
||||
self.value_min = value_min
|
||||
self.value_max = value_max
|
||||
|
||||
def _get_valid_value(data: dict, r: Rule):
|
||||
if r.value_type != type(r.value_default):
|
||||
raise Exception(f"'value_type' does not match type of 'value_default'!")
|
||||
value = data.get(r.key)
|
||||
if value is None:
|
||||
value = r.value_default
|
||||
else:
|
||||
try:
|
||||
value = r.value_type(value)
|
||||
except:
|
||||
value = r.value_default
|
||||
|
||||
value_is_numeric = r.value_type == int or r.value_type == float
|
||||
if value_is_numeric and r.value_min:
|
||||
if r.value_type != type(r.value_min):
|
||||
raise Exception(f"Type of 'value_type' does not match the type of 'value_min'!")
|
||||
value = max(r.value_min, value)
|
||||
if value_is_numeric and r.value_max:
|
||||
if r.value_type != type(r.value_max):
|
||||
raise Exception(f"Type of 'value_type' does not match the type of 'value_max'!")
|
||||
value = min(r.value_max, value)
|
||||
|
||||
return value
|
||||
|
||||
def validated(rules: list[Rule], data: dict = {}):
|
||||
valid = {}
|
||||
for r in rules:
|
||||
valid[r.key] = _get_valid_value(data, r)
|
||||
return valid
|
||||
|
||||
def yaml_load(path, rules: list[Rule]):
|
||||
data = {}
|
||||
try:
|
||||
with open(path, 'r') as file:
|
||||
data = yaml.safe_load(file)
|
||||
except:
|
||||
pass
|
||||
return validated(rules, data)
|
||||
|
||||
def yaml_save(path, rules: list[Rule], data: dict) -> bool:
|
||||
data = validated(rules, data)
|
||||
try:
|
||||
with open(path, 'w') as file:
|
||||
yaml.dump(data, file)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
Before Width: | Height: | Size: 10 KiB |
BIN
demo/scan-model-info.png
Executable file
|
After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 536 KiB |
BIN
demo/tab-model-info-overview.png
Normal file → Executable file
|
Before Width: | Height: | Size: 464 KiB After Width: | Height: | Size: 304 KiB |
BIN
demo/tab-model-node-graph.gif
Executable file
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 82 KiB |
BIN
demo/tab-models.gif
Executable file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
demo/tab-models.png
Normal file → Executable file
|
Before Width: | Height: | Size: 942 KiB After Width: | Height: | Size: 660 KiB |
BIN
demo/tab-settings.png
Normal file → Executable file
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 52 KiB |
27
eslint.config.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import pluginJs from '@eslint/js'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import globals from 'globals'
|
||||
import tsEslint from 'typescript-eslint'
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
{
|
||||
files: ['src/**/*.{js,mjs,cjs,ts,vue}'],
|
||||
},
|
||||
{
|
||||
ignores: ['src/scripts/*', 'src/types/shims.d.ts', 'src/utils/legacy.ts'],
|
||||
},
|
||||
{ languageOptions: { globals: globals.browser } },
|
||||
pluginJs.configs.recommended,
|
||||
...tsEslint.configs.recommended,
|
||||
...pluginVue.configs['flat/essential'],
|
||||
{
|
||||
files: ['src/**/*.vue'],
|
||||
languageOptions: { parserOptions: { parser: tsEslint.parser } },
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
},
|
||||
]
|
||||
11
index.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ComfyUI-Model-Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
51
package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "comfyui-model-manager",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint src",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.9",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.5.5",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.10.0",
|
||||
"eslint-plugin-vue": "^9.28.0",
|
||||
"globals": "^15.12.0",
|
||||
"husky": "^9.1.6",
|
||||
"less": "^4.2.0",
|
||||
"lint-staged": "^15.2.10",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||
"tailwindcss": "^3.4.12",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript-eslint": "^8.13.0",
|
||||
"vite": "^5.4.6",
|
||||
"vue-tsc": "^2.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primevue/themes": "^4.0.7",
|
||||
"@vueuse/core": "^11.3.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"lodash": "^4.17.21",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-metadata-block": "^1.0.6",
|
||||
"primevue": "^4.0.7",
|
||||
"vue": "^3.5.6",
|
||||
"vue-i18n": "^9.14.0",
|
||||
"yaml": "^2.6.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"./**/*.{js,ts,tsx,vue}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
3301
pnpm-lock.yaml
generated
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
25
py/config.py
Normal file
@@ -0,0 +1,25 @@
|
||||
extension_tag = "ComfyUI Model Manager"
|
||||
|
||||
extension_uri: str = None
|
||||
|
||||
|
||||
setting_key = {
|
||||
"api_key": {
|
||||
"civitai": "ModelManager.APIKey.Civitai",
|
||||
"huggingface": "ModelManager.APIKey.HuggingFace",
|
||||
},
|
||||
"download": {
|
||||
"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"
|
||||
|
||||
|
||||
from server import PromptServer
|
||||
|
||||
serverInstance = PromptServer.instance
|
||||
routes = serverInstance.routes
|
||||
549
py/download.py
Normal file
@@ -0,0 +1,549 @@
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
import requests
|
||||
import base64
|
||||
|
||||
|
||||
import folder_paths
|
||||
|
||||
|
||||
from typing import Callable, Awaitable, Any, Literal, Union, Optional
|
||||
from dataclasses import dataclass
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
from . import config
|
||||
from . import utils
|
||||
from . import thread
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskStatus:
|
||||
taskId: str
|
||||
type: str
|
||||
fullname: str
|
||||
preview: str
|
||||
status: Literal["pause", "waiting", "doing"] = "pause"
|
||||
platform: Union[str, None] = None
|
||||
downloadedSize: float = 0
|
||||
totalSize: float = 0
|
||||
progress: float = 0
|
||||
bps: float = 0
|
||||
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
|
||||
class TaskContent:
|
||||
type: str
|
||||
pathIndex: int
|
||||
fullname: str
|
||||
description: str
|
||||
downloadPlatform: str
|
||||
downloadUrl: str
|
||||
sizeBytes: float
|
||||
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)
|
||||
|
||||
def to_dict(self):
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
class ApiKey:
|
||||
|
||||
__store: dict[str, str] = {}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class ModelDownload:
|
||||
def __init__(self):
|
||||
self.api_key = ApiKey()
|
||||
|
||||
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})
|
||||
|
||||
@routes.post("/model-manager/download/setting")
|
||||
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})
|
||||
|
||||
@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()
|
||||
task_file_path = utils.join_path(download_path, f"{task_id}.task")
|
||||
utils.save_dict_pickle_file(task_file_path, task_content)
|
||||
|
||||
def get_task_content(self, task_id: str):
|
||||
download_path = utils.get_download_path()
|
||||
task_file = utils.join_path(download_path, f"{task_id}.task")
|
||||
if not os.path.isfile(task_file):
|
||||
raise RuntimeError(f"Task {task_id} not found")
|
||||
task_content = utils.load_dict_pickle_file(task_file)
|
||||
if isinstance(task_content, TaskContent):
|
||||
return task_content
|
||||
return TaskContent(**task_content)
|
||||
|
||||
def get_task_status(self, task_id: str):
|
||||
task_status = self.download_model_task_status.get(task_id, None)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
self.download_model_task_status[task_id] = task_status
|
||||
|
||||
return task_status
|
||||
|
||||
def delete_task_status(self, task_id: str):
|
||||
self.download_model_task_status.pop(task_id, None)
|
||||
|
||||
async def scan_model_download_task_list(self):
|
||||
"""
|
||||
Scan the download directory and send the task list to the client.
|
||||
"""
|
||||
download_dir = utils.get_download_path()
|
||||
task_files = utils.search_files(download_dir)
|
||||
task_files = folder_paths.filter_files_extensions(task_files, [".task"])
|
||||
task_files = sorted(
|
||||
task_files,
|
||||
key=lambda x: os.stat(utils.join_path(download_dir, x)).st_ctime,
|
||||
reverse=True,
|
||||
)
|
||||
task_list: list[dict] = []
|
||||
for task_file in task_files:
|
||||
task_id = task_file.replace(".task", "")
|
||||
task_status = self.get_task_status(task_id)
|
||||
task_list.append(task_status.to_dict())
|
||||
|
||||
return task_list
|
||||
|
||||
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)
|
||||
|
||||
model_path = utils.get_full_path(model_type, path_index, fullname)
|
||||
# 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")
|
||||
if os.path.exists(task_path):
|
||||
raise RuntimeError(f"Task {task_id} already exists")
|
||||
download_platform = task_data.get("downloadPlatform", None)
|
||||
|
||||
try:
|
||||
preview_file = task_data.pop("previewFile", None)
|
||||
utils.save_model_preview(task_path, preview_file, download_platform)
|
||||
self.set_task_content(task_id, task_data)
|
||||
task_status = TaskStatus(
|
||||
taskId=task_id,
|
||||
type=model_type,
|
||||
fullname=fullname,
|
||||
preview=utils.get_model_preview_name(task_path),
|
||||
platform=download_platform,
|
||||
totalSize=float(task_data.get("sizeBytes", 0)),
|
||||
)
|
||||
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:
|
||||
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))
|
||||
|
||||
async def download_model_file(
|
||||
self,
|
||||
task_id: str,
|
||||
headers: dict,
|
||||
progress_callback: Callable[[TaskStatus], Awaitable[Any]],
|
||||
interval: float = 1.0,
|
||||
):
|
||||
|
||||
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)
|
||||
|
||||
model_path = utils.get_full_path(model_type, path_index, fullname)
|
||||
|
||||
utils.rename_model(download_tmp_file, model_path)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
task_status = self.get_task_status(task_id)
|
||||
task_content = self.get_task_content(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
|
||||
|
||||
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 no token is carried, it will be redirected to the login page.
|
||||
content_type = response.headers.get("content-type")
|
||||
if content_type and content_type.startswith("text/html"):
|
||||
# TODO More checks
|
||||
# In addition to requiring login to download, there may be other restrictions.
|
||||
# The currently one situation is early access??? issues#43
|
||||
# Due to the lack of test data, let’s 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.")
|
||||
|
||||
# When parsing model information from HuggingFace API,
|
||||
# the file size was not found and needs to be obtained from the response header.
|
||||
# Fixed issue #169. Some model information from Civitai, providing the wrong file size
|
||||
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())
|
||||
|
||||
with open(download_tmp_file, "ab") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if task_status.status == "pause":
|
||||
break
|
||||
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
|
||||
if time.time() - last_update_time >= interval:
|
||||
await update_progress()
|
||||
|
||||
await update_progress()
|
||||
|
||||
if total_size > 0 and downloaded_size == total_size:
|
||||
await download_complete()
|
||||
else:
|
||||
task_status.status = "pause"
|
||||
await utils.send_json("update_download_task", task_status.to_dict())
|
||||
584
py/information.py
Normal 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()
|
||||
248
py/manager.py
Normal file
@@ -0,0 +1,248 @@
|
||||
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]] = []
|
||||
if not os.path.exists(directory):
|
||||
return []
|
||||
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))
|
||||
57
py/thread.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import queue
|
||||
|
||||
from . import utils
|
||||
|
||||
|
||||
class DownloadThreadPool:
|
||||
def __init__(self) -> None:
|
||||
self.workers_count = 0
|
||||
self.task_queue = queue.Queue()
|
||||
self.running_tasks = set()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
default_max_workers = 5
|
||||
max_workers: int = default_max_workers
|
||||
self.max_worker = max_workers
|
||||
|
||||
def submit(self, task, task_id):
|
||||
with self._lock:
|
||||
if task_id in self.running_tasks:
|
||||
return "Existing"
|
||||
self.running_tasks.add(task_id)
|
||||
self.task_queue.put((task, task_id))
|
||||
return self._adjust_worker_count()
|
||||
|
||||
def _adjust_worker_count(self):
|
||||
if self.workers_count < self.max_worker:
|
||||
self._start_worker()
|
||||
return "Running"
|
||||
else:
|
||||
return "Waiting"
|
||||
|
||||
def _start_worker(self):
|
||||
t = threading.Thread(target=self._worker, daemon=True)
|
||||
t.start()
|
||||
with self._lock:
|
||||
self.workers_count += 1
|
||||
|
||||
def _worker(self):
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
while True:
|
||||
if self.task_queue.empty():
|
||||
break
|
||||
|
||||
task, task_id = self.task_queue.get()
|
||||
|
||||
try:
|
||||
loop.run_until_complete(task(task_id))
|
||||
with self._lock:
|
||||
self.running_tasks.remove(task_id)
|
||||
except Exception as e:
|
||||
utils.print_error(f"worker run error: {str(e)}")
|
||||
|
||||
with self._lock:
|
||||
self.workers_count -= 1
|
||||
79
py/upload.py
Normal 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)
|
||||
570
py/utils.py
Normal file
@@ -0,0 +1,570 @@
|
||||
import os
|
||||
import json
|
||||
import yaml
|
||||
import shutil
|
||||
import tarfile
|
||||
import logging
|
||||
import requests
|
||||
import traceback
|
||||
import configparser
|
||||
import functools
|
||||
import mimetypes
|
||||
|
||||
import comfy.utils
|
||||
import folder_paths
|
||||
|
||||
from aiohttp import web
|
||||
from typing import Any, Optional
|
||||
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):
|
||||
normpath = os.path.normpath(path)
|
||||
return normpath.replace(os.path.sep, "/")
|
||||
|
||||
|
||||
def join_path(path: str, *paths: list[str]):
|
||||
return normalize_path(os.path.join(path, *paths))
|
||||
|
||||
|
||||
def get_current_version():
|
||||
try:
|
||||
pyproject_path = join_path(config.extension_uri, "pyproject.toml")
|
||||
config_parser = configparser.ConfigParser()
|
||||
config_parser.read(pyproject_path)
|
||||
version = config_parser.get("project", "version")
|
||||
return version.strip("'\"")
|
||||
except:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def download_web_distribution(version: str):
|
||||
web_path = join_path(config.extension_uri, "web")
|
||||
dev_web_file = join_path(web_path, "manager-dev.js")
|
||||
if os.path.exists(dev_web_file):
|
||||
return
|
||||
|
||||
web_version = "0.0.0"
|
||||
version_file = join_path(web_path, "version.yaml")
|
||||
if os.path.exists(version_file):
|
||||
with open(version_file, "r", encoding="utf-8", newline="") as f:
|
||||
version_content = yaml.safe_load(f)
|
||||
web_version = version_content.get("version", web_version)
|
||||
|
||||
if version == web_version:
|
||||
return
|
||||
|
||||
try:
|
||||
print_info(f"current version {version}, web version {web_version}")
|
||||
print_info("Downloading web distribution...")
|
||||
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.raise_for_status()
|
||||
|
||||
temp_file = join_path(config.extension_uri, "temp.tar.gz")
|
||||
with open(temp_file, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
if os.path.exists(web_path):
|
||||
shutil.rmtree(web_path)
|
||||
|
||||
print_info("Extracting web distribution...")
|
||||
with tarfile.open(temp_file, "r:gz") as tar:
|
||||
members = [member for member in tar.getmembers() if member.name.startswith("web/")]
|
||||
tar.extractall(path=config.extension_uri, members=members)
|
||||
|
||||
os.remove(temp_file)
|
||||
print_info("Web distribution downloaded successfully.")
|
||||
except requests.exceptions.RequestException as e:
|
||||
print_error(f"Failed to download web distribution: {e}")
|
||||
except tarfile.TarError as e:
|
||||
print_error(f"Failed to extract web distribution: {e}")
|
||||
except Exception as e:
|
||||
print_error(f"An unexpected error occurred: {e}")
|
||||
|
||||
|
||||
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())
|
||||
model_base_paths = {}
|
||||
folder_black_list = ["configs", "custom_nodes"]
|
||||
for folder in folders:
|
||||
if folder in folder_black_list:
|
||||
continue
|
||||
folders = folder_paths.get_folder_paths(folder)
|
||||
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):
|
||||
"""
|
||||
Get the absolute path in the model type through string concatenation.
|
||||
"""
|
||||
folders = resolve_model_base_paths().get(model_type, [])
|
||||
if not path_index < len(folders):
|
||||
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
|
||||
base_path = folders[path_index]
|
||||
full_path = join_path(base_path, filename)
|
||||
return full_path
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
folders = resolve_model_base_paths().get(model_type, [])
|
||||
if not path_index < len(folders):
|
||||
raise RuntimeError(f"PathIndex {path_index} is not in {model_type}")
|
||||
base_path = folders[path_index]
|
||||
full_path = join_path(base_path, filename)
|
||||
if os.path.isfile(full_path):
|
||||
return full_path
|
||||
elif os.path.islink(full_path):
|
||||
raise RuntimeError(f"WARNING path {full_path} exists but doesn't link anywhere, skipping.")
|
||||
|
||||
|
||||
def get_download_path():
|
||||
download_path = join_path(config.extension_uri, "downloads")
|
||||
if not os.path.exists(download_path):
|
||||
os.makedirs(download_path)
|
||||
return download_path
|
||||
|
||||
|
||||
def recursive_search_files(directory: str, request):
|
||||
if not os.path.isdir(directory):
|
||||
return []
|
||||
|
||||
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):
|
||||
entries = os.listdir(directory)
|
||||
files = [f for f in entries if os.path.isfile(join_path(directory, f))]
|
||||
return files
|
||||
|
||||
|
||||
def file_list_to_name_dict(files: list[str]):
|
||||
file_dict: dict[str, str] = {}
|
||||
for file in files:
|
||||
filename = os.path.splitext(file)[0]
|
||||
file_dict[filename] = file
|
||||
return file_dict
|
||||
|
||||
|
||||
def get_model_metadata(filename: str):
|
||||
if not filename.endswith(".safetensors"):
|
||||
return {}
|
||||
try:
|
||||
out = comfy.utils.safetensors_header(filename, max_size=1024 * 1024)
|
||||
if out is None:
|
||||
return {}
|
||||
dt = json.loads(out)
|
||||
if not "__metadata__" in dt:
|
||||
return {}
|
||||
return dt["__metadata__"]
|
||||
except:
|
||||
return {}
|
||||
|
||||
|
||||
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)
|
||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
return _check_preview_variants(base_dirname, basename, PREVIEW_EXTENSIONS)
|
||||
|
||||
|
||||
def get_model_preview_name(model_path: str) -> str:
|
||||
"""Get the first available preview file or 'no-preview.png' if none found"""
|
||||
base_dirname = os.path.dirname(model_path)
|
||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
|
||||
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"
|
||||
|
||||
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
def remove_model_preview(model_path: str):
|
||||
"""Remove all preview files for a model"""
|
||||
base_dirname = os.path.dirname(model_path)
|
||||
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):
|
||||
base_dirname = os.path.dirname(model_path)
|
||||
files = search_files(base_dirname)
|
||||
files = folder_paths.filter_files_extensions(files, [".txt", ".md"])
|
||||
|
||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
output: list[str] = []
|
||||
for file in files:
|
||||
file_basename = os.path.splitext(file)[0]
|
||||
if file_basename == basename:
|
||||
output.append(file)
|
||||
return output
|
||||
|
||||
|
||||
def get_model_description_name(model_path: str):
|
||||
descriptions = get_model_all_descriptions(model_path)
|
||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
return descriptions[0] if len(descriptions) > 0 else f"{basename}.md"
|
||||
|
||||
|
||||
def save_model_description(model_path: str, content: Any):
|
||||
if not isinstance(content, str):
|
||||
raise RuntimeError("Invalid description")
|
||||
|
||||
base_dirname = os.path.dirname(model_path)
|
||||
|
||||
# save new description
|
||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
extension = ".md"
|
||||
new_desc_path = join_path(base_dirname, f"{basename}{extension}")
|
||||
|
||||
with open(new_desc_path, "w", encoding="utf-8", newline="") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def rename_model(model_path: str, new_model_path: str):
|
||||
if model_path == new_model_path:
|
||||
return
|
||||
|
||||
if os.path.exists(new_model_path):
|
||||
raise RuntimeError(f"Model {new_model_path} already exists")
|
||||
|
||||
model_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||
new_model_name = os.path.splitext(os.path.basename(new_model_path))[0]
|
||||
|
||||
model_dirname = os.path.dirname(model_path)
|
||||
new_model_dirname = os.path.dirname(new_model_path)
|
||||
|
||||
if not os.path.exists(new_model_dirname):
|
||||
os.makedirs(new_model_dirname)
|
||||
|
||||
# move model
|
||||
shutil.move(model_path, new_model_path)
|
||||
|
||||
# move preview
|
||||
previews = get_model_all_previews(model_path)
|
||||
for preview in previews:
|
||||
preview_path = join_path(model_dirname, preview)
|
||||
preview_name = os.path.splitext(preview)[0]
|
||||
preview_ext = os.path.splitext(preview)[1]
|
||||
new_preview_path = (
|
||||
join_path(new_model_dirname, new_model_name + preview_ext)
|
||||
if preview_name == model_name
|
||||
else join_path(new_model_dirname, new_model_name + ".preview" + preview_ext)
|
||||
)
|
||||
shutil.move(preview_path, new_preview_path)
|
||||
|
||||
# move description
|
||||
description = get_model_description_name(model_path)
|
||||
description_path = join_path(model_dirname, description)
|
||||
if os.path.isfile(description_path):
|
||||
new_description_path = join_path(new_model_dirname, f"{new_model_name}.md")
|
||||
shutil.move(description_path, new_description_path)
|
||||
|
||||
|
||||
import pickle
|
||||
|
||||
|
||||
def save_dict_pickle_file(filename: str, data: dict):
|
||||
with open(filename, "wb") as f:
|
||||
pickle.dump(data, f)
|
||||
|
||||
|
||||
def load_dict_pickle_file(filename: str) -> dict:
|
||||
with open(filename, "rb") as f:
|
||||
data = pickle.load(f)
|
||||
return data
|
||||
|
||||
|
||||
def resolve_setting_key(key: str) -> str:
|
||||
key_paths = key.split(".")
|
||||
setting_id = config.setting_key
|
||||
try:
|
||||
for key_path in key_paths:
|
||||
setting_id = setting_id[key_path]
|
||||
except:
|
||||
pass
|
||||
if not isinstance(setting_id, str):
|
||||
raise RuntimeError(f"Invalid key: {key}")
|
||||
|
||||
return setting_id
|
||||
|
||||
|
||||
def set_setting_value(request: web.Request, key: str, value: Any):
|
||||
setting_id = resolve_setting_key(key)
|
||||
settings = config.serverInstance.user_manager.settings.get_settings(request)
|
||||
settings[setting_id] = value
|
||||
config.serverInstance.user_manager.settings.save_settings(request, settings)
|
||||
|
||||
|
||||
def get_setting_value(request: web.Request, key: str, default: Any = None) -> Any:
|
||||
setting_id = resolve_setting_key(key)
|
||||
settings = config.serverInstance.user_manager.settings.get_settings(request)
|
||||
return settings.get(setting_id, default)
|
||||
|
||||
|
||||
async def send_json(event: str, data: Any, sid: str = None):
|
||||
await config.serverInstance.send_json(event, data, sid)
|
||||
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
import importlib.util
|
||||
import importlib.metadata
|
||||
|
||||
|
||||
def is_installed(package_name: str):
|
||||
try:
|
||||
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()
|
||||
@@ -1,8 +1,9 @@
|
||||
[project]
|
||||
name = "comfyui-model-manager"
|
||||
description = "Manage models: browsing, download and delete."
|
||||
version = "1.0.0"
|
||||
license = "LICENSE"
|
||||
version = "2.8.3"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = ["markdownify"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/hayden-fr/ComfyUI-Model-Manager"
|
||||
@@ -12,3 +13,6 @@ Repository = "https://github.com/hayden-fr/ComfyUI-Model-Manager"
|
||||
PublisherId = "hayden"
|
||||
DisplayName = "ComfyUI-Model-Manager"
|
||||
Icon = ""
|
||||
|
||||
[tool.black]
|
||||
line-length = 160
|
||||
|
||||
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
markdownify
|
||||
165
src/App.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<GlobalToast></GlobalToast>
|
||||
<GlobalConfirm></GlobalConfirm>
|
||||
<GlobalLoading></GlobalLoading>
|
||||
<GlobalDialogStack></GlobalDialogStack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DialogDownload from 'components/DialogDownload.vue'
|
||||
import DialogExplorer from 'components/DialogExplorer.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 GlobalLoading from 'components/GlobalLoading.vue'
|
||||
import GlobalToast from 'components/GlobalToast.vue'
|
||||
import { useStoreProvider } from 'hooks/store'
|
||||
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 { dialog, models, config, download } = useStoreProvider()
|
||||
const { toast } = useToast()
|
||||
|
||||
const firstOpenManager = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
const refreshModelsAndConfig = async () => {
|
||||
await Promise.all([models.refresh(true)])
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Refreshed Models',
|
||||
life: 2000,
|
||||
})
|
||||
}
|
||||
|
||||
const openModelScanning = () => {
|
||||
dialog.open({
|
||||
key: 'model-information-scanning',
|
||||
title: t('batchScanModelInformation'),
|
||||
content: DialogScanning,
|
||||
modal: true,
|
||||
defaultSize: {
|
||||
width: 680,
|
||||
height: 490,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openDownloadDialog = () => {
|
||||
dialog.open({
|
||||
key: 'model-manager-download-list',
|
||||
title: t('downloadList'),
|
||||
content: DialogDownload,
|
||||
headerButtons: [
|
||||
{
|
||||
key: 'refresh',
|
||||
icon: 'pi pi-refresh',
|
||||
command: () => download.refresh(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const openUploadDialog = () => {
|
||||
dialog.open({
|
||||
key: 'model-manager-upload',
|
||||
title: t('uploadModel'),
|
||||
content: DialogUpload,
|
||||
headerButtons: [
|
||||
{
|
||||
key: 'refresh',
|
||||
icon: 'pi pi-refresh',
|
||||
command: refreshModelsAndConfig,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const toggleLayout = () => {
|
||||
// flip the flat setting
|
||||
const newValue = !config.flat.value
|
||||
config.flat.value = newValue
|
||||
|
||||
// persist so it survives reloads
|
||||
app.ui?.settings.setSettingValue('ModelManager.UI.Flat', newValue)
|
||||
|
||||
// close the current dialog (because it is keepAlive)
|
||||
dialog.closeAll()
|
||||
|
||||
// reopen with the new layout
|
||||
openManagerDialog()
|
||||
}
|
||||
|
||||
const openManagerDialog = () => {
|
||||
const { cardWidth, gutter, aspect, flat } = config
|
||||
// choose icon depending on current layout
|
||||
const layoutIcon = flat.value ? 'pi pi-folder-open' : 'pi pi-th-large'
|
||||
|
||||
if (firstOpenManager.value) {
|
||||
models.refresh(true)
|
||||
firstOpenManager.value = false
|
||||
}
|
||||
|
||||
dialog.open({
|
||||
key: 'model-manager',
|
||||
title: t('modelManager'),
|
||||
content: flat.value ? DialogManager : DialogExplorer,
|
||||
keepAlive: true,
|
||||
headerButtons: [
|
||||
{
|
||||
key: 'scanning',
|
||||
icon: 'mdi mdi-folder-search-outline text-lg',
|
||||
command: openModelScanning,
|
||||
},
|
||||
{
|
||||
key: 'toggle-layout',
|
||||
icon: layoutIcon,
|
||||
command: toggleLayout,
|
||||
tooltip: flat.value
|
||||
? t('switchToFolderView')
|
||||
: t('switchToFlatView'),
|
||||
},
|
||||
{
|
||||
key: 'refresh',
|
||||
icon: 'pi pi-refresh',
|
||||
command: refreshModelsAndConfig,
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
icon: 'pi pi-download',
|
||||
command: openDownloadDialog,
|
||||
},
|
||||
{
|
||||
key: 'upload',
|
||||
icon: 'pi pi-upload',
|
||||
command: openUploadDialog,
|
||||
},
|
||||
],
|
||||
minWidth: cardWidth * 2 + gutter + 42,
|
||||
minHeight: (cardWidth / aspect) * 0.5 + 162,
|
||||
})
|
||||
}
|
||||
|
||||
app.ui?.menuContainer?.appendChild(
|
||||
$el('button', {
|
||||
id: 'comfyui-model-manager-button',
|
||||
textContent: t('modelManager'),
|
||||
onclick: openManagerDialog,
|
||||
}),
|
||||
)
|
||||
|
||||
app.menu?.settingsGroup.append(
|
||||
new ComfyButton({
|
||||
icon: 'folder-search',
|
||||
tooltip: t('openModelManager'),
|
||||
content: t('modelManager'),
|
||||
action: openManagerDialog,
|
||||
}),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
162
src/components/DialogCreateTask.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-4 px-5">
|
||||
<ResponseInput
|
||||
v-model="modelUrl"
|
||||
:allow-clear="true"
|
||||
:placeholder="$t('pleaseInputModelUrl')"
|
||||
@keypress.enter="searchModelsByUrl"
|
||||
>
|
||||
<template #suffix>
|
||||
<span
|
||||
class="pi pi-search text-base opacity-60"
|
||||
@click="searchModelsByUrl"
|
||||
></span>
|
||||
</template>
|
||||
</ResponseInput>
|
||||
|
||||
<div v-show="data.length > 0">
|
||||
<ResponseSelect
|
||||
v-model="current"
|
||||
:items="data"
|
||||
:type="isMobile ? 'drop' : 'button'"
|
||||
>
|
||||
<template #prefix>
|
||||
<span>version:</span>
|
||||
</template>
|
||||
</ResponseSelect>
|
||||
</div>
|
||||
|
||||
<ResponseScroll class="-mx-5 h-full">
|
||||
<div class="px-5">
|
||||
<KeepAlive>
|
||||
<ModelContent
|
||||
v-if="currentModel"
|
||||
:key="`${currentModel.id}-${currentModel.currentFileId}`"
|
||||
:model="currentModel"
|
||||
:editable="true"
|
||||
@submit="createDownTask"
|
||||
>
|
||||
<template #action>
|
||||
<div v-if="currentModel.files" class="flex-1">
|
||||
<ResponseSelect
|
||||
:model-value="currentModel.currentFileId"
|
||||
:items="currentModel.selectionFiles"
|
||||
:type="isMobile ? 'drop' : 'button'"
|
||||
>
|
||||
</ResponseSelect>
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-download"
|
||||
:label="$t('download')"
|
||||
type="submit"
|
||||
></Button>
|
||||
</template>
|
||||
</ModelContent>
|
||||
</KeepAlive>
|
||||
|
||||
<div v-show="data.length === 0">
|
||||
<div class="flex flex-col items-center gap-4 py-8">
|
||||
<i class="pi pi-box text-3xl"></i>
|
||||
<div>No Models Found</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResponseScroll>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModelContent from 'components/ModelContent.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 { useDialog } from 'hooks/dialog'
|
||||
import { useModelSearch } from 'hooks/download'
|
||||
import { useLoading } from 'hooks/loading'
|
||||
import { genModelFullName } from 'hooks/model'
|
||||
import { request } from 'hooks/request'
|
||||
import { useToast } from 'hooks/toast'
|
||||
import Button from 'primevue/button'
|
||||
import { VersionModel, WithResolved } from 'types/typings'
|
||||
import { previewUrlToFile } from 'utils/common'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { isMobile } = useConfig()
|
||||
const { toast } = useToast()
|
||||
const loading = useLoading()
|
||||
const dialog = useDialog()
|
||||
|
||||
const modelUrl = ref<string>()
|
||||
|
||||
const { current, currentModel, data, search } = useModelSearch()
|
||||
|
||||
const searchModelsByUrl = async () => {
|
||||
if (modelUrl.value) {
|
||||
await search(modelUrl.value)
|
||||
}
|
||||
}
|
||||
|
||||
const createDownTask = async (data: WithResolved<VersionModel>) => {
|
||||
loading.show()
|
||||
|
||||
const formData = new FormData()
|
||||
for (const key in data) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||||
let value = data[key]
|
||||
|
||||
// set preview file
|
||||
if (key === 'preview') {
|
||||
if (value) {
|
||||
const previewFile = await previewUrlToFile(value).catch(() => {
|
||||
loading.hide()
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to download preview',
|
||||
life: 5000,
|
||||
})
|
||||
throw new Error('Failed to download preview')
|
||||
})
|
||||
formData.append('previewFile', previewFile)
|
||||
} else {
|
||||
formData.append('previewFile', value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
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', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then(() => {
|
||||
dialog.close()
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: e.message ?? 'Failed to create download task',
|
||||
life: 15000,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
loading.hide()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
103
src/components/DialogDownload.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-4">
|
||||
<div ref="container" class="whitespace-nowrap px-4">
|
||||
<div :class="['flex gap-4', $sm('justify-end')]">
|
||||
<Button
|
||||
:class="[$sm('w-auto', 'w-full')]"
|
||||
:label="$t('createDownloadTask')"
|
||||
@click="openCreateTask"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResponseScroll>
|
||||
<div class="w-full px-4">
|
||||
<ul class="m-0 flex list-none flex-col gap-4 p-0">
|
||||
<li
|
||||
v-for="item in data"
|
||||
:key="item.taskId"
|
||||
class="rounded-lg border border-gray-500 p-4"
|
||||
>
|
||||
<div class="flex gap-4 overflow-hidden whitespace-nowrap">
|
||||
<div class="h-18 preview-aspect">
|
||||
<div v-if="isVideoUrl(item.preview)" class="h-full w-full">
|
||||
<PreviewVideo :src="item.preview" />
|
||||
</div>
|
||||
<img v-else :src="item.preview" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-3 overflow-hidden">
|
||||
<div class="flex items-center gap-3 overflow-hidden">
|
||||
<span class="flex-1 overflow-hidden text-ellipsis">
|
||||
{{ item.fullname }}
|
||||
</span>
|
||||
<span v-show="item.status === 'waiting'" class="h-4">
|
||||
<i class="pi pi-spinner pi-spin"></i>
|
||||
</span>
|
||||
<span
|
||||
v-show="item.status === 'doing'"
|
||||
class="h-4 cursor-pointer"
|
||||
@click="item.pauseTask"
|
||||
>
|
||||
<i class="pi pi-pause-circle"></i>
|
||||
</span>
|
||||
<span
|
||||
v-show="item.status === 'pause'"
|
||||
class="h-4 cursor-pointer"
|
||||
@click="item.resumeTask"
|
||||
>
|
||||
<i class="pi pi-play-circle"></i>
|
||||
</span>
|
||||
<span class="h-4 cursor-pointer" @click="item.deleteTask">
|
||||
<i class="pi pi-trash text-red-400"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded bg-gray-200">
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-[width]"
|
||||
:style="{ width: `${item.progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div>{{ item.downloadProgress }}</div>
|
||||
<div v-show="item.status === 'doing'">
|
||||
{{ item.downloadSpeed }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ResponseScroll>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DialogCreateTask from 'components/DialogCreateTask.vue'
|
||||
import PreviewVideo from 'components/PreviewVideo.vue'
|
||||
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||
import { useContainerQueries } from 'hooks/container'
|
||||
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'
|
||||
|
||||
const { data } = useDownload()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialog = useDialog()
|
||||
|
||||
const openCreateTask = () => {
|
||||
dialog.open({
|
||||
key: `model-manager-create-task-${Date.now()}`,
|
||||
title: t('parseModelUrl'),
|
||||
content: DialogCreateTask,
|
||||
})
|
||||
}
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const { $sm } = useContainerQueries(container)
|
||||
</script>
|
||||
337
src/components/DialogExplorer.vue
Normal 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>
|
||||
322
src/components/DialogManager.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<div
|
||||
ref="contentContainer"
|
||||
class="flex h-full flex-col gap-4 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-1 justify-center gap-4 px-8"
|
||||
:style="$content_lg(contentStyle)"
|
||||
>
|
||||
<div ref="toolbarContainer" class="col-span-full">
|
||||
<div :class="['flex gap-4', $toolbar_2xl('flex-row', 'flex-col')]">
|
||||
<div class="flex-1">
|
||||
<ResponseInput
|
||||
v-model="searchContent"
|
||||
:placeholder="$t('searchModels')"
|
||||
:allow-clear="true"
|
||||
suffix-icon="pi pi-search"
|
||||
></ResponseInput>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 overflow-hidden">
|
||||
<ResponseSelect
|
||||
class="flex-1"
|
||||
v-model="currentType"
|
||||
:items="typeOptions"
|
||||
></ResponseSelect>
|
||||
<ResponseSelect
|
||||
class="flex-1"
|
||||
v-model="sortOrder"
|
||||
:items="sortOrderOptions"
|
||||
></ResponseSelect>
|
||||
<ResponseSelect
|
||||
class="flex-1"
|
||||
v-model="cardSizeFlag"
|
||||
:items="cardSizeOptions"
|
||||
></ResponseSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResponseScroll :items="list" :itemSize="itemSize" class="h-full flex-1">
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
class="grid grid-cols-1 justify-center gap-8 px-8"
|
||||
:style="contentStyle"
|
||||
>
|
||||
<ModelCard
|
||||
v-for="model in item.row"
|
||||
:key="genModelKey(model)"
|
||||
:model="model"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<div class="flex flex-col items-center gap-4 pt-20 opacity-70">
|
||||
<i class="pi pi-box text-4xl"></i>
|
||||
<div class="select-none text-lg font-bold">No models found</div>
|
||||
</div>
|
||||
</template>
|
||||
</ResponseScroll>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="manager-dialog">
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import ModelCard from 'components/ModelCard.vue'
|
||||
import ResponseInput from 'components/ResponseInput.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 { useI18n } from 'vue-i18n'
|
||||
|
||||
const {
|
||||
isMobile,
|
||||
gutter,
|
||||
cardSize,
|
||||
cardSizeMap,
|
||||
cardSizeFlag,
|
||||
dialog: settings,
|
||||
} = useConfig()
|
||||
|
||||
const { data, folders, openModelDetail, getFullPath } = useModels()
|
||||
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 allType = 'All'
|
||||
const currentType = ref(allType)
|
||||
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
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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 itemSize = computed(() => {
|
||||
let itemHeight = cardSize.value.height
|
||||
let itemGutter = gutter
|
||||
if (isMobile.value) {
|
||||
const baseSize = 16
|
||||
itemHeight = window.innerWidth - baseSize * 2 * 2
|
||||
itemGutter = baseSize * 2
|
||||
}
|
||||
return itemHeight + itemGutter
|
||||
})
|
||||
|
||||
const { width } = useElementSize(contentContainer)
|
||||
|
||||
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 mergedList = Object.values(data.value).flat()
|
||||
const pureModels = mergedList.filter((item) => {
|
||||
return !item.isFolder
|
||||
})
|
||||
|
||||
function buildRegex(raw: string): RegExp {
|
||||
try {
|
||||
// Escape regex specials, then restore * wildcards as .*
|
||||
const escaped = raw
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\\\*/g, '.*')
|
||||
return new RegExp(escaped, 'i') // case-insensitive
|
||||
} catch (e) {
|
||||
return new RegExp(raw, 'i')
|
||||
}
|
||||
}
|
||||
|
||||
const filterList = pureModels.filter((model) => {
|
||||
const showAllModel = currentType.value === allType
|
||||
const matchType = showAllModel || model.type === currentType.value
|
||||
|
||||
const rawFilter = searchContent.value ?? ''
|
||||
const tokens = rawFilter.split(/\s+/).filter(Boolean)
|
||||
const regexes = tokens.map(buildRegex)
|
||||
|
||||
// Require every token to match either the folder or the name
|
||||
const matchesAll = regexes.every((re) =>
|
||||
re.test(model.subFolder) || re.test(model.basename)
|
||||
)
|
||||
|
||||
return matchType && matchesAll
|
||||
})
|
||||
|
||||
let sortStrategy: (a: Model, b: Model) => number = () => 0
|
||||
switch (sortOrder.value) {
|
||||
case 'name':
|
||||
sortStrategy = (a, b) => a.basename.localeCompare(b.basename)
|
||||
break
|
||||
case 'size':
|
||||
sortStrategy = (a, b) => b.sizeBytes - a.sizeBytes
|
||||
break
|
||||
case 'created':
|
||||
sortStrategy = (a, b) => b.createdAt - a.createdAt
|
||||
break
|
||||
case 'modified':
|
||||
sortStrategy = (a, b) => b.updatedAt - a.updatedAt
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
const sortedList = filterList.sort(sortStrategy)
|
||||
|
||||
return chunk(sortedList, cols.value).map((row) => {
|
||||
return { key: row.map(genModelKey).join(','), row }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
const contentStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(auto-fit, ${cardSize.value.width}px)`,
|
||||
gap: `${gutter}px`,
|
||||
paddingLeft: `1rem`,
|
||||
paddingRight: `1rem`,
|
||||
}))
|
||||
|
||||
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>
|
||||
96
src/components/DialogModelDetail.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<ResponseScroll class="h-full">
|
||||
<div class="px-8">
|
||||
<ModelContent
|
||||
v-model:editable="editable"
|
||||
:model="modelContent"
|
||||
@submit="handleSave"
|
||||
@reset="handleCancel"
|
||||
>
|
||||
<template #action="{ metadata }">
|
||||
<template v-if="editable">
|
||||
<Button :label="$t('cancel')" type="reset"></Button>
|
||||
<Button :label="$t('save')" type="submit"></Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
v-show="metadata.modelPage"
|
||||
icon="pi pi-eye"
|
||||
@click="openModelPage(metadata.modelPage)"
|
||||
></Button>
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
@click.stop="addModelNode(model)"
|
||||
></Button>
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
@click.stop="copyModelNode(model)"
|
||||
></Button>
|
||||
<Button
|
||||
v-show="model.preview"
|
||||
icon="pi pi-file-import"
|
||||
@click.stop="loadPreviewWorkflow(model)"
|
||||
></Button>
|
||||
<Button
|
||||
icon="pi pi-pen-to-square"
|
||||
@click="editable = true"
|
||||
></Button>
|
||||
<Button
|
||||
severity="danger"
|
||||
icon="pi pi-trash"
|
||||
@click="handleDelete"
|
||||
></Button>
|
||||
</template>
|
||||
</template>
|
||||
</ModelContent>
|
||||
</div>
|
||||
</ResponseScroll>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModelContent from 'components/ModelContent.vue'
|
||||
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||
import { genModelUrl, useModelNodeAction, useModels } from 'hooks/model'
|
||||
import { useRequest } from 'hooks/request'
|
||||
import Button from 'primevue/button'
|
||||
import { BaseModel, Model, WithResolved } from 'types/typings'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
model: Model
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { remove, update } = useModels()
|
||||
|
||||
const editable = ref(false)
|
||||
|
||||
const modelDetailUrl = genModelUrl(props.model)
|
||||
const { data: extraInfo } = useRequest(modelDetailUrl, {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const modelContent = computed(() => {
|
||||
return Object.assign({}, props.model, extraInfo.value)
|
||||
})
|
||||
|
||||
const handleCancel = () => {
|
||||
editable.value = false
|
||||
}
|
||||
|
||||
const handleSave = async (data: WithResolved<BaseModel>) => {
|
||||
await update(modelContent.value, data)
|
||||
editable.value = false
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
await remove(props.model)
|
||||
}
|
||||
|
||||
const openModelPage = (url: string) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const { addModelNode, copyModelNode, loadPreviewWorkflow } =
|
||||
useModelNodeAction()
|
||||
</script>
|
||||
271
src/components/DialogScanning.vue
Normal 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>
|
||||
274
src/components/DialogUpload.vue
Normal 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>
|
||||
63
src/components/GlobalDialogStack.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<ResponseDialog
|
||||
v-for="(item, index) in stack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
v-bind="omitProps(item)"
|
||||
:auto-z-index="false"
|
||||
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
|
||||
:pt:root:onMousedown="() => rise(item)"
|
||||
@hide="() => close(item)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-1 items-center justify-between pr-2">
|
||||
<span class="p-dialog-title select-none">{{ item.title }}</span>
|
||||
<div class="p-dialog-header-actions">
|
||||
<Button
|
||||
v-for="action in item.headerButtons"
|
||||
:key="action.key"
|
||||
severity="secondary"
|
||||
:text="true"
|
||||
:rounded="true"
|
||||
:icon="action.icon"
|
||||
@click.stop="action.command"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<component :is="item.content" v-bind="item.contentProps"></component>
|
||||
</template>
|
||||
</ResponseDialog>
|
||||
<Dialog :visible="true" :pt:mask:style="{ display: 'none' }"></Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ResponseDialog from 'components/ResponseDialog.vue'
|
||||
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 { config } = usePrimeVue()
|
||||
|
||||
const baseZIndex = computed(() => {
|
||||
return config.zIndex?.modal ?? 1100
|
||||
})
|
||||
|
||||
const omitProps = (item: DialogItem) => {
|
||||
return omit(item, [
|
||||
'key',
|
||||
'visible',
|
||||
'title',
|
||||
'headerButtons',
|
||||
'content',
|
||||
'contentProps',
|
||||
])
|
||||
}
|
||||
</script>
|
||||
15
src/components/GlobalLoading.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div v-show="loading">
|
||||
<div class="fixed left-0 top-0 h-full w-full" style="z-index: 9999">
|
||||
<div class="flex h-full w-full items-center justify-center bg-black/30">
|
||||
<i class="pi pi-spinner pi-spin text-3xl opacity-30"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGlobalLoading } from 'hooks/loading'
|
||||
|
||||
const { loading } = useGlobalLoading()
|
||||
</script>
|
||||
22
src/components/GlobalToast.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<Toast :position="position" :style="style"></Toast>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfig } from 'hooks/config'
|
||||
import Toast from 'primevue/toast'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const config = useConfig()
|
||||
|
||||
const position = computed(() => {
|
||||
return config.isMobile.value ? 'top-center' : 'top-right'
|
||||
})
|
||||
|
||||
const style = computed(() => {
|
||||
if (config.isMobile.value) {
|
||||
return { width: '80vw' }
|
||||
}
|
||||
return {}
|
||||
})
|
||||
</script>
|
||||
250
src/components/ModelBaseInfo.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="editable" class="flex flex-col gap-4">
|
||||
<ResponseSelect v-if="!baseInfo.type" v-model="type" :items="typeOptions">
|
||||
<template #prefix>
|
||||
<span>{{ $t('modelType') }}</span>
|
||||
</template>
|
||||
</ResponseSelect>
|
||||
|
||||
<div class="flex gap-2 overflow-hidden">
|
||||
<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
|
||||
v-model.trim.valid="basename"
|
||||
class="-mr-2 text-right"
|
||||
update-trigger="blur"
|
||||
:validate="validateBasename"
|
||||
>
|
||||
<template #suffix>
|
||||
<span class="text-base opacity-60">
|
||||
{{ extension }}
|
||||
</span>
|
||||
</template>
|
||||
</ResponseInput>
|
||||
</div>
|
||||
|
||||
<table class="w-full table-fixed border-collapse border">
|
||||
<colgroup>
|
||||
<col class="w-32" />
|
||||
<col />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<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">
|
||||
{{ $t(`info.${item.key}`) }}
|
||||
</td>
|
||||
<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 }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ResponseInput from 'components/ResponseInput.vue'
|
||||
import ResponseScroll from 'components/ResponseScroll.vue'
|
||||
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||
import { useDialog } from 'hooks/dialog'
|
||||
import { useModelBaseInfo, useModelFolder } from 'hooks/model'
|
||||
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 { toast } = useToast()
|
||||
|
||||
const {
|
||||
baseInfo,
|
||||
pathIndex,
|
||||
subFolder,
|
||||
basename,
|
||||
extension,
|
||||
type,
|
||||
modelFolders,
|
||||
} = useModelBaseInfo()
|
||||
|
||||
watch(type, () => {
|
||||
subFolder.value = ''
|
||||
})
|
||||
|
||||
const typeOptions = computed(() => {
|
||||
return Object.keys(modelFolders.value).map((curr) => {
|
||||
return {
|
||||
value: curr,
|
||||
label: curr,
|
||||
command: () => {
|
||||
type.value = curr
|
||||
pathIndex.value = 0
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const information = computed(() => {
|
||||
return Object.values(baseInfo.value).filter((row) => {
|
||||
if (editable.value) {
|
||||
const hiddenKeys = ['basename', 'pathIndex']
|
||||
return !hiddenKeys.includes(row.key)
|
||||
}
|
||||
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>
|
||||
102
src/components/ModelCard.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="relative h-full select-none rounded-lg hover:bg-gray-500/40"
|
||||
>
|
||||
<div data-card-main class="flex h-full w-full flex-col">
|
||||
<div data-card-preview class="flex-1 overflow-hidden">
|
||||
<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>
|
||||
|
||||
<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
|
||||
v-if="!model.isFolder"
|
||||
data-draggable-overlay
|
||||
class="absolute left-0 top-0 h-full w-full"
|
||||
draggable="true"
|
||||
@dragend.stop="dragToAddModelNode(model, $event)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-if="!model.isFolder"
|
||||
data-mode-type
|
||||
class="pointer-events-none absolute left-2 top-2"
|
||||
:style="{
|
||||
transform: `scale(${typeLabelScale})`,
|
||||
transformOrigin: 'left top',
|
||||
}"
|
||||
>
|
||||
<div class="rounded-full bg-black/50 px-3 py-1">
|
||||
<span>{{ model.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import PreviewVideo from 'components/PreviewVideo.vue'
|
||||
import { useModelNodeAction } from 'hooks/model'
|
||||
import { BaseModel } from 'types/typings'
|
||||
import { isVideoUrl } from 'utils/media'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
model: BaseModel
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const preview = computed(() =>
|
||||
Array.isArray(props.model.preview)
|
||||
? props.model.preview[0]
|
||||
: props.model.preview,
|
||||
)
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
|
||||
const { width } = useElementSize(container)
|
||||
|
||||
const typeLabelScale = computed(() => {
|
||||
return width.value / 200
|
||||
})
|
||||
|
||||
const { dragToAddModelNode } = useModelNodeAction()
|
||||
</script>
|
||||
106
src/components/ModelContent.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<form
|
||||
ref="container"
|
||||
@submit.prevent="handleSubmit"
|
||||
@reset.prevent="handleReset"
|
||||
>
|
||||
<div class="mx-auto w-full max-w-[50rem]">
|
||||
<div
|
||||
:class="[
|
||||
'relative flex gap-4 overflow-hidden',
|
||||
$xl('flex-row', 'flex-col'),
|
||||
]"
|
||||
>
|
||||
<ModelPreview
|
||||
class="shrink-0"
|
||||
v-model:editable="editable"
|
||||
></ModelPreview>
|
||||
|
||||
<div class="flex flex-col gap-4 overflow-hidden">
|
||||
<div class="flex h-10 items-center justify-end gap-4">
|
||||
<slot name="action" :metadata="formInstance.metadata.value"></slot>
|
||||
</div>
|
||||
|
||||
<ModelBaseInfo v-model:editable="editable"></ModelBaseInfo>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value="0" class="mt-4">
|
||||
<TabList>
|
||||
<Tab value="0">Description</Tab>
|
||||
<Tab value="1">Metadata</Tab>
|
||||
</TabList>
|
||||
<TabPanels pt:root:class="p-0 py-4">
|
||||
<TabPanel value="0">
|
||||
<ModelDescription v-model:editable="editable"></ModelDescription>
|
||||
</TabPanel>
|
||||
<TabPanel value="1">
|
||||
<ModelMetadata></ModelMetadata>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModelBaseInfo from 'components/ModelBaseInfo.vue'
|
||||
import ModelDescription from 'components/ModelDescription.vue'
|
||||
import ModelMetadata from 'components/ModelMetadata.vue'
|
||||
import ModelPreview from 'components/ModelPreview.vue'
|
||||
import { useContainerQueries } from 'hooks/container'
|
||||
import {
|
||||
useModelBaseInfoEditor,
|
||||
useModelDescriptionEditor,
|
||||
useModelFormData,
|
||||
useModelMetadataEditor,
|
||||
useModelPreviewEditor,
|
||||
} from 'hooks/model'
|
||||
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 {
|
||||
model: BaseModel
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const editable = defineModel<boolean>('editable')
|
||||
|
||||
const emits = defineEmits<{
|
||||
submit: [formData: WithResolved<BaseModel>]
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const formInstance = useModelFormData(() => cloneDeep(toRaw(props.model)))
|
||||
|
||||
useModelBaseInfoEditor(formInstance)
|
||||
useModelPreviewEditor(formInstance)
|
||||
useModelDescriptionEditor(formInstance)
|
||||
useModelMetadataEditor(formInstance)
|
||||
|
||||
const handleReset = () => {
|
||||
formInstance.reset()
|
||||
emits('reset')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const data = formInstance.submit()
|
||||
emits('submit', data)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.model,
|
||||
() => {
|
||||
handleReset()
|
||||
},
|
||||
)
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const { $xl } = useContainerQueries(container)
|
||||
</script>
|
||||
234
src/components/ModelDescription.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-show="active"
|
||||
:class="[
|
||||
'w-full resize-none overflow-hidden px-3 py-2 outline-none',
|
||||
'rounded-lg border',
|
||||
'border-[var(--p-form-field-border-color)]',
|
||||
'focus:border-[var(--p-form-field-focus-border-color)]',
|
||||
'relative z-10',
|
||||
]"
|
||||
v-model="innerValue"
|
||||
@input="resizeTextarea"
|
||||
@blur="exitEditMode"
|
||||
></textarea>
|
||||
|
||||
<div v-show="!active">
|
||||
<div v-show="editable" class="mb-4 flex items-center gap-2 text-gray-600">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<span>
|
||||
{{ $t('tapToChange') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
v-if="renderedDescription"
|
||||
:class="$style['markdown-body']"
|
||||
v-html="renderedDescription"
|
||||
></div>
|
||||
<div v-else class="flex flex-col items-center gap-2 py-5">
|
||||
<i class="pi pi-info-circle text-lg"></i>
|
||||
<div>no description</div>
|
||||
</div>
|
||||
<div
|
||||
v-show="editable"
|
||||
class="absolute left-0 top-0 h-full w-full cursor-pointer"
|
||||
@click="entryEditMode"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useModelDescription } from 'hooks/model'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
const editable = defineModel<boolean>('editable')
|
||||
const active = ref(false)
|
||||
|
||||
const { description, renderedDescription } = useModelDescription()
|
||||
|
||||
const textareaRef = ref<HTMLTextAreaElement>()
|
||||
const innerValue = ref<string>()
|
||||
|
||||
watch(
|
||||
description,
|
||||
(value) => {
|
||||
innerValue.value = value
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const resizeTextarea = () => {
|
||||
const textarea = textareaRef.value!
|
||||
|
||||
textarea.style.height = 'auto'
|
||||
const scrollHeight = textarea.scrollHeight
|
||||
|
||||
textarea.style.height = scrollHeight + 'px'
|
||||
|
||||
textarea.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
})
|
||||
}
|
||||
|
||||
const entryEditMode = async () => {
|
||||
active.value = true
|
||||
await nextTick()
|
||||
resizeTextarea()
|
||||
textareaRef.value!.focus()
|
||||
}
|
||||
|
||||
const exitEditMode = () => {
|
||||
description.value = innerValue.value!
|
||||
active.value = false
|
||||
}
|
||||
</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>
|
||||
37
src/components/ModelMetadata.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<table v-if="dataSource.length" class="w-full border-collapse border">
|
||||
<tbody>
|
||||
<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">
|
||||
{{ item.key }}
|
||||
</td>
|
||||
<td class="break-all px-4">{{ item.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-else class="flex flex-col items-center gap-2 py-5">
|
||||
<i class="pi pi-info-circle text-lg"></i>
|
||||
<div>no metadata</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useModelMetadata } from 'hooks/model'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { metadata } = useModelMetadata()
|
||||
|
||||
const dataSource = computed(() => {
|
||||
const dataSource: { key: string; value: any }[] = []
|
||||
|
||||
for (const key in metadata.value) {
|
||||
if (Object.prototype.hasOwnProperty.call(metadata.value, key)) {
|
||||
const value = metadata.value[key]
|
||||
dataSource.push({ key, value })
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
})
|
||||
</script>
|
||||
135
src/components/ModelPreview.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<div
|
||||
class="relative mx-auto w-full overflow-hidden rounded-lg preview-aspect"
|
||||
:style="$sm({ width: `${cardWidth}px` })"
|
||||
>
|
||||
<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
|
||||
v-if="defaultContent.length > 1"
|
||||
v-show="currentType === 'default'"
|
||||
class="absolute top-0 h-full w-full"
|
||||
:value="defaultContent"
|
||||
v-model:page="defaultContentPage"
|
||||
:circular="true"
|
||||
:show-navigators="true"
|
||||
:show-indicators="false"
|
||||
pt:contentcontainer:class="h-full"
|
||||
pt:content:class="h-full"
|
||||
pt:itemlist:class="h-full"
|
||||
:prev-button-props="{
|
||||
class: 'absolute left-4 z-10',
|
||||
rounded: true,
|
||||
severity: 'secondary',
|
||||
}"
|
||||
:next-button-props="{
|
||||
class: 'absolute right-4 z-10',
|
||||
rounded: true,
|
||||
severity: 'secondary',
|
||||
}"
|
||||
>
|
||||
<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
|
||||
v-else
|
||||
:src="slotProps.data"
|
||||
:error="noPreviewContent"
|
||||
></ResponseImage>
|
||||
</template>
|
||||
</Carousel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="editable" class="flex flex-col gap-4 whitespace-nowrap">
|
||||
<div class="h-10"></div>
|
||||
<div
|
||||
:class="[
|
||||
'absolute flex h-10 items-center gap-4',
|
||||
$xl('left-0 translate-x-0', 'left-1/2 -translate-x-1/2'),
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
v-for="type in typeOptions"
|
||||
:key="type"
|
||||
:severity="currentType === type ? undefined : 'secondary'"
|
||||
:label="$t(type)"
|
||||
@click="currentType = type"
|
||||
></Button>
|
||||
</div>
|
||||
|
||||
<div v-show="currentType === 'network'">
|
||||
<div class="absolute left-0 w-full">
|
||||
<ResponseInput
|
||||
v-model="networkContent"
|
||||
prefix-icon="pi pi-globe"
|
||||
:allow-clear="true"
|
||||
></ResponseInput>
|
||||
</div>
|
||||
<div class="h-10"></div>
|
||||
</div>
|
||||
|
||||
<div v-show="currentType === 'local'">
|
||||
<ResponseFileUpload
|
||||
class="absolute left-0 h-24 w-full"
|
||||
@select="updateLocalContent"
|
||||
>
|
||||
</ResponseFileUpload>
|
||||
<div class="h-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PreviewVideo from 'components/PreviewVideo.vue'
|
||||
import ResponseFileUpload from 'components/ResponseFileUpload.vue'
|
||||
import ResponseImage from 'components/ResponseImage.vue'
|
||||
import ResponseInput from 'components/ResponseInput.vue'
|
||||
import { useConfig } from 'hooks/config'
|
||||
import { useContainerQueries } from 'hooks/container'
|
||||
import { useModelPreview } from 'hooks/model'
|
||||
import Button from 'primevue/button'
|
||||
import Carousel from 'primevue/carousel'
|
||||
import { isVideoUrl } from 'utils/media'
|
||||
|
||||
const editable = defineModel<boolean>('editable')
|
||||
const { cardWidth } = useConfig()
|
||||
|
||||
const {
|
||||
preview,
|
||||
typeOptions,
|
||||
currentType,
|
||||
defaultContent,
|
||||
defaultContentPage,
|
||||
networkContent,
|
||||
updateLocalContent,
|
||||
noPreviewContent,
|
||||
localContentType,
|
||||
} = useModelPreview()
|
||||
|
||||
const { $sm, $xl } = useContainerQueries()
|
||||
</script>
|
||||
25
src/components/PreviewVideo.vue
Normal 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>
|
||||
163
src/components/ResponseBreadcrumb.vue
Normal 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>
|
||||
350
src/components/ResponseDialog.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:visible="true"
|
||||
@update:visible="updateVisible"
|
||||
:modal="modal"
|
||||
:close-on-escape="false"
|
||||
:maximizable="!isMobile"
|
||||
maximizeIcon="pi pi-arrow-up-right-and-arrow-down-left-from-center"
|
||||
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
|
||||
:pt:mask:class="['group', { open: visible }]"
|
||||
:pt:root:class="['max-h-full group-[:not(.open)]:!hidden', $style.dialog]"
|
||||
pt:content:class="p-0 flex-1"
|
||||
:base-z-index="1000"
|
||||
:auto-z-index="isNil(zIndex)"
|
||||
:pt:mask:style="isNil(zIndex) ? {} : { zIndex: 1000 + zIndex }"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<template #header>
|
||||
<slot name="header"></slot>
|
||||
</template>
|
||||
|
||||
<slot name="default"></slot>
|
||||
|
||||
<div v-if="allowResize" data-dialog-resizer>
|
||||
<div
|
||||
v-if="resizeAllow?.x"
|
||||
data-resize-pos="left"
|
||||
class="absolute -left-1 top-0 h-full w-2 cursor-ew-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="resizeAllow?.x"
|
||||
data-resize-pos="right"
|
||||
class="absolute -right-1 top-0 h-full w-2 cursor-ew-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="resizeAllow?.y"
|
||||
data-resize-pos="top"
|
||||
class="absolute -top-1 left-0 h-2 w-full cursor-ns-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="resizeAllow?.y"
|
||||
data-resize-pos="bottom"
|
||||
class="absolute -bottom-1 left-0 h-2 w-full cursor-ns-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="resizeAllow?.x && resizeAllow?.y"
|
||||
data-resize-pos="top-left"
|
||||
class="absolute -left-1 -top-1 h-2 w-2 cursor-se-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="resizeAllow?.x && resizeAllow?.y"
|
||||
data-resize-pos="top-right"
|
||||
class="absolute -right-1 -top-1 h-2 w-2 cursor-sw-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="resizeAllow?.x && resizeAllow?.y"
|
||||
data-resize-pos="bottom-left"
|
||||
class="absolute -bottom-1 -left-1 h-2 w-2 cursor-sw-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="resizeAllow?.x && resizeAllow?.y"
|
||||
data-resize-pos="bottom-right"
|
||||
class="absolute -bottom-1 -right-1 h-2 w-2 cursor-se-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
keepAlive?: boolean
|
||||
defaultSize?: Partial<ContainerSize>
|
||||
defaultMobileSize?: Partial<ContainerSize>
|
||||
resizeAllow?: { x?: boolean; y?: boolean }
|
||||
minWidth?: number
|
||||
maxWidth?: number
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
zIndex?: number
|
||||
modal?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
resizeAllow: () => ({ x: true, y: true }),
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const visible = defineModel<boolean>('visible')
|
||||
|
||||
const emit = defineEmits(['hide'])
|
||||
|
||||
const updateVisible = (val: boolean) => {
|
||||
visible.value = val
|
||||
emit('hide')
|
||||
}
|
||||
|
||||
const { isMobile } = useConfig()
|
||||
|
||||
const dialogRef = ref()
|
||||
|
||||
const allowResize = computed(() => {
|
||||
return !isMobile.value
|
||||
})
|
||||
|
||||
const resizeDirection = ref<string[]>([])
|
||||
|
||||
const getContainer = () => {
|
||||
return dialogRef.value.container
|
||||
}
|
||||
|
||||
const minWidth = computed(() => {
|
||||
const defaultMinWidth = 390
|
||||
return props.minWidth ?? defaultMinWidth
|
||||
})
|
||||
|
||||
const maxWidth = computed(() => {
|
||||
const defaultMaxWidth = window.innerWidth
|
||||
return props.maxWidth ?? defaultMaxWidth
|
||||
})
|
||||
|
||||
const minHeight = computed(() => {
|
||||
const defaultMinHeight = 390
|
||||
return props.minHeight ?? defaultMinHeight
|
||||
})
|
||||
|
||||
const maxHeight = computed(() => {
|
||||
const defaultMaxHeight = window.innerHeight
|
||||
return props.maxHeight ?? defaultMaxHeight
|
||||
})
|
||||
|
||||
const isResizing = ref(false)
|
||||
|
||||
const defaultWidth = window.innerWidth * 0.6
|
||||
const defaultHeight = window.innerHeight * 0.8
|
||||
|
||||
const containerSize = ref({
|
||||
width:
|
||||
props.defaultSize?.width ??
|
||||
clamp(defaultWidth, minWidth.value, maxWidth.value),
|
||||
height:
|
||||
props.defaultSize?.height ??
|
||||
clamp(defaultHeight, minHeight.value, maxHeight.value),
|
||||
})
|
||||
const containerPosition = ref<ContainerPosition>({ left: 0, top: 0 })
|
||||
|
||||
const updateContainerSize = (size: ContainerSize) => {
|
||||
const container = getContainer()
|
||||
container.style.width = `${size.width}px`
|
||||
container.style.height = `${size.height}px`
|
||||
}
|
||||
|
||||
const updateContainerPosition = (position: ContainerPosition) => {
|
||||
const container = getContainer()
|
||||
container.style.left = `${position.left}px`
|
||||
container.style.top = `${position.top}px`
|
||||
}
|
||||
|
||||
const recordContainerPosition = () => {
|
||||
const container = getContainer()
|
||||
containerPosition.value = {
|
||||
left: container.offsetLeft,
|
||||
top: container.offsetTop,
|
||||
}
|
||||
}
|
||||
|
||||
const updateGlobalStyle = (direction?: string) => {
|
||||
let cursor = ''
|
||||
let select = ''
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
case 'right':
|
||||
cursor = 'ew-resize'
|
||||
select = 'none'
|
||||
break
|
||||
case 'top':
|
||||
case 'bottom':
|
||||
cursor = 'ns-resize'
|
||||
select = 'none'
|
||||
break
|
||||
case 'top-left':
|
||||
case 'bottom-right':
|
||||
cursor = 'se-resize'
|
||||
select = 'none'
|
||||
break
|
||||
case 'top-right':
|
||||
case 'bottom-left':
|
||||
cursor = 'sw-resize'
|
||||
select = 'none'
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
document.body.style.cursor = cursor
|
||||
document.body.style.userSelect = select
|
||||
}
|
||||
|
||||
const resize = (event: MouseEvent) => {
|
||||
if (isResizing.value) {
|
||||
const container = getContainer()
|
||||
|
||||
for (const direction of resizeDirection.value) {
|
||||
if (direction === 'left') {
|
||||
if (event.clientX > 0) {
|
||||
containerSize.value.width = clamp(
|
||||
container.offsetLeft + container.offsetWidth - event.clientX,
|
||||
minWidth.value,
|
||||
maxWidth.value,
|
||||
)
|
||||
}
|
||||
if (
|
||||
containerSize.value.width > minWidth.value &&
|
||||
containerSize.value.width < maxWidth.value
|
||||
) {
|
||||
containerPosition.value.left = clamp(
|
||||
event.clientX,
|
||||
0,
|
||||
window.innerWidth - containerSize.value.width,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === 'right') {
|
||||
containerSize.value.width = clamp(
|
||||
event.clientX - container.offsetLeft,
|
||||
minWidth.value,
|
||||
maxWidth.value,
|
||||
)
|
||||
}
|
||||
|
||||
if (direction === 'top') {
|
||||
if (event.clientY > 0) {
|
||||
containerSize.value.height = clamp(
|
||||
container.offsetTop + container.offsetHeight - event.clientY,
|
||||
minHeight.value,
|
||||
maxHeight.value,
|
||||
)
|
||||
}
|
||||
if (
|
||||
containerSize.value.height > minHeight.value &&
|
||||
containerSize.value.height < maxHeight.value
|
||||
) {
|
||||
containerPosition.value.top = clamp(
|
||||
event.clientY,
|
||||
0,
|
||||
window.innerHeight - containerSize.value.height,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === 'bottom') {
|
||||
containerSize.value.height = clamp(
|
||||
event.clientY - container.offsetTop,
|
||||
minHeight.value,
|
||||
maxHeight.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
updateContainerSize(containerSize.value)
|
||||
updateContainerPosition(containerPosition.value)
|
||||
}
|
||||
}
|
||||
|
||||
const stopResize = () => {
|
||||
isResizing.value = false
|
||||
resizeDirection.value = []
|
||||
document.removeEventListener('mousemove', resize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
updateGlobalStyle()
|
||||
}
|
||||
|
||||
const startResize = (event: MouseEvent) => {
|
||||
isResizing.value = true
|
||||
const direction =
|
||||
(event.target as HTMLElement).getAttribute('data-resize-pos') ?? ''
|
||||
resizeDirection.value = direction.split('-')
|
||||
recordContainerPosition()
|
||||
updateGlobalStyle(direction)
|
||||
document.addEventListener('mousemove', resize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (allowResize.value) {
|
||||
updateContainerSize(containerSize.value)
|
||||
} else {
|
||||
updateContainerSize({
|
||||
width: props.defaultMobileSize?.width ?? window.innerWidth,
|
||||
height: props.defaultMobileSize?.height ?? window.innerHeight,
|
||||
})
|
||||
}
|
||||
recordContainerPosition()
|
||||
updateContainerPosition(containerPosition.value)
|
||||
getContainer().style.position = 'fixed'
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopResize()
|
||||
})
|
||||
|
||||
watch(allowResize, (allowResize) => {
|
||||
if (allowResize) {
|
||||
updateContainerSize(containerSize.value)
|
||||
updateContainerPosition(containerPosition.value)
|
||||
} else {
|
||||
updateContainerSize({
|
||||
width: props.defaultMobileSize?.width ?? window.innerWidth,
|
||||
height: props.defaultMobileSize?.height ?? window.innerHeight,
|
||||
})
|
||||
updateContainerPosition({ left: 0, top: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
updateContainerSize,
|
||||
updateContainerPosition,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="css" module>
|
||||
@layer tailwind-utilities {
|
||||
:where(.dialog) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
border: 0 solid var(--p-surface-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
58
src/components/ResponseFileUpload.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded-lg border border-gray-500 p-4 text-gray-500"
|
||||
@dragenter.stop.prevent
|
||||
@dragover.stop.prevent
|
||||
@dragleave.stop.prevent
|
||||
@drop.stop.prevent="handleDropFile"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot name="default">
|
||||
<div class="flex h-full flex-col items-center justify-center gap-2">
|
||||
<i class="pi pi-cloud-upload text-2xl"></i>
|
||||
<p class="m-0 select-none overflow-hidden text-ellipsis">
|
||||
{{ $t('uploadFile') }}
|
||||
</p>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SelectEvent, SelectFile } from 'types/typings'
|
||||
|
||||
const emits = defineEmits<{
|
||||
select: [event: SelectEvent]
|
||||
}>()
|
||||
|
||||
const covertFileList = (fileList: FileList) => {
|
||||
const files: SelectFile[] = []
|
||||
for (const file of fileList) {
|
||||
const selectFile = file as SelectFile
|
||||
selectFile.objectURL = URL.createObjectURL(file)
|
||||
files.push(selectFile)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
const handleDropFile = (event: DragEvent) => {
|
||||
const files = event.dataTransfer?.files
|
||||
|
||||
if (files) {
|
||||
emits('select', { originalEvent: event, files: covertFileList(files) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/*,video/*'
|
||||
input.onchange = () => {
|
||||
const files = input.files
|
||||
if (files) {
|
||||
emits('select', { originalEvent: event, files: covertFileList(files) })
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
</script>
|
||||
36
src/components/ResponseImage.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<span class="relative">
|
||||
<img :src="src" :alt="alt" v-bind="$attrs" @error="onError" />
|
||||
<img v-if="error" v-show="loadError" :src="error" class="absolute top-0" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
src?: string
|
||||
alt?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const loadError = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
loadError.value = !props.src
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const onError = () => {
|
||||
loadError.value = true
|
||||
}
|
||||
</script>
|
||||
96
src/components/ResponseInput.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'p-component p-inputtext flex items-center gap-2 border',
|
||||
'focus-within:border-[--p-inputtext-focus-border-color]',
|
||||
]"
|
||||
>
|
||||
<slot name="prefix">
|
||||
<span
|
||||
v-if="prefixIcon"
|
||||
:class="[prefixIcon, 'text-base opacity-60']"
|
||||
></span>
|
||||
</slot>
|
||||
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
class="flex-1 border-none bg-transparent text-base outline-none"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
@paste.stop
|
||||
v-bind="$attrs"
|
||||
@[trigger]="updateContent"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="allowClear"
|
||||
v-show="content"
|
||||
class="pi pi-times text-base opacity-60"
|
||||
@click="clearContent"
|
||||
></span>
|
||||
<slot name="suffix">
|
||||
<span
|
||||
v-if="suffixIcon"
|
||||
:class="[suffixIcon, 'text-base opacity-60']"
|
||||
></span>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
prefixIcon?: string
|
||||
suffixIcon?: string
|
||||
placeholder?: string
|
||||
allowClear?: boolean
|
||||
updateTrigger?: string
|
||||
validate?: (value: string | undefined) => boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const [content, modifiers] = defineModel<string, 'trim' | 'valid'>()
|
||||
|
||||
const inputRef = ref()
|
||||
|
||||
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 updateContent = () => {
|
||||
let value = inputValue.value
|
||||
|
||||
if (modifiers.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
|
||||
inputRef.value.value = value
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const clearContent = () => {
|
||||
content.value = undefined
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
</script>
|
||||
140
src/components/ResponseScroll.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="group/scroll relative h-full overflow-hidden">
|
||||
<div ref="viewport" class="h-full w-full overflow-auto scrollbar-none">
|
||||
<div ref="content">
|
||||
<slot name="default">
|
||||
<slot v-if="renderedItems.length === 0" name="empty">
|
||||
<div class="absolute w-full py-20 text-center">No Data</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="scroll" class="absolute right-0 top-0 h-full w-2">
|
||||
<div
|
||||
ref="thumb"
|
||||
:class="[
|
||||
'absolute w-full cursor-pointer rounded-full bg-gray-500',
|
||||
'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-30',
|
||||
]"
|
||||
:style="{
|
||||
height: `${thumbSize}px`,
|
||||
top: `${thumbOffset}px`,
|
||||
opacity: isDragging ? '0.3' : undefined,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends { key: string }">
|
||||
import { useDraggable, useElementSize, useScroll } from '@vueuse/core'
|
||||
import { clamp } from 'lodash'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface ScrollAreaProps {
|
||||
items?: T[]
|
||||
itemSize?: number
|
||||
}
|
||||
|
||||
const props = defineProps<ScrollAreaProps>()
|
||||
|
||||
const viewport = ref<HTMLElement | null>(null)
|
||||
const content = ref<HTMLElement | null>(null)
|
||||
|
||||
const { height: viewportHeight } = useElementSize(viewport)
|
||||
const { height: contentHeight } = useElementSize(content)
|
||||
const { y: scrollY } = useScroll(viewport)
|
||||
|
||||
const itemSize = computed(() => props.itemSize || 0)
|
||||
|
||||
const viewRows = computed(() =>
|
||||
Math.ceil(viewportHeight.value / itemSize.value),
|
||||
)
|
||||
const offsetRows = computed(() => Math.floor(scrollY.value / itemSize.value))
|
||||
|
||||
const items = computed(() => {
|
||||
return props.items ?? []
|
||||
})
|
||||
|
||||
const state = computed(() => {
|
||||
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>
|
||||
264
src/components/ResponseSelect.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<slot
|
||||
v-if="type === 'drop'"
|
||||
name="target"
|
||||
v-bind="{
|
||||
toggle,
|
||||
prefixIcon,
|
||||
suffixIcon,
|
||||
currentLabel,
|
||||
current,
|
||||
overlayVisible,
|
||||
}"
|
||||
>
|
||||
<div :class="['-my-1 py-1', $attrs.class]" @click="toggle">
|
||||
<Button
|
||||
v-bind="{ rounded, text, severity, size }"
|
||||
class="w-full whitespace-nowrap"
|
||||
>
|
||||
<slot name="prefix">
|
||||
<span v-if="prefixIcon" class="p-button-icon p-button-icon-left">
|
||||
<i :class="prefixIcon"></i>
|
||||
</span>
|
||||
</slot>
|
||||
<span class="flex-1 overflow-scroll text-right scrollbar-none">
|
||||
<slot name="label">{{ currentLabel }}</slot>
|
||||
</span>
|
||||
<slot name="suffix">
|
||||
<span v-if="suffixIcon" class="p-button-icon p-button-icon-right">
|
||||
<i :class="suffixIcon"></i>
|
||||
</span>
|
||||
</slot>
|
||||
</Button>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div v-else class="relative flex-1 overflow-hidden">
|
||||
<div ref="scrollArea" class="h-full w-full overflow-auto scrollbar-none">
|
||||
<div ref="contentArea" class="table max-w-full">
|
||||
<div
|
||||
v-show="showControlButton && scrollPosition !== 'left'"
|
||||
:class="[
|
||||
'pointer-events-none absolute z-10 flex h-full items-center',
|
||||
'top-1/2 [transform:translateY(-50%)]',
|
||||
'left-0 pr-16',
|
||||
'[background-image:linear-gradient(to_right,currentColor,transparent)]',
|
||||
]"
|
||||
style="color: var(--p-dialog-background)"
|
||||
>
|
||||
<Button
|
||||
icon="pi pi-angle-left"
|
||||
class="pointer-events-auto border-none bg-transparent"
|
||||
severity="secondary"
|
||||
@click="scrollTo('prev')"
|
||||
:size="size"
|
||||
></Button>
|
||||
</div>
|
||||
<div class="flex h-10 items-center gap-2">
|
||||
<Button
|
||||
v-for="item in items"
|
||||
severity="secondary"
|
||||
:key="item.value"
|
||||
:data-active="current === item.value"
|
||||
:active="current === item.value"
|
||||
class="data-[active=true]:bg-blue-500 data-[active=true]:text-white"
|
||||
:size="size"
|
||||
@click="item.command"
|
||||
>
|
||||
<span class="whitespace-nowrap">{{ item.label }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-show="showControlButton && scrollPosition !== 'right'"
|
||||
:class="[
|
||||
'pointer-events-none absolute z-10 flex h-full items-center',
|
||||
'top-1/2 [transform:translateY(-50%)]',
|
||||
'right-0 pl-16',
|
||||
'[background-image:linear-gradient(to_left,currentColor,transparent)]',
|
||||
]"
|
||||
style="color: var(--p-dialog-background)"
|
||||
>
|
||||
<Button
|
||||
:size="size"
|
||||
icon="pi pi-angle-right"
|
||||
class="pointer-events-auto border-none bg-transparent"
|
||||
severity="secondary"
|
||||
@click="scrollTo('next')"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot v-if="isMobile" name="mobile">
|
||||
<Drawer
|
||||
v-model:visible="visible"
|
||||
position="bottom"
|
||||
style="height: auto; max-height: 80%"
|
||||
>
|
||||
<template #container>
|
||||
<slot name="container">
|
||||
<slot name="mobile:container">
|
||||
<div class="h-full overflow-scroll scrollbar-none">
|
||||
<Menu
|
||||
:model="items"
|
||||
pt:root:class="border-0 px-4 py-5"
|
||||
:pt:list:onClick="toggle"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<slot name="item" :item="item">
|
||||
<slot name="mobile:container:item" :item="item">
|
||||
<a class="p-menu-item-link justify-between">
|
||||
<span
|
||||
class="p-menu-item-label overflow-hidden break-words"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span v-show="current === item.value">
|
||||
<i class="pi pi-check text-blue-400"></i>
|
||||
</span>
|
||||
</a>
|
||||
</slot>
|
||||
</slot>
|
||||
</template>
|
||||
</Menu>
|
||||
</div>
|
||||
</slot>
|
||||
</slot>
|
||||
</template>
|
||||
</Drawer>
|
||||
</slot>
|
||||
|
||||
<slot v-else name="desktop">
|
||||
<slot name="container">
|
||||
<slot name="desktop:container">
|
||||
<Menu
|
||||
ref="menu"
|
||||
:model="items"
|
||||
:popup="true"
|
||||
:base-z-index="1000"
|
||||
:pt:root:style="{ maxHeight: '300px', overflowX: 'hidden' }"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<slot name="item" :item="item">
|
||||
<slot name="desktop:container:item" :item="item">
|
||||
<a class="p-menu-item-link justify-between">
|
||||
<span class="p-menu-item-label">{{ item.label }}</span>
|
||||
<span v-show="current === item.value">
|
||||
<i class="pi pi-check text-blue-400"></i>
|
||||
</span>
|
||||
</a>
|
||||
</slot>
|
||||
</slot>
|
||||
</template>
|
||||
</Menu>
|
||||
</slot>
|
||||
</slot>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementSize, useScroll } from '@vueuse/core'
|
||||
import { useConfig } from 'hooks/config'
|
||||
import Button, { ButtonProps } from 'primevue/button'
|
||||
import Drawer from 'primevue/drawer'
|
||||
import Menu from 'primevue/menu'
|
||||
import { SelectOptions } from 'types/typings'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const current = defineModel()
|
||||
|
||||
interface Props {
|
||||
items?: SelectOptions[]
|
||||
rounded?: boolean
|
||||
text?: boolean
|
||||
severity?: ButtonProps['severity']
|
||||
size?: ButtonProps['size']
|
||||
type?: 'button' | 'drop'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
severity: 'secondary',
|
||||
type: 'drop',
|
||||
})
|
||||
|
||||
const suffixIcon = ref('pi pi-angle-down')
|
||||
const prefixIcon = computed(() => {
|
||||
return props.items?.find((item) => item.value === current.value)?.icon
|
||||
})
|
||||
|
||||
const currentLabel = computed(() => {
|
||||
return props.items?.find((item) => item.value === current.value)?.label
|
||||
})
|
||||
|
||||
const menu = ref()
|
||||
const visible = ref(false)
|
||||
|
||||
const { isMobile } = useConfig()
|
||||
|
||||
const toggle = (event: MouseEvent) => {
|
||||
if (isMobile.value) {
|
||||
visible.value = !visible.value
|
||||
} else {
|
||||
menu.value.toggle(event)
|
||||
}
|
||||
}
|
||||
|
||||
const overlayVisible = computed(() => {
|
||||
return isMobile.value ? visible.value : (menu.value?.overlayVisible ?? false)
|
||||
})
|
||||
|
||||
// Select Button Type
|
||||
const scrollArea = ref<HTMLElement | null>(null)
|
||||
const contentArea = ref()
|
||||
|
||||
type ScrollPosition = 'left' | 'right'
|
||||
|
||||
const scrollPosition = ref<ScrollPosition | undefined>('left')
|
||||
const showControlButton = ref<boolean>(true)
|
||||
|
||||
const scrollTo = (type: 'prev' | 'next') => {
|
||||
const container = scrollArea.value as HTMLDivElement
|
||||
const scrollLeft = container.scrollLeft
|
||||
const direction = type === 'prev' ? -1 : 1
|
||||
const distance = (container.clientWidth / 3) * 2
|
||||
container.scrollTo({
|
||||
left: scrollLeft + direction * distance,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
const checkScrollPosition = () => {
|
||||
const container = scrollArea.value as HTMLDivElement
|
||||
const content = contentArea.value as HTMLDivElement
|
||||
|
||||
const scrollLeft = container.scrollLeft
|
||||
|
||||
const containerWidth = container.clientWidth
|
||||
const contentWidth = content.clientWidth
|
||||
|
||||
let position: ScrollPosition | undefined = undefined
|
||||
|
||||
if (scrollLeft === 0) {
|
||||
position = 'left'
|
||||
}
|
||||
if (Math.ceil(scrollLeft) >= contentWidth - containerWidth) {
|
||||
position = 'right'
|
||||
}
|
||||
|
||||
scrollPosition.value = position
|
||||
showControlButton.value = contentWidth > containerWidth
|
||||
}
|
||||
|
||||
const { width, height } = useElementSize(scrollArea)
|
||||
|
||||
watch([width, height], () => {
|
||||
checkScrollPosition()
|
||||
})
|
||||
|
||||
useScroll(scrollArea, {
|
||||
onScroll: () => {
|
||||
checkScrollPosition()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
69
src/components/SettingApiKey.vue
Normal 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>
|
||||
110
src/components/SettingCardSize.vue
Normal 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>
|
||||
253
src/hooks/config.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import SettingApiKey from 'components/SettingApiKey.vue'
|
||||
import SettingCardSize from 'components/SettingCardSize.vue'
|
||||
import { request } from 'hooks/request'
|
||||
import { defineStore } from 'hooks/store'
|
||||
import { useToast } from 'hooks/toast'
|
||||
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()
|
||||
|
||||
const mobileDeviceBreakPoint = 759
|
||||
const isMobile = ref(window.innerWidth < mobileDeviceBreakPoint)
|
||||
|
||||
const checkDeviceType = () => {
|
||||
isMobile.value = window.innerWidth < mobileDeviceBreakPoint
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', checkDeviceType)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkDeviceType)
|
||||
})
|
||||
|
||||
const flatLayout = ref(false)
|
||||
|
||||
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 = {
|
||||
isMobile,
|
||||
gutter: 16,
|
||||
defaultCardSizeMap: defaultCardSizeMap,
|
||||
cardSizeMap: cardSizeMap,
|
||||
cardSizeFlag: cardSizeFlag,
|
||||
cardSize: cardSize,
|
||||
cardWidth: 240,
|
||||
aspect: 7 / 9,
|
||||
dialog: {
|
||||
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>>({}),
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
type Config = ReturnType<typeof useConfig>
|
||||
|
||||
declare module 'hooks/store' {
|
||||
interface StoreProvider {
|
||||
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(() => {
|
||||
// API keys
|
||||
app.ui?.settings.addSetting({
|
||||
id: 'ModelManager.APIKey.HuggingFace',
|
||||
category: [t('modelManager'), t('setting.apiKey'), 'HuggingFace'],
|
||||
name: 'HuggingFace API Key',
|
||||
defaultValue: undefined,
|
||||
type: renderApiKey('huggingface'),
|
||||
})
|
||||
|
||||
app.ui?.settings.addSetting({
|
||||
id: 'ModelManager.APIKey.Civitai',
|
||||
category: [t('modelManager'), t('setting.apiKey'), 'Civitai'],
|
||||
name: 'Civitai API Key',
|
||||
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
@@ -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),
|
||||
}
|
||||
}
|
||||
78
src/hooks/dialog.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { defineStore } from 'hooks/store'
|
||||
import { ContainerSize } from 'types/typings'
|
||||
import { Component, markRaw, ref } from 'vue'
|
||||
|
||||
interface HeaderButton {
|
||||
key: string
|
||||
icon: string
|
||||
command: () => void
|
||||
}
|
||||
|
||||
export interface DialogItem {
|
||||
key: string
|
||||
title: string
|
||||
content: Component
|
||||
contentProps?: Record<string, any>
|
||||
keepAlive?: boolean
|
||||
headerButtons?: HeaderButton[]
|
||||
defaultSize?: Partial<ContainerSize>
|
||||
defaultMobileSize?: Partial<ContainerSize>
|
||||
resizeAllow?: { x?: boolean; y?: boolean }
|
||||
minWidth?: number
|
||||
maxWidth?: number
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
modal?: boolean
|
||||
}
|
||||
|
||||
export const useDialog = defineStore('dialog', () => {
|
||||
const stack = ref<(DialogItem & { visible?: boolean })[]>([])
|
||||
|
||||
const rise = (dialog: { key: string }) => {
|
||||
const index = stack.value.findIndex((item) => item.key === dialog.key)
|
||||
if (index !== -1) {
|
||||
const item = stack.value.splice(index, 1)
|
||||
stack.value.push(...item)
|
||||
}
|
||||
}
|
||||
|
||||
const open = (dialog: DialogItem) => {
|
||||
const item = stack.value.find((item) => item.key === dialog.key)
|
||||
if (item) {
|
||||
item.visible = true
|
||||
rise(dialog)
|
||||
} else {
|
||||
stack.value.push({
|
||||
...dialog,
|
||||
content: markRaw(dialog.content),
|
||||
visible: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const close = (dialog?: { key: string }) => {
|
||||
if (!dialog) {
|
||||
stack.value.pop()
|
||||
return
|
||||
}
|
||||
|
||||
const item = stack.value.find((item) => item.key === dialog.key)
|
||||
if (item?.keepAlive) {
|
||||
item.visible = false
|
||||
} else {
|
||||
stack.value = stack.value.filter((item) => item.key !== dialog.key)
|
||||
}
|
||||
}
|
||||
|
||||
const closeAll = () => {
|
||||
stack.value = []
|
||||
}
|
||||
|
||||
return { stack, open, close, closeAll, rise }
|
||||
})
|
||||
|
||||
declare module 'hooks/store' {
|
||||
interface StoreProvider {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
}
|
||||
}
|
||||
274
src/hooks/download.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useLoading } from 'hooks/loading'
|
||||
import { request } from 'hooks/request'
|
||||
import { defineStore } from 'hooks/store'
|
||||
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 { onBeforeMount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import yaml from 'yaml'
|
||||
|
||||
export const useDownload = defineStore('download', (store) => {
|
||||
const { toast, confirm, wrapperToastError } = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const taskList = ref<DownloadTask[]>([])
|
||||
|
||||
const createTaskItem = (item: DownloadTaskOptions) => {
|
||||
const { downloadedSize, totalSize, bps, ...rest } = item
|
||||
|
||||
const task: DownloadTask = {
|
||||
...rest,
|
||||
preview: `/model-manager/preview/download/${item.preview}`,
|
||||
downloadProgress: `${bytesToSize(downloadedSize)} / ${bytesToSize(totalSize)}`,
|
||||
downloadSpeed: `${bytesToSize(bps)}/s`,
|
||||
pauseTask() {
|
||||
wrapperToastError(async () =>
|
||||
request(`/download/${item.taskId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
status: 'pause',
|
||||
}),
|
||||
}),
|
||||
)()
|
||||
},
|
||||
resumeTask: () => {
|
||||
wrapperToastError(async () =>
|
||||
request(`/download/${item.taskId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
status: 'resume',
|
||||
}),
|
||||
}),
|
||||
)()
|
||||
},
|
||||
deleteTask: () => {
|
||||
confirm.require({
|
||||
message: t('deleteAsk', [t('downloadTask').toLowerCase()]),
|
||||
header: 'Danger',
|
||||
icon: 'pi pi-info-circle',
|
||||
rejectProps: {
|
||||
label: t('cancel'),
|
||||
severity: 'secondary',
|
||||
outlined: true,
|
||||
},
|
||||
acceptProps: {
|
||||
label: t('delete'),
|
||||
severity: 'danger',
|
||||
},
|
||||
accept: () => {
|
||||
wrapperToastError(async () =>
|
||||
request(`/download/${item.taskId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
)()
|
||||
},
|
||||
reject: () => {},
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
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(() => {
|
||||
init()
|
||||
|
||||
api.addEventListener('reconnected', () => {
|
||||
refresh()
|
||||
})
|
||||
|
||||
api.addEventListener('fetch_download_task_list', (event) => {
|
||||
const data = event.detail as DownloadTaskOptions[]
|
||||
|
||||
taskList.value = data.map((item) => {
|
||||
return createTaskItem(item)
|
||||
})
|
||||
})
|
||||
|
||||
api.addEventListener('create_download_task', (event) => {
|
||||
const item = event.detail as DownloadTaskOptions
|
||||
taskList.value.unshift(createTaskItem(item))
|
||||
})
|
||||
|
||||
api.addEventListener('update_download_task', (event) => {
|
||||
const item = event.detail as DownloadTaskOptions
|
||||
|
||||
for (const task of taskList.value) {
|
||||
if (task.taskId === item.taskId) {
|
||||
if (item.error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: item.error,
|
||||
life: 15000,
|
||||
})
|
||||
item.error = undefined
|
||||
}
|
||||
Object.assign(task, createTaskItem(item))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
api.addEventListener('delete_download_task', (event) => {
|
||||
const taskId = event.detail as string
|
||||
taskList.value = taskList.value.filter((item) => item.taskId !== taskId)
|
||||
})
|
||||
|
||||
api.addEventListener('complete_download_task', (event) => {
|
||||
const taskId = event.detail as string
|
||||
const task = taskList.value.find((item) => item.taskId === taskId)
|
||||
taskList.value = taskList.value.filter((item) => item.taskId !== taskId)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: `${task?.fullname} Download completed`,
|
||||
life: 2000,
|
||||
})
|
||||
store.models.refresh()
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
})
|
||||
|
||||
return { data: taskList, refresh }
|
||||
})
|
||||
|
||||
declare module 'hooks/store' {
|
||||
interface StoreProvider {
|
||||
download: ReturnType<typeof useDownload>
|
||||
}
|
||||
}
|
||||
|
||||
type WithSelection<T> = SelectOptions & { item: T }
|
||||
|
||||
type FileSelectionVersionModel = VersionModel & {
|
||||
currentFileId?: number
|
||||
selectionFiles?: WithSelection<VersionModelFile>[]
|
||||
}
|
||||
|
||||
export const useModelSearch = () => {
|
||||
const loading = useLoading()
|
||||
const { toast } = useToast()
|
||||
const data = ref<WithSelection<FileSelectionVersionModel>[]>([])
|
||||
const current = ref<string | number>()
|
||||
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) => {
|
||||
if (!url) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
loading.show()
|
||||
return request(`/model-info?model-page=${encodeURIComponent(url)}`, {})
|
||||
.then((resData: VersionModel[]) => {
|
||||
data.value = resData.map((item) => {
|
||||
const resolvedItem = genFileSelectionItem(item)
|
||||
return {
|
||||
label: item.shortname,
|
||||
value: item.id,
|
||||
item: resolvedItem,
|
||||
command() {
|
||||
current.value = item.id
|
||||
},
|
||||
}
|
||||
})
|
||||
current.value = data.value[0]?.value
|
||||
currentModel.value = data.value[0]?.item
|
||||
|
||||
if (resData.length === 0) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'No Model Found',
|
||||
detail: `No model found for ${url}`,
|
||||
life: 3000,
|
||||
})
|
||||
}
|
||||
|
||||
return resData
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: err.message,
|
||||
life: 15000,
|
||||
})
|
||||
return []
|
||||
})
|
||||
.finally(() => loading.hide())
|
||||
}
|
||||
|
||||
watch(current, () => {
|
||||
currentModel.value = data.value.find(
|
||||
(option) => option.value === current.value,
|
||||
)?.item
|
||||
})
|
||||
|
||||
return { data, current, currentModel, search: handleSearchByUrl }
|
||||
}
|
||||
170
src/hooks/explorer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
60
src/hooks/loading.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { defineStore } from 'hooks/store'
|
||||
import { Ref, ref } from 'vue'
|
||||
|
||||
class GlobalLoading {
|
||||
loading: Ref<boolean>
|
||||
|
||||
loadingStack = 0
|
||||
|
||||
bind(loading: Ref<boolean>) {
|
||||
this.loading = loading
|
||||
}
|
||||
|
||||
show() {
|
||||
this.loadingStack++
|
||||
this.loading.value = true
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.loadingStack--
|
||||
if (this.loadingStack <= 0) this.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
export const globalLoading = new GlobalLoading()
|
||||
|
||||
export const useGlobalLoading = defineStore('loading', () => {
|
||||
const loading = ref(false)
|
||||
|
||||
globalLoading.bind(loading)
|
||||
|
||||
return { loading }
|
||||
})
|
||||
|
||||
declare module 'hooks/store' {
|
||||
interface StoreProvider {
|
||||
loading: ReturnType<typeof useGlobalLoading>
|
||||
}
|
||||
}
|
||||
|
||||
export const useLoading = () => {
|
||||
const targetTimer = ref<Record<string, NodeJS.Timeout | undefined>>({})
|
||||
|
||||
const show = (target: string = '_default') => {
|
||||
targetTimer.value[target] = setTimeout(() => {
|
||||
targetTimer.value[target] = undefined
|
||||
globalLoading.show()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const hide = (target: string = '_default') => {
|
||||
if (targetTimer.value[target]) {
|
||||
clearTimeout(targetTimer.value[target])
|
||||
targetTimer.value[target] = undefined
|
||||
} else {
|
||||
globalLoading.hide()
|
||||
}
|
||||
}
|
||||
|
||||
return { show, hide }
|
||||
}
|
||||
36
src/hooks/markdown.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import metadata_block from 'markdown-it-metadata-block'
|
||||
import yaml from 'yaml'
|
||||
|
||||
interface MarkdownOptions {
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export const useMarkdown = (opts?: MarkdownOptions) => {
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
md.use(metadata_block, {
|
||||
parseMetadata: yaml.parse,
|
||||
meta: opts?.metadata ?? {},
|
||||
})
|
||||
|
||||
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
|
||||
const aIndex = tokens[idx].attrIndex('target')
|
||||
|
||||
if (aIndex < 0) {
|
||||
tokens[idx].attrPush(['target', '_blank'])
|
||||
} else {
|
||||
tokens[idx].attrs![aIndex][1] = '_blank'
|
||||
}
|
||||
|
||||
return self.renderToken(tokens, idx, options)
|
||||
}
|
||||
|
||||
return { render: md.render.bind(md) }
|
||||
}
|
||||
|
||||
export type MarkdownTool = ReturnType<typeof useMarkdown>
|
||||
778
src/hooks/model.ts
Normal file
@@ -0,0 +1,778 @@
|
||||
import DialogModelDetail from 'components/DialogModelDetail.vue'
|
||||
import { useLoading } from 'hooks/loading'
|
||||
import { useMarkdown } from 'hooks/markdown'
|
||||
import { request } from 'hooks/request'
|
||||
import { defineStore } from 'hooks/store'
|
||||
import { useToast } from 'hooks/toast'
|
||||
import { castArray, cloneDeep } from 'lodash'
|
||||
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 { ModelGrid } from 'utils/legacy'
|
||||
import { genModelKey, resolveModelTypeLoader } from 'utils/model'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
type InjectionKey,
|
||||
MaybeRefOrGetter,
|
||||
onMounted,
|
||||
provide,
|
||||
type Ref,
|
||||
ref,
|
||||
toRaw,
|
||||
toValue,
|
||||
unref,
|
||||
} from 'vue'
|
||||
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) => {
|
||||
const { toast, confirm } = useToast()
|
||||
const { t } = useI18n()
|
||||
const loading = useLoading()
|
||||
|
||||
const folders = ref<ModelFolder>({})
|
||||
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 needUpdate = false
|
||||
|
||||
// Check current preview
|
||||
if (model.preview !== data.preview) {
|
||||
const preview = data.preview
|
||||
if (preview) {
|
||||
const previewFile = await previewUrlToFile(data.preview as string)
|
||||
updateData.set('previewFile', previewFile)
|
||||
} else {
|
||||
updateData.set('previewFile', 'undefined')
|
||||
}
|
||||
needUpdate = true
|
||||
}
|
||||
|
||||
// Check current description
|
||||
if (model.description !== data.description) {
|
||||
updateData.set('description', data.description)
|
||||
needUpdate = true
|
||||
}
|
||||
|
||||
// Check current name and pathIndex
|
||||
if (
|
||||
model.subFolder !== data.subFolder ||
|
||||
model.pathIndex !== data.pathIndex
|
||||
) {
|
||||
oldKey = genModelKey(model)
|
||||
updateData.set('type', data.type)
|
||||
updateData.set('pathIndex', data.pathIndex.toString())
|
||||
updateData.set('fullname', genModelFullName(data as BaseModel))
|
||||
needUpdate = true
|
||||
}
|
||||
|
||||
if (!needUpdate) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.show()
|
||||
|
||||
await request(genModelUrl(model), {
|
||||
method: 'PUT',
|
||||
body: updateData,
|
||||
})
|
||||
.catch((err) => {
|
||||
const error_message = err.message ?? err.error
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: `Failed to update model: ${error_message}`,
|
||||
life: 15000,
|
||||
})
|
||||
throw new Error(error_message)
|
||||
})
|
||||
.finally(() => {
|
||||
loading.hide()
|
||||
})
|
||||
|
||||
if (oldKey) {
|
||||
store.dialog.close({ key: oldKey })
|
||||
}
|
||||
|
||||
refreshModels(data.type)
|
||||
}
|
||||
|
||||
const deleteModel = async (model: BaseModel) => {
|
||||
return new Promise((resolve) => {
|
||||
confirm.require({
|
||||
message: t('deleteAsk', [t('model').toLowerCase()]),
|
||||
header: 'Danger',
|
||||
icon: 'pi pi-info-circle',
|
||||
rejectProps: {
|
||||
label: t('cancel'),
|
||||
severity: 'secondary',
|
||||
outlined: true,
|
||||
},
|
||||
acceptProps: {
|
||||
label: t('delete'),
|
||||
severity: 'danger',
|
||||
},
|
||||
accept: () => {
|
||||
const dialogKey = genModelKey(model)
|
||||
loading.show()
|
||||
request(genModelUrl(model), {
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(() => {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: `${model.basename} Deleted`,
|
||||
life: 2000,
|
||||
})
|
||||
store.dialog.close({ key: dialogKey })
|
||||
return refreshModels(model.type)
|
||||
})
|
||||
.then(() => {
|
||||
resolve(void 0)
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: e.message ?? 'Failed to delete model',
|
||||
life: 15000,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
loading.hide()
|
||||
})
|
||||
},
|
||||
reject: () => {
|
||||
resolve(void 0)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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' {
|
||||
interface StoreProvider {
|
||||
models: ReturnType<typeof useModels>
|
||||
}
|
||||
}
|
||||
|
||||
export const useModelFormData = (getFormData: () => BaseModel) => {
|
||||
const formData = ref<BaseModel>(getFormData())
|
||||
const modelData = ref<BaseModel>(getFormData())
|
||||
|
||||
type ResetCallback = () => void
|
||||
const resetCallback = ref<ResetCallback[]>([])
|
||||
|
||||
const registerReset = (callback: ResetCallback) => {
|
||||
resetCallback.value.push(callback)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
formData.value = getFormData()
|
||||
modelData.value = getFormData()
|
||||
for (const callback of resetCallback.value) {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
type SubmitCallback = (data: WithResolved<BaseModel>) => void
|
||||
const submitCallback = ref<SubmitCallback[]>([])
|
||||
|
||||
const registerSubmit = (callback: SubmitCallback) => {
|
||||
submitCallback.value.push(callback)
|
||||
}
|
||||
|
||||
const submit = (): WithResolved<BaseModel> => {
|
||||
const data: any = cloneDeep(toRaw(unref(formData)))
|
||||
for (const callback of submitCallback.value) {
|
||||
callback(data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const metadata = ref<Record<string, any>>({})
|
||||
|
||||
return {
|
||||
formData,
|
||||
modelData,
|
||||
registerReset,
|
||||
reset,
|
||||
registerSubmit,
|
||||
submit,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
type ModelFormInstance = ReturnType<typeof useModelFormData>
|
||||
|
||||
/**
|
||||
* Model base info
|
||||
*/
|
||||
const baseInfoKey = Symbol('baseInfo') as InjectionKey<
|
||||
ReturnType<typeof useModelBaseInfoEditor>
|
||||
>
|
||||
|
||||
export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
|
||||
const { formData: model, modelData } = formInstance
|
||||
|
||||
const provideModelFolders = inject(modelFolderProvideKey)
|
||||
const modelFolders = computed<ModelFolder>(() => {
|
||||
return provideModelFolders?.value ?? {}
|
||||
})
|
||||
|
||||
const type = computed({
|
||||
get: () => {
|
||||
return model.value.type
|
||||
},
|
||||
set: (val) => {
|
||||
model.value.type = val
|
||||
},
|
||||
})
|
||||
|
||||
const pathIndex = computed({
|
||||
get: () => {
|
||||
return model.value.pathIndex
|
||||
},
|
||||
set: (val) => {
|
||||
model.value.pathIndex = val
|
||||
},
|
||||
})
|
||||
|
||||
const subFolder = computed({
|
||||
get: () => {
|
||||
return model.value.subFolder
|
||||
},
|
||||
set: (val) => {
|
||||
model.value.subFolder = val
|
||||
},
|
||||
})
|
||||
|
||||
const extension = computed(() => {
|
||||
return model.value.extension
|
||||
})
|
||||
|
||||
const basename = computed({
|
||||
get: () => {
|
||||
return model.value.basename
|
||||
},
|
||||
set: (val) => {
|
||||
model.value.basename = val
|
||||
},
|
||||
})
|
||||
|
||||
interface BaseInfoItem {
|
||||
key: string
|
||||
display: string
|
||||
value: any
|
||||
}
|
||||
|
||||
interface FieldsItem {
|
||||
key: keyof Model
|
||||
formatter: (val: any) => string | undefined | null
|
||||
}
|
||||
|
||||
const baseInfo = computed(() => {
|
||||
const fields: FieldsItem[] = [
|
||||
{
|
||||
key: 'type',
|
||||
formatter: () =>
|
||||
modelData.value.type in modelFolders.value
|
||||
? modelData.value.type
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: 'pathIndex',
|
||||
formatter: () => {
|
||||
const modelType = model.value.type
|
||||
const pathIndex = model.value.pathIndex
|
||||
if (!modelType) {
|
||||
return undefined
|
||||
}
|
||||
const folders = modelFolders.value[modelType] ?? []
|
||||
return [`${folders[pathIndex]}`, model.value.subFolder]
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'basename',
|
||||
formatter: (val) => `${val}${model.value.extension}`,
|
||||
},
|
||||
{
|
||||
key: 'sizeBytes',
|
||||
formatter: (val) => (val == 0 ? 'Unknown' : bytesToSize(val)),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
formatter: (val) => val && formatDate(val),
|
||||
},
|
||||
{
|
||||
key: 'updatedAt',
|
||||
formatter: (val) => val && formatDate(val),
|
||||
},
|
||||
]
|
||||
|
||||
const information: Record<string, BaseInfoItem> = {}
|
||||
for (const item of fields) {
|
||||
const key = item.key
|
||||
const value = model.value[key]
|
||||
const display = item.formatter(value)
|
||||
|
||||
if (display) {
|
||||
information[key] = { key, value, display }
|
||||
}
|
||||
}
|
||||
|
||||
return information
|
||||
})
|
||||
|
||||
const result = {
|
||||
type,
|
||||
baseInfo,
|
||||
basename,
|
||||
extension,
|
||||
subFolder,
|
||||
pathIndex,
|
||||
modelFolders,
|
||||
}
|
||||
|
||||
provide(baseInfoKey, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const useModelBaseInfo = () => {
|
||||
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.
|
||||
*
|
||||
* In edit mode, there are 4 methods for setting a preview picture:
|
||||
* 1. default value, which is the default image of the model type
|
||||
* 2. network picture
|
||||
* 3. local file
|
||||
* 4. no preview
|
||||
*/
|
||||
const previewKey = Symbol('preview') as InjectionKey<
|
||||
ReturnType<typeof useModelPreviewEditor>
|
||||
>
|
||||
|
||||
export const useModelPreviewEditor = (formInstance: ModelFormInstance) => {
|
||||
const { formData: model, registerReset, registerSubmit } = formInstance
|
||||
|
||||
const typeOptions = ref(['default', 'network', 'local', 'none'])
|
||||
const currentType = ref('default')
|
||||
|
||||
/**
|
||||
* Default images
|
||||
*/
|
||||
const defaultContent = computed(() => {
|
||||
return model.value.preview ? castArray(model.value.preview) : []
|
||||
})
|
||||
const defaultContentPage = ref(0)
|
||||
|
||||
/**
|
||||
* Network picture url
|
||||
*/
|
||||
const networkContent = ref<string>()
|
||||
|
||||
/**
|
||||
* Local file url
|
||||
*/
|
||||
const localContent = ref<string>()
|
||||
const localContentType = ref<string>()
|
||||
const updateLocalContent = async (event: SelectEvent) => {
|
||||
const { files } = event
|
||||
localContent.value = files[0].objectURL
|
||||
localContentType.value = files[0].type
|
||||
}
|
||||
|
||||
/**
|
||||
* No preview
|
||||
*/
|
||||
const noPreviewContent = computed(() => {
|
||||
const folder = model.value.type || 'unknown'
|
||||
return `/model-manager/preview/${folder}/0/no-preview.png`
|
||||
})
|
||||
|
||||
const preview = computed(() => {
|
||||
let content: string | undefined
|
||||
|
||||
switch (currentType.value) {
|
||||
case 'default':
|
||||
content = defaultContent.value[defaultContentPage.value]
|
||||
break
|
||||
case 'network':
|
||||
content = networkContent.value
|
||||
break
|
||||
case 'local':
|
||||
content = localContent.value
|
||||
break
|
||||
default:
|
||||
content = undefined
|
||||
break
|
||||
}
|
||||
|
||||
return content
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
registerReset(() => {
|
||||
currentType.value = 'default'
|
||||
defaultContentPage.value = 0
|
||||
networkContent.value = undefined
|
||||
localContent.value = undefined
|
||||
localContentType.value = undefined
|
||||
})
|
||||
|
||||
registerSubmit((data) => {
|
||||
data.preview = preview.value
|
||||
})
|
||||
})
|
||||
|
||||
const result = {
|
||||
preview,
|
||||
typeOptions,
|
||||
currentType,
|
||||
// default value
|
||||
defaultContent,
|
||||
defaultContentPage,
|
||||
// network picture
|
||||
networkContent,
|
||||
// local file
|
||||
localContent,
|
||||
localContentType,
|
||||
updateLocalContent,
|
||||
// no preview
|
||||
noPreviewContent,
|
||||
}
|
||||
|
||||
provide(previewKey, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const useModelPreview = () => {
|
||||
return inject(previewKey)!
|
||||
}
|
||||
|
||||
/**
|
||||
* Model description
|
||||
*/
|
||||
const descriptionKey = Symbol('description') as InjectionKey<
|
||||
ReturnType<typeof useModelDescriptionEditor>
|
||||
>
|
||||
|
||||
export const useModelDescriptionEditor = (formInstance: ModelFormInstance) => {
|
||||
const { formData: model, metadata } = formInstance
|
||||
|
||||
const md = useMarkdown({ metadata: metadata.value })
|
||||
|
||||
const description = computed({
|
||||
get: () => {
|
||||
return model.value.description
|
||||
},
|
||||
set: (val) => {
|
||||
model.value.description = val
|
||||
},
|
||||
})
|
||||
|
||||
const renderedDescription = computed(() => {
|
||||
return description.value ? md.render(description.value) : undefined
|
||||
})
|
||||
|
||||
const result = { renderedDescription, description }
|
||||
|
||||
provide(descriptionKey, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const useModelDescription = () => {
|
||||
return inject(descriptionKey)!
|
||||
}
|
||||
|
||||
/**
|
||||
* Model metadata
|
||||
*/
|
||||
const metadataKey = Symbol('metadata') as InjectionKey<
|
||||
ReturnType<typeof useModelMetadataEditor>
|
||||
>
|
||||
|
||||
export const useModelMetadataEditor = (formInstance: ModelFormInstance) => {
|
||||
const { formData: model } = formInstance
|
||||
|
||||
const metadata = computed(() => {
|
||||
return model.value.metadata
|
||||
})
|
||||
|
||||
const result = { metadata }
|
||||
|
||||
provide(metadataKey, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const useModelMetadata = () => {
|
||||
return inject(metadataKey)!
|
||||
}
|
||||
|
||||
export const useModelNodeAction = () => {
|
||||
const { t } = useI18n()
|
||||
const { toast, wrapperToastError } = useToast()
|
||||
|
||||
const createNode = (model: BaseModel, options: Record<string, any> = {}) => {
|
||||
const nodeType = resolveModelTypeLoader(model.type)
|
||||
if (!nodeType) {
|
||||
throw new Error(t('unSupportedModelType', [model.type]))
|
||||
}
|
||||
|
||||
const node = window.LiteGraph.createNode(nodeType, null, options)
|
||||
const widgetIndex = node.widgets.findIndex((w) => w.type === 'combo')
|
||||
if (widgetIndex > -1) {
|
||||
node.widgets[widgetIndex].value = genModelFullName(model)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
const dragToAddModelNode = wrapperToastError(
|
||||
(model: BaseModel, event: DragEvent) => {
|
||||
// const target = document.elementFromPoint(event.clientX, event.clientY)
|
||||
// if (
|
||||
// target?.tagName.toLocaleLowerCase() === 'canvas' &&
|
||||
// target.id === 'graph-canvas'
|
||||
// ) {
|
||||
// const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY])
|
||||
// const node = createNode({ pos })
|
||||
// app.graph.add(node)
|
||||
// app.canvas.selectNode(node)
|
||||
// }
|
||||
//
|
||||
// Use the legacy method instead
|
||||
const removeEmbeddingExtension = true
|
||||
const strictDragToAdd = false
|
||||
const splitter = systemStat.value?.system.os === 'nt' ? '\\' : '/'
|
||||
|
||||
ModelGrid.dragAddModel(
|
||||
event,
|
||||
model.type,
|
||||
genModelFullName(model, splitter),
|
||||
removeEmbeddingExtension,
|
||||
strictDragToAdd,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const addModelNode = wrapperToastError((model: BaseModel) => {
|
||||
const selectedNodes = app.canvas.selected_nodes
|
||||
const firstSelectedNode = Object.values(selectedNodes)[0]
|
||||
const offset = 25
|
||||
const pos = firstSelectedNode
|
||||
? [firstSelectedNode.pos[0] + offset, firstSelectedNode.pos[1] + offset]
|
||||
: app.canvas.canvas_mouse
|
||||
const node = createNode(model, { pos })
|
||||
app.graph.add(node)
|
||||
app.canvas.selectNode(node)
|
||||
})
|
||||
|
||||
const copyModelNode = wrapperToastError((model: BaseModel) => {
|
||||
const node = createNode(model)
|
||||
app.canvas.copyToClipboard([node])
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: t('modelCopied'),
|
||||
life: 2000,
|
||||
})
|
||||
})
|
||||
|
||||
const loadPreviewWorkflow = wrapperToastError(async (model: BaseModel) => {
|
||||
const previewUrl = model.preview as string
|
||||
const response = await fetch(previewUrl)
|
||||
const data = await response.blob()
|
||||
const type = data.type
|
||||
const extension = type.split('/').pop()
|
||||
const file = new File([data], `${model.basename}.${extension}`, { type })
|
||||
app.handleFile(file)
|
||||
})
|
||||
|
||||
return {
|
||||
addModelNode,
|
||||
dragToAddModelNode,
|
||||
copyModelNode,
|
||||
loadPreviewWorkflow,
|
||||
}
|
||||
}
|
||||
85
src/hooks/request.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useLoading } from 'hooks/loading'
|
||||
import { api } from 'scripts/comfyAPI'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
export const request = async (url: string, options?: RequestInit) => {
|
||||
return api
|
||||
.fetchApi(`/model-manager${url}`, options)
|
||||
.then((response) => response.json())
|
||||
.then((resData) => {
|
||||
if (resData.success) {
|
||||
return resData.data
|
||||
}
|
||||
throw new Error(resData.error)
|
||||
})
|
||||
}
|
||||
|
||||
export interface RequestOptions<T> {
|
||||
method?: RequestInit['method']
|
||||
headers?: RequestInit['headers']
|
||||
defaultParams?: Record<string, any>
|
||||
defaultValue?: any
|
||||
postData?: (data: T) => T
|
||||
manual?: boolean
|
||||
}
|
||||
|
||||
export const useRequest = <T = any>(
|
||||
url: string,
|
||||
options: RequestOptions<T> = {},
|
||||
) => {
|
||||
const loading = useLoading()
|
||||
const postData = options.postData ?? ((data) => data)
|
||||
|
||||
const data = ref<T>(options.defaultValue)
|
||||
const lastParams = ref()
|
||||
|
||||
const fetch = async (
|
||||
params: Record<string, any> = options.defaultParams ?? {},
|
||||
) => {
|
||||
loading.show()
|
||||
|
||||
lastParams.value = params
|
||||
|
||||
let requestUrl = url
|
||||
const requestOptions: RequestInit = {
|
||||
method: options.method,
|
||||
headers: options.headers,
|
||||
}
|
||||
const requestParams = { ...params }
|
||||
|
||||
const templatePattern = /\{(.*?)\}/g
|
||||
const urlParamKeyMatches = requestUrl.matchAll(templatePattern)
|
||||
for (const urlParamKey of urlParamKeyMatches) {
|
||||
const [match, paramKey] = urlParamKey
|
||||
if (paramKey in requestParams) {
|
||||
const paramValue = requestParams[paramKey]
|
||||
delete requestParams[paramKey]
|
||||
requestUrl = requestUrl.replace(match, paramValue)
|
||||
}
|
||||
}
|
||||
|
||||
if (!requestOptions.method) {
|
||||
requestOptions.method = 'GET'
|
||||
}
|
||||
|
||||
if (requestOptions.method !== 'GET') {
|
||||
requestOptions.body = JSON.stringify(requestParams)
|
||||
}
|
||||
|
||||
return request(requestUrl, requestOptions)
|
||||
.then((resData) => (data.value = postData(resData)))
|
||||
.finally(() => loading.hide())
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!options.manual) {
|
||||
fetch()
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
return fetch(lastParams.value)
|
||||
}
|
||||
|
||||
return { data, refresh, fetch }
|
||||
}
|
||||
51
src/hooks/store.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { inject, InjectionKey, provide } from 'vue'
|
||||
|
||||
const providerHooks = new Map<string, any>()
|
||||
const storeEvent = {} as StoreProvider
|
||||
|
||||
export const useStoreProvider = () => {
|
||||
// const storeEvent = {}
|
||||
|
||||
for (const [key, useHook] of providerHooks) {
|
||||
storeEvent[key] = useHook()
|
||||
}
|
||||
|
||||
return storeEvent
|
||||
}
|
||||
|
||||
const storeKeys = new Map<string, symbol>()
|
||||
|
||||
const getStoreKey = (key: string) => {
|
||||
let storeKey = storeKeys.get(key)
|
||||
if (!storeKey) {
|
||||
storeKey = Symbol(key)
|
||||
storeKeys.set(key, storeKey)
|
||||
}
|
||||
return storeKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Using vue provide and inject to implement a simple store
|
||||
*/
|
||||
export const defineStore = <T = any>(
|
||||
key: string,
|
||||
useInitial: (event: StoreProvider) => T,
|
||||
) => {
|
||||
const storeKey = getStoreKey(key) as InjectionKey<T>
|
||||
|
||||
if (providerHooks.has(key) && !import.meta.hot) {
|
||||
console.warn(`[defineStore] key: ${key} already exists.`)
|
||||
} else {
|
||||
providerHooks.set(key, () => {
|
||||
const result = useInitial(storeEvent)
|
||||
provide(storeKey, result ?? storeEvent[key])
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
const useStore = () => {
|
||||
return inject(storeKey)!
|
||||
}
|
||||
|
||||
return useStore
|
||||
}
|
||||
45
src/hooks/toast.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ToastServiceMethods } from 'primevue/toastservice'
|
||||
import { useConfirm as usePrimeConfirm } from 'primevue/useconfirm'
|
||||
import { useToast as usePrimeToast } from 'primevue/usetoast'
|
||||
|
||||
export const globalToast = { value: null } as unknown as {
|
||||
value: ToastServiceMethods
|
||||
}
|
||||
|
||||
export const useToast = () => {
|
||||
const toast = usePrimeToast()
|
||||
const confirm = usePrimeConfirm()
|
||||
|
||||
globalToast.value = toast
|
||||
|
||||
const wrapperToastError = <T extends CallableFunction>(callback: T): T => {
|
||||
const showToast = (error: Error) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: error.message,
|
||||
life: 15000,
|
||||
})
|
||||
}
|
||||
|
||||
const isAsync = callback.constructor.name === 'AsyncFunction'
|
||||
|
||||
let wrapperExec: any
|
||||
|
||||
if (isAsync) {
|
||||
wrapperExec = (...args: any[]) => callback(...args).catch(showToast)
|
||||
} else {
|
||||
wrapperExec = (...args: any[]) => {
|
||||
try {
|
||||
return callback(...args)
|
||||
} catch (error) {
|
||||
showToast(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return wrapperExec
|
||||
}
|
||||
|
||||
return { toast, wrapperToastError, confirm }
|
||||
}
|
||||
26
src/i18n.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { app } from 'scripts/comfyAPI'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import en from './locales/en.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
const messages = {
|
||||
en: en,
|
||||
zh: zh,
|
||||
}
|
||||
|
||||
const getLocalLanguage = () => {
|
||||
const local =
|
||||
app.ui?.settings.getSettingValue<string>('Comfy.Locale') ||
|
||||
navigator.language.split('-')[0] ||
|
||||
'en'
|
||||
|
||||
return local
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: getLocalLanguage(),
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
})
|
||||
77
src/locales/en.json
Normal 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
@@ -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": "展平布局"
|
||||
}
|
||||
}
|
||||
53
src/main.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { definePreset } from '@primevue/themes'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { app } from 'scripts/comfyAPI'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { i18n } from './i18n'
|
||||
import './style.css'
|
||||
|
||||
const ComfyUIPreset = definePreset(Aura, {
|
||||
semantic: {
|
||||
primary: Aura['primitive'].blue,
|
||||
},
|
||||
})
|
||||
|
||||
function createVueApp(rootContainer: string | HTMLElement) {
|
||||
const app = createApp(App)
|
||||
app.directive('tooltip', Tooltip)
|
||||
app
|
||||
.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: ComfyUIPreset,
|
||||
options: {
|
||||
prefix: 'p',
|
||||
cssLayer: {
|
||||
name: 'primevue',
|
||||
order: 'tailwind-base, primevue, tailwind-utilities',
|
||||
},
|
||||
// This is a workaround for the issue with the dark mode selector
|
||||
// https://github.com/primefaces/primevue/issues/5515
|
||||
darkModeSelector: '.dark-theme, :root:has(.dark-theme)',
|
||||
},
|
||||
},
|
||||
})
|
||||
.use(ToastService)
|
||||
.use(ConfirmationService)
|
||||
.use(i18n)
|
||||
.mount(rootContainer)
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.ModelManager',
|
||||
setup() {
|
||||
const container = document.createElement('div')
|
||||
container.id = 'comfyui-model-manager'
|
||||
document.body.appendChild(container)
|
||||
|
||||
createVueApp(container)
|
||||
},
|
||||
})
|
||||
8
src/scripts/comfyAPI.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const app = window.comfyAPI.app.app
|
||||
export const api = window.comfyAPI.api.api
|
||||
|
||||
export const $el = window.comfyAPI.ui.$el
|
||||
|
||||
export const ComfyApp = window.comfyAPI.app.ComfyApp
|
||||
export const ComfyButton = window.comfyAPI.button.ComfyButton
|
||||
export const ComfyDialog = window.comfyAPI.dialog.ComfyDialog
|
||||
6
src/style.css
Normal file
@@ -0,0 +1,6 @@
|
||||
@layer primevue, tailwind-utilities;
|
||||
|
||||
@layer tailwind-utilities {
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
}
|
||||
293
src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,293 @@
|
||||
declare namespace ComfyAPI {
|
||||
namespace api {
|
||||
class ComfyApiEvent {
|
||||
getSystemStats: () => Promise<any>
|
||||
}
|
||||
|
||||
class ComfyApi extends ComfyApiEvent {
|
||||
socket: WebSocket
|
||||
fetchApi: (route: string, options?: RequestInit) => Promise<Response>
|
||||
addEventListener: (
|
||||
type: string,
|
||||
callback: (event: CustomEvent) => void,
|
||||
options?: AddEventListenerOptions,
|
||||
) => void
|
||||
removeEventListener: (
|
||||
type: string,
|
||||
callback: (event: CustomEvent) => void,
|
||||
options?: AddEventListenerOptions,
|
||||
) => void
|
||||
}
|
||||
|
||||
const api: ComfyApi
|
||||
}
|
||||
|
||||
namespace app {
|
||||
interface ComfyExtension {
|
||||
/**
|
||||
* The name of the extension
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
|
||||
* @param app The ComfyUI app instance
|
||||
*/
|
||||
init?(app: ComfyApp): Promise<void> | void
|
||||
/**
|
||||
* Allows any additional setup, called after the application is fully set up and running
|
||||
* @param app The ComfyUI app instance
|
||||
*/
|
||||
setup?(app: ComfyApp): Promise<void> | void
|
||||
}
|
||||
|
||||
interface BaseSidebarTabExtension {
|
||||
id: string
|
||||
title: string
|
||||
icon?: string
|
||||
iconBadge?: string | (() => string | null)
|
||||
order?: number
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface VueSidebarTabExtension extends BaseSidebarTabExtension {
|
||||
type: 'vue'
|
||||
component: import('vue').Component
|
||||
}
|
||||
|
||||
interface CustomSidebarTabExtension extends BaseSidebarTabExtension {
|
||||
type: 'custom'
|
||||
render: (container: HTMLElement) => void
|
||||
destroy?: () => void
|
||||
}
|
||||
|
||||
type SidebarTabExtension =
|
||||
| VueSidebarTabExtension
|
||||
| CustomSidebarTabExtension
|
||||
|
||||
interface ExtensionManager {
|
||||
// Sidebar tabs
|
||||
registerSidebarTab(tab: SidebarTabExtension): void
|
||||
unregisterSidebarTab(id: string): void
|
||||
getSidebarTabs(): SidebarTabExtension[]
|
||||
|
||||
// Toast
|
||||
toast: ToastManager
|
||||
}
|
||||
|
||||
class ComfyApp {
|
||||
ui?: ui.ComfyUI
|
||||
menu?: index.ComfyAppMenu
|
||||
graph: lightGraph.LGraph
|
||||
canvas: lightGraph.LGraphCanvas
|
||||
extensionManager: ExtensionManager
|
||||
registerExtension: (extension: ComfyExtension) => void
|
||||
addNodeOnGraph: (
|
||||
nodeDef: lightGraph.ComfyNodeDef,
|
||||
options?: Record<string, any>,
|
||||
) => lightGraph.LGraphNode
|
||||
getCanvasCenter: () => lightGraph.Vector2
|
||||
clientPosToCanvasPos: (pos: lightGraph.Vector2) => lightGraph.Vector2
|
||||
handleFile: (file: File) => void
|
||||
}
|
||||
|
||||
const app: ComfyApp
|
||||
}
|
||||
|
||||
namespace ui {
|
||||
type Props = {
|
||||
parent?: HTMLElement
|
||||
$?: (el: HTMLElement) => void
|
||||
dataset?: DOMStringMap
|
||||
style?: Partial<CSSStyleDeclaration>
|
||||
for?: string
|
||||
textContent?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
type Children = Element[] | Element | string | string[]
|
||||
|
||||
type ElementType<K extends string> = K extends keyof HTMLElementTagNameMap
|
||||
? HTMLElementTagNameMap[K]
|
||||
: HTMLElement
|
||||
|
||||
const $el: <TTag extends string>(
|
||||
tag: TTag,
|
||||
propsOrChildren?: Children | Props,
|
||||
children?: Children,
|
||||
) => ElementType<TTag>
|
||||
|
||||
class ComfyUI {
|
||||
app: app.ComfyApp
|
||||
settings: ComfySettingsDialog
|
||||
menuHamburger?: HTMLDivElement
|
||||
menuContainer?: HTMLDivElement
|
||||
dialog: dialog.ComfyDialog
|
||||
}
|
||||
|
||||
type SettingInputType =
|
||||
| 'boolean'
|
||||
| 'number'
|
||||
| 'slider'
|
||||
| 'combo'
|
||||
| 'text'
|
||||
| 'hidden'
|
||||
|
||||
type SettingCustomRenderer = (
|
||||
name: string,
|
||||
setter: (v: any) => void,
|
||||
value: any,
|
||||
attrs: any,
|
||||
) => HTMLElement
|
||||
|
||||
interface SettingOption {
|
||||
text: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
interface SettingParams {
|
||||
id: string
|
||||
name: string
|
||||
type: SettingInputType | SettingCustomRenderer
|
||||
defaultValue: any
|
||||
onChange?: (newValue: any, oldValue?: any) => void
|
||||
attrs?: any
|
||||
tooltip?: string
|
||||
options?:
|
||||
| Array<string | SettingOption>
|
||||
| ((value: any) => SettingOption[])
|
||||
// By default category is id.split('.'). However, changing id to assign
|
||||
// new category has poor backward compatibility. Use this field to overwrite
|
||||
// default category from id.
|
||||
// Note: Like id, category value need to be unique.
|
||||
category?: string[]
|
||||
experimental?: boolean
|
||||
deprecated?: boolean
|
||||
}
|
||||
|
||||
class ComfySettingsDialog extends dialog.ComfyDialog {
|
||||
addSetting: (params: SettingParams) => { value: any }
|
||||
getSettingValue: <T>(id: string, defaultValue?: T) => T
|
||||
setSettingValue: <T>(id: string, value: T) => void
|
||||
}
|
||||
}
|
||||
|
||||
namespace index {
|
||||
class ComfyAppMenu {
|
||||
app: app.ComfyApp
|
||||
logo: HTMLElement
|
||||
actionsGroup: button.ComfyButtonGroup
|
||||
settingsGroup: button.ComfyButtonGroup
|
||||
viewGroup: button.ComfyButtonGroup
|
||||
mobileMenuButton: ComfyButton
|
||||
element: HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
namespace button {
|
||||
type ComfyButtonProps = {
|
||||
icon?: string
|
||||
overIcon?: string
|
||||
iconSize?: number
|
||||
content?: string | HTMLElement
|
||||
tooltip?: string
|
||||
enabled?: boolean
|
||||
action?: (e: Event, btn: ComfyButton) => void
|
||||
classList?: ClassList
|
||||
visibilitySetting?: { id: keyof Settings; showValue: boolean }
|
||||
app?: app.ComfyApp
|
||||
}
|
||||
|
||||
class ComfyButton {
|
||||
constructor(props: ComfyButtonProps): ComfyButton
|
||||
}
|
||||
|
||||
class ComfyButtonGroup {
|
||||
insert(button: ComfyButton, index: number): void
|
||||
append(button: ComfyButton): void
|
||||
remove(indexOrButton: ComfyButton | number): void
|
||||
update(): void
|
||||
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 {
|
||||
class LGraphNode implements ComfyNodeDef {
|
||||
widgets: any[]
|
||||
pos: Vector2
|
||||
}
|
||||
|
||||
class LGraphGroup {}
|
||||
|
||||
class LGraph {
|
||||
/**
|
||||
* Adds a new node instance to this graph
|
||||
* @param node the instance of the node
|
||||
*/
|
||||
add(node: LGraphNode | LGraphGroup, skip_compute_order?: boolean): void
|
||||
/**
|
||||
* Returns the top-most node in this position of the canvas
|
||||
* @param x the x coordinate in canvas space
|
||||
* @param y the y coordinate in canvas space
|
||||
* @param nodes_list a list with all the nodes to search from, by default is all the nodes in the graph
|
||||
* @return the node at this position or null
|
||||
*/
|
||||
getNodeOnPos<T extends LGraphNode = LGraphNode>(
|
||||
x: number,
|
||||
y: number,
|
||||
node_list?: LGraphNode[],
|
||||
margin?: number,
|
||||
): T | null
|
||||
}
|
||||
|
||||
class LGraphCanvas {
|
||||
selected_nodes: Record<string, LGraphNode>
|
||||
canvas_mouse: Vector2
|
||||
selectNode: (node: LGraphNode) => void
|
||||
copyToClipboard: (nodes: LGraphNode[]) => void
|
||||
}
|
||||
|
||||
const LiteGraph: {
|
||||
createNode: (
|
||||
type: string,
|
||||
title: string | null,
|
||||
options: object,
|
||||
) => LGraphNode
|
||||
}
|
||||
|
||||
type ComfyNodeDef = {
|
||||
input?: {
|
||||
required?: Record<string, any>
|
||||
optional?: Record<string, any>
|
||||
hidden?: Record<string, any>
|
||||
}
|
||||
output?: (string | any[])[]
|
||||
output_is_list?: boolean[]
|
||||
output_name?: string[]
|
||||
output_tooltips?: string[]
|
||||
name?: string
|
||||
display_name?: string
|
||||
description?: string
|
||||
category?: string
|
||||
output_node?: boolean
|
||||
python_module?: string
|
||||
deprecated?: boolean
|
||||
experimental?: boolean
|
||||
}
|
||||
|
||||
type Vector2 = [number, number]
|
||||
}
|
||||
|
||||
interface Window {
|
||||
comfyAPI: typeof ComfyAPI
|
||||
LiteGraph: typeof lightGraph.LiteGraph
|
||||
}
|
||||
5
src/types/shims.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
export {}
|
||||
|
||||
declare module 'hooks/store' {
|
||||
interface StoreProvider {}
|
||||
}
|
||||
89
src/types/typings.d.ts
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
export type ContainerSize = { width: number; height: number }
|
||||
export type ContainerPosition = { left: number; top: number }
|
||||
|
||||
export interface BaseModel {
|
||||
id: number | string
|
||||
basename: string
|
||||
extension: string
|
||||
sizeBytes: number
|
||||
type: string
|
||||
subFolder: string
|
||||
pathIndex: number
|
||||
isFolder: boolean
|
||||
preview: string | string[]
|
||||
description: string
|
||||
metadata: Record<string, string>
|
||||
}
|
||||
|
||||
export interface Model extends BaseModel {
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
children?: Model[]
|
||||
}
|
||||
|
||||
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
|
||||
downloadPlatform: string
|
||||
downloadUrl: string
|
||||
hashes?: Record<string, string>
|
||||
files?: VersionModelFile[]
|
||||
}
|
||||
|
||||
export type WithResolved<T> = Omit<T, 'preview'> & {
|
||||
preview: string | undefined
|
||||
}
|
||||
|
||||
export type PassThrough<T = void> = T | object | undefined
|
||||
|
||||
export interface SelectOptions {
|
||||
label: string
|
||||
value: any
|
||||
icon?: string
|
||||
command: () => void
|
||||
}
|
||||
|
||||
export interface SelectFile extends File {
|
||||
objectURL: string
|
||||
}
|
||||
|
||||
export interface SelectEvent {
|
||||
files: SelectFile[]
|
||||
originalEvent: Event
|
||||
}
|
||||
|
||||
export interface DownloadTaskOptions {
|
||||
taskId: string
|
||||
type: string
|
||||
fullname: string
|
||||
preview: string
|
||||
status: 'pause' | 'waiting' | 'doing'
|
||||
progress: number
|
||||
downloadedSize: number
|
||||
totalSize: number
|
||||
bps: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface DownloadTask
|
||||
extends Omit<
|
||||
DownloadTaskOptions,
|
||||
'downloadedSize' | 'totalSize' | 'bps' | 'error'
|
||||
> {
|
||||
downloadProgress: string
|
||||
downloadSpeed: string
|
||||
pauseTask: () => void
|
||||
resumeTask: () => void
|
||||
deleteTask: () => void
|
||||
}
|
||||
|
||||
export type CustomEventListener = (event: CustomEvent) => void
|
||||
39
src/utils/common.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export const bytesToSize = (
|
||||
bytes: number | string | undefined | null,
|
||||
decimals = 2,
|
||||
) => {
|
||||
if (typeof bytes === 'undefined' || bytes === null) {
|
||||
bytes = 0
|
||||
}
|
||||
if (typeof bytes === 'string') {
|
||||
bytes = Number(bytes)
|
||||
}
|
||||
if (Number.isNaN(bytes)) {
|
||||
return 'Unknown'
|
||||
}
|
||||
if (bytes === 0) {
|
||||
return '0 Bytes'
|
||||
}
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export const formatDate = (date: number | string | Date) => {
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
export const previewUrlToFile = async (url: string) => {
|
||||
return fetch(url)
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const type = blob.type
|
||||
const extension = type.split('/')[1]
|
||||
const file = new File([blob], `preview.${extension}`, { type })
|
||||
return file
|
||||
})
|
||||
}
|
||||
619
src/utils/legacy.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import { app } from 'scripts/comfyAPI'
|
||||
|
||||
const LiteGraph = window.LiteGraph
|
||||
|
||||
const modelNodeType = {
|
||||
checkpoints: 'CheckpointLoaderSimple',
|
||||
clip: 'CLIPLoader',
|
||||
clip_vision: 'CLIPVisionLoader',
|
||||
controlnet: 'ControlNetLoader',
|
||||
diffusers: 'DiffusersLoader',
|
||||
embeddings: 'Embedding',
|
||||
gligen: 'GLIGENLoader',
|
||||
hypernetworks: 'HypernetworkLoader',
|
||||
photomaker: 'PhotoMakerLoader',
|
||||
loras: 'LoraLoader',
|
||||
style_models: 'StyleModelLoader',
|
||||
unet: 'UNETLoader',
|
||||
upscale_models: 'UpscaleModelLoader',
|
||||
vae: 'VAELoader',
|
||||
vae_approx: undefined,
|
||||
}
|
||||
|
||||
export class ModelGrid {
|
||||
/**
|
||||
* @param {string} nodeType
|
||||
* @returns {int}
|
||||
*/
|
||||
static modelWidgetIndex(nodeType) {
|
||||
return nodeType === undefined ? -1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {string} file
|
||||
* @param {boolean} removeExtension
|
||||
* @returns {string}
|
||||
*/
|
||||
static insertEmbeddingIntoText(text, file, removeExtension) {
|
||||
let name = file
|
||||
if (removeExtension) {
|
||||
name = SearchPath.splitExtension(name)[0]
|
||||
}
|
||||
const sep = text.length === 0 || text.slice(-1).match(/\s/) ? '' : ' '
|
||||
return text + sep + '(embedding:' + name + ':1.0)'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} list
|
||||
* @param {string} searchString
|
||||
* @returns {Array}
|
||||
*/
|
||||
static #filter(list, searchString) {
|
||||
/** @type {string[]} */
|
||||
const keywords = searchString
|
||||
//.replace("*", " ") // TODO: this is wrong for wildcards
|
||||
.split(/(-?".*?"|[^\s"]+)+/g)
|
||||
.map((item) =>
|
||||
item
|
||||
.trim()
|
||||
.replace(/(?:")+/g, '')
|
||||
.toLowerCase(),
|
||||
)
|
||||
.filter(Boolean)
|
||||
|
||||
const regexSHA256 = /^[a-f0-9]{64}$/gi
|
||||
const fields = ['name', 'path']
|
||||
return list.filter((element) => {
|
||||
const text = fields
|
||||
.reduce((memo, field) => memo + ' ' + element[field], '')
|
||||
.toLowerCase()
|
||||
return keywords.reduce((memo, target) => {
|
||||
const excludeTarget = target[0] === '-'
|
||||
if (excludeTarget && target.length === 1) {
|
||||
return memo
|
||||
}
|
||||
const filteredTarget = excludeTarget ? target.slice(1) : target
|
||||
if (
|
||||
element['SHA256'] !== undefined &&
|
||||
regexSHA256.test(filteredTarget)
|
||||
) {
|
||||
return (
|
||||
memo && excludeTarget !== (filteredTarget === element['SHA256'])
|
||||
)
|
||||
} else {
|
||||
return memo && excludeTarget !== text.includes(filteredTarget)
|
||||
}
|
||||
}, true)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place sort. Returns an array alias.
|
||||
* @param {Array} list
|
||||
* @param {string} sortBy
|
||||
* @param {bool} [reverse=false]
|
||||
* @returns {Array}
|
||||
*/
|
||||
static #sort(list, sortBy, reverse = false) {
|
||||
let compareFn = null
|
||||
switch (sortBy) {
|
||||
case MODEL_SORT_DATE_NAME:
|
||||
compareFn = (a, b) => {
|
||||
return a[MODEL_SORT_DATE_NAME].localeCompare(b[MODEL_SORT_DATE_NAME])
|
||||
}
|
||||
break
|
||||
case MODEL_SORT_DATE_MODIFIED:
|
||||
compareFn = (a, b) => {
|
||||
return b[MODEL_SORT_DATE_MODIFIED] - a[MODEL_SORT_DATE_MODIFIED]
|
||||
}
|
||||
break
|
||||
case MODEL_SORT_DATE_CREATED:
|
||||
compareFn = (a, b) => {
|
||||
return b[MODEL_SORT_DATE_CREATED] - a[MODEL_SORT_DATE_CREATED]
|
||||
}
|
||||
break
|
||||
case MODEL_SORT_SIZE_BYTES:
|
||||
compareFn = (a, b) => {
|
||||
return b[MODEL_SORT_SIZE_BYTES] - a[MODEL_SORT_SIZE_BYTES]
|
||||
}
|
||||
break
|
||||
default:
|
||||
console.warn("Invalid filter sort value: '" + sortBy + "'")
|
||||
return list
|
||||
}
|
||||
const sorted = list.sort(compareFn)
|
||||
return reverse ? sorted.reverse() : sorted
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
* @param {string} modelType
|
||||
* @param {string} path
|
||||
* @param {boolean} removeEmbeddingExtension
|
||||
* @param {int} addOffset
|
||||
*/
|
||||
static #addModel(
|
||||
event,
|
||||
modelType,
|
||||
path,
|
||||
removeEmbeddingExtension,
|
||||
addOffset,
|
||||
) {
|
||||
let success = false
|
||||
if (modelType !== 'embeddings') {
|
||||
const nodeType = modelNodeType[modelType]
|
||||
const widgetIndex = ModelGrid.modelWidgetIndex(nodeType)
|
||||
let node = LiteGraph.createNode(nodeType, null, [])
|
||||
if (widgetIndex !== -1 && node) {
|
||||
node.widgets[widgetIndex].value = path
|
||||
const selectedNodes = app.canvas.selected_nodes
|
||||
let isSelectedNode = false
|
||||
for (var i in selectedNodes) {
|
||||
const selectedNode = selectedNodes[i]
|
||||
node.pos[0] = selectedNode.pos[0] + addOffset
|
||||
node.pos[1] = selectedNode.pos[1] + addOffset
|
||||
isSelectedNode = true
|
||||
break
|
||||
}
|
||||
if (!isSelectedNode) {
|
||||
const graphMouse = app.canvas.graph_mouse
|
||||
node.pos[0] = graphMouse[0]
|
||||
node.pos[1] = graphMouse[1]
|
||||
}
|
||||
app.graph.add(node, { doProcessChange: true })
|
||||
app.canvas.selectNode(node)
|
||||
success = true
|
||||
}
|
||||
event.stopPropagation()
|
||||
} else if (modelType === 'embeddings') {
|
||||
const [embeddingDirectory, embeddingFile] = SearchPath.split(path)
|
||||
const selectedNodes = app.canvas.selected_nodes
|
||||
for (var i in selectedNodes) {
|
||||
const selectedNode = selectedNodes[i]
|
||||
const nodeType = modelNodeType[modelType]
|
||||
const widgetIndex = ModelGrid.modelWidgetIndex(nodeType)
|
||||
const target = selectedNode?.widgets[widgetIndex]?.element
|
||||
if (target && target.type === 'textarea') {
|
||||
target.value = ModelGrid.insertEmbeddingIntoText(
|
||||
target.value,
|
||||
embeddingFile,
|
||||
removeEmbeddingExtension,
|
||||
)
|
||||
success = true
|
||||
}
|
||||
}
|
||||
if (!success) {
|
||||
console.warn('Try selecting a node before adding the embedding.')
|
||||
}
|
||||
event.stopPropagation()
|
||||
}
|
||||
comfyButtonAlert(event.target, success, 'mdi-check-bold', 'mdi-close-thick')
|
||||
}
|
||||
|
||||
static #getWidgetComboIndices(node, value) {
|
||||
const widgetIndices = []
|
||||
node?.widgets?.forEach((widget, index) => {
|
||||
if (widget.type === 'combo' && widget.options.values?.includes(value)) {
|
||||
widgetIndices.push(index)
|
||||
}
|
||||
})
|
||||
return widgetIndices
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DragEvent} event
|
||||
* @param {string} modelType
|
||||
* @param {string} path
|
||||
* @param {boolean} removeEmbeddingExtension
|
||||
* @param {boolean} strictlyOnWidget
|
||||
*/
|
||||
static dragAddModel(
|
||||
event,
|
||||
modelType,
|
||||
path,
|
||||
removeEmbeddingExtension,
|
||||
strictlyOnWidget,
|
||||
) {
|
||||
const target = document.elementFromPoint(event.clientX, event.clientY)
|
||||
if (modelType !== 'embeddings' && target.id === 'graph-canvas') {
|
||||
const pos = app.canvas.convertEventToCanvasOffset(event)
|
||||
|
||||
const node = app.graph.getNodeOnPos(
|
||||
pos[0],
|
||||
pos[1],
|
||||
app.canvas.visible_nodes,
|
||||
)
|
||||
|
||||
let widgetIndex = -1
|
||||
if (widgetIndex === -1) {
|
||||
const widgetIndices = this.#getWidgetComboIndices(node, path)
|
||||
if (widgetIndices.length === 0) {
|
||||
widgetIndex = -1
|
||||
} else if (widgetIndices.length === 1) {
|
||||
widgetIndex = widgetIndices[0]
|
||||
if (strictlyOnWidget) {
|
||||
const draggedWidget = app.canvas.processNodeWidgets(
|
||||
node,
|
||||
pos,
|
||||
event,
|
||||
)
|
||||
const widget = node.widgets[widgetIndex]
|
||||
if (draggedWidget != widget) {
|
||||
// != check NOT same object
|
||||
widgetIndex = -1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ambiguous widget (strictlyOnWidget always true)
|
||||
const draggedWidget = app.canvas.processNodeWidgets(node, pos, event)
|
||||
widgetIndex = widgetIndices.findIndex((index) => {
|
||||
return draggedWidget == node.widgets[index] // == check same object
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (widgetIndex !== -1) {
|
||||
node.widgets[widgetIndex].value = path
|
||||
app.canvas.selectNode(node)
|
||||
} else {
|
||||
const expectedNodeType = modelNodeType[modelType]
|
||||
const newNode = LiteGraph.createNode(expectedNodeType, null, [])
|
||||
let newWidgetIndex = ModelGrid.modelWidgetIndex(expectedNodeType)
|
||||
if (newWidgetIndex === -1) {
|
||||
newWidgetIndex = this.#getWidgetComboIndices(newNode, path)[0] ?? -1
|
||||
}
|
||||
if (
|
||||
newNode !== undefined &&
|
||||
newNode !== null &&
|
||||
newWidgetIndex !== -1
|
||||
) {
|
||||
newNode.pos[0] = pos[0]
|
||||
newNode.pos[1] = pos[1]
|
||||
newNode.widgets[newWidgetIndex].value = path
|
||||
app.graph.add(newNode, { doProcessChange: true })
|
||||
app.canvas.selectNode(newNode)
|
||||
}
|
||||
}
|
||||
event.stopPropagation()
|
||||
} else if (modelType === 'embeddings' && target.type === 'textarea') {
|
||||
const pos = app.canvas.convertEventToCanvasOffset(event)
|
||||
const nodeAtPos = app.graph.getNodeOnPos(
|
||||
pos[0],
|
||||
pos[1],
|
||||
app.canvas.visible_nodes,
|
||||
)
|
||||
if (nodeAtPos) {
|
||||
app.canvas.selectNode(nodeAtPos)
|
||||
const [embeddingDirectory, embeddingFile] = SearchPath.split(path)
|
||||
target.value = ModelGrid.insertEmbeddingIntoText(
|
||||
target.value,
|
||||
embeddingFile,
|
||||
removeEmbeddingExtension,
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
* @param {string} modelType
|
||||
* @param {string} path
|
||||
* @param {boolean} removeEmbeddingExtension
|
||||
*/
|
||||
static #copyModelToClipboard(
|
||||
event,
|
||||
modelType,
|
||||
path,
|
||||
removeEmbeddingExtension,
|
||||
) {
|
||||
const nodeType = modelNodeType[modelType]
|
||||
let success = false
|
||||
if (nodeType === 'Embedding') {
|
||||
if (navigator.clipboard) {
|
||||
const [embeddingDirectory, embeddingFile] = SearchPath.split(path)
|
||||
const embeddingText = ModelGrid.insertEmbeddingIntoText(
|
||||
'',
|
||||
embeddingFile,
|
||||
removeEmbeddingExtension,
|
||||
)
|
||||
navigator.clipboard.writeText(embeddingText)
|
||||
success = true
|
||||
} else {
|
||||
console.warn(
|
||||
'Cannot copy the embedding to the system clipboard; Try dragging it instead.',
|
||||
)
|
||||
}
|
||||
} else if (nodeType) {
|
||||
const node = LiteGraph.createNode(nodeType, null, [])
|
||||
const widgetIndex = ModelGrid.modelWidgetIndex(nodeType)
|
||||
if (widgetIndex !== -1) {
|
||||
node.widgets[widgetIndex].value = path
|
||||
app.canvas.copyToClipboard([node])
|
||||
success = true
|
||||
}
|
||||
} else {
|
||||
console.warn(`Unable to copy unknown model type '${modelType}.`)
|
||||
}
|
||||
comfyButtonAlert(event.target, success, 'mdi-check-bold', 'mdi-close-thick')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} models
|
||||
* @param {string} modelType
|
||||
* @param {Object.<HTMLInputElement>} settingsElements
|
||||
* @param {String} searchSeparator
|
||||
* @param {String} systemSeparator
|
||||
* @param {(searchPath: string) => Promise<void>} showModelInfo
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
static #generateInnerHtml(
|
||||
models,
|
||||
modelType,
|
||||
settingsElements,
|
||||
searchSeparator,
|
||||
systemSeparator,
|
||||
showModelInfo,
|
||||
) {
|
||||
// TODO: separate text and model logic; getting too messy
|
||||
// TODO: fallback on button failure to copy text?
|
||||
const canShowButtons = modelNodeType[modelType] !== undefined
|
||||
const showAddButton =
|
||||
canShowButtons && settingsElements['model-show-add-button'].checked
|
||||
const showCopyButton =
|
||||
canShowButtons && settingsElements['model-show-copy-button'].checked
|
||||
const showLoadWorkflowButton =
|
||||
canShowButtons &&
|
||||
settingsElements['model-show-load-workflow-button'].checked
|
||||
const strictDragToAdd =
|
||||
settingsElements['model-add-drag-strict-on-field'].checked
|
||||
const addOffset = parseInt(settingsElements['model-add-offset'].value)
|
||||
const showModelExtension =
|
||||
settingsElements['model-show-label-extensions'].checked
|
||||
const modelInfoButtonOnLeft =
|
||||
!settingsElements['model-info-button-on-left'].checked
|
||||
const removeEmbeddingExtension =
|
||||
!settingsElements['model-add-embedding-extension'].checked
|
||||
const previewThumbnailFormat =
|
||||
settingsElements['model-preview-thumbnail-type'].value
|
||||
const previewThumbnailWidth = Math.round(
|
||||
settingsElements['model-preview-thumbnail-width'].value / 0.75,
|
||||
)
|
||||
const previewThumbnailHeight = Math.round(
|
||||
settingsElements['model-preview-thumbnail-height'].value / 0.75,
|
||||
)
|
||||
const buttonsOnlyOnHover =
|
||||
settingsElements['model-buttons-only-on-hover'].checked
|
||||
if (models.length > 0) {
|
||||
const $overlay = IS_FIREFOX
|
||||
? (modelType, path, removeEmbeddingExtension, strictDragToAdd) => {
|
||||
return $el('div.model-preview-overlay', {
|
||||
ondragstart: (e) => {
|
||||
const data = {
|
||||
modelType: modelType,
|
||||
path: path,
|
||||
removeEmbeddingExtension: removeEmbeddingExtension,
|
||||
strictDragToAdd: strictDragToAdd,
|
||||
}
|
||||
e.dataTransfer.setData('manager-model', JSON.stringify(data))
|
||||
e.dataTransfer.setData('text/plain', '')
|
||||
},
|
||||
draggable: true,
|
||||
})
|
||||
}
|
||||
: (modelType, path, removeEmbeddingExtension, strictDragToAdd) => {
|
||||
return $el('div.model-preview-overlay', {
|
||||
ondragend: (e) =>
|
||||
ModelGrid.dragAddModel(
|
||||
e,
|
||||
modelType,
|
||||
path,
|
||||
removeEmbeddingExtension,
|
||||
strictDragToAdd,
|
||||
),
|
||||
draggable: true,
|
||||
})
|
||||
}
|
||||
const forHiddingButtonsClass = buttonsOnlyOnHover
|
||||
? 'model-buttons-hidden'
|
||||
: 'model-buttons-visible'
|
||||
|
||||
return models.map((item) => {
|
||||
const previewInfo = item.preview
|
||||
const previewThumbnail = $el('img.model-preview', {
|
||||
loading:
|
||||
'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */,
|
||||
src: imageUri(
|
||||
previewInfo?.path,
|
||||
previewInfo?.dateModified,
|
||||
previewThumbnailWidth,
|
||||
previewThumbnailHeight,
|
||||
previewThumbnailFormat,
|
||||
),
|
||||
draggable: false,
|
||||
})
|
||||
const searchPath = item.path
|
||||
const path = SearchPath.systemPath(
|
||||
searchPath,
|
||||
searchSeparator,
|
||||
systemSeparator,
|
||||
)
|
||||
let actionButtons = []
|
||||
if (showCopyButton) {
|
||||
actionButtons.push(
|
||||
new ComfyButton({
|
||||
icon: 'content-copy',
|
||||
tooltip: 'Copy model to clipboard',
|
||||
classList: 'comfyui-button icon-button model-button',
|
||||
action: (e) =>
|
||||
ModelGrid.#copyModelToClipboard(
|
||||
e,
|
||||
modelType,
|
||||
path,
|
||||
removeEmbeddingExtension,
|
||||
),
|
||||
}).element,
|
||||
)
|
||||
}
|
||||
if (
|
||||
showAddButton &&
|
||||
!(modelType === 'embeddings' && !navigator.clipboard)
|
||||
) {
|
||||
actionButtons.push(
|
||||
new ComfyButton({
|
||||
icon: 'plus-box-outline',
|
||||
tooltip: 'Add model to node grid',
|
||||
classList: 'comfyui-button icon-button model-button',
|
||||
action: (e) =>
|
||||
ModelGrid.#addModel(
|
||||
e,
|
||||
modelType,
|
||||
path,
|
||||
removeEmbeddingExtension,
|
||||
addOffset,
|
||||
),
|
||||
}).element,
|
||||
)
|
||||
}
|
||||
if (showLoadWorkflowButton) {
|
||||
actionButtons.push(
|
||||
new ComfyButton({
|
||||
icon: 'arrow-bottom-left-bold-box-outline',
|
||||
tooltip: 'Load preview workflow',
|
||||
classList: 'comfyui-button icon-button model-button',
|
||||
action: async (e) => {
|
||||
const urlString = previewThumbnail.src
|
||||
const url = new URL(urlString)
|
||||
const urlSearchParams = url.searchParams
|
||||
const uri = urlSearchParams.get('uri')
|
||||
const v = urlSearchParams.get('v')
|
||||
const urlFull =
|
||||
urlString.substring(0, urlString.indexOf('?')) +
|
||||
'?uri=' +
|
||||
uri +
|
||||
'&v=' +
|
||||
v
|
||||
await loadWorkflow(urlFull)
|
||||
},
|
||||
}).element,
|
||||
)
|
||||
}
|
||||
const infoButtons = [
|
||||
new ComfyButton({
|
||||
icon: 'information-outline',
|
||||
tooltip: 'View model information',
|
||||
classList: 'comfyui-button icon-button model-button',
|
||||
action: async () => {
|
||||
await showModelInfo(searchPath)
|
||||
},
|
||||
}).element,
|
||||
]
|
||||
return $el('div.item', {}, [
|
||||
previewThumbnail,
|
||||
$overlay(modelType, path, removeEmbeddingExtension, strictDragToAdd),
|
||||
$el(
|
||||
'div.model-preview-top-right.' + forHiddingButtonsClass,
|
||||
{
|
||||
draggable: false,
|
||||
},
|
||||
modelInfoButtonOnLeft ? infoButtons : actionButtons,
|
||||
),
|
||||
$el(
|
||||
'div.model-preview-top-left.' + forHiddingButtonsClass,
|
||||
{
|
||||
draggable: false,
|
||||
},
|
||||
modelInfoButtonOnLeft ? actionButtons : infoButtons,
|
||||
),
|
||||
$el(
|
||||
'div.model-label',
|
||||
{
|
||||
draggable: false,
|
||||
},
|
||||
[
|
||||
$el('p', [
|
||||
showModelExtension
|
||||
? item.name
|
||||
: SearchPath.splitExtension(item.name)[0],
|
||||
]),
|
||||
],
|
||||
),
|
||||
])
|
||||
})
|
||||
} else {
|
||||
return [$el('h2', ['No Models'])]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLDivElement} modelGrid
|
||||
* @param {ModelData} modelData
|
||||
* @param {HTMLSelectElement} modelSelect
|
||||
* @param {Object.<{value: string}>} previousModelType
|
||||
* @param {Object} settings
|
||||
* @param {string} sortBy
|
||||
* @param {boolean} reverseSort
|
||||
* @param {Array} previousModelFilters
|
||||
* @param {HTMLInputElement} modelFilter
|
||||
* @param {(searchPath: string) => Promise<void>} showModelInfo
|
||||
*/
|
||||
static update(
|
||||
modelGrid,
|
||||
modelData,
|
||||
modelSelect,
|
||||
previousModelType,
|
||||
settings,
|
||||
sortBy,
|
||||
reverseSort,
|
||||
previousModelFilters,
|
||||
modelFilter,
|
||||
showModelInfo,
|
||||
) {
|
||||
const models = modelData.models
|
||||
let modelType = modelSelect.value
|
||||
if (models[modelType] === undefined) {
|
||||
modelType = settings['model-default-browser-model-type'].value
|
||||
}
|
||||
if (models[modelType] === undefined) {
|
||||
modelType = 'checkpoints' // panic fallback
|
||||
}
|
||||
|
||||
if (modelType !== previousModelType.value) {
|
||||
if (settings['model-persistent-search'].checked) {
|
||||
previousModelFilters.splice(0, previousModelFilters.length) // TODO: make sure this actually worked!
|
||||
} else {
|
||||
// cache previous filter text
|
||||
previousModelFilters[previousModelType.value] = modelFilter.value
|
||||
// read cached filter text
|
||||
modelFilter.value = previousModelFilters[modelType] ?? ''
|
||||
}
|
||||
previousModelType.value = modelType
|
||||
}
|
||||
|
||||
let modelTypeOptions = []
|
||||
for (const [key, value] of Object.entries(models)) {
|
||||
const el = $el('option', [key])
|
||||
modelTypeOptions.push(el)
|
||||
}
|
||||
modelSelect.innerHTML = ''
|
||||
modelTypeOptions.forEach((option) => modelSelect.add(option))
|
||||
modelSelect.value = modelType
|
||||
|
||||
const searchAppend = settings['model-search-always-append'].value
|
||||
const searchText = modelFilter.value + ' ' + searchAppend
|
||||
const modelList = ModelGrid.#filter(models[modelType], searchText)
|
||||
ModelGrid.#sort(modelList, sortBy, reverseSort)
|
||||
|
||||
modelGrid.innerHTML = ''
|
||||
const modelGridModels = ModelGrid.#generateInnerHtml(
|
||||
modelList,
|
||||
modelType,
|
||||
settings,
|
||||
modelData.searchSeparator,
|
||||
modelData.systemSeparator,
|
||||
showModelInfo,
|
||||
)
|
||||
modelGrid.append.apply(modelGrid, modelGridModels)
|
||||
}
|
||||
}
|
||||
53
src/utils/media.ts
Normal 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))
|
||||
}
|
||||
29
src/utils/model.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { BaseModel } from 'types/typings'
|
||||
|
||||
const loader = {
|
||||
checkpoints: 'CheckpointLoaderSimple',
|
||||
loras: 'LoraLoader',
|
||||
vae: 'VAELoader',
|
||||
clip: 'CLIPLoader',
|
||||
diffusion_models: 'UNETLoader',
|
||||
unet: 'UNETLoader',
|
||||
clip_vision: 'CLIPVisionLoader',
|
||||
style_models: 'StyleModelLoader',
|
||||
embeddings: undefined,
|
||||
diffusers: 'DiffusersLoader',
|
||||
vae_approx: undefined,
|
||||
controlnet: 'ControlNetLoader',
|
||||
gligen: 'GLIGENLoader',
|
||||
upscale_models: 'UpscaleModelLoader',
|
||||
hypernetworks: 'HypernetworkLoader',
|
||||
photomaker: 'PhotoMakerLoader',
|
||||
classifiers: undefined,
|
||||
}
|
||||
|
||||
export const resolveModelTypeLoader = (type: string) => {
|
||||
return loader[type]
|
||||
}
|
||||
|
||||
export const genModelKey = (model: BaseModel) => {
|
||||
return `${model.type}:${model.pathIndex}:${model.subFolder}:${model.basename}${model.extension}`
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
214
tailwind.config.js
Normal file
@@ -0,0 +1,214 @@
|
||||
import plugin from 'tailwindcss/plugin'
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['index.html', './src/**/*.vue'],
|
||||
|
||||
darkMode: ['selector', '.dark-theme'],
|
||||
|
||||
plugins: [
|
||||
plugin(({ addUtilities }) => {
|
||||
addUtilities({
|
||||
'.text-shadow': {
|
||||
'text-shadow': '2px 2px 4px rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
'.scrollbar-none': {
|
||||
'scrollbar-width': 'none',
|
||||
},
|
||||
'.preview-aspect': {
|
||||
'aspect-ratio': '7/9',
|
||||
img: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
],
|
||||
|
||||
corePlugins: {
|
||||
preflight: false, // This disables Tailwind's base styles
|
||||
},
|
||||
|
||||
theme: {
|
||||
fontSize: {
|
||||
xs: '0.75rem',
|
||||
sm: '0.875rem',
|
||||
base: '1rem',
|
||||
lg: '1.125rem',
|
||||
xl: '1.25rem',
|
||||
'2xl': '1.5rem',
|
||||
'3xl': '1.875rem',
|
||||
'4xl': '2.25rem',
|
||||
'5xl': '3rem',
|
||||
'6xl': '4rem',
|
||||
},
|
||||
|
||||
screens: {
|
||||
sm: '640px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
xl: '1280px',
|
||||
'2xl': '1536px',
|
||||
'3xl': '1800px',
|
||||
'4xl': '2500px',
|
||||
'5xl': '3200px',
|
||||
},
|
||||
|
||||
spacing: {
|
||||
px: '1px',
|
||||
0: '0px',
|
||||
0.5: '0.125rem',
|
||||
1: '0.25rem',
|
||||
1.5: '0.375rem',
|
||||
2: '0.5rem',
|
||||
2.5: '0.625rem',
|
||||
3: '0.75rem',
|
||||
3.5: '0.875rem',
|
||||
4: '1rem',
|
||||
4.5: '1.125rem',
|
||||
5: '1.25rem',
|
||||
6: '1.5rem',
|
||||
7: '1.75rem',
|
||||
8: '2rem',
|
||||
9: '2.25rem',
|
||||
10: '2.5rem',
|
||||
11: '2.75rem',
|
||||
12: '3rem',
|
||||
14: '3.5rem',
|
||||
16: '4rem',
|
||||
18: '4.5rem',
|
||||
20: '5rem',
|
||||
24: '6rem',
|
||||
28: '7rem',
|
||||
32: '8rem',
|
||||
36: '9rem',
|
||||
40: '10rem',
|
||||
44: '11rem',
|
||||
48: '12rem',
|
||||
52: '13rem',
|
||||
56: '14rem',
|
||||
60: '15rem',
|
||||
64: '16rem',
|
||||
72: '18rem',
|
||||
80: '20rem',
|
||||
84: '22rem',
|
||||
90: '24rem',
|
||||
96: '26rem',
|
||||
100: '28rem',
|
||||
110: '32rem',
|
||||
},
|
||||
|
||||
extend: {
|
||||
gridTemplateColumns: {
|
||||
dynamic: 'repeat(var(--tw-grid-cols-count), var(--tw-grid-cols-width))',
|
||||
},
|
||||
|
||||
spacing: {
|
||||
dynamic: 'var(--tw-spacing-size)',
|
||||
},
|
||||
|
||||
colors: {
|
||||
zinc: {
|
||||
50: '#fafafa',
|
||||
100: '#f4f4f5',
|
||||
200: '#e4e4e7',
|
||||
300: '#d4d4d8',
|
||||
400: '#a1a1aa',
|
||||
500: '#71717a',
|
||||
600: '#52525b',
|
||||
700: '#3f3f46',
|
||||
800: '#27272a',
|
||||
900: '#18181b',
|
||||
950: '#09090b',
|
||||
},
|
||||
|
||||
gray: {
|
||||
50: '#f8fbfc',
|
||||
100: '#f3f6fa',
|
||||
200: '#edf2f7',
|
||||
300: '#e2e8f0',
|
||||
400: '#cbd5e0',
|
||||
500: '#a0aec0',
|
||||
600: '#718096',
|
||||
700: '#4a5568',
|
||||
800: '#2d3748',
|
||||
900: '#1a202c',
|
||||
950: '#0a1016',
|
||||
},
|
||||
|
||||
teal: {
|
||||
50: '#f0fdfa',
|
||||
100: '#e0fcff',
|
||||
200: '#bef8fd',
|
||||
300: '#87eaf2',
|
||||
400: '#54d1db',
|
||||
500: '#38bec9',
|
||||
600: '#2cb1bc',
|
||||
700: '#14919b',
|
||||
800: '#0e7c86',
|
||||
900: '#005860',
|
||||
950: '#022c28',
|
||||
},
|
||||
|
||||
blue: {
|
||||
50: '#eff6ff',
|
||||
100: '#ebf8ff',
|
||||
200: '#bee3f8',
|
||||
300: '#90cdf4',
|
||||
400: '#63b3ed',
|
||||
500: '#4299e1',
|
||||
600: '#3182ce',
|
||||
700: '#2b6cb0',
|
||||
800: '#2c5282',
|
||||
900: '#2a4365',
|
||||
950: '#172554',
|
||||
},
|
||||
|
||||
green: {
|
||||
50: '#fcfff5',
|
||||
100: '#fafff3',
|
||||
200: '#eaf9c9',
|
||||
300: '#d1efa0',
|
||||
400: '#b2e16e',
|
||||
500: '#96ce4c',
|
||||
600: '#7bb53d',
|
||||
700: '#649934',
|
||||
800: '#507b2e',
|
||||
900: '#456829',
|
||||
950: '#355819',
|
||||
},
|
||||
|
||||
fuchsia: {
|
||||
50: '#fdf4ff',
|
||||
100: '#fae8ff',
|
||||
200: '#f5d0fe',
|
||||
300: '#f0abfc',
|
||||
400: '#e879f9',
|
||||
500: '#d946ef',
|
||||
600: '#c026d3',
|
||||
700: '#a21caf',
|
||||
800: '#86198f',
|
||||
900: '#701a75',
|
||||
950: '#4a044e',
|
||||
},
|
||||
|
||||
orange: {
|
||||
50: '#fff7ed',
|
||||
100: '#ffedd5',
|
||||
200: '#fedbb8',
|
||||
300: '#fbd38d',
|
||||
400: '#f6ad55',
|
||||
500: '#ed8936',
|
||||
600: '#dd6b20',
|
||||
700: '#c05621',
|
||||
800: '#9c4221',
|
||||
900: '#7b341e',
|
||||
950: '#431407',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
33
tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"incremental": true,
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "Node",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": false,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"downlevelIteration": true,
|
||||
|
||||
"paths": {
|
||||
"components/*": ["./src/components/*"],
|
||||
"hooks/*": ["./src/hooks/*"],
|
||||
"scripts/*": ["./src/scripts/*"],
|
||||
"types/*": ["./src/types/*"],
|
||||
"utils/*": ["./src/utils/*"]
|
||||
}
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||
154
vite.config.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { defineConfig, Plugin } from 'vite'
|
||||
|
||||
function css(): Plugin {
|
||||
return {
|
||||
name: 'vite-plugin-css-inject',
|
||||
apply: 'build',
|
||||
enforce: 'post',
|
||||
generateBundle(_, bundle) {
|
||||
const cssCode: string[] = []
|
||||
|
||||
for (const key in bundle) {
|
||||
if (Object.prototype.hasOwnProperty.call(bundle, key)) {
|
||||
const chunk = bundle[key]
|
||||
if (chunk.type === 'asset' && chunk.fileName.endsWith('.css')) {
|
||||
cssCode.push(<string>chunk.source)
|
||||
delete bundle[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key in bundle) {
|
||||
if (Object.prototype.hasOwnProperty.call(bundle, key)) {
|
||||
const chunk = bundle[key]
|
||||
if (chunk.type === 'chunk' && /index-.*\.js$/.test(chunk.fileName)) {
|
||||
const originalCode = chunk.code
|
||||
chunk.code = '(function(){var s=document.createElement("style");'
|
||||
chunk.code += 's.type="text/css",s.dataset.styleId="model-manager",'
|
||||
chunk.code += 's.appendChild(document.createTextNode('
|
||||
chunk.code += JSON.stringify(cssCode.join(''))
|
||||
chunk.code += ')),document.head.appendChild(s);})();'
|
||||
chunk.code += originalCode
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function output(): Plugin {
|
||||
return {
|
||||
name: 'vite-plugin-output-fix',
|
||||
apply: 'build',
|
||||
enforce: 'post',
|
||||
generateBundle(_, bundle) {
|
||||
for (const key in bundle) {
|
||||
const chunk = bundle[key]
|
||||
|
||||
if (chunk.type === 'asset') {
|
||||
if (chunk.fileName === 'index.html') {
|
||||
delete bundle[key]
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.fileName.startsWith('assets/')) {
|
||||
chunk.fileName = chunk.fileName.replace('assets/', '')
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function dev(): Plugin {
|
||||
return {
|
||||
name: 'vite-plugin-dev-fix',
|
||||
apply: 'serve',
|
||||
enforce: 'post',
|
||||
configureServer(server) {
|
||||
server.httpServer?.on('listening', () => {
|
||||
const rootDir = server.config.root
|
||||
const outDir = server.config.build.outDir
|
||||
|
||||
const outDirPath = path.join(rootDir, outDir)
|
||||
if (fs.existsSync(outDirPath)) {
|
||||
fs.rmSync(outDirPath, { recursive: true })
|
||||
}
|
||||
fs.mkdirSync(outDirPath)
|
||||
|
||||
const port = server.config.server.port
|
||||
const content = `import "http://localhost:${port}/src/main.ts";`
|
||||
fs.writeFileSync(path.join(outDirPath, 'manager-dev.js'), content)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createWebVersion(): Plugin {
|
||||
return {
|
||||
name: 'vite-plugin-web-version',
|
||||
apply: 'build',
|
||||
enforce: 'post',
|
||||
writeBundle() {
|
||||
const pyProjectContent = fs.readFileSync('pyproject.toml', 'utf8')
|
||||
const [, version] = pyProjectContent.match(/version = "(.*)"/) ?? []
|
||||
|
||||
const metadata = [
|
||||
`version: ${version}`,
|
||||
`build_time: ${new Date().toISOString()}`,
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const metadataFilePath = path.join(__dirname, 'web', 'version.yaml')
|
||||
fs.writeFileSync(metadataFilePath, metadata, 'utf-8')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), css(), output(), dev(), createWebVersion()],
|
||||
|
||||
build: {
|
||||
outDir: 'web',
|
||||
minify: 'esbuild',
|
||||
target: 'es2022',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
// Disabling tree-shaking
|
||||
// Prevent vite remove unused exports
|
||||
treeshake: true,
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('primevue')) {
|
||||
return 'primevue'
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 1024,
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
src: resolvePath('src'),
|
||||
components: resolvePath('src/components'),
|
||||
hooks: resolvePath('src/hooks'),
|
||||
scripts: resolvePath('src/scripts'),
|
||||
types: resolvePath('src/types'),
|
||||
utils: resolvePath('src/utils'),
|
||||
},
|
||||
},
|
||||
|
||||
esbuild: {
|
||||
minifyIdentifiers: false,
|
||||
keepNames: true,
|
||||
minifySyntax: true,
|
||||
minifyWhitespace: true,
|
||||
},
|
||||
})
|
||||
|
||||
function resolvePath(str: string) {
|
||||
return path.resolve(__dirname, str)
|
||||
}
|
||||
231
web/downshow.js
@@ -1,231 +0,0 @@
|
||||
/**
|
||||
* downshow.js -- A javascript library to convert HTML to markdown.
|
||||
*
|
||||
* Copyright (c) 2013 Alex Cornejo.
|
||||
*
|
||||
* Original Markdown Copyright (c) 2004-2005 John Gruber
|
||||
* <http://darlingfireball.net/projects/markdown/>
|
||||
*
|
||||
* Redistributable under a BSD-style open source license.
|
||||
*
|
||||
* downshow has no external dependencies. It has been tested in chrome and
|
||||
* firefox, it probably works in internet explorer, but YMMV.
|
||||
*
|
||||
* Basic Usage:
|
||||
*
|
||||
* downshow(document.getElementById('#yourid').innerHTML);
|
||||
*
|
||||
* TODO:
|
||||
* - Remove extra whitespace between words in headers and other places.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
var doc;
|
||||
|
||||
// Use browser DOM with jsdom as a fallback (for node.js)
|
||||
try {
|
||||
doc = document;
|
||||
} catch(e) {
|
||||
var jsdom = require("jsdom").jsdom;
|
||||
doc = jsdom("<html><head></head><body></body></html>");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every element in root in their bfs traversal order.
|
||||
*
|
||||
* In the process it transforms any nested lists to conform to the w3c
|
||||
* standard, see: http://www.w3.org/wiki/HTML_lists#Nesting_lists
|
||||
*/
|
||||
function bfsOrder(root) {
|
||||
var inqueue = [root], outqueue = [];
|
||||
root._bfs_parent = null;
|
||||
while (inqueue.length > 0) {
|
||||
var elem = inqueue.shift();
|
||||
outqueue.push(elem);
|
||||
var children = elem.childNodes;
|
||||
var liParent = null;
|
||||
for (var i=0 ; i<children.length; i++) {
|
||||
if (children[i].nodeType == 1) {// element node
|
||||
if (children[i].tagName === 'LI') {
|
||||
liParent = children[i];
|
||||
} else if ((children[i].tagName === 'UL' || children[i].tagName === 'OL') && liParent) {
|
||||
liParent.appendChild(children[i]);
|
||||
i--;
|
||||
continue;
|
||||
}
|
||||
children[i]._bfs_parent = elem;
|
||||
inqueue.push(children[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
outqueue.shift();
|
||||
return outqueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove whitespace and newlines from beginning and end of a sting.
|
||||
*/
|
||||
function trim(str) {
|
||||
return str.replace(/^\s\s*/,'').replace(/\s\s*$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all newlines and trims the resulting string.
|
||||
*/
|
||||
function nltrim(str) {
|
||||
return str.replace(/\s{2,}/g, ' ').replace(/^\s\s*/,'').replace(/\s\s*$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add prefix to the beginning of every line in block.
|
||||
*/
|
||||
function prefixBlock(prefix, block, skipEmpty) {
|
||||
var lines = block.split('\n');
|
||||
for (var i =0; i<lines.length; i++) {
|
||||
// Do not prefix empty lines
|
||||
if (lines[i].length === 0 && skipEmpty === true)
|
||||
continue;
|
||||
else
|
||||
lines[i] = prefix + lines[i];
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the node's content.
|
||||
*/
|
||||
function setContent(node, content, prefix, suffix) {
|
||||
if (content.length > 0) {
|
||||
if (prefix && suffix)
|
||||
node._bfs_text = prefix + content + suffix;
|
||||
else
|
||||
node._bfs_text = content;
|
||||
} else
|
||||
node._bfs_text = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a node's content.
|
||||
*/
|
||||
function getContent(node) {
|
||||
var text = '', atom;
|
||||
for (var i = 0; i<node.childNodes.length; i++) {
|
||||
if (node.childNodes[i].nodeType === 1) {
|
||||
atom = node.childNodes[i]._bfs_text;
|
||||
} else if (node.childNodes[i].nodeType === 3) {
|
||||
atom = node.childNodes[i].data;
|
||||
} else
|
||||
continue;
|
||||
if (text.match(/[\t ]+$/) && atom.match(/^[\t ]+/)) {
|
||||
text = text.replace(/[\t ]+$/,'') + ' ' + atom.replace(/^[\t ]+/, '');
|
||||
} else {
|
||||
text = text + atom;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a node in the DOM tree.
|
||||
* */
|
||||
function processNode(node) {
|
||||
if (node.tagName === 'P' || node.tagName === 'DIV' || node.tagName === 'UL' || node.tagName === 'OL' || node.tagName === 'PRE')
|
||||
setContent(node, getContent(node), '\n\n', '\n\n');
|
||||
else if (node.tagName === 'BR')
|
||||
setContent(node, '\n\n');
|
||||
else if (node.tagName === 'HR')
|
||||
setContent(node, '\n***\n');
|
||||
else if (node.tagName === 'H1')
|
||||
setContent(node, nltrim(getContent(node)), '\n# ', '\n');
|
||||
else if (node.tagName === 'H2')
|
||||
setContent(node, nltrim(getContent(node)), '\n## ', '\n');
|
||||
else if (node.tagName === 'H3')
|
||||
setContent(node, nltrim(getContent(node)), '\n### ', '\n');
|
||||
else if (node.tagName === 'H4')
|
||||
setContent(node, nltrim(getContent(node)), '\n#### ', '\n');
|
||||
else if (node.tagName === 'H5')
|
||||
setContent(node, nltrim(getContent(node)), '\n##### ', '\n');
|
||||
else if (node.tagName === 'H6')
|
||||
setContent(node, nltrim(getContent(node)), '\n###### ', '\n');
|
||||
else if (node.tagName === 'B' || node.tagName === 'STRONG')
|
||||
setContent(node, nltrim(getContent(node)), '**', '**');
|
||||
else if (node.tagName === 'I' || node.tagName === 'EM')
|
||||
setContent(node, nltrim(getContent(node)), '_', '_');
|
||||
else if (node.tagName === 'A') {
|
||||
var href = node.href ? nltrim(node.href) : '', text = nltrim(getContent(node)) || href, title = node.title ? nltrim(node.title) : '';
|
||||
if (href.length > 0)
|
||||
setContent(node, '[' + text + '](' + href + (title ? ' "' + title + '"' : '') + ')');
|
||||
else
|
||||
setContent(node, '');
|
||||
} else if (node.tagName === 'IMG') {
|
||||
var src = node.getAttribute('src') ? nltrim(node.getAttribute('src')) : '', alt = node.alt ? nltrim(node.alt) : '', caption = node.title ? nltrim(node.title) : '';
|
||||
if (src.length > 0)
|
||||
setContent(node, ' + ')');
|
||||
else
|
||||
setContent(node, '');
|
||||
} else if (node.tagName === 'BLOCKQUOTE') {
|
||||
var block_content = getContent(node);
|
||||
if (block_content.length > 0)
|
||||
setContent(node, prefixBlock('> ', block_content), '\n\n', '\n\n');
|
||||
else
|
||||
setContent(node, '');
|
||||
} else if (node.tagName === 'CODE') {
|
||||
if (node._bfs_parent.tagName === 'PRE' && node._bfs_parent._bfs_parent !== null)
|
||||
setContent(node, prefixBlock(' ', getContent(node)));
|
||||
else
|
||||
setContent(node, nltrim(getContent(node)), '`', '`');
|
||||
} else if (node.tagName === 'LI') {
|
||||
var list_content = getContent(node);
|
||||
if (list_content.length > 0)
|
||||
if (node._bfs_parent.tagName === 'OL')
|
||||
setContent(node, trim(prefixBlock(' ', list_content, true)), '1. ', '\n\n');
|
||||
else
|
||||
setContent(node, trim(prefixBlock(' ', list_content, true)), '- ', '\n\n');
|
||||
else
|
||||
setContent(node, '');
|
||||
} else
|
||||
setContent(node, getContent(node));
|
||||
}
|
||||
|
||||
function downshow(html, options) {
|
||||
var root = doc.createElement('pre');
|
||||
root.innerHTML = html;
|
||||
var nodes = bfsOrder(root).reverse(), i;
|
||||
|
||||
if (options && options.nodeParser) {
|
||||
for (i = 0; i<nodes.length; i++) {
|
||||
var result = options.nodeParser(doc, nodes[i].cloneNode(true));
|
||||
if (result === false)
|
||||
processNode(nodes[i]);
|
||||
else
|
||||
setContent(nodes[i], result);
|
||||
}
|
||||
} else {
|
||||
for (i = 0; i<nodes.length; i++) {
|
||||
processNode(nodes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return getContent(root)
|
||||
// remove empty lines between blockquotes
|
||||
.replace(/(\n(?:> )+[^\n]*)\n+(\n(?:> )+)/g, "$1\n$2")
|
||||
// remove empty blockquotes
|
||||
.replace(/\n((?:> )+[ ]*\n)+/g, '\n\n')
|
||||
// remove extra newlines
|
||||
.replace(/\n[ \t]*(?:\n[ \t]*)+\n/g,'\n\n')
|
||||
// remove trailing whitespace
|
||||
.replace(/\s\s*$/, '')
|
||||
// convert lists to inline when not using paragraphs
|
||||
.replace(/^([ \t]*(?:\d+\.|\+|\-)[^\n]*)\n\n+(?=[ \t]*(?:\d+\.|\+|\-|\*)[^\n]*)/gm, "$1\n")
|
||||
// remove starting newlines
|
||||
.replace(/^\n\n*/, '');
|
||||
}
|
||||
|
||||
// Export for use in server and client.
|
||||
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined')
|
||||
module.exports = downshow;
|
||||
else if (typeof define === 'function' && define.amd)
|
||||
define([], function () {return downshow;});
|
||||
else
|
||||
window.downshow = downshow;
|
||||
})();
|
||||
@@ -1,8 +0,0 @@
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
|
||||
|
||||
export default [
|
||||
{languageOptions: { globals: globals.browser }},
|
||||
pluginJs.configs.recommended,
|
||||
];
|
||||