diff --git a/.gitignore b/.gitignore index ba0430d2..c872d7ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -__pycache__/ \ No newline at end of file +__pycache__/ +settings.json \ No newline at end of file diff --git a/routes/api_routes.py b/routes/api_routes.py index 8eb3c35f..20e30e41 100644 --- a/routes/api_routes.py +++ b/routes/api_routes.py @@ -8,6 +8,7 @@ from ..config import config from ..services.lora_scanner import LoraScanner from operator import itemgetter from ..services.websocket_manager import ws_manager +from ..services.settings_manager import settings # 添加这行 logger = logging.getLogger(__name__) @@ -31,6 +32,7 @@ class ApiRoutes: 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) + app.router.add_post('/api/settings', routes.update_settings) async def delete_model(self, request: web.Request) -> web.Response: """Handle model deletion request""" @@ -491,8 +493,8 @@ class ApiRoutes: ) if result.get('success'): - # 更新缓存 - await self.scanner.rescan_directory(save_dir) + # 更新缓存 - 使用正确的扫描方法 + await self.scanner.scan_directory(save_dir) # Changed from rescan_directory to scan_directory return web.json_response(result) else: return web.Response(status=500, text=result.get('error', 'Download failed')) @@ -501,6 +503,20 @@ class ApiRoutes: logger.error(f"Error downloading LoRA: {e}") return web.Response(status=500, text=str(e)) + async def update_settings(self, request: web.Request) -> web.Response: + """Update application settings""" + try: + data = await request.json() + + # Validate and update settings + if 'civitai_api_key' in data: + settings.set('civitai_api_key', data['civitai_api_key']) + + return web.json_response({'success': True}) + except Exception as e: + logger.error(f"Error updating settings: {e}", exc_info=True) # 添加 exc_info=True 以获取完整堆栈 + return web.Response(status=500, text=str(e)) + @classmethod async def cleanup(cls): """Add cleanup method for application shutdown""" diff --git a/routes/lora_routes.py b/routes/lora_routes.py index c5efb131..de3d043b 100644 --- a/routes/lora_routes.py +++ b/routes/lora_routes.py @@ -5,6 +5,7 @@ from typing import Dict, List import logging from ..services.lora_scanner import LoraScanner from ..config import config +from ..services.settings_manager import settings # Add this import logger = logging.getLogger(__name__) logging.getLogger('asyncio').setLevel(logging.CRITICAL) @@ -60,7 +61,8 @@ class LoraRoutes: template = self.template_env.get_template('loras.html') rendered = template.render( folders=[], # 空文件夹列表 - is_initializing=True # 新增标志 + is_initializing=True, # 新增标志 + settings=settings # Pass settings to template ) else: # 正常流程 @@ -68,7 +70,8 @@ class LoraRoutes: template = self.template_env.get_template('loras.html') rendered = template.render( folders=cache.folders, - is_initializing=False + is_initializing=False, + settings=settings # Pass settings to template ) return web.Response( diff --git a/services/civitai_client.py b/services/civitai_client.py index 9ebc6de1..c1e527d6 100644 --- a/services/civitai_client.py +++ b/services/civitai_client.py @@ -2,7 +2,9 @@ import aiohttp import os import json import logging -from typing import Optional, Dict +from email.parser import Parser +from typing import Optional, Dict, Tuple +from urllib.parse import unquote logger = logging.getLogger(__name__) @@ -18,9 +20,74 @@ class CivitaiClient: async def session(self) -> aiohttp.ClientSession: """Lazy initialize the session""" if self._session is None: - self._session = aiohttp.ClientSession() + connector = aiohttp.TCPConnector(ssl=True) + trust_env = True # 允许使用系统环境变量中的代理设置 + self._session = aiohttp.ClientSession(connector=connector, trust_env=trust_env) return self._session + def _parse_content_disposition(self, header: str) -> str: + """Parse filename from content-disposition header""" + if not header: + return None + + # Handle quoted filenames + if 'filename="' in header: + start = header.index('filename="') + 10 + end = header.index('"', start) + return unquote(header[start:end]) + + # Fallback to original parsing + disposition = Parser().parsestr(f'Content-Disposition: {header}') + filename = disposition.get_param('filename') + if filename: + return unquote(filename) + return None + + def _get_request_headers(self) -> dict: + """Get request headers with optional API key""" + headers = { + 'User-Agent': 'ComfyUI-LoRA-Manager/1.0', + 'Content-Type': 'application/json' + } + + from .settings_manager import settings + api_key = settings.get('civitai_api_key') + if (api_key): + headers['Authorization'] = f'Bearer {api_key}' + + return headers + + async def _download_file(self, url: str, save_dir: str, default_filename: str) -> Tuple[bool, str]: + """Download file with content-disposition support""" + session = await self.session + try: + headers = self._get_request_headers() + async with session.get(url, headers=headers, allow_redirects=True) as response: + if response.status != 200: + return False, f"Download failed with status {response.status}" + + # Get filename from content-disposition header + content_disposition = response.headers.get('Content-Disposition') + filename = self._parse_content_disposition(content_disposition) + if not filename: + filename = default_filename + + save_path = os.path.join(save_dir, filename) + + # Stream download to file + with open(save_path, 'wb') as f: + while True: + chunk = await response.content.read(8192) + if not chunk: + break + f.write(chunk) + + return True, save_path + + except Exception as e: + logger.error(f"Download error: {e}") + return False, str(e) + async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]: try: session = await self.session @@ -60,92 +127,24 @@ class CivitaiClient: 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 + # Generate default filename + default_filename = f"lora_{version_info['id']}.safetensors" + logger.info(f"Downloading model: {version_info.get('name', 'Unknown')}") - # 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'} + # Download the model file + success, result = await self._download_file(download_url, save_dir, default_filename) + if not success: + return {'success': False, 'error': result} - with open(save_path, 'wb') as f: - while True: - chunk = await response.content.read(8192) - if not chunk: - break - f.write(chunk) + save_path = result # Create metadata file metadata_path = os.path.splitext(save_path)[0] + '.metadata.json' metadata = { - 'model_name': version_info.get('model', {}).get('name', file_name), + 'model_name': version_info.get('name', os.path.basename(save_path)), 'civitai': version_info, 'preview_url': None, 'from_civitai': True @@ -157,7 +156,7 @@ class CivitaiClient: 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 + metadata['preview_url'] = preview_path.replace(os.sep, '/') # Save metadata with open(metadata_path, 'w', encoding='utf-8') as f: @@ -165,7 +164,7 @@ class CivitaiClient: return { 'success': True, - 'file_path': save_path, + 'file_path': save_path.replace(os.sep, '/'), 'metadata': metadata } diff --git a/services/settings_manager.py b/services/settings_manager.py new file mode 100644 index 00000000..33c2faaf --- /dev/null +++ b/services/settings_manager.py @@ -0,0 +1,46 @@ +import os +import json +import logging +from typing import Any, Dict + +logger = logging.getLogger(__name__) + +class SettingsManager: + def __init__(self): + self.settings_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'settings.json') + self.settings = self._load_settings() + + def _load_settings(self) -> Dict[str, Any]: + """Load settings from file""" + if os.path.exists(self.settings_file): + try: + with open(self.settings_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + logger.error(f"Error loading settings: {e}") + return self._get_default_settings() + + def _get_default_settings(self) -> Dict[str, Any]: + """Return default settings""" + return { + "civitai_api_key": "" + } + + def get(self, key: str, default: Any = None) -> Any: + """Get setting value""" + return self.settings.get(key, default) + + def set(self, key: str, value: Any) -> None: + """Set setting value and save""" + self.settings[key] = value + self._save_settings() + + def _save_settings(self) -> None: + """Save settings to file""" + try: + with open(self.settings_file, 'w', encoding='utf-8') as f: + json.dump(self.settings, f, indent=2) + except Exception as e: + logger.error(f"Error saving settings: {e}") + +settings = SettingsManager() diff --git a/static/css/style.css b/static/css/style.css index 9ac242af..cec3bce9 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1154,4 +1154,61 @@ body.modal-open { padding: var(--space-1); max-height: 200px; overflow-y: auto; +} + +/* Settings styles */ +.settings-toggle { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--card-bg); + border: 1px solid var(--border-color); + color: var(--text-color); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.settings-toggle:hover { + background: var(--lora-accent); + color: white; + transform: translateY(-2px); +} + +.settings-modal { + max-width: 500px; +} + +.api-key-input { + position: relative; + display: flex; + align-items: center; +} + +.api-key-input input { + padding-right: 40px; +} + +.api-key-input .toggle-visibility { + position: absolute; + right: 8px; + background: none; + border: none; + color: var(--text-color); + opacity: 0.6; + cursor: pointer; + padding: 4px 8px; +} + +.api-key-input .toggle-visibility:hover { + opacity: 1; +} + +.input-help { + font-size: 0.85em; + color: var(--text-color); + opacity: 0.8; + margin-top: 4px; } \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js index 81f244c1..e4866bf4 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -21,6 +21,7 @@ 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'; +import { SettingsManager, toggleApiKeyVisibility } from './managers/SettingsManager.js'; // Export all functions that need global access window.loadMoreLoras = loadMoreLoras; @@ -39,6 +40,8 @@ window.refreshLoras = refreshLoras; window.openCivitai = openCivitai; window.showToast = showToast window.toggleFolderTags = toggleFolderTags; +window.settingsManager = new SettingsManager(); +window.toggleApiKeyVisibility = toggleApiKeyVisibility; // Initialize everything when DOM is ready document.addEventListener('DOMContentLoaded', () => { diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index 4b534dcb..fb9c869e 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -34,6 +34,15 @@ export class ModalManager { } }); + // Add settingsModal registration + this.registerModal('settingsModal', { + element: document.getElementById('settingsModal'), + onClose: () => { + this.getModal('settingsModal').element.style.display = 'none'; + document.body.classList.remove('modal-open'); + } + }); + document.addEventListener('keydown', this.boundHandleEscape); this.initialized = true; } diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js new file mode 100644 index 00000000..399e27b4 --- /dev/null +++ b/static/js/managers/SettingsManager.js @@ -0,0 +1,52 @@ +import { modalManager } from './ModalManager.js'; +import { showToast } from '../utils/uiHelpers.js'; + +export class SettingsManager { + constructor() { + this.initialized = false; + } + + showSettings() { + console.log('Opening settings modal...'); // Debug log + modalManager.showModal('settingsModal'); + } + + async saveSettings() { + const apiKey = document.getElementById('civitaiApiKey').value; + + try { + const response = await fetch('/api/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + civitai_api_key: apiKey + }) + }); + + if (!response.ok) { + throw new Error('Failed to save settings'); + } + + showToast('Settings saved successfully', 'success'); + modalManager.closeModal('settingsModal'); + } catch (error) { + showToast('Failed to save settings: ' + error.message, 'error'); + } + } +} + +// Helper function for toggling API key visibility +export function toggleApiKeyVisibility(button) { + const input = button.parentElement.querySelector('input'); + const icon = button.querySelector('i'); + + if (input.type === 'password') { + input.type = 'text'; + icon.className = 'fas fa-eye-slash'; + } else { + input.type = 'password'; + icon.className = 'fas fa-eye'; + } +} diff --git a/templates/components/modals.html b/templates/components/modals.html index 49446f2a..2ba52c5e 100644 --- a/templates/components/modals.html +++ b/templates/components/modals.html @@ -67,4 +67,32 @@ + + + + \ No newline at end of file diff --git a/templates/loras.html b/templates/loras.html index 9274f564..5408af9a 100644 --- a/templates/loras.html +++ b/templates/loras.html @@ -39,6 +39,9 @@ Theme Theme +
+ +
{% include 'components/modals.html' %}