NovelAI Prompt Composer and Tag Manager

Enhances NovelAI image generation with a prompt composer. Allows saving, categorizing, and quickly toggling common prompt elements. WARNING: Beta version - manual prompt textarea interaction required after generation.

Від 13.10.2024. Дивіться остання версія.

// ==UserScript==
// @name         NovelAI Prompt Composer and Tag Manager
// @namespace    http://tampermonkey.net/
// @version      0.9.1
// @description  Enhances NovelAI image generation with a prompt composer. Allows saving, categorizing, and quickly toggling common prompt elements. WARNING: Beta version - manual prompt textarea interaction required after generation.
// @author       ManuMonkey
// @license      MIT
// @match        http*://novelai.net/image
// @require      http://code.jquery.com/jquery-latest.js
// ==/UserScript==

/*
User Guide:
1. Click the "Compose Prompt" button to open the Prompt Composer.
2. Manage your categories and tags.
3. Use checkboxes to toggle tags on/off.
4. Click "Generate Prompt" to create your prompt.
5. IMPORTANT: After generating, manually edit the prompt textarea (e.g., add a space and remove it) for the changes to take effect.
*/

const promptComposerCSS = `
.prompt-composer-modal {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background-color: #f9f9f9;
    padding: 20px;
    border: 2px solid #333;
    border-radius: 10px;
    z-index: 9999999999;
    width: 600px;
    max-height: 80%;
    display: flex;
    flex-direction: column;
    overflow-y: auto;
}

.prompt-composer-title {
    text-align: center;
    margin-bottom: 20px;
    color: #333;
}

.button-container {
    display: flex;
    justify-content: flex-start;
    margin-bottom: 20px;
}

.toggle-button {
    padding: 5px 10px;
    border-radius: 5px;
    border: none;
    cursor: pointer;
    background-color: #666;
    color: #fff;
    margin-right: 10px;
}

.toggle-button:hover {
    background-color: #555;
}

.toggle-button:active {
    background-color: #444;
}

.category-container {
    margin-bottom: 15px;
}

.category-label {
    font-weight: bold;
    color: #333;
}

.checkbox-container {
    display: flex;
    flex-wrap: wrap;
    margin-top: 5px;
    gap: 10px;
}

.checkbox-label {
    color: #333;
    display: flex;
    align-items: center;
    white-space: nowrap;
    margin-right: 10px;
}

.checkbox-input {
    margin-right: 5px;
}

.weight-display {
    font-size: 0.8em;
    color: #666;
}

.weight-control {
    display: none;
    align-items: center;
    margin-left: 5px;
}

.weight-input {
    width: 40px;
    margin: 0 5px;
}

.weight-button {
    padding: 0 5px;
}

.delete-button {
    margin-left: 5px;
    padding: 2px 5px;
    border-radius: 3px;
    border: none;
    cursor: pointer;
    background-color: #ff4d4d;
    color: #fff;
    display: none;
}

.add-tag-container {
    display: none;
    margin-top: 10px;
    align-items: center;
}

.add-tag-input {
    flex-grow: 1;
    padding: 5px;
    border-radius: 5px;
    border: 1px solid #333;
    margin-right: 10px;
}

.add-tag-button {
    padding: 5px 10px;
    border-radius: 5px;
    border: none;
    cursor: pointer;
    background-color: #333;
    color: #fff;
    white-space: nowrap;
}

.tag-textarea {
    width: 100%;
    margin-top: 10px;
    border-radius: 5px;
    padding: 5px;
}

.generate-button, .close-button {
    color: #333;
    background-color: rgb(108, 245, 74);
    font-size: medium;
    padding: 10px;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    width: 48%; /* Make buttons take up almost half the width each */
}

.generate-button:hover, .close-button:hover {
    /* background-color: #87e43b; */
}

.close-button {
    background-color: #ccc;
}

.compose-prompt-button {
    color: #333;
    background-color: rgb(246, 245, 244);
    font-size: small;
    padding: 5px 10px;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    margin-bottom: 10px;
}

.compose-prompt-button:hover {
    background-color: #e6e6e6;
}

.add-category-container {
    display: flex;
    margin-bottom: 15px;
    align-items: center;
}

.add-category-input {
    flex-grow: 1;
    padding: 5px;
    border-radius: 5px;
    border: 1px solid #333;
    margin-right: 10px;
}

.add-category-button {
    padding: 5px 10px;
    border-radius: 5px;
    border: none;
    cursor: pointer;
    background-color: #333;
    color: #fff;
    white-space: nowrap;
}

.category-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 10px;
}

.category-management-buttons {
    display: flex;
    align-items: center;
    margin-left: 10px;
}

.delete-category-button {
    background-color: #ff4d4d;
    color: white;
    border: none;
    border-radius: 5px;
    padding: 5px 10px;
    font-size: 14px;
    cursor: pointer;
    margin-left: 10px;
}

.delete-category-button:hover {
    background-color: #ff3333;
}

.delete-category-button:active {
    background-color: #e60000;
}

.move-category-button {
    background-color: #4CAF50;
    color: white;
    border: none;
    border-radius: 50%;
    width: 24px;
    height: 24px;
    font-size: 16px;
    line-height: 1;
    cursor: pointer;
    margin-right: 5px;
}

.move-category-button:hover {
    background-color: #45a049;
}

.move-category-button {
    width: auto;
    height: auto;
    border-radius: 5px;
    padding: 5px 8px;
}

.category-management {
    margin-bottom: 15px;
    border-top: 1px solid #ccc;
    padding-top: 15px;
}

.bottom-buttons-container {
    display: flex;
    justify-content: space-between;
    margin-top: 20px;
}

@media screen and (min-width: 1200px) {
    .prompt-composer-modal {
        width: 1000px; /* Increased width for larger screens */
    }

    .categories-container {
        display: flex;
        flex-wrap: wrap;
        justify-content: space-between;
    }

    .category-container {
        width: 48%; /* Slightly less than 50% to account for margins */
    }
}

.categories-container {
    max-height: 60vh; /* Limit the height and enable scrolling if needed */
    overflow-y: auto;
}

`;

(function () {
    'use strict';

    // Display a warning message about the beta status
    console.warn("NovelAI Prompt Composer and Tag Manager (v0.9.0) is running in beta. Known issue: After generating a prompt, you need to manually modify the prompt textarea for it to take effect.");

    // Function to inject CSS
    function injectCSS() {
        const style = document.createElement('style');
        style.textContent = promptComposerCSS;
        document.head.appendChild(style);
    }

    // Call the injectCSS function immediately
    injectCSS();

    let tagArea = null;
    let promptComposerData = JSON.parse(localStorage.getItem('promptComposerData')) || {
        categories: [
            { name: "Artists", tags: [] },
            { name: "Persons", tags: [] },
            { name: "Locations", tags: [] },
            { name: "Expressions", tags: [] }
        ],
        customTags: {}
    };

    function saveToLocalStorage() {
        localStorage.setItem('promptComposerData', JSON.stringify(promptComposerData));
    }

    let modalDiv; // Declare modalDiv in the global scope
    let categoriesContainer;

    function renderCategories(editMode = false) {
        categoriesContainer.empty();
        promptComposerData.categories.forEach((category, index) => {
            createCategorySection(category, index, categoriesContainer);
        });
        if (editMode) {
            $('.move-category-button, .delete-category-button').toggle();
        }
    }

    function openPromptComposer() {
        console.log("openPromptComposer called");
        try {
            if ($('#promptComposerModal').length) {
                $('#promptComposerModal').remove();
                return;
            }

            modalDiv = $('<div id="promptComposerModal"></div>').addClass('prompt-composer-modal');
            const title = $('<h3>Prompt Composer</h3>').addClass('prompt-composer-title');
            modalDiv.append(title);

            const buttonContainer = $('<div></div>').addClass('button-container');
            const editButton = $('<button>Edit Tags</button>').addClass('toggle-button').click(() => {
                $('.add-tag-container, .delete-button').toggle();
                $('.add-tag-container:visible').css('display', 'flex');
            });
            const weightToggleButton = $('<button>Toggle Weights</button>').addClass('toggle-button').click(() => {
                const weightsVisible = !$('.weight-control').first().is(':visible');
                $('.weight-control').toggle(weightsVisible);
                $('.weight-display').each(function () {
                    const label = $(this).closest('label');
                    const weightControl = label.find('.weight-control');
                    const tag = {
                        weight: parseFloat(weightControl.find('input[type="number"]').val())
                    };
                    updateWeightDisplay(tag, $(this), weightControl);
                });
            });
            const categoryManagementButton = $('<button>Manage Categories</button>').addClass('toggle-button').click(() => {
                $('.category-management').toggle();
                $('.move-category-button, .delete-category-button').toggle();
            });
            buttonContainer.append(editButton, weightToggleButton, categoryManagementButton);
            modalDiv.append(buttonContainer);

            // Add category management (initially hidden)
            const categoryManagement = $('<div></div>').addClass('category-management').hide();
            const addCategoryContainer = $('<div></div>').addClass('add-category-container');
            const addCategoryInput = $('<input type="text">').addClass('add-category-input')
                .attr('placeholder', 'Add new category...');
            const addCategoryButton = $('<button>Add Category</button>').addClass('add-category-button')
                .click(() => {
                    const newCategory = addCategoryInput.val().trim();
                    if (newCategory && !promptComposerData.categories.some(cat => cat.name === newCategory)) {
                        promptComposerData.categories.push({ name: newCategory, tags: [] });
                        saveToLocalStorage();
                        addCategoryInput.val('');
                        renderCategories(true);
                    }
                });
            addCategoryContainer.append(addCategoryInput, addCategoryButton);
            categoryManagement.append(addCategoryContainer);
            modalDiv.append(categoryManagement);

            // Create a container for categories
            categoriesContainer = $('<div></div>').addClass('categories-container');
            modalDiv.append(categoryManagement);
            modalDiv.append(categoriesContainer);

            renderCategories();

            // Create a container for the bottom buttons
            const bottomButtonsContainer = $('<div></div>').addClass('bottom-buttons-container');

            const generateButton = $('<button>Generate Prompt</button>').addClass('generate-button')
                .click(() => {
                    const combinedPrompt = generatePrompt();
                    tagArea = document.querySelectorAll("[placeholder='Write your prompt here. Use tags to sculpt your outputs.']")[0];
                    tagArea.value = combinedPrompt;
                    saveToLocalStorage();
                    modalDiv.remove();
                });

            const cancelButton = $('<button>Close</button>').addClass('close-button')
                .click(() => {
                    saveToLocalStorage();
                    modalDiv.remove();
                });

            bottomButtonsContainer.append(generateButton, cancelButton);
            modalDiv.append(bottomButtonsContainer);

            $('body').append(modalDiv);

            // Adjust layout based on screen size
            adjustLayout();

            $(window).on('resize', adjustLayout);

            console.log("Modal appended to body");
        } catch (error) {
            console.error("Error in openPromptComposer:", error);
        }
    }

    function adjustLayout() {
        if ($(window).width() >= 1600) {
            $('.category-container').css('width', '32%');
            modalDiv.css('width', '1500px');
        }
        else if ($(window).width() >= 1200) {
            $('.category-container').css('width', '48%');
            modalDiv.css('width', '1000px');
        } else {
            $('.category-container').css('width', '100%');
            modalDiv.css('width', '600px');
        }
    }

    function createCategorySection(category, index, modalDiv) {
        const categoryContainer = $('<div></div>').addClass('category-container');
        const categoryHeader = $('<div></div>').addClass('category-header');
        const label = $('<label></label>').text(category.name + ':').addClass('category-label');

        const deleteCategoryButton = $('<button>Delete Category</button>')
            .addClass('delete-category-button')
            .hide() // Initially hide the delete button
            .click(() => {
                if (confirm(`Are you sure you want to delete the category "${category.name}" and all its tags?`)) {
                    promptComposerData.categories.splice(index, 1);
                    saveToLocalStorage();
                    renderCategories(true);
                }
            });

        const moveCategoryUpButton = $('<button>↑</button>').addClass('move-category-button')
            .hide() // Initially hide the move up button
            .click(() => {
                if (index > 0) {
                    [promptComposerData.categories[index - 1], promptComposerData.categories[index]] =
                        [promptComposerData.categories[index], promptComposerData.categories[index - 1]];
                    saveToLocalStorage();
                    renderCategories(true);
                }
            });

        const moveCategoryDownButton = $('<button>↓</button>').addClass('move-category-button')
            .hide() // Initially hide the move down button
            .click(() => {
                if (index < promptComposerData.categories.length - 1) {
                    [promptComposerData.categories[index], promptComposerData.categories[index + 1]] =
                        [promptComposerData.categories[index + 1], promptComposerData.categories[index]];
                    saveToLocalStorage();
                    renderCategories(true);
                }
            });

        const categoryManagementButtons = $('<div></div>').addClass('category-management-buttons');
        categoryManagementButtons.append(moveCategoryUpButton, moveCategoryDownButton, deleteCategoryButton);

        categoryHeader.append(label, categoryManagementButtons);
        categoryContainer.append(categoryHeader);

        const checkboxContainer = $('<div></div>').addClass('checkbox-container');
        category.tags.forEach(tag => {
            createCheckbox(tag, category.name, checkboxContainer);
        });
        categoryContainer.append(checkboxContainer);

        const addTagContainer = $('<div></div>').addClass('add-tag-container');
        const addTagInput = $('<input type="text">').addClass('add-tag-input')
            .attr('placeholder', 'Add new ' + category.name.toLowerCase() + '...');
        const addTagButton = $('<button>Add</button>').addClass('add-tag-button');

        const addNewTag = () => {
            const newTag = addTagInput.val().trim();
            if (newTag && !category.tags.some(tag => tag.name === newTag)) {
                category.tags.push({ name: newTag, active: false, weight: 1 });
                createCheckbox({ name: newTag, active: false, weight: 1 }, category.name, checkboxContainer, true);
                addTagInput.val('');
                saveToLocalStorage();
            }
        };

        addTagButton.click(addNewTag);
        addTagInput.keypress(function (e) {
            if (e.which == 13) {
                e.preventDefault();
                addNewTag();
            }
        });

        addTagContainer.append(addTagInput, addTagButton);
        categoryContainer.append(addTagContainer);

        const textArea = $('<textarea></textarea>').addClass('tag-textarea')
            .attr('placeholder', 'Enter additional ' + category.name.toLowerCase() + ' here...');

        const customTags = promptComposerData.customTags[category.name] || '';
        textArea.val(customTags);

        textArea.on('input', function () {
            const customTagsValue = $(this).val().trim();
            promptComposerData.customTags[category.name] = customTagsValue;
            saveToLocalStorage();
        });

        categoryContainer.append(textArea);
        categoriesContainer.append(categoryContainer);
    }

    function createCheckbox(tag, categoryName, container, isNew = false) {
        const checkboxLabel = $('<label></label>').addClass('checkbox-label');
        const checkbox = $('<input type="checkbox">').addClass('checkbox-input').val(tag.name);
        checkbox.prop('checked', tag.active);
        checkbox.change(function () {
            tag.active = this.checked;
            saveToLocalStorage();
        });

        // Create weight display
        const weightDisplay = $('<span></span>').addClass('weight-display');

        // Create weight control
        const weightControl = $('<div></div>').addClass('weight-control');
        const weightInput = $('<input type="number" step="0.05" min="0.5" max="1.5">').addClass('weight-input')
            .val(tag.weight || 1);
        const decreaseButton = $('<button>-</button>').addClass('weight-button');
        const increaseButton = $('<button>+</button>').addClass('weight-button');

        decreaseButton.click(() => updateWeight(-0.05));
        increaseButton.click(() => updateWeight(0.05));
        weightInput.on('input', () => {
            tag.weight = parseFloat(weightInput.val());
            updateWeightDisplay(tag, weightDisplay, weightControl);
            saveToLocalStorage();
        });

        function updateWeight(change) {
            let newWeight = (tag.weight || 1) + change;
            newWeight = Math.round(newWeight * 20) / 20; // Round to nearest 0.05
            newWeight = Math.max(0.5, Math.min(1.5, newWeight)); // Clamp between 0.5 and 1.5
            tag.weight = newWeight;
            weightInput.val(newWeight);
            updateWeightDisplay(tag, weightDisplay, weightControl);
            saveToLocalStorage();
        }

        weightControl.append(weightInput, decreaseButton, increaseButton);
        checkboxLabel.append(checkbox, tag.name, weightDisplay, weightControl);

        const deleteButton = $('<button>×</button>').addClass('delete-button').click(() => {
            const category = promptComposerData.categories.find(cat => cat.name === categoryName);
            category.tags = category.tags.filter(t => t.name !== tag.name);
            saveToLocalStorage();
            checkboxLabel.remove();
        });
        if (isNew) { 
            console.log("isNew is true");
            deleteButton.show(); 
        }

        checkboxLabel.append(deleteButton);
        container.append(checkboxLabel);

        updateWeightDisplay(tag, weightDisplay, weightControl);
    }

    function onReady() {
        setTimeout(placeComposerButton, 3000);
    }

    function placeComposerButton() {
        let composeButton = $('<button>Compose Prompt</button>')
            .addClass('compose-prompt-button')
            .click(openPromptComposer);

        let textAreas = document.querySelectorAll("[placeholder='Write your prompt here. Use tags to sculpt your outputs.']");
        if (textAreas.length > 0) {
            let sidebar = textAreas[0].closest('div').parentElement;
            $(sidebar).prepend(composeButton);
        }
    }

    function generatePrompt() {
        return promptComposerData.categories.map(category => {
            const activeTags = category.tags
                .filter(tag => tag.active)
                .map(tag => {
                    let tagText = tag.name;
                    if (tag.weight > 1) {
                        const repetitions = Math.round((tag.weight - 1) / 0.05);
                        tagText = '{'.repeat(repetitions) + tagText + '}'.repeat(repetitions);
                    } else if (tag.weight < 1) {
                        const repetitions = Math.round((1 - tag.weight) / 0.05);
                        tagText = '['.repeat(repetitions) + tagText + ']'.repeat(repetitions);
                    }
                    return tagText;
                });
            const customTags = (promptComposerData.customTags[category.name] || '')
                .split(',')
                .map(tag => tag.trim())
                .filter(tag => tag !== '');
            return [...activeTags, ...customTags];
        }).flat().join(", ");
    }

    function updateWeightDisplay(tag, displayElement, controlElement) {
        const weight = tag.weight || 1;
        if (weight === 1) {
            displayElement.text('');
            if (controlElement) {
                controlElement.find('input').hide();
                controlElement.find('button').show();
            }
        } else {
            if (controlElement && $('.weight-control').first().is(':visible')) {
                displayElement.text('');
                controlElement.find('input, button').show();
            } else {
                displayElement.text(`:${weight.toFixed(2)}`);
                if (controlElement) {
                    controlElement.find('input').hide();
                    controlElement.find('button').show();
                }
            }
        }
    }

    $(onReady);
})();