Lorebook Engine

Processes active LoreCanvas lorebooks and injects relevant entries into the prompt via React state and WebSocket. Supports triggers, groups, limits, and OOC instructions. External script for JanitorAI.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Lorebook Engine
// @namespace    Violentmonkey Scripts
// @version      3.0
// @match        https://janitorai.com/*
// @grant        none
// @author       Glazanochi
// @description  Processes active LoreCanvas lorebooks and injects relevant entries into the prompt via React state and WebSocket. Supports triggers, groups, limits, and OOC instructions. External script for JanitorAI.
// ==/UserScript==

/* jshint esversion: 11 */
/* jshint -W083 */

(function() {
    'use strict';

    // ----- 0. ПРОВЕРКА ДИСПЕТЧЕРА И БД -----
    if (!window.__MANAGER__ || !window.__DB__) {
        console.warn('[LB] Диспетчер или DB не найдены. Модуль остановлен.');
        return;
    }

    // ----- 1. РЕГИСТРАЦИЯ ХРАНИЛИЩА -----
    window.__DB__.registerStore('lorebook_engine', 'key');

    // ----- 2. ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ -----
    const SEND_BTN_SELECTOR = 'button._sendButton_1bz7u_29:not(._stopButton_1bz7u_1)';
    const TEXTAREA_SELECTOR = 'textarea._chatTextarea_1e2lg_1';
    const REGEN_SEL = '._botMessageControlWrapper_1fw1u_1._right_1fw1u_13';
    const CONTINUE_SEL = '._controlPanelButton_4evg7_13';
    const RETRY_SEL = '._footerActionButton_4evg7_615';

    const MODULE_ID = 'lorebook_engine';
    let globalDepth = 3;
    let useGlobalDepth = false;
    let paused = false;
    let active = false;
    let currentChatId = null;
    let isInjecting = false;
    let injectionResetTimer = null;

    // Настройки лимита
    let maxInjectLength = 500;
    let limitBehavior = 'skip';       // 'skip', 'allow', 'trim'

    // Переменные для отложенных инъекций last_message
    let pendingLastMessageInjection = null; // массив {pos, content} для NEW
    let lastMessageRestore = null; // { id, originalText } для восстановления после WS
    let lastMessageOriginalText = null; // исходный текст пользователя из XHR

    // ----- 3. ЛОКАЛИЗАЦИЯ -----
    const L10N = window.__L10N__ || { currentLang: 'ru', t: (key) => key };
    const STRINGS = {
        'le_title': { ru: '📖 Lorebook Engine', en: '📖 Lorebook Engine' },
        'le_depth_label': { ru: 'Глубина сканирования:', en: 'Scan depth:' },
        'le_use_global': { ru: 'Глобальная глубина', en: 'Global depth' },
        'le_pause': { ru: 'Пауза', en: 'Pause' },
        'le_limit_label': { ru: 'Максимальный объём инъекций:', en: 'Max injection length:' },
        'le_limit_behavior': { ru: 'При превышении лимита:', en: 'When limit exceeded:' },
        'le_limit_skip': { ru: 'Не вставлять', en: 'Skip' },
        'le_limit_allow': { ru: 'Вставлять', en: 'Allow' },
        'le_limit_trim': { ru: 'Обрезать', en: 'Trim' },
        'le_limit_tooltip': {
            ru: 'Максимальная суммарная длина всех инъекций лорбука в символах.\nЕсли узел не помещается целиком:\n• Не вставлять — узел будет пропущен.\n• Вставлять — узел вставится целиком, возможно превышение лимита.\n• Обрезать — вставится часть узла, умещающаяся в оставшийся лимит.',
            en: 'Maximum total length of all lorebook injections in characters.\nIf a node does not fit entirely:\n• Skip — the node will be skipped.\n• Allow — the node will be inserted entirely, possibly exceeding the limit.\n• Trim — the part of the node that fits within the remaining limit will be inserted.'
        },
        'le_depth_tooltip': {
            ru: 'Если включено, глубина сканирования всех узлов будет заменена на указанное значение.\nЗначение 0 означает сканирование всей доступной истории.',
            en: 'If enabled, the scan depth of all nodes will be replaced with the specified value.\nA value of 0 means scanning the entire available history.'
        },
        'le_no_api': { ru: 'API лорбуков не найден', en: 'Lorebook API not found' },
        'le_no_target': { ru: '❌ Узел "{label}" не содержит цели (insertTarget)', en: '❌ Node "{label}" has no insertTarget' },
        'le_activated': { ru: 'Активировано узлов:', en: 'Activated nodes:' },
        'le_active_lorebooks': { ru: 'Активные лорбуки:', en: 'Active lorebooks:' },
    };
    function t(key, vars) {
        const lang = L10N.currentLang || 'ru';
        const str = STRINGS[key]?.[lang] || STRINGS[key]?.ru || key;
        return vars ? str.replace(/{([^}]+)}/g, (_, p1) => vars[p1] ?? '') : str;
    }

    // ----- 4. ЗАГРУЗКА/СОХРАНЕНИЕ НАСТРОЕК -----
    async function loadSettings() {
        try {
            const depth = await window.__DB__.dbGet('lorebook_engine', 'globalDepth');
            if (depth !== undefined) globalDepth = Number(depth) || 3;
            const useGlobal = await window.__DB__.dbGet('lorebook_engine', 'useGlobalDepth');
            useGlobalDepth = useGlobal === true || useGlobal === 'true';
            const isPaused = await window.__DB__.dbGet('lorebook_engine', 'paused');
            paused = isPaused === true || isPaused === 'true';
            const maxLen = await window.__DB__.dbGet('lorebook_engine', 'maxInjectLength');
            if (maxLen !== undefined) maxInjectLength = Number(maxLen) || 500;
            const beh = await window.__DB__.dbGet('lorebook_engine', 'limitBehavior');
            if (beh === 'skip' || beh === 'allow' || beh === 'trim') {
                limitBehavior = beh;
            }
        } catch (e) {
            console.error('[LB] Ошибка загрузки настроек:', e);
        }
    }

    async function saveSettings() {
        try {
            await window.__DB__.dbSet('lorebook_engine', 'globalDepth', String(globalDepth));
            await window.__DB__.dbSet('lorebook_engine', 'useGlobalDepth', String(useGlobalDepth));
            await window.__DB__.dbSet('lorebook_engine', 'paused', String(paused));
            await window.__DB__.dbSet('lorebook_engine', 'maxInjectLength', String(maxInjectLength));
            await window.__DB__.dbSet('lorebook_engine', 'limitBehavior', limitBehavior);
        } catch (e) {
            console.error('[LB] Ошибка сохранения настроек:', e);
        }
    }

    // ----- 5. ВСПЛЫВАЮЩАЯ ПОДСКАЗКА (mobile-friendly) -----
    function createInfoIcon(text) {
        const icon = document.createElement('span');
        icon.textContent = 'ℹ️';
        icon.style.cssText = 'cursor:pointer; color:#89b4fa; font-size:16px; margin-left:4px;';
        let tooltip = null;

        function showTooltip() {
            if (tooltip) return;

            const panel = document.getElementById('dispatcher-panel');
            if (!panel) return;
            const panelRect = panel.getBoundingClientRect();

            tooltip = document.createElement('div');
            tooltip.id = 'le-tooltip';
            tooltip.textContent = text;
            tooltip.style.cssText = `
                position: fixed;
                z-index: 10000020;
                background: #1e1e2e;
                border: 1px solid #45475a;
                border-radius: 6px;
                padding: 8px 12px;
                color: #cdd6f4;
                font-size: 13px;
                line-height: 1.5;
                white-space: pre-line;
                box-shadow: 0 4px 12px rgba(0,0,0,0.6);
                max-width: ${panelRect.width - 20}px;
                max-height: 200px;
                overflow-y: auto;
            `;
            document.body.appendChild(tooltip);

            const iconRect = icon.getBoundingClientRect();
            let left = iconRect.right + 8;
            let top = iconRect.top - 10;

            if (left + tooltip.offsetWidth > panelRect.right - 10) {
                left = panelRect.right - tooltip.offsetWidth - 10;
            }
            if (left < panelRect.left + 10) {
                left = panelRect.left + 10;
            }
            if (top + tooltip.offsetHeight > panelRect.bottom - 10) {
                top = panelRect.bottom - tooltip.offsetHeight - 10;
            }
            if (top < panelRect.top + 10) {
                top = panelRect.top + 10;
            }

            tooltip.style.left = left + 'px';
            tooltip.style.top = top + 'px';

            setTimeout(() => {
                document.addEventListener('click', hideTooltip, { once: true });
            }, 0);
        }

        function hideTooltip() {
            if (tooltip) {
                tooltip.remove();
                tooltip = null;
            }
        }

        icon.addEventListener('click', (e) => {
            e.stopPropagation();
            if (tooltip) {
                hideTooltip();
            } else {
                showTooltip();
            }
        });

        return icon;
    }

    // ----- 6. ВИДЖЕТ -----
    function getContent() {
        return `
            <div style="padding:8px;">
                <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
                    <label style="font-size:13px;">${t('le_depth_label')}</label>
                    <input id="le-depth" type="number" min="0" value="${globalDepth}" style="width:60px;background:#11111b;border:1px solid #45475a;color:#cdd6f4;border-radius:4px;padding:2px 4px;">
                </div>
                <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
                    <input id="le-use-global" type="checkbox" ${useGlobalDepth ? 'checked' : ''}>
                    <label style="font-size:13px;">${t('le_use_global')}</label>
                    <span id="le-depth-info"></span>
                </div>
                <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
                    <label style="font-size:13px;">${t('le_limit_label')}</label>
                    <input id="le-max-length" type="number" min="0" value="${maxInjectLength}" style="width:60px;background:#11111b;border:1px solid #45475a;color:#cdd6f4;border-radius:4px;padding:2px 4px;">
                </div>
                <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
                    <label style="font-size:13px;">${t('le_limit_behavior')}</label>
                    <select id="le-limit-behavior" style="background:#11111b;border:1px solid #45475a;color:#cdd6f4;border-radius:4px;padding:2px 4px;">
                        <option value="skip" ${limitBehavior === 'skip' ? 'selected' : ''}>${t('le_limit_skip')}</option>
                        <option value="allow" ${limitBehavior === 'allow' ? 'selected' : ''}>${t('le_limit_allow')}</option>
                        <option value="trim" ${limitBehavior === 'trim' ? 'selected' : ''}>${t('le_limit_trim')}</option>
                    </select>
                    <span id="le-limit-info"></span>
                </div>
                <div style="display:flex;align-items:center;gap:8px;">
                    <input id="le-pause" type="checkbox" ${paused ? 'checked' : ''}>
                    <label style="font-size:13px;">${t('le_pause')}</label>
                </div>
            </div>
        `;
    }

    function bindWidgetEvents() {
        const depthInput = document.getElementById('le-depth');
        const useGlobalCb = document.getElementById('le-use-global');
        const pauseCb = document.getElementById('le-pause');
        const maxLenInput = document.getElementById('le-max-length');
        const limitBehSelect = document.getElementById('le-limit-behavior');

        if (depthInput) {
            depthInput.addEventListener('change', async () => {
                let val = parseInt(depthInput.value);
                if (isNaN(val) || val < 0) val = 3;
                globalDepth = val;
                depthInput.value = val;
                await saveSettings();
            });
        }
        if (useGlobalCb) {
            useGlobalCb.addEventListener('change', async () => {
                useGlobalDepth = useGlobalCb.checked;
                await saveSettings();
            });
        }
        if (pauseCb) {
            pauseCb.addEventListener('change', async () => {
                paused = pauseCb.checked;
                await saveSettings();
            });
        }
        if (maxLenInput) {
            maxLenInput.addEventListener('change', async () => {
                let val = parseInt(maxLenInput.value);
                if (isNaN(val) || val < 0) val = 500;
                maxInjectLength = val;
                maxLenInput.value = val;
                await saveSettings();
            });
        }
        if (limitBehSelect) {
            limitBehSelect.addEventListener('change', async () => {
                limitBehavior = limitBehSelect.value;
                await saveSettings();
            });
        }

        const depthInfo = document.getElementById('le-depth-info');
        if (depthInfo) {
            depthInfo.innerHTML = '';
            depthInfo.appendChild(createInfoIcon(t('le_depth_tooltip')));
        }
        const limitInfo = document.getElementById('le-limit-info');
        if (limitInfo) {
            limitInfo.innerHTML = '';
            limitInfo.appendChild(createInfoIcon(t('le_limit_tooltip')));
        }

        (async () => {
            await loadSettings();
            if (depthInput) depthInput.value = globalDepth;
            if (useGlobalCb) useGlobalCb.checked = useGlobalDepth;
            if (pauseCb) pauseCb.checked = paused;
            if (maxLenInput) maxLenInput.value = maxInjectLength;
            if (limitBehSelect) limitBehSelect.value = limitBehavior;
        })();
    }

    // ----- 7. ПОИСК REACT-СТОРА (автоматический) -----
    function getReactStores() {
        const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
        if (!hook) return null;
        const renderer = Array.from(hook.renderers.values())[0];
        const textarea = document.querySelector(TEXTAREA_SELECTOR);
        if (!textarea) return null;
        let fiber = renderer.findFiberByHostInstance(textarea);
        while (fiber) {
            const props = fiber.memoizedProps;
            if (props && props.subscriptionStore && props.subscriptionStore.userStore && props.chatStore) {
                return {
                    config: props.subscriptionStore.userStore.config,
                    chatInfo: props.chatStore.chatInfo,
                    userStore: props.subscriptionStore.userStore,
                    chatStore: props.chatStore
                };
            }
            fiber = fiber.return;
        }
        return null;
    }

    // ----- 8. ИЗВЛЕЧЕНИЕ СООБЩЕНИЙ ИЗ REACT-СТОРА (без DOM) -----
    function getMessagesFromReact(stores) {
        if (!stores || !stores.chatStore || !stores.chatStore.messagesStore) return [];
        const messagesStore = stores.chatStore.messagesStore;
        const messages = messagesStore.messages;
        if (!Array.isArray(messages)) return [];

        const currentIndex = messagesStore.lastMessageIndex;
        const result = [];

        for (let i = 0; i < messages.length; i++) {
            const item = messages[i];
            if (Array.isArray(item)) {
                const isLast = (i === messages.length - 1);
                let index = item.length - 1;
                if (isLast && typeof currentIndex === 'number' && currentIndex >= 0 && currentIndex < item.length) {
                    index = currentIndex;
                }
                const chosen = item[index];
                if (chosen && typeof chosen.message === 'string') {
                    result.push({ is_bot: chosen.is_bot !== undefined ? chosen.is_bot : true, message: chosen.message });
                }
            } else if (item && typeof item.message === 'string') {
                result.push({ is_bot: item.is_bot !== undefined ? item.is_bot : false, message: item.message });
            }
        }

        return result;
    }

    // Вспомогательная функция поиска объекта последнего пользовательского сообщения
    function findLastUserMessageObject(stores) {
        if (!stores || !stores.chatStore || !stores.chatStore.messagesStore) return null;
        const messages = stores.chatStore.messagesStore.messages;
        if (!Array.isArray(messages)) return null;

        for (let i = messages.length - 1; i >= 0; i--) {
            const item = messages[i];
            const target = Array.isArray(item) ? item[item.length - 1] : item;
            if (target && target.is_bot === false) {
                return target;
            }
        }
        return null;
    }

    // Функция применения инъекций к строке с учётом позиций
    function applyInjectionsToString(originalText, injectionsList) {
        if (!Array.isArray(injectionsList) || injectionsList.length === 0) return originalText;

        let text = originalText || '';
        const hasReplace = injectionsList.some(item => item.pos === 'replace');

        if (hasReplace) {
            text = '';
            const replaceItems = injectionsList.filter(item => item.pos === 'replace');
            for (let i = 0; i < replaceItems.length; i++) {
                if (i === 0) {
                    text = replaceItems[i].content;
                } else {
                    text = text + '\n' + replaceItems[i].content;
                }
            }
        }

        const startParts = injectionsList.filter(item => item.pos === 'start').map(item => item.content);
        const endParts = injectionsList.filter(item => item.pos === 'end').map(item => item.content);

        if (startParts.length > 0) {
            text = startParts.join('\n') + '\n' + text;
        }
        if (endParts.length > 0) {
            text = text + '\n' + endParts.join('\n');
        }

        return text;
    }

    // ----- 9. ЛОГИКА ПОИСКА КЛЮЧЕЙ -----
    function logError(message) {
        console.error('[LB]', message);
        if (window.__MANAGER__ && window.__MANAGER__.logError) {
            window.__MANAGER__.logError(MODULE_ID, message);
        }
    }

    function keyExistsInText(text, key, caseSensitive, wholeWords) {
        if (key.startsWith('/') && key.lastIndexOf('/') > 0) {
            try {
                const lastSlash = key.lastIndexOf('/');
                const pattern = key.substring(1, lastSlash);
                const flags = key.substring(lastSlash + 1);
                const regex = new RegExp(pattern, flags);
                return regex.test(text);
            } catch (e) {
                console.warn('[LB] Некорректный regex в ключе:', key, e);
                return false;
            }
        }

        if (key.includes('*') || key.includes('?')) {
            const escaped = key.replace(/[.+^${}()|[\]\\]/g, '\\$&');
            const pattern = escaped.replace(/\*/g, '.*?').replace(/\?/g, '.');
            try {
                const regex = new RegExp(pattern, caseSensitive ? '' : 'i');
                return regex.test(text);
            } catch (e) {
                console.warn('[LB] Ошибка маски в ключе:', key, e);
                return false;
            }
        }

        const searchText = caseSensitive ? text : text.toLowerCase();
        const targetKey = caseSensitive ? key : key.toLowerCase();

        if (wholeWords) {
            const escapedKey = targetKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
            const regex = new RegExp(`(?<!\\p{L})${escapedKey}(?!\\p{L})`, `u${caseSensitive ? '' : 'i'}`);
            return regex.test(searchText);
        } else {
            return searchText.includes(targetKey);
        }
    }

    function evaluateNodeTrigger(node, text, caseSensitive, wholeWords) {
        if (node.alwaysActive) return true;

        const primaryKeys = node.keywords || [];
        const secondaryKeys = node.additionalKeys || [];
        const condition = node.condition || 'AND_ANY';

        if (primaryKeys.length === 0) return false;

        const foundPrimary = primaryKeys.filter(key => keyExistsInText(text, key, caseSensitive, wholeWords));
        if (foundPrimary.length === 0) return false;

        if (secondaryKeys.length === 0) return true;

        const foundSecondary = secondaryKeys.filter(key => keyExistsInText(text, key, caseSensitive, wholeWords));

        switch (condition) {
            case 'AND_ANY':
                return foundPrimary.some(p => foundSecondary.some(s => p !== s));
            case 'AND_ALL':
                return foundSecondary.length === secondaryKeys.length;
            case 'NOT_ANY':
                return foundSecondary.length === 0;
            case 'NOT_ALL':
                return foundSecondary.length < secondaryKeys.length;
            default:
                return false;
        }
    }

    // ----- 10. ГЕНЕРАЦИЯ ИНЪЕКЦИЙ (возвращает объект с массивами) -----
async function generateInjections(chatMessages, currentUserText) {
    if (!window.__LOREGRAPH__) {
        logError(t('le_no_api'));
        return null;
    }
    try {
        const lorebooks = await window.__LOREGRAPH__.getLorebooks();
        const activeLorebooks = lorebooks.filter(lb => lb.nodes && lb.nodes.length > 0);
        console.log('[LB] ' + t('le_active_lorebooks'), activeLorebooks.map(lb => lb.name || 'unnamed'));

        const allNodes = [];
        for (const lb of activeLorebooks) {
            allNodes.push(...lb.nodes);
        }

        // Определяем максимальную глубину сканирования
        let maxDepth = 0;
        if (useGlobalDepth) {
            maxDepth = globalDepth;
        } else {
            let hasZeroDepth = false;
            for (const node of allNodes) {
                const d = node.scanDepth ?? 0;
                if (d === 0) {
                    hasZeroDepth = true;
                    break;
                }
                if (d > maxDepth) maxDepth = d;
            }
            if (hasZeroDepth) maxDepth = 0;
        }

        if (maxDepth > 0 && chatMessages.length > maxDepth) {
            chatMessages = chatMessages.slice(-maxDepth);
        }

        console.log('[LB DEBUG] Сообщения для поиска ключей (после обрезки):', chatMessages.map(m => m.message.trim()));

        // Этап 1: фильтр chance (первичная проверка)
        let candidates = [];
        for (const node of allNodes) {
            if (Math.random() * 100 > (node.chance ?? 100)) continue;
            candidates.push(node);
        }

        // Этап 2: первичный отбор
        const matched = [];
        for (const node of candidates) {
            const depth = useGlobalDepth ? globalDepth : (node.scanDepth ?? 0);
            const recentMessages = depth > 0 ? chatMessages.slice(-depth) : chatMessages;
            const texts = recentMessages.map(m => m.message || '').join('\n');

            const caseSensitive = node.caseSensitive ?? false;
            const wholeWords = node.wholeWords ?? false;

            if (evaluateNodeTrigger(node, texts, caseSensitive, wholeWords)) {
                matched.push({ node, depth: node.recursionDepth ?? 0 });
            }
        }

        // Этап 3: рекурсивное расширение
        const processedIds = new Set(matched.map(m => m.node.id));
        const queue = matched.filter(m => m.depth > 0).map(m => ({ node: m.node, depth: m.depth }));

        while (queue.length > 0) {
            const { node: parentNode, depth: parentDepth } = queue.shift();
            if (parentDepth <= 0) continue;

            const text = parentNode.description || '';
            if (!text) continue;

            for (const candidateNode of allNodes) {
                if (candidateNode.alwaysActive) continue;
                if (processedIds.has(candidateNode.id)) continue;

                const caseSensitive = candidateNode.caseSensitive ?? false;
                const wholeWords = candidateNode.wholeWords ?? false;

                if (evaluateNodeTrigger(candidateNode, text, caseSensitive, wholeWords)) {
                    const newDepth = parentDepth - 1;
                    matched.push({ node: candidateNode, depth: newDepth });
                    processedIds.add(candidateNode.id);
                    if (newDepth > 0) {
                        queue.push({ node: candidateNode, depth: newDepth });
                    }
                }
            }
        }

        candidates = matched.map(m => m.node);

        // Этап 4: групповой отбор
        const groups = {};
        const ungrouped = [];
        for (const node of candidates) {
            if (node.groups && node.groups.length > 0) {
                for (const g of node.groups) {
                    if (!groups[g]) groups[g] = [];
                    groups[g].push(node);
                }
            } else {
                ungrouped.push(node);
            }
        }

        let finalNodes = [...ungrouped];
        for (const [groupName, groupNodes] of Object.entries(groups)) {
            const totalWeight = groupNodes.reduce((sum, n) => sum + (n.weight || 100), 0);
            let rand = Math.random() * totalWeight;
            for (const n of groupNodes) {
                rand -= (n.weight || 100);
                if (rand <= 0) {
                    if (!finalNodes.includes(n)) finalNodes.push(n);
                    break;
                }
            }
        }

        // Этап 5: сортировка по priority
        finalNodes.sort((a, b) => (b.priority || 10) - (a.priority || 10));

//        console.log('До второй проверки шанса:', finalNodes.map(n => ({ label: n.label || n.id, chance: n.chance })));

        // Этап 5.1: вторая проверка шанса для узлов с chance > 100
        finalNodes = finalNodes.filter(node => {
            const chance = node.chance ?? 100;
            if (chance > 100) {
                const secondChance = chance - 100;
                const passed = Math.random() * 100 <= secondChance;
//                console.log(`Вторая проверка для "${node.label || node.id}" (chance=${chance}): ${passed ? 'прошёл' : 'отсеян'}`);
                return passed;
            }
            return true;
        });

        console.log(t('le_activated'), finalNodes.map(n => n.label || n.id));

//        console.log('После второй проверки шанса:', finalNodes.map(n => n.label || n.id));

        // Этап 6: распределение по целям
        const injections = {
            llm: [],
            proxy: [],
            summary: [],
            user_appearance: [],
            prefill: [],
            last_message: []
        };
        let totalLength = 0;
        const maxLen = maxInjectLength;

        for (const node of finalNodes) {
            if (!node.insertTarget) {
                logError(t('le_no_target', { label: node.label || node.id }));
                continue;
            }
            const pos = node.insertPosition || 'start';
            let content = node.description || '';
            if (!content) continue;

            if (node.insertTarget === 'last_message' && node.oocInstruction) {
                content = `(OOC: ${content})`;
            }

            let allowedContent = content;
            if (totalLength + content.length > maxLen) {
                if (limitBehavior === 'skip') continue;
                else if (limitBehavior === 'trim') {
                    const remaining = maxLen - totalLength;
                    if (remaining <= 0) continue;
                    allowedContent = content.substring(0, remaining);
                }
            }

            totalLength += allowedContent.length;

            switch (node.insertTarget) {
                case 'llm':
                    injections.llm.push({ pos, content: allowedContent });
                    break;
                case 'last_message':
                    injections.last_message.push({ pos, content: allowedContent });
                    break;
                case 'summary':
                    injections.summary.push({ pos, content: allowedContent });
                    break;
                case 'user_appearance':
                    injections.user_appearance.push({ pos, content: allowedContent });
                    break;
                case 'prefill':
                    injections.prefill.push({ pos, content: allowedContent });
                    break;
            }
        }

//        console.log('[LB DEBUG] lastMessage инъекции:', JSON.stringify(injections.last_message));

        return {
            llm: injections.llm,
            proxy: injections.llm,
            summary: injections.summary,
            user_appearance: injections.user_appearance,
            prefill: injections.prefill,
            lastMessage: injections.last_message
        };
    } catch (e) {
        logError('Ошибка генерации инъекций: ' + e.message);
        return null;
    }
}

    // ----- 11. ПЕРЕХВАТЧИК XHR /messages (для last_message при NEW) -----
    const origXHROpen = XMLHttpRequest.prototype.open;
    const origXHRSend = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.open = function(method, url, ...rest) {
        this._lbMethod = method;
        this._lbUrl = url;
        return origXHROpen.call(this, method, url, ...rest);
    };
    XMLHttpRequest.prototype.send = function(body) {
        if (this._lbMethod === 'POST' && this._lbUrl && this._lbUrl.includes('/messages')) {
            this.addEventListener('load', function() {
                if (this.status >= 200 && this.status < 300 && pendingLastMessageInjection) {
                    try {
                        const data = JSON.parse(this.responseText);
                        if (Array.isArray(data)) {
                            let modified = false;
                            let messageId = null;
                            let originalText = null;

                            for (const serverMsg of data) {
                                if (serverMsg && serverMsg.is_bot === false && typeof serverMsg.message === 'string') {
                                    messageId = serverMsg.id;
                                    originalText = serverMsg.message;

                                    // Сохраняем исходный текст для последующего восстановления
                                    lastMessageOriginalText = originalText;
                                    lastMessageRestore = { id: messageId, originalText: originalText };

                                    // Заменяем текст сообщения на сериализованные сырые инъекции
                                    serverMsg.message = JSON.stringify(pendingLastMessageInjection);
                                    modified = true;
                                }
                            }

                            if (modified) {
                                const modifiedResponse = JSON.stringify(data);
                                Object.defineProperty(this, 'responseText', {
                                    get() { return modifiedResponse; },
                                    configurable: true
                                });
                                console.log('[LB] Ответ /messages подменён: вставлены сырые инъекции last_message.');
                            }
                        }
                    } catch (e) {
                        console.warn('[LB] Ошибка при модификации ответа /messages:', e);
                    }
                }
            });
        }
        return origXHRSend.call(this, body);
    };

    // ----- 12. ПЕРЕХВАТЧИК WEBSOCKET (обработка last_message и восстановление) -----
    const origWsSend = WebSocket.prototype.send;
    WebSocket.prototype.send = function(data) {
        if (isInjecting && this.url && this.url.includes('/generateAlpha')) {
            // Если есть сырые инъекции last_message, обрабатываем их в WebSocket
            if (pendingLastMessageInjection && lastMessageOriginalText) {
                try {
                    if (typeof data === 'string') {
                        const body = JSON.parse(data);
                        if (Array.isArray(body.chatMessages)) {
                            // Находим последнее сообщение пользователя
                            for (let i = body.chatMessages.length - 1; i >= 0; i--) {
                                const msg = body.chatMessages[i];
                                const target = Array.isArray(msg) ? msg[msg.length - 1] : msg;
                                if (target && target.is_bot === false) {
                                    // Заменяем сырые инъекции на итоговую строку
                                    target.message = applyInjectionsToString(lastMessageOriginalText, pendingLastMessageInjection);
                                    break;
                                }
                            }
                        }
                        data = JSON.stringify(body);
                    }
                } catch (e) {
                    console.warn('[LB] Ошибка при обработке WebSocket last_message:', e);
                }
                // Очищаем временные данные
                pendingLastMessageInjection = null;
                lastMessageOriginalText = null;
            }

            // Восстановление статических полей
            if (injectionOriginals) {
                const stores = getReactStores();
                if (stores) {
                    const { config, chatInfo, userStore } = stores;
                    if (config) {
                        config.llm_prompt = injectionOriginals.llm;
                        config.proxy_global_prompt = injectionOriginals.proxy;
                        if (config.generation_settings) {
                            config.generation_settings.prefill_text = injectionOriginals.prefill_text;
                            config.generation_settings.prefill_enabled = injectionOriginals.prefill_enabled;
                        }
                    }
                    if (chatInfo?.chat) {
                        chatInfo.chat.summary = injectionOriginals.summary;
                    }
                    if (userStore?.profile) {
                        userStore.profile.profile = injectionOriginals.user_appearance;
                    }
                }
                injectionOriginals = null;
            }

            // Восстановление последнего сообщения пользователя в React
            if (lastMessageRestore) {
                const stores = getReactStores();
                if (stores && stores.chatStore && stores.chatStore.messagesStore) {
                    const ms = stores.chatStore.messagesStore;
                    const findMsg = (arr, id) => {
                        for (let i = arr.length - 1; i >= 0; i--) {
                            const item = arr[i];
                            if (Array.isArray(item)) {
                                for (const alt of item) if (alt.id === id) return alt;
                            } else if (item.id === id) return item;
                        }
                        return null;
                    };
                    const msg = findMsg(ms.messages, lastMessageRestore.id);
                    if (msg) msg.message = lastMessageRestore.originalText;
                }
                lastMessageRestore = null;
            }

            // Сброс таймера
            if (injectionResetTimer) {
                clearTimeout(injectionResetTimer);
                injectionResetTimer = null;
            }
            isInjecting = false;
            console.log('[LB] Инъекция завершена, оригиналы восстановлены.');
        }
        return origWsSend.call(this, data);
    };

    // Глобальные переменные для хранения оригиналов статических полей
    let injectionOriginals = null;

    // ----- 13. ИНЪЕКЦИЯ В REACT -----
    async function injectIntoReact(btn, mode = 'NEW') {
        if (paused || !active || !currentChatId || isInjecting) {
            if (isInjecting) console.warn('[LB] Инъекция уже активна, пропускаем клик');
            btn.click();
            return;
        }

        pendingLastMessageInjection = null;
        lastMessageOriginalText = null;
        lastMessageRestore = null;


        const textarea = document.querySelector(TEXTAREA_SELECTOR);
        const userText = textarea ? textarea.value.trim() : '';

        // Получаем React-сторы
        const stores = getReactStores();
        if (!stores) {
            logError('Не удалось получить React-стора');
            btn.click();
            return;
        }

        // Собираем сообщения из React
        let chatMessages = getMessagesFromReact(stores);

        // Для перегенерации исключаем последнее сообщение бота
        if (mode === 'ALTERNATIVE' && chatMessages.length > 0) {
            chatMessages.pop();
        }

        // Для обычной отправки добавляем текущее сообщение пользователя
        if (mode === 'NEW' && userText) {
            chatMessages.push({ is_bot: false, message: userText });
        }

        const injections = await generateInjections(chatMessages, userText);
        if (!injections) {
            btn.click();
            return;
        }

        const { config, chatInfo, userStore } = stores;

        // Сохраняем оригиналы статических полей
        injectionOriginals = {
            llm: config.llm_prompt,
            proxy: config.proxy_global_prompt,
            summary: chatInfo?.chat?.summary,
            user_appearance: userStore.profile?.profile,
            prefill_text: config.generation_settings?.prefill_text,
            prefill_enabled: config.generation_settings?.prefill_enabled
        };

        // Применяем инъекции к статическим полям
        if (injections.llm && injections.llm.length > 0) {
            config.llm_prompt = applyInjectionsToString(config.llm_prompt, injections.llm);
            config.proxy_global_prompt = applyInjectionsToString(config.proxy_global_prompt, injections.llm);
        }
        if (injections.summary && injections.summary.length > 0 && chatInfo?.chat) {
            chatInfo.chat.summary = applyInjectionsToString(chatInfo.chat.summary, injections.summary);
        }
        if (injections.user_appearance && injections.user_appearance.length > 0 && userStore.profile) {
            userStore.profile.profile = applyInjectionsToString(userStore.profile.profile, injections.user_appearance);
        }
        if (injections.prefill && injections.prefill.length > 0 && config.generation_settings) {
            config.generation_settings.prefill_text = applyInjectionsToString(config.generation_settings.prefill_text, injections.prefill);
        }

        // Обработка last_message
        if (injections.lastMessage && injections.lastMessage.length > 0) {
            if (mode === 'NEW') {
                // Откладываем до ответа XHR /messages
                pendingLastMessageInjection = injections.lastMessage;
                lastMessageOriginalText = null;
            } else {
                // CONTINUE, ALTERNATIVE, RETRY
                const target = findLastUserMessageObject(stores);
                if (target) {
                    const original = target.message;
                    const modified = applyInjectionsToString(original, injections.lastMessage);
                    lastMessageRestore = { id: target.id, originalText: original };
                    target.message = modified;
//                    console.log('[LB DEBUG] last_message инъекция в React для mode=', mode, ':', target.message);
                }
            }
        }

        isInjecting = true;
        // Страховочный сброс через 5 секунд
        if (injectionResetTimer) clearTimeout(injectionResetTimer);
        injectionResetTimer = setTimeout(() => {
            if (isInjecting) {
                console.warn('[LB] Принудительный сброс isInjecting (WebSocket не перехвачен)');
                // Восстановление статических полей
                if (injectionOriginals) {
                    const stores = getReactStores();
                    if (stores) {
                        const { config, chatInfo, userStore } = stores;
                        if (config) {
                            config.llm_prompt = injectionOriginals.llm;
                            config.proxy_global_prompt = injectionOriginals.proxy;
                            if (config.generation_settings) {
                                config.generation_settings.prefill_text = injectionOriginals.prefill_text;
                                config.generation_settings.prefill_enabled = injectionOriginals.prefill_enabled;
                            }
                        }
                        if (chatInfo?.chat) {
                            chatInfo.chat.summary = injectionOriginals.summary;
                        }
                        if (userStore?.profile) {
                            userStore.profile.profile = injectionOriginals.user_appearance;
                        }
                    }
                    injectionOriginals = null;
                }
                // Восстановление последнего сообщения
                if (lastMessageRestore) {
                    const stores = getReactStores();
                    if (stores && stores.chatStore && stores.chatStore.messagesStore) {
                        const ms = stores.chatStore.messagesStore;
                        const findMsg = (arr, id) => {
                            for (let i = arr.length - 1; i >= 0; i--) {
                                const item = arr[i];
                                if (Array.isArray(item)) {
                                    for (const alt of item) if (alt.id === id) return alt;
                                } else if (item.id === id) return item;
                            }
                            return null;
                        };
                        const msg = findMsg(ms.messages, lastMessageRestore.id);
                        if (msg) msg.message = lastMessageRestore.originalText;
                    }
                    lastMessageRestore = null;
                }
                pendingLastMessageInjection = null;
                lastMessageOriginalText = null;
                isInjecting = false;
                injectionResetTimer = null;
            }
        }, 5000);

        // Программный клик
        btn.click();
    }

    // ----- 14. ЯВНАЯ УСТАНОВКА ОБРАБОТЧИКОВ -----
    function isRegenerateCreatingNew(stores) {
        const chatStore = stores.chatStore;
        if (!chatStore || !chatStore.messagesStore) return true;
        const messagesStore = chatStore.messagesStore;
        const messages = messagesStore.messages;
        if (!Array.isArray(messages) || messages.length === 0) return true;
        const lastItem = messages[messages.length - 1];
        if (!Array.isArray(lastItem)) return true;
        const totalAlternatives = lastItem.length;
        const currentIndex = messagesStore.lastMessageIndex;
        return currentIndex >= totalAlternatives - 1;
    }

    function attachButtonHandlers() {
        // Обычная отправка
        document.querySelectorAll(SEND_BTN_SELECTOR).forEach(btn => {
            if (!btn._lbSendHandlerAttached) {
                const hasIcon = btn.querySelector('svg path[d^="M34.9 289.5"]') !== null;
                if (!hasIcon) return;
                btn._lbSendHandlerAttached = true;
                btn.addEventListener('click', (e) => {
                    if (btn.classList.contains('_stopButton_1bz7u_1') || btn.querySelector('svg path[d^="M400 32H48"]') !== null) return;
                    if (!e.isTrusted || !active || paused || isInjecting) return;
                    e.preventDefault();
                    e.stopImmediatePropagation();
                    injectIntoReact(btn, 'NEW');
                });
            }
        });

        // Перегенерация
        document.querySelectorAll(REGEN_SEL).forEach(wrapper => {
            const btn = wrapper.querySelector('button');
            if (btn && !btn._lbRegenHandlerAttached) {
                btn._lbRegenHandlerAttached = true;
                btn.addEventListener('click', (e) => {
                    if (!e.isTrusted || !active || paused || isInjecting) return;
                    const stores = getReactStores();
                    if (stores && !isRegenerateCreatingNew(stores)) {
                        console.log('[LB] Листание генераций, инъекция не требуется');
                        return;
                    }
                    e.preventDefault();
                    e.stopImmediatePropagation();
                    injectIntoReact(btn, 'ALTERNATIVE');
                });
            }
        });

        // Продолжение
        document.querySelectorAll(CONTINUE_SEL).forEach(btn => {
            if (btn._lbContinueHandlerAttached) return;
            const hasIcon = btn.querySelector('svg path[d^="M500.5 231.4l-192-160"]') !== null;
            if (!hasIcon) return;
            btn._lbContinueHandlerAttached = true;
            btn.addEventListener('click', (e) => {
                if (!e.isTrusted || !active || paused || isInjecting) return;
                e.preventDefault();
                e.stopImmediatePropagation();
                injectIntoReact(btn, 'CONTINUE');
            });
        });

        // Retry (Заново)
        document.querySelectorAll(RETRY_SEL).forEach(btn => {
            if (btn._lbRetryHandlerAttached) return;
            const hasIcon = btn.querySelector('svg path[d^="M256 48C141.6"]') !== null;
            if (!hasIcon) return;
            btn._lbRetryHandlerAttached = true;
            btn.addEventListener('click', (e) => {
                if (!e.isTrusted || !active || paused || isInjecting) return;
                e.preventDefault();
                e.stopImmediatePropagation();
                injectIntoReact(btn, 'RETRY');
            });
        });

        // Enter
        attachTextareaHandler();
    }

    function attachTextareaHandler() {
        const textarea = document.querySelector(TEXTAREA_SELECTOR);
        if (textarea && !textarea._lbEnterHandlerAttached) {
            textarea._lbEnterHandlerAttached = true;
            textarea.addEventListener('keydown', (e) => {
                if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
                if (!active || paused || isInjecting) return;
                if (textarea.value.trim() === '') return;
                const sendBtn = document.querySelector(SEND_BTN_SELECTOR);
                if (!sendBtn) return;
                if (sendBtn.disabled) return;
                e.preventDefault();
                e.stopPropagation();
                injectIntoReact(sendBtn, 'NEW');
            });
        }
    }

    // Мутационный наблюдатель
    const observer = new MutationObserver(() => {
        attachButtonHandlers();
    });
    observer.observe(document.body, { childList: true, subtree: true });

    attachButtonHandlers();

    // ----- 15. РЕГИСТРАЦИЯ МОДУЛЯ -----
    window.__MANAGER__.register(MODULE_ID, {
        title: t('le_title'),
        type: 'interface',
        requires: ['__LOREGRAPH__'],
        content: getContent,
        onActivate: () => bindWidgetEvents(),
        onEnable: async (chatId) => {
            active = true;
            currentChatId = chatId;
            await loadSettings();
            attachButtonHandlers();
        },
        onDisable: () => {
            active = false;
            currentChatId = null;
            if (injectionResetTimer) {
                clearTimeout(injectionResetTimer);
                injectionResetTimer = null;
            }
            isInjecting = false;
            injectionOriginals = null;
            pendingLastMessageInjection = null;
            lastMessageRestore = null;
            lastMessageOriginalText = null;
        }
    });

    // Обновление языка
    window.addEventListener('languageChanged', () => {
        if (window.__MANAGER__) {
            window.__MANAGER__.update(MODULE_ID, getContent());
            window.__MANAGER__.setModuleTitle(MODULE_ID, t('le_title'));
            bindWidgetEvents();
        }
    });

    console.log('[LB] Модуль Lorebook Engine зарегистрирован');
})();