Some initial work on the prompt builder.

This commit is contained in:
Shanoah Alkire
2025-09-15 02:03:28 -07:00
parent 70920ac959
commit fc6181524a
9 changed files with 4257 additions and 3 deletions
+323
View File
@@ -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
+526
View File
@@ -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;
+298 -3
View File
@@ -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 {
+7
View File
@@ -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,
+479
View File
@@ -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()
+4
View File
@@ -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: