Some initial work on the prompt builder.
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"description": "Default tag library for SageUtils Prompt Builder",
|
||||
"categories": [
|
||||
{
|
||||
"id": "quality",
|
||||
"name": "Quality Tags",
|
||||
"description": "Tags for image quality and detail",
|
||||
"color": "#4a9eff",
|
||||
"order": 0,
|
||||
"tags": [
|
||||
"masterpiece",
|
||||
"best quality",
|
||||
"high quality",
|
||||
"absurdres",
|
||||
"detailed",
|
||||
"extremely detailed",
|
||||
"intricate details",
|
||||
"sharp focus",
|
||||
"crisp",
|
||||
"clear",
|
||||
"perfect",
|
||||
"flawless",
|
||||
"stunning",
|
||||
"beautiful",
|
||||
"gorgeous"
|
||||
],
|
||||
"sets": [
|
||||
{
|
||||
"id": "pony_quality",
|
||||
"name": "Pony Quality",
|
||||
"description": "Standard quality tags for Pony models",
|
||||
"tags": ["score_9", "score_8_up", "score_7_up", "score_6_up", "score_5_up", "score_4_up"]
|
||||
},
|
||||
{
|
||||
"id": "anime_quality",
|
||||
"name": "Anime Quality",
|
||||
"description": "Quality tags for anime/manga style",
|
||||
"tags": ["masterpiece", "best quality", "high quality", "absurdres"]
|
||||
},
|
||||
{
|
||||
"id": "realistic_quality",
|
||||
"name": "Realistic Quality",
|
||||
"description": "Quality tags for realistic/photographic style",
|
||||
"tags": ["photorealistic", "hyperrealistic", "sharp focus", "detailed"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pony_source",
|
||||
"name": "Pony Source",
|
||||
"description": "Source Tags for Pony v6",
|
||||
"tags": [
|
||||
"source_pony",
|
||||
"source_furry",
|
||||
"source_anime",
|
||||
"source_cartoon",
|
||||
"source_3d",
|
||||
"source_western",
|
||||
"source_comic",
|
||||
"source_monster"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pony_rating",
|
||||
"name": "Pony Rating",
|
||||
"description": "Rating Tags for Pony v6",
|
||||
"tags": [
|
||||
"rating_safe",
|
||||
"rating_questionable",
|
||||
"rating_explicit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "style",
|
||||
"name": "Art Style",
|
||||
"description": "Artistic styles and mediums",
|
||||
"color": "#ff6b6b",
|
||||
"order": 1,
|
||||
"tags": [
|
||||
"anime",
|
||||
"manga",
|
||||
"realistic",
|
||||
"semi-realistic",
|
||||
"cartoon",
|
||||
"digital art",
|
||||
"oil painting",
|
||||
"watercolor",
|
||||
"pencil drawing",
|
||||
"concept art",
|
||||
"illustration",
|
||||
"photorealistic",
|
||||
"3d render",
|
||||
"pixel art",
|
||||
"vector art",
|
||||
"sketch",
|
||||
"painting",
|
||||
"photography"
|
||||
],
|
||||
"sets": [
|
||||
{
|
||||
"id": "anime_styles",
|
||||
"name": "Anime Styles",
|
||||
"description": "Common anime and manga styles",
|
||||
"tags": ["anime", "manga", "cel shading", "flat colors"]
|
||||
},
|
||||
{
|
||||
"id": "realistic_styles",
|
||||
"name": "Realistic Styles",
|
||||
"description": "Photographic and realistic rendering styles",
|
||||
"tags": ["photorealistic", "hyperrealistic", "photography", "cinematic"]
|
||||
},
|
||||
{
|
||||
"id": "artistic_mediums",
|
||||
"name": "Artistic Mediums",
|
||||
"description": "Traditional and digital art mediums",
|
||||
"tags": ["oil painting", "watercolor", "digital art", "pencil drawing"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lighting",
|
||||
"name": "Lighting",
|
||||
"description": "Lighting and atmosphere tags",
|
||||
"color": "#ffd93d",
|
||||
"order": 2,
|
||||
"tags": [
|
||||
"soft lighting",
|
||||
"dramatic lighting",
|
||||
"natural lighting",
|
||||
"golden hour",
|
||||
"blue hour",
|
||||
"sunset",
|
||||
"sunrise",
|
||||
"rim lighting",
|
||||
"backlighting",
|
||||
"studio lighting",
|
||||
"neon lighting",
|
||||
"ambient lighting",
|
||||
"moody lighting",
|
||||
"cinematic lighting",
|
||||
"volumetric lighting"
|
||||
],
|
||||
"sets": [
|
||||
{
|
||||
"id": "time_of_day",
|
||||
"name": "Time of Day",
|
||||
"description": "Time-based lighting conditions",
|
||||
"tags": ["golden hour", "blue hour", "sunset", "sunrise", "midday", "twilight"]
|
||||
},
|
||||
{
|
||||
"id": "studio_lighting",
|
||||
"name": "Studio Lighting",
|
||||
"description": "Professional photography lighting setups",
|
||||
"tags": ["studio lighting", "soft lighting", "rim lighting", "key lighting"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "composition",
|
||||
"name": "Composition",
|
||||
"description": "Camera angles and framing",
|
||||
"color": "#6bcf7f",
|
||||
"order": 3,
|
||||
"tags": [
|
||||
"close-up",
|
||||
"medium shot",
|
||||
"full body",
|
||||
"portrait",
|
||||
"landscape",
|
||||
"wide shot",
|
||||
"overhead view",
|
||||
"low angle",
|
||||
"high angle",
|
||||
"dutch angle",
|
||||
"rule of thirds",
|
||||
"centered",
|
||||
"dynamic angle",
|
||||
"from behind",
|
||||
"from side",
|
||||
"front view"
|
||||
],
|
||||
"sets": [
|
||||
{
|
||||
"id": "portrait_shots",
|
||||
"name": "Portrait Shots",
|
||||
"description": "Character and portrait framing",
|
||||
"tags": ["close-up", "portrait", "headshot", "upper body"]
|
||||
},
|
||||
{
|
||||
"id": "camera_angles",
|
||||
"name": "Camera Angles",
|
||||
"description": "Various camera angle perspectives",
|
||||
"tags": ["low angle", "high angle", "dutch angle", "overhead view"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "character",
|
||||
"name": "Character",
|
||||
"description": "Character-related tags",
|
||||
"color": "#ff9ff3",
|
||||
"order": 4,
|
||||
"tags": [
|
||||
"1girl",
|
||||
"1boy",
|
||||
"solo",
|
||||
"multiple girls",
|
||||
"multiple boys",
|
||||
"young",
|
||||
"adult",
|
||||
"elderly",
|
||||
"child",
|
||||
"teenager",
|
||||
"mature",
|
||||
"cute",
|
||||
"beautiful",
|
||||
"handsome",
|
||||
"elegant"
|
||||
],
|
||||
"sets": [
|
||||
{
|
||||
"id": "character_count",
|
||||
"name": "Character Count",
|
||||
"description": "Number and gender of characters",
|
||||
"tags": ["1girl", "1boy", "solo", "multiple girls", "multiple boys"]
|
||||
},
|
||||
{
|
||||
"id": "age_groups",
|
||||
"name": "Age Groups",
|
||||
"description": "Character age descriptions",
|
||||
"tags": ["young", "adult", "mature", "elderly", "child", "teenager"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "colors",
|
||||
"name": "Colors",
|
||||
"description": "Color-related tags",
|
||||
"color": "#9c88ff",
|
||||
"order": 5,
|
||||
"tags": [
|
||||
"red",
|
||||
"blue",
|
||||
"green",
|
||||
"yellow",
|
||||
"purple",
|
||||
"orange",
|
||||
"pink",
|
||||
"black",
|
||||
"white",
|
||||
"gray",
|
||||
"brown",
|
||||
"colorful",
|
||||
"monochrome",
|
||||
"sepia",
|
||||
"vibrant colors",
|
||||
"pastel colors"
|
||||
],
|
||||
"sets": [
|
||||
{
|
||||
"id": "primary_colors",
|
||||
"name": "Primary Colors",
|
||||
"description": "Basic color palette",
|
||||
"tags": ["red", "blue", "yellow", "green", "purple", "orange"]
|
||||
},
|
||||
{
|
||||
"id": "color_styles",
|
||||
"name": "Color Styles",
|
||||
"description": "Color treatment and mood",
|
||||
"tags": ["colorful", "monochrome", "vibrant colors", "pastel colors", "sepia"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mood",
|
||||
"name": "Mood & Atmosphere",
|
||||
"description": "Emotional and atmospheric tags",
|
||||
"color": "#ff6b9d",
|
||||
"order": 6,
|
||||
"tags": [
|
||||
"peaceful",
|
||||
"serene",
|
||||
"dramatic",
|
||||
"mysterious",
|
||||
"romantic",
|
||||
"melancholic",
|
||||
"joyful",
|
||||
"energetic",
|
||||
"calm",
|
||||
"intense",
|
||||
"dreamy",
|
||||
"ethereal",
|
||||
"dark",
|
||||
"bright",
|
||||
"cozy",
|
||||
"epic"
|
||||
],
|
||||
"sets": [
|
||||
{
|
||||
"id": "positive_moods",
|
||||
"name": "Positive Moods",
|
||||
"description": "Uplifting and positive atmospheres",
|
||||
"tags": ["joyful", "peaceful", "bright", "cozy", "romantic"]
|
||||
},
|
||||
{
|
||||
"id": "dramatic_moods",
|
||||
"name": "Dramatic Moods",
|
||||
"description": "Intense and dramatic atmospheres",
|
||||
"tags": ["dramatic", "mysterious", "intense", "epic", "dark"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"created": "2025-09-15",
|
||||
"version": "1.0",
|
||||
"total_categories": 7,
|
||||
"total_tags": 0,
|
||||
"total_sets": 0,
|
||||
"description": "Comprehensive default tag library for AI image generation prompt building"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Prompt Builder API Module
|
||||
* Handles communication with the server for prompt builder functionality
|
||||
*/
|
||||
|
||||
import { api } from "../../../../scripts/api.js";
|
||||
import { generateWildcardPrompt } from "../../shared/wildcardApi.js";
|
||||
|
||||
/**
|
||||
* Prompt Builder API class for managing server communication
|
||||
*/
|
||||
export class PromptBuilderApi {
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
this.cacheTimeout = 5 * 60 * 1000; // 5 minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a prompt using the wildcard system
|
||||
* @param {string} prompt - The prompt text containing wildcards
|
||||
* @param {number} seed - The random seed for generation
|
||||
* @returns {Promise<{success: boolean, result?: string, error?: string}>}
|
||||
*/
|
||||
async generatePrompt(prompt, seed = 0) {
|
||||
try {
|
||||
console.debug('Generating prompt:', { prompt, seed });
|
||||
|
||||
// Use the existing wildcard API
|
||||
const result = await generateWildcardPrompt(prompt, seed);
|
||||
|
||||
if (result.success) {
|
||||
console.debug('Prompt generated successfully:', result.result);
|
||||
return {
|
||||
success: true,
|
||||
result: result.result,
|
||||
originalPrompt: result.original_prompt,
|
||||
seed: result.seed
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to generate prompt:', result.error);
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || 'Unknown error occurred'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in generatePrompt:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Network error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate multiple prompts with incremented seeds
|
||||
* @param {string} positivePrompt - The positive prompt text
|
||||
* @param {string} negativePrompt - The negative prompt text
|
||||
* @param {number} baseSeed - The base seed for generation
|
||||
* @param {number} count - Number of prompts to generate
|
||||
* @returns {Promise<Array>} - Array of generated prompt objects
|
||||
*/
|
||||
async generateMultiplePrompts(positivePrompt, negativePrompt, baseSeed, count) {
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const currentSeed = baseSeed + i;
|
||||
|
||||
// Generate positive prompt
|
||||
const positiveResult = positivePrompt.trim()
|
||||
? await this.generatePrompt(positivePrompt, currentSeed)
|
||||
: { success: true, result: '', seed: currentSeed };
|
||||
|
||||
// Generate negative prompt
|
||||
const negativeResult = negativePrompt.trim()
|
||||
? await this.generatePrompt(negativePrompt, currentSeed)
|
||||
: { success: true, result: '', seed: currentSeed };
|
||||
|
||||
if (positiveResult.success && negativeResult.success) {
|
||||
results.push({
|
||||
id: `prompt_${Date.now()}_${i}`,
|
||||
seed: currentSeed,
|
||||
positive: positiveResult.result || '',
|
||||
negative: negativeResult.result || '',
|
||||
originalPositive: positivePrompt,
|
||||
originalNegative: negativePrompt,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
// Log error but continue with other prompts
|
||||
console.error(`Failed to generate prompt ${i + 1}:`, {
|
||||
positive: positiveResult.error,
|
||||
negative: negativeResult.error
|
||||
});
|
||||
|
||||
results.push({
|
||||
id: `prompt_${Date.now()}_${i}`,
|
||||
seed: currentSeed,
|
||||
positive: positiveResult.result || '',
|
||||
negative: negativeResult.result || '',
|
||||
originalPositive: positivePrompt,
|
||||
originalNegative: negativePrompt,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: positiveResult.error || negativeResult.error
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in generateMultiplePrompts:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random seed
|
||||
* @returns {number} - Random seed value
|
||||
*/
|
||||
generateRandomSeed() {
|
||||
return Math.floor(Math.random() * 2147483647);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a prompt for wildcard syntax
|
||||
* @param {string} prompt - The prompt to validate
|
||||
* @returns {Object} - Validation result with wildcards found
|
||||
*/
|
||||
validatePrompt(prompt) {
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
return {
|
||||
isValid: true,
|
||||
wildcards: [],
|
||||
hasWildcards: false
|
||||
};
|
||||
}
|
||||
|
||||
// Extract wildcard patterns
|
||||
const wildcardPattern = /__([^_]+)__/g;
|
||||
const wildcards = [];
|
||||
let match;
|
||||
|
||||
while ((match = wildcardPattern.exec(prompt)) !== null) {
|
||||
const wildcardName = match[1];
|
||||
if (!wildcards.includes(wildcardName)) {
|
||||
wildcards.push(wildcardName);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
wildcards,
|
||||
hasWildcards: wildcards.length > 0,
|
||||
wildcardCount: wildcards.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format prompt for display
|
||||
* @param {string} prompt - The prompt to format
|
||||
* @param {number} maxLength - Maximum length before truncation
|
||||
* @returns {string} - Formatted prompt
|
||||
*/
|
||||
formatPromptPreview(prompt, maxLength = 100) {
|
||||
if (!prompt) return '';
|
||||
|
||||
if (prompt.length <= maxLength) {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
return prompt.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
* @param {string} text - Text to copy
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
async copyToClipboard(text) {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
return success;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear API cache
|
||||
*/
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
* @returns {Object} - Cache statistics
|
||||
*/
|
||||
getCacheStats() {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
timeout: this.cacheTimeout
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const promptBuilderApi = new PromptBuilderApi();
|
||||
|
||||
// Export individual functions for convenience
|
||||
export const {
|
||||
generatePrompt,
|
||||
generateMultiplePrompts,
|
||||
generateRandomSeed,
|
||||
validatePrompt,
|
||||
formatPromptPreview,
|
||||
copyToClipboard
|
||||
} = promptBuilderApi;
|
||||
|
||||
export default promptBuilderApi;
|
||||
@@ -0,0 +1,865 @@
|
||||
/**
|
||||
* Prompt Generation Component
|
||||
* Handles the main prompt input, generation controls, and results display
|
||||
*/
|
||||
|
||||
import { promptBuilderApi } from './promptBuilderApi.js';
|
||||
|
||||
/**
|
||||
* Prompt Generation Component
|
||||
*/
|
||||
export const promptGenerationComponent = {
|
||||
// Component state
|
||||
state: {
|
||||
isGenerating: false,
|
||||
results: [],
|
||||
positivePrompt: '',
|
||||
negativePrompt: '',
|
||||
seed: 0,
|
||||
count: 1
|
||||
},
|
||||
|
||||
// Component elements
|
||||
elements: {
|
||||
container: null,
|
||||
positiveTextarea: null,
|
||||
negativeTextarea: null,
|
||||
seedInput: null,
|
||||
countInput: null,
|
||||
generateButton: null,
|
||||
randomSeedButton: null,
|
||||
resultsContainer: null,
|
||||
clearButton: null,
|
||||
loadingIndicator: null
|
||||
},
|
||||
|
||||
/**
|
||||
* Create the prompt generation component
|
||||
* @returns {HTMLElement} - The component container
|
||||
*/
|
||||
create() {
|
||||
this.elements.container = document.createElement('div');
|
||||
this.elements.container.className = 'prompt-generation-component';
|
||||
|
||||
// Create prompt input section
|
||||
const inputSection = this.createInputSection();
|
||||
this.elements.container.appendChild(inputSection);
|
||||
|
||||
// Create controls section
|
||||
const controlsSection = this.createControlsSection();
|
||||
this.elements.container.appendChild(controlsSection);
|
||||
|
||||
// Create results section
|
||||
const resultsSection = this.createResultsSection();
|
||||
this.elements.container.appendChild(resultsSection);
|
||||
|
||||
// Add component styles
|
||||
this.addStyles();
|
||||
|
||||
// Initialize with random seed
|
||||
this.generateRandomSeed();
|
||||
|
||||
return this.elements.container;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create the prompt input section
|
||||
* @returns {HTMLElement} - The input section
|
||||
*/
|
||||
createInputSection() {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'prompt-input-section';
|
||||
|
||||
// Positive prompt
|
||||
const positiveGroup = document.createElement('div');
|
||||
positiveGroup.className = 'input-group';
|
||||
|
||||
const positiveLabel = document.createElement('label');
|
||||
positiveLabel.textContent = 'Positive Prompt';
|
||||
positiveLabel.className = 'input-label';
|
||||
positiveLabel.htmlFor = 'positive-prompt';
|
||||
|
||||
this.elements.positiveTextarea = document.createElement('textarea');
|
||||
this.elements.positiveTextarea.id = 'positive-prompt';
|
||||
this.elements.positiveTextarea.className = 'prompt-textarea positive-prompt';
|
||||
this.elements.positiveTextarea.placeholder = 'Enter positive prompt with __wildcards__...\n\nExample: __character__ in a __location__, __art_style__';
|
||||
this.elements.positiveTextarea.rows = 4;
|
||||
|
||||
positiveGroup.appendChild(positiveLabel);
|
||||
positiveGroup.appendChild(this.elements.positiveTextarea);
|
||||
|
||||
// Negative prompt
|
||||
const negativeGroup = document.createElement('div');
|
||||
negativeGroup.className = 'input-group';
|
||||
|
||||
const negativeLabel = document.createElement('label');
|
||||
negativeLabel.textContent = 'Negative Prompt';
|
||||
negativeLabel.className = 'input-label';
|
||||
negativeLabel.htmlFor = 'negative-prompt';
|
||||
|
||||
this.elements.negativeTextarea = document.createElement('textarea');
|
||||
this.elements.negativeTextarea.id = 'negative-prompt';
|
||||
this.elements.negativeTextarea.className = 'prompt-textarea negative-prompt';
|
||||
this.elements.negativeTextarea.placeholder = 'Enter negative prompt...';
|
||||
this.elements.negativeTextarea.rows = 3;
|
||||
|
||||
negativeGroup.appendChild(negativeLabel);
|
||||
negativeGroup.appendChild(this.elements.negativeTextarea);
|
||||
|
||||
section.appendChild(positiveGroup);
|
||||
section.appendChild(negativeGroup);
|
||||
|
||||
// Add event listeners
|
||||
this.elements.positiveTextarea.addEventListener('input', () => {
|
||||
this.state.positivePrompt = this.elements.positiveTextarea.value;
|
||||
this.updateWildcardHighlighting(this.elements.positiveTextarea);
|
||||
});
|
||||
|
||||
this.elements.negativeTextarea.addEventListener('input', () => {
|
||||
this.state.negativePrompt = this.elements.negativeTextarea.value;
|
||||
this.updateWildcardHighlighting(this.elements.negativeTextarea);
|
||||
});
|
||||
|
||||
return section;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create the controls section
|
||||
* @returns {HTMLElement} - The controls section
|
||||
*/
|
||||
createControlsSection() {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'controls-section';
|
||||
|
||||
// Seed control
|
||||
const seedGroup = document.createElement('div');
|
||||
seedGroup.className = 'control-group';
|
||||
|
||||
const seedLabel = document.createElement('label');
|
||||
seedLabel.textContent = 'Seed';
|
||||
seedLabel.className = 'control-label';
|
||||
|
||||
const seedContainer = document.createElement('div');
|
||||
seedContainer.className = 'seed-container';
|
||||
|
||||
this.elements.seedInput = document.createElement('input');
|
||||
this.elements.seedInput.type = 'number';
|
||||
this.elements.seedInput.className = 'seed-input';
|
||||
this.elements.seedInput.min = '0';
|
||||
this.elements.seedInput.max = '2147483647';
|
||||
this.elements.seedInput.value = '0';
|
||||
|
||||
this.elements.randomSeedButton = document.createElement('button');
|
||||
this.elements.randomSeedButton.textContent = '🎲';
|
||||
this.elements.randomSeedButton.className = 'random-seed-button';
|
||||
this.elements.randomSeedButton.title = 'Generate random seed';
|
||||
|
||||
seedContainer.appendChild(this.elements.seedInput);
|
||||
seedContainer.appendChild(this.elements.randomSeedButton);
|
||||
|
||||
seedGroup.appendChild(seedLabel);
|
||||
seedGroup.appendChild(seedContainer);
|
||||
|
||||
// Count control
|
||||
const countGroup = document.createElement('div');
|
||||
countGroup.className = 'control-group';
|
||||
|
||||
const countLabel = document.createElement('label');
|
||||
countLabel.textContent = 'Count';
|
||||
countLabel.className = 'control-label';
|
||||
|
||||
this.elements.countInput = document.createElement('input');
|
||||
this.elements.countInput.type = 'number';
|
||||
this.elements.countInput.className = 'count-input';
|
||||
this.elements.countInput.min = '1';
|
||||
this.elements.countInput.max = '10';
|
||||
this.elements.countInput.value = '1';
|
||||
|
||||
countGroup.appendChild(countLabel);
|
||||
countGroup.appendChild(this.elements.countInput);
|
||||
|
||||
// Action buttons
|
||||
const actionGroup = document.createElement('div');
|
||||
actionGroup.className = 'action-group';
|
||||
|
||||
this.elements.generateButton = document.createElement('button');
|
||||
this.elements.generateButton.textContent = '✨ Generate Prompts';
|
||||
this.elements.generateButton.className = 'generate-button primary-button';
|
||||
|
||||
this.elements.clearButton = document.createElement('button');
|
||||
this.elements.clearButton.textContent = '🗑️ Clear Results';
|
||||
this.elements.clearButton.className = 'clear-button secondary-button';
|
||||
|
||||
actionGroup.appendChild(this.elements.generateButton);
|
||||
actionGroup.appendChild(this.elements.clearButton);
|
||||
|
||||
section.appendChild(seedGroup);
|
||||
section.appendChild(countGroup);
|
||||
section.appendChild(actionGroup);
|
||||
|
||||
// Add event listeners
|
||||
this.elements.seedInput.addEventListener('input', () => {
|
||||
this.state.seed = parseInt(this.elements.seedInput.value) || 0;
|
||||
});
|
||||
|
||||
this.elements.countInput.addEventListener('input', () => {
|
||||
this.state.count = Math.max(1, Math.min(10, parseInt(this.elements.countInput.value) || 1));
|
||||
this.elements.countInput.value = this.state.count;
|
||||
});
|
||||
|
||||
this.elements.randomSeedButton.addEventListener('click', () => {
|
||||
this.generateRandomSeed();
|
||||
});
|
||||
|
||||
this.elements.generateButton.addEventListener('click', () => {
|
||||
this.generatePrompts();
|
||||
});
|
||||
|
||||
this.elements.clearButton.addEventListener('click', () => {
|
||||
this.clearResults();
|
||||
});
|
||||
|
||||
return section;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create the results section
|
||||
* @returns {HTMLElement} - The results section
|
||||
*/
|
||||
createResultsSection() {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'results-section';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'results-header';
|
||||
|
||||
const title = document.createElement('h4');
|
||||
title.textContent = 'Generated Prompts';
|
||||
title.className = 'results-title';
|
||||
|
||||
this.elements.loadingIndicator = document.createElement('div');
|
||||
this.elements.loadingIndicator.className = 'loading-indicator hidden';
|
||||
this.elements.loadingIndicator.innerHTML = '<div class="spinner"></div> Generating...';
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(this.elements.loadingIndicator);
|
||||
|
||||
this.elements.resultsContainer = document.createElement('div');
|
||||
this.elements.resultsContainer.className = 'results-container';
|
||||
|
||||
section.appendChild(header);
|
||||
section.appendChild(this.elements.resultsContainer);
|
||||
|
||||
return section;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate random seed
|
||||
*/
|
||||
generateRandomSeed() {
|
||||
const randomSeed = promptBuilderApi.generateRandomSeed();
|
||||
this.state.seed = randomSeed;
|
||||
this.elements.seedInput.value = randomSeed;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate prompts using the API
|
||||
*/
|
||||
async generatePrompts() {
|
||||
if (this.state.isGenerating) return;
|
||||
|
||||
const positivePrompt = this.elements.positiveTextarea.value.trim();
|
||||
const negativePrompt = this.elements.negativeTextarea.value.trim();
|
||||
|
||||
if (!positivePrompt && !negativePrompt) {
|
||||
this.showMessage('Please enter at least one prompt (positive or negative)', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setGenerating(true);
|
||||
|
||||
try {
|
||||
const results = await promptBuilderApi.generateMultiplePrompts(
|
||||
positivePrompt,
|
||||
negativePrompt,
|
||||
this.state.seed,
|
||||
this.state.count
|
||||
);
|
||||
|
||||
this.state.results = [...this.state.results, ...results];
|
||||
this.renderResults();
|
||||
this.showMessage(`Generated ${results.length} prompt(s) successfully!`, 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating prompts:', error);
|
||||
this.showMessage('Failed to generate prompts. Please try again.', 'error');
|
||||
} finally {
|
||||
this.setGenerating(false);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set generating state
|
||||
* @param {boolean} isGenerating - Whether generation is in progress
|
||||
*/
|
||||
setGenerating(isGenerating) {
|
||||
this.state.isGenerating = isGenerating;
|
||||
this.elements.generateButton.disabled = isGenerating;
|
||||
this.elements.loadingIndicator.classList.toggle('hidden', !isGenerating);
|
||||
|
||||
if (isGenerating) {
|
||||
this.elements.generateButton.textContent = '⏳ Generating...';
|
||||
} else {
|
||||
this.elements.generateButton.textContent = '✨ Generate Prompts';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all results
|
||||
*/
|
||||
clearResults() {
|
||||
this.state.results = [];
|
||||
this.renderResults();
|
||||
this.showMessage('Results cleared', 'info');
|
||||
},
|
||||
|
||||
/**
|
||||
* Render the results
|
||||
*/
|
||||
renderResults() {
|
||||
this.elements.resultsContainer.innerHTML = '';
|
||||
|
||||
if (this.state.results.length === 0) {
|
||||
const emptyState = document.createElement('div');
|
||||
emptyState.className = 'empty-state';
|
||||
emptyState.textContent = 'No prompts generated yet. Enter prompts above and click Generate!';
|
||||
this.elements.resultsContainer.appendChild(emptyState);
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.results.forEach((result, index) => {
|
||||
const resultItem = this.createResultItem(result, index);
|
||||
this.elements.resultsContainer.appendChild(resultItem);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a result item element
|
||||
* @param {Object} result - The result data
|
||||
* @param {number} index - The result index
|
||||
* @returns {HTMLElement} - The result item element
|
||||
*/
|
||||
createResultItem(result, index) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'result-item';
|
||||
if (result.error) {
|
||||
item.classList.add('has-error');
|
||||
}
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'result-header';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'result-title';
|
||||
title.textContent = `Prompt ${index + 1} (Seed: ${result.seed})`;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'result-actions';
|
||||
|
||||
// Copy positive button
|
||||
if (result.positive) {
|
||||
const copyPositiveBtn = document.createElement('button');
|
||||
copyPositiveBtn.textContent = '📋 Copy Positive';
|
||||
copyPositiveBtn.className = 'copy-button';
|
||||
copyPositiveBtn.addEventListener('click', () => {
|
||||
this.copyResult(result.positive, 'Positive prompt copied!');
|
||||
});
|
||||
actions.appendChild(copyPositiveBtn);
|
||||
}
|
||||
|
||||
// Copy negative button
|
||||
if (result.negative) {
|
||||
const copyNegativeBtn = document.createElement('button');
|
||||
copyNegativeBtn.textContent = '📋 Copy Negative';
|
||||
copyNegativeBtn.className = 'copy-button';
|
||||
copyNegativeBtn.addEventListener('click', () => {
|
||||
this.copyResult(result.negative, 'Negative prompt copied!');
|
||||
});
|
||||
actions.appendChild(copyNegativeBtn);
|
||||
}
|
||||
|
||||
// Load button
|
||||
const loadBtn = document.createElement('button');
|
||||
loadBtn.textContent = '⬆️ Load';
|
||||
loadBtn.className = 'load-button';
|
||||
loadBtn.addEventListener('click', () => {
|
||||
this.loadResult(result);
|
||||
});
|
||||
actions.appendChild(loadBtn);
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(actions);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'result-content';
|
||||
|
||||
if (result.positive) {
|
||||
const positiveDiv = document.createElement('div');
|
||||
positiveDiv.className = 'result-text positive';
|
||||
positiveDiv.innerHTML = `<strong>Positive:</strong> ${result.positive}`;
|
||||
content.appendChild(positiveDiv);
|
||||
}
|
||||
|
||||
if (result.negative) {
|
||||
const negativeDiv = document.createElement('div');
|
||||
negativeDiv.className = 'result-text negative';
|
||||
negativeDiv.innerHTML = `<strong>Negative:</strong> ${result.negative}`;
|
||||
content.appendChild(negativeDiv);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'result-error';
|
||||
errorDiv.textContent = `Error: ${result.error}`;
|
||||
content.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
item.appendChild(header);
|
||||
item.appendChild(content);
|
||||
|
||||
return item;
|
||||
},
|
||||
|
||||
/**
|
||||
* Copy result to clipboard
|
||||
* @param {string} text - Text to copy
|
||||
* @param {string} message - Success message
|
||||
*/
|
||||
async copyResult(text, message) {
|
||||
const success = await promptBuilderApi.copyToClipboard(text);
|
||||
if (success) {
|
||||
this.showMessage(message, 'success');
|
||||
} else {
|
||||
this.showMessage('Failed to copy to clipboard', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load result back into input fields
|
||||
* @param {Object} result - The result to load
|
||||
*/
|
||||
loadResult(result) {
|
||||
if (result.positive) {
|
||||
this.elements.positiveTextarea.value = result.originalPositive || result.positive;
|
||||
this.state.positivePrompt = this.elements.positiveTextarea.value;
|
||||
}
|
||||
if (result.negative) {
|
||||
this.elements.negativeTextarea.value = result.originalNegative || result.negative;
|
||||
this.state.negativePrompt = this.elements.negativeTextarea.value;
|
||||
}
|
||||
if (result.seed) {
|
||||
this.elements.seedInput.value = result.seed;
|
||||
this.state.seed = result.seed;
|
||||
}
|
||||
this.showMessage('Prompt loaded into fields', 'success');
|
||||
},
|
||||
|
||||
/**
|
||||
* Update wildcard highlighting (placeholder for future enhancement)
|
||||
* @param {HTMLElement} textarea - The textarea to update
|
||||
*/
|
||||
updateWildcardHighlighting(textarea) {
|
||||
// For now, just validate the prompt
|
||||
const validation = promptBuilderApi.validatePrompt(textarea.value);
|
||||
|
||||
// Update textarea title with wildcard info
|
||||
if (validation.hasWildcards) {
|
||||
textarea.title = `Contains ${validation.wildcardCount} wildcard(s): ${validation.wildcards.join(', ')}`;
|
||||
} else {
|
||||
textarea.title = '';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a message to the user
|
||||
* @param {string} message - The message to show
|
||||
* @param {string} type - The message type (success, error, warning, info)
|
||||
*/
|
||||
showMessage(message, type = 'info') {
|
||||
// For now, just log to console. In the future, this could show toast notifications
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
|
||||
// You could implement a toast notification system here
|
||||
// For now, we'll use a simple alert for errors
|
||||
if (type === 'error') {
|
||||
// Could show a non-intrusive notification instead
|
||||
setTimeout(() => {
|
||||
if (this.elements.container) {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification ${type}`;
|
||||
notification.textContent = message;
|
||||
this.elements.container.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add component styles
|
||||
*/
|
||||
addStyles() {
|
||||
const styleId = 'prompt-generation-styles';
|
||||
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.prompt-generation-component {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.prompt-input-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-color, #ffffff);
|
||||
}
|
||||
|
||||
.prompt-textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-color, #1a1a1a);
|
||||
color: var(--fg-color, #ffffff);
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.prompt-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #4a9eff);
|
||||
box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.positive-prompt {
|
||||
border-left: 3px solid #4caf50;
|
||||
}
|
||||
|
||||
.negative-prompt {
|
||||
border-left: 3px solid #f44336;
|
||||
}
|
||||
|
||||
.controls-section {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-weight: 500;
|
||||
color: var(--fg-color, #ffffff);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.seed-container {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.seed-input, .count-input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color, #1a1a1a);
|
||||
color: var(--fg-color, #ffffff);
|
||||
font-size: 14px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.random-seed-button {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color-secondary, #2a2a2a);
|
||||
color: var(--fg-color, #ffffff);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.random-seed-button:hover {
|
||||
background: var(--hover-color, #3a3a3a);
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.primary-button, .secondary-button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: var(--primary-color, #4a9eff);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
background: var(--primary-dark, #357abd);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: var(--bg-color-secondary, #2a2a2a);
|
||||
color: var(--fg-color, #ffffff);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
}
|
||||
|
||||
.secondary-button:hover {
|
||||
background: var(--hover-color, #3a3a3a);
|
||||
}
|
||||
|
||||
.results-section {
|
||||
border-top: 1px solid var(--border-color, #444);
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.results-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--fg-color, #ffffff);
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--primary-color, #4a9eff);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-indicator.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.results-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary, #888);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-color-secondary, #2a2a2a);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-item.has-error {
|
||||
border-color: #f44336;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-color-tertiary, #1e1e1e);
|
||||
border-bottom: 1px solid var(--border-color, #444);
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-weight: 500;
|
||||
color: var(--fg-color, #ffffff);
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.copy-button, .load-button {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color, #1a1a1a);
|
||||
color: var(--fg-color, #ffffff);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copy-button:hover, .load-button:hover {
|
||||
background: var(--hover-color, #3a3a3a);
|
||||
}
|
||||
|
||||
.result-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.result-text.positive {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
border-left: 3px solid #4caf50;
|
||||
}
|
||||
|
||||
.result-text.negative {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border-left: 3px solid #f44336;
|
||||
}
|
||||
|
||||
.result-text strong {
|
||||
color: var(--fg-color, #ffffff);
|
||||
}
|
||||
|
||||
.result-error {
|
||||
color: #f44336;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
padding: 8px;
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
z-index: 10000;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.notification.error {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.notification.success {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.notification.warning {
|
||||
background: #ff9800;
|
||||
}
|
||||
|
||||
.notification.info {
|
||||
background: var(--primary-color, #4a9eff);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.controls-section {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
margin-left: 0;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.primary-button, .secondary-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
align-self: stretch;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
};
|
||||
|
||||
export default promptGenerationComponent;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Tag Management API Module
|
||||
* Handles communication with the server for tag library functionality
|
||||
*/
|
||||
|
||||
import { api } from "../../../scripts/api.js";
|
||||
|
||||
/**
|
||||
* Tag API class for managing tag library operations
|
||||
*/
|
||||
export class TagApi {
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
this.cacheTimeout = 5 * 60 * 1000; // 5 minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the complete tag library
|
||||
* @param {boolean} useCache - Whether to use cached data (default: true)
|
||||
* @returns {Promise<{success: boolean, data?: Object, error?: string}>}
|
||||
*/
|
||||
async getTagLibrary(useCache = true) {
|
||||
try {
|
||||
const cacheKey = 'tag_library';
|
||||
|
||||
// Check cache first
|
||||
if (useCache && this.cache.has(cacheKey)) {
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (Date.now() - cached.timestamp < this.cacheTimeout) {
|
||||
return { success: true, data: cached.data };
|
||||
}
|
||||
}
|
||||
|
||||
const response = await api.fetchApi('/sage_utils/tags/library');
|
||||
const result = await response.json();
|
||||
|
||||
// Handle the response format - check if data is nested
|
||||
if (result.success && result.data) {
|
||||
// Cache the result
|
||||
this.cache.set(cacheKey, {
|
||||
data: result.data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error getting tag library:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Network error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the complete tag library
|
||||
* @param {Object} libraryData - The complete library data
|
||||
* @returns {Promise<{success: boolean, data?: Object, error?: string}>}
|
||||
*/
|
||||
async saveTagLibrary(libraryData) {
|
||||
try {
|
||||
const response = await api.fetchApi('/sage_utils/tags/library', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(libraryData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Clear cache on successful save
|
||||
if (result.success) {
|
||||
this.clearCache();
|
||||
}
|
||||
|
||||
// Handle the response format - check if data is nested
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: result.message
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error saving tag library:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Network error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update a category
|
||||
* @param {Object} categoryData - The category data
|
||||
* @returns {Promise<{success: boolean, data?: Object, error?: string}>}
|
||||
*/
|
||||
async saveCategory(categoryData) {
|
||||
try {
|
||||
const response = await api.fetchApi('/sage_utils/tags/category', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(categoryData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Clear cache on successful save
|
||||
if (result.success) {
|
||||
this.clearCache();
|
||||
}
|
||||
|
||||
// Handle the response format - check if data is nested
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: result.message
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error saving category:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Network error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a category
|
||||
* @param {string} categoryId - The category ID to delete
|
||||
* @returns {Promise<{success: boolean, message?: string, error?: string}>}
|
||||
*/
|
||||
async deleteCategory(categoryId) {
|
||||
try {
|
||||
const response = await api.fetchApi(`/sage_utils/tags/category/${encodeURIComponent(categoryId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Clear cache on successful delete
|
||||
if (result.success) {
|
||||
this.clearCache();
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error deleting category:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Network error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for tags across categories
|
||||
* @param {string} query - Search query
|
||||
* @param {string} categoryId - Optional category ID to limit search
|
||||
* @param {number} limit - Optional limit (default: 50)
|
||||
* @returns {Promise<{success: boolean, data?: Object, error?: string}>}
|
||||
*/
|
||||
async searchTags(query, categoryId = '', limit = 50) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
limit: limit.toString()
|
||||
});
|
||||
|
||||
if (categoryId) {
|
||||
params.append('category', categoryId);
|
||||
}
|
||||
|
||||
const response = await api.fetchApi(`/sage_utils/tags/search?${params}`);
|
||||
const result = await response.json();
|
||||
|
||||
// Handle the response format - check if data is nested
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error searching tags:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Network error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a tag to a category
|
||||
* @param {string} categoryId - Category ID
|
||||
* @param {string} tag - Tag to add
|
||||
* @returns {Promise<{success: boolean, data?: Object, error?: string}>}
|
||||
*/
|
||||
async addTagToCategory(categoryId, tag) {
|
||||
try {
|
||||
// First get the current library
|
||||
const libraryResult = await this.getTagLibrary(false); // Don't use cache
|
||||
if (!libraryResult.success) {
|
||||
return libraryResult;
|
||||
}
|
||||
|
||||
const library = libraryResult.data;
|
||||
const category = library.categories.find(c => c.id === categoryId);
|
||||
|
||||
if (!category) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Category '${categoryId}' not found`
|
||||
};
|
||||
}
|
||||
|
||||
// Add tag if it doesn't exist
|
||||
if (!category.tags.includes(tag)) {
|
||||
category.tags.push(tag);
|
||||
category.tags.sort(); // Keep tags sorted
|
||||
|
||||
// Save the updated category
|
||||
return await this.saveCategory(category);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Tag '${tag}' already exists in category '${category.name}'`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error adding tag to category:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Network error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a tag from a category
|
||||
* @param {string} categoryId - Category ID
|
||||
* @param {string} tag - Tag to remove
|
||||
* @returns {Promise<{success: boolean, data?: Object, error?: string}>}
|
||||
*/
|
||||
async removeTagFromCategory(categoryId, tag) {
|
||||
try {
|
||||
// First get the current library
|
||||
const libraryResult = await this.getTagLibrary(false); // Don't use cache
|
||||
if (!libraryResult.success) {
|
||||
return libraryResult;
|
||||
}
|
||||
|
||||
const library = libraryResult.data;
|
||||
const category = library.categories.find(c => c.id === categoryId);
|
||||
|
||||
if (!category) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Category '${categoryId}' not found`
|
||||
};
|
||||
}
|
||||
|
||||
// Remove tag if it exists
|
||||
const tagIndex = category.tags.indexOf(tag);
|
||||
if (tagIndex > -1) {
|
||||
category.tags.splice(tagIndex, 1);
|
||||
|
||||
// Save the updated category
|
||||
return await this.saveCategory(category);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Tag '${tag}' not found in category '${category.name}'`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error removing tag from category:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Network error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a tag set to a category
|
||||
* @param {string} categoryId - Category ID
|
||||
* @param {Object} tagSet - Tag set object {id, name, description, tags}
|
||||
* @returns {Promise<{success: boolean, data?: Object, error?: string}>}
|
||||
*/
|
||||
async addTagSetToCategory(categoryId, tagSet) {
|
||||
try {
|
||||
// First get the current library
|
||||
const libraryResult = await this.getTagLibrary(false); // Don't use cache
|
||||
if (!libraryResult.success) {
|
||||
return libraryResult;
|
||||
}
|
||||
|
||||
const library = libraryResult.data;
|
||||
const category = library.categories.find(c => c.id === categoryId);
|
||||
|
||||
if (!category) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Category '${categoryId}' not found`
|
||||
};
|
||||
}
|
||||
|
||||
// Check if set ID already exists
|
||||
const existingSet = category.sets.find(s => s.id === tagSet.id);
|
||||
if (existingSet) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Tag set with ID '${tagSet.id}' already exists`
|
||||
};
|
||||
}
|
||||
|
||||
// Add the tag set
|
||||
category.sets.push(tagSet);
|
||||
|
||||
// Save the updated category
|
||||
return await this.saveCategory(category);
|
||||
} catch (error) {
|
||||
console.error('Error adding tag set to category:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Network error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a tag set from a category
|
||||
* @param {string} categoryId - Category ID
|
||||
* @param {string} setId - Tag set ID to remove
|
||||
* @returns {Promise<{success: boolean, data?: Object, error?: string}>}
|
||||
*/
|
||||
async removeTagSetFromCategory(categoryId, setId) {
|
||||
try {
|
||||
// First get the current library
|
||||
const libraryResult = await this.getTagLibrary(false); // Don't use cache
|
||||
if (!libraryResult.success) {
|
||||
return libraryResult;
|
||||
}
|
||||
|
||||
const library = libraryResult.data;
|
||||
const category = library.categories.find(c => c.id === categoryId);
|
||||
|
||||
if (!category) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Category '${categoryId}' not found`
|
||||
};
|
||||
}
|
||||
|
||||
// Find and remove the tag set
|
||||
const setIndex = category.sets.findIndex(s => s.id === setId);
|
||||
if (setIndex > -1) {
|
||||
const removedSet = category.sets.splice(setIndex, 1)[0];
|
||||
|
||||
// Save the updated category
|
||||
const result = await this.saveCategory(category);
|
||||
if (result.success) {
|
||||
result.message = `Tag set '${removedSet.name}' removed successfully`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Tag set '${setId}' not found in category '${category.name}'`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error removing tag set from category:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Network error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags by category
|
||||
* @param {string} categoryId - Category ID
|
||||
* @returns {Promise<{success: boolean, data?: Object, error?: string}>}
|
||||
*/
|
||||
async getTagsByCategory(categoryId) {
|
||||
try {
|
||||
const libraryResult = await this.getTagLibrary();
|
||||
if (!libraryResult.success) {
|
||||
return libraryResult;
|
||||
}
|
||||
|
||||
const category = libraryResult.data.categories.find(c => c.id === categoryId);
|
||||
if (!category) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Category '${categoryId}' not found`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
category: category,
|
||||
tags: category.tags || [],
|
||||
sets: category.sets || []
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting tags by category:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Network error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear API cache
|
||||
*/
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
* @returns {Object} - Cache statistics
|
||||
*/
|
||||
getCacheStats() {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
timeout: this.cacheTimeout
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for new categories or tag sets
|
||||
* @param {string} name - Name to base the ID on
|
||||
* @returns {string} - Generated ID
|
||||
*/
|
||||
generateId(name) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, '')
|
||||
.replace(/\s+/g, '_')
|
||||
.substring(0, 50); // Limit length
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate category data
|
||||
* @param {Object} category - Category data to validate
|
||||
* @returns {Object} - Validation result {isValid, errors}
|
||||
*/
|
||||
validateCategory(category) {
|
||||
const errors = [];
|
||||
|
||||
if (!category.id || typeof category.id !== 'string') {
|
||||
errors.push('Category ID is required and must be a string');
|
||||
}
|
||||
|
||||
if (!category.name || typeof category.name !== 'string') {
|
||||
errors.push('Category name is required and must be a string');
|
||||
}
|
||||
|
||||
if (category.tags && !Array.isArray(category.tags)) {
|
||||
errors.push('Tags must be an array');
|
||||
}
|
||||
|
||||
if (category.sets && !Array.isArray(category.sets)) {
|
||||
errors.push('Sets must be an array');
|
||||
}
|
||||
|
||||
// Validate tag sets
|
||||
if (category.sets) {
|
||||
for (const set of category.sets) {
|
||||
if (!set.id || !set.name) {
|
||||
errors.push('Each tag set must have an id and name');
|
||||
}
|
||||
if (set.tags && !Array.isArray(set.tags)) {
|
||||
errors.push('Tag set tags must be an array');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const tagApi = new TagApi();
|
||||
|
||||
// Export individual functions for convenience
|
||||
export const {
|
||||
getTagLibrary,
|
||||
saveTagLibrary,
|
||||
saveCategory,
|
||||
deleteCategory,
|
||||
searchTags,
|
||||
addTagToCategory,
|
||||
removeTagFromCategory,
|
||||
addTagSetToCategory,
|
||||
removeTagSetFromCategory,
|
||||
getTagsByCategory,
|
||||
clearCache,
|
||||
generateId,
|
||||
validateCategory
|
||||
} = tagApi;
|
||||
|
||||
export default tagApi;
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { promptGenerationComponent } from '../components/promptBuilder/promptGeneration.js';
|
||||
import { tagLibraryComponent } from '../components/promptBuilder/tagLibrary.js';
|
||||
|
||||
/**
|
||||
* Creates the main Prompt Builder tab content
|
||||
@@ -30,6 +31,14 @@ export function createPromptBuilderTab(container) {
|
||||
const generationSection = createGenerationSection();
|
||||
contentArea.appendChild(generationSection);
|
||||
|
||||
// Add tag library section (collapsible)
|
||||
const tagLibrarySection = createTagLibrarySection();
|
||||
contentArea.appendChild(tagLibrarySection);
|
||||
|
||||
// TODO: Add saved prompts section
|
||||
// const savedPromptsSection = createSavedPromptsSection();
|
||||
// contentArea.appendChild(savedPromptsSection);
|
||||
|
||||
wrapper.appendChild(contentArea);
|
||||
container.appendChild(wrapper);
|
||||
|
||||
@@ -40,6 +49,7 @@ export function createPromptBuilderTab(container) {
|
||||
return {
|
||||
wrapper,
|
||||
generationSection,
|
||||
tagLibrarySection,
|
||||
destroy: () => {
|
||||
destroyPromptBuilderTab(container);
|
||||
}
|
||||
@@ -108,6 +118,219 @@ function createGenerationSection() {
|
||||
return section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the tag library section
|
||||
* @returns {HTMLElement} - The tag library section element
|
||||
*/
|
||||
function createTagLibrarySection() {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'prompt-builder-section tag-library-section';
|
||||
|
||||
const sectionHeader = document.createElement('div');
|
||||
sectionHeader.className = 'section-header collapsible-header';
|
||||
|
||||
const sectionTitle = document.createElement('h3');
|
||||
sectionTitle.textContent = '🏷️ Tag Library';
|
||||
sectionTitle.className = 'section-title';
|
||||
|
||||
const collapseBtn = document.createElement('button');
|
||||
collapseBtn.className = 'collapse-btn';
|
||||
collapseBtn.textContent = '▼';
|
||||
collapseBtn.title = 'Toggle tag library';
|
||||
|
||||
sectionHeader.appendChild(sectionTitle);
|
||||
sectionHeader.appendChild(collapseBtn);
|
||||
section.appendChild(sectionHeader);
|
||||
|
||||
const sectionContent = document.createElement('div');
|
||||
sectionContent.className = 'section-content';
|
||||
|
||||
// Add collapsible functionality
|
||||
let isCollapsed = false; // Default: expanded
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed = !isCollapsed;
|
||||
sectionContent.style.display = isCollapsed ? 'none' : 'block';
|
||||
collapseBtn.textContent = isCollapsed ? '▶' : '▼';
|
||||
section.classList.toggle('collapsed', isCollapsed);
|
||||
};
|
||||
|
||||
sectionHeader.addEventListener('click', toggleCollapse);
|
||||
|
||||
try {
|
||||
// Create tag library component with tag insertion callbacks
|
||||
const tagLibraryOptions = {
|
||||
allowEdit: true,
|
||||
showSearch: true,
|
||||
compactMode: false,
|
||||
onTagInsert: (tag) => {
|
||||
insertTagIntoActivePrompt(tag);
|
||||
},
|
||||
onTagSetInsert: (tagSet, tagsText) => {
|
||||
insertTagIntoActivePrompt(tagsText);
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading state while component loads
|
||||
sectionContent.innerHTML = '<div class="loading-state">Loading tag library...</div>';
|
||||
|
||||
// Create the component asynchronously
|
||||
tagLibraryComponent.create(sectionContent, tagLibraryOptions).then(() => {
|
||||
// Component loaded successfully
|
||||
console.log('Tag library component loaded successfully');
|
||||
}).catch((error) => {
|
||||
console.error('Failed to load tag library component:', error);
|
||||
// Show error fallback
|
||||
sectionContent.innerHTML = `
|
||||
<div class="tag-library-fallback">
|
||||
<div style="padding: 16px;">
|
||||
<h4>Tag Library</h4>
|
||||
<p>Error loading tag library: ${error.message}</p>
|
||||
<p>Please check the console for more details.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating tag library component:', error);
|
||||
// Fallback content
|
||||
const fallbackContent = document.createElement('div');
|
||||
fallbackContent.className = 'tag-library-fallback';
|
||||
fallbackContent.innerHTML = `
|
||||
<div style="padding: 16px;">
|
||||
<h4>Tag Library</h4>
|
||||
<p>Error loading tag library: ${error.message}</p>
|
||||
<p>Please check the console for more details.</p>
|
||||
</div>
|
||||
`;
|
||||
sectionContent.appendChild(fallbackContent);
|
||||
}
|
||||
|
||||
section.appendChild(sectionContent);
|
||||
return section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a tag into the active prompt field
|
||||
* @param {string} tag - The tag text to insert
|
||||
*/
|
||||
function insertTagIntoActivePrompt(tag) {
|
||||
try {
|
||||
// Find the currently focused prompt input
|
||||
const activeInput = document.querySelector('.prompt-generation-section .positive-prompt:focus, .prompt-generation-section .negative-prompt:focus');
|
||||
|
||||
if (activeInput) {
|
||||
// Insert at cursor position
|
||||
const start = activeInput.selectionStart;
|
||||
const end = activeInput.selectionEnd;
|
||||
const currentValue = activeInput.value;
|
||||
|
||||
// Add comma and space if there's existing text and it doesn't end with a comma or space
|
||||
let insertText = tag;
|
||||
if (start > 0 && !currentValue.slice(start - 1, start).match(/[,\s]/)) {
|
||||
insertText = ', ' + tag;
|
||||
}
|
||||
|
||||
// Insert the tag
|
||||
const newValue = currentValue.slice(0, start) + insertText + currentValue.slice(end);
|
||||
activeInput.value = newValue;
|
||||
|
||||
// Update cursor position
|
||||
const newCursorPos = start + insertText.length;
|
||||
activeInput.setSelectionRange(newCursorPos, newCursorPos);
|
||||
|
||||
// Trigger input event to update any bound data
|
||||
activeInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Keep focus on the input
|
||||
activeInput.focus();
|
||||
|
||||
// Show feedback
|
||||
showTagInsertionFeedback(tag);
|
||||
} else {
|
||||
// No active input, copy to clipboard instead
|
||||
copyToClipboard(tag);
|
||||
showTagInsertionFeedback(tag, 'clipboard');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error inserting tag:', error);
|
||||
// Fallback to clipboard
|
||||
copyToClipboard(tag);
|
||||
showTagInsertionFeedback(tag, 'clipboard');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
* @param {string} text - Text to copy
|
||||
*/
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
return success;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show feedback for tag insertion
|
||||
* @param {string} tag - The inserted tag
|
||||
* @param {string} method - 'insert' or 'clipboard'
|
||||
*/
|
||||
function showTagInsertionFeedback(tag, method = 'insert') {
|
||||
const message = method === 'clipboard'
|
||||
? `Copied "${tag}" to clipboard`
|
||||
: `Inserted "${tag}" into prompt`;
|
||||
|
||||
// Find or create feedback element
|
||||
let feedback = document.querySelector('.tag-insertion-feedback');
|
||||
if (!feedback) {
|
||||
feedback = document.createElement('div');
|
||||
feedback.className = 'tag-insertion-feedback';
|
||||
document.body.appendChild(feedback);
|
||||
}
|
||||
|
||||
feedback.textContent = message;
|
||||
feedback.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
right: 20px;
|
||||
padding: 8px 12px;
|
||||
background: var(--primary-color, #4a9eff);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 10000;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
`;
|
||||
|
||||
// Auto-hide after 2 seconds
|
||||
setTimeout(() => {
|
||||
feedback.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (feedback.parentNode) {
|
||||
feedback.parentNode.removeChild(feedback);
|
||||
}
|
||||
}, 300);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds CSS styles for the prompt builder
|
||||
*/
|
||||
@@ -198,22 +421,94 @@ function addPromptBuilderStyles() {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.prompt-builder-fallback {
|
||||
.tag-library-section {
|
||||
border-color: var(--success-color, #6bcf7f);
|
||||
}
|
||||
|
||||
.tag-library-section .section-header {
|
||||
background: linear-gradient(135deg, var(--success-color, #6bcf7f) 0%, var(--success-dark, #4a9a5a) 100%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-library-section .section-title {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.collapsible-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tag-library-section.collapsed .section-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tag-library-fallback {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #cccccc);
|
||||
}
|
||||
|
||||
.prompt-builder-fallback h4 {
|
||||
.tag-library-fallback h4 {
|
||||
color: var(--error-color, #ff6b6b);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.prompt-builder-fallback p {
|
||||
.tag-library-fallback p {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #cccccc);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-state::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-color, #444);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--primary-color, #4a9eff);
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.tag-insertion-feedback {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.prompt-builder-header {
|
||||
|
||||
@@ -35,6 +35,7 @@ def register_routes(routes_instance):
|
||||
from . import notes_routes
|
||||
from . import gallery_routes
|
||||
from . import wildcard_routes
|
||||
from . import tag_routes
|
||||
from . import utility_routes
|
||||
|
||||
# Track successful registrations
|
||||
@@ -48,6 +49,7 @@ def register_routes(routes_instance):
|
||||
("Notes", notes_routes),
|
||||
("Gallery", gallery_routes),
|
||||
("Wildcard", wildcard_routes),
|
||||
("Tag", tag_routes),
|
||||
("Utility", utility_routes)
|
||||
]
|
||||
|
||||
@@ -158,6 +160,11 @@ ROUTE_GROUPS = {
|
||||
'endpoints': 5,
|
||||
'module': 'wildcard_routes'
|
||||
},
|
||||
'tag': {
|
||||
'description': 'Tag library management and prompt tagging',
|
||||
'endpoints': 5,
|
||||
'module': 'tag_routes'
|
||||
},
|
||||
'utility': {
|
||||
'description': 'Utility functions and miscellaneous endpoints',
|
||||
'endpoints': 3,
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
"""
|
||||
Tag Management Routes Module
|
||||
Handles tag library and tag set management for prompt building.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
from .base import route_error_handler, validate_json_body, success_response, error_response
|
||||
|
||||
# Route list for documentation and registration tracking
|
||||
_route_list = []
|
||||
|
||||
|
||||
def _get_tag_library_path(sage_users_path):
|
||||
"""
|
||||
Get the path to the tag library file.
|
||||
|
||||
Args:
|
||||
sage_users_path: Base SageUtils user directory path
|
||||
|
||||
Returns:
|
||||
Path: Path to tag library JSON file
|
||||
"""
|
||||
return Path(sage_users_path) / "tag_library.json"
|
||||
|
||||
|
||||
def _get_default_tag_library():
|
||||
"""
|
||||
Returns the default tag library structure loaded from config manager.
|
||||
|
||||
Returns:
|
||||
dict: Default tag library data
|
||||
"""
|
||||
try:
|
||||
# Import the default tag library from config manager
|
||||
from ..utils.config_manager import default_tag_library
|
||||
|
||||
if default_tag_library and default_tag_library.get('categories'):
|
||||
return default_tag_library
|
||||
else:
|
||||
# Fallback to minimal structure if no default data
|
||||
logging.warning("Default tag library is empty or not loaded")
|
||||
return {
|
||||
"version": "1.0",
|
||||
"categories": [],
|
||||
"metadata": {
|
||||
"created": None,
|
||||
"modified": None,
|
||||
"total_categories": 0,
|
||||
"total_tags": 0,
|
||||
"total_sets": 0
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading default tag library: {e}")
|
||||
# Return minimal fallback structure
|
||||
return {
|
||||
"version": "1.0",
|
||||
"categories": [],
|
||||
"metadata": {
|
||||
"created": None,
|
||||
"modified": None,
|
||||
"total_categories": 0,
|
||||
"total_tags": 0,
|
||||
"total_sets": 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _validate_category_data(data):
|
||||
"""
|
||||
Validate category data structure.
|
||||
|
||||
Args:
|
||||
data: Category data to validate
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, error_message)
|
||||
"""
|
||||
required_fields = ['id', 'name']
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return False, f"Missing required field: {field}"
|
||||
|
||||
if not isinstance(data.get('tags', []), list):
|
||||
return False, "Tags must be a list"
|
||||
|
||||
if not isinstance(data.get('sets', []), list):
|
||||
return False, "Sets must be a list"
|
||||
|
||||
# Validate sets
|
||||
for tag_set in data.get('sets', []):
|
||||
if not isinstance(tag_set, dict):
|
||||
return False, "Each set must be an object"
|
||||
if 'id' not in tag_set or 'name' not in tag_set:
|
||||
return False, "Each set must have id and name"
|
||||
if not isinstance(tag_set.get('tags', []), list):
|
||||
return False, "Set tags must be a list"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def register_routes(routes_instance):
|
||||
"""
|
||||
Register tag management routes.
|
||||
|
||||
Args:
|
||||
routes_instance: The PromptServer routes instance
|
||||
|
||||
Returns:
|
||||
int: Number of routes registered
|
||||
"""
|
||||
global _route_list
|
||||
_route_list.clear()
|
||||
|
||||
@routes_instance.get('/sage_utils/tags/library')
|
||||
@route_error_handler
|
||||
async def get_tag_library(request):
|
||||
"""
|
||||
Gets the complete tag library.
|
||||
|
||||
Response:
|
||||
JSON with tag library data including categories, tags, and sets
|
||||
"""
|
||||
try:
|
||||
# Dynamic import to avoid ComfyUI dependency issues
|
||||
from ..utils import sage_users_path
|
||||
|
||||
tag_library_path = _get_tag_library_path(sage_users_path)
|
||||
|
||||
if not tag_library_path.exists():
|
||||
# Return default library if none exists
|
||||
default_library = _get_default_tag_library()
|
||||
return success_response(data=default_library)
|
||||
|
||||
# Load existing library
|
||||
with open(tag_library_path, 'r', encoding='utf-8') as f:
|
||||
library_data = json.load(f)
|
||||
|
||||
return success_response(data=library_data)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Get tag library error: {e}")
|
||||
return error_response(f"Failed to get tag library: {str(e)}", status=500)
|
||||
|
||||
@routes_instance.post('/sage_utils/tags/library')
|
||||
@route_error_handler
|
||||
@validate_json_body('categories')
|
||||
async def save_tag_library(request):
|
||||
"""
|
||||
Saves the complete tag library.
|
||||
|
||||
Request Body:
|
||||
categories: Array of category objects
|
||||
metadata: Optional metadata object
|
||||
|
||||
Response:
|
||||
JSON with success message and library statistics
|
||||
"""
|
||||
try:
|
||||
# Dynamic import to avoid ComfyUI dependency issues
|
||||
from ..utils import sage_users_path
|
||||
import datetime
|
||||
|
||||
data = request.json_data
|
||||
categories = data.get('categories', [])
|
||||
metadata = data.get('metadata', {})
|
||||
|
||||
# Update metadata
|
||||
now = datetime.datetime.now().isoformat()
|
||||
metadata.update({
|
||||
'modified': now,
|
||||
'total_categories': len(categories),
|
||||
'total_tags': sum(len(cat.get('tags', [])) for cat in categories),
|
||||
'total_sets': sum(len(cat.get('sets', [])) for cat in categories)
|
||||
})
|
||||
|
||||
if 'created' not in metadata:
|
||||
metadata['created'] = now
|
||||
|
||||
# Prepare library data
|
||||
library_data = {
|
||||
'version': data.get('version', '1.0'),
|
||||
'categories': categories,
|
||||
'metadata': metadata
|
||||
}
|
||||
|
||||
# Validate categories
|
||||
for category in categories:
|
||||
is_valid, error_msg = _validate_category_data(category)
|
||||
if not is_valid:
|
||||
return error_response(f"Invalid category data: {error_msg}", status=400)
|
||||
|
||||
# Save to file
|
||||
tag_library_path = _get_tag_library_path(sage_users_path)
|
||||
tag_library_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(tag_library_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(library_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return success_response(
|
||||
message="Tag library saved successfully",
|
||||
data={
|
||||
"categories": metadata['total_categories'],
|
||||
"tags": metadata['total_tags'],
|
||||
"sets": metadata['total_sets'],
|
||||
"file_size": tag_library_path.stat().st_size
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Save tag library error: {e}")
|
||||
return error_response(f"Failed to save tag library: {str(e)}", status=500)
|
||||
|
||||
@routes_instance.post('/sage_utils/tags/category')
|
||||
@route_error_handler
|
||||
@validate_json_body('id', 'name')
|
||||
async def save_category(request):
|
||||
"""
|
||||
Creates or updates a category in the tag library.
|
||||
|
||||
Request Body:
|
||||
id: Category ID
|
||||
name: Category name
|
||||
description: Optional description
|
||||
color: Optional color code
|
||||
tags: Array of tag strings
|
||||
sets: Array of tag set objects
|
||||
|
||||
Response:
|
||||
JSON with success message and updated category data
|
||||
"""
|
||||
try:
|
||||
# Dynamic import to avoid ComfyUI dependency issues
|
||||
from ..utils import sage_users_path
|
||||
import datetime
|
||||
|
||||
data = request.json_data
|
||||
|
||||
# Validate category data
|
||||
is_valid, error_msg = _validate_category_data(data)
|
||||
if not is_valid:
|
||||
return error_response(error_msg, status=400)
|
||||
|
||||
# Load existing library
|
||||
tag_library_path = _get_tag_library_path(sage_users_path)
|
||||
if tag_library_path.exists():
|
||||
with open(tag_library_path, 'r', encoding='utf-8') as f:
|
||||
library_data = json.load(f)
|
||||
else:
|
||||
library_data = _get_default_tag_library()
|
||||
|
||||
# Find and update category, or add new one
|
||||
category_id = data['id']
|
||||
category_found = False
|
||||
|
||||
for i, category in enumerate(library_data['categories']):
|
||||
if category['id'] == category_id:
|
||||
library_data['categories'][i] = data
|
||||
category_found = True
|
||||
break
|
||||
|
||||
if not category_found:
|
||||
# Add order if not specified
|
||||
if 'order' not in data:
|
||||
data['order'] = len(library_data['categories'])
|
||||
library_data['categories'].append(data)
|
||||
|
||||
# Update metadata
|
||||
now = datetime.datetime.now().isoformat()
|
||||
library_data['metadata']['modified'] = now
|
||||
library_data['metadata']['total_categories'] = len(library_data['categories'])
|
||||
library_data['metadata']['total_tags'] = sum(len(cat.get('tags', [])) for cat in library_data['categories'])
|
||||
library_data['metadata']['total_sets'] = sum(len(cat.get('sets', [])) for cat in library_data['categories'])
|
||||
|
||||
# Save updated library
|
||||
with open(tag_library_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(library_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return success_response(
|
||||
message=f"Category '{data['name']}' {'updated' if category_found else 'created'} successfully",
|
||||
data=data
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Save category error: {e}")
|
||||
return error_response(f"Failed to save category: {str(e)}", status=500)
|
||||
|
||||
@routes_instance.delete('/sage_utils/tags/category/{category_id}')
|
||||
@route_error_handler
|
||||
async def delete_category(request):
|
||||
"""
|
||||
Deletes a category from the tag library.
|
||||
|
||||
Path Parameters:
|
||||
category_id: ID of the category to delete
|
||||
|
||||
Response:
|
||||
JSON with success message
|
||||
"""
|
||||
try:
|
||||
# Dynamic import to avoid ComfyUI dependency issues
|
||||
from ..utils import sage_users_path
|
||||
import datetime
|
||||
|
||||
category_id = request.match_info.get('category_id', '')
|
||||
if not category_id:
|
||||
return error_response("Category ID is required", status=400)
|
||||
|
||||
# Load existing library
|
||||
tag_library_path = _get_tag_library_path(sage_users_path)
|
||||
if not tag_library_path.exists():
|
||||
return error_response("Tag library not found", status=404)
|
||||
|
||||
with open(tag_library_path, 'r', encoding='utf-8') as f:
|
||||
library_data = json.load(f)
|
||||
|
||||
# Find and remove category
|
||||
category_found = False
|
||||
removed_category = None
|
||||
for i, category in enumerate(library_data['categories']):
|
||||
if category['id'] == category_id:
|
||||
removed_category = library_data['categories'].pop(i)
|
||||
category_found = True
|
||||
break
|
||||
|
||||
if not category_found:
|
||||
return error_response(f"Category '{category_id}' not found", status=404)
|
||||
|
||||
# Update metadata
|
||||
now = datetime.datetime.now().isoformat()
|
||||
library_data['metadata']['modified'] = now
|
||||
library_data['metadata']['total_categories'] = len(library_data['categories'])
|
||||
library_data['metadata']['total_tags'] = sum(len(cat.get('tags', [])) for cat in library_data['categories'])
|
||||
library_data['metadata']['total_sets'] = sum(len(cat.get('sets', [])) for cat in library_data['categories'])
|
||||
|
||||
# Save updated library
|
||||
with open(tag_library_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(library_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
return success_response(
|
||||
message=f"Category '{removed_category['name'] if removed_category else category_id}' deleted successfully"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Delete category error: {e}")
|
||||
return error_response(f"Failed to delete category: {str(e)}", status=500)
|
||||
|
||||
@routes_instance.get('/sage_utils/tags/search')
|
||||
@route_error_handler
|
||||
async def search_tags(request):
|
||||
"""
|
||||
Searches for tags across all categories.
|
||||
|
||||
Query Parameters:
|
||||
q: Search query
|
||||
category: Optional category ID to limit search
|
||||
limit: Optional limit (default: 50)
|
||||
|
||||
Response:
|
||||
JSON with search results
|
||||
"""
|
||||
try:
|
||||
# Dynamic import to avoid ComfyUI dependency issues
|
||||
from ..utils import sage_users_path
|
||||
|
||||
query = request.query.get('q', '').lower().strip()
|
||||
category_filter = request.query.get('category', '')
|
||||
limit = int(request.query.get('limit', 50))
|
||||
|
||||
if not query:
|
||||
return error_response("Search query is required", status=400)
|
||||
|
||||
# Load tag library
|
||||
tag_library_path = _get_tag_library_path(sage_users_path)
|
||||
if not tag_library_path.exists():
|
||||
return success_response(data={"results": [], "total": 0})
|
||||
|
||||
with open(tag_library_path, 'r', encoding='utf-8') as f:
|
||||
library_data = json.load(f)
|
||||
|
||||
results = []
|
||||
|
||||
# Search through categories
|
||||
for category in library_data['categories']:
|
||||
if category_filter and category['id'] != category_filter:
|
||||
continue
|
||||
|
||||
# Search individual tags
|
||||
for tag in category.get('tags', []):
|
||||
if query in tag.lower():
|
||||
results.append({
|
||||
'type': 'tag',
|
||||
'text': tag,
|
||||
'category_id': category['id'],
|
||||
'category_name': category['name'],
|
||||
'match_type': 'exact' if query == tag.lower() else 'partial'
|
||||
})
|
||||
|
||||
# Search tag sets
|
||||
for tag_set in category.get('sets', []):
|
||||
if query in tag_set['name'].lower():
|
||||
results.append({
|
||||
'type': 'set',
|
||||
'text': tag_set['name'],
|
||||
'tags': tag_set['tags'],
|
||||
'category_id': category['id'],
|
||||
'category_name': category['name'],
|
||||
'match_type': 'name'
|
||||
})
|
||||
|
||||
# Search within set tags
|
||||
for tag in tag_set.get('tags', []):
|
||||
if query in tag.lower():
|
||||
results.append({
|
||||
'type': 'set_tag',
|
||||
'text': tag,
|
||||
'set_name': tag_set['name'],
|
||||
'category_id': category['id'],
|
||||
'category_name': category['name'],
|
||||
'match_type': 'tag_in_set'
|
||||
})
|
||||
|
||||
# Sort by relevance (exact matches first)
|
||||
results.sort(key=lambda x: (
|
||||
0 if x['match_type'] == 'exact' else 1,
|
||||
x['text'].lower()
|
||||
))
|
||||
|
||||
# Apply limit
|
||||
results = results[:limit]
|
||||
|
||||
return success_response(data={
|
||||
"results": results,
|
||||
"total": len(results),
|
||||
"query": query,
|
||||
"limit": limit
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Search tags error: {e}")
|
||||
return error_response(f"Failed to search tags: {str(e)}", status=500)
|
||||
|
||||
@routes_instance.get('/sage_utils/tags/defaults')
|
||||
@route_error_handler
|
||||
async def get_default_tag_library(request):
|
||||
"""
|
||||
Gets the default tag library structure from assets.
|
||||
|
||||
Response:
|
||||
JSON with default tag library data
|
||||
"""
|
||||
try:
|
||||
default_library = _get_default_tag_library()
|
||||
return success_response(data=default_library)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Get default tag library error: {e}")
|
||||
return error_response(f"Failed to get default tag library: {str(e)}", status=500)
|
||||
|
||||
# Track registered routes
|
||||
_route_list.extend([
|
||||
{"method": "GET", "path": "/sage_utils/tags/library", "description": "Get complete tag library"},
|
||||
{"method": "POST", "path": "/sage_utils/tags/library", "description": "Save complete tag library"},
|
||||
{"method": "POST", "path": "/sage_utils/tags/category", "description": "Create or update category"},
|
||||
{"method": "DELETE", "path": "/sage_utils/tags/category/{category_id}", "description": "Delete category"},
|
||||
{"method": "GET", "path": "/sage_utils/tags/search", "description": "Search tags and sets"},
|
||||
{"method": "GET", "path": "/sage_utils/tags/defaults", "description": "Get default tag library from assets"}
|
||||
])
|
||||
|
||||
logging.info("Tag management routes registered successfully")
|
||||
return len(_route_list)
|
||||
|
||||
|
||||
def get_route_list():
|
||||
"""Get list of registered routes for this module."""
|
||||
return _route_list.copy()
|
||||
@@ -44,6 +44,10 @@ llm_prompts = prompts_manager.load()
|
||||
metadata_template_manager = ConfigManager("metadata_templates")
|
||||
metadata_templates = metadata_template_manager.load()
|
||||
|
||||
# Tag library management for prompt builder
|
||||
tag_library_manager = ConfigManager("default_tag_library")
|
||||
default_tag_library = tag_library_manager.load()
|
||||
|
||||
# Enhanced settings management using the new settings system
|
||||
# This maintains backwards compatibility while providing enhanced functionality
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user