From 2222731f36e0c1b615bb447cd18b5f53e6bd0ce2 Mon Sep 17 00:00:00 2001
From: Will Miao <13051207myq@gmail.com>
Date: Mon, 10 Feb 2025 23:40:38 +0800
Subject: [PATCH] checkpoint
---
lora_manager.py | 1 +
routes/api_routes.py | 72 ++-
services/civitai_client.py | 157 ++++-
static/css/style.css | 64 +-
static/js/main.js | 4 +-
static/js/managers/DownloadManager.js | 185 ++++++
static/js/managers/ModalManager.js | 19 +-
static/js/script.js | 892 --------------------------
templates/components/controls.html | 5 +
templates/components/modals.html | 57 +-
10 files changed, 546 insertions(+), 910 deletions(-)
create mode 100644 static/js/managers/DownloadManager.js
delete mode 100644 static/js/script.js
diff --git a/lora_manager.py b/lora_manager.py
index d1b0d2ce..63ebd761 100644
--- a/lora_manager.py
+++ b/lora_manager.py
@@ -41,6 +41,7 @@ class LoraManager:
# Add cleanup
app.on_shutdown.append(cls._cleanup)
+ app.on_shutdown.append(ApiRoutes.cleanup)
@classmethod
async def _schedule_cache_init(cls, scanner: LoraScanner):
diff --git a/routes/api_routes.py b/routes/api_routes.py
index f8f92bbf..8eb3c35f 100644
--- a/routes/api_routes.py
+++ b/routes/api_routes.py
@@ -16,6 +16,7 @@ class ApiRoutes:
def __init__(self):
self.scanner = LoraScanner()
+ self.civitai_client = CivitaiClient()
@classmethod
def setup_routes(cls, app: web.Application):
@@ -27,6 +28,9 @@ class ApiRoutes:
app.router.add_get('/api/loras', routes.get_loras)
app.router.add_post('/api/fetch-all-civitai', routes.fetch_all_civitai)
app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection)
+ app.router.add_get('/api/lora-roots', routes.get_lora_roots)
+ app.router.add_get('/api/civitai/versions/{model_id}', routes.get_civitai_versions)
+ app.router.add_post('/api/download-lora', routes.download_lora)
async def delete_model(self, request: web.Request) -> web.Response:
"""Handle model deletion request"""
@@ -52,7 +56,6 @@ class ApiRoutes:
async def fetch_civitai(self, request: web.Request) -> web.Response:
"""Handle CivitAI metadata fetch request"""
- client = CivitaiClient()
try:
data = await request.json()
metadata_path = os.path.splitext(data['file_path'])[0] + '.metadata.json'
@@ -63,19 +66,17 @@ class ApiRoutes:
return web.json_response({"success": True, "notice": "Not from CivitAI"})
# Fetch and update metadata
- civitai_metadata = await client.get_model_by_hash(data["sha256"])
+ civitai_metadata = await self.civitai_client.get_model_by_hash(data["sha256"])
if not civitai_metadata:
return await self._handle_not_found_on_civitai(metadata_path, local_metadata)
- await self._update_model_metadata(metadata_path, local_metadata, civitai_metadata, client)
+ await self._update_model_metadata(metadata_path, local_metadata, civitai_metadata, self.civitai_client)
return web.json_response({"success": True})
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)
- finally:
- await client.close()
async def replace_preview(self, request: web.Request) -> web.Response:
"""Handle preview image replacement request"""
@@ -444,3 +445,64 @@ class ApiRoutes:
return False
finally:
await client.close()
+
+ async def get_lora_roots(self, request: web.Request) -> web.Response:
+ """Get all configured LoRA root directories"""
+ return web.json_response({
+ 'roots': config.loras_roots
+ })
+
+ async def get_civitai_versions(self, request: web.Request) -> web.Response:
+ """Get available versions for a Civitai model"""
+ try:
+ model_id = request.match_info['model_id']
+ versions = await self.civitai_client.get_model_versions(model_id)
+ if not versions:
+ return web.Response(status=404, text="Model not found")
+ return web.json_response(versions)
+ except Exception as e:
+ logger.error(f"Error fetching model versions: {e}")
+ return web.Response(status=500, text=str(e))
+
+ async def download_lora(self, request: web.Request) -> web.Response:
+ """Handle LoRA download request"""
+ try:
+ data = await request.json()
+ download_url = data.get('download_url')
+ version_info = data.get('version_info')
+ lora_root = data.get('lora_root')
+ new_folder = data.get('new_folder', '').strip()
+
+ if not download_url or not version_info or not lora_root:
+ return web.Response(status=400, text="Missing required parameters")
+
+ if not os.path.isdir(lora_root):
+ return web.Response(status=400, text="Invalid LoRA root directory")
+
+ # 构建保存路径
+ save_dir = os.path.join(lora_root, new_folder) if new_folder else lora_root
+ os.makedirs(save_dir, exist_ok=True)
+
+ # 使用提供的下载 URL 和版本信息
+ result = await self.civitai_client.download_model_with_info(
+ download_url=download_url,
+ version_info=version_info,
+ save_dir=save_dir
+ )
+
+ if result.get('success'):
+ # 更新缓存
+ await self.scanner.rescan_directory(save_dir)
+ return web.json_response(result)
+ else:
+ return web.Response(status=500, text=result.get('error', 'Download failed'))
+
+ except Exception as e:
+ logger.error(f"Error downloading LoRA: {e}")
+ return web.Response(status=500, text=str(e))
+
+ @classmethod
+ async def cleanup(cls):
+ """Add cleanup method for application shutdown"""
+ if hasattr(cls, '_instance'):
+ await cls._instance.civitai_client.close()
diff --git a/services/civitai_client.py b/services/civitai_client.py
index b34c0149..9ebc6de1 100644
--- a/services/civitai_client.py
+++ b/services/civitai_client.py
@@ -1,26 +1,41 @@
import aiohttp
import os
import json
+import logging
from typing import Optional, Dict
+logger = logging.getLogger(__name__)
+
class CivitaiClient:
def __init__(self):
self.base_url = "https://civitai.com/api/v1"
- self.session = aiohttp.ClientSession()
-
+ self.headers = {
+ 'User-Agent': 'ComfyUI-LoRA-Manager/1.0'
+ }
+ self._session = None
+
+ @property
+ async def session(self) -> aiohttp.ClientSession:
+ """Lazy initialize the session"""
+ if self._session is None:
+ self._session = aiohttp.ClientSession()
+ return self._session
+
async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]:
try:
- async with self.session.get(f"{self.base_url}/model-versions/by-hash/{model_hash}") as response:
+ session = await self.session
+ async with session.get(f"{self.base_url}/model-versions/by-hash/{model_hash}") as response:
if response.status == 200:
return await response.json()
return None
except Exception as e:
- print(f"API Error: {str(e)}")
+ logger.error(f"API Error: {str(e)}")
return None
async def download_preview_image(self, image_url: str, save_path: str):
try:
- async with self.session.get(image_url) as response:
+ session = await self.session
+ async with session.get(image_url) as response:
if response.status == 200:
content = await response.read()
with open(save_path, 'wb') as f:
@@ -31,5 +46,135 @@ class CivitaiClient:
print(f"Download Error: {str(e)}")
return False
+ async def get_model_versions(self, model_id: str) -> Optional[Dict]:
+ """Fetch all versions of a model"""
+ try:
+ session = await self.session
+ url = f"{self.base_url}/models/{model_id}"
+ async with session.get(url, headers=self.headers) as response:
+ if response.status == 200:
+ data = await response.json()
+ return data.get('modelVersions', [])
+ return None
+ except Exception as e:
+ logger.error(f"Error fetching model versions: {e}")
+ return None
+
+ async def download_model_version(self, version_id: str, save_dir: str) -> Dict:
+ """Download a specific model version"""
+ try:
+ session = await self.session
+ # First get version info
+ url = f"{self.base_url}/model-versions/{version_id}"
+ async with session.get(url, headers=self.headers) as response:
+ if response.status != 200:
+ return {'success': False, 'error': 'Version not found'}
+
+ version_data = await response.json()
+ download_url = version_data.get('downloadUrl')
+ if not download_url:
+ return {'success': False, 'error': 'No download URL found'}
+
+ # Download the file
+ file_name = version_data.get('files', [{}])[0].get('name', f'lora_{version_id}.safetensors')
+ save_path = os.path.join(save_dir, file_name)
+
+ async with session.get(download_url, headers=self.headers) as response:
+ if response.status != 200:
+ return {'success': False, 'error': 'Download failed'}
+
+ with open(save_path, 'wb') as f:
+ while True:
+ chunk = await response.content.read(8192)
+ if not chunk:
+ break
+ f.write(chunk)
+
+ # Create metadata file
+ metadata_path = os.path.splitext(save_path)[0] + '.metadata.json'
+ metadata = {
+ 'model_name': version_data.get('model', {}).get('name', file_name),
+ 'civitai': version_data,
+ 'preview_url': None,
+ 'from_civitai': True
+ }
+
+ # Download preview image if available
+ images = version_data.get('images', [])
+ if images:
+ preview_ext = '.mp4' if images[0].get('type') == 'video' else '.png'
+ preview_path = os.path.splitext(save_path)[0] + '.preview' + preview_ext
+ await self.download_preview_image(images[0]['url'], preview_path)
+ metadata['preview_url'] = preview_path
+
+ # Save metadata
+ with open(metadata_path, 'w', encoding='utf-8') as f:
+ json.dump(metadata, f, indent=2, ensure_ascii=False)
+
+ return {
+ 'success': True,
+ 'file_path': save_path,
+ 'metadata': metadata
+ }
+
+ except Exception as e:
+ logger.error(f"Error downloading model version: {e}")
+ return {'success': False, 'error': str(e)}
+
+ async def download_model_with_info(self, download_url: str, version_info: dict, save_dir: str) -> Dict:
+ """Download model using provided version info and URL"""
+ try:
+ session = await self.session
+
+ # Use provided filename or generate one
+ file_name = version_info.get('files', [{}])[0].get('name', f'lora_{version_info["id"]}.safetensors')
+ save_path = os.path.join(save_dir, file_name)
+
+ # Download the file
+ async with session.get(download_url, headers=self.headers) as response:
+ if response.status != 200:
+ return {'success': False, 'error': 'Download failed'}
+
+ with open(save_path, 'wb') as f:
+ while True:
+ chunk = await response.content.read(8192)
+ if not chunk:
+ break
+ f.write(chunk)
+
+ # Create metadata file
+ metadata_path = os.path.splitext(save_path)[0] + '.metadata.json'
+ metadata = {
+ 'model_name': version_info.get('model', {}).get('name', file_name),
+ 'civitai': version_info,
+ 'preview_url': None,
+ 'from_civitai': True
+ }
+
+ # Download preview image if available
+ images = version_info.get('images', [])
+ if images:
+ preview_ext = '.mp4' if images[0].get('type') == 'video' else '.png'
+ preview_path = os.path.splitext(save_path)[0] + '.preview' + preview_ext
+ await self.download_preview_image(images[0]['url'], preview_path)
+ metadata['preview_url'] = preview_path
+
+ # Save metadata
+ with open(metadata_path, 'w', encoding='utf-8') as f:
+ json.dump(metadata, f, indent=2, ensure_ascii=False)
+
+ return {
+ 'success': True,
+ 'file_path': save_path,
+ 'metadata': metadata
+ }
+
+ except Exception as e:
+ logger.error(f"Error downloading model version: {e}")
+ return {'success': False, 'error': str(e)}
+
async def close(self):
- await self.session.close()
\ No newline at end of file
+ """Close the session if it exists"""
+ if self._session is not None:
+ await self._session.close()
+ self._session = None
\ No newline at end of file
diff --git a/static/css/style.css b/static/css/style.css
index 82b5b40e..9ac242af 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -1063,7 +1063,7 @@ body.modal-open {
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
- color: var(--text-color);
+ color: var (--text-color);
display: flex;
align-items: center;
justify-content: center;
@@ -1092,4 +1092,66 @@ body.modal-open {
.back-to-top {
bottom: 60px; /* Give some extra space from bottom on mobile */
}
+}
+
+/* Download Modal Styles */
+.download-step {
+ margin: var(--space-2) 0;
+}
+
+.input-group {
+ margin-bottom: var(--space-2);
+}
+
+.input-group label {
+ display: block;
+ margin-bottom: 8px;
+ color: var(--text-color);
+}
+
+.input-group input,
+.input-group select {
+ width: 100%;
+ padding: 8px;
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius-xs);
+ background: var(--bg-color);
+ color: var(--text-color);
+}
+
+.error-message {
+ color: var(--lora-error);
+ font-size: 0.9em;
+ margin-top: 4px;
+}
+
+.version-list {
+ max-height: 300px;
+ overflow-y: auto;
+ margin: var(--space-2) 0;
+}
+
+.version-item {
+ padding: var(--space-2);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius-sm);
+ margin-bottom: 8px;
+ cursor: pointer;
+}
+
+.version-item:hover {
+ background: var(--lora-surface);
+}
+
+.version-item.selected {
+ border-color: var(--lora-accent);
+ background: oklch(var(--lora-accent) / 0.1);
+}
+
+.folder-browser {
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius-xs);
+ padding: var(--space-1);
+ max-height: 200px;
+ overflow-y: auto;
}
\ No newline at end of file
diff --git a/static/js/main.js b/static/js/main.js
index c213a0ca..81f244c1 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -20,6 +20,7 @@ import {
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { showDeleteModal, confirmDelete, closeDeleteModal } from './utils/modalUtils.js';
import { SearchManager } from './utils/search.js';
+import { DownloadManager } from './managers/DownloadManager.js';
// Export all functions that need global access
window.loadMoreLoras = loadMoreLoras;
@@ -43,6 +44,7 @@ window.toggleFolderTags = toggleFolderTags;
document.addEventListener('DOMContentLoaded', () => {
state.loadingManager = new LoadingManager();
modalManager.initialize(); // Initialize modalManager after DOM is loaded
+ window.downloadManager = new DownloadManager(); // Move this after modalManager initialization
initializeInfiniteScroll();
initializeEventListeners();
lazyLoadImages();
@@ -67,4 +69,4 @@ function initializeEventListeners() {
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
tag.addEventListener('click', toggleFolder);
});
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js
new file mode 100644
index 00000000..32edca79
--- /dev/null
+++ b/static/js/managers/DownloadManager.js
@@ -0,0 +1,185 @@
+import { modalManager } from './ModalManager.js';
+import { showToast } from '../utils/uiHelpers.js';
+
+export class DownloadManager {
+ constructor() {
+ this.currentVersion = null;
+ this.versions = [];
+ this.modelInfo = null;
+ this.modelVersionId = null; // Add new property for initial version ID
+
+ // Add initialization check
+ this.initialized = false;
+ }
+
+ showDownloadModal() {
+ console.log('Showing download modal...'); // Add debug log
+ if (!this.initialized) {
+ // Check if modal exists
+ const modal = document.getElementById('downloadModal');
+ if (!modal) {
+ console.error('Download modal element not found');
+ return;
+ }
+ this.initialized = true;
+ }
+
+ modalManager.showModal('downloadModal');
+ this.resetSteps();
+ }
+
+ resetSteps() {
+ document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
+ document.getElementById('urlStep').style.display = 'block';
+ document.getElementById('loraUrl').value = '';
+ document.getElementById('urlError').textContent = '';
+ this.currentVersion = null;
+ this.versions = [];
+ this.modelInfo = null;
+ this.modelVersionId = null;
+ }
+
+ async validateAndFetchVersions() {
+ const url = document.getElementById('loraUrl').value.trim();
+ const errorElement = document.getElementById('urlError');
+
+ try {
+ const modelId = this.extractModelId(url);
+ if (!modelId) {
+ throw new Error('Invalid Civitai URL format');
+ }
+
+ const response = await fetch(`/api/civitai/versions/${modelId}`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch model versions');
+ }
+
+ this.versions = await response.json();
+ if (!this.versions.length) {
+ throw new Error('No versions available for this model');
+ }
+
+ // If we have a version ID from URL, pre-select it
+ if (this.modelVersionId) {
+ this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
+ }
+
+ this.showVersionStep();
+ } catch (error) {
+ errorElement.textContent = error.message;
+ }
+ }
+
+ extractModelId(url) {
+ const modelMatch = url.match(/civitai\.com\/models\/(\d+)/);
+ const versionMatch = url.match(/modelVersionId=(\d+)/);
+
+ if (modelMatch) {
+ this.modelVersionId = versionMatch ? versionMatch[1] : null;
+ return modelMatch[1];
+ }
+ return null;
+ }
+
+ showVersionStep() {
+ document.getElementById('urlStep').style.display = 'none';
+ document.getElementById('versionStep').style.display = 'block';
+
+ const versionList = document.getElementById('versionList');
+ versionList.innerHTML = this.versions.map(version => `
+
+
${version.name}
+
+ ${version.baseModel ? `
${version.baseModel}
` : ''}
+
${new Date(version.createdAt).toLocaleDateString()}
+
+
+ `).join('');
+ }
+
+ selectVersion(versionId) {
+ this.currentVersion = this.versions.find(v => v.id.toString() === versionId.toString());
+ if (!this.currentVersion) return;
+
+ document.querySelectorAll('.version-item').forEach(item => {
+ item.classList.toggle('selected', item.querySelector('h3').textContent === this.currentVersion.name);
+ });
+ }
+
+ async proceedToLocation() {
+ if (!this.currentVersion) {
+ showToast('Please select a version', 'error');
+ return;
+ }
+
+ document.getElementById('versionStep').style.display = 'none';
+ document.getElementById('locationStep').style.display = 'block';
+
+ try {
+ const response = await fetch('/api/lora-roots');
+ if (!response.ok) {
+ throw new Error('Failed to fetch LoRA roots');
+ }
+
+ const data = await response.json();
+ const loraRoot = document.getElementById('loraRoot');
+ loraRoot.innerHTML = data.roots.map(root =>
+ ``
+ ).join('');
+ } catch (error) {
+ showToast(error.message, 'error');
+ }
+ }
+
+ backToUrl() {
+ document.getElementById('versionStep').style.display = 'none';
+ document.getElementById('urlStep').style.display = 'block';
+ }
+
+ backToVersions() {
+ document.getElementById('locationStep').style.display = 'none';
+ document.getElementById('versionStep').style.display = 'block';
+ }
+
+ async startDownload() {
+ const loraRoot = document.getElementById('loraRoot').value;
+ const newFolder = document.getElementById('newFolder').value.trim();
+
+ if (!loraRoot) {
+ showToast('Please select a LoRA root directory', 'error');
+ return;
+ }
+
+ try {
+ const downloadUrl = this.currentVersion.downloadUrl;
+ if (!downloadUrl) {
+ throw new Error('No download URL available');
+ }
+
+ const response = await fetch('/api/download-lora', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ download_url: downloadUrl,
+ version_info: this.currentVersion,
+ lora_root: loraRoot,
+ new_folder: newFolder
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error(await response.text());
+ }
+
+ const result = await response.json();
+ showToast('Download completed successfully', 'success');
+ modalManager.closeModal('downloadModal');
+
+ // Refresh the grid to show new model
+ window.refreshLoras();
+ } catch (error) {
+ showToast(error.message, 'error');
+ }
+ }
+}
diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js
index 58c0d5ae..4b534dcb 100644
--- a/static/js/managers/ModalManager.js
+++ b/static/js/managers/ModalManager.js
@@ -25,6 +25,15 @@ export class ModalManager {
}
});
+ // Add downloadModal registration
+ this.registerModal('downloadModal', {
+ element: document.getElementById('downloadModal'),
+ onClose: () => {
+ this.getModal('downloadModal').element.style.display = 'none';
+ document.body.classList.remove('modal-open');
+ }
+ });
+
document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true;
}
@@ -56,10 +65,12 @@ export class ModalManager {
modal.element.innerHTML = content;
}
- if (id === 'loraModal') {
- modal.element.style.display = 'block';
- } else if (id === 'deleteModal') {
+ // Update to handle different modal types
+ if (id === 'deleteModal') {
modal.element.classList.add('show');
+ } else {
+ // For loraModal and downloadModal
+ modal.element.style.display = 'block';
}
modal.isOpen = true;
@@ -88,4 +99,4 @@ export class ModalManager {
}
// Create and export a singleton instance
-export const modalManager = new ModalManager();
\ No newline at end of file
+export const modalManager = new ModalManager();
\ No newline at end of file
diff --git a/static/js/script.js b/static/js/script.js
deleted file mode 100644
index ec6b6b51..00000000
--- a/static/js/script.js
+++ /dev/null
@@ -1,892 +0,0 @@
-// Debounce function
-function debounce(func, wait) {
- let timeout;
- return function(...args) {
- clearTimeout(timeout);
- timeout = setTimeout(() => func.apply(this, args), wait);
- };
-}
-
-// Sorting functionality
-// function sortCards(sortBy) { ... }
-
-// Loading management
-class LoadingManager {
- constructor() {
- this.overlay = document.getElementById('loading-overlay');
- this.progressBar = this.overlay.querySelector('.progress-bar');
- this.statusText = this.overlay.querySelector('.loading-status');
- }
-
- show(message = 'Loading...', progress = 0) {
- this.overlay.style.display = 'flex';
- this.setProgress(progress);
- this.setStatus(message);
- }
-
- hide() {
- this.overlay.style.display = 'none';
- this.reset();
- }
-
- setProgress(percent) {
- this.progressBar.style.width = `${percent}%`;
- this.progressBar.setAttribute('aria-valuenow', percent);
- }
-
- setStatus(message) {
- this.statusText.textContent = message;
- }
-
- reset() {
- this.setProgress(0);
- this.setStatus('');
- }
-
- async showWithProgress(callback, options = {}) {
- const { initialMessage = 'Processing...', completionMessage = 'Complete' } = options;
-
- try {
- this.show(initialMessage);
- await callback(this);
- this.setProgress(100);
- this.setStatus(completionMessage);
- await new Promise(resolve => setTimeout(resolve, 500));
- } finally {
- this.hide();
- }
- }
-
- showSimpleLoading(message = 'Loading...') {
- this.overlay.style.display = 'flex';
- this.progressBar.style.display = 'none';
- this.setStatus(message);
- }
-
- restoreProgressBar() {
- this.progressBar.style.display = 'block';
- }
-}
-
-const loadingManager = new LoadingManager();
-
-// Media preview handling
-function createVideoPreview(url) {
- const video = document.createElement('video');
- video.controls = video.autoplay = video.muted = video.loop = true;
- video.src = url;
- return video;
-}
-
-function createImagePreview(url) {
- const img = document.createElement('img');
- img.src = url;
- return img;
-}
-
-function updatePreviewInCard(filePath, file, previewUrl) {
- const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
- const previewContainer = card?.querySelector('.card-preview');
- const oldPreview = previewContainer?.querySelector('img, video');
-
- if (oldPreview) {
- const newPreviewUrl = `${previewUrl}?t=${Date.now()}`;
- const newPreview = file.type.startsWith('video/')
- ? createVideoPreview(newPreviewUrl)
- : createImagePreview(newPreviewUrl);
- oldPreview.replaceWith(newPreview);
- }
-}
-
-// Modal management
-class ModalManager {
- constructor() {
- this.modals = new Map();
- this.boundHandleEscape = this.handleEscape.bind(this);
-
- // 注册所有模态窗口
- this.registerModal('loraModal', {
- element: document.getElementById('loraModal'),
- onClose: () => {
- this.getModal('loraModal').element.style.display = 'none';
- document.body.classList.remove('modal-open');
- }
- });
-
- this.registerModal('deleteModal', {
- element: document.getElementById('deleteModal'),
- onClose: () => {
- this.getModal('deleteModal').element.classList.remove('show');
- document.body.classList.remove('modal-open');
- pendingDeletePath = null;
- }
- });
-
- // 添加全局事件监听
- document.addEventListener('keydown', this.boundHandleEscape);
- }
-
- registerModal(id, config) {
- this.modals.set(id, {
- element: config.element,
- onClose: config.onClose,
- isOpen: false
- });
-
- // 为每个模态窗口添加点击外部关闭事件
- config.element.addEventListener('click', (e) => {
- if (e.target === config.element) {
- this.closeModal(id);
- }
- });
- }
-
- getModal(id) {
- return this.modals.get(id);
- }
-
- showModal(id, content = null) {
- const modal = this.getModal(id);
- if (!modal) return;
-
- if (content) {
- modal.element.innerHTML = content;
- }
-
- if (id === 'loraModal') {
- modal.element.style.display = 'block';
- } else if (id === 'deleteModal') {
- modal.element.classList.add('show');
- }
-
- modal.isOpen = true;
- document.body.classList.add('modal-open');
- }
-
- closeModal(id) {
- const modal = this.getModal(id);
- if (!modal) return;
-
- modal.onClose();
- modal.isOpen = false;
- }
-
- handleEscape(e) {
- if (e.key === 'Escape') {
- // 关闭最后打开的模态窗口
- for (const [id, modal] of this.modals) {
- if (modal.isOpen) {
- this.closeModal(id);
- break;
- }
- }
- }
- }
-}
-
-const modalManager = new ModalManager();
-
-// State management
-let state = {
- currentPage: 1,
- isLoading: false,
- hasMore: true,
- sortBy: 'name',
- activeFolder: null,
- loadingManager: null,
- observer: null // 添加 observer 到状态管理中
-};
-
-// Initialize loading manager
-document.addEventListener('DOMContentLoaded', () => {
- state.loadingManager = new LoadingManager();
- initializeInfiniteScroll();
- initializeEventListeners();
-});
-
-// Initialize infinite scroll
-function initializeInfiniteScroll() {
- // 如果已存在 observer,先断开连接
- if (state.observer) {
- state.observer.disconnect();
- }
-
- // Create intersection observer for infinite scroll
- state.observer = new IntersectionObserver(
- (entries) => {
- const target = entries[0];
- if (target.isIntersecting && !state.isLoading && state.hasMore) {
- loadMoreLoras();
- }
- },
- { threshold: 0.1 }
- );
-
- // Add sentinel element for infinite scroll
- const existingSentinel = document.getElementById('scroll-sentinel');
- if (existingSentinel) {
- state.observer.observe(existingSentinel);
- } else {
- const sentinel = document.createElement('div');
- sentinel.id = 'scroll-sentinel';
- sentinel.style.height = '10px';
- document.getElementById('loraGrid').appendChild(sentinel);
- state.observer.observe(sentinel);
- }
-}
-
-// Initialize event listeners
-function initializeEventListeners() {
- // Sort select handler
- const sortSelect = document.getElementById('sortSelect');
- if (sortSelect) {
- sortSelect.value = state.sortBy;
- sortSelect.addEventListener('change', async (e) => {
- state.sortBy = e.target.value;
- await resetAndReload(); // 直接重新从后端加载已排序的数据
- });
- }
-
- // Folder filter handler
- document.querySelectorAll('.folder-tags .tag').forEach(tag => {
- tag.addEventListener('click', toggleFolder);
- });
-}
-
-// Load more loras
-async function loadMoreLoras() {
- if (state.isLoading || !state.hasMore) return;
-
- state.isLoading = true;
- try {
- // 构建请求参数
- const params = new URLSearchParams({
- page: state.currentPage,
- page_size: 20,
- sort_by: state.sortBy
- });
-
- // 只在有选中文件夹时添加 folder 参数
- if (state.activeFolder !== null) {
- params.append('folder', state.activeFolder);
- }
-
- console.log('Loading loras with params:', params.toString()); // 调试日志
-
- const response = await fetch(`/api/loras?${params}`);
- if (!response.ok) {
- throw new Error(`Failed to fetch loras: ${response.statusText}`);
- }
-
- const data = await response.json();
- console.log('Received data:', data); // 调试日志
-
- if (data.items.length === 0 && state.currentPage === 1) {
- // 如果是第一页且没有数据,显示提示
- const grid = document.getElementById('loraGrid');
- grid.innerHTML = 'No loras found in this folder
';
- state.hasMore = false;
- } else if (data.items.length > 0) {
- state.hasMore = state.currentPage < data.total_pages;
- state.currentPage++;
- appendLoraCards(data.items);
-
- // 确保 sentinel 元素被观察
- const sentinel = document.getElementById('scroll-sentinel');
- if (sentinel && state.observer) {
- state.observer.observe(sentinel);
- }
- } else {
- state.hasMore = false;
- }
-
- } catch (error) {
- console.error('Error loading loras:', error);
- showToast('Failed to load loras: ' + error.message, 'error');
- } finally {
- state.isLoading = false;
- }
-}
-
-// Reset and reload
-async function resetAndReload() {
- console.log('Resetting with state:', { ...state }); // 调试日志
-
- state.currentPage = 1;
- state.hasMore = true;
- state.isLoading = false;
-
- const grid = document.getElementById('loraGrid');
- grid.innerHTML = ''; // 清空网格
-
- // 添加 sentinel
- const sentinel = document.createElement('div');
- sentinel.id = 'scroll-sentinel';
- grid.appendChild(sentinel);
-
- // 重新初始化无限滚动
- initializeInfiniteScroll();
-
- await loadMoreLoras();
-}
-
-// Append lora cards
-function appendLoraCards(loras) {
- const grid = document.getElementById('loraGrid');
- const sentinel = document.getElementById('scroll-sentinel');
-
- loras.forEach(lora => {
- const card = createLoraCard(lora);
- grid.insertBefore(card, sentinel);
- });
-}
-
-// Create lora card
-function createLoraCard(lora) {
- const card = document.createElement('div');
- card.className = 'lora-card';
- card.dataset.sha256 = lora.sha256;
- card.dataset.filepath = lora.file_path;
- card.dataset.name = lora.model_name;
- card.dataset.file_name = lora.file_name;
- card.dataset.folder = lora.folder;
- card.dataset.modified = lora.modified;
- card.dataset.from_civitai = lora.from_civitai;
- card.dataset.meta = JSON.stringify(lora.civitai || {});
-
- card.innerHTML = `
-
- ${lora.preview_url.endsWith('.mp4') ?
- `
` :
- `

`
- }
-
-
-
- `;
-
- // Add click handler for showing modal
- card.addEventListener('click', () => {
- const meta = JSON.parse(card.dataset.meta || '{}');
- if (Object.keys(meta).length) {
- showLoraModal(meta);
- } else {
- showToast(
- card.dataset.from_civitai === 'true' ?
- 'Click "Fetch" to retrieve metadata' :
- 'No CivitAI information available',
- 'info'
- );
- }
- });
-
- return card;
-}
-
-// Refresh loras
-async function refreshLoras() {
- try {
- state.loadingManager.showSimpleLoading('Refreshing loras...');
- await resetAndReload();
- showToast('Refresh complete', 'success');
- } catch (error) {
- console.error('Refresh failed:', error);
- showToast('Failed to refresh loras', 'error');
- } finally {
- state.loadingManager.hide();
- state.loadingManager.restoreProgressBar();
- }
-}
-
-// UI interaction functions
-function showLoraModal(lora) {
- const escapedWords = lora.trainedWords?.length ?
- lora.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
-
- // Organize trigger words by categories
- const categories = {};
- escapedWords.forEach(word => {
- const category = word.includes(':') ? word.split(':')[0] : 'General';
- if (!categories[category]) {
- categories[category] = [];
- }
- categories[category].push(word);
- });
-
- const imageMarkup = lora.images.map(img => {
- if (img.type === 'video') {
- return ``;
- } else {
- return `
`;
- }
- }).join('');
-
- const triggerWordsMarkup = escapedWords.length ? `
-
- ` : 'No trigger words
';
-
- const content = `
-
-
${lora.model.name}
-
- ${imageMarkup}
-
-
About this version: ${lora.description || 'N/A'}
- ${triggerWordsMarkup}
-
-
-
- `;
-
- modalManager.showModal('loraModal', content);
-
- // Add category switching event listeners
- document.querySelectorAll('.trigger-category').forEach(category => {
- category.addEventListener('click', function() {
- const categoryName = this.dataset.category;
- document.querySelectorAll('.trigger-category').forEach(c => c.classList.remove('active'));
- this.classList.add('active');
-
- const wordsList = document.querySelector('.trigger-words-list');
- wordsList.innerHTML = categories[categoryName].map(word => `
-
- `).join('');
- });
- });
-}
-
-function filterByFolder(folderPath) {
- document.querySelectorAll('.lora-card').forEach(card => {
- card.style.display = card.dataset.folder === folderPath ? '' : 'none';
- });
-}
-
-// Initialization
-document.addEventListener('DOMContentLoaded', () => {
- const searchHandler = debounce(term => {
- document.querySelectorAll('.lora-card').forEach(card => {
- card.style.display = [card.dataset.name, card.dataset.folder]
- .some(text => text.toLowerCase().includes(term))
- ? 'block'
- : 'none';
- });
- }, 250);
-
- document.getElementById('searchInput')?.addEventListener('input', e => {
- searchHandler(e.target.value.toLowerCase());
- });
-
- document.getElementById('sortSelect')?.addEventListener('change', e => {
- // 移除这个函数
- // sortCards(e.target.value);
- });
-
- lazyLoadImages();
- restoreFolderFilter();
- initializeLoraCards();
- initTheme();
-});
-
-function initializeLoraCards() {
- document.querySelectorAll('.lora-card').forEach(card => {
- card.addEventListener('click', () => {
- const meta = JSON.parse(card.dataset.meta || '{}');
- if (Object.keys(meta).length) {
- showLoraModal(meta);
- } else {
- showToast(card.dataset.from_civitai === 'True'
- ? 'Click "Fetch" to retrieve metadata'
- : 'No CivitAI information available', 'info');
- }
- });
-
- card.querySelector('.fa-copy')?.addEventListener('click', e => {
- e.stopPropagation();
- navigator.clipboard.writeText(card.dataset.file_name)
- .then(() => showToast('Model name copied', 'success'))
- .catch(() => showToast('Copy failed', 'error'));
- });
- });
-}
-
-// Helper functions
-function showToast(message, type = 'info') {
- const toast = document.createElement('div');
- toast.className = `toast toast-${type}`;
- toast.textContent = message;
- document.body.append(toast);
-
- requestAnimationFrame(() => {
- toast.classList.add('show');
- setTimeout(() => toast.remove(), 2300);
- });
-}
-
-function lazyLoadImages() {
- const observer = new IntersectionObserver(entries => {
- entries.forEach(entry => {
- if (entry.isIntersecting && entry.target.dataset.src) {
- entry.target.src = entry.target.dataset.src;
- observer.unobserve(entry.target);
- }
- });
- });
-
- document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));
-}
-
-function restoreFolderFilter() {
- const activeFolder = localStorage.getItem('activeFolder');
- const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
- if (folderTag) {
- folderTag.classList.add('active');
- filterByFolder(activeFolder);
- }
-}
-
-function initTheme() {
- document.body.dataset.theme = localStorage.getItem('theme') || 'dark';
-}
-
-// Theme toggle
-function toggleTheme() {
- const theme = document.body.dataset.theme === 'light' ? 'dark' : 'light';
- document.body.dataset.theme = theme;
- localStorage.setItem('theme', theme);
-}
-
-let pendingDeletePath = null;
-
-function toggleFolder(tag) {
- // 确保 tag 是 DOM 元素
- const tagElement = (tag instanceof HTMLElement) ? tag : this;
- const folder = tagElement.dataset.folder;
- const wasActive = tagElement.classList.contains('active');
-
- // 清除所有标签的激活状态
- document.querySelectorAll('.folder-tags .tag').forEach(t => {
- t.classList.remove('active');
- });
-
- if (!wasActive) {
- // 激活当前标签
- tagElement.classList.add('active');
- state.activeFolder = folder;
- } else {
- // 取消激活
- state.activeFolder = null;
- }
-
- // 重置并重新加载数据
- resetAndReload();
-}
-
-async function confirmDelete() {
- if (!pendingDeletePath) return;
-
- const modal = document.getElementById('deleteModal');
- const card = document.querySelector(`.lora-card[data-filepath="${pendingDeletePath}"]`);
-
- try {
- const response = await fetch('/api/delete_model', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- file_path: pendingDeletePath
- })
- });
-
- if (response.ok) {
- if (card) {
- card.remove();
- }
- closeDeleteModal();
- } else {
- const error = await response.text();
- alert(`Failed to delete model: ${error}`);
- }
- } catch (error) {
- alert(`Error deleting model: ${error}`);
- }
-}
-
-// Replace the existing deleteModel function with this one
-async function deleteModel(filePath) {
- showDeleteModal(filePath);
-}
-
-function showDeleteModal(filePath) {
- event.stopPropagation();
- pendingDeletePath = filePath;
-
- const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
- const modelName = card.dataset.name;
- const modal = modalManager.getModal('deleteModal').element;
- const modelInfo = modal.querySelector('.delete-model-info');
-
- modelInfo.innerHTML = `
- Model: ${modelName}
-
- File: ${filePath}
- `;
-
- modalManager.showModal('deleteModal');
-}
-
-function copyTriggerWord(word) {
- navigator.clipboard.writeText(word).then(() => {
- const toast = document.createElement('div');
- toast.className = 'toast toast-copy';
- toast.textContent = 'Copied!';
- document.body.appendChild(toast);
-
- requestAnimationFrame(() => {
- toast.classList.add('show');
- setTimeout(() => {
- toast.classList.remove('show');
- setTimeout(() => toast.remove(), 300);
- }, 1000);
- });
- });
-}
-
-function closeDeleteModal() {
- modalManager.closeModal('deleteModal');
-}
-
-function openCivitai(modelName) {
- // 从卡片的data-meta属性中获取civitai ID
- const loraCard = document.querySelector(`.lora-card[data-name="${modelName}"]`);
- if (!loraCard) return;
-
- const metaData = JSON.parse(loraCard.dataset.meta);
- const civitaiId = metaData.modelId; // 使用modelId作为civitai模型ID
- const versionId = metaData.id; // 使用id作为版本ID
-
- // 构建URL
- if (civitaiId) {
- let url = `https://civitai.com/models/${civitaiId}`;
- if (versionId) {
- url += `?modelVersionId=${versionId}`;
- }
- window.open(url, '_blank');
- } else {
- // 如果没有ID,尝试使用名称搜索
- window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
- }
-}
-
-async function replacePreview(filePath) {
- // Get loading elements first
- const loadingOverlay = document.getElementById('loading-overlay');
- const loadingStatus = document.querySelector('.loading-status');
-
- // Create a file input element
- const input = document.createElement('input');
- input.type = 'file';
- input.accept = 'image/*,video/mp4'; // Accept images and MP4 videos
-
- // Handle file selection
- input.onchange = async function() {
- if (!input.files || !input.files[0]) return;
-
- const file = input.files[0];
- const formData = new FormData();
- formData.append('preview_file', file);
- formData.append('model_path', filePath);
-
- try {
- // Show loading overlay
- loadingOverlay.style.display = 'flex';
- loadingStatus.textContent = 'Uploading preview...';
-
- const response = await fetch('/api/replace_preview', {
- method: 'POST',
- body: formData
- });
-
- if (!response.ok) {
- throw new Error('Upload failed');
- }
-
- const data = await response.json();
- const newPreviewPath = `${data.preview_url}?t=${new Date().getTime()}`;
-
- // Update the preview image in the card
- const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
- const previewContainer = card.querySelector('.card-preview');
- const oldPreview = previewContainer.querySelector('img, video');
-
- // Create new preview element based on file type
- if (file.type.startsWith('video/')) {
- const video = document.createElement('video');
- video.controls = true;
- video.autoplay = true;
- video.muted = true;
- video.loop = true;
- video.src = newPreviewPath;
- oldPreview.replaceWith(video);
- } else {
- const img = document.createElement('img');
- img.src = newPreviewPath;
- oldPreview.replaceWith(img);
- }
-
- } catch (error) {
- console.error('Error uploading preview:', error);
- alert('Failed to upload preview image');
- } finally {
- loadingOverlay.style.display = 'none';
- }
- };
-
- // Trigger file selection
- input.click();
-}
-
-// Fetch CivitAI metadata for all loras
-async function fetchCivitai() {
- let ws = null;
-
- await state.loadingManager.showWithProgress(async (loading) => {
- try {
- // 建立 WebSocket 连接
- ws = new WebSocket(`ws://${window.location.host}/ws/fetch-progress`);
-
- // 等待操作完成的 Promise
- const operationComplete = new Promise((resolve, reject) => {
- ws.onmessage = (event) => {
- const data = JSON.parse(event.data);
-
- switch(data.status) {
- case 'started':
- loading.setStatus('Starting metadata fetch...');
- break;
-
- case 'processing':
- const percent = ((data.processed / data.total) * 100).toFixed(1);
- loading.setProgress(percent);
- loading.setStatus(
- `Processing (${data.processed}/${data.total}) ${data.current_name}`
- );
- break;
-
- case 'completed':
- loading.setProgress(100);
- loading.setStatus(
- `Completed: Updated ${data.success} of ${data.processed} loras`
- );
- resolve(); // 完成操作
- break;
-
- case 'error':
- reject(new Error(data.error));
- break;
- }
- };
-
- ws.onerror = (error) => {
- reject(new Error('WebSocket error: ' + error.message));
- };
- });
-
- // 等待 WebSocket 连接建立
- await new Promise((resolve, reject) => {
- ws.onopen = resolve;
- ws.onerror = reject;
- });
-
- // 发起获取请求
- const response = await fetch('/api/fetch-all-civitai', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' }
- });
-
- if (!response.ok) {
- throw new Error('Failed to fetch metadata');
- }
-
- // 等待操作完成
- await operationComplete;
-
- // 重置并重新加载当前视图
- await resetAndReload();
-
- } catch (error) {
- console.error('Error fetching metadata:', error);
- showToast('Failed to fetch metadata: ' + error.message, 'error');
- } finally {
- // 关闭 WebSocket 连接
- if (ws) {
- ws.close();
- }
- }
- }, {
- initialMessage: 'Connecting...',
- completionMessage: 'Metadata update complete'
- });
-}
\ No newline at end of file
diff --git a/templates/components/controls.html b/templates/components/controls.html
index 47ab3518..6771f4a8 100644
--- a/templates/components/controls.html
+++ b/templates/components/controls.html
@@ -23,6 +23,11 @@
+
+
+
-
\ No newline at end of file
+
+
+
+
+
+
×
+
Download LoRA from URL
+
+
+
+
+
+
+
+
+
+
+ Back
+ Next
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back
+ Download
+
+
+
+
\ No newline at end of file