diff --git a/eslint.config.mjs b/eslint.config.mjs
index 1b6120ae1..403755e76 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -102,6 +102,7 @@ const jsConfig = defineConfig([
idbPut: 'readonly',
idbDel: 'readonly',
idbAdd: 'readonly',
+ initTableSorter: 'readonly',
idbCount: 'readonly',
idbFolderCleanup: 'readonly',
idbClearAll: 'readonly',
diff --git a/javascript/script.js b/javascript/script.js
index c4428584e..0ac80487c 100644
--- a/javascript/script.js
+++ b/javascript/script.js
@@ -179,6 +179,92 @@ document.addEventListener('keydown', (e) => {
}
});
+function getSortableCellValue(cell, sortType) {
+ const rawValue = cell?.dataset?.sortValue ?? cell?.textContent?.trim() ?? '';
+ if (sortType === 'number') {
+ const numericValue = Number.parseFloat(rawValue);
+ return Number.isNaN(numericValue) ? Number.NEGATIVE_INFINITY : numericValue;
+ }
+ return rawValue.toLowerCase();
+}
+
+function sortModelListTable(table, columnIndex, sortType, sortOrder) {
+ const tbody = table.querySelector('tbody');
+ if (!tbody) return;
+ const rows = Array.from(tbody.querySelectorAll('tr'));
+ const direction = sortOrder === 'desc' ? -1 : 1;
+ const sortedRows = rows
+ .map((row, index) => ({ row, index }))
+ .sort((a, b) => {
+ const aCell = a.row.children[columnIndex];
+ const bCell = b.row.children[columnIndex];
+ const aValue = getSortableCellValue(aCell, sortType);
+ const bValue = getSortableCellValue(bCell, sortType);
+ if (aValue < bValue) return -1 * direction;
+ if (aValue > bValue) return 1 * direction;
+ return a.index - b.index;
+ });
+ tbody.replaceChildren(...sortedRows.map((item) => item.row));
+}
+
+function applySortIndicators(table, activeHeader, sortOrder) {
+ const headers = table.querySelectorAll('th.sortable');
+ for (const header of headers) {
+ header.classList.remove('sorted-asc', 'sorted-desc');
+ header.removeAttribute('aria-sort');
+ }
+ activeHeader.classList.add(sortOrder === 'desc' ? 'sorted-desc' : 'sorted-asc');
+ activeHeader.setAttribute('aria-sort', sortOrder === 'desc' ? 'descending' : 'ascending');
+}
+
+async function initTableSorter() {
+ const t0 = performance.now();
+ const root = gradioApp();
+ for (const table of root.querySelectorAll('table[data-sortable="true"]')) {
+ if (!table || table.dataset.sortBound === 'true') return;
+ const headers = Array.from(table.querySelectorAll('th.sortable'));
+ if (headers.length === 0) return;
+
+ for (const [index, header] of headers.entries()) {
+ header.style.cursor = 'pointer';
+ header.addEventListener('click', () => {
+ const isCurrentHeader = table.dataset.sortKey === header.dataset.sortKey;
+ const nextOrder = isCurrentHeader && table.dataset.sortOrder === 'asc' ? 'desc' : 'asc';
+ table.dataset.sortKey = header.dataset.sortKey;
+ table.dataset.sortOrder = nextOrder;
+ sortModelListTable(table, index, header.dataset.sortType || 'text', nextOrder);
+ applySortIndicators(table, header, nextOrder);
+ });
+ }
+
+ const defaultSortKey = table.dataset.defaultSortKey || 'name';
+ const defaultSortOrder = table.dataset.defaultSortOrder || 'asc';
+ const defaultHeader = headers.find((header) => header.dataset.sortKey === defaultSortKey) || headers[0];
+ const defaultIndex = headers.indexOf(defaultHeader);
+ table.dataset.sortKey = defaultHeader.dataset.sortKey;
+ table.dataset.sortOrder = defaultSortOrder;
+ sortModelListTable(table, defaultIndex, defaultHeader.dataset.sortType || 'text', defaultSortOrder);
+ applySortIndicators(table, defaultHeader, defaultSortOrder);
+ table.dataset.sortBound = 'true';
+ }
+ onUiUpdate(initTableSorter);
+ const t1 = performance.now();
+ log('initTableSorter', Math.round(t1 - t0));
+ timer('initTableSorter', t1 - t0);
+}
+
+async function deleteFile(filename) {
+ if (!filename) return;
+ if (!confirm(`Are you sure you want to delete the object? This action cannot be undone. ${filename}`)) return; // eslint-disable-line no-alert
+ const res = await authFetch(`${window.api}/delete-file?file=${encodeURIComponent(filename)}`);
+ if (!res || res.status !== 200) {
+ error('FileDelete', { file: filename, status: res?.status, statusText: res?.statusText });
+ return;
+ }
+ const data = await res.json();
+ log('FileDelete', data);
+}
+
/**
* checks that a UI element is not in another hidden element or tab content
*/
diff --git a/javascript/sdnext.css b/javascript/sdnext.css
index e06ed40e2..1bf73a8cd 100644
--- a/javascript/sdnext.css
+++ b/javascript/sdnext.css
@@ -2193,6 +2193,30 @@ div:has(>#tab-gallery-folders) {
background-color: var(--button-primary-border-color) !important;
}
+.simple-table th.sortable {
+ cursor: pointer;
+ user-select: none;
+ position: relative;
+ padding-right: 1.2em;
+}
+
+.simple-table th.sortable::after {
+ content: '↕';
+ position: absolute;
+ right: 0.3em;
+ opacity: 0.55;
+}
+
+.simple-table th.sortable.sorted-asc::after {
+ content: '↑';
+ opacity: 1;
+}
+
+.simple-table th.sortable.sorted-desc::after {
+ content: '↓';
+ opacity: 1;
+}
+
.simple-table tr:nth-child(odd) {
background-color: var(--neutral-900);
}
diff --git a/javascript/startup.js b/javascript/startup.js
index 1a9751523..45411b2d0 100644
--- a/javascript/startup.js
+++ b/javascript/startup.js
@@ -50,6 +50,7 @@ async function initStartup() {
startupPromises.push(initAccordions());
startupPromises.push(initSettings());
startupPromises.push(initImageViewer());
+ startupPromises.push(initGallery());
startupPromises.push(initiGenerationParams());
startupPromises.push(initChangelog());
startupPromises.push(setupControlUI());
@@ -80,6 +81,7 @@ async function initStartup() {
startupPromises.push(applyStyles());
startupPromises.push(initIndexDB());
startupPromises.push(initLogMonitor());
+ startupPromises.push(initTableSorter());
t1 = performance.now();
log('initStartup', Math.round(1000 * (t1 - t0) / 1000000));
diff --git a/modules/api/api.py b/modules/api/api.py
index 93c5bd213..0eb6c30e9 100644
--- a/modules/api/api.py
+++ b/modules/api/api.py
@@ -95,6 +95,7 @@ class Api:
# functional api
self.add_api_route("/sdapi/v1/file", endpoints.get_file, methods=["GET"], tags=["Functional"])
self.add_api_route("/sdapi/v1/delete-image", endpoints.get_deleteimage, methods=["GET"], tags=["Functional"])
+ self.add_api_route("/sdapi/v1/delete-file", endpoints.get_deletefile, methods=["GET"], tags=["Functional"])
self.add_api_route("/sdapi/v1/png-info", endpoints.get_pnginfo, methods=["GET"], response_model=models.ResImageInfo, tags=["Functional"])
self.add_api_route("/sdapi/v1/png-info", endpoints.post_pnginfo, methods=["POST"], response_model=models.ResImageInfo, tags=["Functional"])
self.add_api_route("/sdapi/v1/checkpoint", endpoints.get_checkpoint, methods=["GET"], tags=["Functional"])
diff --git a/modules/api/endpoints.py b/modules/api/endpoints.py
index 3e83e836e..88bb14f24 100644
--- a/modules/api/endpoints.py
+++ b/modules/api/endpoints.py
@@ -1,4 +1,6 @@
+from fastapi.exceptions import HTTPException
from modules import shared
+from modules.logger import log
from modules.api import models, helpers
@@ -327,7 +329,6 @@ def get_file(file: str):
import os
from pathlib import Path
from starlette.responses import FileResponse
- from fastapi.exceptions import HTTPException
allowed_dirs = shared.demo.allowed_paths
if not file.strip():
raise HTTPException(status_code=400, detail="file path is required")
@@ -339,10 +340,31 @@ def get_file(file: str):
raise HTTPException(status_code=403, detail=f"file {file}: is a directory")
return FileResponse(file, media_type='application/octet-stream', filename=file)
+def get_deletefile(file: str):
+ import os
+ from pathlib import Path
+ allowed_dirs = shared.demo.allowed_paths
+ if not file.strip():
+ raise HTTPException(status_code=400, detail="file path is required")
+ if not any(Path(folder).absolute() in Path(file).absolute().parents for folder in allowed_dirs):
+ raise HTTPException(status_code=403, detail=f"file {file}: must be in one of allowed directories")
+ if not os.path.exists(file):
+ raise HTTPException(status_code=404, detail=f"file not found: {file}")
+ try:
+ if os.path.isdir(file):
+ log.warning(f'Delete: folder="{file}"')
+ import shutil
+ shutil.rmtree(file)
+ else:
+ log.warning(f'Delete: file="{file}"')
+ os.remove(file)
+ return {"deleted": f"{file}"}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"error deleting file {file}: {str(e)}") from e
+
def get_deleteimage(file: str):
import os
from pathlib import Path
- from fastapi.exceptions import HTTPException
allowed_dirs = shared.demo.allowed_paths
if not file.strip():
raise HTTPException(status_code=400, detail="file path is required")
@@ -356,6 +378,7 @@ def get_deleteimage(file: str):
raise HTTPException(status_code=403, detail=f"file {file}: not an image file")
try:
os.remove(file)
+ log.warning(f'Delete: image="{file}"')
return {"deleted": f"{file}"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"error deleting file {file}: {str(e)}") from e
@@ -365,7 +388,6 @@ def get_pnginfo(file: str):
import os
from pathlib import Path
from PIL import Image
- from fastapi.exceptions import HTTPException
from modules import images, infotext
allowed_dirs = shared.demo.allowed_paths
if not file.strip():
diff --git a/modules/sd_checkpoint.py b/modules/sd_checkpoint.py
index 086010b9b..ce702888d 100644
--- a/modules/sd_checkpoint.py
+++ b/modules/sd_checkpoint.py
@@ -45,6 +45,8 @@ class CheckpointInfo:
relname = rel(filename, shared.opts.ckpt_dir)
elif relname.startswith(shared.opts.diffusers_dir):
relname = rel(filename, shared.opts.diffusers_dir)
+ elif relname.startswith(shared.opts.hfcache_dir):
+ relname = rel(filename, shared.opts.hfcache_dir)
elif relname.startswith(model_path):
relname = rel(filename, model_path)
elif relname.startswith(paths.script_path):
diff --git a/modules/ui_common.py b/modules/ui_common.py
index b8b03d0a4..a0dcf926b 100644
--- a/modules/ui_common.py
+++ b/modules/ui_common.py
@@ -97,14 +97,14 @@ def delete_files(js_data, files, all_files, index):
os.remove(fn)
if fn in all_files:
all_files.remove(fn)
- log.info(f'Delete: image="{fn}"')
+ log.warning(f'Delete: image="{fn}"')
else:
log.warning(f'Delete: image="{fn}" ui mismatch')
base, _ext = os.path.splitext(fn)
desc = f'{base}.txt'
if os.path.exists(desc) and os.path.isfile(desc):
os.remove(desc)
- log.info(f'Delete: text="{fn}"')
+ log.warning(f'Delete: text="{fn}"')
except Exception as e:
log.error(f'Delete: file="{fn}" {e}')
deleted = ', '.join(deleted) if len(deleted) > 0 else 'none'
diff --git a/modules/ui_models.py b/modules/ui_models.py
index f02bfb9b3..5d216c5c7 100644
--- a/modules/ui_models.py
+++ b/modules/ui_models.py
@@ -1,5 +1,6 @@
import os
import inspect
+from html import escape
from typing import cast
import gradio as gr
from modules import errors, sd_models, sd_vae, extras, sd_samplers, ui_symbols, modelstats
@@ -12,6 +13,16 @@ from modules.shared import opts, log
extra_ui = []
+def get_folder_size(folder):
+ total_size = 0
+ for dirpath, _dirnames, filenames in os.walk(folder):
+ for f in filenames:
+ fp = os.path.join(dirpath, f)
+ if os.path.isfile(fp):
+ total_size += os.path.getsize(fp)
+ return round(total_size / 1024 / 1024 / 1024, 3)
+
+
def update_model_hashes():
from modules import sd_unet, sd_checkpoint
unets = {}
@@ -21,6 +32,69 @@ def update_model_hashes():
yield from sd_models.update_model_hashes(model_type='checkpoint')
+def create_models_table(rows: list = []):
+ from modules import sd_detect
+ html = """
+
+
+
+ | Name |
+ Family |
+ Type |
+ Pipeline |
+ Size |
+ MTime |
+ Hash |
+ |
+
+
+
+ {tbody}
+
+
+ """
+ tbody = ''
+ for row in rows:
+ try:
+ f = row.filename
+ stat_size, stat_mtime = modelstats.stat(f)
+ if os.path.isfile(f):
+ typ = os.path.splitext(f)[1][1:]
+ size = round(stat_size / 1024 / 1024 / 1024, 3)
+ elif os.path.isdir(f):
+ typ = 'diffusers'
+ size = get_folder_size(f)
+ else:
+ typ = 'unknown'
+ size = 0
+ guess = 'Stable Diffusion' # set default guess
+ guess = sd_detect.guess_by_size(f, guess)
+ guess = sd_detect.guess_by_name(f, guess)
+ guess, pipeline = sd_detect.guess_by_diffusers(f, guess)
+ guess = sd_detect.guess_variant(f, guess)
+ pipeline = sd_detect.shared_items.get_pipelines().get(guess, None) if pipeline is None else pipeline
+ model_name = escape(str(row.model_name))
+ pipeline_name = escape(pipeline.__name__ if pipeline else '(unknown)')
+ typ_name = escape(str(typ))
+ guess_name = escape(str(guess))
+ hash_name = escape(str(row.shorthash))
+ mtime_sort = stat_mtime.timestamp() if hasattr(stat_mtime, 'timestamp') else 0
+ tbody += f"""
+
+ | {model_name} |
+ {typ_name} |
+ {guess_name} |
+ {pipeline_name} |
+ {size:.3f} GB |
+ {stat_mtime} |
+ {hash_name} |
+ \uf530 |
+
+ """
+ except Exception as e:
+ log.error(f'Model list: row={vars(row)} {e}')
+ return html.format(tbody=tbody)
+
def create_ui():
log.debug('UI initialize: tab=models')
dummy_component = gr.Label(visible=False)
@@ -31,7 +105,7 @@ def create_ui():
with gr.Column(elem_id='models_input_container', scale=3):
- with gr.Tab(label="Current", elem_id="models_current_tab"):
+ with gr.Tab(label="Active Model", elem_id="models_current_tab"):
def create_modules_table(rows: list):
html = """
@@ -96,61 +170,14 @@ def create_ui():
model_analyze.click(fn=analyze, inputs=[], outputs=[model_desc, model_meta])
- with gr.Tab(label="List", elem_id="models_list_tab"):
- def create_models_table(rows: list):
- from modules import sd_detect
- html = """
-
-
- | Name | Type | Detect | Pipeline | Hash | Size | MTime |
-
-
- {tbody}
-
-
- """
- tbody = ''
- for row in rows:
- try:
- f = row.filename
- stat_size, stat_mtime = modelstats.stat(f)
- if os.path.isfile(f):
- typ = os.path.splitext(f)[1][1:]
- size = f"{round(stat_size / 1024 / 1024 / 1024, 3)} gb"
- elif os.path.isdir(f):
- typ = 'diffusers'
- size = 'folder'
- else:
- typ = 'unknown'
- size = 'unknown'
- guess = 'Diffusion' # set default guess
- guess = sd_detect.guess_by_size(f, guess)
- guess = sd_detect.guess_by_name(f, guess)
- guess, pipeline = sd_detect.guess_by_diffusers(f, guess)
- guess = sd_detect.guess_variant(f, guess)
- pipeline = sd_detect.shared_items.get_pipelines().get(guess, None) if pipeline is None else pipeline
- tbody += f"""
-
- | {row.model_name} |
- {typ} |
- {guess} |
- {pipeline.__name__ if pipeline else '(unknown)'} |
- {row.shorthash} |
- {size} |
- {stat_mtime} |
-
- """
- except Exception as e:
- log.error(f'Model list: row={vars(row)} {e}')
- return html.format(tbody=tbody)
-
+ with gr.Tab(label="Models List", elem_id="models_list_tab"):
with gr.Row():
gr.HTML('List all locally available models
')
with gr.Row():
- model_list_btn = gr.Button(value="List models", variant='primary')
- model_checkhash_btn = gr.Button(value="Calculate missing hashes", variant='secondary')
+ model_list_btn = gr.Button(value="Refresh list", variant='primary')
+ model_checkhash_btn = gr.Button(value="Calculate hashes", variant='secondary')
with gr.Row():
- model_table = gr.HTML(value='', elem_id="model_list_table")
+ model_table = gr.HTML(value=create_models_table(), elem_id="model_list_table")
model_checkhash_btn.click(fn=update_model_hashes, inputs=[], outputs=[model_table])
model_list_btn.click(fn=lambda: create_models_table(list(sd_models.checkpoints_list.values())), inputs=[], outputs=[model_table])