Central UI for managing LoreCanvas modules. Provides a shared panel, per-chat module settings, and IndexedDB storage. External script for JanitorAI.
// ==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('📦 Диспетчер модулей загружен.');
})();
})();