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
@@ -2504,4 +2504,172 @@ describe('Interaction-level regression coverage', () => {
delete stateStub.currentPageType;
});
it('opens the relink modal from the relink-civitai menu action', async () => {
document.body.innerHTML = `
<div id="loraContextMenu" class="context-menu">
<div class="context-menu-item has-submenu" data-has-submenu="link-model">
<div class="context-submenu">
<div class="context-menu-item" data-action="relink-civitai"></div>
</div>
</div>
</div>
<div id="relinkCivitaiModal" class="modal">
<input type="text" id="civitaiModelUrl" />
<div class="input-error" id="civitaiModelUrlError"></div>
<button class="confirm-btn" id="confirmRelinkBtn"></button>
</div>
`;
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
const contextMenu = new LoraContextMenu();
const showModalSpy = vi.spyOn(contextMenu, 'showRelinkCivitaiModal').mockImplementation(() => {});
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/test.safetensors';
document.body.appendChild(card);
contextMenu.showMenu(100, 100, card);
document.querySelector('[data-action="relink-civitai"]').dispatchEvent(new Event('click', { bubbles: true }));
expect(showModalSpy).toHaveBeenCalledTimes(1);
});
it('rejects an unsupported relink URL with an inline error and no fetch', async () => {
document.body.innerHTML = `
<div id="loraContextMenu" class="context-menu"></div>
<div id="relinkCivitaiModal" class="modal">
<input type="text" id="civitaiModelUrl" />
<div class="input-error" id="civitaiModelUrlError"></div>
<button class="confirm-btn" id="confirmRelinkBtn"></button>
</div>
`;
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
const contextMenu = new LoraContextMenu();
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/test.safetensors';
document.body.appendChild(card);
contextMenu.showMenu(100, 100, card);
contextMenu.showRelinkCivitaiModal();
document.getElementById('civitaiModelUrl').value = 'https://example.com/models/123456';
await contextMenu._boundRelinkHandler();
expect(document.getElementById('civitaiModelUrlError').textContent)
.toBe('Invalid URL format. Expected: https://civitai.com/models/{modelId} or https://civarchive.com/models/{modelId}');
expect(global.fetch).not.toHaveBeenCalled();
expect(modalManagerMock.closeModal).not.toHaveBeenCalled();
});
it('posts a valid CivitArchive URL to the relink endpoint with the civarchive source', async () => {
document.body.innerHTML = `
<div id="loraContextMenu" class="context-menu"></div>
<div id="relinkCivitaiModal" class="modal">
<input type="text" id="civitaiModelUrl" />
<div class="input-error" id="civitaiModelUrlError"></div>
<button class="confirm-btn" id="confirmRelinkBtn"></button>
</div>
`;
global.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({ success: true }),
}));
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
const contextMenu = new LoraContextMenu();
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/test.safetensors';
document.body.appendChild(card);
contextMenu.showMenu(100, 100, card);
contextMenu.showRelinkCivitaiModal();
document.getElementById('civitaiModelUrl').value = 'https://civarchive.com/models/123456?modelVersionId=789012';
await contextMenu._boundRelinkHandler();
await flushAsyncTasks();
expect(modalManagerMock.closeModal).toHaveBeenCalledWith('relinkCivitaiModal');
expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalledWith('Re-linking via CivitArchive...');
expect(global.fetch).toHaveBeenCalledWith('/api/lm/loras/relink-civitai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_path: '/models/test.safetensors',
model_id: '123456',
model_version_id: '789012',
source: 'civarchive',
}),
});
expect(showToastMock).toHaveBeenCalledWith('toast.contextMenu.linkCivArchSuccess', {}, 'success');
expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
expect(loadingManagerStub.hide).toHaveBeenCalled();
});
it('posts a Civitai URL without a source key so backend defaults apply', async () => {
document.body.innerHTML = `
<div id="loraContextMenu" class="context-menu"></div>
<div id="relinkCivitaiModal" class="modal">
<input type="text" id="civitaiModelUrl" />
<div class="input-error" id="civitaiModelUrlError"></div>
<button class="confirm-btn" id="confirmRelinkBtn"></button>
</div>
`;
global.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({ success: true }),
}));
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
const contextMenu = new LoraContextMenu();
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/test.safetensors';
document.body.appendChild(card);
contextMenu.showMenu(100, 100, card);
contextMenu.showRelinkCivitaiModal();
document.getElementById('civitaiModelUrl').value = 'https://civitai.com/models/65423?modelVersionId=777';
await contextMenu._boundRelinkHandler();
await flushAsyncTasks();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/loras/relink-civitai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_path: '/models/test.safetensors',
model_id: '65423',
model_version_id: '777',
}),
});
expect(showToastMock).toHaveBeenCalledWith('toast.contextMenu.relinkSuccess', {}, 'success');
});
it('derives relink endpoint prefixes for all model types', async () => {
document.body.innerHTML = `
<div id="loraContextMenu" class="context-menu"></div>
`;
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
const contextMenu = new LoraContextMenu();
contextMenu.modelType = 'lora';
expect(contextMenu.getModelTypePrefix()).toBe('loras');
contextMenu.modelType = 'checkpoint';
expect(contextMenu.getModelTypePrefix()).toBe('checkpoints');
contextMenu.modelType = 'embedding';
expect(contextMenu.getModelTypePrefix()).toBe('embeddings');
contextMenu.modelType = 'unknown';
expect(contextMenu.getModelTypePrefix()).toBe('loras');
});
});
@@ -2,20 +2,15 @@ import { describe, expect, it } from 'vitest';
import { ModelContextMenuMixin } from '../../../static/js/components/ContextMenu/ModelContextMenuMixin.js';
describe('ModelContextMenuMixin.extractModelVersionId', () => {
it('accepts civitai.red model URLs', () => {
expect(
ModelContextMenuMixin.extractModelVersionId(
'https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777'
)
).toEqual({ modelId: '65423', modelVersionId: '777' });
describe('ModelContextMenuMixin.getModelTypePrefix', () => {
it('maps every known model type to its API route prefix', () => {
expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'lora' })).toBe('loras');
expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'checkpoint' })).toBe('checkpoints');
expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'embedding' })).toBe('embeddings');
});
it('rejects model-like URLs from unsupported hosts', () => {
expect(
ModelContextMenuMixin.extractModelVersionId(
'https://example.com/models/65423?modelVersionId=777'
)
).toEqual({ modelId: null, modelVersionId: null });
it('falls back to the loras prefix for unknown types', () => {
expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'unknown' })).toBe('loras');
expect(ModelContextMenuMixin.getModelTypePrefix.call({})).toBe('loras');
});
});
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync, statSync } from 'fs';
import path from 'path';
// Regression guard: every `.modal` element shipped via components/modals.html
// must be registered in ModalManager.initialize(). An unregistered modal makes
// modalManager.showModal(id) silently no-op (see getModal returning undefined),
// which manifests as "clicking the menu item does nothing" with no console
// error — exactly the Link-to-CivitArchive bug this file guards against.
describe('ModalManager registry parity', () => {
const repoRoot = path.resolve(__dirname, '../../..');
const modalsHtml = readFileSync(
path.join(repoRoot, 'templates/components/modals.html'),
'utf-8'
);
const modalManagerSrc = readFileSync(
path.join(repoRoot, 'static/js/managers/ModalManager.js'),
'utf-8'
);
const collectModalIds = (target, seen = new Set()) => {
if (statSync(target).isFile()) {
extractIds(readFileSync(target, 'utf-8'), seen);
return seen;
}
for (const entry of readdirSync(target, { withFileTypes: true })) {
collectModalIds(path.join(target, entry.name), seen);
}
return seen;
};
const extractIds = (content, seen) => {
for (const match of content.matchAll(/id="([A-Za-z][\w-]*)"[^>]*class="modal"/g)) {
seen.add(match[1]);
}
};
it('registers every modal declared in templates', () => {
const includeFiles = [
...modalsHtml.matchAll(/\{%\s*include\s*'([^']+\.html)'\s*%\}/g),
].map((m) => m[1]);
expect(includeFiles.length).toBeGreaterThan(0);
const declaredIds = new Set();
for (const relPath of includeFiles) {
collectModalIds(path.join(repoRoot, 'templates', relPath), declaredIds);
}
expect(declaredIds.size).toBeGreaterThan(0);
const unregistered = [...declaredIds].filter(
(id) => !modalManagerSrc.includes(`registerModal('${id}'`)
);
expect(
unregistered,
'Modal ids rendered on pages but never registered in ModalManager.initialize() — showModal() will silently do nothing for them'
).toEqual([]);
});
});
+45
View File
@@ -11,6 +11,7 @@ import {
getThumbnailUrl,
extractCivitaiImageId,
extractCivitaiModelUrlParts,
classifyModelRelinkUrl,
isCivitaiUrl,
isSupportedCivitaiPageHost,
OptimizationMode
@@ -305,4 +306,48 @@ describe('civitaiUtils', () => {
expect(extractCivitaiImageId('https://example.com/images/126920345')).toBe(null);
});
});
describe('classifyModelRelinkUrl', () => {
it('classifies civitai.com model URLs', () => {
expect(
classifyModelRelinkUrl('https://civitai.com/models/649516/name?modelVersionId=726676')
).toEqual({ source: 'civitai', modelId: '649516', modelVersionId: '726676' });
});
it('classifies civitai.red model URLs without a version id', () => {
expect(
classifyModelRelinkUrl('https://civitai.red/models/65423/')
).toEqual({ source: 'civitai', modelId: '65423', modelVersionId: null });
});
it('classifies civarchive and civitaiarchive model URLs', () => {
expect(
classifyModelRelinkUrl('https://civarchive.com/models/1746460')
).toEqual({ source: 'civarchive', modelId: '1746460', modelVersionId: null });
expect(
classifyModelRelinkUrl('http://www.civitaiarchive.com/models/42?modelVersionId=43')
).toEqual({ source: 'civarchive', modelId: '42', modelVersionId: '43' });
});
it('rejects archive hosts when the path has no numeric model id', () => {
expect(
classifyModelRelinkUrl('https://civarchive.com/images/123')
).toEqual({ source: null, modelId: null, modelVersionId: null });
});
it('rejects unsupported hosts and malformed input', () => {
expect(
classifyModelRelinkUrl('https://example.com/models/65423')
).toEqual({ source: null, modelId: null, modelVersionId: null });
expect(
classifyModelRelinkUrl('not a url')
).toEqual({ source: null, modelId: null, modelVersionId: null });
expect(
classifyModelRelinkUrl('')
).toEqual({ source: null, modelId: null, modelVersionId: null });
expect(
classifyModelRelinkUrl(null)
).toEqual({ source: null, modelId: null, modelVersionId: null });
});
});
});