From e713bd1ca280276ef29e32c837061f468192eb9d Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Fri, 12 Sep 2025 11:22:32 +0800
Subject: [PATCH 001/110] feat: add app-level proxy settings with UI
integration and session management, fixes #382
---
locales/de.json | 21 ++-
locales/en.json | 21 ++-
locales/es.json | 21 ++-
locales/fr.json | 21 ++-
locales/ja.json | 21 ++-
locales/ko.json | 21 ++-
locales/ru.json | 21 ++-
locales/zh-CN.json | 21 ++-
locales/zh-TW.json | 21 ++-
py/lora_manager.py | 3 -
py/routes/misc_routes.py | 18 ++-
py/services/downloader.py | 73 ++++++++++-
py/services/settings_manager.py | 15 ++-
.../css/components/modal/settings-modal.css | 55 +++++++-
static/js/managers/SettingsManager.js | 120 ++++++++++++++---
.../components/modals/settings_modal.html | 124 ++++++++++++++++++
16 files changed, 556 insertions(+), 41 deletions(-)
diff --git a/locales/de.json b/locales/de.json
index 49596c76..f0125334 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -181,7 +181,8 @@
"downloadPathTemplates": "Download-Pfad-Vorlagen",
"exampleImages": "Beispielbilder",
"misc": "Verschiedenes",
- "metadataArchive": "Metadaten-Archiv-Datenbank"
+ "metadataArchive": "Metadaten-Archiv-Datenbank",
+ "proxySettings": "Proxy-Einstellungen"
},
"contentFiltering": {
"blurNsfwContent": "NSFW-Inhalte unscharf stellen",
@@ -300,6 +301,24 @@
"connecting": "Verbindung zum Download-Server wird hergestellt...",
"completed": "Abgeschlossen",
"downloadComplete": "Download erfolgreich abgeschlossen"
+ },
+ "proxySettings": {
+ "enableProxy": "App-Proxy aktivieren",
+ "enableProxyHelp": "Aktivieren Sie benutzerdefinierte Proxy-Einstellungen für diese Anwendung. Überschreibt die System-Proxy-Einstellungen.",
+ "proxyType": "Proxy-Typ",
+ "proxyTypeHelp": "Wählen Sie den Typ des Proxy-Servers (HTTP, HTTPS, SOCKS4, SOCKS5)",
+ "proxyHost": "Proxy-Host",
+ "proxyHostPlaceholder": "proxy.beispiel.de",
+ "proxyHostHelp": "Der Hostname oder die IP-Adresse Ihres Proxy-Servers",
+ "proxyPort": "Proxy-Port",
+ "proxyPortPlaceholder": "8080",
+ "proxyPortHelp": "Die Portnummer Ihres Proxy-Servers",
+ "proxyUsername": "Benutzername (optional)",
+ "proxyUsernamePlaceholder": "benutzername",
+ "proxyUsernameHelp": "Benutzername für die Proxy-Authentifizierung (falls erforderlich)",
+ "proxyPassword": "Passwort (optional)",
+ "proxyPasswordPlaceholder": "passwort",
+ "proxyPasswordHelp": "Passwort für die Proxy-Authentifizierung (falls erforderlich)"
}
},
"loras": {
diff --git a/locales/en.json b/locales/en.json
index 58053fca..24e4704e 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -181,7 +181,8 @@
"downloadPathTemplates": "Download Path Templates",
"exampleImages": "Example Images",
"misc": "Misc.",
- "metadataArchive": "Metadata Archive Database"
+ "metadataArchive": "Metadata Archive Database",
+ "proxySettings": "Proxy Settings"
},
"contentFiltering": {
"blurNsfwContent": "Blur NSFW Content",
@@ -300,6 +301,24 @@
"connecting": "Connecting to download server...",
"completed": "Completed",
"downloadComplete": "Download completed successfully"
+ },
+ "proxySettings": {
+ "enableProxy": "Enable App-level Proxy",
+ "enableProxyHelp": "Enable custom proxy settings for this application, overriding system proxy settings",
+ "proxyType": "Proxy Type",
+ "proxyTypeHelp": "Select the type of proxy server (HTTP, HTTPS, SOCKS4, SOCKS5)",
+ "proxyHost": "Proxy Host",
+ "proxyHostPlaceholder": "proxy.example.com",
+ "proxyHostHelp": "The hostname or IP address of your proxy server",
+ "proxyPort": "Proxy Port",
+ "proxyPortPlaceholder": "8080",
+ "proxyPortHelp": "The port number of your proxy server",
+ "proxyUsername": "Username (Optional)",
+ "proxyUsernamePlaceholder": "username",
+ "proxyUsernameHelp": "Username for proxy authentication (if required)",
+ "proxyPassword": "Password (Optional)",
+ "proxyPasswordPlaceholder": "password",
+ "proxyPasswordHelp": "Password for proxy authentication (if required)"
}
},
"loras": {
diff --git a/locales/es.json b/locales/es.json
index 9072722c..a3e04cd8 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -181,7 +181,8 @@
"downloadPathTemplates": "Plantillas de rutas de descarga",
"exampleImages": "Imágenes de ejemplo",
"misc": "Varios",
- "metadataArchive": "Base de datos de archivo de metadatos"
+ "metadataArchive": "Base de datos de archivo de metadatos",
+ "proxySettings": "Configuración de proxy"
},
"contentFiltering": {
"blurNsfwContent": "Difuminar contenido NSFW",
@@ -300,6 +301,24 @@
"connecting": "Conectando al servidor de descarga...",
"completed": "Completado",
"downloadComplete": "Descarga completada exitosamente"
+ },
+ "proxySettings": {
+ "enableProxy": "Habilitar proxy a nivel de aplicación",
+ "enableProxyHelp": "Habilita la configuración de proxy personalizada para esta aplicación, sobrescribiendo la configuración de proxy del sistema",
+ "proxyType": "Tipo de proxy",
+ "proxyTypeHelp": "Selecciona el tipo de servidor proxy (HTTP, HTTPS, SOCKS4, SOCKS5)",
+ "proxyHost": "Host del proxy",
+ "proxyHostPlaceholder": "proxy.ejemplo.com",
+ "proxyHostHelp": "El nombre de host o dirección IP de tu servidor proxy",
+ "proxyPort": "Puerto del proxy",
+ "proxyPortPlaceholder": "8080",
+ "proxyPortHelp": "El número de puerto de tu servidor proxy",
+ "proxyUsername": "Usuario (opcional)",
+ "proxyUsernamePlaceholder": "usuario",
+ "proxyUsernameHelp": "Usuario para autenticación de proxy (si es necesario)",
+ "proxyPassword": "Contraseña (opcional)",
+ "proxyPasswordPlaceholder": "contraseña",
+ "proxyPasswordHelp": "Contraseña para autenticación de proxy (si es necesario)"
}
},
"loras": {
diff --git a/locales/fr.json b/locales/fr.json
index 16e3e3d7..38a6b1f5 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -181,7 +181,8 @@
"downloadPathTemplates": "Modèles de chemin de téléchargement",
"exampleImages": "Images d'exemple",
"misc": "Divers",
- "metadataArchive": "Base de données d'archive des métadonnées"
+ "metadataArchive": "Base de données d'archive des métadonnées",
+ "proxySettings": "Paramètres du proxy"
},
"contentFiltering": {
"blurNsfwContent": "Flouter le contenu NSFW",
@@ -300,6 +301,24 @@
"connecting": "Connexion au serveur de téléchargement...",
"completed": "Terminé",
"downloadComplete": "Téléchargement terminé avec succès"
+ },
+ "proxySettings": {
+ "enableProxy": "Activer le proxy au niveau de l'application",
+ "enableProxyHelp": "Activer les paramètres de proxy personnalisés pour cette application, remplaçant les paramètres de proxy système",
+ "proxyType": "Type de proxy",
+ "proxyTypeHelp": "Sélectionnez le type de serveur proxy (HTTP, HTTPS, SOCKS4, SOCKS5)",
+ "proxyHost": "Hôte du proxy",
+ "proxyHostPlaceholder": "proxy.exemple.com",
+ "proxyHostHelp": "Le nom d'hôte ou l'adresse IP de votre serveur proxy",
+ "proxyPort": "Port du proxy",
+ "proxyPortPlaceholder": "8080",
+ "proxyPortHelp": "Le numéro de port de votre serveur proxy",
+ "proxyUsername": "Nom d'utilisateur (optionnel)",
+ "proxyUsernamePlaceholder": "nom_utilisateur",
+ "proxyUsernameHelp": "Nom d'utilisateur pour l'authentification proxy (si nécessaire)",
+ "proxyPassword": "Mot de passe (optionnel)",
+ "proxyPasswordPlaceholder": "mot_de_passe",
+ "proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)"
}
},
"loras": {
diff --git a/locales/ja.json b/locales/ja.json
index 90ab446d..f8bdefc6 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -181,7 +181,8 @@
"downloadPathTemplates": "ダウンロードパステンプレート",
"exampleImages": "例画像",
"misc": "その他",
- "metadataArchive": "メタデータアーカイブデータベース"
+ "metadataArchive": "メタデータアーカイブデータベース",
+ "proxySettings": "プロキシ設定"
},
"contentFiltering": {
"blurNsfwContent": "NSFWコンテンツをぼかす",
@@ -300,6 +301,24 @@
"connecting": "ダウンロードサーバーに接続中...",
"completed": "完了",
"downloadComplete": "ダウンロードが正常に完了しました"
+ },
+ "proxySettings": {
+ "enableProxy": "アプリレベルのプロキシを有効化",
+ "enableProxyHelp": "このアプリケーション専用のカスタムプロキシ設定を有効にします(システムのプロキシ設定を上書きします)",
+ "proxyType": "プロキシタイプ",
+ "proxyTypeHelp": "プロキシサーバーの種類を選択(HTTP、HTTPS、SOCKS4、SOCKS5)",
+ "proxyHost": "プロキシホスト",
+ "proxyHostPlaceholder": "proxy.example.com",
+ "proxyHostHelp": "プロキシサーバーのホスト名またはIPアドレス",
+ "proxyPort": "プロキシポート",
+ "proxyPortPlaceholder": "8080",
+ "proxyPortHelp": "プロキシサーバーのポート番号",
+ "proxyUsername": "ユーザー名(任意)",
+ "proxyUsernamePlaceholder": "ユーザー名",
+ "proxyUsernameHelp": "プロキシ認証用のユーザー名(必要な場合)",
+ "proxyPassword": "パスワード(任意)",
+ "proxyPasswordPlaceholder": "パスワード",
+ "proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)"
}
},
"loras": {
diff --git a/locales/ko.json b/locales/ko.json
index d180b076..08ab8e38 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -181,7 +181,8 @@
"downloadPathTemplates": "다운로드 경로 템플릿",
"exampleImages": "예시 이미지",
"misc": "기타",
- "metadataArchive": "메타데이터 아카이브 데이터베이스"
+ "metadataArchive": "메타데이터 아카이브 데이터베이스",
+ "proxySettings": "프록시 설정"
},
"contentFiltering": {
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
@@ -300,6 +301,24 @@
"connecting": "다운로드 서버에 연결 중...",
"completed": "완료됨",
"downloadComplete": "다운로드가 성공적으로 완료되었습니다"
+ },
+ "proxySettings": {
+ "enableProxy": "앱 수준 프록시 활성화",
+ "enableProxyHelp": "이 애플리케이션에 대한 사용자 지정 프록시 설정을 활성화하여 시스템 프록시 설정을 무시합니다",
+ "proxyType": "프록시 유형",
+ "proxyTypeHelp": "프록시 서버 유형을 선택하세요 (HTTP, HTTPS, SOCKS4, SOCKS5)",
+ "proxyHost": "프록시 호스트",
+ "proxyHostPlaceholder": "proxy.example.com",
+ "proxyHostHelp": "프록시 서버의 호스트명 또는 IP 주소",
+ "proxyPort": "프록시 포트",
+ "proxyPortPlaceholder": "8080",
+ "proxyPortHelp": "프록시 서버의 포트 번호",
+ "proxyUsername": "사용자 이름 (선택사항)",
+ "proxyUsernamePlaceholder": "username",
+ "proxyUsernameHelp": "프록시 인증에 필요한 사용자 이름 (필요한 경우)",
+ "proxyPassword": "비밀번호 (선택사항)",
+ "proxyPasswordPlaceholder": "password",
+ "proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)"
}
},
"loras": {
diff --git a/locales/ru.json b/locales/ru.json
index 6850954a..522ac612 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -181,7 +181,8 @@
"downloadPathTemplates": "Шаблоны путей загрузки",
"exampleImages": "Примеры изображений",
"misc": "Разное",
- "metadataArchive": "Архив метаданных"
+ "metadataArchive": "Архив метаданных",
+ "proxySettings": "Настройки прокси"
},
"contentFiltering": {
"blurNsfwContent": "Размывать NSFW контент",
@@ -300,6 +301,24 @@
"connecting": "Подключение к серверу загрузки...",
"completed": "Завершено",
"downloadComplete": "Загрузка успешно завершена"
+ },
+ "proxySettings": {
+ "enableProxy": "Включить прокси на уровне приложения",
+ "enableProxyHelp": "Включить пользовательские настройки прокси для этого приложения, переопределяя системные настройки прокси",
+ "proxyType": "Тип прокси",
+ "proxyTypeHelp": "Выберите тип прокси-сервера (HTTP, HTTPS, SOCKS4, SOCKS5)",
+ "proxyHost": "Хост прокси",
+ "proxyHostPlaceholder": "proxy.example.com",
+ "proxyHostHelp": "Имя хоста или IP-адрес вашего прокси-сервера",
+ "proxyPort": "Порт прокси",
+ "proxyPortPlaceholder": "8080",
+ "proxyPortHelp": "Номер порта вашего прокси-сервера",
+ "proxyUsername": "Имя пользователя (необязательно)",
+ "proxyUsernamePlaceholder": "имя пользователя",
+ "proxyUsernameHelp": "Имя пользователя для аутентификации на прокси (если требуется)",
+ "proxyPassword": "Пароль (необязательно)",
+ "proxyPasswordPlaceholder": "пароль",
+ "proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)"
}
},
"loras": {
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index bab32fd9..5884f1fc 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -181,7 +181,8 @@
"downloadPathTemplates": "下载路径模板",
"exampleImages": "示例图片",
"misc": "其他",
- "metadataArchive": "元数据归档数据库"
+ "metadataArchive": "元数据归档数据库",
+ "proxySettings": "代理设置"
},
"contentFiltering": {
"blurNsfwContent": "模糊 NSFW 内容",
@@ -300,6 +301,24 @@
"connecting": "正在连接下载服务器...",
"completed": "已完成",
"downloadComplete": "下载成功完成"
+ },
+ "proxySettings": {
+ "enableProxy": "启用应用级代理",
+ "enableProxyHelp": "为此应用启用自定义代理设置,覆盖系统代理设置",
+ "proxyType": "代理类型",
+ "proxyTypeHelp": "选择代理服务器类型 (HTTP, HTTPS, SOCKS4, SOCKS5)",
+ "proxyHost": "代理主机",
+ "proxyHostPlaceholder": "proxy.example.com",
+ "proxyHostHelp": "代理服务器的主机名或IP地址",
+ "proxyPort": "代理端口",
+ "proxyPortPlaceholder": "8080",
+ "proxyPortHelp": "代理服务器的端口号",
+ "proxyUsername": "用户名 (可选)",
+ "proxyUsernamePlaceholder": "用户名",
+ "proxyUsernameHelp": "代理认证的用户名 (如果需要)",
+ "proxyPassword": "密码 (可选)",
+ "proxyPasswordPlaceholder": "密码",
+ "proxyPasswordHelp": "代理认证的密码 (如果需要)"
}
},
"loras": {
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index 926b6888..5e982e5f 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -181,7 +181,8 @@
"downloadPathTemplates": "下載路徑範本",
"exampleImages": "範例圖片",
"misc": "其他",
- "metadataArchive": "中繼資料封存資料庫"
+ "metadataArchive": "中繼資料封存資料庫",
+ "proxySettings": "代理設定"
},
"contentFiltering": {
"blurNsfwContent": "模糊 NSFW 內容",
@@ -300,6 +301,24 @@
"connecting": "正在連接下載伺服器...",
"completed": "已完成",
"downloadComplete": "下載成功完成"
+ },
+ "proxySettings": {
+ "enableProxy": "啟用應用程式代理",
+ "enableProxyHelp": "啟用此應用程式的自訂代理設定,將覆蓋系統代理設定",
+ "proxyType": "代理類型",
+ "proxyTypeHelp": "選擇代理伺服器類型(HTTP、HTTPS、SOCKS4、SOCKS5)",
+ "proxyHost": "代理主機",
+ "proxyHostPlaceholder": "proxy.example.com",
+ "proxyHostHelp": "您的代理伺服器主機名稱或 IP 位址",
+ "proxyPort": "代理埠號",
+ "proxyPortPlaceholder": "8080",
+ "proxyPortHelp": "您的代理伺服器埠號",
+ "proxyUsername": "使用者名稱(選填)",
+ "proxyUsernamePlaceholder": "username",
+ "proxyUsernameHelp": "代理驗證所需的使用者名稱(如有需要)",
+ "proxyPassword": "密碼(選填)",
+ "proxyPasswordPlaceholder": "password",
+ "proxyPasswordHelp": "代理驗證所需的密碼(如有需要)"
}
},
"loras": {
diff --git a/py/lora_manager.py b/py/lora_manager.py
index f6598bca..96bae4de 100644
--- a/py/lora_manager.py
+++ b/py/lora_manager.py
@@ -409,9 +409,6 @@ class LoraManager:
await cls._remove_folder_safely(folder_path)
invalid_hash_folders_removed += 1
continue
-
- logger.debug(f"Keeping valid example images folder: {folder_name}")
-
except Exception as e:
logger.error(f"Error processing example images folder {folder_name}: {e}")
diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py
index 5ae2eadd..013923b7 100644
--- a/py/routes/misc_routes.py
+++ b/py/routes/misc_routes.py
@@ -14,6 +14,7 @@ from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS, NODE_TYPES, DEFAULT_NO
from ..services.service_registry import ServiceRegistry
from ..services.metadata_service import get_metadata_archive_manager, update_metadata_providers
from ..services.websocket_manager import ws_manager
+from ..services.downloader import get_downloader
logger = logging.getLogger(__name__)
standalone_mode = 'nodes' not in sys.modules
@@ -123,6 +124,8 @@ class MiscRoutes:
"""Update application settings"""
try:
data = await request.json()
+ proxy_keys = {'proxy_enabled', 'proxy_host', 'proxy_port', 'proxy_username', 'proxy_password', 'proxy_type'}
+ proxy_changed = False
# Validate and update settings
for key, value in data.items():
@@ -142,11 +145,22 @@ class MiscRoutes:
if old_path != value:
logger.info(f"Example images path changed to {value} - server restart required")
- # Save to settings
- settings.set(key, value)
+ # Handle deletion for proxy credentials
+ if value == '__DELETE__' and key in ('proxy_username', 'proxy_password'):
+ settings.delete(key)
+ else:
+ # Save to settings
+ settings.set(key, value)
if key == 'enable_metadata_archive_db':
await update_metadata_providers()
+
+ if key in proxy_keys:
+ proxy_changed = True
+
+ if proxy_changed:
+ downloader = await get_downloader()
+ await downloader.refresh_session()
return web.json_response({'success': True})
except Exception as e:
diff --git a/py/services/downloader.py b/py/services/downloader.py
index dc38c0d1..4a26c38b 100644
--- a/py/services/downloader.py
+++ b/py/services/downloader.py
@@ -45,6 +45,7 @@ class Downloader:
# Session management
self._session = None
self._session_created_at = None
+ self._proxy_url = None # Store proxy URL for current session
# Configuration
self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better throughput
@@ -64,6 +65,13 @@ class Downloader:
await self._create_session()
return self._session
+ @property
+ def proxy_url(self) -> Optional[str]:
+ """Get the current proxy URL (initialize if needed)"""
+ if not hasattr(self, '_proxy_url'):
+ self._proxy_url = None
+ return self._proxy_url
+
def _should_refresh_session(self) -> bool:
"""Check if session should be refreshed"""
if self._session is None:
@@ -84,6 +92,26 @@ class Downloader:
if self._session is not None:
await self._session.close()
+ # Check for app-level proxy settings
+ proxy_url = None
+ if settings.get('proxy_enabled', False):
+ proxy_host = settings.get('proxy_host', '').strip()
+ proxy_port = settings.get('proxy_port', '').strip()
+ proxy_type = settings.get('proxy_type', 'http').lower()
+ proxy_username = settings.get('proxy_username', '').strip()
+ proxy_password = settings.get('proxy_password', '').strip()
+
+ if proxy_host and proxy_port:
+ # Build proxy URL
+ if proxy_username and proxy_password:
+ proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
+ else:
+ proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
+
+ logger.info(f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}")
+ logger.debug("Proxy mode: app-level proxy is active.")
+ else:
+ logger.debug("Proxy mode: system-level proxy (trust_env) will be used if configured in environment.")
# Optimize TCP connection parameters
connector = aiohttp.TCPConnector(
ssl=True,
@@ -102,12 +130,15 @@ class Downloader:
self._session = aiohttp.ClientSession(
connector=connector,
- trust_env=True, # Use system proxy settings
+ trust_env=proxy_url is None, # Only use system proxy if no app-level proxy is set
timeout=timeout
)
+
+ # Store proxy URL for use in requests
+ self._proxy_url = proxy_url
self._session_created_at = datetime.now()
- logger.debug("Created new HTTP session")
+ logger.debug("Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s", bool(proxy_url), proxy_url is None)
def _get_auth_headers(self, use_auth: bool = False) -> Dict[str, str]:
"""Get headers with optional authentication"""
@@ -164,6 +195,11 @@ class Downloader:
while retry_count <= self.max_retries:
try:
session = await self.session
+ # Debug log for proxy mode at request time
+ if self.proxy_url:
+ logger.debug(f"[download_file] Using app-level proxy: {self.proxy_url}")
+ else:
+ logger.debug("[download_file] Using system-level proxy (trust_env) if configured.")
# Add Range header for resume if we have partial data
request_headers = headers.copy()
@@ -177,7 +213,7 @@ class Downloader:
if resume_offset > 0:
logger.debug(f"Requesting range from byte {resume_offset}")
- async with session.get(url, headers=request_headers, allow_redirects=True) as response:
+ async with session.get(url, headers=request_headers, allow_redirects=True, proxy=self.proxy_url) as response:
# Handle different response codes
if response.status == 200:
# Full content response
@@ -202,7 +238,7 @@ class Downloader:
part_size = os.path.getsize(part_path)
logger.warning(f"Range not satisfiable. Part file size: {part_size}")
# Try to get actual file size
- head_response = await session.head(url, headers=headers)
+ head_response = await session.head(url, headers=headers, proxy=self.proxy_url)
if head_response.status == 200:
actual_size = int(head_response.headers.get('content-length', 0))
if part_size == actual_size:
@@ -345,13 +381,18 @@ class Downloader:
"""
try:
session = await self.session
+ # Debug log for proxy mode at request time
+ if self.proxy_url:
+ logger.debug(f"[download_to_memory] Using app-level proxy: {self.proxy_url}")
+ else:
+ logger.debug("[download_to_memory] Using system-level proxy (trust_env) if configured.")
# Prepare headers
headers = self._get_auth_headers(use_auth)
if custom_headers:
headers.update(custom_headers)
- async with session.get(url, headers=headers) as response:
+ async with session.get(url, headers=headers, proxy=self.proxy_url) as response:
if response.status == 200:
content = await response.read()
return True, content
@@ -387,13 +428,18 @@ class Downloader:
"""
try:
session = await self.session
+ # Debug log for proxy mode at request time
+ if self.proxy_url:
+ logger.debug(f"[get_response_headers] Using app-level proxy: {self.proxy_url}")
+ else:
+ logger.debug("[get_response_headers] Using system-level proxy (trust_env) if configured.")
# Prepare headers
headers = self._get_auth_headers(use_auth)
if custom_headers:
headers.update(custom_headers)
- async with session.head(url, headers=headers) as response:
+ async with session.head(url, headers=headers, proxy=self.proxy_url) as response:
if response.status == 200:
return True, dict(response.headers)
else:
@@ -426,12 +472,21 @@ class Downloader:
"""
try:
session = await self.session
+ # Debug log for proxy mode at request time
+ if self.proxy_url:
+ logger.debug(f"[make_request] Using app-level proxy: {self.proxy_url}")
+ else:
+ logger.debug("[make_request] Using system-level proxy (trust_env) if configured.")
# Prepare headers
headers = self._get_auth_headers(use_auth)
if custom_headers:
headers.update(custom_headers)
+ # Add proxy to kwargs if not already present
+ if 'proxy' not in kwargs:
+ kwargs['proxy'] = self.proxy_url
+
async with session.request(method, url, headers=headers, **kwargs) as response:
if response.status == 200:
# Try to parse as JSON, fall back to text
@@ -460,7 +515,13 @@ class Downloader:
await self._session.close()
self._session = None
self._session_created_at = None
+ self._proxy_url = None
logger.debug("Closed HTTP session")
+
+ async def refresh_session(self):
+ """Force refresh the HTTP session (useful when proxy settings change)"""
+ await self._create_session()
+ logger.info("HTTP session refreshed due to settings change")
# Global instance accessor
diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py
index 7d99da48..5a95e58e 100644
--- a/py/services/settings_manager.py
+++ b/py/services/settings_manager.py
@@ -82,7 +82,13 @@ class SettingsManager:
"civitai_api_key": "",
"show_only_sfw": False,
"language": "en",
- "enable_metadata_archive_db": False # Enable metadata archive database
+ "enable_metadata_archive_db": False, # Enable metadata archive database
+ "proxy_enabled": False, # Enable app-level proxy
+ "proxy_host": "", # Proxy host
+ "proxy_port": "", # Proxy port
+ "proxy_username": "", # Proxy username (optional)
+ "proxy_password": "", # Proxy password (optional)
+ "proxy_type": "http" # Proxy type: http, https, socks4, socks5
}
def get(self, key: str, default: Any = None) -> Any:
@@ -94,6 +100,13 @@ class SettingsManager:
self.settings[key] = value
self._save_settings()
+ def delete(self, key: str) -> None:
+ """Delete setting key and save"""
+ if key in self.settings:
+ del self.settings[key]
+ self._save_settings()
+ logger.info(f"Deleted setting: {key}")
+
def _save_settings(self) -> None:
"""Save settings to file"""
try:
diff --git a/static/css/components/modal/settings-modal.css b/static/css/components/modal/settings-modal.css
index a13dc856..3f59203e 100644
--- a/static/css/components/modal/settings-modal.css
+++ b/static/css/components/modal/settings-modal.css
@@ -101,7 +101,7 @@
.api-key-input input {
width: 100%;
padding: 6px 40px 6px 10px; /* Add left padding */
- height: 32px;
+ height: 20px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
@@ -123,6 +123,36 @@
opacity: 1;
}
+/* Text input wrapper styles for consistent input styling */
+.text-input-wrapper {
+ width: 100%;
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.text-input-wrapper input {
+ width: 100%;
+ padding: 6px 10px;
+ height: 20px;
+ border-radius: var(--border-radius-xs);
+ border: 1px solid var(--border-color);
+ background-color: var(--lora-surface);
+ color: var(--text-color);
+ font-size: 0.95em;
+}
+
+.text-input-wrapper input:focus {
+ border-color: var(--lora-accent);
+ outline: none;
+ box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
+}
+
+/* Dark theme specific adjustments */
+[data-theme="dark"] .text-input-wrapper input {
+ background-color: rgba(30, 30, 30, 0.9);
+}
+
.input-help {
font-size: 0.85em;
color: var(--text-color);
@@ -312,7 +342,7 @@ input:checked + .toggle-slider:before {
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
- color: var (--text-color);
+ color: var(--text-color);
font-size: 0.95em;
height: 32px;
}
@@ -571,10 +601,31 @@ input:checked + .toggle-slider:before {
background-color: rgba(30, 30, 30, 0.9);
}
+/* Proxy Settings Styles */
+.proxy-settings-group {
+ margin-left: var(--space-1);
+ padding-left: var(--space-1);
+ border-left: 2px solid var(--lora-border);
+ animation: slideDown 0.3s ease-out;
+}
+
+.proxy-settings-group .setting-item {
+ margin-bottom: var(--space-2);
+}
+
/* Responsive adjustments */
@media (max-width: 768px) {
.placeholder-info {
flex-direction: column;
align-items: flex-start;
}
+
+ .proxy-settings-group {
+ margin-left: 0;
+ padding-left: var(--space-1);
+ border-left: none;
+ border-top: 1px solid var(--lora-border);
+ padding-top: var(--space-2);
+ margin-top: var(--space-2);
+ }
}
\ No newline at end of file
diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js
index 2d477d24..96e3064d 100644
--- a/static/js/managers/SettingsManager.js
+++ b/static/js/managers/SettingsManager.js
@@ -125,7 +125,13 @@ export class SettingsManager {
'default_checkpoint_root',
'default_embedding_root',
'base_model_path_mappings',
- 'download_path_templates'
+ 'download_path_templates',
+ 'proxy_enabled',
+ 'proxy_type',
+ 'proxy_host',
+ 'proxy_port',
+ 'proxy_username',
+ 'proxy_password'
];
// Build payload for syncing
@@ -281,6 +287,60 @@ export class SettingsManager {
const currentLanguage = state.global.settings.language || 'en';
languageSelect.value = currentLanguage;
}
+
+ this.loadProxySettings();
+ }
+
+ loadProxySettings() {
+ // Load proxy enabled setting
+ const proxyEnabledCheckbox = document.getElementById('proxyEnabled');
+ if (proxyEnabledCheckbox) {
+ proxyEnabledCheckbox.checked = state.global.settings.proxy_enabled || false;
+
+ // Add event listener for toggling proxy settings group visibility
+ proxyEnabledCheckbox.addEventListener('change', () => {
+ const proxySettingsGroup = document.getElementById('proxySettingsGroup');
+ if (proxySettingsGroup) {
+ proxySettingsGroup.style.display = proxyEnabledCheckbox.checked ? 'block' : 'none';
+ }
+ });
+
+ // Set initial visibility
+ const proxySettingsGroup = document.getElementById('proxySettingsGroup');
+ if (proxySettingsGroup) {
+ proxySettingsGroup.style.display = proxyEnabledCheckbox.checked ? 'block' : 'none';
+ }
+ }
+
+ // Load proxy type
+ const proxyTypeSelect = document.getElementById('proxyType');
+ if (proxyTypeSelect) {
+ proxyTypeSelect.value = state.global.settings.proxy_type || 'http';
+ }
+
+ // Load proxy host
+ const proxyHostInput = document.getElementById('proxyHost');
+ if (proxyHostInput) {
+ proxyHostInput.value = state.global.settings.proxy_host || '';
+ }
+
+ // Load proxy port
+ const proxyPortInput = document.getElementById('proxyPort');
+ if (proxyPortInput) {
+ proxyPortInput.value = state.global.settings.proxy_port || '';
+ }
+
+ // Load proxy username
+ const proxyUsernameInput = document.getElementById('proxyUsername');
+ if (proxyUsernameInput) {
+ proxyUsernameInput.value = state.global.settings.proxy_username || '';
+ }
+
+ // Load proxy password
+ const proxyPasswordInput = document.getElementById('proxyPassword');
+ if (proxyPasswordInput) {
+ proxyPasswordInput.value = state.global.settings.proxy_password || '';
+ }
}
async loadLoraRoots() {
@@ -791,6 +851,14 @@ export class SettingsManager {
state.global.settings.includeTriggerWords = value;
} else if (settingKey === 'enable_metadata_archive_db') {
state.global.settings.enable_metadata_archive_db = value;
+ } else if (settingKey === 'proxy_enabled') {
+ state.global.settings.proxy_enabled = value;
+
+ // Toggle visibility of proxy settings group
+ const proxySettingsGroup = document.getElementById('proxySettingsGroup');
+ if (proxySettingsGroup) {
+ proxySettingsGroup.style.display = value ? 'block' : 'none';
+ }
} else {
// For any other settings that might be added in the future
state.global.settings[settingKey] = value;
@@ -801,7 +869,7 @@ export class SettingsManager {
try {
// For backend settings, make API call
- if (['show_only_sfw', 'enable_metadata_archive_db'].includes(settingKey)) {
+ if (['show_only_sfw', 'enable_metadata_archive_db', 'proxy_enabled'].includes(settingKey)) {
const payload = {};
payload[settingKey] = value;
@@ -879,6 +947,8 @@ export class SettingsManager {
state.global.settings.compactMode = (value !== 'default');
} else if (settingKey === 'card_info_display') {
state.global.settings.cardInfoDisplay = value;
+ } else if (settingKey === 'proxy_type') {
+ state.global.settings.proxy_type = value;
} else {
// For any other settings that might be added in the future
state.global.settings[settingKey] = value;
@@ -889,7 +959,7 @@ export class SettingsManager {
try {
// For backend settings, make API call
- if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_templates') {
+ if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_templates' || settingKey.startsWith('proxy_')) {
const payload = {};
if (settingKey === 'download_path_templates') {
payload[settingKey] = state.global.settings.download_path_templates;
@@ -1183,7 +1253,7 @@ export class SettingsManager {
const element = document.getElementById(elementId);
if (!element) return;
- const value = element.value;
+ const value = element.value.trim(); // Trim whitespace
// For API key or other inputs that need to be saved on backend
try {
@@ -1193,25 +1263,39 @@ export class SettingsManager {
return; // No change, exit early
}
- // Update state
- state.global.settings[settingKey] = value;
+ // For username and password, remove the setting if value is empty
+ if ((settingKey === 'proxy_username' || settingKey === 'proxy_password') && value === '') {
+ // Remove from state instead of setting to empty string
+ delete state.global.settings[settingKey];
+ } else {
+ // Update state with value (including empty strings for non-optional fields)
+ state.global.settings[settingKey] = value;
+ }
setStorageItem('settings', state.global.settings);
// For backend settings, make API call
- const payload = {};
- payload[settingKey] = value;
-
- const response = await fetch('/api/settings', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(payload)
- });
+ if (settingKey === 'civitai_api_key' || settingKey.startsWith('proxy_')) {
+ const payload = {};
+
+ // For username and password, send delete flag if empty to remove from backend
+ if ((settingKey === 'proxy_username' || settingKey === 'proxy_password') && value === '') {
+ payload[settingKey] = '__DELETE__';
+ } else {
+ payload[settingKey] = value;
+ }
+
+ const response = await fetch('/api/settings', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(payload)
+ });
- if (!response.ok) {
- throw new Error('Failed to save setting');
+ if (!response.ok) {
+ throw new Error('Failed to save setting');
+ }
}
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
diff --git a/templates/components/modals/settings_modal.html b/templates/components/modals/settings_modal.html
index 3b1b3a8a..09fea0c9 100644
--- a/templates/components/modals/settings_modal.html
+++ b/templates/components/modals/settings_modal.html
@@ -445,6 +445,129 @@
+
+
+
{{ t('settings.sections.proxySettings') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('settings.proxySettings.enableProxyHelp') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('settings.proxySettings.proxyTypeHelp') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('settings.proxySettings.proxyHostHelp') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('settings.proxySettings.proxyPortHelp') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('settings.proxySettings.proxyUsernameHelp') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('settings.proxySettings.proxyPasswordHelp') }}
+
+
+
+
+
{{ t('settings.sections.misc') }}
@@ -466,6 +589,7 @@
+
\ No newline at end of file
From a0c2d9b5ad9b9773c204196546dd9a9440ded3ca Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Fri, 12 Sep 2025 11:48:59 +0800
Subject: [PATCH 002/110] refactor: change logger info statements to debug
level for improved logging granularity
---
py/lora_manager.py | 4 ++--
py/services/metadata_service.py | 4 ++--
py/utils/usage_stats.py | 4 ++--
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/py/lora_manager.py b/py/lora_manager.py
index 96bae4de..4379a3e1 100644
--- a/py/lora_manager.py
+++ b/py/lora_manager.py
@@ -221,7 +221,7 @@ class LoraManager:
name='post_init_tasks'
)
- logger.info("LoRA Manager: All services initialized and background tasks scheduled")
+ logger.debug("LoRA Manager: All services initialized and background tasks scheduled")
except Exception as e:
logger.error(f"LoRA Manager: Error initializing services: {e}", exc_info=True)
@@ -427,7 +427,7 @@ class LoraManager:
f"removed {empty_folders_removed} empty folders and {invalid_hash_folders_removed} "
f"folders for deleted/invalid models (total: {total_removed} removed)")
else:
- logger.info(f"Example images cleanup completed: checked {total_folders_checked} folders, "
+ logger.debug(f"Example images cleanup completed: checked {total_folders_checked} folders, "
f"no cleanup needed")
except Exception as e:
diff --git a/py/services/metadata_service.py b/py/services/metadata_service.py
index 86a94eaf..4c20b0b8 100644
--- a/py/services/metadata_service.py
+++ b/py/services/metadata_service.py
@@ -49,7 +49,7 @@ async def initialize_metadata_providers():
civitai_provider = CivitaiModelMetadataProvider(civitai_client)
provider_manager.register_provider('civitai_api', civitai_provider)
providers.append(('civitai_api', civitai_provider))
- logger.info("Civitai API metadata provider registered")
+ logger.debug("Civitai API metadata provider registered")
except Exception as e:
logger.error(f"Failed to initialize Civitai API metadata provider: {e}")
@@ -68,7 +68,7 @@ async def initialize_metadata_providers():
# Only one provider available, set it as default
provider_name, provider = providers[0]
provider_manager.register_provider(provider_name, provider, is_default=True)
- logger.info(f"Single metadata provider registered as default: {provider_name}")
+ logger.debug(f"Single metadata provider registered as default: {provider_name}")
else:
logger.warning("No metadata providers available - this may cause metadata lookup failures")
diff --git a/py/utils/usage_stats.py b/py/utils/usage_stats.py
index 08021964..1466ee21 100644
--- a/py/utils/usage_stats.py
+++ b/py/utils/usage_stats.py
@@ -62,7 +62,7 @@ class UsageStats:
self._bg_task = asyncio.create_task(self._background_processor())
self._initialized = True
- logger.info("Usage statistics tracker initialized")
+ logger.debug("Usage statistics tracker initialized")
def _get_stats_file_path(self) -> str:
"""Get the path to the stats JSON file"""
@@ -164,7 +164,7 @@ class UsageStats:
if "last_save_time" in loaded_stats:
self.stats["last_save_time"] = loaded_stats["last_save_time"]
- logger.info(f"Loaded usage statistics from {self._stats_file_path}")
+ logger.debug(f"Loaded usage statistics from {self._stats_file_path}")
except Exception as e:
logger.error(f"Error loading usage statistics: {e}")
From d5a280cf2bcfa1ef2c012c11b7d4986778b136f3 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Fri, 12 Sep 2025 14:01:52 +0800
Subject: [PATCH 003/110] fix: increase maxItems for autocomplete to improve
user experience
---
web/comfyui/autocomplete.js | 2 +-
web/comfyui/utils.js | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js
index 61fa1984..19b89466 100644
--- a/web/comfyui/autocomplete.js
+++ b/web/comfyui/autocomplete.js
@@ -7,7 +7,7 @@ class AutoComplete {
this.inputElement = inputElement;
this.modelType = modelType;
this.options = {
- maxItems: 15,
+ maxItems: 20,
minChars: 1,
debounceDelay: 200,
showPreview: true,
diff --git a/web/comfyui/utils.js b/web/comfyui/utils.js
index 7c64621b..760cb46a 100644
--- a/web/comfyui/utils.js
+++ b/web/comfyui/utils.js
@@ -285,7 +285,7 @@ export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCall
// Initialize autocomplete on first callback if not already done
if (!autocomplete && inputWidget.inputEl) {
autocomplete = new AutoComplete(inputWidget.inputEl, 'loras', {
- maxItems: 15,
+ maxItems: 20,
minChars: 1,
debounceDelay: 200
});
From 897787d17c415744bc89500d8cd5f890a2824077 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Fri, 12 Sep 2025 14:35:25 +0800
Subject: [PATCH 004/110] refactor(AutoComplete): simplify search term
extraction and insertion logic
---
py/services/base_model_service.py | 2 +-
web/comfyui/autocomplete.js | 51 +++++++++++--------------------
2 files changed, 19 insertions(+), 34 deletions(-)
diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py
index 14a89a52..ed1fc930 100644
--- a/py/services/base_model_service.py
+++ b/py/services/base_model_service.py
@@ -363,7 +363,7 @@ class BaseModelService(ABC):
from ..config import config
return config.get_preview_static_url(preview_url)
- return None
+ return '/loras_static/images/no-preview.png'
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
"""Get the Civitai URL for a model file"""
diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js
index 19b89466..c8c33244 100644
--- a/web/comfyui/autocomplete.js
+++ b/web/comfyui/autocomplete.js
@@ -147,8 +147,8 @@ class AutoComplete {
return '';
}
- // Split on multiple delimiters: comma, space, '>' and other common separators
- const segments = beforeCursor.split(/[,\s>]+/);
+ // Split on comma and '>' delimiters only (do not split on spaces)
+ const segments = beforeCursor.split(/[,\>]+/);
// Return the last non-empty segment as search term
const lastSegment = segments[segments.length - 1] || '';
@@ -381,7 +381,7 @@ class AutoComplete {
async insertSelection(relativePath) {
// Extract just the filename for LoRA name
const fileName = relativePath.split(/[/\\]/).pop().replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
-
+
// Get usage tips and extract strength
let strength = 1.0; // Default strength
try {
@@ -389,7 +389,6 @@ class AutoComplete {
if (response.ok) {
const data = await response.json();
if (data.success && data.usage_tips) {
- // Parse JSON string and extract strength
try {
const usageTips = JSON.parse(data.usage_tips);
if (usageTips.strength && typeof usageTips.strength === 'number') {
@@ -403,44 +402,30 @@ class AutoComplete {
} catch (error) {
console.warn('Failed to fetch usage tips:', error);
}
-
+
// Format the LoRA code with strength
const loraCode = `, `;
-
+
const currentValue = this.inputElement.value;
const caretPos = this.getCaretPosition();
- const lastCommaIndex = currentValue.lastIndexOf(',', caretPos - 1);
-
- let newValue;
- let newCaretPos;
-
- if (lastCommaIndex === -1) {
- // No comma found before cursor, replace from start or current search term start
- const searchTerm = this.getSearchTerm(currentValue.substring(0, caretPos));
- const searchStartPos = caretPos - searchTerm.length;
- newValue = currentValue.substring(0, searchStartPos) + loraCode + currentValue.substring(caretPos);
- newCaretPos = searchStartPos + loraCode.length;
- } else {
- // Replace text after last comma before cursor
- const afterCommaPos = lastCommaIndex + 1;
- // Skip whitespace after comma
- let insertPos = afterCommaPos;
- while (insertPos < caretPos && /\s/.test(currentValue[insertPos])) {
- insertPos++;
- }
-
- newValue = currentValue.substring(0, insertPos) + loraCode + currentValue.substring(caretPos);
- newCaretPos = insertPos + loraCode.length;
- }
-
+
+ // Use getSearchTerm to get the current search term before cursor
+ const beforeCursor = currentValue.substring(0, caretPos);
+ const searchTerm = this.getSearchTerm(beforeCursor);
+ const searchStartPos = caretPos - searchTerm.length;
+
+ // Only replace the search term, not everything after the last comma
+ const newValue = currentValue.substring(0, searchStartPos) + loraCode + currentValue.substring(caretPos);
+ const newCaretPos = searchStartPos + loraCode.length;
+
this.inputElement.value = newValue;
-
+
// Trigger input event to notify about the change
const event = new Event('input', { bubbles: true });
this.inputElement.dispatchEvent(event);
-
+
this.hide();
-
+
// Focus back to input and position cursor
this.inputElement.focus();
this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
From 00b77581fca9c323ed42ac8743ec931db10fd73a Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Fri, 12 Sep 2025 15:20:34 +0800
Subject: [PATCH 005/110] refactor(Downloader): change logger info statements
to debug level for proxy usage
---
py/services/downloader.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/py/services/downloader.py b/py/services/downloader.py
index 4a26c38b..dd2c8c32 100644
--- a/py/services/downloader.py
+++ b/py/services/downloader.py
@@ -108,7 +108,7 @@ class Downloader:
else:
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
- logger.info(f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}")
+ logger.debug(f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}")
logger.debug("Proxy mode: app-level proxy is active.")
else:
logger.debug("Proxy mode: system-level proxy (trust_env) will be used if configured in environment.")
From d05076d258f42a068b24db8938c726014ba9ba4a Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Fri, 12 Sep 2025 21:13:15 +0800
Subject: [PATCH 006/110] feat: add CivArchive metadata provider and support
for optional source parameter in downloads
---
py/routes/base_model_routes.py | 5 +
py/services/download_manager.py | 33 +++---
py/services/metadata_service.py | 11 +-
py/services/model_metadata_provider.py | 138 +++++++++++++++++++++++++
py/utils/routes_common.py | 4 +-
requirements.txt | 1 +
6 files changed, 173 insertions(+), 19 deletions(-)
diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py
index be0f2695..a7970f0b 100644
--- a/py/routes/base_model_routes.py
+++ b/py/routes/base_model_routes.py
@@ -523,6 +523,7 @@ class BaseModelRoutes(ABC):
model_version_id = request.query.get('model_version_id')
download_id = request.query.get('download_id')
use_default_paths = request.query.get('use_default_paths', 'false').lower() == 'true'
+ source = request.query.get('source') # Optional source parameter
# Create a data dictionary that mimics what would be received from a POST request
data = {
@@ -538,6 +539,10 @@ class BaseModelRoutes(ABC):
data['use_default_paths'] = use_default_paths
+ # Add source parameter if provided
+ if source:
+ data['source'] = source
+
# Create a mock request object with the data
future = asyncio.get_event_loop().create_future()
future.set_result(data)
diff --git a/py/services/download_manager.py b/py/services/download_manager.py
index 9f090b20..6638d7d2 100644
--- a/py/services/download_manager.py
+++ b/py/services/download_manager.py
@@ -36,17 +36,10 @@ class DownloadManager:
return
self._initialized = True
- self._civitai_client = None # Will be lazily initialized
# Add download management
self._active_downloads = OrderedDict() # download_id -> download_info
self._download_semaphore = asyncio.Semaphore(5) # Limit concurrent downloads
self._download_tasks = {} # download_id -> asyncio.Task
-
- async def _get_civitai_client(self):
- """Lazily initialize CivitaiClient from registry"""
- if self._civitai_client is None:
- self._civitai_client = await ServiceRegistry.get_civitai_client()
- return self._civitai_client
async def _get_lora_scanner(self):
"""Get the lora scanner from registry"""
@@ -59,7 +52,7 @@ class DownloadManager:
async def download_from_civitai(self, model_id: int = None, model_version_id: int = None,
save_dir: str = None, relative_path: str = '',
progress_callback=None, use_default_paths: bool = False,
- download_id: str = None) -> Dict:
+ download_id: str = None, source: str = None) -> Dict:
"""Download model from Civitai with task tracking and concurrency control
Args:
@@ -70,6 +63,7 @@ class DownloadManager:
progress_callback: Callback function for progress updates
use_default_paths: Flag to use default paths
download_id: Unique identifier for this download task
+ source: Optional source parameter to specify metadata provider
Returns:
Dict with download result
@@ -93,7 +87,7 @@ class DownloadManager:
download_task = asyncio.create_task(
self._download_with_semaphore(
task_id, model_id, model_version_id, save_dir,
- relative_path, progress_callback, use_default_paths
+ relative_path, progress_callback, use_default_paths, source
)
)
@@ -114,7 +108,8 @@ class DownloadManager:
async def _download_with_semaphore(self, task_id: str, model_id: int, model_version_id: int,
save_dir: str, relative_path: str,
- progress_callback=None, use_default_paths: bool = False):
+ progress_callback=None, use_default_paths: bool = False,
+ source: str = None):
"""Execute download with semaphore to limit concurrency"""
# Update status to waiting
if task_id in self._active_downloads:
@@ -144,7 +139,7 @@ class DownloadManager:
result = await self._execute_original_download(
model_id, model_version_id, save_dir,
relative_path, tracking_callback, use_default_paths,
- task_id
+ task_id, source
)
# Update status based on result
@@ -179,7 +174,7 @@ class DownloadManager:
async def _execute_original_download(self, model_id, model_version_id, save_dir,
relative_path, progress_callback, use_default_paths,
- download_id=None):
+ download_id=None, source=None):
"""Wrapper for original download_from_civitai implementation"""
try:
# Check if model version already exists in library
@@ -201,8 +196,12 @@ class DownloadManager:
if await embedding_scanner.check_model_version_exists(model_version_id):
return {'success': False, 'error': 'Model version already exists in embedding library'}
- # Get metadata provider instead of civitai client directly
- metadata_provider = await get_default_metadata_provider()
+ # Get metadata provider based on source parameter
+ if source == 'civarchive':
+ from .metadata_service import get_metadata_provider
+ metadata_provider = await get_metadata_provider('civarchive')
+ else:
+ metadata_provider = await get_default_metadata_provider()
# Get version info based on the provided identifier
version_info = await metadata_provider.get_model_version(model_id, model_version_id)
@@ -396,8 +395,6 @@ class DownloadManager:
model_type: str = "lora", download_id: str = None) -> Dict:
"""Execute the actual download process including preview images and model files"""
try:
- civitai_client = await self._get_civitai_client()
-
# Extract original filename details
original_filename = os.path.basename(metadata.file_path)
base_name, extension = os.path.splitext(original_filename)
@@ -504,11 +501,13 @@ class DownloadManager:
# Download model file with progress tracking using downloader
downloader = await get_downloader()
+ # Determine if the download URL is from Civitai
+ use_auth = download_url.startswith("https://civitai.com/api/download/")
success, result = await downloader.download_file(
download_url,
save_path, # Use full path instead of separate dir and filename
progress_callback=lambda p: self._handle_download_progress(p, progress_callback),
- use_auth=True # Model downloads need authentication
+ use_auth=use_auth # Only use authentication for Civitai downloads
)
if not success:
diff --git a/py/services/metadata_service.py b/py/services/metadata_service.py
index 4c20b0b8..6a4f9dd8 100644
--- a/py/services/metadata_service.py
+++ b/py/services/metadata_service.py
@@ -52,7 +52,16 @@ async def initialize_metadata_providers():
logger.debug("Civitai API metadata provider registered")
except Exception as e:
logger.error(f"Failed to initialize Civitai API metadata provider: {e}")
-
+
+ # Register CivArchive provider, but do NOT add to fallback providers
+ try:
+ from .model_metadata_provider import CivArchiveModelMetadataProvider
+ civarchive_provider = CivArchiveModelMetadataProvider()
+ provider_manager.register_provider('civarchive', civarchive_provider)
+ logger.debug("CivArchive metadata provider registered (not included in fallback)")
+ except Exception as e:
+ logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
+
# Set up fallback provider based on available providers
if len(providers) > 1:
# Always use Civitai API first, then Archive DB
diff --git a/py/services/model_metadata_provider.py b/py/services/model_metadata_provider.py
index 9f54a2e7..b02d0c0e 100644
--- a/py/services/model_metadata_provider.py
+++ b/py/services/model_metadata_provider.py
@@ -2,6 +2,8 @@ from abc import ABC, abstractmethod
import json
import aiosqlite
import logging
+import aiohttp
+from bs4 import BeautifulSoup
from typing import Optional, Dict, List, Tuple, Any
logger = logging.getLogger(__name__)
@@ -55,6 +57,142 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
async def get_model_metadata(self, model_id: str) -> Tuple[Optional[Dict], int]:
return await self.client.get_model_metadata(model_id)
+class CivArchiveModelMetadataProvider(ModelMetadataProvider):
+ """Provider that uses CivArchive HTML page parsing for metadata"""
+
+ def __init__(self, session: aiohttp.ClientSession = None):
+ self.session = session
+ self._own_session = session is None
+
+ async def _get_session(self):
+ """Get or create HTTP session"""
+ if self.session is None:
+ self.session = aiohttp.ClientSession()
+ return self.session
+
+ async def close(self):
+ """Close HTTP session if we own it"""
+ if self._own_session and self.session:
+ await self.session.close()
+ self.session = None
+
+ async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]:
+ """Not supported by CivArchive provider"""
+ return None
+
+ async def get_model_versions(self, model_id: str) -> Optional[Dict]:
+ """Not supported by CivArchive provider"""
+ return None
+
+ async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
+ """Get specific model version by parsing CivArchive HTML page"""
+ if model_id is None or version_id is None:
+ return None
+
+ try:
+ # Construct CivArchive URL
+ url = f"https://civarchive.com/models/{model_id}?modelVersionId={version_id}"
+
+ session = await self._get_session()
+ async with session.get(url) as response:
+ if response.status != 200:
+ return None
+
+ html_content = await response.text()
+
+ # Parse HTML to extract JSON data
+ soup = BeautifulSoup(html_content, 'html.parser')
+ script_tag = soup.find('script', {'id': '__NEXT_DATA__', 'type': 'application/json'})
+
+ if not script_tag:
+ return None
+
+ # Parse JSON content
+ json_data = json.loads(script_tag.string)
+ model_data = json_data.get('props', {}).get('pageProps', {}).get('model')
+
+ if not model_data or 'version' not in model_data:
+ return None
+
+ # Extract version data as base
+ version = model_data['version'].copy()
+
+ # Restructure stats
+ if 'downloadCount' in version and 'ratingCount' in version and 'rating' in version:
+ version['stats'] = {
+ 'downloadCount': version.pop('downloadCount'),
+ 'ratingCount': version.pop('ratingCount'),
+ 'rating': version.pop('rating')
+ }
+
+ # Rename trigger to trainedWords
+ if 'trigger' in version:
+ version['trainedWords'] = version.pop('trigger')
+
+ # Transform files data to expected format
+ if 'files' in version:
+ transformed_files = []
+ for file_data in version['files']:
+ # Find first available mirror (deletedAt is null)
+ available_mirror = None
+ for mirror in file_data.get('mirrors', []):
+ if mirror.get('deletedAt') is None:
+ available_mirror = mirror
+ break
+
+ # Create transformed file entry
+ transformed_file = {
+ 'id': file_data.get('id'),
+ 'sizeKB': file_data.get('sizeKB'),
+ 'name': available_mirror.get('filename', file_data.get('name')) if available_mirror else file_data.get('name'),
+ 'type': file_data.get('type'),
+ 'downloadUrl': available_mirror.get('url') if available_mirror else None,
+ 'primary': True,
+ 'mirrors': file_data.get('mirrors', [])
+ }
+
+ # Transform hash format
+ if 'sha256' in file_data:
+ transformed_file['hashes'] = {
+ 'SHA256': file_data['sha256'].upper()
+ }
+
+ transformed_files.append(transformed_file)
+
+ version['files'] = transformed_files
+
+ # Add model information
+ version['model'] = {
+ 'name': model_data.get('name'),
+ 'type': model_data.get('type'),
+ 'nsfw': model_data.get('is_nsfw', False),
+ 'description': model_data.get('description'),
+ 'tags': model_data.get('tags', [])
+ }
+
+ version['creator'] = {
+ 'username': model_data.get('username'),
+ 'image': ''
+ }
+
+ # Add source identifier
+ version['source'] = 'civarchive'
+ version['is_deleted'] = json_data.get('query', {}).get('is_deleted', False)
+
+ return version
+
+ except Exception as e:
+ logger.error(f"Error fetching CivArchive model version {model_id}/{version_id}: {e}")
+ return None
+
+ async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
+ """Not supported by CivArchive provider - requires both model_id and version_id"""
+ return None, "CivArchive provider requires both model_id and version_id"
+
+ async def get_model_metadata(self, model_id: str) -> Tuple[Optional[Dict], int]:
+ """Not supported by CivArchive provider"""
+ return None, 404
+
class SQLiteModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses SQLite database for metadata"""
diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py
index 80765f7b..3be2677f 100644
--- a/py/utils/routes_common.py
+++ b/py/utils/routes_common.py
@@ -632,6 +632,7 @@ class ModelRouteUtils:
}, status=400)
use_default_paths = data.get('use_default_paths', False)
+ source = data.get('source') # Optional source parameter
# Pass the download_id to download_from_civitai
result = await download_manager.download_from_civitai(
@@ -641,7 +642,8 @@ class ModelRouteUtils:
relative_path=data.get('relative_path', ''),
use_default_paths=use_default_paths,
progress_callback=progress_callback,
- download_id=download_id # Pass download_id explicitly
+ download_id=download_id, # Pass download_id explicitly
+ source=source # Pass source parameter
)
# Include download_id in the response
diff --git a/requirements.txt b/requirements.txt
index 9e280c64..4051dc74 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -9,3 +9,4 @@ numpy
natsort
GitPython
aiosqlite
+beautifulsoup4
From 125fdecd6193dd98a083a7663bed520402cdb3b2 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Sat, 13 Sep 2025 09:03:34 +0800
Subject: [PATCH 007/110] fix: handle missing download URL for primary file in
metadata
---
py/services/download_manager.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/py/services/download_manager.py b/py/services/download_manager.py
index 6638d7d2..26f3f97d 100644
--- a/py/services/download_manager.py
+++ b/py/services/download_manager.py
@@ -294,6 +294,8 @@ class DownloadManager:
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
if not file_info:
return {'success': False, 'error': 'No primary file found in metadata'}
+ if not file_info.get('downloadUrl'):
+ return {'success': False, 'error': 'No download URL found for primary file'}
# 3. Prepare download
file_name = file_info['name']
From 13f13eb095086e981c824ea56b795a0657732466 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Sat, 13 Sep 2025 09:20:55 +0800
Subject: [PATCH 008/110] fix: update preview versions keys for consistency in
state management, fixes #406
---
static/js/components/shared/ModelCard.js | 8 --------
static/js/state/index.js | 6 +++---
2 files changed, 3 insertions(+), 11 deletions(-)
diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js
index 16bdf45d..aa3208a0 100644
--- a/static/js/components/shared/ModelCard.js
+++ b/static/js/components/shared/ModelCard.js
@@ -216,13 +216,6 @@ function handleCardClick(card, modelType) {
}
async function showModelModalFromCard(card, modelType) {
- // Get the appropriate preview versions map
- const previewVersionsKey = modelType;
- const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map();
- const version = previewVersions.get(card.dataset.filepath);
- const previewUrl = card.dataset.preview_url || '/loras_static/images/no-preview.png';
- const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
-
// Create model metadata object
const modelMeta = {
sha256: card.dataset.sha256,
@@ -235,7 +228,6 @@ async function showModelModalFromCard(card, modelType) {
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
notes: card.dataset.notes || '',
- preview_url: versionedPreviewUrl,
favorite: card.dataset.favorite === 'true',
// Parse civitai metadata from the card's dataset
civitai: JSON.parse(card.dataset.meta || '{}'),
diff --git a/static/js/state/index.js b/static/js/state/index.js
index 65d5619f..71f6b859 100644
--- a/static/js/state/index.js
+++ b/static/js/state/index.js
@@ -10,9 +10,9 @@ const savedSettings = getStorageItem('settings', {
});
// Load preview versions from localStorage for each model type
-const loraPreviewVersions = getMapFromStorage('lora_preview_versions');
-const checkpointPreviewVersions = getMapFromStorage('checkpoint_preview_versions');
-const embeddingPreviewVersions = getMapFromStorage('embedding_preview_versions');
+const loraPreviewVersions = getMapFromStorage('loras_preview_versions');
+const checkpointPreviewVersions = getMapFromStorage('checkpoints_preview_versions');
+const embeddingPreviewVersions = getMapFromStorage('embeddings_preview_versions');
export const state = {
// Global state
From 1a76f74482b2a066c618b26677b62c6e387ac30d Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Sat, 13 Sep 2025 13:07:25 +0800
Subject: [PATCH 009/110] refactor(BaseModelRoutes): temporary comment out
model description and creator checks
---
py/routes/base_model_routes.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py
index a7970f0b..aea644e3 100644
--- a/py/routes/base_model_routes.py
+++ b/py/routes/base_model_routes.py
@@ -630,8 +630,8 @@ class BaseModelRoutes(ABC):
not model.get('civitai')
or not model['civitai'].get('id')
# or not model.get('tags') # Skipping tag cause it could be empty legitimately
- or not model.get('modelDescription')
- or not (model.get('civitai') and model['civitai'].get('creator'))
+ # or not model.get('modelDescription')
+ # or not (model.get('civitai') and model['civitai'].get('creator'))
)
and (
(enable_metadata_archive_db)
From e5339c178a3f470c8387c767a85dc05bb688f3f1 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Sat, 13 Sep 2025 16:36:01 +0800
Subject: [PATCH 010/110] fix: increase max-height for expanded sidebar tree
children to improve visibility, fixes #403
---
static/css/components/sidebar.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/static/css/components/sidebar.css b/static/css/components/sidebar.css
index 008c16a5..c0af436d 100644
--- a/static/css/components/sidebar.css
+++ b/static/css/components/sidebar.css
@@ -233,7 +233,7 @@
}
.sidebar-tree-children.expanded {
- max-height: 9999px;
+ max-height: 50000px;
}
.sidebar-tree-children .sidebar-tree-node-content {
From 6b606a5cc8114efbbc5de099506bd3f152d53e83 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Sat, 13 Sep 2025 20:04:41 +0800
Subject: [PATCH 011/110] refactor(CivArchiveModelMetadataProvider): remove
session management and use downloader for HTTP requests
---
py/services/model_metadata_provider.py | 24 +++++-------------------
1 file changed, 5 insertions(+), 19 deletions(-)
diff --git a/py/services/model_metadata_provider.py b/py/services/model_metadata_provider.py
index b02d0c0e..9957b849 100644
--- a/py/services/model_metadata_provider.py
+++ b/py/services/model_metadata_provider.py
@@ -4,7 +4,8 @@ import aiosqlite
import logging
import aiohttp
from bs4 import BeautifulSoup
-from typing import Optional, Dict, List, Tuple, Any
+from typing import Optional, Dict, Tuple
+from .downloader import get_downloader
logger = logging.getLogger(__name__)
@@ -60,22 +61,6 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
class CivArchiveModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses CivArchive HTML page parsing for metadata"""
- def __init__(self, session: aiohttp.ClientSession = None):
- self.session = session
- self._own_session = session is None
-
- async def _get_session(self):
- """Get or create HTTP session"""
- if self.session is None:
- self.session = aiohttp.ClientSession()
- return self.session
-
- async def close(self):
- """Close HTTP session if we own it"""
- if self._own_session and self.session:
- await self.session.close()
- self.session = None
-
async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]:
"""Not supported by CivArchive provider"""
return None
@@ -92,8 +77,9 @@ class CivArchiveModelMetadataProvider(ModelMetadataProvider):
try:
# Construct CivArchive URL
url = f"https://civarchive.com/models/{model_id}?modelVersionId={version_id}"
-
- session = await self._get_session()
+
+ downloader = await get_downloader()
+ session = await downloader.session
async with session.get(url) as response:
if response.status != 200:
return None
From 9366d3d2d0c4f543fe66c56097e9f9fbdfa3630e Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Sun, 14 Sep 2025 22:57:17 +0800
Subject: [PATCH 012/110] feat: add API endpoint for fetching application
settings and update frontend settings management
---
py/routes/misc_routes.py | 42 +++
py/services/settings_manager.py | 1 -
settings.json.example | 1 -
static/js/core.js | 5 +
static/js/managers/SettingsManager.js | 498 ++++++++++++--------------
static/js/state/index.js | 10 +-
6 files changed, 291 insertions(+), 266 deletions(-)
diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py
index 013923b7..494eca9b 100644
--- a/py/routes/misc_routes.py
+++ b/py/routes/misc_routes.py
@@ -88,6 +88,7 @@ class MiscRoutes:
@staticmethod
def setup_routes(app):
"""Register miscellaneous routes"""
+ app.router.add_get('/api/settings', MiscRoutes.get_settings)
app.router.add_post('/api/settings', MiscRoutes.update_settings)
app.router.add_get('/api/health-check', lambda request: web.json_response({'status': 'ok'}))
@@ -119,6 +120,47 @@ class MiscRoutes:
app.router.add_post('/api/remove-metadata-archive', MiscRoutes.remove_metadata_archive)
app.router.add_get('/api/metadata-archive-status', MiscRoutes.get_metadata_archive_status)
+ @staticmethod
+ async def get_settings(request):
+ """Get application settings that should be synced to frontend"""
+ try:
+ # Define keys that should be synced from backend to frontend
+ sync_keys = [
+ 'civitai_api_key',
+ 'default_lora_root',
+ 'default_checkpoint_root',
+ 'default_embedding_root',
+ 'base_model_path_mappings',
+ 'download_path_templates',
+ 'enable_metadata_archive_db',
+ 'language',
+ 'proxy_enabled',
+ 'proxy_type',
+ 'proxy_host',
+ 'proxy_port',
+ 'proxy_username',
+ 'proxy_password'
+ ]
+
+ # Build response with only the keys that should be synced
+ response_data = {}
+ for key in sync_keys:
+ value = settings.get(key)
+ if value is not None:
+ response_data[key] = value
+
+ return web.json_response({
+ 'success': True,
+ 'settings': response_data
+ })
+
+ except Exception as e:
+ logger.error(f"Error getting settings: {e}", exc_info=True)
+ return web.json_response({
+ 'success': False,
+ 'error': str(e)
+ }, status=500)
+
@staticmethod
async def update_settings(request):
"""Update application settings"""
diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py
index 5a95e58e..ec71104e 100644
--- a/py/services/settings_manager.py
+++ b/py/services/settings_manager.py
@@ -80,7 +80,6 @@ class SettingsManager:
"""Return default settings"""
return {
"civitai_api_key": "",
- "show_only_sfw": False,
"language": "en",
"enable_metadata_archive_db": False, # Enable metadata archive database
"proxy_enabled": False, # Enable app-level proxy
diff --git a/settings.json.example b/settings.json.example
index 0765577b..673aa76d 100644
--- a/settings.json.example
+++ b/settings.json.example
@@ -1,6 +1,5 @@
{
"civitai_api_key": "your_civitai_api_key_here",
- "show_only_sfw": false,
"folder_paths": {
"loras": [
"C:/path/to/your/loras_folder",
diff --git a/static/js/core.js b/static/js/core.js
index d7daa0c5..779f3094 100644
--- a/static/js/core.js
+++ b/static/js/core.js
@@ -38,6 +38,11 @@ export class AppCore {
console.log(`AppCore: Language set: ${i18n.getCurrentLocale()}`);
+ // Initialize settings manager and wait for it to sync from backend
+ console.log('AppCore: Initializing settings...');
+ await settingsManager.waitForInitialization();
+ console.log('AppCore: Settings initialized');
+
// Initialize managers
state.loadingManager = new LoadingManager();
modalManager.initialize();
diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js
index 96e3064d..0767baab 100644
--- a/static/js/managers/SettingsManager.js
+++ b/static/js/managers/SettingsManager.js
@@ -10,122 +10,194 @@ export class SettingsManager {
constructor() {
this.initialized = false;
this.isOpen = false;
+ this.initializationPromise = null;
// Add initialization to sync with modal state
this.currentPage = document.body.dataset.page || 'loras';
- // Ensure settings are loaded from localStorage
- this.loadSettingsFromStorage();
-
- // Sync settings to backend if needed
- this.syncSettingsToBackendIfNeeded();
+ // Start initialization but don't await here to avoid blocking constructor
+ this.initializationPromise = this.initializeSettings();
this.initialize();
}
- loadSettingsFromStorage() {
+ // Add method to wait for initialization to complete
+ async waitForInitialization() {
+ if (this.initializationPromise) {
+ await this.initializationPromise;
+ }
+ }
+
+ async initializeSettings() {
+ // Load frontend-only settings from localStorage
+ this.loadFrontendSettingsFromStorage();
+
+ // Sync settings from backend to frontend
+ await this.syncSettingsFromBackend();
+ }
+
+ loadFrontendSettingsFromStorage() {
// Get saved settings from localStorage
const savedSettings = getStorageItem('settings');
- // Migrate legacy default_loras_root to default_lora_root if present
- if (savedSettings && savedSettings.default_loras_root && !savedSettings.default_lora_root) {
- savedSettings.default_lora_root = savedSettings.default_loras_root;
- delete savedSettings.default_loras_root;
- setStorageItem('settings', savedSettings);
- }
+ // Frontend-only settings that should be stored in localStorage
+ const frontendOnlyKeys = [
+ 'blurMatureContent',
+ 'show_only_sfw',
+ 'autoplayOnHover',
+ 'displayDensity',
+ 'cardInfoDisplay',
+ 'optimizeExampleImages',
+ 'autoDownloadExampleImages',
+ 'includeTriggerWords'
+ ];
- // Apply saved settings to state if available
+ // Apply saved frontend settings to state if available
if (savedSettings) {
- state.global.settings = { ...state.global.settings, ...savedSettings };
+ const frontendSettings = {};
+ frontendOnlyKeys.forEach(key => {
+ if (savedSettings[key] !== undefined) {
+ frontendSettings[key] = savedSettings[key];
+ }
+ });
+ state.global.settings = { ...state.global.settings, ...frontendSettings };
}
- // Initialize default values for new settings if they don't exist
- if (state.global.settings.compactMode === undefined) {
- state.global.settings.compactMode = false;
+ // Initialize default values for frontend settings if they don't exist
+ if (state.global.settings.blurMatureContent === undefined) {
+ state.global.settings.blurMatureContent = true;
+ }
+
+ if (state.global.settings.show_only_sfw === undefined) {
+ state.global.settings.show_only_sfw = false;
+ }
+
+ if (state.global.settings.autoplayOnHover === undefined) {
+ state.global.settings.autoplayOnHover = false;
}
- // Set default for optimizeExampleImages if undefined
if (state.global.settings.optimizeExampleImages === undefined) {
state.global.settings.optimizeExampleImages = true;
}
- // Set default for autoDownloadExampleImages if undefined
if (state.global.settings.autoDownloadExampleImages === undefined) {
state.global.settings.autoDownloadExampleImages = true;
}
- // Set default for cardInfoDisplay if undefined
if (state.global.settings.cardInfoDisplay === undefined) {
state.global.settings.cardInfoDisplay = 'always';
}
- // Set default for defaultCheckpointRoot if undefined
- if (state.global.settings.default_checkpoint_root === undefined) {
- state.global.settings.default_checkpoint_root = '';
- }
-
- // Convert old boolean compactMode to new displayDensity string
- if (typeof state.global.settings.displayDensity === 'undefined') {
+ if (state.global.settings.displayDensity === undefined) {
+ // Migrate legacy compactMode if it exists
if (state.global.settings.compactMode === true) {
state.global.settings.displayDensity = 'compact';
} else {
state.global.settings.displayDensity = 'default';
}
- // We can delete the old setting, but keeping it for backwards compatibility
}
- // Migrate legacy download_path_template to new structure
- if (state.global.settings.download_path_template && !state.global.settings.download_path_templates) {
- const legacyTemplate = state.global.settings.download_path_template;
- state.global.settings.download_path_templates = {
- lora: legacyTemplate,
- checkpoint: legacyTemplate,
- embedding: legacyTemplate
- };
- delete state.global.settings.download_path_template;
- setStorageItem('settings', state.global.settings);
- }
-
- // Set default for download path templates if undefined
- if (state.global.settings.download_path_templates === undefined) {
- state.global.settings.download_path_templates = { ...DEFAULT_PATH_TEMPLATES };
- }
-
- // Ensure all model types have templates
- Object.keys(DEFAULT_PATH_TEMPLATES).forEach(modelType => {
- if (typeof state.global.settings.download_path_templates[modelType] === 'undefined') {
- state.global.settings.download_path_templates[modelType] = DEFAULT_PATH_TEMPLATES[modelType];
- }
- });
-
- // Set default for base model path mappings if undefined
- if (state.global.settings.base_model_path_mappings === undefined) {
- state.global.settings.base_model_path_mappings = {};
- }
-
- // Set default for defaultEmbeddingRoot if undefined
- if (state.global.settings.default_embedding_root === undefined) {
- state.global.settings.default_embedding_root = '';
- }
-
- // Set default for includeTriggerWords if undefined
if (state.global.settings.includeTriggerWords === undefined) {
state.global.settings.includeTriggerWords = false;
}
+
+ // Save updated frontend settings to localStorage
+ this.saveFrontendSettingsToStorage();
}
- async syncSettingsToBackendIfNeeded() {
- // Get local settings from storage
- const localSettings = getStorageItem('settings') || {};
+ async syncSettingsFromBackend() {
+ try {
+ const response = await fetch('/api/settings');
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ if (data.success && data.settings) {
+ // Merge backend settings with current state
+ state.global.settings = { ...state.global.settings, ...data.settings };
+
+ // Set defaults for backend settings if they're null/undefined
+ this.setBackendSettingDefaults();
+
+ console.log('Settings synced from backend');
+ } else {
+ console.error('Failed to sync settings from backend:', data.error);
+ }
+ } catch (error) {
+ console.error('Failed to sync settings from backend:', error);
+ // Set defaults if backend sync fails
+ this.setBackendSettingDefaults();
+ }
+ }
- // Fields that need to be synced to backend
- const fieldsToSync = [
+ setBackendSettingDefaults() {
+ // Set defaults for backend settings
+ const backendDefaults = {
+ civitai_api_key: '',
+ default_lora_root: '',
+ default_checkpoint_root: '',
+ default_embedding_root: '',
+ base_model_path_mappings: {},
+ download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
+ enable_metadata_archive_db: false,
+ language: 'en',
+ proxy_enabled: false,
+ proxy_type: 'http',
+ proxy_host: '',
+ proxy_port: '',
+ proxy_username: '',
+ proxy_password: ''
+ };
+
+ Object.keys(backendDefaults).forEach(key => {
+ if (state.global.settings[key] === undefined || state.global.settings[key] === null) {
+ state.global.settings[key] = backendDefaults[key];
+ }
+ });
+
+ // Ensure all model types have templates
+ Object.keys(DEFAULT_PATH_TEMPLATES).forEach(modelType => {
+ if (!state.global.settings.download_path_templates[modelType]) {
+ state.global.settings.download_path_templates[modelType] = DEFAULT_PATH_TEMPLATES[modelType];
+ }
+ });
+ }
+
+ saveFrontendSettingsToStorage() {
+ // Save only frontend-specific settings to localStorage
+ const frontendOnlyKeys = [
+ 'blurMatureContent',
+ 'show_only_sfw',
+ 'autoplayOnHover',
+ 'displayDensity',
+ 'cardInfoDisplay',
+ 'optimizeExampleImages',
+ 'autoDownloadExampleImages',
+ 'includeTriggerWords'
+ ];
+
+ const frontendSettings = {};
+ frontendOnlyKeys.forEach(key => {
+ if (state.global.settings[key] !== undefined) {
+ frontendSettings[key] = state.global.settings[key];
+ }
+ });
+
+ setStorageItem('settings', frontendSettings);
+ }
+
+ // Helper method to determine if a setting should be saved to backend
+ isBackendSetting(settingKey) {
+ const backendKeys = [
'civitai_api_key',
'default_lora_root',
- 'default_checkpoint_root',
+ 'default_checkpoint_root',
'default_embedding_root',
'base_model_path_mappings',
'download_path_templates',
+ 'enable_metadata_archive_db',
+ 'language',
'proxy_enabled',
'proxy_type',
'proxy_host',
@@ -133,30 +205,38 @@ export class SettingsManager {
'proxy_username',
'proxy_password'
];
+ return backendKeys.includes(settingKey);
+ }
- // Build payload for syncing
- const payload = {};
+ // Helper method to save setting based on whether it's frontend or backend
+ async saveSetting(settingKey, value) {
+ // Update state
+ state.global.settings[settingKey] = value;
- fieldsToSync.forEach(key => {
- if (localSettings[key] !== undefined) {
- payload[key] = localSettings[key];
- }
- });
-
- // Only send request if there is something to sync
- if (Object.keys(payload).length > 0) {
+ if (this.isBackendSetting(settingKey)) {
+ // Save to backend
try {
- await fetch('/api/settings', {
+ const payload = {};
+ payload[settingKey] = value;
+
+ const response = await fetch('/api/settings', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: {
+ 'Content-Type': 'application/json',
+ },
body: JSON.stringify(payload)
});
- // Log success to console
- console.log('Settings synced to backend');
- } catch (e) {
- // Log error to console
- console.error('Failed to sync settings to backend:', e);
+
+ if (!response.ok) {
+ throw new Error('Failed to save setting to backend');
+ }
+ } catch (error) {
+ console.error(`Failed to save backend setting ${settingKey}:`, error);
+ throw error;
}
+ } else {
+ // Save frontend settings to localStorage
+ this.saveFrontendSettingsToStorage();
}
}
@@ -603,23 +683,8 @@ export class SettingsManager {
async saveBaseModelMappings() {
try {
- // Save to localStorage
- setStorageItem('settings', state.global.settings);
-
- // Save to backend
- const response = await fetch('/api/settings', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- base_model_path_mappings: state.global.settings.base_model_path_mappings
- })
- });
-
- if (!response.ok) {
- throw new Error('Failed to save base model mappings');
- }
+ // Save to backend using universal save method
+ await this.saveSetting('base_model_path_mappings', state.global.settings.base_model_path_mappings);
// Show success toast
const mappingCount = Object.keys(state.global.settings.base_model_path_mappings).length;
@@ -793,23 +858,8 @@ export class SettingsManager {
async saveDownloadPathTemplates() {
try {
- // Save to localStorage
- setStorageItem('settings', state.global.settings);
-
- // Save to backend
- const response = await fetch('/api/settings', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- download_path_templates: state.global.settings.download_path_templates
- })
- });
-
- if (!response.ok) {
- throw new Error('Failed to save download path templates');
- }
+ // Save to backend using universal save method
+ await this.saveSetting('download_path_templates', state.global.settings.download_path_templates);
showToast('toast.settings.downloadTemplatesUpdated', {}, 'success');
@@ -834,61 +884,40 @@ export class SettingsManager {
const value = element.checked;
- // Update frontend state
- if (settingKey === 'blur_mature_content') {
- state.global.settings.blurMatureContent = value;
- } else if (settingKey === 'show_only_sfw') {
- state.global.settings.show_only_sfw = value;
- } else if (settingKey === 'autoplay_on_hover') {
- state.global.settings.autoplayOnHover = value;
- } else if (settingKey === 'optimize_example_images') {
- state.global.settings.optimizeExampleImages = value;
- } else if (settingKey === 'auto_download_example_images') {
- state.global.settings.autoDownloadExampleImages = value;
- } else if (settingKey === 'compact_mode') {
- state.global.settings.compactMode = value;
- } else if (settingKey === 'include_trigger_words') {
- state.global.settings.includeTriggerWords = value;
- } else if (settingKey === 'enable_metadata_archive_db') {
- state.global.settings.enable_metadata_archive_db = value;
- } else if (settingKey === 'proxy_enabled') {
- state.global.settings.proxy_enabled = value;
-
- // Toggle visibility of proxy settings group
- const proxySettingsGroup = document.getElementById('proxySettingsGroup');
- if (proxySettingsGroup) {
- proxySettingsGroup.style.display = value ? 'block' : 'none';
- }
- } else {
- // For any other settings that might be added in the future
- state.global.settings[settingKey] = value;
- }
-
- // Save to localStorage
- setStorageItem('settings', state.global.settings);
-
try {
- // For backend settings, make API call
- if (['show_only_sfw', 'enable_metadata_archive_db', 'proxy_enabled'].includes(settingKey)) {
- const payload = {};
- payload[settingKey] = value;
+ // Update frontend state with mapped keys
+ if (settingKey === 'blur_mature_content') {
+ await this.saveSetting('blurMatureContent', value);
+ } else if (settingKey === 'show_only_sfw') {
+ await this.saveSetting('show_only_sfw', value);
+ } else if (settingKey === 'autoplay_on_hover') {
+ await this.saveSetting('autoplayOnHover', value);
+ } else if (settingKey === 'optimize_example_images') {
+ await this.saveSetting('optimizeExampleImages', value);
+ } else if (settingKey === 'auto_download_example_images') {
+ await this.saveSetting('autoDownloadExampleImages', value);
+ } else if (settingKey === 'compact_mode') {
+ await this.saveSetting('compactMode', value);
+ } else if (settingKey === 'include_trigger_words') {
+ await this.saveSetting('includeTriggerWords', value);
+ } else if (settingKey === 'enable_metadata_archive_db') {
+ await this.saveSetting('enable_metadata_archive_db', value);
+ } else if (settingKey === 'proxy_enabled') {
+ await this.saveSetting('proxy_enabled', value);
- const response = await fetch('/api/settings', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(payload)
- });
-
- if (!response.ok) {
- throw new Error('Failed to save setting');
- }
-
- // Refresh metadata archive status when enable setting changes
- if (settingKey === 'enable_metadata_archive_db') {
- await this.updateMetadataArchiveStatus();
+ // Toggle visibility of proxy settings group
+ const proxySettingsGroup = document.getElementById('proxySettingsGroup');
+ if (proxySettingsGroup) {
+ proxySettingsGroup.style.display = value ? 'block' : 'none';
}
+ } else {
+ // For any other settings that might be added in the future
+ await this.saveSetting(settingKey, value);
+ }
+
+ // Refresh metadata archive status when enable setting changes
+ if (settingKey === 'enable_metadata_archive_db') {
+ await this.updateMetadataArchiveStatus();
}
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
@@ -933,55 +962,31 @@ export class SettingsManager {
const value = element.value;
- // Update frontend state
- if (settingKey === 'default_lora_root') {
- state.global.settings.default_lora_root = value;
- } else if (settingKey === 'default_checkpoint_root') {
- state.global.settings.default_checkpoint_root = value;
- } else if (settingKey === 'default_embedding_root') {
- state.global.settings.default_embedding_root = value;
- } else if (settingKey === 'display_density') {
- state.global.settings.displayDensity = value;
-
- // Also update compactMode for backwards compatibility
- state.global.settings.compactMode = (value !== 'default');
- } else if (settingKey === 'card_info_display') {
- state.global.settings.cardInfoDisplay = value;
- } else if (settingKey === 'proxy_type') {
- state.global.settings.proxy_type = value;
- } else {
- // For any other settings that might be added in the future
- state.global.settings[settingKey] = value;
- }
-
- // Save to localStorage
- setStorageItem('settings', state.global.settings);
-
try {
- // For backend settings, make API call
- if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_templates' || settingKey.startsWith('proxy_')) {
- const payload = {};
- if (settingKey === 'download_path_templates') {
- payload[settingKey] = state.global.settings.download_path_templates;
- } else {
- payload[settingKey] = value;
- }
+ // Update frontend state with mapped keys
+ if (settingKey === 'default_lora_root') {
+ await this.saveSetting('default_lora_root', value);
+ } else if (settingKey === 'default_checkpoint_root') {
+ await this.saveSetting('default_checkpoint_root', value);
+ } else if (settingKey === 'default_embedding_root') {
+ await this.saveSetting('default_embedding_root', value);
+ } else if (settingKey === 'display_density') {
+ await this.saveSetting('displayDensity', value);
- const response = await fetch('/api/settings', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(payload)
- });
-
- if (!response.ok) {
- throw new Error('Failed to save setting');
- }
-
- showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
+ // Also update compactMode for backwards compatibility
+ state.global.settings.compactMode = (value !== 'default');
+ this.saveFrontendSettingsToStorage();
+ } else if (settingKey === 'card_info_display') {
+ await this.saveSetting('cardInfoDisplay', value);
+ } else if (settingKey === 'proxy_type') {
+ await this.saveSetting('proxy_type', value);
+ } else {
+ // For any other settings that might be added in the future
+ await this.saveSetting(settingKey, value);
}
+ showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
+
// Apply frontend settings immediately
this.applyFrontendSettings();
@@ -1167,9 +1172,8 @@ export class SettingsManager {
showToast('settings.metadataArchive.downloadSuccess', 'success');
- // Update settings in state
- state.global.settings.enable_metadata_archive_db = true;
- setStorageItem('settings', state.global.settings);
+ // Update settings using universal save method
+ await this.saveSetting('enable_metadata_archive_db', true);
// Update UI
const enableCheckbox = document.getElementById('enableMetadataArchive');
@@ -1223,9 +1227,8 @@ export class SettingsManager {
if (data.success) {
showToast('settings.metadataArchive.removeSuccess', 'success');
- // Update settings in state
- state.global.settings.enable_metadata_archive_db = false;
- setStorageItem('settings', state.global.settings);
+ // Update settings using universal save method
+ await this.saveSetting('enable_metadata_archive_db', false);
// Update UI
const enableCheckbox = document.getElementById('enableMetadataArchive');
@@ -1255,7 +1258,6 @@ export class SettingsManager {
const value = element.value.trim(); // Trim whitespace
- // For API key or other inputs that need to be saved on backend
try {
// Check if value has changed from existing value
const currentValue = state.global.settings[settingKey] || '';
@@ -1263,27 +1265,14 @@ export class SettingsManager {
return; // No change, exit early
}
- // For username and password, remove the setting if value is empty
+ // For username and password, handle empty values specially
if ((settingKey === 'proxy_username' || settingKey === 'proxy_password') && value === '') {
// Remove from state instead of setting to empty string
delete state.global.settings[settingKey];
- } else {
- // Update state with value (including empty strings for non-optional fields)
- state.global.settings[settingKey] = value;
- }
-
- setStorageItem('settings', state.global.settings);
-
- // For backend settings, make API call
- if (settingKey === 'civitai_api_key' || settingKey.startsWith('proxy_')) {
- const payload = {};
- // For username and password, send delete flag if empty to remove from backend
- if ((settingKey === 'proxy_username' || settingKey === 'proxy_password') && value === '') {
- payload[settingKey] = '__DELETE__';
- } else {
- payload[settingKey] = value;
- }
+ // Send delete flag to backend
+ const payload = {};
+ payload[settingKey] = '__DELETE__';
const response = await fetch('/api/settings', {
method: 'POST',
@@ -1294,8 +1283,11 @@ export class SettingsManager {
});
if (!response.ok) {
- throw new Error('Failed to save setting');
+ throw new Error('Failed to delete setting');
}
+ } else {
+ // Use the universal save method
+ await this.saveSetting(settingKey, value);
}
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
@@ -1312,26 +1304,8 @@ export class SettingsManager {
const selectedLanguage = element.value;
try {
- // Update local state
- state.global.settings.language = selectedLanguage;
-
- // Save to localStorage
- setStorageItem('settings', state.global.settings);
-
- // Save to backend
- const response = await fetch('/api/settings', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- language: selectedLanguage
- })
- });
-
- if (!response.ok) {
- throw new Error('Failed to save language setting to backend');
- }
+ // Use the universal save method for language (frontend-only setting)
+ await this.saveSetting('language', selectedLanguage);
// Reload the page to apply the new language
window.location.reload();
diff --git a/static/js/state/index.js b/static/js/state/index.js
index 71f6b859..9e48e86f 100644
--- a/static/js/state/index.js
+++ b/static/js/state/index.js
@@ -2,11 +2,17 @@
import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
-// Load settings from localStorage or use defaults
+// Load only frontend settings from localStorage with defaults
+// Backend settings will be loaded by SettingsManager from the backend
const savedSettings = getStorageItem('settings', {
blurMatureContent: true,
show_only_sfw: false,
- cardInfoDisplay: 'always'
+ cardInfoDisplay: 'always',
+ autoplayOnHover: false,
+ displayDensity: 'default',
+ optimizeExampleImages: true,
+ autoDownloadExampleImages: true,
+ includeTriggerWords: false
});
// Load preview versions from localStorage for each model type
From 2f7e44a76fbae4a592b82f15baff1ef60a36f3b2 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Mon, 15 Sep 2025 10:30:06 +0800
Subject: [PATCH 013/110] refactor(settings): Update synchronization logic
---
locales/de.json | 1 +
locales/en.json | 1 +
locales/es.json | 1 +
locales/fr.json | 1 +
locales/ja.json | 1 +
locales/ko.json | 1 +
locales/ru.json | 1 +
locales/zh-CN.json | 19 ++--
locales/zh-TW.json | 1 +
py/routes/misc_routes.py | 5 +-
static/js/core.js | 4 +-
static/js/managers/ExampleImagesManager.js | 104 ++++++---------------
static/js/managers/SettingsManager.js | 34 +++----
13 files changed, 72 insertions(+), 102 deletions(-)
diff --git a/locales/de.json b/locales/de.json
index f0125334..0f667920 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -1166,6 +1166,7 @@
},
"exampleImages": {
"pathUpdated": "Beispielbilder-Pfad erfolgreich aktualisiert",
+ "pathUpdateFailed": "Fehler beim Aktualisieren des Beispielbilder-Pfads: {message}",
"downloadInProgress": "Download bereits in Bearbeitung",
"enterLocationFirst": "Bitte geben Sie zuerst einen Download-Speicherort ein",
"downloadStarted": "Beispielbilder-Download gestartet",
diff --git a/locales/en.json b/locales/en.json
index 24e4704e..306ba26b 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -1166,6 +1166,7 @@
},
"exampleImages": {
"pathUpdated": "Example images path updated successfully",
+ "pathUpdateFailed": "Failed to update example images path: {message}",
"downloadInProgress": "Download already in progress",
"enterLocationFirst": "Please enter a download location first",
"downloadStarted": "Example images download started",
diff --git a/locales/es.json b/locales/es.json
index a3e04cd8..f8aa33e9 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -1166,6 +1166,7 @@
},
"exampleImages": {
"pathUpdated": "Ruta de imágenes de ejemplo actualizada exitosamente",
+ "pathUpdateFailed": "Error al actualizar la ruta de imágenes de ejemplo: {message}",
"downloadInProgress": "Descarga ya en progreso",
"enterLocationFirst": "Por favor introduce primero una ubicación de descarga",
"downloadStarted": "Descarga de imágenes de ejemplo iniciada",
diff --git a/locales/fr.json b/locales/fr.json
index 38a6b1f5..7fc405b8 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -1166,6 +1166,7 @@
},
"exampleImages": {
"pathUpdated": "Chemin des images d'exemple mis à jour avec succès",
+ "pathUpdateFailed": "Échec de la mise à jour du chemin des images d'exemple : {message}",
"downloadInProgress": "Téléchargement déjà en cours",
"enterLocationFirst": "Veuillez d'abord entrer un emplacement de téléchargement",
"downloadStarted": "Téléchargement des images d'exemple démarré",
diff --git a/locales/ja.json b/locales/ja.json
index f8bdefc6..80488619 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -1166,6 +1166,7 @@
},
"exampleImages": {
"pathUpdated": "例画像パスが正常に更新されました",
+ "pathUpdateFailed": "例画像パスの更新に失敗しました:{message}",
"downloadInProgress": "ダウンロードは既に進行中です",
"enterLocationFirst": "最初にダウンロード場所を入力してください",
"downloadStarted": "例画像のダウンロードが開始されました",
diff --git a/locales/ko.json b/locales/ko.json
index 08ab8e38..bb8dbe37 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -1166,6 +1166,7 @@
},
"exampleImages": {
"pathUpdated": "예시 이미지 경로가 성공적으로 업데이트되었습니다",
+ "pathUpdateFailed": "예시 이미지 경로 업데이트 실패: {message}",
"downloadInProgress": "이미 다운로드가 진행 중입니다",
"enterLocationFirst": "먼저 다운로드 위치를 입력해주세요",
"downloadStarted": "예시 이미지 다운로드가 시작되었습니다",
diff --git a/locales/ru.json b/locales/ru.json
index 522ac612..99f61f5e 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -1166,6 +1166,7 @@
},
"exampleImages": {
"pathUpdated": "Путь к примерам изображений успешно обновлен",
+ "pathUpdateFailed": "Не удалось обновить путь к примерам изображений: {message}",
"downloadInProgress": "Загрузка уже в процессе",
"enterLocationFirst": "Пожалуйста, сначала введите место загрузки",
"downloadStarted": "Загрузка примеров изображений начата",
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index 5884f1fc..7c01d9ac 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -21,17 +21,17 @@
"disabled": "已禁用"
},
"language": {
- "select": "语言",
- "select_help": "选择你喜欢的界面语言",
+ "select": "Language",
+ "select_help": "Choose your preferred language for the interface",
"english": "English",
"chinese_simplified": "中文(简体)",
"chinese_traditional": "中文(繁体)",
- "russian": "俄语",
- "german": "德语",
- "japanese": "日语",
- "korean": "韩语",
- "french": "法语",
- "spanish": "西班牙语"
+ "russian": "Русский",
+ "german": "Deutsch",
+ "japanese": "日本語",
+ "korean": "한국어",
+ "french": "Français",
+ "spanish": "Español"
},
"fileSize": {
"zero": "0 字节",
@@ -311,7 +311,7 @@
"proxyHostPlaceholder": "proxy.example.com",
"proxyHostHelp": "代理服务器的主机名或IP地址",
"proxyPort": "代理端口",
- "proxyPortPlaceholder": "8080",
+ "proxyPortPlaceholder": "8080",
"proxyPortHelp": "代理服务器的端口号",
"proxyUsername": "用户名 (可选)",
"proxyUsernamePlaceholder": "用户名",
@@ -1166,6 +1166,7 @@
},
"exampleImages": {
"pathUpdated": "示例图片路径更新成功",
+ "pathUpdateFailed": "更新示例图片路径失败:{message}",
"downloadInProgress": "下载已在进行中",
"enterLocationFirst": "请先输入下载位置",
"downloadStarted": "示例图片下载已开始",
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index 5e982e5f..4846649c 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -1166,6 +1166,7 @@
},
"exampleImages": {
"pathUpdated": "範例圖片路徑已更新",
+ "pathUpdateFailed": "更新範例圖片路徑失敗:{message}",
"downloadInProgress": "下載已在進行中",
"enterLocationFirst": "請先輸入下載位置",
"downloadStarted": "範例圖片下載已開始",
diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py
index 494eca9b..32f51f68 100644
--- a/py/routes/misc_routes.py
+++ b/py/routes/misc_routes.py
@@ -139,7 +139,10 @@ class MiscRoutes:
'proxy_host',
'proxy_port',
'proxy_username',
- 'proxy_password'
+ 'proxy_password',
+ 'example_images_path',
+ 'optimizeExampleImages',
+ 'autoDownloadExampleImages'
]
# Build response with only the keys that should be synced
diff --git a/static/js/core.js b/static/js/core.js
index 779f3094..c2ad6cdb 100644
--- a/static/js/core.js
+++ b/static/js/core.js
@@ -7,7 +7,7 @@ import { HeaderManager } from './components/Header.js';
import { settingsManager } from './managers/SettingsManager.js';
import { moveManager } from './managers/MoveManager.js';
import { bulkManager } from './managers/BulkManager.js';
-import { exampleImagesManager } from './managers/ExampleImagesManager.js';
+import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
import { helpManager } from './managers/HelpManager.js';
import { bannerService } from './managers/BannerService.js';
import { initTheme, initBackToTop } from './utils/uiHelpers.js';
@@ -50,7 +50,7 @@ export class AppCore {
bannerService.initialize();
window.modalManager = modalManager;
window.settingsManager = settingsManager;
- window.exampleImagesManager = exampleImagesManager;
+ window.exampleImagesManager = new ExampleImagesManager();
window.helpManager = helpManager;
window.moveManager = moveManager;
window.bulkManager = bulkManager;
diff --git a/static/js/managers/ExampleImagesManager.js b/static/js/managers/ExampleImagesManager.js
index 60006400..5ef58702 100644
--- a/static/js/managers/ExampleImagesManager.js
+++ b/static/js/managers/ExampleImagesManager.js
@@ -1,9 +1,10 @@
import { showToast } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
+import { settingsManager } from './SettingsManager.js';
// ExampleImagesManager.js
-class ExampleImagesManager {
+export class ExampleImagesManager {
constructor() {
this.isDownloading = false;
this.isPaused = false;
@@ -27,7 +28,12 @@ class ExampleImagesManager {
}
// Initialize the manager
- initialize() {
+ async initialize() {
+ // Wait for settings to be initialized before proceeding
+ if (window.settingsManager) {
+ await window.settingsManager.waitForInitialization();
+ }
+
// Initialize event listeners
this.initEventListeners();
@@ -78,86 +84,41 @@ class ExampleImagesManager {
// Get custom path input element
const pathInput = document.getElementById('exampleImagesPath');
- // Set path from storage if available
- const savedPath = getStorageItem('example_images_path', '');
- if (savedPath) {
+ // Set path from backend settings
+ const savedPath = state.global.settings.example_images_path || '';
+ if (pathInput) {
pathInput.value = savedPath;
// Enable download button if path is set
- this.updateDownloadButtonState(true);
-
- // Sync the saved path with the backend
- try {
- const response = await fetch('/api/settings', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- example_images_path: savedPath
- })
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! Status: ${response.status}`);
- }
-
- const data = await response.json();
- if (!data.success) {
- console.error('Failed to sync example images path with backend:', data.error);
- }
- } catch (error) {
- console.error('Failed to sync saved path with backend:', error);
- }
- } else {
- // Disable download button if no path is set
- this.updateDownloadButtonState(false);
+ this.updateDownloadButtonState(!!savedPath);
}
// Add event listener to validate path input
- pathInput.addEventListener('input', async () => {
- const hasPath = pathInput.value.trim() !== '';
- this.updateDownloadButtonState(hasPath);
-
- // Save path to storage when changed
- if (hasPath) {
- setStorageItem('example_images_path', pathInput.value);
+ if (pathInput) {
+ pathInput.addEventListener('input', async () => {
+ const hasPath = pathInput.value.trim() !== '';
+ this.updateDownloadButtonState(hasPath);
- // Update path in backend settings
+ // Update path in backend settings using settingsManager
try {
- const response = await fetch('/api/settings', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- example_images_path: pathInput.value
- })
- });
-
- if (!response.ok) {
- throw new Error(`HTTP error! Status: ${response.status}`);
- }
-
- const data = await response.json();
- if (!data.success) {
- console.error('Failed to update example images path in backend:', data.error);
- } else {
+ await settingsManager.saveSetting('example_images_path', pathInput.value);
+ if (hasPath) {
showToast('toast.exampleImages.pathUpdated', {}, 'success');
}
} catch (error) {
console.error('Failed to update example images path:', error);
+ showToast('toast.exampleImages.pathUpdateFailed', { message: error.message }, 'error');
}
- }
- // Setup or clear auto download based on path availability
- if (state.global.settings.autoDownloadExampleImages) {
- if (hasPath) {
- this.setupAutoDownload();
- } else {
- this.clearAutoDownload();
+ // Setup or clear auto download based on path availability
+ if (state.global.settings.autoDownloadExampleImages) {
+ if (hasPath) {
+ this.setupAutoDownload();
+ } else {
+ this.clearAutoDownload();
+ }
}
- }
- });
+ });
+ }
} catch (error) {
console.error('Failed to initialize path options:', error);
}
@@ -255,7 +216,7 @@ class ExampleImagesManager {
return;
}
- const optimize = document.getElementById('optimizeExampleImages').checked;
+ const optimize = state.global.settings.optimizeExampleImages;
const response = await fetch('/api/download-example-images', {
method: 'POST',
@@ -746,7 +707,7 @@ class ExampleImagesManager {
console.log('Performing auto download check...');
const outputDir = document.getElementById('exampleImagesPath').value;
- const optimize = document.getElementById('optimizeExampleImages').checked;
+ const optimize = state.global.settings.optimizeExampleImages;
const response = await fetch('/api/download-example-images', {
method: 'POST',
@@ -771,6 +732,3 @@ class ExampleImagesManager {
}
}
}
-
-// Create singleton instance
-export const exampleImagesManager = new ExampleImagesManager();
diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js
index 0767baab..bafb520d 100644
--- a/static/js/managers/SettingsManager.js
+++ b/static/js/managers/SettingsManager.js
@@ -47,8 +47,6 @@ export class SettingsManager {
'autoplayOnHover',
'displayDensity',
'cardInfoDisplay',
- 'optimizeExampleImages',
- 'autoDownloadExampleImages',
'includeTriggerWords'
];
@@ -76,14 +74,6 @@ export class SettingsManager {
state.global.settings.autoplayOnHover = false;
}
- if (state.global.settings.optimizeExampleImages === undefined) {
- state.global.settings.optimizeExampleImages = true;
- }
-
- if (state.global.settings.autoDownloadExampleImages === undefined) {
- state.global.settings.autoDownloadExampleImages = true;
- }
-
if (state.global.settings.cardInfoDisplay === undefined) {
state.global.settings.cardInfoDisplay = 'always';
}
@@ -147,7 +137,10 @@ export class SettingsManager {
proxy_host: '',
proxy_port: '',
proxy_username: '',
- proxy_password: ''
+ proxy_password: '',
+ example_images_path: '',
+ optimizeExampleImages: true,
+ autoDownloadExampleImages: true
};
Object.keys(backendDefaults).forEach(key => {
@@ -172,8 +165,6 @@ export class SettingsManager {
'autoplayOnHover',
'displayDensity',
'cardInfoDisplay',
- 'optimizeExampleImages',
- 'autoDownloadExampleImages',
'includeTriggerWords'
];
@@ -203,7 +194,10 @@ export class SettingsManager {
'proxy_host',
'proxy_port',
'proxy_username',
- 'proxy_password'
+ 'proxy_password',
+ 'example_images_path',
+ 'optimizeExampleImages',
+ 'autoDownloadExampleImages'
];
return backendKeys.includes(settingKey);
}
@@ -218,7 +212,7 @@ export class SettingsManager {
try {
const payload = {};
payload[settingKey] = value;
-
+
const response = await fetch('/api/settings', {
method: 'POST',
headers: {
@@ -230,6 +224,12 @@ export class SettingsManager {
if (!response.ok) {
throw new Error('Failed to save setting to backend');
}
+
+ // Parse response and check for success
+ const data = await response.json();
+ if (data.success === false) {
+ throw new Error(data.error || 'Failed to save setting to backend');
+ }
} catch (error) {
console.error(`Failed to save backend setting ${settingKey}:`, error);
throw error;
@@ -985,8 +985,6 @@ export class SettingsManager {
await this.saveSetting(settingKey, value);
}
- showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
-
// Apply frontend settings immediately
this.applyFrontendSettings();
@@ -999,8 +997,10 @@ export class SettingsManager {
if (value === 'compact') densityName = "Compact";
showToast('toast.settings.displayDensitySet', { density: densityName }, 'success');
+ return;
}
+ showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
} catch (error) {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
From 26891e12a4e87407771eec83e6055e4b44add509 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Mon, 15 Sep 2025 11:34:39 +0800
Subject: [PATCH 014/110] refactor(ExampleImagesManager): enhance path input
handling with Enter key and blur events
---
static/js/managers/ExampleImagesManager.js | 26 ++++++++++++++++++----
1 file changed, 22 insertions(+), 4 deletions(-)
diff --git a/static/js/managers/ExampleImagesManager.js b/static/js/managers/ExampleImagesManager.js
index 5ef58702..38150749 100644
--- a/static/js/managers/ExampleImagesManager.js
+++ b/static/js/managers/ExampleImagesManager.js
@@ -94,11 +94,10 @@ export class ExampleImagesManager {
// Add event listener to validate path input
if (pathInput) {
- pathInput.addEventListener('input', async () => {
+ // Save path on Enter key or blur
+ const savePath = async () => {
const hasPath = pathInput.value.trim() !== '';
this.updateDownloadButtonState(hasPath);
-
- // Update path in backend settings using settingsManager
try {
await settingsManager.saveSetting('example_images_path', pathInput.value);
if (hasPath) {
@@ -108,7 +107,6 @@ export class ExampleImagesManager {
console.error('Failed to update example images path:', error);
showToast('toast.exampleImages.pathUpdateFailed', { message: error.message }, 'error');
}
-
// Setup or clear auto download based on path availability
if (state.global.settings.autoDownloadExampleImages) {
if (hasPath) {
@@ -117,6 +115,26 @@ export class ExampleImagesManager {
this.clearAutoDownload();
}
}
+ };
+ let ignoreNextBlur = false;
+ pathInput.addEventListener('keydown', async (e) => {
+ if (e.key === 'Enter') {
+ ignoreNextBlur = true;
+ await savePath();
+ pathInput.blur(); // Remove focus from the input after saving
+ }
+ });
+ pathInput.addEventListener('blur', async () => {
+ if (ignoreNextBlur) {
+ ignoreNextBlur = false;
+ return;
+ }
+ await savePath();
+ });
+ // Still update button state on input, but don't save
+ pathInput.addEventListener('input', () => {
+ const hasPath = pathInput.value.trim() !== '';
+ this.updateDownloadButtonState(hasPath);
});
}
} catch (error) {
From 1147725fd74de646b563f408923b4272dc90c098 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Mon, 15 Sep 2025 12:23:46 +0800
Subject: [PATCH 015/110] feat(settings): add base model, author, and first tag
option to download path templates refactor(constants): reorder preset tag
suggestions for consistency
---
locales/de.json | 1 +
locales/en.json | 1 +
locales/es.json | 1 +
locales/fr.json | 1 +
locales/ja.json | 1 +
locales/ko.json | 1 +
locales/ru.json | 1 +
locales/zh-CN.json | 1 +
locales/zh-TW.json | 1 +
py/utils/constants.py | 6 +++---
static/js/utils/constants.js | 10 ++++++++--
templates/components/modals/settings_modal.html | 3 +++
12 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/locales/de.json b/locales/de.json
index 0f667920..bcff9fc9 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -240,6 +240,7 @@
"baseModelFirstTag": "Basis-Modell + Erster Tag",
"baseModelAuthor": "Basis-Modell + Autor",
"authorFirstTag": "Autor + Erster Tag",
+ "baseModelAuthorFirstTag": "Basis-Modell + Autor + Erster Tag",
"customTemplate": "Benutzerdefinierte Vorlage"
},
"customTemplatePlaceholder": "Benutzerdefinierte Vorlage eingeben (z.B. {base_model}/{author}/{first_tag})",
diff --git a/locales/en.json b/locales/en.json
index 306ba26b..237755c0 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -240,6 +240,7 @@
"baseModelFirstTag": "Base Model + First Tag",
"baseModelAuthor": "Base Model + Author",
"authorFirstTag": "Author + First Tag",
+ "baseModelAuthorFirstTag": "Base Model + Author + First Tag",
"customTemplate": "Custom Template"
},
"customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})",
diff --git a/locales/es.json b/locales/es.json
index f8aa33e9..a4a8f401 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -240,6 +240,7 @@
"baseModelFirstTag": "Modelo base + primera etiqueta",
"baseModelAuthor": "Modelo base + autor",
"authorFirstTag": "Autor + primera etiqueta",
+ "baseModelAuthorFirstTag": "Modelo base + autor + primera etiqueta",
"customTemplate": "Plantilla personalizada"
},
"customTemplatePlaceholder": "Introduce plantilla personalizada (ej., {base_model}/{author}/{first_tag})",
diff --git a/locales/fr.json b/locales/fr.json
index 7fc405b8..b492d805 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -240,6 +240,7 @@
"baseModelFirstTag": "Modèle de base + Premier tag",
"baseModelAuthor": "Modèle de base + Auteur",
"authorFirstTag": "Auteur + Premier tag",
+ "baseModelAuthorFirstTag": "Modèle de base + Auteur + Premier tag",
"customTemplate": "Modèle personnalisé"
},
"customTemplatePlaceholder": "Entrez un modèle personnalisé (ex: {base_model}/{author}/{first_tag})",
diff --git a/locales/ja.json b/locales/ja.json
index 80488619..f2f889d4 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -240,6 +240,7 @@
"baseModelFirstTag": "ベースモデル + 最初のタグ",
"baseModelAuthor": "ベースモデル + 作成者",
"authorFirstTag": "作成者 + 最初のタグ",
+ "baseModelAuthorFirstTag": "ベースモデル + 作成者 + 最初のタグ",
"customTemplate": "カスタムテンプレート"
},
"customTemplatePlaceholder": "カスタムテンプレートを入力(例:{base_model}/{author}/{first_tag})",
diff --git a/locales/ko.json b/locales/ko.json
index bb8dbe37..4f329b89 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -240,6 +240,7 @@
"baseModelFirstTag": "베이스 모델 + 첫 번째 태그",
"baseModelAuthor": "베이스 모델 + 제작자",
"authorFirstTag": "제작자 + 첫 번째 태그",
+ "baseModelAuthorFirstTag": "베이스 모델 + 제작자 + 첫 번째 태그",
"customTemplate": "사용자 정의 템플릿"
},
"customTemplatePlaceholder": "사용자 정의 템플릿 입력 (예: {base_model}/{author}/{first_tag})",
diff --git a/locales/ru.json b/locales/ru.json
index 99f61f5e..a0e78b34 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -240,6 +240,7 @@
"baseModelFirstTag": "Базовая модель + Первый тег",
"baseModelAuthor": "Базовая модель + Автор",
"authorFirstTag": "Автор + Первый тег",
+ "baseModelAuthorFirstTag": "Базовая модель + Автор + Первый тег",
"customTemplate": "Пользовательский шаблон"
},
"customTemplatePlaceholder": "Введите пользовательский шаблон (например, {base_model}/{author}/{first_tag})",
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index 7c01d9ac..9b713351 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -240,6 +240,7 @@
"baseModelFirstTag": "基础模型 + 首标签",
"baseModelAuthor": "基础模型 + 作者",
"authorFirstTag": "作者 + 首标签",
+ "baseModelAuthorFirstTag": "基础模型 + 作者 + 首标签",
"customTemplate": "自定义模板"
},
"customTemplatePlaceholder": "输入自定义模板(如:{base_model}/{author}/{first_tag})",
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index 4846649c..1c70b7c3 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -240,6 +240,7 @@
"baseModelFirstTag": "基礎模型 + 第一標籤",
"baseModelAuthor": "基礎模型 + 作者",
"authorFirstTag": "作者 + 第一標籤",
+ "baseModelAuthorFirstTag": "基礎模型 + 作者 + 第一標籤",
"customTemplate": "自訂範本"
},
"customTemplatePlaceholder": "輸入自訂範本(例如:{base_model}/{author}/{first_tag})",
diff --git a/py/utils/constants.py b/py/utils/constants.py
index 88932766..243badff 100644
--- a/py/utils/constants.py
+++ b/py/utils/constants.py
@@ -53,8 +53,8 @@ AUTO_ORGANIZE_BATCH_SIZE = 50 # Process models in batches to avoid overwhelming
# Civitai model tags in priority order for subfolder organization
CIVITAI_MODEL_TAGS = [
- 'character', 'style', 'concept', 'clothing',
- 'realistic', 'anime', 'toon', 'furry',
- 'poses', 'background', 'tool', 'vehicle', 'buildings',
+ 'character', 'concept', 'clothing',
+ 'realistic', 'anime', 'toon', 'furry', 'style',
+ 'poses', 'background', 'tool', 'vehicle', 'buildings',
'objects', 'assets', 'animal', 'action'
]
\ No newline at end of file
diff --git a/static/js/utils/constants.js b/static/js/utils/constants.js
index d27d6283..5690f904 100644
--- a/static/js/utils/constants.js
+++ b/static/js/utils/constants.js
@@ -92,6 +92,12 @@ export const DOWNLOAD_PATH_TEMPLATES = {
description: 'Organize by base model and author',
example: 'Flux.1 D/authorname/model-name.safetensors'
},
+ BASE_MODEL_AUTHOR_TAG: {
+ value: '{base_model}/{author}/{first_tag}',
+ label: 'Base Model + Author + First Tag',
+ description: 'Organize by base model, author, and primary tag',
+ example: 'Flux.1 D/authorname/style/model-name.safetensors'
+ },
AUTHOR_TAG: {
value: '{author}/{first_tag}',
label: 'Author + First Tag',
@@ -189,8 +195,8 @@ export const BASE_MODEL_CATEGORIES = {
// Preset tag suggestions
export const PRESET_TAGS = [
- 'character', 'style', 'concept', 'clothing',
- 'realistic', 'anime', 'toon', 'furry',
+ 'character', 'concept', 'clothing',
+ 'realistic', 'anime', 'toon', 'furry', 'style',
'poses', 'background', 'vehicle', 'buildings',
'objects', 'animal'
];
diff --git a/templates/components/modals/settings_modal.html b/templates/components/modals/settings_modal.html
index 09fea0c9..1cba359d 100644
--- a/templates/components/modals/settings_modal.html
+++ b/templates/components/modals/settings_modal.html
@@ -254,6 +254,7 @@
+
@@ -280,6 +281,7 @@
+
@@ -306,6 +308,7 @@
+
From 2b847039d4e7425a65c2487bee2414be74f42b69 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Mon, 15 Sep 2025 15:38:01 +0800
Subject: [PATCH 016/110] refactor(settings-modal): adjust font size for path
template preview
---
static/css/components/modal/settings-modal.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/static/css/components/modal/settings-modal.css b/static/css/components/modal/settings-modal.css
index 3f59203e..8165bb67 100644
--- a/static/css/components/modal/settings-modal.css
+++ b/static/css/components/modal/settings-modal.css
@@ -376,7 +376,7 @@ input:checked + .toggle-slider:before {
padding: var(--space-1);
margin-top: 8px;
font-family: monospace;
- font-size: 1.1em;
+ font-size: 0.9em;
color: var(--lora-accent);
display: none;
}
From c49be91aa0f9c6fd787c08fdcfcea0e21d3e0607 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Mon, 15 Sep 2025 16:04:20 +0800
Subject: [PATCH 017/110] refactor(update_routes): exclude civitai folder from
plugin update process
---
py/routes/update_routes.py | 20 ++++++++++++++------
1 file changed, 14 insertions(+), 6 deletions(-)
diff --git a/py/routes/update_routes.py b/py/routes/update_routes.py
index d139ce77..bf77baaf 100644
--- a/py/routes/update_routes.py
+++ b/py/routes/update_routes.py
@@ -154,7 +154,7 @@ class UpdateRoutes:
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
"""
Download latest release ZIP from GitHub and replace plugin files.
- Skips settings.json. Writes extracted file list to .tracking.
+ Skips settings.json and civitai folder. Writes extracted file list to .tracking.
"""
repo_owner = "willmiao"
repo_name = "ComfyUI-Lora-Manager"
@@ -193,7 +193,8 @@ class UpdateRoutes:
zip_path = tmp_zip_path
- UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json'])
+ # Skip both settings.json and civitai folder
+ UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai'])
# Extract ZIP to temp dir
with tempfile.TemporaryDirectory() as tmp_dir:
@@ -202,17 +203,17 @@ class UpdateRoutes:
# Find extracted folder (GitHub ZIP contains a root folder)
extracted_root = next(os.scandir(tmp_dir)).path
- # Copy files, skipping settings.json
+ # Copy files, skipping settings.json and civitai folder
for item in os.listdir(extracted_root):
+ if item == 'settings.json' or item == 'civitai':
+ continue
src = os.path.join(extracted_root, item)
dst = os.path.join(plugin_root, item)
if os.path.isdir(src):
if os.path.exists(dst):
shutil.rmtree(dst)
- shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json'))
+ shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json', 'civitai'))
else:
- if item == 'settings.json':
- continue
shutil.copy2(src, dst)
# Write .tracking file: list all files under extracted_root, relative to extracted_root
@@ -220,8 +221,15 @@ class UpdateRoutes:
tracking_info_file = os.path.join(plugin_root, '.tracking')
tracking_files = []
for root, dirs, files in os.walk(extracted_root):
+ # Skip civitai folder and its contents
+ rel_root = os.path.relpath(root, extracted_root)
+ if rel_root == 'civitai' or rel_root.startswith('civitai' + os.sep):
+ continue
for file in files:
rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
+ # Skip settings.json and any file under civitai
+ if rel_path == 'settings.json' or rel_path.startswith('civitai' + os.sep):
+ continue
tracking_files.append(rel_path.replace("\\", "/"))
with open(tracking_info_file, "w", encoding='utf-8') as file:
file.write('\n'.join(tracking_files))
From 4bb8981e78cf1181abd95fdda755065d1fe0d735 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Mon, 15 Sep 2025 16:22:59 +0800
Subject: [PATCH 018/110] refactor(routes): update API endpoints for settings
to use '/api/lm/settings', see #435
---
py/routes/misc_routes.py | 4 ++--
static/js/managers/OnboardingManager.js | 2 +-
static/js/managers/SettingsManager.js | 6 +++---
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py
index 32f51f68..89fadace 100644
--- a/py/routes/misc_routes.py
+++ b/py/routes/misc_routes.py
@@ -88,8 +88,8 @@ class MiscRoutes:
@staticmethod
def setup_routes(app):
"""Register miscellaneous routes"""
- app.router.add_get('/api/settings', MiscRoutes.get_settings)
- app.router.add_post('/api/settings', MiscRoutes.update_settings)
+ app.router.add_get('/api/lm/settings', MiscRoutes.get_settings)
+ app.router.add_post('/api/lm/settings', MiscRoutes.update_settings)
app.router.add_get('/api/health-check', lambda request: web.json_response({'status': 'ok'}))
diff --git a/static/js/managers/OnboardingManager.js b/static/js/managers/OnboardingManager.js
index ccbb971f..047cd000 100644
--- a/static/js/managers/OnboardingManager.js
+++ b/static/js/managers/OnboardingManager.js
@@ -186,7 +186,7 @@ export class OnboardingManager {
setStorageItem('settings', state.global.settings);
// Save to backend
- const response = await fetch('/api/settings', {
+ const response = await fetch('/api/lm/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js
index bafb520d..33040aac 100644
--- a/static/js/managers/SettingsManager.js
+++ b/static/js/managers/SettingsManager.js
@@ -97,7 +97,7 @@ export class SettingsManager {
async syncSettingsFromBackend() {
try {
- const response = await fetch('/api/settings');
+ const response = await fetch('/api/lm/settings');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -213,7 +213,7 @@ export class SettingsManager {
const payload = {};
payload[settingKey] = value;
- const response = await fetch('/api/settings', {
+ const response = await fetch('/api/lm/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1274,7 +1274,7 @@ export class SettingsManager {
const payload = {};
payload[settingKey] = '__DELETE__';
- const response = await fetch('/api/settings', {
+ const response = await fetch('/api/lm/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
From 4540e47055fd6d9b930fa456cbd9303b54065b30 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Mon, 15 Sep 2025 18:07:22 +0800
Subject: [PATCH 019/110] refactor(baseModelApi): update example images path
retrieval to use state settings
---
static/js/api/baseModelApi.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js
index 2a7c058d..0e77823c 100644
--- a/static/js/api/baseModelApi.js
+++ b/static/js/api/baseModelApi.js
@@ -938,8 +938,8 @@ export class BaseModelApiClient {
ws.onerror = reject;
});
- // Get the output directory from storage
- const outputDir = getStorageItem('example_images_path', '');
+ // Get the output directory from state
+ const outputDir = state.global?.settings?.example_images_path || '';
if (!outputDir) {
throw new Error('Please set the example images path in the settings first.');
}
From 6f9245df0130ebc116dcdef20a230e6f1a64f18d Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Mon, 15 Sep 2025 18:53:04 +0800
Subject: [PATCH 020/110] refactor(downloader): enhance download_to_memory to
return response headers and improve error handling
---
py/services/downloader.py | 27 ++--
py/utils/example_images_processor.py | 182 +++++++++++++++++----------
2 files changed, 136 insertions(+), 73 deletions(-)
diff --git a/py/services/downloader.py b/py/services/downloader.py
index dd2c8c32..4f6b5f97 100644
--- a/py/services/downloader.py
+++ b/py/services/downloader.py
@@ -366,8 +366,9 @@ class Downloader:
self,
url: str,
use_auth: bool = False,
- custom_headers: Optional[Dict[str, str]] = None
- ) -> Tuple[bool, Union[bytes, str]]:
+ custom_headers: Optional[Dict[str, str]] = None,
+ return_headers: bool = False
+ ) -> Tuple[bool, Union[bytes, str], Optional[Dict]]:
"""
Download a file to memory (for small files like preview images)
@@ -375,9 +376,10 @@ class Downloader:
url: Download URL
use_auth: Whether to include authentication headers
custom_headers: Additional headers to include in request
+ return_headers: Whether to return response headers along with content
Returns:
- Tuple[bool, Union[bytes, str]]: (success, content or error message)
+ Tuple[bool, Union[bytes, str], Optional[Dict]]: (success, content or error message, response headers if requested)
"""
try:
session = await self.session
@@ -395,19 +397,26 @@ class Downloader:
async with session.get(url, headers=headers, proxy=self.proxy_url) as response:
if response.status == 200:
content = await response.read()
- return True, content
+ if return_headers:
+ return True, content, dict(response.headers)
+ else:
+ return True, content, None
elif response.status == 401:
- return False, "Unauthorized access - invalid or missing API key"
+ error_msg = "Unauthorized access - invalid or missing API key"
+ return False, error_msg, None
elif response.status == 403:
- return False, "Access forbidden"
+ error_msg = "Access forbidden"
+ return False, error_msg, None
elif response.status == 404:
- return False, "File not found"
+ error_msg = "File not found"
+ return False, error_msg, None
else:
- return False, f"Download failed with status {response.status}"
+ error_msg = f"Download failed with status {response.status}"
+ return False, error_msg, None
except Exception as e:
logger.error(f"Error downloading to memory from {url}: {e}")
- return False, str(e)
+ return False, str(e), None
async def get_response_headers(
self,
diff --git a/py/utils/example_images_processor.py b/py/utils/example_images_processor.py
index 9dba4e2c..f1cfd2bf 100644
--- a/py/utils/example_images_processor.py
+++ b/py/utils/example_images_processor.py
@@ -23,17 +23,60 @@ class ExampleImagesProcessor:
return ''.join(random.choice(chars) for _ in range(length))
@staticmethod
- def get_civitai_optimized_url(image_url):
- """Convert Civitai image URL to its optimized WebP version"""
+ def get_civitai_optimized_url(media_url):
+ """Convert Civitai media URL (image or video) to its optimized version"""
base_pattern = r'(https://image\.civitai\.com/[^/]+/[^/]+)'
- match = re.match(base_pattern, image_url)
+ match = re.match(base_pattern, media_url)
if match:
base_url = match.group(1)
- return f"{base_url}/optimized=true/image.webp"
+ return f"{base_url}/optimized=true"
- return image_url
+ return media_url
+ @staticmethod
+ def _get_file_extension_from_content_or_headers(content, headers, fallback_url=None):
+ """Determine file extension from content magic bytes or headers"""
+ # Check magic bytes for common formats
+ if content:
+ if content.startswith(b'\xFF\xD8\xFF'):
+ return '.jpg'
+ elif content.startswith(b'\x89PNG\r\n\x1A\n'):
+ return '.png'
+ elif content.startswith(b'GIF87a') or content.startswith(b'GIF89a'):
+ return '.gif'
+ elif content.startswith(b'RIFF') and b'WEBP' in content[:12]:
+ return '.webp'
+ elif content.startswith(b'\x00\x00\x00\x18ftypmp4') or content.startswith(b'\x00\x00\x00\x20ftypmp4'):
+ return '.mp4'
+ elif content.startswith(b'\x1A\x45\xDF\xA3'):
+ return '.webm'
+
+ # Check Content-Type header
+ if headers:
+ content_type = headers.get('content-type', '').lower()
+ type_map = {
+ 'image/jpeg': '.jpg',
+ 'image/png': '.png',
+ 'image/gif': '.gif',
+ 'image/webp': '.webp',
+ 'video/mp4': '.mp4',
+ 'video/webm': '.webm',
+ 'video/quicktime': '.mov'
+ }
+ if content_type in type_map:
+ return type_map[content_type]
+
+ # Fallback to URL extension if available
+ if fallback_url:
+ filename = os.path.basename(fallback_url.split('?')[0])
+ ext = os.path.splitext(filename)[1].lower()
+ if ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or ext in SUPPORTED_MEDIA_EXTENSIONS['videos']:
+ return ext
+
+ # Default fallback
+ return '.jpg'
+
@staticmethod
async def download_model_images(model_hash, model_name, model_images, model_dir, optimize, downloader):
"""Download images for a single model
@@ -48,45 +91,49 @@ class ExampleImagesProcessor:
if not image_url:
continue
- # Get image filename from URL
- image_filename = os.path.basename(image_url.split('?')[0])
- image_ext = os.path.splitext(image_filename)[1].lower()
-
- # Handle images and videos
- is_image = image_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
- is_video = image_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
-
- if not (is_image or is_video):
- logger.debug(f"Skipping unsupported file type: {image_filename}")
- continue
-
- # Use 0-based indexing instead of 1-based indexing
- save_filename = f"image_{i}{image_ext}"
-
- # If optimizing images and this is a Civitai image, use their pre-optimized WebP version
- if is_image and optimize and 'civitai.com' in image_url:
+ # Apply optimization for Civitai URLs if enabled
+ original_url = image_url
+ if optimize and 'civitai.com' in image_url:
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
- save_filename = f"image_{i}.webp"
- # Check if already downloaded
- save_path = os.path.join(model_dir, save_filename)
- if os.path.exists(save_path):
- logger.debug(f"File already exists: {save_path}")
- continue
-
- # Download the file
+ # Download the file first to determine the actual file type
try:
- logger.debug(f"Downloading {save_filename} for {model_name}")
+ logger.debug(f"Downloading media file {i} for {model_name}")
- # Download using the unified downloader
- success, content = await downloader.download_to_memory(
+ # Download using the unified downloader with headers
+ success, content, headers = await downloader.download_to_memory(
image_url,
- use_auth=False # Example images don't need auth
+ use_auth=False, # Example images don't need auth
+ return_headers=True
)
if success:
+ # Determine file extension from content or headers
+ media_ext = ExampleImagesProcessor._get_file_extension_from_content_or_headers(
+ content, headers, original_url
+ )
+
+ # Check if the detected file type is supported
+ is_image = media_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
+ is_video = media_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
+
+ if not (is_image or is_video):
+ logger.debug(f"Skipping unsupported file type: {media_ext}")
+ continue
+
+ # Use 0-based indexing with the detected extension
+ save_filename = f"image_{i}{media_ext}"
+ save_path = os.path.join(model_dir, save_filename)
+
+ # Check if already downloaded
+ if os.path.exists(save_path):
+ logger.debug(f"File already exists: {save_path}")
+ continue
+
+ # Save the file
with open(save_path, 'wb') as f:
f.write(content)
+
elif "404" in str(content):
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
logger.warning(error_msg)
@@ -119,45 +166,49 @@ class ExampleImagesProcessor:
if not image_url:
continue
- # Get image filename from URL
- image_filename = os.path.basename(image_url.split('?')[0])
- image_ext = os.path.splitext(image_filename)[1].lower()
-
- # Handle images and videos
- is_image = image_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
- is_video = image_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
-
- if not (is_image or is_video):
- logger.debug(f"Skipping unsupported file type: {image_filename}")
- continue
-
- # Use 0-based indexing instead of 1-based indexing
- save_filename = f"image_{i}{image_ext}"
-
- # If optimizing images and this is a Civitai image, use their pre-optimized WebP version
- if is_image and optimize and 'civitai.com' in image_url:
+ # Apply optimization for Civitai URLs if enabled
+ original_url = image_url
+ if optimize and 'civitai.com' in image_url:
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
- save_filename = f"image_{i}.webp"
- # Check if already downloaded
- save_path = os.path.join(model_dir, save_filename)
- if os.path.exists(save_path):
- logger.debug(f"File already exists: {save_path}")
- continue
-
- # Download the file
+ # Download the file first to determine the actual file type
try:
- logger.debug(f"Downloading {save_filename} for {model_name}")
+ logger.debug(f"Downloading media file {i} for {model_name}")
- # Download using the unified downloader
- success, content = await downloader.download_to_memory(
+ # Download using the unified downloader with headers
+ success, content, headers = await downloader.download_to_memory(
image_url,
- use_auth=False # Example images don't need auth
+ use_auth=False, # Example images don't need auth
+ return_headers=True
)
if success:
+ # Determine file extension from content or headers
+ media_ext = ExampleImagesProcessor._get_file_extension_from_content_or_headers(
+ content, headers, original_url
+ )
+
+ # Check if the detected file type is supported
+ is_image = media_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
+ is_video = media_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
+
+ if not (is_image or is_video):
+ logger.debug(f"Skipping unsupported file type: {media_ext}")
+ continue
+
+ # Use 0-based indexing with the detected extension
+ save_filename = f"image_{i}{media_ext}"
+ save_path = os.path.join(model_dir, save_filename)
+
+ # Check if already downloaded
+ if os.path.exists(save_path):
+ logger.debug(f"File already exists: {save_path}")
+ continue
+
+ # Save the file
with open(save_path, 'wb') as f:
f.write(content)
+
elif "404" in str(content):
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
logger.warning(error_msg)
@@ -569,4 +620,7 @@ class ExampleImagesProcessor:
return web.json_response({
'success': False,
'error': str(e)
- }, status=500)
\ No newline at end of file
+ }, status=500)
+
+
+
\ No newline at end of file
From 86074c87d74b4cbc8f9f4a032bee46222e0ed657 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Mon, 15 Sep 2025 19:24:09 +0800
Subject: [PATCH 021/110] refactor(downloader): update download_to_memory calls
to include response headers
---
py/services/civitai_client.py | 2 +-
py/services/download_manager.py | 2 +-
py/utils/routes_common.py | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/py/services/civitai_client.py b/py/services/civitai_client.py
index 04d56d29..e037ba35 100644
--- a/py/services/civitai_client.py
+++ b/py/services/civitai_client.py
@@ -94,7 +94,7 @@ class CivitaiClient:
async def download_preview_image(self, image_url: str, save_path: str):
try:
downloader = await get_downloader()
- success, content = await downloader.download_to_memory(
+ success, content, headers = await downloader.download_to_memory(
image_url,
use_auth=False # Preview images don't need auth
)
diff --git a/py/services/download_manager.py b/py/services/download_manager.py
index 26f3f97d..76b974fb 100644
--- a/py/services/download_manager.py
+++ b/py/services/download_manager.py
@@ -463,7 +463,7 @@ class DownloadManager:
# Download the original image to temp path using downloader
downloader = await get_downloader()
- success, content = await downloader.download_to_memory(
+ success, content, headers = await downloader.download_to_memory(
images[0]['url'],
use_auth=False
)
diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py
index 3be2677f..db2f94db 100644
--- a/py/utils/routes_common.py
+++ b/py/utils/routes_common.py
@@ -141,7 +141,7 @@ class ModelRouteUtils:
else:
# For images, download and then optimize to WebP using downloader
downloader = await get_downloader()
- success, content = await downloader.download_to_memory(
+ success, content, headers = await downloader.download_to_memory(
first_preview['url'],
use_auth=False
)
From 0dc4b6f7284dfe815da87d12cdac025849d06b01 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Mon, 15 Sep 2025 20:18:39 +0800
Subject: [PATCH 022/110] refactor(showcase): improve custom image
identification logic in renderMediaItem and findLocalFile functions
---
static/js/components/shared/showcase/MediaRenderers.js | 2 --
static/js/components/shared/showcase/ShowcaseView.js | 4 ++--
2 files changed, 2 insertions(+), 4 deletions(-)
diff --git a/static/js/components/shared/showcase/MediaRenderers.js b/static/js/components/shared/showcase/MediaRenderers.js
index b8ef109f..9f1a6726 100644
--- a/static/js/components/shared/showcase/MediaRenderers.js
+++ b/static/js/components/shared/showcase/MediaRenderers.js
@@ -75,8 +75,6 @@ export function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText,
data-remote-src="${remoteUrl}"
data-nsfw-level="${nsfwLevel}"
alt="Preview"
- crossorigin="anonymous"
- referrerpolicy="no-referrer"
width="${media.width}"
height="${media.height}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js
index e5ccfae0..b4b96b0f 100644
--- a/static/js/components/shared/showcase/ShowcaseView.js
+++ b/static/js/components/shared/showcase/ShowcaseView.js
@@ -191,7 +191,7 @@ function renderMediaItem(img, index, exampleFiles) {
);
// Determine if this is a custom image (has id property)
- const isCustomImage = Boolean(img.id);
+ const isCustomImage = Boolean(typeof img.id === 'string' && img.id);
// Create the media control buttons HTML
const mediaControlsHtml = `
@@ -235,7 +235,7 @@ function findLocalFile(img, index, exampleFiles) {
let localFile = null;
- if (img.id) {
+ if (typeof img.id === 'string' && img.id) {
// This is a custom image, find by custom_
const customPrefix = `custom_${img.id}`;
localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
From 64e1dd3dd68e496cd8cd2a9d516e057221a4d4b0 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Mon, 15 Sep 2025 21:35:24 +0800
Subject: [PATCH 023/110] chore(release): update release notes for v0.9.3 with
new features and bug fixes
---
README.md | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/README.md b/README.md
index 1627d434..7e932acc 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,11 @@ Enhance your Civitai browsing experience with our companion browser extension! S
## Release Notes
+### v0.9.3
+* **Metadata Archive Database Support** - Added the ability to download and utilize a metadata archive database, enabling access to metadata for models that have been deleted from CivitAI.
+* **App-Level Proxy Settings** - Introduced support for configuring a global proxy within the application, making it easier to use the manager behind network restrictions.
+* **Bug Fixes** - Various bug fixes for improved stability and reliability.
+
### v0.9.2
* **Bulk Auto-Organization Action** - Added a new bulk auto-organization feature. You can now select multiple models and automatically organize them according to your current path template settings for streamlined management.
* **Bug Fixes** - Addressed several bugs to improve stability and reliability.
From 30956aeefc4d394a70de7f0385e5a37ac4f9c230 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Tue, 16 Sep 2025 15:05:31 +0800
Subject: [PATCH 024/110] feat(middleware): add cache control middleware to
manage response caching for image files
---
middleware/__init__.py | 1 +
middleware/cache_middleware.py | 52 ++++++++++++++++++++++++++++++++++
standalone.py | 3 +-
3 files changed, 55 insertions(+), 1 deletion(-)
create mode 100644 middleware/__init__.py
create mode 100644 middleware/cache_middleware.py
diff --git a/middleware/__init__.py b/middleware/__init__.py
new file mode 100644
index 00000000..2d7c7c3a
--- /dev/null
+++ b/middleware/__init__.py
@@ -0,0 +1 @@
+"""Server middleware modules"""
diff --git a/middleware/cache_middleware.py b/middleware/cache_middleware.py
new file mode 100644
index 00000000..374ef793
--- /dev/null
+++ b/middleware/cache_middleware.py
@@ -0,0 +1,52 @@
+"""Cache control middleware for ComfyUI server"""
+
+from aiohttp import web
+from typing import Callable, Awaitable
+
+# Time in seconds
+ONE_HOUR: int = 3600
+ONE_DAY: int = 86400
+IMG_EXTENSIONS = (
+ ".jpg",
+ ".jpeg",
+ ".png",
+ ".ppm",
+ ".bmp",
+ ".pgm",
+ ".tif",
+ ".tiff",
+ ".webp",
+)
+
+
+@web.middleware
+async def cache_control(
+ request: web.Request, handler: Callable[[web.Request], Awaitable[web.Response]]
+) -> web.Response:
+ """Cache control middleware that sets appropriate cache headers based on file type and response status"""
+ response: web.Response = await handler(request)
+
+ if (
+ request.path.endswith(".js")
+ or request.path.endswith(".css")
+ or request.path.endswith("index.json")
+ ):
+ response.headers.setdefault("Cache-Control", "no-cache")
+ return response
+
+ # Early return for non-image files - no cache headers needed
+ if not request.path.lower().endswith(IMG_EXTENSIONS):
+ return response
+
+ # Handle image files
+ if response.status == 404:
+ response.headers.setdefault("Cache-Control", f"public, max-age={ONE_HOUR}")
+ elif response.status in (200, 201, 202, 203, 204, 205, 206, 301, 308):
+ # Success responses and permanent redirects - cache for 1 day
+ response.headers.setdefault("Cache-Control", f"public, max-age={ONE_DAY}")
+ elif response.status in (302, 303, 307):
+ # Temporary redirects - no cache
+ response.headers.setdefault("Cache-Control", "no-cache")
+ # Note: 304 Not Modified falls through - no cache headers set
+
+ return response
diff --git a/standalone.py b/standalone.py
index b8532abf..56d4545d 100644
--- a/standalone.py
+++ b/standalone.py
@@ -2,6 +2,7 @@ from pathlib import Path
import os
import sys
import json
+from middleware.cache_middleware import cache_control
# Create mock modules for py/nodes directory - add this before any other imports
def mock_nodes_directory():
@@ -129,7 +130,7 @@ class StandaloneServer:
"""Server implementation for standalone mode"""
def __init__(self):
- self.app = web.Application(logger=logger)
+ self.app = web.Application(logger=logger, middlewares=[cache_control])
self.instance = self # Make it compatible with PromptServer.instance pattern
# Ensure the app's access logger is configured to reduce verbosity
From 4275dc30037cdec77b073c3732e9f1e2dab1d0a2 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Tue, 16 Sep 2025 15:16:53 +0800
Subject: [PATCH 025/110] refactor(middleware): reorganize cache middleware
into py directory and update import paths
---
{middleware => py/middleware}/__init__.py | 0
{middleware => py/middleware}/cache_middleware.py | 0
standalone.py | 2 +-
3 files changed, 1 insertion(+), 1 deletion(-)
rename {middleware => py/middleware}/__init__.py (100%)
rename {middleware => py/middleware}/cache_middleware.py (100%)
diff --git a/middleware/__init__.py b/py/middleware/__init__.py
similarity index 100%
rename from middleware/__init__.py
rename to py/middleware/__init__.py
diff --git a/middleware/cache_middleware.py b/py/middleware/cache_middleware.py
similarity index 100%
rename from middleware/cache_middleware.py
rename to py/middleware/cache_middleware.py
diff --git a/standalone.py b/standalone.py
index 56d4545d..f38a89fc 100644
--- a/standalone.py
+++ b/standalone.py
@@ -2,7 +2,7 @@ from pathlib import Path
import os
import sys
import json
-from middleware.cache_middleware import cache_control
+from py.middleware.cache_middleware import cache_control
# Create mock modules for py/nodes directory - add this before any other imports
def mock_nodes_directory():
From 0566d50346e2517aae7c31e44283cbd361b098ac Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Tue, 16 Sep 2025 15:39:12 +0800
Subject: [PATCH 026/110] feat(middleware): add .mp4 to image extensions for
cache control
---
py/middleware/cache_middleware.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/py/middleware/cache_middleware.py b/py/middleware/cache_middleware.py
index 374ef793..4df22b30 100644
--- a/py/middleware/cache_middleware.py
+++ b/py/middleware/cache_middleware.py
@@ -16,6 +16,7 @@ IMG_EXTENSIONS = (
".tif",
".tiff",
".webp",
+ ".mp4"
)
From adf7b6d4b21fb3b0bc010a5f130c0db1f0931388 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Tue, 16 Sep 2025 18:55:59 +0800
Subject: [PATCH 027/110] chore(version): bump version to 0.9.3 in
pyproject.toml
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 202fb961..1ad4e018 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
-version = "0.9.2"
+version = "0.9.3"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
From 183c000080e47bbcc052d96e2ef46ed681412715 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Tue, 16 Sep 2025 21:48:20 +0800
Subject: [PATCH 028/110] Refactor ComfyUI: Remove legacy tags widget and
related dynamic imports
- Deleted the legacy tags widget implementation from legacy_tags_widget.js.
- Updated trigger_word_toggle.js to directly import the new tags widget.
- Removed unused dynamic import functions and version checks from utils.js.
- Cleaned up lora_loader.js and lora_stacker.js by removing redundant node registration code.
---
web/comfyui/legacy_loras_widget.js | 978 -----------------------------
web/comfyui/legacy_tags_widget.js | 193 ------
web/comfyui/lora_loader.js | 26 -
web/comfyui/lora_stacker.js | 25 -
web/comfyui/trigger_word_toggle.js | 12 +-
web/comfyui/utils.js | 32 -
6 files changed, 2 insertions(+), 1264 deletions(-)
delete mode 100644 web/comfyui/legacy_loras_widget.js
delete mode 100644 web/comfyui/legacy_tags_widget.js
diff --git a/web/comfyui/legacy_loras_widget.js b/web/comfyui/legacy_loras_widget.js
deleted file mode 100644
index bf2ba96f..00000000
--- a/web/comfyui/legacy_loras_widget.js
+++ /dev/null
@@ -1,978 +0,0 @@
-import { api } from "../../scripts/api.js";
-import { app } from "../../scripts/app.js";
-
-export function addLorasWidget(node, name, opts, callback) {
- // Create container for loras
- const container = document.createElement("div");
- container.className = "comfy-loras-container";
- Object.assign(container.style, {
- display: "flex",
- flexDirection: "column",
- gap: "8px",
- padding: "6px",
- backgroundColor: "rgba(40, 44, 52, 0.6)",
- borderRadius: "6px",
- width: "100%",
- });
-
- // Initialize default value
- const defaultValue = opts?.defaultVal || [];
-
- // Parse LoRA entries from value
- const parseLoraValue = (value) => {
- if (!value) return [];
- return Array.isArray(value) ? value : [];
- };
-
- // Format LoRA data
- const formatLoraValue = (loras) => {
- return loras;
- };
-
- // Function to create toggle element
- const createToggle = (active, onChange) => {
- const toggle = document.createElement("div");
- toggle.className = "comfy-lora-toggle";
-
- updateToggleStyle(toggle, active);
-
- toggle.addEventListener("click", (e) => {
- e.stopPropagation();
- onChange(!active);
- });
-
- return toggle;
- };
-
- // Helper function to update toggle style
- function updateToggleStyle(toggleEl, active) {
- Object.assign(toggleEl.style, {
- width: "18px",
- height: "18px",
- borderRadius: "4px",
- cursor: "pointer",
- transition: "all 0.2s ease",
- backgroundColor: active ? "rgba(66, 153, 225, 0.9)" : "rgba(45, 55, 72, 0.7)",
- border: `1px solid ${active ? "rgba(66, 153, 225, 0.9)" : "rgba(226, 232, 240, 0.2)"}`,
- });
-
- // Add hover effect
- toggleEl.onmouseenter = () => {
- toggleEl.style.transform = "scale(1.05)";
- toggleEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.15)";
- };
-
- toggleEl.onmouseleave = () => {
- toggleEl.style.transform = "scale(1)";
- toggleEl.style.boxShadow = "none";
- };
- }
-
- // Create arrow button for strength adjustment
- const createArrowButton = (direction, onClick) => {
- const button = document.createElement("div");
- button.className = `comfy-lora-arrow comfy-lora-arrow-${direction}`;
-
- Object.assign(button.style, {
- width: "16px",
- height: "16px",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- cursor: "pointer",
- userSelect: "none",
- fontSize: "12px",
- color: "rgba(226, 232, 240, 0.8)",
- transition: "all 0.2s ease",
- });
-
- button.textContent = direction === "left" ? "◀" : "▶";
-
- button.addEventListener("click", (e) => {
- e.stopPropagation();
- onClick();
- });
-
- // Add hover effect
- button.onmouseenter = () => {
- button.style.color = "white";
- button.style.transform = "scale(1.2)";
- };
-
- button.onmouseleave = () => {
- button.style.color = "rgba(226, 232, 240, 0.8)";
- button.style.transform = "scale(1)";
- };
-
- return button;
- };
-
- // 添加预览弹窗组件
- class PreviewTooltip {
- constructor() {
- this.element = document.createElement('div');
- Object.assign(this.element.style, {
- position: 'fixed',
- zIndex: 9999,
- background: 'rgba(0, 0, 0, 0.85)',
- borderRadius: '6px',
- boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
- display: 'none',
- overflow: 'hidden',
- maxWidth: '300px',
- });
- document.body.appendChild(this.element);
- this.hideTimeout = null; // 添加超时处理变量
-
- // 添加全局点击事件来隐藏tooltip
- document.addEventListener('click', () => this.hide());
-
- // 添加滚动事件监听
- document.addEventListener('scroll', () => this.hide(), true);
- }
-
- async show(loraName, x, y) {
- try {
- // 清除之前的隐藏定时器
- if (this.hideTimeout) {
- clearTimeout(this.hideTimeout);
- this.hideTimeout = null;
- }
-
- // 如果已经显示同一个lora的预览,则不重复显示
- if (this.element.style.display === 'block' && this.currentLora === loraName) {
- return;
- }
-
- this.currentLora = loraName;
-
- // 获取预览URL
- const response = await api.fetchApi(`/loras/preview-url?name=${encodeURIComponent(loraName)}`, {
- method: 'GET'
- });
-
- if (!response.ok) {
- throw new Error('Failed to fetch preview URL');
- }
-
- const data = await response.json();
- if (!data.success || !data.preview_url) {
- throw new Error('No preview available');
- }
-
- // 清除现有内容
- while (this.element.firstChild) {
- this.element.removeChild(this.element.firstChild);
- }
-
- // Create media container with relative positioning
- const mediaContainer = document.createElement('div');
- Object.assign(mediaContainer.style, {
- position: 'relative',
- maxWidth: '300px',
- maxHeight: '300px',
- });
-
- const isVideo = data.preview_url.endsWith('.mp4');
- const mediaElement = isVideo ? document.createElement('video') : document.createElement('img');
-
- Object.assign(mediaElement.style, {
- maxWidth: '300px',
- maxHeight: '300px',
- objectFit: 'contain',
- display: 'block',
- });
-
- if (isVideo) {
- mediaElement.autoplay = true;
- mediaElement.loop = true;
- mediaElement.muted = true;
- mediaElement.controls = false;
- }
-
- mediaElement.src = data.preview_url;
-
- // Create name label with absolute positioning
- const nameLabel = document.createElement('div');
- nameLabel.textContent = loraName;
- Object.assign(nameLabel.style, {
- position: 'absolute',
- bottom: '0',
- left: '0',
- right: '0',
- padding: '8px',
- color: 'rgba(255, 255, 255, 0.95)',
- fontSize: '13px',
- fontFamily: "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif",
- background: 'linear-gradient(transparent, rgba(0, 0, 0, 0.8))',
- whiteSpace: 'nowrap',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- textAlign: 'center',
- backdropFilter: 'blur(4px)',
- WebkitBackdropFilter: 'blur(4px)',
- });
-
- mediaContainer.appendChild(mediaElement);
- mediaContainer.appendChild(nameLabel);
- this.element.appendChild(mediaContainer);
-
- // 添加淡入效果
- this.element.style.opacity = '0';
- this.element.style.display = 'block';
- this.position(x, y);
-
- requestAnimationFrame(() => {
- this.element.style.transition = 'opacity 0.15s ease';
- this.element.style.opacity = '1';
- });
- } catch (error) {
- console.warn('Failed to load preview:', error);
- }
- }
-
- position(x, y) {
- // 确保预览框不超出视窗边界
- const rect = this.element.getBoundingClientRect();
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
-
- let left = x + 10; // 默认在鼠标右侧偏移10px
- let top = y + 10; // 默认在鼠标下方偏移10px
-
- // 检查右边界
- if (left + rect.width > viewportWidth) {
- left = x - rect.width - 10;
- }
-
- // 检查下边界
- if (top + rect.height > viewportHeight) {
- top = y - rect.height - 10;
- }
-
- Object.assign(this.element.style, {
- left: `${left}px`,
- top: `${top}px`
- });
- }
-
- hide() {
- // 使用淡出效果
- if (this.element.style.display === 'block') {
- this.element.style.opacity = '0';
- this.hideTimeout = setTimeout(() => {
- this.element.style.display = 'none';
- this.currentLora = null;
- // 停止视频播放
- const video = this.element.querySelector('video');
- if (video) {
- video.pause();
- }
- this.hideTimeout = null;
- }, 150);
- }
- }
-
- cleanup() {
- if (this.hideTimeout) {
- clearTimeout(this.hideTimeout);
- }
- // 移除所有事件监听器
- document.removeEventListener('click', () => this.hide());
- document.removeEventListener('scroll', () => this.hide(), true);
- this.element.remove();
- }
- }
-
- // 创建预览tooltip实例
- const previewTooltip = new PreviewTooltip();
-
- // Function to handle strength adjustment via dragging
- const handleStrengthDrag = (name, initialStrength, initialX, event, widget) => {
- // Calculate drag sensitivity (how much the strength changes per pixel)
- // Using 0.01 per 10 pixels of movement
- const sensitivity = 0.001;
-
- // Get the current mouse position
- const currentX = event.clientX;
-
- // Calculate the distance moved
- const deltaX = currentX - initialX;
-
- // Calculate the new strength value based on movement
- // Moving right increases, moving left decreases
- let newStrength = Number(initialStrength) + (deltaX * sensitivity);
-
- // Limit the strength to reasonable bounds (now between -10 and 10)
- newStrength = Math.max(-10, Math.min(10, newStrength));
- newStrength = Number(newStrength.toFixed(2));
-
- // Update the lora data
- const lorasData = parseLoraValue(widget.value);
- const loraIndex = lorasData.findIndex(l => l.name === name);
-
- if (loraIndex >= 0) {
- lorasData[loraIndex].strength = newStrength;
-
- // Update the widget value
- widget.value = formatLoraValue(lorasData);
-
- // Force re-render to show updated strength value
- renderLoras(widget.value, widget);
- }
- };
-
- // Function to initialize drag operation
- const initDrag = (loraEl, nameEl, name, widget) => {
- let isDragging = false;
- let initialX = 0;
- let initialStrength = 0;
-
- // Create a style element for drag cursor override if it doesn't exist
- if (!document.getElementById('comfy-lora-drag-style')) {
- const styleEl = document.createElement('style');
- styleEl.id = 'comfy-lora-drag-style';
- styleEl.textContent = `
- body.comfy-lora-dragging,
- body.comfy-lora-dragging * {
- cursor: ew-resize !important;
- }
- `;
- document.head.appendChild(styleEl);
- }
-
- // Create a drag handler that's applied to the entire lora entry
- // except toggle and strength controls
- loraEl.addEventListener('mousedown', (e) => {
- // Skip if clicking on toggle or strength control areas
- if (e.target.closest('.comfy-lora-toggle') ||
- e.target.closest('input') ||
- e.target.closest('.comfy-lora-arrow')) {
- return;
- }
-
- // Store initial values
- const lorasData = parseLoraValue(widget.value);
- const loraData = lorasData.find(l => l.name === name);
-
- if (!loraData) return;
-
- initialX = e.clientX;
- initialStrength = loraData.strength;
- isDragging = true;
-
- // Add class to body to enforce cursor style globally
- document.body.classList.add('comfy-lora-dragging');
-
- // Prevent text selection during drag
- e.preventDefault();
- });
-
- // Use the document for move and up events to ensure drag continues
- // even if mouse leaves the element
- document.addEventListener('mousemove', (e) => {
- if (!isDragging) return;
-
- // Call the strength adjustment function
- handleStrengthDrag(name, initialStrength, initialX, e, widget);
-
- // Prevent showing the preview tooltip during drag
- previewTooltip.hide();
- });
-
- document.addEventListener('mouseup', () => {
- if (isDragging) {
- isDragging = false;
- // Remove the class to restore normal cursor behavior
- document.body.classList.remove('comfy-lora-dragging');
- }
- });
- };
-
- // Function to create menu item
- const createMenuItem = (text, icon, onClick) => {
- const menuItem = document.createElement('div');
- Object.assign(menuItem.style, {
- padding: '6px 20px',
- cursor: 'pointer',
- color: 'rgba(226, 232, 240, 0.9)',
- fontSize: '13px',
- userSelect: 'none',
- display: 'flex',
- alignItems: 'center',
- gap: '8px',
- });
-
- // Create icon element
- const iconEl = document.createElement('div');
- iconEl.innerHTML = icon;
- Object.assign(iconEl.style, {
- width: '14px',
- height: '14px',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- });
-
- // Create text element
- const textEl = document.createElement('span');
- textEl.textContent = text;
-
- menuItem.appendChild(iconEl);
- menuItem.appendChild(textEl);
-
- menuItem.addEventListener('mouseenter', () => {
- menuItem.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
- });
-
- menuItem.addEventListener('mouseleave', () => {
- menuItem.style.backgroundColor = 'transparent';
- });
-
- if (onClick) {
- menuItem.addEventListener('click', onClick);
- }
-
- return menuItem;
- };
-
- // Function to create context menu
- const createContextMenu = (x, y, loraName, widget) => {
- // Hide preview tooltip first
- previewTooltip.hide();
-
- // Remove existing context menu if any
- const existingMenu = document.querySelector('.comfy-lora-context-menu');
- if (existingMenu) {
- existingMenu.remove();
- }
-
- const menu = document.createElement('div');
- menu.className = 'comfy-lora-context-menu';
- Object.assign(menu.style, {
- position: 'fixed',
- left: `${x}px`,
- top: `${y}px`,
- backgroundColor: 'rgba(30, 30, 30, 0.95)',
- border: '1px solid rgba(255, 255, 255, 0.1)',
- borderRadius: '4px',
- padding: '4px 0',
- zIndex: 1000,
- boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
- minWidth: '180px',
- });
-
- // View on Civitai option with globe icon
- const viewOnCivitaiOption = createMenuItem(
- 'View on Civitai',
- '',
- async () => {
- menu.remove();
- document.removeEventListener('click', closeMenu);
-
- try {
- // Get Civitai URL from API
- const response = await api.fetchApi(`/loras/civitai-url?name=${encodeURIComponent(loraName)}`, {
- method: 'GET'
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(errorText || 'Failed to get Civitai URL');
- }
-
- const data = await response.json();
- if (data.success && data.civitai_url) {
- // Open the URL in a new tab
- window.open(data.civitai_url, '_blank');
- } else {
- // Show error message if no Civitai URL
- if (app && app.extensionManager && app.extensionManager.toast) {
- app.extensionManager.toast.add({
- severity: 'warning',
- summary: 'Not Found',
- detail: 'This LoRA has no associated Civitai URL',
- life: 3000
- });
- } else {
- alert('This LoRA has no associated Civitai URL');
- }
- }
- } catch (error) {
- console.error('Error getting Civitai URL:', error);
- if (app && app.extensionManager && app.extensionManager.toast) {
- app.extensionManager.toast.add({
- severity: 'error',
- summary: 'Error',
- detail: error.message || 'Failed to get Civitai URL',
- life: 5000
- });
- } else {
- alert('Error: ' + (error.message || 'Failed to get Civitai URL'));
- }
- }
- }
- );
-
- // Delete option with trash icon
- const deleteOption = createMenuItem(
- 'Delete',
- '',
- () => {
- menu.remove();
- document.removeEventListener('click', closeMenu);
-
- const lorasData = parseLoraValue(widget.value).filter(l => l.name !== loraName);
- widget.value = formatLoraValue(lorasData);
-
- if (widget.callback) {
- widget.callback(widget.value);
- }
- }
- );
-
- // Save recipe option with bookmark icon
- const saveOption = createMenuItem(
- 'Save Recipe',
- '',
- () => {
- menu.remove();
- document.removeEventListener('click', closeMenu);
- saveRecipeDirectly(widget);
- }
- );
-
- // Add separator
- const separator = document.createElement('div');
- Object.assign(separator.style, {
- margin: '4px 0',
- borderTop: '1px solid rgba(255, 255, 255, 0.1)',
- });
-
- menu.appendChild(viewOnCivitaiOption); // Add the new menu option
- menu.appendChild(deleteOption);
- menu.appendChild(separator);
- menu.appendChild(saveOption);
-
- document.body.appendChild(menu);
-
- // Close menu when clicking outside
- const closeMenu = (e) => {
- if (!menu.contains(e.target)) {
- menu.remove();
- document.removeEventListener('click', closeMenu);
- }
- };
- setTimeout(() => document.addEventListener('click', closeMenu), 0);
- };
-
- // Function to render loras from data
- const renderLoras = (value, widget) => {
- // Clear existing content
- while (container.firstChild) {
- container.removeChild(container.firstChild);
- }
-
- // Parse the loras data
- const lorasData = parseLoraValue(value);
-
- if (lorasData.length === 0) {
- // Show message when no loras are added
- const emptyMessage = document.createElement("div");
- emptyMessage.textContent = "No LoRAs added";
- Object.assign(emptyMessage.style, {
- textAlign: "center",
- padding: "20px 0",
- color: "rgba(226, 232, 240, 0.8)",
- fontStyle: "italic",
- userSelect: "none", // Add this line to prevent text selection
- WebkitUserSelect: "none", // For Safari support
- MozUserSelect: "none", // For Firefox support
- msUserSelect: "none", // For IE/Edge support
- });
- container.appendChild(emptyMessage);
- return;
- }
-
- // Create header
- const header = document.createElement("div");
- header.className = "comfy-loras-header";
- Object.assign(header.style, {
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- padding: "4px 8px",
- borderBottom: "1px solid rgba(226, 232, 240, 0.2)",
- marginBottom: "8px"
- });
-
- // Add toggle all control
- const allActive = lorasData.every(lora => lora.active);
- const toggleAll = createToggle(allActive, (active) => {
- // Update all loras active state
- const lorasData = parseLoraValue(widget.value);
- lorasData.forEach(lora => lora.active = active);
-
- const newValue = formatLoraValue(lorasData);
- widget.value = newValue;
- });
-
- // Add label to toggle all
- const toggleLabel = document.createElement("div");
- toggleLabel.textContent = "Toggle All";
- Object.assign(toggleLabel.style, {
- color: "rgba(226, 232, 240, 0.8)",
- fontSize: "13px",
- marginLeft: "8px",
- userSelect: "none", // Add this line to prevent text selection
- WebkitUserSelect: "none", // For Safari support
- MozUserSelect: "none", // For Firefox support
- msUserSelect: "none", // For IE/Edge support
- });
-
- const toggleContainer = document.createElement("div");
- Object.assign(toggleContainer.style, {
- display: "flex",
- alignItems: "center",
- });
- toggleContainer.appendChild(toggleAll);
- toggleContainer.appendChild(toggleLabel);
-
- // Strength label
- const strengthLabel = document.createElement("div");
- strengthLabel.textContent = "Strength";
- Object.assign(strengthLabel.style, {
- color: "rgba(226, 232, 240, 0.8)",
- fontSize: "13px",
- marginRight: "8px",
- userSelect: "none", // Add this line to prevent text selection
- WebkitUserSelect: "none", // For Safari support
- MozUserSelect: "none", // For Firefox support
- msUserSelect: "none", // For IE/Edge support
- });
-
- header.appendChild(toggleContainer);
- header.appendChild(strengthLabel);
- container.appendChild(header);
-
- // Render each lora entry
- lorasData.forEach((loraData) => {
- const { name, strength, active } = loraData;
-
- const loraEl = document.createElement("div");
- loraEl.className = "comfy-lora-entry";
- Object.assign(loraEl.style, {
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- padding: "8px",
- borderRadius: "6px",
- backgroundColor: active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)",
- transition: "all 0.2s ease",
- marginBottom: "6px",
- });
-
- // Create toggle for this lora
- const toggle = createToggle(active, (newActive) => {
- // Update this lora's active state
- const lorasData = parseLoraValue(widget.value);
- const loraIndex = lorasData.findIndex(l => l.name === name);
-
- if (loraIndex >= 0) {
- lorasData[loraIndex].active = newActive;
-
- const newValue = formatLoraValue(lorasData);
- widget.value = newValue;
- }
- });
-
- // Create name display
- const nameEl = document.createElement("div");
- nameEl.textContent = name;
- Object.assign(nameEl.style, {
- marginLeft: "10px",
- flex: "1",
- overflow: "hidden",
- textOverflow: "ellipsis",
- whiteSpace: "nowrap",
- color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)",
- fontSize: "13px",
- cursor: "pointer", // Add pointer cursor to indicate hoverable area
- userSelect: "none", // Add this line to prevent text selection
- WebkitUserSelect: "none", // For Safari support
- MozUserSelect: "none", // For Firefox support
- msUserSelect: "none", // For IE/Edge support
- });
-
- // Move preview tooltip events to nameEl instead of loraEl
- nameEl.addEventListener('mouseenter', async (e) => {
- e.stopPropagation();
- const rect = nameEl.getBoundingClientRect();
- await previewTooltip.show(name, rect.right, rect.top);
- });
-
- nameEl.addEventListener('mouseleave', (e) => {
- e.stopPropagation();
- previewTooltip.hide();
- });
-
- // Remove the preview tooltip events from loraEl
- loraEl.onmouseenter = () => {
- loraEl.style.backgroundColor = active ? "rgba(50, 60, 80, 0.8)" : "rgba(40, 45, 55, 0.6)";
- };
-
- loraEl.onmouseleave = () => {
- loraEl.style.backgroundColor = active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)";
- };
-
- // Add context menu event
- loraEl.addEventListener('contextmenu', (e) => {
- e.preventDefault();
- e.stopPropagation();
- createContextMenu(e.clientX, e.clientY, name, widget);
- });
-
- // Create strength control
- const strengthControl = document.createElement("div");
- Object.assign(strengthControl.style, {
- display: "flex",
- alignItems: "center",
- gap: "8px",
- });
-
- // Left arrow
- const leftArrow = createArrowButton("left", () => {
- // Decrease strength
- const lorasData = parseLoraValue(widget.value);
- const loraIndex = lorasData.findIndex(l => l.name === name);
-
- if (loraIndex >= 0) {
- lorasData[loraIndex].strength = (lorasData[loraIndex].strength - 0.05).toFixed(2);
-
- const newValue = formatLoraValue(lorasData);
- widget.value = newValue;
- }
- });
-
- // Strength display
- const strengthEl = document.createElement("input");
- strengthEl.type = "text";
- strengthEl.value = typeof strength === 'number' ? strength.toFixed(2) : Number(strength).toFixed(2);
- Object.assign(strengthEl.style, {
- minWidth: "50px",
- width: "50px",
- textAlign: "center",
- color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)",
- fontSize: "13px",
- background: "none",
- border: "1px solid transparent",
- padding: "2px 4px",
- borderRadius: "3px",
- outline: "none",
- });
-
- // 添加hover效果
- strengthEl.addEventListener('mouseenter', () => {
- strengthEl.style.border = "1px solid rgba(226, 232, 240, 0.2)";
- });
-
- strengthEl.addEventListener('mouseleave', () => {
- if (document.activeElement !== strengthEl) {
- strengthEl.style.border = "1px solid transparent";
- }
- });
-
- // 处理焦点
- strengthEl.addEventListener('focus', () => {
- strengthEl.style.border = "1px solid rgba(66, 153, 225, 0.6)";
- strengthEl.style.background = "rgba(0, 0, 0, 0.2)";
- // 自动选中所有内容
- strengthEl.select();
- });
-
- strengthEl.addEventListener('blur', () => {
- strengthEl.style.border = "1px solid transparent";
- strengthEl.style.background = "none";
- });
-
- // 处理输入变化
- strengthEl.addEventListener('change', () => {
- let newValue = parseFloat(strengthEl.value);
-
- // 验证输入
- if (isNaN(newValue)) {
- newValue = 1.0;
- }
-
- // 更新数值
- const lorasData = parseLoraValue(widget.value);
- const loraIndex = lorasData.findIndex(l => l.name === name);
-
- if (loraIndex >= 0) {
- lorasData[loraIndex].strength = newValue.toFixed(2);
-
- // 更新值并触发回调
- const newLorasValue = formatLoraValue(lorasData);
- widget.value = newLorasValue;
- }
- });
-
- // 处理按键事件
- strengthEl.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') {
- strengthEl.blur();
- }
- });
-
- // Right arrow
- const rightArrow = createArrowButton("right", () => {
- // Increase strength
- const lorasData = parseLoraValue(widget.value);
- const loraIndex = lorasData.findIndex(l => l.name === name);
-
- if (loraIndex >= 0) {
- lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + 0.05).toFixed(2);
-
- const newValue = formatLoraValue(lorasData);
- widget.value = newValue;
- }
- });
-
- strengthControl.appendChild(leftArrow);
- strengthControl.appendChild(strengthEl);
- strengthControl.appendChild(rightArrow);
-
- // Assemble entry
- const leftSection = document.createElement("div");
- Object.assign(leftSection.style, {
- display: "flex",
- alignItems: "center",
- flex: "1",
- minWidth: "0", // Allow shrinking
- });
-
- leftSection.appendChild(toggle);
- leftSection.appendChild(nameEl);
-
- loraEl.appendChild(leftSection);
- loraEl.appendChild(strengthControl);
-
- container.appendChild(loraEl);
-
- // Initialize drag functionality
- initDrag(loraEl, nameEl, name, widget);
- });
- };
-
- // Store the value in a variable to avoid recursion
- let widgetValue = defaultValue;
-
- // Create widget with initial properties
- const widget = node.addDOMWidget(name, "loras", container, {
- getValue: function() {
- return widgetValue;
- },
- setValue: function(v) {
- // Remove duplicates by keeping the last occurrence of each lora name
- const uniqueValue = (v || []).reduce((acc, lora) => {
- // Remove any existing lora with the same name
- const filtered = acc.filter(l => l.name !== lora.name);
- // Add the current lora
- return [...filtered, lora];
- }, []);
-
- widgetValue = uniqueValue;
- renderLoras(widgetValue, widget);
-
- // Update container height after rendering
- requestAnimationFrame(() => {
- const minHeight = this.getMinHeight();
- container.style.height = `${minHeight}px`;
-
- // Force node to update size
- node.setSize([node.size[0], node.computeSize()[1]]);
- node.setDirtyCanvas(true, true);
- });
- },
- getMinHeight: function() {
- // Calculate height based on content
- const lorasCount = parseLoraValue(widgetValue).length;
- return Math.max(
- 100,
- lorasCount > 0 ? 60 + lorasCount * 44 : 60
- );
- },
- });
-
- widget.value = defaultValue;
-
- widget.callback = callback;
-
- widget.serializeValue = () => {
- // Add dummy items to avoid the 2-element serialization issue, a bug in comfyui
- return [...widgetValue,
- { name: "__dummy_item1__", strength: 0, active: false, _isDummy: true },
- { name: "__dummy_item2__", strength: 0, active: false, _isDummy: true }
- ];
- }
-
- widget.onRemove = () => {
- container.remove();
- previewTooltip.cleanup();
- };
-
- return { minWidth: 400, minHeight: 200, widget };
-}
-
-// Function to directly save the recipe without dialog
-async function saveRecipeDirectly(widget) {
- try {
- // Show loading toast
- if (app && app.extensionManager && app.extensionManager.toast) {
- app.extensionManager.toast.add({
- severity: 'info',
- summary: 'Saving Recipe',
- detail: 'Please wait...',
- life: 2000
- });
- }
-
- // Send the request
- const response = await fetch('/api/recipes/save-from-widget', {
- method: 'POST'
- });
-
- const result = await response.json();
-
- // Show result toast
- if (app && app.extensionManager && app.extensionManager.toast) {
- if (result.success) {
- app.extensionManager.toast.add({
- severity: 'success',
- summary: 'Recipe Saved',
- detail: 'Recipe has been saved successfully',
- life: 3000
- });
- } else {
- app.extensionManager.toast.add({
- severity: 'error',
- summary: 'Error',
- detail: result.error || 'Failed to save recipe',
- life: 5000
- });
- }
- }
- } catch (error) {
- console.error('Error saving recipe:', error);
-
- // Show error toast
- if (app && app.extensionManager && app.extensionManager.toast) {
- app.extensionManager.toast.add({
- severity: 'error',
- summary: 'Error',
- detail: 'Failed to save recipe: ' + (error.message || 'Unknown error'),
- life: 5000
- });
- }
- }
-}
diff --git a/web/comfyui/legacy_tags_widget.js b/web/comfyui/legacy_tags_widget.js
deleted file mode 100644
index d43cb016..00000000
--- a/web/comfyui/legacy_tags_widget.js
+++ /dev/null
@@ -1,193 +0,0 @@
-export function addTagsWidget(node, name, opts, callback) {
- // Create container for tags
- const container = document.createElement("div");
- container.className = "comfy-tags-container";
- Object.assign(container.style, {
- display: "flex",
- flexWrap: "wrap",
- gap: "4px", // 从8px减小到4px
- padding: "6px",
- minHeight: "30px",
- backgroundColor: "rgba(40, 44, 52, 0.6)", // Darker, more modern background
- borderRadius: "6px", // Slightly larger radius
- width: "100%",
- });
-
- // Initialize default value as array
- const initialTagsData = opts?.defaultVal || [];
-
- // Function to render tags from array data
- const renderTags = (tagsData, widget) => {
- // Clear existing tags
- while (container.firstChild) {
- container.removeChild(container.firstChild);
- }
-
- const normalizedTags = tagsData;
-
- if (normalizedTags.length === 0) {
- // Show message when no tags are present
- const emptyMessage = document.createElement("div");
- emptyMessage.textContent = "No trigger words detected";
- Object.assign(emptyMessage.style, {
- textAlign: "center",
- padding: "20px 0",
- color: "rgba(226, 232, 240, 0.8)",
- fontStyle: "italic",
- userSelect: "none",
- WebkitUserSelect: "none",
- MozUserSelect: "none",
- msUserSelect: "none",
- });
- container.appendChild(emptyMessage);
- return;
- }
-
- normalizedTags.forEach((tagData, index) => {
- const { text, active } = tagData;
- const tagEl = document.createElement("div");
- tagEl.className = "comfy-tag";
-
- updateTagStyle(tagEl, active);
-
- tagEl.textContent = text;
- tagEl.title = text; // Set tooltip for full content
-
- // Add click handler to toggle state
- tagEl.addEventListener("click", (e) => {
- e.stopPropagation();
-
- // Toggle active state for this specific tag using its index
- const updatedTags = [...widget.value];
- updatedTags[index].active = !updatedTags[index].active;
- updateTagStyle(tagEl, updatedTags[index].active);
-
- widget.value = updatedTags;
- });
-
- container.appendChild(tagEl);
- });
- };
-
- // Helper function to update tag style based on active state
- function updateTagStyle(tagEl, active) {
- const baseStyles = {
- padding: "4px 12px", // 垂直内边距从6px减小到4px
- borderRadius: "6px", // Matching container radius
- maxWidth: "200px", // Increased max width
- overflow: "hidden",
- textOverflow: "ellipsis",
- whiteSpace: "nowrap",
- fontSize: "13px", // Slightly larger font
- cursor: "pointer",
- transition: "all 0.2s ease", // Smoother transition
- border: "1px solid transparent",
- display: "inline-block",
- boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
- margin: "2px", // 从4px减小到2px
- userSelect: "none", // Add this line to prevent text selection
- WebkitUserSelect: "none", // For Safari support
- MozUserSelect: "none", // For Firefox support
- msUserSelect: "none", // For IE/Edge support
- };
-
- if (active) {
- Object.assign(tagEl.style, {
- ...baseStyles,
- backgroundColor: "rgba(66, 153, 225, 0.9)", // Modern blue
- color: "white",
- borderColor: "rgba(66, 153, 225, 0.9)",
- });
- } else {
- Object.assign(tagEl.style, {
- ...baseStyles,
- backgroundColor: "rgba(45, 55, 72, 0.7)", // Darker inactive state
- color: "rgba(226, 232, 240, 0.8)", // Lighter text for contrast
- borderColor: "rgba(226, 232, 240, 0.2)",
- });
- }
-
- // Add hover effect
- tagEl.onmouseenter = () => {
- tagEl.style.transform = "translateY(-1px)";
- tagEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.15)";
- };
-
- tagEl.onmouseleave = () => {
- tagEl.style.transform = "translateY(0)";
- tagEl.style.boxShadow = "0 1px 2px rgba(0,0,0,0.1)";
- };
- }
-
- // Store the value as array
- let widgetValue = initialTagsData;
-
- // Create widget with initial properties
- const widget = node.addDOMWidget(name, "tags", container, {
- getValue: function() {
- return widgetValue;
- },
- setValue: function(v) {
- widgetValue = v;
- renderTags(widgetValue, widget);
-
- // Update container height after rendering
- requestAnimationFrame(() => {
- const minHeight = this.getMinHeight();
- container.style.height = `${minHeight}px`;
-
- // Force node to update size
- node.setSize([node.size[0], node.computeSize()[1]]);
- node.setDirtyCanvas(true, true);
- });
- },
- getMinHeight: function() {
- const minHeight = 150;
- // If no tags or only showing the empty message, return a minimum height
- if (widgetValue.length === 0) {
- return minHeight; // Height for empty state with message
- }
-
- // Get all tag elements
- const tagElements = container.querySelectorAll('.comfy-tag');
-
- if (tagElements.length === 0) {
- return minHeight; // Fallback if elements aren't rendered yet
- }
-
- // Calculate the actual height based on tag positions
- let maxBottom = 0;
-
- tagElements.forEach(tag => {
- const rect = tag.getBoundingClientRect();
- const tagBottom = rect.bottom - container.getBoundingClientRect().top;
- maxBottom = Math.max(maxBottom, tagBottom);
- });
-
- // Add padding (top and bottom padding of container)
- const computedStyle = window.getComputedStyle(container);
- const paddingTop = parseInt(computedStyle.paddingTop, 10) || 0;
- const paddingBottom = parseInt(computedStyle.paddingBottom, 10) || 0;
-
- // Add extra buffer for potential wrapping issues and to ensure no clipping
- const extraBuffer = 20;
-
- // Round up to nearest 5px for clean sizing and ensure minimum height
- return Math.max(minHeight, Math.ceil((maxBottom + paddingBottom + extraBuffer) / 5) * 5);
- },
- });
-
- widget.value = initialTagsData;
-
- widget.callback = callback;
-
- widget.serializeValue = () => {
- // Add dummy items to avoid the 2-element serialization issue, a bug in comfyui
- return [...widgetValue,
- { text: "__dummy_item__", active: false, _isDummy: true },
- { text: "__dummy_item__", active: false, _isDummy: true }
- ];
- };
-
- return { minWidth: 300, minHeight: 150, widget };
-}
diff --git a/web/comfyui/lora_loader.js b/web/comfyui/lora_loader.js
index aa62252f..f13f48a6 100644
--- a/web/comfyui/lora_loader.js
+++ b/web/comfyui/lora_loader.js
@@ -176,32 +176,6 @@ app.registerExtension({
inputWidget,
originalCallback
);
-
- // Register this node with the backend
- this.registerNode = async () => {
- try {
- await fetch("/api/register-node", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- node_id: this.id,
- bgcolor: this.bgcolor,
- title: this.title,
- graph_id: this.graph.id,
- }),
- });
- } catch (error) {
- console.warn("Failed to register node:", error);
- }
- };
-
- // Ensure the node is registered after creation
- // Call registration
- // setTimeout(() => {
- // this.registerNode();
- // }, 0);
});
}
},
diff --git a/web/comfyui/lora_stacker.js b/web/comfyui/lora_stacker.js
index 9b812926..5648891d 100644
--- a/web/comfyui/lora_stacker.js
+++ b/web/comfyui/lora_stacker.js
@@ -101,31 +101,6 @@ app.registerExtension({
inputWidget,
originalCallback
);
-
- // Register this node with the backend
- this.registerNode = async () => {
- try {
- await fetch("/api/register-node", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- node_id: this.id,
- bgcolor: this.bgcolor,
- title: this.title,
- graph_id: this.graph.id,
- }),
- });
- } catch (error) {
- console.warn("Failed to register node:", error);
- }
- };
-
- // Call registration
- // setTimeout(() => {
- // this.registerNode();
- // }, 0);
});
}
},
diff --git a/web/comfyui/trigger_word_toggle.js b/web/comfyui/trigger_word_toggle.js
index 74a3fe1d..57541bd4 100644
--- a/web/comfyui/trigger_word_toggle.js
+++ b/web/comfyui/trigger_word_toggle.js
@@ -1,11 +1,7 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
-import { CONVERTED_TYPE, dynamicImportByVersion } from "./utils.js";
-
-// Function to get the appropriate tags widget based on ComfyUI version
-async function getTagsWidgetModule() {
- return await dynamicImportByVersion("./tags_widget.js", "./legacy_tags_widget.js");
-}
+import { CONVERTED_TYPE } from "./utils.js";
+import { addTagsWidget } from "./tags_widget.js";
// TriggerWordToggle extension for ComfyUI
app.registerExtension({
@@ -30,10 +26,6 @@ app.registerExtension({
// Wait for node to be properly initialized
requestAnimationFrame(async () => {
- // Dynamically import the appropriate tags widget module
- const tagsModule = await getTagsWidgetModule();
- const { addTagsWidget } = tagsModule;
-
// Get the widget object directly from the returned object
const result = addTagsWidget(node, "toggle_trigger_words", {
defaultVal: []
diff --git a/web/comfyui/utils.js b/web/comfyui/utils.js
index 760cb46a..08cbff7a 100644
--- a/web/comfyui/utils.js
+++ b/web/comfyui/utils.js
@@ -20,10 +20,6 @@ export function chainCallback(object, property, callback) {
}
}
-export function getComfyUIFrontendVersion() {
- return window['__COMFYUI_FRONTEND_VERSION__'] || "0.0.0";
-}
-
/**
* Show a toast notification
* @param {Object|string} options - Toast options object or message string for backward compatibility
@@ -78,29 +74,6 @@ export function showToast(options, type = 'info') {
}
}
-// Dynamically import the appropriate widget based on app version
-export async function dynamicImportByVersion(latestModulePath, legacyModulePath) {
- // Parse app version and compare with 1.12.6 (version when tags widget API changed)
- const currentVersion = getComfyUIFrontendVersion();
- const versionParts = currentVersion.split('.').map(part => parseInt(part, 10));
- const requiredVersion = [1, 12, 6];
-
- // Compare version numbers
- for (let i = 0; i < 3; i++) {
- if (versionParts[i] > requiredVersion[i]) {
- console.log(`Using latest widget: ${latestModulePath}`);
- return import(latestModulePath);
- } else if (versionParts[i] < requiredVersion[i]) {
- console.log(`Using legacy widget: ${legacyModulePath}`);
- return import(legacyModulePath);
- }
- }
-
- // If we get here, versions are equal, use the latest module
- console.log(`Using latest widget: ${latestModulePath}`);
- return import(latestModulePath);
-}
-
export function hideWidgetForGood(node, widget, suffix = "") {
widget.origType = widget.type;
widget.origComputeSize = widget.computeSize;
@@ -124,11 +97,6 @@ export function hideWidgetForGood(node, widget, suffix = "") {
}
}
-// Function to get the appropriate loras widget based on ComfyUI version
-export async function getLorasWidgetModule() {
- return await dynamicImportByVersion("./loras_widget.js", "./legacy_loras_widget.js");
-}
-
// Update pattern to match both formats: or
export const LORA_PATTERN = //g;
From 1cddeee264275aa418fc3bedb69c0e1637cffeeb Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Wed, 17 Sep 2025 11:04:51 +0800
Subject: [PATCH 029/110] style(autocomplete): remove font styles from dropdown
for consistency
---
web/comfyui/autocomplete.js | 2 --
1 file changed, 2 deletions(-)
diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js
index c8c33244..6e4e8e03 100644
--- a/web/comfyui/autocomplete.js
+++ b/web/comfyui/autocomplete.js
@@ -47,8 +47,6 @@ class AutoComplete {
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: none;
- font-family: Arial, sans-serif;
- font-size: 14px;
min-width: 200px;
width: auto;
backdrop-filter: blur(8px);
From 933e2fc01d14f047f9aaa7e024627410bbfd2ee2 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Wed, 17 Sep 2025 15:47:30 +0800
Subject: [PATCH 030/110] feat(routes): integrate CivitAI model version
retrieval for various model types
---
py/routes/base_model_routes.py | 107 +++++++++++++++++++++++++++++++--
py/routes/checkpoint_routes.py | 59 +++---------------
py/routes/embedding_routes.py | 59 +++---------------
py/routes/lora_routes.py | 102 +++----------------------------
4 files changed, 126 insertions(+), 201 deletions(-)
diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py
index aea644e3..499d4617 100644
--- a/py/routes/base_model_routes.py
+++ b/py/routes/base_model_routes.py
@@ -14,6 +14,7 @@ from ..services.settings_manager import settings
from ..services.server_i18n import server_i18n
from ..services.model_file_service import ModelFileService, ModelMoveService
from ..services.websocket_progress_callback import WebSocketProgressCallback
+from ..services.metadata_service import get_default_metadata_provider
from ..config import config
logger = logging.getLogger(__name__)
@@ -84,14 +85,17 @@ class BaseModelRoutes(ABC):
# Autocomplete route
app.router.add_get(f'/api/{prefix}/relative-paths', self.get_relative_paths)
+ # Common CivitAI integration
+ app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions)
+ app.router.add_get(f'/api/{prefix}/civitai/model/version/{{modelVersionId}}', self.get_civitai_model_by_version)
+ app.router.add_get(f'/api/{prefix}/civitai/model/hash/{{hash}}', self.get_civitai_model_by_hash)
+
# Common Download management
app.router.add_post(f'/api/download-model', self.download_model)
app.router.add_get(f'/api/download-model-get', self.download_model_get)
app.router.add_get(f'/api/cancel-download-get', self.cancel_download_get)
app.router.add_get(f'/api/download-progress/{{download_id}}', self.get_download_progress)
- # app.router.add_get(f'/api/civitai/versions/{{model_id}}', self.get_civitai_versions)
-
# Add generic page route
app.router.add_get(f'/{prefix}', self.handle_models_page)
@@ -704,10 +708,101 @@ class BaseModelRoutes(ABC):
async def get_civitai_versions(self, request: web.Request) -> web.Response:
"""Get available versions for a Civitai model with local availability info"""
- # This will be implemented by subclasses as they need CivitAI client access
- return web.json_response({
- "error": "Not implemented in base class"
- }, status=501)
+ try:
+ model_id = request.match_info['model_id']
+ metadata_provider = await get_default_metadata_provider()
+ response = await metadata_provider.get_model_versions(model_id)
+ if not response or not response.get('modelVersions'):
+ return web.Response(status=404, text="Model not found")
+
+ versions = response.get('modelVersions', [])
+ model_type = response.get('type', '')
+
+ # Check model type - allow subclasses to override validation
+ if not self._validate_civitai_model_type(model_type):
+ return web.json_response({
+ 'error': f"Model type mismatch. Expected {self._get_expected_model_types()}, got {model_type}"
+ }, status=400)
+
+ # Check local availability for each version
+ for version in versions:
+ # Find the model file (type="Model" and primary=true) in the files list
+ model_file = self._find_model_file(version.get('files', []))
+
+ if model_file:
+ sha256 = model_file.get('hashes', {}).get('SHA256')
+ if sha256:
+ # Set existsLocally and localPath at the version level
+ version['existsLocally'] = self.service.has_hash(sha256)
+ if version['existsLocally']:
+ version['localPath'] = self.service.get_path_by_hash(sha256)
+
+ # Also set the model file size at the version level for easier access
+ version['modelSizeKB'] = model_file.get('sizeKB')
+ else:
+ # No model file found in this version
+ version['existsLocally'] = False
+
+ return web.json_response(versions)
+ except Exception as e:
+ logger.error(f"Error fetching {self.model_type} model versions: {e}")
+ return web.Response(status=500, text=str(e))
+
+ async def get_civitai_model_by_version(self, request: web.Request) -> web.Response:
+ """Get CivitAI model details by model version ID"""
+ try:
+ model_version_id = request.match_info.get('modelVersionId')
+
+ # Get model details from metadata provider
+ metadata_provider = await get_default_metadata_provider()
+ model, error_msg = await metadata_provider.get_model_version_info(model_version_id)
+
+ if not model:
+ # Log warning for failed model retrieval
+ logger.warning(f"Failed to fetch model version {model_version_id}: {error_msg}")
+
+ # Determine status code based on error message
+ status_code = 404 if error_msg and "not found" in error_msg.lower() else 500
+
+ return web.json_response({
+ "success": False,
+ "error": error_msg or "Failed to fetch model information"
+ }, status=status_code)
+
+ return web.json_response(model)
+ except Exception as e:
+ logger.error(f"Error fetching model details: {e}")
+ return web.json_response({
+ "success": False,
+ "error": str(e)
+ }, status=500)
+
+ async def get_civitai_model_by_hash(self, request: web.Request) -> web.Response:
+ """Get CivitAI model details by hash"""
+ try:
+ hash = request.match_info.get('hash')
+ metadata_provider = await get_default_metadata_provider()
+ model = await metadata_provider.get_model_by_hash(hash)
+ return web.json_response(model)
+ except Exception as e:
+ logger.error(f"Error fetching model details by hash: {e}")
+ return web.json_response({
+ "success": False,
+ "error": str(e)
+ }, status=500)
+
+ def _validate_civitai_model_type(self, model_type: str) -> bool:
+ """Validate CivitAI model type - to be overridden by subclasses"""
+ return True # Default: accept all types
+
+ def _get_expected_model_types(self) -> str:
+ """Get expected model types string for error messages - to be overridden by subclasses"""
+ return "any model type"
+
+ def _find_model_file(self, files: list) -> dict:
+ """Find the appropriate model file from the files list - can be overridden by subclasses"""
+ # Find the primary model file (type="Model" and primary=true) in the files list
+ return next((file for file in files if file.get('type') == 'Model' and file.get('primary') == True), None)
# Common model move handlers
async def move_model(self, request: web.Request) -> web.Response:
diff --git a/py/routes/checkpoint_routes.py b/py/routes/checkpoint_routes.py
index a0f6a027..712eaafc 100644
--- a/py/routes/checkpoint_routes.py
+++ b/py/routes/checkpoint_routes.py
@@ -36,9 +36,6 @@ class CheckpointRoutes(BaseModelRoutes):
def setup_specific_routes(self, app: web.Application, prefix: str):
"""Setup Checkpoint-specific routes"""
- # Checkpoint-specific CivitAI integration
- app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions_checkpoint)
-
# Checkpoint info by name
app.router.add_get(f'/api/{prefix}/info/{{name}}', self.get_checkpoint_info)
@@ -46,6 +43,14 @@ class CheckpointRoutes(BaseModelRoutes):
app.router.add_get(f'/api/{prefix}/checkpoints_roots', self.get_checkpoints_roots)
app.router.add_get(f'/api/{prefix}/unet_roots', self.get_unet_roots)
+ def _validate_civitai_model_type(self, model_type: str) -> bool:
+ """Validate CivitAI model type for Checkpoint"""
+ return model_type.lower() == 'checkpoint'
+
+ def _get_expected_model_types(self) -> str:
+ """Get expected model types string for error messages"""
+ return "Checkpoint"
+
async def get_checkpoint_info(self, request: web.Request) -> web.Response:
"""Get detailed information for a specific checkpoint by name"""
try:
@@ -61,54 +66,6 @@ class CheckpointRoutes(BaseModelRoutes):
logger.error(f"Error in get_checkpoint_info: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
- async def get_civitai_versions_checkpoint(self, request: web.Request) -> web.Response:
- """Get available versions for a Civitai checkpoint model with local availability info"""
- try:
- model_id = request.match_info['model_id']
- metadata_provider = await get_default_metadata_provider()
- response = await metadata_provider.get_model_versions(model_id)
- if not response or not response.get('modelVersions'):
- return web.Response(status=404, text="Model not found")
-
- versions = response.get('modelVersions', [])
- model_type = response.get('type', '')
-
- # Check model type - should be Checkpoint
- if model_type.lower() != 'checkpoint':
- return web.json_response({
- 'error': f"Model type mismatch. Expected Checkpoint, got {model_type}"
- }, status=400)
-
- # Check local availability for each version
- for version in versions:
- # Find the primary model file (type="Model" and primary=true) in the files list
- model_file = next((file for file in version.get('files', [])
- if file.get('type') == 'Model' and file.get('primary') == True), None)
-
- # If no primary file found, try to find any model file
- if not model_file:
- model_file = next((file for file in version.get('files', [])
- if file.get('type') == 'Model'), None)
-
- if model_file:
- sha256 = model_file.get('hashes', {}).get('SHA256')
- if sha256:
- # Set existsLocally and localPath at the version level
- version['existsLocally'] = self.service.has_hash(sha256)
- if version['existsLocally']:
- version['localPath'] = self.service.get_path_by_hash(sha256)
-
- # Also set the model file size at the version level for easier access
- version['modelSizeKB'] = model_file.get('sizeKB')
- else:
- # No model file found in this version
- version['existsLocally'] = False
-
- return web.json_response(versions)
- except Exception as e:
- logger.error(f"Error fetching checkpoint model versions: {e}")
- return web.Response(status=500, text=str(e))
-
async def get_checkpoints_roots(self, request: web.Request) -> web.Response:
"""Return the list of checkpoint roots from config"""
try:
diff --git a/py/routes/embedding_routes.py b/py/routes/embedding_routes.py
index ab028666..70b5b26b 100644
--- a/py/routes/embedding_routes.py
+++ b/py/routes/embedding_routes.py
@@ -35,12 +35,17 @@ class EmbeddingRoutes(BaseModelRoutes):
def setup_specific_routes(self, app: web.Application, prefix: str):
"""Setup Embedding-specific routes"""
- # Embedding-specific CivitAI integration
- app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions_embedding)
-
# Embedding info by name
app.router.add_get(f'/api/{prefix}/info/{{name}}', self.get_embedding_info)
+ def _validate_civitai_model_type(self, model_type: str) -> bool:
+ """Validate CivitAI model type for Embedding"""
+ return model_type.lower() in ['textualinversion', 'embedding']
+
+ def _get_expected_model_types(self) -> str:
+ """Get expected model types string for error messages"""
+ return "TextualInversion/Embedding"
+
async def get_embedding_info(self, request: web.Request) -> web.Response:
"""Get detailed information for a specific embedding by name"""
try:
@@ -55,51 +60,3 @@ class EmbeddingRoutes(BaseModelRoutes):
except Exception as e:
logger.error(f"Error in get_embedding_info: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
-
- async def get_civitai_versions_embedding(self, request: web.Request) -> web.Response:
- """Get available versions for a Civitai embedding model with local availability info"""
- try:
- model_id = request.match_info['model_id']
- metadata_provider = await get_default_metadata_provider()
- response = await metadata_provider.get_model_versions(model_id)
- if not response or not response.get('modelVersions'):
- return web.Response(status=404, text="Model not found")
-
- versions = response.get('modelVersions', [])
- model_type = response.get('type', '')
-
- # Check model type - should be TextualInversion (Embedding)
- if model_type.lower() not in ['textualinversion', 'embedding']:
- return web.json_response({
- 'error': f"Model type mismatch. Expected TextualInversion/Embedding, got {model_type}"
- }, status=400)
-
- # Check local availability for each version
- for version in versions:
- # Find the primary model file (type="Model" and primary=true) in the files list
- model_file = next((file for file in version.get('files', [])
- if file.get('type') == 'Model' and file.get('primary') == True), None)
-
- # If no primary file found, try to find any model file
- if not model_file:
- model_file = next((file for file in version.get('files', [])
- if file.get('type') == 'Model'), None)
-
- if model_file:
- sha256 = model_file.get('hashes', {}).get('SHA256')
- if sha256:
- # Set existsLocally and localPath at the version level
- version['existsLocally'] = self.service.has_hash(sha256)
- if version['existsLocally']:
- version['localPath'] = self.service.get_path_by_hash(sha256)
-
- # Also set the model file size at the version level for easier access
- version['modelSizeKB'] = model_file.get('sizeKB')
- else:
- # No model file found in this version
- version['existsLocally'] = False
-
- return web.json_response(versions)
- except Exception as e:
- logger.error(f"Error fetching embedding model versions: {e}")
- return web.Response(status=500, text=str(e))
diff --git a/py/routes/lora_routes.py b/py/routes/lora_routes.py
index 4e261004..d70a2801 100644
--- a/py/routes/lora_routes.py
+++ b/py/routes/lora_routes.py
@@ -44,11 +44,6 @@ class LoraRoutes(BaseModelRoutes):
app.router.add_get(f'/api/{prefix}/get-trigger-words', self.get_lora_trigger_words)
app.router.add_get(f'/api/{prefix}/usage-tips-by-path', self.get_lora_usage_tips_by_path)
- # CivitAI integration with LoRA-specific validation
- app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions_lora)
- app.router.add_get(f'/api/{prefix}/civitai/model/version/{{modelVersionId}}', self.get_civitai_model_by_version)
- app.router.add_get(f'/api/{prefix}/civitai/model/hash/{{hash}}', self.get_civitai_model_by_hash)
-
# ComfyUI integration
app.router.add_post(f'/api/{prefix}/get_trigger_words', self.get_trigger_words)
@@ -76,6 +71,15 @@ class LoraRoutes(BaseModelRoutes):
return params
+ def _validate_civitai_model_type(self, model_type: str) -> bool:
+ """Validate CivitAI model type for LoRA"""
+ from ..utils.constants import VALID_LORA_TYPES
+ return model_type.lower() in VALID_LORA_TYPES
+
+ def _get_expected_model_types(self) -> str:
+ """Get expected model types string for error messages"""
+ return "LORA, LoCon, or DORA"
+
# LoRA-specific route handlers
async def get_letter_counts(self, request: web.Request) -> web.Response:
"""Get count of LoRAs for each letter of the alphabet"""
@@ -210,94 +214,6 @@ class LoraRoutes(BaseModelRoutes):
'error': str(e)
}, status=500)
- # CivitAI integration methods
- async def get_civitai_versions_lora(self, request: web.Request) -> web.Response:
- """Get available versions for a Civitai LoRA model with local availability info"""
- try:
- model_id = request.match_info['model_id']
- metadata_provider = await get_default_metadata_provider()
- response = await metadata_provider.get_model_versions(model_id)
- if not response or not response.get('modelVersions'):
- return web.Response(status=404, text="Model not found")
-
- versions = response.get('modelVersions', [])
- model_type = response.get('type', '')
-
- # Check model type - should be LORA, LoCon, or DORA
- from ..utils.constants import VALID_LORA_TYPES
- if model_type.lower() not in VALID_LORA_TYPES:
- return web.json_response({
- 'error': f"Model type mismatch. Expected LORA or LoCon, got {model_type}"
- }, status=400)
-
- # Check local availability for each version
- for version in versions:
- # Find the model file (type="Model") in the files list
- model_file = next((file for file in version.get('files', [])
- if file.get('type') == 'Model'), None)
-
- if model_file:
- sha256 = model_file.get('hashes', {}).get('SHA256')
- if sha256:
- # Set existsLocally and localPath at the version level
- version['existsLocally'] = self.service.has_hash(sha256)
- if version['existsLocally']:
- version['localPath'] = self.service.get_path_by_hash(sha256)
-
- # Also set the model file size at the version level for easier access
- version['modelSizeKB'] = model_file.get('sizeKB')
- else:
- # No model file found in this version
- version['existsLocally'] = False
-
- return web.json_response(versions)
- except Exception as e:
- logger.error(f"Error fetching LoRA model versions: {e}")
- return web.Response(status=500, text=str(e))
-
- async def get_civitai_model_by_version(self, request: web.Request) -> web.Response:
- """Get CivitAI model details by model version ID"""
- try:
- model_version_id = request.match_info.get('modelVersionId')
-
- # Get model details from metadata provider
- metadata_provider = await get_default_metadata_provider()
- model, error_msg = await metadata_provider.get_model_version_info(model_version_id)
-
- if not model:
- # Log warning for failed model retrieval
- logger.warning(f"Failed to fetch model version {model_version_id}: {error_msg}")
-
- # Determine status code based on error message
- status_code = 404 if error_msg and "not found" in error_msg.lower() else 500
-
- return web.json_response({
- "success": False,
- "error": error_msg or "Failed to fetch model information"
- }, status=status_code)
-
- return web.json_response(model)
- except Exception as e:
- logger.error(f"Error fetching model details: {e}")
- return web.json_response({
- "success": False,
- "error": str(e)
- }, status=500)
-
- async def get_civitai_model_by_hash(self, request: web.Request) -> web.Response:
- """Get CivitAI model details by hash"""
- try:
- hash = request.match_info.get('hash')
- metadata_provider = await get_default_metadata_provider()
- model = await metadata_provider.get_model_by_hash(hash)
- return web.json_response(model)
- except Exception as e:
- logger.error(f"Error fetching model details by hash: {e}")
- return web.json_response({
- "success": False,
- "error": str(e)
- }, status=500)
-
async def get_trigger_words(self, request: web.Request) -> web.Response:
"""Get trigger words for specified LoRA models"""
try:
From ded17c1479d5262670e139b45ea3def4e99c4035 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Wed, 17 Sep 2025 22:06:59 +0800
Subject: [PATCH 031/110] feat(routes): add model versions status endpoint and
enhance metadata retrieval
---
py/routes/embedding_routes.py | 4 +-
py/routes/misc_routes.py | 112 ++++++++++++++++++++++++-
py/services/civitai_client.py | 3 +-
py/services/model_metadata_provider.py | 4 +-
4 files changed, 118 insertions(+), 5 deletions(-)
diff --git a/py/routes/embedding_routes.py b/py/routes/embedding_routes.py
index 70b5b26b..eefa8bdd 100644
--- a/py/routes/embedding_routes.py
+++ b/py/routes/embedding_routes.py
@@ -40,11 +40,11 @@ class EmbeddingRoutes(BaseModelRoutes):
def _validate_civitai_model_type(self, model_type: str) -> bool:
"""Validate CivitAI model type for Embedding"""
- return model_type.lower() in ['textualinversion', 'embedding']
+ return model_type.lower() == 'textualinversion'
def _get_expected_model_types(self) -> str:
"""Get expected model types string for error messages"""
- return "TextualInversion/Embedding"
+ return "TextualInversion"
async def get_embedding_info(self, request: web.Request) -> web.Response:
"""Get detailed information for a specific embedding by name"""
diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py
index 89fadace..5cae7002 100644
--- a/py/routes/misc_routes.py
+++ b/py/routes/misc_routes.py
@@ -12,7 +12,7 @@ from ..utils.lora_metadata import extract_trained_words
from ..config import config
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS, NODE_TYPES, DEFAULT_NODE_COLOR
from ..services.service_registry import ServiceRegistry
-from ..services.metadata_service import get_metadata_archive_manager, update_metadata_providers
+from ..services.metadata_service import get_metadata_archive_manager, update_metadata_providers, get_metadata_provider
from ..services.websocket_manager import ws_manager
from ..services.downloader import get_downloader
logger = logging.getLogger(__name__)
@@ -119,6 +119,9 @@ class MiscRoutes:
app.router.add_post('/api/download-metadata-archive', MiscRoutes.download_metadata_archive)
app.router.add_post('/api/remove-metadata-archive', MiscRoutes.remove_metadata_archive)
app.router.add_get('/api/metadata-archive-status', MiscRoutes.get_metadata_archive_status)
+
+ # Add route for checking model versions in library
+ app.router.add_get('/api/model-versions-status', MiscRoutes.get_model_versions_status)
@staticmethod
async def get_settings(request):
@@ -832,6 +835,113 @@ class MiscRoutes:
'success': False,
'error': str(e)
}, status=500)
+
+ @staticmethod
+ async def get_model_versions_status(request):
+ """
+ Get all versions of a model from metadata provider and check their library status
+
+ Expects query parameters:
+ - modelId: int - Civitai model ID (required)
+
+ Returns:
+ - JSON with model type and versions list, each version includes 'inLibrary' flag
+ """
+ try:
+ # Get the modelId from query parameters
+ model_id_str = request.query.get('modelId')
+
+ # Validate modelId parameter (required)
+ if not model_id_str:
+ return web.json_response({
+ 'success': False,
+ 'error': 'Missing required parameter: modelId'
+ }, status=400)
+
+ try:
+ # Convert modelId to integer
+ model_id = int(model_id_str)
+ except ValueError:
+ return web.json_response({
+ 'success': False,
+ 'error': 'Parameter modelId must be an integer'
+ }, status=400)
+
+ # Get metadata provider
+ metadata_provider = await get_metadata_provider()
+ if not metadata_provider:
+ return web.json_response({
+ 'success': False,
+ 'error': 'Metadata provider not available'
+ }, status=503)
+
+ # Get model versions from metadata provider
+ response = await metadata_provider.get_model_versions(model_id)
+ if not response or not response.get('modelVersions'):
+ return web.json_response({
+ 'success': False,
+ 'error': 'Model not found'
+ }, status=404)
+
+ versions = response.get('modelVersions', [])
+ model_name = response.get('name', '')
+ model_type = response.get('type', '').lower()
+
+ # Determine scanner based on model type
+ scanner = None
+ normalized_type = None
+
+ if model_type in ['lora', 'locon', 'dora']:
+ scanner = await ServiceRegistry.get_lora_scanner()
+ normalized_type = 'lora'
+ elif model_type == 'checkpoint':
+ scanner = await ServiceRegistry.get_checkpoint_scanner()
+ normalized_type = 'checkpoint'
+ elif model_type == 'textualinversion':
+ scanner = await ServiceRegistry.get_embedding_scanner()
+ normalized_type = 'embedding'
+ else:
+ return web.json_response({
+ 'success': False,
+ 'error': f'Model type "{model_type}" is not supported'
+ }, status=400)
+
+ if not scanner:
+ return web.json_response({
+ 'success': False,
+ 'error': f'Scanner for type "{normalized_type}" is not available'
+ }, status=503)
+
+ # Get local versions from scanner
+ local_versions = await scanner.get_model_versions_by_id(model_id)
+ local_version_ids = set(version['versionId'] for version in local_versions)
+
+ # Add inLibrary flag to each version
+ enriched_versions = []
+ for version in versions:
+ version_id = version.get('id')
+ enriched_version = {
+ 'id': version_id,
+ 'name': version.get('name', ''),
+ 'thumbnailUrl': version.get('images')[0]['url'] if version.get('images') else None,
+ 'inLibrary': version_id in local_version_ids
+ }
+ enriched_versions.append(enriched_version)
+
+ return web.json_response({
+ 'success': True,
+ 'modelId': model_id,
+ 'modelName': model_name,
+ 'modelType': model_type,
+ 'versions': enriched_versions
+ })
+
+ except Exception as e:
+ logger.error(f"Failed to get model versions status: {e}", exc_info=True)
+ return web.json_response({
+ 'success': False,
+ 'error': str(e)
+ }, status=500)
@staticmethod
async def open_file_location(request):
diff --git a/py/services/civitai_client.py b/py/services/civitai_client.py
index e037ba35..463bd036 100644
--- a/py/services/civitai_client.py
+++ b/py/services/civitai_client.py
@@ -122,7 +122,8 @@ class CivitaiClient:
# Also return model type along with versions
return {
'modelVersions': result.get('modelVersions', []),
- 'type': result.get('type', '')
+ 'type': result.get('type', ''),
+ 'name': result.get('name', '')
}
return None
except Exception as e:
diff --git a/py/services/model_metadata_provider.py b/py/services/model_metadata_provider.py
index 9957b849..ee38f373 100644
--- a/py/services/model_metadata_provider.py
+++ b/py/services/model_metadata_provider.py
@@ -224,6 +224,7 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
model_data = json.loads(model_row['data'])
model_type = model_row['type']
+ model_name = model_row['name']
# Get all versions for this model
versions_query = """
@@ -260,7 +261,8 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
return {
'modelVersions': model_versions,
- 'type': model_type
+ 'type': model_type,
+ 'name': model_name
}
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
From bdc86ddf1596338c6b010ce783dd3b295216e128 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Thu, 18 Sep 2025 14:50:40 +0800
Subject: [PATCH 032/110] Refactor API endpoints to use '/api/lm/' prefix
- Updated all relevant routes in `stats_routes.py` and `update_routes.py` to include the new '/api/lm/' prefix for consistency.
- Modified API endpoint configurations in `apiConfig.js` to reflect the new structure, ensuring all CRUD and bulk operations are correctly routed.
- Adjusted fetch calls in various components and managers to utilize the updated API paths, including recipe, model, and example image operations.
- Ensured all instances of the old API paths were replaced with the new '/api/lm/' prefix across the codebase for uniformity and to prevent broken links.
---
py/routes/base_model_routes.py | 78 +++++++--------
py/routes/checkpoint_routes.py | 6 +-
py/routes/embedding_routes.py | 2 +-
py/routes/example_images_routes.py | 20 ++--
py/routes/lora_routes.py | 8 +-
py/routes/misc_routes.py | 28 +++---
py/routes/recipe_routes.py | 36 +++----
py/routes/stats_routes.py | 12 +--
py/routes/update_routes.py | 6 +-
static/js/api/apiConfig.js | 94 +++++++++----------
static/js/api/recipeApi.js | 8 +-
.../ContextMenu/ModelContextMenuMixin.js | 4 +-
.../ContextMenu/RecipeContextMenu.js | 12 +--
static/js/components/DuplicatesManager.js | 4 +-
.../js/components/ModelDuplicatesManager.js | 10 +-
static/js/components/RecipeCard.js | 6 +-
static/js/components/RecipeModal.js | 8 +-
static/js/components/alphabet/AlphabetBar.js | 2 +-
static/js/components/initialization.js | 2 +-
static/js/components/shared/ModelCard.js | 2 +-
static/js/components/shared/ModelModal.js | 2 +-
static/js/components/shared/RecipeTab.js | 4 +-
static/js/components/shared/TriggerWords.js | 2 +-
.../components/shared/showcase/MediaUtils.js | 2 +-
.../shared/showcase/ShowcaseView.js | 6 +-
static/js/managers/ExampleImagesManager.js | 12 +--
static/js/managers/FilterManager.js | 4 +-
static/js/managers/SettingsManager.js | 12 +--
static/js/managers/UpdateService.js | 6 +-
static/js/managers/import/DownloadManager.js | 2 +-
static/js/managers/import/FolderBrowser.js | 4 +-
static/js/managers/import/ImageProcessor.js | 6 +-
static/js/statistics.js | 12 +--
static/js/utils/uiHelpers.js | 6 +-
web/comfyui/autocomplete.js | 4 +-
web/comfyui/loras_widget_components.js | 2 +-
web/comfyui/loras_widget_events.js | 6 +-
web/comfyui/loras_widget_utils.js | 2 +-
web/comfyui/ui_utils.js | 2 +-
web/comfyui/usage_stats.js | 6 +-
40 files changed, 225 insertions(+), 225 deletions(-)
diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py
index 499d4617..2b2f7a7c 100644
--- a/py/routes/base_model_routes.py
+++ b/py/routes/base_model_routes.py
@@ -48,53 +48,53 @@ class BaseModelRoutes(ABC):
prefix: URL prefix (e.g., 'loras', 'checkpoints')
"""
# Common model management routes
- app.router.add_get(f'/api/{prefix}/list', self.get_models)
- app.router.add_post(f'/api/{prefix}/delete', self.delete_model)
- app.router.add_post(f'/api/{prefix}/exclude', self.exclude_model)
- app.router.add_post(f'/api/{prefix}/fetch-civitai', self.fetch_civitai)
- app.router.add_post(f'/api/{prefix}/fetch-all-civitai', self.fetch_all_civitai)
- app.router.add_post(f'/api/{prefix}/relink-civitai', self.relink_civitai)
- app.router.add_post(f'/api/{prefix}/replace-preview', self.replace_preview)
- app.router.add_post(f'/api/{prefix}/save-metadata', self.save_metadata)
- app.router.add_post(f'/api/{prefix}/add-tags', self.add_tags)
- app.router.add_post(f'/api/{prefix}/rename', self.rename_model)
- app.router.add_post(f'/api/{prefix}/bulk-delete', self.bulk_delete_models)
- app.router.add_post(f'/api/{prefix}/verify-duplicates', self.verify_duplicates)
- app.router.add_post(f'/api/{prefix}/move_model', self.move_model)
- app.router.add_post(f'/api/{prefix}/move_models_bulk', self.move_models_bulk)
- app.router.add_get(f'/api/{prefix}/auto-organize', self.auto_organize_models)
- app.router.add_post(f'/api/{prefix}/auto-organize', self.auto_organize_models)
- app.router.add_get(f'/api/{prefix}/auto-organize-progress', self.get_auto_organize_progress)
+ app.router.add_get(f'/api/lm/{prefix}/list', self.get_models)
+ app.router.add_post(f'/api/lm/{prefix}/delete', self.delete_model)
+ app.router.add_post(f'/api/lm/{prefix}/exclude', self.exclude_model)
+ app.router.add_post(f'/api/lm/{prefix}/fetch-civitai', self.fetch_civitai)
+ app.router.add_post(f'/api/lm/{prefix}/fetch-all-civitai', self.fetch_all_civitai)
+ app.router.add_post(f'/api/lm/{prefix}/relink-civitai', self.relink_civitai)
+ app.router.add_post(f'/api/lm/{prefix}/replace-preview', self.replace_preview)
+ app.router.add_post(f'/api/lm/{prefix}/save-metadata', self.save_metadata)
+ app.router.add_post(f'/api/lm/{prefix}/add-tags', self.add_tags)
+ app.router.add_post(f'/api/lm/{prefix}/rename', self.rename_model)
+ app.router.add_post(f'/api/lm/{prefix}/bulk-delete', self.bulk_delete_models)
+ app.router.add_post(f'/api/lm/{prefix}/verify-duplicates', self.verify_duplicates)
+ app.router.add_post(f'/api/lm/{prefix}/move_model', self.move_model)
+ app.router.add_post(f'/api/lm/{prefix}/move_models_bulk', self.move_models_bulk)
+ app.router.add_get(f'/api/lm/{prefix}/auto-organize', self.auto_organize_models)
+ app.router.add_post(f'/api/lm/{prefix}/auto-organize', self.auto_organize_models)
+ app.router.add_get(f'/api/lm/{prefix}/auto-organize-progress', self.get_auto_organize_progress)
# Common query routes
- app.router.add_get(f'/api/{prefix}/top-tags', self.get_top_tags)
- app.router.add_get(f'/api/{prefix}/base-models', self.get_base_models)
- app.router.add_get(f'/api/{prefix}/scan', self.scan_models)
- app.router.add_get(f'/api/{prefix}/roots', self.get_model_roots)
- app.router.add_get(f'/api/{prefix}/folders', self.get_folders)
- app.router.add_get(f'/api/{prefix}/folder-tree', self.get_folder_tree)
- app.router.add_get(f'/api/{prefix}/unified-folder-tree', self.get_unified_folder_tree)
- app.router.add_get(f'/api/{prefix}/find-duplicates', self.find_duplicate_models)
- app.router.add_get(f'/api/{prefix}/find-filename-conflicts', self.find_filename_conflicts)
- app.router.add_get(f'/api/{prefix}/get-notes', self.get_model_notes)
- app.router.add_get(f'/api/{prefix}/preview-url', self.get_model_preview_url)
- app.router.add_get(f'/api/{prefix}/civitai-url', self.get_model_civitai_url)
- app.router.add_get(f'/api/{prefix}/metadata', self.get_model_metadata)
- app.router.add_get(f'/api/{prefix}/model-description', self.get_model_description)
+ app.router.add_get(f'/api/lm/{prefix}/top-tags', self.get_top_tags)
+ app.router.add_get(f'/api/lm/{prefix}/base-models', self.get_base_models)
+ app.router.add_get(f'/api/lm/{prefix}/scan', self.scan_models)
+ app.router.add_get(f'/api/lm/{prefix}/roots', self.get_model_roots)
+ app.router.add_get(f'/api/lm/{prefix}/folders', self.get_folders)
+ app.router.add_get(f'/api/lm/{prefix}/folder-tree', self.get_folder_tree)
+ app.router.add_get(f'/api/lm/{prefix}/unified-folder-tree', self.get_unified_folder_tree)
+ app.router.add_get(f'/api/lm/{prefix}/find-duplicates', self.find_duplicate_models)
+ app.router.add_get(f'/api/lm/{prefix}/find-filename-conflicts', self.find_filename_conflicts)
+ app.router.add_get(f'/api/lm/{prefix}/get-notes', self.get_model_notes)
+ app.router.add_get(f'/api/lm/{prefix}/preview-url', self.get_model_preview_url)
+ app.router.add_get(f'/api/lm/{prefix}/civitai-url', self.get_model_civitai_url)
+ app.router.add_get(f'/api/lm/{prefix}/metadata', self.get_model_metadata)
+ app.router.add_get(f'/api/lm/{prefix}/model-description', self.get_model_description)
# Autocomplete route
- app.router.add_get(f'/api/{prefix}/relative-paths', self.get_relative_paths)
+ app.router.add_get(f'/api/lm/{prefix}/relative-paths', self.get_relative_paths)
# Common CivitAI integration
- app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions)
- app.router.add_get(f'/api/{prefix}/civitai/model/version/{{modelVersionId}}', self.get_civitai_model_by_version)
- app.router.add_get(f'/api/{prefix}/civitai/model/hash/{{hash}}', self.get_civitai_model_by_hash)
+ app.router.add_get(f'/api/lm/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions)
+ app.router.add_get(f'/api/lm/{prefix}/civitai/model/version/{{modelVersionId}}', self.get_civitai_model_by_version)
+ app.router.add_get(f'/api/lm/{prefix}/civitai/model/hash/{{hash}}', self.get_civitai_model_by_hash)
# Common Download management
- app.router.add_post(f'/api/download-model', self.download_model)
- app.router.add_get(f'/api/download-model-get', self.download_model_get)
- app.router.add_get(f'/api/cancel-download-get', self.cancel_download_get)
- app.router.add_get(f'/api/download-progress/{{download_id}}', self.get_download_progress)
+ app.router.add_post(f'/api/lm/download-model', self.download_model)
+ app.router.add_get(f'/api/lm/download-model-get', self.download_model_get)
+ app.router.add_get(f'/api/lm/cancel-download-get', self.cancel_download_get)
+ app.router.add_get(f'/api/lm/download-progress/{{download_id}}', self.get_download_progress)
# Add generic page route
app.router.add_get(f'/{prefix}', self.handle_models_page)
diff --git a/py/routes/checkpoint_routes.py b/py/routes/checkpoint_routes.py
index 712eaafc..95c747e5 100644
--- a/py/routes/checkpoint_routes.py
+++ b/py/routes/checkpoint_routes.py
@@ -37,11 +37,11 @@ class CheckpointRoutes(BaseModelRoutes):
def setup_specific_routes(self, app: web.Application, prefix: str):
"""Setup Checkpoint-specific routes"""
# Checkpoint info by name
- app.router.add_get(f'/api/{prefix}/info/{{name}}', self.get_checkpoint_info)
+ app.router.add_get(f'/api/lm/{prefix}/info/{{name}}', self.get_checkpoint_info)
# Checkpoint roots and Unet roots
- app.router.add_get(f'/api/{prefix}/checkpoints_roots', self.get_checkpoints_roots)
- app.router.add_get(f'/api/{prefix}/unet_roots', self.get_unet_roots)
+ app.router.add_get(f'/api/lm/{prefix}/checkpoints_roots', self.get_checkpoints_roots)
+ app.router.add_get(f'/api/lm/{prefix}/unet_roots', self.get_unet_roots)
def _validate_civitai_model_type(self, model_type: str) -> bool:
"""Validate CivitAI model type for Checkpoint"""
diff --git a/py/routes/embedding_routes.py b/py/routes/embedding_routes.py
index eefa8bdd..29b2f9fd 100644
--- a/py/routes/embedding_routes.py
+++ b/py/routes/embedding_routes.py
@@ -36,7 +36,7 @@ class EmbeddingRoutes(BaseModelRoutes):
def setup_specific_routes(self, app: web.Application, prefix: str):
"""Setup Embedding-specific routes"""
# Embedding info by name
- app.router.add_get(f'/api/{prefix}/info/{{name}}', self.get_embedding_info)
+ app.router.add_get(f'/api/lm/{prefix}/info/{{name}}', self.get_embedding_info)
def _validate_civitai_model_type(self, model_type: str) -> bool:
"""Validate CivitAI model type for Embedding"""
diff --git a/py/routes/example_images_routes.py b/py/routes/example_images_routes.py
index 9f20b470..07cb0e71 100644
--- a/py/routes/example_images_routes.py
+++ b/py/routes/example_images_routes.py
@@ -12,16 +12,16 @@ class ExampleImagesRoutes:
@staticmethod
def setup_routes(app):
"""Register example images routes"""
- app.router.add_post('/api/download-example-images', ExampleImagesRoutes.download_example_images)
- app.router.add_post('/api/import-example-images', ExampleImagesRoutes.import_example_images)
- app.router.add_get('/api/example-images-status', ExampleImagesRoutes.get_example_images_status)
- app.router.add_post('/api/pause-example-images', ExampleImagesRoutes.pause_example_images)
- app.router.add_post('/api/resume-example-images', ExampleImagesRoutes.resume_example_images)
- app.router.add_post('/api/open-example-images-folder', ExampleImagesRoutes.open_example_images_folder)
- app.router.add_get('/api/example-image-files', ExampleImagesRoutes.get_example_image_files)
- app.router.add_get('/api/has-example-images', ExampleImagesRoutes.has_example_images)
- app.router.add_post('/api/delete-example-image', ExampleImagesRoutes.delete_example_image)
- app.router.add_post('/api/force-download-example-images', ExampleImagesRoutes.force_download_example_images)
+ app.router.add_post('/api/lm/download-example-images', ExampleImagesRoutes.download_example_images)
+ app.router.add_post('/api/lm/import-example-images', ExampleImagesRoutes.import_example_images)
+ app.router.add_get('/api/lm/example-images-status', ExampleImagesRoutes.get_example_images_status)
+ app.router.add_post('/api/lm/pause-example-images', ExampleImagesRoutes.pause_example_images)
+ app.router.add_post('/api/lm/resume-example-images', ExampleImagesRoutes.resume_example_images)
+ app.router.add_post('/api/lm/open-example-images-folder', ExampleImagesRoutes.open_example_images_folder)
+ app.router.add_get('/api/lm/example-image-files', ExampleImagesRoutes.get_example_image_files)
+ app.router.add_get('/api/lm/has-example-images', ExampleImagesRoutes.has_example_images)
+ app.router.add_post('/api/lm/delete-example-image', ExampleImagesRoutes.delete_example_image)
+ app.router.add_post('/api/lm/force-download-example-images', ExampleImagesRoutes.force_download_example_images)
@staticmethod
async def download_example_images(request):
diff --git a/py/routes/lora_routes.py b/py/routes/lora_routes.py
index d70a2801..0ddb41ab 100644
--- a/py/routes/lora_routes.py
+++ b/py/routes/lora_routes.py
@@ -40,12 +40,12 @@ class LoraRoutes(BaseModelRoutes):
def setup_specific_routes(self, app: web.Application, prefix: str):
"""Setup LoRA-specific routes"""
# LoRA-specific query routes
- app.router.add_get(f'/api/{prefix}/letter-counts', self.get_letter_counts)
- app.router.add_get(f'/api/{prefix}/get-trigger-words', self.get_lora_trigger_words)
- app.router.add_get(f'/api/{prefix}/usage-tips-by-path', self.get_lora_usage_tips_by_path)
+ app.router.add_get(f'/api/lm/{prefix}/letter-counts', self.get_letter_counts)
+ app.router.add_get(f'/api/lm/{prefix}/get-trigger-words', self.get_lora_trigger_words)
+ app.router.add_get(f'/api/lm/{prefix}/usage-tips-by-path', self.get_lora_usage_tips_by_path)
# ComfyUI integration
- app.router.add_post(f'/api/{prefix}/get_trigger_words', self.get_trigger_words)
+ app.router.add_post(f'/api/lm/{prefix}/get_trigger_words', self.get_trigger_words)
def _parse_specific_params(self, request: web.Request) -> Dict:
"""Parse LoRA-specific parameters"""
diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py
index 5cae7002..90b7d578 100644
--- a/py/routes/misc_routes.py
+++ b/py/routes/misc_routes.py
@@ -91,37 +91,37 @@ class MiscRoutes:
app.router.add_get('/api/lm/settings', MiscRoutes.get_settings)
app.router.add_post('/api/lm/settings', MiscRoutes.update_settings)
- app.router.add_get('/api/health-check', lambda request: web.json_response({'status': 'ok'}))
+ app.router.add_get('/api/lm/health-check', lambda request: web.json_response({'status': 'ok'}))
- app.router.add_post('/api/open-file-location', MiscRoutes.open_file_location)
+ app.router.add_post('/api/lm/open-file-location', MiscRoutes.open_file_location)
# Usage stats routes
- app.router.add_post('/api/update-usage-stats', MiscRoutes.update_usage_stats)
- app.router.add_get('/api/get-usage-stats', MiscRoutes.get_usage_stats)
+ app.router.add_post('/api/lm/update-usage-stats', MiscRoutes.update_usage_stats)
+ app.router.add_get('/api/lm/get-usage-stats', MiscRoutes.get_usage_stats)
# Lora code update endpoint
- app.router.add_post('/api/update-lora-code', MiscRoutes.update_lora_code)
+ app.router.add_post('/api/lm/update-lora-code', MiscRoutes.update_lora_code)
# Add new route for getting trained words
- app.router.add_get('/api/trained-words', MiscRoutes.get_trained_words)
+ app.router.add_get('/api/lm/trained-words', MiscRoutes.get_trained_words)
# Add new route for getting model example files
- app.router.add_get('/api/model-example-files', MiscRoutes.get_model_example_files)
+ app.router.add_get('/api/lm/model-example-files', MiscRoutes.get_model_example_files)
# Node registry endpoints
- app.router.add_post('/api/register-nodes', MiscRoutes.register_nodes)
- app.router.add_get('/api/get-registry', MiscRoutes.get_registry)
+ app.router.add_post('/api/lm/register-nodes', MiscRoutes.register_nodes)
+ app.router.add_get('/api/lm/get-registry', MiscRoutes.get_registry)
# Add new route for checking if a model exists in the library
- app.router.add_get('/api/check-model-exists', MiscRoutes.check_model_exists)
+ app.router.add_get('/api/lm/check-model-exists', MiscRoutes.check_model_exists)
# Add routes for metadata archive database management
- app.router.add_post('/api/download-metadata-archive', MiscRoutes.download_metadata_archive)
- app.router.add_post('/api/remove-metadata-archive', MiscRoutes.remove_metadata_archive)
- app.router.add_get('/api/metadata-archive-status', MiscRoutes.get_metadata_archive_status)
+ app.router.add_post('/api/lm/download-metadata-archive', MiscRoutes.download_metadata_archive)
+ app.router.add_post('/api/lm/remove-metadata-archive', MiscRoutes.remove_metadata_archive)
+ app.router.add_get('/api/lm/metadata-archive-status', MiscRoutes.get_metadata_archive_status)
# Add route for checking model versions in library
- app.router.add_get('/api/model-versions-status', MiscRoutes.get_model_versions_status)
+ app.router.add_get('/api/lm/model-versions-status', MiscRoutes.get_model_versions_status)
@staticmethod
async def get_settings(request):
diff --git a/py/routes/recipe_routes.py b/py/routes/recipe_routes.py
index 003d869a..0c8843b4 100644
--- a/py/routes/recipe_routes.py
+++ b/py/routes/recipe_routes.py
@@ -61,46 +61,46 @@ class RecipeRoutes:
routes = cls()
app.router.add_get('/loras/recipes', routes.handle_recipes_page)
- app.router.add_get('/api/recipes', routes.get_recipes)
- app.router.add_get('/api/recipe/{recipe_id}', routes.get_recipe_detail)
- app.router.add_post('/api/recipes/analyze-image', routes.analyze_recipe_image)
- app.router.add_post('/api/recipes/analyze-local-image', routes.analyze_local_image)
- app.router.add_post('/api/recipes/save', routes.save_recipe)
- app.router.add_delete('/api/recipe/{recipe_id}', routes.delete_recipe)
+ app.router.add_get('/api/lm/recipes', routes.get_recipes)
+ app.router.add_get('/api/lm/recipe/{recipe_id}', routes.get_recipe_detail)
+ app.router.add_post('/api/lm/recipes/analyze-image', routes.analyze_recipe_image)
+ app.router.add_post('/api/lm/recipes/analyze-local-image', routes.analyze_local_image)
+ app.router.add_post('/api/lm/recipes/save', routes.save_recipe)
+ app.router.add_delete('/api/lm/recipe/{recipe_id}', routes.delete_recipe)
# Add new filter-related endpoints
- app.router.add_get('/api/recipes/top-tags', routes.get_top_tags)
- app.router.add_get('/api/recipes/base-models', routes.get_base_models)
+ app.router.add_get('/api/lm/recipes/top-tags', routes.get_top_tags)
+ app.router.add_get('/api/lm/recipes/base-models', routes.get_base_models)
# Add new sharing endpoints
- app.router.add_get('/api/recipe/{recipe_id}/share', routes.share_recipe)
- app.router.add_get('/api/recipe/{recipe_id}/share/download', routes.download_shared_recipe)
+ app.router.add_get('/api/lm/recipe/{recipe_id}/share', routes.share_recipe)
+ app.router.add_get('/api/lm/recipe/{recipe_id}/share/download', routes.download_shared_recipe)
# Add new endpoint for getting recipe syntax
- app.router.add_get('/api/recipe/{recipe_id}/syntax', routes.get_recipe_syntax)
+ app.router.add_get('/api/lm/recipe/{recipe_id}/syntax', routes.get_recipe_syntax)
# Add new endpoint for updating recipe metadata (name, tags and source_path)
- app.router.add_put('/api/recipe/{recipe_id}/update', routes.update_recipe)
+ app.router.add_put('/api/lm/recipe/{recipe_id}/update', routes.update_recipe)
# Add new endpoint for reconnecting deleted LoRAs
- app.router.add_post('/api/recipe/lora/reconnect', routes.reconnect_lora)
+ app.router.add_post('/api/lm/recipe/lora/reconnect', routes.reconnect_lora)
# Add new endpoint for finding duplicate recipes
- app.router.add_get('/api/recipes/find-duplicates', routes.find_duplicates)
+ app.router.add_get('/api/lm/recipes/find-duplicates', routes.find_duplicates)
# Add new endpoint for bulk deletion of recipes
- app.router.add_post('/api/recipes/bulk-delete', routes.bulk_delete)
+ app.router.add_post('/api/lm/recipes/bulk-delete', routes.bulk_delete)
# Start cache initialization
app.on_startup.append(routes._init_cache)
- app.router.add_post('/api/recipes/save-from-widget', routes.save_recipe_from_widget)
+ app.router.add_post('/api/lm/recipes/save-from-widget', routes.save_recipe_from_widget)
# Add route to get recipes for a specific Lora
- app.router.add_get('/api/recipes/for-lora', routes.get_recipes_for_lora)
+ app.router.add_get('/api/lm/recipes/for-lora', routes.get_recipes_for_lora)
# Add new endpoint for scanning and rebuilding the recipe cache
- app.router.add_get('/api/recipes/scan', routes.scan_recipes)
+ app.router.add_get('/api/lm/recipes/scan', routes.scan_recipes)
async def _init_cache(self, app):
"""Initialize cache on startup"""
diff --git a/py/routes/stats_routes.py b/py/routes/stats_routes.py
index b61762d1..f8c0aaa4 100644
--- a/py/routes/stats_routes.py
+++ b/py/routes/stats_routes.py
@@ -507,12 +507,12 @@ class StatsRoutes:
app.router.add_get('/statistics', self.handle_stats_page)
# Register API routes
- app.router.add_get('/api/stats/collection-overview', self.get_collection_overview)
- app.router.add_get('/api/stats/usage-analytics', self.get_usage_analytics)
- app.router.add_get('/api/stats/base-model-distribution', self.get_base_model_distribution)
- app.router.add_get('/api/stats/tag-analytics', self.get_tag_analytics)
- app.router.add_get('/api/stats/storage-analytics', self.get_storage_analytics)
- app.router.add_get('/api/stats/insights', self.get_insights)
+ app.router.add_get('/api/lm/stats/collection-overview', self.get_collection_overview)
+ app.router.add_get('/api/lm/stats/usage-analytics', self.get_usage_analytics)
+ app.router.add_get('/api/lm/stats/base-model-distribution', self.get_base_model_distribution)
+ app.router.add_get('/api/lm/stats/tag-analytics', self.get_tag_analytics)
+ app.router.add_get('/api/lm/stats/storage-analytics', self.get_storage_analytics)
+ app.router.add_get('/api/lm/stats/insights', self.get_insights)
async def _on_startup(self, app):
"""Initialize services when the app starts"""
diff --git a/py/routes/update_routes.py b/py/routes/update_routes.py
index bf77baaf..2febfba3 100644
--- a/py/routes/update_routes.py
+++ b/py/routes/update_routes.py
@@ -17,9 +17,9 @@ class UpdateRoutes:
@staticmethod
def setup_routes(app):
"""Register update check routes"""
- app.router.add_get('/api/check-updates', UpdateRoutes.check_updates)
- app.router.add_get('/api/version-info', UpdateRoutes.get_version_info)
- app.router.add_post('/api/perform-update', UpdateRoutes.perform_update)
+ app.router.add_get('/api/lm/check-updates', UpdateRoutes.check_updates)
+ app.router.add_get('/api/lm/version-info', UpdateRoutes.get_version_info)
+ app.router.add_post('/api/lm/perform-update', UpdateRoutes.perform_update)
@staticmethod
async def check_updates(request):
diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js
index 33622dc5..aa168413 100644
--- a/static/js/api/apiConfig.js
+++ b/static/js/api/apiConfig.js
@@ -55,48 +55,48 @@ export function getApiEndpoints(modelType) {
return {
// Base CRUD operations
- list: `/api/${modelType}/list`,
- delete: `/api/${modelType}/delete`,
- exclude: `/api/${modelType}/exclude`,
- rename: `/api/${modelType}/rename`,
- save: `/api/${modelType}/save-metadata`,
+ list: `/api/lm/${modelType}/list`,
+ delete: `/api/lm/${modelType}/delete`,
+ exclude: `/api/lm/${modelType}/exclude`,
+ rename: `/api/lm/${modelType}/rename`,
+ save: `/api/lm/${modelType}/save-metadata`,
// Bulk operations
- bulkDelete: `/api/${modelType}/bulk-delete`,
+ bulkDelete: `/api/lm/${modelType}/bulk-delete`,
// Tag operations
- addTags: `/api/${modelType}/add-tags`,
+ addTags: `/api/lm/${modelType}/add-tags`,
// Move operations (now common for all model types that support move)
- moveModel: `/api/${modelType}/move_model`,
- moveBulk: `/api/${modelType}/move_models_bulk`,
+ moveModel: `/api/lm/${modelType}/move_model`,
+ moveBulk: `/api/lm/${modelType}/move_models_bulk`,
// CivitAI integration
- fetchCivitai: `/api/${modelType}/fetch-civitai`,
- fetchAllCivitai: `/api/${modelType}/fetch-all-civitai`,
- relinkCivitai: `/api/${modelType}/relink-civitai`,
- civitaiVersions: `/api/${modelType}/civitai/versions`,
+ fetchCivitai: `/api/lm/${modelType}/fetch-civitai`,
+ fetchAllCivitai: `/api/lm/${modelType}/fetch-all-civitai`,
+ relinkCivitai: `/api/lm/${modelType}/relink-civitai`,
+ civitaiVersions: `/api/lm/${modelType}/civitai/versions`,
// Preview management
- replacePreview: `/api/${modelType}/replace-preview`,
+ replacePreview: `/api/lm/${modelType}/replace-preview`,
// Query operations
- scan: `/api/${modelType}/scan`,
- topTags: `/api/${modelType}/top-tags`,
- baseModels: `/api/${modelType}/base-models`,
- roots: `/api/${modelType}/roots`,
- folders: `/api/${modelType}/folders`,
- folderTree: `/api/${modelType}/folder-tree`,
- unifiedFolderTree: `/api/${modelType}/unified-folder-tree`,
- duplicates: `/api/${modelType}/find-duplicates`,
- conflicts: `/api/${modelType}/find-filename-conflicts`,
- verify: `/api/${modelType}/verify-duplicates`,
- metadata: `/api/${modelType}/metadata`,
- modelDescription: `/api/${modelType}/model-description`,
+ scan: `/api/lm/${modelType}/scan`,
+ topTags: `/api/lm/${modelType}/top-tags`,
+ baseModels: `/api/lm/${modelType}/base-models`,
+ roots: `/api/lm/${modelType}/roots`,
+ folders: `/api/lm/${modelType}/folders`,
+ folderTree: `/api/lm/${modelType}/folder-tree`,
+ unifiedFolderTree: `/api/lm/${modelType}/unified-folder-tree`,
+ duplicates: `/api/lm/${modelType}/find-duplicates`,
+ conflicts: `/api/lm/${modelType}/find-filename-conflicts`,
+ verify: `/api/lm/${modelType}/verify-duplicates`,
+ metadata: `/api/lm/${modelType}/metadata`,
+ modelDescription: `/api/lm/${modelType}/model-description`,
// Auto-organize operations
- autoOrganize: `/api/${modelType}/auto-organize`,
- autoOrganizeProgress: `/api/${modelType}/auto-organize-progress`,
+ autoOrganize: `/api/lm/${modelType}/auto-organize`,
+ autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`,
// Model-specific endpoints (will be merged with specific configs)
specific: {}
@@ -108,24 +108,24 @@ export function getApiEndpoints(modelType) {
*/
export const MODEL_SPECIFIC_ENDPOINTS = {
[MODEL_TYPES.LORA]: {
- letterCounts: `/api/${MODEL_TYPES.LORA}/letter-counts`,
- notes: `/api/${MODEL_TYPES.LORA}/get-notes`,
- triggerWords: `/api/${MODEL_TYPES.LORA}/get-trigger-words`,
- previewUrl: `/api/${MODEL_TYPES.LORA}/preview-url`,
- civitaiUrl: `/api/${MODEL_TYPES.LORA}/civitai-url`,
- metadata: `/api/${MODEL_TYPES.LORA}/metadata`,
- getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`,
- civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`,
- civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash`,
+ letterCounts: `/api/lm/${MODEL_TYPES.LORA}/letter-counts`,
+ notes: `/api/lm/${MODEL_TYPES.LORA}/get-notes`,
+ triggerWords: `/api/lm/${MODEL_TYPES.LORA}/get-trigger-words`,
+ previewUrl: `/api/lm/${MODEL_TYPES.LORA}/preview-url`,
+ civitaiUrl: `/api/lm/${MODEL_TYPES.LORA}/civitai-url`,
+ metadata: `/api/lm/${MODEL_TYPES.LORA}/metadata`,
+ getTriggerWordsPost: `/api/lm/${MODEL_TYPES.LORA}/get_trigger_words`,
+ civitaiModelByVersion: `/api/lm/${MODEL_TYPES.LORA}/civitai/model/version`,
+ civitaiModelByHash: `/api/lm/${MODEL_TYPES.LORA}/civitai/model/hash`,
},
[MODEL_TYPES.CHECKPOINT]: {
- info: `/api/${MODEL_TYPES.CHECKPOINT}/info`,
- checkpoints_roots: `/api/${MODEL_TYPES.CHECKPOINT}/checkpoints_roots`,
- unet_roots: `/api/${MODEL_TYPES.CHECKPOINT}/unet_roots`,
- metadata: `/api/${MODEL_TYPES.CHECKPOINT}/metadata`,
+ info: `/api/lm/${MODEL_TYPES.CHECKPOINT}/info`,
+ checkpoints_roots: `/api/lm/${MODEL_TYPES.CHECKPOINT}/checkpoints_roots`,
+ unet_roots: `/api/lm/${MODEL_TYPES.CHECKPOINT}/unet_roots`,
+ metadata: `/api/lm/${MODEL_TYPES.CHECKPOINT}/metadata`,
},
[MODEL_TYPES.EMBEDDING]: {
- metadata: `/api/${MODEL_TYPES.EMBEDDING}/metadata`,
+ metadata: `/api/lm/${MODEL_TYPES.EMBEDDING}/metadata`,
}
};
@@ -173,11 +173,11 @@ export function getCurrentModelType(explicitType = null) {
// Download API endpoints (shared across all model types)
export const DOWNLOAD_ENDPOINTS = {
- download: '/api/download-model',
- downloadGet: '/api/download-model-get',
- cancelGet: '/api/cancel-download-get',
- progress: '/api/download-progress',
- exampleImages: '/api/force-download-example-images' // New endpoint for downloading example images
+ download: '/api/lm/download-model',
+ downloadGet: '/api/lm/download-model-get',
+ cancelGet: '/api/lm/cancel-download-get',
+ progress: '/api/lm/download-progress',
+ exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images
};
// WebSocket endpoints
diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js
index fec0d02f..3b912905 100644
--- a/static/js/api/recipeApi.js
+++ b/static/js/api/recipeApi.js
@@ -21,7 +21,7 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
// If we have a specific recipe ID to load
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
// Special case: load specific recipe
- const response = await fetch(`/api/recipe/${pageState.customFilter.recipeId}`);
+ const response = await fetch(`/api/lm/recipe/${pageState.customFilter.recipeId}`);
if (!response.ok) {
throw new Error(`Failed to load recipe: ${response.statusText}`);
@@ -72,7 +72,7 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
}
// Fetch recipes
- const response = await fetch(`/api/recipes?${params.toString()}`);
+ const response = await fetch(`/api/lm/recipes?${params.toString()}`);
if (!response.ok) {
throw new Error(`Failed to load recipes: ${response.statusText}`);
@@ -207,7 +207,7 @@ export async function refreshRecipes() {
state.loadingManager.showSimpleLoading('Refreshing recipes...');
// Call the API endpoint to rebuild the recipe cache
- const response = await fetch('/api/recipes/scan');
+ const response = await fetch('/api/lm/recipes/scan');
if (!response.ok) {
const data = await response.json();
@@ -274,7 +274,7 @@ export async function updateRecipeMetadata(filePath, updates) {
const basename = filePath.split('/').pop().split('\\').pop();
const recipeId = basename.substring(0, basename.lastIndexOf('.'));
- const response = await fetch(`/api/recipe/${recipeId}/update`, {
+ const response = await fetch(`/api/lm/recipe/${recipeId}/update`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
diff --git a/static/js/components/ContextMenu/ModelContextMenuMixin.js b/static/js/components/ContextMenu/ModelContextMenuMixin.js
index cd376dd1..3c461a9a 100644
--- a/static/js/components/ContextMenu/ModelContextMenuMixin.js
+++ b/static/js/components/ContextMenu/ModelContextMenuMixin.js
@@ -125,8 +125,8 @@ export const ModelContextMenuMixin = {
state.loadingManager.showSimpleLoading('Re-linking to Civitai...');
const endpoint = this.modelType === 'checkpoint' ?
- '/api/checkpoints/relink-civitai' :
- '/api/loras/relink-civitai';
+ '/api/lm/checkpoints/relink-civitai' :
+ '/api/lm/loras/relink-civitai';
const response = await fetch(endpoint, {
method: 'POST',
diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js
index 351263c7..bb8b8e69 100644
--- a/static/js/components/ContextMenu/RecipeContextMenu.js
+++ b/static/js/components/ContextMenu/RecipeContextMenu.js
@@ -103,7 +103,7 @@ export class RecipeContextMenu extends BaseContextMenu {
return;
}
- fetch(`/api/recipe/${recipeId}/syntax`)
+ fetch(`/api/lm/recipe/${recipeId}/syntax`)
.then(response => response.json())
.then(data => {
if (data.success && data.syntax) {
@@ -126,7 +126,7 @@ export class RecipeContextMenu extends BaseContextMenu {
return;
}
- fetch(`/api/recipe/${recipeId}/syntax`)
+ fetch(`/api/lm/recipe/${recipeId}/syntax`)
.then(response => response.json())
.then(data => {
if (data.success && data.syntax) {
@@ -149,7 +149,7 @@ export class RecipeContextMenu extends BaseContextMenu {
}
// First get the recipe details to access its LoRAs
- fetch(`/api/recipe/${recipeId}`)
+ fetch(`/api/lm/recipe/${recipeId}`)
.then(response => response.json())
.then(recipe => {
// Clear any previous filters first
@@ -189,7 +189,7 @@ export class RecipeContextMenu extends BaseContextMenu {
try {
// First get the recipe details
- const response = await fetch(`/api/recipe/${recipeId}`);
+ const response = await fetch(`/api/lm/recipe/${recipeId}`);
const recipe = await response.json();
// Get missing LoRAs
@@ -209,9 +209,9 @@ export class RecipeContextMenu extends BaseContextMenu {
// Determine which endpoint to use based on available data
if (lora.modelVersionId) {
- endpoint = `/api/loras/civitai/model/version/${lora.modelVersionId}`;
+ endpoint = `/api/lm/loras/civitai/model/version/${lora.modelVersionId}`;
} else if (lora.hash) {
- endpoint = `/api/loras/civitai/model/hash/${lora.hash}`;
+ endpoint = `/api/lm/loras/civitai/model/hash/${lora.hash}`;
} else {
console.error("Missing both hash and modelVersionId for lora:", lora);
return null;
diff --git a/static/js/components/DuplicatesManager.js b/static/js/components/DuplicatesManager.js
index f49a2d02..a2360c06 100644
--- a/static/js/components/DuplicatesManager.js
+++ b/static/js/components/DuplicatesManager.js
@@ -13,7 +13,7 @@ export class DuplicatesManager {
async findDuplicates() {
try {
- const response = await fetch('/api/recipes/find-duplicates');
+ const response = await fetch('/api/lm/recipes/find-duplicates');
if (!response.ok) {
throw new Error('Failed to find duplicates');
}
@@ -354,7 +354,7 @@ export class DuplicatesManager {
const recipeIds = Array.from(this.selectedForDeletion);
// Call API to bulk delete
- const response = await fetch('/api/recipes/bulk-delete', {
+ const response = await fetch('/api/lm/recipes/bulk-delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
diff --git a/static/js/components/ModelDuplicatesManager.js b/static/js/components/ModelDuplicatesManager.js
index c8879ce9..33df3779 100644
--- a/static/js/components/ModelDuplicatesManager.js
+++ b/static/js/components/ModelDuplicatesManager.js
@@ -48,7 +48,7 @@ export class ModelDuplicatesManager {
// Method to check for duplicates count using existing endpoint
async checkDuplicatesCount() {
try {
- const endpoint = `/api/${this.modelType}/find-duplicates`;
+ const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
const response = await fetch(endpoint);
if (!response.ok) {
@@ -104,7 +104,7 @@ export class ModelDuplicatesManager {
async findDuplicates() {
try {
// Determine API endpoint based on model type
- const endpoint = `/api/${this.modelType}/find-duplicates`;
+ const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
const response = await fetch(endpoint);
if (!response.ok) {
@@ -623,7 +623,7 @@ export class ModelDuplicatesManager {
const filePaths = Array.from(this.selectedForDeletion);
// Call API to bulk delete
- const response = await fetch(`/api/${this.modelType}/bulk-delete`, {
+ const response = await fetch(`/api/lm/${this.modelType}/bulk-delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -648,7 +648,7 @@ export class ModelDuplicatesManager {
// Check if there are still duplicates
try {
- const endpoint = `/api/${this.modelType}/find-duplicates`;
+ const endpoint = `/api/lm/${this.modelType}/find-duplicates`;
const dupResponse = await fetch(endpoint);
if (!dupResponse.ok) {
@@ -756,7 +756,7 @@ export class ModelDuplicatesManager {
const filePaths = group.models.map(model => model.file_path);
// Make API request to verify hashes
- const response = await fetch(`/api/${this.modelType}/verify-duplicates`, {
+ const response = await fetch(`/api/lm/${this.modelType}/verify-duplicates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js
index 0eaa68ad..c496a928 100644
--- a/static/js/components/RecipeCard.js
+++ b/static/js/components/RecipeCard.js
@@ -203,7 +203,7 @@ class RecipeCard {
return;
}
- fetch(`/api/recipe/${recipeId}/syntax`)
+ fetch(`/api/lm/recipe/${recipeId}/syntax`)
.then(response => response.json())
.then(data => {
if (data.success && data.syntax) {
@@ -299,7 +299,7 @@ class RecipeCard {
deleteBtn.disabled = true;
// Call API to delete the recipe
- fetch(`/api/recipe/${recipeId}`, {
+ fetch(`/api/lm/recipe/${recipeId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
@@ -341,7 +341,7 @@ class RecipeCard {
showToast('toast.recipes.preparingForSharing', {}, 'info');
// Call the API to process the image with metadata
- fetch(`/api/recipe/${recipeId}/share`)
+ fetch(`/api/lm/recipe/${recipeId}/share`)
.then(response => {
if (!response.ok) {
throw new Error('Failed to prepare recipe for sharing');
diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js
index ffbfa2aa..68d61eb9 100644
--- a/static/js/components/RecipeModal.js
+++ b/static/js/components/RecipeModal.js
@@ -784,7 +784,7 @@ class RecipeModal {
try {
// Fetch recipe syntax from backend
- const response = await fetch(`/api/recipe/${this.recipeId}/syntax`);
+ const response = await fetch(`/api/lm/recipe/${this.recipeId}/syntax`);
if (!response.ok) {
throw new Error(`Failed to get recipe syntax: ${response.statusText}`);
@@ -830,9 +830,9 @@ class RecipeModal {
// Determine which endpoint to use based on available data
if (lora.modelVersionId) {
- endpoint = `/api/loras/civitai/model/version/${lora.modelVersionId}`;
+ endpoint = `/api/lm/loras/civitai/model/version/${lora.modelVersionId}`;
} else if (lora.hash) {
- endpoint = `/api/loras/civitai/model/hash/${lora.hash}`;
+ endpoint = `/api/lm/loras/civitai/model/hash/${lora.hash}`;
} else {
console.error("Missing both hash and modelVersionId for lora:", lora);
return null;
@@ -1003,7 +1003,7 @@ class RecipeModal {
state.loadingManager.showSimpleLoading('Reconnecting LoRA...');
// Call API to reconnect the LoRA
- const response = await fetch('/api/recipe/lora/reconnect', {
+ const response = await fetch('/api/lm/recipe/lora/reconnect', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
diff --git a/static/js/components/alphabet/AlphabetBar.js b/static/js/components/alphabet/AlphabetBar.js
index 82113758..2d358156 100644
--- a/static/js/components/alphabet/AlphabetBar.js
+++ b/static/js/components/alphabet/AlphabetBar.js
@@ -46,7 +46,7 @@ export class AlphabetBar {
*/
async fetchLetterCounts() {
try {
- const response = await fetch('/api/loras/letter-counts');
+ const response = await fetch('/api/lm/loras/letter-counts');
if (!response.ok) {
throw new Error(`Failed to fetch letter counts: ${response.statusText}`);
diff --git a/static/js/components/initialization.js b/static/js/components/initialization.js
index e7b6818f..9a547a86 100644
--- a/static/js/components/initialization.js
+++ b/static/js/components/initialization.js
@@ -169,7 +169,7 @@ class InitializationManager {
*/
pollProgress() {
const checkProgress = () => {
- fetch('/api/init-status')
+ fetch('/api/lm/init-status')
.then(response => response.json())
.then(data => {
this.handleProgressUpdate(data);
diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js
index aa3208a0..c17b60fd 100644
--- a/static/js/components/shared/ModelCard.js
+++ b/static/js/components/shared/ModelCard.js
@@ -186,7 +186,7 @@ async function handleExampleImagesAccess(card, modelType) {
const modelHash = card.dataset.sha256;
try {
- const response = await fetch(`/api/has-example-images?model_hash=${modelHash}`);
+ const response = await fetch(`/api/lm/has-example-images?model_hash=${modelHash}`);
const data = await response.json();
if (data.has_images) {
diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js
index 2a1f525b..081b0a46 100644
--- a/static/js/components/shared/ModelModal.js
+++ b/static/js/components/shared/ModelModal.js
@@ -460,7 +460,7 @@ async function saveNotes(filePath) {
*/
async function openFileLocation(filePath) {
try {
- const resp = await fetch('/api/open-file-location', {
+ const resp = await fetch('/api/lm/open-file-location', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 'file_path': filePath })
diff --git a/static/js/components/shared/RecipeTab.js b/static/js/components/shared/RecipeTab.js
index 78c4e9cc..ffa439c9 100644
--- a/static/js/components/shared/RecipeTab.js
+++ b/static/js/components/shared/RecipeTab.js
@@ -22,7 +22,7 @@ export function loadRecipesForLora(loraName, sha256) {
`;
// Fetch recipes that use this Lora by hash
- fetch(`/api/recipes/for-lora?hash=${encodeURIComponent(sha256.toLowerCase())}`)
+ fetch(`/api/lm/recipes/for-lora?hash=${encodeURIComponent(sha256.toLowerCase())}`)
.then(response => response.json())
.then(data => {
if (!data.success) {
@@ -166,7 +166,7 @@ function copyRecipeSyntax(recipeId) {
return;
}
- fetch(`/api/recipe/${recipeId}/syntax`)
+ fetch(`/api/lm/recipe/${recipeId}/syntax`)
.then(response => response.json())
.then(data => {
if (data.success && data.syntax) {
diff --git a/static/js/components/shared/TriggerWords.js b/static/js/components/shared/TriggerWords.js
index 28d1f173..564f5230 100644
--- a/static/js/components/shared/TriggerWords.js
+++ b/static/js/components/shared/TriggerWords.js
@@ -14,7 +14,7 @@ import { getModelApiClient } from '../../api/modelApiFactory.js';
*/
async function fetchTrainedWords(filePath) {
try {
- const response = await fetch(`/api/trained-words?file_path=${encodeURIComponent(filePath)}`);
+ const response = await fetch(`/api/lm/trained-words?file_path=${encodeURIComponent(filePath)}`);
const data = await response.json();
if (data.success) {
diff --git a/static/js/components/shared/showcase/MediaUtils.js b/static/js/components/shared/showcase/MediaUtils.js
index 0da17c53..7a56f5ab 100644
--- a/static/js/components/shared/showcase/MediaUtils.js
+++ b/static/js/components/shared/showcase/MediaUtils.js
@@ -408,7 +408,7 @@ export function initMediaControlHandlers(container) {
try {
// Call the API to delete the custom example
- const response = await fetch('/api/delete-example-image', {
+ const response = await fetch('/api/lm/delete-example-image', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js
index b4b96b0f..257753fd 100644
--- a/static/js/components/shared/showcase/ShowcaseView.js
+++ b/static/js/components/shared/showcase/ShowcaseView.js
@@ -29,7 +29,7 @@ export async function loadExampleImages(images, modelHash) {
let localFiles = [];
try {
- const endpoint = '/api/example-image-files';
+ const endpoint = '/api/lm/example-image-files';
const params = `model_hash=${modelHash}`;
const response = await fetch(`${endpoint}?${params}`);
@@ -374,7 +374,7 @@ async function handleImportFiles(files, modelHash, importContainer) {
});
// Call API to import files
- const response = await fetch('/api/import-example-images', {
+ const response = await fetch('/api/lm/import-example-images', {
method: 'POST',
body: formData
});
@@ -386,7 +386,7 @@ async function handleImportFiles(files, modelHash, importContainer) {
}
// Get updated local files
- const updatedFilesResponse = await fetch(`/api/example-image-files?model_hash=${modelHash}`);
+ const updatedFilesResponse = await fetch(`/api/lm/example-image-files?model_hash=${modelHash}`);
const updatedFilesResult = await updatedFilesResponse.json();
if (!updatedFilesResult.success) {
diff --git a/static/js/managers/ExampleImagesManager.js b/static/js/managers/ExampleImagesManager.js
index 38150749..7cb6d2ac 100644
--- a/static/js/managers/ExampleImagesManager.js
+++ b/static/js/managers/ExampleImagesManager.js
@@ -172,7 +172,7 @@ export class ExampleImagesManager {
async checkDownloadStatus() {
try {
- const response = await fetch('/api/example-images-status');
+ const response = await fetch('/api/lm/example-images-status');
const data = await response.json();
if (data.success) {
@@ -236,7 +236,7 @@ export class ExampleImagesManager {
const optimize = state.global.settings.optimizeExampleImages;
- const response = await fetch('/api/download-example-images', {
+ const response = await fetch('/api/lm/download-example-images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -278,7 +278,7 @@ export class ExampleImagesManager {
}
try {
- const response = await fetch('/api/pause-example-images', {
+ const response = await fetch('/api/lm/pause-example-images', {
method: 'POST'
});
@@ -314,7 +314,7 @@ export class ExampleImagesManager {
}
try {
- const response = await fetch('/api/resume-example-images', {
+ const response = await fetch('/api/lm/resume-example-images', {
method: 'POST'
});
@@ -358,7 +358,7 @@ export class ExampleImagesManager {
async updateProgress() {
try {
- const response = await fetch('/api/example-images-status');
+ const response = await fetch('/api/lm/example-images-status');
const data = await response.json();
if (data.success) {
@@ -727,7 +727,7 @@ export class ExampleImagesManager {
const outputDir = document.getElementById('exampleImagesPath').value;
const optimize = state.global.settings.optimizeExampleImages;
- const response = await fetch('/api/download-example-images', {
+ const response = await fetch('/api/lm/download-example-images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js
index 12c35c0d..adac4ede 100644
--- a/static/js/managers/FilterManager.js
+++ b/static/js/managers/FilterManager.js
@@ -66,7 +66,7 @@ export class FilterManager {
tagsContainer.innerHTML = 'Loading tags...
';
// Determine the API endpoint based on the page type
- const tagsEndpoint = `/api/${this.currentPage}/top-tags?limit=20`;
+ const tagsEndpoint = `/api/lm/${this.currentPage}/top-tags?limit=20`;
const response = await fetch(tagsEndpoint);
if (!response.ok) throw new Error('Failed to fetch tags');
@@ -134,7 +134,7 @@ export class FilterManager {
if (!baseModelTagsContainer) return;
// Set the API endpoint based on current page
- const apiEndpoint = `/api/${this.currentPage}/base-models`;
+ const apiEndpoint = `/api/lm/${this.currentPage}/base-models`;
// Fetch base models
fetch(apiEndpoint)
diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js
index 33040aac..9de7a7dc 100644
--- a/static/js/managers/SettingsManager.js
+++ b/static/js/managers/SettingsManager.js
@@ -429,7 +429,7 @@ export class SettingsManager {
if (!defaultLoraRootSelect) return;
// Fetch lora roots
- const response = await fetch('/api/loras/roots');
+ const response = await fetch('/api/lm/loras/roots');
if (!response.ok) {
throw new Error('Failed to fetch LoRA roots');
}
@@ -468,7 +468,7 @@ export class SettingsManager {
if (!defaultCheckpointRootSelect) return;
// Fetch checkpoint roots
- const response = await fetch('/api/checkpoints/roots');
+ const response = await fetch('/api/lm/checkpoints/roots');
if (!response.ok) {
throw new Error('Failed to fetch checkpoint roots');
}
@@ -507,7 +507,7 @@ export class SettingsManager {
if (!defaultEmbeddingRootSelect) return;
// Fetch embedding roots
- const response = await fetch('/api/embeddings/roots');
+ const response = await fetch('/api/lm/embeddings/roots');
if (!response.ok) {
throw new Error('Failed to fetch embedding roots');
}
@@ -1023,7 +1023,7 @@ export class SettingsManager {
async updateMetadataArchiveStatus() {
try {
- const response = await fetch('/api/metadata-archive-status');
+ const response = await fetch('/api/lm/metadata-archive-status');
const data = await response.json();
const statusContainer = document.getElementById('metadataArchiveStatus');
@@ -1152,7 +1152,7 @@ export class SettingsManager {
// Wait for WebSocket to be ready
await wsReady;
- const response = await fetch(`/api/download-metadata-archive?download_id=${encodeURIComponent(actualDownloadId)}`, {
+ const response = await fetch(`/api/lm/download-metadata-archive?download_id=${encodeURIComponent(actualDownloadId)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -1215,7 +1215,7 @@ export class SettingsManager {
removeBtn.textContent = translate('settings.metadataArchive.removingButton');
}
- const response = await fetch('/api/remove-metadata-archive', {
+ const response = await fetch('/api/lm/remove-metadata-archive', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
diff --git a/static/js/managers/UpdateService.js b/static/js/managers/UpdateService.js
index 5beb0f94..b1d29960 100644
--- a/static/js/managers/UpdateService.js
+++ b/static/js/managers/UpdateService.js
@@ -97,7 +97,7 @@ export class UpdateService {
try {
// Call backend API to check for updates with nightly flag
- const response = await fetch(`/api/check-updates?nightly=${this.nightlyMode}`);
+ const response = await fetch(`/api/lm/check-updates?nightly=${this.nightlyMode}`);
const data = await response.json();
if (data.success) {
@@ -280,7 +280,7 @@ export class UpdateService {
// Update progress
this.updateProgress(10, translate('update.updateProgress.preparing'));
- const response = await fetch('/api/perform-update', {
+ const response = await fetch('/api/lm/perform-update', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -444,7 +444,7 @@ export class UpdateService {
async checkVersionInfo() {
try {
// Call API to get current version info
- const response = await fetch('/api/version-info');
+ const response = await fetch('/api/lm/version-info');
const data = await response.json();
if (data.success) {
diff --git a/static/js/managers/import/DownloadManager.js b/static/js/managers/import/DownloadManager.js
index ffbefb20..c71e9e31 100644
--- a/static/js/managers/import/DownloadManager.js
+++ b/static/js/managers/import/DownloadManager.js
@@ -68,7 +68,7 @@ export class DownloadManager {
formData.append('metadata', JSON.stringify(completeMetadata));
// Send save request
- const response = await fetch('/api/recipes/save', {
+ const response = await fetch('/api/lm/recipes/save', {
method: 'POST',
body: formData
});
diff --git a/static/js/managers/import/FolderBrowser.js b/static/js/managers/import/FolderBrowser.js
index 32f39b80..43cfbada 100644
--- a/static/js/managers/import/FolderBrowser.js
+++ b/static/js/managers/import/FolderBrowser.js
@@ -100,7 +100,7 @@ export class FolderBrowser {
}
// Fetch LoRA roots
- const rootsResponse = await fetch('/api/loras/roots');
+ const rootsResponse = await fetch('/api/lm/loras/roots');
if (!rootsResponse.ok) {
throw new Error(`Failed to fetch LoRA roots: ${rootsResponse.status}`);
}
@@ -120,7 +120,7 @@ export class FolderBrowser {
}
// Fetch folders
- const foldersResponse = await fetch('/api/loras/folders');
+ const foldersResponse = await fetch('/api/lm/loras/folders');
if (!foldersResponse.ok) {
throw new Error(`Failed to fetch folders: ${foldersResponse.status}`);
}
diff --git a/static/js/managers/import/ImageProcessor.js b/static/js/managers/import/ImageProcessor.js
index 37f4b7ef..66ca0dc2 100644
--- a/static/js/managers/import/ImageProcessor.js
+++ b/static/js/managers/import/ImageProcessor.js
@@ -62,7 +62,7 @@ export class ImageProcessor {
async analyzeImageFromUrl(url) {
try {
// Call the API with URL data
- const response = await fetch('/api/recipes/analyze-image', {
+ const response = await fetch('/api/lm/recipes/analyze-image', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -110,7 +110,7 @@ export class ImageProcessor {
async analyzeImageFromLocalPath(path) {
try {
// Call the API with local path data
- const response = await fetch('/api/recipes/analyze-local-image', {
+ const response = await fetch('/api/lm/recipes/analyze-local-image', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -169,7 +169,7 @@ export class ImageProcessor {
formData.append('image', this.importManager.recipeImage);
// Upload image for analysis
- const response = await fetch('/api/recipes/analyze-image', {
+ const response = await fetch('/api/lm/recipes/analyze-image', {
method: 'POST',
body: formData
});
diff --git a/static/js/statistics.js b/static/js/statistics.js
index 4b79934b..930199aa 100644
--- a/static/js/statistics.js
+++ b/static/js/statistics.js
@@ -65,12 +65,12 @@ class StatisticsManager {
storageAnalytics,
insights
] = await Promise.all([
- this.fetchData('/api/stats/collection-overview'),
- this.fetchData('/api/stats/usage-analytics'),
- this.fetchData('/api/stats/base-model-distribution'),
- this.fetchData('/api/stats/tag-analytics'),
- this.fetchData('/api/stats/storage-analytics'),
- this.fetchData('/api/stats/insights')
+ this.fetchData('/api/lm/stats/collection-overview'),
+ this.fetchData('/api/lm/stats/usage-analytics'),
+ this.fetchData('/api/lm/stats/base-model-distribution'),
+ this.fetchData('/api/lm/stats/tag-analytics'),
+ this.fetchData('/api/lm/stats/storage-analytics'),
+ this.fetchData('/api/lm/stats/insights')
]);
this.data = {
diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js
index 2281ecf7..f4e4091a 100644
--- a/static/js/utils/uiHelpers.js
+++ b/static/js/utils/uiHelpers.js
@@ -370,7 +370,7 @@ export function copyLoraSyntax(card) {
export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntaxType = 'lora') {
try {
// Get registry information from the new endpoint
- const registryResponse = await fetch('/api/get-registry');
+ const registryResponse = await fetch('/api/lm/get-registry');
const registryData = await registryResponse.json();
if (!registryData.success) {
@@ -417,7 +417,7 @@ export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntax
async function sendToSpecificNode(nodeIds, loraSyntax, replaceMode, syntaxType) {
try {
// Call the backend API to update the lora code
- const response = await fetch('/api/update-lora-code', {
+ const response = await fetch('/api/lm/update-lora-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -676,7 +676,7 @@ initializeMouseTracking();
*/
export async function openExampleImagesFolder(modelHash) {
try {
- const response = await fetch('/api/open-example-images-folder', {
+ const response = await fetch('/api/lm/open-example-images-folder', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js
index 6e4e8e03..202026ad 100644
--- a/web/comfyui/autocomplete.js
+++ b/web/comfyui/autocomplete.js
@@ -156,7 +156,7 @@ class AutoComplete {
async search(term = '') {
try {
this.currentSearchTerm = term;
- const response = await api.fetchApi(`/${this.modelType}/relative-paths?search=${encodeURIComponent(term)}&limit=${this.options.maxItems}`);
+ const response = await api.fetchApi(`/lm/${this.modelType}/relative-paths?search=${encodeURIComponent(term)}&limit=${this.options.maxItems}`);
const data = await response.json();
if (data.success && data.relative_paths && data.relative_paths.length > 0) {
@@ -383,7 +383,7 @@ class AutoComplete {
// Get usage tips and extract strength
let strength = 1.0; // Default strength
try {
- const response = await api.fetchApi(`/loras/usage-tips-by-path?relative_path=${encodeURIComponent(relativePath)}`);
+ const response = await api.fetchApi(`/lm/loras/usage-tips-by-path?relative_path=${encodeURIComponent(relativePath)}`);
if (response.ok) {
const data = await response.json();
if (data.success && data.usage_tips) {
diff --git a/web/comfyui/loras_widget_components.js b/web/comfyui/loras_widget_components.js
index fcde1972..fc40dce3 100644
--- a/web/comfyui/loras_widget_components.js
+++ b/web/comfyui/loras_widget_components.js
@@ -269,7 +269,7 @@ export class PreviewTooltip {
this.currentLora = loraName;
// Get preview URL
- const response = await api.fetchApi(`/loras/preview-url?name=${encodeURIComponent(loraName)}`, {
+ const response = await api.fetchApi(`/lm/loras/preview-url?name=${encodeURIComponent(loraName)}`, {
method: 'GET'
});
diff --git a/web/comfyui/loras_widget_events.js b/web/comfyui/loras_widget_events.js
index fa0d5633..85564891 100644
--- a/web/comfyui/loras_widget_events.js
+++ b/web/comfyui/loras_widget_events.js
@@ -491,7 +491,7 @@ export function createContextMenu(x, y, loraName, widget, previewTooltip, render
try {
// Get Civitai URL from API
- const response = await api.fetchApi(`/loras/civitai-url?name=${encodeURIComponent(loraName)}`, {
+ const response = await api.fetchApi(`/lm/loras/civitai-url?name=${encodeURIComponent(loraName)}`, {
method: 'GET'
});
@@ -547,7 +547,7 @@ export function createContextMenu(x, y, loraName, widget, previewTooltip, render
try {
// Get notes from API
- const response = await api.fetchApi(`/loras/get-notes?name=${encodeURIComponent(loraName)}`, {
+ const response = await api.fetchApi(`/lm/loras/get-notes?name=${encodeURIComponent(loraName)}`, {
method: 'GET'
});
@@ -584,7 +584,7 @@ export function createContextMenu(x, y, loraName, widget, previewTooltip, render
try {
// Get trigger words from API
- const response = await api.fetchApi(`/loras/get-trigger-words?name=${encodeURIComponent(loraName)}`, {
+ const response = await api.fetchApi(`/lm/loras/get-trigger-words?name=${encodeURIComponent(loraName)}`, {
method: 'GET'
});
diff --git a/web/comfyui/loras_widget_utils.js b/web/comfyui/loras_widget_utils.js
index 1b4193b3..85913c72 100644
--- a/web/comfyui/loras_widget_utils.js
+++ b/web/comfyui/loras_widget_utils.js
@@ -70,7 +70,7 @@ export async function saveRecipeDirectly() {
}
// Send the request to the backend API
- const response = await fetch('/api/recipes/save-from-widget', {
+ const response = await fetch('/api/lm/recipes/save-from-widget', {
method: 'POST'
});
diff --git a/web/comfyui/ui_utils.js b/web/comfyui/ui_utils.js
index 524940c8..4996c67a 100644
--- a/web/comfyui/ui_utils.js
+++ b/web/comfyui/ui_utils.js
@@ -107,7 +107,7 @@ const initializeWidgets = () => {
// Fetch version info from the API
const fetchVersionInfo = async () => {
try {
- const response = await fetch('/api/version-info');
+ const response = await fetch('/api/lm/version-info');
const data = await response.json();
if (data.success) {
diff --git a/web/comfyui/usage_stats.js b/web/comfyui/usage_stats.js
index b0844114..b89eaf46 100644
--- a/web/comfyui/usage_stats.js
+++ b/web/comfyui/usage_stats.js
@@ -38,7 +38,7 @@ app.registerExtension({
async updateUsageStats(promptId) {
try {
// Call backend endpoint with the prompt_id
- const response = await fetch(`/api/update-usage-stats`, {
+ const response = await fetch(`/api/lm/update-usage-stats`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -79,7 +79,7 @@ app.registerExtension({
}
}
- const response = await fetch('/api/register-nodes', {
+ const response = await fetch('/api/lm/register-nodes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -158,7 +158,7 @@ app.registerExtension({
try {
// Search for current relative path
- const response = await api.fetchApi(`/${modelType}/relative-paths?search=${encodeURIComponent(fileName)}&limit=2`);
+ const response = await api.fetchApi(`/lm/${modelType}/relative-paths?search=${encodeURIComponent(fileName)}&limit=2`);
const data = await response.json();
if (!data.success || !data.relative_paths || data.relative_paths.length === 0) {
From bc4cd45fcbba1bf1b1ceb77bfa4c8edb9360c9b6 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Thu, 18 Sep 2025 15:09:32 +0800
Subject: [PATCH 033/110] fix(lora_manager): rename invalid hash folder removal
to orphaned folders and update logging
---
py/lora_manager.py | 21 +++++++--------------
1 file changed, 7 insertions(+), 14 deletions(-)
diff --git a/py/lora_manager.py b/py/lora_manager.py
index 4379a3e1..1a99d508 100644
--- a/py/lora_manager.py
+++ b/py/lora_manager.py
@@ -368,7 +368,7 @@ class LoraManager:
total_folders_checked = 0
empty_folders_removed = 0
- invalid_hash_folders_removed = 0
+ orphaned_folders_removed = 0
# Scan the example images directory
try:
@@ -392,9 +392,8 @@ class LoraManager:
# Check if folder name is a valid SHA256 hash (64 hex characters)
if len(folder_name) != 64 or not all(c in '0123456789abcdefABCDEF' for c in folder_name):
- logger.debug(f"Removing invalid hash folder: {folder_name}")
- await cls._remove_folder_safely(folder_path)
- invalid_hash_folders_removed += 1
+ # Skip non-hash folders to avoid deleting other content
+ logger.debug(f"Skipping non-hash folder: {folder_name}")
continue
# Check if hash exists in any of the scanners
@@ -407,7 +406,7 @@ class LoraManager:
if not hash_exists:
logger.debug(f"Removing example images folder for deleted model: {folder_name}")
await cls._remove_folder_safely(folder_path)
- invalid_hash_folders_removed += 1
+ orphaned_folders_removed += 1
continue
except Exception as e:
@@ -421,11 +420,11 @@ class LoraManager:
return
# Log final cleanup report
- total_removed = empty_folders_removed + invalid_hash_folders_removed
+ total_removed = empty_folders_removed + orphaned_folders_removed
if total_removed > 0:
logger.info(f"Example images cleanup completed: checked {total_folders_checked} folders, "
- f"removed {empty_folders_removed} empty folders and {invalid_hash_folders_removed} "
- f"folders for deleted/invalid models (total: {total_removed} removed)")
+ f"removed {empty_folders_removed} empty folders and {orphaned_folders_removed} "
+ f"folders for deleted models (total: {total_removed} removed)")
else:
logger.debug(f"Example images cleanup completed: checked {total_folders_checked} folders, "
f"no cleanup needed")
@@ -470,11 +469,5 @@ class LoraManager:
try:
logger.info("LoRA Manager: Cleaning up services")
- # Close CivitaiClient gracefully
- civitai_client = await ServiceRegistry.get_service("civitai_client")
- if civitai_client:
- await civitai_client.close()
- logger.info("Closed CivitaiClient connection")
-
except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True)
From 46e430ebbbae10a96ac8b2a962dd5bf1b1dfb797 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Thu, 18 Sep 2025 15:45:57 +0800
Subject: [PATCH 034/110] fix(utils): update API endpoint for fetching
connected trigger words
---
web/comfyui/utils.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/comfyui/utils.js b/web/comfyui/utils.js
index 08cbff7a..8060414d 100644
--- a/web/comfyui/utils.js
+++ b/web/comfyui/utils.js
@@ -183,7 +183,7 @@ export function collectActiveLorasFromChain(node, visited = new Set()) {
export function updateConnectedTriggerWords(node, loraNames) {
const connectedNodeIds = getConnectedTriggerToggleNodes(node);
if (connectedNodeIds.length > 0) {
- fetch("/api/loras/get_trigger_words", {
+ fetch("/api/lm/loras/get_trigger_words", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
From d30fbeb286af6f9f951f578d6093569c888ccdd1 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Thu, 18 Sep 2025 19:22:29 +0800
Subject: [PATCH 035/110] feat(example_images): add dedicated folder check and
update settings handling for example images path, see #431
---
py/routes/misc_routes.py | 58 ++++++++++++++++++++-
py/utils/example_images_download_manager.py | 39 ++++++++++----
static/js/managers/ExampleImagesManager.js | 17 +-----
static/js/managers/SettingsManager.js | 6 +--
4 files changed, 88 insertions(+), 32 deletions(-)
diff --git a/py/routes/misc_routes.py b/py/routes/misc_routes.py
index 90b7d578..f8173133 100644
--- a/py/routes/misc_routes.py
+++ b/py/routes/misc_routes.py
@@ -4,6 +4,7 @@ import sys
import threading
import asyncio
import subprocess
+import re
from server import PromptServer # type: ignore
from aiohttp import web
from ..services.settings_manager import settings
@@ -85,6 +86,54 @@ node_registry = NodeRegistry()
class MiscRoutes:
"""Miscellaneous routes for various utility functions"""
+ @staticmethod
+ def is_dedicated_example_images_folder(folder_path):
+ """
+ Check if a folder is a dedicated example images folder.
+
+ A dedicated folder should either be:
+ 1. Empty
+ 2. Only contain .download_progress.json file and/or folders with valid SHA256 hash names (64 hex characters)
+
+ Args:
+ folder_path (str): Path to the folder to check
+
+ Returns:
+ bool: True if the folder is dedicated, False otherwise
+ """
+ try:
+ if not os.path.exists(folder_path) or not os.path.isdir(folder_path):
+ return False
+
+ items = os.listdir(folder_path)
+
+ # Empty folder is considered dedicated
+ if not items:
+ return True
+
+ # Check each item in the folder
+ for item in items:
+ item_path = os.path.join(folder_path, item)
+
+ # Allow .download_progress.json file
+ if item == '.download_progress.json' and os.path.isfile(item_path):
+ continue
+
+ # Allow folders with valid SHA256 hash names (64 hex characters)
+ if os.path.isdir(item_path):
+ # Check if the folder name is a valid SHA256 hash
+ if re.match(r'^[a-fA-F0-9]{64}$', item):
+ continue
+
+ # If we encounter anything else, it's not a dedicated folder
+ return False
+
+ return True
+
+ except Exception as e:
+ logger.error(f"Error checking if folder is dedicated: {e}")
+ return False
+
@staticmethod
def setup_routes(app):
"""Register miscellaneous routes"""
@@ -180,7 +229,7 @@ class MiscRoutes:
if value == settings.get(key):
# No change, skip
continue
- # Special handling for example_images_path - verify path exists
+ # Special handling for example_images_path - verify path exists and is dedicated
if key == 'example_images_path' and value:
if not os.path.exists(value):
return web.json_response({
@@ -188,6 +237,13 @@ class MiscRoutes:
'error': f"Path does not exist: {value}"
})
+ # Check if folder is dedicated for example images
+ if not MiscRoutes.is_dedicated_example_images_folder(value):
+ return web.json_response({
+ 'success': False,
+ 'error': "Please set a dedicated folder for example images."
+ })
+
# Path changed - server restart required for new path to take effect
old_path = settings.get('example_images_path')
if old_path != value:
diff --git a/py/utils/example_images_download_manager.py b/py/utils/example_images_download_manager.py
index e3f46244..58df8d72 100644
--- a/py/utils/example_images_download_manager.py
+++ b/py/utils/example_images_download_manager.py
@@ -40,10 +40,10 @@ class DownloadManager:
Expects a JSON body with:
{
- "output_dir": "path/to/output", # Base directory to save example images
"optimize": true, # Whether to optimize images (default: true)
"model_types": ["lora", "checkpoint"], # Model types to process (default: both)
- "delay": 1.0 # Delay between downloads to avoid rate limiting (default: 1.0)
+ "delay": 1.0, # Delay between downloads to avoid rate limiting (default: 1.0)
+ "auto_mode": false # Flag to indicate automatic download (default: false)
}
"""
global download_task, is_downloading, download_progress
@@ -64,16 +64,30 @@ class DownloadManager:
try:
# Parse the request body
data = await request.json()
- output_dir = data.get('output_dir')
+ auto_mode = data.get('auto_mode', False)
optimize = data.get('optimize', True)
model_types = data.get('model_types', ['lora', 'checkpoint'])
delay = float(data.get('delay', 0.2)) # Default to 0.2 seconds
+ # Get output directory from settings
+ from ..services.service_registry import ServiceRegistry
+ settings_manager = await ServiceRegistry.get_settings_manager()
+ output_dir = settings_manager.get('example_images_path')
+
if not output_dir:
- return web.json_response({
- 'success': False,
- 'error': 'Missing output_dir parameter'
- }, status=400)
+ error_msg = 'Example images path not configured in settings'
+ if auto_mode:
+ # For auto mode, just log and return success to avoid showing error toasts
+ logger.debug(error_msg)
+ return web.json_response({
+ 'success': True,
+ 'message': 'Example images path not configured, skipping auto download'
+ })
+ else:
+ return web.json_response({
+ 'success': False,
+ 'error': error_msg
+ }, status=400)
# Create the output directory
os.makedirs(output_dir, exist_ok=True)
@@ -426,7 +440,6 @@ class DownloadManager:
Expects a JSON body with:
{
"model_hashes": ["hash1", "hash2", ...], # List of model hashes to download
- "output_dir": "path/to/output", # Base directory to save example images
"optimize": true, # Whether to optimize images (default: true)
"model_types": ["lora", "checkpoint"], # Model types to process (default: both)
"delay": 1.0 # Delay between downloads (default: 1.0)
@@ -444,7 +457,6 @@ class DownloadManager:
# Parse the request body
data = await request.json()
model_hashes = data.get('model_hashes', [])
- output_dir = data.get('output_dir')
optimize = data.get('optimize', True)
model_types = data.get('model_types', ['lora', 'checkpoint'])
delay = float(data.get('delay', 0.2)) # Default to 0.2 seconds
@@ -454,11 +466,16 @@ class DownloadManager:
'success': False,
'error': 'Missing model_hashes parameter'
}, status=400)
-
+
+ # Get output directory from settings
+ from ..services.service_registry import ServiceRegistry
+ settings_manager = await ServiceRegistry.get_settings_manager()
+ output_dir = settings_manager.get('example_images_path')
+
if not output_dir:
return web.json_response({
'success': False,
- 'error': 'Missing output_dir parameter'
+ 'error': 'Example images path not configured in settings'
}, status=400)
# Create the output directory
diff --git a/static/js/managers/ExampleImagesManager.js b/static/js/managers/ExampleImagesManager.js
index 7cb6d2ac..1c710040 100644
--- a/static/js/managers/ExampleImagesManager.js
+++ b/static/js/managers/ExampleImagesManager.js
@@ -100,9 +100,7 @@ export class ExampleImagesManager {
this.updateDownloadButtonState(hasPath);
try {
await settingsManager.saveSetting('example_images_path', pathInput.value);
- if (hasPath) {
showToast('toast.exampleImages.pathUpdated', {}, 'success');
- }
} catch (error) {
console.error('Failed to update example images path:', error);
showToast('toast.exampleImages.pathUpdateFailed', { message: error.message }, 'error');
@@ -227,13 +225,6 @@ export class ExampleImagesManager {
}
try {
- const outputDir = document.getElementById('exampleImagesPath').value || '';
-
- if (!outputDir) {
- showToast('toast.exampleImages.enterLocationFirst', {}, 'warning');
- return;
- }
-
const optimize = state.global.settings.optimizeExampleImages;
const response = await fetch('/api/lm/download-example-images', {
@@ -242,7 +233,6 @@ export class ExampleImagesManager {
'Content-Type': 'application/json'
},
body: JSON.stringify({
- output_dir: outputDir,
optimize: optimize,
model_types: ['lora', 'checkpoint', 'embedding'] // Example types, adjust as needed
})
@@ -691,9 +681,8 @@ export class ExampleImagesManager {
return false;
}
- // Check if download path is set
- const pathInput = document.getElementById('exampleImagesPath');
- if (!pathInput || !pathInput.value.trim()) {
+ // Check if download path is set in settings
+ if (!state.global.settings.example_images_path) {
return false;
}
@@ -724,7 +713,6 @@ export class ExampleImagesManager {
try {
console.log('Performing auto download check...');
- const outputDir = document.getElementById('exampleImagesPath').value;
const optimize = state.global.settings.optimizeExampleImages;
const response = await fetch('/api/lm/download-example-images', {
@@ -733,7 +721,6 @@ export class ExampleImagesManager {
'Content-Type': 'application/json'
},
body: JSON.stringify({
- output_dir: outputDir,
optimize: optimize,
model_types: ['lora', 'checkpoint', 'embedding'],
auto_mode: true // Flag to indicate this is an automatic download
diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js
index 9de7a7dc..6633c3e1 100644
--- a/static/js/managers/SettingsManager.js
+++ b/static/js/managers/SettingsManager.js
@@ -140,7 +140,7 @@ export class SettingsManager {
proxy_password: '',
example_images_path: '',
optimizeExampleImages: true,
- autoDownloadExampleImages: true
+ autoDownloadExampleImages: false
};
Object.keys(backendDefaults).forEach(key => {
@@ -972,10 +972,6 @@ export class SettingsManager {
await this.saveSetting('default_embedding_root', value);
} else if (settingKey === 'display_density') {
await this.saveSetting('displayDensity', value);
-
- // Also update compactMode for backwards compatibility
- state.global.settings.compactMode = (value !== 'default');
- this.saveFrontendSettingsToStorage();
} else if (settingKey === 'card_info_display') {
await this.saveSetting('cardInfoDisplay', value);
} else if (settingKey === 'proxy_type') {
From fb0d6b56411c0033994434bf07f1034540ccc403 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Thu, 18 Sep 2025 19:33:47 +0800
Subject: [PATCH 036/110] feat(docs): add comprehensive documentation for LoRA
Manager Civitai Extension, including features, installation, privacy, and
usage guidelines
---
docs/EventManagementImplementation.md | 182 ----------------
docs/EventManagerDocs.md | 301 --------------------------
docs/LM-Extension-Wiki.md | 176 +++++++++++++++
3 files changed, 176 insertions(+), 483 deletions(-)
delete mode 100644 docs/EventManagementImplementation.md
delete mode 100644 docs/EventManagerDocs.md
create mode 100644 docs/LM-Extension-Wiki.md
diff --git a/docs/EventManagementImplementation.md b/docs/EventManagementImplementation.md
deleted file mode 100644
index 6631fc16..00000000
--- a/docs/EventManagementImplementation.md
+++ /dev/null
@@ -1,182 +0,0 @@
-# Event Management Implementation Summary
-
-## What Has Been Implemented
-
-### 1. Enhanced EventManager Class
-- **Location**: `static/js/utils/EventManager.js`
-- **Features**:
- - Priority-based event handling
- - Conditional execution based on application state
- - Element filtering (target/exclude selectors)
- - Mouse button filtering
- - Automatic cleanup with cleanup functions
- - State tracking for app modes
- - Error handling for event handlers
-
-### 2. BulkManager Integration
-- **Location**: `static/js/managers/BulkManager.js`
-- **Migrated Events**:
- - Global keyboard shortcuts (Ctrl+A, Escape, B key)
- - Marquee selection events (mousedown, mousemove, mouseup, contextmenu)
- - State synchronization with EventManager
-- **Benefits**:
- - Centralized priority handling
- - Conditional execution based on modal state
- - Better coordination with other components
-
-### 3. UIHelpers Integration
-- **Location**: `static/js/utils/uiHelpers.js`
-- **Migrated Events**:
- - Mouse position tracking for node selector positioning
- - Node selector click events (outside clicks and selection)
- - State management for node selector
-- **Benefits**:
- - Reduced direct DOM listeners
- - Coordinated state tracking
- - Better cleanup
-
-### 4. ModelCard Integration
-- **Location**: `static/js/components/shared/ModelCard.js`
-- **Migrated Events**:
- - Model card click delegation
- - Action button handling (star, globe, copy, etc.)
- - Better return value handling for event propagation
-- **Benefits**:
- - Single event listener for all model cards
- - Priority-based execution
- - Better event flow control
-
-### 5. Documentation and Initialization
-- **EventManagerDocs.md**: Comprehensive documentation
-- **eventManagementInit.js**: Initialization and global handlers
-- **Features**:
- - Global escape key handling
- - Modal state synchronization
- - Error handling
- - Analytics integration points
- - Cleanup on page unload
-
-## Application States Tracked
-
-1. **bulkMode**: When bulk selection mode is active
-2. **marqueeActive**: When marquee selection is in progress
-3. **modalOpen**: When any modal dialog is open
-4. **nodeSelectorActive**: When node selector popup is visible
-
-## Priority Levels Used
-
-- **250+**: Critical system events (escape keys)
-- **200+**: High priority system events (modal close)
-- **100-199**: Application-level shortcuts (bulk operations)
-- **80-99**: UI interactions (marquee selection)
-- **60-79**: Component interactions (model cards)
-- **10-49**: Tracking and monitoring
-- **1-9**: Analytics and low-priority tasks
-
-## Event Flow Examples
-
-### Bulk Mode Toggle (B key)
-1. **Priority 100**: BulkManager keyboard handler catches 'b' key
-2. Toggles bulk mode state
-3. Updates EventManager state
-4. Updates UI accordingly
-5. Stops propagation (returns true)
-
-### Marquee Selection
-1. **Priority 80**: BulkManager mousedown handler (only in .models-container, excluding cards/buttons)
-2. Starts marquee selection
-3. **Priority 90**: BulkManager mousemove handler (only when marquee active)
-4. Updates selection rectangle
-5. **Priority 90**: BulkManager mouseup handler ends selection
-
-### Model Card Click
-1. **Priority 60**: ModelCard delegation handler checks for specific elements
-2. If action button: handles action and stops propagation
-3. If general card click: continues to other handlers
-4. Bulk selection may also handle the event if in bulk mode
-
-## Remaining Event Listeners (Not Yet Migrated)
-
-### High Priority for Migration
-1. **SearchManager keyboard events** - Global search shortcuts
-2. **ModalManager escape handling** - Already integrated with initialization
-3. **Scroll-based events** - Back to top, virtual scrolling
-4. **Resize events** - Panel positioning, responsive layouts
-
-### Medium Priority
-1. **Form input events** - Tag inputs, settings forms
-2. **Component-specific events** - Recipe modal, showcase view
-3. **Sidebar events** - Resize handling, toggle events
-
-### Low Priority (Can Remain As-Is)
-1. **VirtualScroller events** - Performance-critical, specialized
-2. **Component lifecycle events** - Modal open/close callbacks
-3. **One-time setup events** - Theme initialization, etc.
-
-## Benefits Achieved
-
-### Performance Improvements
-- **Reduced DOM listeners**: From ~15+ individual listeners to ~5 coordinated handlers
-- **Conditional execution**: Handlers only run when conditions are met
-- **Priority ordering**: Important events handled first
-- **Better memory management**: Automatic cleanup prevents leaks
-
-### Coordination Improvements
-- **State synchronization**: All components aware of app state
-- **Event flow control**: Proper propagation stopping
-- **Conflict resolution**: Priority system prevents conflicts
-- **Debugging**: Centralized event handling for easier debugging
-
-### Code Quality Improvements
-- **Consistent patterns**: All event handling follows same patterns
-- **Better separation of concerns**: Event logic separated from business logic
-- **Error handling**: Centralized error catching and reporting
-- **Documentation**: Clear patterns for future development
-
-## Next Steps (Recommendations)
-
-### 1. Migrate Search Events
-```javascript
-// In SearchManager.js
-eventManager.addHandler('keydown', 'search-shortcuts', (e) => {
- if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
- this.focusSearchInput();
- return true;
- }
-}, { priority: 120 });
-```
-
-### 2. Integrate Resize Events
-```javascript
-// Create ResizeManager
-eventManager.addHandler('resize', 'layout-resize', debounce((e) => {
- this.updateLayoutDimensions();
-}, 250), { priority: 50 });
-```
-
-### 3. Add Debug Mode
-```javascript
-// In EventManager.js
-if (window.DEBUG_EVENTS) {
- console.log(`Event ${eventType} handled by ${source} (priority: ${priority})`);
-}
-```
-
-### 4. Create Event Analytics
-```javascript
-// Track event patterns for optimization
-eventManager.addHandler('*', 'analytics', (e) => {
- this.trackEventUsage(e.type, performance.now());
-}, { priority: 1 });
-```
-
-## Testing Recommendations
-
-1. **Verify bulk mode interactions** work correctly
-2. **Test marquee selection** in various scenarios
-3. **Check modal state synchronization**
-4. **Verify node selector** positioning and cleanup
-5. **Test keyboard shortcuts** don't conflict
-6. **Verify proper cleanup** when components are destroyed
-
-The centralized event management system provides a solid foundation for coordinated, efficient event handling across the application while maintaining good performance and code organization.
diff --git a/docs/EventManagerDocs.md b/docs/EventManagerDocs.md
deleted file mode 100644
index 2ccc8174..00000000
--- a/docs/EventManagerDocs.md
+++ /dev/null
@@ -1,301 +0,0 @@
-# Centralized Event Management System
-
-This document describes the centralized event management system that coordinates event handling across the ComfyUI LoRA Manager application.
-
-## Overview
-
-The `EventManager` class provides a centralized way to handle DOM events with priority-based execution, conditional execution based on application state, and proper cleanup mechanisms.
-
-## Features
-
-- **Priority-based execution**: Handlers with higher priority run first
-- **Conditional execution**: Handlers can be executed based on application state
-- **Element filtering**: Handlers can target specific elements or exclude others
-- **Automatic cleanup**: Cleanup functions are called when handlers are removed
-- **State tracking**: Tracks application states like bulk mode, modal open, etc.
-
-## Basic Usage
-
-### Importing
-
-```javascript
-import { eventManager } from './EventManager.js';
-```
-
-### Adding Event Handlers
-
-```javascript
-eventManager.addHandler('click', 'myComponent', (event) => {
- console.log('Button clicked!');
- return true; // Stop propagation to other handlers
-}, {
- priority: 100,
- targetSelector: '.my-button',
- skipWhenModalOpen: true
-});
-```
-
-### Removing Event Handlers
-
-```javascript
-// Remove specific handler
-eventManager.removeHandler('click', 'myComponent');
-
-// Remove all handlers for a component
-eventManager.removeAllHandlersForSource('myComponent');
-```
-
-### Updating Application State
-
-```javascript
-// Set state
-eventManager.setState('bulkMode', true);
-eventManager.setState('modalOpen', true);
-
-// Get state
-const isBulkMode = eventManager.getState('bulkMode');
-```
-
-## Available States
-
-- `bulkMode`: Whether bulk selection mode is active
-- `marqueeActive`: Whether marquee selection is in progress
-- `modalOpen`: Whether any modal is currently open
-- `nodeSelectorActive`: Whether the node selector popup is active
-
-## Handler Options
-
-### Priority
-Higher numbers = higher priority. Handlers run in descending priority order.
-
-```javascript
-{
- priority: 100 // High priority
-}
-```
-
-### Conditional Execution
-
-```javascript
-{
- onlyInBulkMode: true, // Only run when bulk mode is active
- onlyWhenMarqueeActive: true, // Only run when marquee selection is active
- skipWhenModalOpen: true, // Skip when any modal is open
- skipWhenNodeSelectorActive: true, // Skip when node selector is active
- onlyWhenNodeSelectorActive: true // Only run when node selector is active
-}
-```
-
-### Element Filtering
-
-```javascript
-{
- targetSelector: '.model-card', // Only handle events on matching elements
- excludeSelector: 'button, input', // Exclude events from these elements
- button: 0 // Only handle specific mouse button (0=left, 1=middle, 2=right)
-}
-```
-
-### Cleanup Functions
-
-```javascript
-{
- cleanup: () => {
- // Custom cleanup logic
- console.log('Handler cleaned up');
- }
-}
-```
-
-## Integration Examples
-
-### BulkManager Integration
-
-```javascript
-class BulkManager {
- registerEventHandlers() {
- // High priority keyboard shortcuts
- eventManager.addHandler('keydown', 'bulkManager-keyboard', (e) => {
- return this.handleGlobalKeyboard(e);
- }, {
- priority: 100,
- skipWhenModalOpen: true
- });
-
- // Marquee selection
- eventManager.addHandler('mousedown', 'bulkManager-marquee-start', (e) => {
- return this.handleMarqueeStart(e);
- }, {
- priority: 80,
- skipWhenModalOpen: true,
- targetSelector: '.models-container',
- excludeSelector: '.model-card, button, input',
- button: 0
- });
- }
-
- cleanup() {
- eventManager.removeAllHandlersForSource('bulkManager-keyboard');
- eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
- }
-}
-```
-
-### Modal Integration
-
-```javascript
-class ModalManager {
- showModal(modalId) {
- // Update state when modal opens
- eventManager.setState('modalOpen', true);
- this.displayModal(modalId);
- }
-
- closeModal(modalId) {
- // Update state when modal closes
- eventManager.setState('modalOpen', false);
- this.hideModal(modalId);
- }
-}
-```
-
-### Component Event Delegation
-
-```javascript
-export function setupComponentEvents() {
- eventManager.addHandler('click', 'myComponent-actions', (event) => {
- const button = event.target.closest('.action-button');
- if (!button) return false;
-
- this.handleAction(button.dataset.action);
- return true; // Stop propagation
- }, {
- priority: 60,
- targetSelector: '.component-container'
- });
-}
-```
-
-## Best Practices
-
-### 1. Use Descriptive Source Names
-Use the format `componentName-purposeDescription`:
-```javascript
-// Good
-'bulkManager-marqueeSelection'
-'nodeSelector-clickOutside'
-'modelCard-delegation'
-
-// Avoid
-'bulk'
-'click'
-'handler1'
-```
-
-### 2. Set Appropriate Priorities
-- 200+: Critical system events (escape keys, critical modals)
-- 100-199: High priority application events (keyboard shortcuts)
-- 50-99: Normal UI interactions (buttons, cards)
-- 1-49: Low priority events (tracking, analytics)
-
-### 3. Use Conditional Execution
-Instead of checking state inside handlers, use options:
-```javascript
-// Good
-eventManager.addHandler('click', 'bulk-action', handler, {
- onlyInBulkMode: true
-});
-
-// Avoid
-eventManager.addHandler('click', 'bulk-action', (e) => {
- if (!state.bulkMode) return;
- // handler logic
-});
-```
-
-### 4. Clean Up Properly
-Always clean up handlers when components are destroyed:
-```javascript
-class MyComponent {
- constructor() {
- this.registerEvents();
- }
-
- destroy() {
- eventManager.removeAllHandlersForSource('myComponent');
- }
-}
-```
-
-### 5. Return Values Matter
-- Return `true` to stop event propagation to other handlers
-- Return `false` or `undefined` to continue with other handlers
-
-## Migration Guide
-
-### From Direct Event Listeners
-
-**Before:**
-```javascript
-document.addEventListener('click', (e) => {
- if (e.target.closest('.my-button')) {
- this.handleClick(e);
- }
-});
-```
-
-**After:**
-```javascript
-eventManager.addHandler('click', 'myComponent-button', (e) => {
- this.handleClick(e);
-}, {
- targetSelector: '.my-button'
-});
-```
-
-### From Event Delegation
-
-**Before:**
-```javascript
-container.addEventListener('click', (e) => {
- const card = e.target.closest('.model-card');
- if (!card) return;
-
- if (e.target.closest('.action-btn')) {
- this.handleAction(e);
- }
-});
-```
-
-**After:**
-```javascript
-eventManager.addHandler('click', 'container-actions', (e) => {
- const card = e.target.closest('.model-card');
- if (!card) return false;
-
- if (e.target.closest('.action-btn')) {
- this.handleAction(e);
- return true;
- }
-}, {
- targetSelector: '.container'
-});
-```
-
-## Performance Benefits
-
-1. **Reduced DOM listeners**: Single listener per event type instead of multiple
-2. **Conditional execution**: Handlers only run when conditions are met
-3. **Priority ordering**: Important handlers run first, avoiding unnecessary work
-4. **Automatic cleanup**: Prevents memory leaks from orphaned listeners
-5. **Centralized debugging**: All event handling flows through one system
-
-## Debugging
-
-Enable debug logging to trace event handling:
-```javascript
-// Add to EventManager.js for debugging
-console.log(`Handling ${eventType} event with ${handlers.length} handlers`);
-```
-
-The event manager provides a foundation for coordinated, efficient event handling across the entire application.
diff --git a/docs/LM-Extension-Wiki.md b/docs/LM-Extension-Wiki.md
new file mode 100644
index 00000000..272d512b
--- /dev/null
+++ b/docs/LM-Extension-Wiki.md
@@ -0,0 +1,176 @@
+## Overview
+
+The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com). With this extension, you can:
+
+✅ Instantly see which models are already present in your local library
+✅ Download new models with a single click
+✅ Manage downloads efficiently with queue and parallel download support
+✅ Keep your downloaded models automatically organized according to your custom settings
+
+
+
+---
+
+## Why Are All Features for Supporters Only?
+
+I love building tools for the Stable Diffusion and ComfyUI communities, and LoRA Manager is a passion project that I've poured countless hours into. When I created this companion extension, my hope was to offer its core features for free, as a thank-you to all of you.
+
+Unfortunately, I've reached a point where I need to be realistic. The level of support from the free model has been far lower than what's needed to justify the continuous development and maintenance for both projects. It was a difficult decision, but I've chosen to make the extension's features exclusive to supporters.
+
+This change is crucial for me to be able to continue dedicating my time to improving the free and open-source LoRA Manager, which I'm committed to keeping available for everyone.
+
+Your support does more than just unlock a few features—it allows me to keep innovating and ensures the core LoRA Manager project thrives. I'm incredibly grateful for your understanding and any support you can offer. ❤️
+
+(_For those who previously supported me on Ko-fi with a one-time donation, I'll be sending out license keys individually as a thank-you._)
+
+
+---
+
+## Installation
+
+### Supported Browsers & Installation Methods
+
+| Browser | Installation Method |
+|--------------------|-------------------------------------------------------------------------------------|
+| **Google Chrome** | [Chrome Web Store link](https://chromewebstore.google.com/detail/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) |
+| **Microsoft Edge** | Install via Chrome Web Store (compatible) |
+| **Brave Browser** | Install via Chrome Web Store (compatible) |
+| **Opera** | Install via Chrome Web Store (compatible) |
+| **Firefox** | |
+
+For non-Chrome browsers (e.g., Microsoft Edge), you can typically install extensions from the Chrome Web Store by following these steps: open the extension’s Chrome Web Store page, click 'Get extension', then click 'Allow' when prompted to enable installations from other stores, and finally click 'Add extension' to complete the installation.
+
+---
+
+## Privacy & Security
+
+I understand concerns around browser extensions and privacy, and I want to be fully transparent about how the **LM Civitai Extension** works:
+
+- **Reviewed and Verified**
+ This extension has been **manually reviewed and approved by the Chrome Web Store**. The Firefox version uses the **exact same code** (only the packaging format differs) and has passed **Mozilla’s Add-on review**.
+
+- **Minimal Network Access**
+ The only external server this extension connects to is:
+ **`https://willmiao.shop`** — used solely for **license validation**.
+
+ It does **not collect, transmit, or store any personal or usage data**.
+ No browsing history, no user IDs, no analytics, no hidden trackers.
+
+- **Local-Only Model Detection**
+ Model detection and LoRA Manager communication all happen **locally** within your browser, directly interacting with your local LoRA Manager backend.
+
+I value your trust and are committed to keeping your local setup private and secure. If you have any questions, feel free to reach out!
+
+---
+
+## How to Use
+
+After installing the extension, you'll automatically receive a **7-day trial** to explore all features.
+
+When the extension is correctly installed and your license is valid:
+
+- Open **Civitai**, and you'll see visual indicators added by the extension on model cards, showing:
+ - ✅ Models already present in your local library
+ - ⬇️ A download button for models not in your library
+
+Clicking the download button adds the corresponding model version to the download queue, waiting to be downloaded. You can set up to **5 models to download simultaneously**.
+
+### Visual Indicators Appear On:
+
+- **Home Page** — Featured models
+- **Models Page**
+- **Creator Profiles** — If the creator has set their models to be visible
+- **Recommended Resources** — On individual model pages
+
+### Version Buttons on Model Pages
+
+On a specific model page, visual indicators also appear on version buttons, showing which versions are already in your local library.
+
+When switching to a specific version by clicking a version button:
+
+- Clicking the download button will open a dropdown:
+ - Download via **LoRA Manager**
+ - Download via **Original Download** (browser download)
+
+You can check **Remember my choice** to set your preferred default. You can change this setting anytime in the extension's settings.
+
+
+
+### Resources on Image Pages (2025-08-05) — now shows in-library indicators for image resources. ‘Import image as recipe’ coming soon!
+
+
+
+---
+
+## Model Download Location & LoRA Manager Settings
+
+To use the **one-click download function**, you must first set:
+
+- Your **Default LoRAs Root**
+- Your **Default Checkpoints Root**
+
+These are set within LoRA Manager's settings.
+
+When everything is configured, downloaded model files will be placed in:
+
+`//`
+
+
+### Update: Default Path Customization (2025-07-21)
+
+A new setting to customize the default download path has been added in the nightly version. You can now personalize where models are saved when downloading via the LM Civitai Extension.
+
+
+
+The previous YAML path mapping file will be deprecated—settings will now be unified in settings.json to simplify configuration.
+
+---
+
+## Backend Port Configuration
+
+If your **ComfyUI** or **LoRA Manager** backend is running on a port **other than the default 8188**, you must configure the backend port in the extension's settings.
+
+After correctly setting and saving the port, you'll see in the extension's header area:
+- A **Healthy** status with the tooltip: `Connected to LoRA Manager on port xxxx`
+
+
+---
+
+## Advanced Usage
+
+### Connecting to a Remote LoRA Manager
+
+If your LoRA Manager is running on another computer, you can still connect from your browser using port forwarding.
+
+> **Why can't you set a remote IP directly?**
+>
+> For privacy and security, the extension only requests access to `http://127.0.0.1/*`. Supporting remote IPs would require much broader permissions, which may be rejected by browser stores and could raise user concerns.
+
+**Solution: Port Forwarding with `socat`**
+
+On your browser computer, run:
+
+`socat TCP-LISTEN:8188,bind=127.0.0.1,fork TCP:REMOTE.IP.ADDRESS.HERE:8188`
+
+- Replace `REMOTE.IP.ADDRESS.HERE` with the IP of the machine running LoRA Manager.
+- Adjust the port if needed.
+
+This lets the extension connect to `127.0.0.1:8188` as usual, with traffic forwarded to your remote server.
+
+_Thanks to user **Temikus** for sharing this solution!_
+
+---
+
+## Roadmap
+
+The extension will evolve alongside **LoRA Manager** improvements. Planned features include:
+
+- [x] Support for **additional model types** (e.g., embeddings)
+- [ ] One-click **Recipe Import**
+- [x] Display of in-library status for all resources in the **Resources Used** section of the image page
+- [x] One-click **Auto-organize Models**
+
+**Stay tuned — and thank you for your support!**
+
+---
+
From 67b274c1b24431d567d65f4479caee1e4397f1de Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Thu, 18 Sep 2025 21:55:21 +0800
Subject: [PATCH 037/110] feat(settings): add 'show_only_sfw' setting to manage
content visibility
---
py/services/settings_manager.py | 1 +
static/js/managers/SettingsManager.js | 4 ++--
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py
index ec71104e..adc1fb3a 100644
--- a/py/services/settings_manager.py
+++ b/py/services/settings_manager.py
@@ -81,6 +81,7 @@ class SettingsManager:
return {
"civitai_api_key": "",
"language": "en",
+ "show_only_sfw": False, # Show only SFW content
"enable_metadata_archive_db": False, # Enable metadata archive database
"proxy_enabled": False, # Enable app-level proxy
"proxy_host": "", # Proxy host
diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js
index 6633c3e1..8d4da06a 100644
--- a/static/js/managers/SettingsManager.js
+++ b/static/js/managers/SettingsManager.js
@@ -43,7 +43,6 @@ export class SettingsManager {
// Frontend-only settings that should be stored in localStorage
const frontendOnlyKeys = [
'blurMatureContent',
- 'show_only_sfw',
'autoplayOnHover',
'displayDensity',
'cardInfoDisplay',
@@ -132,6 +131,7 @@ export class SettingsManager {
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
enable_metadata_archive_db: false,
language: 'en',
+ show_only_sfw: false,
proxy_enabled: false,
proxy_type: 'http',
proxy_host: '',
@@ -161,7 +161,6 @@ export class SettingsManager {
// Save only frontend-specific settings to localStorage
const frontendOnlyKeys = [
'blurMatureContent',
- 'show_only_sfw',
'autoplayOnHover',
'displayDensity',
'cardInfoDisplay',
@@ -189,6 +188,7 @@ export class SettingsManager {
'download_path_templates',
'enable_metadata_archive_db',
'language',
+ 'show_only_sfw',
'proxy_enabled',
'proxy_type',
'proxy_host',
From fc6f1bf95be94041f7dd9bf721bcca938f79720c Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Fri, 19 Sep 2025 11:17:19 +0800
Subject: [PATCH 038/110] fix(lora_loader): remove unnecessary string stripping
from lora names in loaders, fixes #441
---
py/nodes/lora_loader.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/py/nodes/lora_loader.py b/py/nodes/lora_loader.py
index 7ff8c219..d431c263 100644
--- a/py/nodes/lora_loader.py
+++ b/py/nodes/lora_loader.py
@@ -115,7 +115,7 @@ class LoraManagerLoader:
formatted_loras = []
for item in loaded_loras:
parts = item.split(":")
- lora_name = parts[0].strip()
+ lora_name = parts[0]
strength_parts = parts[1].strip().split(",")
if len(strength_parts) > 1:
@@ -165,7 +165,7 @@ class LoraManagerTextLoader:
loras = []
for match in matches:
- lora_name = match[0].strip()
+ lora_name = match[0]
model_strength = float(match[1])
clip_strength = float(match[2]) if match[2] else model_strength
From 1610048974203268e1f7eee6a0d51a063d4c9e86 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Fri, 19 Sep 2025 16:36:34 +0800
Subject: [PATCH 039/110] refactor(metadata): update model fetching methods to
return error messages alongside results
---
py/recipes/base.py | 2 +-
py/recipes/parsers/civitai_image.py | 43 ++++++-------
py/routes/base_model_routes.py | 13 ++--
py/routes/update_routes.py | 6 +-
py/services/civitai_client.py | 83 ++++++------------------
py/services/model_metadata_provider.py | 88 +++++---------------------
py/utils/routes_common.py | 6 +-
7 files changed, 66 insertions(+), 175 deletions(-)
diff --git a/py/recipes/base.py b/py/recipes/base.py
index 78bb933f..3897c683 100644
--- a/py/recipes/base.py
+++ b/py/recipes/base.py
@@ -55,7 +55,7 @@ class RecipeMetadataParser(ABC):
# Unpack the tuple to get the actual data
civitai_info, error_msg = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
- if not civitai_info or civitai_info.get("error") == "Model not found":
+ if not civitai_info or error_msg == "Model not found":
# Model not found or deleted
lora_entry['isDeleted'] = True
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
diff --git a/py/recipes/parsers/civitai_image.py b/py/recipes/parsers/civitai_image.py
index 8e96c99b..31234ab9 100644
--- a/py/recipes/parsers/civitai_image.py
+++ b/py/recipes/parsers/civitai_image.py
@@ -91,7 +91,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
result["base_model"] = metadata["baseModel"]
elif "Model hash" in metadata and metadata_provider:
model_hash = metadata["Model hash"]
- model_info = await metadata_provider.get_model_by_hash(model_hash)
+ model_info, error = await metadata_provider.get_model_by_hash(model_hash)
if model_info:
result["base_model"] = model_info.get("baseModel", "")
elif "Model" in metadata and isinstance(metadata.get("resources"), list):
@@ -100,7 +100,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if resource.get("type") == "model" and resource.get("name") == metadata.get("Model"):
# This is likely the checkpoint model
if metadata_provider and resource.get("hash"):
- model_info = await metadata_provider.get_model_by_hash(resource.get("hash"))
+ model_info, error = await metadata_provider.get_model_by_hash(resource.get("hash"))
if model_info:
result["base_model"] = model_info.get("baseModel", "")
@@ -201,11 +201,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if version_id and metadata_provider:
try:
# Use get_model_version_info instead of get_model_version
- civitai_info, error = await metadata_provider.get_model_version_info(version_id)
-
- if error:
- logger.warning(f"Error getting model version info: {error}")
- continue
+ civitai_info = await metadata_provider.get_model_version_info(version_id)
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
@@ -267,26 +263,23 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if version_id and metadata_provider:
try:
# Use get_model_version_info with the version ID
- civitai_info, error = await metadata_provider.get_model_version_info(version_id)
+ civitai_info = await metadata_provider.get_model_version_info(version_id)
- if error:
- logger.warning(f"Error getting model version info: {error}")
- else:
- populated_entry = await self.populate_lora_from_civitai(
- lora_entry,
- civitai_info,
- recipe_scanner,
- base_model_counts
- )
+ populated_entry = await self.populate_lora_from_civitai(
+ lora_entry,
+ civitai_info,
+ recipe_scanner,
+ base_model_counts
+ )
+
+ if populated_entry is None:
+ continue # Skip invalid LoRA types
- if populated_entry is None:
- continue # Skip invalid LoRA types
-
- lora_entry = populated_entry
-
- # Track this LoRA for deduplication
- if version_id:
- added_loras[version_id] = len(result["loras"])
+ lora_entry = populated_entry
+
+ # Track this LoRA for deduplication
+ if version_id:
+ added_loras[version_id] = len(result["loras"])
except Exception as e:
logger.error(f"Error fetching Civitai info for model ID {version_id}: {e}")
diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py
index 2b2f7a7c..f32af9cd 100644
--- a/py/routes/base_model_routes.py
+++ b/py/routes/base_model_routes.py
@@ -624,7 +624,7 @@ class BaseModelRoutes(ABC):
success = 0
needs_resort = False
- # Prepare models to process, only those without CivitAI data or missing tags, description, or creator
+ # Prepare models to process, only those without CivitAI data
enable_metadata_archive_db = settings.get('enable_metadata_archive_db', False)
to_process = [
model for model in cache.raw_data
@@ -633,9 +633,6 @@ class BaseModelRoutes(ABC):
and (
not model.get('civitai')
or not model['civitai'].get('id')
- # or not model.get('tags') # Skipping tag cause it could be empty legitimately
- # or not model.get('modelDescription')
- # or not (model.get('civitai') and model['civitai'].get('creator'))
)
and (
(enable_metadata_archive_db)
@@ -782,7 +779,13 @@ class BaseModelRoutes(ABC):
try:
hash = request.match_info.get('hash')
metadata_provider = await get_default_metadata_provider()
- model = await metadata_provider.get_model_by_hash(hash)
+ model, error = await metadata_provider.get_model_by_hash(hash)
+ if error:
+ logger.warning(f"Error getting model by hash: {error}")
+ return web.json_response({
+ "success": False,
+ "error": error
+ }, status=404)
return web.json_response(model)
except Exception as e:
logger.error(f"Error fetching model details by hash: {e}")
diff --git a/py/routes/update_routes.py b/py/routes/update_routes.py
index 2febfba3..a25e085e 100644
--- a/py/routes/update_routes.py
+++ b/py/routes/update_routes.py
@@ -7,7 +7,7 @@ import shutil
import tempfile
from aiohttp import web
from typing import Dict, List
-from ..services.downloader import get_downloader, Downloader
+from ..services.downloader import get_downloader
logger = logging.getLogger(__name__)
@@ -265,7 +265,7 @@ class UpdateRoutes:
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/commits/main"
try:
- downloader = await Downloader.get_instance()
+ downloader = await get_downloader()
success, data = await downloader.make_request('GET', github_url, custom_headers={'Accept': 'application/vnd.github+json'})
if not success:
@@ -431,7 +431,7 @@ class UpdateRoutes:
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
try:
- downloader = await Downloader.get_instance()
+ downloader = await get_downloader()
success, data = await downloader.make_request('GET', github_url, custom_headers={'Accept': 'application/vnd.github+json'})
if not success:
diff --git a/py/services/civitai_client.py b/py/services/civitai_client.py
index 463bd036..ccadbcb5 100644
--- a/py/services/civitai_client.py
+++ b/py/services/civitai_client.py
@@ -1,4 +1,3 @@
-from datetime import datetime
import os
import logging
import asyncio
@@ -59,17 +58,17 @@ class CivitaiClient:
return success, result
- async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]:
+ async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
try:
downloader = await get_downloader()
- success, version = await downloader.make_request(
+ success, result = await downloader.make_request(
'GET',
f"{self.base_url}/model-versions/by-hash/{model_hash}",
use_auth=True
)
if success:
# Get model ID from version data
- model_id = version.get('modelId')
+ model_id = result.get('modelId')
if model_id:
# Fetch additional model metadata
success_model, data = await downloader.make_request(
@@ -79,17 +78,24 @@ class CivitaiClient:
)
if success_model:
# Enrich version_info with model data
- version['model']['description'] = data.get("description")
- version['model']['tags'] = data.get("tags", [])
+ result['model']['description'] = data.get("description")
+ result['model']['tags'] = data.get("tags", [])
# Add creator from model data
- version['creator'] = data.get("creator")
+ result['creator'] = data.get("creator")
- return version
- return None
+ return result, None
+
+ # Handle specific error cases
+ if "not found" in str(result):
+ return None, "Model not found"
+
+ # Other error cases
+ logger.error(f"Failed to fetch model info for {model_hash[:10]}: {result}")
+ return None, str(result)
except Exception as e:
logger.error(f"API Error: {str(e)}")
- return None
+ return None, str(e)
async def download_preview_image(self, image_url: str, save_path: str):
try:
@@ -246,8 +252,8 @@ class CivitaiClient:
return result, None
# Handle specific error cases
- if "404" in str(result):
- error_msg = f"Model not found (status 404)"
+ if "not found" in str(result):
+ error_msg = f"Model not found"
logger.warning(f"Model version not found: {version_id} - {error_msg}")
return None, error_msg
@@ -259,59 +265,6 @@ class CivitaiClient:
logger.error(error_msg)
return None, error_msg
- async def get_model_metadata(self, model_id: str) -> Tuple[Optional[Dict], int]:
- """Fetch model metadata (description, tags, and creator info) from Civitai API
-
- Args:
- model_id: The Civitai model ID
-
- Returns:
- Tuple[Optional[Dict], int]: A tuple containing:
- - A dictionary with model metadata or None if not found
- - The HTTP status code from the request (0 for exceptions)
- """
- try:
- downloader = await get_downloader()
- url = f"{self.base_url}/models/{model_id}"
-
- success, result = await downloader.make_request(
- 'GET',
- url,
- use_auth=True
- )
-
- if not success:
- # Try to extract status code from error message
- status_code = 0
- if "404" in str(result):
- status_code = 404
- elif "401" in str(result):
- status_code = 401
- elif "403" in str(result):
- status_code = 403
- logger.warning(f"Failed to fetch model metadata: {result}")
- return None, status_code
-
- # Extract relevant metadata
- metadata = {
- "description": result.get("description") or "No model description available",
- "tags": result.get("tags", []),
- "creator": {
- "username": result.get("creator", {}).get("username"),
- "image": result.get("creator", {}).get("image")
- }
- }
-
- if metadata["description"] or metadata["tags"] or metadata["creator"]["username"]:
- return metadata, 200
- else:
- logger.warning(f"No metadata found for model {model_id}")
- return None, 200
-
- except Exception as e:
- logger.error(f"Error fetching model metadata: {e}", exc_info=True)
- return None, 0
-
async def get_image_info(self, image_id: str) -> Optional[Dict]:
"""Fetch image information from Civitai API
diff --git a/py/services/model_metadata_provider.py b/py/services/model_metadata_provider.py
index ee38f373..55091118 100644
--- a/py/services/model_metadata_provider.py
+++ b/py/services/model_metadata_provider.py
@@ -2,7 +2,6 @@ from abc import ABC, abstractmethod
import json
import aiosqlite
import logging
-import aiohttp
from bs4 import BeautifulSoup
from typing import Optional, Dict, Tuple
from .downloader import get_downloader
@@ -13,7 +12,7 @@ class ModelMetadataProvider(ABC):
"""Base abstract class for all model metadata providers"""
@abstractmethod
- async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]:
+ async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
"""Find model by hash value"""
pass
@@ -31,11 +30,6 @@ class ModelMetadataProvider(ABC):
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
"""Fetch model version metadata"""
pass
-
- @abstractmethod
- async def get_model_metadata(self, model_id: str) -> Tuple[Optional[Dict], int]:
- """Fetch model metadata (description, tags, and creator info)"""
- pass
class CivitaiModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses Civitai API for metadata"""
@@ -43,7 +37,7 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
def __init__(self, civitai_client):
self.client = civitai_client
- async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]:
+ async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
return await self.client.get_model_by_hash(model_hash)
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
@@ -54,16 +48,13 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
return await self.client.get_model_version_info(version_id)
-
- async def get_model_metadata(self, model_id: str) -> Tuple[Optional[Dict], int]:
- return await self.client.get_model_metadata(model_id)
class CivArchiveModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses CivArchive HTML page parsing for metadata"""
- async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]:
+ async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
"""Not supported by CivArchive provider"""
- return None
+ return None, "CivArchive provider does not support hash lookup"
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
"""Not supported by CivArchive provider"""
@@ -174,10 +165,6 @@ class CivArchiveModelMetadataProvider(ModelMetadataProvider):
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
"""Not supported by CivArchive provider - requires both model_id and version_id"""
return None, "CivArchive provider requires both model_id and version_id"
-
- async def get_model_metadata(self, model_id: str) -> Tuple[Optional[Dict], int]:
- """Not supported by CivArchive provider"""
- return None, 404
class SQLiteModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses SQLite database for metadata"""
@@ -185,7 +172,7 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
def __init__(self, db_path: str):
self.db_path = db_path
- async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]:
+ async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
"""Find model by hash value from SQLite database"""
async with aiosqlite.connect(self.db_path) as db:
# Look up in model_files table to get model_id and version_id
@@ -200,14 +187,15 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
file_row = await cursor.fetchone()
if not file_row:
- return None
+ return None, "Model not found"
# Get version details
model_id = file_row['model_id']
version_id = file_row['version_id']
# Build response in the same format as Civitai API
- return await self._get_version_with_model_data(db, model_id, version_id)
+ result = await self._get_version_with_model_data(db, model_id, version_id)
+ return result, None if result else "Error retrieving model data"
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
"""Get all versions of a model from SQLite database"""
@@ -324,37 +312,6 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
version_data = await self._get_version_with_model_data(db, model_id, version_id)
return version_data, None
- async def get_model_metadata(self, model_id: str) -> Tuple[Optional[Dict], int]:
- """Fetch model metadata from SQLite database"""
- async with aiosqlite.connect(self.db_path) as db:
- db.row_factory = aiosqlite.Row
-
- # Get model details
- model_query = "SELECT name, type, data, username FROM models WHERE id = ?"
- cursor = await db.execute(model_query, (model_id,))
- model_row = await cursor.fetchone()
-
- if not model_row:
- return None, 404
-
- # Parse data JSON
- try:
- model_data = json.loads(model_row['data'])
-
- # Extract relevant metadata
- metadata = {
- "description": model_data.get("description", "No model description available"),
- "tags": model_data.get("tags", []),
- "creator": {
- "username": model_row['username'] or model_data.get("creator", {}).get("username"),
- "image": model_data.get("creator", {}).get("image")
- }
- }
-
- return metadata, 200
- except json.JSONDecodeError:
- return None, 500
-
async def _get_version_with_model_data(self, db, model_id, version_id) -> Optional[Dict]:
"""Helper to build version data with model information"""
# Get version details
@@ -409,15 +366,16 @@ class FallbackMetadataProvider(ModelMetadataProvider):
def __init__(self, providers: list):
self.providers = providers
- async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]:
+ async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
for provider in self.providers:
try:
- result = await provider.get_model_by_hash(model_hash)
+ result, error = await provider.get_model_by_hash(model_hash)
if result:
- return result
- except Exception:
+ return result, error
+ except Exception as e:
+ logger.debug(f"Provider failed for get_model_by_hash: {e}")
continue
- return None
+ return None, "Model not found"
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
for provider in self.providers:
@@ -452,17 +410,6 @@ class FallbackMetadataProvider(ModelMetadataProvider):
continue
return None, "No provider could retrieve the data"
- async def get_model_metadata(self, model_id: str) -> Tuple[Optional[Dict], int]:
- for provider in self.providers:
- try:
- result, status = await provider.get_model_metadata(model_id)
- if result:
- return result, status
- except Exception as e:
- logger.debug(f"Provider failed for get_model_metadata: {e}")
- continue
- return None, 404
-
class ModelMetadataProviderManager:
"""Manager for selecting and using model metadata providers"""
@@ -485,7 +432,7 @@ class ModelMetadataProviderManager:
if is_default or self.default_provider is None:
self.default_provider = name
- async def get_model_by_hash(self, model_hash: str, provider_name: str = None) -> Optional[Dict]:
+ async def get_model_by_hash(self, model_hash: str, provider_name: str = None) -> Tuple[Optional[Dict], Optional[str]]:
"""Find model by hash using specified or default provider"""
provider = self._get_provider(provider_name)
return await provider.get_model_by_hash(model_hash)
@@ -505,11 +452,6 @@ class ModelMetadataProviderManager:
provider = self._get_provider(provider_name)
return await provider.get_model_version_info(version_id)
- async def get_model_metadata(self, model_id: str, provider_name: str = None) -> Tuple[Optional[Dict], int]:
- """Fetch model metadata using specified or default provider"""
- provider = self._get_provider(provider_name)
- return await provider.get_model_metadata(model_id)
-
def _get_provider(self, provider_name: str = None) -> ModelMetadataProvider:
"""Get provider by name or default provider"""
if provider_name and provider_name in self.providers:
diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py
index db2f94db..e6bd4ac7 100644
--- a/py/utils/routes_common.py
+++ b/py/utils/routes_common.py
@@ -215,7 +215,7 @@ class ModelRouteUtils:
else:
metadata_provider = await get_default_metadata_provider()
- civitai_metadata = await metadata_provider.get_model_by_hash(sha256)
+ civitai_metadata, error = await metadata_provider.get_model_by_hash(sha256)
if not civitai_metadata:
# Mark as not from CivitAI if not found
local_metadata['from_civitai'] = False
@@ -387,10 +387,10 @@ class ModelRouteUtils:
metadata_provider = await get_default_metadata_provider()
# Fetch and update metadata
- civitai_metadata = await metadata_provider.get_model_by_hash(local_metadata["sha256"])
+ civitai_metadata, error = await metadata_provider.get_model_by_hash(local_metadata["sha256"])
if not civitai_metadata:
await ModelRouteUtils.handle_not_found_on_civitai(metadata_path, local_metadata)
- return web.json_response({"success": False, "error": "Not found on CivitAI"}, status=404)
+ return web.json_response({"success": False, "error": error}, status=404)
await ModelRouteUtils.update_model_metadata(metadata_path, local_metadata, civitai_metadata, metadata_provider)
From f3544b3471e30eee5c3be2a65ec2d87fc7c522d7 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Fri, 19 Sep 2025 22:57:05 +0800
Subject: [PATCH 040/110] refactor(settings): replace getStorageItem with
state.global.settings for default root retrieval
---
.../shared/showcase/ShowcaseView.js | 14 ++
static/js/managers/DownloadManager.js | 2 +-
static/js/managers/ImportManager.js | 2 +-
static/js/managers/MoveManager.js | 3 +-
static/js/managers/import/FolderBrowser.js | 221 ------------------
5 files changed, 17 insertions(+), 225 deletions(-)
delete mode 100644 static/js/managers/import/FolderBrowser.js
diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js
index 257753fd..2830b166 100644
--- a/static/js/components/shared/showcase/ShowcaseView.js
+++ b/static/js/components/shared/showcase/ShowcaseView.js
@@ -33,6 +33,20 @@ export async function loadExampleImages(images, modelHash) {
const params = `model_hash=${modelHash}`;
const response = await fetch(`${endpoint}?${params}`);
+ if (!response.ok) {
+ // Try to parse error message from backend
+ let errorMsg = `HTTP error ${response.status}`;
+ try {
+ const errorData = await response.json();
+ if (errorData && errorData.error) {
+ errorMsg = errorData.error;
+ }
+ } catch (e) {
+ // Ignore JSON parse error
+ }
+ console.warn("Failed to get example files:", errorMsg);
+ return;
+ }
const result = await response.json();
if (result.success) {
diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js
index 36137785..c84e7a5f 100644
--- a/static/js/managers/DownloadManager.js
+++ b/static/js/managers/DownloadManager.js
@@ -308,7 +308,7 @@ export class DownloadManager {
// Set default root if available
const singularType = this.apiClient.modelType.replace(/s$/, '');
const defaultRootKey = `default_${singularType}_root`;
- const defaultRoot = getStorageItem('settings', {})[defaultRootKey];
+ const defaultRoot = state.global.settings[defaultRootKey];
console.log(`Default root for ${this.apiClient.modelType}:`, defaultRoot);
console.log('Available roots:', rootsData.roots);
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
diff --git a/static/js/managers/ImportManager.js b/static/js/managers/ImportManager.js
index 95d1549d..3aa4de35 100644
--- a/static/js/managers/ImportManager.js
+++ b/static/js/managers/ImportManager.js
@@ -228,7 +228,7 @@ export class ImportManager {
// Set default root if available
const defaultRootKey = 'default_lora_root';
- const defaultRoot = getStorageItem('settings', {})[defaultRootKey];
+ const defaultRoot = state.global.settings[defaultRootKey];
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
loraRoot.value = defaultRoot;
}
diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js
index 528b2847..1b23a827 100644
--- a/static/js/managers/MoveManager.js
+++ b/static/js/managers/MoveManager.js
@@ -2,7 +2,6 @@ import { showToast } from '../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { modalManager } from './ModalManager.js';
import { bulkManager } from './BulkManager.js';
-import { getStorageItem } from '../utils/storageHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { sidebarManager } from '../components/SidebarManager.js';
@@ -87,7 +86,7 @@ class MoveManager {
// Set default root if available
const settingsKey = `default_${currentPageType.slice(0, -1)}_root`;
- const defaultRoot = getStorageItem('settings', {})[settingsKey];
+ const defaultRoot = state.global.settings[settingsKey];
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
modelRootSelect.value = defaultRoot;
}
diff --git a/static/js/managers/import/FolderBrowser.js b/static/js/managers/import/FolderBrowser.js
deleted file mode 100644
index 43cfbada..00000000
--- a/static/js/managers/import/FolderBrowser.js
+++ /dev/null
@@ -1,221 +0,0 @@
-import { showToast } from '../../utils/uiHelpers.js';
-import { translate } from '../../utils/i18nHelpers.js';
-import { getStorageItem } from '../../utils/storageHelpers.js';
-
-export class FolderBrowser {
- constructor(importManager) {
- this.importManager = importManager;
- this.folderClickHandler = null;
- this.updateTargetPath = this.updateTargetPath.bind(this);
- }
-
- async proceedToLocation() {
- // Show the location step with special handling
- this.importManager.stepManager.showStep('locationStep');
-
- // Double-check after a short delay to ensure the step is visible
- setTimeout(() => {
- const locationStep = document.getElementById('locationStep');
- if (locationStep.style.display !== 'block' ||
- window.getComputedStyle(locationStep).display !== 'block') {
- // Force display again
- locationStep.style.display = 'block';
-
- // If still not visible, try with injected style
- if (window.getComputedStyle(locationStep).display !== 'block') {
- this.importManager.stepManager.injectedStyles = document.createElement('style');
- this.importManager.stepManager.injectedStyles.innerHTML = `
- #locationStep {
- display: block !important;
- opacity: 1 !important;
- visibility: visible !important;
- }
- `;
- document.head.appendChild(this.importManager.stepManager.injectedStyles);
- }
- }
- }, 100);
-
- try {
- // Display missing LoRAs that will be downloaded
- const missingLorasList = document.getElementById('missingLorasList');
- if (missingLorasList && this.importManager.downloadableLoRAs.length > 0) {
- // Calculate total size
- const totalSize = this.importManager.downloadableLoRAs.reduce((sum, lora) => {
- return sum + (lora.size ? parseInt(lora.size) : 0);
- }, 0);
-
- // Update total size display
- const totalSizeDisplay = document.getElementById('totalDownloadSize');
- if (totalSizeDisplay) {
- totalSizeDisplay.textContent = this.importManager.formatFileSize(totalSize);
- }
-
- // Update header to include count of missing LoRAs
- const missingLorasHeader = document.querySelector('.summary-header h3');
- if (missingLorasHeader) {
- missingLorasHeader.innerHTML = `Missing LoRAs (${this.importManager.downloadableLoRAs.length}) ${this.importManager.formatFileSize(totalSize)}`;
- }
-
- // Generate missing LoRAs list
- missingLorasList.innerHTML = this.importManager.downloadableLoRAs.map(lora => {
- const sizeDisplay = lora.size ?
- this.importManager.formatFileSize(lora.size) : 'Unknown size';
- const baseModel = lora.baseModel ?
- `${lora.baseModel}` : '';
- const isEarlyAccess = lora.isEarlyAccess;
-
- // Early access badge
- let earlyAccessBadge = '';
- if (isEarlyAccess) {
- earlyAccessBadge = `
- Early Access
- `;
- }
-
- return `
-
-
-
${lora.name}
- ${baseModel}
- ${earlyAccessBadge}
-
-
${sizeDisplay}
-
- `;
- }).join('');
-
- // Set up toggle for missing LoRAs list
- const toggleBtn = document.getElementById('toggleMissingLorasList');
- if (toggleBtn) {
- toggleBtn.addEventListener('click', () => {
- missingLorasList.classList.toggle('collapsed');
- const icon = toggleBtn.querySelector('i');
- if (icon) {
- icon.classList.toggle('fa-chevron-down');
- icon.classList.toggle('fa-chevron-up');
- }
- });
- }
- }
-
- // Fetch LoRA roots
- const rootsResponse = await fetch('/api/lm/loras/roots');
- if (!rootsResponse.ok) {
- throw new Error(`Failed to fetch LoRA roots: ${rootsResponse.status}`);
- }
-
- const rootsData = await rootsResponse.json();
- const loraRoot = document.getElementById('importLoraRoot');
- if (loraRoot) {
- loraRoot.innerHTML = rootsData.roots.map(root =>
- ``
- ).join('');
-
- // Set default lora root if available
- const defaultRoot = getStorageItem('settings', {}).default_lora_root;
- if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
- loraRoot.value = defaultRoot;
- }
- }
-
- // Fetch folders
- const foldersResponse = await fetch('/api/lm/loras/folders');
- if (!foldersResponse.ok) {
- throw new Error(`Failed to fetch folders: ${foldersResponse.status}`);
- }
-
- const foldersData = await foldersResponse.json();
- const folderBrowser = document.getElementById('importFolderBrowser');
- if (folderBrowser) {
- folderBrowser.innerHTML = foldersData.folders.map(folder =>
- folder ? `${folder}
` : ''
- ).join('');
- }
-
- // Initialize folder browser after loading data
- this.initializeFolderBrowser();
- } catch (error) {
- console.error('Error in API calls:', error);
- showToast('toast.recipes.folderBrowserError', { message: error.message }, 'error');
- }
- }
-
- initializeFolderBrowser() {
- const folderBrowser = document.getElementById('importFolderBrowser');
- if (!folderBrowser) return;
-
- // Cleanup existing handler if any
- this.cleanup();
-
- // Create new handler
- this.folderClickHandler = (event) => {
- const folderItem = event.target.closest('.folder-item');
- if (!folderItem) return;
-
- if (folderItem.classList.contains('selected')) {
- folderItem.classList.remove('selected');
- this.importManager.selectedFolder = '';
- } else {
- folderBrowser.querySelectorAll('.folder-item').forEach(f =>
- f.classList.remove('selected'));
- folderItem.classList.add('selected');
- this.importManager.selectedFolder = folderItem.dataset.folder;
- }
-
- // Update path display after folder selection
- this.updateTargetPath();
- };
-
- // Add the new handler
- folderBrowser.addEventListener('click', this.folderClickHandler);
-
- // Add event listeners for path updates
- const loraRoot = document.getElementById('importLoraRoot');
- const newFolder = document.getElementById('importNewFolder');
-
- if (loraRoot) loraRoot.addEventListener('change', this.updateTargetPath);
- if (newFolder) newFolder.addEventListener('input', this.updateTargetPath);
-
- // Update initial path
- this.updateTargetPath();
- }
-
- cleanup() {
- if (this.folderClickHandler) {
- const folderBrowser = document.getElementById('importFolderBrowser');
- if (folderBrowser) {
- folderBrowser.removeEventListener('click', this.folderClickHandler);
- this.folderClickHandler = null;
- }
- }
-
- // Remove path update listeners
- const loraRoot = document.getElementById('importLoraRoot');
- const newFolder = document.getElementById('importNewFolder');
-
- if (loraRoot) loraRoot.removeEventListener('change', this.updateTargetPath);
- if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath);
- }
-
- updateTargetPath() {
- const pathDisplay = document.getElementById('importTargetPathDisplay');
- if (!pathDisplay) return;
-
- const loraRoot = document.getElementById('importLoraRoot')?.value || '';
- const newFolder = document.getElementById('importNewFolder')?.value?.trim() || '';
-
- let fullPath = loraRoot || translate('recipes.controls.import.selectLoraRoot', {}, 'Select a LoRA root directory');
-
- if (loraRoot) {
- if (this.importManager.selectedFolder) {
- fullPath += '/' + this.importManager.selectedFolder;
- }
- if (newFolder) {
- fullPath += '/' + newFolder;
- }
- }
-
- pathDisplay.innerHTML = `${fullPath}`;
- }
-}
From 3053b13fcb2ff6ae852ae3fe27e44e2ba0edc798 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Fri, 19 Sep 2025 23:22:47 +0800
Subject: [PATCH 041/110] feat(metadata): enhance model processing with CivitAI
metadata checks and new fields for archive DB status
---
py/routes/base_model_routes.py | 18 +++++-----
py/utils/models.py | 2 ++
py/utils/routes_common.py | 35 ++++++++++++-------
.../shared/showcase/ShowcaseView.js | 14 --------
4 files changed, 33 insertions(+), 36 deletions(-)
diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py
index f32af9cd..db914960 100644
--- a/py/routes/base_model_routes.py
+++ b/py/routes/base_model_routes.py
@@ -626,18 +626,16 @@ class BaseModelRoutes(ABC):
# Prepare models to process, only those without CivitAI data
enable_metadata_archive_db = settings.get('enable_metadata_archive_db', False)
+ # Filter models that need CivitAI metadata update
to_process = [
model for model in cache.raw_data
- if (
- model.get('sha256')
- and (
- not model.get('civitai')
- or not model['civitai'].get('id')
- )
- and (
- (enable_metadata_archive_db)
- or (not enable_metadata_archive_db and model.get('from_civitai') is True)
- )
+ if model.get('sha256')
+ and (
+ not model.get('civitai') or not model['civitai'].get('id')
+ )
+ and (
+ (enable_metadata_archive_db and not model.get('db_checked', False))
+ or (not enable_metadata_archive_db and model.get('from_civitai') is True)
)
]
total_to_process = len(to_process)
diff --git a/py/utils/models.py b/py/utils/models.py
index 9e1dc737..159146d5 100644
--- a/py/utils/models.py
+++ b/py/utils/models.py
@@ -24,6 +24,8 @@ class BaseModelMetadata:
civitai_deleted: bool = False # Whether deleted from Civitai
favorite: bool = False # Whether the model is a favorite
exclude: bool = False # Whether to exclude this model from the cache
+ db_checked: bool = False # Whether checked in archive DB
+ last_checked_at: float = 0 # Last checked timestamp
_unknown_fields: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False) # Store unknown fields
def __post_init__(self):
diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py
index e6bd4ac7..d6635118 100644
--- a/py/utils/routes_common.py
+++ b/py/utils/routes_common.py
@@ -3,6 +3,7 @@ import json
import logging
from typing import Dict, List, Callable, Awaitable
from aiohttp import web
+from datetime import datetime
from .model_utils import determine_base_model
from .constants import PREVIEW_EXTENSIONS, CARD_PREVIEW_WIDTH
@@ -82,7 +83,6 @@ class ModelRouteUtils:
# Update local metadata with merged civitai data
local_metadata['civitai'] = merged_civitai
- local_metadata['from_civitai'] = True
# Update model-related metadata from civitai_metadata.model
if 'model' in civitai_metadata and civitai_metadata['model']:
@@ -203,12 +203,11 @@ class ModelRouteUtils:
return False
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
-
- # Check if model metadata exists
- local_metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
+ enable_metadata_archive_db = settings.get('enable_metadata_archive_db', False)
- if model_data.get('from_civitai') is False:
- if not settings.get('enable_metadata_archive_db', False):
+ if model_data.get('civitai_deleted') is True and model_data.get('db_checked') is False:
+ # If CivitAI deleted flag is set, skip CivitAI API provider
+ if not enable_metadata_archive_db:
return False
# Likely deleted from CivitAI, use archive_db if available
metadata_provider = await get_metadata_provider('sqlite')
@@ -217,11 +216,24 @@ class ModelRouteUtils:
civitai_metadata, error = await metadata_provider.get_model_by_hash(sha256)
if not civitai_metadata:
- # Mark as not from CivitAI if not found
- local_metadata['from_civitai'] = False
- model_data['from_civitai'] = False
- await MetadataManager.save_metadata(file_path, local_metadata)
+ if error == "Model not found":
+ model_data['from_civitai'] = False
+ model_data['civitai_deleted'] = True
+ model_data['db_checked'] = enable_metadata_archive_db
+ model_data['last_checked_at'] = datetime.now().timestamp()
+
+ # Remove 'folder' key from model_data if present before saving
+ data_to_save = model_data.copy()
+ data_to_save.pop('folder', None)
+ await MetadataManager.save_metadata(file_path, data_to_save)
return False
+
+ model_data['from_civitai'] = True
+ model_data['civitai_deleted'] = civitai_metadata.get('source') == 'archive_db'
+ model_data['db_checked'] = enable_metadata_archive_db
+
+ local_metadata = model_data.copy()
+ local_metadata.pop('folder', None) # Remove 'folder' key if present
# Update metadata
await ModelRouteUtils.update_model_metadata(
@@ -235,8 +247,7 @@ class ModelRouteUtils:
update_dict = {
'model_name': local_metadata.get('model_name'),
'preview_url': local_metadata.get('preview_url'),
- 'from_civitai': True,
- 'civitai': civitai_metadata
+ 'civitai': local_metadata.get('civitai'),
}
model_data.update(update_dict)
diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js
index 2830b166..257753fd 100644
--- a/static/js/components/shared/showcase/ShowcaseView.js
+++ b/static/js/components/shared/showcase/ShowcaseView.js
@@ -33,20 +33,6 @@ export async function loadExampleImages(images, modelHash) {
const params = `model_hash=${modelHash}`;
const response = await fetch(`${endpoint}?${params}`);
- if (!response.ok) {
- // Try to parse error message from backend
- let errorMsg = `HTTP error ${response.status}`;
- try {
- const errorData = await response.json();
- if (errorData && errorData.error) {
- errorMsg = errorData.error;
- }
- } catch (e) {
- // Ignore JSON parse error
- }
- console.warn("Failed to get example files:", errorMsg);
- return;
- }
const result = await response.json();
if (result.success) {
From ab7266f3a46e6f9fe55da7d0b088acdadb177198 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Sat, 20 Sep 2025 08:12:14 +0800
Subject: [PATCH 042/110] fix(download_manager): streamline output directory
retrieval by using settings directly, fixes #443
---
py/utils/example_images_download_manager.py | 11 ++++-------
1 file changed, 4 insertions(+), 7 deletions(-)
diff --git a/py/utils/example_images_download_manager.py b/py/utils/example_images_download_manager.py
index 58df8d72..842192f2 100644
--- a/py/utils/example_images_download_manager.py
+++ b/py/utils/example_images_download_manager.py
@@ -10,6 +10,7 @@ from .example_images_processor import ExampleImagesProcessor
from .example_images_metadata import MetadataUpdater
from ..services.websocket_manager import ws_manager # Add this import at the top
from ..services.downloader import get_downloader
+from ..services.settings_manager import settings
logger = logging.getLogger(__name__)
@@ -70,10 +71,8 @@ class DownloadManager:
delay = float(data.get('delay', 0.2)) # Default to 0.2 seconds
# Get output directory from settings
- from ..services.service_registry import ServiceRegistry
- settings_manager = await ServiceRegistry.get_settings_manager()
- output_dir = settings_manager.get('example_images_path')
-
+ output_dir = settings.get('example_images_path')
+
if not output_dir:
error_msg = 'Example images path not configured in settings'
if auto_mode:
@@ -468,9 +467,7 @@ class DownloadManager:
}, status=400)
# Get output directory from settings
- from ..services.service_registry import ServiceRegistry
- settings_manager = await ServiceRegistry.get_settings_manager()
- output_dir = settings_manager.get('example_images_path')
+ output_dir = settings.get('example_images_path')
if not output_dir:
return web.json_response({
From a8d7070832d9eb5496aaeeef4c5ee949c9a7867e Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Sat, 20 Sep 2025 21:35:34 +0800
Subject: [PATCH 043/110] feat(civitai): enhance metadata fetching with error
handling and cache validation
---
py/routes/base_model_routes.py | 58 +++++++++++++++++++++--------
py/utils/example_images_metadata.py | 4 +-
py/utils/routes_common.py | 34 ++++++++++-------
3 files changed, 65 insertions(+), 31 deletions(-)
diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py
index db914960..6c3f11b4 100644
--- a/py/routes/base_model_routes.py
+++ b/py/routes/base_model_routes.py
@@ -255,20 +255,45 @@ class BaseModelRoutes(ABC):
return await ModelRouteUtils.handle_exclude_model(request, self.service.scanner)
async def fetch_civitai(self, request: web.Request) -> web.Response:
- """Handle CivitAI metadata fetch request"""
- response = await ModelRouteUtils.handle_fetch_civitai(request, self.service.scanner)
-
- # If successful, format the metadata before returning
- if response.status == 200:
- data = json.loads(response.body.decode('utf-8'))
- if data.get("success") and data.get("metadata"):
- formatted_metadata = await self.service.format_response(data["metadata"])
- return web.json_response({
- "success": True,
- "metadata": formatted_metadata
- })
-
- return response
+ """Handle CivitAI metadata fetch request - force refresh model metadata"""
+ try:
+ data = await request.json()
+ file_path = data.get('file_path')
+ if not file_path:
+ return web.json_response({"success": False, "error": "File path is required"}, status=400)
+
+ # Get model data from cache
+ cache = await self.service.scanner.get_cached_data()
+ model_data = next((item for item in cache.raw_data if item['file_path'] == file_path), None)
+
+ if not model_data:
+ return web.json_response({"success": False, "error": "Model not found in cache"}, status=404)
+
+ # Check if model has SHA256 hash
+ if not model_data.get('sha256'):
+ return web.json_response({"success": False, "error": "No SHA256 hash found"}, status=400)
+
+ # Use fetch_and_update_model to get and update metadata
+ success, error = await ModelRouteUtils.fetch_and_update_model(
+ sha256=model_data['sha256'],
+ file_path=file_path,
+ model_data=model_data,
+ update_cache_func=self.service.scanner.update_single_model_cache
+ )
+
+ if not success:
+ return web.json_response({"success": False, "error": error})
+
+ # Format the updated metadata for response
+ formatted_metadata = await self.service.format_response(model_data)
+ return web.json_response({
+ "success": True,
+ "metadata": formatted_metadata
+ })
+
+ except Exception as e:
+ logger.error(f"Error fetching from CivitAI: {e}", exc_info=True)
+ return web.json_response({"success": False, "error": str(e)}, status=500)
async def relink_civitai(self, request: web.Request) -> web.Response:
"""Handle CivitAI metadata re-linking request"""
@@ -652,12 +677,13 @@ class BaseModelRoutes(ABC):
for model in to_process:
try:
original_name = model.get('model_name')
- if await ModelRouteUtils.fetch_and_update_model(
+ result, error = await ModelRouteUtils.fetch_and_update_model(
sha256=model['sha256'],
file_path=model['file_path'],
model_data=model,
update_cache_func=self.service.scanner.update_single_model_cache
- ):
+ )
+ if result:
success += 1
if original_name != model.get('model_name'):
needs_resort = True
diff --git a/py/utils/example_images_metadata.py b/py/utils/example_images_metadata.py
index 496d5ad0..71566bff 100644
--- a/py/utils/example_images_metadata.py
+++ b/py/utils/example_images_metadata.py
@@ -53,7 +53,7 @@ class MetadataUpdater:
async def update_cache_func(old_path, new_path, metadata):
return await scanner.update_single_model_cache(old_path, new_path, metadata)
- success = await ModelRouteUtils.fetch_and_update_model(
+ success, error = await ModelRouteUtils.fetch_and_update_model(
model_hash,
file_path,
model_data,
@@ -64,7 +64,7 @@ class MetadataUpdater:
logger.info(f"Successfully refreshed metadata for {model_name}")
return True
else:
- logger.warning(f"Failed to refresh metadata for {model_name}")
+ logger.warning(f"Failed to refresh metadata for {model_name}, {error}")
return False
except Exception as e:
diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py
index d6635118..b5f6af30 100644
--- a/py/utils/routes_common.py
+++ b/py/utils/routes_common.py
@@ -184,7 +184,7 @@ class ModelRouteUtils:
file_path: str,
model_data: dict,
update_cache_func: Callable[[str, str, Dict], Awaitable[bool]]
- ) -> bool:
+ ) -> tuple[bool, str]:
"""Fetch and update metadata for a single model
Args:
@@ -194,21 +194,22 @@ class ModelRouteUtils:
update_cache_func: Function to update the cache with new metadata
Returns:
- bool: True if successful, False otherwise
+ tuple[bool, str]: (success, error_message). When success is True, error_message is None.
"""
try:
# Validate input parameters
if not isinstance(model_data, dict):
- logger.error(f"Invalid model_data type: {type(model_data)}")
- return False
+ error_msg = f"Invalid model_data type: {type(model_data)}"
+ logger.error(error_msg)
+ return False, error_msg
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
enable_metadata_archive_db = settings.get('enable_metadata_archive_db', False)
- if model_data.get('civitai_deleted') is True and model_data.get('db_checked') is False:
+ if model_data.get('civitai_deleted') is True:
# If CivitAI deleted flag is set, skip CivitAI API provider
- if not enable_metadata_archive_db:
- return False
+ if not enable_metadata_archive_db or model_data.get('db_checked') is True:
+ return False, "CivitAI model is deleted and metadata archive DB is not enabled"
# Likely deleted from CivitAI, use archive_db if available
metadata_provider = await get_metadata_provider('sqlite')
else:
@@ -226,11 +227,16 @@ class ModelRouteUtils:
data_to_save = model_data.copy()
data_to_save.pop('folder', None)
await MetadataManager.save_metadata(file_path, data_to_save)
- return False
+
+ # For other errors, log and return False with error message
+ error_msg = f"Error fetching metadata: {error} (model_name={model_data.get('model_name', '')})"
+ logger.error(error_msg)
+ return False, error_msg
model_data['from_civitai'] = True
model_data['civitai_deleted'] = civitai_metadata.get('source') == 'archive_db'
model_data['db_checked'] = enable_metadata_archive_db
+ model_data['last_checked_at'] = datetime.now().timestamp()
local_metadata = model_data.copy()
local_metadata.pop('folder', None) # Remove 'folder' key if present
@@ -254,14 +260,16 @@ class ModelRouteUtils:
# Update cache using the provided function
await update_cache_func(file_path, file_path, local_metadata)
- return True
+ return True, None
except KeyError as e:
- logger.error(f"Error fetching CivitAI data - Missing key: {e} in model_data={model_data}")
- return False
+ error_msg = f"Error fetching metadata - Missing key: {e} in model_data={model_data}"
+ logger.error(error_msg)
+ return False, error_msg
except Exception as e:
- logger.error(f"Error fetching CivitAI data: {str(e)}", exc_info=True) # Include stack trace
- return False
+ error_msg = f"Error fetching metadata: {str(e)}"
+ logger.error(error_msg, exc_info=True) # Include stack trace
+ return False, error_msg
@staticmethod
def filter_civitai_data(data: Dict, minimal: bool = False) -> Dict:
From 40cbb2155c7e0d458e89fcbd802584ddd8daccab Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Sat, 20 Sep 2025 21:43:00 +0800
Subject: [PATCH 044/110] refactor(baseModelApi): comment out failure message
handling in bulk metadata refresh
---
static/js/api/baseModelApi.js | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js
index 0e77823c..df80d764 100644
--- a/static/js/api/baseModelApi.js
+++ b/static/js/api/baseModelApi.js
@@ -538,13 +538,13 @@ export class BaseModelApiClient {
completionMessage = translate('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`);
showToast('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, 'warning');
- if (failedItems.length > 0) {
- const failureMessage = failedItems.length <= 3
- ? failedItems.map(item => `${item.fileName}: ${item.error}`).join('\n')
- : failedItems.slice(0, 3).map(item => `${item.fileName}: ${item.error}`).join('\n') +
- `\n(and ${failedItems.length - 3} more)`;
- showToast('toast.api.bulkMetadataFailureDetails', { failures: failureMessage }, 'warning', 6000);
- }
+ // if (failedItems.length > 0) {
+ // const failureMessage = failedItems.length <= 3
+ // ? failedItems.map(item => `${item.fileName}: ${item.error}`).join('\n')
+ // : failedItems.slice(0, 3).map(item => `${item.fileName}: ${item.error}`).join('\n') +
+ // `\n(and ${failedItems.length - 3} more)`;
+ // showToast('toast.api.bulkMetadataFailureDetails', { failures: failureMessage }, 'warning', 6000);
+ // }
} else {
completionMessage = translate('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, `Failed to refresh metadata for any ${this.apiConfig.config.displayName}s`);
showToast('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, 'error');
From 9e1a2e3bb73a7f299f2e3e254fe8087c789e88af Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Sat, 20 Sep 2025 22:03:29 +0800
Subject: [PATCH 045/110] chore(pyproject): bump version to 0.9.4
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 1ad4e018..801a705e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
-version = "0.9.3"
+version = "0.9.4"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
From 6261f7d18d6883a811fe50ca2332f24b2de201e2 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Sat, 20 Sep 2025 23:21:10 +0800
Subject: [PATCH 046/110] Update LM extension wiki
---
docs/LM-Extension-Wiki.md | 6 +++++-
wiki-images/civarchive-models-page.png | Bin 0 -> 2539770 bytes
2 files changed, 5 insertions(+), 1 deletion(-)
create mode 100644 wiki-images/civarchive-models-page.png
diff --git a/docs/LM-Extension-Wiki.md b/docs/LM-Extension-Wiki.md
index 272d512b..ddaeac4e 100644
--- a/docs/LM-Extension-Wiki.md
+++ b/docs/LM-Extension-Wiki.md
@@ -1,6 +1,9 @@
## Overview
-The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com). With this extension, you can:
+The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com).
+It also supports browsing on [CivArchive](https://civarchive.com/) (formerly CivitaiArchive).
+
+With this extension, you can:
✅ Instantly see which models are already present in your local library
✅ Download new models with a single click
@@ -8,6 +11,7 @@ The **LoRA Manager Civitai Extension** is a Browser extension designed to work s
✅ Keep your downloaded models automatically organized according to your custom settings

+
---
diff --git a/wiki-images/civarchive-models-page.png b/wiki-images/civarchive-models-page.png
new file mode 100644
index 0000000000000000000000000000000000000000..780add13c97ca04c40210d0cacbfbd9edcef53f8
GIT binary patch
literal 2539770
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