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') ? - `` : - `${lora.model_name}` - } -
- - ${lora.base_model} - -
- - - - - - -
-
- -
- `; - - // 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 `Preview`; - } - }).join(''); - - const triggerWordsMarkup = escapedWords.length ? ` -
-
Trigger Words
-
- ${escapedWords.map(word => ` -
- ${word} - - - - - -
- `).join('')} -
-
- ` : '
No trigger words
'; - - const content = ` - - `; - - 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 => ` -
- ${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 + + + + \ No newline at end of file