Novelai Prompt Helper / Novelai 提示词增强

Tag suggestions and weight shortcuts for NovelAI prompts. 通过Ctrl+↑/↓快速调整输入光标所在的Tag权重,Ctrl+←/→移动Tag位置。自动格式化Prompt。

// ==UserScript==
// @name         Novelai Prompt Helper / Novelai 提示词增强
// @namespace    https://novelai.net
// @version      1.1.5
// @description  Tag suggestions and weight shortcuts for NovelAI prompts. 通过Ctrl+↑/↓快速调整输入光标所在的Tag权重,Ctrl+←/→移动Tag位置。自动格式化Prompt。
// @author       Takoro
// @match        https://novelai.net/image
// @match        https://novelai.github.io/image
// @icon         https://www.google.com/s2/favicons?sz=64&domain=novelai.net
// @license      MIT
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_openInTab
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @connect      danbooru.donmai.us
// @connect      raw.githubusercontent.com
// ==/UserScript==
(function () {
    'use strict';
    if (typeof window.__NAIWeightAdjusting !== 'boolean') {
        window.__NAIWeightAdjusting = false;
    }
    if (typeof window.__NAIWeightAdjustingUntil !== 'number') {
        window.__NAIWeightAdjustingUntil = 0;
    }
    const LOCALE_KEY = 'nai_prompt_helper_locale_v1';
    const AUTOFORMAT_KEY = 'nai_prompt_helper_autoformat_enabled_v1';
    const LOCALE_MESSAGES = {
        zh: {
            infoTitle: 'Did you mean...?',
            hints: [
                '按住 Ctrl + 左键点击标签可以打开对应的 Wiki 页面。',
                '紫色标签代表系列/IP,绿色标签代表角色。',
                '标签后面的数字表示相关作品数量。',
                '可以尝试使用 Tab、Enter 或方向键选择候选项。',
                'Takoro'
            ],
            fallbackHint: 'Danbooru API 连接失败,已切换到本地缓存。',
            apiTimeoutLog: '[NAI Prompt Helper] 网络请求超时,改用本地缓存。',
            logReady: '[NAI Prompt Helper] 标签联想模块就绪。',
            menuSwitchLabel: '切换到英文界面',
            menuEnableFormat: '启用自动格式化',
            menuDisableFormat: '禁用自动格式化',
        },
        en: {
            infoTitle: 'Did you mean...?',
            hints: [
                'Ctrl + Click a tag to open its Danbooru wiki page.',
                'Purple tags are series/IP names; green tags are characters.',
                'Numbers after a tag show total post counts.',
                'Use Tab, Enter, or arrow keys to pick a suggestion.',
                'Takoro'
            ],
            fallbackHint: 'Danbooru API error, switching to cached suggestions.',
            apiTimeoutLog: '[NAI Prompt Helper] API timeout, using cached suggestions.',
            logReady: '[NAI Prompt Helper] Tag suggestions ready.',
            menuSwitchLabel: 'Switch to Chinese UI',
            menuEnableFormat: 'Enable Auto-Formatting',
            menuDisableFormat: 'Disable Auto-Formatting',
        }
    };
    const SUPPORTED_LOCALES = Object.keys(LOCALE_MESSAGES);
    function detectInitialLocale() {
        try {
            if (typeof GM_getValue === 'function') {
                const stored = GM_getValue(LOCALE_KEY);
                if (stored && SUPPORTED_LOCALES.includes(stored)) {
                    return stored;
                }
            }
        } catch (error) { }
        const nav = (navigator.language || navigator.userLanguage || '').toLowerCase();
        if (nav.startsWith('zh')) { return 'zh'; }
        return 'en';
    }
    let currentLocale = detectInitialLocale();
    let autoFormatEnabled = true;
    try {
        if (typeof GM_getValue === 'function') {
            const storedValue = GM_getValue(AUTOFORMAT_KEY);
            if (typeof storedValue === 'boolean') {
                autoFormatEnabled = storedValue;
            } else {
                GM_setValue(AUTOFORMAT_KEY, true);
            }
        }
    } catch (e) { }
    let registeredMenuIds = [];
    const isChineseLocale = currentLocale === 'zh';
    function t(key) {
        const bundle = LOCALE_MESSAGES[currentLocale] || LOCALE_MESSAGES.en;
        return bundle[key] ?? LOCALE_MESSAGES.en[key] ?? key;
    }
    function getHints() {
        const bundle = LOCALE_MESSAGES[currentLocale] || LOCALE_MESSAGES.en;
        return bundle.hints || [];
    }
    function clearLocaleMenus() {
        if (typeof GM_unregisterMenuCommand !== 'function') return;
        registeredMenuIds.forEach(id => {
            try { GM_unregisterMenuCommand(id); } catch (error) { }
        });
        registeredMenuIds = [];
    }
    function registerMenus() {
        if (typeof GM_registerMenuCommand !== 'function') return;
        clearLocaleMenus();
        const otherLocale = currentLocale === 'zh' ? 'en' : 'zh';
        const localeLabel = LOCALE_MESSAGES[currentLocale].menuSwitchLabel;
        try {
            const localeCmdId = GM_registerMenuCommand(localeLabel, () => {
                try {
                    if (typeof GM_setValue === 'function') {
                        GM_setValue(LOCALE_KEY, otherLocale);
                    }
                } catch (error) { }
                window.location.reload();
            });
            if (typeof localeCmdId !== 'undefined') {
                registeredMenuIds.push(localeCmdId);
            }
        } catch (error) { }
        const formatLabel = autoFormatEnabled ? t('menuDisableFormat') : t('menuEnableFormat');
        try {
            const formatCmdId = GM_registerMenuCommand(formatLabel, () => {
                try {
                    if (typeof GM_setValue === 'function') {
                        GM_setValue(AUTOFORMAT_KEY, !autoFormatEnabled);
                    }
                } catch (error) { }
                window.location.reload();
            });
            if (typeof formatCmdId !== 'undefined') {
                registeredMenuIds.push(formatCmdId);
            }
        } catch (error) { }
    }
    function formatPromptText(text) {
        const structure = WeightShortcuts.parsePromptStructure(text);
        WeightShortcuts.normalizeTree(structure);
        return WeightShortcuts.serializeTree(structure);
    }
    registerMenus();
    const TagAssist = (() => {
        const MAX_SUGGESTIONS = 10;
        const DEBOUNCE_DELAY = 400;
        const API_BASE_URL = 'https://danbooru.donmai.us';
        const TRANSLATION_URL = 'https://raw.githubusercontent.com/Yellow-Rush/zh_CN-Tags/main/danbooru.csv';
        const CACHE_KEY = 'danbooru_translations_cache';
        const CACHE_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
        const TITLE_COLOR = '#e3dccc';
        const SHORT_QUERY_PREFIX_LENGTH = 2;
        const ALLOWED_CHARS_REGEX = /^[a-zA-Z\d_\-\s'\^=@()\u4e00-\u9fa5\u3040-\u309F\u30A0-\u30FF]+$/;
        const BREAK_CHARS = ',{}[]:.';
        let popup = null;
        let selectedIndex = -1;
        let currentMatches = [];
        let isPopupActive = false;
        let isKeyboardNavigation = false;
        let apiAbortController = null;
        let lastKnownRange = null;
        let translations = new Map();
        GM_addStyle(`
                .autocomplete-container { position: absolute; background: #0e0f21; border: 1px solid #3B3B52; border-radius: 8px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); max-height: 450px; display: flex; flex-direction: column; z-index: 100000; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; color: #EAEAEB; min-width: 350px; max-width: 500px; }
                .suggestion-info-display { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #1a1c30; border-bottom: 1px solid #3B3B52; font-size: 13.5px; line-height: 1.6; min-height: 17px; font-weight: bold; }
                .info-hint { color: #8a8a9e; font-weight: normal; font-size: 12px; margin-left: 10px; white-space: nowrap; }
                .info-hint-error { color: #ff7b7b; font-weight: bold; font-size: 12px; margin-left: 10px; white-space: nowrap; }
                .info-title { color: ${TITLE_COLOR}; }
                .suggestion-scroll-wrapper { padding: 7px; overflow-y: auto; }
                .suggestion-flex { display: flex; flex-wrap: wrap; gap: 6px; }
                .suggestion-item { display: flex; align-items: center; justify-content: space-between; padding: 4px 10px; background: #22253f; border: 1px solid transparent; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; font-size: 14px; height: 26px; white-space: nowrap; flex-shrink: 0; }
                .suggestion-item:hover, .suggestion-item.selected { background: #34395f; border-color: #F5F3C2; }
                .suggestion-text { overflow: hidden; text-overflow: ellipsis; color: #EAEAEB; }
                .suggestion-count { color: #8a8a9e; margin-left: 12px; font-size: 12px; }
                .suggestion-item[data-category='4'] .suggestion-text { color: #a6f3a6; }
                .suggestion-item[data-category='3'] .suggestion-text { color: #d6bcf5; }
            `);
        const normalizeQuery = (query) => query.trim().replace(/\s+/g, '_');
        function shouldSkipWeightSuggestions(textBeforeCursor, delimiterIndex) {
            if (delimiterIndex < 0 || textBeforeCursor[delimiterIndex] !== ':') return false;
            if (textBeforeCursor.substring(delimiterIndex - 1, delimiterIndex + 1) === '::') { return false; }
            let i = delimiterIndex - 1;
            if (i >= 0 && textBeforeCursor[i] === ':') {
                i--;
                while (i >= 0 && /\s/.test(textBeforeCursor[i])) i--;
                if (i >= 0 && /[\d.]/.test(textBeforeCursor[i])) {
                    let end = i;
                    while (i >= 0 && /[\d.]/.test(textBeforeCursor[i])) i--;
                    const numericPart = textBeforeCursor.substring(i + 1, end + 1);
                    if (numericPart && /^\d+(?:\.\d+)?$/.test(numericPart)) {
                        const precedingChar = i >= 0 ? textBeforeCursor[i] : '';
                        if (i < 0 || BREAK_CHARS.includes(precedingChar) || /\s/.test(precedingChar)) { return true; }
                    }
                }
                return false;
            }
            while (i >= 0 && /\s/.test(textBeforeCursor[i])) i--;
            if (i < 0) return false;
            let end = i;
            while (i >= 0 && /[\d.]/.test(textBeforeCursor[i])) i--;
            const numericPart = textBeforeCursor.substring(i + 1, end + 1);
            if (!numericPart || !/^\d+(?:\.\d+)?$/.test(numericPart)) return false;
            const precedingChar = i >= 0 ? textBeforeCursor[i] : '';
            if (i >= 0 && !BREAK_CHARS.includes(precedingChar) && !/\s/.test(precedingChar)) return false;
            return true;
        }
        function getClonedSelectionRange() {
            const selection = window.getSelection();
            if (!selection || !selection.rangeCount) return null;
            return selection.getRangeAt(0).cloneRange();
        }
        function loadTranslations() {
            if (!isChineseLocale) { translations = new Map(); return; }
            const cachedData = GM_getValue(CACHE_KEY);
            if (cachedData && cachedData.timestamp && (Date.now() - cachedData.timestamp < CACHE_DURATION_MS)) {
                translations = new Map(cachedData.translations); return;
            }
            GM_xmlhttpRequest({
                method: "GET", url: TRANSLATION_URL,
                onload: function (response) {
                    if (response.status === 200) {
                        const lines = response.responseText.split('\n').filter(line => line.trim());
                        lines.forEach(line => {
                            const columns = line.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/);
                            if (columns.length >= 2) {
                                const en = (columns[0] || '').trim().replace(/^"|"$/g, '');
                                const zh = (columns[1] || '').trim().replace(/^"|"$/g, '');
                                if (en && zh) { translations.set(en, zh); }
                            }
                        });
                        GM_setValue(CACHE_KEY, { translations: Array.from(translations.entries()), timestamp: Date.now() });
                    }
                },
            });
        }
        function openWikiPage(tagName) { if (!tagName) return; GM_openInTab(`${API_BASE_URL}/wiki_pages/show_or_new?title=${tagName}`, { active: true }); }
        function getRandomHint() {
            const hints = getHints();
            if (!hints.length || Math.random() < 0.5) { return ""; }
            return hints[Math.floor(Math.random() * hints.length)];
        }
        function searchLocalSuggestions(query, input) {
            if (!isChineseLocale || !translations.size) return;
            const queryLower = query.toLowerCase();
            const rankedItems = [];
            for (const [en, zh] of translations.entries()) {
                const zhLower = zh.toLowerCase();
                let score = 0;
                if (zhLower.startsWith(queryLower)) score = 1;
                else if (zhLower.includes(queryLower)) score = 2;
                if (score > 0) { rankedItems.push({ score: score, data: { en: en, count: undefined, category: 0 } }); }
            }
            const finalMatches = rankedItems.sort((a, b) => a.score - b.score).map(item => item.data).slice(0, MAX_SUGGESTIONS);
            updatePopup(input, finalMatches);
        }
        function searchLocalFallback(query, input) {
            if (!isChineseLocale || !translations.size) { hidePopup(); return; }
            const queryLower = query.toLowerCase(), normalizedQuery = normalizeQuery(queryLower), spacedQuery = queryLower.replace(/\s+/g, ' ');
            if (!normalizedQuery) { hidePopup(); return; }
            const rankedItems = [];
            for (const en of translations.keys()) {
                const enLower = en.toLowerCase();
                let score = 0;
                const enNormalized = normalizeQuery(enLower), enSpaced = enLower.replace(/_/g, ' ');
                if (enNormalized.startsWith(normalizedQuery) || enSpaced.startsWith(spacedQuery)) score = 1;
                else if (enNormalized.includes(normalizedQuery) || enSpaced.includes(spacedQuery)) score = 2;
                if (score > 0) { rankedItems.push({ score: score, data: { en: en, count: undefined, category: 0 } }); }
            }
            const finalMatches = rankedItems.sort((a, b) => a.score - b.score).map(item => item.data).slice(0, MAX_SUGGESTIONS);
            if (finalMatches.length > 0) { updatePopup(input, finalMatches, t('fallbackHint')); } else { hidePopup(); }
        }
        function fetchSuggestions(query, input) {
            const normalizedQuery = normalizeQuery(query);
            if (!normalizedQuery) { hidePopup(); return; }
            if (apiAbortController) apiAbortController.abort();
            apiAbortController = new AbortController();
            const searchPattern = normalizedQuery.length <= SHORT_QUERY_PREFIX_LENGTH ? `${normalizedQuery}*` : `*${normalizedQuery}*`;
            const params = new URLSearchParams({ 'search[name_matches]': searchPattern, 'search[order]': 'count', 'limit': MAX_SUGGESTIONS, 'search[hide_empty]': 'true' });
            GM_xmlhttpRequest({
                method: "GET",
                url: `${API_BASE_URL}/tags.json?${params.toString()}`,
                signal: apiAbortController.signal,
                timeout: 1500,
                headers: {
                    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36",
                    "Referer": "https://danbooru.donmai.us/"
                },

                ontimeout: function () {
                    console.error(`[NAI Prompt Helper] API 请求超时 (ontimeout)。(Timeout: 1500ms). URL: ${API_BASE_URL}/tags.json?${params.toString()}`);
                    searchLocalFallback(query, input);
                },

                onload: function (response) {
                    if (response.status === 200) {
                        const matches = JSON.parse(response.responseText).map(tag => ({ en: tag.name, count: tag.post_count, category: tag.category }));
                        updatePopup(input, matches);
                    } else {
                        console.error(`[NAI Prompt Helper] API 错误: 收到 HTTP 状态 ${response.status} ${response.statusText}`);
                        console.warn(`[NAI Prompt Helper] 失败的URL: ${response.finalUrl}`);
                        searchLocalFallback(query, input);
                    }
             },

                onerror: (error) => {
                    console.error('[NAI Prompt Helper] API 请求失败 (onerror)。 详细信息:', error);
                    if (error.readyState !== 0) { // 忽略 abort 信号
                        searchLocalFallback(query, input);
                 }
                }
            });
        }
        function updatePopup(input, matches, overrideHint = null) {
            createPopup();
            let hintHTML = overrideHint ? `<span class="info-hint-error">${overrideHint}</span>` : (getRandomHint() ? `<span class="info-hint">${getRandomHint()}</span>` : '');
            popup.innerHTML = `<div class="suggestion-info-display"><span class="info-title">${t('infoTitle')}</span>${hintHTML}</div><div class="suggestion-scroll-wrapper"><div class="suggestion-flex"></div></div>`;
            const flexContainer = popup.querySelector('.suggestion-flex');
            currentMatches = matches;
            if (matches.length === 0) { hidePopup(); return; }
            matches.forEach((tag, index) => {
                const item = document.createElement('div');
                item.className = 'suggestion-item';
                item.dataset.category = tag.category || '0';
                const enText = tag.en.replace(/_/g, ' '), zhText = translations.get(tag.en), displayText = zhText ? `${enText} (${zhText})` : enText;
                let countHTML = '';
                if (tag.count !== undefined && tag.count !== null) {
                    const countText = tag.count > 1000 ? `${(tag.count / 1000).toFixed(1)}k` : tag.count;
                    countHTML = `<span class="suggestion-count">${countText}</span>`;
                }
                item.innerHTML = `<span class="suggestion-text">${displayText}</span>${countHTML}`;
                item.addEventListener('mouseover', () => {
                    isKeyboardNavigation = false;
                    if (selectedIndex !== index) { selectedIndex = index; updateSelectionUI(); }
                });
                item.addEventListener('mousedown', (e) => {
                    e.preventDefault();
                    selectedIndex = index;
                    updateSelectionUI();
                    if (e.ctrlKey) { openWikiPage(tag.en); }
                    else {
                        const currentInput = getActiveInputElement();
                        if (currentInput) {
                            const rangeToRestore = lastKnownRange ? lastKnownRange.cloneRange() : getClonedSelectionRange();
                            currentInput.focus();
                            requestAnimationFrame(() => insertTag(currentInput, tag.en, rangeToRestore));
                        }
                    }
                });
                flexContainer.appendChild(item);
            });
            positionPopup(input);
            popup.style.display = 'flex';
            isPopupActive = true;
            if (matches.length > 0) { selectedIndex = 0; updateSelectionUI(); }
        }

        function findNodeAndOffsetFromGlobal(root, globalOffset) {
            const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
            let accumulatedLength = 0;
            let lastTextNode = null;
            let currentNode;
            while (currentNode = treeWalker.nextNode()) {
                lastTextNode = currentNode;
                const nodeLength = currentNode.textContent.length;
                if (accumulatedLength + nodeLength >= globalOffset) {
                    return { node: currentNode, offset: globalOffset - accumulatedLength };
                }
                accumulatedLength += nodeLength;
            }
            if (lastTextNode) {
                return { node: lastTextNode, offset: lastTextNode.textContent.length };
            }
            return { node: root, offset: 0 };
        }

        function insertTag(input, tag, rangeToRestore) {
            const cleanTag = tag.replace(/_/g, ' ');
            const workingRange = rangeToRestore ? rangeToRestore.cloneRange() : getClonedSelectionRange();
            if (!workingRange) return;

            const tempRange = document.createRange();
            tempRange.setStart(input, 0);
            tempRange.setEnd(workingRange.startContainer, workingRange.startOffset);
            const globalCursorPos = tempRange.toString().length;
            const fullText = input.textContent;

            if (autoFormatEnabled) {

                let start = globalCursorPos;
                while (start > 0 && !BREAK_CHARS.includes(fullText[start - 1])) { start--; }
                let end = globalCursorPos;
                while (end < fullText.length && !BREAK_CHARS.includes(fullText[end])) { end++; }

                const textBefore = fullText.substring(0, start);
                const textAfter = fullText.substring(end);
                const newFullText = textBefore + cleanTag + textAfter;

                let formattedText = formatPromptText(newFullText);
                formattedText = formattedText.trim();

                if (formattedText.length > 0 && !formattedText.endsWith(',')) {
                    formattedText += ', ';
                } else if (formattedText.endsWith(',')) {
                    formattedText += ' ';
                }

                let newCursorPos = -1;
                const searchStart = Math.max(0, start - 15);
                const foundIndex = formattedText.indexOf(cleanTag, searchStart);

                if (foundIndex !== -1) {
                    newCursorPos = foundIndex + cleanTag.length + 2;
                } else {
                    newCursorPos = start + cleanTag.length + 2;
                }

                if (newCursorPos >= 2 && formattedText.substring(newCursorPos - 2, newCursorPos) === '::') {
                    newCursorPos -= 2;
                }

                newCursorPos = Math.min(newCursorPos, formattedText.length);
                WeightShortcuts.updateInputContent(input, formattedText, newCursorPos, newCursorPos);

            } else {
                try {
                    if (!input.contains(workingRange.startContainer)) return;

                    let start = globalCursorPos;
                    while (start > 0 && !BREAK_CHARS.includes(fullText[start - 1])) { start--; }
                    let end = globalCursorPos;
                    while (end < fullText.length && !BREAK_CHARS.includes(fullText[end])) { end++; }

                    const { node: startNode, offset: startOffset } = findNodeAndOffsetFromGlobal(input, start);
                    const { node: endNode, offset: endOffset } = findNodeAndOffsetFromGlobal(input, end);

                    const replacementRange = document.createRange();
                    replacementRange.setStart(startNode, startOffset);
                    replacementRange.setEnd(endNode, endOffset);

                    replacementRange.deleteContents();

                    let textToInsert = cleanTag;
                    const textBefore = fullText.substring(0, start).trimEnd();
                    let prefix = '';
                    if (textBefore.length > 0) {
                        if (textBefore.endsWith(',')) {
                            prefix = ' ';
                        } else {
                            prefix = ', ';
                        }
                    }
                    textToInsert = prefix + textToInsert + ', ';

                    const newTextNode = document.createTextNode(textToInsert);
                    replacementRange.insertNode(newTextNode);

                    replacementRange.setStartAfter(newTextNode);
                    replacementRange.collapse(true);

                    const selection = window.getSelection();
                    selection.removeAllRanges();
                    selection.addRange(replacementRange);

                    const inputEvent = new Event('input', { bubbles: true, composed: true });
                    input.dispatchEvent(inputEvent);
                } catch (error) {
                     console.error("[NAI Prompt Helper] An error occurred during surgical insertTag:", error);
                }
            }

            window.__NAIWeightAdjustingUntil = Date.now() + 500;
            hidePopup();
        }
        function createPopup() { if (!popup) { popup = document.createElement('div'); popup.className = 'autocomplete-container'; document.body.appendChild(popup); } }
        function hidePopup() { if (popup) popup.style.display = 'none'; if (apiAbortController) apiAbortController.abort(); selectedIndex = -1; isPopupActive = false; currentMatches = []; isKeyboardNavigation = false; }
        const debounce = (func, delay) => { let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => func(...args), delay); }; };
        function getActiveInputElement() { const selection = window.getSelection(); if (!selection.rangeCount) return null; const node = selection.focusNode; const pElement = node.nodeType === 3 ? node.parentElement.closest('p') : node.closest('p'); if (pElement && (pElement.closest('[class*="prompt-input"]') || pElement.closest('[class*="character-prompt-input"]'))) { return pElement; } return null; }
        function positionPopup(input) { let rect; const selection = window.getSelection(); if (selection.rangeCount > 0) { const range = selection.getRangeAt(0).cloneRange(); if (range.collapsed) { const tempSpan = document.createElement('span'); tempSpan.appendChild(document.createTextNode('\u200b')); range.insertNode(tempSpan); rect = tempSpan.getBoundingClientRect(); tempSpan.remove(); } else { rect = range.getBoundingClientRect(); } } if (!rect || (rect.width === 0 && rect.height === 0)) { rect = input.getBoundingClientRect(); } popup.style.top = `${rect.bottom + window.scrollY + 5}px`; popup.style.left = `${rect.left + window.scrollX}px`; }
        function updateSelectionUI() { const items = popup.querySelectorAll('.suggestion-item'); items.forEach((item, index) => item.classList.toggle('selected', index === selectedIndex)); const selectedEl = popup.querySelector('.selected'); if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' }); }
        function saveSelection() {
            const selection = window.getSelection();
            const activeInput = getActiveInputElement();
            if (activeInput && selection.rangeCount > 0) {
                const range = selection.getRangeAt(0);
                if (activeInput.contains(range.commonAncestorContainer)) { lastKnownRange = range.cloneRange(); }
            }
        }
        const handleInput = debounce(() => {
            const now = Date.now();
            if (window.__NAIWeightAdjusting || now < window.__NAIWeightAdjustingUntil) { hidePopup(); return; }
            const input = getActiveInputElement(); if (!input) { hidePopup(); return; }
            const textBeforeCursor = (() => { const sel = window.getSelection(); if (!sel.rangeCount) return ''; const range = sel.getRangeAt(0).cloneRange(); const parent = sel.focusNode.parentElement; if (!parent) return ''; range.selectNodeContents(parent); range.setEnd(sel.focusNode, sel.focusOffset); return range.toString(); })();
            if (textBeforeCursor.endsWith(',')) { hidePopup(); return; }
            const lastDelimiterIndex = Math.max(textBeforeCursor.lastIndexOf(','), textBeforeCursor.lastIndexOf(':'), textBeforeCursor.lastIndexOf('['), textBeforeCursor.lastIndexOf('{'));
            const currentQuery = textBeforeCursor.substring(lastDelimiterIndex + 1).trim();
            if (shouldSkipWeightSuggestions(textBeforeCursor, lastDelimiterIndex)) { hidePopup(); return; }
            if (currentQuery.length < 1) { hidePopup(); return; }
            if (!ALLOWED_CHARS_REGEX.test(currentQuery)) { hidePopup(); return; }
            if (/^\d*\.?\d*$/.test(currentQuery)) { hidePopup(); return; }
            if (/[\u4e00-\u9fa5]/.test(currentQuery)) { searchLocalSuggestions(currentQuery, input); } else { fetchSuggestions(currentQuery, input); }
        }, DEBOUNCE_DELAY);
        function handleKeydown(e) {
            if (e.ctrlKey) { return; }
            if (e.key === ',') { hidePopup(); return; }
            if (!isPopupActive) return;
            const keyMap = { 'ArrowDown': 1, 'ArrowRight': 1, 'ArrowUp': -1, 'ArrowLeft': -1 };
            if (keyMap[e.key] !== undefined) {
                e.preventDefault();
                isKeyboardNavigation = true;
                selectedIndex = (selectedIndex + keyMap[e.key] + currentMatches.length) % currentMatches.length;
                updateSelectionUI();
            } else if (e.key === 'Enter' || e.key === 'Tab') {
                e.preventDefault(); e.stopPropagation();
                if (selectedIndex >= 0 && currentMatches[selectedIndex]) {
                    const input = getActiveInputElement();
                    if (input) {
                        const rangeForInsert = getClonedSelectionRange();
                        insertTag(input, currentMatches[selectedIndex].en, rangeForInsert);
                    }
                } else { hidePopup(); }
            } else if (e.key === 'Escape') { e.preventDefault(); hidePopup(); }
            else { isKeyboardNavigation = false; }
        }
        function handleClickOutside(e) { const input = getActiveInputElement(); if (isPopupActive && popup && !popup.contains(e.target) && !input?.contains(e.target)) { hidePopup(); } }
        function init() {
            loadTranslations();
            document.addEventListener('input', handleInput);
            document.addEventListener('keydown', handleKeydown, true);
            document.addEventListener('mousedown', handleClickOutside);
            document.addEventListener('keyup', saveSelection);
            document.addEventListener('mouseup', saveSelection);
            document.addEventListener('selectionchange', saveSelection);
        }
        return { init };
    })();
    const WeightShortcuts = (() => {
        function getActiveInputElement() {
            const selection = window.getSelection();
            if (!selection.rangeCount) return null;
            const node = selection.focusNode;
            const pElement = node.nodeType === 3 ? node.parentElement.closest('p') : node.closest('p');
            if (pElement && (pElement.closest('.prompt-input-box-prompt') || pElement.closest('.prompt-input-box-base-prompt') || pElement.closest('.prompt-input-box-negative-prompt') || pElement.closest('.prompt-input-box-undesired-content') || pElement.closest('[class*="character-prompt-input"]'))) {
                return pElement;
            }
            return null;
        }
        function getSelectedTagInfo(inputElement) {
            if (!inputElement) return null;
            const selection = window.getSelection();
            if (!selection.rangeCount) return null;
            const range = selection.getRangeAt(0);
            const node = range.startContainer;
            const offset = range.startOffset;
            const fullText = inputElement.textContent || '';
            let globalOffset = 0;
            if (node.nodeType === 3) {
                const treeWalker = document.createTreeWalker(inputElement, NodeFilter.SHOW_TEXT);
                let currentNode;
                while ((currentNode = treeWalker.nextNode())) {
                    if (currentNode === node) break;
                    globalOffset += currentNode.length;
                }
                globalOffset += offset;
            } else { globalOffset = offset; }
            let start = globalOffset;
            while (start > 0 && fullText[start - 1] !== ',' && fullText[start - 1] !== '\n') { start--; }
            let end = globalOffset;
            while (end < fullText.length && fullText[end] !== ',' && fullText[end] !== '\n') { end++; }
            if (fullText[start] === ',' || fullText[start] === '\n') start++;
            if (fullText[end - 1] === ',') end--;
            const tagText = fullText.slice(start, end).trim();
            return tagText ? { tagText, start, end, fullText, cursorOffset: globalOffset } : null;
        }
        function updateInputContent(inputElement, newContent, selectionStart, selectionEnd) {
            window.__NAIWeightAdjusting = true;
            window.__NAIWeightAdjustingUntil = Date.now() + 500;
            try {
                if (inputElement.childNodes.length === 1 && inputElement.firstChild.nodeType === 3) {
                    inputElement.firstChild.textContent = newContent;
                } else {
                    const newTextNode = document.createTextNode(newContent);
                    inputElement.innerHTML = '';
                    inputElement.appendChild(newTextNode);
                }
                const newRange = document.createRange();
                const textLength = inputElement.firstChild.textContent.length;
                const safeIndex = Math.max(0, Math.min(textLength, selectionEnd));
                newRange.setStart(inputElement.firstChild, safeIndex);
                newRange.setEnd(inputElement.firstChild, safeIndex);
                const selection = window.getSelection();
                selection.removeAllRanges();
                selection.addRange(newRange);
                const inputEvent = new Event('input', { bubbles: true });
                inputElement.dispatchEvent(inputEvent);
            } finally { window.__NAIWeightAdjusting = false; }
        }
        function parsePromptStructure(text) {
            return parseSequence(text, 0, false).items;
        }
        function parseSequence(text, startIndex, stopAtClose) {
            const items = [];
            let i = startIndex;
            const readSeparator = (input, index, shouldStopAtClose) => {
                let cursor = index; let separator = '';
                while (cursor < input.length) {
                    if (shouldStopAtClose && input.startsWith('::', cursor)) { break; }
                    const ch = input[cursor];
                    if (ch === ',') { separator += ch; cursor++; while (cursor < input.length && input[cursor] === ' ') { separator += input[cursor]; cursor++; } }
                    else if (ch === '\n' || ch === '\r') { separator += ch; cursor++; }
                    else if (ch === ' ' || ch === '\t') { separator += ch; cursor++; }
                    else { break; }
                }
                return { separator, nextIndex: cursor };
            };
            while (i < text.length) {
                if (stopAtClose && text.startsWith('::', i)) { return { items, index: i + 2, closeStart: i, closeEnd: i + 2 }; }
                while (i < text.length && (text[i] === ' ' || text[i] === '\t')) { i++; }
                if (stopAtClose && text.startsWith('::', i)) { return { items, index: i + 2, closeStart: i, closeEnd: i + 2 }; }
                if (i >= text.length) { break; }
                const doubleColonMatch = text.slice(i).match(/^(-?\d+(?:\.\d+)?)(::)(?!:)/);
                if (doubleColonMatch) {
                    const groupStart = i;
                    const weightString = doubleColonMatch[1];
                    const parsedWeight = parseFloat(weightString);
                    i += doubleColonMatch[0].length;
                    const childResult = parseSequence(text, i, true);
                    const groupNode = { type: 'group', weight: isNaN(parsedWeight) ? 1.0 : parsedWeight, format: 'doubleColon', start: groupStart, weightStart: groupStart, weightEnd: groupStart + weightString.length, prefixEnd: i, closeStart: childResult.closeStart ?? childResult.index, closeEnd: childResult.closeEnd ?? childResult.index, children: childResult.items };
                    i = childResult.index;
                    const separatorInfo = readSeparator(text, i, stopAtClose);
                    i = separatorInfo.nextIndex;
                    items.push({ node: groupNode, separator: separatorInfo.separator });
                    continue;
                }
                const groupMatch = text.slice(i).match(/^(-?\d+(?:\.\d+)?)(\s*):(?!:)/);
                if (groupMatch) {
                    const groupStart = i;
                    const weightString = groupMatch[1];
                    const parsedWeight = parseFloat(weightString);
                    i += groupMatch[0].length;
                    const childResult = parseSequence(text, i, true);
                    const groupNode = { type: 'group', weight: isNaN(parsedWeight) ? 1.0 : parsedWeight, format: 'singleColon', start: groupStart, weightStart: groupStart, weightEnd: groupStart + weightString.length, prefixEnd: i, closeStart: childResult.closeStart ?? childResult.index, closeEnd: childResult.closeEnd ?? childResult.index, children: childResult.items };
                    i = childResult.index;
                    const separatorInfo = readSeparator(text, i, stopAtClose);
                    i = separatorInfo.nextIndex;
                    items.push({ node: groupNode, separator: separatorInfo.separator });
                    continue;
                }
                const tagRawStart = i;
                while (i < text.length && text[i] !== ',' && text[i] !== '\n' && text[i] !== '\r') {
                    if (stopAtClose && text.startsWith('::', i)) { break; }
                    if (i === tagRawStart) {
                        const aheadMatch = text.slice(i).match(/^(-?\d+(?:\.\d+)?)(\s*):(?!:)/);
                        if (aheadMatch) { break; }
                    }
                    i++;
                }
                const rawSegment = text.slice(tagRawStart, i);
                if (rawSegment.trim()) {
                    let leadingSpaces = 0;
                    while (leadingSpaces < rawSegment.length && /\s/.test(rawSegment[leadingSpaces])) { leadingSpaces++; }
                    let trailingSpaces = 0;
                    while (trailingSpaces < rawSegment.length - leadingSpaces && /\s/.test(rawSegment[rawSegment.length - 1 - trailingSpaces])) { trailingSpaces++; }
                    const contentStart = tagRawStart + leadingSpaces;
                    const contentEnd = i - trailingSpaces;
                    const tagText = text.slice(contentStart, contentEnd);
                    const tagNode = { type: 'tag', text: tagText, start: contentStart, end: contentEnd };
                    const separatorInfo = readSeparator(text, i, stopAtClose);
                    i = separatorInfo.nextIndex;
                    items.push({ node: tagNode, separator: separatorInfo.separator });
                } else {
                    const separatorInfo = readSeparator(text, i, stopAtClose);
                    i = separatorInfo.nextIndex;
                    if (items.length) { items[items.length - 1].separator += separatorInfo.separator; }
                }
            }
            return { items, index: i };
        }
        function normalizeSequence(sequence) {
            for (let i = 0; i < sequence.length; i++) {
                sequence[i].separator = i < sequence.length - 1 ? ', ' : '';
                if (sequence[i].node.type === 'group') { normalizeSequence(sequence[i].node.children); }
            }
        }
        function normalizeTree(sequence) { normalizeSequence(sequence); }
        function formatWeightValue(value) {
            const rounded = Math.round(value * 20) / 20;
            let str = rounded.toFixed(2);
            return str.replace(/\.?0+$/, '');
        }
        function serializeTree(sequence) {
            const state = { output: '', offset: 0 };
            serializeSequence(sequence, state);
            return state.output;
        }
        function serializeSequence(sequence, state) {
            for (let i = 0; i < sequence.length; i++) {
                serializeNode(sequence[i].node, state);
                const sep = sequence[i].separator || '';
                state.output += sep;
                state.offset += sep.length;
            }
        }
        function serializeNode(node, state) {
            if (node.type === 'tag') {
                node.serializeStart = state.offset;
                node.serializeEnd = state.offset + node.text.length;
                state.output += node.text;
                state.offset += node.text.length;
            } else if (node.type === 'group') {
                const weightStr = formatWeightValue(node.weight);
                const format = node.format || 'singleColon';
                if (format === 'doubleColon') {
                    state.output += `${weightStr}::`;
                    state.offset += weightStr.length + 2;
                } else {
                    state.output += `${weightStr}:`;
                    state.offset += weightStr.length + 1;
                }
                serializeSequence(node.children, state);
                state.output += '::';
                state.offset += 2;
            }
        }
        function findTagNodeByOffset(sequence, offset, ancestors = []) {
            for (let i = 0; i < sequence.length; i++) {
                const item = sequence[i];
                const node = item.node;
                if (node.type === 'tag') {
                    if (node.start <= offset && offset <= node.end) { return { item, sequence, index: i, ancestors }; }
                } else if (node.type === 'group') {
                    const result = findTagNodeByOffset(node.children, offset, ancestors.concat({ groupNode: node, entry: item, parentSequence: sequence, index: i }));
                    if (result) { return result; }
                }
            }
            return null;
        }
        function removeGroupWrapper(groupInfo) {
            const { parentSequence, index, groupNode } = groupInfo;
            const children = groupNode.children;
            parentSequence.splice(index, 1, ...children);
        }
        function wrapTagWithGroup(target, newWeight) {
            const { sequence, index, item } = target;
            const tagNode = item.node, originalSeparator = item.separator || '';
            sequence.splice(index, 1);
            const groupNode = { type: 'group', weight: newWeight, format: 'doubleColon', children: [{ node: tagNode, separator: '' }] };
            sequence.splice(index, 0, { node: groupNode, separator: originalSeparator });
        }
        function adjustWeightForTag(target, direction) {
            const step = 0.05;
            const tagNode = target.item.node;
            const ancestors = target.ancestors;
            if (ancestors.length > 0) {
                const outerGroupInfo = ancestors[0];
                const groupNode = outerGroupInfo.groupNode;
                let newWeight = groupNode.weight + (direction * step);
                newWeight = Math.round(newWeight * 20) / 20;
                if (Math.abs(newWeight - 1.0) < 0.001) { removeGroupWrapper(outerGroupInfo); }
                else { groupNode.weight = newWeight; }
                return tagNode;
            }
            let newWeight = 1.0 + (direction * step);
            newWeight = Math.round(newWeight * 20) / 20;
            if (Math.abs(newWeight - 1.0) < 0.001) { return tagNode; }
            wrapTagWithGroup(target, newWeight);
            return tagNode;
        }
        function resolveMovementContext(target) {
            let { sequence, index, item, ancestors } = target;
            let movedAsGroup = false;
            if (ancestors.length) {
                const immediate = ancestors[ancestors.length - 1];
                if (immediate.groupNode.children.length === 1) {
                    movedAsGroup = true;
                    sequence = immediate.parentSequence;
                    index = immediate.index;
                    item = immediate.entry;
                    ancestors = ancestors.slice(0, -1);
                }
            }
            return { sequence, index, item, ancestors, movedAsGroup };
        }
        function moveTagWithinStructure(target, direction) {
            const context = resolveMovementContext(target);
            const { sequence, index, item, ancestors } = context;
            const isOriginalWeighted = target.ancestors.length > 0;
            if (direction === -1) {
                if (index > 0) {
                    const prevItem = sequence[index - 1];
                    if (!isOriginalWeighted && prevItem.node.type === 'group') {
                        sequence.splice(index, 1);
                        item.separator = '';
                        prevItem.node.children.push(item);
                        return true;
                    }
                    sequence[index - 1] = item;
                    sequence[index] = prevItem;
                    return true;
                }
                if (!ancestors.length) { return false; }
                const parentContext = ancestors[ancestors.length - 1];
                const { parentSequence, index: parentIndex } = parentContext;
                sequence.splice(index, 1);
                if (sequence.length === 0) {
                    parentSequence.splice(parentIndex, 1);
                    parentSequence.splice(parentIndex, 0, item);
                } else { parentSequence.splice(parentIndex, 0, item); }
                return true;
            }
            if (direction === 1) {
                if (index < sequence.length - 1) {
                    const nextItem = sequence[index + 1];
                    if (!isOriginalWeighted && nextItem.node.type === 'group') {
                        sequence.splice(index, 1);
                        item.separator = '';
                        nextItem.node.children.unshift(item);
                        return true;
                    }
                    sequence[index + 1] = item;
                    sequence[index] = nextItem;
                    return true;
                }
                if (!ancestors.length) { return false; }
                const parentContext = ancestors[ancestors.length - 1];
                const { parentSequence, index: parentIndex } = parentContext;
                sequence.splice(index, 1);
                if (sequence.length === 0) {
                    parentSequence.splice(parentIndex, 1);
                    parentSequence.splice(parentIndex, 0, item);
                } else { parentSequence.splice(parentIndex + 1, 0, item); }
                return true;
            }
            return false;
        }
        function handleKeydown(event) {
            const inputElement = getActiveInputElement();
            if (!inputElement || !event.ctrlKey) return;
            if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
                event.preventDefault(); event.stopPropagation();
                const tagInfo = getSelectedTagInfo(inputElement); if (!tagInfo) return;
                const direction = (event.key === 'ArrowUp') ? 1 : -1;
                const leadingWhitespace = tagInfo.fullText.slice(tagInfo.start, tagInfo.end).match(/^\s*/)[0];
                const structure = parsePromptStructure(tagInfo.fullText);
                const offsetForSearch = tagInfo.cursorOffset ?? (tagInfo.start + leadingWhitespace.length);
                const target = findTagNodeByOffset(structure, offsetForSearch); if (!target) { return; }
                const tagNode = adjustWeightForTag(target, direction);
                normalizeTree(structure);
                const serialized = serializeTree(structure);
                const newStart = tagNode.serializeStart ?? (tagInfo.start + leadingWhitespace.length);
                const newEnd = tagNode.serializeEnd ?? (newStart + tagNode.text.length);
                updateInputContent(inputElement, serialized, newStart, newEnd);
            } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
                event.preventDefault(); event.stopPropagation();
                const tagInfo = getSelectedTagInfo(inputElement); if (!tagInfo) return;
                const direction = (event.key === 'ArrowLeft') ? -1 : 1;
                const leadingWhitespace = tagInfo.fullText.slice(tagInfo.start, tagInfo.end).match(/^\s*/)[0];
                const structure = parsePromptStructure(tagInfo.fullText);
                const offsetForSearch = tagInfo.cursorOffset ?? (tagInfo.start + leadingWhitespace.length);
                const target = findTagNodeByOffset(structure, offsetForSearch); if (!target) { return; }
                const tagNode = target.item.node;
                if (!moveTagWithinStructure(target, direction)) { return; }
                normalizeTree(structure);
                const serialized = serializeTree(structure);
                const newStart = tagNode.serializeStart ?? (tagInfo.start + leadingWhitespace.length);
                const newEnd = tagNode.serializeEnd ?? (newStart + tagNode.text.length);
                updateInputContent(inputElement, serialized, newStart, newEnd);
            }
        }
        function init() {
            const checkInterval = setInterval(() => {
                const inputElement = getActiveInputElement();
                if (inputElement) { clearInterval(checkInterval); document.addEventListener('keydown', handleKeydown, true); }
            }, 500);
        }
        return { init, parsePromptStructure, normalizeTree, serializeTree, updateInputContent };
    })();
    function init() {
        TagAssist.init();
        WeightShortcuts.init();
    }
    if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); }
    else { init(); }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址