Add model description in lora details

This commit is contained in:
Will Miao
2025-03-10 00:20:31 +08:00
parent e8e5012f0c
commit 0069f84630
5 changed files with 495 additions and 58 deletions

View File

@@ -41,6 +41,7 @@ class ApiRoutes:
app.router.add_post('/api/download-lora', routes.download_lora)
app.router.add_post('/api/settings', routes.update_settings)
app.router.add_post('/api/move_model', routes.move_model)
app.router.add_get('/api/lora-model-description', routes.get_lora_model_description) # Add new route
app.router.add_post('/loras/api/save-metadata', routes.save_metadata)
app.router.add_get('/api/lora-preview-url', routes.get_lora_preview_url) # Add new route
app.router.add_post('/api/move_models_bulk', routes.move_models_bulk)
@@ -691,3 +692,61 @@ class ApiRoutes:
except Exception as e:
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
return web.Response(text=str(e), status=500)
async def get_lora_model_description(self, request: web.Request) -> web.Response:
"""Get model description for a Lora model"""
try:
# Get parameters
model_id = request.query.get('model_id')
file_path = request.query.get('file_path')
if not model_id:
return web.json_response({
'success': False,
'error': 'Model ID is required'
}, status=400)
# Check if we already have the description stored in metadata
description = None
if file_path:
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
if os.path.exists(metadata_path):
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
description = metadata.get('modelDescription')
except Exception as e:
logger.error(f"Error loading metadata from {metadata_path}: {e}")
# If description is not in metadata, fetch from CivitAI
if not description:
logger.info(f"Fetching model description for model ID: {model_id}")
description = await self.civitai_client.get_model_description(model_id)
# Save the description to metadata if we have a file path and got a description
if file_path and description:
try:
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
if os.path.exists(metadata_path):
with open(metadata_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
metadata['modelDescription'] = description
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
logger.info(f"Saved model description to metadata for {file_path}")
except Exception as e:
logger.error(f"Error saving model description to metadata: {e}")
return web.json_response({
'success': True,
'description': description or "<p>No model description available.</p>"
})
except Exception as e:
logger.error(f"Error getting model description: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -163,6 +163,41 @@ class CivitaiClient:
logger.error(f"Error fetching model version info: {e}")
return None
async def get_model_description(self, model_id: str) -> Optional[str]:
"""Fetch the model description from Civitai API
Args:
model_id: The Civitai model ID
Returns:
Optional[str]: The model description HTML or None if not found
"""
try:
session = await self.session
headers = self._get_request_headers()
url = f"{self.base_url}/models/{model_id}"
logger.info(f"Fetching model description from {url}")
async with session.get(url, headers=headers) as response:
if response.status != 200:
logger.warning(f"Failed to fetch model description: Status {response.status}")
return None
data = await response.json()
description = data.get('description')
if description:
logger.info(f"Successfully retrieved description for model {model_id}")
return description
else:
logger.warning(f"No description found for model {model_id}")
return None
except Exception as e:
logger.error(f"Error fetching model description: {e}", exc_info=True)
return None
async def close(self):
"""Close the session if it exists"""
if self._session is not None:

View File

@@ -479,4 +479,224 @@
/* Ensure close button is accessible */
.modal-content .close {
z-index: 10; /* Ensure close button is above other elements */
}
/* Tab System Styling */
.showcase-tabs {
display: flex;
border-bottom: 1px solid var(--lora-border);
margin-bottom: var(--space-2);
position: relative;
z-index: 2;
}
.tab-btn {
padding: var(--space-1) var(--space-2);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-color);
cursor: pointer;
font-size: 0.95em;
transition: all 0.2s;
opacity: 0.7;
position: relative;
}
.tab-btn:hover {
opacity: 1;
background: oklch(var(--lora-accent) / 0.05);
}
.tab-btn.active {
border-bottom: 2px solid var(--lora-accent);
opacity: 1;
font-weight: 600;
}
.tab-content {
position: relative;
min-height: 100px;
}
.tab-pane {
display: none;
}
.tab-pane.active {
display: block;
}
/* Model Description Styling */
.model-description-container {
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
overflow: hidden;
min-height: 200px;
position: relative;
}
.model-description-loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-3);
color: var(--text-color);
opacity: 0.7;
font-size: 0.9em;
}
.model-description-loading .fa-spinner {
margin-right: var(--space-1);
}
.model-description-content {
padding: var(--space-2);
line-height: 1.5;
overflow-wrap: break-word;
}
.model-description-content img {
max-width: 100%;
height: auto;
}
.model-description-content pre {
background: rgba(0, 0, 0, 0.05);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
white-space: pre-wrap;
}
.model-description-content code {
font-family: monospace;
}
.model-description-content hr {
border: 0;
border-top: 1px solid var(--lora-border);
margin: var(--space-2) 0;
}
.model-description-content a {
color: var(--lora-accent);
text-decoration: none;
}
.model-description-content a:hover {
text-decoration: underline;
}
/* Adjust dark mode for model description */
[data-theme="dark"] .model-description-content pre {
background: rgba(255, 255, 255, 0.05);
}
.hidden {
display: none !important;
}
.error-message {
color: var(--lora-error);
text-align: center;
padding: var(--space-2);
}
.no-examples {
text-align: center;
padding: var(--space-3);
color: var(--text-color);
opacity: 0.7;
}
/* Adjust the media wrapper for tab system */
#showcase-tab .carousel-container {
margin-top: var(--space-2);
}
/* Enhanced Model Description Styling */
.model-description-container {
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
overflow: hidden;
min-height: 200px;
position: relative;
/* Remove the max-height and overflow-y to allow content to expand naturally */
}
.model-description-content {
padding: var(--space-2);
line-height: 1.5;
overflow-wrap: break-word;
font-size: 0.95em;
}
.model-description-content h1,
.model-description-content h2,
.model-description-content h3,
.model-description-content h4,
.model-description-content h5,
.model-description-content h6 {
margin-top: 1em;
margin-bottom: 0.5em;
font-weight: 600;
}
.model-description-content p {
margin-bottom: 1em;
}
.model-description-content img {
max-width: 100%;
height: auto;
border-radius: var(--border-radius-xs);
display: block;
margin: 1em 0;
}
.model-description-content pre {
background: rgba(0, 0, 0, 0.05);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
white-space: pre-wrap;
margin: 1em 0;
overflow-x: auto;
}
.model-description-content code {
font-family: monospace;
font-size: 0.9em;
background: rgba(0, 0, 0, 0.05);
padding: 0.1em 0.3em;
border-radius: 3px;
}
.model-description-content pre code {
background: transparent;
padding: 0;
}
.model-description-content ul,
.model-description-content ol {
margin-left: 1.5em;
margin-bottom: 1em;
}
.model-description-content li {
margin-bottom: 0.5em;
}
.model-description-content blockquote {
border-left: 3px solid var(--lora-accent);
padding-left: 1em;
margin-left: 0;
margin-right: 0;
font-style: italic;
opacity: 0.8;
}
/* Adjust dark mode for model description */
[data-theme="dark"] .model-description-content pre,
[data-theme="dark"] .model-description-content code {
background: rgba(255, 255, 255, 0.05);
}

View File

@@ -325,4 +325,19 @@ export async function refreshSingleLoraMetadata(filePath) {
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
}
}
export async function fetchModelDescription(modelId, filePath) {
try {
const response = await fetch(`/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`);
if (!response.ok) {
throw new Error(`Failed to fetch model description: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching model description:', error);
throw error;
}
}

View File

@@ -81,10 +81,35 @@ export function showLoraModal(lora) {
<div class="description-text">${lora.description || 'N/A'}</div>
</div>
</div>
</div>
${renderShowcaseImages(lora.civitai.images)}
<div class="showcase-section" data-lora-id="${lora.civitai?.modelId || ''}">
<div class="showcase-tabs">
<button class="tab-btn active" data-tab="showcase">Examples</button>
<button class="tab-btn" data-tab="description">Model Description</button>
</div>
<div class="tab-content">
<div id="showcase-tab" class="tab-pane active">
${renderShowcaseContent(lora.civitai?.images)}
</div>
<div id="description-tab" class="tab-pane">
<div class="model-description-container">
<div class="model-description-loading">
<i class="fas fa-spinner fa-spin"></i> Loading model description...
</div>
<div class="model-description-content">
${lora.modelDescription || ''}
</div>
</div>
</div>
</div>
<button class="back-to-top" onclick="scrollToTop(this)">
<i class="fas fa-arrow-up"></i>
</button>
</div>
</div>
</div>
`;
@@ -92,6 +117,143 @@ export function showLoraModal(lora) {
modalManager.showModal('loraModal', content);
setupEditableFields();
setupShowcaseScroll();
setupTabSwitching();
// If we have a model ID but no description, fetch it
if (lora.civitai?.modelId && !lora.modelDescription) {
loadModelDescription(lora.civitai.modelId, lora.file_path);
}
}
// Function to render showcase content
function renderShowcaseContent(images) {
if (!images?.length) return '<div class="no-examples">No example images available</div>';
return `
<div class="scroll-indicator" onclick="toggleShowcase(this)">
<i class="fas fa-chevron-down"></i>
<span>Scroll or click to show ${images.length} examples</span>
</div>
<div class="carousel collapsed">
<div class="carousel-container">
${images.map(img => {
// 计算适当的展示高度:
// 1. 保持原始宽高比
// 2. 限制最大高度为视窗高度的60%
// 3. 确保最小高度为容器宽度的40%
const aspectRatio = (img.height / img.width) * 100;
const containerWidth = 800; // modal content的最大宽度
const minHeightPercent = 40; // 最小高度为容器宽度的40%
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
const heightPercent = Math.max(
minHeightPercent,
Math.min(maxHeightPercent, aspectRatio)
);
if (img.type === 'video') {
return `
<div class="media-wrapper" style="padding-bottom: ${heightPercent}%">
<video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer" data-src="${img.url}"
class="lazy">
<source data-src="${img.url}" type="video/mp4">
Your browser does not support video playback
</video>
</div>
`;
}
return `
<div class="media-wrapper" style="padding-bottom: ${heightPercent}%">
<img data-src="${img.url}"
alt="Preview"
crossorigin="anonymous"
referrerpolicy="no-referrer"
width="${img.width}"
height="${img.height}"
class="lazy">
</div>
`;
}).join('')}
</div>
</div>
`;
}
// New function to handle tab switching
function setupTabSwitching() {
const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
// Remove active class from all tabs
document.querySelectorAll('.showcase-tabs .tab-btn').forEach(btn =>
btn.classList.remove('active')
);
document.querySelectorAll('.tab-content .tab-pane').forEach(tab =>
tab.classList.remove('active')
);
// Add active class to clicked tab
button.classList.add('active');
const tabId = `${button.dataset.tab}-tab`;
document.getElementById(tabId).classList.add('active');
// If switching to description tab, make sure content is properly sized
if (button.dataset.tab === 'description') {
const descriptionContent = document.querySelector('.model-description-content');
if (descriptionContent && descriptionContent.innerHTML.trim() !== '') {
document.querySelector('.model-description-loading')?.classList.add('hidden');
}
}
});
});
}
// New function to load model description
async function loadModelDescription(modelId, filePath) {
try {
const descriptionContainer = document.querySelector('.model-description-content');
const loadingElement = document.querySelector('.model-description-loading');
if (!descriptionContainer || !loadingElement) return;
// Show loading indicator
loadingElement.classList.remove('hidden');
descriptionContainer.classList.add('hidden');
// Try to get model description from API
const response = await fetch(`/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`);
if (!response.ok) {
throw new Error(`Failed to fetch model description: ${response.statusText}`);
}
const data = await response.json();
if (data.success && data.description) {
// Update the description content
descriptionContainer.innerHTML = data.description;
// Process any links in the description to open in new tab
const links = descriptionContainer.querySelectorAll('a');
links.forEach(link => {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
});
// Show the description and hide loading indicator
descriptionContainer.classList.remove('hidden');
loadingElement.classList.add('hidden');
} else {
throw new Error(data.error || 'No description available');
}
} catch (error) {
console.error('Error loading model description:', error);
const loadingElement = document.querySelector('.model-description-loading');
if (loadingElement) {
loadingElement.innerHTML = `<div class="error-message">Failed to load model description. ${error.message}</div>`;
}
}
}
// 添加复制文件名的函数
@@ -350,61 +512,7 @@ function renderTriggerWords(words) {
}
function renderShowcaseImages(images) {
if (!images?.length) return '';
return `
<div class="showcase-section">
<div class="scroll-indicator" onclick="toggleShowcase(this)">
<i class="fas fa-chevron-down"></i>
<span>Scroll or click to show ${images.length} examples</span>
</div>
<div class="carousel collapsed">
<div class="carousel-container">
${images.map(img => {
// 计算适当的展示高度:
// 1. 保持原始宽高比
// 2. 限制最大高度为视窗高度的60%
// 3. 确保最小高度为容器宽度的40%
const aspectRatio = (img.height / img.width) * 100;
const containerWidth = 800; // modal content的最大宽度
const minHeightPercent = 40; // 最小高度为容器宽度的40%
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
const heightPercent = Math.max(
minHeightPercent,
Math.min(maxHeightPercent, aspectRatio)
);
if (img.type === 'video') {
return `
<div class="media-wrapper" style="padding-bottom: ${heightPercent}%">
<video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer" data-src="${img.url}"
class="lazy">
<source data-src="${img.url}" type="video/mp4">
Your browser does not support video playback
</video>
</div>
`;
}
return `
<div class="media-wrapper" style="padding-bottom: ${heightPercent}%">
<img data-src="${img.url}"
alt="Preview"
crossorigin="anonymous"
referrerpolicy="no-referrer"
width="${img.width}"
height="${img.height}"
class="lazy">
</div>
`;
}).join('')}
</div>
</div>
<button class="back-to-top" onclick="scrollToTop(this)">
<i class="fas fa-arrow-up"></i>
</button>
</div>
`;
return renderShowcaseContent(images);
}
export function toggleShowcase(element) {
@@ -558,4 +666,4 @@ function formatFileSize(bytes) {
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
}