Compare commits

..

2 Commits

Author SHA1 Message Date
Will Miao
37f0e8f213 fix(trigger-words): raise group word limit 2026-04-21 16:35:25 +08:00
Will Miao
ecf7ea21e4 fix(duplicates): clear stale hash mismatch state (#900) 2026-04-21 16:22:04 +08:00
13 changed files with 303 additions and 24 deletions

View File

@@ -1761,8 +1761,8 @@
},
"triggerWords": {
"loadFailed": "Konnte trainierte Wörter nicht laden",
"tooLong": "Trigger Word sollte 100 Wörter nicht überschreiten",
"tooMany": "Maximal 30 Trigger Words erlaubt",
"tooLong": "Trigger Word sollte 500 Wörter nicht überschreiten",
"tooMany": "Maximal 100 Trigger Words erlaubt",
"alreadyExists": "Dieses Trigger Word existiert bereits",
"updateSuccess": "Trigger Words erfolgreich aktualisiert",
"updateFailed": "Fehler beim Aktualisieren der Trigger Words",

View File

@@ -1761,8 +1761,8 @@
},
"triggerWords": {
"loadFailed": "Could not load trained words",
"tooLong": "Trigger word should not exceed 100 words",
"tooMany": "Maximum 30 trigger words allowed",
"tooLong": "Trigger word should not exceed 500 words",
"tooMany": "Maximum 100 trigger words allowed",
"alreadyExists": "This trigger word already exists",
"updateSuccess": "Trigger words updated successfully",
"updateFailed": "Failed to update trigger words",

View File

@@ -1761,8 +1761,8 @@
},
"triggerWords": {
"loadFailed": "No se pudieron cargar palabras entrenadas",
"tooLong": "La palabra clave no debe exceder 100 palabras",
"tooMany": "Máximo 30 palabras clave permitidas",
"tooLong": "La palabra clave no debe exceder 500 palabras",
"tooMany": "Máximo 100 palabras clave permitidas",
"alreadyExists": "Esta palabra clave ya existe",
"updateSuccess": "Palabras clave actualizadas exitosamente",
"updateFailed": "Error al actualizar palabras clave",

View File

@@ -1761,8 +1761,8 @@
},
"triggerWords": {
"loadFailed": "Impossible de charger les mots entraînés",
"tooLong": "Le mot-clé ne doit pas dépasser 100 mots",
"tooMany": "Maximum 30 mots-clés autorisés",
"tooLong": "Le mot-clé ne doit pas dépasser 500 mots",
"tooMany": "Maximum 100 mots-clés autorisés",
"alreadyExists": "Ce mot-clé existe déjà",
"updateSuccess": "Mots-clés mis à jour avec succès",
"updateFailed": "Échec de la mise à jour des mots-clés",

View File

@@ -1761,8 +1761,8 @@
},
"triggerWords": {
"loadFailed": "לא ניתן היה לטעון מילים מאומנות",
"tooLong": "מילת טריגר לא תעלה על 100 מילים",
"tooMany": "מותרות עד 30 מילות טריגר",
"tooLong": "מילת טריגר לא תעלה על 500 מילים",
"tooMany": "מותרות עד 100 מילות טריגר",
"alreadyExists": "מילת טריגר זו כבר קיימת",
"updateSuccess": "מילות הטריגר עודכנו בהצלחה",
"updateFailed": "עדכון מילות הטריגר נכשל",

View File

@@ -1761,8 +1761,8 @@
},
"triggerWords": {
"loadFailed": "学習済みワードを読み込めませんでした",
"tooLong": "トリガーワードは100ワードを超えてはいけません",
"tooMany": "最大30トリガーワードまで許可されています",
"tooLong": "トリガーワードは500ワードを超えてはいけません",
"tooMany": "最大100トリガーワードまで許可されています",
"alreadyExists": "このトリガーワードは既に存在します",
"updateSuccess": "トリガーワードが正常に更新されました",
"updateFailed": "トリガーワードの更新に失敗しました",

View File

@@ -1761,8 +1761,8 @@
},
"triggerWords": {
"loadFailed": "학습된 단어를 로딩할 수 없습니다",
"tooLong": "트리거 단어는 100단어를 초과할 수 없습니다",
"tooMany": "최대 30개의 트리거 단어만 허용됩니다",
"tooLong": "트리거 단어는 500단어를 초과할 수 없습니다",
"tooMany": "최대 100개의 트리거 단어만 허용됩니다",
"alreadyExists": "이 트리거 단어는 이미 존재합니다",
"updateSuccess": "트리거 단어가 성공적으로 업데이트되었습니다",
"updateFailed": "트리거 단어 업데이트에 실패했습니다",

View File

@@ -1761,8 +1761,8 @@
},
"triggerWords": {
"loadFailed": "Не удалось загрузить обученные слова",
"tooLong": "Триггерное слово не должно превышать 100 слов",
"tooMany": "Максимум 30 триггерных слов разрешено",
"tooLong": "Триггерное слово не должно превышать 500 слов",
"tooMany": "Максимум 100 триггерных слов разрешено",
"alreadyExists": "Это триггерное слово уже существует",
"updateSuccess": "Триггерные слова успешно обновлены",
"updateFailed": "Не удалось обновить триггерные слова",

View File

@@ -1761,8 +1761,8 @@
},
"triggerWords": {
"loadFailed": "无法加载训练词",
"tooLong": "触发词不能超过100个词",
"tooMany": "最多允许30个触发词",
"tooLong": "触发词不能超过500个词",
"tooMany": "最多允许100个触发词",
"alreadyExists": "该触发词已存在",
"updateSuccess": "触发词更新成功",
"updateFailed": "触发词更新失败",

View File

@@ -1761,8 +1761,8 @@
},
"triggerWords": {
"loadFailed": "無法載入訓練詞",
"tooLong": "觸發詞不可超過 100 個字",
"tooMany": "最多允許 30 個觸發詞",
"tooLong": "觸發詞不可超過 500 個字",
"tooMany": "最多允許 100 個觸發詞",
"alreadyExists": "此觸發詞已存在",
"updateSuccess": "觸發詞已更新",
"updateFailed": "更新觸發詞失敗",

View File

@@ -121,6 +121,7 @@ export class ModelDuplicatesManager {
}
this.duplicateGroups = data.duplicates || [];
this._pruneVerificationState();
// Update the badge with the current count
this.updateDuplicatesBadge(this.duplicateGroups.length);
@@ -402,6 +403,44 @@ export class ModelDuplicatesManager {
}
});
}
_getGroupFilePaths(group) {
return new Set((group?.models || []).map(model => model.file_path));
}
_clearMismatchStateForGroup(group) {
this._getGroupFilePaths(group).forEach(filePath => {
this.mismatchedFiles.delete(filePath);
});
}
_pruneVerificationState() {
const visiblePaths = new Set();
const visibleHashes = new Set();
this.duplicateGroups.forEach(group => {
visibleHashes.add(group.hash);
this._getGroupFilePaths(group).forEach(filePath => visiblePaths.add(filePath));
});
Array.from(this.mismatchedFiles.keys()).forEach(filePath => {
if (!visiblePaths.has(filePath)) {
this.mismatchedFiles.delete(filePath);
}
});
Array.from(this.selectedForDeletion).forEach(filePath => {
if (!visiblePaths.has(filePath)) {
this.selectedForDeletion.delete(filePath);
}
});
Array.from(this.verifiedGroups).forEach(hash => {
if (!visibleHashes.has(hash)) {
this.verifiedGroups.delete(hash);
}
});
}
renderModelCard(model, groupHash) {
// Create basic card structure
@@ -619,10 +658,11 @@ export class ModelDuplicatesManager {
toggleSelectAllInGroup(hash) {
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-hash="${hash}"]`);
const allSelected = Array.from(checkboxes).every(checkbox => checkbox.checked);
const selectableCheckboxes = Array.from(checkboxes).filter(checkbox => !checkbox.disabled);
const allSelected = selectableCheckboxes.length > 0 && selectableCheckboxes.every(checkbox => checkbox.checked);
// If all are selected, deselect all; otherwise select all
checkboxes.forEach(checkbox => {
selectableCheckboxes.forEach(checkbox => {
checkbox.checked = !allSelected;
const filePath = checkbox.dataset.filePath;
const card = checkbox.closest('.model-card');
@@ -830,11 +870,14 @@ export class ModelDuplicatesManager {
// Process verification results
const verifiedAsDuplicates = data.verified_as_duplicates;
const mismatchedFiles = data.mismatched_files || [];
this._clearMismatchStateForGroup(group);
// Update mismatchedFiles map
if (data.new_hash_map) {
Object.entries(data.new_hash_map).forEach(([path, hash]) => {
this.mismatchedFiles.set(path, hash);
this.selectedForDeletion.delete(path);
});
}
@@ -843,6 +886,7 @@ export class ModelDuplicatesManager {
// Re-render the duplicate groups to show verification status
this.renderDuplicateGroups();
this.updateSelectedCount();
// Show appropriate toast message
if (mismatchedFiles.length > 0) {

View File

@@ -8,6 +8,9 @@ import { translate } from '../../utils/i18nHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { escapeAttribute, escapeHtml } from './utils.js';
const MAX_WORDS_PER_TRIGGER_GROUP = 500;
const MAX_TRIGGER_WORD_GROUPS = 100;
/**
* Fetch trained words for a model
* @param {string} filePath - Path to the model file
@@ -523,14 +526,14 @@ function addNewTriggerWord(word) {
}
// Validation: Check length
if (word.split(/\s+/).length > 100) {
if (word.split(/\s+/).length > MAX_WORDS_PER_TRIGGER_GROUP) {
showToast('toast.triggerWords.tooLong', {}, 'error');
return;
}
// Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
if (currentTags.length >= 100) {
if (currentTags.length >= MAX_TRIGGER_WORD_GROUPS) {
showToast('toast.triggerWords.tooMany', {}, 'error');
return;
}

View File

@@ -0,0 +1,232 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const resetAndReloadMock = vi.fn();
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
resetAndReload: resetAndReloadMock,
}));
const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js');
const { state } = await import('../../../static/js/state/index.js');
const carPath = '/models/loras/aspark-owl.safetensors';
const copyPath = '/models/loras/aspark-owl-copy.safetensors';
const stalePath = '/models/loras/old-mismatch.safetensors';
function createModel(filePath, sha256, modelName = 'Aspark Owl - 2019') {
return {
file_path: filePath,
file_name: filePath.split('/').pop(),
model_name: modelName,
sha256,
preview_url: '',
preview_nsfw_level: 0,
modified: Date.now(),
civitai: { name: 'Version 1' },
};
}
function createGroup(hash = 'actual-hash') {
return {
hash,
models: [
createModel(carPath, hash),
createModel(copyPath, hash, 'Aspark Owl - 2019 Copy'),
],
};
}
async function createManager() {
document.body.innerHTML = `
<div id="modelGrid"></div>
<span id="duplicatesBadge"></span>
<span id="duplicatesSelectedCount"></span>
<button class="btn-delete-selected"></button>
`;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
statusText: 'OK',
json: async () => ({ success: true, duplicates: [] }),
});
const manager = new ModelDuplicatesManager({}, 'loras');
await Promise.resolve();
await Promise.resolve();
global.fetch.mockClear();
return manager;
}
beforeEach(() => {
vi.clearAllMocks();
state.loadingManager = {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
};
});
afterEach(() => {
vi.restoreAllMocks();
state.loadingManager = null;
});
describe('ModelDuplicatesManager verification state', () => {
it('clears stale Different Hash state when a later verification confirms the group is duplicate', async () => {
const manager = await createManager();
const group = createGroup();
manager.duplicateGroups = [group];
manager.mismatchedFiles.set(carPath, 'old-actual-hash');
manager.renderDuplicateGroups();
expect(document.querySelector(`[data-file-path="${carPath}"]`).classList.contains('hash-mismatch')).toBe(true);
global.fetch.mockResolvedValueOnce({
ok: true,
statusText: 'OK',
json: async () => ({
success: true,
verified_as_duplicates: true,
mismatched_files: [],
new_hash_map: {},
}),
});
await manager.handleVerifyHashes(group);
const carCard = document.querySelector(`[data-file-path="${carPath}"]`);
const carCheckbox = carCard.querySelector('.selector-checkbox');
expect(manager.mismatchedFiles.has(carPath)).toBe(false);
expect(carCard.classList.contains('hash-mismatch')).toBe(false);
expect(carCard.querySelector('.mismatch-badge')).toBeNull();
expect(carCheckbox.disabled).toBe(false);
});
it('keeps showing Different Hash for files returned as mismatched by the current verification', async () => {
const manager = await createManager();
const group = createGroup('metadata-hash');
manager.duplicateGroups = [group];
manager.selectedForDeletion.add(carPath);
manager.selectedForDeletion.add(copyPath);
global.fetch.mockResolvedValueOnce({
ok: true,
statusText: 'OK',
json: async () => ({
success: true,
verified_as_duplicates: false,
mismatched_files: [carPath],
new_hash_map: {
[carPath]: 'actual-car-hash',
},
}),
});
await manager.handleVerifyHashes(group);
const carCard = document.querySelector(`[data-file-path="${carPath}"]`);
const carCheckbox = carCard.querySelector('.selector-checkbox');
expect(manager.mismatchedFiles.get(carPath)).toBe('actual-car-hash');
expect(manager.selectedForDeletion.has(carPath)).toBe(false);
expect(manager.selectedForDeletion.has(copyPath)).toBe(true);
expect(carCard.classList.contains('hash-mismatch')).toBe(true);
expect(carCard.querySelector('.mismatch-badge')?.textContent).toContain('Different Hash');
expect(carCheckbox.disabled).toBe(true);
});
it('refreshes selected count and delete button when selected files become mismatched', async () => {
const manager = await createManager();
const group = createGroup('metadata-hash');
manager.duplicateGroups = [group];
manager.selectedForDeletion.add(carPath);
manager.updateSelectedCount();
expect(document.getElementById('duplicatesSelectedCount').textContent).toBe('1');
expect(document.querySelector('.btn-delete-selected').disabled).toBe(false);
global.fetch.mockResolvedValueOnce({
ok: true,
statusText: 'OK',
json: async () => ({
success: true,
verified_as_duplicates: false,
mismatched_files: [carPath],
new_hash_map: {
[carPath]: 'actual-car-hash',
},
}),
});
await manager.handleVerifyHashes(group);
expect(manager.selectedForDeletion.size).toBe(0);
expect(document.getElementById('duplicatesSelectedCount').textContent).toBe('0');
expect(document.querySelector('.btn-delete-selected').disabled).toBe(true);
expect(document.querySelector('.btn-delete-selected').classList.contains('disabled')).toBe(true);
});
it('preserves valid selected deletion candidates when verification succeeds', async () => {
const manager = await createManager();
const group = createGroup();
manager.duplicateGroups = [group];
manager.selectedForDeletion.add(carPath);
manager.selectedForDeletion.add(copyPath);
global.fetch.mockResolvedValueOnce({
ok: true,
statusText: 'OK',
json: async () => ({
success: true,
verified_as_duplicates: true,
mismatched_files: [],
new_hash_map: {},
}),
});
await manager.handleVerifyHashes(group);
expect(manager.selectedForDeletion.has(carPath)).toBe(true);
expect(manager.selectedForDeletion.has(copyPath)).toBe(true);
expect(document.querySelector(`[data-file-path="${carPath}"] .selector-checkbox`).checked).toBe(true);
expect(document.querySelector(`[data-file-path="${copyPath}"] .selector-checkbox`).checked).toBe(true);
});
it('prunes mismatch and verified state that no longer belongs to refreshed duplicate groups', async () => {
const manager = await createManager();
const visibleGroup = createGroup('visible-hash');
manager.mismatchedFiles.set(stalePath, 'stale-hash');
manager.mismatchedFiles.set(carPath, 'visible-mismatch');
manager.verifiedGroups.add('stale-group-hash');
manager.verifiedGroups.add('visible-hash');
global.fetch.mockResolvedValueOnce({
ok: true,
statusText: 'OK',
json: async () => ({
success: true,
duplicates: [visibleGroup],
}),
});
await manager.findDuplicates();
expect(manager.mismatchedFiles.has(stalePath)).toBe(false);
expect(manager.mismatchedFiles.has(carPath)).toBe(true);
expect(manager.verifiedGroups.has('stale-group-hash')).toBe(false);
expect(manager.verifiedGroups.has('visible-hash')).toBe(true);
});
});