feat: Add save metadata route and update checkpoint card functionality

This commit is contained in:
Will Miao
2025-04-12 11:18:21 +08:00
parent 10a4fe04d1
commit e84a8a72c5
7 changed files with 250 additions and 120 deletions

View File

@@ -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)

View File

@@ -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: (() => {

View File

@@ -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

View File

@@ -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) {

View File

@@ -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(() => {

View File

@@ -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);
}
});

View File

@@ -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
}