feat(relink): accept CivitArchive URLs when linking models

This commit is contained in:
Will Miao
2026-08-26 21:31:30 +08:00
parent 3025c64fea
commit 641a61f804
21 changed files with 756 additions and 121 deletions
@@ -6,7 +6,7 @@ import { bulkManager } from '../../managers/BulkManager.js';
import { MODEL_CONFIG } from '../../api/apiConfig.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js';
import { classifyModelRelinkUrl } from '../../utils/civitaiUtils.js';
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
export const ModelContextMenuMixin = {
@@ -106,6 +106,17 @@ export const ModelContextMenuMixin = {
},
// Civitai re-linking methods
getModelTypePrefix() {
// Map the mixin model type to its API route prefix; the relink route
// exists for all model types via COMMON_ROUTE_DEFINITIONS.
const prefixMap = {
lora: 'loras',
checkpoint: 'checkpoints',
embedding: 'embeddings'
};
return prefixMap[this.modelType] || 'loras';
},
showRelinkCivitaiModal() {
const filePath = this.currentCard.dataset.filepath;
if (!filePath) return;
@@ -123,43 +134,55 @@ export const ModelContextMenuMixin = {
// Create new bound handler
this._boundRelinkHandler = async () => {
const url = urlInput.value.trim();
const { modelId, modelVersionId } = this.extractModelVersionId(url);
if (!modelId) {
errorDiv.textContent = 'Invalid URL format. Must include model ID.';
const { source, modelId, modelVersionId } = classifyModelRelinkUrl(url);
if (!source || !modelId) {
errorDiv.textContent = 'Invalid URL format. Expected: https://civitai.com/models/{modelId} or https://civarchive.com/models/{modelId}';
return;
}
errorDiv.textContent = '';
modalManager.closeModal('relinkCivitaiModal');
try {
state.loadingManager.showSimpleLoading('Re-linking to Civitai...');
const endpoint = this.modelType === 'checkpoint' ?
'/api/lm/checkpoints/relink-civitai' :
'/api/lm/loras/relink-civitai';
const isCivArchive = source === 'civarchive';
state.loadingManager.showSimpleLoading(
isCivArchive ? 'Re-linking via CivitArchive...' : 'Re-linking to Civitai...'
);
const endpoint = `/api/lm/${this.getModelTypePrefix()}/relink-civitai`;
const payload = {
file_path: filePath,
model_id: modelId,
model_version_id: modelVersionId
};
// Omitted source keeps backend default-provider behaviour; only
// civarchive pins the provider explicitly.
if (isCivArchive) {
payload.source = source;
}
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_path: filePath,
model_id: modelId,
model_version_id: modelVersionId
})
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`Failed to re-link model: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
showToast('toast.contextMenu.relinkSuccess', {}, 'success');
showToast(
isCivArchive ? 'toast.contextMenu.linkCivArchSuccess' : 'toast.contextMenu.relinkSuccess',
{},
'success'
);
// Reload the current view to show updated data
await this.resetAndReload();
} else {
@@ -255,10 +278,6 @@ export const ModelContextMenuMixin = {
setTimeout(() => urlInput.focus(), 50);
},
extractModelVersionId(url) {
return extractCivitaiModelUrlParts(url);
},
parseModelId(value) {
if (value === undefined || value === null || value === '') {
return null;
+41
View File
@@ -207,6 +207,47 @@ export function extractCivitaiModelUrlParts(url) {
}
}
const CIVITARCHIVE_PAGE_HOSTS = new Set([
'civitaiarchive.com',
'civarchive.com',
]);
/**
* Classify a relink URL by its hosting source and extract ids.
* CivitArchive mirrors the Civitai id namespace, so both sources resolve to
* the same {modelId, modelVersionId} shape; only `source` differs.
*/
export function classifyModelRelinkUrl(url) {
if (!url || typeof url !== 'string') {
return { source: null, modelId: null, modelVersionId: null };
}
let parsedUrl;
try {
parsedUrl = new URL(url.trim());
} catch (e) {
return { source: null, modelId: null, modelVersionId: null };
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return { source: null, modelId: null, modelVersionId: null };
}
const hostname = parsedUrl.hostname.toLowerCase().replace(/^www\./, '');
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
const modelId = pathMatch ? pathMatch[1] : null;
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
if (SUPPORTED_CIVITAI_PAGE_HOSTS.has(hostname)) {
return { source: 'civitai', modelId, modelVersionId };
}
if (CIVITARCHIVE_PAGE_HOSTS.has(hostname) && modelId) {
return { source: 'civarchive', modelId, modelVersionId };
}
return { source: null, modelId: null, modelVersionId: null };
}
export function extractCivitaiImageId(url) {
if (!url) {
return null;