chore(priority-tags): add newline terminator

This commit is contained in:
pixelpaws
2025-10-11 17:38:20 +08:00
parent 47da9949d9
commit 6120922204
26 changed files with 1079 additions and 99 deletions

View File

@@ -204,6 +204,71 @@
width: 100%; /* Full width */
}
.settings-help-text {
font-size: 0.9em;
color: var(--text-color);
opacity: 0.8;
margin-bottom: var(--space-2);
line-height: 1.4;
}
.settings-help-text.subtle {
font-size: 0.85em;
opacity: 0.7;
margin-top: var(--space-1);
}
.priority-tags-grid {
display: grid;
gap: var(--space-2);
}
@media (min-width: 640px) {
.priority-tags-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
}
.priority-tags-group {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.priority-tags-input {
width: 100%;
min-height: 72px;
padding: 8px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
resize: vertical;
}
.priority-tags-input:focus {
border-color: var(--lora-accent);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
}
.priority-tags-input.input-error {
border-color: var(--danger-color, #dc2626);
box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12);
}
.input-error-message {
font-size: 0.8em;
color: var(--danger-color, #dc2626);
display: none;
}
.metadata-suggestions-loading {
font-size: 0.85em;
opacity: 0.7;
padding: 6px 0;
}
/* Settings Styles */
.settings-section {
margin-top: var(--space-3);

View File

@@ -4,7 +4,47 @@
*/
import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { PRESET_TAGS } from '../../utils/constants.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getPriorityTagSuggestions } from '../../utils/priorityTagHelpers.js';
let priorityTagSuggestions = [];
let priorityTagSuggestionsLoaded = false;
let priorityTagSuggestionsPromise = null;
function ensurePriorityTagSuggestions() {
if (!priorityTagSuggestionsPromise) {
priorityTagSuggestionsPromise = getPriorityTagSuggestions()
.then((tags) => {
priorityTagSuggestions = tags;
priorityTagSuggestionsLoaded = true;
return tags;
})
.catch(() => {
priorityTagSuggestions = [];
priorityTagSuggestionsLoaded = true;
return priorityTagSuggestions;
})
.finally(() => {
priorityTagSuggestionsPromise = null;
});
}
return priorityTagSuggestionsLoaded && !priorityTagSuggestionsPromise
? Promise.resolve(priorityTagSuggestions)
: priorityTagSuggestionsPromise;
}
ensurePriorityTagSuggestions();
window.addEventListener('lm:priority-tags-updated', () => {
priorityTagSuggestionsLoaded = false;
ensurePriorityTagSuggestions().then(() => {
document.querySelectorAll('.metadata-edit-container .metadata-suggestions-container').forEach((container) => {
renderPriorityTagSuggestions(container, getCurrentEditTags());
});
updateSuggestionsDropdown();
});
});
// Create a named function so we can remove it later
let saveTagsHandler = null;
@@ -260,7 +300,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
function createSuggestionsDropdown(existingTags = []) {
const dropdown = document.createElement('div');
dropdown.className = 'metadata-suggestions-dropdown';
// Create header
const header = document.createElement('div');
header.className = 'metadata-suggestions-header';
@@ -273,11 +313,33 @@ function createSuggestionsDropdown(existingTags = []) {
// Create tag container
const container = document.createElement('div');
container.className = 'metadata-suggestions-container';
// Add each preset tag as a suggestion
PRESET_TAGS.forEach(tag => {
if (priorityTagSuggestionsLoaded && !priorityTagSuggestionsPromise) {
renderPriorityTagSuggestions(container, existingTags);
} else {
container.innerHTML = `<div class="metadata-suggestions-loading">${translate('settings.priorityTags.loadingSuggestions', 'Loading suggestions…')}</div>`;
ensurePriorityTagSuggestions().then(() => {
if (!container.isConnected) {
return;
}
renderPriorityTagSuggestions(container, getCurrentEditTags());
updateSuggestionsDropdown();
}).catch(() => {
if (container.isConnected) {
container.innerHTML = '';
}
});
}
dropdown.appendChild(container);
return dropdown;
}
function renderPriorityTagSuggestions(container, existingTags = []) {
container.innerHTML = '';
priorityTagSuggestions.forEach((tag) => {
const isAdded = existingTags.includes(tag);
const item = document.createElement('div');
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
item.title = tag;
@@ -285,28 +347,21 @@ function createSuggestionsDropdown(existingTags = []) {
<span class="metadata-suggestion-text">${tag}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
`;
if (!isAdded) {
item.addEventListener('click', () => {
addNewTag(tag);
// Also populate the input field for potential editing
const input = document.querySelector('.metadata-input');
if (input) input.value = tag;
// Focus on the input
if (input) input.focus();
// Update dropdown without removing it
updateSuggestionsDropdown();
});
}
container.appendChild(item);
});
dropdown.appendChild(container);
return dropdown;
}
/**
@@ -406,10 +461,9 @@ function addNewTag(tag) {
function updateSuggestionsDropdown() {
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
if (!dropdown) return;
// Get all current tags
const currentTags = document.querySelectorAll('.metadata-item');
const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag);
const existingTags = getCurrentEditTags();
// Update status of each item in dropdown
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
@@ -456,6 +510,11 @@ function updateSuggestionsDropdown() {
});
}
function getCurrentEditTags() {
const currentTags = document.querySelectorAll('.metadata-item');
return Array.from(currentTags).map(tag => tag.dataset.tag);
}
/**
* Restore original tags when canceling edit
* @param {HTMLElement} section - The tags section

View File

@@ -4,7 +4,8 @@ import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
import { PRESET_TAGS, BASE_MODEL_CATEGORIES } from '../utils/constants.js';
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
import { eventManager } from '../utils/EventManager.js';
import { translate } from '../utils/i18nHelpers.js';
@@ -59,6 +60,22 @@ export class BulkManager {
setContentRating: true
}
};
window.addEventListener('lm:priority-tags-updated', () => {
const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container');
if (!container) {
return;
}
getPriorityTagSuggestions().then((tags) => {
if (!container.isConnected) {
return;
}
this.renderBulkSuggestionItems(container, tags);
this.updateBulkSuggestionsDropdown();
}).catch(() => {
// Ignore refresh failures; UI will retry on next open
});
});
}
initialize() {
@@ -565,7 +582,7 @@ export class BulkManager {
// Create suggestions dropdown
const tagForm = document.querySelector('#bulkAddTagsModal .metadata-add-form');
if (tagForm) {
const suggestionsDropdown = this.createBulkSuggestionsDropdown(PRESET_TAGS);
const suggestionsDropdown = this.createBulkSuggestionsDropdown();
tagForm.appendChild(suggestionsDropdown);
}
@@ -586,10 +603,10 @@ export class BulkManager {
}
}
createBulkSuggestionsDropdown(presetTags) {
createBulkSuggestionsDropdown() {
const dropdown = document.createElement('div');
dropdown.className = 'metadata-suggestions-dropdown';
const header = document.createElement('div');
header.className = 'metadata-suggestions-header';
header.innerHTML = `
@@ -597,15 +614,34 @@ export class BulkManager {
<small>Click to add</small>
`;
dropdown.appendChild(header);
const container = document.createElement('div');
container.className = 'metadata-suggestions-container';
presetTags.forEach(tag => {
// Check if tag is already added
container.innerHTML = `<div class="metadata-suggestions-loading">${translate('settings.priorityTags.loadingSuggestions', 'Loading suggestions…')}</div>`;
getPriorityTagSuggestions().then((tags) => {
if (!container.isConnected) {
return;
}
this.renderBulkSuggestionItems(container, tags);
this.updateBulkSuggestionsDropdown();
}).catch(() => {
if (container.isConnected) {
container.innerHTML = '';
}
});
dropdown.appendChild(container);
return dropdown;
}
renderBulkSuggestionItems(container, tags) {
container.innerHTML = '';
tags.forEach(tag => {
const existingTags = this.getBulkExistingTags();
const isAdded = existingTags.includes(tag);
const item = document.createElement('div');
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
item.title = tag;
@@ -613,7 +649,7 @@ export class BulkManager {
<span class="metadata-suggestion-text">${tag}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
`;
if (!isAdded) {
item.addEventListener('click', () => {
this.addBulkTag(tag);
@@ -622,16 +658,12 @@ export class BulkManager {
input.value = tag;
input.focus();
}
// Update dropdown to show added indicator
this.updateBulkSuggestionsDropdown();
});
}
container.appendChild(item);
});
dropdown.appendChild(container);
return dropdown;
}
addBulkTag(tag) {

View File

@@ -2,10 +2,11 @@ import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { state, createDefaultSettings } from '../state/index.js';
import { resetAndReload } from '../api/modelApiFactory.js';
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES } from '../utils/constants.js';
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js';
import { i18n } from '../i18n/index.js';
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
export class SettingsManager {
constructor() {
@@ -111,6 +112,17 @@ export class SettingsManager {
merged.download_path_templates = { ...DEFAULT_PATH_TEMPLATES, ...templates };
const priorityTags = backendSettings?.priority_tags;
const normalizedPriority = { ...DEFAULT_PRIORITY_TAG_CONFIG };
if (priorityTags && typeof priorityTags === 'object' && !Array.isArray(priorityTags)) {
Object.entries(priorityTags).forEach(([modelType, configValue]) => {
if (typeof configValue === 'string') {
normalizedPriority[modelType] = configValue.trim();
}
});
}
merged.priority_tags = normalizedPriority;
Object.keys(merged).forEach(key => this.backendSettingKeys.add(key));
return merged;
@@ -201,14 +213,14 @@ export class SettingsManager {
settingsManager.validateTemplate(modelType, template);
settingsManager.updateTemplatePreview(modelType, template);
});
customInput.addEventListener('blur', (e) => {
const template = e.target.value;
if (settingsManager.validateTemplate(modelType, template)) {
settingsManager.updateTemplate(modelType, template);
}
});
customInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.target.blur();
@@ -216,7 +228,9 @@ export class SettingsManager {
});
}
});
this.setupPriorityTagInputs();
this.initialized = true;
}
@@ -291,6 +305,9 @@ export class SettingsManager {
// Load download path templates
this.loadDownloadPathTemplates();
// Load priority tag settings
this.loadPriorityTagSettings();
// Set include trigger words setting
const includeTriggerWordsCheckbox = document.getElementById('includeTriggerWords');
if (includeTriggerWordsCheckbox) {
@@ -325,6 +342,131 @@ export class SettingsManager {
this.loadProxySettings();
}
setupPriorityTagInputs() {
['lora', 'checkpoint', 'embedding'].forEach((modelType) => {
const textarea = document.getElementById(`${modelType}PriorityTagsInput`);
if (!textarea) {
return;
}
textarea.addEventListener('input', () => this.handlePriorityTagInput(modelType));
textarea.addEventListener('blur', () => this.handlePriorityTagBlur(modelType));
});
}
loadPriorityTagSettings() {
const priorityConfig = state.global.settings.priority_tags || {};
['lora', 'checkpoint', 'embedding'].forEach((modelType) => {
const textarea = document.getElementById(`${modelType}PriorityTagsInput`);
if (!textarea) {
return;
}
const storedValue = priorityConfig[modelType] ?? DEFAULT_PRIORITY_TAG_CONFIG[modelType] ?? '';
textarea.value = storedValue;
this.displayPriorityTagValidation(modelType, true, []);
});
}
handlePriorityTagInput(modelType) {
const textarea = document.getElementById(`${modelType}PriorityTagsInput`);
if (!textarea) {
return;
}
const validation = validatePriorityTagString(textarea.value);
this.displayPriorityTagValidation(modelType, validation.valid, validation.errors);
}
async handlePriorityTagBlur(modelType) {
const textarea = document.getElementById(`${modelType}PriorityTagsInput`);
if (!textarea) {
return;
}
const validation = validatePriorityTagString(textarea.value);
if (!validation.valid) {
this.displayPriorityTagValidation(modelType, false, validation.errors);
return;
}
const sanitized = validation.formatted;
const currentValue = state.global.settings.priority_tags?.[modelType] || '';
this.displayPriorityTagValidation(modelType, true, []);
if (sanitized === currentValue) {
textarea.value = sanitized;
return;
}
const updatedConfig = {
...state.global.settings.priority_tags,
[modelType]: sanitized,
};
try {
textarea.value = sanitized;
await this.saveSetting('priority_tags', updatedConfig);
showToast('settings.priorityTags.saveSuccess', {}, 'success');
await this.refreshPriorityTagSuggestions();
} catch (error) {
console.error('Failed to save priority tag configuration:', error);
showToast('settings.priorityTags.saveError', {}, 'error');
}
}
displayPriorityTagValidation(modelType, isValid, errors = []) {
const textarea = document.getElementById(`${modelType}PriorityTagsInput`);
const errorElement = document.getElementById(`${modelType}PriorityTagsError`);
if (!textarea) {
return;
}
if (isValid || errors.length === 0) {
textarea.classList.remove('input-error');
if (errorElement) {
errorElement.textContent = '';
errorElement.style.display = 'none';
}
return;
}
textarea.classList.add('input-error');
if (errorElement) {
const message = this.getPriorityTagErrorMessage(errors[0]);
errorElement.textContent = message;
errorElement.style.display = 'block';
}
}
getPriorityTagErrorMessage(error) {
if (!error) {
return '';
}
const entryIndex = error.index ?? 0;
switch (error.type) {
case 'missingClosingParen':
return translate('settings.priorityTags.validation.missingClosingParen', { index: entryIndex }, `Entry ${entryIndex} is missing a closing parenthesis.`);
case 'missingCanonical':
return translate('settings.priorityTags.validation.missingCanonical', { index: entryIndex }, `Entry ${entryIndex} must include a canonical tag.`);
case 'duplicateCanonical':
return translate('settings.priorityTags.validation.duplicateCanonical', { tag: error.canonical }, `The canonical tag "${error.canonical}" is duplicated.`);
default:
return translate('settings.priorityTags.validation.unknown', {}, 'Invalid priority tag configuration.');
}
}
async refreshPriorityTagSuggestions() {
invalidatePriorityTagSuggestionsCache();
try {
await getPriorityTagSuggestionsMap();
window.dispatchEvent(new CustomEvent('lm:priority-tags-updated'));
} catch (error) {
console.warn('Failed to refresh priority tag suggestions:', error);
}
}
loadProxySettings() {
// Load proxy enabled setting
const proxyEnabledCheckbox = document.getElementById('proxyEnabled');

View File

@@ -1,7 +1,7 @@
// Create the new hierarchical state structure
import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { DEFAULT_PATH_TEMPLATES } from '../utils/constants.js';
import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
const DEFAULT_SETTINGS_BASE = Object.freeze({
civitai_api_key: '',
@@ -28,6 +28,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
card_info_display: 'always',
include_trigger_words: false,
compact_mode: false,
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
});
export function createDefaultSettings() {
@@ -35,6 +36,7 @@ export function createDefaultSettings() {
...DEFAULT_SETTINGS_BASE,
base_model_path_mappings: {},
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
};
}

View File

@@ -194,10 +194,16 @@ export const BASE_MODEL_CATEGORIES = {
]
};
// Preset tag suggestions
export const PRESET_TAGS = [
// Default priority tag entries for fallback suggestions and initial settings
export const DEFAULT_PRIORITY_TAG_ENTRIES = [
'character', 'concept', 'clothing',
'realistic', 'anime', 'toon', 'furry', 'style',
'poses', 'background', 'vehicle', 'buildings',
'objects', 'animal'
'poses', 'background', 'tool', 'vehicle', 'buildings',
'objects', 'assets', 'animal', 'action'
];
export const DEFAULT_PRIORITY_TAG_CONFIG = {
lora: DEFAULT_PRIORITY_TAG_ENTRIES.join(', '),
checkpoint: DEFAULT_PRIORITY_TAG_ENTRIES.join(', '),
embedding: DEFAULT_PRIORITY_TAG_ENTRIES.join(', ')
};

View File

@@ -0,0 +1,223 @@
import { DEFAULT_PRIORITY_TAG_CONFIG } from './constants.js';
function splitPriorityEntries(raw = '') {
const segments = [];
raw.split('\n').forEach(line => {
line.split(',').forEach(part => {
const trimmed = part.trim();
if (trimmed) {
segments.push(trimmed);
}
});
});
return segments;
}
export function parsePriorityTagString(raw = '') {
const entries = [];
const rawEntries = splitPriorityEntries(raw);
rawEntries.forEach((entry) => {
const { canonical, aliases } = parsePriorityEntry(entry);
if (!canonical) {
return;
}
entries.push({ canonical, aliases });
});
return entries;
}
function parsePriorityEntry(entry) {
let canonical = entry;
let aliasSection = '';
const openIndex = entry.indexOf('(');
if (openIndex !== -1) {
if (!entry.endsWith(')')) {
canonical = entry.replace('(', '').replace(')', '');
} else {
canonical = entry.slice(0, openIndex).trim();
aliasSection = entry.slice(openIndex + 1, -1);
}
}
canonical = canonical.trim();
if (!canonical) {
return { canonical: '', aliases: [] };
}
const aliasList = aliasSection ? aliasSection.split('|').map((alias) => alias.trim()).filter(Boolean) : [];
const seen = new Set();
const normalizedCanonical = canonical.toLowerCase();
const uniqueAliases = [];
aliasList.forEach((alias) => {
const normalized = alias.toLowerCase();
if (normalized === normalizedCanonical) {
return;
}
if (!seen.has(normalized)) {
seen.add(normalized);
uniqueAliases.push(alias);
}
});
return { canonical, aliases: uniqueAliases };
}
export function formatPriorityTagEntries(entries, useNewlines = false) {
if (!entries.length) {
return '';
}
const separator = useNewlines ? ',\n' : ', ';
return entries.map(({ canonical, aliases }) => {
if (aliases && aliases.length) {
return `${canonical}(${aliases.join('|')})`;
}
return canonical;
}).join(separator);
}
export function validatePriorityTagString(raw = '') {
const trimmed = raw.trim();
if (!trimmed) {
return { valid: true, errors: [], entries: [], formatted: '' };
}
const errors = [];
const entries = [];
const rawEntries = splitPriorityEntries(raw);
const seenCanonicals = new Set();
rawEntries.forEach((entry, index) => {
const hasOpening = entry.includes('(');
const hasClosing = entry.endsWith(')');
if (hasOpening && !hasClosing) {
errors.push({ type: 'missingClosingParen', index: index + 1 });
}
const { canonical, aliases } = parsePriorityEntry(entry);
if (!canonical) {
errors.push({ type: 'missingCanonical', index: index + 1 });
return;
}
const normalizedCanonical = canonical.toLowerCase();
if (seenCanonicals.has(normalizedCanonical)) {
errors.push({ type: 'duplicateCanonical', canonical });
} else {
seenCanonicals.add(normalizedCanonical);
}
entries.push({ canonical, aliases });
});
const formatted = errors.length === 0
? formatPriorityTagEntries(entries, raw.includes('\n'))
: raw.trim();
return {
valid: errors.length === 0,
errors,
entries,
formatted,
};
}
let cachedPriorityTagMap = null;
let fetchPromise = null;
export async function getPriorityTagSuggestionsMap() {
if (cachedPriorityTagMap) {
return cachedPriorityTagMap;
}
if (!fetchPromise) {
fetchPromise = fetch('/api/lm/priority-tags')
.then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (!data || data.success === false || typeof data.tags !== 'object') {
throw new Error(data?.error || 'Invalid response payload');
}
const normalized = {};
Object.entries(data.tags).forEach(([modelType, tags]) => {
if (!Array.isArray(tags)) {
return;
}
normalized[modelType] = tags.filter(tag => typeof tag === 'string' && tag.trim());
});
const withDefaults = applyDefaultPriorityTagFallback(normalized);
cachedPriorityTagMap = withDefaults;
return withDefaults;
})
.catch(() => {
const fallback = buildDefaultPriorityTagMap();
cachedPriorityTagMap = fallback;
return fallback;
})
.finally(() => {
fetchPromise = null;
});
}
return fetchPromise;
}
export async function getPriorityTagSuggestions() {
const map = await getPriorityTagSuggestionsMap();
const unique = new Set();
Object.values(map).forEach((tags) => {
tags.forEach((tag) => {
unique.add(tag);
});
});
return Array.from(unique);
}
function applyDefaultPriorityTagFallback(map) {
const result = { ...buildDefaultPriorityTagMap(), ...map };
Object.entries(result).forEach(([key, tags]) => {
result[key] = dedupeTags(Array.isArray(tags) ? tags : []);
});
return result;
}
function buildDefaultPriorityTagMap() {
const map = {};
Object.entries(DEFAULT_PRIORITY_TAG_CONFIG).forEach(([modelType, configString]) => {
const entries = parsePriorityTagString(configString);
map[modelType] = entries.map((entry) => entry.canonical);
});
return map;
}
function dedupeTags(tags) {
const seen = new Set();
const ordered = [];
tags.forEach((tag) => {
const normalized = tag.toLowerCase();
if (!seen.has(normalized)) {
seen.add(normalized);
ordered.push(tag);
}
});
return ordered;
}
export function getDefaultPriorityTagConfig() {
return { ...DEFAULT_PRIORITY_TAG_CONFIG };
}
export function invalidatePriorityTagSuggestionsCache() {
cachedPriorityTagMap = null;
fetchPromise = null;
}