From f1b6e82ccffb961cb16101077b07208641875a47 Mon Sep 17 00:00:00 2001 From: Vladimir Mandic Date: Fri, 8 May 2026 12:16:21 +0200 Subject: [PATCH] models table improvements Co-authored-by: Copilot Signed-off-by: Vladimir Mandic --- eslint.config.mjs | 1 + javascript/script.js | 86 +++++++++++++++++++++++++ javascript/sdnext.css | 24 +++++++ javascript/startup.js | 2 + modules/api/api.py | 1 + modules/api/endpoints.py | 28 ++++++++- modules/sd_checkpoint.py | 2 + modules/ui_common.py | 4 +- modules/ui_models.py | 131 +++++++++++++++++++++++---------------- 9 files changed, 222 insertions(+), 57 deletions(-) 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 = """ + + + + + + + + + + + + + + + {tbody} + +
NameFamilyTypePipelineSizeMTimeHash
+ """ + 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 = """ -
- - - - - {tbody} - -
NameTypeDetectPipelineHashSizeMTime
- """ - 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])