Files

1047 lines
29 KiB
JavaScript

import { state } from './state.js';
import { encodeName } from './utils.js';
const TAG_CACHE_PREFIX = 'mudknight_tag_cache_';
const TAG_CACHE_META = 'mudknight_tag_cache_meta';
const CACHE_EXPIRY_HOURS = 24;
const AUTOCOMPLETE_CACHE_KEY = 'mudknight_autocomplete_data';
const AUTOCOMPLETE_CACHE_EXPIRY = 5 * 60 * 1000; // 5 minutes
function getCacheKey(url) {
// Create a safe key from URL
return TAG_CACHE_PREFIX + btoa(url).replace(/[^a-zA-Z0-9]/g, '_');
}
function getCacheMeta() {
try {
const meta = localStorage.getItem(TAG_CACHE_META);
return meta ? JSON.parse(meta) : {};
} catch {
return {};
}
}
function setCacheMeta(meta) {
try {
localStorage.setItem(TAG_CACHE_META, JSON.stringify(meta));
} catch (e) {
console.error('Failed to save cache metadata:', e);
}
}
function getCachedFile(url) {
try {
const key = getCacheKey(url);
const meta = getCacheMeta();
const urlMeta = meta[url];
if (!urlMeta) return null;
// Check if cache is expired
const cacheAge = Date.now() - urlMeta.timestamp;
const maxAge = CACHE_EXPIRY_HOURS * 60 * 60 * 1000;
if (cacheAge > maxAge) {
console.log(`Cache expired for ${url}`);
return null;
}
const cached = localStorage.getItem(key);
if (cached) {
console.log(
`Using cached file for ${url} ` +
`(${(cacheAge / 1000 / 60).toFixed(0)} minutes old)`
);
return cached;
}
} catch (e) {
console.error(`Error reading cache for ${url}:`, e);
}
return null;
}
function setCachedFile(url, text) {
try {
const key = getCacheKey(url);
const meta = getCacheMeta();
// Try to store the file
localStorage.setItem(key, text);
// Update metadata
meta[url] = {
timestamp: Date.now(),
size: text.length,
key: key
};
setCacheMeta(meta);
console.log(
`Cached ${(text.length / 1024).toFixed(1)}KB for ${url}`
);
return true;
} catch (e) {
// localStorage quota exceeded
if (e.name === 'QuotaExceededError' ||
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') {
console.warn(
`Cache full, cannot cache ${url}. ` +
`Consider clearing old caches.`
);
} else {
console.error(`Error caching ${url}:`, e);
}
return false;
}
}
function clearRemovedCaches(currentUrls) {
try {
const meta = getCacheMeta();
const currentUrlSet = new Set(
currentUrls.map(u => u.trim()).filter(Boolean)
);
let cleared = 0;
for (const [url, urlMeta] of Object.entries(meta)) {
if (!currentUrlSet.has(url)) {
// URL was removed, clear its cache
try {
localStorage.removeItem(urlMeta.key);
delete meta[url];
cleared++;
console.log(`Cleared cache for removed URL: ${url}`);
} catch (e) {
console.error(`Error clearing cache for ${url}:`, e);
}
}
}
if (cleared > 0) {
setCacheMeta(meta);
console.log(`Cleared ${cleared} removed cache(s)`);
}
} catch (e) {
console.error('Error clearing removed caches:', e);
}
}
export async function loadAllAutocompleteData(
customSources = '',
customTags = '',
loadDanbooru = true
) {
console.log('Loading autocomplete data...');
const tags = await loadAutocompleteTags(customSources, customTags, loadDanbooru);
const [characterPresets, tagPresets, wildcardPresets, loras, embeddings] =
await Promise.all([
loadCharacterPresets(tags),
loadTagPresets(tags),
loadWildcardPresets(),
loadLoras(),
loadEmbeddings()
]);
return {
tags,
characterPresets,
tagPresets,
wildcardPresets,
loras,
embeddings
};
}
export async function loadCharacters() {
const response = await fetch('/character_editor');
if (response.ok) {
return await response.json();
}
throw new Error('Failed to load characters');
}
export async function loadModels() {
const response = await fetch('/model_editor');
if (response.ok) {
return await response.json();
}
throw new Error('Failed to load models');
}
export async function loadStyles() {
const response = await fetch('/style_editor');
if (response.ok) {
return await response.json();
}
throw new Error('Failed to load styles');
}
export async function loadTags() {
const response = await fetch('/tag_editor');
if (response.ok) {
return await response.json();
}
throw new Error('Failed to load tags');
}
async function parseTagFile(text, url) {
const lines = text.split('\n');
const tags = [];
// Detect format: CSV has 3+ fields, TXT has 2
const firstLine = lines.find(l => l.trim()) || '';
const firstParts = parseCsvLine(firstLine);
const isCsv = firstParts.length >= 3;
console.log(
`Parsing ${url} as ${isCsv ? 'CSV' : 'TXT'} format`
);
for (const line of lines) {
if (!line.trim()) continue;
if (isCsv) {
// CSV format: tag,category,count,aliases
const parts = parseCsvLine(line);
if (parts.length < 3) continue;
const tag = parts[0].trim();
const category = parseInt(parts[1]) || 0;
const count = parseInt(parts[2]) || 0;
const aliasField = parts[3] || '';
if (category === 2) continue;
tags.push({
tag: tag,
category: category,
count: count,
isAlias: false
});
if (aliasField) {
const aliases = parseAliases(aliasField);
// Check if we should lower alias priority
const lowerAliasPriority = localStorage.getItem(
"Comfy.Settings.Mudknight Utils.Autocomplete" +
".LowerAliasPriority"
) !== 'false'; // Default true
for (const alias of aliases) {
tags.push({
tag: alias,
category: category,
count: lowerAliasPriority ? 0 : count,
isAlias: true,
aliasFor: tag
});
}
}
} else {
// TXT format: tag,count
const parts = line.split(',').map(p => p.trim());
if (parts.length < 2) continue;
const tag = parts[0];
const count = parseInt(parts[1]) || 0;
if (!tag) continue;
tags.push({
tag: tag,
category: 0,
count: count,
isAlias: false
});
}
}
console.log(`Parsed ${tags.length} tags from ${url}`);
return tags;
}
export async function loadAutocompleteTags(customSourcesStr = '', customTagsStr = '', loadDanbooru = true) {
try {
const allTags = new Map();
// Parse custom sources first to clear removed caches
const sources = customSourcesStr && customSourcesStr.trim()
? customSourcesStr
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0)
: [];
// Clear caches for removed URLs
clearRemovedCaches(sources);
// Load base Danbooru CSV
if (loadDanbooru) {
try {
const response = await fetch(
'/extensions/comfyui-mudknight-utils/danbooru.csv'
);
if (response.ok) {
const text = await response.text();
const tags = await parseTagFile(text, 'danbooru.csv');
for (const tag of tags) {
allTags.set(tag.tag.toLowerCase(), tag);
}
console.log(
`Loaded ${tags.length} tags from Danbooru CSV`
);
}
} catch (error) {
console.log('Danbooru CSV not found, skipping');
}
} else {
console.log('Danbooru CSV disabled by setting');
}
// Load custom sources
if (sources.length > 0) {
console.log(
`Loading ${sources.length} custom tag source(s)...`
);
for (const sourceUrl of sources) {
try {
console.log(`Checking ${sourceUrl}...`);
// Try to get cached version first
let text = getCachedFile(sourceUrl);
if (!text) {
// Fetch from network
console.log(`Fetching ${sourceUrl}...`);
const response = await fetch(sourceUrl);
if (!response.ok) {
console.error(
`Failed to load ${sourceUrl}: ` +
`${response.status} ${response.statusText}`
);
continue;
}
const contentType = response.headers.get(
'content-type'
) || '';
console.log(`Content-Type: ${contentType}`);
text = await response.text();
console.log(
`Fetched ${(text.length / 1024).toFixed(1)}KB ` +
`from ${sourceUrl}`
);
// Cache the file
setCachedFile(sourceUrl, text);
}
const tags = await parseTagFile(text, sourceUrl);
// Merge tags
for (const tag of tags) {
const key = tag.tag.toLowerCase();
const existing = allTags.get(key);
if (existing && !tag.isAlias) {
// Inherit category from CSV sources
if (existing.category !== 0) {
tag.category = existing.category;
}
// Inherit aliases
if (existing.isAlias && existing.aliasFor) {
tag.aliasFor = existing.aliasFor;
}
}
allTags.set(key, tag);
}
console.log(
`Merged ${tags.length} tags from ${sourceUrl}`
);
} catch (error) {
console.error(
`Error loading ${sourceUrl}:`,
error.message || error
);
if (error.message &&
error.message.includes('CORS')) {
console.error(
'CORS error: The server must allow ' +
'cross-origin requests.'
);
}
}
}
}
if (customTagsStr && customTagsStr.trim()) {
console.log('Parsing custom tags...');
const customTags = parseCustomTags(customTagsStr);
for (const tag of customTags) {
const key = tag.tag.toLowerCase();
allTags.set(key, tag);
}
console.log(`Added ${customTags.length} custom tags`);
}
try {
const tagUsage = await loadTagUsage();
const usageCount = Object.keys(tagUsage).length;
if (usageCount > 0) {
// Check if usage boost is enabled
const applyUsage = localStorage.getItem(
"Comfy.Settings.Mudknight Utils.Autocomplete.ApplyUsage"
) !== 'false';
const onlyExistingTags = localStorage.getItem(
"Comfy.Settings.Mudknight Utils.Autocomplete" +
".UsageOnlyExisting"
) === 'true';
if (applyUsage) {
console.log(
`Applying usage counts for ${usageCount} tags...`
);
let appliedCount = 0;
// Filter usage to only existing tags if enabled
let filteredUsage = tagUsage;
if (onlyExistingTags) {
filteredUsage = {};
for (const [key, usage] of Object.entries(tagUsage)) {
if (allTags.has(key)) {
filteredUsage[key] = usage;
}
}
console.log(
`Filtered to ${Object.keys(filteredUsage).length}` +
` existing tags`
);
}
for (const [key, tag] of allTags.entries()) {
const usage = filteredUsage[key] || 0;
if (usage > 0) {
// Boost used tags significantly
tag.count = (tag.count || 0) + usage;
appliedCount++;
}
}
console.log(`Applied usage boost to ${appliedCount} tags`);
} else {
console.log('Tag usage boost disabled');
}
}
} catch (error) {
console.error('Error applying tag usage:', error);
}
// Convert map to sorted array
const tags = Array.from(allTags.values());
// Boost autocomplete keywords
const keywords = ['character', 'tag', 'embedding', 'wildcard'];
keywords.forEach(keyword => {
const existing = tags.find(t => t.tag === keyword);
if (existing) {
existing.count = 9999999;
} else {
tags.push({
tag: keyword,
category: 5, // meta
count: 9999999,
isAlias: false,
isKeyword: true
});
}
});
tags.sort((a, b) => b.count - a.count);
console.log(
`Total ${tags.length} tags loaded ` +
`(${allTags.size} unique)`
);
return tags;
} catch (error) {
console.error('Error loading autocomplete tags:', error);
return [];
}
}
function parseCustomTags(customTagsStr) {
if (!customTagsStr || !customTagsStr.trim()) {
return [];
}
const tags = [];
const entries = customTagsStr
.split(',')
.map(e => e.trim())
.filter(Boolean);
for (const entry of entries) {
const tagName = entry.replace(/ /g, '_');
tags.push({
tag: tagName,
category: 0, // General category
count: 0,
isAlias: false,
isCustom: true
});
}
return tags;
}
function parseCsvLine(line) {
const parts = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
parts.push(current);
current = '';
} else {
current += char;
}
}
if (current) {
parts.push(current);
}
return parts;
}
function parseAliases(aliasField) {
if (!aliasField) return [];
// Remove quotes and leading/trailing slashes
let cleaned = aliasField.replace(/^["\/]+|["\/]+$/g, '');
// Split by comma
const aliases = cleaned.split(',')
.map(a => a.trim())
.filter(a => a.length > 0);
return aliases;
}
export async function loadCharacterPresets(danbooruTags) {
try {
const response = await fetch('/character_editor');
if (!response.ok) {
console.log('Failed to load character presets');
return [];
}
const characters = await response.json();
// Create tag lookup map for faster searching
const tagMap = new Map();
danbooruTags.forEach(tag => {
tagMap.set(tag.tag.toLowerCase(), tag);
});
// First, collect all character data
const presetData = [];
for (const [name, data] of Object.entries(characters)) {
// Normalize: strip backslashes, trim, lowercase,
// replace spaces with underscores
const nameLower = name.trim()
.replace(/\\/g, '') // Remove backslashes
.toLowerCase()
.replace(/ /g, '_');
const danbooruTag = tagMap.get(nameLower);
// Inherit properties from danbooru if exists,
// otherwise default to character category
const category = danbooruTag ? danbooruTag.category : 4;
const count = danbooruTag ? danbooruTag.count : 0;
presetData.push({
name: name,
nameLower: nameLower,
category: category,
count: count
});
}
// Check all images in parallel
const imageChecks = presetData.map(item =>
checkCharacterImage(item.name).then(hasImage => ({
...item,
hasImage: hasImage
}))
);
const presetsWithImages = await Promise.all(imageChecks);
// Build final presets array
const presets = presetsWithImages.map(item => ({
tag: item.nameLower,
category: item.category,
count: item.count,
isAlias: false,
isPreset: true,
presetType: 'character',
characterName: item.name, // Store original name for image lookup
hasImage: item.hasImage
}));
console.log(`Loaded ${presets.length} character presets`);
return presets;
} catch (error) {
console.error('Error loading character presets:', error);
return [];
}
}
async function checkCharacterImage(name) {
try {
const response = await fetch(`/character_editor/image/${encodeName(name)}`);
return response.ok;
} catch (error) {
return false;
}
}
export async function loadTagPresets(danbooruTags) {
try {
const response = await fetch('/tag_editor');
if (!response.ok) {
console.log('Failed to load tag presets');
return [];
}
const tags = await response.json();
// Create tag lookup map for faster searching
const tagMap = new Map();
danbooruTags.forEach(tag => {
tagMap.set(tag.tag.toLowerCase(), tag);
});
const presets = [];
for (const name of Object.keys(tags)) {
// Normalize: strip backslashes, trim, lowercase,
// replace spaces with underscores
const nameLower = name.trim()
.replace(/\\/g, '') // Remove backslashes
.toLowerCase()
.replace(/ /g, '_');
const danbooruTag = tagMap.get(nameLower);
// Inherit properties from danbooru if exists,
// otherwise default to general category
const category = danbooruTag ? danbooruTag.category : 0;
const count = danbooruTag ? danbooruTag.count : 0;
presets.push({
tag: nameLower,
category: category,
count: count,
isAlias: false,
isPreset: true,
presetType: 'tag'
});
}
console.log(`Loaded ${presets.length} tag presets`);
return presets;
} catch (error) {
console.error('Error loading tag presets:', error);
return [];
}
}
export async function saveCharacters(characters) {
const response = await fetch('/character_editor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(characters)
});
if (!response.ok) {
throw new Error('Failed to save characters');
}
}
export async function saveModels(models) {
const response = await fetch('/model_editor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(models)
});
if (!response.ok) {
throw new Error('Failed to save models');
}
}
export async function saveStyles(styles) {
const response = await fetch('/style_editor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(styles)
});
if (!response.ok) {
throw new Error('Failed to save styles');
}
}
export async function saveTags(tags) {
const response = await fetch('/tag_editor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tags)
});
if (!response.ok) {
throw new Error('Failed to save tags');
}
}
export async function renameCharacter(oldName, newName, data) {
const response = await fetch('/character_editor/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
oldName: oldName,
newName: newName,
data: data
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to rename');
}
}
export async function checkImages(type) {
const dataMap = {
character: state.characters,
style: state.styles
};
const imageMap = {
character: state.characterImages,
style: state.styleImages
};
const endpoint = type === 'character' ?
'/character_editor/image/' : '/style_editor/image/';
// Create array of all check promises
const checks = Object.keys(dataMap[type]).map(async (name) => {
try {
const response = await fetch(
`${endpoint}${encodeName(name)}`,
{ method: 'HEAD' } // Use HEAD for faster checks
);
return { name, exists: response.ok };
} catch (e) {
return { name, exists: false };
}
});
// Wait for all checks to complete in parallel
const results = await Promise.all(checks);
// Update state with results
results.forEach(({ name, exists }) => {
imageMap[type][name] = exists;
});
}
export function getImageUrl(name, type = 'character') {
const endpoint = type === 'character' ?
'/character_editor/image/' : '/style_editor/image/';
// Remove timestamp to allow browser caching
return `${endpoint}${encodeName(name)}`;
}
export async function uploadImage(file, name, type = 'character') {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const endpoint = type === 'character' ?
'/character_editor/image/' : '/style_editor/image/';
const response = await fetch(
`${endpoint}${encodeName(name)}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
image: e.target.result
})
}
);
if (response.ok) {
if (type === 'character') {
state.characterImages[name] = true;
} else {
state.styleImages[name] = true;
}
resolve(true);
} else {
throw new Error('Failed to upload image');
}
} catch (error) {
console.error('Error uploading image:', error);
resolve(false);
}
};
reader.readAsDataURL(file);
});
}
export async function deleteImage(name, type = 'character') {
const endpoint = type === 'character' ?
'/character_editor/image/' : '/style_editor/image/';
const response = await fetch(
`${endpoint}${encodeName(name)}`,
{
method: 'DELETE'
}
);
if (response.ok || response.status === 404) {
if (type === 'character') {
state.characterImages[name] = false;
} else {
state.styleImages[name] = false;
}
return true;
}
throw new Error('Failed to delete image');
}
export async function loadLoraTriggerWords(name) {
try {
// Encode the path but preserve slashes so aiohttp's {name:.*}
// route pattern matches correctly.
const encoded = name.split('/').map(encodeURIComponent).join('/');
const response = await fetch(`/lora_trigger_words/${encoded}`);
if (!response.ok) return [];
const words = await response.json();
return Array.isArray(words) ? words : [];
} catch (error) {
console.error('Error loading LoRA trigger words:', error);
return [];
}
}
export async function loadLoras() {
try {
const response = await fetch('/lora_list');
if (!response.ok) {
console.log('Failed to load LoRA list');
return [];
}
const loras = await response.json();
console.log(`Loaded ${loras.length} LoRAs`);
return loras;
} catch (error) {
console.error('Error loading LoRAs:', error);
return [];
}
}
export async function loadEmbeddings() {
try {
const response = await fetch('/embedding_list');
if (!response.ok) {
console.log('Failed to load embedding list');
return [];
}
const embeddings = await response.json();
console.log(`Loaded ${embeddings.length} embeddings`);
return embeddings;
} catch (error) {
console.error('Error loading embeddings:', error);
return [];
}
}
export async function loadTagUsage() {
try {
const response = await fetch('/tag_usage');
if (!response.ok) {
console.log('No tag usage data available');
return {};
}
const usage = await response.json();
console.log(`Loaded usage data for ${Object.keys(usage).length} tags`);
return usage;
} catch (error) {
console.error('Error loading tag usage:', error);
return {};
}
}
export async function resetTagUsage() {
try {
const response = await fetch('/tag_usage/reset', {
method: 'POST'
});
if (!response.ok) {
throw new Error('Failed to reset tag usage');
}
return true;
} catch (error) {
console.error('Error resetting tag usage:', error);
return false;
}
}
export async function updateTagUsageCounts(tags, usageMultiplier = 1) {
try {
const tagUsage = await loadTagUsage();
if (Object.keys(tagUsage).length === 0) {
return tags;
}
const applyUsage = localStorage.getItem(
"Comfy.Settings.Mudknight Utils.Autocomplete.ApplyUsage"
) !== 'false';
if (!applyUsage) {
console.log('Tag usage boost disabled');
return tags;
}
const onlyExistingTags = localStorage.getItem(
"Comfy.Settings.Mudknight Utils.Autocomplete" +
".UsageOnlyExisting"
) === 'true';
console.log(
`Updating usage counts for ${Object.keys(tagUsage).length}` +
` tags...`
);
// Create usage map
let filteredUsage = tagUsage;
if (onlyExistingTags) {
const existingTags = new Set(
tags.map(t => t.tag.toLowerCase())
);
filteredUsage = {};
for (const [key, usage] of Object.entries(tagUsage)) {
if (existingTags.has(key)) {
filteredUsage[key] = usage;
}
}
}
// Update counts in place
let updatedCount = 0;
tags.forEach(tag => {
const usage = filteredUsage[tag.tag.toLowerCase()] || 0;
if (usage > 0) {
tag.count = (tag.count || 0) + (usage * usageMultiplier);
updatedCount++;
}
});
console.log(`Updated usage for ${updatedCount} tags`);
return tags;
} catch (error) {
console.error('Error updating tag usage:', error);
return tags;
}
}
export async function loadAutocompleteSettings() {
try {
const response = await fetch('/autocomplete_settings');
if (!response.ok) {
return { collect_tag_usage: true };
}
return await response.json();
} catch (error) {
console.error('Error loading autocomplete settings:', error);
return { collect_tag_usage: true };
}
}
export async function saveAutocompleteSettings(settings) {
try {
const response = await fetch('/autocomplete_settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
return response.ok;
} catch (error) {
console.error('Error saving autocomplete settings:', error);
return false;
}
}
export async function loadWildcards() {
const response = await fetch('/wildcard_editor');
if (response.ok) {
return await response.json();
}
throw new Error('Failed to load wildcards');
}
export async function saveWildcards(wildcards) {
const response = await fetch('/wildcard_editor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(wildcards)
});
if (!response.ok) {
throw new Error('Failed to save wildcards');
}
}
export async function loadWildcardPresets() {
try {
const response = await fetch('/wildcard_editor');
if (!response.ok) {
return [];
}
const wildcards = await response.json();
const presets = Object.keys(wildcards).map(name => ({
tag: name.toLowerCase().replace(/ /g, '_'),
category: 5, // meta category
count: 0,
isAlias: false,
isPreset: true,
presetType: 'wildcard'
}));
return presets;
} catch (error) {
console.error('Error loading wildcard presets:', error);
return [];
}
}