Add CivitAI re-linking functionality and related UI components. Fixes #216

- Implemented new API endpoints for re-linking models to CivitAI.
- Added context menu options for re-linking in both Lora and Checkpoint context menus.
- Created a modal for user confirmation and input for CivitAI model URL.
- Updated styles for the new modal and context menu items.
- Enhanced error handling and user feedback during the re-linking process.
This commit is contained in:
Will Miao
2025-06-09 17:23:03 +08:00
parent 4e5c8b2dd0
commit 787e37b7c6
13 changed files with 438 additions and 454 deletions

View File

@@ -45,6 +45,7 @@ class ApiRoutes:
app.router.add_post('/api/delete_model', routes.delete_model)
app.router.add_post('/api/loras/exclude', routes.exclude_model) # Add new exclude endpoint
app.router.add_post('/api/fetch-civitai', routes.fetch_civitai)
app.router.add_post('/api/relink-civitai', routes.relink_civitai) # Add new relink endpoint
app.router.add_post('/api/replace_preview', routes.replace_preview)
app.router.add_get('/api/loras', routes.get_loras)
app.router.add_post('/api/fetch-all-civitai', routes.fetch_all_civitai)
@@ -1285,3 +1286,9 @@ class ApiRoutes:
'success': False,
'error': str(e)
}, status=500)
async def relink_civitai(self, request: web.Request) -> web.Response:
"""Handle CivitAI metadata re-linking request by model version ID for LoRAs"""
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
return await ModelRouteUtils.handle_relink_civitai(request, self.scanner)

View File

@@ -51,6 +51,7 @@ class CheckpointsRoutes:
app.router.add_post('/api/checkpoints/delete', self.delete_model)
app.router.add_post('/api/checkpoints/exclude', self.exclude_model) # Add new exclude endpoint
app.router.add_post('/api/checkpoints/fetch-civitai', self.fetch_civitai)
app.router.add_post('/api/checkpoints/relink-civitai', self.relink_civitai) # Add new relink endpoint
app.router.add_post('/api/checkpoints/replace-preview', self.replace_preview)
app.router.add_post('/api/checkpoints/download', self.download_checkpoint)
app.router.add_post('/api/checkpoints/save-metadata', self.save_metadata) # Add new route
@@ -811,3 +812,7 @@ class CheckpointsRoutes:
'success': False,
'error': str(e)
}, status=500)
async def relink_civitai(self, request: web.Request) -> web.Response:
"""Handle CivitAI metadata re-linking request by model version ID for checkpoints"""
return await ModelRouteUtils.handle_relink_civitai(request, self.scanner)

View File

@@ -613,4 +613,68 @@ class ModelRouteUtils:
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def handle_relink_civitai(request: web.Request, scanner) -> web.Response:
"""Handle CivitAI metadata re-linking request by model version ID
Args:
request: The aiohttp request
scanner: The model scanner instance with cache management methods
Returns:
web.Response: The HTTP response
"""
try:
data = await request.json()
file_path = data.get('file_path')
model_version_id = data.get('model_version_id')
if not file_path or not model_version_id:
return web.json_response({"success": False, "error": "Both file_path and model_version_id are required"}, status=400)
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
# Check if model metadata exists
local_metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
# Create a client for fetching from Civitai
client = await CivitaiClient.get_instance()
try:
# Fetch metadata by model version ID
civitai_metadata, error = await client.get_model_version_info(model_version_id)
if not civitai_metadata:
error_msg = error or "Model version not found on CivitAI"
return web.json_response({"success": False, "error": error_msg}, status=404)
# Find the primary model file to get the correct SHA256 hash
primary_model_file = None
for file in civitai_metadata.get('files', []):
if file.get('primary', False) and file.get('type') == 'Model':
primary_model_file = file
break
if not primary_model_file or not primary_model_file.get('hashes', {}).get('SHA256'):
return web.json_response({"success": False, "error": "No SHA256 hash found in model metadata"}, status=404)
# Update the SHA256 hash in local metadata (convert to lowercase)
local_metadata['sha256'] = primary_model_file['hashes']['SHA256'].lower()
# Update metadata with CivitAI information
await ModelRouteUtils.update_model_metadata(metadata_path, local_metadata, civitai_metadata, client)
# Update the cache
await scanner.update_single_model_cache(file_path, file_path, local_metadata)
return web.json_response({
"success": True,
"message": f"Model successfully re-linked to Civitai version {model_version_id}",
"hash": local_metadata['sha256']
})
finally:
await client.close()
except Exception as e:
logger.error(f"Error re-linking to CivitAI: {e}", exc_info=True)
return web.json_response({"success": False, "error": str(e)}, status=500)

View File

@@ -274,83 +274,6 @@
background: var(--lora-accent);
}
/* NSFW Level Selector */
.nsfw-level-selector {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
padding: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
z-index: var(--z-modal);
width: 300px;
display: none;
}
.nsfw-level-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.nsfw-level-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.close-nsfw-selector {
background: transparent;
border: none;
color: var(--text-color);
cursor: pointer;
padding: 4px;
border-radius: var(--border-radius-xs);
}
.close-nsfw-selector:hover {
background: var(--border-color);
}
.current-level {
margin-bottom: 12px;
padding: 8px;
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
}
.nsfw-level-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.nsfw-level-btn {
flex: 1 0 calc(33% - 8px);
padding: 8px;
border-radius: var(--border-radius-xs);
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
}
.nsfw-level-btn:hover {
background: var(--lora-border);
}
.nsfw-level-btn.active {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
/* Mobile optimizations */
@media (max-width: 768px) {
.selected-thumbnails-strip {

View File

@@ -39,4 +39,81 @@
.context-menu-item i {
width: 16px;
text-align: center;
}
/* NSFW Level Selector */
.nsfw-level-selector {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
padding: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
z-index: var(--z-modal);
width: 300px;
display: none;
}
.nsfw-level-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.nsfw-level-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.close-nsfw-selector {
background: transparent;
border: none;
color: var(--text-color);
cursor: pointer;
padding: 4px;
border-radius: var(--border-radius-xs);
}
.close-nsfw-selector:hover {
background: var(--border-color);
}
.current-level {
margin-bottom: 12px;
padding: 8px;
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
}
.nsfw-level-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.nsfw-level-btn {
flex: 1 0 calc(33% - 8px);
padding: 8px;
border-radius: var(--border-radius-xs);
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
}
.nsfw-level-btn:hover {
background: var(--lora-border);
}
.nsfw-level-btn.active {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}

View File

@@ -110,7 +110,7 @@ body.modal-open {
margin-top: var(--space-3);
}
.cancel-btn, .delete-btn, .exclude-btn {
.cancel-btn, .delete-btn, .exclude-btn, .confirm-btn {
padding: 8px var(--space-2);
border-radius: 6px;
border: none;
@@ -131,7 +131,7 @@ body.modal-open {
}
/* Style for exclude button - different from delete button */
.exclude-btn {
.exclude-btn, .confirm-btn {
background: var(--lora-accent, #4f46e5);
color: white;
}
@@ -144,7 +144,7 @@ body.modal-open {
opacity: 0.9;
}
.exclude-btn:hover {
.exclude-btn:hover, .confirm-btn:hover {
opacity: 0.9;
background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%);
}
@@ -918,4 +918,58 @@ input:checked + .toggle-slider:before {
/* Dark theme adjustments */
[data-theme="dark"] .update-date-badge {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
/* Re-link to Civitai Modal styles */
.warning-box {
background-color: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.5);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
margin-bottom: var(--space-3);
}
.warning-box i {
color: var(--lora-warning);
margin-right: var(--space-1);
}
.warning-box ul {
padding-left: 20px;
margin: var(--space-1) 0;
}
.warning-box li {
margin-bottom: 4px;
}
.input-group {
display: flex;
flex-direction: column;
margin-bottom: var(--space-2);
}
.input-group label {
margin-bottom: var(--space-1);
font-weight: 500;
}
.input-group input {
padding: 8px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
}
.input-error {
color: var(--lora-error);
font-size: 0.9em;
min-height: 20px;
margin-top: 4px;
}
[data-theme="dark"] .warning-box {
background-color: rgba(255, 193, 7, 0.05);
border-color: rgba(255, 193, 7, 0.3);
}

View File

@@ -1,372 +0,0 @@
import { refreshSingleLoraMetadata } from '../api/loraApi.js';
import { showToast, getNSFWLevelName } from '../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../utils/constants.js';
import { getStorageItem } from '../utils/storageHelpers.js';
export class LoraContextMenu {
constructor() {
this.menu = document.getElementById('loraContextMenu');
this.currentCard = null;
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.init();
}
init() {
document.addEventListener('click', () => this.hideMenu());
document.addEventListener('contextmenu', (e) => {
const card = e.target.closest('.lora-card');
if (!card) {
this.hideMenu();
return;
}
e.preventDefault();
this.showMenu(e.clientX, e.clientY, card);
});
this.menu.addEventListener('click', (e) => {
const menuItem = e.target.closest('.context-menu-item');
if (!menuItem || !this.currentCard) return;
const action = menuItem.dataset.action;
if (!action) return;
switch(action) {
case 'detail':
// Trigger the main card click which shows the modal
this.currentCard.click();
break;
case 'civitai':
// Only trigger if the card is from civitai
if (this.currentCard.dataset.from_civitai === 'true') {
if (this.currentCard.dataset.meta === '{}') {
showToast('Please fetch metadata from CivitAI first', 'info');
} else {
this.currentCard.querySelector('.fa-globe')?.click();
}
} else {
showToast('No CivitAI information available', 'info');
}
break;
case 'copyname':
this.currentCard.querySelector('.fa-copy')?.click();
break;
case 'preview':
this.currentCard.querySelector('.fa-image')?.click();
break;
case 'delete':
this.currentCard.querySelector('.fa-trash')?.click();
break;
case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath);
break;
case 'refresh-metadata':
refreshSingleLoraMetadata(this.currentCard.dataset.filepath);
break;
case 'set-nsfw':
this.showNSFWLevelSelector(null, null, this.currentCard);
break;
}
this.hideMenu();
});
// Initialize NSFW Level Selector events
this.initNSFWSelector();
}
initNSFWSelector() {
// Close button
const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector');
closeBtn.addEventListener('click', () => {
this.nsfwSelector.style.display = 'none';
});
// Level buttons
const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn');
levelButtons.forEach(btn => {
btn.addEventListener('click', async () => {
const level = parseInt(btn.dataset.level);
const filePath = this.nsfwSelector.dataset.cardPath;
if (!filePath) return;
try {
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
// Update card data
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
let metaData = {};
try {
metaData = JSON.parse(card.dataset.meta || '{}');
} catch (err) {
console.error('Error parsing metadata:', err);
}
metaData.preview_nsfw_level = level;
card.dataset.meta = JSON.stringify(metaData);
card.dataset.nsfwLevel = level.toString();
// Apply blur effect immediately
this.updateCardBlurEffect(card, level);
}
showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success');
this.nsfwSelector.style.display = 'none';
} catch (error) {
showToast(`Failed to set content rating: ${error.message}`, 'error');
}
});
});
// Close when clicking outside
document.addEventListener('click', (e) => {
if (this.nsfwSelector.style.display === 'block' &&
!this.nsfwSelector.contains(e.target) &&
!e.target.closest('.context-menu-item[data-action="set-nsfw"]')) {
this.nsfwSelector.style.display = 'none';
}
});
}
async saveModelMetadata(filePath, data) {
const response = await fetch('/api/loras/save-metadata', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) {
throw new Error('Failed to save metadata');
}
return await response.json();
}
updateCardBlurEffect(card, level) {
// Get user settings for blur threshold
const blurThreshold = parseInt(getStorageItem('nsfwBlurLevel') || '4');
// Get card preview container
const previewContainer = card.querySelector('.card-preview');
if (!previewContainer) return;
// Get preview media element
const previewMedia = previewContainer.querySelector('img') || previewContainer.querySelector('video');
if (!previewMedia) return;
// Check if blur should be applied
if (level >= blurThreshold) {
// Add blur class to the preview container
previewContainer.classList.add('blurred');
// Get or create the NSFW overlay
let nsfwOverlay = previewContainer.querySelector('.nsfw-overlay');
if (!nsfwOverlay) {
// Create new overlay
nsfwOverlay = document.createElement('div');
nsfwOverlay.className = 'nsfw-overlay';
// Create and configure the warning content
const warningContent = document.createElement('div');
warningContent.className = 'nsfw-warning';
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
if (level >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (level >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (level >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
// Add warning text and show button
warningContent.innerHTML = `
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
`;
// Add click event to the show button
const showBtn = warningContent.querySelector('.show-content-btn');
showBtn.addEventListener('click', (e) => {
e.stopPropagation();
previewContainer.classList.remove('blurred');
nsfwOverlay.style.display = 'none';
// Update toggle button icon if it exists
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
});
nsfwOverlay.appendChild(warningContent);
previewContainer.appendChild(nsfwOverlay);
} else {
// Update existing overlay
const warningText = nsfwOverlay.querySelector('p');
if (warningText) {
let nsfwText = "Mature Content";
if (level >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (level >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (level >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
warningText.textContent = nsfwText;
}
nsfwOverlay.style.display = 'flex';
}
// Get or create the toggle button in the header
const cardHeader = previewContainer.querySelector('.card-header');
if (cardHeader) {
let toggleBtn = cardHeader.querySelector('.toggle-blur-btn');
if (!toggleBtn) {
toggleBtn = document.createElement('button');
toggleBtn.className = 'toggle-blur-btn';
toggleBtn.title = 'Toggle blur';
toggleBtn.innerHTML = '<i class="fas fa-eye"></i>';
// Add click event to toggle button
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isBlurred = previewContainer.classList.toggle('blurred');
const icon = toggleBtn.querySelector('i');
// Update icon and overlay visibility
if (isBlurred) {
icon.className = 'fas fa-eye';
nsfwOverlay.style.display = 'flex';
} else {
icon.className = 'fas fa-eye-slash';
nsfwOverlay.style.display = 'none';
}
});
// Add to the beginning of header
cardHeader.insertBefore(toggleBtn, cardHeader.firstChild);
// Update base model label class
const baseModelLabel = cardHeader.querySelector('.base-model-label');
if (baseModelLabel && !baseModelLabel.classList.contains('with-toggle')) {
baseModelLabel.classList.add('with-toggle');
}
} else {
// Update existing toggle button
toggleBtn.querySelector('i').className = 'fas fa-eye';
}
}
} else {
// Remove blur
previewContainer.classList.remove('blurred');
// Hide overlay if it exists
const overlay = previewContainer.querySelector('.nsfw-overlay');
if (overlay) overlay.style.display = 'none';
// Update or remove toggle button
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
// We'll leave the button but update the icon
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
}
}
showNSFWLevelSelector(x, y, card) {
const selector = document.getElementById('nsfwLevelSelector');
const currentLevelEl = document.getElementById('currentNSFWLevel');
// Get current NSFW level
let currentLevel = 0;
try {
const metaData = JSON.parse(card.dataset.meta || '{}');
currentLevel = metaData.preview_nsfw_level || 0;
// Update if we have no recorded level but have a dataset attribute
if (!currentLevel && card.dataset.nsfwLevel) {
currentLevel = parseInt(card.dataset.nsfwLevel) || 0;
}
} catch (err) {
console.error('Error parsing metadata:', err);
}
currentLevelEl.textContent = getNSFWLevelName(currentLevel);
// Position the selector
if (x && y) {
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const selectorRect = selector.getBoundingClientRect();
// Center the selector if no coordinates provided
let finalX = (viewportWidth - selectorRect.width) / 2;
let finalY = (viewportHeight - selectorRect.height) / 2;
selector.style.left = `${finalX}px`;
selector.style.top = `${finalY}px`;
}
// Highlight current level button
document.querySelectorAll('.nsfw-level-btn').forEach(btn => {
if (parseInt(btn.dataset.level) === currentLevel) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Store reference to current card
selector.dataset.cardPath = card.dataset.filepath;
// Show selector
selector.style.display = 'block';
}
showMenu(x, y, card) {
this.currentCard = card;
this.menu.style.display = 'block';
// 获取菜单尺寸
const menuRect = this.menu.getBoundingClientRect();
// 获取视口尺寸
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
// 计算最终位置 - 使用 clientX/Y不需要考虑滚动偏移
let finalX = x;
let finalY = y;
// 确保菜单不会超出右侧边界
if (x + menuRect.width > viewportWidth) {
finalX = x - menuRect.width;
}
// 确保菜单不会超出底部边界
if (y + menuRect.height > viewportHeight) {
finalY = y - menuRect.height;
}
// 直接设置位置,因为 position: fixed 是相对于视口定位的
this.menu.style.left = `${finalX}px`;
this.menu.style.top = `${finalY}px`;
}
hideMenu() {
this.menu.style.display = 'none';
this.currentCard = null;
}
}
// For backward compatibility, re-export the LoraContextMenu class
// export { LoraContextMenu } from './ContextMenu/LoraContextMenu.js';

View File

@@ -4,6 +4,9 @@ import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../util
import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.js';
import { showExcludeModal } from '../../utils/modalUtils.js';
import { modalManager } from '../../managers/ModalManager.js';
import { state } from '../../state/index.js';
import { resetAndReload } from '../../api/checkpointApi.js';
export class CheckpointContextMenu extends BaseContextMenu {
constructor() {
@@ -56,6 +59,10 @@ export class CheckpointContextMenu extends BaseContextMenu {
// Refresh metadata from CivitAI
refreshSingleCheckpointMetadata(this.currentCard.dataset.filepath);
break;
case 'relink-civitai':
// Handle re-link to Civitai
this.showRelinkCivitaiModal();
break;
case 'set-nsfw':
// Set NSFW level
this.showNSFWLevelSelector(null, null, this.currentCard);
@@ -319,4 +326,87 @@ export class CheckpointContextMenu extends BaseContextMenu {
// Show selector
selector.style.display = 'block';
}
showRelinkCivitaiModal() {
const filePath = this.currentCard.dataset.filepath;
if (!filePath) return;
// Set up confirm button handler
const confirmBtn = document.getElementById('confirmRelinkBtn');
const urlInput = document.getElementById('civitaiModelUrl');
const errorDiv = document.getElementById('civitaiModelUrlError');
// Remove previous event listener if exists
if (this._boundRelinkHandler) {
confirmBtn.removeEventListener('click', this._boundRelinkHandler);
}
// Create new bound handler
this._boundRelinkHandler = async () => {
const url = urlInput.value.trim();
const modelVersionId = this.extractModelVersionId(url);
if (!modelVersionId) {
errorDiv.textContent = 'Invalid URL format. Must include modelVersionId parameter.';
return;
}
errorDiv.textContent = '';
modalManager.closeModal('relinkCivitaiModal');
try {
state.loadingManager.showSimpleLoading('Re-linking to Civitai...');
const response = await fetch('/api/checkpoints/relink-civitai', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_path: filePath,
model_version_id: modelVersionId
})
});
if (!response.ok) {
throw new Error(`Failed to re-link model: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
showToast('Model successfully re-linked to Civitai', 'success');
// Reload the current view to show updated data
await resetAndReload();
} else {
throw new Error(data.error || 'Failed to re-link model');
}
} catch (error) {
console.error('Error re-linking model:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
state.loadingManager.hide();
}
};
// Set new event listener
confirmBtn.addEventListener('click', this._boundRelinkHandler);
// Clear previous input
urlInput.value = '';
errorDiv.textContent = '';
// Show modal
modalManager.showModal('relinkCivitaiModal');
}
extractModelVersionId(url) {
try {
const parsedUrl = new URL(url);
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return modelVersionId;
} catch (e) {
return null;
}
}
}

View File

@@ -1,9 +1,11 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { refreshSingleLoraMetadata, saveModelMetadata, replacePreview } from '../../api/loraApi.js';
import { refreshSingleLoraMetadata, saveModelMetadata, replacePreview, resetAndReload } from '../../api/loraApi.js';
import { showToast, getNSFWLevelName, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.js';
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
import { modalManager } from '../../managers/ModalManager.js';
import { state } from '../../state/index.js';
export class LoraContextMenu extends BaseContextMenu {
constructor() {
@@ -64,6 +66,9 @@ export class LoraContextMenu extends BaseContextMenu {
case 'refresh-metadata':
refreshSingleLoraMetadata(this.currentCard.dataset.filepath);
break;
case 'relink-civitai':
this.showRelinkCivitaiModal();
break;
case 'set-nsfw':
this.showNSFWLevelSelector(null, null, this.currentCard);
break;
@@ -93,6 +98,90 @@ export class LoraContextMenu extends BaseContextMenu {
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
}
// New method to handle re-link to Civitai
showRelinkCivitaiModal() {
const filePath = this.currentCard.dataset.filepath;
if (!filePath) return;
// Set up confirm button handler
const confirmBtn = document.getElementById('confirmRelinkBtn');
const urlInput = document.getElementById('civitaiModelUrl');
const errorDiv = document.getElementById('civitaiModelUrlError');
// Remove previous event listener if exists
if (this._boundRelinkHandler) {
confirmBtn.removeEventListener('click', this._boundRelinkHandler);
}
// Create new bound handler
this._boundRelinkHandler = async () => {
const url = urlInput.value.trim();
const modelVersionId = this.extractModelVersionId(url);
if (!modelVersionId) {
errorDiv.textContent = 'Invalid URL format. Must include modelVersionId parameter.';
return;
}
errorDiv.textContent = '';
modalManager.closeModal('relinkCivitaiModal');
try {
state.loadingManager.showSimpleLoading('Re-linking to Civitai...');
const response = await fetch('/api/relink-civitai', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_path: filePath,
model_version_id: modelVersionId
})
});
if (!response.ok) {
throw new Error(`Failed to re-link model: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
showToast('Model successfully re-linked to Civitai', 'success');
// Reload the current view to show updated data
await resetAndReload();
} else {
throw new Error(data.error || 'Failed to re-link model');
}
} catch (error) {
console.error('Error re-linking model:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
state.loadingManager.hide();
}
};
// Set new event listener
confirmBtn.addEventListener('click', this._boundRelinkHandler);
// Clear previous input
urlInput.value = '';
errorDiv.textContent = '';
// Show modal
modalManager.showModal('relinkCivitaiModal');
}
extractModelVersionId(url) {
try {
const parsedUrl = new URL(url);
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return modelVersionId;
} catch (e) {
return null;
}
}
// NSFW Selector methods from the original context menu
initNSFWSelector() {
// Close button

View File

@@ -221,6 +221,19 @@ export class ModalManager {
});
}
// Add relinkCivitaiModal registration
const relinkCivitaiModal = document.getElementById('relinkCivitaiModal');
if (relinkCivitaiModal) {
this.registerModal('relinkCivitaiModal', {
element: relinkCivitaiModal,
onClose: () => {
this.getModal('relinkCivitaiModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true;
}

View File

@@ -18,6 +18,7 @@
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
<div class="context-menu-item" data-action="civitai"><i class="fas fa-external-link-alt"></i> View on CivitAI</div>
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> Refresh Civitai Data</div>
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> Re-link to Civitai</div>
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> Copy Model Filename</div>
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> Open Examples Folder</div>
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> Replace Preview</div>

View File

@@ -8,6 +8,9 @@
<div class="context-menu-item" data-action="refresh-metadata">
<i class="fas fa-sync"></i> Refresh Civitai Data
</div>
<div class="context-menu-item" data-action="relink-civitai">
<i class="fas fa-link"></i> Re-link to Civitai
</div>
<div class="context-menu-item" data-action="copyname">
<i class="fas fa-copy"></i> Copy LoRA Syntax
</div>

View File

@@ -571,4 +571,34 @@
</div>
</div>
</div>
</div>
<!-- Re-link to Civitai Modal -->
<div id="relinkCivitaiModal" class="modal">
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('relinkCivitaiModal')">&times;</button>
<h2>Re-link to Civitai</h2>
<div class="warning-box">
<i class="fas fa-exclamation-triangle"></i>
<p><strong>Warning:</strong> This is a potentially destructive operation. Re-linking will:</p>
<ul>
<li>Override existing metadata</li>
<li>Potentially modify the model hash</li>
<li>May have other unintended consequences</li>
</ul>
<p>Only proceed if you're sure this is what you want.</p>
</div>
<div class="input-group">
<label for="civitaiModelUrl">Civitai Model URL:</label>
<input type="text" id="civitaiModelUrl" placeholder="https://civitai.com/models/1098030?modelVersionId=1233411" />
<div class="input-error" id="civitaiModelUrlError"></div>
<div class="input-help">
The URL must include the modelVersionId parameter.
</div>
</div>
<div class="modal-actions">
<button class="cancel-btn" onclick="modalManager.closeModal('relinkCivitaiModal')">Cancel</button>
<button class="confirm-btn" id="confirmRelinkBtn">Confirm Re-link</button>
</div>
</div>
</div>