Processes active LoreCanvas lorebooks and injects relevant entries into the prompt via React state and WebSocket. Supports triggers, groups, limits, and OOC instructions. External script for JanitorAI.
// ==UserScript==
// @name Lorebook Engine
// @namespace Violentmonkey Scripts
// @version 3.0
// @match https://janitorai.com/*
// @grant none
// @author Glazanochi
// @description Processes active LoreCanvas lorebooks and injects relevant entries into the prompt via React state and WebSocket. Supports triggers, groups, limits, and OOC instructions. External script for JanitorAI.
// ==/UserScript==
/* jshint esversion: 11 */
/* jshint -W083 */
(function() {
'use strict';
// ----- 0. ПРОВЕРКА ДИСПЕТЧЕРА И БД -----
if (!window.__MANAGER__ || !window.__DB__) {
console.warn('[LB] Диспетчер или DB не найдены. Модуль остановлен.');
return;
}
// ----- 1. РЕГИСТРАЦИЯ ХРАНИЛИЩА -----
window.__DB__.registerStore('lorebook_engine', 'key');
// ----- 2. ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ -----
const SEND_BTN_SELECTOR = 'button._sendButton_1bz7u_29:not(._stopButton_1bz7u_1)';
const TEXTAREA_SELECTOR = 'textarea._chatTextarea_1e2lg_1';
const REGEN_SEL = '._botMessageControlWrapper_1fw1u_1._right_1fw1u_13';
const CONTINUE_SEL = '._controlPanelButton_4evg7_13';
const RETRY_SEL = '._footerActionButton_4evg7_615';
const MODULE_ID = 'lorebook_engine';
let globalDepth = 3;
let useGlobalDepth = false;
let paused = false;
let active = false;
let currentChatId = null;
let isInjecting = false;
let injectionResetTimer = null;
// Настройки лимита
let maxInjectLength = 500;
let limitBehavior = 'skip'; // 'skip', 'allow', 'trim'
// Переменные для отложенных инъекций last_message
let pendingLastMessageInjection = null; // массив {pos, content} для NEW
let lastMessageRestore = null; // { id, originalText } для восстановления после WS
let lastMessageOriginalText = null; // исходный текст пользователя из XHR
// ----- 3. ЛОКАЛИЗАЦИЯ -----
const L10N = window.__L10N__ || { currentLang: 'ru', t: (key) => key };
const STRINGS = {
'le_title': { ru: '📖 Lorebook Engine', en: '📖 Lorebook Engine' },
'le_depth_label': { ru: 'Глубина сканирования:', en: 'Scan depth:' },
'le_use_global': { ru: 'Глобальная глубина', en: 'Global depth' },
'le_pause': { ru: 'Пауза', en: 'Pause' },
'le_limit_label': { ru: 'Максимальный объём инъекций:', en: 'Max injection length:' },
'le_limit_behavior': { ru: 'При превышении лимита:', en: 'When limit exceeded:' },
'le_limit_skip': { ru: 'Не вставлять', en: 'Skip' },
'le_limit_allow': { ru: 'Вставлять', en: 'Allow' },
'le_limit_trim': { ru: 'Обрезать', en: 'Trim' },
'le_limit_tooltip': {
ru: 'Максимальная суммарная длина всех инъекций лорбука в символах.\nЕсли узел не помещается целиком:\n• Не вставлять — узел будет пропущен.\n• Вставлять — узел вставится целиком, возможно превышение лимита.\n• Обрезать — вставится часть узла, умещающаяся в оставшийся лимит.',
en: 'Maximum total length of all lorebook injections in characters.\nIf a node does not fit entirely:\n• Skip — the node will be skipped.\n• Allow — the node will be inserted entirely, possibly exceeding the limit.\n• Trim — the part of the node that fits within the remaining limit will be inserted.'
},
'le_depth_tooltip': {
ru: 'Если включено, глубина сканирования всех узлов будет заменена на указанное значение.\nЗначение 0 означает сканирование всей доступной истории.',
en: 'If enabled, the scan depth of all nodes will be replaced with the specified value.\nA value of 0 means scanning the entire available history.'
},
'le_no_api': { ru: 'API лорбуков не найден', en: 'Lorebook API not found' },
'le_no_target': { ru: '❌ Узел "{label}" не содержит цели (insertTarget)', en: '❌ Node "{label}" has no insertTarget' },
'le_activated': { ru: 'Активировано узлов:', en: 'Activated nodes:' },
'le_active_lorebooks': { ru: 'Активные лорбуки:', en: 'Active lorebooks:' },
};
function t(key, vars) {
const lang = L10N.currentLang || 'ru';
const str = STRINGS[key]?.[lang] || STRINGS[key]?.ru || key;
return vars ? str.replace(/{([^}]+)}/g, (_, p1) => vars[p1] ?? '') : str;
}
// ----- 4. ЗАГРУЗКА/СОХРАНЕНИЕ НАСТРОЕК -----
async function loadSettings() {
try {
const depth = await window.__DB__.dbGet('lorebook_engine', 'globalDepth');
if (depth !== undefined) globalDepth = Number(depth) || 3;
const useGlobal = await window.__DB__.dbGet('lorebook_engine', 'useGlobalDepth');
useGlobalDepth = useGlobal === true || useGlobal === 'true';
const isPaused = await window.__DB__.dbGet('lorebook_engine', 'paused');
paused = isPaused === true || isPaused === 'true';
const maxLen = await window.__DB__.dbGet('lorebook_engine', 'maxInjectLength');
if (maxLen !== undefined) maxInjectLength = Number(maxLen) || 500;
const beh = await window.__DB__.dbGet('lorebook_engine', 'limitBehavior');
if (beh === 'skip' || beh === 'allow' || beh === 'trim') {
limitBehavior = beh;
}
} catch (e) {
console.error('[LB] Ошибка загрузки настроек:', e);
}
}
async function saveSettings() {
try {
await window.__DB__.dbSet('lorebook_engine', 'globalDepth', String(globalDepth));
await window.__DB__.dbSet('lorebook_engine', 'useGlobalDepth', String(useGlobalDepth));
await window.__DB__.dbSet('lorebook_engine', 'paused', String(paused));
await window.__DB__.dbSet('lorebook_engine', 'maxInjectLength', String(maxInjectLength));
await window.__DB__.dbSet('lorebook_engine', 'limitBehavior', limitBehavior);
} catch (e) {
console.error('[LB] Ошибка сохранения настроек:', e);
}
}
// ----- 5. ВСПЛЫВАЮЩАЯ ПОДСКАЗКА (mobile-friendly) -----
function createInfoIcon(text) {
const icon = document.createElement('span');
icon.textContent = 'ℹ️';
icon.style.cssText = 'cursor:pointer; color:#89b4fa; font-size:16px; margin-left:4px;';
let tooltip = null;
function showTooltip() {
if (tooltip) return;
const panel = document.getElementById('dispatcher-panel');
if (!panel) return;
const panelRect = panel.getBoundingClientRect();
tooltip = document.createElement('div');
tooltip.id = 'le-tooltip';
tooltip.textContent = text;
tooltip.style.cssText = `
position: fixed;
z-index: 10000020;
background: #1e1e2e;
border: 1px solid #45475a;
border-radius: 6px;
padding: 8px 12px;
color: #cdd6f4;
font-size: 13px;
line-height: 1.5;
white-space: pre-line;
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
max-width: ${panelRect.width - 20}px;
max-height: 200px;
overflow-y: auto;
`;
document.body.appendChild(tooltip);
const iconRect = icon.getBoundingClientRect();
let left = iconRect.right + 8;
let top = iconRect.top - 10;
if (left + tooltip.offsetWidth > panelRect.right - 10) {
left = panelRect.right - tooltip.offsetWidth - 10;
}
if (left < panelRect.left + 10) {
left = panelRect.left + 10;
}
if (top + tooltip.offsetHeight > panelRect.bottom - 10) {
top = panelRect.bottom - tooltip.offsetHeight - 10;
}
if (top < panelRect.top + 10) {
top = panelRect.top + 10;
}
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
setTimeout(() => {
document.addEventListener('click', hideTooltip, { once: true });
}, 0);
}
function hideTooltip() {
if (tooltip) {
tooltip.remove();
tooltip = null;
}
}
icon.addEventListener('click', (e) => {
e.stopPropagation();
if (tooltip) {
hideTooltip();
} else {
showTooltip();
}
});
return icon;
}
// ----- 6. ВИДЖЕТ -----
function getContent() {
return `
<div style="padding:8px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<label style="font-size:13px;">${t('le_depth_label')}</label>
<input id="le-depth" type="number" min="0" value="${globalDepth}" style="width:60px;background:#11111b;border:1px solid #45475a;color:#cdd6f4;border-radius:4px;padding:2px 4px;">
</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<input id="le-use-global" type="checkbox" ${useGlobalDepth ? 'checked' : ''}>
<label style="font-size:13px;">${t('le_use_global')}</label>
<span id="le-depth-info"></span>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<label style="font-size:13px;">${t('le_limit_label')}</label>
<input id="le-max-length" type="number" min="0" value="${maxInjectLength}" style="width:60px;background:#11111b;border:1px solid #45475a;color:#cdd6f4;border-radius:4px;padding:2px 4px;">
</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<label style="font-size:13px;">${t('le_limit_behavior')}</label>
<select id="le-limit-behavior" style="background:#11111b;border:1px solid #45475a;color:#cdd6f4;border-radius:4px;padding:2px 4px;">
<option value="skip" ${limitBehavior === 'skip' ? 'selected' : ''}>${t('le_limit_skip')}</option>
<option value="allow" ${limitBehavior === 'allow' ? 'selected' : ''}>${t('le_limit_allow')}</option>
<option value="trim" ${limitBehavior === 'trim' ? 'selected' : ''}>${t('le_limit_trim')}</option>
</select>
<span id="le-limit-info"></span>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<input id="le-pause" type="checkbox" ${paused ? 'checked' : ''}>
<label style="font-size:13px;">${t('le_pause')}</label>
</div>
</div>
`;
}
function bindWidgetEvents() {
const depthInput = document.getElementById('le-depth');
const useGlobalCb = document.getElementById('le-use-global');
const pauseCb = document.getElementById('le-pause');
const maxLenInput = document.getElementById('le-max-length');
const limitBehSelect = document.getElementById('le-limit-behavior');
if (depthInput) {
depthInput.addEventListener('change', async () => {
let val = parseInt(depthInput.value);
if (isNaN(val) || val < 0) val = 3;
globalDepth = val;
depthInput.value = val;
await saveSettings();
});
}
if (useGlobalCb) {
useGlobalCb.addEventListener('change', async () => {
useGlobalDepth = useGlobalCb.checked;
await saveSettings();
});
}
if (pauseCb) {
pauseCb.addEventListener('change', async () => {
paused = pauseCb.checked;
await saveSettings();
});
}
if (maxLenInput) {
maxLenInput.addEventListener('change', async () => {
let val = parseInt(maxLenInput.value);
if (isNaN(val) || val < 0) val = 500;
maxInjectLength = val;
maxLenInput.value = val;
await saveSettings();
});
}
if (limitBehSelect) {
limitBehSelect.addEventListener('change', async () => {
limitBehavior = limitBehSelect.value;
await saveSettings();
});
}
const depthInfo = document.getElementById('le-depth-info');
if (depthInfo) {
depthInfo.innerHTML = '';
depthInfo.appendChild(createInfoIcon(t('le_depth_tooltip')));
}
const limitInfo = document.getElementById('le-limit-info');
if (limitInfo) {
limitInfo.innerHTML = '';
limitInfo.appendChild(createInfoIcon(t('le_limit_tooltip')));
}
(async () => {
await loadSettings();
if (depthInput) depthInput.value = globalDepth;
if (useGlobalCb) useGlobalCb.checked = useGlobalDepth;
if (pauseCb) pauseCb.checked = paused;
if (maxLenInput) maxLenInput.value = maxInjectLength;
if (limitBehSelect) limitBehSelect.value = limitBehavior;
})();
}
// ----- 7. ПОИСК REACT-СТОРА (автоматический) -----
function getReactStores() {
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
if (!hook) return null;
const renderer = Array.from(hook.renderers.values())[0];
const textarea = document.querySelector(TEXTAREA_SELECTOR);
if (!textarea) return null;
let fiber = renderer.findFiberByHostInstance(textarea);
while (fiber) {
const props = fiber.memoizedProps;
if (props && props.subscriptionStore && props.subscriptionStore.userStore && props.chatStore) {
return {
config: props.subscriptionStore.userStore.config,
chatInfo: props.chatStore.chatInfo,
userStore: props.subscriptionStore.userStore,
chatStore: props.chatStore
};
}
fiber = fiber.return;
}
return null;
}
// ----- 8. ИЗВЛЕЧЕНИЕ СООБЩЕНИЙ ИЗ REACT-СТОРА (без DOM) -----
function getMessagesFromReact(stores) {
if (!stores || !stores.chatStore || !stores.chatStore.messagesStore) return [];
const messagesStore = stores.chatStore.messagesStore;
const messages = messagesStore.messages;
if (!Array.isArray(messages)) return [];
const currentIndex = messagesStore.lastMessageIndex;
const result = [];
for (let i = 0; i < messages.length; i++) {
const item = messages[i];
if (Array.isArray(item)) {
const isLast = (i === messages.length - 1);
let index = item.length - 1;
if (isLast && typeof currentIndex === 'number' && currentIndex >= 0 && currentIndex < item.length) {
index = currentIndex;
}
const chosen = item[index];
if (chosen && typeof chosen.message === 'string') {
result.push({ is_bot: chosen.is_bot !== undefined ? chosen.is_bot : true, message: chosen.message });
}
} else if (item && typeof item.message === 'string') {
result.push({ is_bot: item.is_bot !== undefined ? item.is_bot : false, message: item.message });
}
}
return result;
}
// Вспомогательная функция поиска объекта последнего пользовательского сообщения
function findLastUserMessageObject(stores) {
if (!stores || !stores.chatStore || !stores.chatStore.messagesStore) return null;
const messages = stores.chatStore.messagesStore.messages;
if (!Array.isArray(messages)) return null;
for (let i = messages.length - 1; i >= 0; i--) {
const item = messages[i];
const target = Array.isArray(item) ? item[item.length - 1] : item;
if (target && target.is_bot === false) {
return target;
}
}
return null;
}
// Функция применения инъекций к строке с учётом позиций
function applyInjectionsToString(originalText, injectionsList) {
if (!Array.isArray(injectionsList) || injectionsList.length === 0) return originalText;
let text = originalText || '';
const hasReplace = injectionsList.some(item => item.pos === 'replace');
if (hasReplace) {
text = '';
const replaceItems = injectionsList.filter(item => item.pos === 'replace');
for (let i = 0; i < replaceItems.length; i++) {
if (i === 0) {
text = replaceItems[i].content;
} else {
text = text + '\n' + replaceItems[i].content;
}
}
}
const startParts = injectionsList.filter(item => item.pos === 'start').map(item => item.content);
const endParts = injectionsList.filter(item => item.pos === 'end').map(item => item.content);
if (startParts.length > 0) {
text = startParts.join('\n') + '\n' + text;
}
if (endParts.length > 0) {
text = text + '\n' + endParts.join('\n');
}
return text;
}
// ----- 9. ЛОГИКА ПОИСКА КЛЮЧЕЙ -----
function logError(message) {
console.error('[LB]', message);
if (window.__MANAGER__ && window.__MANAGER__.logError) {
window.__MANAGER__.logError(MODULE_ID, message);
}
}
function keyExistsInText(text, key, caseSensitive, wholeWords) {
if (key.startsWith('/') && key.lastIndexOf('/') > 0) {
try {
const lastSlash = key.lastIndexOf('/');
const pattern = key.substring(1, lastSlash);
const flags = key.substring(lastSlash + 1);
const regex = new RegExp(pattern, flags);
return regex.test(text);
} catch (e) {
console.warn('[LB] Некорректный regex в ключе:', key, e);
return false;
}
}
if (key.includes('*') || key.includes('?')) {
const escaped = key.replace(/[.+^${}()|[\]\\]/g, '\\$&');
const pattern = escaped.replace(/\*/g, '.*?').replace(/\?/g, '.');
try {
const regex = new RegExp(pattern, caseSensitive ? '' : 'i');
return regex.test(text);
} catch (e) {
console.warn('[LB] Ошибка маски в ключе:', key, e);
return false;
}
}
const searchText = caseSensitive ? text : text.toLowerCase();
const targetKey = caseSensitive ? key : key.toLowerCase();
if (wholeWords) {
const escapedKey = targetKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(?<!\\p{L})${escapedKey}(?!\\p{L})`, `u${caseSensitive ? '' : 'i'}`);
return regex.test(searchText);
} else {
return searchText.includes(targetKey);
}
}
function evaluateNodeTrigger(node, text, caseSensitive, wholeWords) {
if (node.alwaysActive) return true;
const primaryKeys = node.keywords || [];
const secondaryKeys = node.additionalKeys || [];
const condition = node.condition || 'AND_ANY';
if (primaryKeys.length === 0) return false;
const foundPrimary = primaryKeys.filter(key => keyExistsInText(text, key, caseSensitive, wholeWords));
if (foundPrimary.length === 0) return false;
if (secondaryKeys.length === 0) return true;
const foundSecondary = secondaryKeys.filter(key => keyExistsInText(text, key, caseSensitive, wholeWords));
switch (condition) {
case 'AND_ANY':
return foundPrimary.some(p => foundSecondary.some(s => p !== s));
case 'AND_ALL':
return foundSecondary.length === secondaryKeys.length;
case 'NOT_ANY':
return foundSecondary.length === 0;
case 'NOT_ALL':
return foundSecondary.length < secondaryKeys.length;
default:
return false;
}
}
// ----- 10. ГЕНЕРАЦИЯ ИНЪЕКЦИЙ (возвращает объект с массивами) -----
async function generateInjections(chatMessages, currentUserText) {
if (!window.__LOREGRAPH__) {
logError(t('le_no_api'));
return null;
}
try {
const lorebooks = await window.__LOREGRAPH__.getLorebooks();
const activeLorebooks = lorebooks.filter(lb => lb.nodes && lb.nodes.length > 0);
console.log('[LB] ' + t('le_active_lorebooks'), activeLorebooks.map(lb => lb.name || 'unnamed'));
const allNodes = [];
for (const lb of activeLorebooks) {
allNodes.push(...lb.nodes);
}
// Определяем максимальную глубину сканирования
let maxDepth = 0;
if (useGlobalDepth) {
maxDepth = globalDepth;
} else {
let hasZeroDepth = false;
for (const node of allNodes) {
const d = node.scanDepth ?? 0;
if (d === 0) {
hasZeroDepth = true;
break;
}
if (d > maxDepth) maxDepth = d;
}
if (hasZeroDepth) maxDepth = 0;
}
if (maxDepth > 0 && chatMessages.length > maxDepth) {
chatMessages = chatMessages.slice(-maxDepth);
}
console.log('[LB DEBUG] Сообщения для поиска ключей (после обрезки):', chatMessages.map(m => m.message.trim()));
// Этап 1: фильтр chance (первичная проверка)
let candidates = [];
for (const node of allNodes) {
if (Math.random() * 100 > (node.chance ?? 100)) continue;
candidates.push(node);
}
// Этап 2: первичный отбор
const matched = [];
for (const node of candidates) {
const depth = useGlobalDepth ? globalDepth : (node.scanDepth ?? 0);
const recentMessages = depth > 0 ? chatMessages.slice(-depth) : chatMessages;
const texts = recentMessages.map(m => m.message || '').join('\n');
const caseSensitive = node.caseSensitive ?? false;
const wholeWords = node.wholeWords ?? false;
if (evaluateNodeTrigger(node, texts, caseSensitive, wholeWords)) {
matched.push({ node, depth: node.recursionDepth ?? 0 });
}
}
// Этап 3: рекурсивное расширение
const processedIds = new Set(matched.map(m => m.node.id));
const queue = matched.filter(m => m.depth > 0).map(m => ({ node: m.node, depth: m.depth }));
while (queue.length > 0) {
const { node: parentNode, depth: parentDepth } = queue.shift();
if (parentDepth <= 0) continue;
const text = parentNode.description || '';
if (!text) continue;
for (const candidateNode of allNodes) {
if (candidateNode.alwaysActive) continue;
if (processedIds.has(candidateNode.id)) continue;
const caseSensitive = candidateNode.caseSensitive ?? false;
const wholeWords = candidateNode.wholeWords ?? false;
if (evaluateNodeTrigger(candidateNode, text, caseSensitive, wholeWords)) {
const newDepth = parentDepth - 1;
matched.push({ node: candidateNode, depth: newDepth });
processedIds.add(candidateNode.id);
if (newDepth > 0) {
queue.push({ node: candidateNode, depth: newDepth });
}
}
}
}
candidates = matched.map(m => m.node);
// Этап 4: групповой отбор
const groups = {};
const ungrouped = [];
for (const node of candidates) {
if (node.groups && node.groups.length > 0) {
for (const g of node.groups) {
if (!groups[g]) groups[g] = [];
groups[g].push(node);
}
} else {
ungrouped.push(node);
}
}
let finalNodes = [...ungrouped];
for (const [groupName, groupNodes] of Object.entries(groups)) {
const totalWeight = groupNodes.reduce((sum, n) => sum + (n.weight || 100), 0);
let rand = Math.random() * totalWeight;
for (const n of groupNodes) {
rand -= (n.weight || 100);
if (rand <= 0) {
if (!finalNodes.includes(n)) finalNodes.push(n);
break;
}
}
}
// Этап 5: сортировка по priority
finalNodes.sort((a, b) => (b.priority || 10) - (a.priority || 10));
// console.log('До второй проверки шанса:', finalNodes.map(n => ({ label: n.label || n.id, chance: n.chance })));
// Этап 5.1: вторая проверка шанса для узлов с chance > 100
finalNodes = finalNodes.filter(node => {
const chance = node.chance ?? 100;
if (chance > 100) {
const secondChance = chance - 100;
const passed = Math.random() * 100 <= secondChance;
// console.log(`Вторая проверка для "${node.label || node.id}" (chance=${chance}): ${passed ? 'прошёл' : 'отсеян'}`);
return passed;
}
return true;
});
console.log(t('le_activated'), finalNodes.map(n => n.label || n.id));
// console.log('После второй проверки шанса:', finalNodes.map(n => n.label || n.id));
// Этап 6: распределение по целям
const injections = {
llm: [],
proxy: [],
summary: [],
user_appearance: [],
prefill: [],
last_message: []
};
let totalLength = 0;
const maxLen = maxInjectLength;
for (const node of finalNodes) {
if (!node.insertTarget) {
logError(t('le_no_target', { label: node.label || node.id }));
continue;
}
const pos = node.insertPosition || 'start';
let content = node.description || '';
if (!content) continue;
if (node.insertTarget === 'last_message' && node.oocInstruction) {
content = `(OOC: ${content})`;
}
let allowedContent = content;
if (totalLength + content.length > maxLen) {
if (limitBehavior === 'skip') continue;
else if (limitBehavior === 'trim') {
const remaining = maxLen - totalLength;
if (remaining <= 0) continue;
allowedContent = content.substring(0, remaining);
}
}
totalLength += allowedContent.length;
switch (node.insertTarget) {
case 'llm':
injections.llm.push({ pos, content: allowedContent });
break;
case 'last_message':
injections.last_message.push({ pos, content: allowedContent });
break;
case 'summary':
injections.summary.push({ pos, content: allowedContent });
break;
case 'user_appearance':
injections.user_appearance.push({ pos, content: allowedContent });
break;
case 'prefill':
injections.prefill.push({ pos, content: allowedContent });
break;
}
}
// console.log('[LB DEBUG] lastMessage инъекции:', JSON.stringify(injections.last_message));
return {
llm: injections.llm,
proxy: injections.llm,
summary: injections.summary,
user_appearance: injections.user_appearance,
prefill: injections.prefill,
lastMessage: injections.last_message
};
} catch (e) {
logError('Ошибка генерации инъекций: ' + e.message);
return null;
}
}
// ----- 11. ПЕРЕХВАТЧИК XHR /messages (для last_message при NEW) -----
const origXHROpen = XMLHttpRequest.prototype.open;
const origXHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
this._lbMethod = method;
this._lbUrl = url;
return origXHROpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function(body) {
if (this._lbMethod === 'POST' && this._lbUrl && this._lbUrl.includes('/messages')) {
this.addEventListener('load', function() {
if (this.status >= 200 && this.status < 300 && pendingLastMessageInjection) {
try {
const data = JSON.parse(this.responseText);
if (Array.isArray(data)) {
let modified = false;
let messageId = null;
let originalText = null;
for (const serverMsg of data) {
if (serverMsg && serverMsg.is_bot === false && typeof serverMsg.message === 'string') {
messageId = serverMsg.id;
originalText = serverMsg.message;
// Сохраняем исходный текст для последующего восстановления
lastMessageOriginalText = originalText;
lastMessageRestore = { id: messageId, originalText: originalText };
// Заменяем текст сообщения на сериализованные сырые инъекции
serverMsg.message = JSON.stringify(pendingLastMessageInjection);
modified = true;
}
}
if (modified) {
const modifiedResponse = JSON.stringify(data);
Object.defineProperty(this, 'responseText', {
get() { return modifiedResponse; },
configurable: true
});
console.log('[LB] Ответ /messages подменён: вставлены сырые инъекции last_message.');
}
}
} catch (e) {
console.warn('[LB] Ошибка при модификации ответа /messages:', e);
}
}
});
}
return origXHRSend.call(this, body);
};
// ----- 12. ПЕРЕХВАТЧИК WEBSOCKET (обработка last_message и восстановление) -----
const origWsSend = WebSocket.prototype.send;
WebSocket.prototype.send = function(data) {
if (isInjecting && this.url && this.url.includes('/generateAlpha')) {
// Если есть сырые инъекции last_message, обрабатываем их в WebSocket
if (pendingLastMessageInjection && lastMessageOriginalText) {
try {
if (typeof data === 'string') {
const body = JSON.parse(data);
if (Array.isArray(body.chatMessages)) {
// Находим последнее сообщение пользователя
for (let i = body.chatMessages.length - 1; i >= 0; i--) {
const msg = body.chatMessages[i];
const target = Array.isArray(msg) ? msg[msg.length - 1] : msg;
if (target && target.is_bot === false) {
// Заменяем сырые инъекции на итоговую строку
target.message = applyInjectionsToString(lastMessageOriginalText, pendingLastMessageInjection);
break;
}
}
}
data = JSON.stringify(body);
}
} catch (e) {
console.warn('[LB] Ошибка при обработке WebSocket last_message:', e);
}
// Очищаем временные данные
pendingLastMessageInjection = null;
lastMessageOriginalText = null;
}
// Восстановление статических полей
if (injectionOriginals) {
const stores = getReactStores();
if (stores) {
const { config, chatInfo, userStore } = stores;
if (config) {
config.llm_prompt = injectionOriginals.llm;
config.proxy_global_prompt = injectionOriginals.proxy;
if (config.generation_settings) {
config.generation_settings.prefill_text = injectionOriginals.prefill_text;
config.generation_settings.prefill_enabled = injectionOriginals.prefill_enabled;
}
}
if (chatInfo?.chat) {
chatInfo.chat.summary = injectionOriginals.summary;
}
if (userStore?.profile) {
userStore.profile.profile = injectionOriginals.user_appearance;
}
}
injectionOriginals = null;
}
// Восстановление последнего сообщения пользователя в React
if (lastMessageRestore) {
const stores = getReactStores();
if (stores && stores.chatStore && stores.chatStore.messagesStore) {
const ms = stores.chatStore.messagesStore;
const findMsg = (arr, id) => {
for (let i = arr.length - 1; i >= 0; i--) {
const item = arr[i];
if (Array.isArray(item)) {
for (const alt of item) if (alt.id === id) return alt;
} else if (item.id === id) return item;
}
return null;
};
const msg = findMsg(ms.messages, lastMessageRestore.id);
if (msg) msg.message = lastMessageRestore.originalText;
}
lastMessageRestore = null;
}
// Сброс таймера
if (injectionResetTimer) {
clearTimeout(injectionResetTimer);
injectionResetTimer = null;
}
isInjecting = false;
console.log('[LB] Инъекция завершена, оригиналы восстановлены.');
}
return origWsSend.call(this, data);
};
// Глобальные переменные для хранения оригиналов статических полей
let injectionOriginals = null;
// ----- 13. ИНЪЕКЦИЯ В REACT -----
async function injectIntoReact(btn, mode = 'NEW') {
if (paused || !active || !currentChatId || isInjecting) {
if (isInjecting) console.warn('[LB] Инъекция уже активна, пропускаем клик');
btn.click();
return;
}
pendingLastMessageInjection = null;
lastMessageOriginalText = null;
lastMessageRestore = null;
const textarea = document.querySelector(TEXTAREA_SELECTOR);
const userText = textarea ? textarea.value.trim() : '';
// Получаем React-сторы
const stores = getReactStores();
if (!stores) {
logError('Не удалось получить React-стора');
btn.click();
return;
}
// Собираем сообщения из React
let chatMessages = getMessagesFromReact(stores);
// Для перегенерации исключаем последнее сообщение бота
if (mode === 'ALTERNATIVE' && chatMessages.length > 0) {
chatMessages.pop();
}
// Для обычной отправки добавляем текущее сообщение пользователя
if (mode === 'NEW' && userText) {
chatMessages.push({ is_bot: false, message: userText });
}
const injections = await generateInjections(chatMessages, userText);
if (!injections) {
btn.click();
return;
}
const { config, chatInfo, userStore } = stores;
// Сохраняем оригиналы статических полей
injectionOriginals = {
llm: config.llm_prompt,
proxy: config.proxy_global_prompt,
summary: chatInfo?.chat?.summary,
user_appearance: userStore.profile?.profile,
prefill_text: config.generation_settings?.prefill_text,
prefill_enabled: config.generation_settings?.prefill_enabled
};
// Применяем инъекции к статическим полям
if (injections.llm && injections.llm.length > 0) {
config.llm_prompt = applyInjectionsToString(config.llm_prompt, injections.llm);
config.proxy_global_prompt = applyInjectionsToString(config.proxy_global_prompt, injections.llm);
}
if (injections.summary && injections.summary.length > 0 && chatInfo?.chat) {
chatInfo.chat.summary = applyInjectionsToString(chatInfo.chat.summary, injections.summary);
}
if (injections.user_appearance && injections.user_appearance.length > 0 && userStore.profile) {
userStore.profile.profile = applyInjectionsToString(userStore.profile.profile, injections.user_appearance);
}
if (injections.prefill && injections.prefill.length > 0 && config.generation_settings) {
config.generation_settings.prefill_text = applyInjectionsToString(config.generation_settings.prefill_text, injections.prefill);
}
// Обработка last_message
if (injections.lastMessage && injections.lastMessage.length > 0) {
if (mode === 'NEW') {
// Откладываем до ответа XHR /messages
pendingLastMessageInjection = injections.lastMessage;
lastMessageOriginalText = null;
} else {
// CONTINUE, ALTERNATIVE, RETRY
const target = findLastUserMessageObject(stores);
if (target) {
const original = target.message;
const modified = applyInjectionsToString(original, injections.lastMessage);
lastMessageRestore = { id: target.id, originalText: original };
target.message = modified;
// console.log('[LB DEBUG] last_message инъекция в React для mode=', mode, ':', target.message);
}
}
}
isInjecting = true;
// Страховочный сброс через 5 секунд
if (injectionResetTimer) clearTimeout(injectionResetTimer);
injectionResetTimer = setTimeout(() => {
if (isInjecting) {
console.warn('[LB] Принудительный сброс isInjecting (WebSocket не перехвачен)');
// Восстановление статических полей
if (injectionOriginals) {
const stores = getReactStores();
if (stores) {
const { config, chatInfo, userStore } = stores;
if (config) {
config.llm_prompt = injectionOriginals.llm;
config.proxy_global_prompt = injectionOriginals.proxy;
if (config.generation_settings) {
config.generation_settings.prefill_text = injectionOriginals.prefill_text;
config.generation_settings.prefill_enabled = injectionOriginals.prefill_enabled;
}
}
if (chatInfo?.chat) {
chatInfo.chat.summary = injectionOriginals.summary;
}
if (userStore?.profile) {
userStore.profile.profile = injectionOriginals.user_appearance;
}
}
injectionOriginals = null;
}
// Восстановление последнего сообщения
if (lastMessageRestore) {
const stores = getReactStores();
if (stores && stores.chatStore && stores.chatStore.messagesStore) {
const ms = stores.chatStore.messagesStore;
const findMsg = (arr, id) => {
for (let i = arr.length - 1; i >= 0; i--) {
const item = arr[i];
if (Array.isArray(item)) {
for (const alt of item) if (alt.id === id) return alt;
} else if (item.id === id) return item;
}
return null;
};
const msg = findMsg(ms.messages, lastMessageRestore.id);
if (msg) msg.message = lastMessageRestore.originalText;
}
lastMessageRestore = null;
}
pendingLastMessageInjection = null;
lastMessageOriginalText = null;
isInjecting = false;
injectionResetTimer = null;
}
}, 5000);
// Программный клик
btn.click();
}
// ----- 14. ЯВНАЯ УСТАНОВКА ОБРАБОТЧИКОВ -----
function isRegenerateCreatingNew(stores) {
const chatStore = stores.chatStore;
if (!chatStore || !chatStore.messagesStore) return true;
const messagesStore = chatStore.messagesStore;
const messages = messagesStore.messages;
if (!Array.isArray(messages) || messages.length === 0) return true;
const lastItem = messages[messages.length - 1];
if (!Array.isArray(lastItem)) return true;
const totalAlternatives = lastItem.length;
const currentIndex = messagesStore.lastMessageIndex;
return currentIndex >= totalAlternatives - 1;
}
function attachButtonHandlers() {
// Обычная отправка
document.querySelectorAll(SEND_BTN_SELECTOR).forEach(btn => {
if (!btn._lbSendHandlerAttached) {
const hasIcon = btn.querySelector('svg path[d^="M34.9 289.5"]') !== null;
if (!hasIcon) return;
btn._lbSendHandlerAttached = true;
btn.addEventListener('click', (e) => {
if (btn.classList.contains('_stopButton_1bz7u_1') || btn.querySelector('svg path[d^="M400 32H48"]') !== null) return;
if (!e.isTrusted || !active || paused || isInjecting) return;
e.preventDefault();
e.stopImmediatePropagation();
injectIntoReact(btn, 'NEW');
});
}
});
// Перегенерация
document.querySelectorAll(REGEN_SEL).forEach(wrapper => {
const btn = wrapper.querySelector('button');
if (btn && !btn._lbRegenHandlerAttached) {
btn._lbRegenHandlerAttached = true;
btn.addEventListener('click', (e) => {
if (!e.isTrusted || !active || paused || isInjecting) return;
const stores = getReactStores();
if (stores && !isRegenerateCreatingNew(stores)) {
console.log('[LB] Листание генераций, инъекция не требуется');
return;
}
e.preventDefault();
e.stopImmediatePropagation();
injectIntoReact(btn, 'ALTERNATIVE');
});
}
});
// Продолжение
document.querySelectorAll(CONTINUE_SEL).forEach(btn => {
if (btn._lbContinueHandlerAttached) return;
const hasIcon = btn.querySelector('svg path[d^="M500.5 231.4l-192-160"]') !== null;
if (!hasIcon) return;
btn._lbContinueHandlerAttached = true;
btn.addEventListener('click', (e) => {
if (!e.isTrusted || !active || paused || isInjecting) return;
e.preventDefault();
e.stopImmediatePropagation();
injectIntoReact(btn, 'CONTINUE');
});
});
// Retry (Заново)
document.querySelectorAll(RETRY_SEL).forEach(btn => {
if (btn._lbRetryHandlerAttached) return;
const hasIcon = btn.querySelector('svg path[d^="M256 48C141.6"]') !== null;
if (!hasIcon) return;
btn._lbRetryHandlerAttached = true;
btn.addEventListener('click', (e) => {
if (!e.isTrusted || !active || paused || isInjecting) return;
e.preventDefault();
e.stopImmediatePropagation();
injectIntoReact(btn, 'RETRY');
});
});
// Enter
attachTextareaHandler();
}
function attachTextareaHandler() {
const textarea = document.querySelector(TEXTAREA_SELECTOR);
if (textarea && !textarea._lbEnterHandlerAttached) {
textarea._lbEnterHandlerAttached = true;
textarea.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
if (!active || paused || isInjecting) return;
if (textarea.value.trim() === '') return;
const sendBtn = document.querySelector(SEND_BTN_SELECTOR);
if (!sendBtn) return;
if (sendBtn.disabled) return;
e.preventDefault();
e.stopPropagation();
injectIntoReact(sendBtn, 'NEW');
});
}
}
// Мутационный наблюдатель
const observer = new MutationObserver(() => {
attachButtonHandlers();
});
observer.observe(document.body, { childList: true, subtree: true });
attachButtonHandlers();
// ----- 15. РЕГИСТРАЦИЯ МОДУЛЯ -----
window.__MANAGER__.register(MODULE_ID, {
title: t('le_title'),
type: 'interface',
requires: ['__LOREGRAPH__'],
content: getContent,
onActivate: () => bindWidgetEvents(),
onEnable: async (chatId) => {
active = true;
currentChatId = chatId;
await loadSettings();
attachButtonHandlers();
},
onDisable: () => {
active = false;
currentChatId = null;
if (injectionResetTimer) {
clearTimeout(injectionResetTimer);
injectionResetTimer = null;
}
isInjecting = false;
injectionOriginals = null;
pendingLastMessageInjection = null;
lastMessageRestore = null;
lastMessageOriginalText = null;
}
});
// Обновление языка
window.addEventListener('languageChanged', () => {
if (window.__MANAGER__) {
window.__MANAGER__.update(MODULE_ID, getContent());
window.__MANAGER__.setModuleTitle(MODULE_ID, t('le_title'));
bindWidgetEvents();
}
});
console.log('[LB] Модуль Lorebook Engine зарегистрирован');
})();