Sleazy Fork is available in English.

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.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

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

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==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 зарегистрирован');
})();