Add local availability check for Civitai model versions; enhance download manager UI to indicate local status

This commit is contained in:
Will Miao
2025-03-06 20:45:09 +08:00
parent 5c521e40d4
commit c9c86d8c0f
12 changed files with 1192 additions and 1101 deletions

View File

@@ -488,12 +488,22 @@ class ApiRoutes:
}) })
async def get_civitai_versions(self, request: web.Request) -> web.Response: async def get_civitai_versions(self, request: web.Request) -> web.Response:
"""Get available versions for a Civitai model""" """Get available versions for a Civitai model with local availability info"""
try: try:
model_id = request.match_info['model_id'] model_id = request.match_info['model_id']
versions = await self.civitai_client.get_model_versions(model_id) versions = await self.civitai_client.get_model_versions(model_id)
if not versions: if not versions:
return web.Response(status=404, text="Model not found") return web.Response(status=404, text="Model not found")
# Check local availability for each version
for version in versions:
for file in version.get('files', []):
sha256 = file.get('hashes', {}).get('SHA256')
if sha256:
file['existsLocally'] = self.scanner.has_lora_hash(sha256)
if file['existsLocally']:
file['localPath'] = self.scanner.get_lora_path_by_hash(sha256)
return web.json_response(versions) return web.json_response(versions)
except Exception as e: except Exception as e:
logger.error(f"Error fetching model versions: {e}") logger.error(f"Error fetching model versions: {e}")

View File

@@ -4,7 +4,7 @@ import os
import json import json
import logging import logging
from email.parser import Parser from email.parser import Parser
from typing import Optional, Dict, Tuple from typing import Optional, Dict, Tuple, List
from urllib.parse import unquote from urllib.parse import unquote
from ..utils.models import LoraMetadata from ..utils.models import LoraMetadata
@@ -135,16 +135,15 @@ class CivitaiClient:
print(f"Download Error: {str(e)}") print(f"Download Error: {str(e)}")
return False return False
async def get_model_versions(self, model_id: str) -> Optional[Dict]: async def get_model_versions(self, model_id: str) -> List[Dict]:
"""Fetch all versions of a model""" """Get all versions of a model with local availability info"""
try: try:
session = await self.session session = await self.session # 等待获取 session
url = f"{self.base_url}/models/{model_id}" async with session.get(f"{self.base_url}/models/{model_id}") as response:
async with session.get(url, headers=self.headers) as response: if response.status != 200:
if response.status == 200: return None
data = await response.json() data = await response.json()
return data.get('modelVersions', []) return data.get('modelVersions', [])
return None
except Exception as e: except Exception as e:
logger.error(f"Error fetching model versions: {e}") logger.error(f"Error fetching model versions: {e}")
return None return None

View File

@@ -86,26 +86,32 @@ class LoraFileHandler(FileSystemEventHandler):
if not changes: if not changes:
return return
logger.info(f"Processing {len(changes)} file changes") logger.info(f"Processing {len(changes)} file changes")
cache = await self.scanner.get_cached_data() # 先完成可能的初始化 cache = await self.scanner.get_cached_data()
needs_resort = False needs_resort = False
new_folders = set() # 用于收集新的文件夹 new_folders = set()
for action, file_path in changes: for action, file_path in changes:
try: try:
if action == 'add': if action == 'add':
# 扫描新文件 # Scan new file
lora_data = await self.scanner.scan_single_lora(file_path) lora_data = await self.scanner.scan_single_lora(file_path)
if lora_data: if lora_data:
cache.raw_data.append(lora_data) cache.raw_data.append(lora_data)
new_folders.add(lora_data['folder']) # 收集新文件夹 new_folders.add(lora_data['folder'])
# Update hash index
if 'sha256' in lora_data:
self.scanner._hash_index.add_entry(
lora_data['sha256'],
lora_data['file_path']
)
needs_resort = True needs_resort = True
elif action == 'remove': elif action == 'remove':
# 从缓存中移除 # Remove from cache and hash index
logger.info(f"Removing {file_path} from cache") logger.info(f"Removing {file_path} from cache")
self.scanner._hash_index.remove_by_path(file_path)
cache.raw_data = [ cache.raw_data = [
item for item in cache.raw_data item for item in cache.raw_data
if item['file_path'] != file_path if item['file_path'] != file_path
@@ -118,7 +124,7 @@ class LoraFileHandler(FileSystemEventHandler):
if needs_resort: if needs_resort:
await cache.resort() await cache.resort()
# 更新文件夹列表,包括新添加的文件夹 # Update folder list
all_folders = set(cache.folders) | new_folders all_folders = set(cache.folders) | new_folders
cache.folders = sorted(list(all_folders), key=lambda x: x.lower()) cache.folders = sorted(list(all_folders), key=lambda x: x.lower())

View File

@@ -0,0 +1,48 @@
from typing import Dict, Optional
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class LoraHashIndex:
"""Index for mapping LoRA file hashes to their file paths"""
def __init__(self):
self._hash_to_path: Dict[str, str] = {}
def add_entry(self, sha256: str, file_path: str) -> None:
"""Add or update a hash -> path mapping"""
if not sha256 or not file_path:
return
self._hash_to_path[sha256] = file_path
def remove_entry(self, sha256: str) -> None:
"""Remove a hash entry"""
self._hash_to_path.pop(sha256, None)
def remove_by_path(self, file_path: str) -> None:
"""Remove entry by file path"""
for sha256, path in list(self._hash_to_path.items()):
if path == file_path:
del self._hash_to_path[sha256]
break
def get_path(self, sha256: str) -> Optional[str]:
"""Get file path for a given hash"""
return self._hash_to_path.get(sha256)
def get_hash(self, file_path: str) -> Optional[str]:
"""Get hash for a given file path"""
for sha256, path in self._hash_to_path.items():
if path == file_path:
return sha256
return None
def has_hash(self, sha256: str) -> bool:
"""Check if hash exists in index"""
return sha256 in self._hash_to_path
def clear(self) -> None:
"""Clear all entries"""
self._hash_to_path.clear()

View File

@@ -10,6 +10,7 @@ from ..config import config
from ..utils.file_utils import load_metadata, get_file_info from ..utils.file_utils import load_metadata, get_file_info
from .lora_cache import LoraCache from .lora_cache import LoraCache
from difflib import SequenceMatcher from difflib import SequenceMatcher
from .lora_hash_index import LoraHashIndex
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,6 +29,7 @@ class LoraScanner:
# 确保初始化只执行一次 # 确保初始化只执行一次
if not hasattr(self, '_initialized'): if not hasattr(self, '_initialized'):
self._cache: Optional[LoraCache] = None self._cache: Optional[LoraCache] = None
self._hash_index = LoraHashIndex()
self._initialization_lock = asyncio.Lock() self._initialization_lock = asyncio.Lock()
self._initialization_task: Optional[asyncio.Task] = None self._initialization_task: Optional[asyncio.Task] = None
self._initialized = True self._initialized = True
@@ -85,9 +87,17 @@ class LoraScanner:
async def _initialize_cache(self) -> None: async def _initialize_cache(self) -> None:
"""Initialize or refresh the cache""" """Initialize or refresh the cache"""
try: try:
# Clear existing hash index
self._hash_index.clear()
# Scan for new data # Scan for new data
raw_data = await self.scan_all_loras() raw_data = await self.scan_all_loras()
# Build hash index
for lora_data in raw_data:
if 'sha256' in lora_data and 'file_path' in lora_data:
self._hash_index.add_entry(lora_data['sha256'], lora_data['file_path'])
# Update cache # Update cache
self._cache = LoraCache( self._cache = LoraCache(
raw_data=raw_data, raw_data=raw_data,
@@ -416,13 +426,23 @@ class LoraScanner:
async def update_single_lora_cache(self, original_path: str, new_path: str, metadata: Dict) -> bool: async def update_single_lora_cache(self, original_path: str, new_path: str, metadata: Dict) -> bool:
cache = await self.get_cached_data() cache = await self.get_cached_data()
# Remove old path from hash index if exists
self._hash_index.remove_by_path(original_path)
cache.raw_data = [ cache.raw_data = [
item for item in cache.raw_data item for item in cache.raw_data
if item['file_path'] != original_path if item['file_path'] != original_path
] ]
if metadata: if metadata:
metadata['folder'] = self._calculate_folder(new_path) metadata['folder'] = self._calculate_folder(new_path)
cache.raw_data.append(metadata) cache.raw_data.append(metadata)
# Update hash index with new path
if 'sha256' in metadata:
self._hash_index.add_entry(metadata['sha256'], new_path)
all_folders = set(cache.folders) all_folders = set(cache.folders)
all_folders.add(metadata['folder']) all_folders.add(metadata['folder'])
cache.folders = sorted(list(all_folders), key=lambda x: x.lower()) cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
@@ -430,7 +450,6 @@ class LoraScanner:
# Resort cache # Resort cache
await cache.resort() await cache.resort()
async def _update_metadata_paths(self, metadata_path: str, lora_path: str) -> Dict: async def _update_metadata_paths(self, metadata_path: str, lora_path: str) -> Dict:
"""Update file paths in metadata file""" """Update file paths in metadata file"""
try: try:
@@ -457,3 +476,16 @@ class LoraScanner:
except Exception as e: except Exception as e:
logger.error(f"Error updating metadata paths: {e}", exc_info=True) logger.error(f"Error updating metadata paths: {e}", exc_info=True)
# Add new methods for hash index functionality
def has_lora_hash(self, sha256: str) -> bool:
"""Check if a LoRA with given hash exists"""
return self._hash_index.has_hash(sha256)
def get_lora_path_by_hash(self, sha256: str) -> Optional[str]:
"""Get file path for a LoRA by its hash"""
return self._hash_index.get_path(sha256)
def get_lora_hash_by_path(self, file_path: str) -> Optional[str]:
"""Get hash for a LoRA by its file path"""
return self._hash_index.get_hash(file_path)

View File

@@ -0,0 +1,240 @@
/* 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 Styles */
.version-list {
max-height: 400px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 1px;
}
.version-item {
display: flex;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
background: var(--bg-color);
margin: 1px;
position: relative;
}
.version-item:hover {
border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1;
}
.version-item.selected {
border: 2px solid var(--lora-accent);
background: oklch(var(--lora-accent) / 0.05);
}
.version-thumbnail {
width: 80px;
height: 80px;
flex-shrink: 0;
border-radius: var(--border-radius-xs);
overflow: hidden;
background: var(--bg-color);
}
.version-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.version-content {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 0;
}
.version-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
}
.version-content h3 {
margin: 0;
font-size: 1.1em;
color: var(--text-color);
flex: 1;
}
.version-info {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: 0.9em;
}
.version-info .base-model {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
}
.version-meta {
display: flex;
gap: 12px;
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
}
.version-meta span {
display: flex;
align-items: center;
gap: 4px;
}
/* Local Version Badge */
.local-badge {
display: inline-flex;
align-items: center;
background: var(--lora-accent);
color: var(--lora-text);
padding: 4px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.8em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
position: relative;
}
.local-badge i {
margin-right: 4px;
font-size: 0.9em;
}
.local-path {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
margin-top: 4px;
font-size: 0.9em;
color: var(--text-color);
white-space: normal;
word-break: break-all;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1;
min-width: 200px;
max-width: 300px;
}
.local-badge:hover .local-path {
display: block;
}
/* Folder Browser Styles */
.folder-browser {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
max-height: 200px;
overflow-y: auto;
}
.folder-item {
padding: 8px;
cursor: pointer;
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
}
.folder-item:hover {
background: var(--lora-surface);
}
.folder-item.selected {
background: oklch(var(--lora-accent) / 0.1);
border: 1px solid var(--lora-accent);
}
/* Path Preview Styles */
.path-preview {
margin-bottom: var(--space-3);
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-sm);
border: 1px dashed var(--border-color);
}
.path-preview label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
font-size: 0.9em;
opacity: 0.8;
}
.path-display {
padding: var(--space-1);
color: var(--text-color);
font-family: monospace;
font-size: 0.9em;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
opacity: 0.85;
background: var(--lora-surface);
border-radius: var(--border-radius-xs);
}
/* Dark theme adjustments */
[data-theme="dark"] .version-item {
background: var(--lora-surface);
}
[data-theme="dark"] .local-path {
background: var(--lora-surface);
border-color: var(--lora-border);
}

View File

@@ -0,0 +1,417 @@
/* Lora Modal Header */
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border);
}
/* Info Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.info-item {
padding: var(--space-2);
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
}
.info-item.full-width {
grid-column: 1 / -1;
}
.info-item label {
display: block;
font-size: 0.85em;
color: var(--text-color);
opacity: 0.8;
margin-bottom: 4px;
}
.info-item span {
color: var(--text-color);
word-break: break-word;
}
.info-item.usage-tips,
.info-item.notes {
grid-column: 1 / -1 !important; /* Make notes section full width */
}
/* Add specific styles for notes content */
.info-item.notes .editable-field [contenteditable] {
min-height: 60px; /* Increase height for multiple lines */
max-height: 150px; /* Limit maximum height */
overflow-y: auto; /* Add scrolling for long content */
white-space: pre-wrap; /* Preserve line breaks */
line-height: 1.5; /* Improve readability */
padding: 8px 12px; /* Slightly increase padding */
}
.file-path {
font-family: monospace;
font-size: 0.9em;
}
.description-text {
line-height: 1.5;
max-height: 100px;
overflow-y: auto;
}
/* Showcase Section */
.showcase-section {
position: relative;
margin-top: var(--space-4);
}
.carousel {
transition: max-height 0.3s ease-in-out;
overflow: hidden;
}
.carousel.collapsed {
max-height: 0;
}
.carousel-container {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.media-wrapper {
position: relative;
width: 100%;
background: var(--lora-surface);
margin-bottom: var(--space-2);
}
.media-wrapper:last-child {
margin-bottom: 0;
}
.media-wrapper img,
.media-wrapper video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
/* Scroll Indicator */
.scroll-indicator {
cursor: pointer;
padding: var(--space-2);
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: var(--space-2);
transition: background-color 0.2s, transform 0.2s;
}
.scroll-indicator:hover {
background: oklch(var(--lora-accent) / 0.1);
transform: translateY(-1px);
}
.scroll-indicator span {
font-size: 0.9em;
color: var(--text-color);
}
.lazy {
opacity: 0;
transition: opacity 0.3s;
}
.lazy[src] {
opacity: 1;
}
/* Update Trigger Words styles */
.info-item.trigger-words {
padding: var(--space-2);
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
}
.trigger-words-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
margin-top: var(--space-1);
}
/* Update Trigger Words styles */
.trigger-word-tag {
display: inline-flex;
align-items: center;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: 4px 8px;
cursor: pointer;
transition: all 0.2s ease;
gap: 6px;
}
/* Update trigger word content color to use theme accent */
.trigger-word-content {
color: var(--lora-accent) !important; /* Override general span color */
font-size: 0.85em;
line-height: 1.4;
word-break: break-word;
}
/* Keep the hover effect using accent color */
.trigger-word-tag:hover {
background: oklch(var(--lora-accent) / 0.1);
border-color: var(--lora-accent);
}
.trigger-word-copy {
display: flex;
align-items: center;
color: var(--text-color);
opacity: 0.5;
flex-shrink: 0;
transition: opacity 0.2s;
}
/* Editable Fields */
.editable-field {
position: relative;
display: flex;
gap: 8px;
align-items: flex-start;
}
.editable-field [contenteditable] {
flex: 1;
min-height: 24px;
padding: 4px 8px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
line-height: 1.4;
color: var(--text-color);
transition: border-color 0.2s;
word-break: break-word;
}
.editable-field [contenteditable]:focus {
outline: none;
border-color: var(--lora-accent);
background: var(--bg-color);
}
.editable-field [contenteditable]:empty::before {
content: attr(data-placeholder);
color: var(--text-color);
opacity: 0.5;
}
.save-btn {
padding: 4px 8px;
background: var(--lora-accent);
border: none;
border-radius: var(--border-radius-xs);
color: white;
cursor: pointer;
transition: opacity 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.save-btn:hover {
opacity: 0.9;
}
.save-btn i {
font-size: 0.9em;
}
@media (max-width: 640px) {
.info-item.usage-tips,
.info-item.notes {
grid-column: 1 / -1;
}
}
/* 修改 back-to-top 按钮样式,使其固定在 modal 内部 */
.modal-content .back-to-top {
position: sticky; /* 改用 sticky 定位 */
float: right; /* 使用 float 确保按钮在右侧 */
bottom: 20px; /* 距离底部的距离 */
margin-right: 20px; /* 右侧间距 */
margin-top: -56px; /* 负边距确保不占用额外空间 */
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;
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: all 0.3s ease;
z-index: 10;
}
.modal-content .back-to-top.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.modal-content .back-to-top:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
/* Update Preset Controls styles */
.preset-controls {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.preset-controls select,
.preset-controls input {
padding: var(--space-1);
background: var(--bg-color);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
color: var(--text-color);
}
.preset-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.preset-tag {
display: flex;
align-items: center;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
padding: calc(var(--space-1) * 0.5) var(--space-1);
gap: var(--space-1);
transition: all 0.2s ease;
}
.preset-tag span {
color: var(--lora-accent);
font-size: 0.9em;
}
.preset-tag i {
color: var(--text-color);
opacity: 0.5;
cursor: pointer;
transition: all 0.2s ease;
}
.preset-tag:hover {
background: oklch(var(--lora-accent) / 0.1);
border-color: var(--lora-accent);
}
.preset-tag i:hover {
color: var(--lora-error);
opacity: 1;
}
.add-preset-btn {
padding: calc(var(--space-1) * 0.5) var(--space-2);
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: opacity 0.2s;
}
.add-preset-btn:hover {
opacity: 0.9;
}
/* File name copy styles */
.file-name-wrapper {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px;
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
}
.file-name-wrapper:hover {
background: oklch(var(--lora-accent) / 0.1);
}
.file-name-wrapper i {
color: var(--text-color);
opacity: 0.5;
transition: opacity 0.2s;
}
.file-name-wrapper:hover i {
opacity: 1;
color: var(--lora-accent);
}
/* Base Model and Size combined styles */
.info-item.base-size {
display: flex;
gap: var(--space-3);
}
.base-wrapper {
flex: 2; /* 分配更多空间给base model */
}
.size-wrapper {
flex: 1;
border-left: 1px solid var(--lora-border);
padding-left: var(--space-3);
}
.base-wrapper label,
.size-wrapper label {
display: block;
margin-bottom: 4px;
}
.size-wrapper span {
font-family: monospace;
font-size: 0.9em;
opacity: 0.9;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
/* Support Modal Styles */
.support-modal {
max-width: 550px;
}
.support-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border);
}
.support-icon {
font-size: 1.8em;
color: var(--lora-error);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.support-content {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.support-content > p {
font-size: 1.1em;
text-align: center;
margin-bottom: var(--space-2);
}
.support-section {
background: rgba(0, 0, 0, 0.02); /* 轻微的灰色背景 */
border: 1px solid rgba(0, 0, 0, 0.08); /* 更明显的边框 */
border-radius: var(--border-radius-sm);
padding: var(--space-3);
margin-bottom: var(--space-2);
}
.support-section h3 {
display: flex;
align-items: center;
gap: 10px;
margin-top: 0;
margin-bottom: var(--space-1);
font-size: 1.1em;
color: var(--lora-accent);
}
.support-section h3 i {
opacity: 0.8;
}
.support-section p {
margin-top: 6px;
margin-bottom: var(--space-2);
color: var(--text-color);
opacity: 0.9;
}
.support-links {
display: flex;
gap: var(--space-2);
margin-top: var(--space-2);
}
.social-link {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
text-decoration: none;
color: var(--text-color);
transition: all 0.2s ease;
}
.social-link:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
.kofi-button {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px 20px;
background: #FF5E5B;
color: white;
border-radius: var(--border-radius-sm);
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
margin-top: var(--space-1);
}
.kofi-button:hover {
background: #E04946;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.support-footer {
text-align: center;
margin-top: var(--space-2);
font-style: italic;
color: var(--text-color);
}
/* Add support toggle button style */
.support-toggle {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--lora-error);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.support-toggle:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
.support-toggle i {
font-size: 1.1em;
position: relative;
top: 1px;
left: -0.5px;
}
@media (max-width: 480px) {
.support-links {
flex-direction: column;
}
}
/* Civitai link styles */
.civitai-link {
display: flex;
align-items: center;
gap: 8px;
}
.civitai-icon {
width: 20px;
height: 20px;
color: #1b98e4; /* Civitai brand blue color */
}
.social-link:hover .civitai-icon {
color: white; /* Icon color changes to white on hover */
}
/* 增强hover状态的视觉反馈 */
.social-link:hover,
.update-link:hover,
.folder-item:hover {
border-color: var(--lora-accent);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

View File

@@ -0,0 +1,208 @@
/* Update Modal Styles */
.update-modal {
max-width: 600px;
}
.update-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border);
}
.update-icon {
font-size: 1.8em;
color: var(--lora-accent);
animation: bounce 1.5s infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.update-content {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.update-info {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(0, 0, 0, 0.02); /* 轻微的灰色背景 */
border: 1px solid rgba(0, 0, 0, 0.08); /* 更明显的边框 */
border-radius: var(--border-radius-sm);
padding: var(--space-3);
}
.version-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.current-version, .new-version {
display: flex;
align-items: center;
gap: 10px;
}
.label {
font-size: 0.9em;
color: var(--text-color);
opacity: 0.8;
}
.version-number {
font-family: monospace;
font-weight: 600;
}
.new-version .version-number {
color: var(--lora-accent);
}
.update-link {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
text-decoration: none;
color: var(--text-color);
transition: all 0.2s ease;
}
.update-link:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
.changelog-section {
background: rgba(0, 0, 0, 0.02); /* 轻微的灰色背景 */
border: 1px solid rgba(0, 0, 0, 0.08); /* 更明显的边框 */
border-radius: var(--border-radius-sm);
padding: var(--space-3);
}
.changelog-section h3 {
margin-top: 0;
margin-bottom: var(--space-2);
color: var(--lora-accent);
font-size: 1.1em;
}
.changelog-content {
max-height: 300px; /* Increased height since we removed instructions */
overflow-y: auto;
}
.changelog-item {
margin-bottom: var(--space-2);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border);
}
.changelog-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.changelog-item h4 {
margin-top: 0;
margin-bottom: 8px;
font-size: 1em;
color: var(--text-color);
}
.changelog-item ul {
margin: 0;
padding-left: 20px;
}
.changelog-item li {
margin-bottom: 4px;
color: var(--text-color);
}
@media (max-width: 480px) {
.update-info {
flex-direction: column;
gap: var(--space-2);
}
.version-info {
width: 100%;
}
}
/* Update preferences section */
.update-preferences {
border-top: 1px solid var(--lora-border);
margin-top: var(--space-2);
padding-top: var(--space-2);
}
/* Toggle switch styles */
.toggle-switch {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggle-slider {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
background-color: var(--border-color);
border-radius: 20px;
transition: .4s;
flex-shrink: 0;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: white;
border-radius: 50%;
transition: .4s;
}
input:checked + .toggle-slider {
background-color: var(--lora-accent);
}
input:checked + .toggle-slider:before {
transform: translateX(20px);
}
.toggle-label {
font-size: 0.9em;
color: var(--text-color);
}

View File

@@ -7,9 +7,13 @@
/* Import Components */ /* Import Components */
@import 'components/card.css'; @import 'components/card.css';
@import 'components/modal.css'; @import 'components/modal.css';
@import 'components/download-modal.css';
@import 'components/toast.css'; @import 'components/toast.css';
@import 'components/loading.css'; @import 'components/loading.css';
@import 'components/menu.css'; @import 'components/menu.css';
@import 'components/update-modal.css';
@import 'components/lora-modal.css';
@import 'components/support-modal.css';
.initialization-notice { .initialization-notice {
display: flex; display: flex;

View File

@@ -71,7 +71,6 @@ export class DownloadManager {
const errorElement = document.getElementById('urlError'); const errorElement = document.getElementById('urlError');
try { try {
// Show loading while fetching versions
this.loadingManager.showSimpleLoading('Fetching model versions...'); this.loadingManager.showSimpleLoading('Fetching model versions...');
const modelId = this.extractModelId(url); const modelId = this.extractModelId(url);
@@ -98,7 +97,6 @@ export class DownloadManager {
} catch (error) { } catch (error) {
errorElement.textContent = error.message; errorElement.textContent = error.message;
} finally { } finally {
// Hide loading when done
this.loadingManager.hide(); this.loadingManager.hide();
} }
} }
@@ -120,19 +118,31 @@ export class DownloadManager {
const versionList = document.getElementById('versionList'); const versionList = document.getElementById('versionList');
versionList.innerHTML = this.versions.map(version => { versionList.innerHTML = this.versions.map(version => {
// Find first image (skip videos)
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4')); const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png'; const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
const fileSize = (version.files[0]?.sizeKB / 1024).toFixed(2); // Convert to MB const fileSize = (version.files[0]?.sizeKB / 1024).toFixed(2);
const existsLocally = version.files[0]?.existsLocally;
const localPath = version.files[0]?.localPath;
// 更新本地状态指示器为badge样式
const localStatus = existsLocally ?
`<div class="local-badge">
<i class="fas fa-check"></i> In Library
<div class="local-path">${localPath}</div>
</div>` : '';
return ` return `
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}" <div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''} ${existsLocally ? 'exists-locally' : ''}"
onclick="downloadManager.selectVersion('${version.id}')"> onclick="downloadManager.selectVersion('${version.id}')">
<div class="version-thumbnail"> <div class="version-thumbnail">
<img src="${thumbnailUrl}" alt="Version preview"> <img src="${thumbnailUrl}" alt="Version preview">
</div> </div>
<div class="version-content"> <div class="version-content">
<div class="version-header">
<h3>${version.name}</h3> <h3>${version.name}</h3>
${localStatus}
</div>
<div class="version-info"> <div class="version-info">
${version.baseModel ? `<div class="base-model">${version.baseModel}</div>` : ''} ${version.baseModel ? `<div class="base-model">${version.baseModel}</div>` : ''}
</div> </div>
@@ -150,6 +160,12 @@ export class DownloadManager {
this.currentVersion = this.versions.find(v => v.id.toString() === versionId.toString()); this.currentVersion = this.versions.find(v => v.id.toString() === versionId.toString());
if (!this.currentVersion) return; if (!this.currentVersion) return;
// Check if version exists locally
const existsLocally = this.currentVersion.files[0]?.existsLocally;
if (existsLocally) {
showToast('This version already exists in your library', 'info');
}
document.querySelectorAll('.version-item').forEach(item => { document.querySelectorAll('.version-item').forEach(item => {
item.classList.toggle('selected', item.querySelector('h3').textContent === this.currentVersion.name); item.classList.toggle('selected', item.querySelector('h3').textContent === this.currentVersion.name);
}); });