refactor: Migrate the project functionality and optimize the code structure
3
.gitignore
vendored
@@ -194,3 +194,6 @@ node_modules/
|
||||
|
||||
# dist
|
||||
web/
|
||||
|
||||
# config
|
||||
config/
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"endOfLine": "lf",
|
||||
"semi": false,
|
||||
"plugins": [
|
||||
"prettier-plugin-organize-imports"
|
||||
"prettier-plugin-organize-imports",
|
||||
"prettier-plugin-tailwindcss"
|
||||
]
|
||||
}
|
||||
48
.vscode/settings.json
vendored
@@ -1,23 +1,47 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"apng",
|
||||
"Civitai",
|
||||
"ckpt",
|
||||
"tailwindcss",
|
||||
"vnode",
|
||||
"unref",
|
||||
"civitai",
|
||||
"huggingface",
|
||||
"comfyui",
|
||||
"FYUIKMNVB",
|
||||
"gguf",
|
||||
"ckpt",
|
||||
"gligen",
|
||||
"jfif",
|
||||
"locon",
|
||||
"loras",
|
||||
"noimage",
|
||||
"onnx",
|
||||
"rfilename",
|
||||
"safetensors",
|
||||
"unet",
|
||||
"upscaler"
|
||||
"controlnet",
|
||||
"hypernetwork",
|
||||
"hypernetworks",
|
||||
"photomaker",
|
||||
"upscaler",
|
||||
"comfyorg",
|
||||
"fullname",
|
||||
"primevue",
|
||||
"maximizable",
|
||||
"inputgroup",
|
||||
"inputgroupaddon",
|
||||
"iconfield",
|
||||
"inputicon",
|
||||
"inputtext",
|
||||
"overlaybadge",
|
||||
"usetoast",
|
||||
"toastservice",
|
||||
"useconfirm",
|
||||
"confirmationservice",
|
||||
"confirmdialog",
|
||||
"popupmenu",
|
||||
"inplace",
|
||||
"contentcontainer",
|
||||
"itemlist",
|
||||
],
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
}
|
||||
},
|
||||
"editor.quickSuggestions": {
|
||||
"strings": "on"
|
||||
},
|
||||
"css.lint.unknownAtRules": "ignore"
|
||||
}
|
||||
50
README.md
@@ -4,64 +4,60 @@ Download, browse and delete models in ComfyUI.
|
||||
|
||||
Designed to support desktop, mobile and multi-screen devices.
|
||||
|
||||
<img src="demo/beta-menu-model-manager-button-settings-group.png" alt="Model Manager Demo Screenshot" width="65%"/>
|
||||
# Usage
|
||||
|
||||
<img src="demo/tab-models.png" alt="Model Manager Demo Screenshot" width="65%"/>
|
||||
```bash
|
||||
cd /path/to/ComfyUI/custom_nodes
|
||||
git clone https://github.com/hayden-fr/ComfyUI-Model-Manager.git
|
||||
cd /path/to/ComfyUI/custom_nodes/ComfyUI-Model-Manager
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 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" height="300" >
|
||||
|
||||
### Support Node Graph
|
||||
|
||||
<img src="demo/tab-model-node-graph.gif" height="300" >
|
||||
|
||||
- Drag a model thumbnail onto the graph to add a new node.
|
||||
- Drag a model thumbnail onto an existing node to set the input field.
|
||||
- If there are multiple valid possible fields, then the drag must be exact.
|
||||
- Drag an embedding thumbnail onto a text area, or highlight any number of nodes, to append it onto the end of the text.
|
||||
- Drag the preview image in a model's info view onto the graph to load the embedded workflow (if it exists).
|
||||
|
||||
<img src="demo/tab-model-preview-thumbnail-buttons-example.png" alt="Model Manager Demo Screenshot" width="65%"/>
|
||||
|
||||
- Press the "copy" button to copy a model to ComfyUI's clipboard or copy the embedding to the system clipboard. (Copying the embedding to the system clipboard requires a secure http connection.)
|
||||
- Press the "add" button to add the model to the ComfyUI graph or append the embedding to one or more selected nodes.
|
||||
- Press the "load workflow" button to try and load a workflow embedded in a model's preview image.
|
||||
|
||||
### Download Tab
|
||||
|
||||
<img src="demo/tab-download.png" alt="Model Manager Demo Screenshot" width="65%"/>
|
||||
<img src="demo/tab-download.png" height="300" >
|
||||
|
||||
- View multiple models associated with a url.
|
||||
- Select a save directory and input a filename.
|
||||
- Optionally set a model's preview image.
|
||||
- Optionally edit and save descriptions as a .txt note. (Default behavior can be set in the settings tab.)
|
||||
- Add Civitai and HuggingFace API tokens in `server_settings.yaml`.
|
||||
- Optionally edit and save descriptions as a .md note.
|
||||
- Add Civitai and HuggingFace API tokens in ComfyUI's settings.
|
||||
|
||||
<img src="demo/tab-settings.png" height="150" >
|
||||
|
||||
### 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" height="300"/>
|
||||
|
||||
- Search in real-time for models using the search bar.
|
||||
- Use advance keyword search by typing `"multiple words in quotes"` or a minus sign before to `-exclude` a word or phrase.
|
||||
- Add `/` at the start of a search to view a dropdown list of subdirectories (for example, `/0/1.5/styles/clothing`).
|
||||
- Any directory paths in ComfyUI's `extra_model_paths.yaml` or directories added in `ComfyUI/models/` will automatically be detected.
|
||||
- Sort models by "Date Created", "Date Modified", "Name" and "File Size".
|
||||
- Sort models by "Name", "File Size", "Date Created" and "Date Modified".
|
||||
|
||||
### Model Info View
|
||||
|
||||
<img src="demo/tab-model-info-overview.png" alt="Model Manager Demo Screenshot" width="65%"/>
|
||||
<img src="demo/tab-model-info-overview.png" alt="Model Manager Demo Screenshot" height="300"/>
|
||||
|
||||
- View file info and metadata.
|
||||
- Rename, move or **permanently** remove a model and all of it's related files.
|
||||
- Read, edit and save notes. (Saved as a `.txt` file beside the model).
|
||||
- `Ctrl+s` or `⌘+S` to save a note when the textarea is in focus.
|
||||
- Autosave can be enabled in settings. (Note: Once the model info view is closed, the undo history is lost.)
|
||||
- Read, edit and save notes. (Saved as a `.md` file beside the model).
|
||||
- Change or remove a model's preview image.
|
||||
- View training tags and use the random tag generator to generate prompt ideas. (Inspired by the one in A1111.)
|
||||
|
||||
### Settings Tab
|
||||
|
||||
<img src="demo/tab-settings.png" alt="Model Manager Demo Screenshot" width="65%"/>
|
||||
|
||||
- Settings are saved to `ui_settings.yaml`.
|
||||
- Most settings should update immediately, but a few may require a page reload to take effect.
|
||||
- Press the "Fix Extensions" button to correct all image file extensions in the model directories. (Note: This may take a minute or so to complete.)
|
||||
|
||||
1351
__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 |
|
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 |
16
package.json
@@ -4,12 +4,16 @@
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@types/lodash": "^4.17.9",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.10.0",
|
||||
@@ -20,15 +24,23 @@
|
||||
"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.6.0",
|
||||
"vite": "^5.4.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primevue/themes": "^4.0.7",
|
||||
"dayjs": "^1.11.13",
|
||||
"lodash": "^4.17.21",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-metadata-block": "^1.0.6",
|
||||
"primevue": "^4.0.7",
|
||||
"turndown": "^7.2.0",
|
||||
"vue": "^3.4.31",
|
||||
"vue-i18n": "^9.13.1"
|
||||
"vue-i18n": "^9.13.1",
|
||||
"yaml": "^2.6.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"./**/*.{js,ts,tsx,vue}": [
|
||||
|
||||
209
pnpm-lock.yaml
generated
@@ -8,19 +8,52 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@primevue/themes':
|
||||
specifier: ^4.0.7
|
||||
version: 4.0.7
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
markdown-it:
|
||||
specifier: ^14.1.0
|
||||
version: 14.1.0
|
||||
markdown-it-metadata-block:
|
||||
specifier: ^1.0.6
|
||||
version: 1.0.6
|
||||
primevue:
|
||||
specifier: ^4.0.7
|
||||
version: 4.0.7(vue@3.5.6(typescript@5.6.2))
|
||||
turndown:
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0
|
||||
vue:
|
||||
specifier: ^3.4.31
|
||||
version: 3.5.6(typescript@5.6.2)
|
||||
vue-i18n:
|
||||
specifier: ^9.13.1
|
||||
version: 9.14.0(vue@3.5.6(typescript@5.6.2))
|
||||
yaml:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
devDependencies:
|
||||
'@tailwindcss/container-queries':
|
||||
specifier: ^0.1.1
|
||||
version: 0.1.1(tailwindcss@3.4.12)
|
||||
'@types/lodash':
|
||||
specifier: ^4.17.9
|
||||
version: 4.17.9
|
||||
'@types/markdown-it':
|
||||
specifier: ^14.1.2
|
||||
version: 14.1.2
|
||||
'@types/node':
|
||||
specifier: ^22.5.5
|
||||
version: 22.5.5
|
||||
'@types/turndown':
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.1.4
|
||||
version: 5.1.4(vite@5.4.6(@types/node@22.5.5)(less@4.2.0))(vue@3.5.6(typescript@5.6.2))
|
||||
@@ -51,6 +84,9 @@ importers:
|
||||
prettier-plugin-organize-imports:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(prettier@3.3.3)(typescript@5.6.2)
|
||||
prettier-plugin-tailwindcss:
|
||||
specifier: ^0.6.8
|
||||
version: 0.6.8(prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2))(prettier@3.3.3)
|
||||
tailwindcss:
|
||||
specifier: ^3.4.12
|
||||
version: 3.4.12
|
||||
@@ -297,6 +333,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||
|
||||
'@mixmark-io/domino@2.2.0':
|
||||
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -331,6 +370,10 @@ packages:
|
||||
resolution: {integrity: sha512-tj4dfRdV5iN6O0mbkpjhMsGlT3wZTqOPL779ndY5gKuCwN5zcFmKmABWVQmr/ClRivnMkw6Yr1x6gRTV/N0ydg==}
|
||||
engines: {node: '>=12.11.0'}
|
||||
|
||||
'@primevue/themes@4.0.7':
|
||||
resolution: {integrity: sha512-ZbDUrpBmtuqdeegNwUaJTubaLDBBJWOc4Z6UoQM3DG2c7EAE19wQbuh+cG9zqA7sT/Xsp+ACC/Z9e4FnfqB55g==}
|
||||
engines: {node: '>=12.11.0'}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.22.0':
|
||||
resolution: {integrity: sha512-/IZQvg6ZR0tAkEi4tdXOraQoWeJy9gbQ/cx4I7k9dJaCk9qrXEcdouxRVz5kZXt5C2bQ9pILoAA+KB4C/d3pfw==}
|
||||
cpu: [arm]
|
||||
@@ -420,12 +463,32 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/container-queries@0.1.1':
|
||||
resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.2.0'
|
||||
|
||||
'@types/estree@1.0.5':
|
||||
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
|
||||
|
||||
'@types/linkify-it@5.0.0':
|
||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||
|
||||
'@types/lodash@4.17.9':
|
||||
resolution: {integrity: sha512-w9iWudx1XWOHW5lQRS9iKpK/XuRhnN+0T7HvdCCd802FYkT1AMTnxndJHGrNJwRoRHkslGr4S29tjm1cT7x/7w==}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||
|
||||
'@types/mdurl@2.0.0':
|
||||
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
||||
|
||||
'@types/node@22.5.5':
|
||||
resolution: {integrity: sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==}
|
||||
|
||||
'@types/turndown@5.0.5':
|
||||
resolution: {integrity: sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.6.0':
|
||||
resolution: {integrity: sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -667,6 +730,9 @@ packages:
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
dayjs@1.11.13:
|
||||
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
|
||||
|
||||
debug@4.3.7:
|
||||
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -1002,6 +1068,9 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
lint-staged@15.2.10:
|
||||
resolution: {integrity: sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==}
|
||||
engines: {node: '>=18.12.0'}
|
||||
@@ -1035,6 +1104,16 @@ packages:
|
||||
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
markdown-it-metadata-block@1.0.6:
|
||||
resolution: {integrity: sha512-0nMBdV/CLy/bFfcw3wFdiZ6sgEv/yWAoNxgb3qY+5lLEP804r/JT9yLmLH3Z3YrqGDHb5xIi7gqhj7gwbPHycQ==}
|
||||
|
||||
markdown-it@14.1.0:
|
||||
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
|
||||
hasBin: true
|
||||
|
||||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
merge-stream@2.0.0:
|
||||
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
||||
|
||||
@@ -1244,6 +1323,61 @@ packages:
|
||||
vue-tsc:
|
||||
optional: true
|
||||
|
||||
prettier-plugin-tailwindcss@0.6.8:
|
||||
resolution: {integrity: sha512-dGu3kdm7SXPkiW4nzeWKCl3uoImdd5CTZEJGxyypEPL37Wj0HT2pLqjrvSei1nTeuQfO4PUfjeW5cTUNRLZ4sA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
peerDependencies:
|
||||
'@ianvs/prettier-plugin-sort-imports': '*'
|
||||
'@prettier/plugin-pug': '*'
|
||||
'@shopify/prettier-plugin-liquid': '*'
|
||||
'@trivago/prettier-plugin-sort-imports': '*'
|
||||
'@zackad/prettier-plugin-twig-melody': '*'
|
||||
prettier: ^3.0
|
||||
prettier-plugin-astro: '*'
|
||||
prettier-plugin-css-order: '*'
|
||||
prettier-plugin-import-sort: '*'
|
||||
prettier-plugin-jsdoc: '*'
|
||||
prettier-plugin-marko: '*'
|
||||
prettier-plugin-multiline-arrays: '*'
|
||||
prettier-plugin-organize-attributes: '*'
|
||||
prettier-plugin-organize-imports: '*'
|
||||
prettier-plugin-sort-imports: '*'
|
||||
prettier-plugin-style-order: '*'
|
||||
prettier-plugin-svelte: '*'
|
||||
peerDependenciesMeta:
|
||||
'@ianvs/prettier-plugin-sort-imports':
|
||||
optional: true
|
||||
'@prettier/plugin-pug':
|
||||
optional: true
|
||||
'@shopify/prettier-plugin-liquid':
|
||||
optional: true
|
||||
'@trivago/prettier-plugin-sort-imports':
|
||||
optional: true
|
||||
'@zackad/prettier-plugin-twig-melody':
|
||||
optional: true
|
||||
prettier-plugin-astro:
|
||||
optional: true
|
||||
prettier-plugin-css-order:
|
||||
optional: true
|
||||
prettier-plugin-import-sort:
|
||||
optional: true
|
||||
prettier-plugin-jsdoc:
|
||||
optional: true
|
||||
prettier-plugin-marko:
|
||||
optional: true
|
||||
prettier-plugin-multiline-arrays:
|
||||
optional: true
|
||||
prettier-plugin-organize-attributes:
|
||||
optional: true
|
||||
prettier-plugin-organize-imports:
|
||||
optional: true
|
||||
prettier-plugin-sort-imports:
|
||||
optional: true
|
||||
prettier-plugin-style-order:
|
||||
optional: true
|
||||
prettier-plugin-svelte:
|
||||
optional: true
|
||||
|
||||
prettier@3.3.3:
|
||||
resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1256,6 +1390,10 @@ packages:
|
||||
prr@1.0.1:
|
||||
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
|
||||
|
||||
punycode.js@2.3.1:
|
||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1420,6 +1558,9 @@ packages:
|
||||
tslib@2.7.0:
|
||||
resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
|
||||
|
||||
turndown@7.2.0:
|
||||
resolution: {integrity: sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1442,6 +1583,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uc.micro@2.1.0:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
undici-types@6.19.8:
|
||||
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
|
||||
|
||||
@@ -1538,6 +1682,11 @@ packages:
|
||||
engines: {node: '>= 14'}
|
||||
hasBin: true
|
||||
|
||||
yaml@2.6.0:
|
||||
resolution: {integrity: sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==}
|
||||
engines: {node: '>= 14'}
|
||||
hasBin: true
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1708,6 +1857,8 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@mixmark-io/domino@2.2.0': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -1742,6 +1893,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- vue
|
||||
|
||||
'@primevue/themes@4.0.7':
|
||||
dependencies:
|
||||
'@primeuix/styled': 0.0.5
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.22.0':
|
||||
optional: true
|
||||
|
||||
@@ -1790,12 +1945,29 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.22.0':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.12)':
|
||||
dependencies:
|
||||
tailwindcss: 3.4.12
|
||||
|
||||
'@types/estree@1.0.5': {}
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/lodash@4.17.9': {}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
dependencies:
|
||||
'@types/linkify-it': 5.0.0
|
||||
'@types/mdurl': 2.0.0
|
||||
|
||||
'@types/mdurl@2.0.0': {}
|
||||
|
||||
'@types/node@22.5.5':
|
||||
dependencies:
|
||||
undici-types: 6.19.8
|
||||
|
||||
'@types/turndown@5.0.5': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.6.0(@typescript-eslint/parser@8.6.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.11.1
|
||||
@@ -2074,6 +2246,8 @@ snapshots:
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
dayjs@1.11.13: {}
|
||||
|
||||
debug@4.3.7:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -2430,6 +2604,10 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
lint-staged@15.2.10:
|
||||
dependencies:
|
||||
chalk: 5.3.0
|
||||
@@ -2482,6 +2660,19 @@ snapshots:
|
||||
semver: 5.7.2
|
||||
optional: true
|
||||
|
||||
markdown-it-metadata-block@1.0.6: {}
|
||||
|
||||
markdown-it@14.1.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 4.5.0
|
||||
linkify-it: 5.0.0
|
||||
mdurl: 2.0.0
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
merge-stream@2.0.0: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
@@ -2618,7 +2809,7 @@ snapshots:
|
||||
postcss-load-config@4.0.2(postcss@8.4.47):
|
||||
dependencies:
|
||||
lilconfig: 3.1.2
|
||||
yaml: 2.5.1
|
||||
yaml: 2.6.0
|
||||
optionalDependencies:
|
||||
postcss: 8.4.47
|
||||
|
||||
@@ -2647,6 +2838,12 @@ snapshots:
|
||||
prettier: 3.3.3
|
||||
typescript: 5.6.2
|
||||
|
||||
prettier-plugin-tailwindcss@0.6.8(prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.6.2))(prettier@3.3.3):
|
||||
dependencies:
|
||||
prettier: 3.3.3
|
||||
optionalDependencies:
|
||||
prettier-plugin-organize-imports: 4.1.0(prettier@3.3.3)(typescript@5.6.2)
|
||||
|
||||
prettier@3.3.3: {}
|
||||
|
||||
primevue@4.0.7(vue@3.5.6(typescript@5.6.2)):
|
||||
@@ -2661,6 +2858,8 @@ snapshots:
|
||||
prr@1.0.1:
|
||||
optional: true
|
||||
|
||||
punycode.js@2.3.1: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
@@ -2849,6 +3048,10 @@ snapshots:
|
||||
|
||||
tslib@2.7.0: {}
|
||||
|
||||
turndown@7.2.0:
|
||||
dependencies:
|
||||
'@mixmark-io/domino': 2.2.0
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -2868,6 +3071,8 @@ snapshots:
|
||||
|
||||
typescript@5.6.2: {}
|
||||
|
||||
uc.micro@2.1.0: {}
|
||||
|
||||
undici-types@6.19.8: {}
|
||||
|
||||
update-browserslist-db@1.1.0(browserslist@4.23.3):
|
||||
@@ -2950,4 +3155,6 @@ snapshots:
|
||||
|
||||
yaml@2.5.1: {}
|
||||
|
||||
yaml@2.6.0: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
33
py/config.py
Normal file
@@ -0,0 +1,33 @@
|
||||
extension_uri: str = None
|
||||
model_base_paths: dict[str, list[str]] = {}
|
||||
|
||||
|
||||
setting_key = {
|
||||
"api_key": {
|
||||
"civitai": "ModelManager.APIKey.Civitai",
|
||||
"huggingface": "ModelManager.APIKey.HuggingFace",
|
||||
},
|
||||
"download": {
|
||||
"max_task_count": "ModelManager.Download.MaxTaskCount",
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self):
|
||||
self.headers = {}
|
||||
|
||||
|
||||
class CustomException(BaseException):
|
||||
def __init__(self, type: str, message: str = None) -> None:
|
||||
self.type = type
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
362
py/download.py
Normal file
@@ -0,0 +1,362 @@
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
import folder_paths
|
||||
import traceback
|
||||
from typing import Callable, Awaitable, Any, Literal, Union, Optional
|
||||
from dataclasses import dataclass
|
||||
from . import config
|
||||
from . import utils
|
||||
from . import socket
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskContent:
|
||||
type: str
|
||||
pathIndex: int
|
||||
fullname: str
|
||||
description: str
|
||||
downloadPlatform: str
|
||||
downloadUrl: str
|
||||
sizeBytes: float
|
||||
hashes: Optional[dict[str, str]] = None
|
||||
|
||||
|
||||
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 = os.path.join(download_path, f"{task_id}.task")
|
||||
utils.save_dict_pickle_file(task_file_path, utils.unpack_dataclass(task_content))
|
||||
|
||||
|
||||
def get_task_content(task_id: str):
|
||||
download_path = utils.get_download_path()
|
||||
task_file = os.path.join(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)
|
||||
task_content["pathIndex"] = int(task_content.get("pathIndex", 0))
|
||||
task_content["sizeBytes"] = float(task_content.get("sizeBytes", 0))
|
||||
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 = os.path.join(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(sid: str):
|
||||
"""
|
||||
Scan the download directory and send the task list to the client.
|
||||
"""
|
||||
try:
|
||||
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(os.path.join(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)
|
||||
|
||||
await socket.send_json("downloadTaskList", task_list, sid)
|
||||
except Exception as e:
|
||||
error_msg = f"Refresh task list failed: {e}"
|
||||
await socket.send_json("error", error_msg, sid)
|
||||
logging.error(error_msg)
|
||||
|
||||
|
||||
async def create_model_download_task(post: dict):
|
||||
"""
|
||||
Creates a download task for the given post.
|
||||
"""
|
||||
model_type = post.get("type", None)
|
||||
path_index = int(post.get("pathIndex", None))
|
||||
fullname = post.get("fullname", None)
|
||||
|
||||
model_path = utils.get_full_path(model_type, path_index, fullname)
|
||||
# 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 = os.path.join(download_path, f"{task_id}.task")
|
||||
if os.path.exists(task_path):
|
||||
raise RuntimeError(f"Task {task_id} already exists")
|
||||
|
||||
try:
|
||||
previewFile = post.pop("previewFile", None)
|
||||
utils.save_model_preview_image(task_path, previewFile)
|
||||
set_task_content(task_id, post)
|
||||
task_status = TaskStatus(
|
||||
taskId=task_id,
|
||||
type=model_type,
|
||||
fullname=fullname,
|
||||
preview=utils.get_model_preview_name(task_path),
|
||||
platform=post.get("downloadPlatform", None),
|
||||
totalSize=float(post.get("sizeBytes", 0)),
|
||||
)
|
||||
download_model_task_status[task_id] = task_status
|
||||
await socket.send_json("createDownloadTask", task_status)
|
||||
except Exception as e:
|
||||
await delete_model_download_task(task_id)
|
||||
raise RuntimeError(str(e)) from e
|
||||
|
||||
await download_model(task_id)
|
||||
return task_id
|
||||
|
||||
|
||||
async def pause_model_download_task(task_id: str):
|
||||
task_status = get_task_status(task_id=task_id)
|
||||
task_status.status = "pause"
|
||||
|
||||
|
||||
async def delete_model_download_task(task_id: str):
|
||||
task_status = get_task_status(task_id)
|
||||
is_running = task_status.status == "doing"
|
||||
task_status.status = "waiting"
|
||||
await socket.send_json("deleteDownloadTask", task_id)
|
||||
|
||||
# Pause the task
|
||||
if is_running:
|
||||
task_status.status = "pause"
|
||||
time.sleep(1)
|
||||
|
||||
download_dir = utils.get_download_path()
|
||||
task_file_list = os.listdir(download_dir)
|
||||
for task_file in task_file_list:
|
||||
task_file_target = os.path.splitext(task_file)[0]
|
||||
if task_file_target == task_id:
|
||||
delete_task_status(task_id)
|
||||
os.remove(os.path.join(download_dir, task_file))
|
||||
|
||||
await socket.send_json("deleteDownloadTask", task_id)
|
||||
|
||||
|
||||
async def download_model(task_id: str):
|
||||
async def download_task(task_id: str):
|
||||
async def report_progress(task_status: TaskStatus):
|
||||
await socket.send_json("updateDownloadTask", task_status)
|
||||
|
||||
try:
|
||||
# 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 socket.send_json("updateDownloadTask", task_status)
|
||||
|
||||
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("api_key.civitai")
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
elif download_platform == "huggingface":
|
||||
api_key = utils.get_setting_value("api_key.huggingface")
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
progress_interval = 1.0
|
||||
await download_model_file(
|
||||
task_id=task_id,
|
||||
headers=headers,
|
||||
progress_callback=report_progress,
|
||||
interval=progress_interval,
|
||||
)
|
||||
except Exception as e:
|
||||
task_status.status = "pause"
|
||||
task_status.error = str(e)
|
||||
await socket.send_json("updateDownloadTask", task_status)
|
||||
task_status.error = None
|
||||
logging.error(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 socket.send_json("updateDownloadTask", task_status)
|
||||
except Exception as e:
|
||||
task_status.status = "pause"
|
||||
task_status.error = str(e)
|
||||
await socket.send_json("updateDownloadTask", task_status)
|
||||
task_status.error = None
|
||||
logging.error(traceback.format_exc())
|
||||
|
||||
|
||||
async def download_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 = os.path.join(download_path, f"{task_id}.md")
|
||||
with open(description_file, "w") 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 = os.path.join(download_path, f"{task_id}.task")
|
||||
os.remove(task_file)
|
||||
await socket.send_json("completeDownloadTask", 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 = os.path.join(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"):
|
||||
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 = int(response.headers.get("content-length", 0))
|
||||
task_content.sizeBytes = total_size
|
||||
task_status.totalSize = total_size
|
||||
set_task_content(task_id, task_content)
|
||||
await socket.send_json("updateDownloadTask", task_content)
|
||||
|
||||
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 socket.send_json("updateDownloadTask", task_status)
|
||||
145
py/services.py
Normal file
@@ -0,0 +1,145 @@
|
||||
import os
|
||||
import logging
|
||||
import traceback
|
||||
import folder_paths
|
||||
|
||||
from typing import Any
|
||||
from multidict import MultiDictProxy
|
||||
from . import utils
|
||||
from . import socket
|
||||
from . import download
|
||||
|
||||
|
||||
async def connect_websocket(request):
|
||||
async def message_handler(event_type: str, detail: Any, sid: str):
|
||||
try:
|
||||
if event_type == "downloadTaskList":
|
||||
await download.scan_model_download_task_list(sid=sid)
|
||||
|
||||
if event_type == "resumeDownloadTask":
|
||||
await download.download_model(task_id=detail)
|
||||
|
||||
if event_type == "pauseDownloadTask":
|
||||
await download.pause_model_download_task(task_id=detail)
|
||||
|
||||
if event_type == "deleteDownloadTask":
|
||||
await download.delete_model_download_task(task_id=detail)
|
||||
except Exception:
|
||||
logging.error(traceback.format_exc())
|
||||
|
||||
ws = await socket.create_websocket_handler(request, handler=message_handler)
|
||||
return ws
|
||||
|
||||
|
||||
def scan_models_by_model_type(model_type: str):
|
||||
"""
|
||||
Scans all models in the given model type and returns a list of models.
|
||||
"""
|
||||
out = []
|
||||
folders, extensions = folder_paths.folder_names_and_paths[model_type]
|
||||
for path_index, base_path in enumerate(folders):
|
||||
files = utils.recursive_search_files(base_path)
|
||||
|
||||
models = folder_paths.filter_files_extensions(files, extensions)
|
||||
|
||||
for fullname in models:
|
||||
"""
|
||||
fullname is model path relative to base_path
|
||||
eg.
|
||||
abs_path is /path/to/models/stable-diffusion/custom_group/model_name.ckpt
|
||||
base_path is /path/to/models/stable-diffusion
|
||||
fullname is custom_group/model_name.ckpt
|
||||
basename is custom_group/model_name
|
||||
extension is .ckpt
|
||||
"""
|
||||
|
||||
fullname = fullname.replace(os.path.sep, "/")
|
||||
basename = os.path.splitext(fullname)[0]
|
||||
extension = os.path.splitext(fullname)[1]
|
||||
prefix_path = fullname.replace(os.path.basename(fullname), "")
|
||||
|
||||
abs_path = os.path.join(base_path, fullname)
|
||||
file_stats = os.stat(abs_path)
|
||||
|
||||
# Resolve metadata
|
||||
metadata = utils.get_model_metadata(abs_path)
|
||||
|
||||
# Resolve preview
|
||||
image_name = utils.get_model_preview_name(abs_path)
|
||||
image_name = os.path.join(prefix_path, image_name)
|
||||
abs_image_path = os.path.join(base_path, image_name)
|
||||
if os.path.isfile(abs_image_path):
|
||||
image_state = os.stat(abs_image_path)
|
||||
image_timestamp = round(image_state.st_mtime_ns / 1000000)
|
||||
image_name = f"{image_name}?ts={image_timestamp}"
|
||||
model_preview = (
|
||||
f"/model-manager/preview/{model_type}/{path_index}/{image_name}"
|
||||
)
|
||||
|
||||
# Resolve description
|
||||
description_file = utils.get_model_description_name(abs_path)
|
||||
description_file = os.path.join(prefix_path, description_file)
|
||||
abs_desc_path = os.path.join(base_path, description_file)
|
||||
description = None
|
||||
if os.path.isfile(abs_desc_path):
|
||||
with open(abs_desc_path, "r", encoding="utf-8") as f:
|
||||
description = f.read()
|
||||
|
||||
out.append(
|
||||
{
|
||||
"fullname": fullname,
|
||||
"basename": basename,
|
||||
"extension": extension,
|
||||
"type": model_type,
|
||||
"pathIndex": path_index,
|
||||
"sizeBytes": file_stats.st_size,
|
||||
"preview": model_preview,
|
||||
"description": description,
|
||||
"createdAt": round(file_stats.st_ctime_ns / 1000000),
|
||||
"updatedAt": round(file_stats.st_mtime_ns / 1000000),
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def update_model(model_path: str, post: MultiDictProxy):
|
||||
|
||||
if "previewFile" in post:
|
||||
previewFile = post["previewFile"]
|
||||
utils.save_model_preview_image(model_path, previewFile)
|
||||
|
||||
if "description" in post:
|
||||
description = post["description"]
|
||||
utils.save_model_description(model_path, description)
|
||||
|
||||
if "type" in post and "pathIndex" in post and "fullname" in post:
|
||||
model_type = post.get("type", None)
|
||||
path_index = int(post.get("pathIndex", None))
|
||||
fullname = post.get("fullname", None)
|
||||
if model_type is None or path_index is None or fullname is None:
|
||||
raise RuntimeError("Invalid type or pathIndex or fullname")
|
||||
|
||||
# get new path
|
||||
new_model_path = utils.get_full_path(model_type, path_index, fullname)
|
||||
|
||||
utils.rename_model(model_path, new_model_path)
|
||||
|
||||
|
||||
def remove_model(model_path: str):
|
||||
model_dirname = os.path.dirname(model_path)
|
||||
os.remove(model_path)
|
||||
|
||||
model_previews = utils.get_model_all_images(model_path)
|
||||
for preview in model_previews:
|
||||
os.remove(os.path.join(model_dirname, preview))
|
||||
|
||||
model_descriptions = utils.get_model_all_descriptions(model_path)
|
||||
for description in model_descriptions:
|
||||
os.remove(os.path.join(model_dirname, description))
|
||||
|
||||
|
||||
async def create_model_download_task(post):
|
||||
dict_post = dict(post)
|
||||
return await download.create_model_download_task(dict_post)
|
||||
63
py/socket.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import aiohttp
|
||||
import logging
|
||||
import uuid
|
||||
import json
|
||||
from aiohttp import web
|
||||
from typing import Any, Callable, Awaitable
|
||||
from . import utils
|
||||
|
||||
|
||||
__sockets: dict[str, web.WebSocketResponse] = {}
|
||||
|
||||
|
||||
async def create_websocket_handler(
|
||||
request, handler: Callable[[str, Any, str], Awaitable[Any]]
|
||||
):
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
sid = request.rel_url.query.get("clientId", "")
|
||||
if sid:
|
||||
# Reusing existing session, remove old
|
||||
__sockets.pop(sid, None)
|
||||
else:
|
||||
sid = uuid.uuid4().hex
|
||||
|
||||
__sockets[sid] = ws
|
||||
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.ERROR:
|
||||
logging.warning(
|
||||
"ws connection closed with exception %s" % ws.exception()
|
||||
)
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
data = json.loads(msg.data)
|
||||
await handler(data.get("type"), data.get("detail"), sid)
|
||||
finally:
|
||||
__sockets.pop(sid, None)
|
||||
return ws
|
||||
|
||||
|
||||
async def send_json(event: str, data: Any, sid: str = None):
|
||||
detail = utils.unpack_dataclass(data)
|
||||
message = {"type": event, "data": detail}
|
||||
|
||||
if sid is None:
|
||||
socket_list = list(__sockets.values())
|
||||
for ws in socket_list:
|
||||
await __send_socket_catch_exception(ws.send_json, message)
|
||||
elif sid in __sockets:
|
||||
await __send_socket_catch_exception(__sockets[sid].send_json, message)
|
||||
|
||||
|
||||
async def __send_socket_catch_exception(function, message):
|
||||
try:
|
||||
await function(message)
|
||||
except (
|
||||
aiohttp.ClientError,
|
||||
aiohttp.ClientPayloadError,
|
||||
ConnectionResetError,
|
||||
BrokenPipeError,
|
||||
ConnectionError,
|
||||
) as err:
|
||||
logging.warning("send error: {}".format(err))
|
||||
64
py/thread.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import queue
|
||||
import logging
|
||||
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 = utils.get_setting_value(
|
||||
"download.max_task_count", default_max_workers
|
||||
)
|
||||
|
||||
if max_workers <= 0:
|
||||
max_workers = default_max_workers
|
||||
utils.set_setting_value("download.max_task_count", max_workers)
|
||||
|
||||
self.max_worker = max_workers
|
||||
|
||||
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:
|
||||
logging.error(f"worker run error: {str(e)}")
|
||||
|
||||
with self._lock:
|
||||
self.workers_count -= 1
|
||||
282
py/utils.py
Normal file
@@ -0,0 +1,282 @@
|
||||
import os
|
||||
import comfy.utils
|
||||
import json
|
||||
import logging
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
from typing import Any
|
||||
from . import config
|
||||
|
||||
|
||||
def resolve_model_base_paths():
|
||||
folders = list(folder_paths.folder_names_and_paths.keys())
|
||||
config.model_base_paths = {}
|
||||
for folder in folders:
|
||||
if folder == "configs":
|
||||
continue
|
||||
if folder == "custom_nodes":
|
||||
continue
|
||||
config.model_base_paths[folder] = folder_paths.get_folder_paths(folder)
|
||||
|
||||
|
||||
def get_full_path(model_type: str, path_index: int, filename: str):
|
||||
"""
|
||||
Get the absolute path in the model type through string concatenation.
|
||||
"""
|
||||
folders = config.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]
|
||||
return os.path.join(base_path, filename)
|
||||
|
||||
|
||||
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 = config.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 = os.path.join(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 = os.path.join(config.extension_uri, "downloads")
|
||||
if not os.path.exists(download_path):
|
||||
os.makedirs(download_path)
|
||||
return download_path
|
||||
|
||||
|
||||
def recursive_search_files(directory: str):
|
||||
files, folder_all = folder_paths.recursive_search(
|
||||
directory, excluded_dir_names=[".git"]
|
||||
)
|
||||
files.sort()
|
||||
return files
|
||||
|
||||
|
||||
def search_files(directory: str):
|
||||
entries = os.listdir(directory)
|
||||
files = [f for f in entries if os.path.isfile(os.path.join(directory, f))]
|
||||
files.sort()
|
||||
return files
|
||||
|
||||
|
||||
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)
|
||||
return images[0] if len(images) > 0 else "no-preview.png"
|
||||
|
||||
|
||||
def save_model_preview_image(model_path: str, image_file: Any):
|
||||
if not isinstance(image_file, web.FileField):
|
||||
raise RuntimeError("Invalid image file")
|
||||
|
||||
content_type: str = image_file.content_type
|
||||
if not content_type.startswith("image/"):
|
||||
raise RuntimeError(f"FileTypeError: expected image, got {content_type}")
|
||||
|
||||
base_dirname = os.path.dirname(model_path)
|
||||
|
||||
# remove old preview images
|
||||
old_preview_images = get_model_all_images(model_path)
|
||||
a1111_civitai_helper_image = False
|
||||
for image in old_preview_images:
|
||||
if os.path.splitext(image)[1].endswith(".preview"):
|
||||
a1111_civitai_helper_image = True
|
||||
image_path = os.path.join(base_dirname, image)
|
||||
os.remove(image_path)
|
||||
|
||||
# save new preview image
|
||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
extension = f".{content_type.split('/')[1]}"
|
||||
new_preview_path = os.path.join(base_dirname, f"{basename}{extension}")
|
||||
|
||||
with open(new_preview_path, "wb") as f:
|
||||
f.write(image_file.file.read())
|
||||
|
||||
# TODO Is it possible to abandon the current rules and adopt the rules of a1111 civitai_helper?
|
||||
if a1111_civitai_helper_image:
|
||||
"""
|
||||
Keep preview image of a1111_civitai_helper
|
||||
"""
|
||||
new_preview_path = os.path.join(base_dirname, f"{basename}.preview{extension}")
|
||||
with open(new_preview_path, "wb") as f:
|
||||
f.write(image_file.file.read())
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# remove old descriptions
|
||||
old_descriptions = get_model_all_descriptions(model_path)
|
||||
for desc in old_descriptions:
|
||||
description_path = os.path.join(base_dirname, desc)
|
||||
os.remove(description_path)
|
||||
|
||||
# save new description
|
||||
basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
extension = ".md"
|
||||
new_desc_path = os.path.join(base_dirname, f"{basename}{extension}")
|
||||
|
||||
with open(new_desc_path, "w", encoding="utf-8") 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
|
||||
os.rename(model_path, new_model_path)
|
||||
|
||||
# move preview
|
||||
previews = get_model_all_images(model_path)
|
||||
for preview in previews:
|
||||
preview_path = os.path.join(model_dirname, preview)
|
||||
preview_name = os.path.splitext(preview)[0]
|
||||
preview_ext = os.path.splitext(preview)[1]
|
||||
new_preview_path = (
|
||||
os.path.join(new_model_dirname, new_model_name + preview_ext)
|
||||
if preview_name == model_name
|
||||
else os.path.join(
|
||||
new_model_dirname, new_model_name + ".preview" + preview_ext
|
||||
)
|
||||
)
|
||||
os.rename(preview_path, new_preview_path)
|
||||
|
||||
# move description
|
||||
description = get_model_description_name(model_path)
|
||||
description_path = os.path.join(model_dirname, description)
|
||||
if os.path.isfile(description_path):
|
||||
new_description_path = os.path.join(new_model_dirname, f"{new_model_name}.md")
|
||||
os.rename(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(key: str, value: Any):
|
||||
setting_id = resolve_setting_key(key)
|
||||
fake_request = config.FakeRequest()
|
||||
settings = config.serverInstance.user_manager.settings.get_settings(fake_request)
|
||||
settings[setting_id] = value
|
||||
config.serverInstance.user_manager.settings.save_settings(fake_request, settings)
|
||||
|
||||
|
||||
def get_setting_value(key: str, default: Any = None) -> Any:
|
||||
setting_id = resolve_setting_key(key)
|
||||
fake_request = config.FakeRequest()
|
||||
settings = config.serverInstance.user_manager.settings.get_settings(fake_request)
|
||||
return settings.get(setting_id, default)
|
||||
|
||||
|
||||
from dataclasses import asdict, is_dataclass
|
||||
|
||||
|
||||
def unpack_dataclass(data: Any):
|
||||
if isinstance(data, dict):
|
||||
return {key: unpack_dataclass(value) for key, value in data.items()}
|
||||
elif isinstance(data, list):
|
||||
return [unpack_dataclass(x) for x in data]
|
||||
elif is_dataclass(data):
|
||||
return asdict(data)
|
||||
else:
|
||||
return data
|
||||
41
src/App.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<DialogManager></DialogManager>
|
||||
<DialogDownload></DialogDownload>
|
||||
<GlobalToast></GlobalToast>
|
||||
<ConfirmDialog></ConfirmDialog>
|
||||
<GlobalLoading></GlobalLoading>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GlobalToast from 'components/GlobalToast.vue'
|
||||
import DialogManager from 'components/DialogManager.vue'
|
||||
import DialogDownload from 'components/DialogDownload.vue'
|
||||
import GlobalLoading from 'components/GlobalLoading.vue'
|
||||
import ConfirmDialog from 'primevue/confirmdialog'
|
||||
import { $el, app, ComfyButton } from 'scripts/comfyAPI'
|
||||
import { onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStoreProvider } from 'hooks/store'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { dialogManager } = useStoreProvider()
|
||||
|
||||
onMounted(() => {
|
||||
app.ui?.menuContainer?.appendChild(
|
||||
$el('button', {
|
||||
id: 'comfyui-model-manager-button',
|
||||
textContent: t('modelManager'),
|
||||
onclick: () => dialogManager.toggle(),
|
||||
}),
|
||||
)
|
||||
|
||||
app.menu?.settingsGroup.append(
|
||||
new ComfyButton({
|
||||
icon: 'folder-search',
|
||||
tooltip: t('openModelManager'),
|
||||
content: t('modelManager'),
|
||||
action: () => dialogManager.toggle(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
160
src/components/DialogCreateTask.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
:header="$t('parseModelUrl')"
|
||||
:modal="true"
|
||||
: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:style="--p-mask-background: rgba(0, 0, 0, 0.3)"
|
||||
pt:root:class="max-h-full"
|
||||
pt:content:class="px-0"
|
||||
@after-hide="clearContent"
|
||||
>
|
||||
<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 pi-inputicon"
|
||||
@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>
|
||||
|
||||
<ResponseScrollArea class="-mx-5 h-full">
|
||||
<div class="px-5">
|
||||
<ModelContent
|
||||
v-for="{ item } in data"
|
||||
v-show="current == item.id"
|
||||
:key="item.id"
|
||||
:model="item"
|
||||
:editable="true"
|
||||
@submit="createDownTask"
|
||||
>
|
||||
<template #action>
|
||||
<Button
|
||||
icon="pi pi-download"
|
||||
:label="$t('download')"
|
||||
type="submit"
|
||||
></Button>
|
||||
</template>
|
||||
</ModelContent>
|
||||
|
||||
<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>
|
||||
</ResponseScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogResizer :min-width="390"></DialogResizer>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DialogResizer from 'components/DialogResizer.vue'
|
||||
import ResponseInput from 'components/ResponseInput.vue'
|
||||
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
|
||||
import ModelContent from 'components/ModelContent.vue'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { useModelSearch } from 'hooks/download'
|
||||
import { ref } from 'vue'
|
||||
import { previewUrlToFile } from 'utils/common'
|
||||
import { useLoading } from 'hooks/loading'
|
||||
import { request } from 'hooks/request'
|
||||
import { useToast } from 'hooks/toast'
|
||||
import { useConfig } from 'hooks/config'
|
||||
|
||||
const visible = defineModel<boolean>('visible')
|
||||
|
||||
const { isMobile } = useConfig()
|
||||
const { toast } = useToast()
|
||||
const loading = useLoading()
|
||||
|
||||
const modelUrl = ref<string>()
|
||||
|
||||
const { current, data, search } = useModelSearch()
|
||||
|
||||
const searchModelsByUrl = async () => {
|
||||
if (modelUrl.value) {
|
||||
await search(modelUrl.value)
|
||||
}
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
modelUrl.value = undefined
|
||||
data.value = []
|
||||
}
|
||||
|
||||
const createDownTask = async (data: VersionModel) => {
|
||||
const formData = new FormData()
|
||||
|
||||
loading.show()
|
||||
// set base info
|
||||
formData.append('type', data.type)
|
||||
formData.append('pathIndex', data.pathIndex.toString())
|
||||
formData.append('fullname', data.fullname)
|
||||
// set preview
|
||||
const previewFile = await previewUrlToFile(data.preview as string).catch(
|
||||
() => {
|
||||
loading.hide()
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to download preview',
|
||||
life: 15000,
|
||||
})
|
||||
throw new Error('Failed to download preview')
|
||||
},
|
||||
)
|
||||
formData.append('previewFile', previewFile)
|
||||
// set description
|
||||
formData.append('description', data.description)
|
||||
// set model download info
|
||||
formData.append('downloadPlatform', data.downloadPlatform)
|
||||
formData.append('downloadUrl', data.downloadUrl)
|
||||
formData.append('sizeBytes', data.sizeBytes.toString())
|
||||
formData.append('hashes', JSON.stringify(data.hashes))
|
||||
|
||||
await request('/model', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then(() => {
|
||||
visible.value = false
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: e.message ?? 'Failed to create download task',
|
||||
life: 15000,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
loading.hide()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
167
src/components/DialogDownload.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
:modal="true"
|
||||
pt:mask:style="--p-mask-background: rgba(0, 0, 0, 0.3)"
|
||||
pt:root:class="max-h-full"
|
||||
pt:content:class="px-0"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-1 items-center justify-between pr-2">
|
||||
<span class="p-dialog-title select-none">
|
||||
{{ $t('downloadList') }}
|
||||
</span>
|
||||
<div class="p-dialog-header-actions">
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
@click="refresh"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex h-full flex-col gap-4">
|
||||
<div class="whitespace-nowrap px-4 @container">
|
||||
<div class="flex gap-4 @sm:justify-end">
|
||||
<Button
|
||||
class="w-full @sm:w-auto"
|
||||
:label="$t('createDownloadTask')"
|
||||
@click="toggleCreateTask"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResponseScrollArea>
|
||||
<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>
|
||||
<!-- <ul class="m-0 flex list-none flex-col gap-4 p-0 px-4 pb-0">
|
||||
<li
|
||||
v-for="item in data"
|
||||
:key="item.taskId"
|
||||
class="flex flex-row gap-3 overflow-hidden rounded-lg border border-gray-500 p-4"
|
||||
>
|
||||
<div class="h-18 preview-aspect">
|
||||
<img
|
||||
:src="`/model-manager/preview/download/${item.preview}`"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ item.fullname }}
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<i v-show="item.status === 'waiting'">
|
||||
{{ $t('waiting') }}...
|
||||
</i>
|
||||
<i
|
||||
v-show="item.status === 'doing'"
|
||||
class="pi pi-pause-circle"
|
||||
@click="item.pauseTask"
|
||||
></i>
|
||||
<i
|
||||
v-show="item.status === 'pause'"
|
||||
class="pi pi-play-circle"
|
||||
@click="item.resumeTask"
|
||||
></i>
|
||||
<i
|
||||
class="pi pi-trash text-red-400"
|
||||
@click="item.deleteTask"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 flex-1 overflow-hidden rounded bg-gray-200">
|
||||
<div class="h-full *:h-full *:bg-blue-500 *:transition-all">
|
||||
<div :style="{ width: `${item.progress}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>{{ item.downloadProgress }}</div>
|
||||
<div v-show="item.status === 'doing'">
|
||||
{{ item.downloadSpeed }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul> -->
|
||||
</ResponseScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogResizer :min-width="390" :min-height="390"></DialogResizer>
|
||||
</Dialog>
|
||||
|
||||
<DialogCreateTask v-model:visible="openCreateTask"></DialogCreateTask>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DialogCreateTask from 'components/DialogCreateTask.vue'
|
||||
import DialogResizer from 'components/DialogResizer.vue'
|
||||
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { useDownload } from 'hooks/download'
|
||||
import { useBoolean } from 'hooks/utils'
|
||||
|
||||
const { visible, data, refresh } = useDownload()
|
||||
|
||||
const [openCreateTask, toggleCreateTask] = useBoolean()
|
||||
</script>
|
||||
223
src/components/DialogManager.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="updateVisible"
|
||||
: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 }]"
|
||||
pt:root:class="max-h-full group-[:not(.open)]:!hidden"
|
||||
pt:content:class="px-0"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-1 items-center justify-between pr-2">
|
||||
<span class="p-dialog-title select-none">{{ $t('modelManager') }}</span>
|
||||
<div class="p-dialog-header-actions">
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
@click="refreshModels"
|
||||
></Button>
|
||||
<Button
|
||||
icon="pi pi-download"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
@click="download.toggle"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="flex h-full flex-col gap-4 overflow-hidden @container/content"
|
||||
:style="{
|
||||
['--card-width']: `${cardWidth}px`,
|
||||
['--gutter']: `${gutter}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'grid grid-cols-1 justify-center gap-4 px-8',
|
||||
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
|
||||
'@lg/content:gap-[var(--gutter)]',
|
||||
'@lg/content:px-4',
|
||||
]"
|
||||
>
|
||||
<div class="col-span-full @container/toolbar">
|
||||
<div :class="['flex flex-col gap-4', '@2xl/toolbar:flex-row']">
|
||||
<ResponseInput
|
||||
v-model="searchContent"
|
||||
:placeholder="$t('searchModels')"
|
||||
:allow-clear="true"
|
||||
suffix-icon="pi pi-search"
|
||||
></ResponseInput>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 overflow-hidden"
|
||||
>
|
||||
<ResponseSelect
|
||||
v-model="currentType"
|
||||
:items="typeOptions"
|
||||
:type="isMobile ? 'drop' : 'button'"
|
||||
></ResponseSelect>
|
||||
<ResponseSelect
|
||||
v-model="sortOrder"
|
||||
:items="sortOrderOptions"
|
||||
></ResponseSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResponseScrollArea class="h-full">
|
||||
<div
|
||||
:class="[
|
||||
'-mt-8 grid grid-cols-1 justify-center gap-8 px-8',
|
||||
'@lg/content:grid-cols-[repeat(auto-fit,var(--card-width))]',
|
||||
'@lg/content:gap-[var(--gutter)]',
|
||||
'@lg/content:-mt-[var(--gutter)]',
|
||||
'@lg/content:px-4',
|
||||
]"
|
||||
>
|
||||
<div class="col-span-full"></div>
|
||||
<div v-for="model in list" v-show="model.visible" :key="model.id">
|
||||
<DialogModelCard
|
||||
:key="`${model.type}:${model.pathIndex}:${model.fullname}`"
|
||||
:model="model"
|
||||
></DialogModelCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="noneDisplayModel" class="flex justify-center pt-20">
|
||||
<div class="select-none text-lg font-bold">No models found</div>
|
||||
</div>
|
||||
</ResponseScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogResizer
|
||||
:min-width="cardWidth * 2 + gutter + 42"
|
||||
:min-height="cardWidth * aspect * 0.5 + 162"
|
||||
></DialogResizer>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="manager-dialog">
|
||||
import { useConfig } from 'hooks/config'
|
||||
import { useDialogManager } from 'hooks/manager'
|
||||
import { useModels } from 'hooks/model'
|
||||
import DialogResizer from 'components/DialogResizer.vue'
|
||||
import DialogModelCard from 'components/DialogModelCard.vue'
|
||||
import ResponseInput from 'components/ResponseInput.vue'
|
||||
import ResponseSelect from 'components/ResponseSelect.vue'
|
||||
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Button from 'primevue/button'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useToast } from 'hooks/toast'
|
||||
import { useDownload } from 'hooks/download'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { isMobile, cardWidth, gutter, aspect, refreshSetting } = useConfig()
|
||||
|
||||
const download = useDownload()
|
||||
|
||||
const { visible, updateVisible, open } = useDialogManager()
|
||||
const { data, refresh } = useModels()
|
||||
const { toast } = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const searchContent = ref<string>()
|
||||
|
||||
const currentType = ref('all')
|
||||
const typeOptions = ref(
|
||||
[
|
||||
{ label: 'ALL', value: 'all' },
|
||||
{ label: 'Checkpoint', value: 'checkpoints' },
|
||||
{ label: 'embedding', value: 'embeddings' },
|
||||
{ label: 'Hypernetwork', value: 'hypernetworks' },
|
||||
{ label: 'Lora', value: 'loras' },
|
||||
{ label: 'VAE', value: 'vae' },
|
||||
{ label: 'VAE approx', value: 'vae_approx' },
|
||||
{ label: 'Controlnet', value: 'controlnet' },
|
||||
{ label: 'Clip', value: 'clip' },
|
||||
{ label: 'Clip Vision', value: 'clip_vision' },
|
||||
{ label: 'Diffusers', value: 'diffusers' },
|
||||
{ label: 'Gligen', value: 'gligen' },
|
||||
{ label: 'Photomaker', value: 'photomaker' },
|
||||
{ label: 'Style Models', value: 'style_models' },
|
||||
{ label: 'Unet', value: 'unet' },
|
||||
].map((item) => {
|
||||
return {
|
||||
...item,
|
||||
command: () => {
|
||||
currentType.value = item.value
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
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 list = computed(() => {
|
||||
const filterList = data.value.map((model) => {
|
||||
const showAllModel = currentType.value === 'all'
|
||||
|
||||
const matchType = showAllModel || model.type === currentType.value
|
||||
const matchName = model.fullname
|
||||
.toLowerCase()
|
||||
.includes(searchContent.value?.toLowerCase() || '')
|
||||
|
||||
model.visible = matchType && matchName
|
||||
|
||||
return model
|
||||
})
|
||||
|
||||
let sortStrategy = (a: Model, b: Model) => 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
|
||||
}
|
||||
|
||||
return filterList.sort(sortStrategy)
|
||||
})
|
||||
|
||||
const noneDisplayModel = computed(() => {
|
||||
return !list.value.some((model) => model.visible)
|
||||
})
|
||||
|
||||
const refreshModels = async () => {
|
||||
await Promise.all([refresh(), refreshSetting()])
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Refreshed Models',
|
||||
life: 2000,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
97
src/components/DialogModelCard.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
class="group/card relative w-full cursor-pointer select-none preview-aspect"
|
||||
@click.stop.prevent="toggle"
|
||||
>
|
||||
<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 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 text-2xl font-bold @lg:text-lg">
|
||||
{{ model.basename }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute left-0 top-0 w-full">
|
||||
<div class="flex flex-row items-start justify-between">
|
||||
<div class="flex items-center rounded-full bg-black/30 px-3 py-2">
|
||||
<div class="font-bold @lg:text-xs">
|
||||
{{ displayType }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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>
|
||||
|
||||
<DialogModelDetail
|
||||
v-model:visible="visible"
|
||||
:model="model"
|
||||
></DialogModelDetail>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBoolean } from 'hooks/utils'
|
||||
import DialogModelDetail from 'components/DialogModelDetail.vue'
|
||||
import Button from 'primevue/button'
|
||||
import { resolveModelType } from 'utils/model'
|
||||
import { computed } from 'vue'
|
||||
import { useModelNodeAction } from 'hooks/model'
|
||||
|
||||
interface Props {
|
||||
model: Model
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const [visible, toggle] = useBoolean()
|
||||
|
||||
const displayType = computed(() => resolveModelType(props.model.type).display)
|
||||
const preview = computed(() =>
|
||||
Array.isArray(props.model.preview)
|
||||
? props.model.preview[0]
|
||||
: props.model.preview,
|
||||
)
|
||||
|
||||
const { addModelNode, dragToAddModelNode, copyModelNode, loadPreviewWorkflow } =
|
||||
useModelNodeAction(props.model)
|
||||
</script>
|
||||
103
src/components/DialogModelDetail.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
:header="filename"
|
||||
: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:title:class="whitespace-nowrap text-ellipsis overflow-hidden"
|
||||
pt:root:class="max-h-full"
|
||||
pt:content:class="px-0"
|
||||
@after-hide="handleCancel"
|
||||
>
|
||||
<ResponseScrollArea class="h-full">
|
||||
<div class="px-8">
|
||||
<ModelContent
|
||||
v-model:editable="editable"
|
||||
:model="model"
|
||||
@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>
|
||||
</ResponseScrollArea>
|
||||
<DialogResizer :min-width="390"></DialogResizer>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import ModelContent from 'components/ModelContent.vue'
|
||||
import DialogResizer from 'components/DialogResizer.vue'
|
||||
import ResponseScrollArea from 'components/ResponseScrollArea.vue'
|
||||
import { useConfig } from 'hooks/config'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useModelNodeAction, useModels } from 'hooks/model'
|
||||
|
||||
const visible = defineModel<boolean>('visible')
|
||||
interface Props {
|
||||
model: Model
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { isMobile } = useConfig()
|
||||
const { remove, update } = useModels()
|
||||
|
||||
const editable = ref(false)
|
||||
|
||||
const filename = computed(() => {
|
||||
const basename = props.model.fullname.split('/').pop()!
|
||||
return basename.replace(props.model.extension, '')
|
||||
})
|
||||
|
||||
const handleCancel = () => {
|
||||
editable.value = false
|
||||
}
|
||||
|
||||
const handleSave = async (data: BaseModel) => {
|
||||
editable.value = false
|
||||
await update(props.model, data)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
await remove(props.model)
|
||||
}
|
||||
|
||||
const openModelPage = (url: string) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const { addModelNode, copyModelNode, loadPreviewWorkflow } = useModelNodeAction(
|
||||
props.model,
|
||||
)
|
||||
</script>
|
||||
303
src/components/DialogResizer.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div v-if="allowResize" data-dialog-resizer>
|
||||
<div
|
||||
v-if="allow?.x"
|
||||
data-resize-pos="left"
|
||||
class="absolute -left-1 top-0 h-full w-2 cursor-ew-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="allow?.x"
|
||||
data-resize-pos="right"
|
||||
class="absolute -right-1 top-0 h-full w-2 cursor-ew-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="allow?.y"
|
||||
data-resize-pos="top"
|
||||
class="absolute -top-1 left-0 h-2 w-full cursor-ns-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="allow?.y"
|
||||
data-resize-pos="bottom"
|
||||
class="absolute -bottom-1 left-0 h-2 w-full cursor-ns-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
<div
|
||||
v-if="allow?.x && allow?.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="allow?.x && allow?.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="allow?.x && allow?.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="allow?.x && allow?.y"
|
||||
data-resize-pos="bottom-right"
|
||||
class="absolute -bottom-1 -right-1 h-2 w-2 cursor-se-resize"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { clamp } from 'lodash'
|
||||
import { useConfig } from 'hooks/config'
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue'
|
||||
|
||||
type ContainerSize = { width: number; height: number }
|
||||
type ContainerPosition = { left: number; top: number }
|
||||
|
||||
interface ResizableProps {
|
||||
defaultSize?: Partial<ContainerSize>
|
||||
defaultMobileSize?: Partial<ContainerSize>
|
||||
allow?: { x?: boolean; y?: boolean }
|
||||
minWidth?: number
|
||||
maxWidth?: number
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ResizableProps>(), {
|
||||
allow: () => ({ x: true, y: true }),
|
||||
})
|
||||
|
||||
const config = useConfig()
|
||||
const allowResize = computed(() => {
|
||||
return !config.isMobile.value
|
||||
})
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
const resizeDirection = ref<string[]>([])
|
||||
|
||||
const getContainer = () => {
|
||||
return instance!.parent!.vnode.el as HTMLDivElement
|
||||
}
|
||||
|
||||
const minWidth = computed(() => {
|
||||
const defaultMinWidth = 100
|
||||
return props.minWidth ?? defaultMinWidth
|
||||
})
|
||||
|
||||
const maxWidth = computed(() => {
|
||||
const defaultMaxWidth = window.innerWidth
|
||||
return props.maxWidth ?? defaultMaxWidth
|
||||
})
|
||||
|
||||
const minHeight = computed(() => {
|
||||
const defaultMinHeight = 100
|
||||
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(() => {
|
||||
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>
|
||||
17
src/components/FormWrapper.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<form @submit="handleSubmit" @reset="handleReset">
|
||||
<slot name="default"></slot>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emits = defineEmits(['submit', 'reset'])
|
||||
|
||||
const handleReset = () => {
|
||||
emits('reset')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
emits('submit')
|
||||
}
|
||||
</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>
|
||||
90
src/components/ModelBaseInfo.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<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="pi-inputicon">
|
||||
{{ 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" class="h-8 border-b">
|
||||
<td class="border-r bg-gray-300 px-4 dark:bg-gray-800">
|
||||
{{ $t(`info.${item.key}`) }}
|
||||
</td>
|
||||
<td class="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 { useConfig } from 'hooks/config'
|
||||
import { useModelBaseInfo } from 'hooks/model'
|
||||
import { resolveModelType } from 'utils/model'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const editable = defineModel<boolean>('editable')
|
||||
|
||||
const { modelFolders } = useConfig()
|
||||
|
||||
const { baseInfo, pathIndex, basename, extension, type } = useModelBaseInfo()
|
||||
|
||||
const typeOptions = computed(() => {
|
||||
return Object.keys(modelFolders.value).map((curr) => {
|
||||
return {
|
||||
value: curr,
|
||||
label: resolveModelType(curr).display,
|
||||
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) {
|
||||
return row.key !== 'fullname'
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
96
src/components/ModelContent.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<form
|
||||
class="@container"
|
||||
@submit.prevent="handleSubmit"
|
||||
@reset.prevent="handleReset"
|
||||
>
|
||||
<div class="mx-auto w-full max-w-[50rem]">
|
||||
<div class="relative flex flex-col gap-4 overflow-hidden @xl:flex-row">
|
||||
<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 ModelPreview from 'components/ModelPreview.vue'
|
||||
import ModelBaseInfo from 'components/ModelBaseInfo.vue'
|
||||
import ModelDescription from 'components/ModelDescription.vue'
|
||||
import ModelMetadata from 'components/ModelMetadata.vue'
|
||||
import Tab from 'primevue/tab'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import TabList from 'primevue/tablist'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import {
|
||||
useModelBaseInfoEditor,
|
||||
useModelDescriptionEditor,
|
||||
useModelFormData,
|
||||
useModelMetadataEditor,
|
||||
useModelPreviewEditor,
|
||||
} from 'hooks/model'
|
||||
import { toRaw, watch } from 'vue'
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
interface Props {
|
||||
model: BaseModel
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const editable = defineModel<boolean>('editable')
|
||||
|
||||
const emits = defineEmits<{
|
||||
submit: [formData: 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()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
91
src/components/ModelDescription.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<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="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="markdown-it"
|
||||
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>
|
||||
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" 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>
|
||||
112
src/components/ModelPreview.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-4"
|
||||
:style="{ ['--preview-width']: `${cardWidth}px` }"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
:class="[
|
||||
'relative mx-auto w-full',
|
||||
'@sm:w-[var(--preview-width)]',
|
||||
'overflow-hidden rounded-lg preview-aspect',
|
||||
]"
|
||||
>
|
||||
<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="[
|
||||
'flex h-10 items-center gap-4',
|
||||
'absolute left-1/2 -translate-x-1/2',
|
||||
'@xl:left-0 @xl:translate-x-0',
|
||||
]"
|
||||
>
|
||||
<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 ResponseImage from 'components/ResponseImage.vue'
|
||||
import ResponseInput from 'components/ResponseInput.vue'
|
||||
import ResponseFileUpload from 'components/ResponseFileUpload.vue'
|
||||
import Button from 'primevue/button'
|
||||
import Carousel from 'primevue/carousel'
|
||||
import { useModelPreview } from 'hooks/model'
|
||||
import { useConfig } from 'hooks/config'
|
||||
|
||||
const editable = defineModel<boolean>('editable')
|
||||
const { cardWidth } = useConfig()
|
||||
|
||||
const {
|
||||
preview,
|
||||
typeOptions,
|
||||
currentType,
|
||||
defaultContent,
|
||||
defaultContentPage,
|
||||
networkContent,
|
||||
updateLocalContent,
|
||||
noPreviewContent,
|
||||
} = useModelPreview()
|
||||
</script>
|
||||
56
src/components/ResponseFileUpload.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<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">
|
||||
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>
|
||||
82
src/components/ResponseInput.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="p-component p-inputtext flex items-center gap-2">
|
||||
<slot name="prefix">
|
||||
<span v-if="prefixIcon" :class="[prefixIcon, 'pi-inputicon']"></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 pi-inputicon"
|
||||
@click="clearContent"
|
||||
></span>
|
||||
<slot name="suffix">
|
||||
<span v-if="suffixIcon" :class="[suffixIcon, 'pi-inputicon']"></span>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, 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 ?? 'input')
|
||||
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>
|
||||
|
||||
<style>
|
||||
.p-inputtext:focus-within {
|
||||
border-color: var(--p-inputtext-focus-border-color);
|
||||
box-shadow: var(--p-inputtext-focus-ring-shadow);
|
||||
outline: var(--p-inputtext-focus-ring-width)
|
||||
var(--p-inputtext-focus-ring-style) var(--p-inputtext-focus-ring-color);
|
||||
outline-offset: var(--p-inputtext-focus-ring-offset);
|
||||
}
|
||||
|
||||
.p-inputtext .pi-inputicon {
|
||||
font-size: 1rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
214
src/components/ResponseScrollArea.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div data-scroll-area class="group/scroll relative overflow-hidden">
|
||||
<div
|
||||
ref="viewport"
|
||||
data-scroll-viewport
|
||||
class="h-full w-full overflow-auto scrollbar-none"
|
||||
@scroll="onContentScroll"
|
||||
v-resize="onContainerResize"
|
||||
>
|
||||
<div data-scroll-content style="min-width: 100%">
|
||||
<slot name="default"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="scroll in scrollbars"
|
||||
:key="scroll.direction"
|
||||
v-show="scroll.visible"
|
||||
v-bind="{ [`data-scroll-bar-${scroll.direction}`]: '' }"
|
||||
:class="[
|
||||
'pointer-events-none absolute z-auto h-full w-full rounded-full',
|
||||
'data-[scroll-bar-horizontal]:bottom-0 data-[scroll-bar-horizontal]:left-0 data-[scroll-bar-horizontal]:h-2',
|
||||
'data-[scroll-bar-vertical]:right-0 data-[scroll-bar-vertical]:top-0 data-[scroll-bar-vertical]:w-2',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-bind="{ ['data-scroll-thumb']: scroll.direction }"
|
||||
:class="[
|
||||
'pointer-events-auto absolute h-full w-full rounded-full',
|
||||
'cursor-pointer bg-black dark:bg-white',
|
||||
'opacity-0 transition-opacity duration-300 group-hover/scroll:opacity-10',
|
||||
]"
|
||||
:style="{
|
||||
[scrollbarAttrs[scroll.direction].size]: `${scroll.size}px`,
|
||||
[scrollbarAttrs[scroll.direction].offset]: `${scroll.offset}px`,
|
||||
opacity: isDragging ? 0.1 : '',
|
||||
}"
|
||||
@mousedown="startDragThumb"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onUnmounted, ref } from 'vue'
|
||||
import { clamp, throttle } from 'lodash'
|
||||
|
||||
interface ScrollAreaProps {
|
||||
scrollbar?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ScrollAreaProps>(), {
|
||||
scrollbar: true,
|
||||
})
|
||||
const emit = defineEmits(['scroll', 'resize'])
|
||||
|
||||
type ScrollbarDirection = 'horizontal' | 'vertical'
|
||||
|
||||
interface Scrollbar {
|
||||
direction: ScrollbarDirection
|
||||
visible: boolean
|
||||
size: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
interface ScrollbarAttribute {
|
||||
clientSize: string
|
||||
scrollOffset: string
|
||||
pagePosition: string
|
||||
offset: string
|
||||
size: string
|
||||
}
|
||||
|
||||
const scrollbarAttrs: Record<ScrollbarDirection, ScrollbarAttribute> = {
|
||||
horizontal: {
|
||||
clientSize: 'clientWidth',
|
||||
scrollOffset: 'scrollLeft',
|
||||
pagePosition: 'pageX',
|
||||
offset: 'left',
|
||||
size: 'width',
|
||||
},
|
||||
vertical: {
|
||||
clientSize: 'clientHeight',
|
||||
scrollOffset: 'scrollTop',
|
||||
pagePosition: 'pageY',
|
||||
offset: 'top',
|
||||
size: 'height',
|
||||
},
|
||||
}
|
||||
|
||||
const scrollbars = ref<Record<ScrollbarDirection, Scrollbar>>({
|
||||
horizontal: {
|
||||
direction: 'horizontal',
|
||||
visible: props.scrollbar,
|
||||
size: 0,
|
||||
offset: 0,
|
||||
},
|
||||
vertical: {
|
||||
direction: 'vertical',
|
||||
visible: props.scrollbar,
|
||||
size: 0,
|
||||
offset: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const isDragging = ref(false)
|
||||
|
||||
const onContainerResize: ResizeObserverCallback = throttle((entries) => {
|
||||
emit('resize', entries)
|
||||
if (isDragging.value) return
|
||||
|
||||
const entry = entries[0]
|
||||
const container = entry.target as HTMLElement
|
||||
const content = container.querySelector('[data-scroll-content]')!
|
||||
|
||||
const resolveScrollbarSize = (item: Scrollbar, attr: ScrollbarAttribute) => {
|
||||
const containerSize: number = container[attr.clientSize]
|
||||
const contentSize: number = content[attr.clientSize]
|
||||
item.visible = props.scrollbar && contentSize > containerSize
|
||||
item.size = Math.pow(containerSize, 2) / contentSize
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
resolveScrollbarSize(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
|
||||
resolveScrollbarSize(scrollbars.value.vertical, scrollbarAttrs.vertical)
|
||||
})
|
||||
})
|
||||
|
||||
const onContentScroll = throttle((event: Event) => {
|
||||
emit('scroll', event)
|
||||
if (isDragging.value) return
|
||||
|
||||
const container = event.target as HTMLDivElement
|
||||
const content = container.querySelector('[data-scroll-content]')!
|
||||
|
||||
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
|
||||
const containerSize = container[attr.clientSize]
|
||||
const contentSize = content[attr.clientSize]
|
||||
const scrollOffset = container[attr.scrollOffset]
|
||||
|
||||
item.offset =
|
||||
(scrollOffset / (contentSize - containerSize)) *
|
||||
(containerSize - item.size)
|
||||
}
|
||||
|
||||
resolveOffset(scrollbars.value.horizontal, scrollbarAttrs.horizontal)
|
||||
resolveOffset(scrollbars.value.vertical, scrollbarAttrs.vertical)
|
||||
})
|
||||
|
||||
const viewport = ref<HTMLElement>()
|
||||
const draggingDirection = ref<ScrollbarDirection>()
|
||||
const prevDraggingEvent = ref<MouseEvent>()
|
||||
|
||||
const moveThumb = throttle((event: MouseEvent) => {
|
||||
if (isDragging.value) {
|
||||
const container = viewport.value!
|
||||
const content = container.querySelector('[data-scroll-content]')!
|
||||
|
||||
const resolveOffset = (item: Scrollbar, attr: ScrollbarAttribute) => {
|
||||
const containerSize = container[attr.clientSize]
|
||||
const contentSize = content[attr.clientSize]
|
||||
|
||||
// Resolve thumb position
|
||||
const prevPagePos = prevDraggingEvent.value![attr.pagePosition]
|
||||
const currPagePos = event[attr.pagePosition]
|
||||
const offset = currPagePos - prevPagePos
|
||||
item.offset = clamp(item.offset + offset, 0, containerSize - item.size)
|
||||
|
||||
// Resolve scroll position
|
||||
const scrollOffset = containerSize - item.size
|
||||
const offsetSize = contentSize - containerSize
|
||||
|
||||
container[attr.scrollOffset] = (item.offset / scrollOffset) * offsetSize
|
||||
}
|
||||
|
||||
const scrollDirection = draggingDirection.value!
|
||||
|
||||
resolveOffset(
|
||||
scrollbars.value[scrollDirection],
|
||||
scrollbarAttrs[scrollDirection],
|
||||
)
|
||||
prevDraggingEvent.value = event
|
||||
}
|
||||
})
|
||||
|
||||
const stopMoveThumb = () => {
|
||||
isDragging.value = false
|
||||
draggingDirection.value = undefined
|
||||
prevDraggingEvent.value = undefined
|
||||
document.removeEventListener('mousemove', moveThumb)
|
||||
document.removeEventListener('mouseup', stopMoveThumb)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
const startDragThumb = (event: MouseEvent) => {
|
||||
isDragging.value = true
|
||||
const target = event.target as HTMLElement
|
||||
draggingDirection.value = <any>target.getAttribute('data-scroll-thumb')
|
||||
prevDraggingEvent.value = event
|
||||
document.addEventListener('mousemove', moveThumb)
|
||||
document.addEventListener('mouseup', stopMoveThumb)
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'default'
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopMoveThumb()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
viewport,
|
||||
})
|
||||
</script>
|
||||
234
src/components/ResponseSelect.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<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"
|
||||
v-resize="checkScrollPosition"
|
||||
@scroll="checkScrollPosition"
|
||||
>
|
||||
<div ref="contentArea" class="table max-w-full">
|
||||
<div
|
||||
v-show="showControlButton && scrollPosition !== 'left'"
|
||||
:class="[
|
||||
'pointer-events-none absolute left-0 top-1/2 z-10',
|
||||
'-translate-y-1/2 bg-gradient-to-r from-current to-transparent pr-16',
|
||||
]"
|
||||
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 right-0 top-1/2 z-10',
|
||||
'-translate-y-1/2 bg-gradient-to-l from-current to-transparent pl-16',
|
||||
]"
|
||||
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">
|
||||
<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 { useConfig } from 'hooks/config'
|
||||
import Button, { ButtonProps } from 'primevue/button'
|
||||
import Drawer from 'primevue/drawer'
|
||||
import Menu from 'primevue/menu'
|
||||
import { computed, ref } 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()
|
||||
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
|
||||
}
|
||||
</script>
|
||||
69
src/hooks/config.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useRequest } from 'hooks/request'
|
||||
import { defineStore } from 'hooks/store'
|
||||
import { app } from 'scripts/comfyAPI'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
export const useConfig = defineStore('config', () => {
|
||||
const mobileDeviceBreakPoint = 759
|
||||
const isMobile = ref(window.innerWidth < mobileDeviceBreakPoint)
|
||||
|
||||
type ModelFolder = Record<string, string[]>
|
||||
const { data: modelFolders, refresh: refreshModelFolders } =
|
||||
useRequest<ModelFolder>('/base-folders')
|
||||
|
||||
const checkDeviceType = () => {
|
||||
isMobile.value = window.innerWidth < mobileDeviceBreakPoint
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', checkDeviceType)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkDeviceType)
|
||||
})
|
||||
|
||||
const refreshSetting = async () => {
|
||||
return Promise.all([refreshModelFolders()])
|
||||
}
|
||||
|
||||
const config = {
|
||||
isMobile,
|
||||
gutter: 16,
|
||||
cardWidth: 240,
|
||||
aspect: 7 / 9,
|
||||
modelFolders,
|
||||
refreshSetting,
|
||||
}
|
||||
|
||||
useAddConfigSettings(config)
|
||||
|
||||
return config
|
||||
})
|
||||
|
||||
type Config = ReturnType<typeof useConfig>
|
||||
|
||||
declare module 'hooks/store' {
|
||||
interface StoreProvider {
|
||||
config: Config
|
||||
}
|
||||
}
|
||||
|
||||
function useAddConfigSettings(config: Config) {
|
||||
onMounted(() => {
|
||||
// API keys
|
||||
app.ui?.settings.addSetting({
|
||||
id: 'ModelManager.APIKey.HuggingFace',
|
||||
name: 'HuggingFace API Key',
|
||||
type: 'text',
|
||||
defaultValue: undefined,
|
||||
})
|
||||
|
||||
app.ui?.settings.addSetting({
|
||||
id: 'ModelManager.APIKey.Civitai',
|
||||
name: 'Civitai API Key',
|
||||
type: 'text',
|
||||
defaultValue: undefined,
|
||||
})
|
||||
})
|
||||
}
|
||||
423
src/hooks/download.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { useLoading } from 'hooks/loading'
|
||||
import { MarkdownTool, useMarkdown } from 'hooks/markdown'
|
||||
import { socket } from 'hooks/socket'
|
||||
import { defineStore } from 'hooks/store'
|
||||
import { useToast } from 'hooks/toast'
|
||||
import { useBoolean } from 'hooks/utils'
|
||||
import { bytesToSize } from 'utils/common'
|
||||
import { onBeforeMount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export const useDownload = defineStore('download', (store) => {
|
||||
const [visible, toggle] = useBoolean()
|
||||
const { toast, confirm } = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const taskList = ref<DownloadTask[]>([])
|
||||
|
||||
const refresh = () => {
|
||||
socket.send('downloadTaskList', null)
|
||||
}
|
||||
|
||||
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() {
|
||||
socket.send('pauseDownloadTask', item.taskId)
|
||||
},
|
||||
resumeTask: () => {
|
||||
socket.send('resumeDownloadTask', item.taskId)
|
||||
},
|
||||
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: () => {
|
||||
socket.send('deleteDownloadTask', item.taskId)
|
||||
},
|
||||
reject: () => {},
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
socket.addEventListener('reconnected', () => {
|
||||
refresh()
|
||||
})
|
||||
|
||||
socket.addEventListener('downloadTaskList', (event) => {
|
||||
const data = event.detail as DownloadTaskOptions[]
|
||||
|
||||
taskList.value = data.map((item) => {
|
||||
return createTaskItem(item)
|
||||
})
|
||||
})
|
||||
|
||||
socket.addEventListener('createDownloadTask', (event) => {
|
||||
const item = event.detail as DownloadTaskOptions
|
||||
taskList.value.unshift(createTaskItem(item))
|
||||
})
|
||||
|
||||
socket.addEventListener('updateDownloadTask', (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))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
socket.addEventListener('deleteDownloadTask', (event) => {
|
||||
const taskId = event.detail as string
|
||||
taskList.value = taskList.value.filter((item) => item.taskId !== taskId)
|
||||
})
|
||||
|
||||
socket.addEventListener('completeDownloadTask', (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 { visible, toggle, data: taskList, refresh }
|
||||
})
|
||||
|
||||
declare module 'hooks/store' {
|
||||
interface StoreProvider {
|
||||
download: ReturnType<typeof useDownload>
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ModelSearch {
|
||||
constructor(readonly md: MarkdownTool) {}
|
||||
|
||||
abstract search(pathname: string): Promise<VersionModel[]>
|
||||
}
|
||||
|
||||
class Civitai extends ModelSearch {
|
||||
async search(searchUrl: string): Promise<VersionModel[]> {
|
||||
const { pathname, searchParams } = new URL(searchUrl)
|
||||
|
||||
const [, modelId] = pathname.match(/^\/models\/(\d*)/) ?? []
|
||||
const versionId = searchParams.get('modelVersionId')
|
||||
|
||||
if (!modelId) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
return fetch(`https://civitai.com/api/v1/models/${modelId}`)
|
||||
.then((response) => response.json())
|
||||
.then((resData) => {
|
||||
const modelVersions: any[] = resData.modelVersions.filter(
|
||||
(version: any) => {
|
||||
if (versionId) {
|
||||
return version.id == versionId
|
||||
}
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
const models: VersionModel[] = []
|
||||
|
||||
for (const version of modelVersions) {
|
||||
const modelFiles: any[] = version.files.filter(
|
||||
(file: any) => file.type === 'Model',
|
||||
)
|
||||
|
||||
const shortname = modelFiles.length > 0 ? version.name : undefined
|
||||
|
||||
for (const file of modelFiles) {
|
||||
const fullname = file.name
|
||||
const extension = `.${fullname.split('.').pop()}`
|
||||
const basename = fullname.replace(extension, '')
|
||||
|
||||
models.push({
|
||||
id: file.id,
|
||||
shortname: shortname ?? basename,
|
||||
fullname: fullname,
|
||||
basename: basename,
|
||||
extension: extension,
|
||||
preview: version.images.map((i: any) => i.url),
|
||||
sizeBytes: file.sizeKB * 1024,
|
||||
type: this.resolveType(resData.type),
|
||||
pathIndex: 0,
|
||||
description: [
|
||||
'---',
|
||||
`website: Civitai`,
|
||||
``,
|
||||
`modelPage: https://civitai.com/models/${modelId}?modelVersionId=${version.id}`,
|
||||
'---',
|
||||
'',
|
||||
'# Trigger Words',
|
||||
`\n${(version.trainedWords ?? ['No trigger words']).join(', ')}\n`,
|
||||
'# About this version',
|
||||
this.resolveDescription(
|
||||
version.description,
|
||||
'\nNo description about this version\n',
|
||||
),
|
||||
`# ${resData.name}`,
|
||||
this.resolveDescription(
|
||||
resData.description,
|
||||
'No description about this model',
|
||||
),
|
||||
].join('\n'),
|
||||
metadata: file.metadata,
|
||||
downloadPlatform: 'civitai',
|
||||
downloadUrl: file.downloadUrl,
|
||||
hashes: file.hashes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return models
|
||||
})
|
||||
}
|
||||
|
||||
private resolveType(type: string) {
|
||||
const mapLegacy = {
|
||||
TextualInversion: 'embeddings',
|
||||
LoCon: 'loras',
|
||||
DoRA: 'loras',
|
||||
Controlnet: 'controlnet',
|
||||
Upscaler: 'upscale_models',
|
||||
VAE: 'vae',
|
||||
}
|
||||
return mapLegacy[type] ?? `${type.toLowerCase()}s`
|
||||
}
|
||||
|
||||
private resolveDescription(content: string, defaultContent: string) {
|
||||
const mdContent = this.md.parse(content ?? '').trim()
|
||||
return mdContent || defaultContent
|
||||
}
|
||||
}
|
||||
|
||||
class Huggingface extends ModelSearch {
|
||||
async search(searchUrl: string): Promise<VersionModel[]> {
|
||||
const { pathname } = new URL(searchUrl)
|
||||
const [, space, name, ...restPaths] = pathname.split('/')
|
||||
|
||||
if (!space || !name) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
const modelId = `${space}/${name}`
|
||||
const restPathname = restPaths.join('/')
|
||||
|
||||
return fetch(`https://huggingface.co/api/models/${modelId}`)
|
||||
.then((response) => response.json())
|
||||
.then((resData) => {
|
||||
const siblingFiles: string[] = resData.siblings.map(
|
||||
(item: any) => item.rfilename,
|
||||
)
|
||||
|
||||
const modelFiles: string[] = this.filterTreeFiles(
|
||||
this.filterModelFiles(siblingFiles),
|
||||
restPathname,
|
||||
)
|
||||
const images: string[] = this.filterTreeFiles(
|
||||
this.filterImageFiles(siblingFiles),
|
||||
restPathname,
|
||||
).map((filename) => {
|
||||
return `https://huggingface.co/${modelId}/resolve/main/${filename}`
|
||||
})
|
||||
|
||||
const models: VersionModel[] = []
|
||||
|
||||
for (const filename of modelFiles) {
|
||||
const fullname = filename.split('/').pop()!
|
||||
const extension = `.${fullname.split('.').pop()}`
|
||||
const basename = fullname.replace(extension, '')
|
||||
|
||||
models.push({
|
||||
id: filename,
|
||||
shortname: filename,
|
||||
fullname: fullname,
|
||||
basename: basename,
|
||||
extension: extension,
|
||||
preview: images,
|
||||
sizeBytes: 0,
|
||||
type: 'unknown',
|
||||
pathIndex: 0,
|
||||
description: [
|
||||
'---',
|
||||
`website: HuggingFace`,
|
||||
`author: ${resData.author}`,
|
||||
`modelPage: https://huggingface.co/${modelId}`,
|
||||
'---',
|
||||
'',
|
||||
'# Trigger Words',
|
||||
'\nNo trigger words\n',
|
||||
'# About this version',
|
||||
'\nNo description about this version\n',
|
||||
`# ${resData.modelId}`,
|
||||
'\nNo description about this model\n',
|
||||
].join('\n'),
|
||||
metadata: {},
|
||||
downloadPlatform: 'huggingface',
|
||||
downloadUrl: `https://huggingface.co/${modelId}/resolve/main/${filename}?download=true`,
|
||||
})
|
||||
}
|
||||
|
||||
return models
|
||||
})
|
||||
}
|
||||
|
||||
private filterTreeFiles(files: string[], pathname: string) {
|
||||
const [target, , ...paths] = pathname.split('/')
|
||||
|
||||
if (!target) return files
|
||||
|
||||
if (target !== 'tree' && target !== 'blob') return files
|
||||
|
||||
const pathPrefix = paths.join('/')
|
||||
return files.filter((file) => {
|
||||
return file.startsWith(pathPrefix)
|
||||
})
|
||||
}
|
||||
|
||||
private filterModelFiles(files: string[]) {
|
||||
const extension = [
|
||||
'.bin',
|
||||
'.ckpt',
|
||||
'.gguf',
|
||||
'.onnx',
|
||||
'.pt',
|
||||
'.pth',
|
||||
'.safetensors',
|
||||
]
|
||||
return files.filter((file) => {
|
||||
const ext = file.split('.').pop()
|
||||
return ext ? extension.includes(`.${ext}`) : false
|
||||
})
|
||||
}
|
||||
|
||||
private filterImageFiles(files: string[]) {
|
||||
const extension = [
|
||||
'.png',
|
||||
'.webp',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
'.jfif',
|
||||
'.gif',
|
||||
'.apng',
|
||||
]
|
||||
|
||||
return files.filter((file) => {
|
||||
const ext = file.split('.').pop()
|
||||
return ext ? extension.includes(`.${ext}`) : false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class UnknownWebsite extends ModelSearch {
|
||||
async search(searchUrl: string): Promise<VersionModel[]> {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
'Unknown Website, please input a URL from huggingface.co or civitai.com.',
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const useModelSearch = () => {
|
||||
const loading = useLoading()
|
||||
const md = useMarkdown()
|
||||
const { toast } = useToast()
|
||||
const data = ref<(SelectOptions & { item: VersionModel })[]>([])
|
||||
const current = ref<string | number>()
|
||||
|
||||
const handleSearchByUrl = async (url: string) => {
|
||||
if (!url) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
let instance: ModelSearch = new UnknownWebsite(md)
|
||||
|
||||
const { hostname } = new URL(url ?? '')
|
||||
|
||||
if (hostname === 'civitai.com') {
|
||||
instance = new Civitai(md)
|
||||
}
|
||||
|
||||
if (hostname === 'huggingface.co') {
|
||||
instance = new Huggingface(md)
|
||||
}
|
||||
|
||||
loading.show()
|
||||
return instance
|
||||
.search(url)
|
||||
.then((resData) => {
|
||||
data.value = resData.map((item) => ({
|
||||
label: item.shortname,
|
||||
value: item.id,
|
||||
item,
|
||||
command() {
|
||||
current.value = item.id
|
||||
},
|
||||
}))
|
||||
current.value = data.value[0]?.value
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
return { data, current, search: handleSearchByUrl }
|
||||
}
|
||||
55
src/hooks/loading.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineStore } from 'hooks/store'
|
||||
import { useBoolean } from 'hooks/utils'
|
||||
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] = useBoolean()
|
||||
|
||||
globalLoading.bind(loading)
|
||||
|
||||
return { loading }
|
||||
})
|
||||
|
||||
export const useLoading = () => {
|
||||
const timer = ref<NodeJS.Timeout>()
|
||||
|
||||
const show = () => {
|
||||
timer.value = setTimeout(() => {
|
||||
timer.value = undefined
|
||||
globalLoading.show()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value)
|
||||
timer.value = undefined
|
||||
} else {
|
||||
globalLoading.hide()
|
||||
}
|
||||
}
|
||||
|
||||
return { show, hide }
|
||||
}
|
||||
27
src/hooks/manager.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineStore } from 'hooks/store'
|
||||
import { useBoolean } from 'hooks/utils'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export const useDialogManager = defineStore('dialogManager', () => {
|
||||
const [visible, toggle] = useBoolean()
|
||||
|
||||
const mounted = ref(false)
|
||||
const open = ref(false)
|
||||
|
||||
watch(visible, (visible) => {
|
||||
open.value = visible
|
||||
mounted.value = true
|
||||
})
|
||||
|
||||
const updateVisible = (val: boolean) => {
|
||||
visible.value = val
|
||||
}
|
||||
|
||||
return { visible: mounted, open, updateVisible, toggle }
|
||||
})
|
||||
|
||||
declare module 'hooks/store' {
|
||||
interface StoreProvider {
|
||||
dialogManager: ReturnType<typeof useDialogManager>
|
||||
}
|
||||
}
|
||||
49
src/hooks/markdown.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import metadata_block from 'markdown-it-metadata-block'
|
||||
import TurndownService from 'turndown'
|
||||
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)
|
||||
}
|
||||
|
||||
const turndown = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
bulletListMarker: '-',
|
||||
})
|
||||
|
||||
turndown.addRule('paragraph', {
|
||||
filter: 'p',
|
||||
replacement: function (content) {
|
||||
return `\n\n${content}`
|
||||
},
|
||||
})
|
||||
|
||||
return { render: md.render.bind(md), parse: turndown.turndown.bind(turndown) }
|
||||
}
|
||||
|
||||
export type MarkdownTool = ReturnType<typeof useMarkdown>
|
||||
547
src/hooks/model.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
import { useLoading } from 'hooks/loading'
|
||||
import { useMarkdown } from 'hooks/markdown'
|
||||
import { request, useRequest } from 'hooks/request'
|
||||
import { defineStore } from 'hooks/store'
|
||||
import { useToast } from 'hooks/toast'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { app } from 'scripts/comfyAPI'
|
||||
import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common'
|
||||
import { ModelGrid } from 'utils/legacy'
|
||||
import { resolveModelType } from 'utils/model'
|
||||
// import {}
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
InjectionKey,
|
||||
onMounted,
|
||||
provide,
|
||||
ref,
|
||||
toRaw,
|
||||
unref,
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export const useModels = defineStore('models', () => {
|
||||
const { data, refresh } = useRequest<(Model & { visible?: boolean })[]>(
|
||||
'/models',
|
||||
{ defaultValue: [] },
|
||||
)
|
||||
const { toast, confirm } = useToast()
|
||||
const { t } = useI18n()
|
||||
const loading = useLoading()
|
||||
|
||||
const updateModel = async (model: BaseModel, data: BaseModel) => {
|
||||
const formData = new FormData()
|
||||
|
||||
// Check current preview
|
||||
if (model.preview !== data.preview) {
|
||||
const previewFile = await previewUrlToFile(data.preview as string)
|
||||
formData.append('previewFile', previewFile)
|
||||
}
|
||||
|
||||
// Check current description
|
||||
if (model.description !== data.description) {
|
||||
formData.append('description', data.description)
|
||||
}
|
||||
|
||||
// Check current name and pathIndex
|
||||
if (
|
||||
model.fullname !== data.fullname ||
|
||||
model.pathIndex !== data.pathIndex
|
||||
) {
|
||||
formData.append('type', data.type)
|
||||
formData.append('pathIndex', data.pathIndex.toString())
|
||||
formData.append('fullname', data.fullname)
|
||||
}
|
||||
|
||||
if (formData.keys().next().done) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.show()
|
||||
await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to update model',
|
||||
life: 15000,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
loading.hide()
|
||||
})
|
||||
|
||||
await refresh()
|
||||
}
|
||||
|
||||
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: () => {
|
||||
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,
|
||||
})
|
||||
return refresh()
|
||||
})
|
||||
.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: () => {},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return { data, refresh, 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: BaseModel) => void
|
||||
const submitCallback = ref<SubmitCallback[]>([])
|
||||
|
||||
const registerSubmit = (callback: SubmitCallback) => {
|
||||
submitCallback.value.push(callback)
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const data = 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 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
|
||||
}
|
||||
|
||||
const baseInfo = computed(() => {
|
||||
const fields: FieldsItem[] = [
|
||||
{
|
||||
key: 'type',
|
||||
formatter: () => resolveModelType(modelData.value.type).display,
|
||||
},
|
||||
{
|
||||
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,
|
||||
}
|
||||
|
||||
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 Array.isArray(model.value.preview)
|
||||
? model.value.preview
|
||||
: [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 = noPreviewContent.value
|
||||
break
|
||||
}
|
||||
|
||||
return content
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
registerReset(() => {
|
||||
currentType.value = 'default'
|
||||
defaultContentPage.value = 0
|
||||
networkContent.value = undefined
|
||||
localContent.value = undefined
|
||||
})
|
||||
|
||||
registerSubmit((data) => {
|
||||
data.preview = preview.value ?? noPreviewContent.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 = resolveModelType(model.type).loader
|
||||
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 }
|
||||
}
|
||||
22
src/hooks/resize.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { throttle } from 'lodash'
|
||||
import { Directive } from 'vue'
|
||||
|
||||
export const resizeDirective: Directive<HTMLElement, ResizeObserverCallback> = {
|
||||
mounted: (el, binding) => {
|
||||
const callback = binding.value ?? (() => {})
|
||||
const observer = new ResizeObserver(callback)
|
||||
observer.observe(el)
|
||||
el['observer'] = observer
|
||||
},
|
||||
unmounted: (el) => {
|
||||
const observer = el['observer']
|
||||
observer.disconnect()
|
||||
},
|
||||
}
|
||||
|
||||
export const defineResizeCallback = (
|
||||
callback: ResizeObserverCallback,
|
||||
wait?: number,
|
||||
) => {
|
||||
return throttle(callback, wait ?? 100)
|
||||
}
|
||||
82
src/hooks/socket.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { globalToast } from 'hooks/toast'
|
||||
import { readonly } from 'vue'
|
||||
|
||||
class WebSocketEvent extends EventTarget {
|
||||
private socket: WebSocket | null
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.createSocket()
|
||||
}
|
||||
|
||||
private createSocket(isReconnect?: boolean) {
|
||||
const api_host = location.host
|
||||
const api_base = location.pathname.split('/').slice(0, -1).join('/')
|
||||
|
||||
let opened = false
|
||||
let existingSession = window.name
|
||||
if (existingSession) {
|
||||
existingSession = '?clientId=' + existingSession
|
||||
}
|
||||
|
||||
this.socket = readonly(
|
||||
new WebSocket(
|
||||
`ws${window.location.protocol === 'https:' ? 's' : ''}://${api_host}${api_base}/model-manager/ws${existingSession}`,
|
||||
),
|
||||
)
|
||||
|
||||
this.socket.addEventListener('open', () => {
|
||||
opened = true
|
||||
if (isReconnect) {
|
||||
this.dispatchEvent(new CustomEvent('reconnected'))
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.addEventListener('error', () => {
|
||||
if (this.socket) this.socket.close()
|
||||
})
|
||||
|
||||
this.socket.addEventListener('close', (event) => {
|
||||
setTimeout(() => {
|
||||
this.socket = null
|
||||
this.createSocket(true)
|
||||
}, 300)
|
||||
if (opened) {
|
||||
this.dispatchEvent(new CustomEvent('status', { detail: null }))
|
||||
this.dispatchEvent(new CustomEvent('reconnecting'))
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'error') {
|
||||
globalToast.value?.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: msg.data,
|
||||
life: 15000,
|
||||
})
|
||||
} else {
|
||||
this.dispatchEvent(new CustomEvent(msg.type, { detail: msg.data }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
addEventListener = (
|
||||
type: string,
|
||||
callback: CustomEventListener | null,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
) => {
|
||||
super.addEventListener(type, callback, options)
|
||||
}
|
||||
|
||||
send(type: string, data: any) {
|
||||
this.socket?.send(JSON.stringify({ type, detail: data }))
|
||||
}
|
||||
}
|
||||
|
||||
export const socket = new WebSocketEvent()
|
||||
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 Function>(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 }
|
||||
}
|
||||
11
src/hooks/utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useBoolean = (defaultValue?: boolean) => {
|
||||
const target = ref(defaultValue ?? false)
|
||||
|
||||
const toggle = (value?: any) => {
|
||||
target.value = typeof value === 'boolean' ? value : !target.value
|
||||
}
|
||||
|
||||
return [target, toggle] as const
|
||||
}
|
||||
88
src/i18n.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
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',
|
||||
sort: {
|
||||
name: 'Name',
|
||||
size: 'Largest',
|
||||
created: 'Latest created',
|
||||
modified: 'Latest modified',
|
||||
},
|
||||
info: {
|
||||
type: 'Model Type',
|
||||
fullname: 'File Name',
|
||||
sizeBytes: 'File Size',
|
||||
createdAt: 'Created At',
|
||||
updatedAt: 'Updated At',
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
model: '模型',
|
||||
modelManager: '模型管理器',
|
||||
openModelManager: '打开模型管理器',
|
||||
searchModels: '搜索模型',
|
||||
modelCopied: '模型节点已拷贝',
|
||||
download: '下载',
|
||||
downloadList: '下载列表',
|
||||
downloadTask: '下载任务',
|
||||
createDownloadTask: '创建下载任务',
|
||||
parseModelUrl: '解析模型URL',
|
||||
pleaseInputModelUrl: '输入 civitai.com 或 huggingface.co 的 URL',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
delete: '删除',
|
||||
deleteAsk: '确定要删除此{0}?',
|
||||
modelType: '模型类型',
|
||||
default: '默认',
|
||||
network: '网络',
|
||||
local: '本地',
|
||||
none: '无',
|
||||
uploadFile: '上传文件',
|
||||
tapToChange: '点击描述可更改内容',
|
||||
sort: {
|
||||
name: '名称',
|
||||
size: '最大',
|
||||
created: '最新创建',
|
||||
modified: '最新修改',
|
||||
},
|
||||
info: {
|
||||
type: '类型',
|
||||
fullname: '文件名',
|
||||
sizeBytes: '文件大小',
|
||||
createdAt: '创建时间',
|
||||
updatedAt: '更新时间',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale:
|
||||
localStorage.getItem('Comfy.Settings.Comfy.Locale') ||
|
||||
navigator.language.split('-')[0] ||
|
||||
'en',
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
})
|
||||
55
src/main.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { definePreset } from '@primevue/themes'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import { resizeDirective } from 'hooks/resize'
|
||||
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.directive('resize', resizeDirective)
|
||||
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)
|
||||
},
|
||||
})
|
||||
7
src/scripts/comfyAPI.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
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
|
||||
157
src/style.css
Normal file
@@ -0,0 +1,157 @@
|
||||
@layer primevue, tailwind-utilities;
|
||||
|
||||
@layer tailwind-utilities {
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--tw-border-spacing-x: 0;
|
||||
--tw-border-spacing-y: 0;
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-rotate: 0;
|
||||
--tw-skew-x: 0;
|
||||
--tw-skew-y: 0;
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
--tw-pan-x: ;
|
||||
--tw-pan-y: ;
|
||||
--tw-pinch-zoom: ;
|
||||
--tw-scroll-snap-strictness: proximity;
|
||||
--tw-gradient-from-position: ;
|
||||
--tw-gradient-via-position: ;
|
||||
--tw-gradient-to-position: ;
|
||||
--tw-ordinal: ;
|
||||
--tw-slashed-zero: ;
|
||||
--tw-numeric-figure: ;
|
||||
--tw-numeric-spacing: ;
|
||||
--tw-numeric-fraction: ;
|
||||
--tw-ring-inset: ;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-color: rgb(59 130 246 / 0.5);
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-colored: 0 0 #0000;
|
||||
--tw-blur: ;
|
||||
--tw-brightness: ;
|
||||
--tw-contrast: ;
|
||||
--tw-grayscale: ;
|
||||
--tw-hue-rotate: ;
|
||||
--tw-invert: ;
|
||||
--tw-saturate: ;
|
||||
--tw-sepia: ;
|
||||
--tw-drop-shadow: ;
|
||||
--tw-backdrop-blur: ;
|
||||
--tw-backdrop-brightness: ;
|
||||
--tw-backdrop-contrast: ;
|
||||
--tw-backdrop-grayscale: ;
|
||||
--tw-backdrop-hue-rotate: ;
|
||||
--tw-backdrop-invert: ;
|
||||
--tw-backdrop-opacity: ;
|
||||
--tw-backdrop-saturate: ;
|
||||
--tw-backdrop-sepia: ;
|
||||
--tw-contain-size: ;
|
||||
--tw-contain-layout: ;
|
||||
--tw-contain-paint: ;
|
||||
--tw-contain-style: ;
|
||||
}
|
||||
|
||||
*.border,
|
||||
*.border-x,
|
||||
*.border-y,
|
||||
*.border-l,
|
||||
*.border-t,
|
||||
*.border-r,
|
||||
*.border-b {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
table,
|
||||
th,
|
||||
tr,
|
||||
td {
|
||||
border-width: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.comfy-modal {
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
.markdown-it {
|
||||
font-family: theme('fontFamily.sans');
|
||||
line-height: theme('lineHeight.relaxed');
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
|
||||
h1 {
|
||||
font-size: theme('fontSize.2xl');
|
||||
font-weight: theme('fontWeight.bold');
|
||||
border-bottom: 1px solid #ddd;
|
||||
margin-top: theme('margin.4');
|
||||
margin-bottom: theme('margin.4');
|
||||
padding-bottom: theme('padding[2.5]');
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: theme('fontSize.xl');
|
||||
font-weight: theme('fontWeight.bold');
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: theme('fontSize.lg');
|
||||
}
|
||||
|
||||
a {
|
||||
color: #1e8bc3;
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
p img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 1em 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 5px solid #ddd;
|
||||
padding: 10px 20px;
|
||||
margin: 1.5em 0;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
background: #f9f9f9;
|
||||
padding: 3px 5px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
272
src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,272 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
addSetting: (params: SettingParams) => { value: any }
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
11
src/types/shims.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
export {}
|
||||
|
||||
declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
vResize: (typeof import('hooks/resize'))['resizeDirective']
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'hooks/store' {
|
||||
interface StoreProvider {}
|
||||
}
|
||||
69
src/types/typings.d.ts
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
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>
|
||||
}
|
||||
|
||||
interface Model extends BaseModel {
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface VersionModel extends BaseModel {
|
||||
shortname: string
|
||||
downloadPlatform: string
|
||||
downloadUrl: string
|
||||
hashes?: Record<string, string>
|
||||
}
|
||||
|
||||
type PassThrough<T = void> = T | object | undefined
|
||||
|
||||
interface SelectOptions {
|
||||
label: string
|
||||
value: any
|
||||
icon?: string
|
||||
command: () => void
|
||||
}
|
||||
|
||||
interface SelectFile extends File {
|
||||
objectURL: string
|
||||
}
|
||||
|
||||
interface SelectEvent {
|
||||
files: SelectFile[]
|
||||
originalEvent: Event
|
||||
}
|
||||
|
||||
interface DownloadTaskOptions {
|
||||
taskId: string
|
||||
type: string
|
||||
fullname: string
|
||||
preview: string
|
||||
status: 'pause' | 'waiting' | 'doing'
|
||||
progress: number
|
||||
downloadedSize: number
|
||||
totalSize: number
|
||||
bps: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface DownloadTask
|
||||
extends Omit<
|
||||
DownloadTaskOptions,
|
||||
'downloadedSize' | 'totalSize' | 'bps' | 'error'
|
||||
> {
|
||||
downloadProgress: string
|
||||
downloadSpeed: string
|
||||
pauseTask: () => void
|
||||
resumeTask: () => void
|
||||
deleteTask: () => void
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
620
src/utils/legacy.ts
Normal file
@@ -0,0 +1,620 @@
|
||||
// @ts-nocheck
|
||||
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)
|
||||
}
|
||||
}
|
||||
45
src/utils/model.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
const loader = {
|
||||
checkpoints: 'CheckpointLoaderSimple',
|
||||
clip: 'CLIPLoader',
|
||||
clip_vision: 'CLIPVisionLoader',
|
||||
controlnet: 'ControlNetLoader',
|
||||
diffusers: 'DiffusersLoader',
|
||||
diffusion_models: 'DiffusersLoader',
|
||||
embeddings: 'Embedding',
|
||||
gligen: 'GLIGENLoader',
|
||||
hypernetworks: 'HypernetworkLoader',
|
||||
photomaker: 'PhotoMakerLoader',
|
||||
loras: 'LoraLoader',
|
||||
style_models: 'StyleModelLoader',
|
||||
unet: 'UNETLoader',
|
||||
upscale_models: 'UpscaleModelLoader',
|
||||
vae: 'VAELoader',
|
||||
vae_approx: undefined,
|
||||
}
|
||||
|
||||
const display = {
|
||||
all: 'ALL',
|
||||
checkpoints: 'Checkpoint',
|
||||
clip: 'Clip',
|
||||
clip_vision: 'Clip Vision',
|
||||
controlnet: 'Controlnet',
|
||||
diffusers: 'Diffusers',
|
||||
diffusion_models: 'Diffusers',
|
||||
embeddings: 'embedding',
|
||||
gligen: 'Gligen',
|
||||
hypernetworks: 'Hypernetwork',
|
||||
photomaker: 'Photomaker',
|
||||
loras: 'LoRA',
|
||||
style_models: 'Style Model',
|
||||
unet: 'Unet',
|
||||
upscale_models: 'Upscale Model',
|
||||
vae: 'VAE',
|
||||
vae_approx: 'VAE approx',
|
||||
}
|
||||
|
||||
export const resolveModelType = (type: string) => {
|
||||
return {
|
||||
display: display[type],
|
||||
loader: loader[type],
|
||||
}
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,8 +1,213 @@
|
||||
import container from '@tailwindcss/container-queries'
|
||||
import plugin from 'tailwindcss/plugin'
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [],
|
||||
theme: {
|
||||
extend: {},
|
||||
content: ['index.html', './src/**/*.vue'],
|
||||
|
||||
darkMode: ['selector', '.dark-theme'],
|
||||
|
||||
plugins: [
|
||||
container,
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
@@ -24,7 +24,14 @@
|
||||
"allowJs": true,
|
||||
"baseUrl": ".",
|
||||
"outDir": "./web",
|
||||
"rootDir": "./"
|
||||
"rootDir": "./",
|
||||
"paths": {
|
||||
"components/*": ["src/components/*"],
|
||||
"hooks/*": ["src/hooks/*"],
|
||||
"scripts/*": ["src/scripts/*"],
|
||||
"types/*": ["src/types/*"],
|
||||
"utils/*": ["src/utils/*"],
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
|
||||
112
vite.config.ts
@@ -1,8 +1,93 @@
|
||||
import { defineConfig } from 'vite'
|
||||
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://127.0.0.1:${port}/src/main.ts";`
|
||||
fs.writeFileSync(path.join(outDirPath, 'manager-dev.js'), content)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [vue(), css(), output(), dev()],
|
||||
|
||||
build: {
|
||||
outDir: 'web',
|
||||
@@ -13,6 +98,25 @@ export default defineConfig({
|
||||
// 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'),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -23,3 +127,7 @@ export default defineConfig({
|
||||
minifyWhitespace: true,
|
||||
},
|
||||
})
|
||||
|
||||
function resolvePath(str: string) {
|
||||
return path.resolve(__dirname, str)
|
||||
}
|
||||
|
||||