Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
|
||||||
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}'
|
||||||
99
.github/workflows/publish.yml
vendored
@@ -1,21 +1,106 @@
|
|||||||
name: Publish to Comfy registry
|
name: Release and Publish to Comfy registry
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- "pyproject.toml"
|
- 'pyproject.toml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-node:
|
publish-node:
|
||||||
name: Publish Custom Node to registry
|
name: Release and Publish Custom Node to registry
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v4
|
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:
|
with:
|
||||||
## Add your own personal access token to your Github Repository secrets and reference it here.
|
script: |
|
||||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
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 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")
|
||||||
9
.gitignore
vendored
@@ -188,3 +188,12 @@ Icon
|
|||||||
Network Trash Folder
|
Network Trash Folder
|
||||||
Temporary Items
|
Temporary Items
|
||||||
.apdisk
|
.apdisk
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# dist
|
||||||
|
web/
|
||||||
|
|
||||||
|
# config
|
||||||
|
config/
|
||||||
|
|||||||
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"
|
|
||||||
}
|
|
||||||
65
.vscode/settings.json
vendored
@@ -1,20 +1,47 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"apng",
|
"tailwindcss",
|
||||||
"Civitai",
|
"vnode",
|
||||||
"ckpt",
|
"unref",
|
||||||
"comfyui",
|
"civitai",
|
||||||
"FYUIKMNVB",
|
"huggingface",
|
||||||
"gguf",
|
"comfyui",
|
||||||
"gligen",
|
"ckpt",
|
||||||
"jfif",
|
"gligen",
|
||||||
"locon",
|
"loras",
|
||||||
"loras",
|
"safetensors",
|
||||||
"noimage",
|
"unet",
|
||||||
"onnx",
|
"controlnet",
|
||||||
"rfilename",
|
"hypernetwork",
|
||||||
"unet",
|
"hypernetworks",
|
||||||
"upscaler"
|
"photomaker",
|
||||||
],
|
"upscaler",
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"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"
|
||||||
|
}
|
||||||
|
|||||||
48
README.md
@@ -4,64 +4,64 @@ Download, browse and delete models in ComfyUI.
|
|||||||
|
|
||||||
Designed to support desktop, mobile and multi-screen devices.
|
Designed to support desktop, mobile and multi-screen devices.
|
||||||
|
|
||||||
<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
|
## 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 the graph to add a new node.
|
||||||
- Drag a model thumbnail onto an existing node to set the input field.
|
- 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.
|
- 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 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).
|
- 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 "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 "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.
|
- Press the "load workflow" button to try and load a workflow embedded in a model's preview image.
|
||||||
|
|
||||||
### Download Tab
|
### 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.
|
- View multiple models associated with a url.
|
||||||
- Select a save directory and input a filename.
|
- Select a save directory and input a filename.
|
||||||
- Optionally set a model's preview image.
|
- 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.)
|
- Optionally edit and save descriptions as a .md note.
|
||||||
- Add Civitai and HuggingFace API tokens in `server_settings.yaml`.
|
- 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
|
### 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.
|
- 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.
|
- Sort models by "Name", "File Size", "Date Created" and "Date Modified".
|
||||||
- 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".
|
|
||||||
|
|
||||||
### Model Info View
|
### 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.
|
- View file info and metadata.
|
||||||
- Rename, move or **permanently** remove a model and all of it's related files.
|
- 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).
|
- Read, edit and save notes. (Saved as a `.md` 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.)
|
|
||||||
- Change or remove a model's preview image.
|
- Change or remove a model's preview image.
|
||||||
- View training tags and use the random tag generator to generate prompt ideas. (Inspired by the one in A1111.)
|
- View training tags and use the random tag generator to generate prompt ideas. (Inspired by the one in A1111.)
|
||||||
|
|
||||||
### 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`.
|
- Scan models and try to download information & preview.
|
||||||
- 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.)
|
|
||||||
|
|||||||
1242
__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.4.31",
|
||||||
|
"vue-i18n": "^9.13.1",
|
||||||
|
"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
|
||||||
491
py/download.py
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import folder_paths
|
||||||
|
from typing import Callable, Awaitable, Any, Literal, Union, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
download_model_task_status: dict[str, TaskStatus] = {}
|
||||||
|
download_thread_pool = thread.DownloadThreadPool()
|
||||||
|
|
||||||
|
|
||||||
|
def set_task_content(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(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(task_id: str):
|
||||||
|
task_status = download_model_task_status.get(task_id, None)
|
||||||
|
|
||||||
|
if task_status is None:
|
||||||
|
download_path = utils.get_download_path()
|
||||||
|
task_content = 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
download_model_task_status[task_id] = task_status
|
||||||
|
|
||||||
|
return task_status
|
||||||
|
|
||||||
|
|
||||||
|
def delete_task_status(task_id: str):
|
||||||
|
download_model_task_status.pop(task_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def scan_model_download_task_list():
|
||||||
|
"""
|
||||||
|
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 = get_task_status(task_id)
|
||||||
|
task_list.append(task_status.to_dict())
|
||||||
|
|
||||||
|
return task_list
|
||||||
|
|
||||||
|
|
||||||
|
async def create_model_download_task(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_image(task_path, preview_file, download_platform)
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
download_model_task_status[task_id] = task_status
|
||||||
|
await utils.send_json("create_download_task", task_status.to_dict())
|
||||||
|
except Exception as e:
|
||||||
|
await delete_model_download_task(task_id)
|
||||||
|
raise RuntimeError(str(e)) from e
|
||||||
|
|
||||||
|
await download_model(task_id, request)
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
|
async def pause_model_download_task(task_id: str):
|
||||||
|
task_status = get_task_status(task_id=task_id)
|
||||||
|
task_status.status = "pause"
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_model_download_task(task_id: str):
|
||||||
|
task_status = get_task_status(task_id)
|
||||||
|
is_running = task_status.status == "doing"
|
||||||
|
task_status.status = "waiting"
|
||||||
|
await 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:
|
||||||
|
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(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 = 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 = utils.get_setting_value(request, "api_key.civitai")
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
elif download_platform == "huggingface":
|
||||||
|
api_key = utils.get_setting_value(request, "api_key.huggingface")
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
progress_interval = 1.0
|
||||||
|
await download_model_file(
|
||||||
|
task_id=task_id,
|
||||||
|
headers=headers,
|
||||||
|
progress_callback=report_progress,
|
||||||
|
interval=progress_interval,
|
||||||
|
)
|
||||||
|
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 = download_thread_pool.submit(download_task, task_id)
|
||||||
|
if status == "Waiting":
|
||||||
|
task_status = 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(
|
||||||
|
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 = get_task_status(task_id)
|
||||||
|
task_content = 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.
|
||||||
|
if total_size == 0:
|
||||||
|
total_size = float(response.headers.get("content-length", 0))
|
||||||
|
task_content.sizeBytes = total_size
|
||||||
|
task_status.totalSize = total_size
|
||||||
|
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())
|
||||||
|
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
|
||||||
|
class ModelDownload:
|
||||||
|
def add_routes(self, routes):
|
||||||
|
|
||||||
|
@routes.get("/model-manager/download/task")
|
||||||
|
async def scan_download_tasks(request):
|
||||||
|
"""
|
||||||
|
Read download task list.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await 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 pause_model_download_task(task_id)
|
||||||
|
elif status == "resume":
|
||||||
|
await 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 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 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})
|
||||||
437
py/information.py
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import yaml
|
||||||
|
import requests
|
||||||
|
import markdownify
|
||||||
|
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
from . import utils
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
model_files: list[dict] = version.get("files", [])
|
||||||
|
model_files = utils.filter_with(model_files, {"type": "Model"})
|
||||||
|
|
||||||
|
shortname = version.get("name", None) if len(model_files) > 0 else None
|
||||||
|
|
||||||
|
for file in model_files:
|
||||||
|
fullname = file.get("name", None)
|
||||||
|
extension = os.path.splitext(fullname)[1]
|
||||||
|
basename = os.path.splitext(fullname)[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": file.get("id"),
|
||||||
|
"shortname": shortname or basename,
|
||||||
|
"fullname": fullname,
|
||||||
|
"basename": basename,
|
||||||
|
"extension": extension,
|
||||||
|
"preview": metadata_info.get("preview"),
|
||||||
|
"sizeBytes": file.get("sizeKB", 0) * 1024,
|
||||||
|
"type": self._resolve_model_type(res_data.get("type", "unknown")),
|
||||||
|
"pathIndex": 0,
|
||||||
|
"description": "\n".join(description_parts),
|
||||||
|
"metadata": file.get("metadata"),
|
||||||
|
"downloadPlatform": "civitai",
|
||||||
|
"downloadUrl": file.get("downloadUrl"),
|
||||||
|
"hashes": file.get("hashes"),
|
||||||
|
}
|
||||||
|
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": "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,
|
||||||
|
"fullname": fullname,
|
||||||
|
"basename": basename,
|
||||||
|
"extension": extension,
|
||||||
|
"preview": image_files,
|
||||||
|
"sizeBytes": 0,
|
||||||
|
"type": "unknown",
|
||||||
|
"pathIndex": 0,
|
||||||
|
"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
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_searcher_by_url(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()
|
||||||
|
|
||||||
|
|
||||||
|
import folder_paths
|
||||||
|
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
|
||||||
|
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.post("/model-manager/model-info/scan")
|
||||||
|
async def download_model_info(request):
|
||||||
|
"""
|
||||||
|
Create a task to download model information.
|
||||||
|
"""
|
||||||
|
post = await utils.get_request_body(request)
|
||||||
|
try:
|
||||||
|
scan_mode = post.get("scanMode", "diff")
|
||||||
|
await self.download_model_info(scan_mode, request)
|
||||||
|
return web.json_response({"success": True})
|
||||||
|
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 image.
|
||||||
|
If the file does not exist, no-preview.png is returned.
|
||||||
|
|
||||||
|
:param type: The type of the model. eg.checkpoints, loras, vae, etc.
|
||||||
|
:param index: The index of the model folders.
|
||||||
|
:param filename: The filename of the image.
|
||||||
|
"""
|
||||||
|
model_type = request.match_info.get("type", None)
|
||||||
|
index = int(request.match_info.get("index", None))
|
||||||
|
filename = request.match_info.get("filename", None)
|
||||||
|
|
||||||
|
extension_uri = config.extension_uri
|
||||||
|
|
||||||
|
try:
|
||||||
|
folders = folder_paths.get_folder_paths(model_type)
|
||||||
|
base_path = folders[index]
|
||||||
|
abs_path = utils.join_path(base_path, filename)
|
||||||
|
except:
|
||||||
|
abs_path = extension_uri
|
||||||
|
|
||||||
|
if not os.path.isfile(abs_path):
|
||||||
|
abs_path = utils.join_path(extension_uri, "assets", "no-preview.png")
|
||||||
|
return web.FileResponse(abs_path)
|
||||||
|
|
||||||
|
@routes.get("/model-manager/preview/download/{filename}")
|
||||||
|
async def read_download_preview(request):
|
||||||
|
filename = request.match_info.get("filename", None)
|
||||||
|
extension_uri = config.extension_uri
|
||||||
|
|
||||||
|
download_path = utils.get_download_path()
|
||||||
|
preview_path = utils.join_path(download_path, filename)
|
||||||
|
|
||||||
|
if not os.path.isfile(preview_path):
|
||||||
|
preview_path = utils.join_path(extension_uri, "assets", "no-preview.png")
|
||||||
|
|
||||||
|
return web.FileResponse(preview_path)
|
||||||
|
|
||||||
|
def fetch_model_info(self, model_page: str):
|
||||||
|
if not model_page:
|
||||||
|
return []
|
||||||
|
|
||||||
|
model_searcher = get_model_searcher_by_url(model_page)
|
||||||
|
result = model_searcher.search_by_url(model_page)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def download_model_info(self, scan_mode: str, request):
|
||||||
|
utils.print_info(f"Download model info for {scan_mode}")
|
||||||
|
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):
|
||||||
|
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)
|
||||||
|
basename = os.path.splitext(fullname)[0]
|
||||||
|
|
||||||
|
abs_model_path = utils.join_path(base_path, fullname)
|
||||||
|
|
||||||
|
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" and (has_preview and has_description):
|
||||||
|
continue
|
||||||
|
|
||||||
|
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_image_url = preview_url_list[0] if preview_url_list else None
|
||||||
|
if preview_image_url:
|
||||||
|
utils.print_debug(f"Save preview image to {abs_image_path}")
|
||||||
|
utils.save_model_preview_image(abs_model_path, preview_image_url)
|
||||||
|
|
||||||
|
description = model_info.get("description", None)
|
||||||
|
if description:
|
||||||
|
utils.save_model_description(abs_model_path, description)
|
||||||
|
except Exception as e:
|
||||||
|
utils.print_error(f"Failed to download model info for {abs_model_path}: {e}")
|
||||||
|
|
||||||
|
utils.print_debug("Completed scan model information.")
|
||||||
229
py/manager.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
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}/"
|
||||||
|
|
||||||
|
fullname = utils.normalize_path(entry.path).replace(prefix_path, "")
|
||||||
|
basename = os.path.splitext(fullname)[0]
|
||||||
|
extension = os.path.splitext(fullname)[1]
|
||||||
|
|
||||||
|
if extension not in folder_paths.supported_pt_extensions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
model_preview = f"/model-manager/preview/{folder}/{path_index}/{basename}.webp"
|
||||||
|
|
||||||
|
stat = entry.stat()
|
||||||
|
return {
|
||||||
|
"fullname": fullname,
|
||||||
|
"basename": basename,
|
||||||
|
"extension": extension,
|
||||||
|
"type": folder,
|
||||||
|
"pathIndex": path_index,
|
||||||
|
"sizeBytes": stat.st_size,
|
||||||
|
"preview": model_preview,
|
||||||
|
"createdAt": round(stat.st_ctime_ns / 1000000),
|
||||||
|
"updatedAt": round(stat.st_mtime_ns / 1000000),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_all_files_entry(directory: str):
|
||||||
|
files = []
|
||||||
|
with os.scandir(directory) as it:
|
||||||
|
for entry in it:
|
||||||
|
# Skip hidden files
|
||||||
|
if not include_hidden_files:
|
||||||
|
if entry.name.startswith("."):
|
||||||
|
continue
|
||||||
|
if entry.is_dir():
|
||||||
|
files.extend(get_all_files_entry(entry.path))
|
||||||
|
elif entry.is_file():
|
||||||
|
files.append(entry)
|
||||||
|
return files
|
||||||
|
|
||||||
|
for path_index, base_path in enumerate(folders):
|
||||||
|
if not os.path.exists(base_path):
|
||||||
|
continue
|
||||||
|
file_entries = get_all_files_entry(base_path)
|
||||||
|
with ThreadPoolExecutor() as executor:
|
||||||
|
futures = {executor.submit(get_file_info, entry, base_path, path_index): entry for entry in file_entries}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
file_info = future.result()
|
||||||
|
if file_info is None:
|
||||||
|
continue
|
||||||
|
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"]
|
||||||
|
if type(previewFile) is str and previewFile == "undefined":
|
||||||
|
utils.remove_model_preview_image(model_path)
|
||||||
|
else:
|
||||||
|
utils.save_model_preview_image(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_images(model_path)
|
||||||
|
for preview in model_previews:
|
||||||
|
os.remove(utils.join_path(model_dirname, preview))
|
||||||
|
|
||||||
|
model_descriptions = utils.get_model_all_descriptions(model_path)
|
||||||
|
for description in model_descriptions:
|
||||||
|
os.remove(utils.join_path(model_dirname, description))
|
||||||
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
|
||||||
470
py/utils.py
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import yaml
|
||||||
|
import shutil
|
||||||
|
import tarfile
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
import traceback
|
||||||
|
import configparser
|
||||||
|
import functools
|
||||||
|
|
||||||
|
import comfy.utils
|
||||||
|
import folder_paths
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from typing import Any
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
|
||||||
|
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}] {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 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 get_model_all_images(model_path: str):
|
||||||
|
base_dirname = os.path.dirname(model_path)
|
||||||
|
files = search_files(base_dirname)
|
||||||
|
files = folder_paths.filter_files_content_types(files, ["image"])
|
||||||
|
|
||||||
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
|
output: list[str] = []
|
||||||
|
for file in files:
|
||||||
|
file_basename = os.path.splitext(file)[0]
|
||||||
|
if file_basename == basename:
|
||||||
|
output.append(file)
|
||||||
|
if file_basename == f"{basename}.preview":
|
||||||
|
output.append(file)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_preview_name(model_path: str):
|
||||||
|
images = get_model_all_images(model_path)
|
||||||
|
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
|
|
||||||
|
for image in images:
|
||||||
|
image_name = os.path.splitext(image)[0]
|
||||||
|
image_ext = os.path.splitext(image)[1]
|
||||||
|
if image_name == basename and image_ext.lower() == ".webp":
|
||||||
|
return image
|
||||||
|
|
||||||
|
return images[0] if len(images) > 0 else "no-preview.png"
|
||||||
|
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
|
||||||
|
def remove_model_preview_image(model_path: str):
|
||||||
|
basename = os.path.splitext(model_path)[0]
|
||||||
|
preview_path = f"{basename}.webp"
|
||||||
|
if os.path.exists(preview_path):
|
||||||
|
os.remove(preview_path)
|
||||||
|
|
||||||
|
|
||||||
|
def save_model_preview_image(model_path: str, image_file_or_url: Any, platform: str | None = None):
|
||||||
|
basename = os.path.splitext(model_path)[0]
|
||||||
|
preview_path = f"{basename}.webp"
|
||||||
|
# Download image file if it is url
|
||||||
|
if type(image_file_or_url) is str:
|
||||||
|
image_url = image_file_or_url
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_response = requests.get(image_url)
|
||||||
|
image_response.raise_for_status()
|
||||||
|
|
||||||
|
image = Image.open(BytesIO(image_response.content))
|
||||||
|
image.save(preview_path, "WEBP")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Failed to download image: {e}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Assert image as file
|
||||||
|
image_file = image_file_or_url
|
||||||
|
|
||||||
|
if not isinstance(image_file, web.FileField):
|
||||||
|
raise RuntimeError("Invalid image file")
|
||||||
|
|
||||||
|
content_type: str = image_file.content_type
|
||||||
|
if not content_type.startswith("image/"):
|
||||||
|
if platform == "huggingface":
|
||||||
|
# huggingface previewFile content_type='text/plain', not startswith("image/")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"FileTypeError: expected image, got {content_type}")
|
||||||
|
image = Image.open(image_file.file)
|
||||||
|
image.save(preview_path, "WEBP")
|
||||||
|
|
||||||
|
|
||||||
|
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_images(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]
|
[project]
|
||||||
name = "comfyui-model-manager"
|
name = "comfyui-model-manager"
|
||||||
description = "Manage models: browsing, download and delete."
|
description = "Manage models: browsing, download and delete."
|
||||||
version = "1.0.0"
|
version = "2.3.4"
|
||||||
license = "LICENSE"
|
license = { file = "LICENSE" }
|
||||||
|
dependencies = ["markdownify"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Repository = "https://github.com/hayden-fr/ComfyUI-Model-Manager"
|
Repository = "https://github.com/hayden-fr/ComfyUI-Model-Manager"
|
||||||
@@ -12,3 +13,6 @@ Repository = "https://github.com/hayden-fr/ComfyUI-Model-Manager"
|
|||||||
PublisherId = "hayden"
|
PublisherId = "hayden"
|
||||||
DisplayName = "ComfyUI-Model-Manager"
|
DisplayName = "ComfyUI-Model-Manager"
|
||||||
Icon = ""
|
Icon = ""
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 160
|
||||||
|
|||||||
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
markdownify
|
||||||
99
src/App.vue
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<GlobalToast></GlobalToast>
|
||||||
|
<GlobalConfirm></GlobalConfirm>
|
||||||
|
<GlobalLoading></GlobalLoading>
|
||||||
|
<GlobalDialogStack></GlobalDialogStack>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import DialogDownload from 'components/DialogDownload.vue'
|
||||||
|
import DialogManager from 'components/DialogManager.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 openDownloadDialog = () => {
|
||||||
|
dialog.open({
|
||||||
|
key: 'model-manager-download-list',
|
||||||
|
title: t('downloadList'),
|
||||||
|
content: DialogDownload,
|
||||||
|
headerButtons: [
|
||||||
|
{
|
||||||
|
key: 'refresh',
|
||||||
|
icon: 'pi pi-refresh',
|
||||||
|
command: () => download.refresh(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openManagerDialog = () => {
|
||||||
|
const { cardWidth, gutter, aspect } = config
|
||||||
|
|
||||||
|
if (firstOpenManager.value) {
|
||||||
|
models.refresh(true)
|
||||||
|
firstOpenManager.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.open({
|
||||||
|
key: 'model-manager',
|
||||||
|
title: t('modelManager'),
|
||||||
|
content: DialogManager,
|
||||||
|
keepAlive: true,
|
||||||
|
headerButtons: [
|
||||||
|
{
|
||||||
|
key: 'refresh',
|
||||||
|
icon: 'pi pi-refresh',
|
||||||
|
command: refreshModelsAndConfig,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'download',
|
||||||
|
icon: 'pi pi-download',
|
||||||
|
command: openDownloadDialog,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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>
|
||||||
150
src/components/DialogCreateTask.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<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"
|
||||||
|
:model="currentModel"
|
||||||
|
:editable="true"
|
||||||
|
@submit="createDownTask"
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<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 { 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
98
src/components/DialogDownload.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<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">
|
||||||
|
<img :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 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 { 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>
|
||||||
234
src/components/DialogManager.vue
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<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"
|
||||||
|
></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 { useModels } from 'hooks/model'
|
||||||
|
import { chunk } from 'lodash'
|
||||||
|
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 } = 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 filterList = mergedList.filter((model) => {
|
||||||
|
const showAllModel = currentType.value === allType
|
||||||
|
|
||||||
|
const matchType = showAllModel || model.type === currentType.value
|
||||||
|
const matchName = model.fullname
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchContent.value?.toLowerCase() || '')
|
||||||
|
|
||||||
|
return matchType && matchName
|
||||||
|
})
|
||||||
|
|
||||||
|
let sortStrategy: (a: Model, b: Model) => number = () => 0
|
||||||
|
switch (sortOrder.value) {
|
||||||
|
case 'name':
|
||||||
|
sortStrategy = (a, b) => a.fullname.localeCompare(b.fullname)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
91
src/components/DialogModelDetail.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<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"></Button>
|
||||||
|
<Button icon="pi pi-copy" @click.stop="copyModelNode"></Button>
|
||||||
|
<Button
|
||||||
|
v-show="model.preview"
|
||||||
|
icon="pi pi-file-import"
|
||||||
|
@click.stop="loadPreviewWorkflow"
|
||||||
|
></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 { 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 = `/model/${props.model.type}/${props.model.pathIndex}/${props.model.fullname}`
|
||||||
|
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(
|
||||||
|
props.model,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
62
src/components/GlobalDialogStack.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<ResponseDialog
|
||||||
|
v-for="(item, index) in stack"
|
||||||
|
v-model:visible="item.visible"
|
||||||
|
:key="item.key"
|
||||||
|
:keep-alive="item.keepAlive"
|
||||||
|
:default-size="item.defaultSize"
|
||||||
|
:default-mobile-size="item.defaultMobileSize"
|
||||||
|
:resize-allow="item.resizeAllow"
|
||||||
|
:min-width="item.minWidth"
|
||||||
|
:max-width="item.maxWidth"
|
||||||
|
:min-height="item.minHeight"
|
||||||
|
:max-height="item.maxHeight"
|
||||||
|
: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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ResponseDialog from 'components/ResponseDialog.vue'
|
||||||
|
import { useDialog } from 'hooks/dialog'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import { usePrimeVue } from 'primevue/config'
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const { stack, rise, close } = useDialog()
|
||||||
|
|
||||||
|
const { config } = usePrimeVue()
|
||||||
|
|
||||||
|
const baseZIndex = computed(() => {
|
||||||
|
return config.zIndex?.modal ?? 1100
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
for (const key in config.zIndex) {
|
||||||
|
config.zIndex[key] = baseZIndex.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</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>
|
||||||
94
src/components/ModelBaseInfo.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<ResponseSelect class="w-full" v-model="pathIndex" :items="pathOptions">
|
||||||
|
</ResponseSelect>
|
||||||
|
|
||||||
|
<ResponseInput
|
||||||
|
v-model.trim="basename"
|
||||||
|
class="-mr-2 text-right"
|
||||||
|
update-trigger="blur"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{{ item.display }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ResponseInput from 'components/ResponseInput.vue'
|
||||||
|
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||||
|
import { useModelBaseInfo } from 'hooks/model'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const editable = defineModel<boolean>('editable')
|
||||||
|
|
||||||
|
const { baseInfo, pathIndex, basename, extension, type, modelFolders } =
|
||||||
|
useModelBaseInfo()
|
||||||
|
|
||||||
|
const typeOptions = computed(() => {
|
||||||
|
return Object.keys(modelFolders.value).map((curr) => {
|
||||||
|
return {
|
||||||
|
value: curr,
|
||||||
|
label: curr,
|
||||||
|
command: () => {
|
||||||
|
type.value = curr
|
||||||
|
pathIndex.value = 0
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const pathOptions = computed(() => {
|
||||||
|
return (modelFolders.value[type.value] ?? []).map((folder, index) => {
|
||||||
|
return {
|
||||||
|
value: index,
|
||||||
|
label: folder,
|
||||||
|
command: () => {
|
||||||
|
pathIndex.value = index
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const information = computed(() => {
|
||||||
|
return Object.values(baseInfo.value).filter((row) => {
|
||||||
|
if (editable.value) {
|
||||||
|
const hiddenKeys = ['fullname', 'pathIndex']
|
||||||
|
return !hiddenKeys.includes(row.key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
135
src/components/ModelCard.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="group/card relative cursor-pointer select-none"
|
||||||
|
:style="{ width: `${cardSize.width}px`, height: `${cardSize.height}px` }"
|
||||||
|
v-tooltip.top="{ value: model.basename, disabled: showModelName }"
|
||||||
|
@click.stop="openDetailDialog"
|
||||||
|
>
|
||||||
|
<div class="h-full overflow-hidden rounded-lg">
|
||||||
|
<div class="h-full bg-gray-500 duration-300 group-hover/card:scale-110">
|
||||||
|
<img class="h-full w-full object-cover" :src="preview" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-draggable-overlay
|
||||||
|
class="absolute left-0 top-0 h-full w-full"
|
||||||
|
draggable="true"
|
||||||
|
@dragend.stop="dragToAddModelNode"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="pointer-events-none absolute left-0 top-0 h-full w-full p-4">
|
||||||
|
<div class="relative h-full w-full text-white">
|
||||||
|
<div v-show="showModelName" class="absolute bottom-0 left-0">
|
||||||
|
<div class="drop-shadow-[0px_2px_2px_rgba(0,0,0,0.75)]">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'line-clamp-3 break-all font-bold',
|
||||||
|
$lg('text-lg', 'text-2xl'),
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ model.basename }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute left-0 top-0 w-full">
|
||||||
|
<div class="flex flex-row items-start justify-between">
|
||||||
|
<div
|
||||||
|
v-show="showModelType"
|
||||||
|
class="flex items-center rounded-full bg-black/30 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div :class="['font-bold', $lg('text-xs')]">
|
||||||
|
{{ model.type }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-show="showToolButton"
|
||||||
|
class="opacity-0 duration-300 group-hover/card:opacity-100"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4 *:pointer-events-auto">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-plus"
|
||||||
|
severity="secondary"
|
||||||
|
rounded
|
||||||
|
@click.stop="addModelNode"
|
||||||
|
></Button>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-copy"
|
||||||
|
severity="secondary"
|
||||||
|
rounded
|
||||||
|
@click.stop="copyModelNode"
|
||||||
|
></Button>
|
||||||
|
<Button
|
||||||
|
v-show="model.preview"
|
||||||
|
icon="pi pi-file-import"
|
||||||
|
severity="secondary"
|
||||||
|
rounded
|
||||||
|
@click.stop="loadPreviewWorkflow"
|
||||||
|
></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import DialogModelDetail from 'components/DialogModelDetail.vue'
|
||||||
|
import { useConfig } from 'hooks/config'
|
||||||
|
import { useContainerQueries } from 'hooks/container'
|
||||||
|
import { useDialog } from 'hooks/dialog'
|
||||||
|
import { useModelNodeAction } from 'hooks/model'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import { Model } from 'types/typings'
|
||||||
|
import { genModelKey } from 'utils/model'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
model: Model
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const { cardSize } = useConfig()
|
||||||
|
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const openDetailDialog = () => {
|
||||||
|
const basename = props.model.fullname.split('/').pop()!
|
||||||
|
const filename = basename.replace(props.model.extension, '')
|
||||||
|
|
||||||
|
dialog.open({
|
||||||
|
key: genModelKey(props.model),
|
||||||
|
title: filename,
|
||||||
|
content: DialogModelDetail,
|
||||||
|
contentProps: { model: props.model },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = computed(() =>
|
||||||
|
Array.isArray(props.model.preview)
|
||||||
|
? props.model.preview[0]
|
||||||
|
: props.model.preview,
|
||||||
|
)
|
||||||
|
|
||||||
|
const showToolButton = computed(() => {
|
||||||
|
return cardSize.value.width >= 180 && cardSize.value.height >= 240
|
||||||
|
})
|
||||||
|
|
||||||
|
const showModelName = computed(() => {
|
||||||
|
return cardSize.value.width >= 160 && cardSize.value.height >= 120
|
||||||
|
})
|
||||||
|
|
||||||
|
const showModelType = computed(() => {
|
||||||
|
return cardSize.value.width >= 120
|
||||||
|
})
|
||||||
|
|
||||||
|
const { addModelNode, dragToAddModelNode, copyModelNode, loadPreviewWorkflow } =
|
||||||
|
useModelNodeAction(props.model)
|
||||||
|
|
||||||
|
const { $lg } = useContainerQueries()
|
||||||
|
</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 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>
|
||||||
108
src/components/ModelPreview.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<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` })"
|
||||||
|
>
|
||||||
|
<ResponseImage :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">
|
||||||
|
<ResponseImage
|
||||||
|
: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 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'
|
||||||
|
|
||||||
|
const editable = defineModel<boolean>('editable')
|
||||||
|
const { cardWidth } = useConfig()
|
||||||
|
|
||||||
|
const {
|
||||||
|
preview,
|
||||||
|
typeOptions,
|
||||||
|
currentType,
|
||||||
|
defaultContent,
|
||||||
|
defaultContentPage,
|
||||||
|
networkContent,
|
||||||
|
updateLocalContent,
|
||||||
|
noPreviewContent,
|
||||||
|
} = useModelPreview()
|
||||||
|
|
||||||
|
const { $sm, $xl } = useContainerQueries()
|
||||||
|
</script>
|
||||||
348
src/components/ResponseDialog.vue
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
:visible="true"
|
||||||
|
@update:visible="updateVisible"
|
||||||
|
: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="px-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
|
||||||
|
}
|
||||||
|
|
||||||
|
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/*'
|
||||||
|
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>
|
||||||
78
src/components/ResponseInput.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<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="innerValue"
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const [content, modifiers] = defineModel<string, 'trim'>()
|
||||||
|
|
||||||
|
const inputRef = ref()
|
||||||
|
|
||||||
|
const innerValue = ref(content)
|
||||||
|
const trigger = computed(() => props.updateTrigger ?? 'change')
|
||||||
|
const updateContent = () => {
|
||||||
|
let value = innerValue.value
|
||||||
|
|
||||||
|
if (modifiers.trim) {
|
||||||
|
value = innerValue.value?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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">
|
||||||
|
import { useDraggable, useElementSize, useScroll } from '@vueuse/core'
|
||||||
|
import { clamp } from 'lodash'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
interface ScrollAreaProps {
|
||||||
|
items?: (T & { key: string })[]
|
||||||
|
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>
|
||||||
253
src/components/ResponseSelect.vue
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<template>
|
||||||
|
<slot
|
||||||
|
v-if="type === 'drop'"
|
||||||
|
name="target"
|
||||||
|
v-bind="{ toggle, prefixIcon, suffixIcon, currentLabel, current }"
|
||||||
|
>
|
||||||
|
<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
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>
|
||||||
286
src/hooks/config.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import SettingCardSize from 'components/SettingCardSize.vue'
|
||||||
|
import { request } from 'hooks/request'
|
||||||
|
import { defineStore } from 'hooks/store'
|
||||||
|
import { $el, app, ComfyDialog } from 'scripts/comfyAPI'
|
||||||
|
import { computed, onMounted, onUnmounted, readonly, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useToast } from './toast'
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { toast } = useToast()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const confirm = (opts: {
|
||||||
|
message?: string
|
||||||
|
accept?: () => void
|
||||||
|
reject?: () => void
|
||||||
|
}) => {
|
||||||
|
const dialog = new ComfyDialog('div', [])
|
||||||
|
|
||||||
|
dialog.show(
|
||||||
|
$el('div', [
|
||||||
|
$el('p', { textContent: opts.message }),
|
||||||
|
$el('div.flex.gap-4', [
|
||||||
|
$el('button.flex-1', {
|
||||||
|
textContent: 'Cancel',
|
||||||
|
onclick: () => {
|
||||||
|
opts.reject?.()
|
||||||
|
dialog.close()
|
||||||
|
document.body.removeChild(dialog.element)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
$el('button.flex-1', {
|
||||||
|
textContent: 'Continue',
|
||||||
|
onclick: () => {
|
||||||
|
opts.accept?.()
|
||||||
|
dialog.close()
|
||||||
|
document.body.removeChild(dialog.element)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// API keys
|
||||||
|
app.ui?.settings.addSetting({
|
||||||
|
id: 'ModelManager.APIKey.HuggingFace',
|
||||||
|
category: [t('modelManager'), t('setting.apiKey'), 'HuggingFace'],
|
||||||
|
name: 'HuggingFace API Key',
|
||||||
|
type: 'text',
|
||||||
|
defaultValue: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
app.ui?.settings.addSetting({
|
||||||
|
id: 'ModelManager.APIKey.Civitai',
|
||||||
|
category: [t('modelManager'), t('setting.apiKey'), 'Civitai'],
|
||||||
|
name: 'Civitai API Key',
|
||||||
|
type: 'text',
|
||||||
|
defaultValue: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Scan information
|
||||||
|
app.ui?.settings.addSetting({
|
||||||
|
id: 'ModelManager.ScanFiles.Full',
|
||||||
|
category: [t('modelManager'), t('setting.scan'), 'Full'],
|
||||||
|
name: t('setting.scanAll'),
|
||||||
|
defaultValue: '',
|
||||||
|
type: () => {
|
||||||
|
return $el('button.p-button.p-component.p-button-secondary', {
|
||||||
|
textContent: 'Full Scan',
|
||||||
|
onclick: () => {
|
||||||
|
confirm({
|
||||||
|
message: [
|
||||||
|
'This operation will override current files.',
|
||||||
|
'This may take a while and generate MANY server requests!',
|
||||||
|
'USE AT YOUR OWN RISK! Continue?',
|
||||||
|
].join('\n'),
|
||||||
|
accept: () => {
|
||||||
|
store.loading.loading.value = true
|
||||||
|
request('/model-info/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ scanMode: 'full' }),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Complete download information',
|
||||||
|
life: 2000,
|
||||||
|
})
|
||||||
|
store.models.refresh()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: err.message ?? 'Failed to download information',
|
||||||
|
life: 15000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
store.loading.loading.value = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
app.ui?.settings.addSetting({
|
||||||
|
id: 'ModelManager.ScanFiles.Incremental',
|
||||||
|
category: [t('modelManager'), t('setting.scan'), 'Incremental'],
|
||||||
|
name: t('setting.scanMissing'),
|
||||||
|
defaultValue: '',
|
||||||
|
type: () => {
|
||||||
|
return $el('button.p-button.p-component.p-button-secondary', {
|
||||||
|
textContent: 'Diff Scan',
|
||||||
|
onclick: () => {
|
||||||
|
confirm({
|
||||||
|
message: [
|
||||||
|
'Download missing information or preview.',
|
||||||
|
'This may take a while and generate MANY server requests!',
|
||||||
|
'USE AT YOUR OWN RISK! Continue?',
|
||||||
|
].join('\n'),
|
||||||
|
accept: () => {
|
||||||
|
store.loading.loading.value = true
|
||||||
|
request('/model-info/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ scanMode: 'diff' }),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Complete download information',
|
||||||
|
life: 2000,
|
||||||
|
})
|
||||||
|
store.models.refresh()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: err.message ?? 'Failed to download information',
|
||||||
|
life: 15000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
store.loading.loading.value = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/hooks/dialog.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { defineStore } from 'hooks/store'
|
||||||
|
import { ContainerSize } from 'types/typings'
|
||||||
|
import { Component, markRaw, ref } from 'vue'
|
||||||
|
|
||||||
|
interface HeaderButton {
|
||||||
|
key: string
|
||||||
|
icon: string
|
||||||
|
command: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stack, open, close, rise }
|
||||||
|
})
|
||||||
|
|
||||||
|
declare module 'hooks/store' {
|
||||||
|
interface StoreProvider {
|
||||||
|
dialog: ReturnType<typeof useDialog>
|
||||||
|
}
|
||||||
|
}
|
||||||
215
src/hooks/download.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { useLoading } from 'hooks/loading'
|
||||||
|
import { request } from 'hooks/request'
|
||||||
|
import { defineStore } from 'hooks/store'
|
||||||
|
import { useToast } from 'hooks/toast'
|
||||||
|
import { api } from 'scripts/comfyAPI'
|
||||||
|
import {
|
||||||
|
BaseModel,
|
||||||
|
DownloadTask,
|
||||||
|
DownloadTaskOptions,
|
||||||
|
SelectOptions,
|
||||||
|
VersionModel,
|
||||||
|
} from 'types/typings'
|
||||||
|
import { bytesToSize } from 'utils/common'
|
||||||
|
import { onBeforeMount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
export const useDownload = defineStore('download', (store) => {
|
||||||
|
const { toast, confirm } = 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() {
|
||||||
|
request(`/download/${item.taskId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: 'pause',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
resumeTask: () => {
|
||||||
|
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: () => {
|
||||||
|
request(`/download/${item.taskId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
reject: () => {},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
return request('/download/task')
|
||||||
|
.then((resData: DownloadTaskOptions[]) => {
|
||||||
|
taskList.value = resData.map((item) => createTaskItem(item))
|
||||||
|
return taskList.value
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: err.message ?? 'Failed to refresh download task list',
|
||||||
|
life: 15000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useModelSearch = () => {
|
||||||
|
const loading = useLoading()
|
||||||
|
const { toast } = useToast()
|
||||||
|
const data = ref<(SelectOptions & { item: VersionModel })[]>([])
|
||||||
|
const current = ref<string | number>()
|
||||||
|
const currentModel = ref<BaseModel>()
|
||||||
|
|
||||||
|
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) => ({
|
||||||
|
label: item.shortname,
|
||||||
|
value: item.id,
|
||||||
|
item,
|
||||||
|
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 }
|
||||||
|
}
|
||||||
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>
|
||||||
637
src/hooks/model.ts
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
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 { 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,
|
||||||
|
InjectionKey,
|
||||||
|
onMounted,
|
||||||
|
provide,
|
||||||
|
ref,
|
||||||
|
toRaw,
|
||||||
|
unref,
|
||||||
|
} from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { configSetting } from './config'
|
||||||
|
|
||||||
|
type ModelFolder = Record<string, string[]>
|
||||||
|
|
||||||
|
const modelFolderProvideKey = Symbol('modelFolder')
|
||||||
|
|
||||||
|
export const useModels = defineStore('models', (store) => {
|
||||||
|
const { toast, confirm } = useToast()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const loading = useLoading()
|
||||||
|
|
||||||
|
const folders = ref<ModelFolder>({})
|
||||||
|
const refreshFolders = async () => {
|
||||||
|
return request('/models').then((resData) => {
|
||||||
|
folders.value = resData
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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) ?? []
|
||||||
|
return 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.fullname !== data.fullname ||
|
||||||
|
model.pathIndex !== data.pathIndex
|
||||||
|
) {
|
||||||
|
oldKey = genModelKey(model)
|
||||||
|
updateData.set('type', data.type)
|
||||||
|
updateData.set('pathIndex', data.pathIndex.toString())
|
||||||
|
updateData.set('fullname', data.fullname)
|
||||||
|
needUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needUpdate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.show()
|
||||||
|
await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
|
||||||
|
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(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Success',
|
||||||
|
detail: `${model.fullname} 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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
folders: folders,
|
||||||
|
data: models,
|
||||||
|
refresh: refreshAllModels,
|
||||||
|
remove: deleteModel,
|
||||||
|
update: updateModel,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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<any>(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 extension = computed(() => {
|
||||||
|
return model.value.extension
|
||||||
|
})
|
||||||
|
|
||||||
|
const basename = computed({
|
||||||
|
get: () => {
|
||||||
|
return model.value.fullname.replace(model.value.extension, '')
|
||||||
|
},
|
||||||
|
set: (val) => {
|
||||||
|
model.value.fullname = `${val ?? ''}${model.value.extension}`
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = modelData.value.type
|
||||||
|
const pathIndex = modelData.value.pathIndex
|
||||||
|
const folders = modelFolders.value[modelType] ?? []
|
||||||
|
return `${folders[pathIndex]}`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fullname',
|
||||||
|
formatter: (val) => val,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
pathIndex,
|
||||||
|
modelFolders,
|
||||||
|
}
|
||||||
|
|
||||||
|
provide(baseInfoKey, result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useModelBaseInfo = () => {
|
||||||
|
return inject(baseInfoKey)!
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 updateLocalContent = async (event: SelectEvent) => {
|
||||||
|
const { files } = event
|
||||||
|
localContent.value = files[0].objectURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No preview
|
||||||
|
*/
|
||||||
|
const noPreviewContent = computed(() => {
|
||||||
|
return `/model-manager/preview/${model.value.type}/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
|
||||||
|
})
|
||||||
|
|
||||||
|
registerSubmit((data) => {
|
||||||
|
data.preview = preview.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
preview,
|
||||||
|
typeOptions,
|
||||||
|
currentType,
|
||||||
|
// default value
|
||||||
|
defaultContent,
|
||||||
|
defaultContentPage,
|
||||||
|
// network picture
|
||||||
|
networkContent,
|
||||||
|
// local file
|
||||||
|
localContent,
|
||||||
|
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 = (model: BaseModel) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { toast, wrapperToastError } = useToast()
|
||||||
|
|
||||||
|
const createNode = (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 = model.fullname
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragToAddModelNode = wrapperToastError((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
|
||||||
|
|
||||||
|
ModelGrid.dragAddModel(
|
||||||
|
event,
|
||||||
|
model.type,
|
||||||
|
model.fullname,
|
||||||
|
removeEmbeddingExtension,
|
||||||
|
strictDragToAdd,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const addModelNode = wrapperToastError(() => {
|
||||||
|
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({ pos })
|
||||||
|
app.graph.add(node)
|
||||||
|
app.canvas.selectNode(node)
|
||||||
|
})
|
||||||
|
|
||||||
|
const copyModelNode = wrapperToastError(() => {
|
||||||
|
const node = createNode()
|
||||||
|
app.canvas.copyToClipboard([node])
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Success',
|
||||||
|
detail: t('modelCopied'),
|
||||||
|
life: 2000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadPreviewWorkflow = wrapperToastError(async () => {
|
||||||
|
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.fullname}.${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 }
|
||||||
|
}
|
||||||
145
src/i18n.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { app } from 'scripts/comfyAPI'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
en: {
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
fullname: '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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
model: '模型',
|
||||||
|
modelManager: '模型管理器',
|
||||||
|
openModelManager: '打开模型管理器',
|
||||||
|
searchModels: '搜索模型',
|
||||||
|
modelCopied: '模型节点已拷贝',
|
||||||
|
download: '下载',
|
||||||
|
downloadList: '下载列表',
|
||||||
|
downloadTask: '下载任务',
|
||||||
|
createDownloadTask: '创建下载任务',
|
||||||
|
parseModelUrl: '解析模型URL',
|
||||||
|
pleaseInputModelUrl: '输入 civitai.com 或 huggingface.co 的 URL',
|
||||||
|
cancel: '取消',
|
||||||
|
save: '保存',
|
||||||
|
delete: '删除',
|
||||||
|
deleteAsk: '确定要删除此{0}?',
|
||||||
|
modelType: '模型类型',
|
||||||
|
default: '默认',
|
||||||
|
network: '网络',
|
||||||
|
local: '本地',
|
||||||
|
none: '无',
|
||||||
|
uploadFile: '上传文件',
|
||||||
|
tapToChange: '点击描述可更改内容',
|
||||||
|
name: '名称',
|
||||||
|
width: '宽度',
|
||||||
|
height: '高度',
|
||||||
|
reset: '重置',
|
||||||
|
sort: {
|
||||||
|
name: '名称',
|
||||||
|
size: '最大',
|
||||||
|
created: '最新创建',
|
||||||
|
modified: '最新修改',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
extraLarge: '超大图标',
|
||||||
|
large: '大图标',
|
||||||
|
medium: '中等图标',
|
||||||
|
small: '小图标',
|
||||||
|
custom: '自定义尺寸',
|
||||||
|
customTip: '在 `设置 > 模型管理器 > 外观` 中设置',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
type: '类型',
|
||||||
|
pathIndex: '目录',
|
||||||
|
fullname: '文件名',
|
||||||
|
sizeBytes: '文件大小',
|
||||||
|
createdAt: '创建时间',
|
||||||
|
updatedAt: '更新时间',
|
||||||
|
},
|
||||||
|
setting: {
|
||||||
|
apiKey: '密钥',
|
||||||
|
cardHeight: '卡片高度',
|
||||||
|
cardWidth: '卡片宽度',
|
||||||
|
scan: '扫描',
|
||||||
|
scanMissing: '下载缺失的信息或预览图片',
|
||||||
|
scanAll: '覆盖所有模型信息和预览图片',
|
||||||
|
includeHiddenFiles: '包含隐藏文件(以 . 开头的文件或文件夹)',
|
||||||
|
excludeScanTypes: '排除扫描类型(使用英文逗号隔开)',
|
||||||
|
ui: '外观',
|
||||||
|
cardSize: '卡片尺寸',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
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
|
||||||
10
src/style.css
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
@layer primevue, tailwind-utilities;
|
||||||
|
|
||||||
|
@layer tailwind-utilities {
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-modal {
|
||||||
|
z-index: 3000;
|
||||||
|
}
|
||||||
284
src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
declare namespace ComfyAPI {
|
||||||
|
namespace api {
|
||||||
|
class ComfyApi {
|
||||||
|
socket: WebSocket
|
||||||
|
fetchApi: (route: string, options?: RequestInit) => Promise<Response>
|
||||||
|
addEventListener: (
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
76
src/types/typings.d.ts
vendored
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
export type ContainerSize = { width: number; height: number }
|
||||||
|
export type ContainerPosition = { left: number; top: number }
|
||||||
|
|
||||||
|
export interface BaseModel {
|
||||||
|
id: number | string
|
||||||
|
fullname: string
|
||||||
|
basename: string
|
||||||
|
extension: string
|
||||||
|
sizeBytes: number
|
||||||
|
type: string
|
||||||
|
pathIndex: number
|
||||||
|
preview: string | string[]
|
||||||
|
description: string
|
||||||
|
metadata: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Model extends BaseModel {
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionModel extends BaseModel {
|
||||||
|
shortname: string
|
||||||
|
downloadPlatform: string
|
||||||
|
downloadUrl: string
|
||||||
|
hashes?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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.fullname}`
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
211
tailwind.config.js
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import plugin from 'tailwindcss/plugin'
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['index.html', './src/**/*.vue'],
|
||||||
|
|
||||||
|
darkMode: ['selector', '.dark-theme'],
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
plugin(({ addUtilities }) => {
|
||||||
|
addUtilities({
|
||||||
|
'.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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
32
tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"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,
|
||||||
|
|
||||||
|
/* 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: true,
|
||||||
|
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,
|
|
||||||
];
|
|
||||||
2498
web/marked.js
@@ -1,717 +0,0 @@
|
|||||||
/* model manager */
|
|
||||||
.model-manager {
|
|
||||||
background-color: var(--comfy-menu-bg);
|
|
||||||
box-sizing: border-box;
|
|
||||||
color: var(--bg-color);
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 15px;
|
|
||||||
height: 100%;
|
|
||||||
padding: 8px;
|
|
||||||
position: fixed;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
z-index: 1100;
|
|
||||||
|
|
||||||
/*override comfy-modal settings*/
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
justify-content: unset;
|
|
||||||
max-height: 100vh;
|
|
||||||
max-width: 100vw;
|
|
||||||
transform: none;
|
|
||||||
/*disable double-tap zoom on model manager*/
|
|
||||||
touch-action: manipulation;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-modal-content {
|
|
||||||
width: 100%;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .no-highlight {
|
|
||||||
user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-webkit-text-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager label:has(> *){
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager label > * {
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* sidebar */
|
|
||||||
|
|
||||||
.model-manager {
|
|
||||||
--model-manager-sidebar-width-left: 50vw;
|
|
||||||
--model-manager-sidebar-width-right: 50vw;
|
|
||||||
--model-manager-sidebar-height-top: 50vh;
|
|
||||||
--model-manager-sidebar-height-bottom: 50vh;
|
|
||||||
|
|
||||||
--model-manager-thumbnail-width: 240px;
|
|
||||||
--model-manager-thumbnail-height: 360px;
|
|
||||||
|
|
||||||
--model-manager-left: 0;
|
|
||||||
--model-manager-right: 0;
|
|
||||||
--model-manager-top: 0;
|
|
||||||
--model-manager-bottom: 0;
|
|
||||||
|
|
||||||
left: var(--model-manager-left);
|
|
||||||
top: var(--model-manager-right);
|
|
||||||
right: var(--model-manager-top);
|
|
||||||
bottom: var(--model-manager-bottom);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager.cursor-drag-left,
|
|
||||||
.model-manager.cursor-drag-right {
|
|
||||||
cursor: ew-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager.cursor-drag-top,
|
|
||||||
.model-manager.cursor-drag-bottom {
|
|
||||||
cursor: ns-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager.cursor-drag-top.cursor-drag-left,
|
|
||||||
.model-manager.cursor-drag-bottom.cursor-drag-right {
|
|
||||||
cursor: nwse-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager.cursor-drag-top.cursor-drag-right,
|
|
||||||
.model-manager.cursor-drag-bottom.cursor-drag-left {
|
|
||||||
cursor: nesw-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* sidebar buttons */
|
|
||||||
.model-manager .sidebar-buttons {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--input-text);
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .sidebar-buttons .radio-button-group-active {
|
|
||||||
border-color: var(--fg-color);
|
|
||||||
color: var(--fg-color);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager[data-sidebar-state="left"] {
|
|
||||||
width: var(--model-manager-sidebar-width-left);
|
|
||||||
max-width: 95vw;
|
|
||||||
min-width: 22vw;
|
|
||||||
right: auto;
|
|
||||||
border-right: solid var(--border-color) 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager[data-sidebar-state="top"] {
|
|
||||||
height: var(--model-manager-sidebar-height-top);
|
|
||||||
max-height: 95vh;
|
|
||||||
min-height: 22vh;
|
|
||||||
bottom: auto;
|
|
||||||
border-bottom: solid var(--border-color) 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager[data-sidebar-state="bottom"] {
|
|
||||||
height: var(--model-manager-sidebar-height-bottom);
|
|
||||||
max-height: 95vh;
|
|
||||||
min-height: 22vh;
|
|
||||||
top: auto;
|
|
||||||
border-top: solid var(--border-color) 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager[data-sidebar-state="right"] {
|
|
||||||
width: var(--model-manager-sidebar-width-right);
|
|
||||||
max-width: 95vw;
|
|
||||||
min-width: 22vw;
|
|
||||||
left: auto;
|
|
||||||
border-left: solid var(--border-color) 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* common */
|
|
||||||
.model-manager h1 {
|
|
||||||
min-width: 0;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager textarea {
|
|
||||||
border: solid 2px var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1.2em;
|
|
||||||
resize: vertical;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager input[type="file"] {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager button, .model-manager .model-manager-head .topbar-right select {
|
|
||||||
margin: 0;
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager button:not(.icon-button),
|
|
||||||
.model-manager select,
|
|
||||||
.model-manager input {
|
|
||||||
padding: 4px 8px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager button:disabled,
|
|
||||||
.model-manager select:disabled,
|
|
||||||
.model-manager input:disabled {
|
|
||||||
background-color: var(--comfy-menu-bg);
|
|
||||||
filter: brightness(1.2);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager select:hover{
|
|
||||||
filter: brightness(1.2);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager button.block {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager ::-webkit-scrollbar {
|
|
||||||
width: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager ::-webkit-scrollbar-track {
|
|
||||||
background-color: var(--comfy-input-bg);
|
|
||||||
border-right: 1px solid var(--border-color);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager ::-webkit-scrollbar-thumb {
|
|
||||||
background-color: var(--fg-color);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .search-text-area::-webkit-input-placeholder {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.model-manager .search-text-area:-moz-placeholder {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.model-manager .search-text-area::-moz-placeholder {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.model-manager .search-text-area:-ms-input-placeholder {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .icon-button {
|
|
||||||
height: 40px;
|
|
||||||
width: 40px;
|
|
||||||
line-height: 1.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .row {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .tab-header {
|
|
||||||
display: flex;
|
|
||||||
padding: 8px 0px;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .tab-header-flex-block {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-button-success {
|
|
||||||
color: green;
|
|
||||||
border-color: green;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-button-failure {
|
|
||||||
color: darkred;
|
|
||||||
border-color: darkred;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .no-select {
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* main content */
|
|
||||||
.model-manager .model-manager-panel {
|
|
||||||
color: var(--fg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-tab-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-tab-group .tab-button {
|
|
||||||
background-color: var(--comfy-menu-bg);
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
border-bottom: none;
|
|
||||||
border-radius: 8px 8px 0px 0px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 8px 12px;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-tab-group .tab-button.active {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
margin-bottom: -2px;
|
|
||||||
cursor: default;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-manager-body {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-manager-panel {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-manager-body {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 8px 0px 8px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-manager-body .tab-contents {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
width: auto;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-manager-body .tab-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* model info view */
|
|
||||||
.model-manager .model-info-container {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
color: var(--fg-color);
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-metadata {
|
|
||||||
table-layout: fixed;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-metadata-key {
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
width: 20%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-metadata-value {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
width: 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager th {
|
|
||||||
border: 1px solid;
|
|
||||||
padding: 4px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* download tab */
|
|
||||||
|
|
||||||
.model-manager .download-model-infos {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0;
|
|
||||||
row-gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .download-details summary {
|
|
||||||
background-color: var(--comfy-menu-bg);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 16px;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .download-details[open] summary {
|
|
||||||
background-color: var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .download-details > div {
|
|
||||||
column-gap: 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding: 8px;
|
|
||||||
row-gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager [data-name="Download"] .download-settings-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager [data-name="Download"] .download-settings {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
row-gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .download-button {
|
|
||||||
max-width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* models tab */
|
|
||||||
.model-manager [data-name="Models"] .row {
|
|
||||||
position: sticky;
|
|
||||||
z-index: 1;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* preview image */
|
|
||||||
.model-manager .item {
|
|
||||||
position: relative;
|
|
||||||
width: var(--model-manager-thumbnail-width);;
|
|
||||||
height: var(--model-manager-thumbnail-height);;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .item img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-info-container .item {
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
.model-manager .model-info-container .item img {
|
|
||||||
height: auto;
|
|
||||||
width: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 50vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-preview-button-left,
|
|
||||||
.model-manager .model-preview-button-right {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
margin: auto;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-preview-button-right {
|
|
||||||
right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-preview-button-left {
|
|
||||||
left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .item .model-preview-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
background-color: rgba(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* grid */
|
|
||||||
.model-manager .comfy-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-grid .model-label {
|
|
||||||
background-color: rgb(from var(--content-hover-bg) r g b / 0.6);
|
|
||||||
width: 100%;
|
|
||||||
height: 2.2rem;
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 2.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-grid .model-label > p {
|
|
||||||
width: calc(100% - 2rem);
|
|
||||||
overflow-x: scroll;
|
|
||||||
white-space: nowrap;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-grid .model-label {
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-grid .model-label ::-webkit-scrollbar {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-grid .model-preview-top-right,
|
|
||||||
.model-manager .comfy-grid .model-preview-top-left {
|
|
||||||
position: absolute;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-grid .model-preview-top-right {
|
|
||||||
right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-grid .model-preview-top-left {
|
|
||||||
left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .item .model-buttons-hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .item:hover .model-buttons-hidden,
|
|
||||||
.model-manager .comfy-grid .model-buttons-visible {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-grid .model-button {
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-grid .model-button:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-grid .model-label {
|
|
||||||
user-select: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* radio */
|
|
||||||
.model-manager .comfy-radio-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-radio {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 4px 16px;
|
|
||||||
color: var(--input-text);
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
background-color: var(--comfy-input-bg);
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-radio:has(> input[type="radio"]:checked) {
|
|
||||||
border-color: var(--border-color);
|
|
||||||
background-color: var(--comfy-menu-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .comfy-radio input[type="radio"]:checked + label {
|
|
||||||
color: var(--fg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .radio-input {
|
|
||||||
opacity: 0;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* model preview select */
|
|
||||||
.model-manager .model-preview-select-radio-container {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-preview-select-radio-inputs > div {
|
|
||||||
padding: 16px 0 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-preview-select-radio-container img {
|
|
||||||
position: relative;
|
|
||||||
width: 230px;
|
|
||||||
height: 345px;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 8px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* topbar */
|
|
||||||
.model-manager .topbar-buttons {
|
|
||||||
display: flex;
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .topbar-buttons button {
|
|
||||||
height: 33px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
width: 33px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-manager-head .topbar-left {
|
|
||||||
display: flex;
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-manager-head .topbar-right {
|
|
||||||
column-gap: 4px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-manager-head .topbar-right select {
|
|
||||||
position: relative;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
font-size: 20px;
|
|
||||||
text-align-last: center;
|
|
||||||
-o-appearance: none;
|
|
||||||
-ms-appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* search dropdown */
|
|
||||||
.model-manager .input-dropdown-container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .search-models {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: row;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-select-dropdown {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .search-text-area,
|
|
||||||
.model-manager .plain-text-area,
|
|
||||||
.model-manager .model-select-dropdown {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 36px;
|
|
||||||
padding-block: 0;
|
|
||||||
min-width: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-select-dropdown {
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .search-directory-dropdown {
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
border: 2px var(--border-color) solid;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: var(--fg-color);
|
|
||||||
max-height: 40vh;
|
|
||||||
overflow: auto;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (pointer:none), (pointer:coarse) {
|
|
||||||
.model-manager .search-directory-dropdown {
|
|
||||||
max-height: 17.5vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .search-directory-dropdown:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .search-directory-dropdown > p {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.85em 20px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.model-manager .search-directory-dropdown > p {
|
|
||||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
|
||||||
scrollbar-width: none; /* Firefox */
|
|
||||||
}
|
|
||||||
.model-manager .search-directory-dropdown > p::-webkit-scrollbar {
|
|
||||||
display: none; /* Safari and Chrome */
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .search-directory-dropdown > p.search-directory-dropdown-key-selected,
|
|
||||||
.model-manager .search-directory-dropdown > p.search-directory-dropdown-mouse-selected {
|
|
||||||
background-color: var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .search-directory-dropdown > p.search-directory-dropdown-key-selected {
|
|
||||||
border-left: 1mm solid var(--input-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* model manager settings */
|
|
||||||
.model-manager .model-manager-settings > div,
|
|
||||||
.model-manager .model-manager-settings > label,
|
|
||||||
.model-manager .tag-generator-settings > label,
|
|
||||||
.model-manager .tag-generator-settings > div {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-manager-settings button {
|
|
||||||
height: 40px;
|
|
||||||
min-width: 120px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .model-manager-settings input[type="number"],
|
|
||||||
.model-manager .tag-generator-settings input[type="number"]{
|
|
||||||
width: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-manager .search-settings-text {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||