diff --git a/py/routes/checkpoints_routes.py b/py/routes/checkpoints_routes.py index 53c94a84..99597bf3 100644 --- a/py/routes/checkpoints_routes.py +++ b/py/routes/checkpoints_routes.py @@ -51,6 +51,7 @@ class CheckpointsRoutes: app.router.add_post('/api/checkpoints/fetch-civitai', self.fetch_civitai) 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 async def get_checkpoints(self, request): """Get paginated checkpoint data""" @@ -522,3 +523,44 @@ class CheckpointsRoutes: "success": False, "error": str(e) }, status=500) + + async def save_metadata(self, request: web.Request) -> web.Response: + """Handle saving metadata updates for checkpoints""" + try: + if self.scanner is None: + self.scanner = await ServiceRegistry.get_checkpoint_scanner() + + data = await request.json() + file_path = data.get('file_path') + if not file_path: + return web.Response(text='File path is required', status=400) + + # Remove file path from data to avoid saving it + metadata_updates = {k: v for k, v in data.items() if k != 'file_path'} + + # Get metadata file path + metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' + + # Load existing metadata + metadata = await ModelRouteUtils.load_local_metadata(metadata_path) + + # Update metadata + metadata.update(metadata_updates) + + # Save updated metadata + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + + # Update cache + await self.scanner.update_single_model_cache(file_path, file_path, metadata) + + # If model_name was updated, resort the cache + if 'model_name' in metadata_updates: + cache = await self.scanner.get_cached_data() + await cache.resort(name_only=True) + + return web.json_response({'success': True}) + + except Exception as e: + logger.error(f"Error saving checkpoint metadata: {e}", exc_info=True) + return web.Response(text=str(e), status=500) diff --git a/static/js/components/CheckpointCard.js b/static/js/components/CheckpointCard.js index c000ecfc..0ff1271d 100644 --- a/static/js/components/CheckpointCard.js +++ b/static/js/components/CheckpointCard.js @@ -15,6 +15,7 @@ export function createCheckpointCard(checkpoint) { card.dataset.modified = checkpoint.modified; card.dataset.file_size = checkpoint.file_size; card.dataset.from_civitai = checkpoint.from_civitai; + card.dataset.notes = checkpoint.notes || ''; card.dataset.base_model = checkpoint.base_model || 'Unknown'; // Store metadata if available @@ -124,6 +125,7 @@ export function createCheckpointCard(checkpoint) { file_size: parseInt(card.dataset.file_size || '0'), from_civitai: card.dataset.from_civitai === 'true', base_model: card.dataset.base_model, + notes: card.dataset.notes || '', preview_url: versionedPreviewUrl, // Parse civitai metadata from the card's dataset civitai: (() => { diff --git a/static/js/components/checkpointModal/ModelMetadata.js b/static/js/components/checkpointModal/ModelMetadata.js index 56e84266..4bf7b2b3 100644 --- a/static/js/components/checkpointModal/ModelMetadata.js +++ b/static/js/components/checkpointModal/ModelMetadata.js @@ -4,6 +4,7 @@ */ import { showToast } from '../../utils/uiHelpers.js'; import { BASE_MODELS } from '../../utils/constants.js'; +import { updateCheckpointCard } from '../../utils/cardUpdater.js'; /** * Save model metadata to the server @@ -12,7 +13,7 @@ import { BASE_MODELS } from '../../utils/constants.js'; * @returns {Promise} - Promise that resolves with the server response */ export async function saveModelMetadata(filePath, data) { - const response = await fetch('/checkpoints/api/save-metadata', { + const response = await fetch('/api/checkpoints/save-metadata', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -32,8 +33,9 @@ export async function saveModelMetadata(filePath, data) { /** * Set up model name editing functionality + * @param {string} filePath - The full file path of the model. */ -export function setupModelNameEditing() { +export function setupModelNameEditing(filePath) { const modelNameContent = document.querySelector('.model-name-content'); const editBtn = document.querySelector('.edit-model-name-btn'); @@ -76,10 +78,7 @@ export function setupModelNameEditing() { if (this.textContent.trim() === '') { // Restore original model name if empty - const filePath = document.querySelector('#checkpointModal .modal-content') - .querySelector('.file-path').textContent + - document.querySelector('#checkpointModal .modal-content') - .querySelector('#file-name').textContent; + // Use the passed filePath to find the card const checkpointCard = document.querySelector(`.checkpoint-card[data-filepath="${filePath}"]`); if (checkpointCard) { this.textContent = checkpointCard.dataset.model_name; @@ -91,10 +90,7 @@ export function setupModelNameEditing() { modelNameContent.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); - const filePath = document.querySelector('#checkpointModal .modal-content') - .querySelector('.file-path').textContent + - document.querySelector('#checkpointModal .modal-content') - .querySelector('#file-name').textContent; + // Use the passed filePath saveModelName(filePath); this.blur(); } @@ -142,22 +138,15 @@ async function saveModelName(filePath) { try { await saveModelMetadata(filePath, { model_name: newModelName }); - // Update the corresponding checkpoint card's dataset and display - const checkpointCard = document.querySelector(`.checkpoint-card[data-filepath="${filePath}"]`); - if (checkpointCard) { - checkpointCard.dataset.model_name = newModelName; - const titleElement = checkpointCard.querySelector('.card-title'); - if (titleElement) { - titleElement.textContent = newModelName; - } - } + // Update the card with the new model name + updateCheckpointCard(filePath, { name: newModelName }); showToast('Model name updated successfully', 'success'); - // Reload the page to reflect the sorted order - setTimeout(() => { - window.location.reload(); - }, 1500); + // No need to reload the entire page + // setTimeout(() => { + // window.location.reload(); + // }, 1500); } catch (error) { showToast('Failed to update model name', 'error'); } @@ -165,8 +154,9 @@ async function saveModelName(filePath) { /** * Set up base model editing functionality + * @param {string} filePath - The full file path of the model. */ -export function setupBaseModelEditing() { +export function setupBaseModelEditing(filePath) { const baseModelContent = document.querySelector('.base-model-content'); const editBtn = document.querySelector('.edit-base-model-btn'); @@ -269,13 +259,7 @@ export function setupBaseModelEditing() { // Only save if the value has actually changed if (valueChanged || baseModelContent.textContent.trim() !== originalValue) { - // Get file path for saving - const filePath = document.querySelector('#checkpointModal .modal-content') - .querySelector('.file-path').textContent + - document.querySelector('#checkpointModal .modal-content') - .querySelector('#file-name').textContent; - - // Save the changes, passing the original value for comparison + // Use the passed filePath for saving saveBaseModel(filePath, originalValue); } @@ -323,11 +307,8 @@ async function saveBaseModel(filePath, originalValue) { try { await saveModelMetadata(filePath, { base_model: newBaseModel }); - // Update the corresponding checkpoint card's dataset - const checkpointCard = document.querySelector(`.checkpoint-card[data-filepath="${filePath}"]`); - if (checkpointCard) { - checkpointCard.dataset.base_model = newBaseModel; - } + // Update the card with the new base model + updateCheckpointCard(filePath, { base_model: newBaseModel }); showToast('Base model updated successfully', 'success'); } catch (error) { @@ -337,8 +318,9 @@ async function saveBaseModel(filePath, originalValue) { /** * Set up file name editing functionality + * @param {string} filePath - The full file path of the model. */ -export function setupFileNameEditing() { +export function setupFileNameEditing(filePath) { const fileNameContent = document.querySelector('.file-name-content'); const editBtn = document.querySelector('.edit-file-name-btn'); @@ -440,10 +422,7 @@ export function setupFileNameEditing() { } try { - // Get the full file path - const filePath = document.querySelector('#checkpointModal .modal-content') - .querySelector('.file-path').textContent + originalValue; - + // Use the passed filePath (which includes the original filename) // Call API to rename the file const response = await fetch('/api/rename_checkpoint', { method: 'POST', @@ -451,7 +430,7 @@ export function setupFileNameEditing() { 'Content-Type': 'application/json', }, body: JSON.stringify({ - file_path: filePath, + file_path: filePath, // Use the full original path new_file_name: newFileName }) }); @@ -461,11 +440,24 @@ export function setupFileNameEditing() { if (result.success) { showToast('File name updated successfully', 'success'); + // Get the new file path from the result + const pathParts = filePath.split(/[\\/]/); + pathParts.pop(); // Remove old filename + const newFilePath = [...pathParts, newFileName].join('/'); + // Update the checkpoint card with new file path - const checkpointCard = document.querySelector(`.checkpoint-card[data-filepath="${filePath}"]`); - if (checkpointCard) { - const newFilePath = filePath.replace(originalValue, newFileName); - checkpointCard.dataset.filepath = newFilePath; + updateCheckpointCard(filePath, { + filepath: newFilePath, + file_name: newFileName + }); + + // Update the file name display in the modal + document.querySelector('#file-name').textContent = newFileName; + + // Update the modal's data-filepath attribute + const modalContent = document.querySelector('#checkpointModal .modal-content'); + if (modalContent) { + modalContent.dataset.filepath = newFilePath; } // Reload the page after a short delay to reflect changes diff --git a/static/js/components/checkpointModal/index.js b/static/js/components/checkpointModal/index.js index 479b0c9b..879f9218 100644 --- a/static/js/components/checkpointModal/index.js +++ b/static/js/components/checkpointModal/index.js @@ -15,6 +15,7 @@ import { saveModelMetadata } from './ModelMetadata.js'; import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js'; +import { updateCheckpointCard } from '../../utils/cardUpdater.js'; /** * Display the checkpoint modal with the given checkpoint data @@ -119,13 +120,13 @@ export function showCheckpointModal(checkpoint) { `; modalManager.showModal('checkpointModal', content); - setupEditableFields(); + setupEditableFields(checkpoint.file_path); setupShowcaseScroll(); setupTabSwitching(); setupTagTooltip(); - setupModelNameEditing(); - setupBaseModelEditing(); - setupFileNameEditing(); + setupModelNameEditing(checkpoint.file_path); + setupBaseModelEditing(checkpoint.file_path); + setupFileNameEditing(checkpoint.file_path); // If we have a model ID but no description, fetch it if (checkpoint.civitai?.modelId && !checkpoint.modelDescription) { @@ -135,8 +136,9 @@ export function showCheckpointModal(checkpoint) { /** * Set up editable fields in the checkpoint modal +* @param {string} filePath - The full file path of the model. */ -function setupEditableFields() { +function setupEditableFields(filePath) { const editableFields = document.querySelectorAll('.editable-field [contenteditable]'); editableFields.forEach(field => { @@ -165,10 +167,6 @@ function setupEditableFields() { return; } e.preventDefault(); - const filePath = document.querySelector('#checkpointModal .modal-content') - .querySelector('.file-path').textContent + - document.querySelector('#checkpointModal .modal-content') - .querySelector('#file-name').textContent; await saveNotes(filePath); } }); @@ -185,10 +183,7 @@ async function saveNotes(filePath) { await saveModelMetadata(filePath, { notes: content }); // Update the corresponding checkpoint card's dataset - const checkpointCard = document.querySelector(`.checkpoint-card[data-filepath="${filePath}"]`); - if (checkpointCard) { - checkpointCard.dataset.notes = content; - } + updateCheckpointCard(filePath, { notes: content }); showToast('Notes saved successfully', 'success'); } catch (error) { diff --git a/static/js/components/loraModal/ModelMetadata.js b/static/js/components/loraModal/ModelMetadata.js index 6e4c7632..b5eb72ac 100644 --- a/static/js/components/loraModal/ModelMetadata.js +++ b/static/js/components/loraModal/ModelMetadata.js @@ -4,6 +4,7 @@ */ import { showToast } from '../../utils/uiHelpers.js'; import { BASE_MODELS } from '../../utils/constants.js'; +import { updateLoraCard } from '../../utils/cardUpdater.js'; /** * 保存模型元数据到服务器 @@ -32,13 +33,17 @@ export async function saveModelMetadata(filePath, data) { /** * 设置模型名称编辑功能 + * @param {string} filePath - 文件路径 */ -export function setupModelNameEditing() { +export function setupModelNameEditing(filePath) { const modelNameContent = document.querySelector('.model-name-content'); const editBtn = document.querySelector('.edit-model-name-btn'); if (!modelNameContent || !editBtn) return; + // Store the file path in a data attribute for later use + modelNameContent.dataset.filePath = filePath; + // Show edit button on hover const modelNameHeader = document.querySelector('.model-name-header'); modelNameHeader.addEventListener('mouseenter', () => { @@ -76,10 +81,7 @@ export function setupModelNameEditing() { if (this.textContent.trim() === '') { // Restore original model name if empty - const filePath = document.querySelector('#loraModal .modal-content') - .querySelector('.file-path').textContent + - document.querySelector('#loraModal .modal-content') - .querySelector('#file-name').textContent + '.safetensors'; + const filePath = this.dataset.filePath; const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); if (loraCard) { this.textContent = loraCard.dataset.model_name; @@ -91,10 +93,7 @@ export function setupModelNameEditing() { modelNameContent.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); - const filePath = document.querySelector('#loraModal .modal-content') - .querySelector('.file-path').textContent + - document.querySelector('#loraModal .modal-content') - .querySelector('#file-name').textContent + '.safetensors'; + const filePath = this.dataset.filePath; saveModelName(filePath); this.blur(); } @@ -144,21 +143,9 @@ async function saveModelName(filePath) { await saveModelMetadata(filePath, { model_name: newModelName }); // Update the corresponding lora card's dataset and display - const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (loraCard) { - loraCard.dataset.model_name = newModelName; - const titleElement = loraCard.querySelector('.card-title'); - if (titleElement) { - titleElement.textContent = newModelName; - } - } + updateLoraCard(filePath, { model_name: newModelName }); showToast('Model name updated successfully', 'success'); - - // Reload the page to reflect the sorted order - setTimeout(() => { - window.location.reload(); - }, 1500); } catch (error) { showToast('Failed to update model name', 'error'); } @@ -166,13 +153,17 @@ async function saveModelName(filePath) { /** * 设置基础模型编辑功能 + * @param {string} filePath - 文件路径 */ -export function setupBaseModelEditing() { +export function setupBaseModelEditing(filePath) { const baseModelContent = document.querySelector('.base-model-content'); const editBtn = document.querySelector('.edit-base-model-btn'); if (!baseModelContent || !editBtn) return; + // Store the file path in a data attribute for later use + baseModelContent.dataset.filePath = filePath; + // Show edit button on hover const baseModelDisplay = document.querySelector('.base-model-display'); baseModelDisplay.addEventListener('mouseenter', () => { @@ -270,11 +261,8 @@ export function setupBaseModelEditing() { // Only save if the value has actually changed if (valueChanged || baseModelContent.textContent.trim() !== originalValue) { - // Get file path for saving - const filePath = document.querySelector('#loraModal .modal-content') - .querySelector('.file-path').textContent + - document.querySelector('#loraModal .modal-content') - .querySelector('#file-name').textContent + '.safetensors'; + // Get file path from the dataset + const filePath = baseModelContent.dataset.filePath; // Save the changes, passing the original value for comparison saveBaseModel(filePath, originalValue); @@ -325,10 +313,7 @@ async function saveBaseModel(filePath, originalValue) { await saveModelMetadata(filePath, { base_model: newBaseModel }); // Update the corresponding lora card's dataset - const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (loraCard) { - loraCard.dataset.base_model = newBaseModel; - } + updateLoraCard(filePath, { base_model: newBaseModel }); showToast('Base model updated successfully', 'success'); } catch (error) { @@ -338,13 +323,17 @@ async function saveBaseModel(filePath, originalValue) { /** * 设置文件名编辑功能 + * @param {string} filePath - 文件路径 */ -export function setupFileNameEditing() { +export function setupFileNameEditing(filePath) { const fileNameContent = document.querySelector('.file-name-content'); const editBtn = document.querySelector('.edit-file-name-btn'); if (!fileNameContent || !editBtn) return; + // Store the original file path + fileNameContent.dataset.filePath = filePath; + // Show edit button on hover const fileNameWrapper = document.querySelector('.file-name-wrapper'); fileNameWrapper.addEventListener('mouseenter', () => { @@ -441,9 +430,8 @@ export function setupFileNameEditing() { } try { - // Get the full file path - const filePath = document.querySelector('#loraModal .modal-content') - .querySelector('.file-path').textContent + originalValue + '.safetensors'; + // Get the file path from the dataset + const filePath = this.dataset.filePath; // Call API to rename the file const response = await fetch('/api/rename_lora', { @@ -462,12 +450,9 @@ export function setupFileNameEditing() { if (result.success) { showToast('File name updated successfully', 'success'); - // Update the LoRA card with new file path - const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (loraCard) { - const newFilePath = filePath.replace(originalValue, newFileName); - loraCard.dataset.filepath = newFilePath; - } + // Get the new file path and update the card + const newFilePath = filePath.replace(originalValue, newFileName); + updateLoraCard(filePath, {}, newFilePath); // Reload the page after a short delay to reflect changes setTimeout(() => { diff --git a/static/js/components/loraModal/index.js b/static/js/components/loraModal/index.js index 0cc44203..017a1b2a 100644 --- a/static/js/components/loraModal/index.js +++ b/static/js/components/loraModal/index.js @@ -18,6 +18,7 @@ import { saveModelMetadata } from './ModelMetadata.js'; import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js'; +import { updateLoraCard } from '../../utils/cardUpdater.js'; /** * 显示LoRA模型弹窗 @@ -152,14 +153,14 @@ export function showLoraModal(lora) { `; modalManager.showModal('loraModal', content); - setupEditableFields(); + setupEditableFields(lora.file_path); setupShowcaseScroll(); setupTabSwitching(); setupTagTooltip(); setupTriggerWordsEditMode(); - setupModelNameEditing(); - setupBaseModelEditing(); - setupFileNameEditing(); + setupModelNameEditing(lora.file_path); + setupBaseModelEditing(lora.file_path); + setupFileNameEditing(lora.file_path); // If we have a model ID but no description, fetch it if (lora.civitai?.modelId && !lora.modelDescription) { @@ -188,10 +189,7 @@ window.saveNotes = async function(filePath) { await saveModelMetadata(filePath, { notes: content }); // Update the corresponding lora card's dataset - const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - if (loraCard) { - loraCard.dataset.notes = content; - } + updateLoraCard(filePath, { notes: content }); showToast('Notes saved successfully', 'success'); } catch (error) { @@ -199,7 +197,7 @@ window.saveNotes = async function(filePath) { } }; -function setupEditableFields() { +function setupEditableFields(filePath) { const editableFields = document.querySelectorAll('.editable-field [contenteditable]'); editableFields.forEach(field => { @@ -247,11 +245,6 @@ function setupEditableFields() { if (!key || !value) return; - const filePath = document.querySelector('#loraModal .modal-content') - .querySelector('.file-path').textContent + - document.querySelector('#loraModal .modal-content') - .querySelector('#file-name').textContent + '.safetensors'; - const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); const currentPresets = parsePresets(loraCard.dataset.usage_tips); @@ -262,7 +255,9 @@ function setupEditableFields() { usage_tips: newPresetsJson }); - loraCard.dataset.usage_tips = newPresetsJson; + // Update the card with the new usage tips + updateLoraCard(filePath, { usage_tips: newPresetsJson }); + presetTags.innerHTML = renderPresetTags(currentPresets); presetSelector.value = ''; @@ -280,10 +275,6 @@ function setupEditableFields() { return; } e.preventDefault(); - const filePath = document.querySelector('#loraModal .modal-content') - .querySelector('.file-path').textContent + - document.querySelector('#loraModal .modal-content') - .querySelector('#file-name').textContent + '.safetensors'; await saveNotes(filePath); } }); diff --git a/static/js/utils/cardUpdater.js b/static/js/utils/cardUpdater.js new file mode 100644 index 00000000..7f5b69f6 --- /dev/null +++ b/static/js/utils/cardUpdater.js @@ -0,0 +1,123 @@ +/** + * Utility functions to update checkpoint cards after modal edits + */ + +/** + * Update the checkpoint card after metadata edits in the modal + * @param {string} filePath - Path to the checkpoint file + * @param {Object} updates - Object containing the updates (model_name, base_model, etc) + */ +export function updateCheckpointCard(filePath, updates) { + // Find the card with matching filepath + const checkpointCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (!checkpointCard) return; + + // Update card dataset and visual elements based on the updates object + Object.entries(updates).forEach(([key, value]) => { + // Update dataset + checkpointCard.dataset[key] = value; + + // Update visual elements based on the property + switch(key) { + case 'name': // model_name + // Update the model name in the footer + const modelNameElement = checkpointCard.querySelector('.model-name'); + if (modelNameElement) modelNameElement.textContent = value; + break; + + case 'base_model': + // Update the base model label in the card header + const baseModelLabel = checkpointCard.querySelector('.base-model-label'); + if (baseModelLabel) { + baseModelLabel.textContent = value; + baseModelLabel.title = value; + } + break; + + case 'filepath': + // The filepath was changed (file renamed), update the dataset + checkpointCard.dataset.filepath = value; + break; + + case 'tags': + // Update tags if they're displayed on the card + try { + checkpointCard.dataset.tags = JSON.stringify(value); + } catch (e) { + console.error('Failed to update tags:', e); + } + break; + + // Add other properties as needed + } + }); +} + +/** + * Update the Lora card after metadata edits in the modal + * @param {string} filePath - Path to the Lora file + * @param {Object} updates - Object containing the updates (model_name, base_model, notes, usage_tips, etc) + * @param {string} [newFilePath] - Optional new file path if the file has been renamed + */ +export function updateLoraCard(filePath, updates, newFilePath) { + // Find the card with matching filepath + const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (!loraCard) return; + + // If file was renamed, update the filepath first + if (newFilePath) { + loraCard.dataset.filepath = newFilePath; + } + + // Update card dataset and visual elements based on the updates object + Object.entries(updates).forEach(([key, value]) => { + // Update dataset + loraCard.dataset[key] = value; + + // Update visual elements based on the property + switch(key) { + case 'model_name': + // Update the model name in the card title + const titleElement = loraCard.querySelector('.card-title'); + if (titleElement) titleElement.textContent = value; + + // Also update the model name in the footer if it exists + const modelNameElement = loraCard.querySelector('.model-name'); + if (modelNameElement) modelNameElement.textContent = value; + break; + + case 'base_model': + // Update the base model label in the card header if it exists + const baseModelLabel = loraCard.querySelector('.base-model-label'); + if (baseModelLabel) { + baseModelLabel.textContent = value; + baseModelLabel.title = value; + } + break; + + case 'tags': + // Update tags if they're displayed on the card + try { + if (typeof value === 'string') { + loraCard.dataset.tags = value; + } else { + loraCard.dataset.tags = JSON.stringify(value); + } + + // If there's a tag container, update its content + const tagContainer = loraCard.querySelector('.card-tags'); + if (tagContainer) { + // This depends on how your tags are rendered + // You may need to update this logic based on your tag rendering function + } + } catch (e) { + console.error('Failed to update tags:', e); + } + break; + + // No visual updates needed for notes, usage_tips as they're typically not shown on cards + } + }); + + return loraCard; // Return the updated card element for chaining +} \ No newline at end of file