Files
LittleWhiteBox/modules/message-preview.js
2026-01-17 16:34:39 +08:00

670 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { extension_settings, getContext } from "../../../../extensions.js";
import { saveSettingsDebounced, eventSource, event_types } from "../../../../../script.js";
import { EXT_ID } from "../core/constants.js";
const C = { MAX_HISTORY: 10, CHECK: 200, DEBOUNCE: 300, CLEAN: 300000, TARGET: "/api/backends/chat-completions/generate", TIMEOUT: 30, ASSOC_DELAY: 1000, REQ_WINDOW: 30000 };
const S = { active: false, isPreview: false, isLong: false, isHistoryUiBound: false, previewData: null, previewIds: new Set(), interceptedIds: [], history: [], listeners: [], resolve: null, reject: null, sendBtnWasDisabled: false, longPressTimer: null, longPressDelay: 1000, chatLenBefore: 0, restoreLong: null, cleanTimer: null, previewAbort: null, tailAPI: null, genEndedOff: null, cleanupFallback: null, pendingPurge: false };
const $q = (sel) => $(sel);
const ON = (e, c) => eventSource.on(e, c);
const OFF = (e, c) => eventSource.removeListener(e, c);
const now = () => Date.now();
const geEnabled = () => { try { return ("isXiaobaixEnabled" in window) ? !!window.isXiaobaixEnabled : true; } catch { return true; } };
const debounce = (fn, w) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), w); }; };
const safeJson = (t) => { try { return JSON.parse(t); } catch { return null; } };
const readText = async (b) => { try { if (!b) return ""; if (typeof b === "string") return b; if (b instanceof Blob) return await b.text(); if (b instanceof URLSearchParams) return b.toString(); if (typeof b === "object" && typeof b.text === "function") return await b.text(); } catch { } return ""; };
function isSafeBody(body) { if (!body) return true; return (typeof body === "string" || body instanceof Blob || body instanceof URLSearchParams || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || (typeof FormData !== "undefined" && body instanceof FormData)); }
async function safeReadBodyFromInput(input, options) { try { if (input instanceof Request) return await readText(input.clone()); const body = options?.body; if (!isSafeBody(body)) return ""; return await readText(body); } catch { return ""; } }
const isGen = (u) => String(u || "").includes(C.TARGET);
const isTarget = async (input, opt = {}) => { try { const url = input instanceof Request ? input.url : input; if (!isGen(url)) return false; const text = await safeReadBodyFromInput(input, opt); return text ? text.includes('"messages"') : true; } catch { return input instanceof Request ? isGen(input.url) : isGen(input); } };
const getSettings = () => { const d = extension_settings[EXT_ID] || (extension_settings[EXT_ID] = {}); d.preview = d.preview || { enabled: false, timeoutSeconds: C.TIMEOUT }; d.recorded = d.recorded || { enabled: true }; d.preview.timeoutSeconds = C.TIMEOUT; return d; };
function injectPreviewModalStyles() {
if (document.getElementById('message-preview-modal-styles')) return;
const style = document.createElement('style');
style.id = 'message-preview-modal-styles';
style.textContent = `
.mp-overlay{position:fixed;inset:0;background:none;z-index:9999;display:flex;align-items:center;justify-content:center;pointer-events:none}
.mp-modal{
width:clamp(360px,55vw,860px);
max-width:95vw;
background:var(--SmartThemeBlurTintColor);
border:2px solid var(--SmartThemeBorderColor);
border-radius:10px;
box-shadow:0 8px 16px var(--SmartThemeShadowColor);
pointer-events:auto;
display:flex;
flex-direction:column;
height:80vh;
max-height:calc(100vh - 60px);
resize:both;
overflow:hidden;
}
.mp-header{display:flex;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--SmartThemeBorderColor);font-weight:600;cursor:move;flex-shrink:0}
.mp-body{height:60vh;overflow:auto;padding:10px;flex:1;min-height:160px}
.mp-footer{display:flex;gap:8px;justify-content:flex-end;padding:12px 14px;border-top:1px solid var(--SmartThemeBorderColor);flex-shrink:0}
.mp-close{cursor:pointer}
.mp-btn{cursor:pointer;border:1px solid var(--SmartThemeBorderColor);background:var(--SmartThemeBlurTintColor);padding:6px 10px;border-radius:6px}
.mp-search-input{padding:4px 8px;border:1px solid var(--SmartThemeBorderColor);border-radius:4px;background:var(--SmartThemeShadowColor);color:inherit;font-size:12px;width:120px}
.mp-search-btn{padding:4px 6px;font-size:12px;min-width:24px;text-align:center}
.mp-search-info{font-size:12px;opacity:.8;white-space:nowrap}
.message-preview-container{height:100%}
.message-preview-content-box{height:100%;overflow:auto}
.mp-highlight{background-color:yellow;color:black;padding:1px 2px;border-radius:2px}
.mp-highlight.current{background-color:orange;font-weight:bold}
@media (max-width:999px){
.mp-overlay{position:absolute;inset:0;align-items:flex-start}
.mp-modal{width:100%;max-width:100%;max-height:100%;margin:0;border-radius:10px 10px 0 0;height:100vh;resize:none}
.mp-header{padding:8px 14px}
.mp-body{padding:8px}
.mp-footer{padding:8px 14px;flex-wrap:wrap;gap:6px}
.mp-search-input{width:150px}
}
`;
document.head.appendChild(style);
}
function setupModalDrag(modal, overlay, header) {
modal.style.position = 'absolute';
modal.style.left = '50%';
modal.style.top = '50%';
modal.style.transform = 'translate(-50%, -50%)';
let dragging = false, sx = 0, sy = 0, sl = 0, st = 0;
function onDown(e) {
if (!(e instanceof PointerEvent) || e.button !== 0) return;
dragging = true;
const overlayRect = overlay.getBoundingClientRect();
const rect = modal.getBoundingClientRect();
modal.style.left = (rect.left - overlayRect.left) + 'px';
modal.style.top = (rect.top - overlayRect.top) + 'px';
modal.style.transform = '';
sx = e.clientX; sy = e.clientY;
sl = parseFloat(modal.style.left) || 0;
st = parseFloat(modal.style.top) || 0;
window.addEventListener('pointermove', onMove, { passive: true });
window.addEventListener('pointerup', onUp, { once: true });
e.preventDefault();
}
function onMove(e) {
if (!dragging) return;
const dx = e.clientX - sx, dy = e.clientY - sy;
let nl = sl + dx, nt = st + dy;
const maxLeft = (overlay.clientWidth || overlay.getBoundingClientRect().width) - modal.offsetWidth;
const maxTop = (overlay.clientHeight || overlay.getBoundingClientRect().height) - modal.offsetHeight;
nl = Math.max(0, Math.min(maxLeft, nl));
nt = Math.max(0, Math.min(maxTop, nt));
modal.style.left = nl + 'px';
modal.style.top = nt + 'px';
}
function onUp() {
dragging = false;
window.removeEventListener('pointermove', onMove);
}
header.addEventListener('pointerdown', onDown);
}
function createMovableModal(title, content) {
injectPreviewModalStyles();
const overlay = document.createElement('div');
overlay.className = 'mp-overlay';
const modal = document.createElement('div');
modal.className = 'mp-modal';
const header = document.createElement('div');
header.className = 'mp-header';
// Template-only UI markup (title is escaped by caller).
// eslint-disable-next-line no-unsanitized/property
header.innerHTML = `<span>${title}</span><span class="mp-close">✕</span>`;
const body = document.createElement('div');
body.className = 'mp-body';
// Content is already escaped before building the preview.
// eslint-disable-next-line no-unsanitized/property
body.innerHTML = content;
const footer = document.createElement('div');
footer.className = 'mp-footer';
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property
footer.innerHTML = `
<input type="text" class="mp-search-input" placeholder="搜索..." />
<button class="mp-btn mp-search-btn" id="mp-search-prev">↑</button>
<button class="mp-btn mp-search-btn" id="mp-search-next">↓</button>
<span class="mp-search-info" id="mp-search-info"></span>
<button class="mp-btn" id="mp-toggle-format">切换原始格式</button>
<button class="mp-btn" id="mp-focus-search">搜索</button>
<button class="mp-btn" id="mp-close">关闭</button>
`;
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
overlay.appendChild(modal);
setupModalDrag(modal, overlay, header);
let searchResults = [];
let currentIndex = -1;
const searchInput = footer.querySelector('.mp-search-input');
const searchInfo = footer.querySelector('#mp-search-info');
const prevBtn = footer.querySelector('#mp-search-prev');
const nextBtn = footer.querySelector('#mp-search-next');
function clearHighlights() {
body.querySelectorAll('.mp-highlight').forEach(el => {
// Controlled markup generated locally.
// eslint-disable-next-line no-unsanitized/property
el.outerHTML = el.innerHTML;
});
}
function performSearch(query) {
clearHighlights();
searchResults = [];
currentIndex = -1;
if (!query.trim()) { searchInfo.textContent = ''; return; }
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null, false);
const nodes = [];
let node;
while ((node = walker.nextNode())) { nodes.push(node); }
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
nodes.forEach(textNode => {
const text = textNode.textContent;
if (!text || !regex.test(text)) return;
let html = text;
let offset = 0;
regex.lastIndex = 0;
const matches = [...text.matchAll(regex)];
matches.forEach((m) => {
const start = m.index + offset;
const end = start + m[0].length;
const before = html.slice(0, start);
const mid = html.slice(start, end);
const after = html.slice(end);
const span = `<span class="mp-highlight" data-search-index="${searchResults.length}">${mid}</span>`;
html = before + span + after;
offset += span.length - m[0].length;
searchResults.push({});
});
const parent = textNode.parentElement;
// Controlled markup generated locally.
// eslint-disable-next-line no-unsanitized/property
parent.innerHTML = parent.innerHTML.replace(text, html);
});
updateSearchInfo();
if (searchResults.length > 0) { currentIndex = 0; highlightCurrent(); }
}
function updateSearchInfo() { if (!searchResults.length) searchInfo.textContent = searchInput.value.trim() ? '无结果' : ''; else searchInfo.textContent = `${currentIndex + 1}/${searchResults.length}`; }
function highlightCurrent() {
body.querySelectorAll('.mp-highlight.current').forEach(el => el.classList.remove('current'));
if (currentIndex >= 0 && currentIndex < searchResults.length) {
const el = body.querySelector(`.mp-highlight[data-search-index="${currentIndex}"]`);
if (el) { el.classList.add('current'); el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
}
}
function navigateSearch(direction) {
if (!searchResults.length) return;
if (direction === 'next') currentIndex = (currentIndex + 1) % searchResults.length;
else currentIndex = currentIndex <= 0 ? searchResults.length - 1 : currentIndex - 1;
updateSearchInfo();
highlightCurrent();
}
let searchTimeout;
searchInput.addEventListener('input', (e) => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => performSearch(e.target.value), 250); });
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) navigateSearch('prev'); else navigateSearch('next'); } else if (e.key === 'Escape') { searchInput.value = ''; performSearch(''); } });
prevBtn.addEventListener('click', () => navigateSearch('prev'));
nextBtn.addEventListener('click', () => navigateSearch('next'));
footer.querySelector('#mp-focus-search')?.addEventListener('click', () => { searchInput.focus(); if (searchInput.value) navigateSearch('next'); });
const close = () => overlay.remove();
header.querySelector('.mp-close').addEventListener('click', close);
footer.querySelector('#mp-close').addEventListener('click', close);
footer.querySelector('#mp-toggle-format').addEventListener('click', (e) => {
const box = body.querySelector(".message-preview-content-box");
const f = box?.querySelector(".mp-state-formatted");
const r = box?.querySelector(".mp-state-raw");
if (!(f && r)) return;
const showRaw = r.style.display === "none";
r.style.display = showRaw ? "block" : "none";
f.style.display = showRaw ? "none" : "block";
e.currentTarget.textContent = showRaw ? "切换整理格式" : "切换原始格式";
searchInput.value = "";
clearHighlights();
searchInfo.textContent = "";
searchResults = [];
currentIndex = -1;
});
document.body.appendChild(overlay);
return { overlay, modal, body, close };
}
const MIRROR = { MERGE: "merge", MERGE_TOOLS: "merge_tools", SEMI: "semi", SEMI_TOOLS: "semi_tools", STRICT: "strict", STRICT_TOOLS: "strict_tools", SINGLE: "single" };
const roleMap = { system: { label: "SYSTEM:", color: "#F7E3DA" }, user: { label: "USER:", color: "#F0ADA7" }, assistant: { label: "ASSISTANT:", color: "#6BB2CC" } };
const escapeHtml = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]));
const colorXml = (t) => {
const safe = escapeHtml(t);
return safe.replace(/&lt;([^&]+?)&gt;/g, '<span style="color:#999;font-weight:bold;">&lt;$1&gt;</span>');
};
const getNames = (req) => { const n = { charName: String(req?.char_name || ""), userName: String(req?.user_name || ""), groupNames: Array.isArray(req?.group_names) ? req.group_names.map(String) : [] }; n.startsWithGroupName = (m) => n.groupNames.some((g) => String(m || "").startsWith(`${g}: `)); return n; };
const toText = (m) => { const c = m?.content; if (typeof c === "string") return c; if (Array.isArray(c)) return c.map((p) => p?.type === "text" ? String(p.text || "") : p?.type === "image_url" ? "[image]" : p?.type === "video_url" ? "[video]" : typeof p === "string" ? p : (typeof p?.content === "string" ? p.content : "")).filter(Boolean).join("\n\n"); return String(c || ""); };
const applyName = (m, n) => { const { role, name } = m; let t = toText(m); if (role === "system" && name === "example_assistant") { if (n.charName && !t.startsWith(`${n.charName}: `) && !n.startsWithGroupName(t)) t = `${n.charName}: ${t}`; } else if (role === "system" && name === "example_user") { if (n.userName && !t.startsWith(`${n.userName}: `)) t = `${n.userName}: ${t}`; } else if (name && role !== "system" && !t.startsWith(`${name}: `)) t = `${name}: ${t}`; return { ...m, content: t, name: undefined }; };
function mergeMessages(messages, names, { strict = false, placeholders = false, single = false, tools = false } = {}) {
if (!Array.isArray(messages)) return [];
let mapped = messages.map((m) => applyName({ ...m }, names)).map((x) => { const m = { ...x }; if (!tools) { if (m.role === "tool") m.role = "user"; delete m.tool_calls; delete m.tool_call_id; } if (single) { if (m.role === "assistant") { const t = String(m.content || ""); if (names.charName && !t.startsWith(`${names.charName}: `) && !names.startsWithGroupName(t)) m.content = `${names.charName}: ${t}`; } if (m.role === "user") { const t = String(m.content || ""); if (names.userName && !t.startsWith(`${names.userName}: `)) m.content = `${names.userName}: ${t}`; } m.role = "user"; } return m; });
const squash = (arr) => { const out = []; for (const m of arr) { if (out.length && out[out.length - 1].role === m.role && String(m.content || "").length && m.role !== "tool") out[out.length - 1].content += `\n\n${m.content}`; else out.push(m); } return out; };
let sq = squash(mapped);
if (strict) { for (let i = 0; i < sq.length; i++) if (i > 0 && sq[i].role === "system") sq[i].role = "user"; if (placeholders) { if (!sq.length) sq.push({ role: "user", content: "[Start a new chat]" }); else if (sq[0].role === "system" && (sq.length === 1 || sq[1].role !== "user")) sq.splice(1, 0, { role: "user", content: "[Start a new chat]" }); else if (sq[0].role !== "system" && sq[0].role !== "user") sq.unshift({ role: "user", content: "[Start a new chat]" }); } return squash(sq); }
if (!sq.length) sq.push({ role: "user", content: "[Start a new chat]" });
return sq;
}
function mirror(requestData) {
try {
let type = String(requestData?.custom_prompt_post_processing || "").toLowerCase();
const source = String(requestData?.chat_completion_source || "").toLowerCase();
if (source === "perplexity") type = MIRROR.STRICT;
const names = getNames(requestData || {}), src = Array.isArray(requestData?.messages) ? JSON.parse(JSON.stringify(requestData.messages)) : [];
const mk = (o) => mergeMessages(src, names, o);
switch (type) {
case MIRROR.MERGE: return mk({ strict: false });
case MIRROR.MERGE_TOOLS: return mk({ strict: false, tools: true });
case MIRROR.SEMI: return mk({ strict: true });
case MIRROR.SEMI_TOOLS: return mk({ strict: true, tools: true });
case MIRROR.STRICT: return mk({ strict: true, placeholders: true });
case MIRROR.STRICT_TOOLS: return mk({ strict: true, placeholders: true, tools: true });
case MIRROR.SINGLE: return mk({ strict: true, single: true });
default: return src;
}
} catch { return Array.isArray(requestData?.messages) ? requestData.messages : []; }
}
const finalMsgs = (d) => { try { if (d?.requestData?.messages) return mirror(d.requestData); if (Array.isArray(d?.messages)) return d.messages; return []; } catch { return Array.isArray(d?.messages) ? d.messages : []; } };
const formatPreview = (d) => {
const msgs = finalMsgs(d);
let out = `↓酒馆日志↓(${msgs.length})\n${"-".repeat(30)}\n`;
msgs.forEach((m, i) => {
const txt = String(m.content || "");
const safeTxt = escapeHtml(txt);
const rm = roleMap[m.role] || { label: `${String(m.role || "").toUpperCase()}:`, color: "#FFF" };
out += `<div style="color:${rm.color};font-weight:bold;margin-top:${i ? "15px" : "0"};">${rm.label}</div>`;
out += /<[^>]+>/g.test(txt) ? `<pre style="white-space:pre-wrap;margin:5px 0;color:${rm.color};">${colorXml(txt)}</pre>` : `<div style="margin:5px 0;color:${rm.color};white-space:pre-wrap;">${safeTxt}</div>`;
});
return out;
};
const stripTop = (o) => { try { if (!o || typeof o !== "object") return o; if (Array.isArray(o)) return o; const messages = Array.isArray(o.messages) ? JSON.parse(JSON.stringify(o.messages)) : undefined; return typeof messages !== "undefined" ? { messages } : {}; } catch { return {}; } };
const formatRaw = (d) => { try { const hasReq = Array.isArray(d?.requestData?.messages), hasMsgs = !hasReq && Array.isArray(d?.messages); let obj; if (hasReq) { const req = JSON.parse(JSON.stringify(d.requestData)); try { req.messages = mirror(req); } catch { } obj = req; } else if (hasMsgs) { const fake = { ...(d || {}), messages: d.messages }; let mm = null; try { mm = mirror(fake); } catch { } obj = { ...(d || {}), messages: mm || d.messages }; } else obj = d?.requestData ?? d; obj = stripTop(obj); return colorXml(JSON.stringify(obj, null, 2)); } catch { try { return colorXml(String(d)); } catch { return ""; } } };
const buildPreviewHtml = (d) => { const formatted = formatPreview(d), raw = formatRaw(d); return `<div class="message-preview-container"><div class="message-preview-content-box"><div class="mp-state-formatted">${formatted}</div><pre class="mp-state-raw" style="display:none;">${raw}</pre></div></div>`; };
const openPopup = async (html, title) => { createMovableModal(title, html); };
const displayPreview = async (d) => { try { await openPopup(buildPreviewHtml(d), "消息拦截"); } catch { toastr.error("显示拦截失败"); } };
const pushHistory = (r) => { S.history.unshift(r); if (S.history.length > C.MAX_HISTORY) S.history.length = C.MAX_HISTORY; };
const extractUser = (ms) => { if (!Array.isArray(ms)) return ""; for (let i = ms.length - 1; i >= 0; i--) if (ms[i]?.role === "user") return ms[i].content || ""; return ""; };
async function recordReal(input, options) {
try {
const url = input instanceof Request ? input.url : input;
const body = await safeReadBodyFromInput(input, options);
if (!body) return;
const data = safeJson(body) || {}, ctx = getContext();
pushHistory({ url, method: options?.method || (input instanceof Request ? input.method : "POST"), requestData: data, messages: data.messages || [], model: data.model || "Unknown", timestamp: now(), messageId: ctx.chat?.length || 0, characterName: ctx.characters?.[ctx.characterId]?.name || "Unknown", userInput: extractUser(data.messages || []), isRealRequest: true });
setTimeout(() => { if (S.history[0] && !S.history[0].associatedMessageId) S.history[0].associatedMessageId = ctx.chat?.length || 0; }, C.ASSOC_DELAY);
} catch { }
}
const findRec = (id) => {
if (!S.history.length) return null;
const preds = [(r) => r.associatedMessageId === id, (r) => r.messageId === id, (r) => r.messageId === id - 1, (r) => Math.abs(r.messageId - id) <= 1];
for (const p of preds) { const m = S.history.find(p); if (m) return m; }
const cs = S.history.filter((r) => r.messageId <= id + 2);
return cs.length ? cs.sort((a, b) => b.messageId - a.messageId)[0] : S.history[0];
};
// Improved purgePreviewArtifacts - follows SillyTavern's batch delete pattern
async function purgePreviewArtifacts() {
try {
if (!S.pendingPurge) return;
S.pendingPurge = false;
const ctx = getContext();
const chat = Array.isArray(ctx.chat) ? ctx.chat : [];
const start = Math.max(0, Number(S.chatLenBefore) || 0);
if (start >= chat.length) return;
// 1. Remove DOM elements (following SillyTavern's pattern from #dialogue_del_mes_ok)
const $chat = $('#chat');
$chat.find(`.mes[mesid="${start}"]`).nextAll('.mes').addBack().remove();
// 2. Truncate chat array
chat.length = start;
// 3. Update last_mes class
$('#chat .mes').removeClass('last_mes');
$('#chat .mes').last().addClass('last_mes');
// 4. Save chat and emit MESSAGE_DELETED event (critical for other plugins)
ctx.saveChat?.();
await eventSource.emit(event_types.MESSAGE_DELETED, start);
} catch (e) {
console.error('[message-preview] purgePreviewArtifacts error', e);
}
}
function oneShotOnLast(ev, handler) {
const wrapped = (...args) => {
try { handler(...args); } finally { off(); }
};
let off = () => { };
if (typeof eventSource.makeLast === "function") {
eventSource.makeLast(ev, wrapped);
off = () => {
try { eventSource.removeListener?.(ev, wrapped); } catch { }
try { eventSource.off?.(ev, wrapped); } catch { }
};
} else if (S.tailAPI?.onLast) {
const disposer = S.tailAPI.onLast(ev, wrapped);
off = () => { try { disposer?.(); } catch { } };
} else {
eventSource.on(ev, wrapped);
off = () => { try { eventSource.removeListener?.(ev, wrapped); } catch { } };
}
return off;
}
function installEventSourceTail(es) {
if (!es || es.__lw_tailInstalled) return es?.__lw_tailAPI || null;
const SYM = { MW_STACK: Symbol.for("lwbox.es.emitMiddlewareStack"), BASE: Symbol.for("lwbox.es.emitBase"), ORIG_DESC: Symbol.for("lwbox.es.emit.origDesc"), COMPOSED: Symbol.for("lwbox.es.emit.composed"), ID: Symbol.for("lwbox.middleware.identity") };
const getFnFromDesc = (d) => { try { if (typeof d?.value === "function") return d.value; if (typeof d?.get === "function") { const v = d.get.call(es); if (typeof v === "function") return v; } } catch { } return es.emit?.bind?.(es) || es.emit; };
const compose = (base, stack) => stack.reduce((acc, mw) => mw(acc), base);
const tails = new Map();
const addTail = (ev, fn) => { if (typeof fn !== "function") return () => { }; const arr = tails.get(ev) || []; arr.push(fn); tails.set(ev, arr); return () => { const a = tails.get(ev); if (!a) return; const i = a.indexOf(fn); if (i >= 0) a.splice(i, 1); }; };
const runTails = (ev, args) => { const arr = tails.get(ev); if (!arr?.length) return; for (const h of arr.slice()) { try { h(...args); } catch (e) { } } };
const makeTailMw = () => { const mw = (next) => function patchedEmit(ev, ...args) { let r; try { r = next.call(this, ev, ...args); } catch (e) { queueMicrotask(() => runTails(ev, args)); throw e; } if (r && typeof r.then === "function") r.finally(() => runTails(ev, args)); else queueMicrotask(() => runTails(ev, args)); return r; }; Object.defineProperty(mw, SYM.ID, { value: true }); return Object.freeze(mw); };
const ensureAccessor = () => { try { const d = Object.getOwnPropertyDescriptor(es, "emit"); if (!es[SYM.ORIG_DESC]) es[SYM.ORIG_DESC] = d || null; es[SYM.BASE] ||= getFnFromDesc(d); Object.defineProperty(es, "emit", { configurable: true, enumerable: d?.enumerable ?? true, get() { return reapply(); }, set(v) { if (typeof v === "function") { es[SYM.BASE] = v; queueMicrotask(reapply); } } }); } catch { } };
const reapply = () => { try { const base = es[SYM.BASE] || getFnFromDesc(Object.getOwnPropertyDescriptor(es, "emit")) || es.emit.bind(es); const stack = es[SYM.MW_STACK] || (es[SYM.MW_STACK] = []); let idx = stack.findIndex((m) => m && m[SYM.ID]); if (idx === -1) { stack.push(makeTailMw()); idx = stack.length - 1; } if (idx !== stack.length - 1) { const mw = stack[idx]; stack.splice(idx, 1); stack.push(mw); } const composed = compose(base, stack) || base; if (!es[SYM.COMPOSED] || es[SYM.COMPOSED]._base !== base || es[SYM.COMPOSED]._stack !== stack) { composed._base = base; composed._stack = stack; es[SYM.COMPOSED] = composed; } return es[SYM.COMPOSED]; } catch { return es.emit; } };
ensureAccessor();
queueMicrotask(reapply);
const api = { onLast: (e, h) => addTail(e, h), removeLast: (e, h) => { const a = tails.get(e); if (!a) return; const i = a.indexOf(h); if (i >= 0) a.splice(i, 1); }, uninstall() { try { const s = es[SYM.MW_STACK]; const i = Array.isArray(s) ? s.findIndex((m) => m && m[SYM.ID]) : -1; if (i >= 0) s.splice(i, 1); const orig = es[SYM.ORIG_DESC]; if (orig) { try { Object.defineProperty(es, "emit", orig); } catch { Object.defineProperty(es, "emit", { configurable: true, enumerable: true, writable: true, value: es[SYM.BASE] || es.emit }); } } else { Object.defineProperty(es, "emit", { configurable: true, enumerable: true, writable: true, value: es[SYM.BASE] || es.emit }); } } catch { } delete es.__lw_tailInstalled; delete es.__lw_tailAPI; tails.clear(); } };
Object.defineProperty(es, "__lw_tailInstalled", { value: true });
Object.defineProperty(es, "__lw_tailAPI", { value: api });
return api;
}
let __installed = false;
const MW_KEY = Symbol.for("lwbox.fetchMiddlewareStack");
const BASE_KEY = Symbol.for("lwbox.fetchBase");
const ORIG_KEY = Symbol.for("lwbox.fetch.origDesc");
const CMP_KEY = Symbol.for("lwbox.fetch.composed");
const ID = Symbol.for("lwbox.middleware.identity");
const getFetchFromDesc = (d) => { try { if (typeof d?.value === "function") return d.value; if (typeof d?.get === "function") { const v = d.get.call(window); if (typeof v === "function") return v; } } catch { } return globalThis.fetch; };
const compose = (base, stack) => stack.reduce((acc, mw) => mw(acc), base);
const withTimeout = (p, ms = 200) => { try { return Promise.race([p, new Promise((r) => setTimeout(r, ms))]); } catch { return p; } };
const ensureAccessor = () => { try { const d = Object.getOwnPropertyDescriptor(window, "fetch"); if (!window[ORIG_KEY]) window[ORIG_KEY] = d || null; window[BASE_KEY] ||= getFetchFromDesc(d); Object.defineProperty(window, "fetch", { configurable: true, enumerable: d?.enumerable ?? true, get() { return reapply(); }, set(v) { if (typeof v === "function") { window[BASE_KEY] = v; queueMicrotask(reapply); } } }); } catch { } };
const reapply = () => { try { const base = window[BASE_KEY] || getFetchFromDesc(Object.getOwnPropertyDescriptor(window, "fetch")); const stack = window[MW_KEY] || (window[MW_KEY] = []); let idx = stack.findIndex((m) => m && m[ID]); if (idx === -1) { stack.push(makeMw()); idx = stack.length - 1; } if (idx !== window[MW_KEY].length - 1) { const mw = window[MW_KEY][idx]; window[MW_KEY].splice(idx, 1); window[MW_KEY].push(mw); } const composed = compose(base, stack) || base; if (!window[CMP_KEY] || window[CMP_KEY]._base !== base || window[CMP_KEY]._stack !== stack) { composed._base = base; composed._stack = stack; window[CMP_KEY] = composed; } return window[CMP_KEY]; } catch { return globalThis.fetch; } };
function makeMw() {
const mw = (next) => async function f(input, options = {}) {
try {
if (await isTarget(input, options)) {
if (S.isPreview || S.isLong) {
const url = input instanceof Request ? input.url : input;
return interceptPreview(url, options).catch(() => new Response(JSON.stringify({ error: { message: "拦截失败,请手动中止消息生成。" } }), { status: 200, headers: { "Content-Type": "application/json" } }));
} else { try { await withTimeout(recordReal(input, options)); } catch { } }
}
} catch { }
return Reflect.apply(next, this, arguments);
};
Object.defineProperty(mw, ID, { value: true, enumerable: false });
return Object.freeze(mw);
}
function installFetch() {
if (__installed) return; __installed = true;
try {
window[MW_KEY] ||= [];
window[BASE_KEY] ||= getFetchFromDesc(Object.getOwnPropertyDescriptor(window, "fetch"));
ensureAccessor();
if (!window[MW_KEY].some((m) => m && m[ID])) window[MW_KEY].push(makeMw());
else {
const i = window[MW_KEY].findIndex((m) => m && m[ID]);
if (i !== window[MW_KEY].length - 1) {
const mw = window[MW_KEY][i];
window[MW_KEY].splice(i, 1);
window[MW_KEY].push(mw);
}
}
queueMicrotask(reapply);
window.addEventListener("pageshow", reapply, { passive: true });
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") reapply(); }, { passive: true });
window.addEventListener("focus", reapply, { passive: true });
} catch { }
}
function uninstallFetch() {
if (!__installed) return;
try {
const s = window[MW_KEY];
const i = Array.isArray(s) ? s.findIndex((m) => m && m[ID]) : -1;
if (i >= 0) s.splice(i, 1);
const others = Array.isArray(window[MW_KEY]) && window[MW_KEY].length;
const orig = window[ORIG_KEY];
if (!others) {
if (orig) {
try { Object.defineProperty(window, "fetch", orig); }
catch { Object.defineProperty(window, "fetch", { configurable: true, enumerable: true, writable: true, value: window[BASE_KEY] || globalThis.fetch }); }
} else {
Object.defineProperty(window, "fetch", { configurable: true, enumerable: true, writable: true, value: window[BASE_KEY] || globalThis.fetch });
}
} else {
reapply();
}
} catch { }
__installed = false;
}
const setupFetch = () => { if (!S.active) { installFetch(); S.active = true; } };
const restoreFetch = () => { if (S.active) { uninstallFetch(); S.active = false; } };
const updateFetchState = () => { const st = getSettings(), need = (st.preview.enabled || st.recorded.enabled); if (need && !S.active) setupFetch(); if (!need && S.active) restoreFetch(); };
async function interceptPreview(url, options) {
const body = await safeReadBodyFromInput(url, options);
const data = safeJson(body) || {};
const userInput = extractUser(data?.messages || []);
const ctx = getContext();
if (S.isLong) {
const chat = Array.isArray(ctx.chat) ? ctx.chat : [];
let start = chat.length;
if (chat.length > 0 && chat[chat.length - 1]?.is_user === true) start = chat.length - 1;
S.chatLenBefore = start;
S.pendingPurge = true;
oneShotOnLast(event_types.GENERATION_ENDED, () => setTimeout(() => purgePreviewArtifacts(), 0));
}
S.previewData = { url, method: options?.method || "POST", requestData: data, messages: data?.messages || [], model: data?.model || "Unknown", timestamp: now(), userInput, isPreview: true };
if (S.isLong) { setTimeout(() => { displayPreview(S.previewData); }, 100); } else if (S.resolve) { S.resolve({ success: true, data: S.previewData }); S.resolve = S.reject = null; }
const payload = S.isLong ? { choices: [{ message: { content: "【小白X】已拦截消息" }, finish_reason: "stop" }], intercepted: true } : { choices: [{ message: { content: "" }, finish_reason: "stop" }] };
return new Response(JSON.stringify(payload), { status: 200, headers: { "Content-Type": "application/json" } });
}
const addHistoryButtonsDebounced = debounce(() => {
const set = getSettings(); if (!set.recorded.enabled || !geEnabled()) return;
$(".mes_history_preview").remove();
$("#chat .mes").each(function () {
const id = parseInt($(this).attr("mesid")), isUser = $(this).attr("is_user") === "true";
if (id <= 0 || isUser) return;
const btn = $(`<div class="mes_btn mes_history_preview" title="查看历史API请求"><i class="fa-regular fa-note-sticky"></i></div>`).on("click", (e) => { e.preventDefault(); e.stopPropagation(); showHistoryPreview(id); });
if (window.registerButtonToSubContainer && window.registerButtonToSubContainer(id, btn[0])) return;
$(this).find(".flex-container.flex1.alignitemscenter").append(btn);
});
}, C.DEBOUNCE);
const disableSend = (dis = true) => {
const $b = $q("#send_but");
if (dis) { S.sendBtnWasDisabled = $b.prop("disabled"); $b.prop("disabled", true).off("click.preview-block").on("click.preview-block", (e) => { e.preventDefault(); e.stopImmediatePropagation(); return false; }); }
else { $b.prop("disabled", S.sendBtnWasDisabled).off("click.preview-block"); S.sendBtnWasDisabled = false; }
};
const triggerSend = () => {
const $b = $q("#send_but"), $t = $q("#send_textarea"), txt = String($t.val() || ""); if (!txt.trim()) return false;
const was = $b.prop("disabled"); $b.prop("disabled", false); $b[0].dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); if (was) $b.prop("disabled", true); return true;
};
async function showPreview() {
let toast = null, backup = null;
try {
const set = getSettings(); if (!set.preview.enabled || !geEnabled()) return toastr.warning("消息拦截功能未启用");
const text = String($q("#send_textarea").val() || "").trim(); if (!text) return toastr.error("请先输入消息内容");
backup = text; disableSend(true);
const ctx = getContext();
S.chatLenBefore = Array.isArray(ctx.chat) ? ctx.chat.length : 0;
S.isPreview = true; S.previewData = null; S.previewIds.clear(); S.previewAbort = new AbortController();
S.pendingPurge = true;
const endHandler = () => {
try { if (S.genEndedOff) { S.genEndedOff(); S.genEndedOff = null; } } catch { }
if (S.pendingPurge) {
setTimeout(() => purgePreviewArtifacts(), 0);
}
};
S.genEndedOff = oneShotOnLast(event_types.GENERATION_ENDED, endHandler);
clearTimeout(S.cleanupFallback);
S.cleanupFallback = setTimeout(() => {
try { if (S.genEndedOff) { S.genEndedOff(); S.genEndedOff = null; } } catch { }
purgePreviewArtifacts();
}, 1500);
toast = toastr.info(`正在拦截请求...${set.preview.timeoutSeconds}秒超时)`, "消息拦截", { timeOut: 0, tapToDismiss: false });
if (!triggerSend()) throw new Error("无法触发发送事件");
const res = await waitIntercept().catch((e) => ({ success: false, error: e?.message || e }));
if (toast) { toastr.clear(toast); toast = null; }
if (res.success) { await displayPreview(res.data); toastr.success("拦截成功!", "", { timeOut: 3000 }); }
else toastr.error(`拦截失败: ${res.error}`, "", { timeOut: 5000 });
} catch (e) {
if (toast) toastr.clear(toast); toastr.error(`拦截异常: ${e.message}`, "", { timeOut: 5000 });
} finally {
try { S.previewAbort?.abort("拦截结束"); } catch { } S.previewAbort = null;
if (S.resolve) S.resolve({ success: false, error: "拦截已取消" }); S.resolve = S.reject = null;
clearTimeout(S.cleanupFallback); S.cleanupFallback = null;
S.isPreview = false; S.previewData = null;
disableSend(false); if (backup) $q("#send_textarea").val(backup);
}
}
async function showHistoryPreview(messageId) {
try {
const set = getSettings(); if (!set.recorded.enabled || !geEnabled()) return;
const rec = findRec(messageId);
if (rec?.messages?.length || rec?.requestData?.messages?.length) await openPopup(buildPreviewHtml({ ...rec, isHistoryPreview: true, targetMessageId: messageId }), `消息历史查看 - 第 ${messageId + 1} 条消息`);
else toastr.warning(`未找到第 ${messageId + 1} 条消息的API请求记录`);
} catch { toastr.error("查看历史消息失败"); }
}
const cleanupMemory = () => {
if (S.history.length > C.MAX_HISTORY) S.history = S.history.slice(0, C.MAX_HISTORY);
S.previewIds.clear(); S.previewData = null; $(".mes_history_preview").each(function () { if (!$(this).closest(".mes").length) $(this).remove(); });
if (!S.isLong) S.interceptedIds = [];
};
function onLast(ev, handler) {
if (typeof eventSource.makeLast === "function") { eventSource.makeLast(ev, handler); S.listeners.push({ e: ev, h: handler, off: () => { } }); return; }
if (S.tailAPI?.onLast) { const off = S.tailAPI.onLast(ev, handler); S.listeners.push({ e: ev, h: handler, off }); return; }
const tail = (...args) => queueMicrotask(() => { try { handler(...args); } catch { } });
eventSource.on(ev, tail);
S.listeners.push({ e: ev, h: tail, off: () => eventSource.removeListener?.(ev, tail) });
}
const addEvents = () => {
removeEvents();
[
{ e: event_types.MESSAGE_RECEIVED, h: addHistoryButtonsDebounced },
{ e: event_types.CHARACTER_MESSAGE_RENDERED, h: addHistoryButtonsDebounced },
{ e: event_types.USER_MESSAGE_RENDERED, h: addHistoryButtonsDebounced },
{ e: event_types.CHAT_CHANGED, h: () => { S.history = []; setTimeout(addHistoryButtonsDebounced, C.CHECK); } },
{ e: event_types.MESSAGE_RECEIVED, h: (messageId) => setTimeout(() => { const r = S.history.find((x) => !x.associatedMessageId && now() - x.timestamp < C.REQ_WINDOW); if (r) r.associatedMessageId = messageId; }, 100) },
].forEach(({ e, h }) => onLast(e, h));
const late = (payload) => {
try {
const ctx = getContext();
pushHistory({
url: C.TARGET, method: "POST", requestData: payload, messages: payload?.messages || [], model: payload?.model || "Unknown",
timestamp: now(), messageId: ctx.chat?.length || 0, characterName: ctx.characters?.[ctx.characterId]?.name || "Unknown",
userInput: extractUser(payload?.messages || []), isRealRequest: true, source: "settings_ready",
});
} catch { }
queueMicrotask(() => updateFetchState());
};
if (typeof eventSource.makeLast === "function") { eventSource.makeLast(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off: () => { } }); }
else if (S.tailAPI?.onLast) { const off = S.tailAPI.onLast(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off }); }
else { ON(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off: () => OFF(event_types.CHAT_COMPLETION_SETTINGS_READY, late) }); queueMicrotask(() => { try { OFF(event_types.CHAT_COMPLETION_SETTINGS_READY, late); } catch { } try { ON(event_types.CHAT_COMPLETION_SETTINGS_READY, late); } catch { } }); }
};
const removeEvents = () => { S.listeners.forEach(({ e, h, off }) => { if (typeof off === "function") { try { off(); } catch { } } else { try { OFF(e, h); } catch { } } }); S.listeners = []; };
const toggleLong = () => {
S.isLong = !S.isLong;
const $b = $q("#message_preview_btn");
if (S.isLong) {
$b.css("color", "red");
toastr.info("持续拦截已开启", "", { timeOut: 2000 });
} else {
$b.css("color", "");
S.pendingPurge = false;
toastr.info("持续拦截已关闭", "", { timeOut: 2000 });
}
};
const bindBtn = () => {
const $b = $q("#message_preview_btn");
$b.on("mousedown touchstart", () => { S.longPressTimer = setTimeout(() => toggleLong(), S.longPressDelay); });
$b.on("mouseup touchend mouseleave", () => { if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; } });
$b.on("click", () => { if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; return; } if (!S.isLong) showPreview(); });
};
const waitIntercept = () => new Promise((resolve, reject) => {
const t = setTimeout(() => { if (S.resolve) { S.resolve({ success: false, error: `等待超时 (${getSettings().preview.timeoutSeconds}秒)` }); S.resolve = S.reject = null; } }, getSettings().preview.timeoutSeconds * 1000);
S.resolve = (v) => { clearTimeout(t); resolve(v); }; S.reject = (e) => { clearTimeout(t); reject(e); };
});
function cleanup() {
removeEvents(); restoreFetch(); disableSend(false);
$(".mes_history_preview").remove(); $("#message_preview_btn").remove(); cleanupMemory();
Object.assign(S, { resolve: null, reject: null, isPreview: false, isLong: false, interceptedIds: [], chatLenBefore: 0, sendBtnWasDisabled: false, pendingPurge: false });
if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; }
if (S.restoreLong) { try { S.restoreLong(); } catch { } S.restoreLong = null; }
if (S.genEndedOff) { try { S.genEndedOff(); } catch { } S.genEndedOff = null; }
if (S.cleanupFallback) { clearTimeout(S.cleanupFallback); S.cleanupFallback = null; }
}
function initMessagePreview() {
try {
cleanup(); S.tailAPI = installEventSourceTail(eventSource);
const set = getSettings();
const btn = $(`<div id="message_preview_btn" class="fa-regular fa-note-sticky interactable" title="预览消息"></div>`);
$("#send_but").before(btn); bindBtn();
$("#xiaobaix_preview_enabled").prop("checked", set.preview.enabled).on("change", function () {
if (!geEnabled()) return; set.preview.enabled = $(this).prop("checked"); saveSettingsDebounced();
$("#message_preview_btn").toggle(set.preview.enabled);
if (set.preview.enabled) { if (!S.cleanTimer) S.cleanTimer = setInterval(cleanupMemory, C.CLEAN); }
else { if (S.cleanTimer) { clearInterval(S.cleanTimer); S.cleanTimer = null; } }
updateFetchState();
if (!set.preview.enabled && set.recorded.enabled) { addEvents(); addHistoryButtonsDebounced(); }
});
$("#xiaobaix_recorded_enabled").prop("checked", set.recorded.enabled).on("change", function () {
if (!geEnabled()) return; set.recorded.enabled = $(this).prop("checked"); saveSettingsDebounced();
if (set.recorded.enabled) { addEvents(); addHistoryButtonsDebounced(); }
else { $(".mes_history_preview").remove(); S.history.length = 0; if (!set.preview.enabled) removeEvents(); }
updateFetchState();
});
if (!set.preview.enabled) $("#message_preview_btn").hide();
updateFetchState(); if (set.recorded.enabled) addHistoryButtonsDebounced();
if (set.preview.enabled || set.recorded.enabled) addEvents();
if (window.registerModuleCleanup) window.registerModuleCleanup("messagePreview", cleanup);
if (set.preview.enabled) S.cleanTimer = setInterval(cleanupMemory, C.CLEAN);
} catch { toastr.error("模块初始化失败"); }
}
window.addEventListener("beforeunload", cleanup);
window.messagePreviewCleanup = cleanup;
export { initMessagePreview, addHistoryButtonsDebounced, cleanup };