☰

Lorebook Engine

Classic lorebook engine for LoreCanvas. Reads active lorebooks, finds entries by keywords, and sends them to the prompt through Injection Manager. Supports triggers, groups, priorities, scan depth, and OOC.

נכון ליום 25-10-2026. ראה הגרסה האחרונה.

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Lorebook Engine
// @namespace    Violentmonkey Scripts
// @version      4.2
// @author       Glazanochi
// @description     Classic lorebook engine for LoreCanvas. Reads active lorebooks, finds entries by keywords, and sends them to the prompt through Injection Manager. Supports triggers, groups, priorities, scan depth, and OOC.
// @description:ru  Классический движок лорбуков для LoreCanvas. Читает активные лорбуки, ищет записи по ключевым словам и отправляет их в промпт через Injection Manager. Поддерживает триггеры, группы, приоритеты, глубину сканирования и OOC.
// @match        https://janitorai.com/*
// @grant        none
// @order        4
// ==/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 TEXTAREA_SELECTOR = 'textarea._chatTextarea_1e2lg_1';

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

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

    // Переменная для отмены регистрации генератора
    let generatorUnregister = null;

    // ----- 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]?.en || 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-СТОРА -----
    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;
    }

    // ----- 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'));

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

// Игнорируем узлы, помеченные как LoreGraph (loregraph === true)
allNodes = allNodes.filter(node => !node.loregraph);

            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()));

            let candidates = [];
            for (const node of allNodes) {
                if (Math.random() * 100 > (node.chance ?? 100)) continue;
                candidates.push(node);
            }

            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 });
                }
            }

            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);

            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;
                    }
                }
            }

            finalNodes.sort((a, b) => (b.priority || 10) - (a.priority || 10));

            finalNodes = finalNodes.filter(node => {
                const chance = node.chance ?? 100;
                if (chance > 100) {
                    const secondChance = chance - 100;
                    const passed = Math.random() * 100 <= secondChance;
                    return passed;
                }
                return true;
            });

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

            const injections = {
                llm: [],
                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;
                }
            }

            return injections;
        } catch (e) {
            logError('Ошибка генерации инъекций: ' + e.message);
            return null;
        }
    }

    // ----- 11. ГЕНЕРАТОР ДЛЯ ИНЪЕКТОРА -----
    async function generator(mode) {
        if (!active || paused || !window.__INJECTOR__) return [];

        const stores = getReactStores();
        if (!stores) return [];

        let chatMessages = getMessagesFromReact(stores);
        const textarea = document.querySelector(TEXTAREA_SELECTOR);
        const userText = (mode === 'NEW') ? (textarea ? textarea.value.trim() : '') : '';

        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) return [];

        const flat = [];

        for (const inj of injections.llm) {
            flat.push({ target: 'llm', position: inj.pos, content: inj.content });
        }
        for (const inj of injections.summary) {
            flat.push({ target: 'summary', position: inj.pos, content: inj.content });
        }
        for (const inj of injections.user_appearance) {
            flat.push({ target: 'user_appearance', position: inj.pos, content: inj.content });
        }
        for (const inj of injections.prefill) {
            flat.push({ target: 'prefill', position: inj.pos, content: inj.content });
        }
        for (const inj of injections.last_message) {
            flat.push({ target: 'last_message', position: inj.pos, content: inj.content });
        }

        return flat;
    }

    // ----- 12. РЕГИСТРАЦИЯ МОДУЛЯ -----
    window.__MANAGER__.register(MODULE_ID, {
        title: t('le_title'),
        type: 'interface',
        requires: ['__LOREGRAPH__', '__INJECTOR__'],
        content: getContent,
        onActivate: () => bindWidgetEvents(),
        onEnable: async (chatId) => {
            if (active && currentChatId === chatId) return;
            if (active) {
                active = false;
                currentChatId = null;
                if (generatorUnregister) {
                    generatorUnregister();
                    generatorUnregister = null;
                }
            }
            active = true;
            currentChatId = chatId;
            await loadSettings();
            if (window.__INJECTOR__) {
                if (generatorUnregister) generatorUnregister();
                generatorUnregister = window.__INJECTOR__.registerGenerator(generator);
            }
        },
        onDisable: () => {
            active = false;
            currentChatId = null;
            if (generatorUnregister) {
                generatorUnregister();
                generatorUnregister = 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 зарегистрирован');
})();