Module Dispatcher

Central UI for managing LoreCanvas modules. Provides a shared panel, per-chat module settings, and IndexedDB storage. External script for JanitorAI.

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.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

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         Module Dispatcher
// @namespace    Violentmonkey Scripts
// @version      4.5
// @match        https://janitorai.com/*
// @grant        none
// @author       Glazanochi
// @description  Central UI for managing LoreCanvas modules. Provides a shared panel, per-chat module settings, and IndexedDB storage. External script for JanitorAI.
// ==/UserScript==

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

(function() {
    'use strict';

    // ============================================================
    // 0. ЯДРО IndexedDB (безопасное управление версиями)
    // ============================================================
    const DB_NAME = 'JanitorModulesDB';

    const BASE_STORES = {
        dispatcher: { keyPath: 'key' },
        chats_data: { keyPath: 'key' }
    };

    const registeredStores = { ...BASE_STORES };

    let dbInstance = null;
    let openPromise = null;

    function registerStore(storeName, keyPath) {
        if (registeredStores[storeName]) {
            console.warn(`Хранилище "${storeName}" уже зарегистрировано.`);
            return;
        }
        registeredStores[storeName] = { keyPath };
        console.log(`📌 Зарегистрировано хранилище "${storeName}"`);
    }

    async function openDB() {
        if (dbInstance) return dbInstance;
        if (openPromise) return openPromise;

        openPromise = (async () => {
            const databases = await indexedDB.databases();
            const currentDB = databases.find(db => db.name === DB_NAME);
            const currentVersion = currentDB ? currentDB.version : 0;

            if (currentVersion === 0) {
                return await createOrUpgrade(1);
            }

            dbInstance = await new Promise((resolve, reject) => {
                const request = indexedDB.open(DB_NAME, currentVersion);
                request.onsuccess = () => resolve(request.result);
                request.onerror = () => reject(request.error);
                request.onblocked = () => reject(new Error('Database blocked'));
            });

            const missingStores = Object.keys(registeredStores).filter(
                name => !dbInstance.objectStoreNames.contains(name)
            );

            if (missingStores.length > 0) {
                dbInstance.close();
                dbInstance = null;
                const newVersion = currentVersion + 1;
                return await createOrUpgrade(newVersion);
            }

            return dbInstance;
        })();

        return openPromise;
    }

    function createOrUpgrade(version) {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(DB_NAME, version);

            request.onupgradeneeded = (event) => {
                const db = event.target.result;
                for (const [name, config] of Object.entries(registeredStores)) {
                    if (!db.objectStoreNames.contains(name)) {
                        db.createObjectStore(name, { keyPath: config.keyPath });
                        console.log(`🆕 Создано хранилище "${name}"`);
                    }
                }
            };

            request.onsuccess = () => {
                dbInstance = request.result;
                console.log('✅ IndexedDB открыта (версия ' + version + ')');
                resolve(dbInstance);
            };

            request.onerror = () => reject(request.error);
            request.onblocked = () => reject(new Error('Database blocked'));
        });
    }

    async function getStore(storeName, mode = 'readonly') {
        const db = await openDB();
        const tx = db.transaction(storeName, mode);
        return tx.objectStore(storeName);
    }

    async function dbGet(storeName, key) {
        const store = await getStore(storeName);
        return new Promise((resolve, reject) => {
            const req = store.get(key);
            req.onsuccess = () => resolve(req.result ? req.result.value : undefined);
            req.onerror = () => reject(req.error);
        });
    }

    async function dbSet(storeName, key, value) {
        const store = await getStore(storeName, 'readwrite');
        return new Promise((resolve, reject) => {
            const req = store.put({ key, value });
            req.onsuccess = () => resolve();
            req.onerror = () => reject(req.error);
        });
    }

    async function dbDelete(storeName, key) {
        const store = await getStore(storeName, 'readwrite');
        return new Promise((resolve, reject) => {
            const req = store.delete(key);
            req.onsuccess = () => resolve();
            req.onerror = () => reject(req.error);
        });
    }

    async function dbGetAllKeys(storeName) {
        const store = await getStore(storeName);
        return new Promise((resolve, reject) => {
            const req = store.getAllKeys();
            req.onsuccess = () => resolve(req.result);
            req.onerror = () => reject(req.error);
        });
    }

    async function dbGetAll(storeName) {
        const store = await getStore(storeName);
        return new Promise((resolve, reject) => {
            const req = store.getAll();
            req.onsuccess = () => resolve(req.result);
            req.onerror = () => reject(req.error);
        });
    }

    async function getDispatcherData(key) { return await dbGet('dispatcher', key); }
    async function setDispatcherData(key, value) { await dbSet('dispatcher', key, value); }
    async function removeDispatcherData(key) { await dbDelete('dispatcher', key); }

    async function getChatData(chatId) { return await dbGet('chats_data', chatId); }
    async function setChatData(chatId, value) { await dbSet('chats_data', chatId, value); }
    async function removeChatData(chatId) { await dbDelete('chats_data', chatId); }

    async function updateChatField(chatId, field, value) {
        const record = await getChatData(chatId);
        if (!record) throw new Error('Chat not found');
        record[field] = value;
        await setChatData(chatId, record);
    }

    async function getModuleSettings(chatId, moduleId) {
        const record = await getChatData(chatId);
        if (!record || !record.moduleSettings) return {};
        return record.moduleSettings[moduleId] || {};
    }

    async function setModuleSettings(chatId, moduleId, settings) {
        const record = await getChatData(chatId);
        if (!record) throw new Error('Chat not found');
        if (!record.moduleSettings) record.moduleSettings = {};
        record.moduleSettings[moduleId] = settings;
        await setChatData(chatId, record);
    }

    window.__DB__ = {
        registerStore,
        openDB,
        getDispatcherData,
        setDispatcherData,
        removeDispatcherData,
        getChatData,
        setChatData,
        removeChatData,
        getModuleSettings,
        setModuleSettings,
        updateChatField,
        dbGet,
        dbSet,
        dbDelete,
        dbGetAllKeys,
        dbGetAll,
        getStore
    };

    // ============================================================
    // 1. ЛОКАЛИЗАЦИЯ
    // ============================================================
    const L10N = {
        currentLang: (navigator.language || 'en').startsWith('ru') ? 'ru' : 'en',

        strings: {
            'dispatcher_title': { ru: 'Диспетчер модулей', en: 'Module Dispatcher' },
            'dispatcher_help': {
                ru: '📦 Каждый модуль можно включить или выключить чекбоксом.\n▶ Нажмите на название модуля, чтобы раскрыть его настройки.\n📋 Кнопка «Чаты» показывает все сохранённые чаты и позволяет быстро переключаться между ними.\n🌎 Переключает язык интерфейса.\n🔄 Обновить страницу.\n▶️ Минимизировать панель Диспетчера.',
                en: '📦 Each module can be turned on/off with the checkbox.\n▶ Click on the module name to expand its settings.\n📋 The «Chats» button shows all saved chats and allows quick switching.\n🌎 Switches the interface language.\n🔄 Reload page.\n▶️ Minimize the Dispatcher panel.'
            },
            'no_modules': { ru: 'Нет активных модулей', en: 'No active modules' },
            'collapse_all': { ru: 'Свернуть всё', en: 'Collapse all' },
            'lang_switch': { ru: '🌎 EN', en: '🌍 RU' },
            'log_title': { ru: '📋 Лог ошибок', en: '📋 Error log' },
            'log_clear': { ru: 'Очистить', en: 'Clear' },
            'log_empty': { ru: 'Лог пуст', en: 'Log is empty' },

            'chats_title': { ru: '📋 Чаты', en: '📋 Chats' },
            'chats_tooltip': { ru: 'Управление чатами', en: 'Chat management' },
            'chats_help': {
                ru: '📋 Здесь показаны все чаты, в которых вы были с активным диспетчером.\n🗑️ Кнопка «Очистить все» удаляет все сохранения чатов.\n🗑️ Корзина у чата удаляет только этот чат.\n🔗 Нажмите на имя чата, чтобы перейти в него.\n⚠️ Удаление чата здесь не удаляет его с сайта - а лишь удаляет из БД настройки модулей, привязаные к этому чату.',
                en: '📋 Here are all chats you visited with the dispatcher active.\n🗑️ «Clear all» button deletes all chat saves.\n🗑️ The trash icon deletes only that chat.\n🔗 Click on chat name to navigate to it.\n⚠️ Deleting a chat here does not delete it from the site - it only deletes the module settings associated with this chat from the database.'
            },
            'lang_tooltip': { ru: 'Сменить язык', en: 'Switch language' },
            'collapse_tooltip': { ru: 'Свернуть всё', en: 'Collapse all' },
            'reload_tooltip': { ru: 'Обновить страницу', en: 'Reload page' },

            'chats_hint': { ru: 'Удалить настройки чатов в Диспетчере модулей. Это НЕ удаляет чат с сайта. Используйте, чтобы очистить память от несуществующих чатов, которые вы удалили на сайте.', en: 'Delete chat settings in Module Dispatcher. This does NOT delete the chat from the site. Use to clear memory of non-existent chats you have deleted on the site.' },
            'chats_empty': { ru: 'Нет сохранённых чатов', en: 'No saved chats' },
            'chats_delete_all': { ru: 'Очистить все', en: 'Clear all' },
            'chats_delete_confirm': { ru: 'Удалить все сохранения чатов?', en: 'Delete all chat saves?' },
            'chats_delete_single': { ru: 'Удалить', en: 'Delete' },
            'chats_created': { ru: 'Создан', en: 'Created' },
            'chats_last_used': { ru: 'Последний вход', en: 'Last used' },
            'chats_modules_active': { ru: 'Активные модули', en: 'Active modules' },
            'chats_unknown': { ru: 'Без названия', en: 'Untitled' },
            'chats_participants': { ru: 'Участники', en: 'Participants' },
            'chats_no_participants': { ru: '—', en: '—' },
            'dialog_cancel': { ru: 'Закрыть', en: 'Close' },
            'dialog_ok': { ru: 'OK', en: 'OK' },
            'confirm_delete_chat': { ru: 'Удалить чат «{name}»?', en: 'Delete chat «{name}»?' },
            'confirm_delete_all_chats': { ru: 'Удалить все сохранения чатов?', en: 'Delete all chat saves?' },
        },

        t(key, vars) {
            const lang = this.currentLang;
            let str = this.strings[key]?.[lang] || this.strings[key]?.en || key;
            if (vars) str = str.replace(/{([^}]+)}/g, (_, p) => vars[p] !== undefined ? vars[p] : '');
            return str;
        },

        async setLang(lang) {
            if (lang === 'ru' || lang === 'en') {
                this.currentLang = lang;
                await setDispatcherData('lang', lang);
                if (window.__MANAGER__ && window.__MANAGER__._updateUI) {
                    window.__MANAGER__._updateUI();
                }
                window.dispatchEvent(new CustomEvent('languageChanged', { detail: { lang } }));
            }
        }
    };

    window.__L10N__ = L10N;

    // ============================================================
    // 2. ДИСПЕТЧЕР
    // ============================================================
    (function setupDispatcher() {
        if (window.__MANAGER__) return;

        const modules = {};
        let moduleOrder = [];
        let logEntries = [];
        let panelElement = null;
        let containerElement = null;
        let logContent = null;
        let logIndicator = null;
        let logExpanded = false;

        let chatData = {};
        let currentChatId = null;
        let lastCheckedUrl = '';
        let metadataInterval = null;

        let panelCollapsed = false;
        let toggleButton = null;

        const hooks = {};

        // --- Вспомогательный диалог подтверждения ---
        function showConfirmDialog(title, message, zIndex = 10000010) {
            return new Promise(resolve => {
                const overlay = document.createElement('div');
                overlay.style.cssText = `
                    position: fixed;
                    top: 0; left: 0; width: 100%; height: 100%;
                    background: rgba(0,0,0,0.6);
                    backdrop-filter: blur(4px);
                    z-index: ${zIndex};
                    display: flex;
                    justify-content: center;
                    align-items: center;
                    font-family: sans-serif;
                `;

                const box = document.createElement('div');
                box.style.cssText = `
                    background: #1e1e2e;
                    padding: 20px;
                    border-radius: 16px;
                    border: 2px solid #cba6f7;
                    max-width: 380px;
                    width: 90%;
                    color: #cdd6f4;
                    box-shadow: 0 8px 32px rgba(0,0,0,0.9);
                    text-align: center;
                `;

                if (title) {
                    const h = document.createElement('h3');
                    h.style.cssText = 'margin:0 0 12px 0; color:#cba6f7; font-size:18px;';
                    h.textContent = title;
                    box.appendChild(h);
                }
                if (message) {
                    const p = document.createElement('div');
                    p.style.cssText = 'margin-bottom:16px; font-size:14px; line-height:1.4;';
                    p.textContent = message;
                    box.appendChild(p);
                }

                const btnRow = document.createElement('div');
                btnRow.style.cssText = 'display:flex; justify-content:center; gap:12px;';
                const okBtn = document.createElement('button');
                okBtn.textContent = L10N.t('dialog_ok');
                okBtn.style.cssText = 'background:#a6e3a1;color:#111;border:none;border-radius:6px;padding:6px 24px;font-weight:bold;cursor:pointer;';
                okBtn.addEventListener('click', () => { overlay.remove(); resolve(true); });
                const cancelBtn = document.createElement('button');
                cancelBtn.textContent = L10N.t('dialog_cancel');
                cancelBtn.style.cssText = 'background:#f38ba8;color:#111;border:none;border-radius:6px;padding:6px 24px;font-weight:bold;cursor:pointer;';
                cancelBtn.addEventListener('click', () => { overlay.remove(); resolve(false); });
                btnRow.appendChild(cancelBtn);
                btnRow.appendChild(okBtn);
                box.appendChild(btnRow);

                overlay.appendChild(box);
                document.body.appendChild(overlay);
                overlay.addEventListener('click', (e) => { if (e.target === overlay) { overlay.remove(); resolve(false); } });
            });
        }

        // --- Работа с чатами ---
        function getChatIdFromURL() {
            const m = location.pathname.match(/\/chats\/(\d+)/);
            return m ? m[1] : null;
        }

        function getChatNameFromPage() {
            const title = document.title || '';
            const name = title.replace(/\s*[-|–]\s*JanitorAI\s*$/i, '').trim();
            if (!document.querySelector('textarea._chatTextarea_1e2lg_1')) return null;
            return name || null;
        }

function getParticipantsFromReact() {
    const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
    if (!hook) return [];

    const renderer = Array.from(hook.renderers.values())[0];
    if (!renderer) return [];

    const textarea = document.querySelector('textarea._chatTextarea_1e2lg_1');
    if (!textarea) return [];

    let fiber = renderer.findFiberByHostInstance(textarea);
    while (fiber) {
        const props = fiber.memoizedProps;
        if (props && props.chatStore && props.chatStore.chatInfo) {
            const chatInfo = props.chatStore.chatInfo;
            const participants = new Set();

            const characterName = chatInfo.character?.chat_name;
            if (characterName) participants.add(characterName);

            const activePersona = props.chatStore.activePersona;
            let activePersonaName = null;

            if (activePersona) {
                const activeId = typeof activePersona === 'object' ? activePersona.id : activePersona;
                const found = chatInfo.personas?.find(p => p.id === activeId);
                if (found) activePersonaName = found.name;
            } else {
                const defaultPersona = chatInfo.personas?.find(p => p.is_default === true);
                if (defaultPersona) activePersonaName = defaultPersona.name;
            }

            if (activePersonaName) participants.add(activePersonaName);

            return Array.from(participants);
        }
        fiber = fiber.return;
    }

    return [];
}

        async function loadChatData() {
            try {
                const allRecords = await dbGetAll('chats_data');
                chatData = {};
                for (const record of allRecords) {
                    chatData[record.key] = record.value;
                }
            } catch (e) {
                console.error('Failed to load chat data:', e);
                chatData = {};
            }
        }

        async function ensureChatExists(chatId) {
            if (!chatId) return null;
            const stored = await getChatData(chatId);
            if (stored) {
                chatData[chatId] = stored;
                return stored;
            }
            const newChat = {
                name: '',
                participants: [],
                url: '',
                createdAt: Date.now(),
                lastUsed: Date.now(),
                modules: []
            };
            await setChatData(chatId, newChat);
            chatData[chatId] = newChat;
            return newChat;
        }

        async function fillChatMetadata(chatId) {
            const record = chatData[chatId];
            if (!record) return;

            let changed = false;
            const name = getChatNameFromPage();
            const participants = getParticipantsFromReact();

            if (name && (!record.name || record.name === L10N.t('chats_unknown'))) {
                record.name = name;
                changed = true;
            }
            if (participants.length > 0 && (!record.participants || record.participants.length === 0)) {
                record.participants = participants;
                changed = true;
            }
            record.url = location.href;
            record.lastUsed = Date.now();

            if (changed || !record.url) {
                await updateChatField(chatId, 'name', record.name);
                await updateChatField(chatId, 'participants', record.participants);
            }
            await updateChatField(chatId, 'url', record.url);
            await updateChatField(chatId, 'lastUsed', record.lastUsed);
        }

        function startMetadataPolling(chatId) {
            if (metadataInterval) clearInterval(metadataInterval);
            metadataInterval = setInterval(async () => {
                const currentId = getChatIdFromURL();
                if (currentId !== chatId) {
                    clearInterval(metadataInterval);
                    metadataInterval = null;
                    return;
                }
                const record = chatData[chatId];
                if (!record) return;
                if (record.name && record.name !== L10N.t('chats_unknown') && record.participants && record.participants.length > 0) {
                    clearInterval(metadataInterval);
                    metadataInterval = null;
                    return;
                }
                await fillChatMetadata(chatId);
            }, 500);
        }

        async function syncAllModules(chatId) {
            if (!chatId || !chatData[chatId]) {
                for (const id of moduleOrder) {
                    await setModuleEnabled(id, false, false, chatId);
                }
                return;
            }
            const enabledModules = chatData[chatId].modules || [];
            for (const id of moduleOrder) {
                const isEnabled = enabledModules.includes(id);
                await setModuleEnabled(id, isEnabled, false, chatId);
            }
        }

        async function setModuleEnabled(moduleId, enabled, save = true, chatId = currentChatId) {
            const mod = modules[moduleId];
            if (!mod || mod._pendingDeps) return;

            mod._enabled = enabled;

            const tile = mod.element;
            if (tile) {
                const checkbox = tile.querySelector('.module-checkbox');
                if (checkbox) checkbox.checked = enabled;
                tile.style.opacity = enabled ? '1' : '0.5';
                if (!enabled && mod.type === 'interface' && mod.expanded) {
                    toggleExpand(moduleId, true);
                }
            }

            if (enabled) {
                if (mod.onEnable) mod.onEnable(chatId);
            } else {
                if (mod.onDisable) mod.onDisable(chatId);
            }

            if (save && chatId && chatData[chatId]) {
                const record = chatData[chatId];
                if (enabled) {
                    if (!record.modules.includes(moduleId)) {
                        record.modules.push(moduleId);
                    }
                } else {
                    record.modules = record.modules.filter(id => id !== moduleId);
                }
                await updateChatField(chatId, 'modules', record.modules);
            }
        }

        function getCurrentChatId() {
            return currentChatId;
        }

        async function handleUrlChange() {
            const newChatId = getChatIdFromURL();
            if (newChatId !== currentChatId) {
                if (metadataInterval) {
                    clearInterval(metadataInterval);
                    metadataInterval = null;
                }
                await syncAllModules(currentChatId);
                currentChatId = newChatId;
                await runHook('chatChanged', currentChatId);
            }
            if (currentChatId) {
                await ensureChatExists(currentChatId);
                await fillChatMetadata(currentChatId);
                startMetadataPolling(currentChatId);
            }
            if (panelElement) {
                panelElement.style.display = currentChatId ? (panelCollapsed ? 'none' : 'flex') : 'none';
                if (toggleButton) {
                    toggleButton.style.display = currentChatId && panelCollapsed ? 'flex' : 'none';
                }
            }
            await syncAllModules(currentChatId);
        }

        function createPanel() {
            if (panelElement) return;

            const panel = document.createElement('div');
            panel.id = 'dispatcher-panel';
            panel.style.cssText = `
                position: fixed;
                top: 60px;
                right: 10px;
                width: 340px;
                max-height: calc(100vh - 70px);
                background: #181825;
                color: #cdd6f4;
                border: 2px solid #cba6f7;
                border-radius: 8px;
                padding: 0;
                z-index: 99999;
                font-family: sans-serif;
                font-size: 13px;
                box-shadow: 0 4px 12px rgba(0,0,0,0.5);
                display: none;
                flex-direction: column;
                overflow: hidden;
                user-select: none;
            `;

            const header = document.createElement('div');
            header.className = 'dispatcher-header';
            header.style.cssText = `
                padding: 6px 10px;
                background: #1e1e2e;
                border-bottom: 1px solid #45475a;
                cursor: grab;
                flex-shrink: 0;
                font-weight: bold;
                font-size: 15px;
                color: #cba6f7;
                text-align: center;
                display: flex;
                align-items: center;
                justify-content: space-between;
            `;

            const helpBtn = document.createElement('button');
            helpBtn.textContent = '❔';
            helpBtn.title = L10N.t('dispatcher_help');
            helpBtn.style.cssText = `
                width: 32px; height: 32px; border-radius: 50%; border: 1px solid #45475a;
                background: #313244; color: #cdd6f4; font-size: 18px;
                cursor: pointer; display: flex; align-items: center; justify-content: center;
                transition: 0.15s; flex-shrink: 0;
            `;
            helpBtn.addEventListener('mouseenter', () => { helpBtn.style.background = '#45475a'; });
            helpBtn.addEventListener('mouseleave', () => { helpBtn.style.background = '#313244'; });
            helpBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                showDispatcherTooltip(helpBtn);
            });

            const headerTitle = document.createElement('span');
            headerTitle.className = 'dispatcher-header-title';
            headerTitle.textContent = L10N.t('dispatcher_title');
            headerTitle.style.cssText = 'flex:1; text-align:center;';

            const collapseBtn = document.createElement('button');
            collapseBtn.textContent = '▶️';
            collapseBtn.title = L10N.t('collapse_tooltip');
            collapseBtn.style.cssText = `
                width: 32px; height: 32px; border-radius: 50%; border: 1px solid #45475a;
                background: #313244; color: #cdd6f4; font-size: 18px;
                cursor: pointer; display: flex; align-items: center; justify-content: center;
                transition: 0.15s; flex-shrink: 0;
            `;
            collapseBtn.addEventListener('mouseenter', () => { collapseBtn.style.background = '#45475a'; });
            collapseBtn.addEventListener('mouseleave', () => { collapseBtn.style.background = '#313244'; });
            collapseBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                togglePanel();
            });

            header.appendChild(helpBtn);
            header.appendChild(headerTitle);
            header.appendChild(collapseBtn);
            panel.appendChild(header);

            const toolbar = document.createElement('div');
            toolbar.className = 'dispatcher-toolbar';
            toolbar.style.cssText = `
                display: flex;
                justify-content: flex-end;
                align-items: center;
                padding: 4px 10px;
                background: #181825;
                border-bottom: 1px solid #313244;
                flex-shrink: 0;
                gap: 6px;
            `;

            const reloadBtn = document.createElement('button');
            reloadBtn.id = 'dispatcher-reload-btn';
            reloadBtn.textContent = '🔄';
            reloadBtn.title = L10N.t('reload_tooltip');
            reloadBtn.style.cssText = `
                background: #313244;
                border: 1px solid #45475a;
                border-radius: 4px;
                color: #cdd6f4;
                cursor: pointer;
                font-size: 15px;
                padding: 4px 8px;
                transition: background 0.15s, border-color 0.15s;
            `;
            reloadBtn.addEventListener('mouseenter', () => { reloadBtn.style.background = '#45475a'; reloadBtn.style.borderColor = '#89b4fa'; });
            reloadBtn.addEventListener('mouseleave', () => { reloadBtn.style.background = '#313244'; reloadBtn.style.borderColor = '#45475a'; });
            reloadBtn.addEventListener('click', () => location.reload());

            const chatsBtn = document.createElement('button');
            chatsBtn.id = 'dispatcher-chats-btn';
            chatsBtn.textContent = L10N.t('chats_title');
            chatsBtn.title = L10N.t('chats_tooltip');
            chatsBtn.style.cssText = `
                background: #313244;
                border: 1px solid #45475a;
                border-radius: 4px;
                color: #cdd6f4;
                cursor: pointer;
                font-size: 13px;
                padding: 4px 8px;
                font-weight: 500;
                transition: background 0.15s, border-color 0.15s;
            `;
            chatsBtn.addEventListener('mouseenter', () => { chatsBtn.style.background = '#45475a'; chatsBtn.style.borderColor = '#89b4fa'; });
            chatsBtn.addEventListener('mouseleave', () => { chatsBtn.style.background = '#313244'; chatsBtn.style.borderColor = '#45475a'; });
            chatsBtn.addEventListener('click', () => openChatsModal());

            const langBtn = document.createElement('button');
            langBtn.id = 'dispatcher-lang-btn';
            langBtn.textContent = L10N.t('lang_switch');
            langBtn.title = L10N.t('lang_tooltip');
            langBtn.style.cssText = `
                background: #313244;
                border: 1px solid #45475a;
                border-radius: 4px;
                color: #cdd6f4;
                cursor: pointer;
                font-size: 13px;
                padding: 4px 8px;
                font-weight: 500;
                transition: background 0.15s, border-color 0.15s;
            `;
            langBtn.addEventListener('mouseenter', () => { langBtn.style.background = '#45475a'; langBtn.style.borderColor = '#89b4fa'; });
            langBtn.addEventListener('mouseleave', () => { langBtn.style.background = '#313244'; langBtn.style.borderColor = '#45475a'; });
            langBtn.addEventListener('click', () => {
                const newLang = L10N.currentLang === 'ru' ? 'en' : 'ru';
                L10N.setLang(newLang);
            });

            const collapseAllBtn = document.createElement('button');
            collapseAllBtn.id = 'dispatcher-collapse-all';
            collapseAllBtn.textContent = '▼';
            collapseAllBtn.title = L10N.t('collapse_tooltip');
            collapseAllBtn.style.cssText = `
                background: #313244;
                border: 1px solid #45475a;
                border-radius: 4px;
                color: #cdd6f4;
                cursor: pointer;
                font-size: 14px;
                padding: 4px 8px;
                transition: background 0.15s, border-color 0.15s;
            `;
            collapseAllBtn.addEventListener('mouseenter', () => { collapseAllBtn.style.background = '#45475a'; collapseAllBtn.style.borderColor = '#89b4fa'; });
            collapseAllBtn.addEventListener('mouseleave', () => { collapseAllBtn.style.background = '#313244'; collapseAllBtn.style.borderColor = '#45475a'; });
            collapseAllBtn.addEventListener('click', () => collapseAll());

            toolbar.appendChild(reloadBtn);
            toolbar.appendChild(chatsBtn);
            toolbar.appendChild(langBtn);
            toolbar.appendChild(collapseAllBtn);
            panel.appendChild(toolbar);

            const container = document.createElement('div');
            container.id = 'dispatcher-modules';
            container.style.cssText = `
                overflow-y: auto;
                flex: 1;
                padding: 4px 0;
                border-top: 1px solid #313244;
            `;
            panel.appendChild(container);

            const emptyMsg = document.createElement('div');
            emptyMsg.id = 'dispatcher-empty';
            emptyMsg.style.cssText = `padding: 20px; text-align: center; color: #a6adc8; font-style: italic;`;
            emptyMsg.textContent = L10N.t('no_modules');
            container.appendChild(emptyMsg);

            const logWrapper = document.createElement('div');
            logWrapper.id = 'dispatcher-log';
            logWrapper.style.cssText = `border-top: 1px solid #45475a; flex-shrink: 0;`;

            const logHeader = document.createElement('div');
            logHeader.className = 'dispatcher-log-header';
            logHeader.style.cssText = `
                padding: 6px 12px;
                cursor: pointer;
                display: flex;
                justify-content: space-between;
                align-items: center;
                background: #181825;
                transition: background 0.15s;
                user-select: none;
            `;
            logHeader.addEventListener('mouseenter', () => logHeader.style.background = '#313244');
            logHeader.addEventListener('mouseleave', () => logHeader.style.background = '#181825');

            const logTitle = document.createElement('span');
            logTitle.style.cssText = `font-weight: bold; color: #a6adc8;`;
            logTitle.textContent = L10N.t('log_title');

            const logControls = document.createElement('span');
            logControls.style.cssText = `display: flex; align-items: center; gap: 6px;`;

            logIndicator = document.createElement('span');
            logIndicator.style.cssText = `
                background: #f38ba8;
                color: #111;
                border-radius: 50%;
                padding: 1px 6px;
                font-size: 11px;
                font-weight: bold;
                display: none;
            `;
            logIndicator.textContent = '0';

            const logExpandIcon = document.createElement('span');
            logExpandIcon.id = 'dispatcher-log-icon';
            logExpandIcon.textContent = '▶';
            logExpandIcon.style.cssText = `font-size: 12px; color: #a6adc8;`;

            logControls.appendChild(logIndicator);
            logControls.appendChild(logExpandIcon);
            logHeader.appendChild(logTitle);
            logHeader.appendChild(logControls);
            logWrapper.appendChild(logHeader);

            logContent = document.createElement('div');
            logContent.id = 'dispatcher-log-content';
            logContent.style.cssText = `
                padding: 4px 12px 8px 12px;
                max-height: 150px;
                overflow-y: auto;
                display: none;
                background: #11111b;
                border-top: 1px solid #313244;
                font-size: 12px;
                line-height: 1.4;
            `;
            const emptyLogMsg = document.createElement('div');
            emptyLogMsg.id = 'dispatcher-log-empty';
            emptyLogMsg.style.cssText = `color: #a6adc8; font-style: italic; padding: 4px 0;`;
            emptyLogMsg.textContent = L10N.t('log_empty');
            logContent.appendChild(emptyLogMsg);

            const clearBtn = document.createElement('button');
            clearBtn.id = 'dispatcher-log-clear';
            clearBtn.textContent = L10N.t('log_clear');
            clearBtn.style.cssText = `
                background: #f38ba8;
                color: #111;
                border: none;
                border-radius: 4px;
                padding: 2px 10px;
                font-weight: bold;
                cursor: pointer;
                font-size: 11px;
                margin-top: 4px;
                display: none;
            `;
            clearBtn.addEventListener('click', () => { logEntries = []; updateLogUI(); });
            logContent.appendChild(clearBtn);
            logWrapper.appendChild(logContent);
            panel.appendChild(logWrapper);

            document.body.appendChild(panel);
            panelElement = panel;
            containerElement = container;

            toggleButton = document.createElement('button');
            toggleButton.textContent = '◀️';
            toggleButton.style.cssText = `
                position: fixed;
                top: 60px;
                right: 10px;
                width: 40px; height: 40px;
                border-radius: 50%;
                background: #313244;
                border: 2px solid #cba6f7;
                color: #cdd6f4;
                font-size: 20px;
                cursor: pointer;
                z-index: 100000;
                display: none;
                align-items: center; justify-content: center;
                transition: 0.15s;
            `;
            toggleButton.addEventListener('mouseenter', () => { toggleButton.style.background = '#45475a'; });
            toggleButton.addEventListener('mouseleave', () => { toggleButton.style.background = '#313244'; });
            toggleButton.addEventListener('click', () => togglePanel());
            document.body.appendChild(toggleButton);

            rebuildList();

            let isDragging = false;
            let dragOffsetX = 0, dragOffsetY = 0;
            header.addEventListener('mousedown', function(e) {
                if (e.target.closest('button')) return;
                isDragging = true;
                const rect = panel.getBoundingClientRect();
                dragOffsetX = e.clientX - rect.left;
                dragOffsetY = e.clientY - rect.top;
                panel.style.cursor = 'grabbing';
                document.addEventListener('mousemove', onDrag);
                document.addEventListener('mouseup', onDragEnd);
                e.preventDefault();
            });

            function onDrag(e) {
                if (!isDragging) return;
                let left = e.clientX - dragOffsetX;
                let top = e.clientY - dragOffsetY;
                const maxX = window.innerWidth - panel.offsetWidth;
                const maxY = window.innerHeight - panel.offsetHeight;
                left = Math.max(0, Math.min(left, maxX));
                top = Math.max(0, Math.min(top, maxY));
                panel.style.left = left + 'px';
                panel.style.top = top + 'px';
                panel.style.right = 'auto';
            }

            function onDragEnd() {
                isDragging = false;
                panel.style.cursor = '';
                document.removeEventListener('mousemove', onDrag);
                document.removeEventListener('mouseup', onDragEnd);
            }

            logHeader.addEventListener('click', () => toggleLog());

            function togglePanel() {
                panelCollapsed = !panelCollapsed;
                if (panelCollapsed) {
                    panel.style.display = 'none';
                    toggleButton.style.display = 'flex';
                } else {
                    panel.style.display = currentChatId ? 'flex' : 'none';
                    toggleButton.style.display = 'none';
                }
            }

function showDispatcherTooltip(anchor) {
    // Удаляем предыдущий тултип
    const old = document.getElementById('dispatcher-tooltip');
    if (old) old.remove();

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

    // Закрытие по клику вне тултипа
    setTimeout(() => {
        document.addEventListener('click', function handler(e) {
            if (!tooltip.contains(e.target) && e.target !== anchor) {
                tooltip.remove();
                document.removeEventListener('click', handler);
            }
        });
    }, 0);
}
        }

        // --- Менеджер чатов (с кастомными подтверждениями и тултипом) ---
        async function openChatsModal() {
            if (currentChatId) await fillChatMetadata(currentChatId);

            const overlay = document.createElement('div');
            overlay.style.cssText = `
                position: fixed;
                top: 0; left: 0; width: 100%; height: 100%;
                background: rgba(0,0,0,0.6);
                backdrop-filter: blur(4px);
                z-index: 10000010;
                display: flex;
                justify-content: center;
                align-items: center;
                font-family: sans-serif;
            `;

            const modal = document.createElement('div');
            modal.style.cssText = `
                background: #1e1e2e;
                padding: 10px;
                border-radius: 16px;
                border: 2px solid #cba6f7;
                max-width: 600px;
                width: 95%;
                max-height: 95vh;
                color: #cdd6f4;
                box-shadow: 0 8px 32px rgba(0,0,0,0.9);
                display: flex;
                flex-direction: column;
                position: relative;
            `;

            // Заголовок с тултипом
            const titleRow = document.createElement('div');
            titleRow.style.cssText = 'display:flex; align-items:center; justify-content:center; margin-bottom:8px;';

            const title = document.createElement('h2');
            title.textContent = L10N.t('chats_title');
            title.style.cssText = `margin: 0; color: #cba6f7; font-size: 20px;`;

            const chatsHelpBtn = document.createElement('button');
            chatsHelpBtn.textContent = '❔';
            chatsHelpBtn.title = L10N.t('chats_help');
            chatsHelpBtn.style.cssText = `
                width: 28px; height: 28px; border-radius: 50%; border: 1px solid #45475a;
                background: #313244; color: #cdd6f4; font-size: 16px;
                cursor: pointer; display: flex; align-items: center; justify-content: center;
                margin-left: 8px; transition: 0.15s;
            `;
            chatsHelpBtn.addEventListener('mouseenter', () => { chatsHelpBtn.style.background = '#45475a'; });
            chatsHelpBtn.addEventListener('mouseleave', () => { chatsHelpBtn.style.background = '#313244'; });
            chatsHelpBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                // Показываем тултип внутри модального окна
                const old = document.getElementById('chats-help-tooltip');
                if (old) { old.remove(); return; }
                const tooltip = document.createElement('div');
                tooltip.id = 'chats-help-tooltip';
                tooltip.textContent = L10N.t('chats_help');
                tooltip.style.cssText = `
                    position: absolute;
                    top: 50px;
                    left: 10px;
                    right: 10px;
                    background: #1e1e2e;
                    border: 1px solid #45475a;
                    border-radius: 6px;
                    padding: 10px;
                    color: #cdd6f4;
                    font-size: 13px;
                    line-height: 1.5;
                    z-index: 10000011;
                    box-shadow: 0 4px 12px rgba(0,0,0,0.6);
                    white-space: pre-line;
                `;
                modal.appendChild(tooltip);
                setTimeout(() => {
                    document.addEventListener('click', function handler(ev) {
                        if (!tooltip.contains(ev.target) && ev.target !== chatsHelpBtn) {
                            tooltip.remove();
                            document.removeEventListener('click', handler);
                        }
                    });
                }, 0);
            });

            titleRow.appendChild(title);
            titleRow.appendChild(chatsHelpBtn);
            modal.appendChild(titleRow);

            const listContainer = document.createElement('div');
            listContainer.style.cssText = `overflow-y: auto; flex: 1; margin-bottom: 12px; padding-right: 4px;`;

            const emptyMsg = document.createElement('div');
            emptyMsg.textContent = L10N.t('chats_empty');
            emptyMsg.style.cssText = `color: #a6adc8; text-align: center; padding: 20px; font-style: italic;`;

            const btnContainer = document.createElement('div');
            btnContainer.style.cssText = `display: flex; justify-content: center; gap: 8px; margin-top: 8px;`;

            const deleteAllBtn = document.createElement('button');
            deleteAllBtn.textContent = L10N.t('chats_delete_all');
            deleteAllBtn.style.cssText = `
                background: #f38ba8;
                color: #111;
                border: none;
                border-radius: 6px;
                padding: 6px 16px;
                font-weight: bold;
                cursor: pointer;
            `;
            deleteAllBtn.addEventListener('click', async () => {
                const confirmed = await showConfirmDialog(
                    L10N.t('chats_delete_confirm'),
                    L10N.t('confirm_delete_all_chats')
                );
                if (!confirmed) return;
                const keys = await dbGetAllKeys('chats_data');
                for (const key of keys) await removeChatData(key);
                chatData = {};
                renderChatsList(listContainer, emptyMsg);
            });

            const closeBtn = document.createElement('button');
            closeBtn.textContent = L10N.t('dialog_cancel');
            closeBtn.style.cssText = `
                background: #45475a;
                color: #cdd6f4;
                border: none;
                border-radius: 6px;
                padding: 6px 16px;
                font-weight: bold;
                cursor: pointer;
            `;
            closeBtn.addEventListener('click', () => overlay.remove());

            btnContainer.appendChild(deleteAllBtn);
            btnContainer.appendChild(closeBtn);

            function renderChatsList(container, emptyEl) {
                container.innerHTML = '';
                const ids = Object.keys(chatData);
                if (ids.length === 0) {
                    container.appendChild(emptyEl);
                    return;
                }
                ids.sort((a, b) => (chatData[b]?.lastUsed || 0) - (chatData[a]?.lastUsed || 0));

                for (const id of ids) {
                    const data = chatData[id];
                    const card = document.createElement('div');
                    card.style.cssText = `
                        background: #181825;
                        border: 1px solid #313244;
                        border-radius: 8px;
                        padding: 10px 14px;
                        margin-bottom: 8px;
                        display: flex;
                        justify-content: space-between;
                        align-items: center;
                        gap: 12px;
                    `;

                    const info = document.createElement('div');
                    info.style.cssText = `flex: 1; min-width: 0;`;

                    const nameLink = document.createElement('a');
                    nameLink.href = data.url || '#';
                    nameLink.style.cssText = `
                        font-weight: bold;
                        font-size: 14px;
                        color: #89b4fa;
                        text-decoration: none;
                        display: inline-block;
                    `;
                    nameLink.textContent = data.name || L10N.t('chats_unknown');
                    nameLink.addEventListener('mouseenter', () => nameLink.style.textDecoration = 'underline');
                    nameLink.addEventListener('mouseleave', () => nameLink.style.textDecoration = 'none');

                    const nameLine = document.createElement('div');
                    nameLine.style.cssText = `margin-bottom: 2px;`;
                    nameLine.appendChild(nameLink);

                    const participants = data.participants?.length > 0 ? data.participants.join(', ') : L10N.t('chats_no_participants');
                    const participantsLine = document.createElement('div');
                    participantsLine.style.cssText = `font-size: 12px; color: #a6adc8;`;
                    participantsLine.textContent = `${L10N.t('chats_participants')}: ${participants}`;

                    const created = new Date(data.createdAt).toLocaleString();
                    const lastUsed = new Date(data.lastUsed).toLocaleString();
                    const metaLine = document.createElement('div');
                    metaLine.style.cssText = `font-size: 11px; color: #a6adc8; margin-top: 2px;`;
                    metaLine.textContent = `${L10N.t('chats_created')}: ${created} | ${L10N.t('chats_last_used')}: ${lastUsed}`;

                    const modulesList = document.createElement('div');
                    modulesList.style.cssText = `font-size: 12px; color: #89b4fa; margin-top: 4px;`;
                    const activeModules = data.modules || [];
                    if (activeModules.length > 0) {
                        const names = activeModules.map(mid => modules[mid]?.title || mid).join(', ');
                        modulesList.textContent = `${L10N.t('chats_modules_active')}: ${names}`;
                    } else {
                        modulesList.textContent = `${L10N.t('chats_modules_active')}: —`;
                    }

                    info.appendChild(nameLine);
                    info.appendChild(participantsLine);
                    info.appendChild(metaLine);
                    info.appendChild(modulesList);

                    // Кнопка удаления чата (корзина)
                    const delBtn = document.createElement('button');
                    delBtn.textContent = '🗑️';
                    delBtn.title = L10N.t('chats_delete_single');
                    delBtn.style.cssText = `
                        background: #f38ba8;
                        color: #111;
                        border: none;
                        border-radius: 6px;
                        padding: 12px 8px;
                        font-weight: bold;
                        cursor: pointer;
                        font-size: 18px;
                        flex-shrink: 0;
                        transition: background 0.15s;
                    `;
                    delBtn.addEventListener('mouseenter', () => { delBtn.style.background = '#e06c8a'; });
                    delBtn.addEventListener('mouseleave', () => { delBtn.style.background = '#f38ba8'; });
                    delBtn.addEventListener('click', async () => {
                        const confirmed = await showConfirmDialog(
                            L10N.t('chats_delete_single'),
                            L10N.t('confirm_delete_chat', { name: data.name || id })
                        );
                        if (!confirmed) return;
                        await removeChatData(id);
                        delete chatData[id];
                        renderChatsList(container, emptyEl);
                    });

                    card.appendChild(info);
                    card.appendChild(delBtn);
                    container.appendChild(card);
                }
            }

            renderChatsList(listContainer, emptyMsg);
            modal.appendChild(listContainer);
            modal.appendChild(btnContainer);
            overlay.appendChild(modal);
            document.body.appendChild(overlay);
            overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
        }

        function logError(moduleId, message) {
            const entry = { module: moduleId, message: message, timestamp: Date.now() };
            logEntries.push(entry);
            if (logEntries.length > 100) logEntries.shift();
            if (!logExpanded) toggleLog();
            updateLogUI();
        }

        function toggleLog() {
            logExpanded = !logExpanded;
            const content = document.getElementById('dispatcher-log-content');
            const icon = document.getElementById('dispatcher-log-icon');
            if (content) content.style.display = logExpanded ? 'block' : 'none';
            if (icon) icon.textContent = logExpanded ? '▼' : '▶';
            if (logExpanded && content) content.scrollTop = content.scrollHeight;
        }

        function updateLogUI() {
            const content = document.getElementById('dispatcher-log-content');
            const emptyMsg = document.getElementById('dispatcher-log-empty');
            const clearBtn = document.getElementById('dispatcher-log-clear');
            const indicator = logIndicator;
            if (!content) return;

            content.querySelectorAll('.log-entry').forEach(el => el.remove());

            if (logEntries.length === 0) {
                if (emptyMsg) emptyMsg.style.display = 'block';
                if (clearBtn) clearBtn.style.display = 'none';
                if (indicator) indicator.style.display = 'none';
                return;
            }

            if (emptyMsg) emptyMsg.style.display = 'none';
            if (clearBtn) clearBtn.style.display = 'block';
            if (indicator) {
                indicator.textContent = logEntries.length;
                indicator.style.display = 'inline-block';
            }

            for (const entry of logEntries) {
                const div = document.createElement('div');
                div.className = 'log-entry';
                div.style.cssText = `padding: 3px 0; border-bottom: 1px solid #313244; color: #f38ba8;`;
                const moduleName = modules[entry.module]?.title || entry.module;
                div.textContent = `[${moduleName}] ${entry.message}`;
                content.insertBefore(div, clearBtn);
            }

            if (logExpanded) content.scrollTop = content.scrollHeight;
        }

        // --- Управление модулями с зависимостями ---
        function checkDependencies(requires) {
            if (!requires || !Array.isArray(requires)) return true;
            return requires.every(globalName => window[globalName] !== undefined);
        }

        function registerModule(moduleId, config) {
            if (modules[moduleId]) {
                console.warn(`Модуль "${moduleId}" уже зарегистрирован.`);
                return;
            }
            if (!config.title) {
                console.error(`Модуль "${moduleId}" не имеет title.`);
                return;
            }
            if (!config.type || (config.type !== 'interface' && config.type !== 'button')) {
                console.error(`Модуль "${moduleId}" имеет неверный тип.`);
                return;
            }
            if (config.type === 'interface' && typeof config.content !== 'function') {
                console.error(`Модуль "${moduleId}" типа interface должен предоставлять функцию content.`);
                return;
            }
            if (config.type === 'button' && typeof config.onClick !== 'function') {
                console.error(`Модуль "${moduleId}" типа button должен предоставлять функцию onClick.`);
                return;
            }

            const depsOk = checkDependencies(config.requires);

            modules[moduleId] = {
                title: config.title,
                type: config.type,
                content: config.content || null,
                onClick: config.onClick || null,
                onActivate: config.onActivate || null,
                onDeactivate: config.onDeactivate || null,
                onEnable: config.onEnable || null,
                onDisable: config.onDisable || null,
                onClose: config.onClose || null,
                element: null,
                contentElement: null,
                expanded: false,
                _enabled: false,
                _pendingDeps: !depsOk,
                _requires: config.requires || []
            };

            moduleOrder.push(moduleId);
            rebuildList();

            if (!depsOk) {
                console.log(`⏳ Модуль "${moduleId}" ожидает зависимости: ${config.requires.join(', ')}`);
            } else {
                if (currentChatId && chatData[currentChatId]) {
                    const enabledModules = chatData[currentChatId].modules || [];
                    const isEnabled = enabledModules.includes(moduleId);
                    setModuleEnabled(moduleId, isEnabled, false, currentChatId);
                } else {
                    setModuleEnabled(moduleId, false, false, currentChatId);
                }
            }
        }

        function checkPendingModules() {
            for (const id of moduleOrder) {
                const mod = modules[id];
                if (mod && mod._pendingDeps && checkDependencies(mod._requires)) {
                    mod._pendingDeps = false;
                    console.log(`✅ Зависимости для модуля "${id}" удовлетворены`);
                    if (currentChatId && chatData[currentChatId]) {
                        const enabledModules = chatData[currentChatId].modules || [];
                        const isEnabled = enabledModules.includes(id);
                        setModuleEnabled(id, isEnabled, false, currentChatId);
                    } else {
                        setModuleEnabled(id, false, false, currentChatId);
                    }
                    rebuildList();
                }
            }
        }

        setInterval(checkPendingModules, 1000);

        function updateModule(moduleId, newContent) {
            if (!modules[moduleId]) return;
            const mod = modules[moduleId];
            if (mod.type !== 'interface') return;
            if (mod.contentElement) {
                mod.contentElement.innerHTML = newContent;
            }
            mod.content = () => newContent;
        }

        function closeModule(moduleId) {
            if (!modules[moduleId]) return;
            const mod = modules[moduleId];
            if (mod.onClose) mod.onClose();
            if (mod.element?.parentNode) mod.element.parentNode.removeChild(mod.element);
            delete modules[moduleId];
            const idx = moduleOrder.indexOf(moduleId);
            if (idx !== -1) moduleOrder.splice(idx, 1);
            rebuildList();
        }

        function collapseAll() {
            for (const id of moduleOrder) {
                const mod = modules[id];
                if (mod.type === 'interface' && mod.expanded) toggleExpand(id, true);
            }
        }

        function toggleExpand(moduleId, forceCollapse = false) {
            const mod = modules[moduleId];
            if (!mod || mod.type !== 'interface') return;
            if (!forceCollapse && mod._enabled === false) return;
            const newState = forceCollapse ? false : !mod.expanded;
            mod.expanded = newState;

            const tile = mod.element;
            if (tile) {
                const indicator = tile.querySelector('.dispatcher-indicator');
                if (indicator) indicator.textContent = newState ? '▼' : '▶';
                const contentDiv = mod.contentElement;
                if (contentDiv) contentDiv.style.display = newState ? 'block' : 'none';
            }
            if (newState && mod.onActivate) mod.onActivate();
            if (!newState && mod.onDeactivate) mod.onDeactivate();
        }

        function rebuildList() {
            if (!containerElement) return;
            while (containerElement.firstChild) containerElement.removeChild(containerElement.firstChild);

            if (moduleOrder.length === 0) {
                const empty = document.createElement('div');
                empty.id = 'dispatcher-empty';
                empty.style.cssText = `padding: 20px; text-align: center; color: #a6adc8; font-style: italic;`;
                empty.textContent = L10N.t('no_modules');
                containerElement.appendChild(empty);
                return;
            }

            for (const id of moduleOrder) {
                const mod = modules[id];
                const tile = document.createElement('div');
                tile.className = 'dispatcher-tile';
                tile.dataset.moduleId = id;
                tile.style.cssText = `
                    padding: 6px 10px; margin: 2px 4px; border-radius: 4px;
                    background: #1e1e2e; border: 1px solid #313244;
                    cursor: pointer; display: flex; align-items: center; gap: 6px;
                    transition: background 0.15s, opacity 0.2s;
                `;
                tile.style.opacity = mod._pendingDeps ? '0.4' : (mod._enabled ? '1' : '0.5');

                const checkbox = document.createElement('input');
                checkbox.type = 'checkbox';
                checkbox.className = 'module-checkbox';
                checkbox.style.cssText = `width: 16px; height: 16px; cursor: pointer; flex-shrink: 0; accent-color: #cba6f7;`;
                checkbox.checked = mod._enabled;
                checkbox.disabled = mod._pendingDeps;
                checkbox.addEventListener('change', function(e) {
                    e.stopPropagation();
                    if (mod._pendingDeps) return;
                    setModuleEnabled(id, this.checked, true, currentChatId);
                });
                tile.appendChild(checkbox);

                const titleSpan = document.createElement('span');
                titleSpan.className = 'dispatcher-title';
                titleSpan.textContent = mod.title;
                titleSpan.style.cssText = `flex: 1; font-weight: 500;`;

                const indicator = document.createElement('span');
                indicator.className = 'dispatcher-indicator';
                indicator.style.cssText = `font-size: 12px; color: #a6adc8; margin-left: 8px;`;
                if (mod.type === 'interface') {
                    indicator.textContent = mod.expanded ? '▼' : '▶';
                } else {
                    indicator.textContent = '';
                }

                tile.appendChild(titleSpan);
                tile.appendChild(indicator);

                tile.addEventListener('click', function(e) {
                    if (e.target === checkbox) return;
                    if (mod._pendingDeps || !mod._enabled) return;
                    if (mod.type === 'interface') toggleExpand(id);
                    else if (mod.type === 'button' && mod.onClick) mod.onClick();
                });

                let contentContainer = null;
                if (mod.type === 'interface') {
                    contentContainer = document.createElement('div');
                    contentContainer.className = 'dispatcher-content';
                    contentContainer.style.cssText = `
                        padding: 8px 12px; background: #11111b;
                        border-top: 1px solid #313244;
                        display: ${mod.expanded ? 'block' : 'none'};
                    `;
                    try {
                        contentContainer.innerHTML = mod.content();
                    } catch (e) {
                        contentContainer.textContent = 'Error loading content';
                        console.error(`Ошибка загрузки содержимого модуля ${id}:`, e);
                    }
                    mod.contentElement = contentContainer;
                }

                mod.element = tile;
                mod.contentElement = contentContainer;

                containerElement.appendChild(tile);
                if (contentContainer) containerElement.appendChild(contentContainer);
            }
        }

        function updateUI() {
            const header = document.querySelector('.dispatcher-header');
            if (header) {
                const titleSpan = header.querySelector('.dispatcher-header-title');
                if (titleSpan) {
                    titleSpan.textContent = L10N.t('dispatcher_title');
                }
            }

            const chatsBtn = document.getElementById('dispatcher-chats-btn');
            if (chatsBtn) { chatsBtn.textContent = L10N.t('chats_title'); chatsBtn.title = L10N.t('chats_tooltip'); }
            const langBtn = document.getElementById('dispatcher-lang-btn');
            if (langBtn) { langBtn.textContent = L10N.t('lang_switch'); langBtn.title = L10N.t('lang_tooltip'); }
            const collapseBtn = document.getElementById('dispatcher-collapse-all');
            if (collapseBtn) collapseBtn.title = L10N.t('collapse_tooltip');
            const reloadBtn = document.getElementById('dispatcher-reload-btn');
            if (reloadBtn) reloadBtn.title = L10N.t('reload_tooltip');

            const collapsePanelBtn = document.querySelector('.dispatcher-header button:last-child');
            if (collapsePanelBtn) collapsePanelBtn.title = L10N.t('collapse_tooltip');

            const tooltip = document.getElementById('dispatcher-tooltip');
            if (tooltip) {
                tooltip.textContent = L10N.t('dispatcher_help');
            }

            const empty = document.getElementById('dispatcher-empty');
            if (empty) empty.textContent = L10N.t('no_modules');
            const logTitle = document.querySelector('#dispatcher-log .dispatcher-log-header span');
            if (logTitle) logTitle.textContent = L10N.t('log_title');
            const clearBtn = document.getElementById('dispatcher-log-clear');
            if (clearBtn) clearBtn.textContent = L10N.t('log_clear');
            const emptyLog = document.getElementById('dispatcher-log-empty');
            if (emptyLog) emptyLog.textContent = L10N.t('log_empty');
        }

        function showPanel() {
            if (panelElement) {
                panelElement.style.display = 'flex';
                if (toggleButton) toggleButton.style.display = 'none';
                panelCollapsed = false;
            } else {
                createPanel();
            }
        }

        // --- Система хуков ---
        function addHook(eventName, callback) {
            if (!hooks[eventName]) hooks[eventName] = [];
            hooks[eventName].push({ moduleId: null, callback });
        }

        async function runHook(eventName, ...args) {
            const hookList = hooks[eventName];
            if (!hookList || hookList.length === 0) return true;

            for (const hook of hookList) {
                try {
                    const result = await hook.callback(...args);
                    if (result === false) return false;
                } catch (e) {
                    console.error(`Ошибка в хуке ${eventName}:`, e);
                }
            }
            return true;
        }

        async function init() {
            await openDB();

            const savedLang = await getDispatcherData('lang');
            if (savedLang === 'ru' || savedLang === 'en') {
                L10N.currentLang = savedLang;
            }

            window.dispatchEvent(new CustomEvent('languageChanged', { detail: { lang: L10N.currentLang } }));

            await loadChatData();
            createPanel();
            await handleUrlChange();

            setTimeout(async () => { await handleUrlChange(); }, 2000);
            lastCheckedUrl = location.href;
            setInterval(async () => {
                if (location.href !== lastCheckedUrl) {
                    lastCheckedUrl = location.href;
                    await handleUrlChange();
                }
            }, 500);
            setTimeout(async () => { await syncAllModules(currentChatId); }, 500);
        }

        setTimeout(init, 50);

        window.__MANAGER__ = {
            register: registerModule,
            update: updateModule,
            close: closeModule,
setModuleTitle: function(moduleId, newTitle) {
    if (modules[moduleId]) {
        modules[moduleId].title = newTitle;
        const tile = document.querySelector(`.dispatcher-tile[data-module-id="${moduleId}"] .dispatcher-title`);
        if (tile) tile.textContent = newTitle;
    }
},
            show: showPanel,
            logError: logError,
            getCurrentChatId: getCurrentChatId,
            _updateUI: updateUI,
            getModules: () => moduleOrder.map(id => ({ id, ...modules[id] })),
            _refreshChat: () => handleUrlChange(),
            getModuleSettings: async (moduleId) => {
                if (!currentChatId) return {};
                return await getModuleSettings(currentChatId, moduleId);
            },
            setModuleSettings: async (moduleId, settings) => {
                if (!currentChatId) throw new Error('No active chat');
                await setModuleSettings(currentChatId, moduleId, settings);
            },
            addHook,
            runHook
        };

        console.log('📦 Диспетчер модулей загружен.');
    })();
})();