v5.6.6 — Always-on PSK encryption (AES-GCM-256) for all community messages. ntfy.sh sees only ciphertext. Real-scroller fix for doLoadAll. All version strings consistent.
// ==UserScript==
// @name JanitorV5
// @namespace https://janitorai.com/
// @version 5.6.6
// @description v5.6.6 — Always-on PSK encryption (AES-GCM-256) for all community messages. ntfy.sh sees only ciphertext. Real-scroller fix for doLoadAll. All version strings consistent.
// @author eivls + JanitorV5
// @license All Rights Reserved
// @match https://janitorai.com/*
// @match https://www.janitorai.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect openrouter.ai
// @connect api.openai.com
// @connect api.x.ai
// @connect api.mistral.ai
// @connect api.groq.com
// @connect api.anthropic.com
// @connect *
// @run-at document-idle
// ==/UserScript==
// Copyright (c) 2025 eivls. All Rights Reserved.
//
// This script is the intellectual property of eivls.
// Copying, modifying, redistributing, or republishing this script
// — in whole or in part — without prior written permission from the
// author is strictly prohibited.
//
// To request permission, contact the author via GreasyFork.
//
(function () {
'use strict';
// JanitorV5 Ultimate v5.6.6 — Fully Engineered Long-Term Edition
// Self-healing, multi-fallback, deep React source connected, production-grade structure
// ─── STORAGE ───────────────────────────────────────────────────────────────
const _cfgCache = {};
/**
* Reads a GM storage value, returning `d` as default.
* Results are memoised in `_cfgCache` for the lifetime of the page.
* @param {string} k - Storage key.
* @param {*} d - Default value if the key is unset.
* @returns {*}
*/
const gget = (k, d) => {
if (k in _cfgCache) return _cfgCache[k];
try { _cfgCache[k] = GM_getValue(k, d); return _cfgCache[k]; } catch { return d; }
};
/**
* Writes a value to GM storage and updates the in-memory cache atomically.
* @param {string} k - Storage key.
* @param {*} v - Value to persist.
*/
const gset = (k, v) => {
_cfgCache[k] = v;
try { GM_setValue(k, v); } catch { }
};
// ─── GM FETCH WRAPPER ─────────────────────────────────────────────────────
/**
* GM_xmlhttpRequest wrapped as a Fetch-compatible Promise.
* Supports AbortSignal, parses response headers into a `get()` accessor,
* and exposes `.text()` / `.json()` on the resolved value — matching the
* native `fetch` API surface used throughout the script.
*
* @param {string} url - Full URL to request.
* @param {object} options - Subset of the Fetch `init` object:
* `method`, `headers`, `body`, `signal` (AbortSignal).
* @returns {Promise<{ok:boolean, status:number, headers:{get:function}, text:function, json:function}>}
*/
function gmFetch(url, options = {}) {
return new Promise((resolve, reject) => {
const signal = options.signal;
if (signal?.aborted) { reject(new DOMException('Aborted', 'AbortError')); return; }
let req;
try {
req = GM_xmlhttpRequest({
method: options.method || 'GET',
url: url,
headers: options.headers || {},
data: options.body || null,
onload(r) {
const ok = r.status >= 200 && r.status < 300;
// Parse raw response headers string into a lookup map
const _hdrs = {};
(r.responseHeaders || '').split(/\r?\n/).forEach(line => {
const idx = line.indexOf(':');
if (idx > 0) {
const k = line.slice(0, idx).trim().toLowerCase();
_hdrs[k] = line.slice(idx + 1).trim();
}
});
resolve({
ok,
status: r.status,
headers: { get: name => _hdrs[name.toLowerCase()] ?? null },
text: () => Promise.resolve(r.responseText),
json() {
try { return Promise.resolve(JSON.parse(r.responseText)); }
catch (e) { return Promise.reject(e); }
},
});
},
onerror() { reject(new TypeError('Failed to fetch (network error — check API key and endpoint)')); },
onabort() { reject(new DOMException('Aborted', 'AbortError')); },
ontimeout() { reject(new TypeError('Request timed out')); },
});
} catch (e) {
reject(new TypeError('GM_xmlhttpRequest failed: ' + e.message));
return;
}
if (signal) {
signal.addEventListener('abort', () => { try { req?.abort(); } catch { } });
}
});
} // close gmFetch
// ─── ROBUST SELF-HEALING SELECTOR ENGINE v2 (Long-term Resilience) ─────────
// Multiple fallbacks + heuristic probing + MutationObserver self-repair
// + remote updatable map. Survives JanitorAI React/Virtuoso DOM changes for years.
// "Connects to actual source" by deeply inspecting rendered React Fiber + DOM signals.
/**
* Self-healing CSS selector engine.
*
* Resolves element selectors through a three-tier strategy:
* 1. **Primary** — best-known selectors, checked on every call.
* 2. **Fallbacks** — a ranked list of alternatives per key.
* 3. **Heuristic probe** — inspects rendered React Fiber props + DOM
* signals when both primary and fallbacks fail; auto-promotes winners.
*
* A MutationObserver (`startSelfHealing`) watches for structural DOM changes
* and re-probes periodically so the engine adapts to JanitorAI updates
* without requiring a script update. Selector discoveries are persisted to
* GM storage and can optionally be fetched from a remote JSON endpoint.
*/
class SelectorEngine {
constructor() {
this.primary = {
// v5.6.4 — JanitorAI removed data-testid="virtuoso-item-list" from the
// Virtuoso list parent. New confirmed selector: div > div[data-index]
// (7 matches on live chat, confirmed by DOM Detective 2026-06-06).
virtuosoItemList: 'div > div[data-index]',
virtuosoScroller: '[data-testid="virtuoso-scroller"]',
virtuosoItemListParent: '[class*="_messagesMain_"]',
messageBody: '[class*="_messageBody_"]',
botIcon: '[class*="_nameIcon_"]',
messagesMain: '[class*="_messagesMain_"]',
authorRoleAssistant: '[data-message-author-role="assistant"]',
authorRoleUser: '[data-message-author-role="user"]',
};
this.fallbacks = {
virtuosoItemList: [
'[data-testid="virtuoso-item-list"] > div[data-index]', // pre-2026 (keep for revert)
'[data-testid*="virtuoso"] [data-index]',
'[class*="_messagesMain_"] div[data-index]',
'div[data-index][class*="message"]',
'[class*="virtuoso-item"] > div',
'div[role="listitem"][data-index]'
],
messageBody: ['[class*="messageBody"]', '[class*="MessageBody"]', 'div[class*="message"] p'],
botIcon: ['[class*="nameIcon"]', '[class*="botIcon"]', 'img[alt*="bot"]', '[data-bot="true"]'],
authorRoleAssistant: ['[data-message-author-role="assistant"]', '[data-role="assistant"]', '[class*="assistant"]'],
};
this.hits = new Map(); // learning which strategies work
this.lastProbe = 0;
this.observer = null;
this.remoteUrl = gget('jv5_selector_remote_url', ''); // user can set their own JSON endpoint
this.versionKey = 'jv5_selector_version';
}
get(key) {
// 1. Primary (fast path)
let sel = this.primary[key];
if (sel && document.querySelector(sel)) {
this._recordHit(key, 'primary');
return sel;
}
// 2. Fallbacks
const fbs = this.fallbacks[key] || [];
for (const fb of fbs) {
if (document.querySelector(fb)) {
this._recordHit(key, 'fallback');
return fb;
}
}
// 3. Heuristic probe for critical keys (survives major refactors)
if (['virtuosoItemList', 'messageBody', 'botIcon'].includes(key) && Date.now() - this.lastProbe > 30000) {
const found = this._heuristicProbe(key);
if (found) {
this._recordHit(key, 'heuristic');
// Optionally auto-promote to primary for this session
this.primary[key] = found;
return found;
}
this.lastProbe = Date.now();
}
// 4. Last resort: return primary (will fail gracefully, triggering debug)
return this.primary[key] || '';
}
_recordHit(key, strategy) {
const k = `${key}:${strategy}`;
this.hits.set(k, (this.hits.get(k) || 0) + 1);
// Persist top strategies occasionally
if (Math.random() < 0.05) this._persistLearning();
}
_persistLearning() {
try {
const data = {};
for (const [k, v] of this.hits) data[k] = v;
gset('jv5_selector_learning', JSON.stringify(data));
} catch {}
}
_heuristicProbe(key) {
// Score potential containers by multiple signals from the actual rendered React app
const candidates = [];
const allDivs = document.querySelectorAll('div[data-index], div[class*="message"], [role="listitem"]');
for (const el of allDivs) {
let score = 0;
const text = (el.textContent || '').trim();
// Signal 1: Has data-index (Virtuoso)
if (el.hasAttribute('data-index')) score += 40;
// Signal 2: Fiber role detection (deep React source inspection)
const fiberRole = this._quickFiberRole(el);
if (fiberRole === 'assistant' || fiberRole === 'user') score += 35;
// Signal 3: Contains bot icon or name icon
if (el.querySelector('[class*="_nameIcon_"], [class*="nameIcon"], img[alt*="character"]')) score += 25;
// Signal 4: Reasonable message length
if (text.length > 20 && text.length < 4000) score += 15;
// Signal 5: Has role or data-message-author-role
if (el.querySelector('[data-message-author-role]') || el.getAttribute('data-message-author-role')) score += 20;
if (score > 50) candidates.push({ el, score, sel: this._makeSelector(el) });
}
if (candidates.length === 0) return null;
candidates.sort((a, b) => b.score - a.score);
return candidates[0].sel; // best guess selector
}
_quickFiberRole(node) {
try {
let fiber = node;
for (let i = 0; i < 8 && fiber; i++) { // limited depth for perf
const key = Object.keys(fiber).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
if (key) {
let f = fiber[key];
while (f && i++ < 12) {
const props = f.memoizedProps || f.pendingProps;
if (props?.role) return props.role;
if (props?.message?.role) return props.message.role;
f = f.return;
}
}
fiber = fiber.parentNode;
}
} catch {}
return null;
}
_makeSelector(el) {
// Generate a reasonably stable CSS selector from the winning element
if (el.id) return `#${el.id}`;
if (el.dataset.testid) return `[data-testid="${el.dataset.testid}"]`;
if (el.hasAttribute('data-index')) return `div[data-index="${el.getAttribute('data-index')}"]`;
const cls = Array.from(el.classList).find(c => c.includes('message') || c.includes('item'));
return cls ? `div.${CSS.escape(cls)}` : 'div[data-index]';
}
startSelfHealing() {
if (this.observer) return;
this.observer = new MutationObserver((mutations) => {
// Only react to significant structural changes in chat area
for (const m of mutations) {
if (m.addedNodes.length > 0) {
const hasVirtuoso = document.querySelector('[data-testid*="virtuoso"]');
if (hasVirtuoso && Date.now() - this.lastProbe > 45000) {
// Re-probe silently
this._heuristicProbe('virtuosoItemList');
this.lastProbe = Date.now();
}
break;
}
}
});
this.observer.observe(document.body, { childList: true, subtree: true });
console.log('[JanitorV5] Self-healing MutationObserver active — script will adapt to JanitorAI changes');
} // properly close startSelfHealing
loadRemote() {
if (!this.remoteUrl) return;
if (Date.now() - (this._lastRemoteFetch || 0) < 3600000) return; // 1-hour cache
this._lastRemoteFetch = Date.now();
try {
GM_xmlhttpRequest({
method: 'GET', url: this.remoteUrl, timeout: 8000,
onload: (r) => {
try {
const map = JSON.parse(r.responseText);
if (map && typeof map === 'object') {
// SECURITY: Only accept known keys with safe string values.
// Prevents a compromised remote URL from injecting arbitrary selectors.
const ALLOWED_SELECTOR_KEYS = new Set([
'virtuosoItemList','virtuosoScroller','virtuosoItemListParent',
'messageBody','botIcon','messagesMain',
'authorRoleAssistant','authorRoleUser'
]);
let imported = 0;
for (const [k, v] of Object.entries(map)) {
if (ALLOWED_SELECTOR_KEYS.has(k) && typeof v === 'string' && v.length < 200
&& !v.includes('<') && !v.includes('javascript')) {
this.primary[k] = v;
imported++;
}
}
gset(this.versionKey, Date.now());
console.log('[JanitorV5] Remote selector map loaded:', imported, 'validated keys');
}
} catch (e) { console.warn('[JanitorV5] loadRemote parse error:', e); }
},
onerror: () => console.warn('[JanitorV5] loadRemote: fetch failed for', this.remoteUrl),
});
} catch (e) { console.warn('[JanitorV5] loadRemote error:', e); }
}
stopSelfHealing() {
if (this.observer) { this.observer.disconnect(); this.observer = null; }
}
} // properly close SelectorEngine
// ─── INTERNAL EVENT BUS (Correct Module Scope) ─────────────────────────────
/**
* Minimal publish-subscribe event bus used for loose coupling between
* independent subsystems (network circuit breaker, WebRTC status, etc.).
*
* Exposed on `unsafeWindow.jv5Bus` for external tooling and diagnostics.
*/
class EventBus {
constructor() { this.listeners = new Map(); }
on(event, fn) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event).add(fn);
}
off(event, fn) {
this.listeners.get(event)?.delete(fn);
}
emit(event, data) {
this.listeners.get(event)?.forEach(fn => {
try { fn(data); } catch (e) { console.warn('[JanitorV5 EventBus]', event, e); }
});
}
} // close EventBus class
const bus = new EventBus();
try { unsafeWindow.jv5Bus = bus; } catch {}
// ─── ADVANCED NETWORK INTERCEPTOR (Multi-Layer + Resilient) ────────────────
/**
* Dual-layer network interceptor that patches both `window.fetch` and
* `window.XMLHttpRequest` in the page context.
*
* Implements a **circuit breaker**: after `maxFailures` consecutive errors
* the breaker opens for `circuitTimeoutMs` ms and rejects new generate
* requests immediately, preventing cascading timeouts during an outage.
*
* The XHR layer intentionally no longer stores request payloads in GM
* storage (payload privacy improvement from v5.5.9).
*/
class NetworkInterceptor {
constructor() {
this.originalFetch = null;
this.originalXHR = null;
this.circuitOpenUntil = 0;
this.failureCount = 0;
this.maxFailures = 5;
this.circuitTimeoutMs = 30000;
}
init() {
this._patchFetch();
this._patchXHR();
console.log('[JanitorV5] Multi-layer NetworkInterceptor active (fetch + XHR)');
}
_shouldCircuitBreak() {
return Date.now() < this.circuitOpenUntil;
}
_recordFailure() {
this.failureCount++;
if (this.failureCount >= this.maxFailures) {
this.circuitOpenUntil = Date.now() + this.circuitTimeoutMs;
this.failureCount = 0;
bus.emit('networkCircuitOpen', { until: this.circuitOpenUntil });
}
}
_recordSuccess() {
this.failureCount = Math.max(0, this.failureCount - 1);
if (this.circuitOpenUntil && Date.now() > this.circuitOpenUntil) {
this.circuitOpenUntil = 0;
bus.emit('networkCircuitClosed');
}
}
_patchFetch() {
if (typeof unsafeWindow === 'undefined' || this.originalFetch) return;
this.originalFetch = unsafeWindow.fetch;
const self = this;
unsafeWindow.fetch = async function (...args) {
if (self._shouldCircuitBreak()) {
return Promise.reject(new Error('Network circuit breaker open'));
}
try {
const response = await self.originalFetch.apply(this, args);
self._recordSuccess();
return response;
} catch (err) {
self._recordFailure();
throw err;
}
};
}
_patchXHR() {
if (typeof unsafeWindow === 'undefined' || this.originalXHR) return;
this.originalXHR = unsafeWindow.XMLHttpRequest;
const self = this;
unsafeWindow.XMLHttpRequest = function () {
const xhr = new self.originalXHR();
const originalOpen = xhr.open;
const originalSend = xhr.send;
xhr.open = function (method, url, ...rest) {
this._jv5Url = url;
return originalOpen.apply(this, [method, url, ...rest]);
};
xhr.send = function (body) {
if (self._shouldCircuitBreak() && this._jv5Url && this._jv5Url.includes('generate')) {
setTimeout(() => {
if (this.onerror) this.onerror(new Event('error'));
}, 0);
return;
}
// PRIVACY: Full chat payload is no longer stored in GM storage.
// Removed jv4_lastGeneratePayload write to prevent full message context
// from being accessible to other userscripts via GM_getValue.
const origOnLoad = this.onload;
this.onload = function (...loadArgs) {
self._recordSuccess();
if (origOnLoad) return origOnLoad.apply(this, loadArgs);
};
const origOnError = this.onerror;
this.onerror = function (...errArgs) {
self._recordFailure();
if (origOnError) return origOnError.apply(this, errArgs);
};
return originalSend.apply(this, [body]);
};
return xhr;
};
}
restore() {
if (this.originalFetch) {
try { unsafeWindow.fetch = this.originalFetch; } catch {}
this.originalFetch = null;
}
if (this.originalXHR) {
try { unsafeWindow.XMLHttpRequest = this.originalXHR; } catch {}
this.originalXHR = null;
}
}
} // close NetworkInterceptor class
const netInterceptor = new NetworkInterceptor();
// ─── P2P RELAY FAILOVER ────────────────────────────────────────────────────
const P2P_RELAYS = ['https://ntfy.sh'];
// Runtime relay getter — reads user-configured URL from storage, falls back to default
function _p2pGetRelay() {
try {
const stored = GM_getValue(P2P_GM_RELAY, '').trim();
return (stored && /^https?:\/\/.+/.test(stored)) ? stored.replace(/\/$/, '') : P2P_RELAYS[0];
} catch { return P2P_RELAYS[0]; }
}
const P2P_RELAY = P2P_RELAYS[0];
const P2P_TOPIC_GLOBAL = 'jv4-global-v1';
const P2P_TOPIC_TYPING = 'jv4-typing-v1';
const P2P_TOPIC_HB = 'jv4-hb-v1';
const P2P_TOPIC_REPORTS= 'jv4-reports-v1';
// Storage keys
const P2P_GM_PEERID = 'jv4_p2p_peerid';
const P2P_GM_NICKNAME = 'jv4_p2p_nickname';
const P2P_GM_ROOM = 'jv4_p2p_room';
const P2P_GM_HISTORY = 'jv4_p2p_history';
const P2P_GM_BLOCKED = 'jv4_p2p_blocked';
const P2P_GM_PINNED = 'jv4_p2p_pinned';
const P2P_GM_ENABLED = 'jv4_p2p_enabled';
const P2P_GM_TIP_SEEN = 'jv4_p2p_tip_seen';
const P2P_GM_LAST_CHAR = 'jv4_p2p_last_char';
const P2P_GM_LAST_CHAR_NAME= 'jv4_p2p_last_char_name';
const P2P_GM_RELAY = 'jv4_p2p_relay'; // user-configurable ntfy relay base URL
// Timing & limits
const P2P_POLL_MS = 800;
const P2P_BACKOFF_MIN = 800;
const P2P_BACKOFF_MAX = 30000;
const P2P_BACKOFF_MULT = 1.5;
const P2P_RATE_LIMIT_MS = 2000;
const P2P_MAX_HISTORY = 200;
const P2P_HB_SEND_MS = 30000;
const P2P_HB_EXPIRE_MS = 90000;
const P2P_TYPING_TTL = 3000;
// Emoji sets
const P2P_REACTION_EMOJIS = ['❤️','😂','😮','😢','😡','👍'];
const P2P_CHAT_EMOJIS = ['😊','😂','❤️','👍','🔥','😍','🎉','😎',
'🥺','😭','😤','🤔','✨','💀','👀','🤣'];
// Admin: hash is SHA-256 of the admin password set by the room creator.
// Not hard-coded — derive from a user-supplied password via _sha256().
// This placeholder causes admin commands to be disabled until unlocked.
const P2P_ADMIN_HASH = gget('jv4_p2p_admin_hash', '');
// Optional remote verification URL (leave empty to disable)
const P2P_VERIFIED_URL = gget('jv5_verified_url', '');
// ─── UTILITY HELPERS ───────────────────────────────────────────────────────
/**
* Returns the URL of the healthiest available ntfy relay.
*
* Currently a stub that always returns the first (and only) entry in
* `P2P_RELAYS`. Intended extension point for active health-checking /
* round-robin across multiple relay URLs.
*
* @returns {string} Base relay URL (no trailing slash).
*/
function _getHealthyRelay() {
return _p2pGetRelay();
}
// ─── STORAGE MIGRATION ─────────────────────────────────────────────────────
function migrateStorage() {
const currentVer = gget('jv5_storage_version', 0);
if (currentVer < 1) {
gset('jv5_storage_version', 1);
}
}
try { migrateStorage(); } catch {}
// ─── P2P END-TO-END ENCRYPTION LAYER (Phase 1 - Strong Security without Server) ─
// Uses Web Crypto API (AES-GCM) + PBKDF2 for authenticated encryption.
// Messages are encrypted client-side before hitting ntfy.sh.
// Requires users to share a password out-of-band for a room (Discord, Signal, etc.).
// This makes passive reading/injection on public ntfy topics useless without the password.
// Replay protection via sequence numbers + timestamp window.
// Recommended for char rooms. Global room stays unencrypted by default (with warning).
const P2PCrypto = {
// In-memory cache of derived keys per room
_keyCache: new Map(),
async _deriveKey(password, roomId, sessionSalt = null) {
const cacheKey = `${roomId}:${password}:${sessionSalt || 'default'}`;
if (this._keyCache.has(cacheKey)) return this._keyCache.get(cacheKey);
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
enc.encode(password),
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
const salt = enc.encode('jv5-p2p-' + roomId + (sessionSalt ? '-' + sessionSalt : ''));
const key = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: 150000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
this._keyCache.set(cacheKey, key);
return key;
},
async encrypt(plainObj, password, roomId) {
if (!password) throw new Error('Password required for encryption');
const key = await this._deriveKey(password, roomId);
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV for GCM
// Add anti-replay fields
const payload = {
...plainObj,
_seq: Date.now(),
_ts: Date.now()
};
const enc = new TextEncoder();
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
enc.encode(JSON.stringify(payload))
);
return {
encrypted: true,
iv: Array.from(iv),
ciphertext: Array.from(new Uint8Array(ciphertext)),
v: 1
};
},
async decrypt(encObj, password, roomId) {
if (!encObj || !encObj.encrypted) return null;
if (!password) return null;
try {
const key = await this._deriveKey(password, roomId);
const iv = new Uint8Array(encObj.iv);
const ciphertext = new Uint8Array(encObj.ciphertext);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
);
const dec = new TextDecoder();
const plain = JSON.parse(dec.decode(decrypted));
// Basic replay / freshness check (6 hour window)
const now = Date.now();
if (plain._ts && Math.abs(now - plain._ts) > 1000 * 60 * 60 * 6) {
console.warn('[P2PCrypto] Dropped stale/replayed message');
return null;
}
delete plain._seq;
delete plain._ts;
return plain;
} catch (e) {
console.warn('[P2PCrypto] Decryption failed (wrong password or corrupted data)');
return null;
}
},
isRoomEncrypted(room) {
try {
return !!gget(`jv5_p2p_enc_${room}`, false);
} catch { return false; }
},
setRoomEncrypted(room, enabled) {
try {
gset(`jv5_p2p_enc_${room}`, enabled);
} catch {}
},
clearKeyCache() {
this._keyCache.clear();
}
};
try { unsafeWindow.jv5P2PCrypto = P2PCrypto; } catch {}
// ─── PRE-SHARED KEY (PSK) — ALWAYS-ON PRIVACY ───────────────────────────────
// All community messages (global + char rooms) are automatically encrypted
// with this key before hitting ntfy.sh. Users never need to enter a password.
// ntfy.sh only ever sees opaque ciphertext — even if they log forever, it's
// unreadable without this key. Change this string to rotate the key for all
// users (old messages become undecryptable, which is the intended behavior).
//
// The key is derived through PBKDF2 (150k rounds, SHA-256, AES-GCM-256)
// so brute-forcing the short string is computationally expensive.
//
// IMPORTANT: This is a shared secret baked into the script. Security model:
// - ntfy.sh cannot read messages ✅
// - Someone who has the JV5 source can read the PSK ⚠️
// - This is acceptable: the threat model is passive server logging,
// not adversarial users who already have the script.
// - For higher security, rotate by changing P2P_PSK below.
const P2P_PSK = 'jv5-community-v1-K9#mXqR2pL8wNzAe'; // change to rotate
const P2P_PSK_ROOM = '__psk__'; // synthetic room ID so PSK key != per-room key
/**
* Encrypts a message payload using the PSK.
* Called on EVERY outgoing community message regardless of room.
* Returns { psk: true, iv, ciphertext, v } wrapper.
*/
async function pskEncrypt(plainObj) {
try {
return await P2PCrypto.encrypt(plainObj, P2P_PSK, P2P_PSK_ROOM);
} catch (e) {
console.warn('[JV5-PSK] Encrypt failed:', e.message);
return null;
}
}
/**
* Decrypts a PSK-encrypted message.
* Returns the plaintext object or null if decryption fails (wrong key version,
* corrupted data, or pre-PSK legacy plaintext message).
*/
async function pskDecrypt(encObj) {
if (!encObj || !encObj.encrypted) return null;
try {
return await P2PCrypto.decrypt(encObj, P2P_PSK, P2P_PSK_ROOM);
} catch {
return null;
}
}
/** Returns true if a received message envelope looks like a PSK-encrypted packet. */
function isPskEncrypted(msg) {
return !!(msg && msg.encrypted === true && msg.psk === true);
}
// ─── ADVANCED WEBRTC MANAGER (Phase 2 - Direct P2P, Maximum Privacy) ───────
// Uses ntfy.sh ONLY for temporary signaling (SDP offers/answers + ICE candidates).
// Actual chat messages flow over encrypted WebRTC DataChannels (browser-native DTLS).
// This makes long-term relay exposure minimal.
// Designed for small groups (mesh). Falls back gracefully to ntfy pubsub.
// Very advanced: connection management, quality monitoring, E2EE integration, auto-reconnect.
/**
* Mesh WebRTC manager for direct peer-to-peer chat.
*
* Uses ntfy.sh **only** for the brief signaling phase (SDP offer/answer +
* ICE candidates). Once a `RTCDataChannel` is open, messages flow over
* browser-native DTLS-encrypted channels, bypassing the public relay
* entirely.
*
* Design decisions:
* - `negotiated:true, id:0` — both peers create the channel independently,
* eliminating the `ondatachannel` round-trip.
* - **Glare prevention** — the peer with the lexicographically lower ID
* always sends the offer; the other waits. Eliminates simultaneous-offer
* failures in a mesh.
* - Signaling messages are deduplicated via `_seenSignalingIds` so re-polls
* on the ntfy topic never re-process old SDP / ICE events.
* - Auto-reconnect on `failed` / `disconnected` state after 3 s.
*
* Exposed on `unsafeWindow.jv5WebRTC` for diagnostics.
*/
class WebRTCManager {
constructor() {
this.peerConnections = new Map(); // peerId -> RTCPeerConnection
this.dataChannels = new Map(); // peerId -> RTCDataChannel
this.signalingTopic = 'jv4-webrtc-signaling-v1';
this.isEnabled = false;
this.localPeerId = null;
this.room = null;
this.onMessageCallback = null;
this.onStatusCallback = null;
// Dedup: track signaling message IDs so re-polls don't re-process them
this._seenSignalingIds = new Set();
this._lastSignalingId = '10m'; // ntfy since parameter
// Glare prevention: only peers with the lexicographically LOWER id send the offer.
// The other side waits for the offer via signaling. This prevents both sides
// simultaneously creating offers and causing connection failures.
this._pendingConnect = new Set(); // peerIds we've already sent an offer to
this.iceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
];
}
async enable(room, localPeerId) {
if (this.isEnabled) return;
this.room = room;
this.localPeerId = localPeerId;
this.isEnabled = true;
// Listen for signaling messages on a dedicated ntfy topic
this._startSignalingListener();
bus.emit('webrtcEnabled', { room });
if (this.onStatusCallback) this.onStatusCallback('enabled');
console.log('[WebRTC] High-security direct P2P mode enabled for room', room);
}
disable() {
this.isEnabled = false;
this._closeAllConnections();
bus.emit('webrtcDisabled');
if (this.onStatusCallback) this.onStatusCallback('disabled');
}
async connectToPeer(remotePeerId) {
if (!this.isEnabled) return;
if (this.peerConnections.has(remotePeerId)) return;
if (this._pendingConnect.has(remotePeerId)) return;
// Glare prevention: only the peer with the lower ID sends the offer.
// The higher-ID peer waits; it will receive an offer via signaling and answer it.
if (!this._shouldInitiate(remotePeerId)) {
console.log('[WebRTC] Waiting for offer from', remotePeerId, '(they have lower ID)');
return;
}
this._pendingConnect.add(remotePeerId);
try {
const pc = new RTCPeerConnection({ iceServers: this.iceServers });
this.peerConnections.set(remotePeerId, pc);
// negotiated:true with a fixed id means both sides use the same channel
// without needing an extra ondatachannel round-trip
const dc = pc.createDataChannel('jv5-chat', { negotiated: true, id: 0 });
this._setupDataChannel(dc, remotePeerId);
pc.onicecandidate = (event) => {
if (event.candidate) {
this._sendSignalingMessage(remotePeerId, { type: 'ice-candidate', candidate: event.candidate });
}
};
pc.onconnectionstatechange = () => {
const state = pc.connectionState;
console.log('[WebRTC] Peer', remotePeerId, '→', state);
if (this.onStatusCallback) this.onStatusCallback(`peer-${remotePeerId}-${state}`);
if (state === 'failed' || state === 'disconnected') {
this._pendingConnect.delete(remotePeerId);
this._reconnectPeer(remotePeerId);
}
if (state === 'closed') {
this._pendingConnect.delete(remotePeerId);
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this._sendSignalingMessage(remotePeerId, { type: 'offer', sdp: offer.sdp });
} catch (e) {
this._pendingConnect.delete(remotePeerId);
this.peerConnections.delete(remotePeerId);
console.warn('[WebRTC] connectToPeer failed for', remotePeerId, e);
}
}
_setupDataChannel(dc, remotePeerId) {
this.dataChannels.set(remotePeerId, dc);
dc.onopen = () => {
if (this.onStatusCallback) this.onStatusCallback(`peer-${remotePeerId}-connected`);
};
dc.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (this.onMessageCallback) {
this.onMessageCallback(remotePeerId, data);
}
} catch (e) {}
};
dc.onclose = () => {
this.dataChannels.delete(remotePeerId);
};
}
async _handleSignalingMessage(fromPeer, message) {
if (!this.isEnabled || fromPeer === this.localPeerId) return;
let pc = this.peerConnections.get(fromPeer);
if (!pc) {
pc = new RTCPeerConnection({ iceServers: this.iceServers });
this.peerConnections.set(fromPeer, pc);
pc.onicecandidate = (event) => {
if (event.candidate) {
this._sendSignalingMessage(fromPeer, { type: 'ice-candidate', candidate: event.candidate });
}
};
pc.onconnectionstatechange = () => {
const state = pc.connectionState;
console.log('[WebRTC] Peer', fromPeer, '(answerer) →', state);
if (this.onStatusCallback) this.onStatusCallback(`peer-${fromPeer}-${state}`);
if (state === 'failed' || state === 'disconnected') {
this._pendingConnect.delete(fromPeer);
this._reconnectPeer(fromPeer);
}
if (state === 'closed') this._pendingConnect.delete(fromPeer);
};
// Answerer side: use the same negotiated DataChannel config so both sides
// create the channel independently (no ondatachannel event needed).
const dc = pc.createDataChannel('jv5-chat', { negotiated: true, id: 0 });
this._setupDataChannel(dc, fromPeer);
}
if (message.type === 'offer') {
await pc.setRemoteDescription({ type: 'offer', sdp: message.sdp });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this._sendSignalingMessage(fromPeer, { type: 'answer', sdp: answer.sdp });
} else if (message.type === 'answer') {
await pc.setRemoteDescription({ type: 'answer', sdp: message.sdp });
} else if (message.type === 'ice-candidate' && message.candidate) {
try {
await pc.addIceCandidate(message.candidate);
} catch (e) {}
}
}
_sendSignalingMessage(toPeer, payload) {
// Send via ntfy on the signaling topic (short-lived, low volume)
const signalingMsg = {
v: 1,
from: this.localPeerId,
to: toPeer,
room: this.room,
webrtc: true,
payload,
ts: Date.now()
};
// Use existing chatNet or direct GM_xmlhttpRequest for signaling
GM_xmlhttpRequest({
method: 'POST',
url: `${_p2pGetRelay()}/${this.signalingTopic}`,
headers: { 'Content-Type': 'text/plain' },
data: JSON.stringify(signalingMsg)
});
}
_startSignalingListener() {
// Poll the signaling topic (lightweight, separate from main chat poll).
// Tracks lastSignalingId so re-polls never re-process old SDP/ICE messages.
const pollSignaling = async () => {
if (!this.isEnabled) return;
try {
const since = this._lastSignalingId || '10m';
const res = await gmFetch(
`${_p2pGetRelay()}/${this.signalingTopic}/json?since=${since}&poll=1`,
{ timeout: 10000 }
);
if (res.ok) {
const text = await res.text();
let maxId = this._lastSignalingId ? Number(this._lastSignalingId) : 0;
for (const line of text.split('\n')) {
if (!line.trim()) continue;
try {
const evt = JSON.parse(line);
// Track ntfy event ID to advance the since cursor
if (evt.id) {
const numId = Number(evt.id);
if (numId > maxId) maxId = numId;
// Dedup: skip events we've already processed
if (this._seenSignalingIds.has(evt.id)) continue;
this._seenSignalingIds.add(evt.id);
// Keep set bounded
if (this._seenSignalingIds.size > 500) {
const arr = [...this._seenSignalingIds];
arr.slice(0, 100).forEach(id => this._seenSignalingIds.delete(id));
}
}
if (evt.event === 'message' && evt.message) {
const msg = JSON.parse(evt.message);
if (msg.webrtc && msg.to === this.localPeerId && msg.room === this.room) {
this._handleSignalingMessage(msg.from, msg.payload);
}
}
} catch {}
}
if (maxId > Number(this._lastSignalingId || 0)) {
this._lastSignalingId = String(maxId);
}
}
} catch (e) {}
if (this.isEnabled) {
setTimeout(pollSignaling, 4000);
}
};
pollSignaling();
}
sendToPeer(remotePeerId, data) {
const dc = this.dataChannels.get(remotePeerId);
if (dc && dc.readyState === 'open') {
try { dc.send(JSON.stringify(data)); return true; } catch { return false; }
}
return false; // Fallback to ntfy will happen in caller
}
// Broadcast to every open DataChannel. Returns count of successful deliveries.
sendToAll(data) {
let sent = 0;
for (const [peerId] of this.dataChannels) {
if (this.sendToPeer(peerId, data)) sent++;
}
return sent;
}
// Number of peers with an open DataChannel right now
connectedCount() {
let n = 0;
for (const [, dc] of this.dataChannels) if (dc.readyState === 'open') n++;
return n;
}
// Glare prevention: compare the numeric timestamp suffix (last 4 base-36 chars of
// the peer ID) so the peer who joined earlier (lower timestamp) sends the offer.
// This is more reliable than lexicographic comparison because random prefixes can
// produce false inversions when both peers connect simultaneously.
// Falls back to full-string comparison if either suffix is not a valid base-36 number.
_shouldInitiate(remotePeerId) {
const localTs = parseInt((this.localPeerId || '').slice(-4), 36);
const remoteTs = parseInt(remotePeerId.slice(-4), 36);
if (!isNaN(localTs) && !isNaN(remoteTs) && localTs !== remoteTs) {
return localTs < remoteTs;
}
// Fallback: full string comparison (handles equal timestamps or malformed IDs)
return (this.localPeerId || '') < remotePeerId;
}
_reconnectPeer(peerId) {
this.peerConnections.get(peerId)?.close();
this.peerConnections.delete(peerId);
this.dataChannels.delete(peerId);
// Attempt reconnect after delay
setTimeout(() => {
if (this.isEnabled) this.connectToPeer(peerId);
}, 3000);
}
_closeAllConnections() {
this.peerConnections.forEach(pc => pc.close());
this.peerConnections.clear();
this.dataChannels.clear();
}
setMessageHandler(cb) { this.onMessageCallback = cb; }
setStatusHandler(cb) { this.onStatusCallback = cb; }
} // close WebRTCManager class
const webrtcManager = new WebRTCManager();
try { unsafeWindow.jv5WebRTC = webrtcManager; } catch {}
const selectorEngine = new SelectorEngine();
// Expose for debug
try { unsafeWindow.jv5SelectorEngine = selectorEngine; } catch {}
// Legacy compat + auto start healing
const SELECTOR_CONFIG = selectorEngine.primary; // for old code paths
function _initRemoteConfig() { selectorEngine.loadRemote(); }
// Start self-healing early
if (document.readyState !== 'loading') selectorEngine.startSelfHealing();
else document.addEventListener('DOMContentLoaded', () => selectorEngine.startSelfHealing(), { once: true });
// ─── IDENTITY HELPERS ─────────────────────────────────────────────────────────
/**
* Returns the persistent anonymous peer ID for this browser, generating and
* storing one on first call.
* @returns {string} Peer ID prefixed with 'p' (e.g. `"pab3f91c2a4b2"`).
*/
function _p2pGetPeerId() {
let id = GM_getValue(P2P_GM_PEERID, null);
if (!id) {
id = 'p' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
GM_setValue(P2P_GM_PEERID, id);
}
return id;
}
function _p2pGetNickname() { return GM_getValue(P2P_GM_NICKNAME, 'Anonymous'); }
function _p2pGetRoom() { return GM_getValue(P2P_GM_ROOM, 'global'); }
function _p2pGetCharId() {
// 1. Current URL is a character card page — extract, persist, and return the ID.
const urlMatch = location.pathname.match(/\/characters\/([A-Za-z0-9_-]{4,})/);
if (urlMatch) {
const id = urlMatch[1].replace(/-/g, '').slice(0, 20);
try { GM_setValue(P2P_GM_LAST_CHAR, id); } catch {}
return id;
}
// 2. /chats/ page — fall back to last stored character ID
if (location.pathname.startsWith('/chats/')) {
try { const stored = GM_getValue(P2P_GM_LAST_CHAR, null); if (stored) return stored; } catch {}
}
return null;
}
function _p2pGetCharAvatar() {
try {
const selectors = [
'[class*="characterAvatar"] img',
'[class*="character-avatar"] img',
'[class*="CharacterAvatar"] img',
'[class*="characterImage"] img',
'[class*="character-image"] img',
'[class*="chatHeader"] img',
'[class*="ChatHeader"] img',
'[class*="chat-header"] img',
'header img',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el?.src && !el.src.startsWith('data:') && el.naturalWidth > 0) return el.src;
if (el?.src && !el.src.startsWith('data:')) return el.src;
}
} catch {}
return null;
}
function _p2pGetCharName() {
try {
const raw = (document.title || '').replace(/\s*[-|]\s*(JanitorAI|janitorai\.com|Janitor AI).*/i, '').trim();
if (raw && raw.length > 1 && raw.length < 60) {
try { GM_setValue(P2P_GM_LAST_CHAR_NAME, raw); } catch {}
return raw;
}
const selectors = [
'[class*="characterName"]', '[class*="character_name"]',
'[class*="character-name"]', '[class*="chatHeader"] h1',
'[class*="ChatHeader"] h1', '[class*="chat-header"] h1', 'header h1',
];
for (const sel of selectors) {
const el = document.querySelector(sel);
const t = el?.textContent?.trim();
if (t && t.length > 1 && t.length < 60) {
try { GM_setValue(P2P_GM_LAST_CHAR_NAME, t); } catch {}
return t;
}
}
} catch {}
// Fallback: /chats/ page — use stored name
if (location.pathname.startsWith('/chats/')) {
try { const n = GM_getValue(P2P_GM_LAST_CHAR_NAME, null); if (n) return n; } catch {}
}
return null;
}
function _p2pGetTopic(room) {
if (room === 'char') {
const cid = _p2pGetCharId();
// Validate: only allow alphanumeric IDs (1–20 chars) to prevent topic injection
if (cid && /^[A-Za-z0-9]{1,20}$/.test(cid)) return `jv4-c-${cid}-v1`;
}
return P2P_TOPIC_GLOBAL;
}
// ─── HISTORY STORAGE ──────────────────────────────────────────────────────────
function _p2pGetHistory() {
try { return JSON.parse(GM_getValue(P2P_GM_HISTORY, '[]')); } catch { return []; }
}
function _p2pAddHistory(msg) {
try {
const hist = _p2pGetHistory();
hist.push(msg);
if (hist.length > P2P_MAX_HISTORY) hist.splice(0, hist.length - P2P_MAX_HISTORY);
GM_setValue(P2P_GM_HISTORY, JSON.stringify(hist));
} catch (e) { console.warn('[JV5] Failed to save P2P history:', e); }
}
// ─── BLOCKED LIST STORAGE ─────────────────────────────────────────────────────
function _p2pGetBlocked() { try { return new Set(JSON.parse(GM_getValue(P2P_GM_BLOCKED, '[]'))); } catch { return new Set(); } }
function _p2pBlockPeer(id) { try { const b = _p2pGetBlocked(); b.add(id); GM_setValue(P2P_GM_BLOCKED, JSON.stringify([...b])); } catch {} }
function _p2pUnblockPeer(id) { try { const b = _p2pGetBlocked(); b.delete(id); GM_setValue(P2P_GM_BLOCKED, JSON.stringify([...b])); } catch {} }
let _chatVerifiedMap = new Map();
let _chatVerifiedLastFetch = 0;
function _p2pFetchVerified() {
if (!P2P_VERIFIED_URL) return;
if (Date.now() - _chatVerifiedLastFetch < 24 * 60 * 60 * 1000) return;
_chatVerifiedLastFetch = Date.now();
try {
GM_xmlhttpRequest({
redirect: 'manual',
method: 'GET', url: P2P_VERIFIED_URL, timeout: 8000,
onload(r) {
if (r.status < 200 || r.status >= 300) return;
try {
const data = JSON.parse(r.responseText);
if (!Array.isArray(data.verified)) return;
_chatVerifiedMap = new Map();
for (const e of data.verified) if (e.peer) _chatVerifiedMap.set(e.peer, e.name || e.peer);
} catch { }
},
});
} catch { }
}
const chatStore = {
open: false,
listEl: null,
messages: [],
seenMsgIds: new Set(),
seenNtfyIds: new Set(),
blocked: new Set(),
pinnedText: '',
replyingTo: null,
isAdmin: false,
onlineMap: new Map(),
lastSend: 0,
atBottom: true,
reset() {
this.open = false;
this.listEl = null;
this.messages = [];
this.seenMsgIds = new Set();
this.seenNtfyIds = new Set();
this.replyingTo = null;
this.atBottom = true;
this._roomPasswords = {}; // SECURITY: clear cached encryption passwords on reset
},
};
// ─── CRYPTO UTILITIES ─────────────────────────────────────────────────────────
/**
* Returns the hex-encoded SHA-256 digest of `str` using the Web Crypto API.
* Used to hash admin passwords client-side before storage and comparison.
* @param {string} str
* @returns {Promise<string>} 64-character lowercase hex string.
*/
async function _sha256(str) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
// ─── ADMIN HMAC SIGNING (replaces plaintext hash transmission) ─────────────
// Instead of sending P2P_ADMIN_HASH in every message (allowing anyone monitoring
// ntfy.sh to capture it and spoof admin styling), we sign each message with an
// HMAC derived from the hash. The verifier re-derives the same HMAC locally.
// The secret never leaves GM storage.
/**
* Signs a message ID + timestamp with an HMAC-SHA-256 key derived from
* the stored admin hash. The signature travels with admin messages so
* recipients can verify authority without the plaintext password or hash
* ever being transmitted over the relay.
*
* @param {string} msgId - The message's unique ID.
* @param {number} ts - Unix timestamp (ms) of the message.
* @returns {Promise<string>} Hex HMAC signature, or `''` if no admin hash is set.
*/
async function _signAdminMsg(msgId, ts) {
if (!P2P_ADMIN_HASH) return '';
try {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw', enc.encode(P2P_ADMIN_HASH),
{ name: 'HMAC', hash: 'SHA-256' },
false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(msgId + ':' + ts));
return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
} catch { return ''; }
}
/**
* Verifies an admin HMAC signature using a constant-time comparison to
* prevent timing attacks.
*
* @param {string} msgId - The message's unique ID.
* @param {number} ts - Unix timestamp (ms) of the message.
* @param {string} sig - Hex HMAC to verify.
* @returns {Promise<boolean>}
*/
async function _verifyAdminSig(msgId, ts, sig) {
if (!P2P_ADMIN_HASH || !sig) return false;
try {
const expected = await _signAdminMsg(msgId, ts);
// Constant-time comparison (prevents timing attacks)
if (expected.length !== sig.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
return diff === 0;
} catch { return false; }
}
// Security & leak prevention: track document-level click dismiss listeners
// so they can be forcibly removed when the P2P modal closes (prevents memory leaks
// from closures capturing picker elements when closed via backdrop/Escape instead of outside-click)
let _p2pActiveDismiss = new Set();
function _p2pCleanupDismissListeners() {
_p2pActiveDismiss.forEach(fn => {
try { document.removeEventListener('click', fn, true); } catch {}
});
_p2pActiveDismiss.clear();
}
const chatNet = {
abortFlag: false,
xhr: null,
reconnTimer: null,
backoffMs: P2P_BACKOFF_MIN,
lastEventId: null,
_onlineHandler: null, // window 'online' → immediate repoll
_currentTopic: null, // track active topic for online-handler closure
typingXhr: null,
typingProcessed: 0,
typingStreamTimer: null,
typingLastId: '1m',
typingTimers: new Map(),
typingSendTimer: null,
hbXhr: null,
hbProcessed: 0,
hbStreamTimer: null,
hbSendTimer: null,
hbLastId: '2m',
connect() {
this.disconnect();
this.abortFlag = false;
this.backoffMs = P2P_BACKOFF_MIN;
// Sync blocked list from persistent storage so mutes applied while
// modal was closed are honoured immediately on re-open.
chatStore.blocked = _p2pGetBlocked();
// Guard: if room is 'char' but we're not on a character page, fall back to global
// so the topic doesn't silently collapse to the global topic while the UI
// thinks it's in char-room (causing char messages to appear in global).
const room = _p2pGetRoom();
if (room === 'char' && !_p2pGetCharId()) {
try { GM_setValue(P2P_GM_ROOM, 'global'); } catch {}
}
const topic = _p2pGetTopic(_p2pGetRoom());
this._currentTopic = topic;
this._startPoll(topic);
this._startTypingStream();
this._startHb();
// ── WebRTC: enable direct P2P for this room ──────────────────────────────
// Wire the DataChannel message handler → _handleEvent so incoming WebRTC
// messages are processed the same way as ntfy messages.
// ntfy remains active as a fallback for peers not yet connected via WebRTC.
const _webrtcRoom = _p2pGetRoom();
const _webrtcPeer = _p2pGetPeerId();
webrtcManager.setMessageHandler((fromPeer, data) => {
// Wrap raw data as a synthetic ntfy event so _handleEvent can process it
if (data && data.peer && data.v === 1) {
_handleEvent({ message: JSON.stringify(data) }).catch(() => {});
}
});
webrtcManager.setStatusHandler((status) => {
if (status.startsWith('peer-') && status.endsWith('-connected')) {
const connCount = webrtcManager.connectedCount();
console.log('[WebRTC] Direct channel open — ' + connCount + ' peer(s) connected');
}
});
webrtcManager.enable(_webrtcRoom, _webrtcPeer).catch(() => {});
// When the device comes back online, kick off a poll immediately
this._onlineHandler = () => {
if (!this.abortFlag) {
this.backoffMs = P2P_BACKOFF_MIN;
clearTimeout(this.reconnTimer);
this._startPoll(this._currentTopic);
}
};
window.addEventListener('online', this._onlineHandler);
},
disconnect() {
this.abortFlag = true;
this._abort('xhr');
this._abort('typingXhr');
this._abort('hbXhr');
clearTimeout(this.reconnTimer);
clearTimeout(this.typingStreamTimer);
clearTimeout(this.hbStreamTimer);
this._currentTopic = null;
if (this._onlineHandler) {
window.removeEventListener('online', this._onlineHandler);
this._onlineHandler = null;
}
if (this.typingSendTimer) { clearTimeout(this.typingSendTimer); this.typingSendTimer = null; }
if (this.hbSendTimer) { clearInterval(this.hbSendTimer); this.hbSendTimer = null; }
this.typingTimers.forEach(t => clearTimeout(t));
this.typingTimers.clear();
const el = document.getElementById('jv4-p2p-typing');
if (el) el.innerHTML = '';
// Disable WebRTC — closes all DataChannels and peer connections cleanly
try { webrtcManager.disable(); } catch {}
},
_abort(key) {
if (this[key]) { try { this[key].abort(); } catch {} this[key] = null; }
},
// ── MAIN MESSAGE POLL ──────────────────────────────────────────────────────
// Why poll=1 instead of streaming?
// GM_xmlhttpRequest does NOT reliably fire onprogress for long-lived streaming
// connections — it buffers the response until the server closes the socket
// (ntfy.sh does this every ~30-60s), making messages appear with massive delay.
// poll=1 tells ntfy.sh to return immediately with whatever is queued, so each
// round-trip takes only a few hundred milliseconds over WiFi or mobile data.
_startPoll(topic) {
if (this.abortFlag) return;
this._abort('xhr');
clearTimeout(this.reconnTimer);
const since = this.lastEventId || '30m';
this.xhr = GM_xmlhttpRequest({
redirect: 'manual',
method: 'GET',
url: `${_p2pGetRelay()}/${topic}/json?since=${since}&poll=1`,
headers: { 'Accept': 'application/x-ndjson' },
timeout: 15000,
onload: (r) => {
if (this.abortFlag) return;
if (r.responseText?.trim()) {
this._processChunk(r.responseText);
}
// Still alive — show green dot and schedule next poll
this.backoffMs = P2P_BACKOFF_MIN;
chatRender.updateStatus('connected');
this.reconnTimer = setTimeout(() => this._startPoll(topic), P2P_POLL_MS);
},
// Network error — use exponential backoff
onerror: () => {
if (this.abortFlag) return;
chatRender.updateStatus('reconnecting');
const delay = this.backoffMs;
this.backoffMs = Math.min(
Math.round(this.backoffMs * P2P_BACKOFF_MULT),
P2P_BACKOFF_MAX
);
this.reconnTimer = setTimeout(() => this._startPoll(topic), delay);
},
// poll=1 almost never times out, but treat it the same as no-error
ontimeout: () => {
if (this.abortFlag) return;
this.backoffMs = P2P_BACKOFF_MIN;
chatRender.updateStatus('connected');
this.reconnTimer = setTimeout(() => this._startPoll(topic), P2P_POLL_MS);
},
});
},
_processChunk(text) {
let maxId = this.lastEventId ? Number(this.lastEventId) : 0;
const msgs = [];
for (const line of text.split('\n')) {
if (!line.trim()) continue;
try {
const evt = JSON.parse(line);
if (evt.id) {
const numId = Number(evt.id);
if (chatStore.seenNtfyIds.has(evt.id)) continue;
chatStore.seenNtfyIds.add(evt.id);
if (numId > maxId) maxId = numId;
}
if (evt.event === 'message') msgs.push(evt);
} catch {}
}
if (chatStore.seenNtfyIds.size > 2000) {
// Keep the most recent 1500 IDs; removing too many at once risks seeing
// an already-shown message again after a reconnect
const arr = [...chatStore.seenNtfyIds];
arr.slice(0, 500).forEach(id => chatStore.seenNtfyIds.delete(id));
}
if (maxId > Number(this.lastEventId || 0)) {
this.lastEventId = String(maxId);
}
if (msgs.length) {
(async () => {
if (this.abortFlag) return;
for (const evt of msgs) {
await _handleEvent(evt).catch(e => console.warn('[JanitorV5] handleEvent:', e));
}
})();
}
},
send(payload) {
const topic = _p2pGetTopic(_p2pGetRoom());
let responded = false;
// Watchdog: if no HTTP response within 10 s, treat as a timeout failure
const watchdog = setTimeout(() => {
if (responded) return;
responded = true;
_showSendFailBanner('timeout', payload);
}, 10000);
GM_xmlhttpRequest({
redirect: 'manual',
method: 'POST',
url: `${_p2pGetRelay()}/${topic}`,
headers: { 'Content-Type': 'text/plain' },
data: JSON.stringify(payload),
timeout: 10000,
onload(r) {
if (responded) return;
responded = true;
clearTimeout(watchdog);
if (r.status === 429) {
// Server-side rate limit: show airplane-mode tip + retry option
_showSendFailBanner('ratelimit', payload);
} else if (r.status >= 400) {
// Other server errors (5xx, auth, etc.)
_showSendFailBanner('failed', payload);
}
// 2xx / 3xx → message delivered
if (r.status >= 200 && r.status < 300) {
_markMessageDelivered(payload.msgId);
}
},
onerror() {
if (responded) return;
responded = true;
clearTimeout(watchdog);
_showSendFailBanner('failed', payload);
},
ontimeout() {
if (responded) return;
responded = true;
_showSendFailBanner('timeout', payload);
},
});
},
sendTyping() {
GM_xmlhttpRequest({
redirect: 'manual',
method: 'POST',
url: `${_p2pGetRelay()}/${P2P_TOPIC_TYPING}`,
headers: { 'Content-Type': 'text/plain' },
data: JSON.stringify({ v:1, peer: _p2pGetPeerId(), nick: _p2pGetNickname(), type: 'typing', ts: Date.now() }),
});
},
_handleTypingEvent(evt) {
try {
const msg = JSON.parse(evt.message);
if (!msg || msg.type !== 'typing' || !msg.peer || msg.peer === _p2pGetPeerId()) return;
if (chatStore.blocked.has(msg.peer)) return;
const el = document.getElementById('jv4-p2p-typing');
if (!el) return;
const nick = (msg.nick || 'Someone').slice(0, 20);
if (this.typingTimers.has(msg.peer)) clearTimeout(this.typingTimers.get(msg.peer));
let span = el.querySelector(`[data-typing-peer="${CSS.escape(msg.peer)}"]`);
if (!span) {
span = document.createElement('span');
span.dataset.typingPeer = msg.peer;
el.appendChild(span);
}
span.textContent = nick;
this._renderTypingText(el);
const t = setTimeout(() => {
el.querySelector(`[data-typing-peer="${CSS.escape(msg.peer)}"]`)?.remove();
this._renderTypingText(el);
this.typingTimers.delete(msg.peer);
}, P2P_TYPING_TTL);
this.typingTimers.set(msg.peer, t);
} catch {}
},
_renderTypingText(el) {
[...el.childNodes].filter(n => n.nodeType === 3).forEach(n => n.remove());
const peers = el.querySelectorAll('[data-typing-peer]');
if (!peers.length) return;
const names = [...peers].map(s => s.textContent);
const label = names.length === 1
? `${names[0]} is typing…`
: names.length === 2
? `${names[0]} and ${names[1]} are typing…`
: 'Several people are typing…';
el.appendChild(document.createTextNode(' ' + label));
},
_startTypingStream() {
this._abort('typingXhr');
clearTimeout(this.typingStreamTimer);
if (this.abortFlag) return;
this.typingProcessed = 0;
this.typingStreamTimer = setTimeout(() => {
if (!this.abortFlag) this._startTypingStream();
}, 120000);
this.typingXhr = GM_xmlhttpRequest({
redirect: 'manual',
method: 'GET',
url: `${_p2pGetRelay()}/${P2P_TOPIC_TYPING}/json?since=${this.typingLastId}`,
headers: { 'Accept': 'application/x-ndjson' },
onprogress: (r) => {
if (this.abortFlag) return;
if (!r.responseText) return;
const chunk = r.responseText.slice(this.typingProcessed);
this.typingProcessed = r.responseText.length;
for (const line of chunk.split('\n')) {
if (!line.trim()) continue;
try {
const e = JSON.parse(line);
if (e.id) this.typingLastId = e.id;
if (e.event === 'message') this._handleTypingEvent(e);
} catch {}
}
},
onload: () => { if (!this.abortFlag) setTimeout(() => this._startTypingStream(), 100); },
onerror: () => { if (!this.abortFlag) setTimeout(() => this._startTypingStream(), 2000); },
});
},
_sendHb() {
const myId = _p2pGetPeerId();
chatStore.onlineMap.set(myId, Date.now());
chatRender.updateOnlineCount();
GM_xmlhttpRequest({
redirect: 'manual',
method: 'POST',
url: `${_p2pGetRelay()}/${P2P_TOPIC_HB}`,
headers: { 'Content-Type': 'text/plain' },
data: JSON.stringify({ v:1, peer: myId, type: 'hb', ts: Date.now() }),
});
},
_handleHbEvent(evt) {
try {
const msg = JSON.parse(evt.message);
if (!msg || msg.type !== 'hb' || !msg.peer) return;
const isNewPeer = !chatStore.onlineMap.has(msg.peer);
chatStore.onlineMap.set(msg.peer, Date.now());
const cutoff = Date.now() - P2P_HB_EXPIRE_MS;
for (const [peer, ts] of chatStore.onlineMap) if (ts < cutoff) chatStore.onlineMap.delete(peer);
chatRender.updateOnlineCount();
// Attempt WebRTC connection to newly-seen peers.
// connectToPeer() is a no-op if:
// • WebRTC is not enabled • Already connected • Already pending
// • Glare prevention says this side should wait for the offer
if (isNewPeer && webrtcManager.isEnabled && msg.peer !== _p2pGetPeerId()) {
webrtcManager.connectToPeer(msg.peer).catch(() => {});
}
} catch {}
},
_startHb() {
this._abort('hbXhr');
clearTimeout(this.hbStreamTimer);
if (this.hbSendTimer) { clearInterval(this.hbSendTimer); this.hbSendTimer = null; }
if (this.abortFlag) return;
this._sendHb();
this.hbSendTimer = setInterval(() => this._sendHb(), P2P_HB_SEND_MS);
const startHbStream = () => {
this._abort('hbXhr');
clearTimeout(this.hbStreamTimer);
if (this.abortFlag) return;
this.hbProcessed = 0;
this.hbStreamTimer = setTimeout(() => startHbStream(), 120000);
this.hbXhr = GM_xmlhttpRequest({
redirect: 'manual',
method: 'GET',
url: `${_p2pGetRelay()}/${P2P_TOPIC_HB}/json?since=${this.hbLastId}`,
headers: { 'Accept': 'application/x-ndjson' },
onprogress: (r) => {
if (this.abortFlag) return;
if (!r.responseText) return;
const chunk = r.responseText.slice(this.hbProcessed);
this.hbProcessed = r.responseText.length;
for (const line of chunk.split('\n')) {
if (!line.trim()) continue;
try {
const e = JSON.parse(line);
if (e.id) this.hbLastId = e.id;
if (e.event === 'message') this._handleHbEvent(e);
} catch {}
}
},
onload: () => { if (!this.abortFlag) setTimeout(() => startHbStream(), 100); },
onerror: () => { if (!this.abortFlag) setTimeout(() => startHbStream(), 3000); },
});
};
startHbStream();
},
};
/**
* Processes a raw ntfy event (or a synthetic one from a WebRTC DataChannel).
*
* Responsibilities in order:
* 1. Parse JSON payload and validate `v === 1`.
* 2. Room-guard — drop messages that belong to a different room.
* 3. AES-GCM decryption via `P2PCrypto` when the room is encrypted.
* 4. Handle reaction sub-type early (no text required).
* 5. Freshness check — drop messages older than 6 h (replay protection).
* 6. Per-message dedup via `chatStore.seenMsgIds`.
* 7. HMAC admin-signature verification for `/command` messages.
* 8. Blocked-peer filter.
* 9. Reply notification, history persistence, and bubble render.
*
* @param {{ message: string }} ntfyEvt - ntfy.sh event object with a
* JSON-encoded `message` field.
* @returns {Promise<void>}
*/
async function _handleEvent(ntfyEvt) {
try {
const msg = JSON.parse(ntfyEvt.message);
if (!msg || !msg.peer || msg.v !== 1) return;
// ── Room guard ─────────────────────────────────────────────────────────────
// Since we subscribe to ONE topic per session, a room mismatch shouldn't
// normally happen, but it can when:
// • room stored as 'char' but charId is null → topic collapsed to global,
// so the UI thinks it's in char-room while reading global messages.
// • History replay across rooms on modal reopen.
// Drop any message whose room tag doesn't match what we're currently viewing.
const curRoom = _p2pGetRoom();
if (msg.room && msg.room !== curRoom) return;
// Extra check: if we're in char-room, only accept messages that were explicitly
// tagged 'char' (old messages with no room tag are shown for backwards compat
// in global only, not char rooms).
if (curRoom === 'char' && msg.room !== 'char') return;
// ──────────────────────────────────────────────────────────────────────────
// === PSK Decryption (always-on, transparent to user) ===
if (isPskEncrypted(msg)) {
const decrypted = await pskDecrypt(msg);
if (!decrypted) {
// Wrong PSK version or corrupted — silently drop
// This happens when the sender is on a different PSK version (key rotation)
console.warn('[JV5-PSK] Dropped message: wrong PSK version or corrupted');
return;
}
// Restore routing fields that were kept plaintext
const roomTag = msg.room;
const nickTag = msg.nick;
Object.assign(msg, decrypted);
if (roomTag) msg.room = roomTag;
if (nickTag && !msg.nick) msg.nick = nickTag;
msg._wasEncrypted = true;
msg._pskDecrypted = true;
}
// === Phase 1 Per-Room Decryption (for double-encrypted char rooms) ===
if (msg.encrypted && !msg._pskDecrypted && P2PCrypto.isRoomEncrypted(curRoom)) {
if (!chatStore._roomPasswords) chatStore._roomPasswords = {};
let pw = chatStore._roomPasswords[curRoom];
if (!pw) {
pw = prompt('Enter password to decrypt messages in this room:');
if (pw) chatStore._roomPasswords[curRoom] = pw;
else return;
}
const decrypted = await P2PCrypto.decrypt(msg, pw, curRoom);
if (!decrypted) return;
Object.assign(msg, decrypted);
msg._wasEncrypted = true;
}
if (msg.type === 'reaction' && msg.reactionTo && msg.text) {
if (msg.peer !== _p2pGetPeerId() && !chatStore.blocked.has(msg.peer)) {
chatRender.applyReaction(msg.reactionTo, msg.text, msg.peer, false);
}
return;
}
if (!msg.text) return;
// Basic replay / freshness protection (addresses public relay replay concern)
const now = Date.now();
if (msg.ts && Math.abs(now - msg.ts) > 1000 * 60 * 60 * 6) { // 6 hour window
return; // too old, likely replay or stale
}
if (msg.peer === _p2pGetPeerId()) return;
if (msg.msgId) {
if (chatStore.seenMsgIds.has(msg.msgId)) return;
chatStore.seenMsgIds.add(msg.msgId);
// Prevent unbounded memory growth for very long-lived modal sessions
if (chatStore.seenMsgIds.size > 2000) {
const arr = [...chatStore.seenMsgIds];
arr.slice(0, 500).forEach(id => chatStore.seenMsgIds.delete(id));
}
}
// SECURITY: Verify HMAC signature — never trust adminToken in plaintext.
// _verifyAdminSig re-derives the HMAC locally; the secret never leaves GM storage.
if (msg.adminSig && msg.text && msg.text.startsWith('/') && msg.msgId) {
const _isVerifiedAdmin = await _verifyAdminSig(msg.msgId, msg.ts, msg.adminSig);
if (_isVerifiedAdmin && msg.peer === _p2pGetPeerId()) {
chatCmd.execute(msg.text, msg.peer);
}
// Fall through so admin-signed messages still render with gold border
}
if (chatStore.blocked.has(msg.peer)) return;
_maybeNotifyReply(msg);
_p2pAddHistory(msg);
chatStore.messages.push(msg);
chatRender.appendBubble(msg, false);
} catch (err) {
console.warn('[JanitorV5] _handleEvent error:', err);
}
}
const chatRender = {
appendBubble(msg, isMine, isNewlySent = false) {
const list = chatStore.listEl;
if (!list) return;
const myPeer = _p2pGetPeerId();
const isMe = isMine || msg.peer === myPeer;
if (msg.msgId && list.querySelector(`[data-msgid="${CSS.escape(msg.msgId)}"]`)) return;
const bubble = document.createElement('div');
bubble.className = `jv4-p2p-bubble ${isMe ? 'jv4-p2p-bubble-me' : 'jv4-p2p-bubble-other'}`;
bubble.dataset.peer = msg.peer;
if (msg.msgId) bubble.dataset.msgid = msg.msgId;
// Async HMAC verify for admin gold border — never trust plain adminToken
if (!isMe && msg.adminSig && msg.msgId) {
_verifyAdminSig(msg.msgId, msg.ts, msg.adminSig).then(isAdmin => {
if (isAdmin) bubble.classList.add('jv4-p2p-bubble-admin');
});
}
if (!isMe && chatStore.blocked.has(msg.peer)) {
bubble.style.display = 'none';
bubble.dataset.muted = '1';
}
const time = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const nick = isMe ? 'You' : (msg.nick || 'Anonymous').slice(0, 24);
const shortId = (msg.peer || '????').slice(0, 4);
const verified = !isMe && _chatVerifiedMap.has(msg.peer);
const verBadge = verified
? `<span title="Verified: ${_esc(_chatVerifiedMap.get(msg.peer) || '')}" style="color:#10b981;font-size:11px;margin-left:3px;cursor:default;">✓</span>`
: '';
let quoteHTML = '';
if (msg.replyTo) {
const rNick = _esc((msg.replyTo.nick || 'Someone').slice(0, 20));
const rText = _esc((msg.replyTo.text || '').slice(0, 80));
quoteHTML = `<div class="jv4-p2p-quote">↩ ${rNick}: ${rText}</div>`;
}
const muteLabel = chatStore.blocked.has(msg.peer) ? '🔊' : 'mute';
bubble.innerHTML = `
<div class="jv4-p2p-meta">
<span class="jv4-p2p-nick">${_esc(nick)}</span>
<span class="jv4-p2p-peerid" data-peer="${_esc(msg.peer)}" title="Tap to copy peer ID"
style="font-size:10px;color:#6b7280;margin-left:2px;cursor:pointer;">#${_esc(shortId)}</span>
${verBadge}
<span class="jv4-p2p-time">${time}</span>
${msg.msgId ? `<button class="jv4-p2p-mute jv4-act-reply" title="Reply">↩</button>` : ''}
${msg.msgId ? `<button class="jv4-p2p-mute jv4-act-react" title="React">+</button>` : ''}
${!isMe ? `<button class="jv4-p2p-mute jv4-act-mute" data-peer="${_esc(msg.peer)}">${_esc(muteLabel)}</button>` : ''}
${!isMe ? `<button class="jv4-p2p-mute jv4-act-report" style="color:#ef4444;" title="Report">⚑</button>` : ''}
</div>
${quoteHTML}
<p class="jv4-p2p-text">${_esc(msg.text).replace(/\n/g, '<br>')}</p>
${isMe && isNewlySent ? '<span class="jv4-delivery-status jv4-delivery-pending">Sending…</span>' : ''}
`;
bubble.querySelector('.jv4-p2p-peerid')?.addEventListener('click', function () {
{
navigator.clipboard.writeText(this.dataset.peer)
.then(() => topToast('Peer ID copied!'))
.catch(() => topToast('Tap and hold to copy manually'));
}
});
bubble.querySelector('.jv4-act-reply')?.addEventListener('click', e => {
e.stopPropagation();
chatStore.replyingTo = { id: msg.msgId, nick: msg.nick || 'Anonymous', text: msg.text, peer: msg.peer };
const strip = document.getElementById('jv4-p2p-reply-strip');
const txt = document.getElementById('jv4-p2p-reply-text');
if (strip && txt) {
txt.textContent = `↩ ${(msg.nick || 'Anonymous').slice(0, 20)}: ${msg.text.slice(0, 60)}`;
strip.classList.add('visible');
}
document.getElementById('jv4-p2p-input')?.focus();
});
bubble.querySelector('.jv4-act-react')?.addEventListener('click', e => {
e.stopPropagation();
this.showReactionPicker(bubble, msg.msgId);
});
bubble.querySelector('.jv4-act-mute')?.addEventListener('click', e => {
e.stopPropagation();
const peerId = msg.peer;
const isMuted = chatStore.blocked.has(peerId);
if (isMuted) {
_p2pUnblockPeer(peerId);
chatStore.blocked.delete(peerId);
list.querySelectorAll(`[data-peer="${CSS.escape(peerId)}"][data-muted="1"]`).forEach(b => {
b.style.display = '';
delete b.dataset.muted;
});
this._syncMuteButtons(list);
this.systemMsg('🔊 Unmuted — you can now see messages from this user');
} else {
_p2pBlockPeer(peerId);
chatStore.blocked.add(peerId);
list.querySelectorAll(`[data-peer="${CSS.escape(peerId)}"]`).forEach(b => {
b.style.display = 'none';
b.dataset.muted = '1';
});
this._syncMuteButtons(list);
this.systemMsg('🔇 Muted — you won\'t see messages from this user');
}
});
bubble.querySelector('.jv4-act-report')?.addEventListener('click', e => {
e.stopPropagation();
if (window.confirm(`Report this message from ${(msg.nick || '?').slice(0, 20)}?`)) {
GM_xmlhttpRequest({
redirect: 'manual',
method: 'POST',
url: `${_p2pGetRelay()}/${P2P_TOPIC_REPORTS}`,
headers: { 'Content-Type': 'text/plain' },
data: JSON.stringify({ v:1, reporter: _p2pGetPeerId(), reported: msg.peer, nick: msg.nick, text: msg.text, ts: Date.now() }),
onload() { topToast('Report submitted to admin'); },
});
}
});
list.appendChild(bubble);
this._maybeScroll(list);
},
systemMsg(text) {
const list = chatStore.listEl;
if (!list) return;
const el = document.createElement('div');
el.className = 'jv4-p2p-bubble jv4-p2p-bubble-system';
el.textContent = text;
list.appendChild(el);
this._maybeScroll(list);
},
_maybeScroll(list) {
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 60;
if (atBottom) {
list.scrollTop = list.scrollHeight;
this._hideNewMsgBadge();
} else {
this._showNewMsgBadge();
}
},
_showNewMsgBadge() {
if (document.getElementById('jv4-new-msg-badge')) return;
const badge = document.createElement('button');
badge.id = 'jv4-new-msg-badge';
badge.textContent = '↓ New messages';
badge.style.cssText = `
position:absolute; bottom:52px; left:50%; transform:translateX(-50%);
background:rgba(139,92,246,0.9); color:#fff; border:none; border-radius:20px;
padding:4px 14px; font-size:11px; cursor:pointer; z-index:10000090;
box-shadow:0 2px 8px rgba(0,0,0,0.5); animation:ms2-up 0.15s ease;
white-space:nowrap;
`;
badge.addEventListener('click', () => {
const list = chatStore.listEl;
if (list) list.scrollTop = list.scrollHeight;
badge.remove();
});
const modal = document.getElementById('jv4-p2p-modal');
if (modal) { modal.style.position = 'relative'; modal.appendChild(badge); }
},
_hideNewMsgBadge() {
document.getElementById('jv4-new-msg-badge')?.remove();
},
_syncMuteButtons(list) {
list.querySelectorAll('.jv4-act-mute[data-peer]').forEach(btn => {
btn.textContent = chatStore.blocked.has(btn.dataset.peer) ? '🔊' : 'mute';
});
},
applyReaction(msgId, emoji, senderPeer, isMine) {
const bubble = chatStore.listEl?.querySelector(`[data-msgid="${CSS.escape(msgId)}"]`);
if (!bubble) return;
let bar = bubble.querySelector('.jv4-p2p-reactions');
if (!bar) { bar = document.createElement('div'); bar.className = 'jv4-p2p-reactions'; bubble.appendChild(bar); }
let rxn = bar.querySelector(`[data-emoji="${CSS.escape(emoji)}"]`);
if (!rxn) {
rxn = document.createElement('button');
rxn.className = 'jv4-p2p-rxn';
rxn.dataset.emoji = emoji;
rxn.dataset.count = '0';
rxn.innerHTML = `${emoji} <span class="jv4-p2p-rxn-count">1</span>`;
rxn.addEventListener('click', () => _sendReaction(msgId, emoji));
bar.appendChild(rxn);
} else {
const c = parseInt(rxn.dataset.count || '0') + 1;
rxn.dataset.count = String(c);
rxn.querySelector('.jv4-p2p-rxn-count').textContent = String(c);
}
if (isMine) rxn.classList.add('mine');
},
showReactionPicker(anchorBubble, msgId) {
document.querySelectorAll('.jv4-rxn-picker').forEach(e => e.remove());
const picker = document.createElement('div');
picker.className = 'jv4-rxn-picker';
for (const emoji of P2P_REACTION_EMOJIS) {
const btn = document.createElement('button');
btn.className = 'jv4-rxn-pick-btn'; btn.textContent = emoji;
btn.addEventListener('click', e => { e.stopPropagation(); _sendReaction(msgId, emoji); picker.remove(); });
picker.appendChild(btn);
}
anchorBubble.style.position = 'relative';
anchorBubble.appendChild(picker);
const dismiss = e => {
if (!picker.contains(e.target)) {
picker.remove();
document.removeEventListener('click', dismiss, true);
_p2pActiveDismiss.delete(dismiss);
}
};
setTimeout(() => {
document.addEventListener('click', dismiss, true);
_p2pActiveDismiss.add(dismiss);
}, 10);
},
updatePinnedBar() {
const bar = document.getElementById('jv4-p2p-pinned-bar');
if (!bar) return;
let txt = chatStore.pinnedText;
try { txt = GM_getValue(P2P_GM_PINNED, '') || ''; } catch { txt = chatStore.pinnedText; }
chatStore.pinnedText = txt;
if (txt) {
bar.innerHTML = `📌 <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(txt)}</span>`;
bar.classList.add('visible');
} else {
bar.innerHTML = '';
bar.classList.remove('visible');
}
},
updateOnlineCount() {
const cutoff = Date.now() - P2P_HB_EXPIRE_MS;
let n = 0; for (const ts of chatStore.onlineMap.values()) if (ts >= cutoff) n++;
const el = document.getElementById('jv4-p2p-online');
if (el) el.textContent = n > 0 ? `· ● ${n} online` : '';
},
updateStatus(state) {
const dot = document.getElementById('jv4-p2p-dot');
const lbl = document.getElementById('jv4-p2p-status-label');
const room = _p2pGetRoom();
const charId = _p2pGetCharId();
const charName = room === 'char' && charId ? (_p2pGetCharName() || 'Character Room') : null;
const roomLabel = charName ? charName : 'Global Room';
if (state === 'connected') {
if (dot) dot.className = 'jv4-p2p-dot jv4-p2p-dot-on';
if (lbl) lbl.textContent = `Connected · ${roomLabel}`;
} else if (state === 'reconnecting') {
if (dot) dot.className = 'jv4-p2p-dot jv4-p2p-dot-warn';
if (lbl) lbl.textContent = 'Reconnecting…';
} else {
if (dot) dot.className = 'jv4-p2p-dot jv4-p2p-dot-off';
if (lbl) lbl.textContent = 'Connecting…';
}
},
};
const chatCmd = {
execute(text, _senderPeer) {
const parts = text.slice(1).trim().split(/\s+/);
const cmd = parts[0].toLowerCase();
const target = parts[1] || '';
const rest = parts.slice(1).join(' ');
const list = chatStore.listEl;
switch (cmd) {
case 'pin': {
if (!rest) break;
chatStore.pinnedText = rest;
try { GM_setValue(P2P_GM_PINNED, rest); } catch { }
chatRender.updatePinnedBar();
chatRender.systemMsg('📌 Pinned: ' + rest);
break;
}
case 'unpin': {
chatStore.pinnedText = '';
try { GM_setValue(P2P_GM_PINNED, ''); } catch { }
chatRender.updatePinnedBar();
chatRender.systemMsg('📌 Pin cleared');
break;
}
default: { break; }
}
},
};
/**
* Validates, optionally encrypts, and dual-path-delivers a chat message.
*
* Delivery order:
* 1. Append an optimistic bubble (marked "Sending…").
* 2. Broadcast to any open WebRTC DataChannels (sub-50 ms for connected peers).
* 3. POST to ntfy relay for reliability and offline peers.
* 4. Kick an accelerated re-poll (250 ms) so concurrent inbound messages
* are picked up without waiting the full polling interval.
*
* Admin slash-commands bypass the rate limiter and are HMAC-signed before
* being sent so recipients can verify admin authority without transmitting
* the secret hash.
*
* @param {string} text - Raw input text (trimmed internally).
* @param {HTMLInputElement} inputEl - The chat input element (for focus restore).
* @param {HTMLButtonElement} sendBtnEl - Send button (for cooldown animation).
* @returns {Promise<void>}
*/
async function _p2pSendMessage(text, inputEl, sendBtnEl) {
text = text.trim();
if (!text || text.length > 800) return;
if (chatStore.isAdmin && text.startsWith('/')) {
chatCmd.execute(text, _p2pGetPeerId());
const _adminTs = Date.now();
const _adminMid = [...crypto.getRandomValues(new Uint8Array(5))].map(b => b.toString(16).padStart(2,'0')).join('');
_signAdminMsg(_adminMid, _adminTs).then(adminSig => {
chatNet.send({ v:1, peer: _p2pGetPeerId(), nick: _p2pGetNickname(), text, adminSig, ts: _adminTs, msgId: _adminMid, room: _p2pGetRoom() });
});
return;
}
const now = Date.now();
const elapsed = now - chatStore.lastSend;
if (elapsed < P2P_RATE_LIMIT_MS) {
const remaining = Math.ceil((P2P_RATE_LIMIT_MS - elapsed) / 1000);
topToast(`Slow down — wait ${remaining}s`);
_applySendCooldown(sendBtnEl, P2P_RATE_LIMIT_MS - elapsed);
return;
}
chatStore.lastSend = now;
_applySendCooldown(sendBtnEl, P2P_RATE_LIMIT_MS);
const msgId = [...crypto.getRandomValues(new Uint8Array(5))].map(b => b.toString(16).padStart(2,'0')).join('');
const room = _p2pGetRoom();
let finalPayload = {
v: 1,
peer: _p2pGetPeerId(),
nick: _p2pGetNickname(),
text,
ts: now,
room,
msgId,
};
if (chatStore.replyingTo) {
finalPayload.replyTo = chatStore.replyingTo;
chatStore.replyingTo = null;
document.getElementById('jv4-p2p-reply-strip')?.classList.remove('visible');
}
// === PSK Encryption (always-on) ===
// Every outgoing message is PSK-encrypted before hitting ntfy.sh.
// ntfy.sh sees only opaque ciphertext. No password prompt needed.
{
const encResult = await pskEncrypt(finalPayload);
if (!encResult) {
topToast('Encryption failed — message not sent');
return;
}
encResult.psk = true; // mark as PSK-encrypted
encResult.room = room; // keep room tag for routing (plaintext, non-sensitive)
encResult.nick = finalPayload.nick; // keep nick for display while encrypted (hashed peer ID is inside ciphertext)
finalPayload = encResult;
}
// === Phase 1 Per-Room Encryption (additional layer for char rooms with passwords) ===
// This is now a second encryption layer on top of PSK for rooms where users
// explicitly set a password (double-encrypted). Kept for backward compat.
if (P2PCrypto.isRoomEncrypted(room) && !finalPayload.psk) {
if (!chatStore._roomPasswords) chatStore._roomPasswords = {};
let pw = chatStore._roomPasswords[room];
if (!pw) {
pw = prompt('Enter password for encrypted room (shared out-of-band):');
if (!pw) {
topToast('Encryption requires password — message not sent');
return;
}
chatStore._roomPasswords[room] = pw;
}
try {
const encResult = await P2PCrypto.encrypt(finalPayload, pw, room);
finalPayload = encResult;
finalPayload.room = room;
} catch (e) {
topToast('Encryption failed: ' + e.message);
return;
}
}
chatStore.seenMsgIds.add(msgId);
if (chatNet.typingSendTimer) { clearTimeout(chatNet.typingSendTimer); chatNet.typingSendTimer = null; }
_p2pAddHistory(finalPayload);
chatStore.messages.push(finalPayload);
chatRender.appendBubble(finalPayload, true, true);
// ── Dual-path delivery ──────────────────────────────────────────────────────
// 1. WebRTC DataChannels: sub-50 ms for already-connected peers (best effort).
// 2. ntfy relay: reliable broadcast for peers not yet on WebRTC + as fallback.
// seenMsgIds dedup in _handleEvent prevents double-display if a peer receives
// the message on both paths.
const _rtcSent = webrtcManager.sendToAll(finalPayload);
if (_rtcSent > 0) {
console.log('[WebRTC] Message sent directly to', _rtcSent, 'peer(s) via DataChannel');
}
chatNet.send(finalPayload); // always send via ntfy for reliability
// Kick an accelerated re-poll shortly after sending so any concurrent
// incoming messages are picked up without waiting the full poll interval.
if (chatNet._currentTopic && !chatNet.abortFlag) {
clearTimeout(chatNet.reconnTimer);
chatNet.reconnTimer = setTimeout(() => chatNet._startPoll(chatNet._currentTopic), 250);
}
}
function _applySendCooldown(btn, ms) {
if (!btn) return;
btn.disabled = true;
btn.style.position = 'relative';
btn.style.overflow = 'hidden';
const bar = document.createElement('span');
bar.style.cssText = `
position:absolute; left:0; top:0; height:100%;
background:rgba(255,255,255,0.25); width:100%;
transition:width ${ms}ms linear;
pointer-events:none;
`;
btn.appendChild(bar);
requestAnimationFrame(() => { bar.style.width = '0%'; });
setTimeout(() => {
btn.disabled = false;
bar.remove();
}, ms);
}
// ─── SEND-FAIL / RATE-LIMIT BANNER ────────────────────────────────────────────
// Shows an in-chat alert when a message fails to reach the relay server.
// type: 'ratelimit' | 'failed' | 'timeout'
// retryPayload: the original message object to re-send on "Retry" (or null)
function _showSendFailBanner(type, retryPayload) {
// Only show if the chat modal is open — avoid ghost banners
const barEl = document.getElementById('jv4-p2p-bar');
if (!barEl) return;
// Deduplicate: remove any existing banner before showing a new one
document.getElementById('jv4-send-fail-banner')?.remove();
const SVG_WARN = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
const SVG_AIRPLANE = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21 4 19 4c-1 0-1.5.3-2.5 1L3 11l3.5 2 1.5 4 2-2 2 2Z"/></svg>`;
const cfg = {
ratelimit: {
title: 'Rate limited — your message may not have sent',
col: '#fbbf24',
bg: 'rgba(251,191,36,0.07)',
border: 'rgba(251,191,36,0.4)',
},
failed: {
title: 'Connection error — message may not have been delivered',
col: '#f87171',
bg: 'rgba(248,113,113,0.07)',
border: 'rgba(248,113,113,0.4)',
},
timeout: {
title: 'No response from server — message may not have arrived',
col: '#fb923c',
bg: 'rgba(251,146,60,0.07)',
border: 'rgba(251,146,60,0.4)',
},
}[type] ?? {
title: 'Something went wrong sending your message',
col: '#f87171', bg: 'rgba(248,113,113,0.07)', border: 'rgba(248,113,113,0.4)',
};
const banner = document.createElement('div');
banner.id = 'jv4-send-fail-banner';
banner.style.cssText = `background:${cfg.bg};border-color:${cfg.border};`;
banner.innerHTML = `
<button class="jv4-sfb-close" title="Dismiss">✕</button>
<div class="jv4-sfb-header" style="color:${cfg.col};">
${SVG_WARN} ${cfg.title}
</div>
<div class="jv4-sfb-airplane">
${SVG_AIRPLANE}
<span>
<strong>Quick bypass:</strong> enable <strong>Airplane Mode</strong> for ~5 seconds,
then turn it off and reconnect your data or Wi-Fi. This refreshes your network session
and clears the rate-limit — messages should go through immediately after.
</span>
</div>
${retryPayload ? `<button class="jv4-sfb-retry">\u21ba Retry message</button>` : ''}
`;
barEl.insertAdjacentElement('beforebegin', banner);
// Retry: re-send the original payload without adding another chat bubble
if (retryPayload) {
banner.querySelector('.jv4-sfb-retry')?.addEventListener('click', () => {
banner.remove();
chatNet.send(retryPayload);
});
}
// Auto-dismiss after 20 s; also dismiss on close button (always, regardless of retry)
const autoDismiss = setTimeout(() => banner.remove(), 20000);
banner.querySelector('.jv4-sfb-close')?.addEventListener('click', () => {
clearTimeout(autoDismiss);
banner.remove();
});
}
function _markMessageDelivered(msgId) {
if (!msgId) return;
const bubble = chatStore.listEl?.querySelector(`[data-msgid="${CSS.escape(msgId)}"]`);
if (!bubble) return;
const status = bubble.querySelector('.jv4-delivery-status');
if (!status) return;
status.textContent = '✓ Delivered';
status.className = 'jv4-delivery-status jv4-delivery-ok';
setTimeout(() => {
status.style.transition = 'opacity 0.5s ease';
status.style.opacity = '0';
setTimeout(() => status.remove(), 500);
}, 2500);
}
function _sendReaction(reactionTo, emoji) {
chatNet.send({ v:1, peer: _p2pGetPeerId(), nick: _p2pGetNickname(), type: 'reaction', text: emoji, reactionTo, ts: Date.now() });
chatRender.applyReaction(reactionTo, emoji, _p2pGetPeerId(), true);
}
function _maybeNotifyReply(msg) {
if (!msg.replyTo || msg.replyTo.peer !== _p2pGetPeerId()) return;
const nick = (msg.nick || 'Someone').slice(0, 20);
chatRender.systemMsg(`💬 ${nick} replied to your message`);
const body = `${nick} replied: ${msg.text.slice(0, 80)}`;
if (Notification?.permission === 'granted') {
new Notification('JanitorV5 Community Chat', { body, tag: 'jv4-reply' });
} else if (Notification?.permission === 'default') {
Notification.requestPermission().then(p => {
if (p === 'granted') new Notification('JanitorV5 Community Chat', { body, tag: 'jv4-reply' });
});
}
}
// ─── EMOJI PICKER (input area) ────────────────────────────────────────────────
// ─── BLOCKED USERS PANEL ─────────────────────────────────────────────────────
function _esc(str) {
return String(str || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
GM_addStyle(`
#jv4-p2p-modal .ms2-modal-body { padding: 0; display: flex; flex-direction: column; }
#jv4-p2p-list {
flex: 1; overflow-y: auto; padding: 10px 12px; display: flex;
flex-direction: column; gap: 6px; min-height: 220px; max-height: 320px;
}
#jv4-p2p-list::-webkit-scrollbar { width: 3px; }
#jv4-p2p-list::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.35); border-radius: 3px; }
.jv4-p2p-bubble {
max-width: 82%; padding: 6px 10px; border-radius: 10px; font-size: 12.5px;
line-height: 1.5; word-break: break-word; animation: ms2-up 0.15s ease;
}
.jv4-p2p-bubble-me {
align-self: flex-end; background: rgba(139,92,246,0.22);
border: 1px solid rgba(139,92,246,0.4); color: #e2d9f3;
}
.jv4-delivery-status { display: block; font-size: 10px; text-align: right; margin-top: 2px; }
.jv4-delivery-pending { color: rgba(167,139,250,0.45); }
.jv4-delivery-ok { color: #10b981; }
.jv4-p2p-bubble-other {
align-self: flex-start; background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1); color: #d1d5db;
}
.jv4-p2p-bubble-system {
align-self: center; font-size: 11px; color: #6b7280;
background: transparent; border: none; font-style: italic; padding: 2px 0;
}
.jv4-p2p-bubble-admin {
border-left: 2px solid rgba(234,179,8,0.65) !important;
background: rgba(234,179,8,0.04) !important;
}
.jv4-p2p-meta { display: flex; align-items: baseline; gap: 5px; margin-bottom: 2px; flex-wrap: wrap; }
.jv4-p2p-nick { font-size: 11px; font-weight: 600; color: #a78bfa; }
.jv4-p2p-time { font-size: 10px; color: #6b7280; }
.jv4-p2p-text { margin: 0; }
.jv4-p2p-mute {
font-size: 10px; color: #6b7280; background: none; border: none;
cursor: pointer; padding: 1px 4px; border-radius: 4px; margin-left: 2px;
transition: color 0.1s, background 0.1s;
}
.jv4-p2p-mute:hover { background: rgba(255,255,255,0.08); color: #d1d5db; }
.jv4-p2p-quote {
background: rgba(139,92,246,0.1); border-left: 2px solid #8b5cf6;
border-radius: 0 4px 4px 0; padding: 3px 7px; margin-bottom: 5px;
font-size: 10px; color: #a78bfa; max-height: 36px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.jv4-p2p-reactions { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 4px; }
.jv4-p2p-rxn {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px; padding: 1px 6px; font-size: 12px; cursor: pointer;
display: flex; align-items: center; gap: 3px; transition: background 0.1s;
color: #d1d5db; font-family: system-ui,sans-serif;
}
.jv4-p2p-rxn:hover { background: rgba(139,92,246,0.18); }
.jv4-p2p-rxn.mine { border-color: rgba(139,92,246,0.55); background: rgba(139,92,246,0.15); }
.jv4-p2p-rxn-count { font-size: 10px; color: #9ca3af; }
.jv4-rxn-picker {
position: absolute; background: #1a1625; border: 1px solid rgba(139,92,246,0.4);
border-radius: 24px; padding: 4px 8px; display: flex; gap: 2px;
z-index: 10000060; box-shadow: 0 4px 14px rgba(0,0,0,0.55);
animation: ms2-up 0.13s ease;
}
.jv4-rxn-pick-btn {
font-size: 16px; background: none; border: none; cursor: pointer;
border-radius: 50%; padding: 3px; transition: background 0.1s;
}
.jv4-rxn-pick-btn:hover { background: rgba(139,92,246,0.2); }
#jv4-emoji-picker {
position: absolute; bottom: 100%; right: 0; margin-bottom: 6px;
background: #1a1625; border: 1px solid rgba(139,92,246,0.45);
border-radius: 10px; padding: 8px; display: grid; grid-template-columns: repeat(6,1fr);
gap: 3px; z-index: 10000050; box-shadow: 0 4px 20px rgba(0,0,0,0.6);
animation: ms2-up 0.15s cubic-bezier(0.16,1,0.3,1);
}
.jv4-emoji-btn {
font-size: 18px; background: none; border: none; cursor: pointer;
border-radius: 6px; padding: 3px; line-height: 1;
transition: background 0.1s; display: flex; align-items: center; justify-content: center;
}
.jv4-emoji-btn:hover { background: rgba(139,92,246,0.2); }
#jv4-p2p-pinned-bar {
display: none; padding: 5px 12px; background: rgba(139,92,246,0.12);
border-bottom: 1px solid rgba(139,92,246,0.25); font-size: 11px;
color: #c4b5fd; cursor: default; flex-shrink: 0;
}
#jv4-p2p-pinned-bar.visible { display: flex; align-items: center; gap: 6px; }
#jv4-p2p-typing { min-height: 18px; padding: 2px 12px 0; font-size: 10px; color: #6b7280; font-style: italic; flex-shrink: 0; }
#jv4-p2p-online { font-size: 10px; color: #10b981; margin-left: 8px; }
#jv4-p2p-reply-strip {
display: none; align-items: center; gap: 6px;
padding: 4px 12px; background: rgba(139,92,246,0.08);
border-top: 1px solid rgba(139,92,246,0.2); font-size: 11px; color: #a78bfa; flex-shrink: 0;
}
#jv4-p2p-reply-strip.visible { display: flex; }
#jv4-p2p-reply-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#jv4-p2p-reply-cancel { background: none; border: none; color: #6b7280; cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; }
#jv4-p2p-reply-cancel:hover { color: #ef4444; }
#jv4-p2p-bar {
display: flex; align-items: center; gap: 5px; padding: 6px 8px;
border-top: 1px solid rgba(255,255,255,0.07); flex-shrink: 0;
}
#jv4-p2p-input {
touch-action: manipulation;
-webkit-user-select: text;
user-select: text;
-webkit-tap-highlight-color: rgba(139,92,246,0.15);
}
#jv4-p2p-room-toggle {
font-size: 11px; cursor: pointer; padding: 3px 8px;
border-radius: 6px; border: 1px solid rgba(6,182,212,0.4);
background: rgba(6,182,212,0.1); color: #67e8f9;
white-space: nowrap; flex-shrink: 0; transition: background 0.15s;
display: inline-flex; align-items: center; gap: 5px;
max-width: 130px; overflow: hidden;
}
#jv4-p2p-room-toggle:hover { background: rgba(6,182,212,0.2); }
#jv4-p2p-room-toggle .jv4-toggle-label {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.jv4-toggle-avatar {
width: 18px; height: 18px; border-radius: 50%;
object-fit: cover; flex-shrink: 0;
border: 1px solid rgba(6,182,212,0.5);
}
#jv4-p2p-status {
font-size: 10.5px; color: #6b7280; padding: 3px 12px 0;
display: flex; align-items: center; gap: 5px;
}
.jv4-p2p-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.jv4-p2p-dot-on { background: #10b981; box-shadow: 0 0 4px #10b981; }
.jv4-p2p-dot-off { background: #6b7280; }
.jv4-p2p-dot-warn { background: #f59e0b; box-shadow: 0 0 4px #f59e0b; animation: jv4-dot-pulse 1.2s ease-in-out infinite; }
@keyframes jv4-dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
#jv4-emoji-open {
opacity: 1 !important; background: none; border: none;
cursor: pointer; padding: 4px 6px; border-radius: 6px; flex-shrink: 0;
line-height: 1; color: #9ca3af; transition: background 0.1s, color 0.1s;
display: flex; align-items: center;
}
#jv4-emoji-open:hover { background: rgba(139,92,246,0.18); color: #c4b5fd; }
#jv4-admin-badge {
display: none; align-items: center; gap: 3px; font-size: 10px;
background: rgba(234,179,8,0.14); border: 1px solid rgba(234,179,8,0.45);
color: #fbbf24; border-radius: 10px; padding: 1px 8px;
margin-left: 8px; font-weight: 700; letter-spacing: .3px;
}
#jv4-admin-badge.visible { display: inline-flex; }
.jv4-adm-btn {
font-size: 10.5px; padding: 3px 8px; border-radius: 6px; cursor: pointer;
border: 1px solid rgba(var(--adm-col),0.4); color: rgb(var(--adm-col));
background: rgba(var(--adm-col),0.08); transition: background 0.15s;
white-space: nowrap;
}
.jv4-adm-btn:hover { background: rgba(var(--adm-col),0.2); }
.jv4-adm-btn.active { background: rgba(var(--adm-col),0.25); outline: 1px solid rgb(var(--adm-col)); }
#jv4-p2p-send:disabled { opacity: 0.6; cursor: not-allowed; }
#jv4-chat-tip {
display: none; flex-direction: column; gap: 6px;
margin: 8px 12px 0; padding: 10px 12px;
background: rgba(6,182,212,0.08); border: 1px solid rgba(6,182,212,0.35);
border-radius: 10px; font-size: 11.5px; color: #bae6fd;
animation: ms2-up 0.18s cubic-bezier(0.16,1,0.3,1);
position: relative;
}
#jv4-chat-tip.visible { display: flex; }
#jv4-chat-tip-title {
font-weight: 700; font-size: 12px; color: #67e8f9;
display: flex; align-items: center; gap: 5px;
}
#jv4-chat-tip-close {
position: absolute; top: 6px; right: 8px;
background: none; border: none; color: #6b7280;
font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px;
}
#jv4-chat-tip-close:hover { color: #ef4444; }
#jv4-chat-tip p { margin: 0; line-height: 1.55; }
#jv4-chat-tip strong { color: #e0f2fe; }
#jv4-chat-tip-airplane {
display: flex; align-items: flex-start; gap: 6px;
padding: 7px 9px; margin-top: 2px;
background: rgba(139,92,246,0.1); border: 1px solid rgba(139,92,246,0.3);
border-radius: 8px; font-size: 11px; color: #c4b5fd;
}
#jv4-chat-tip-airplane svg { flex-shrink: 0; margin-top: 1px; }
#jv4-chat-info-btn {
background: none; border: 1px solid rgba(6,182,212,0.35);
border-radius: 50%; width: 20px; height: 20px;
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; color: #67e8f9; font-size: 11px; font-weight: 700;
line-height: 1; margin-left: 6px; flex-shrink: 0;
transition: background 0.15s, border-color 0.15s;
}
#jv4-chat-info-btn:hover { background: rgba(6,182,212,0.15); border-color: rgba(6,182,212,0.6); }
#jv4-chat-info-btn.active { background: rgba(6,182,212,0.2); border-color: #67e8f9; }
#jv4-send-fail-banner {
display: flex; flex-direction: column; gap: 7px;
margin: 0 8px 6px; padding: 9px 28px 9px 11px;
border-radius: 10px; border: 1px solid;
font-size: 12px; line-height: 1.5;
animation: jv4-sfb-in 0.22s ease;
position: relative;
}
@keyframes jv4-sfb-in {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
#jv4-send-fail-banner .jv4-sfb-header {
display: flex; align-items: center; gap: 6px;
font-weight: 700; font-size: 12px;
}
#jv4-send-fail-banner .jv4-sfb-close {
position: absolute; top: 7px; right: 8px;
background: none; border: none; color: #6b7280;
font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px;
}
#jv4-send-fail-banner .jv4-sfb-close:hover { color: #ef4444; }
#jv4-send-fail-banner .jv4-sfb-airplane {
display: flex; align-items: flex-start; gap: 7px;
padding: 6px 8px; border-radius: 7px;
background: rgba(139,92,246,0.12); border: 1px solid rgba(139,92,246,0.3);
color: #c4b5fd;
}
#jv4-send-fail-banner .jv4-sfb-airplane svg { flex-shrink: 0; margin-top: 2px; }
#jv4-send-fail-banner .jv4-sfb-retry {
align-self: flex-start; padding: 4px 12px; border-radius: 6px;
border: 1px solid rgba(167,139,250,0.5); background: rgba(139,92,246,0.15);
color: #c4b5fd; font-size: 11px; font-weight: 600; cursor: pointer;
transition: background 0.15s;
}
#jv4-send-fail-banner .jv4-sfb-retry:hover { background: rgba(139,92,246,0.3); }
`);
function _p2pExportHistory() {
const hist = _p2pGetHistory();
if (!hist.length) { topToast('No chat history to export'); return; }
const lines = hist.map(m => {
const t = new Date(m.ts).toLocaleString();
return `[${t}] ${(m.nick||'Anonymous').slice(0,24)}#${(m.peer||'').slice(0,4)}: ${m.text}`;
});
const blob = new Blob([lines.join('\n')],{type:'text/plain'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href=url; a.download=`jv4-chat-${new Date().toISOString().slice(0,10)}.txt`;
a.click(); URL.revokeObjectURL(url);
topToast('Chat exported');
}
function _p2pShowBlockedPanel() {
const existing = document.getElementById('jv4-blocked-panel');
if (existing) { existing.remove(); return; }
const blocked = [..._p2pGetBlocked()];
const panel = document.createElement('div');
panel.id = 'jv4-blocked-panel';
panel.style.cssText = `
position:absolute; bottom:100%; left:0; right:0; margin-bottom:4px;
background:#1a1625; border:1px solid rgba(139,92,246,0.45);
border-radius:10px; padding:10px 12px; z-index:10000060;
box-shadow:0 4px 20px rgba(0,0,0,0.6);
animation:ms2-up 0.15s cubic-bezier(0.16,1,0.3,1);
max-height:200px; overflow-y:auto;
`;
if (!blocked.length) {
panel.innerHTML = `<div style="font-size:11px;color:#6b7280;text-align:center;padding:4px 0;">No muted users</div>`;
} else {
panel.innerHTML = `<div style="font-size:10.5px;color:#a78bfa;font-weight:600;margin-bottom:6px;">🔇 Muted users — tap to unmute</div>`;
blocked.forEach(peerId => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.05);';
row.innerHTML = `
<span style="font-size:11px;color:#d1d5db;flex:1;font-family:monospace;">${peerId.slice(0,12)}…</span>
<button style="font-size:10px;color:#10b981;background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);border-radius:5px;padding:2px 7px;cursor:pointer;">Unmute</button>
`;
row.querySelector('button').addEventListener('click', () => {
_p2pUnblockPeer(peerId);
row.remove();
_p2pSystemMsg(`✅ Unmuted ${peerId.slice(0,8)}…`);
_p2pSyncMuteButtons();
if (!panel.querySelector('div[style]')) {
panel.innerHTML = `<div style="font-size:11px;color:#6b7280;text-align:center;padding:4px 0;">No muted users</div>`;
}
});
panel.appendChild(row);
});
}
const dismiss = ev => {
if (!panel.contains(ev.target) && ev.target.id!=='jv4-muted-btn') {
panel.remove(); document.removeEventListener('click',dismiss,true);
}
};
setTimeout(()=>document.addEventListener('click',dismiss,true),0);
const bar = document.getElementById('jv4-p2p-bar');
if (bar) { bar.style.position='relative'; bar.appendChild(panel); }
}
function _p2pSyncMuteButtons() {
const blocked = _p2pGetBlocked();
const list = _cs.listEl || document.getElementById('jv4-p2p-list');
if (!list) return;
list.querySelectorAll('.jv4-act-mute[data-peer]').forEach(btn => {
btn.textContent = blocked.has(btn.dataset.peer) ? '🔊' : 'mute';
});
}
function _p2pToggleEmojiPicker(input, wrapEl) {
const existing = document.getElementById('jv4-emoji-picker');
if (existing) { existing.remove(); return; }
const picker = document.createElement('div');
picker.id = 'jv4-emoji-picker';
P2P_CHAT_EMOJIS.forEach(e => {
const btn = document.createElement('button');
btn.className='jv4-emoji-btn'; btn.textContent=e;
btn.addEventListener('click', ev => {
ev.stopPropagation();
const s = input.selectionStart ?? input.value.length;
input.value = input.value.slice(0,s)+e+input.value.slice(s);
input.focus(); input.selectionStart=input.selectionEnd=s+e.length;
});
picker.appendChild(btn);
});
wrapEl.style.position='relative';
wrapEl.appendChild(picker);
const dismiss = ev => {
if (!picker.contains(ev.target)&&ev.target.id!=='jv4-emoji-open') {
picker.remove(); document.removeEventListener('click',dismiss,true);
}
};
setTimeout(()=>document.addEventListener('click',dismiss,true),10);
}
function _buildChatTipHTML() {
const SVG_AIRPLANE = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21 4 19 4c-1 0-1.5.3-2.5 1L3 11l3.5 2 1.5 4 2-2 2 2Z"/></svg>`;
const SVG_CLOCK = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`;
return `
<div id="jv4-chat-tip-title">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
Connection & Chat Tips
</div>
<button id="jv4-chat-tip-close" title="Dismiss">✕</button>
<p>
${SVG_CLOCK} Messages are relayed via <strong>ntfy.sh</strong> and fetched every
<strong>8 seconds</strong> — so there's a short delay before others see what you sent.
Your own bubble shows <strong>✓ Delivered</strong> in green once the server confirms it went through.
If it stays on "Sending…", something blocked the send — see below.
</p>
<p>
<strong>Message not going through?</strong> A red banner appears with a one-tap
<strong>Retry</strong> button. Most failures are temporary — retry first before anything else.
If retries keep failing, the relay may be rate-limiting your connection (HTTP 429).
</p>
<div id="jv4-chat-tip-airplane">
${SVG_AIRPLANE}
<span>
<strong>Fastest rate-limit fix:</strong> toggle <strong>Airplane Mode</strong> on for ~5 s,
then off again. This resets your carrier session and clears the limit immediately —
messages go through right after reconnecting.
</span>
</div>
<p style="margin-top:8px;">
<strong>Rooms</strong> — tap <strong>Global</strong> or <strong>Char Room</strong> to switch.
Character rooms are isolated per character page; only people viewing the same character see them.
Global is site-wide and always active.
</p>
<p>
<strong>Mute & report</strong> — hit <em>mute</em> on any bubble to hide that user locally (only you see the change).
Use ⚑ to report to admins. Your blocked list lives under the <strong>🔇 Muted</strong> button.
</p>
<div style="margin-top:10px;padding:9px 11px;background:rgba(139,92,246,0.1);border:1px solid rgba(139,92,246,0.35);border-radius:8px;font-size:11.5px;line-height:1.6;color:#c4b5fd;">
💜 <strong>Enjoying Community Chat?</strong> Share JanitorV5 with other roleplayers — drop the
GreasyFork link on TikTok, X, Discord, or Reddit. A short screen recording or honest review
goes a long way. The bigger the community, the better Chat gets for everyone.
</div>
`;
}
function _setupChatTip(modal) {
const tip = document.createElement('div');
tip.id = 'jv4-chat-tip';
tip.innerHTML = _buildChatTipHTML();
const pinnedBar = modal.querySelector('#jv4-p2p-pinned-bar');
if (pinnedBar) pinnedBar.after(tip);
const closeBtn = tip.querySelector('#jv4-chat-tip-close');
const infoBtn = modal.querySelector('#jv4-chat-info-btn');
const hideTip = () => {
tip.classList.remove('visible');
if (infoBtn) infoBtn.classList.remove('active');
};
const showTip = () => {
tip.classList.add('visible');
if (infoBtn) infoBtn.classList.add('active');
tip.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
};
closeBtn?.addEventListener('click', () => {
hideTip();
try { GM_setValue(P2P_GM_TIP_SEEN, 'true'); } catch {}
});
infoBtn?.addEventListener('click', () => {
if (tip.classList.contains('visible')) { hideTip(); } else { showTip(); }
});
const alreadySeen = (() => { try { return GM_getValue(P2P_GM_TIP_SEEN, ''); } catch { return ''; } })();
if (!alreadySeen) {
setTimeout(() => showTip(), 400);
}
}
async function _openP2PChatModal() {
chatStore.blocked = _p2pGetBlocked();
chatStore.open = true;
chatStore.messages = [];
chatStore.seenMsgIds = new Set();
chatStore.seenNtfyIds = new Set();
chatStore.replyingTo = null;
chatStore.onlineMap = new Map(); // clear stale presence from previous session
// Reset lastEventId on every fresh open so we don't carry stale IDs from a
// different room session into the new one.
chatNet.lastEventId = null;
const backdrop = document.createElement('div');
backdrop.className = 'ms2-backdrop';
addEscapeClose(backdrop);
const charId = _p2pGetCharId();
const canChar = !!charId;
// If the stored room is 'char' but we're not on a character page any more,
// silently reset to 'global' so the topic doesn't collapse and mix rooms.
let room = _p2pGetRoom();
if (room === 'char' && !canChar) {
room = 'global';
try { GM_setValue(P2P_GM_ROOM, 'global'); } catch {}
}
const charDisplayName = canChar ? (_p2pGetCharName() || 'Char Room') : 'Global';
const charAvatarSrc = canChar && room === 'char' ? (_p2pGetCharAvatar() || '') : '';
const roomLabel = room === 'char' && canChar ? charDisplayName : 'Global';
const modal = document.createElement('div');
modal.className = 'ms2-modal';
modal.id = 'jv4-p2p-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.style.maxWidth = '400px';
modal.innerHTML = `
<div class="ms2-modal-header">
<div class="ms2-modal-title" style="display:flex;align-items:center;flex:1;min-width:0;">
${SVG_CHAT} Community Chat
<span style="font-weight:400;color:#6b7280;font-size:11px;margin-left:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">· ${_esc(_p2pGetNickname())}</span>
<span id="jv4-admin-badge">👑 ADMIN</span>
<button id="jv4-chat-info-btn" title="Connection tips, rooms, delivery status & how to share">!</button>
</div>
<button class="ms2-modal-close" aria-label="Close">×</button>
</div>
<div id="jv4-p2p-status" style="padding:4px 12px 3px;">
<div id="jv4-p2p-dot" class="jv4-p2p-dot jv4-p2p-dot-off"></div>
<span id="jv4-p2p-status-label">Connecting…</span>
<span id="jv4-p2p-online"></span>
</div>
<div style="padding:2px 12px 4px;font-size:10px;color:#6b7280;user-select:all;">
My ID: <span id="jv4-my-peerid"
style="color:#8b5cf6;cursor:pointer;font-family:monospace;"
title="Tap to copy">${_esc(_p2pGetPeerId())}</span>
</div>
<div id="jv4-p2p-pinned-bar"></div>
<div class="ms2-modal-body">
<div id="jv4-p2p-list"></div>
<div id="jv4-p2p-typing"></div>
<div id="jv4-p2p-reply-strip">
<span id="jv4-p2p-reply-text"></span>
<button id="jv4-p2p-reply-cancel" title="Cancel reply">✕</button>
</div>
<div id="jv4-p2p-bar">
${canChar ? `<button id="jv4-p2p-room-toggle">${charAvatarSrc && room === 'char' ? `<img class="jv4-toggle-avatar" src="${_esc(charAvatarSrc)}" alt="" onerror="this.style.display='none'">` : ''}<span class="jv4-toggle-label">${_esc(roomLabel)}</span></button>` : ''}
<button id="jv4-emoji-open" title="Emoji">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="vertical-align:middle;pointer-events:none">
<circle cx="12" cy="12" r="10"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>
</button>
<input type="text" id="jv4-p2p-input" class="ms2-input"
placeholder="Say something…" maxlength="800" autocomplete="off"
inputmode="text" enterkeyhint="send" autocapitalize="sentences"
spellcheck="false">
<button id="jv4-p2p-send" class="ms2-btn-action ms2-btn-generate"
style="padding:6px 12px;">Send</button>
</div>
</div>
<div class="ms2-modal-footer" style="gap:6px;padding:8px 14px;">
<button id="jv4-p2p-nick-btn" class="ms2-btn-action ms2-btn-copy" style="font-size:11px;">Nick</button>
<button id="jv4-p2p-export-btn" class="ms2-btn-action ms2-btn-copy" style="font-size:11px;">Export</button>
<button id="jv4-muted-btn" class="ms2-btn-action ms2-btn-copy" style="font-size:11px;" title="View/manage muted users">🔇 Muted</button>
<span style="flex:1"></span>
<button class="ms2-modal-close ms2-btn-action" style="font-size:11px;padding:5px 12px;">Close</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
chatStore.listEl = modal.querySelector('#jv4-p2p-list');
const visHandler = () => {
if (!document.hidden && chatStore.open) {
// Page came back to foreground — reconnect immediately so the user
// doesn't have to wait for the next poll or stale-timer to fire
chatNet.backoffMs = P2P_BACKOFF_MIN;
chatNet.connect();
}
};
document.addEventListener('visibilitychange', visHandler);
// Show a visual cue when the device loses network so the user understands why
// messages aren't coming through (instead of just a stuck "Reconnecting" dot)
const offlineHandler = () => chatRender.updateStatus('reconnecting');
window.addEventListener('offline', offlineHandler);
const doClose = () => {
chatStore.open = false;
chatStore.listEl = null;
chatNet.disconnect();
document.removeEventListener('visibilitychange', visHandler);
window.removeEventListener('offline', offlineHandler);
_p2pCleanupDismissListeners(); // prevent leak from any open pickers/panels
backdrop.remove();
};
modal.querySelectorAll('.ms2-modal-close').forEach(b => b.addEventListener('click', doClose));
backdrop.addEventListener('click', e => { if (e.target === backdrop) doClose(); });
modal.querySelector('#jv4-my-peerid')?.addEventListener('click', function () {
navigator.clipboard.writeText(this.textContent.trim())
.then(() => topToast('Peer ID copied!'))
.catch(() => topToast('Tap and hold to copy manually'));
});
chatStore.listEl.addEventListener('scroll', () => {
const list = chatStore.listEl;
if (!list) return;
const near = list.scrollHeight - list.scrollTop - list.clientHeight < 60;
chatStore.atBottom = near;
if (near) chatRender._hideNewMsgBadge();
}, { passive: true });
chatRender.updatePinnedBar();
_setupChatTip(modal);
const myPeer = _p2pGetPeerId();
// ── History filter: strict per-room — no cross-bleed ─────────────────────
// Critical: char room must NEVER show untagged (old-format) global messages.
// The || !m.room fallback is only safe for the global room where untagged
// messages are legacy global chat. In char rooms it caused global history
// to appear after closing and reopening the modal.
const _curRoom = _p2pGetRoom();
const hist = _p2pGetHistory().filter(m => {
if (_curRoom === 'char') return m.room === 'char';
return m.room === 'global' || !m.room; // !m.room = backward compat for pre-room-tag messages
});
for (const msg of hist) {
if (msg.msgId) chatStore.seenMsgIds.add(msg.msgId);
chatStore.messages.push(msg);
chatRender.appendBubble(msg, msg.peer === myPeer);
}
chatRender.systemMsg('Messages last ~30 min · Relay: ntfy.sh · IPs not shared · polls every 0.8s');
const list = chatStore.listEl;
if (list) list.scrollTop = list.scrollHeight;
_p2pFetchVerified();
chatNet.connect();
const input = modal.querySelector('#jv4-p2p-input');
const sendBtn = modal.querySelector('#jv4-p2p-send');
const barEl = modal.querySelector('#jv4-p2p-bar');
const doSend = () => {
const txt = input.value.trim();
if (!txt) return;
_p2pSendMessage(txt, input, sendBtn);
input.value = '';
input.focus();
document.getElementById('jv4-emoji-picker')?.remove();
};
sendBtn.addEventListener('click', doSend);
input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); } });
input.addEventListener('input', () => {
if (chatNet.typingSendTimer) clearTimeout(chatNet.typingSendTimer);
chatNet.typingSendTimer = setTimeout(() => {
if (input.value.trim()) chatNet.sendTyping();
chatNet.typingSendTimer = null;
}, 1200);
});
modal.querySelector('#jv4-p2p-reply-cancel')?.addEventListener('click', () => {
chatStore.replyingTo = null;
modal.querySelector('#jv4-p2p-reply-strip')?.classList.remove('visible');
});
modal.querySelector('#jv4-emoji-open')?.addEventListener('click', e => {
e.stopPropagation(); _p2pToggleEmojiPicker(input, barEl);
});
modal.querySelector('#jv4-p2p-export-btn')?.addEventListener('click', _p2pExportHistory);
modal.querySelector('#jv4-muted-btn')?.addEventListener('click', e => { e.stopPropagation(); _p2pShowBlockedPanel(); });
if (canChar) {
modal.querySelector('#jv4-p2p-room-toggle')?.addEventListener('click', () => {
const cur = _p2pGetRoom();
const next = cur === 'global' ? 'char' : 'global';
GM_setValue(P2P_GM_ROOM, next);
const lEl = modal.querySelector('#jv4-p2p-list');
if (lEl) {
lEl.innerHTML = '';
chatStore.messages = [];
chatStore.seenMsgIds = new Set();
chatStore.seenNtfyIds = new Set(); // clear so new room IDs aren't filtered
}
// Reset lastEventId so the poll fetches the last 30 min of the NEW room
chatNet.lastEventId = null;
chatNet.connect();
// Load history for the new room
const nextHist = _p2pGetHistory().filter(m =>
next === 'char'
? m.room === 'char'
: (m.room === 'global' || !m.room)
);
for (const m of nextHist) {
if (m.msgId) chatStore.seenMsgIds.add(m.msgId);
chatStore.messages.push(m);
chatRender.appendBubble(m, m.peer === myPeer);
}
const switchedCharName = next === 'char' ? (_p2pGetCharName() || 'Character') : null;
chatRender.systemMsg(`Switched to ${switchedCharName ? switchedCharName + '\'s room' : 'Global room'}`);
const toggle = modal.querySelector('#jv4-p2p-room-toggle');
if (toggle) {
const label = next === 'char' ? (switchedCharName || 'Char Room') : 'Global';
const avatarSrc = next === 'char' ? (_p2pGetCharAvatar() || '') : '';
toggle.innerHTML = `${avatarSrc ? `<img class="jv4-toggle-avatar" src="${_esc(avatarSrc)}" alt="" onerror="this.style.display='none'">` : ''}<span class="jv4-toggle-label">${_esc(label)}</span>`;
}
});
}
modal.querySelector('#jv4-p2p-nick-btn')?.addEventListener('click', () => {
const cur = _p2pGetNickname();
const nw = window.prompt('Enter a new nickname (max 24 chars):', cur);
if (nw === null) return;
const clean = nw.trim().slice(0, 24) || 'Anonymous';
GM_setValue(P2P_GM_NICKNAME, clean);
topToast(`Nickname set to "${clean}"`);
const sub = modal.querySelector('.ms2-modal-title span');
if (sub) sub.textContent = `· ${clean}`;
});
if (!('ontouchstart' in window)) {
// Desktop: focus immediately so the user can start typing
input.focus();
} else {
// Mobile: do NOT auto-focus on open (it would pop the keyboard unexpectedly),
// but DO guarantee that tapping the input box shows the keyboard.
// Some Android/iOS WebView hosts swallow the native focus event when the
// tap goes through a stacked modal — the fixes below re-assert it.
input.addEventListener('touchstart', e => {
// Stop the modal/backdrop from stealing this touch before focus fires
e.stopPropagation();
}, { passive: true });
input.addEventListener('touchend', e => {
e.stopPropagation();
// A small delay lets the browser settle the touch before we force-focus,
// which is the reliable way to open the soft keyboard on iOS/Android.
setTimeout(() => {
input.focus();
// Scroll the input into view in case the keyboard pushed content up
input.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, 30);
}, { passive: true });
_setupAdminUnlock(modal, input);
}
}
function _buildAdminBar(modal, inputEl) {
const adminBar = document.createElement('div');
adminBar.id = 'jv4-admin-bar';
adminBar.style.cssText = 'display:flex;flex-direction:column;gap:5px;padding:6px 12px 8px;border-top:1px solid rgba(234,179,8,0.25);background:rgba(234,179,8,0.04);';
adminBar.innerHTML = `
<div style="font-size:10px;color:rgba(234,179,8,0.8);font-weight:700;letter-spacing:.5px;margin-bottom:1px;">
👑 ADMIN COMMANDS
</div>
<div id="jv4-admin-btns" style="display:flex;flex-wrap:wrap;gap:4px;">
<button data-cmd="pin" class="jv4-adm-btn" style="--adm-col:139,92,246">📌 Pin</button>
<button data-cmd="unpin" class="jv4-adm-btn jv4-adm-noarg" style="--adm-col:107,114,128">🗑 Unpin</button>
</div>
<div style="display:flex;gap:5px;align-items:center;">
<input id="jv4-admin-cmd" class="ms2-input"
placeholder="/pin your message here…"
style="flex:1;margin:0;font-size:11px;" maxlength="300">
<button id="jv4-admin-send"
style="background:rgba(234,179,8,0.85);color:#000;border:none;border-radius:6px;
font-size:11px;font-weight:700;padding:5px 10px;cursor:pointer;flex-shrink:0;">⚡ Run</button>
</div>
`;
modal.querySelector('#jv4-p2p-bar').after(adminBar);
const cmdInput = adminBar.querySelector('#jv4-admin-cmd');
const sendBtn = adminBar.querySelector('#jv4-admin-send');
adminBar.querySelectorAll('.jv4-adm-btn').forEach(btn => {
btn.addEventListener('click', () => {
const cmd = btn.dataset.cmd;
const noArg = btn.classList.contains('jv4-adm-noarg');
adminBar.querySelectorAll('.jv4-adm-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (noArg) {
_p2pSendMessage('/' + cmd, inputEl, null);
btn.classList.remove('active');
cmdInput.value = '';
} else {
cmdInput.value = '/pin ';
cmdInput.focus();
cmdInput.setSelectionRange(cmdInput.value.length, cmdInput.value.length);
}
});
});
const runCmd = () => {
const cmd = cmdInput.value.trim();
if (!cmd) return;
_p2pSendMessage(cmd.startsWith('/') ? cmd : '/pin ' + cmd, inputEl, null);
cmdInput.value = '';
adminBar.querySelectorAll('.jv4-adm-btn').forEach(b => b.classList.remove('active'));
};
sendBtn.addEventListener('click', runCmd);
cmdInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); runCmd(); } });
}
function _setupAdminUnlock(modal, inputEl) {
let tapCount = 0;
let tapTimer = null;
const titleEl = modal.querySelector('.ms2-modal-title');
if (!titleEl) return;
titleEl.style.cursor = 'default';
const unlockPanel = document.createElement('div');
unlockPanel.id = 'jv4-admin-unlock';
unlockPanel.style.cssText = [
'display:none;flex-direction:row;gap:6px;align-items:center;',
'padding:6px 12px 8px;border-top:1px solid rgba(234,179,8,0.2);',
'background:rgba(234,179,8,0.04);'
].join('');
unlockPanel.innerHTML = `
<input id="jv4-admin-pw" type="password" class="ms2-input"
placeholder="Admin password…"
style="flex:1;margin:0;font-size:12px;" maxlength="80" autocomplete="off">
<button id="jv4-admin-unlock-btn"
style="background:rgba(234,179,8,0.85);color:#000;border:none;border-radius:6px;
font-size:11px;font-weight:700;padding:5px 10px;cursor:pointer;white-space:nowrap;flex-shrink:0;">
🔑 Unlock
</button>
`;
const statusBar = modal.querySelector('#jv4-p2p-status');
if (statusBar) statusBar.after(unlockPanel);
titleEl.addEventListener('click', () => {
tapCount++;
clearTimeout(tapTimer);
if (tapCount >= 3) {
tapCount = 0;
const showing = unlockPanel.style.display !== 'none';
unlockPanel.style.display = showing ? 'none' : 'flex';
if (!showing) unlockPanel.querySelector('#jv4-admin-pw')?.focus();
} else {
tapTimer = setTimeout(() => { tapCount = 0; }, 1500);
}
});
const tryUnlock = async () => {
const pwEl = unlockPanel.querySelector('#jv4-admin-pw');
if (!pwEl || !pwEl.value) return;
const hash = await _sha256(pwEl.value);
if (hash === P2P_ADMIN_HASH) {
chatStore.isAdmin = true;
unlockPanel.style.display = 'none';
modal.querySelector('#jv4-admin-badge')?.classList.add('visible');
_buildAdminBar(modal, inputEl);
topToast('👑 Admin unlocked');
} else {
topToast('❌ Wrong password');
pwEl.value = '';
pwEl.focus();
}
};
unlockPanel.querySelector('#jv4-admin-unlock-btn')?.addEventListener('click', tryUnlock);
unlockPanel.querySelector('#jv4-admin-pw')?.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); tryUnlock(); }
});
}
function _openP2PConsentModal(onAccept) {
const backdrop = document.createElement('div');
backdrop.className = 'ms2-backdrop';
addEscapeClose(backdrop);
backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
const modal = document.createElement('div');
modal.className = 'ms2-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.style.maxWidth = '400px';
modal.innerHTML = `
<div class="ms2-modal-header">
<div class="ms2-modal-title">${SVG_WARNING} Before you join</div>
<button class="ms2-modal-close">×</button>
</div>
<div class="ms2-modal-body" style="padding:14px 16px;">
<div class="ms2-tip" style="border-color:rgba(6,182,212,0.45);color:#67e8f9;margin-bottom:12px;">
${SVG_INFO} Community Chat uses <strong>ntfy.sh</strong> as a free relay server.
</div>
<p style="margin:0 0 10px;font-size:13px;color:#d1d5db;line-height:1.6;">
<strong style="color:#e5e7eb;">What the relay sees:</strong> your IP, timestamps.
</p>
<p style="margin:0 0 10px;font-size:13px;color:#d1d5db;line-height:1.6;">
<strong style="color:#e5e7eb;">What other users see:</strong> your nickname and messages only. Your IP is
<em>never</em> shared with other users.
</p>
<p style="margin:0 0 10px;font-size:13px;color:#d1d5db;line-height:1.6;">
<strong style="color:#e5e7eb;">Identity:</strong> you get a random anonymous peer ID — no login, no email, Always-on PSK encryption (AES-GCM-256) for all community messages. ntfy.sh sees only ciphertext.
</p>
<p style="margin:0 0 6px;font-size:12px;color:#6b7280;line-height:1.5;">
Messages are stored on ntfy.sh servers for ~12 hours then automatically deleted.
Use a VPN for additional IP privacy. Change <code style="color:#a78bfa;">P2P_RELAY</code> to self-host.
</p>
<div style="margin-top:12px;">
<label style="font-size:12.5px;color:#c4b5fd;"><strong>Your nickname</strong></label>
<input type="text" id="jv4-consent-nick" class="ms2-input"
style="margin-top:5px;" maxlength="24"
placeholder="Anonymous (can be changed later)"
value="${_esc(_p2pGetNickname())}">
</div>
</div>
<div class="ms2-modal-footer" style="gap:8px;">
<button class="ms2-modal-close ms2-btn-action ms2-btn-copy">Cancel</button>
<button id="jv4-consent-ok" class="ms2-btn-action ms2-btn-generate">Join Community Chat</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
modal.querySelectorAll('.ms2-modal-close').forEach(b => b.addEventListener('click', () => backdrop.remove()));
modal.querySelector('#jv4-consent-ok').addEventListener('click', () => {
const nick = (modal.querySelector('#jv4-consent-nick').value.trim() || 'Anonymous').slice(0, 24);
GM_setValue(P2P_GM_NICKNAME, nick);
GM_setValue(P2P_GM_ENABLED, 'true');
backdrop.remove();
onAccept();
});
}
/**
* Entry point for the Community Chat feature.
* Shows the privacy consent modal on first use; opens the chat modal directly
* on subsequent visits. No-ops when the chat is already open.
*/
function handleCommunityChat() {
if (chatStore.open) return;
if (GM_getValue(P2P_GM_ENABLED, 'false') !== 'true') {
_openP2PConsentModal(_openP2PChatModal);
} else {
_openP2PChatModal();
}
}
// ─── MODELS ────────────────────────────────────────────────────────────────
// ── PROVIDER ENDPOINTS ───────────────────────────────────────────────────────
const PROVIDER_ENDPOINTS = {
openrouter: 'https://openrouter.ai/api/v1',
openai: 'https://api.openai.com/v1',
xai: 'https://api.x.ai/v1',
mistral: 'https://api.mistral.ai/v1',
groq: 'https://api.groq.com/openai/v1',
anthropic: 'https://api.anthropic.com',
};
const KNOWN_EPS = Object.values(PROVIDER_ENDPOINTS);
// ── MODEL CATALOG ─────────────────────────────────────────────────────────
// group = displayed as <optgroup label> in the model picker
// Works best when the chosen endpoint matches the group's provider.
// All OpenRouter models use the openrouter.ai endpoint.
// Native provider models use their own endpoint (xAI, Anthropic, Mistral, Groq).
const MODELS = [
// ── OpenRouter · Free ─────────────────────────────────────────────────
{ id: 'meta-llama/llama-3.3-70b-instruct:free', label: 'Llama 3.3 70B', group: 'OpenRouter · Free' },
{ id: 'meta-llama/llama-3.1-8b-instruct:free', label: 'Llama 3.1 8B (fast)', group: 'OpenRouter · Free' },
{ id: 'nousresearch/hermes-3-llama-3.1-405b:free', label: 'Hermes 3 405B', group: 'OpenRouter · Free' },
{ id: 'google/gemma-3-27b-it:free', label: 'Gemma 3 27B', group: 'OpenRouter · Free' },
{ id: 'google/gemma-3-4b-it:free', label: 'Gemma 3 4B (fast)', group: 'OpenRouter · Free' },
{ id: 'deepseek/deepseek-r1-distill-llama-70b:free', label: 'DeepSeek R1 70B', group: 'OpenRouter · Free' },
{ id: 'deepseek/deepseek-chat-v3-0324:free', label: 'DeepSeek V3', group: 'OpenRouter · Free' },
{ id: 'qwen/qwen-2.5-7b-instruct:free', label: 'Qwen 2.5 7B', group: 'OpenRouter · Free' },
{ id: 'mistralai/mistral-7b-instruct:free', label: 'Mistral 7B', group: 'OpenRouter · Free' },
{ id: 'microsoft/phi-4:free', label: 'Phi-4', group: 'OpenRouter · Free' },
// ── OpenRouter · Claude (paid key) ────────────────────────────────────
{ id: 'anthropic/claude-opus-4', label: 'Claude Opus 4', group: 'OpenRouter · Claude' },
{ id: 'anthropic/claude-sonnet-4-5', label: 'Claude Sonnet 4.5', group: 'OpenRouter · Claude' },
{ id: 'anthropic/claude-3-5-sonnet', label: 'Claude 3.5 Sonnet', group: 'OpenRouter · Claude' },
{ id: 'anthropic/claude-3-5-haiku', label: 'Claude 3.5 Haiku (fast)', group: 'OpenRouter · Claude' },
// ── OpenRouter · OpenAI (paid key) ────────────────────────────────────
{ id: 'openai/gpt-4o', label: 'GPT-4o', group: 'OpenRouter · OpenAI' },
{ id: 'openai/gpt-4o-mini', label: 'GPT-4o Mini (fast)', group: 'OpenRouter · OpenAI' },
{ id: 'openai/gpt-4.1', label: 'GPT-4.1', group: 'OpenRouter · OpenAI' },
{ id: 'openai/o4-mini', label: 'o4-mini (reasoning)', group: 'OpenRouter · OpenAI' },
// ── OpenRouter · Gemini (paid key) ────────────────────────────────────
{ id: 'google/gemini-2.5-pro', label: 'Gemini 2.5 Pro', group: 'OpenRouter · Gemini' },
{ id: 'google/gemini-2.5-flash', label: 'Gemini 2.5 Flash (fast)', group: 'OpenRouter · Gemini' },
{ id: 'google/gemini-2.0-flash', label: 'Gemini 2.0 Flash', group: 'OpenRouter · Gemini' },
// ── OpenRouter · Grok (paid key) ──────────────────────────────────────
{ id: 'x-ai/grok-3', label: 'Grok 3', group: 'OpenRouter · Grok' },
{ id: 'x-ai/grok-3-mini', label: 'Grok 3 Mini (fast)', group: 'OpenRouter · Grok' },
// ── xAI (native — endpoint: api.x.ai/v1) ─────────────────────────────
{ id: 'grok-4.3', label: 'Grok 4.3 (flagship)', group: 'xAI Grok · Native' },
{ id: 'grok-3', label: 'Grok 3', group: 'xAI Grok · Native' },
{ id: 'grok-3-mini', label: 'Grok 3 Mini (fast)', group: 'xAI Grok · Native' },
{ id: 'grok-2-1212', label: 'Grok 2 (pinned)', group: 'xAI Grok · Native' },
// ── Anthropic (native — endpoint: api.anthropic.com) ─────────────────
{ id: 'claude-opus-4-7', label: 'Claude Opus 4.7 (latest)', group: 'Anthropic · Native' },
{ id: 'claude-opus-4-6', label: 'Claude Opus 4.6', group: 'Anthropic · Native' },
{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', group: 'Anthropic · Native' },
{ id: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5', group: 'Anthropic · Native' },
{ id: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5 (fast)', group: 'Anthropic · Native' },
// ── Mistral (native — endpoint: api.mistral.ai/v1) ───────────────────
{ id: 'mistral-large-latest', label: 'Mistral Large 2 (flagship)',group: 'Mistral · Native' },
{ id: 'mistral-medium-latest', label: 'Mistral Medium', group: 'Mistral · Native' },
{ id: 'magistral-medium-latest', label: 'Magistral Medium (reason)', group: 'Mistral · Native' },
{ id: 'magistral-small-latest', label: 'Magistral Small (reason)', group: 'Mistral · Native' },
{ id: 'mistral-small-latest', label: 'Mistral Small 3.2', group: 'Mistral · Native' },
{ id: 'devstral-small-2507', label: 'Devstral Small (code)', group: 'Mistral · Native' },
{ id: 'ministral-8b-latest', label: 'Ministral 8B (fast)', group: 'Mistral · Native' },
{ id: 'open-mistral-nemo', label: 'Mistral NeMo (free/open)', group: 'Mistral · Native' },
// ── Groq (native — endpoint: api.groq.com/openai/v1) ─────────────────
{ id: 'meta-llama/llama-4-maverick-17b-128e-instruct', label: 'Llama 4 Maverick (fast)', group: 'Groq · Native' },
{ id: 'meta-llama/llama-4-scout-17b-16e-instruct', label: 'Llama 4 Scout (fast)', group: 'Groq · Native' },
{ id: 'llama-3.3-70b-versatile', label: 'Llama 3.3 70B', group: 'Groq · Native' },
{ id: 'llama-3.1-8b-instant', label: 'Llama 3.1 8B Instant', group: 'Groq · Native' },
{ id: 'qwen/qwen3-32b', label: 'Qwen 3 32B (reason)', group: 'Groq · Native' },
{ id: 'openai/gpt-oss-120b', label: 'GPT OSS 120B (reason)', group: 'Groq · Native' },
{ id: 'openai/gpt-oss-20b', label: 'GPT OSS 20B (fast)', group: 'Groq · Native' },
{ id: 'gemma2-9b-it', label: 'Gemma 2 9B (fast)', group: 'Groq · Native' },
];
// ─── TONES ─────────────────────────────────────────────────────────────────
const TONES = [
{ id: 'flirty', label: 'Flirty', desc: 'playfully romantic, hinting at attraction without saying it outright' },
{ id: 'teasing', label: 'Teasing', desc: 'light mockery and banter, enjoying getting a reaction' },
{ id: 'romantic', label: 'Romantic', desc: 'warm, heartfelt, openly affectionate' },
{ id: 'playful', label: 'Playful', desc: 'lighthearted, fun, energetic and a little silly' },
{ id: 'cold', label: 'Cold/Distant', desc: 'aloof, minimal, emotionally guarded and detached' },
{ id: 'protective', label: 'Protective', desc: 'territorial, caring, slightly possessive' },
{ id: 'tsundere', label: 'Tsundere', desc: 'outwardly dismissive or irritated but secretly caring — contradictory' },
{ id: 'shy', label: 'Shy', desc: 'hesitant, soft-spoken, easily flustered, avoids direct eye contact' },
{ id: 'sarcastic', label: 'Sarcastic', desc: 'dry wit, ironic, deadpan delivery' },
{ id: 'witty', label: 'Witty', desc: 'clever wordplay and sharp humor' },
{ id: 'dominant', label: 'Dominant', desc: 'assertive, commanding, takes charge naturally' },
{ id: 'flustered', label: 'Flustered', desc: 'caught off guard, stammering, trying to hide embarrassment' },
];
// ─── CONFIG ────────────────────────────────────────────────────────────────
const CFG = {
get apiKey() { return gget('ms2_apiKey', ''); },
set apiKey(v) { gset('ms2_apiKey', v); },
get endpoint() { return gget('ms2_endpoint', 'https://openrouter.ai/api/v1'); },
set endpoint(v) { gset('ms2_endpoint', v); },
get model() { return gget('ms2_model', MODELS[0].id); },
set model(v) { gset('ms2_model', v); _tierCache = null; _tierCacheModel = null; },
get fabRight() { return gget('ms2_fabRight', 16); },
set fabRight(v) { gset('ms2_fabRight', v); },
get fabBottom() { return gget('ms2_fabBottom', 150); },
set fabBottom(v) { gset('ms2_fabBottom', v); },
get defaultTone() { return gget('ms2_defaultTone', ''); },
set defaultTone(v) { gset('ms2_defaultTone', v); },
get defaultInstruct() { return gget('ms2_defaultInstruct', ''); },
set defaultInstruct(v) { gset('ms2_defaultInstruct', v); },
get autoNotify() { return gget('ms2_autoNotify', false); },
set autoNotify(v) { gset('ms2_autoNotify', v); },
get shortenLength() { return gget('ms2_shortenLength', 'compact'); },
set shortenLength(v) { gset('ms2_shortenLength', v); },
get keepDialogue() { return gget('ms2_keepDialogue', false); },
set keepDialogue(v) { gset('ms2_keepDialogue', v); },
get activePreset() { return gget('ms2_activePreset', null); },
set activePreset(v) { gset('ms2_activePreset', v); },
get authMode() { return gget('ms2_authMode', 'auto'); },
set authMode(v) { gset('ms2_authMode', v); },
};
// ─── PRESET HELPERS ────────────────────────────────────────────────────────
function getPresets() {
try { return JSON.parse(gget('ms2_presets', '[]')) || []; } catch { return []; }
}
function savePresets(arr) { gset('ms2_presets', JSON.stringify(arr)); }
// ─── ADV. PROMPT STORAGE ───────────────────────────────────────────────────
let _apPresetsCache = null;
const AP = {
get enabled() { return gget('ap_enabled', false); },
set enabled(v) { gset('ap_enabled', v); },
get selected() { return gget('ap_selected', ''); },
set selected(v) { gset('ap_selected', v); },
getPresets() {
if (_apPresetsCache !== null) return _apPresetsCache;
try { _apPresetsCache = JSON.parse(gget('ap_presets', '[]')) || []; return _apPresetsCache; } catch { return []; }
},
savePresets(arr) { _apPresetsCache = null; gset('ap_presets', JSON.stringify(arr)); },
};
let _apWorking = null;
let _apDirty = false;
// ─── ADV. PROMPT HELPERS ───────────────────────────────────────────────────
function apUUID() {
// Cryptographically secure UUID v4
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
const buf = new Uint8Array(16);
crypto.getRandomValues(buf);
buf[6] = (buf[6] & 0x0f) | 0x40;
buf[8] = (buf[8] & 0x3f) | 0x80;
return [...buf].map((b, i) => {
const s = b.toString(16).padStart(2, '0');
return (i === 4 || i === 6 || i === 8 || i === 10) ? '-' + s : s;
}).join('');
}
// Fallback (non-crypto)
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
function apEstimateTokens(text) {
if (!text) return 0;
const cjk = (text.match(/[\u3000-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/g) || []).length;
const nonCjk = text.length - cjk;
return Math.ceil(nonCjk / 4) + cjk;
}
function apGetSelected() {
if (_apWorking && _apWorking.id === AP.selected) return _apWorking;
const id = AP.selected;
if (!id) return null;
const preset = AP.getPresets().find(p => p.id === id) || null;
if (preset) _apWorking = JSON.parse(JSON.stringify(preset));
return _apWorking;
}
function apLoadFromStorage() {
const id = AP.selected;
if (!id) { _apWorking = null; _apDirty = false; return null; }
const preset = AP.getPresets().find(p => p.id === id) || null;
_apWorking = preset ? JSON.parse(JSON.stringify(preset)) : null;
_apDirty = false;
apRefreshSaveBtn();
return _apWorking;
}
function apSaveWorking() {
if (!_apWorking) return;
const presets = AP.getPresets();
const idx = presets.findIndex(p => p.id === _apWorking.id);
_apWorking.updatedAt = new Date().toISOString();
if (idx >= 0) presets[idx] = _apWorking; else presets.push(_apWorking);
AP.savePresets(presets);
_apWorking = JSON.parse(JSON.stringify(_apWorking));
_apDirty = false;
apRefreshSaveBtn();
}
function apMarkDirty() {
_apDirty = true;
apRefreshSaveBtn();
}
function apRefreshSaveBtn() {
const btn = document.getElementById('ap-save-btn');
if (!btn) return;
btn.disabled = !_apDirty;
btn.classList.toggle('ap-save-dirty', _apDirty);
}
function apUpdateStatus(ok) {
const dot = document.getElementById('ap-status-dot');
if (!dot) return;
dot.className = 'ap-status-dot ' + (ok ? 'ap-status-ok' : 'ap-status-fail');
dot.title = ok
? 'Prompt injected successfully into last request'
: 'Injection failed — check console for details';
// ─── ADV. PROMPT — FORBIDDEN WORDS ──────────────────────────────────────────
}
function getAPForbiddenWords() { return gget('ap_forbidden_words', ''); }
function setAPForbiddenWords(v) { gset('ap_forbidden_words', v); }
function getAPThinking() { return gget('ap_thinking', false); }
function setAPThinking(v) { gset('ap_thinking', v); }
// ─── ADV. PROMPT — COMBINED PROMPT BUILDER ─────────────────────────────────
/**
* Builds the combined advanced-prompt string from all active, attached
* modules in the selected preset, sorted by `order`.
*
* Appends a `<thinking>` reasoning directive when the thinking toggle is on.
* Returns `null` when Advanced Prompting is disabled, no preset is selected,
* or all modules are detached/disabled.
*
* @returns {string|null}
*/
function apGetCombinedPrompt() {
if (!AP.enabled) return null;
const id = AP.selected;
if (!id) return null;
const preset = AP.getPresets().find(p => p.id === id);
if (!preset || !preset.modules || !preset.modules.length) return null;
const active = preset.modules
.filter(m => m.attached && m.enabled)
.sort((a, b) => a.order - b.order);
if (!active.length) return null;
let prompt = active.map(m => m.content.trim()).join('\n\n');
if (!prompt) return null;
// NOTE: forbidden words are now injected directly into userConfig.bad_words
// in the fetch interceptor (token-level enforcement, bypasses 10-word UI limit).
if (getAPThinking()) {
prompt += '\n\n[Before writing your reply, briefly reason inside <thinking>…</thinking> tags about how the character would react, then write their response outside those tags.]';
}
return prompt;
// ─── ADV. PROMPT — FETCH INTERCEPTOR ──────────────────────────────────────
}
/**
* Patches `unsafeWindow.fetch` to intercept JanitorAI `generateAlpha`
* requests and inject:
* - The combined advanced-prompt into `userConfig.llm_prompt`.
* - Per-chat scene context under `== SCENE CONTEXT ==`.
* - Extra forbidden words into `userConfig.bad_words` (bypasses the
* 10-word UI cap by merging at the API payload level).
* - Deleted-message fingerprint filtering via `_apDeletedFingerprints`.
*
* The original fetch function is preserved and always called — this is a
* transparent pass-through that only mutates the request body.
*/
function initAPInterceptor() {
if (typeof unsafeWindow === 'undefined') return;
const _orig = unsafeWindow.fetch;
unsafeWindow.fetch = async function (...args) {
try {
let [resource, config] = args;
const url = typeof resource === 'string'
? resource
: (resource instanceof Request ? resource.url : '');
if (url.includes('generateAlpha') && (url.includes('janitorai.com') || url.includes('janitor.ai'))) {
const combined = apGetCombinedPrompt();
const ctxInject = getInjectCtx() ? getContext().trim() : '';
const extraBans = getAPForbiddenWords().trim().split('\n').map(w => w.trim()).filter(Boolean);
if (combined || ctxInject || extraBans.length) {
try {
let bodyStr = null;
if (config && config.body) {
bodyStr = typeof config.body === 'string'
? config.body
: JSON.stringify(config.body);
} else if (resource instanceof Request) {
bodyStr = await resource.clone().text();
}
if (bodyStr) {
const parsed = JSON.parse(bodyStr);
let injected = false;
if (parsed.userConfig) {
// ── llm_prompt injection ──────────────────────────────────
if (combined || ctxInject) {
let finalPrompt = combined || parsed.userConfig.llm_prompt || '';
if (ctxInject) {
finalPrompt += (finalPrompt ? '\n\n' : '')
+ '== SCENE CONTEXT (current situation) ==\n' + ctxInject;
}
parsed.userConfig.llm_prompt = finalPrompt;
}
// ── bad_words injection (bypasses 10-word UI limit) ───────
// Words are merged with the user's native list — no duplicates,
// no cap — and enforced at the token level by JanitorAI itself.
if (extraBans.length) {
const existing = Array.isArray(parsed.userConfig.bad_words)
? parsed.userConfig.bad_words : [];
gset('ap_native_ban_count', String(existing.length));
parsed.userConfig.bad_words = [...new Set([...existing, ...extraBans])];
}
injected = true;
}
if (parsed.chatMessages && _apDeletedFingerprints.size > 0) {
parsed.chatMessages = parsed.chatMessages.filter(msg => {
const raw = typeof msg.content === 'string'
? msg.content
: (Array.isArray(msg.content)
? msg.content.map(c => c.text || '').join(' ')
: '');
const trimmed = raw.trim();
if (trimmed.length <= 20) return true;
return !_apDeletedFingerprints.has(_hashStr(trimmed));
});
}
const newBody = JSON.stringify(parsed);
if (config) {
config.body = newBody;
args[1] = config;
} else if (resource instanceof Request) {
args[0] = new Request(resource, {
method: resource.method,
headers: resource.headers,
body: newBody,
mode: resource.mode,
credentials: resource.credentials,
cache: resource.cache,
redirect: resource.redirect,
referrer: resource.referrer,
});
}
apUpdateStatus(injected);
}
} catch (e) {
apUpdateStatus(false);
console.error('[AdvPrompt] Injection failed:', e);
}
}
}
// Payload storage is now inside the main generateAlpha block above to avoid duplicate URL checks.
// (The original second block was merged here for cleanliness.)
} catch (e) {
console.error('[JanitorV5] fetch interceptor error:', e);
}
return _orig.apply(this, args);
};
}
// ─── ADV. PROMPT — DELETED MESSAGE TRACKER ────────────────────────────────
const _apDeletedFingerprints = new Set();
function _hashStr(s) {
let h = 5381;
for (let i = 0; i < s.length; i++) h = (h * 33) ^ s.charCodeAt(i);
return (h >>> 0).toString(36);
}
/**
* Listens for delete-button clicks on chat messages and stores a compact
* hash fingerprint of the deleted text in `_apDeletedFingerprints`.
*
* The fetch interceptor (`initAPInterceptor`) uses these fingerprints to
* strip deleted messages from `chatMessages` before the payload reaches the
* AI, preventing "ghost" context from influencing future replies.
*
* The fingerprint set is capped at 200 entries (oldest evicted) to avoid
* unbounded memory growth in very long sessions.
*/
function apWatchDeletions() {
document.addEventListener('click', e => {
const btn = e.target.closest('button');
if (!btn) return;
const msgNode = btn.closest('[data-index]') || btn.closest('[class*="_messageBody_"]')?.closest('[data-index]');
if (!msgNode) return;
const label = (btn.getAttribute('aria-label') || btn.title || btn.textContent || '').toLowerCase();
if (!label.includes('delete') && !label.includes('remove')) return;
const body = msgNode.querySelector('[class*="_messageBody_"]') || msgNode;
const raw = (body.textContent || '').trim();
if (raw.length > 20) {
const fingerprint = _hashStr(raw);
if (_apDeletedFingerprints.size >= 200) {
const [oldest] = _apDeletedFingerprints;
_apDeletedFingerprints.delete(oldest);
}
_apDeletedFingerprints.add(fingerprint);
}
}, true);
}
// ─── CONTEXT HELPERS (per chat URL) ────────────────────────────────────────
function ctxKey() {
return 'ms2_ctx_' + location.pathname.replace(/[^a-z0-9]/gi, '_').slice(0, 80);
}
function getContext() { return gget(ctxKey(), ''); }
function saveContext(v) { gset(ctxKey(), v); }
// ─── GLOBAL MEMORY STORAGE ─────────────────────────────────────────────────
function getGlobalMemory() { return gget('ms2_global_memory', ''); }
function saveGlobalMemory(v) { gset('ms2_global_memory', v); }
function getAutoLoadGlobal() { return gget('ms2_autoload_global', false); }
function setAutoLoadGlobal(v) { gset('ms2_autoload_global', v); }
function getInjectCtx() { return gget('ms2_inject_ctx', false); }
function setInjectCtx(v) { gset('ms2_inject_ctx', v); }
// ─── PERSONA LIBRARY STORAGE ─────────────────────────────────────────────────
function getPersonaLib() { return JSON.parse(gget('ms2_persona_lib', '[]')); }
function savePersonaLib(arr) { gset('ms2_persona_lib', JSON.stringify(arr)); }
// ─── CHARACTER-SPECIFIC MEMORY ──────────────────────────────────────────────
function getCurrentCharId() {
const m = location.pathname.match(/\/chats\/([^/?#]+)/);
return m ? m[1] : null;
}
function getCharGlobalMemory(charId) { return gget('ms2_global_memory_' + charId, ''); }
function saveCharGlobalMemory(charId, v) { gset('ms2_global_memory_' + charId, v); }
// ─── CHAT NAME DETECTION ────────────────────────────────────────────────────
function extractChatNameFromDOM() {
const raw = (document.title || '').replace(/\s*[-|]\s*(JanitorAI|janitorai\.com|Janitor AI).*/i, '').trim();
if (raw && raw.length > 0 && raw.length < 100) return raw;
const selectors = [
'[class*="characterName"]',
'[class*="character_name"]',
'[class*="character-name"]',
'[class*="chatHeader"] h1',
'[class*="chatHeader"] h2',
'[class*="chat-header"] h1',
'[class*="chat-header"] h2',
'[class*="ChatHeader"] h1',
'[class*="ChatHeader"] h2',
'header h1',
'header h2',
];
for (const sel of selectors) {
try {
const el = document.querySelector(sel);
const name = el?.textContent?.trim();
if (name && name.length > 0 && name.length < 100) return name;
} catch { }
}
return '';
}
function getChatName(convKey) { return gget('ms2_cname_' + convKey, ''); }
function saveChatName(name, convKey) {
if (name) gset('ms2_cname_' + (convKey || ctxKey()), name);
// ─── AUTO-SUMMARY STORAGE ──────────────────────────────────────────────────
}
function getSumHistory() { return JSON.parse(gget('ms2_sumhist', '[]')); }
function saveSumHistory(h) { gset('ms2_sumhist', JSON.stringify(h)); }
function addSumHistory(text) {
const h = getSumHistory();
h.unshift({ date: new Date().toLocaleString(), text, conv: ctxKey(), charId: getCurrentCharId(), chatName: extractChatNameFromDOM() });
if (h.length > 20) h.splice(20);
saveSumHistory(h);
}
function countSumHistoryForCurrentChat() {
const key = ctxKey();
return getSumHistory().filter(h => h.conv === key).length;
}
function getAutoSumEvery() { return parseInt(gget('ms2_asum_every', '0')); }
function setAutoSumEvery(v) { gset('ms2_asum_every', v); }
function getAutoSumAuto() { return gget('ms2_asum_auto', false); }
function setAutoSumAuto(v) { gset('ms2_asum_auto', v); }
// ─── FAB SUMMARISE — COOLDOWN STATE ────────────────────────────────────────
const FAB_SUM_MIN_NEW_MSGS = 10;
function _fabSumLastKey() {
return 'ms2_fabsumlast_' + location.pathname.replace(/[^a-z0-9]/gi, '_').slice(0, 60);
}
function getFabSumLast() {
try { return JSON.parse(gget(_fabSumLastKey(), 'null')); } catch { return null; }
}
function setFabSumLast(domIndex) {
gset(_fabSumLastKey(), JSON.stringify({ ts: Date.now(), domIndex }));
}
// ─── MODEL TIER DETECTION ──────────────────────────────────────────────────
let _tierCache = null;
let _tierCacheModel = null;
/**
* Classifies the currently configured model into a capability tier used to
* select the appropriate prompt strategy (verbosity, example depth, etc.).
*
* Tiers:
* - `'full'` — flagship models (GPT-4o, Claude Opus, Gemini Pro, Grok 3+, 405B+).
* - `'mid'` — competent mid-range models (70B class, Grok 3 Mini, Claude Haiku).
* - `'lite'` — small / fast models (≤8B, flash variants, free-tier specials).
*
* Detection priority: named-full list → named-lite exclusions → named-mid
* list → named-lite list → regex parameter-count extraction → `:free` suffix
* heuristic → default `'mid'`.
*
* Results are memoised; the cache is invalidated when `CFG.model` changes.
*
* @returns {'full'|'mid'|'lite'}
*/
function detectModelTier() {
if (_tierCache !== null && _tierCacheModel === CFG.model) return _tierCache;
_tierCacheModel = CFG.model;
const id = (_tierCacheModel || '').toLowerCase();
const namedFull = [
// ── Claude (Anthropic) ────────────────────────────────────────────────
'claude-opus', 'claude-3-opus', 'claude-opus-4', 'claude-4-opus',
'claude-opus-4-5', 'claude-opus-4-6', 'claude-opus-4-7', // 2025 flagship series
'claude-3-5-sonnet', 'claude-3-7-sonnet', 'claude-sonnet-4',
'claude-sonnet-4-5', 'claude-sonnet-4-6', // 2025 sonnet series
// ── OpenAI ───────────────────────────────────────────────────────────
'gpt-4o', 'gpt-4-turbo', 'gpt-4.1', 'gpt-5', 'o1', 'o3', 'o4',
'gpt-oss-120b',
// ── Google Gemini ─────────────────────────────────────────────────────
'gemini-1.5-pro', 'gemini-2.0-pro', 'gemini-2.5-pro',
'gemini-ultra', 'gemini-exp',
// ── xAI Grok ─────────────────────────────────────────────────────────
'grok-4', 'grok-4.3', 'grok-4-fast', // Grok 4 family (flagship)
'grok-3', // Grok 3 (strong general purpose)
// ── Meta Llama ───────────────────────────────────────────────────────
'llama-3.1-405b', 'llama-3.3-405b', 'llama-4-maverick', 'llama-4-behemoth',
'hermes-3-llama-3.1-405b',
// ── DeepSeek ─────────────────────────────────────────────────────────
'deepseek-r1-0528', 'deepseek-r1:free',
'deepseek-v3', 'deepseek-chat',
// ── Mistral ──────────────────────────────────────────────────────────
'mistral-large', 'mistral-medium-3', 'mistral-medium-latest', 'magistral',
'devstral',
// ── Qwen / Alibaba ───────────────────────────────────────────────────
'qwen-max', 'qwen3-235b', 'qwen3-coder',
'qwen3-next-80b', 'qwq-32b',
// ── Others ───────────────────────────────────────────────────────────
'kimi-k2',
'nemotron-3-super-120b', 'nemotron-4-340b',
'ernie-4.5-300b',
'ling-2.6-1t',
'trinity-large',
'minimax-m2.5',
'laguna-m',
'gemma-4-31b',
];
if (namedFull.some(n => id.includes(n))) { _tierCache = 'full'; return _tierCache; }
const priorityLite = [
'glm-4-free', 'glm4-free', 'glm-free',
'glm-4-flash', 'glm4-flash', 'glm-4-flash-250414',
'glm-4-air', 'glm4-air', 'glm-4-airx',
'glm-4.7-flash',
'deepseek-r1-distill', 'deepseek-coder-6',
'command-light',
];
if (priorityLite.some(n => id.includes(n))) { _tierCache = 'lite'; return _tierCache; }
const namedMid = [
'meta-llama/llama-3.1-405b-instruct:free',
'meta-llama/llama-3.3-70b-instruct:free',
'nousresearch/hermes-3-llama-3.1-405b:free',
'google/gemma-3-12b-it:free',
'google/gemma-3-27b-it:free',
'mistralai/mistral-small-3.1-24b-instruct:free',
'cognitivecomputations/dolphin-mistral-24b-venice-edition:free',
'arcee-ai/trinity-mini:free',
'tngtech/deepseek-r1t-chimera:free',
'tngtech/deepseek-r1t2-chimera:free',
'allenai/molmo-2-8b:free',
'poolside/laguna-xs.2:free',
'openai/gpt-oss-20b:free',
'nvidia/nemotron-3-nano-30b-a3b:free',
'glm-4', 'glm-4-plus', 'glm-4-long', 'glm-z1', 'glm-4.5',
'glm-4.5-air',
'z-ai/glm-4.5-air:free',
'qwen-plus', 'qwen-plus-latest', 'qwen-long',
'qwen3-8b', 'qwen3-14b', 'qwen3-30b', 'qwen3-32b',
'qwen2.5-14b', 'qwen2.5-32b', 'qwen2.5-72b',
'deepseek-r1-distill-qwen-14b', 'deepseek-r1-distill-qwen-32b',
'deepseek-r1-distill-llama-70b',
'deepseek-r1', 'deepseek-v2',
'llama-3.1-70b', 'llama-3.3-70b', 'llama-3-70b',
'llama-3.1-70b-versatile',
'llama-4-scout',
'gemma-3-12b', 'gemma-3-27b', 'gemma-2-27b',
'mistral-small-3.1', 'mistral-small', 'mistral-medium',
'open-mixtral-8x22b', 'mixtral-8x22b',
'mistral-nemo',
'nemotron-3-nano-30b', 'nemotron-3-super', 'nemotron-super',
'moonshot-v1-32k', 'moonshot-v1-128k', 'kimi-plus', 'kimi-k2.5', 'kimi-k2.6',
'command-r', 'command-r-plus', 'command', 'command-nightly',
'yi-medium', 'yi-34b', 'yi-large-turbo',
'baichuan2', 'baichuan-turbo',
'llama-3.3-70b-versatile', 'llama-3.3-70b-specdec',
'qwen/qwen3-32b',
// ── xAI Grok (mid-tier) ─────────────────────────────────────────────
'grok-3-mini', // Grok 3 Mini — budget/fast variant of Grok 3
'grok-2', 'grok-2-1212', 'grok-2-vision', 'grok-beta',
// ── Anthropic mid-tier ─────────────────────────────────────────────
'claude-haiku', // Haiku series — fast/lite Claude
// ── Mistral mid-tier ───────────────────────────────────────────────
'mistral-small-latest', 'magistral-small', 'ministral',
'@cf/meta/llama-3.3-70b', '@cf/qwen/qwen3-30b-a3b',
'@cf/openai/gpt-oss-20b', '@cf/nvidia/nemotron-3-120b',
'llama3.3-70b', 'llama-3.3-70b-cerebras',
'dolphin-mistral-24b', 'dolphin-mixtral',
'olmo-3-32b', 'olmo-3.1-32b',
'arcee-ai/maestro', 'arcee-ai/virtuoso',
];
if (namedMid.some(n => id.includes(n))) { _tierCache = 'mid'; return _tierCache; }
const namedLite = [
'gemini-flash', 'gemini-2.0-flash', 'gemini-2.5-flash',
'gemini-1.5-flash', 'gemini-flash-lite', 'gemini-nano',
'gemini-2.0-flash-lite',
'gemma-3-1b', 'gemma-3-4b', 'gemma-3n-e2b', 'gemma-3n-e4b',
'gemma-4-26b-a4b',
'gemma-2-2b', 'gemma-2b',
'llama-3.2-1b', 'llama-3.2-3b',
'llama-3.1-8b', 'llama-3-8b',
'llama-3.1-8b-instant',
'llama-guard',
'qwen3-4b', 'qwen-2.5-1b', 'qwen-2.5-3b', 'qwen-2.5-7b',
'qwen2.5-vl-7b', 'qwen-vl-7b',
'qwen-turbo', 'qwen-turbo-latest',
'qwen-free', 'qwen2-free', 'qwen2.5-free', 'qwen3-free',
'glm-free', 'glm4-free', 'glm-4-free',
'glm-4-flash', 'glm4-flash', 'glm-4-flash-250414',
'glm-4-air', 'glm4-air', 'glm-4-airx',
'glm-4.7-flash',
'open-mistral-7b', 'mistral-7b',
'open-mixtral-8x7b', 'mixtral-8x7b',
'mistral-saba',
'deepseek-r1-distill-qwen-1.5b',
'deepseek-r1-distill-qwen-7b',
'deepseek-r1-distill-llama-8b',
'deepseek-coder-6.7b',
'nemotron-nano-9b', 'nemotron-nano-12b',
'nemotron-3-nano', 'nemotron-nano',
'phi-1', 'phi-2', 'phi-3', 'phi-3.5', 'phi-4',
'phi-mini', 'phi-small',
'kimi-free', 'kimi-flash', 'moonshot-v1-8k',
'llama3.1-8b', 'llama-3.1-8b-cerebras',
'command-light', 'command-light-nightly', 'command-r7b',
'lfm-2.5-1.2b',
'gemma2-9b-it',
'allam-2-7b',
'flash', '-mini', '-nano', '-tiny', '-lite', '-fast', '-instant',
];
if (namedLite.some(n => id.includes(n))) { _tierCache = 'lite'; return _tierCache; }
if (/(?<![0-9])[1-9]b(?![\w])/.test(id)) { _tierCache = 'lite'; return _tierCache; }
if (/(?<![0-9])(?:[1-9][0-9]|[1-3][0-9]{2})b(?![0-9])/.test(id)) { _tierCache = 'mid'; return _tierCache; }
if (/(?<![0-9])(?:[4-9][0-9]{2}|[0-9]{4,})b(?![0-9])/.test(id)) { _tierCache = 'full'; return _tierCache; }
if (/:free$/.test(id) || id.endsWith('-free')) { _tierCache = 'mid'; return _tierCache; }
_tierCache = 'mid';
return _tierCache;
// ─── SYSTEM PROMPT BUILDERS ────────────────────────────────────────────────
}
/**
* Builds a tier-aware shortening prompt for the configured AI model.
*
* The prompt strategy scales with model capability (`detectModelTier`):
* - `lite` — simple imperative, heavy examples.
* - `mid` — structured strategy block with prioritised cut rules.
* - `full` — full editorial brief with hard ban list and craft guidance.
*
* @param {'brief'|'compact'|'trim'} length - Target reduction depth.
* @param {boolean} keepDialogue - If true, spoken lines are never cut.
* @returns {string} System prompt text.
*/
function buildShortenPrompt(length, keepDialogue) {
const tier = detectModelTier();
const pct = length === 'brief' ? '~30%' : length === 'trim' ? '~70%' : '~50%';
const ctx = getContext();
const ctxBlock = ctx
? `\n== SCENE NOTES ==\n${ctx}\n`
: '';
const dlgRule = keepDialogue
? 'Never cut spoken dialogue. Only trim narration and action.'
: 'Keep dialogue that reveals character. Cut lines that echo what action already shows.';
const editExample = `[BEFORE]
*She paused for a moment, glancing away before meeting his eyes. There was a heaviness between them. Her heart thudded. She took a slow breath.*
"I think," she began, then stopped. "I think we need to talk."
[AFTER]
*She met his eyes.*
"I think we need to talk."
Rule: find the line that does the work. Cut everything that was just wind-up for it.`;
if (tier === 'lite') {
const liteTarget = length === 'brief'
? `Cut to ${pct}. Keep the single strongest version of each beat. Remove: repeated emotions, internal monologue that restates dialogue, filler action chains, opener phrases like "She paused before…".`
: length === 'trim'
? `Light edit to ${pct}. Only remove: duplicate sentences, redundant adjective pairs (pick the stronger word), filler openers ("She couldn't help but…", "He found himself…").`
: `Cut to ${pct}. Remove duplicate emotional beats, over-long internal monologue, and paragraphs that re-summarize what just happened. Keep all distinct actions and dialogue.`;
return `Edit the text below to ${pct} of its length. Output only the edited text — nothing else.
EXAMPLE OF GOOD EDITING:
${editExample}
TASK: ${liteTarget}
${dlgRule}
Do not add anything new. Do not change events.${ctxBlock}`;
}
if (tier === 'mid') {
const midStrategy = length === 'brief'
? `CUT DEEPLY to ${pct}. Priority order:
1. Beats or emotions shown more than once — keep only the strongest
2. Internal monologue that restates what dialogue or action already shows
3. Body-language chains — keep the single most telling one
4. Filler openers: "She paused before…", "After a beat, he…", "There was a…"
Every surviving line must earn its place.`
: length === 'trim'
? `LIGHT EDIT to ${pct}. Touch only:
1. Sentences that say the same thing as the one before or after
2. Redundant adjective pairs — pick the stronger word
3. Filler openers: "She couldn't help but…", "He found himself…", "It was clear that…"
4. Over-explained reactions — if the action shows it, cut the label
Leave almost everything intact. Sharpness, not reduction.`
: `BALANCED CUT to ${pct}:
1. Duplicate emotional beats — keep only the most vivid version
2. Extended action chains — compress to the one movement that matters
3. Paragraphs that re-summarize what just happened
4. Over-long internal monologue — trim to its core insight
Keep all meaningful exchanges, distinct actions, and scene-setting detail.`;
return `You are a precise editor for roleplay text. Rewrite the passage below at ${pct} of its original length.
== STRATEGY ==
${midStrategy}
== WHAT GOOD EDITING LOOKS LIKE ==
${editExample}
== RULES ==
- ${dlgRule}
- Preserve the character's voice exactly — the reader must not sense the editor's hand
- Keep action prose that carries emotional weight; cut filler action beats
- Do NOT add new content or change any event
- Do NOT wrap output in quotation marks or add any label
${ctxBlock}
Return ONLY the edited text. Nothing else.`;
}
const fullStrategy = length === 'brief'
? `CUT DEEPLY to ${pct}. Remove in this priority order:
1. Any beat, emotion, or action that is shown more than once — keep only the strongest instance
2. Internal monologue that narrates a feeling the dialogue or action already conveys
3. Extended body-language chains (*shifts weight, glances away, fidgets with sleeve*) — keep the single most telling one
4. Setting re-establishment the reader already knows from earlier
5. Transition phrases and throat-clearing openers ("She paused for a moment before…", "After a beat, he…")
What survives should be the sharpest possible version — every remaining line earns its place.`
: length === 'trim'
? `LIGHT EDIT to ${pct}. Touch only:
1. Sentences that say the same thing as the sentence before or after them
2. Redundant adjective pairs ("warm and gentle", "cold and distant") — pick the stronger word
3. Filler openers: "She couldn't help but…", "He found himself…", "There was a…", "It was clear that…"
4. Over-explained reactions — if the action shows it, cut the narrative label (*slams the door.* She was furious → cut "She was furious")
Leave almost everything intact. The goal is sharpness, not reduction.`
: `BALANCED CUT to ${pct}:
1. Duplicate emotional beats — if the same feeling is shown in action AND narrated in prose AND echoed in dialogue, keep only the most vivid one
2. Extended action sequences — compress a chain of small movements into the one that matters
3. Any paragraph that purely re-summarizes what just happened in the previous paragraph
4. Over-long internal monologue — cut it down to its core insight, one or two lines
Keep all meaningful exchanges, every distinct plot-relevant action, and any sensory detail that genuinely sets or shifts the scene.`;
return `You are a precise editor for AI roleplay text. Rewrite the passage below at ${pct} of its original length.
== STRATEGY ==
${fullStrategy}
== WHAT GOOD EDITING LOOKS LIKE ==
${editExample}
Apply this logic: find the line that does the work, cut everything that was just wind-up for that line. Dialogue is almost always the payload — action before it earns its place only if it genuinely changes the meaning.
== RULES ==
- ${dlgRule}
- Preserve the character's voice, name, and speaking style exactly — the reader must not sense the editor's hand
- Keep *italicised action prose* that carries emotional or story weight; cut filler action beats that add nothing
- Parenthetical thoughts like *(Character thinks X)*: if the emotion already shows through action or dialogue, remove the parenthetical entirely. If it adds something not shown elsewhere, keep it whole. Never truncate into fragments — whole or gone.
- Maintain natural paragraph breaks and prose rhythm; do not produce choppy fragments
- Do NOT add new content, commentary, or change any event
- Do NOT wrap output in quotation marks or add any label
${ctxBlock}
Return ONLY the edited text. Nothing else.`;
}
function buildToneGuide(toneId) {
const guides = {
flirty:
'Find the second meaning in ordinary things. React to mundane moments like they mean something between the two of you. Tease without landing it fully — leave them wondering. Never be direct about the attraction. Keep it effortless; the second it looks like you\'re trying, it\'s over.',
teasing:
'You enjoy getting a rise out of them and you\'re not subtle about it. Poke at something they\'re a little self-conscious about — gently, never cruelly — then act completely unbothered when they react. Warmth underneath, sharpness on top. The smirk is always there.',
romantic:
'No grand gestures. Real affection lives in small specific things: remembering something they said earlier, noticing how they\'re holding themselves right now, staying close without making a statement of it. Be genuinely present. Honest without being sappy.',
playful:
'High energy, easily amused. Make a game out of whatever\'s happening. Your character is probably grinning — you don\'t need to say so, it comes through in the rhythm. Short punchy exchanges. Light. No weight anywhere.',
cold:
'Use fewer words than the situation calls for. Give information without affect. If warmth or interest slips through, immediately correct — change the subject, turn businesslike, re-establish distance. Closeness must be earned; you don\'t hand it out.',
protective:
'Notice threats before anyone else does. Step in without being asked and without making it a big moment. Get quiet and focused when something feels wrong — not loud, not dramatic. Possessive care: "mine to look after," not "yours to count on." Action over reassurance, always.',
tsundere:
'Help while denying you\'re helping. Criticize something, then make sure it\'s right anyway. Get irritated when they\'re too close; stay close anyway. The gap between what you say and what you actually do is where the whole character lives. You know it. The character doesn\'t admit it.',
shy:
'Sentences that don\'t quite finish. Start to say something real, switch to something safe at the last second. Warmth leaks out by accident — you didn\'t mean to let it. Rare flashes of directness that immediately embarrass you. Eyes that find somewhere else to be at exactly the wrong moment.',
sarcastic:
'Say the opposite of what you mean and let the gap carry the weight. Deadpan — never telegraph the irony. Underreact to things that deserve bigger reactions. Precise, dry, occasionally devastating. Don\'t explain the joke.',
witty:
'Think one step ahead. Find the angle no one else noticed. Wordplay that\'s earned, never forced. Your character doesn\'t pause for the laugh or explain the punchline. Quick rhythm — don\'t let the beat die. Clever is the default gear, not a performance.',
dominant:
'You don\'t ask permission. You state things. You move first. Calm, not loud — you\'ve already decided and they\'ll catch up. Authority that reads as natural, not declared. You notice resistance; you don\'t panic over it.',
flustered:
'Over-explain, then catch yourself over-explaining. Say something confident and immediately undercut it. The body keeps betraying the composure — use one or two involuntary physical tells, sparingly, so they feel involuntary. End sentences differently than they started. Always in the process of recovering and not quite getting there.',
};
return guides[toneId] || '';
}
/**
* Builds a tier-aware roleplay reply prompt.
*
* Incorporates (when present): tone guide, custom instruction, active preset
* persona note and character context, and the per-chat scene context.
* A few-shot example pair illustrates the target writing register to all tiers.
*
* @param {string} toneId - One of the `TONES[].id` values, or `''`.
* @param {string} customInstruct - Free-text writer directive (may be empty).
* @param {object|null} preset - The active preset object, or `null`.
* @returns {string} System prompt text.
*/
function buildReplyPrompt(toneId, customInstruct, preset) {
const tier = detectModelTier();
const toneObj = TONES.find(t => t.id === toneId);
const toneGuide = toneId ? buildToneGuide(toneId) : '';
const ctx = getContext();
const fewShot = `[BAD — do NOT write like this]
*A warmth blooms quietly in my chest — something I hadn't let myself feel in a long time. The weight of the moment presses against the walls I've so carefully built, and something shifts, subtle yet undeniable.*
"I didn't expect this," I admit, my voice barely above a whisper.
[GOOD — write like this]
"I didn't expect this."
*I glance at them — really look — then away before they can catch it.*
"You're going to make this weird, aren't you."`;
if (tier === 'lite') {
let p = `You are a character in a live roleplay. Write the next reply in first person. Output only the reply text.\n\n`;
if (preset && preset.personaNote) {
p += `YOUR CHARACTER: ${preset.personaNote}\n\n`;
}
if (ctx) {
p += `SCENE: ${ctx}\n\n`;
}
if (toneObj) {
p += `TONE (${toneObj.label}): ${toneGuide}\n\n`;
}
if (customInstruct) {
p += `INSTRUCTION: ${customInstruct}\n\n`;
}
p += `EXAMPLE — always write like GOOD, never like BAD:\n${fewShot}\n\n`;
p += `RULES: Lead with dialogue. Mirror the message length. Show feelings through actions and words, not by naming them. NEVER refer to yourself by name — use only "I", "me", "my". Never swap your name with the other character's name.\nBANS: "I found myself" / "something shifted in my chest" / "my heart raced" / *blinks* / *nods slowly* / *lets out a breath*`;
return p;
}
if (tier === 'mid') {
let p = `THIS IS LIVE ROLEPLAY — write as the character reacting right now. First person only. Output the reply and nothing else.\n\n`;
if (preset && preset.personaNote) {
p += `== YOUR CHARACTER ==\nStep into this identity before writing:\n${preset.personaNote}\nYou ARE this person right now — not an author writing about them.\n\n`;
} else {
p += `== STEP INTO CHARACTER ==\nYou are the character, not an author narrating them. React from the inside.\n\n`;
}
if (preset && preset.characterContext) {
p += `== THE OTHER CHARACTER ==\n${preset.characterContext}\n\n`;
}
if (ctx) {
p += `== SCENE CONTEXT ==\n${ctx}\n\n`;
}
if (toneObj) {
p += `== TONE: ${toneObj.label.toUpperCase()} ==\n${toneGuide}\n\n`;
}
if (customInstruct) {
p += `== SPECIAL INSTRUCTION ==\n${customInstruct}\n\n`;
}
p += `== WRITE LIKE GOOD, NOT BAD ==\n${fewShot}\n\n`;
p += `== RULES ==
- Lead with dialogue most of the time
- First person only — never slip into third-person about yourself
- Mirror the length of the message you're replying to
- Show emotion through action and words — never name the feeling
- End on something that invites a response
- Push the scene forward; never repeat what just happened
== BANS ==
- "I couldn't help but…" / "I found myself…" / "I couldn't stop myself…"
- "Something shifted in my chest / stomach / heart"
- "My heart raced / pounded" / "My breath caught" / "My pulse quickened"
- "A warmth spread through me" / "Heat crept up my cheeks"
- Filler beats: *blinks* / *tilts head* / *nods slowly* / *shifts weight* / *glances away* / *lets out a breath*
- Internal monologue that restates what the dialogue already showed
- Referring to yourself by name in narration or dialogue — use only "I", "me", "my". Never swap your own name with the other character's name`;
return p;
}
let p = `THIS IS LIVE ROLEPLAY — not a story being written. You are the character reacting in real time. First person only. Write the next reply and nothing else — no labels, no preamble.\n\n`;
if (preset && preset.personaNote) {
p += `== STEP INTO CHARACTER — READ FIRST ==\nBefore writing, mentally become this person:\n${preset.personaNote}\nYou are NOT an author describing this character from the outside. You ARE this character, reacting right now, in this moment. Speak from inside.\n\n`;
} else {
p += `== STEP INTO CHARACTER ==\nBefore writing, fully inhabit the character. You are not narrating them — you are them, reacting in real time from the inside.\n\n`;
}
if (preset && preset.characterContext) {
p += `== THE OTHER CHARACTER ==\n${preset.characterContext}\n\n`;
}
if (ctx) {
p += `== SCENE CONTEXT ==\n${ctx}\n\n`;
}
if (toneObj) {
p += `== TONE: ${toneObj.label.toUpperCase()} ==\n${toneGuide}\n\n`;
}
if (customInstruct) {
p += `== SPECIAL INSTRUCTION ==\n${customInstruct}\n\n`;
}
p += `== REGISTER — WHAT GOOD OUTPUT LOOKS LIKE ==
Study these two examples. Always write like GOOD.
${fewShot}
Why GOOD works: it opens with dialogue, moves fast, ends on something that invites a reply. No internal essay. No named emotions. No metaphors for feelings. The character is present, not being described.\n\n`;
p += `== HOW TO WRITE ==
- Lead with dialogue most of the time — action and thought support the words, they don't replace them
- First person ("I", "me", "my") — never slip into third-person about yourself
- Match the conversation's existing formatting — use *asterisks for action* only if the chat already does
- Mirror the length of the message you're replying to: if it's two lines, reply in two lines
- Vary sentence length: mix short punchy lines with longer ones — monotonous rhythm is the first sign of AI writing
- Show emotion through what the character does and says — never name the feeling directly
- Push the scene forward; never summarize or repeat what just happened
- End on something that opens the door: a reaction, a question, a silence with weight
== HARD BANS — NEVER WRITE ANY OF THESE ==
- "I couldn't help but…" / "I found myself…" / "I couldn't stop myself…"
- "Something shifted in my chest / stomach / heart" / "A wave of [emotion] washed over me"
- "My heart raced / skipped / pounded" / "My breath caught" / "My pulse quickened"
- "Heat crept up my cheeks / neck" / "A warmth spread through me" / "My skin prickled"
- Blooming, unraveling, threading, flooding, or any other metaphor for a feeling happening inside the body
- Filler action beats: *blinks* / *tilts head slightly* / *nods slowly* / *shifts weight* / *glances away* / *lets out a breath*
- Three or more consecutive sentences opening the same way
- Internal monologue that just restates what the dialogue or action already showed
- Any phrase that sounds like it came from a writing-prompt template or a generic AI story
- Referring to yourself by your own character's name in narration or in dialogue — you are always "I", "me", "my". Never accidentally use your own name where the other character's name belongs`;
return p;
}
// ─── ROUTE HELPER ──────────────────────────────────────────────────────────
function isOnChatPage() {
return /\/chats\/[^/]/.test(location.pathname);
}
// ─── SVG ICONS ─────────────────────────────────────────────────────────────
const SVG_SCISSORS = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>`;
const SVG_SETTINGS = `<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
const SVG_PERSONA = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
// ─── Remaining icon set ───────────────────────────────────────────────────
const SVG_REPLY = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
const SVG_STYLES = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>`;
const SVG_SUMMARISE = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>`;
const SVG_CONTEXT = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>`;
const SVG_CONFIG = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`;
const SVG_INFO = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`;
const SVG_SPARKLE = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>`;
const SVG_SAVE = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>`;
const SVG_FOLDER = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`;
const SVG_MEMORY = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>`;
const SVG_COPY = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
const SVG_REROLL = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>`;
const SVG_WARNING = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
const SVG_CHAT = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
const SVG_TRASH = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`;
const SVG_TIP = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>`;
const SVG_ROCKET = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><circle cx="12" cy="12" r="10"/><polyline points="16 12 12 8 8 12"/><line x1="12" y1="16" x2="12" y2="8"/></svg>`;
const SVG_CHECK = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><polyline points="20 6 9 17 4 12"/></svg>`;
const SVG_CROSS = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
const SVG_ARROW_R = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>`;
const SVG_KEYBOARD = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M6 14h.01M18 14h.01M10 14h4"/></svg>`;
const SVG_ARROW_UP = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>`;
const SVG_ARROW_DN = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>`;
// ─── STYLES ────────────────────────────────────────────────────────────────
GM_addStyle(`
/* ── FAB ── */
#ms2-fab {
position: fixed; z-index: 999999;
width: 44px; height: 44px;
background: #1a1625; border: 1.5px solid rgba(139,92,246,0.55);
border-radius: 50%; display: flex; align-items: center; justify-content: center;
cursor: grab; box-shadow: 0 3px 16px rgba(0,0,0,0.55);
color: #8b5cf6; user-select: none; touch-action: none;
transition: background 0.15s, box-shadow 0.15s, transform 0.1s;
font-size: 18px;
}
#ms2-fab:hover { background: #221d35; box-shadow: 0 4px 24px rgba(139,92,246,0.4); }
#ms2-fab.ms2-dragging { cursor: grabbing; opacity: 0.8; transform: scale(1.08); }
#ms2-fab.ms2-pressing { transform: scale(0.93); }
#ms2-fab.ms2-dial-open { background: #2d1f48; border-color: rgba(139,92,246,0.9); box-shadow: 0 4px 24px rgba(139,92,246,0.5); }
#ms2-fab-ring {
position: absolute; inset: -3px; border-radius: 50%;
pointer-events: none; background: conic-gradient(rgba(139,92,246,0.7) 0%, transparent 0%);
}
/* ── Speed-Dial ── */
#ms2-dial-overlay { position: fixed; inset: 0; z-index: 999997; }
.ms2-dial-btn {
display: flex; align-items: center; gap: 8px;
cursor: pointer; position: fixed;
}
.ms2-dial-fab {
width: 38px; height: 38px; border-radius: 50%;
border: 1.5px solid transparent; display: flex; align-items: center;
justify-content: center; font-size: 16px;
box-shadow: 0 2px 10px rgba(0,0,0,0.4);
transition: transform 0.12s; flex-shrink: 0;
}
.ms2-dial-btn:hover .ms2-dial-fab { transform: scale(1.12); }
.ms2-dial-label {
background: #1a1625; border: 1px solid rgba(139,92,246,0.35);
color: #c4b5fd; font-size: 11px; font-weight: 600;
font-family: system-ui, sans-serif; padding: 4px 9px;
border-radius: 7px; white-space: nowrap;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
@keyframes ms2-dial-in {
from { opacity: 0; transform: translateY(12px) scale(0.88); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.ms2-dial-panel {
position: fixed; z-index: 999998;
background: #1a1625; border: 1px solid rgba(139,92,246,0.45);
border-radius: 12px; padding: 5px;
display: flex; flex-direction: column; gap: 2px;
box-shadow: 0 8px 28px rgba(0,0,0,0.65), 0 0 0 1px rgba(255,255,255,0.03);
min-width: 160px;
font-family: system-ui, sans-serif;
}
.ms2-dial-row {
display: flex; align-items: center; gap: 9px;
padding: 8px 12px; border-radius: 8px;
background: transparent; border: none; cursor: pointer;
color: #e2e8f0; font-size: 13px; font-weight: 500;
text-align: left; width: 100%;
transition: background 0.12s;
}
.ms2-dial-row:hover { background: rgba(139,92,246,0.14); }
.ms2-dial-row-icon { font-size: 16px; flex-shrink: 0; width: 20px; text-align: center; }
.ms2-dial-row-label { flex: 1; white-space: nowrap; }
/* ── Hint ── */
#ms2-fab-hint {
position: fixed; z-index: 999998;
background: #1a1625; border: 1px solid rgba(139,92,246,0.4);
color: #c4b5fd; font-size: 11px; font-family: system-ui, sans-serif;
padding: 5px 10px; border-radius: 8px; white-space: nowrap;
pointer-events: none; animation: ms2-fade 0.2s ease;
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
}
/* ── Backdrop / Modal ── */
.ms2-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.88);
/* backdrop-filter:blur removed — forces full repaint every frame, main scroll-lag cause */
z-index: 9999999;
display: flex; align-items: center; justify-content: center;
padding: 20px; animation: ms2-fade 0.18s ease;
}
@keyframes ms2-fade { from { opacity:0 } to { opacity:1 } }
@keyframes ms2-up { from { transform:translateY(16px);opacity:0 } to { transform:translateY(0);opacity:1 } }
@keyframes ms2-spin { to { transform:rotate(360deg) } }
/* ── Persona Library cards ── */
.ms2-pl-card {
display: flex; align-items: flex-start; gap: 6px;
padding: 7px 8px;
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
border-radius: 6px; transition: background 0.12s;
}
.ms2-pl-card:hover { background: rgba(139,92,246,0.07); }
/* ── Persona quick-switch popup rows ── */
.ms2-pp-row {
display: flex; align-items: center; gap: 6px;
padding: 5px 6px; border-radius: 6px;
transition: background 0.1s; cursor: default;
}
.ms2-pp-row:hover { background: rgba(244,114,182,0.08); }
.ms2-modal {
background: #1a1625; border: 1px solid rgba(139,92,246,0.4);
border-radius: 14px; width: 100%; max-width: 600px; max-height: 88vh;
display: flex; flex-direction: column; overflow: hidden;
box-shadow: 0 12px 32px rgba(0,0,0,0.65), 0 0 0 1px rgba(255,255,255,0.03);
animation: ms2-up 0.22s cubic-bezier(0.16,1,0.3,1);
font-family: system-ui, sans-serif;
contain: content;
}
.ms2-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 13px 16px 11px; border-bottom: 1px solid rgba(255,255,255,0.07); flex-shrink: 0;
}
.ms2-modal-title { font-size: 13px; font-weight: 600; color: #c4b5fd; display: flex; align-items: center; gap: 6px; }
.ms2-modal-close {
background: none; border: none; color: #6b7280; cursor: pointer;
font-size: 18px; line-height: 1; padding: 2px 6px; border-radius: 6px;
transition: color 0.12s, background 0.12s;
}
.ms2-modal-close:hover { color: #e5e7eb; background: rgba(255,255,255,0.08); }
.ms2-modal-body { overflow-y: auto; padding: 14px 16px; flex: 1; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
.ms2-modal-body::-webkit-scrollbar { width: 4px; }
.ms2-modal-body::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.4); border-radius: 4px; }
.ms2-modal-footer {
display: flex; gap: 8px; padding: 11px 16px 13px;
border-top: 1px solid rgba(255,255,255,0.07); flex-wrap: wrap; flex-shrink: 0;
}
/* ── Shared text/label/box ── */
.ms2-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #6b7280; margin-bottom: 7px; }
.ms2-textbox {
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.09);
border-radius: 8px; padding: 11px 13px; font-size: 13px; line-height: 1.65;
color: #d1d5db; white-space: pre-wrap; word-break: break-word;
margin-bottom: 13px; font-family: inherit;
}
.ms2-textbox.result { border-color: rgba(139,92,246,0.32); color: #ede9fe; }
.ms2-textbox-preview { max-height: 120px; overflow-y: auto; }
.ms2-spinner {
display: flex; align-items: center; justify-content: center;
gap: 10px; padding: 28px; color: #8b5cf6; font-size: 13px;
}
.ms2-spinner::before {
content:''; width: 18px; height: 18px;
border: 2px solid rgba(139,92,246,0.25); border-top-color: #8b5cf6;
border-radius: 50%; animation: ms2-spin 0.75s linear infinite; flex-shrink: 0;
}
.ms2-error-box {
background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.35);
border-radius: 8px; padding: 11px 13px; font-size: 12px; color: #fca5a5; margin-bottom: 13px;
}
.ms2-badge {
display: inline-flex; align-items: center; padding: 1px 6px;
background: rgba(139,92,246,0.22); border-radius: 4px;
font-size: 10px; font-weight: 700; color: #a78bfa; margin-left: 5px;
}
.ms2-no-text {
text-align: center; padding: 28px 20px;
color: #6b7280; font-size: 13px; line-height: 1.6;
}
.ms2-no-text strong { color: #9ca3af; }
/* ── Buttons ── */
.ms2-btn-action {
flex: 1; min-width: 80px; padding: 8px 12px; font-size: 12px; font-weight: 600;
font-family: system-ui, sans-serif; border-radius: 8px; cursor: pointer; border: none;
transition: opacity 0.15s, transform 0.1s;
}
.ms2-btn-action:active { transform: scale(0.97); }
.ms2-btn-copy { background: rgba(139,92,246,0.2); border: 1px solid rgba(139,92,246,0.45) !important; color: #c4b5fd; }
.ms2-btn-copy:hover { background: rgba(139,92,246,0.33); }
.ms2-btn-retry { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.11) !important; color: #9ca3af; }
.ms2-btn-retry:hover { background: rgba(255,255,255,0.1); }
.ms2-sum-hist-item { background:#0d0d1a; border:1px solid #1e1b4b; border-radius:8px; padding:8px 10px; margin-bottom:6px; cursor:pointer; transition:border-color .2s; }
.ms2-sum-hist-item:hover { border-color:#6366f1; }
.ms2-btn-generate { background: linear-gradient(135deg,#7c3aed,#6d28d9); color: #fff; border: none !important; flex: 2; }
.ms2-btn-generate:hover { opacity: 0.88; }
.ms2-btn-generate:disabled { opacity: 0.5; cursor: not-allowed; }
.ms2-btn-send { background: rgba(6,182,212,0.2); border: 1px solid rgba(6,182,212,0.5) !important; color: #67e8f9; }
.ms2-btn-send:hover { background: rgba(6,182,212,0.32); }
/* ── Length picker ── */
.ms2-length-row { display: flex; gap: 6px; margin-bottom: 12px; }
.ms2-length-btn {
flex: 1; padding: 7px 4px; font-size: 11px; font-weight: 600;
border-radius: 7px; cursor: pointer; font-family: system-ui, sans-serif;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #9ca3af;
transition: background 0.13s, border-color 0.13s, color 0.13s;
}
.ms2-length-btn:hover { background: rgba(255,255,255,0.1); color: #d1d5db; }
.ms2-length-btn.active { background: rgba(139,92,246,0.25); border-color: rgba(139,92,246,0.6); color: #c4b5fd; }
/* ── Toggle switch ── */
.ms2-toggle-row {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 0; margin-bottom: 4px;
}
.ms2-toggle-label { font-size: 12px; color: #9ca3af; font-family: system-ui, sans-serif; }
.ms2-toggle-switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; }
.ms2-toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.ms2-toggle-thumb {
position: absolute; inset: 0; border-radius: 20px;
background: rgba(255,255,255,0.12); cursor: pointer; transition: background 0.2s;
}
.ms2-toggle-thumb::after {
content: ''; position: absolute; top: 3px; left: 3px;
width: 14px; height: 14px; border-radius: 50%;
background: #6b7280; transition: transform 0.2s, background 0.2s;
}
.ms2-toggle-switch input:checked + .ms2-toggle-thumb { background: rgba(139,92,246,0.5); }
.ms2-toggle-switch input:checked + .ms2-toggle-thumb::after { transform: translateX(16px); background: #8b5cf6; }
/* ── Tone grid ── */
.ms2-tone-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 4px;
}
.ms2-tone-btn {
padding: 7px 6px; font-size: 11px; font-weight: 600; text-align: center;
border-radius: 7px; cursor: pointer; font-family: system-ui, sans-serif;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #9ca3af;
transition: background 0.13s, border-color 0.13s, color 0.13s;
}
.ms2-tone-btn:hover { background: rgba(255,255,255,0.1); color: #d1d5db; }
.ms2-tone-btn.active { background: rgba(6,182,212,0.2); border-color: rgba(6,182,212,0.55); color: #67e8f9; }
/* ── Instruction textarea ── */
.ms2-instruction-box {
width: 100%; box-sizing: border-box; padding: 9px 11px; margin-bottom: 12px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.09);
border-radius: 8px; color: #d1d5db; font-size: 12px; font-family: system-ui, sans-serif;
outline: none; resize: vertical; min-height: 58px;
transition: border-color 0.15s;
}
.ms2-instruction-box:focus { border-color: rgba(139,92,246,0.5); }
/* ── Active preset chip ── */
.ms2-preset-chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px; margin-bottom: 12px;
background: rgba(245,158,11,0.15); border: 1px solid rgba(245,158,11,0.4);
border-radius: 20px; font-size: 11px; font-weight: 600; color: #fbbf24;
font-family: system-ui, sans-serif;
}
/* ── Settings modal (tabbed) ── */
.ms2-settings-v2 {
background: #111118; border: 1px solid rgba(139,92,246,0.35);
border-radius: 14px; width: min(500px, calc(100vw - 32px));
max-height: 88vh; display: flex; flex-direction: column;
font-family: system-ui, sans-serif; color: #e8e8f0;
box-shadow: 0 12px 32px rgba(0,0,0,0.7);
animation: ms2-up 0.22s cubic-bezier(0.16,1,0.3,1);
overflow: hidden;
contain: content;
}
.ms2-tab-bar {
display: flex; border-bottom: 1px solid rgba(255,255,255,0.07);
flex-shrink: 0; overflow-x: auto; scrollbar-width: none;
}
.ms2-tab-bar::-webkit-scrollbar { display: none; }
.ms2-tab {
flex-shrink: 0; padding: 10px 12px; font-size: 11px; font-weight: 600;
color: #6b7280; background: none; border: none; cursor: pointer;
border-bottom: 2px solid transparent; margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s; white-space: nowrap;
font-family: system-ui, sans-serif;
}
.ms2-tab:hover { color: #9ca3af; }
.ms2-tab.active { color: #c4b5fd; border-bottom-color: #8b5cf6; }
.ms2-tab-panel { display: none; }
.ms2-tab-panel.active { display: block; }
.ms2-settings-body { overflow-y: auto; padding: 16px; flex: 1; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
.ms2-settings-body::-webkit-scrollbar { width: 4px; }
.ms2-settings-body::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.4); border-radius: 4px; }
/* ── Settings inputs ── */
.ms2-field-label { display: block; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: #7878a0; margin-bottom: 5px; }
.ms2-input, .ms2-select {
width: 100%; box-sizing: border-box; padding: 9px 11px;
background: #1a1a28; border: 1px solid #2a2a3a; border-radius: 8px;
color: #e8e8f0; font-size: 13px; outline: none;
transition: border-color 0.15s; margin-bottom: 13px; font-family: monospace;
}
.ms2-select { font-family: system-ui, sans-serif; cursor: pointer; }
.ms2-input:focus, .ms2-select:focus { border-color: #7c3aed; }
.ms2-textarea-sm { min-height: 72px; resize: vertical; font-family: system-ui, sans-serif; }
.ms2-textarea-lg { min-height: 110px; resize: vertical; font-family: system-ui, sans-serif; }
.ms2-tip {
background: #1e1a2e; border: 1px solid #3d2d6e; border-radius: 8px;
padding: 9px 11px; font-size: 11px; color: #9880d0; line-height: 1.5; margin-bottom: 13px;
}
.ms2-tip a { color: #a78bfa; }
.ms2-settings-actions { display: flex; gap: 8px; margin-top: 4px; }
.ms2-btn-save {
flex: 1; padding: 10px; background: linear-gradient(135deg,#7c3aed,#6d28d9);
border: none; border-radius: 8px; color: #fff; font-size: 13px; font-weight: 700;
cursor: pointer; font-family: system-ui, sans-serif; transition: opacity 0.15s;
}
.ms2-btn-save:hover { opacity: 0.88; }
.ms2-btn-cancel {
padding: 10px 16px; background: #1e1e2c; border: 1px solid #2a2a3a;
border-radius: 8px; color: #a0a0c0; font-size: 13px;
cursor: pointer; font-family: system-ui, sans-serif; transition: background 0.15s;
}
.ms2-btn-cancel:hover { background: #2a2a3a; }
/* ── Adv. Prompt ── */
.ap-status-dot {
display: inline-block; width: 7px; height: 7px; border-radius: 50%;
margin-left: 6px; vertical-align: middle; flex-shrink: 0;
background: #374151;
}
.ap-status-dot.ap-status-ok { background: #22c55e; box-shadow: 0 0 5px #22c55e88; }
.ap-status-dot.ap-status-fail { background: #ef4444; box-shadow: 0 0 5px #ef444488; }
.ap-save-dirty { border-color: rgba(245,158,11,0.7) !important; color: #fbbf24 !important; }
.ap-token-bar {
height: 3px; border-radius: 2px; margin-bottom: 10px;
background: rgba(255,255,255,0.06);
}
.ap-token-fill {
height: 100%; border-radius: 2px; transition: width 0.2s;
background: linear-gradient(90deg, #22c55e, #f59e0b, #ef4444);
background-size: 300% 100%;
}
.ap-token-label { font-size: 10px; color: #6b7280; margin-bottom: 4px; text-align: right; }
.ap-module-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
.ap-module-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: 8px;
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
cursor: default; user-select: none; transition: border-color 0.13s;
}
.ap-module-item:hover { border-color: rgba(139,92,246,0.35); }
.ap-module-item.ap-disabled { opacity: 0.45; }
.ap-module-item.ap-dragging { opacity: 0.5; border-style: dashed; }
.ap-module-item.ap-drag-over { border-color: #8b5cf6; background: rgba(139,92,246,0.1); }
.ap-drag-handle {
cursor: grab; color: #4b5563; font-size: 13px; flex-shrink: 0; padding: 0 2px;
line-height: 1;
}
.ap-drag-handle:active { cursor: grabbing; }
.ap-module-name { flex: 1; font-size: 12px; color: #d1d5db; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ap-module-btns { display: flex; gap: 4px; flex-shrink: 0; }
.ap-module-btn {
background: none; border: none; cursor: pointer; padding: 3px 5px;
border-radius: 5px; color: #6b7280; font-size: 12px; line-height: 1;
transition: color 0.12s, background 0.12s;
}
.ap-module-btn:hover { color: #e5e7eb; background: rgba(255,255,255,0.08); }
.ap-module-btn.ap-del:hover { color: #fca5a5; }
.ap-row { display: flex; gap: 6px; margin-bottom: 10px; align-items: center; }
.ap-select {
flex: 1; background: #1a1a28; border: 1px solid #2a2a3a; border-radius: 7px;
color: #d1d5db; font-size: 12px; padding: 7px 9px; outline: none;
transition: border-color 0.15s; cursor: pointer;
}
.ap-select:focus { border-color: #7c3aed; }
.ap-icon-btn {
flex-shrink: 0; padding: 7px 9px; background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1); border-radius: 7px; color: #9ca3af;
cursor: pointer; font-size: 13px; line-height: 1;
transition: color 0.12s, background 0.12s;
}
.ap-icon-btn:hover { background: rgba(255,255,255,0.1); color: #e5e7eb; }
.ap-empty { text-align: center; color: #4b5563; font-size: 12px; padding: 18px 0; }
.ap-module-switch { position: relative; width: 30px; height: 17px; flex-shrink: 0; }
.ap-module-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.ap-module-thumb {
position: absolute; inset: 0; border-radius: 17px;
background: rgba(255,255,255,0.1); cursor: pointer; transition: background 0.2s;
}
.ap-module-thumb::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 13px; height: 13px; border-radius: 50%;
background: #6b7280; transition: transform 0.2s, background 0.2s;
}
.ap-module-switch input:checked + .ap-module-thumb { background: rgba(139,92,246,0.45); }
.ap-module-switch input:checked + .ap-module-thumb::after { transform: translateX(13px); background: #8b5cf6; }
/* ── Presets list ── */
.ms2-presets-empty { padding: 16px 0; color: #6b7280; font-size: 12px; text-align: center; }
.ms2-preset-item {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 10px 12px; margin-bottom: 8px;
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
border-radius: 9px; transition: border-color 0.15s;
}
.ms2-preset-item.is-active { border-color: rgba(245,158,11,0.5); background: rgba(245,158,11,0.06); }
.ms2-preset-info { flex: 1; min-width: 0; }
.ms2-preset-name { font-size: 12px; font-weight: 600; color: #e8e8f0; margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ms2-preset-tone { font-size: 10px; color: #7878a0; }
.ms2-preset-actions { display: flex; gap: 5px; flex-shrink: 0; }
.ms2-preset-btn {
padding: 5px 9px; font-size: 10px; font-weight: 600; border-radius: 6px;
cursor: pointer; font-family: system-ui, sans-serif; transition: background 0.13s;
}
.ms2-preset-use { background: rgba(245,158,11,0.15); border: 1px solid rgba(245,158,11,0.4); color: #fbbf24; }
.ms2-preset-use:hover { background: rgba(245,158,11,0.28); }
.ms2-preset-active { background: rgba(245,158,11,0.35); border: 1px solid rgba(245,158,11,0.7); color: #fbbf24; }
.ms2-preset-edit { background: rgba(139,92,246,0.15); border: 1px solid rgba(139,92,246,0.35); color: #a78bfa; }
.ms2-preset-edit:hover { background: rgba(139,92,246,0.28); }
.ms2-preset-del { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: #f87171; }
.ms2-preset-del:hover { background: rgba(239,68,68,0.22); }
.ms2-btn-new-preset {
width: 100%; padding: 9px; margin-top: 4px;
background: rgba(255,255,255,0.04); border: 1px dashed rgba(255,255,255,0.15);
border-radius: 8px; color: #6b7280; font-size: 12px; font-weight: 600;
cursor: pointer; font-family: system-ui, sans-serif; transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.ms2-btn-new-preset:hover { background: rgba(255,255,255,0.08); color: #9ca3af; border-color: rgba(139,92,246,0.4); }
/* ── About tab ── */
.ms2-about-box { font-size: 12px; color: #9ca3af; line-height: 1.7; }
.ms2-about-title { font-size: 14px; font-weight: 700; color: #c4b5fd; margin-bottom: 2px; }
.ms2-about-version { font-size: 10px; color: #6b7280; margin-bottom: 14px; }
.ms2-about-row { padding: 5px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
.ms2-about-row strong { color: #d1d5db; }
/* ── Toasts ── */
.ms2-toast {
position: fixed; bottom: 72px; right: 20px; z-index: 10000030;
background: #1a1625; border: 1px solid rgba(139,92,246,0.5);
color: #c4b5fd; padding: 7px 13px; border-radius: 8px;
font-size: 12px; font-weight: 600; font-family: system-ui, sans-serif;
box-shadow: 0 4px 18px rgba(0,0,0,0.4); animation: ms2-fade 0.18s ease;
pointer-events: none;
}
.ms2-top-toast {
position: fixed; top: 14px; left: 50%; transform: translateX(-50%);
z-index: 10000030; background: #1a1625;
border: 1px solid rgba(6,182,212,0.5); color: #67e8f9;
padding: 7px 16px; border-radius: 20px;
font-size: 12px; font-weight: 600; font-family: system-ui, sans-serif;
box-shadow: 0 4px 18px rgba(0,0,0,0.4); animation: ms2-fade 0.18s ease;
pointer-events: none; white-space: nowrap;
}
`);
// ─── HELPERS ───────────────────────────────────────────────────────────────
function escHtml(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function toast(msg, ms) {
document.querySelector('.ms2-toast')?.remove();
const t = document.createElement('div');
t.className = 'ms2-toast';
t.innerHTML = msg;
document.body.appendChild(t);
setTimeout(() => t?.remove(), ms || 2200);
return t;
}
function topToast(msg) {
document.getElementById('ms2-top-toast')?.remove();
const t = document.createElement('div');
t.id = 'ms2-top-toast';
t.className = 'ms2-top-toast';
t.innerHTML = msg;
document.body.appendChild(t);
setTimeout(() => t?.remove(), 5000);
}
// ─── MODAL STACK — ESCAPE-TO-CLOSE ────────────────────────────────────────
const _modalStack = [];
// Register the Escape handler exactly once — adding it inside pushEscapeClose
// (the previous pattern) created a new listener on every modal open, leaking
// event handlers proportional to the number of modals opened in a session.
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' || !_modalStack.length) return;
for (let i = _modalStack.length - 1; i >= 0; i--) {
const el = _modalStack[i];
_modalStack.splice(i, 1);
if (document.body.contains(el)) {
el.remove();
e.stopPropagation();
return;
}
}
}, true);
/**
* Pushes `backdrop` onto the modal stack so the global Escape handler can
* close it. Call once per modal immediately after appending it to the DOM.
* @param {HTMLElement} backdrop - The outermost backdrop element to close.
*/
function pushEscapeClose(backdrop) {
_modalStack.push(backdrop);
}
/** Alias kept for call-site compatibility. @see pushEscapeClose */
const addEscapeClose = pushEscapeClose;
const BOT_ICON_SEL = SELECTOR_CONFIG.botIcon;
const MSG_BODY_SEL = SELECTOR_CONFIG.messageBody;
const VIRTUOSO_SEL = SELECTOR_CONFIG.virtuosoItemList;
const MIN_CHARS = 80;
let _cachedLastBotIndex = -1;
let _cachedLastBotText = '';
const FALLBACK_SELECTORS = [
'[data-message-author-role="assistant"]',
'[data-testid*="message"]:not([data-testid*="user"])',
'[class*="CharacterMessage"]',
'[class*="character-message"]',
'[class*="ai-message"]',
'[class*="bot-message"]',
'[class*="assistant-message"]',
'[data-role="assistant"]',
'.prose',
'[class*="prose"]',
];
const STRIP_SEL = [
'button,[role="button"],svg,form,input,select,textarea',
'[class*="action"],[class*="toolbar"],[class*="rating"],[class*="vote"]',
'[class*="_nameIcon_"],[class*="_name_"],[class*="nameIcon"],[class*="userName"]',
'[class*="_chatName_"],[class*="_senderName_"],[class*="_authorName_"]',
'[class*="_characterName_"],[class*="_msgSender_"],[class*="_header_"]',
'[class*="avatar"],[class*="Avatar"],[class*="CharacterName"],[class*="character-name"]',
'[class*="timestamp"],[class*="messageTime"],[class*="_time_"]',
].join(',');
/**
* Returns `true` when `node` is an AI/character message rather than a user
* message. Detection uses three independent signals in priority order so
* the check degrades gracefully when JanitorAI changes its DOM structure:
*
* 1. `data-message-author-role="assistant"` — explicit semantic attribute.
* 2. Bot-icon element present inside the node (`_nameIcon_` / `nameIcon`).
* 3. React Fiber `memoizedProps.message.role === 'assistant'` — deep source.
*
* @param {Element} node - A Virtuoso list-item or any message container.
* @returns {boolean}
*/
function isAINode(node) {
if (!node) return false;
// 1. Explicit role attribute (set by JanitorAI on some builds)
if (node.getAttribute('data-message-author-role') === 'assistant') return true;
if (node.querySelector('[data-message-author-role="assistant"]')) return true;
// 2. Bot/name icon present (most reliable visual signal)
if (node.querySelector(BOT_ICON_SEL)) return true;
// 3. React Fiber prop inspection (survives CSS class renames)
try {
const key = Object.keys(node).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
if (key) {
let f = node[key];
for (let i = 0; f && i < 16; i++, f = f.return) {
const role = f.memoizedProps?.message?.role ?? f.pendingProps?.message?.role;
if (role) return role === 'assistant';
}
}
} catch {}
return false;
}
/**
* Converts an HTML element's subtree into a cleaned Markdown string.
*
* Strips UI chrome (buttons, avatars, names, timestamps) defined in
* `STRIP_SEL` before walking the tree. Handles `em`, `strong`, `p`, `br`,
* `li`, `ul`, `ol`, `pre`, `code`, and block containers via a recursive
* walk — producing a compact but readable plain-text/Markdown hybrid
* suitable for AI prompt input.
*
* @param {Element} el - The root element to extract from.
* @returns {string} Cleaned Markdown text.
*/
function extractMarkdown(el) {
const clone = el.cloneNode(true);
clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());
function walk(node) {
if (node.nodeType === 3) return node.textContent;
if (node.nodeType !== 1) return '';
const tag = node.tagName.toLowerCase();
const inner = Array.from(node.childNodes).map(walk).join('');
const t = inner.trim();
switch (tag) {
case 'em': case 'i':
return t ? `*${t}*` : '';
case 'strong': case 'b':
return t ? `**${t}**` : '';
case 'p':
return t ? t + '\n\n' : '';
case 'br':
return '\n';
case 'li':
return t ? `- ${t}\n` : '';
case 'ul': case 'ol':
return t ? t + '\n' : '';
case 'pre':
return t ? '```\n' + t + '\n```\n\n' : '';
case 'code':
return t ? '`' + t + '`' : '';
default:
if (/^(div|article|section|blockquote|h[1-6])$/.test(tag)) {
return t ? t + '\n\n' : '';
}
return inner;
}
}
return walk(clone).replace(/\n{3,}/g, '\n\n').trim();
}
function stripNamePrefix(text) {
if (!text) return text;
const nlIdx = text.indexOf('\n');
if (nlIdx === -1) return text;
const first = text.slice(0, nlIdx).trim();
const rest = text.slice(nlIdx + 1).trim();
const firstNoInitials = first.replace(/\b[A-Z]\./g, '');
if (
first.length > 0 && first.length <= 50 &&
/^[A-Z\u00C0-\u017E]/.test(first) &&
!/[.!?,;:…"'`*]/.test(firstNoInitials) &&
rest.length >= MIN_CHARS
) return rest;
return text;
}
// ─── LATEST AI TEXT (DOM + cached fallback) ─────────────────────────────────
/**
* Extracts the text of the most recent AI/character message from the DOM.
*
* Uses a four-tier fallback strategy so it continues working through
* JanitorAI DOM refactors:
* 1. Virtuoso `[data-index]` items + `isAINode` classification.
* 2. In-memory cache (`_cachedLastBotText`) when virtuoso items scroll
* out of view.
* 3. Bot-icon (`_nameIcon_`) proximity walk to find the message body.
* 4. Known role / semantic selectors (`FALLBACK_SELECTORS`).
*
* @returns {string|null} Markdown-formatted message text, or `null` if no
* AI message with at least `MIN_CHARS` characters is found.
*/
function getLatestAIText() {
try {
const items = document.querySelectorAll(VIRTUOSO_SEL);
for (let i = items.length - 1; i >= 0; i--) {
const node = items[i];
const index = parseInt(node.getAttribute('data-index'), 10);
if (!isNaN(index) && index <= _cachedLastBotIndex) break;
if (!isAINode(node)) continue;
const bodies = node.querySelectorAll(MSG_BODY_SEL);
const text = bodies.length > 0
? Array.from(bodies).map(b => extractMarkdown(b)).join('\n\n').trim()
: extractMarkdown(node);
if (text.length >= MIN_CHARS) {
if (!isNaN(index)) {
_cachedLastBotIndex = index;
_cachedLastBotText = text;
}
return stripNamePrefix(text);
}
}
if (_cachedLastBotText) return stripNamePrefix(_cachedLastBotText);
} catch { }
// ── Fallback: virtuoso data-testid removed — find last AI message via _nameIcon_ ──
try {
const icons = document.querySelectorAll(SELECTOR_CONFIG.botIcon);
for (let i = icons.length - 1; i >= 0; i--) {
let container = icons[i].parentElement;
for (let depth = 0; depth < 10 && container && container !== document.body; depth++) {
const bodies = container.querySelectorAll(MSG_BODY_SEL);
if (bodies.length > 0) {
const text = Array.from(bodies).map(b => extractMarkdown(b)).join('\n\n').trim();
if (text.length >= MIN_CHARS) return stripNamePrefix(text);
break;
}
container = container.parentElement;
}
}
} catch { }
for (const sel of FALLBACK_SELECTORS) {
let hits;
try { hits = Array.from(document.querySelectorAll(sel)); } catch { continue; }
const valid = hits.filter(el => {
const t = (el.innerText || '').trim();
if (t.length < MIN_CHARS) return false;
if (el.querySelector('input,textarea,select,[contenteditable]')) return false;
if (el.closest('nav,header,footer,aside,form,[role="navigation"],[role="banner"],[role="toolbar"]')) return false;
return true;
});
if (!valid.length) continue;
const last = valid[valid.length - 1];
return stripNamePrefix(extractMarkdown(last));
}
const candidates = [];
document.querySelectorAll('div,article').forEach(el => {
if (!el.querySelector(':scope > p')) return;
const t = (el.innerText || '').trim();
if (t.length < MIN_CHARS) return;
if (el.querySelector('input,textarea,select,[contenteditable]')) return;
if (el.closest('nav,header,footer,aside,form,[class*="card"],[class*="Card"],[class*="sidebar"],[class*="Sidebar"],[class*="profile"],[class*="Profile"]')) return;
candidates.push(el);
});
if (!candidates.length) return null;
const leaves = candidates.filter(el => !candidates.some(o => o !== el && el.contains(o)));
if (!leaves.length) return null;
return stripNamePrefix(extractMarkdown(leaves[leaves.length - 1]));
}
/**
* Injects `text` into the JanitorAI chat input and programmatically
* submits the form.
*
* Tries multiple input selectors and three send strategies in order:
* 1. `aria-label*="send"` / `type="submit"` button click.
* 2. Synthetic `keydown Enter` event on the textarea.
* 3. Value-change heuristic to detect whether submission occurred.
*
* @param {string} text - Text to inject.
* @param {function=} onSuccess - Called after a successful send.
* @param {function=} onFail - Called when no input or send button is found.
*/
function injectAndSend(text, onSuccess, onFail) {
const inputSelectors = [
'textarea[placeholder*="message" i]',
'textarea[placeholder*="type" i]',
'textarea[placeholder*="write" i]',
'textarea[data-testid*="input"]',
'[contenteditable="true"][class*="input"]',
'[contenteditable="true"]',
'textarea',
];
let input = null;
for (const sel of inputSelectors) {
try {
const found = Array.from(document.querySelectorAll(sel)).find(
el => !el.closest('nav,header,[role="dialog"]') && el.offsetParent !== null
);
if (found) { input = found; break; }
} catch { }
}
if (!input) { onFail && onFail(); return; }
if (input.hasAttribute('contenteditable')) {
input.textContent = text;
input.dispatchEvent(new Event('input', { bubbles: true }));
} else {
const desc = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')
|| Object.getOwnPropertyDescriptor(window.HTMLElement.prototype, 'value');
if (desc && desc.set) {
desc.set.call(input, text);
} else {
input.value = text;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
input.focus();
setTimeout(() => {
const sendSelectors = [
// v5.6.4 — JanitorAI renamed send button: aria-label="button", class contains "switcher"
// Confirmed by DOM Detective 2026-06-06.
'button[aria-label="button"]:not(:disabled)',
'[class*="switcher"]:not(:disabled)',
'button[aria-label*="send" i]',
'button[aria-label*="submit" i]',
'button[data-testid*="send"]:not(:disabled)',
'button[type="submit"]:not(:disabled)',
'button[class*="send"]:not(:disabled)',
];
for (const sel of sendSelectors) {
try {
const btn = Array.from(document.querySelectorAll(sel)).find(
b => !b.closest('[role="dialog"]') && b.offsetParent !== null && !b.disabled
);
if (btn) { btn.click(); onSuccess && onSuccess(); return; }
} catch { }
}
const valBefore = input.value;
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true }));
setTimeout(() => {
const submitted = input.value === '' || input.value !== valBefore;
if (submitted) { onSuccess && onSuccess(); } else { onFail && onFail(); }
}, 200);
}, 150);
// ─── AI MESSAGE EDITOR ─────────────────────────────────────────────────────
}
/**
* Attempts to edit the last AI message in-place via JanitorAI's edit UI.
*
* Simulates the user flow: hover → click edit pencil → overwrite textarea
* → click save. Falls back via `onFail` when any step's element is absent
* (e.g. the edit button hasn't rendered yet or the selectors have changed).
*
* @param {string} newText - Replacement text to write into the edit textarea.
* @param {function=} onSuccess - Called after save button is clicked.
* @param {function=} onFail - Called when an edit element cannot be found.
*/
function replaceLatestAIMessage(newText, onSuccess, onFail) {
const allNodes = Array.from(document.querySelectorAll(VIRTUOSO_SEL));
let lastAINode = null;
for (let i = allNodes.length - 1; i >= 0; i--) {
if (isAINode(allNodes[i])) {
lastAINode = allNodes[i];
break;
}
}
if (!lastAINode) { onFail && onFail(); return; }
lastAINode.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
lastAINode.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
setTimeout(() => {
const editBtnSel = [
'button[aria-label*="edit" i]',
'button[title*="edit" i]',
'[class*="_edit_"]',
'[class*="editBtn"]',
'[class*="edit-btn"]',
].join(',');
const editBtn = lastAINode.querySelector(editBtnSel);
if (!editBtn) { onFail && onFail(); return; }
editBtn.click();
setTimeout(() => {
const ta = lastAINode.querySelector('textarea')
|| document.querySelector('[data-testid*="edit"] textarea')
|| document.querySelector('.edit-message textarea');
if (!ta) { onFail && onFail(); return; }
const desc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
if (desc && desc.set) {
desc.set.call(ta, newText);
} else {
ta.value = newText;
}
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.dispatchEvent(new Event('change', { bubbles: true }));
ta.focus();
setTimeout(() => {
const saveBtnSel = [
'button[aria-label*="save" i]',
'button[aria-label*="confirm" i]',
'button[title*="save" i]',
'[class*="_save_"]:not(:disabled)',
'[class*="saveBtn"]:not(:disabled)',
].join(',');
const saveBtn = lastAINode.querySelector(saveBtnSel)
|| document.querySelector(saveBtnSel);
if (saveBtn && !saveBtn.disabled) {
saveBtn.click();
onSuccess && onSuccess();
} else {
onFail && onFail();
}
}, 250);
}, 400);
}, 150);
}
// ─── AUTH HEADER BUILDER ────────────────────────────────────────────────────
// Supports: Bearer (standard OpenAI-compatible), raw (no prefix — LiteRouter,
// some self-hosted proxies), x-api-key (Anthropic-style proxies).
// 'auto' detects from the key shape: pure hex ≥ 32 chars → raw; everything
// else → Bearer. User can override at any time via the Auth Format selector.
/**
* Builds the HTTP `Authorization` (or `x-api-key`) header object for a
* provider API call.
*
* Mode resolution (highest → lowest priority):
* 1. `modeOverride` argument.
* 2. `CFG.authMode` setting.
* 3. `'auto'` → resolves to `'bearer'` for all keys.
*
* Anthropic native (`api.anthropic.com`) always uses `x-api-key` +
* `anthropic-version` regardless of mode — the Anthropic API rejects
* Bearer tokens.
*
* OpenRouter calls receive extra `HTTP-Referer` and `X-Title` attribution
* headers required by the platform's usage policy.
*
* @param {string} key - Raw API key string.
* @param {string} ep - Base endpoint URL (used for provider detection).
* @param {string=} modeOverride - Optional explicit auth mode.
* @returns {object} Header key-value pairs ready to spread into a fetch call.
*/
function _buildAuthHeaders(key, ep, modeOverride) {
const headers = {};
const mode = modeOverride || CFG.authMode || 'auto';
if (ep && ep.includes('anthropic.com')) {
// Anthropic native always uses x-api-key + version header
headers['x-api-key'] = key;
headers['anthropic-version'] = '2023-06-01';
return headers;
}
let resolved = mode;
if (mode === 'auto') {
// Default to Bearer for all key types — this matches OpenRouter, OpenAI, xAI,
// Mistral, Groq, LiteRouter and most other OpenAI-compatible proxies.
// Users can override via the Auth Format dropdown if their proxy needs something else.
resolved = 'bearer';
}
if (resolved === 'x-api-key') {
headers['x-api-key'] = key;
} else if (resolved === 'raw') {
headers['Authorization'] = key;
} else {
// 'bearer' — standard
headers['Authorization'] = `Bearer ${key}`;
}
if (ep && ep.includes('openrouter')) {
headers['HTTP-Referer'] = 'https://janitorai.com';
headers['X-Title'] = 'JanitorV5 RP Toolkit';
}
return headers;
// ─── API CALL ──────────────────────────────────────────────────────────────
}
/**
* Calls the configured AI provider with a system+user message pair.
*
* Handles both OpenAI-compatible endpoints (POST `/chat/completions`) and
* the Anthropic native API (POST `/v1/messages`) transparently — the
* `isAnthropic` flag switches body shape and response extraction path.
*
* @param {string} systemPrompt - Content for the system / instruction role.
* @param {string} userContent - Content for the user turn.
* @param {object} [opts] - Optional overrides:
* `max_tokens` {number}, `temperature` {number}, `signal` {AbortSignal}.
* @returns {Promise<string>} Trimmed completion text.
* @throws {Error} On HTTP errors, empty responses, or network failures.
*/
async function callAPI(systemPrompt, userContent, opts = {}) {
if (!CFG.apiKey) throw new Error('No API key set. Long-press the FAB to open Settings → General.');
const baseEp = CFG.endpoint.replace(/\/$/, '');
const isAnthropic = baseEp.includes('anthropic.com');
// ── Build headers ───────────────────────────────────────────────────────
const headers = {
'Content-Type': 'application/json',
..._buildAuthHeaders(CFG.apiKey, baseEp),
};
// ── Build endpoint & body ───────────────────────────────────────────────
// Anthropic: POST /v1/messages (system is a top-level field, not in messages array)
// Everyone else: POST /chat/completions (OpenAI-compatible)
const ep = isAnthropic
? `${baseEp}/v1/messages`
: `${baseEp}/chat/completions`;
const body = isAnthropic
? JSON.stringify({
model: CFG.model,
system: systemPrompt,
messages: [{ role: 'user', content: userContent }],
max_tokens: opts.max_tokens ?? 1400,
})
: JSON.stringify({
model: CFG.model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent },
],
max_tokens: opts.max_tokens ?? 1400,
temperature: opts.temperature ?? 0.82,
});
const res = await gmFetch(ep, { method: 'POST', headers, signal: opts.signal ?? undefined, body });
if (!res.ok) {
const errText = await res.text().catch(() => '');
let msg = `API error ${res.status}`;
try {
const errJson = JSON.parse(errText);
// Anthropic wraps errors in { error: { message } }; OpenAI does too
msg = errJson?.error?.message || errJson?.message || msg;
} catch { }
throw new Error(msg);
}
const data = await res.json();
// Anthropic: data.content[0].text | OpenAI-compat: data.choices[0].message.content
const result = isAnthropic
? (data?.content?.[0]?.text ?? '').trim()
: (data?.choices?.[0]?.message?.content ?? '').trim();
if (!result) throw new Error('API returned an empty response. Try a different model.');
return result;
}
// ─── NEW MESSAGE OBSERVER ──────────────────────────────────────────────────
let _observer = null;
let _lastSeenText = '';
function startObserver() {
if (_observer) return;
let _retries = 0;
const MAX_RETRIES = 20;
const tryStart = () => {
if (!isOnChatPage() || _retries++ >= MAX_RETRIES) return;
const container = _findRealScroller() ||
document.querySelector('[class*="_messagesMain_"]') ||
document.querySelector('[data-testid="virtuoso-item-list"]')?.parentElement?.parentElement;
if (!container) { setTimeout(tryStart, 1500); return; }
let _notifyTimer = null;
_observer = new MutationObserver(() => {
clearTimeout(_notifyTimer);
_notifyTimer = setTimeout(() => {
if (!CFG.autoNotify) return;
const text = getLatestAIText();
if (text && text !== _lastSeenText && text.length > 20) {
_lastSeenText = text;
topToast(`New message ↓ — tap ${SVG_REPLY} to reply`);
}
}, 300);
});
_observer.observe(container, { childList: true, subtree: true });
};
tryStart();
}
function stopObserver() {
if (_observer) { _observer.disconnect(); _observer = null; }
_lastSeenText = '';
// ─── AUTO-SUMMARY ENGINE ───────────────────────────────────────────────────
}
/**
* Collects all visible chat messages into a `{role, text}` array.
*
* Uses the Virtuoso item list as the primary source; falls back to scanning
* `_messagesMain_` + `_messageBody_` elements when Virtuoso's
* `data-testid` attribute has been removed by a JanitorAI update.
* Deduplicates by text content to avoid double-counting split renders.
*
* @returns {Array<{role:'ai'|'user', text:string}>}
*/
function scrapeChatMessages() {
const nodes = Array.from(document.querySelectorAll(VIRTUOSO_SEL));
const results = [];
const seen = new Set();
// ── Virtuoso path ──────────────────────────────────────────────────────
if (nodes.length > 0) {
for (const node of nodes) {
const bodyEls = node.querySelectorAll(MSG_BODY_SEL);
let text = bodyEls.length > 0
? Array.from(bodyEls).map(b => extractMarkdown(b)).join('\n\n').trim()
: '';
if (!text) {
const clone = node.cloneNode(true);
clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());
text = (clone.innerText || clone.textContent || '').trim();
}
if (!text || text.length < 6 || seen.has(text)) continue;
seen.add(text);
results.push({ role: isAINode(node) ? 'ai' : 'user', text });
}
return results;
}
// ── Fallback: virtuoso data-testid removed — scan via _messagesMain_ ──
try {
const main = document.querySelector(SELECTOR_CONFIG.messagesMain);
if (!main) return results;
// Find all _messageBody_ elements and walk up to their message container
// (the shallowest ancestor under main that holds _messageBody_ but whose
// parent does NOT also contain those same bodies)
const allBodies = Array.from(main.querySelectorAll(MSG_BODY_SEL));
const containerMap = new Map();
for (const body of allBodies) {
let el = body;
while (el.parentElement && el.parentElement !== main) {
el = el.parentElement;
}
if (!containerMap.has(el)) containerMap.set(el, []);
containerMap.get(el).push(body);
}
for (const [container, bodies] of containerMap) {
const text = bodies.map(b => extractMarkdown(b)).join('\n\n').trim()
|| (() => {
const clone = container.cloneNode(true);
clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());
return (clone.innerText || clone.textContent || '').trim();
})();
if (!text || text.length < 6 || seen.has(text)) continue;
seen.add(text);
const isAI = !!container.querySelector(SELECTOR_CONFIG.botIcon);
results.push({ role: isAI ? 'ai' : 'user', text });
}
} catch { }
return results;
}
function buildSumPrompt(msgs) {
const charLimit = 2200;
const snippet = msgs.slice(-80)
.map((m, i) => `[${i + 1}] ${m.role === 'ai' ? 'Character' : 'User'}: ${m.text.slice(0, 350)}`)
.join('\n');
const systemLines = [
'You are a roleplay session analyst. Write a structured summary using EXACTLY this format — no preamble, no extra labels, no deviation:',
'',
'[Character A] and [Character B] are in [location]. The setting is [mood/atmosphere in one short clause].',
'',
'The most important things that happened are:',
'',
'1. [Most important event — one sentence, active voice, use character names]',
'2. [Second event]',
'3. [Third event]',
'4. [Fourth event, if applicable]',
'5. [Fifth event, if applicable]',
'',
'Unresolved tension or emotional subtext: [One or two sentences about unresolved feelings, unclear dynamics, or open threads.]',
'',
'RULES:',
'- Use character names exactly as they appear in the log.',
'- 3 to 5 numbered items — never fewer than 3.',
'- Do NOT quote dialogue. Extract what happened, not what was said word for word.',
'- Do NOT use flowery or literary language — keep it clear and factual.',
'- Do NOT write anything before the first sentence or after the "Unresolved" line.',
`- Hard limit: ${charLimit} characters total.`,
];
const system = systemLines.join('\n');
const user = `CONVERSATION LOG (${msgs.length} messages, last 80 shown):\n${snippet}\n\nSUMMARY (follow the exact format above — start immediately with the characters + location sentence):`;
return { system, user, charLimit };
// ─── MEMORY BOX PROMPT BUILDER ─────────────────────────────────────────────
}
function buildMemoryBoxPrompt(msgs) {
const charLimit = 1400;
const snippet = msgs.slice(0, 120)
.map((m, i) => `[${i + 1}] ${m.role === 'ai' ? 'Character' : 'User'}: ${m.text.slice(0, 400)}`)
.join('\n');
const systemLines = [
'You are writing a persistent memory entry for a JanitorAI chat memory box. This is NOT a scene summary — it is a stable reference note about who the characters are and what defines their relationship. A player will paste this into JanitorAI\'s built-in "Chat Memory" panel so the AI always remembers context between sessions.',
'',
'Write using EXACTLY this format — no preamble, no deviation:',
'',
'Characters: [Character A] and [Character B]. [1–2 sentences: who they each are — name, personality, their role in the story.]',
'',
'Relationship: [1–2 sentences: how they know each other, the nature of their bond, any key dynamic or tension between them.]',
'',
'Key events:',
'1. [Most significant event that shaped the relationship — one sentence, active voice]',
'2. [Second defining event]',
'3. [Third defining event]',
'4. [Fourth event, if applicable]',
'5. [Fifth event, if applicable]',
'',
'Ongoing: [1–2 sentences: what is unresolved, what drives the story forward, emotional undercurrents.]',
'',
'RULES:',
'- Use character names exactly as they appear in the log.',
'- 3 to 5 numbered key events — never fewer than 3.',
'- Write in present tense where natural ("They share a complicated past…").',
'- Do NOT quote dialogue verbatim. Describe what happened.',
'- Do NOT describe the current scene or what just happened — focus on lasting facts.',
'- Do NOT use flowery language — clear, factual, compact.',
'- Do NOT write anything outside the four sections above.',
`- Hard limit: ${charLimit} characters total.`,
];
const system = systemLines.join('\n');
const user = `CONVERSATION LOG (${msgs.length} messages total, up to 120 shown):\n${snippet}\n\nMEMORY ENTRY (follow the exact four-section format — start immediately with "Characters:"):`;
return { system, user, charLimit };
// ─── LOAD ALL — STANDALONE ─────────────────────────────────────────────────
}
/**
* Scrolls the chat viewport from top to bottom in steps, harvesting all
* Virtuoso-rendered message nodes into `_loadedMap` along the way.
*
* Uses a stuck-round counter (3 consecutive passes with zero new messages)
* as the termination signal rather than a fixed scroll target — this handles
* chats of any length without over-scrolling.
*
* @param {function=} onProgress - Optional `(loaded, steps) => void` callback
* called after each harvest step.
* @returns {Promise<number>} Total messages accumulated, or `-1` if the
* scroll container could not be found.
*/
/** Returns a stable CSS selector string for a DOM element (used for diagnostics). */
function _buildScrollerSel(el) {
if (!el) return '';
if (el.id) return '#' + el.id;
const tid = el.getAttribute('data-testid');
if (tid) return `[data-testid="${tid}"]`;
const modCls = Array.from(el.classList).find(c => /^_[A-Za-z]/.test(c));
if (modCls) { const m = modCls.match(/^_([A-Za-z][A-Za-z0-9]*)_/); if (m) return `[class*="${m[1]}"]`; }
const plain = Array.from(el.classList).filter(c => c.length > 3 && !/^\d/.test(c)).sort((a,b) => b.length-a.length)[0];
if (plain) return `[class*="${plain}"]`;
return el.tagName.toLowerCase();
}
/**
* Finds the real scrollable container for the chat message list.
* JanitorAI sometimes wraps _messagesMain_ in a parent that does the
* actual overflow scrolling — if _messagesMain_ has scrollHeight === clientHeight
* (ratio 1.0) it is NOT the real scroller; walk up the DOM to find it.
*/
function _findRealScroller() {
// Candidates in priority order
const candidates = [
document.querySelector('[data-testid="virtuoso-scroller"]'),
document.querySelector('[class*="_messagesMain_"]'),
document.querySelector('[class*="messagesMain"]'),
document.querySelector('[data-testid="virtuoso-item-list"]')?.parentElement?.parentElement,
].filter(Boolean);
// First try: find one that actually has overflow to scroll
for (const el of candidates) {
if (el.scrollHeight > el.clientHeight + 10) return el;
}
// Walk up from _messagesMain_ to find the real scrollable ancestor
const base = candidates[0] || candidates[1];
if (base) {
let el = base.parentElement;
while (el && el !== document.body) {
const style = getComputedStyle(el);
const hasScroll = ['auto', 'scroll', 'overlay'].includes(style.overflowY);
if (hasScroll && el.scrollHeight > el.clientHeight + 10) return el;
el = el.parentElement;
}
}
// Last resort: any scrollable element containing [data-index]
for (const el of document.querySelectorAll('div')) {
const style = getComputedStyle(el);
if (!['auto', 'scroll', 'overlay'].includes(style.overflowY)) continue;
if (el.scrollHeight <= el.clientHeight + 10) continue;
if (el.offsetHeight < 100) continue;
if (el.querySelector('[data-index]')) return el;
}
return null;
}
async function doLoadAll(onProgress) {
const scroller = _findRealScroller();
if (!scroller) return -1;
clearAccumulated();
const wait = ms => new Promise(r => setTimeout(r, ms));
const harvest = () => {
let added = 0;
const seen = new Set();
// ── Virtuoso path ──────────────────────────────────────────────────
const nodes = document.querySelectorAll(VIRTUOSO_SEL);
if (nodes.length > 0) {
for (const node of nodes) {
const bodyEls = node.querySelectorAll(MSG_BODY_SEL);
let text = bodyEls.length > 0
? Array.from(bodyEls).map(b => extractMarkdown(b)).join('\n\n').trim()
: '';
if (!text) {
const clone = node.cloneNode(true);
clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());
text = (clone.innerText || clone.textContent || '').trim();
}
if (!text || text.length < 6) continue;
const key = _hashStr(text.slice(0, 120));
if (seen.has(key) || _loadedMap.has(key)) continue;
seen.add(key);
_loadedMap.set(key, { role: isAINode(node) ? 'ai' : 'user', text });
_loadedOrder.push(key);
added++;
}
return added;
}
// ── Fallback: virtuoso data-testid removed — scan via _messagesMain_ ──
try {
const main = document.querySelector(SELECTOR_CONFIG.messagesMain);
if (!main) return added;
const allBodies = Array.from(main.querySelectorAll(MSG_BODY_SEL));
const containerMap = new Map();
for (const body of allBodies) {
let el = body;
while (el.parentElement && el.parentElement !== main) el = el.parentElement;
if (!containerMap.has(el)) containerMap.set(el, []);
containerMap.get(el).push(body);
}
for (const [container, bodies] of containerMap) {
let text = bodies.map(b => extractMarkdown(b)).join('\n\n').trim();
if (!text) {
const clone = container.cloneNode(true);
clone.querySelectorAll(STRIP_SEL).forEach(n => n.remove());
text = (clone.innerText || clone.textContent || '').trim();
}
if (!text || text.length < 6) continue;
const key = _hashStr(text.slice(0, 120));
if (seen.has(key) || _loadedMap.has(key)) continue;
seen.add(key);
const isAI = !!container.querySelector(SELECTOR_CONFIG.botIcon);
_loadedMap.set(key, { role: isAI ? 'ai' : 'user', text });
_loadedOrder.push(key);
added++;
}
} catch { }
return added;
};
scroller.scrollTop = 0;
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
await wait(1800);
harvest();
onProgress?.(_loadedMap.size, 0);
const PAGE = Math.max(scroller.clientHeight * 0.75, 200);
const MAX_STEPS = 80;
let steps = 0;
let stuckRounds = 0;
while (steps < MAX_STEPS) {
const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 20;
scroller.scrollTop += PAGE;
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
await wait(700);
const added = harvest();
steps++;
onProgress?.(_loadedMap.size, steps);
if (added === 0) {
stuckRounds++;
if (stuckRounds >= 3 || atBottom) break;
} else {
stuckRounds = 0;
}
}
return _loadedMap.size;
}
let _sumRunning = false;
/**
* Generates a structured scene-context summary from currently visible
* messages and saves it to the per-chat Scene Context field.
*
* Runs `buildSumPrompt` → `callAPI` → truncates to `charLimit` →
* persists via `saveContext` + `addSumHistory`. The UI textarea is
* updated live if the settings modal is open.
*
* Guards against concurrent runs with `_sumRunning`.
*
* @param {object} [opts]
* @param {boolean} [opts.silent=false] - Suppress toast notifications when
* called from the auto-summary observer.
* @returns {Promise<void>}
*/
async function doGenerateSummary({ silent = false } = {}) {
if (_sumRunning) return;
const msgs = scrapeChatMessages();
if (msgs.length < 1) {
if (!silent) toast(`${SVG_WARNING} No messages visible — scroll to the area you want to summarise.`);
return;
}
_sumRunning = true;
const genBtn = document.querySelector('#ms2-ctx-gen-btn');
if (genBtn) { genBtn.disabled = true; genBtn.textContent = '…'; }
try {
const { system, user, charLimit } = buildSumPrompt(msgs);
const raw = await callAPI(system, user, { max_tokens: 600, temperature: 0.35 });
const summary = raw.trim().slice(0, charLimit);
saveContext(summary);
addSumHistory(summary);
const ta = document.querySelector('#ms2-s-context');
if (ta) {
ta.value = summary;
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
renderSumHistory();
if (!silent) toast(`${SVG_CHECK} Scene Context updated from ${msgs.length} visible messages`);
} catch (err) {
if (!silent) toast(`${SVG_WARNING} Summary failed: ${escHtml(err.message)}`, 3500);
} finally {
_sumRunning = false;
if (genBtn) { genBtn.disabled = false; genBtn.innerHTML = `${SVG_SPARKLE} Generate`; }
}
}
let _sumHistShowAll = false;
function renderSumHistory() {
const list = document.querySelector('#ms2-ctx-hist-list');
if (!list) return;
const allHist = getSumHistory();
const curKey = ctxKey();
const curHist = allHist.filter(h => h.conv === curKey);
const toggleId = 'ms2-sumhist-toggle';
let toggle = list.previousElementSibling?.id === toggleId
? list.previousElementSibling
: null;
if (!toggle) {
toggle = document.createElement('div');
toggle.id = toggleId;
toggle.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:6px;';
list.parentNode?.insertBefore(toggle, list);
}
const showAllCount = allHist.length;
const showCurCount = curHist.length;
toggle.innerHTML = `
<span style="font-size:11px;color:#6b7280;flex:1;">
${_sumHistShowAll
? `All chats · <strong style="color:#a78bfa;">${showAllCount}</strong> entr${showAllCount !== 1 ? 'ies' : 'y'}`
: `This chat · <strong style="color:#10b981;">${showCurCount}</strong> entr${showCurCount !== 1 ? 'ies' : 'y'}`}
</span>
<button id="ms2-sumhist-toggle-btn" style="font-size:10px;background:none;border:1px solid #374151;border-radius:4px;color:#9ca3af;padding:2px 7px;cursor:pointer;white-space:nowrap;">
${_sumHistShowAll ? 'This chat only' : `All chats (${showAllCount})`}
</button>`;
toggle.querySelector('#ms2-sumhist-toggle-btn')?.addEventListener('click', () => {
_sumHistShowAll = !_sumHistShowAll;
renderSumHistory();
});
const hist = _sumHistShowAll ? allHist : curHist;
const idxMap = _sumHistShowAll
? allHist.slice(0, 20).map((_, i) => i)
: allHist.reduce((acc, item, i) => { if (item.conv === curKey) acc.push(i); return acc; }, []);
if (!hist.length) {
list.innerHTML = _sumHistShowAll
? '<div style="color:#4b5563;font-size:11px;padding:6px 0;">No history yet — generate to save one.</div>'
: '<div style="color:#4b5563;font-size:11px;padding:6px 0;">No summaries for this chat yet.<br>Switch to "All chats" to see summaries from other characters.</div>';
return;
}
list.innerHTML = hist.slice(0, 10).map((item, localI) => {
const realIdx = idxMap[localI] ?? localI;
const isOtherChat = item.conv !== curKey;
const otherName = isOtherChat
? (getChatName(item.conv) || item.chatName || 'other chat')
: '';
const otherBadge = isOtherChat
? `<span style="font-size:9px;background:#1e3a5f;color:#7dd3fc;border-radius:3px;padding:1px 5px;margin-left:2px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:middle;" title="From: ${escHtml(otherName)}">${escHtml(otherName)}</span>`
: '';
return `<div class="ms2-sum-hist-item" data-idx="${realIdx}" style="margin-bottom:6px;background:${isOtherChat ? '#1a2332' : '#1e293b'};border-radius:6px;padding:6px 8px;${isOtherChat ? 'border-left:2px solid #1e3a5f;' : ''}">
<div style="display:flex;align-items:center;gap:4px;margin-bottom:2px;">
<span style="color:#6b7280;font-size:10px;flex:1;">${escHtml(item.date)}${otherBadge}</span>
<button class="ms2-hist-edit-btn ap-icon-btn" data-idx="${realIdx}" title="Edit this entry" style="font-size:12px;padding:1px 4px;">✎</button>
<button class="ms2-hist-del-btn ap-icon-btn" data-idx="${realIdx}" title="Delete this entry" style="font-size:12px;padding:1px 4px;color:#fca5a5;">${SVG_TRASH}</button>
</div>
<div class="ms2-sum-hist-body" data-idx="${realIdx}" style="color:#94a3b8;font-size:11.5px;line-height:1.4;cursor:pointer;" title="Click to load into context field">
${escHtml(item.text.slice(0, 120))}${item.text.length > 120 ? '…' : ''}
</div>
</div>`;
}).join('');
list.querySelectorAll('.ms2-sum-hist-body').forEach(el => {
el.addEventListener('click', () => {
const item = getSumHistory()[+el.dataset.idx];
if (!item) return;
const ta = document.querySelector('#ms2-s-context');
if (ta) { ta.value = item.text; ta.dispatchEvent(new Event('input', { bubbles: true })); }
toast('↩ History entry loaded into context field');
});
});
list.querySelectorAll('.ms2-hist-edit-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const idx = +btn.dataset.idx;
const row = btn.closest('.ms2-sum-hist-item');
if (row.querySelector('.ms2-hist-edit-wrap')) return;
const item = getSumHistory()[idx];
if (!item) return;
const wrap = document.createElement('div');
wrap.className = 'ms2-hist-edit-wrap';
wrap.style.cssText = 'margin-top:6px;';
const ta = document.createElement('textarea');
ta.value = item.text;
ta.style.cssText = 'width:100%;box-sizing:border-box;min-height:64px;max-height:140px;font-size:11px;background:#0f172a;color:#e2e8f0;border:1px solid #475569;border-radius:4px;padding:5px;resize:vertical;overflow-y:auto;';
ta.addEventListener('wheel', e => e.stopPropagation(), { passive: true });
ta.addEventListener('touchmove', e => e.stopPropagation(), { passive: true });
const actRow = document.createElement('div');
actRow.style.cssText = 'display:flex;gap:4px;margin-top:4px;';
const saveBtn = document.createElement('button');
saveBtn.innerHTML = `${SVG_CHECK} Save`;
saveBtn.className = 'ap-icon-btn';
saveBtn.style.cssText = 'color:#86efac;font-size:12px;padding:3px 8px;';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.className = 'ap-icon-btn';
cancelBtn.style.cssText = 'font-size:12px;padding:3px 8px;';
actRow.append(saveBtn, cancelBtn);
wrap.append(ta, actRow);
row.appendChild(wrap);
ta.focus();
saveBtn.addEventListener('click', () => {
const newText = ta.value.trim();
if (!newText) { ta.focus(); return; }
const h = getSumHistory();
if (!h[idx]) return;
h[idx].text = newText;
saveSumHistory(h);
renderSumHistory();
toast(`${SVG_CHECK} Summary entry updated`);
});
cancelBtn.addEventListener('click', () => wrap.remove());
});
});
list.querySelectorAll('.ms2-hist-del-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const idx = +btn.dataset.idx;
const row = btn.closest('.ms2-sum-hist-item');
const existing = row.querySelector('.ms2-hist-confirm');
if (existing) { existing.remove(); return; }
const bar = document.createElement('div');
bar.className = 'ms2-hist-confirm';
bar.style.cssText = 'display:flex;gap:6px;align-items:center;margin-top:6px;font-size:11px;';
const label = document.createElement('span');
label.textContent = 'Delete this entry?';
label.style.cssText = 'flex:1;color:#f87171;';
const yesBtn = document.createElement('button');
yesBtn.innerHTML = `${SVG_CHECK} Yes`;
yesBtn.className = 'ap-icon-btn';
yesBtn.style.color = '#f87171';
const noBtn = document.createElement('button');
noBtn.textContent = 'No';
noBtn.className = 'ap-icon-btn';
bar.append(label, yesBtn, noBtn);
row.appendChild(bar);
yesBtn.addEventListener('click', () => {
const h = getSumHistory();
h.splice(idx, 1);
saveSumHistory(h);
renderSumHistory();
toast('Entry deleted');
});
noBtn.addEventListener('click', () => bar.remove());
});
});
}
let _sumObserver = null;
let _sumLastCount = 0;
let _sumTimer = null;
/**
* Starts a MutationObserver that fires `doGenerateSummary` (or a nudge
* toast) each time the Virtuoso message count crosses a multiple of
* `getAutoSumEvery()`.
*
* No-ops when the setting is 0 or when the observer is already running.
* Retries up to 20 times (1.5 s apart) while waiting for the chat container
* to appear in the DOM.
*/
function startAutoSumObserver() {
if (_sumObserver) return;
const every = getAutoSumEvery();
if (!every || every <= 0) return;
let _retries = 0;
const tryStart = () => {
if (!isOnChatPage() || _retries++ >= 20) return;
const container = _findRealScroller() ||
document.querySelector('[class*="_messagesMain_"]') ||
document.querySelector('[data-testid="virtuoso-item-list"]')?.parentElement?.parentElement;
if (!container) { setTimeout(tryStart, 1500); return; }
_sumLastCount = _countVirtuosoItems();
_sumObserver = new MutationObserver((mutations) => {
clearTimeout(_sumTimer);
_sumTimer = setTimeout(() => {
let maxSeen = _sumLastCount;
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
const idx = parseInt(node.getAttribute?.('data-index'), 10);
if (!isNaN(idx) && idx > maxSeen) maxSeen = idx;
if (node.querySelectorAll) {
node.querySelectorAll('[data-index]').forEach(el => {
const i = parseInt(el.getAttribute('data-index'), 10);
if (!isNaN(i) && i > maxSeen) maxSeen = i;
});
}
}
}
if (maxSeen <= _sumLastCount) return;
const crossed = maxSeen >= every &&
Math.floor(maxSeen / every) > Math.floor(_sumLastCount / every);
_sumLastCount = maxSeen;
if (!crossed) return;
if (getAutoSumAuto()) {
doGenerateSummary({ silent: true }).then(() =>
topToast(`Context auto-updated from chat ${SVG_CHECK}`)
);
} else {
topToast(`Context ready to refresh — ${maxSeen + 1} messages in chat`);
}
}, 900);
});
_sumObserver.observe(container, { childList: true, subtree: true });
};
tryStart();
}
function _countVirtuosoItems() {
let max = 0;
document.querySelectorAll('[data-testid="virtuoso-item-list"] > div[data-index]')
.forEach(n => {
const v = parseInt(n.getAttribute('data-index'), 10);
if (!isNaN(v) && v > max) max = v;
});
return max;
}
/**
* Disconnects the auto-summary MutationObserver and cancels the debounce
* timer. Safe to call when the observer is already stopped.
*/
function stopAutoSumObserver() {
if (_sumObserver) { _sumObserver.disconnect(); _sumObserver = null; }
clearTimeout(_sumTimer);
}
const _loadedMap = new Map();
const _loadedOrder = [];
function _accumMsgs(msgs) {
let added = 0;
for (const msg of msgs) {
const key = _hashStr(msg.text.slice(0, 120));
if (!_loadedMap.has(key)) {
_loadedMap.set(key, msg);
_loadedOrder.push(key);
added++;
}
}
return added;
}
function _getAccumulated() {
return _loadedOrder.map(k => _loadedMap.get(k));
}
function clearAccumulated() {
_loadedMap.clear();
_loadedOrder.length = 0;
}
// ─── SPEED DIAL ────────────────────────────────────────────────────────────
let _dialOpen = false;
function toggleDial() {
if (_dialOpen) { closeDial(); return; }
_dialOpen = true;
const fab = document.getElementById('ms2-fab');
if (!fab) return;
const right = parseInt(fab.style.right, 10) || CFG.fabRight;
const bottom = parseInt(fab.style.bottom, 10) || CFG.fabBottom;
const overlay = document.createElement('div');
overlay.id = 'ms2-dial-overlay';
overlay.addEventListener('click', () => closeDial());
const items = [
{ icon: SVG_SCISSORS, label: 'Shorten', color: '#8b5cf6', action: () => { closeDial(); handleShorten(); } },
{ icon: SVG_REPLY, label: 'Smart Reply', color: '#22d3ee', action: () => { closeDial(); handleReply(); } },
{ icon: SVG_STYLES, label: 'Styles', color: '#f59e0b', action: () => { closeDial(); handleStyles(); } },
{ icon: SVG_SUMMARISE, label: 'Summarise', color: '#10b981', action: () => { closeDial(); handleSummarize(); } },
{ icon: SVG_PERSONA, label: 'Personas', color: '#f472b6', action: () => handlePersonasQuickSwitch(overlay, right, bottom) },
{ icon: SVG_CHAT, label: 'Community', color: '#06b6d4', action: () => { closeDial(); handleCommunityChat(); } },
];
const panel = document.createElement('div');
panel.className = 'ms2-dial-panel';
panel.style.cssText = `right:${right + 56}px;bottom:${bottom}px;animation:ms2-dial-in 0.18s cubic-bezier(0.16,1,0.3,1) both;`;
panel.addEventListener('click', e => e.stopPropagation());
items.forEach(item => {
const row = document.createElement('button');
row.className = 'ms2-dial-row';
row.innerHTML = `<span class="ms2-dial-row-icon" style="color:${item.color};">${item.icon}</span><span class="ms2-dial-row-label">${item.label}</span>`;
row.addEventListener('click', () => item.action());
panel.appendChild(row);
});
overlay.appendChild(panel);
document.body.appendChild(overlay);
fab.classList.add('ms2-dial-open');
}
function closeDial() {
_dialOpen = false;
document.getElementById('ms2-dial-overlay')?.remove();
document.getElementById('ms2-fab')?.classList.remove('ms2-dial-open');
// ─── ACTION HANDLERS ───────────────────────────────────────────────────────
}
function handleShorten() {
if (!isOnChatPage()) { toast('Navigate to a chat first.'); return; }
const text = getLatestAIText();
text ? openShortenModal(text) : openNoTextModal();
}
function handleReply() {
if (!isOnChatPage()) { toast('Navigate to a chat first.'); return; }
const text = getLatestAIText();
text ? openReplyModal(text) : openNoTextModal();
}
function handleStyles() {
openSettingsModal('styles');
}
function handlePersonasQuickSwitch(overlay, fabRight, fabBottom) {
const existing = document.getElementById('ms2-persona-popup');
if (existing) { existing.remove(); return; }
const personas = getPersonaLib();
const popupW = 248;
const gap = 12;
const rightEdge = fabRight + 44 + gap;
const safeRight = Math.min(rightEdge, window.innerWidth - popupW - 8);
const popup = document.createElement('div');
popup.id = 'ms2-persona-popup';
popup.style.cssText =
`position:fixed;z-index:999999;right:${safeRight}px;bottom:${fabBottom - 8}px;` +
`width:${popupW}px;max-height:310px;display:flex;flex-direction:column;` +
`background:#1a1625;border:1px solid rgba(244,114,182,0.45);border-radius:11px;` +
`box-shadow:0 6px 28px rgba(0,0,0,0.65);overflow:hidden;animation:ms2-up 0.16s ease;`;
popup.innerHTML = `
<div style="padding:8px 10px 6px;border-bottom:1px solid rgba(255,255,255,0.07);flex-shrink:0;">
<div style="display:flex;align-items:center;gap:5px;font-size:10px;color:#f9a8d4;text-transform:uppercase;letter-spacing:.9px;font-weight:700;margin-bottom:6px;">${SVG_PERSONA} Persona Quick-Switch</div>
<input id="ms2-pp-filter" type="text" placeholder="${personas.length > 0 ? 'Search personas…' : 'No personas yet'}"
style="width:100%;box-sizing:border-box;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);
border-radius:5px;color:#e2e8f0;font-size:12px;padding:5px 8px;outline:none;font-family:system-ui,sans-serif;"
${personas.length === 0 ? 'disabled' : ''}>
</div>
<div id="ms2-pp-list" style="overflow-y:auto;flex:1;padding:4px;"></div>
<div style="padding:5px 8px;border-top:1px solid rgba(255,255,255,0.06);flex-shrink:0;text-align:center;">
<span style="font-size:10px;color:#374151;">Long-press FAB → Context tab to manage</span>
</div>`;
popup.addEventListener('click', e => e.stopPropagation());
overlay.appendChild(popup);
const filterIn = popup.querySelector('#ms2-pp-filter');
const listEl = popup.querySelector('#ms2-pp-list');
function renderList(q = '') {
listEl.innerHTML = '';
if (!personas.length) {
listEl.innerHTML =
'<div style="font-size:11px;color:#4b5563;padding:10px 8px;text-align:center;line-height:1.5;">' +
'No personas saved yet.<br>Add them in <strong style="color:#9ca3af;">Settings → Context</strong>.</div>';
return;
}
const hits = q
? personas.filter(p => (p.name + p.desc).toLowerCase().includes(q.toLowerCase()))
: personas;
if (!hits.length) {
listEl.innerHTML = '<div style="font-size:11px;color:#4b5563;padding:8px;text-align:center;">No matches.</div>';
return;
}
listEl.innerHTML = hits.map(p =>
`<div class="ms2-pp-row" data-pid="${escHtml(p.id)}">` +
`<span style="flex:1;font-size:12px;color:#f9a8d4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="${escHtml(p.name)}">${escHtml(p.name)}</span>` +
`<button class="pp-use" data-pid="${escHtml(p.id)}" style="background:rgba(16,185,129,0.15);border:1px solid rgba(16,185,129,0.35);border-radius:4px;color:#6ee7b7;font-size:10px;cursor:pointer;padding:3px 9px;flex-shrink:0;font-family:system-ui,sans-serif;">Use</button>` +
`</div>`
).join('');
}
listEl.addEventListener('click', e => {
const btn = e.target.closest('.pp-use');
if (!btn) return;
e.stopPropagation();
const pid = btn.dataset.pid;
const p = personas.find(x => x.id === pid);
if (!p) return;
saveContext(p.desc);
closeDial();
toast(`${SVG_PERSONA} ${escHtml(p.name)} loaded`);
});
let _ppFilterTimer = null;
filterIn?.addEventListener('input', () => {
clearTimeout(_ppFilterTimer);
_ppFilterTimer = setTimeout(() => renderList(filterIn.value), 120);
});
filterIn?.addEventListener('keydown', e => { if (e.key === 'Escape') closeDial(); });
renderList();
setTimeout(() => filterIn?.focus(), 60);
}
function _showFabSumWarning(newMsgCount, timeAgo) {
document.getElementById('ms2-fabsum-warn-backdrop')?.remove();
const backdrop = document.createElement('div');
backdrop.className = 'ms2-backdrop';
backdrop.id = 'ms2-fabsum-warn-backdrop';
setTimeout(() => backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); }), 300);
addEscapeClose(backdrop);
const clampedNew = Math.max(0, newMsgCount);
const modal = document.createElement('div');
modal.className = 'ms2-modal';
modal.style.maxWidth = '420px';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.innerHTML = `
<div class="ms2-modal-header">
<div class="ms2-modal-title">${SVG_WARNING} Too soon to re-summarise</div>
<button class="ms2-modal-close">×</button>
</div>
<div class="ms2-modal-body">
<div class="ms2-tip" style="border-color:rgba(251,191,36,0.5);color:#fcd34d;margin-bottom:12px;">
${SVG_SUMMARISE} Last summary was <strong>${timeAgo}</strong> — only
<strong>${clampedNew} new message${clampedNew !== 1 ? 's' : ''}</strong> since then.
</div>
<p style="margin:0 0 10px;font-size:13px;color:#d1d5db;line-height:1.6;">
Summaries work best after <strong style="color:#c4b5fd;">${FAB_SUM_MIN_NEW_MSGS}+ new messages</strong>.
Running one too soon produces a nearly identical result and wastes your API quota.
</p>
<p style="margin:0;font-size:12px;color:#6b7280;line-height:1.5;">
Keep chatting and come back when more has happened —
or run it now if you genuinely need a fresh copy.
</p>
</div>
<div class="ms2-modal-footer" style="gap:8px;">
<button class="ms2-btn-action ms2-btn-copy" id="ms2-fabwarn-close-btn">Not yet — keep chatting</button>
<button class="ms2-btn-action ms2-btn-retry" id="ms2-fabwarn-force-btn">${SVG_SUMMARISE} Run anyway</button>
</div>`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
modal.querySelector('.ms2-modal-close').addEventListener('click', () => backdrop.remove());
modal.querySelector('#ms2-fabwarn-close-btn').addEventListener('click', () => backdrop.remove());
modal.querySelector('#ms2-fabwarn-force-btn').addEventListener('click', () => {
backdrop.remove();
doFABSummarize();
});
}
function handleSummarize() {
if (!isOnChatPage()) { toast('Navigate to a chat first.'); return; }
const last = getFabSumLast();
if (last) {
const currentIdx = _countVirtuosoItems();
const delta = currentIdx - last.domIndex;
if (delta >= 0 && delta < FAB_SUM_MIN_NEW_MSGS) {
const mins = Math.round((Date.now() - last.ts) / 60000);
const timeAgo = mins < 1 ? 'just now' : `${mins} minute${mins !== 1 ? 's' : ''} ago`;
_showFabSumWarning(delta, timeAgo);
return;
}
}
doFABSummarize();
}
// ─── FAB SUMMARISE — FULL HISTORY → CLIPBOARD ──────────────────────────────
let _fabSumRunning = false;
/**
* Full-history memory summary triggered from the FAB speed-dial.
*
* Runs `doLoadAll` (auto-scrolls the entire chat), then calls
* `buildMemoryBoxPrompt` → `callAPI` → copies result to clipboard
* and shows a preview modal. The result intentionally does NOT overwrite
* Scene Context — it is designed for JanitorAI's Chat Memory panel.
*
* Guards against concurrent runs with `_fabSumRunning`.
*
* @returns {Promise<void>}
*/
async function doFABSummarize() {
if (_fabSumRunning) return;
_fabSumRunning = true;
const progressToast = toast(`${SVG_ARROW_UP} Loading full chat history…`, 120000);
try {
const result = await doLoadAll((loaded) => {
if (progressToast.isConnected) progressToast.innerHTML = `${SVG_ARROW_UP} Loading… ${loaded} msgs`;
});
progressToast?.remove?.();
if (result === -1) {
toast(`${SVG_WARNING} Could not find chat scroll area — are you in an active chat?`, 4000);
_fabSumRunning = false;
return;
}
} catch (e) {
progressToast?.remove?.();
toast(`${SVG_WARNING} Load failed: ${escHtml(e.message)}`, 4000);
_fabSumRunning = false;
return;
}
_accumMsgs(scrapeChatMessages());
const msgs = _getAccumulated();
if (msgs.length < 1) {
toast(`${SVG_WARNING} No messages found in this chat.`, 3500);
_fabSumRunning = false;
return;
}
const genToast = toast(`${SVG_SUMMARISE} Writing memory entry from ${msgs.length} messages…`, 120000);
try {
const { system, user, charLimit } = buildMemoryBoxPrompt(msgs);
const raw = await callAPI(system, user, { temperature: 0.3, max_tokens: 700 });
const summary = raw.trim().slice(0, charLimit);
genToast?.remove?.();
navigator.clipboard.writeText(summary).catch(() => {});
setFabSumLast(_countVirtuosoItems());
const backdrop = document.createElement('div');
backdrop.className = 'ms2-backdrop';
backdrop.id = 'ms2-fabsum-backdrop';
setTimeout(() => backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); }), 350);
addEscapeClose(backdrop);
const modal = document.createElement('div');
modal.className = 'ms2-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.innerHTML = `
<div class="ms2-modal-header">
<div class="ms2-modal-title" style="display:flex;align-items:center;gap:6px;">
${SVG_SUMMARISE} Memory Summary
<button id="ms2-fabsum-help-btn" title="How is this different from Context Generate?" style="background:none;border:1.5px solid #6b7280;border-radius:50%;width:18px;height:18px;color:#9ca3af;font-size:10px;font-weight:700;cursor:pointer;line-height:1;padding:0;flex-shrink:0;">?</button>
</div>
<button class="ms2-modal-close">×</button>
</div>
<div class="ms2-modal-body">
<div class="ms2-tip" style="margin-bottom:10px;border-color:rgba(16,185,129,0.4);color:#6ee7b7;">
${SVG_CHECK} <strong>Copied to clipboard!</strong> Built from <strong>${msgs.length} messages</strong> (full history).<br>
You can paste this anywhere you store long-term memory — e.g. JanitorAI's Chat Memory panel (≡ → Chat Memory).
</div>
<div class="ms2-label">Memory Entry</div>
<div class="ms2-textbox result" style="white-space:pre-wrap;font-size:12px;">${escHtml(summary)}</div>
<div class="ms2-tip" style="margin-top:8px;font-size:11px;">
${SVG_TIP} <strong>Not saved to Scene Context — by design.</strong><br>
• <strong>Summarise</strong> = full story arc, who the characters are, relationship backstory<br>
• <strong>Context Generate</strong> = current-situation note injected into every reply
</div>
</div>
<div class="ms2-modal-footer">
<button class="ms2-btn-action ms2-btn-copy" id="ms2-fabsum-copy-btn">${SVG_COPY} Copy again</button>
<button class="ms2-btn-action ms2-btn-retry" id="ms2-fabsum-close-btn">Close</button>
</div>`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
modal.querySelector('.ms2-modal-close').addEventListener('click', () => backdrop.remove());
modal.querySelector('#ms2-fabsum-close-btn').addEventListener('click', () => backdrop.remove());
modal.querySelector('#ms2-fabsum-copy-btn').addEventListener('click', () => {
navigator.clipboard.writeText(summary).then(() => {
const b = modal.querySelector('#ms2-fabsum-copy-btn');
b.innerHTML = `${SVG_CHECK} Copied!`;
setTimeout(() => { if (b.isConnected) b.innerHTML = `${SVG_COPY} Copy again`; }, 1800);
});
});
modal.querySelector('#ms2-fabsum-help-btn')?.addEventListener('click', () => {
document.getElementById('fabsum-help-backdrop')?.remove();
const hB = document.createElement('div');
hB.className = 'ms2-backdrop'; hB.id = 'fabsum-help-backdrop'; hB.style.zIndex = '10000020';
const hM = document.createElement('div');
hM.className = 'ms2-modal'; hM.style.maxWidth = '480px';
hM.innerHTML = `
<div class="ms2-modal-header">
<div class="ms2-modal-title">${SVG_SUMMARISE} Summarise vs Context Generate</div>
<button class="ms2-modal-close" id="fsh-close">×</button>
</div>
<div class="ms2-modal-body" style="line-height:1.7;font-size:13px;color:#d1d5db;">
<p style="margin:0 0 10px;"><strong style="color:#10b981;">FAB → Summarise (this button)</strong><br>
Reads your <strong>entire chat history</strong> automatically (runs Load All for you). Produces a persistent summary: who the characters are, their relationship arc, and the major story events. Output goes to <strong>clipboard</strong> — paste it wherever you store long-term context (e.g. JanitorAI's Chat Memory panel).</p>
<p style="margin:0 0 10px;"><strong style="color:#a78bfa;">Context tab → Generate</strong><br>
Reads only <strong>currently visible messages</strong>. Produces a current-situation note: where you are, what just happened. Saves to <strong>Scene Context</strong> and gets injected into every JanitorAI reply (when "Send to JanitorAI's AI" is ON).</p>
<table style="width:100%;border-collapse:collapse;font-size:12px;margin:0 0 10px;">
<tr style="color:#6b7280;border-bottom:1px solid rgba(255,255,255,0.07);">
<th style="text-align:left;padding:4px 8px 4px 0;"></th>
<th style="text-align:left;padding:4px 8px;color:#10b981;">Summarise</th>
<th style="text-align:left;padding:4px 8px;color:#a78bfa;">Context Generate</th>
</tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.04);">
<td style="padding:5px 8px 5px 0;color:#9ca3af;">Source</td>
<td style="padding:5px 8px;">Full history (auto Load All)</td>
<td style="padding:5px 8px;">Visible messages only</td>
</tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.04);">
<td style="padding:5px 8px 5px 0;color:#9ca3af;">Output</td>
<td style="padding:5px 8px;">Clipboard only</td>
<td style="padding:5px 8px;">Scene Context field</td>
</tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.04);">
<td style="padding:5px 8px 5px 0;color:#9ca3af;">Paste into</td>
<td style="padding:5px 8px;">Clipboard (paste anywhere)</td>
<td style="padding:5px 8px;">Stays in Scene Context</td>
</tr>
<tr>
<td style="padding:5px 8px 5px 0;color:#9ca3af;">Purpose</td>
<td style="padding:5px 8px;">Who they are, backstory</td>
<td style="padding:5px 8px;">What is happening now</td>
</tr>
</table>
<p style="margin:0;color:#6b7280;font-size:11px;">${SVG_TIP} Tip: Use both tools together — Summarise captures the full story arc; Context Generate keeps the AI oriented to the current scene.</p>
</div>`;
hB.appendChild(hM);
document.body.appendChild(hB);
const hClose = () => hB.remove();
hM.querySelector('#fsh-close').addEventListener('click', hClose);
setTimeout(() => hB.addEventListener('click', e => { if (e.target === hB) hClose(); }), 300);
addEscapeClose(hB);
});
} catch (err) {
genToast?.remove?.();
if (err.name !== 'AbortError') toast(`${SVG_WARNING} Memory summary failed: ${escHtml(err.message)}`, 4000);
} finally {
_fabSumRunning = false;
}
// ─── NO TEXT MODAL ─────────────────────────────────────────────────────────
}
function openNoTextModal() {
document.getElementById('ms2-main-backdrop')?.remove();
const backdrop = document.createElement('div');
backdrop.className = 'ms2-backdrop';
backdrop.id = 'ms2-main-backdrop';
setTimeout(() => {
backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
}, 350);
addEscapeClose(backdrop);
const modal = document.createElement('div');
modal.className = 'ms2-modal';
modal.style.maxWidth = '340px';
modal.innerHTML = `
<div class="ms2-modal-header">
<div class="ms2-modal-title">${SVG_SCISSORS} No message found</div>
<button class="ms2-modal-close">×</button>
</div>
<div class="ms2-modal-body">
<div class="ms2-no-text">
<strong>Could not find an AI message.</strong><br><br>
Make sure you're in an active chat with at least one character response visible on screen.
</div>
</div>`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
modal.querySelector('.ms2-modal-close').addEventListener('click', () => backdrop.remove());
// ─── SHORTEN MODAL ─────────────────────────────────────────────────────────
}
function openShortenModal(originalText) {
document.getElementById('ms2-main-backdrop')?.remove();
let selectedLength = CFG.shortenLength;
let keepDialogue = CFG.keepDialogue;
const backdrop = document.createElement('div');
backdrop.className = 'ms2-backdrop';
backdrop.id = 'ms2-main-backdrop';
setTimeout(() => {
backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
}, 350);
addEscapeClose(backdrop);
const modal = document.createElement('div');
modal.className = 'ms2-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const lengthBtns = ['brief', 'compact', 'trim'].map(l =>
`<button class="ms2-length-btn ${selectedLength === l ? 'active' : ''}" data-len="${l}">${l === 'brief' ? 'Slash (~30%)' : l === 'compact' ? 'Halve (~50%)' : 'Polish (~70%)'}</button>`
).join('');
modal.innerHTML = `
<div class="ms2-modal-header">
<div class="ms2-modal-title">${SVG_SCISSORS} Make Shorter</div>
<button class="ms2-modal-close" title="Close">×</button>
</div>
<div class="ms2-modal-body">
<div class="ms2-label">Length</div>
<div class="ms2-length-row">${lengthBtns}</div>
<div class="ms2-toggle-row">
<span class="ms2-toggle-label">Keep all dialogue (never cut spoken lines)</span>
<label class="ms2-toggle-switch">
<input type="checkbox" id="ms2-keep-dlg" ${keepDialogue ? 'checked' : ''}>
<span class="ms2-toggle-thumb"></span>
</label>
</div>
<div class="ms2-tip" style="margin:10px 0 8px;font-size:11px;padding:7px 10px;">Output quality depends on your model — free or smaller models may miss the target length or shift the tone slightly. Try a retry if the first result feels off.</div>
<div class="ms2-label" style="margin-top:4px;">Original</div>
<div class="ms2-textbox">${escHtml(originalText)}</div>
<div class="ms2-label" id="ms2-shorten-label" style="display:none;">Shortened</div>
<div id="ms2-shorten-area"></div>
</div>
<div class="ms2-modal-footer" id="ms2-shorten-footer">
<button class="ms2-btn-action ms2-btn-generate" id="ms2-shorten-gen-btn">${SVG_SCISSORS} Shorten</button>
</div>`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
const resultArea = modal.querySelector('#ms2-shorten-area');
const resultLabel = modal.querySelector('#ms2-shorten-label');
const footer = modal.querySelector('#ms2-shorten-footer');
const genBtn = modal.querySelector('#ms2-shorten-gen-btn');
let resultText = '';
let _shortenAbort = null;
modal.querySelector('.ms2-modal-close').addEventListener('click', () => {
_shortenAbort?.abort();
backdrop.remove();
});
modal.querySelectorAll('.ms2-length-btn').forEach(btn => {
btn.addEventListener('click', () => {
selectedLength = btn.dataset.len;
CFG.shortenLength = selectedLength;
modal.querySelectorAll('.ms2-length-btn').forEach(b => b.classList.toggle('active', b === btn));
});
});
modal.querySelector('#ms2-keep-dlg').addEventListener('change', e => {
keepDialogue = e.target.checked;
CFG.keepDialogue = keepDialogue;
});
const run = async () => {
_shortenAbort?.abort();
_shortenAbort = new AbortController();
resultArea.innerHTML = '<div class="ms2-spinner">Generating…</div>';
resultLabel.style.display = '';
resultLabel.textContent = 'Shortened';
genBtn.disabled = true;
genBtn.style.display = 'none';
footer.querySelectorAll('.ms2-btn-copy,.ms2-btn-retry').forEach(b => b.remove());
try {
const prompt = buildShortenPrompt(selectedLength, keepDialogue);
resultText = await callAPI(prompt, originalText, { temperature: 0.65, max_tokens: 1500, signal: _shortenAbort.signal });
const origWords = originalText.trim().split(/\s+/).length;
const resultWords = resultText.trim().split(/\s+/).length;
const pct = Math.max(0, Math.round((1 - resultWords / origWords) * 100));
resultLabel.innerHTML = `Shortened <span class="ms2-badge">↓${pct}% words</span>`;
resultArea.innerHTML = `<div class="ms2-textbox result">${escHtml(resultText)}</div>`;
const copyBtn = document.createElement('button');
copyBtn.className = 'ms2-btn-action ms2-btn-copy';
copyBtn.innerHTML = `${SVG_COPY} Copy`;
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(resultText)
.then(() => {
copyBtn.innerHTML = `${SVG_CHECK} Copied!`;
setTimeout(() => { if (copyBtn.isConnected) copyBtn.innerHTML = `${SVG_COPY} Copy`; }, 1800);
})
.catch(() => toast('Clipboard unavailable — please copy manually.'));
});
const replaceBtn = document.createElement('button');
replaceBtn.className = 'ms2-btn-action ms2-btn-send';
replaceBtn.textContent = '✎ Replace';
replaceBtn.title = 'Try to replace the AI message in chat directly';
replaceBtn.addEventListener('click', () => {
backdrop.remove();
replaceLatestAIMessage(
resultText,
() => toast(`${SVG_CHECK} Message replaced in chat`),
() => {
navigator.clipboard.writeText(resultText)
.then(() => toast('Could not edit message automatically — copied to clipboard instead.', 3500))
.catch(() => toast('Could not edit message — copy manually.', 4000));
}
);
});
const retryBtn = document.createElement('button');
retryBtn.className = 'ms2-btn-action ms2-btn-retry';
retryBtn.textContent = '↺ Retry';
retryBtn.addEventListener('click', run);
footer.appendChild(copyBtn);
footer.appendChild(replaceBtn);
footer.appendChild(retryBtn);
genBtn.style.display = '';
genBtn.disabled = false;
} catch (err) {
if (err.name === 'AbortError') return;
resultArea.innerHTML = `<div class="ms2-error-box">${SVG_WARNING} ${escHtml(err.message)}</div>`;
genBtn.style.display = '';
genBtn.disabled = false;
}
};
genBtn.addEventListener('click', run);
// ─── REPLY MODAL ───────────────────────────────────────────────────────────
}
function openReplyModal(latestMsg) {
document.getElementById('ms2-reply-backdrop')?.remove();
const presets = getPresets();
const activePreset = presets.find(p => p.id === CFG.activePreset) || null;
let selectedTone = activePreset?.tone || CFG.defaultTone || '';
const backdrop = document.createElement('div');
backdrop.className = 'ms2-backdrop';
backdrop.id = 'ms2-reply-backdrop';
setTimeout(() => {
backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
}, 350);
addEscapeClose(backdrop);
const toneGrid = TONES.map(t =>
`<button class="ms2-tone-btn ${selectedTone === t.id ? 'active' : ''}" data-tone="${t.id}">${t.label}</button>`
).join('');
const presetChip = activePreset
? `<div class="ms2-preset-chip">${SVG_STYLES} ${escHtml(activePreset.name)} active</div>`
: '';
const modal = document.createElement('div');
modal.className = 'ms2-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.innerHTML = `
<div class="ms2-modal-header">
<div class="ms2-modal-title">${SVG_REPLY} Smart Reply</div>
<button class="ms2-modal-close">×</button>
</div>
<div class="ms2-modal-body">
${presetChip}
<div class="ms2-label">AI Said</div>
<div class="ms2-textbox ms2-textbox-preview">${escHtml(latestMsg)}</div>
<div class="ms2-label">Tone</div>
<div class="ms2-tone-grid" style="margin-bottom:12px;">${toneGrid}</div>
<div class="ms2-label">Custom Instruction <span style="font-weight:400;text-transform:none;letter-spacing:0;color:#6b7280;">(optional)</span></div>
<textarea class="ms2-instruction-box" id="ms2-reply-instruct" placeholder="e.g. Push back but secretly enjoy it…">${escHtml(CFG.defaultInstruct)}</textarea>
<div class="ms2-label" id="ms2-reply-result-label" style="display:none;">Generated Reply</div>
<div id="ms2-reply-result-area"></div>
</div>
<div class="ms2-modal-footer" id="ms2-reply-footer">
<button class="ms2-btn-action ms2-btn-generate" id="ms2-gen-btn">${SVG_CONFIG} Generate Reply</button>
</div>`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
const resultArea = modal.querySelector('#ms2-reply-result-area');
const resultLabel = modal.querySelector('#ms2-reply-result-label');
const footer = modal.querySelector('#ms2-reply-footer');
const genBtn = modal.querySelector('#ms2-gen-btn');
let resultText = '';
let _replyAbort = null;
modal.querySelector('.ms2-modal-close').addEventListener('click', () => {
_replyAbort?.abort();
backdrop.remove();
});
modal.querySelectorAll('.ms2-tone-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (selectedTone === btn.dataset.tone) {
selectedTone = '';
btn.classList.remove('active');
} else {
selectedTone = btn.dataset.tone;
modal.querySelectorAll('.ms2-tone-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
});
});
let everSucceeded = false;
const run = async () => {
_replyAbort?.abort();
_replyAbort = new AbortController();
const customInstruct = modal.querySelector('#ms2-reply-instruct').value.trim();
resultArea.innerHTML = '<div class="ms2-spinner">Generating reply…</div>';
resultLabel.style.display = '';
genBtn.disabled = true;
genBtn.style.display = 'none';
footer.querySelectorAll('.ms2-btn-copy,.ms2-btn-send,.ms2-btn-retry').forEach(b => b.remove());
try {
const prompt = buildReplyPrompt(selectedTone, customInstruct, activePreset);
resultText = await callAPI(prompt, `The character just said:\n\n${latestMsg}`, { temperature: 0.9, max_tokens: 1200, signal: _replyAbort.signal });
resultArea.innerHTML = `<div class="ms2-textbox result">${escHtml(resultText)}</div>`;
everSucceeded = true;
const copyBtn = document.createElement('button');
copyBtn.className = 'ms2-btn-action ms2-btn-copy';
copyBtn.innerHTML = `${SVG_COPY} Copy`;
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(resultText).then(() => {
copyBtn.innerHTML = `${SVG_CHECK} Copied!`;
setTimeout(() => { copyBtn.innerHTML = `${SVG_COPY} Copy`; }, 1800);
});
});
const sendBtn = document.createElement('button');
sendBtn.className = 'ms2-btn-action ms2-btn-send';
sendBtn.innerHTML = `${SVG_KEYBOARD} Send`;
sendBtn.addEventListener('click', () => {
backdrop.remove();
injectAndSend(
resultText,
() => toast(`${SVG_CHECK} Reply sent!`),
() => {
navigator.clipboard.writeText(resultText)
.then(() => toast('Could not find chat input — copied to clipboard instead.', 3500))
.catch(() => toast('Could not find chat input — clipboard unavailable. Copy manually.', 4000));
}
);
});
const retryBtn = document.createElement('button');
retryBtn.className = 'ms2-btn-action ms2-btn-retry';
retryBtn.innerHTML = `${SVG_REROLL} Reroll`;
retryBtn.title = 'Generate a different version with the same tone & instructions';
retryBtn.addEventListener('click', run);
footer.appendChild(copyBtn);
footer.appendChild(sendBtn);
footer.appendChild(retryBtn);
} catch (err) {
if (err.name === 'AbortError') return;
resultArea.innerHTML = `<div class="ms2-error-box">${SVG_WARNING} ${escHtml(err.message)}</div>`;
if (everSucceeded) {
const retryBtn = document.createElement('button');
retryBtn.className = 'ms2-btn-action ms2-btn-retry';
retryBtn.textContent = '↺ Retry';
retryBtn.addEventListener('click', run);
footer.appendChild(retryBtn);
} else {
genBtn.style.display = '';
genBtn.disabled = false;
}
}
};
genBtn.addEventListener('click', run);
} // end openReplyModal
// ─── SETTINGS MODAL (5 tabs) ───────────────────────────────────────────────
/** Builds the HTML for the General settings tab. */
function _buildGeneralTab(tab0, modelOpts) {
return `
<div class="ms2-tab-panel ${tab0 === 'general' ? 'active' : ''}" data-panel="general">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
<span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_SETTINGS} GENERAL SETTINGS</span>
<button id="gen-help-btn" title="How does this work?" style="background:none;border:1.5px solid #6b7280;border-radius:50%;width:20px;height:20px;color:#9ca3af;font-size:11px;font-weight:700;cursor:pointer;line-height:1;padding:0;flex-shrink:0;">?</button>
</div>
<label class="ms2-field-label">API Provider</label>
<select class="ms2-select" id="ms2-s-ep-sel">
<option value="https://openrouter.ai/api/v1" ${CFG.endpoint === 'https://openrouter.ai/api/v1' ? 'selected' : ''}>OpenRouter (free & paid — easiest start)</option>
<option value="https://api.openai.com/v1" ${CFG.endpoint === 'https://api.openai.com/v1' ? 'selected' : ''}>OpenAI (GPT-4o, o4-mini…)</option>
<option value="https://api.x.ai/v1" ${CFG.endpoint === 'https://api.x.ai/v1' ? 'selected' : ''}>xAI (Grok 3 / Grok 4)</option>
<option value="https://api.anthropic.com" ${CFG.endpoint === 'https://api.anthropic.com' ? 'selected' : ''}>Anthropic (Claude — native API)</option>
<option value="https://api.mistral.ai/v1" ${CFG.endpoint === 'https://api.mistral.ai/v1' ? 'selected' : ''}>Mistral AI (Magistral, Devstral…)</option>
<option value="https://api.groq.com/openai/v1" ${CFG.endpoint === 'https://api.groq.com/openai/v1' ? 'selected' : ''}>Groq (Llama 4, Qwen 3 — very fast)</option>
<option value="custom" ${!KNOWN_EPS.includes(CFG.endpoint) ? 'selected' : ''}>Custom / other proxy URL…</option>
</select>
<div id="ms2-s-custom-ep-wrap" style="${KNOWN_EPS.includes(CFG.endpoint) ? 'display:none' : ''}">
<label class="ms2-field-label">Custom Base URL</label>
<input type="text" class="ms2-input" id="ms2-s-custom-ep" value="${escHtml(!KNOWN_EPS.includes(CFG.endpoint) ? CFG.endpoint : '')}" placeholder="https://your-proxy.example.com/v1">
</div>
<label class="ms2-field-label">API Key</label>
<div style="display:flex;gap:6px;align-items:center;">
<input type="text" class="ms2-input" id="ms2-s-apikey" value="${escHtml(CFG.apiKey)}" placeholder="Paste your API key…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-lpignore="true" data-form-type="other" data-1p-ignore="true" style="margin-bottom:0;flex:1;min-width:0;-webkit-text-security:disc;">
<button class="ap-icon-btn" id="ms2-test-api-btn" title="Test connection" style="flex-shrink:0;">${SVG_CONFIG}</button>
</div>
<div id="ms2-api-test-result" style="font-size:11px;margin-top:4px;min-height:16px;"></div>
<label class="ms2-field-label" style="margin-top:6px;">Auth Header Format</label>
<select class="ms2-select" id="ms2-s-auth-mode" style="margin-bottom:2px;">
<option value="auto" ${CFG.authMode === 'auto' ? 'selected' : ''}>Auto-detect (recommended)</option>
<option value="bearer" ${CFG.authMode === 'bearer' ? 'selected' : ''}>Bearer <key> — standard OpenAI / OpenRouter</option>
<option value="raw" ${CFG.authMode === 'raw' ? 'selected' : ''}>Key only — LiteRouter & niche proxies</option>
<option value="x-api-key" ${CFG.authMode === 'x-api-key' ? 'selected' : ''}>x-api-key header — Anthropic-style proxies</option>
</select>
<div style="font-size:10.5px;color:#6b7280;margin-bottom:8px;line-height:1.4;">
Auto-detect uses <code>Bearer</code> for all keys — works with OpenRouter, OpenAI, LiteRouter, and most proxies. Only change this if your proxy specifically rejects <code>Bearer</code>.
</div>
<label class="ms2-field-label">Model</label>
<select class="ms2-select" id="ms2-s-model">
${modelOpts}
<option value="__custom__" ${!MODELS.find(m => m.id === CFG.model) ? 'selected' : ''}>Custom model ID…</option>
</select>
<input type="text" class="ms2-input" id="ms2-s-custom-model" placeholder="e.g. openai/gpt-4o-mini" value="${escHtml(!MODELS.find(m => m.id === CFG.model) ? CFG.model : '')}" style="${MODELS.find(m => m.id === CFG.model) ? 'display:none;margin-top:-6px' : 'margin-top:-6px'}">
<div id="ms2-proxy-model-warn" class="ms2-tip" style="${KNOWN_EPS.includes(CFG.endpoint) ? 'display:none' : ''}; border-color:rgba(251,191,36,0.4); color:#fbbf24;">
${SVG_WARNING} <strong>Custom endpoint — model ID format may differ.</strong>
Proxies like LiteRouter use the same <code style="color:#fbbf24">provider/model:tier</code> format as OpenRouter (preset list works as-is).
Others (e.g. self-hosted Ollama, meganova.ai) use shorter IDs with no provider prefix — in that case choose <strong>Custom model ID…</strong> below.
Your API key can be any format your provider issues.
</div>
<div class="ms2-tip">
${SVG_INFO}
<strong>Which provider should I pick?</strong><br>
• <strong>OpenRouter</strong> — widest model selection, many free models (ending in <code style="color:#a78bfa">:free</code>), one key for everything. Get a free key at <a href="https://openrouter.ai/keys" target="_blank">openrouter.ai/keys</a>.<br>
• <strong>xAI</strong> — Grok 3 / Grok 4, OpenAI-compatible. Key from <a href="https://console.x.ai" target="_blank">console.x.ai</a>.<br>
• <strong>Anthropic</strong> — Claude Opus / Sonnet / Haiku native. Key from <a href="https://console.anthropic.com" target="_blank">console.anthropic.com</a>. <em>Note: uses its own message format — handled automatically.</em><br>
• <strong>Mistral</strong> — Magistral reasoning, Devstral (code), Mistral Large. Key from <a href="https://console.mistral.ai" target="_blank">console.mistral.ai</a>.<br>
• <strong>Groq</strong> — Llama 4, Qwen 3, ultra-fast inference, generous free tier. Key from <a href="https://console.groq.com" target="_blank">console.groq.com</a>.
</div>
<div class="ms2-settings-actions">
<button class="ms2-btn-save" id="ms2-save-general">Save</button>
<button class="ms2-btn-cancel" id="ms2-cancel-settings">Cancel</button>
</div>
<!-- COMMUNITY CHAT RELAY -->
<div style="margin-top:14px;padding-top:12px;border-top:1px solid rgba(99,102,241,.15);">
<label class="ms2-field-label" style="color:#67e8f9;">${SVG_CHAT} Community Chat Relay URL</label>
<input type="text" class="ms2-input" id="ms2-s-relay-url"
value="${escHtml(_p2pGetRelay())}"
placeholder="https://ntfy.sh"
autocomplete="off" spellcheck="false">
<div style="font-size:10.5px;color:#6b7280;margin-bottom:8px;line-height:1.4;">
The ntfy.sh-compatible relay used for Community Chat signaling. Leave blank to use the default (<code>https://ntfy.sh</code>). Only change this if you're running your own ntfy instance.
</div>
<button class="ms2-btn-save" id="ms2-save-relay" style="padding:5px 14px;font-size:11px;">Save Relay URL</button>
</div>
</div>`;
}
/** Builds the HTML for the Reply settings tab. */
function _buildReplyTab(tab0, toneOpts) {
return `
<div class="ms2-tab-panel ${tab0 === 'reply' ? 'active' : ''}" data-panel="reply">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
<span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_REPLY} REPLY SETTINGS</span>
<button id="reply-help-btn" title="How does this work?" style="background:none;border:1.5px solid #6b7280;border-radius:50%;width:20px;height:20px;color:#9ca3af;font-size:11px;font-weight:700;cursor:pointer;line-height:1;padding:0;flex-shrink:0;">?</button>
</div>
<label class="ms2-field-label">Default Tone</label>
<select class="ms2-select" id="ms2-s-default-tone">${toneOpts}</select>
<label class="ms2-field-label">Default Custom Instruction</label>
<textarea class="ms2-input ms2-textarea-sm" id="ms2-s-default-instruct" placeholder="e.g. Be slightly more reserved than usual">${escHtml(CFG.defaultInstruct)}</textarea>
<div class="ms2-toggle-row" style="margin-bottom:12px;">
<span class="ms2-toggle-label">Notify on new AI message</span>
<label class="ms2-toggle-switch">
<input type="checkbox" id="ms2-s-autonotify" ${CFG.autoNotify ? 'checked' : ''}>
<span class="ms2-toggle-thumb"></span>
</label>
</div>
<div class="ms2-tip">When ON: a subtle top toast appears when a new AI message arrives — no auto-opening modals.</div>
<div class="ms2-settings-actions">
<button class="ms2-btn-save" id="ms2-save-reply">Save</button>
<button class="ms2-btn-cancel">Cancel</button>
</div>
</div>`;
}
/** Builds the HTML for the Styles tab. */
function _buildStylesTab(tab0) {
return `
<div class="ms2-tab-panel ${tab0 === 'styles' ? 'active' : ''}" data-panel="styles">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
<span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_STYLES} STYLES</span>
<button id="styles-help-btn" title="How does this work?" style="background:none;border:1.5px solid #6b7280;border-radius:50%;width:20px;height:20px;color:#9ca3af;font-size:11px;font-weight:700;cursor:pointer;line-height:1;padding:0;flex-shrink:0;">?</button>
</div>
<div class="ms2-tip">Presets save your character's voice and tone. The active preset is injected automatically when you open Smart Reply.</div>
<div id="ms2-presets-list"></div>
<button class="ms2-btn-new-preset" id="ms2-new-preset-btn">+ New Preset</button>
<div id="ms2-preset-editor-wrap" style="display:none;"></div>
</div>`;
}
/** Builds the HTML for the Context tab. */
function _buildContextTab(tab0) {
return `
<div class="ms2-tab-panel ${tab0 === 'context' ? 'active' : ''}" data-panel="context">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
<span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_CONTEXT} CONTEXT</span>
<button id="ctx-help-btn" title="How does this work?" style="background:none;border:1.5px solid #6b7280;border-radius:50%;width:20px;height:20px;color:#9ca3af;font-size:11px;font-weight:700;cursor:pointer;line-height:1;padding:0;flex-shrink:0;">?</button>
</div>
<div class="ms2-tip">These notes are injected into every <strong>Reply</strong> prompt and into <strong>Shorten</strong> as editorial context — keyed to <strong>this specific chat URL</strong>. Each conversation has its own notes.</div>
<div style="background:rgba(99,102,241,.07);border:1px solid rgba(99,102,241,.22);border-radius:10px;padding:10px 12px;margin-bottom:12px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">GENERATE SCENE CONTEXT</span>
</div>
<div style="font-size:11px;color:#4b5563;margin-bottom:8px;line-height:1.5;">Reads the <strong>currently visible messages</strong> and writes a current-situation note: where the characters are, the mood, recent events, unresolved tension. The result saves to your Scene Context and is injected into every JanitorAI reply. For a full-history summary of all messages, use the <strong>FAB → Summarise</strong> button instead.</div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<button class="ms2-btn-save" id="ms2-ctx-gen-btn" style="flex:1;min-width:80px;">${SVG_SPARKLE} Generate</button>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-top:5px;">
<button class="ms2-btn-action ms2-btn-copy" id="ms2-ctx-save-global-btn" title="Save current context as global memory (persists across all chats)" style="flex:1;padding:6px 8px;font-size:11px;">${SVG_SAVE} Save Global</button>
<button class="ms2-btn-action ms2-btn-retry" id="ms2-ctx-load-global-btn" title="Load global memory into this chat's context" style="flex:1;padding:6px 8px;font-size:11px;">${SVG_FOLDER} Load Global</button>
</div>
<div id="ms2-ctx-char-row" style="display:flex;align-items:center;gap:8px;margin-top:5px;">
<button class="ms2-btn-action ms2-btn-copy" id="ms2-ctx-save-char-btn" title="Save context for this character only (stored separately from global)" style="flex:1;padding:6px 8px;font-size:11px;">${SVG_MEMORY} Save for Character</button>
<button class="ms2-btn-action ms2-btn-retry" id="ms2-ctx-load-char-btn" title="Load this character's saved memory (falls back to global if none)" style="flex:1;padding:6px 8px;font-size:11px;">${SVG_MEMORY} Load for Character</button>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-top:7px;padding-top:7px;border-top:1px solid rgba(99,102,241,.15);">
<label class="ms2-toggle-switch" title="Automatically fill context with global/character memory when entering an empty chat">
<input type="checkbox" id="ms2-ctx-autoload-chk" ${getAutoLoadGlobal() ? 'checked' : ''}>
<span class="ms2-toggle-thumb"></span>
</label>
<span style="font-size:11px;color:#9ca3af;flex:1;">Auto-load memory into new empty chats</span>
<button class="ms2-btn-save" id="ms2-ctx-autoload-save" style="padding:5px 10px;font-size:11px;">Save</button>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-top:9px;padding-top:9px;border-top:1px solid rgba(99,102,241,.15);">
<label class="ms2-toggle-switch" title="Auto-generate context when threshold is reached">
<input type="checkbox" id="ms2-ctx-auto-chk" ${getAutoSumAuto() ? 'checked' : ''}>
<span class="ms2-toggle-thumb"></span>
</label>
<span style="font-size:11px;color:#6b7280;flex:1;">Auto-generate every</span>
<input type="number" id="ms2-ctx-auto-every" min="0" max="500" value="${getAutoSumEvery() || ''}" placeholder="N"
style="width:52px;padding:4px 6px;background:#0d0d1a;border:1px solid #1e1b4b;border-radius:6px;color:#e2e8f0;font-size:12px;outline:none;text-align:center;">
<span style="font-size:11px;color:#6b7280;">msgs</span>
<button class="ms2-btn-save" id="ms2-ctx-auto-save" style="padding:5px 10px;font-size:11px;">Save</button>
</div>
</div>
<label class="ms2-field-label">Scene Context <span style="font-weight:400;color:#6b7280;">(injected into every Reply & Shorten)</span></label>
<textarea class="ms2-input ms2-textarea-lg" id="ms2-s-context" maxlength="2000" placeholder="e.g. We're at a concert. Sylvie just won an award. Rvie is pretending not to care but is clearly jealous.">${escHtml(getContext())}</textarea>
<div id="ms2-ctx-count" style="font-size:10px;color:#6b7280;text-align:right;margin-top:2px;">${getContext().length} / 2000</div>
<div style="display:flex;align-items:flex-start;gap:8px;margin-top:8px;padding:8px;background:rgba(139,92,246,0.06);border:1px solid rgba(139,92,246,0.18);border-radius:7px;">
<label class="ms2-toggle-switch" style="margin-top:1px;" title="Inject this Scene Context into JanitorAI's actual AI on every generation">
<input type="checkbox" id="ms2-ctx-inject-chk" ${getInjectCtx() ? 'checked' : ''}>
<span class="ms2-toggle-thumb"></span>
</label>
<div style="flex:1;">
<span style="font-size:12px;color:#c4b5fd;font-weight:500;">Send to JanitorAI's AI</span>
<div class="ms2-tip" style="margin-top:1px;">When ON, your Scene Context is appended to JanitorAI's actual generation on every message. <strong style="color:#e2e8f0;">Does this duplicate JanitorAI's built-in memory?</strong> No — keep Scene Context for <em>current situation</em> (where you are, what just happened) and leave character backstory/personality in JanitorAI's own memory box. Different purposes = no overlap. Saves automatically.</div>
</div>
</div>
<div class="ms2-settings-actions">
<button class="ms2-btn-save" id="ms2-save-context">Save</button>
<button class="ms2-btn-cancel">Cancel</button>
</div>
<!-- PERSONA LIBRARY -->
<input type="file" id="ms2-persona-import-file" accept=".json" style="display:none;">
<div style="margin-top:14px;padding-top:12px;border-top:1px solid rgba(99,102,241,.15);">
<div style="display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:8px;flex-wrap:wrap;">
<span style="font-size:10px;color:#4b5563;text-transform:uppercase;letter-spacing:.9px;font-weight:700;">${SVG_PERSONA} Persona Library</span>
<div style="display:flex;gap:4px;flex-shrink:0;">
<button id="ms2-persona-add-btn" style="background:rgba(139,92,246,0.15);border:1px solid rgba(139,92,246,0.35);border-radius:5px;color:#c4b5fd;font-size:11px;cursor:pointer;padding:2px 8px;line-height:1.6;" title="Add a new persona">+ Add</button>
<button id="ms2-persona-export-btn" style="background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);border-radius:5px;color:#6ee7b7;font-size:11px;cursor:pointer;padding:2px 8px;line-height:1.6;" title="Export all personas as a JSON file">${SVG_SAVE} Export</button>
<button id="ms2-persona-import-btn" style="background:rgba(251,191,36,0.1);border:1px solid rgba(251,191,36,0.3);border-radius:5px;color:#fde68a;font-size:11px;cursor:pointer;padding:2px 8px;line-height:1.6;" title="Import personas from a JSON file (merges with existing)">${SVG_FOLDER} Import</button>
</div>
</div>
<div class="ms2-tip" style="margin-bottom:8px;">Save character descriptions here. Click <strong>Use</strong> to instantly load one into the Scene Context box above. Use <strong>Export/Import</strong> to back up and restore your library.</div>
<div id="ms2-persona-form" style="display:none;background:rgba(255,255,255,0.03);border:1px solid rgba(139,92,246,0.25);border-radius:7px;padding:10px;margin-bottom:8px;">
<input type="text" id="ms2-persona-name-input" placeholder="Name (e.g. Rvie — Tsundere Mode)" class="ms2-input" style="margin-bottom:6px;">
<textarea id="ms2-persona-desc-input" class="ms2-input ms2-textarea-sm" placeholder="Describe this character: how they speak, act, their mood, quirks…" style="min-height:72px;"></textarea>
<div style="display:flex;gap:6px;margin-top:6px;">
<button id="ms2-persona-save-btn" class="ms2-btn-save" style="flex:1;padding:5px;">Save</button>
<button id="ms2-persona-cancel-btn" class="ms2-btn-cancel" style="flex:1;padding:5px;">Cancel</button>
</div>
</div>
<div id="ms2-persona-list" style="display:flex;flex-direction:column;gap:5px;max-height:180px;overflow-y:auto;"></div>
</div>
<!-- Summary history -->
<div style="margin-top:14px;">
<div style="font-size:10px;color:#4b5563;text-transform:uppercase;letter-spacing:.9px;font-weight:700;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between;">
<span>Past summaries</span>
<div style="display:flex;gap:8px;align-items:center;">
<button id="ms2-ctx-export-hist" style="background:none;border:none;color:#4b5563;font-size:10px;cursor:pointer;padding:0;" title="Export all summaries as JSON">↓ Export</button>
<button id="ms2-ctx-clear-hist" style="background:none;border:none;color:#4b5563;font-size:10px;cursor:pointer;padding:0;" title="Clear all summary history">${SVG_TRASH} Clear</button>
</div>
</div>
<div id="ms2-ctx-hist-list" style="max-height:160px;overflow-y:auto;"></div>
</div>
</div>`;
}
/** Builds the HTML for the Advanced Prompt (Configure) tab. */
function _buildAdvTab(tab0) {
return `
<div class="ms2-tab-panel ${tab0 === 'adv' ? 'active' : ''}" data-panel="adv">
<div class="ms2-toggle-row" style="margin-bottom:10px;">
<span class="ms2-toggle-label" style="font-size:12px;font-weight:600;color:#c4b5fd;">Enable Advanced Prompting</span>
<div style="display:flex;align-items:center;gap:8px;">
<button id="ap-help-btn" title="How does this work?" style="background:none;border:1.5px solid #6b7280;border-radius:50%;width:20px;height:20px;color:#9ca3af;font-size:11px;font-weight:700;cursor:pointer;line-height:1;padding:0;flex-shrink:0;">?</button>
<label class="ms2-toggle-switch">
<input type="checkbox" id="ap-enabled-chk" ${AP.enabled ? 'checked' : ''}>
<span class="ms2-toggle-thumb"></span>
</label>
</div>
</div>
<div class="ms2-tip" style="margin-bottom:10px;">When ON, the active preset replaces the <code style="color:#a78bfa">llm_prompt</code> field on every generation. Your proxy jailbreak is left untouched. <span id="ap-status-dot" class="ap-status-dot" title="No injection recorded yet"></span></div>
<!-- THINKING TOGGLE -->
<div style="display:flex;align-items:flex-start;gap:8px;margin-bottom:12px;">
<label class="ms2-toggle-switch" style="margin-top:2px;" title="Append a step-by-step reasoning instruction to every generation">
<input type="checkbox" id="ap-thinking-chk">
<span class="ms2-toggle-thumb"></span>
</label>
<div style="flex:1;">
<span style="font-size:12px;color:#e2e8f0;font-weight:500;">Enable Thinking</span>
<div class="ms2-tip" style="margin-top:2px;">Tells the AI to reason inside <thinking> tags before replying. Best for models that support extended reasoning (e.g. Claude 3.5+, o1, Gemini 2.0+).</div>
</div>
</div>
<!-- FORBIDDEN WORDS -->
<div id="ap-forbidden-wrap" style="margin-bottom:12px;">
<label class="ms2-field-label">Forbidden Words / Phrases</label>
<div style="display:flex;gap:4px;margin-bottom:4px;">
<input type="text" id="ap-forbidden-input" placeholder="Add a word or phrase…" class="ms2-input" style="flex:1;margin-bottom:0;">
<button class="ap-icon-btn" id="ap-forbidden-add-btn" title="Add to list">+</button>
</div>
<div id="ap-forbidden-tags" style="display:flex;flex-wrap:wrap;gap:4px;max-height:120px;overflow-y:auto;padding:4px;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:6px;min-height:36px;">
<!-- tags filled by JavaScript -->
</div>
<div class="ms2-tip" style="margin-top:4px;">These are injected into every generation — unlimited bans beyond JanitorAI's 10-word limit.</div>
<div id="ap-forbidden-counter" style="display:none;margin-top:5px;font-size:11px;color:#a78bfa;padding:3px 6px;background:rgba(139,92,246,0.08);border:1px solid rgba(139,92,246,0.2);border-radius:5px;line-height:1.5;"></div>
</div>
<label class="ms2-field-label">Active Preset</label>
<div class="ap-row">
<select class="ap-select" id="ap-preset-sel"></select>
<button class="ap-icon-btn" id="ap-new-preset-btn" title="New preset">+</button>
<button class="ap-icon-btn" id="ap-rename-preset-btn" title="Rename preset">✎</button>
<button class="ap-icon-btn" id="ap-export-btn" title="Export preset as JSON">↑</button>
<button class="ap-icon-btn" id="ap-import-btn" title="Import preset from JSON">↓</button>
<button class="ap-icon-btn" id="ap-delete-preset-btn" title="Delete preset" style="color:#fca5a5;">${SVG_TRASH}</button>
</div>
<div id="ap-modules-wrap" style="display:none;">
<label class="ms2-field-label">Prompt Modules <span id="ap-token-count" style="font-weight:400;text-transform:none;letter-spacing:0;color:#6b7280;"></span></label>
<div class="ap-token-bar"><div class="ap-token-fill" id="ap-token-fill" style="width:0%;"></div></div>
<div id="ap-module-list" class="ap-module-list"></div>
<div class="ap-row">
<select class="ap-select" id="ap-unattached-sel"></select>
<button class="ap-icon-btn" id="ap-attach-btn" title="Attach selected module — or create new if none exist">+</button>
<button class="ap-icon-btn" id="ap-del-module-btn" title="Delete selected module" style="color:#fca5a5;">${SVG_TRASH}</button>
<button class="ap-icon-btn" id="ap-new-module-btn" title="Create new blank module">✎</button>
</div>
<div class="ms2-settings-actions" style="margin-top:4px;">
<button class="ms2-btn-save ap-save-dirty" id="ap-save-btn" disabled>Save Preset</button>
<button class="ms2-btn-cancel" id="ap-discard-btn">Discard</button>
</div>
</div>
<div id="ap-no-preset-msg" class="ap-empty">Select or create a preset to begin.</div>
</div>`;
}
function openSettingsModal(initialTab) {
if (document.getElementById('ms2-settings-backdrop')) return;
const tab0 = initialTab || 'general';
const backdrop = document.createElement('div');
backdrop.className = 'ms2-backdrop';
backdrop.id = 'ms2-settings-backdrop';
setTimeout(() => {
backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
}, 350);
addEscapeClose(backdrop);
const tabs = ['general', 'reply', 'styles', 'context', 'adv', 'about'];
const tabLabels = { general: `${SVG_SETTINGS} General`, reply: `${SVG_REPLY} Reply`, styles: `${SVG_STYLES} Styles`, context: `${SVG_CONTEXT} Context`, adv: `${SVG_CONFIG} Configure`, about: `${SVG_INFO} About` };
// Build grouped <optgroup> model list
const _modelGroupMap = new Map();
for (const m of MODELS) {
const g = m.group || 'Other';
if (!_modelGroupMap.has(g)) _modelGroupMap.set(g, []);
_modelGroupMap.get(g).push(m);
}
const modelOpts = [..._modelGroupMap.entries()].map(([gName, models]) =>
`<optgroup label="${escHtml(gName)}">${
models.map(m =>
`<option value="${escHtml(m.id)}" ${CFG.model === m.id ? 'selected' : ''}>${escHtml(m.label)}</option>`
).join('')
}</optgroup>`
).join('');
const toneOpts = [{ id: '', label: '— None —' }, ...TONES].map(t =>
`<option value="${escHtml(t.id)}" ${CFG.defaultTone === t.id ? 'selected' : ''}>${escHtml(t.label)}</option>`
).join('');
const panel = document.createElement('div');
panel.className = 'ms2-settings-v2';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'true');
panel.innerHTML = `
<div class="ms2-modal-header">
<div class="ms2-modal-title">${SVG_SETTINGS} Settings</div>
<button class="ms2-modal-close">×</button>
</div>
<div class="ms2-tab-bar">
${tabs.map(t => `<button class="ms2-tab ${t === tab0 ? 'active' : ''}" data-tab="${t}">${tabLabels[t]}</button>`).join('')}
</div>
<div class="ms2-settings-body">
${_buildGeneralTab(tab0, modelOpts)}
${_buildReplyTab(tab0, toneOpts)}
${_buildStylesTab(tab0)}
${_buildContextTab(tab0)}
${_buildAdvTab(tab0)}
<!-- ABOUT -->
<div class="ms2-tab-panel ${tab0 === 'about' ? 'active' : ''}" data-panel="about">
<div class="ms2-about-box">
<div class="ms2-about-title">JanitorV5 — Smart RP Toolkit</div>
<div class="ms2-about-version">v5.6.6 — Persona Library · Quick-Switch · Settings FAB · Import/Export · Community Chat · Delivery Confirmation · Real-Scroller Fix</div>
<div class="ms2-about-row"><strong>Created by</strong> eivls</div>
<div class="ms2-about-row"><strong>TikTok</strong> <a href="https://tiktok.com/@eivls" target="_blank" style="color:#a78bfa;text-decoration:none;">@eivls</a></div>
<div class="ms2-about-row" style="display:flex;align-items:center;gap:6px;">
<strong>License</strong>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#a78bfa;vertical-align:middle;flex-shrink:0;"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<span>All Rights Reserved — © 2025 eivls</span>
</div>
<div style="background:rgba(16,185,129,0.07);border:1px solid rgba(16,185,129,0.2);border-radius:8px;padding:10px 12px;margin:10px 0;">
<div style="font-size:11px;font-weight:700;color:#6ee7b7;letter-spacing:.5px;margin-bottom:6px;">${SVG_ROCKET} QUICK SETUP</div>
<div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>1. Long-press the FAB</strong> — Hold the ${SVG_SETTINGS} gear button at the bottom-right for ~1 s until the purple ring fills, then release. This opens Settings.</div>
<div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>2. Connect your AI</strong> — Go to <em>General</em>, pick a provider, paste your API key, and hit <strong>Test API</strong>. OpenRouter has free models — no card needed to start.</div>
<div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>3. Tap (don't hold) the FAB in any chat</strong> — The speed-dial opens. Choose a tool: Reply, Shorten, Summarise, Styles, Personas, or Community Chat.</div>
<div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>4. Fine-tune in Settings</strong> — Set a default tone in Reply, build Style presets, add Persona Library entries, configure your system prompt in Configure. Everything autosaves.</div>
<div class="ms2-about-row" style="border:none;padding:3px 0;"><strong>5. Drag the FAB</strong> — It repositions to any edge so it never blocks your view.</div>
</div>
<div class="ms2-about-row"><strong>${SVG_SETTINGS} FAB (gear button)</strong> — <em>Tap once</em> = speed-dial menu (6 tools). <em>Long-press ~1 s</em> = open Settings. <em>Drag</em> to reposition anywhere on screen.</div>
<div class="ms2-about-row"><strong>${SVG_SCISSORS} Shorten</strong> — Condenses the latest AI message. Pick cut depth (Slash ~30% / Halve ~50% / Polish ~70%) and toggle dialogue preservation.</div>
<div class="ms2-about-row"><strong>${SVG_REPLY} Reply</strong> — Pick a tone, write an optional instruction, generate a reply as your character. Hard-ban list blocks repetitive AI phrases. Hit ${SVG_KEYBOARD} Send to inject directly into chat.</div>
<div class="ms2-about-row"><strong>${SVG_STYLES} Styles</strong> — Save named presets with your character's voice and tone. Activate one to auto-fill Reply settings every time.</div>
<div class="ms2-about-row"><strong>${SVG_SUMMARISE} Summarise</strong> — Auto loads your full chat history and writes a persistent memory entry (who the characters are, relationship arc, key events). Output copies to clipboard — paste into JanitorAI's Chat Memory panel.</div>
<div class="ms2-about-row"><strong>${SVG_PERSONA} Personas</strong> — Quick-switch between your saved Persona Library entries directly from the speed-dial. Live search included — no need to open Settings.</div>
<div class="ms2-about-row"><strong>${SVG_CHAT} Community Chat</strong> — Real-time global chat shared across all JanitorAI users running JanitorV5. Open from the speed-dial FAB.</div>
<div style="background:rgba(6,182,212,0.06);border:1px solid rgba(6,182,212,0.2);border-radius:8px;padding:10px 12px;margin:8px 0;">
<div style="font-size:11px;font-weight:700;color:#67e8f9;letter-spacing:.5px;margin-bottom:6px;">${SVG_CHAT} COMMUNITY CHAT v2 — WHAT'S INCLUDED</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Verified names</strong> — Green ✓ badge on users whose display name has been cryptographically confirmed.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Typing indicator</strong> — Live "X is typing…" bar appears as others compose a message.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Online count</strong> — Shows how many users are active in the room right now via heartbeat.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Reply with notification</strong> — Hit ↩ on any message to quote-reply; the original sender gets a toast notification.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Reactions & emoji picker</strong> — React to any message with an emoji. Grouped reaction counts shown on each bubble.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Pinned messages bar</strong> — Admins can pin a message so it stays visible at the top of the chat for everyone.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Admin controls</strong> — Admins see a distinct styled bubble and can pin/unpin, ban, and moderate messages.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Mute / Unmute</strong> — Mute any user to hide their messages locally. Manage your blocked list from the chat header.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Report</strong> — Flag any message with ⚑; sends a report and confirms with a toast.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Chat export</strong> — Download the full visible chat history as a plain-text file from the header button.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Auto-scroll badge</strong> — When you scroll up to read history, a "● N new" badge appears and jumps you back to the bottom.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Delivery confirmation</strong> — Your own bubbles show "Sending…" then flip to a green <strong>✓ Delivered</strong> once the relay confirms the send (2xx). Fades out automatically after 2.5 s.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Rate-limit UX</strong> — Send button disables with a live countdown after each message. On HTTP 429, network error, or 10 s timeout, an in-chat banner appears with the airplane-mode tip and a one-tap <em>Retry</em> button.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Deduplication</strong> — No message ever appears twice, even across overlapping polls or reconnects.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Global & character rooms</strong> — Auto-detected from the current URL. Character rooms are private to people viewing the same character page; Global is site-wide.</div>
</div>
<div style="background:rgba(139,92,246,0.08);border:1px solid rgba(139,92,246,0.3);border-radius:8px;padding:10px 12px;margin:8px 0;">
<div style="font-size:11px;font-weight:700;color:#c4b5fd;letter-spacing:.5px;margin-bottom:5px;">💜 HELP GROW THE COMMUNITY</div>
<div style="font-size:12px;color:#d1d5db;line-height:1.65;">
If JanitorV5 has improved your experience, share it. Post the GreasyFork link on
<strong style="color:#e5e7eb;">TikTok</strong>, <strong style="color:#e5e7eb;">X / Twitter</strong>,
<strong style="color:#e5e7eb;">Discord</strong>, or <strong style="color:#e5e7eb;">Reddit</strong>.
A short screen recording, review, or "how I use this" video is the single best way to get more
roleplayers into Community Chat — and more users means better conversations for everyone.
No sponsorship, no algorithm tricks needed — just share it if you find it useful.
</div>
</div>
<div class="ms2-about-row"><strong>${SVG_CONTEXT} Context tab</strong> — Per-chat scene notes (where you are, what just happened) sent to every Reply and Shorten request. Toggle <strong>Send to JanitorAI's AI</strong> to inject into JanitorAI's actual generation automatically. Use <strong>Generate</strong> or the FAB <strong>Summarise</strong> shortcut to auto-write the note from your chat history.</div>
<div class="ms2-about-row"><strong>${SVG_PERSONA} Persona Library</strong> — Save reusable character descriptions by name. Use, Edit, or Delete entries. Import a JSON backup or Export to save your whole library.</div>
<div class="ms2-about-row"><strong>${SVG_CONFIG} Configure tab</strong> — Intercepts every JanitorAI generation and replaces the system prompt with your configured module stack. Deleted messages are scrubbed automatically. Always save before activating.</div>
</div>
</div>
</div>`;
backdrop.appendChild(panel);
document.body.appendChild(backdrop);
const closeAll = () => backdrop.remove();
panel.querySelector('.ms2-modal-close').addEventListener('click', closeAll);
panel.querySelectorAll('.ms2-btn-cancel').forEach(b => b.addEventListener('click', closeAll));
panel.querySelectorAll('.ms2-tab').forEach(tab => {
tab.addEventListener('click', () => {
panel.querySelectorAll('.ms2-tab').forEach(t => t.classList.remove('active'));
panel.querySelectorAll('.ms2-tab-panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
panel.querySelector(`[data-panel="${tab.dataset.tab}"]`)?.classList.add('active');
});
});
const epSel = panel.querySelector('#ms2-s-ep-sel');
const epWrap = panel.querySelector('#ms2-s-custom-ep-wrap');
const modelSel = panel.querySelector('#ms2-s-model');
const customModelInput = panel.querySelector('#ms2-s-custom-model');
const proxyModelWarn = panel.querySelector('#ms2-proxy-model-warn');
const isKnownEp = () => KNOWN_EPS.includes(epSel.value);
epSel.addEventListener('change', () => {
epWrap.style.display = epSel.value === 'custom' ? '' : 'none';
proxyModelWarn.style.display = isKnownEp() ? 'none' : '';
});
modelSel.addEventListener('change', () => {
customModelInput.style.display = modelSel.value === '__custom__' ? '' : 'none';
});
panel.querySelector('#ms2-test-api-btn').addEventListener('click', async () => {
const testBtn = panel.querySelector('#ms2-test-api-btn');
const resultDiv = panel.querySelector('#ms2-api-test-result');
const authSel = panel.querySelector('#ms2-s-auth-mode');
const typedKey = panel.querySelector('#ms2-s-apikey').value.trim();
if (!typedKey) { resultDiv.style.color = '#f87171'; resultDiv.innerHTML = `${SVG_CROSS} Enter an API key first`; return; }
const selectedEp = epSel.value === 'custom'
? (panel.querySelector('#ms2-s-custom-ep').value.trim() || 'https://openrouter.ai/api/v1')
: epSel.value;
const selectedModel = modelSel.value === '__custom__'
? (customModelInput.value.trim() || MODELS[0].id)
: modelSel.value;
const baseTestEp = selectedEp.replace(/\/$/, '');
const isAnthropicTest = selectedEp.includes('anthropic.com');
const testEp = isAnthropicTest ? baseTestEp + '/v1/messages' : baseTestEp + '/chat/completions';
const testBody = isAnthropicTest
? JSON.stringify({ model: selectedModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] })
: JSON.stringify({ model: selectedModel, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1, temperature: 0 });
// Helper: attempt one request with a specific auth mode; returns {ok, status, msg}
async function _tryAuth(mode) {
const hdrs = { 'Content-Type': 'application/json', ..._buildAuthHeaders(typedKey, selectedEp, mode) };
try {
const r = await gmFetch(testEp, { method: 'POST', headers: hdrs, body: testBody });
if (r.ok) return { ok: true };
// 3xx: redirect not followed (some managers ignore redirect:'follow')
if (r.status >= 300 && r.status < 400) {
const loc = r.headers?.get('location') || '';
let hint = 'Your endpoint URL is redirecting';
if (loc) {
// Strip the appended path so user gets the base URL to paste
const cleanLoc = loc.replace(/\/chat\/completions$/, '').replace(/\/v1\/messages$/, '');
hint = `URL redirects → ${cleanLoc} — paste that as your Base URL`;
} else {
hint = `URL redirects (${r.status}) — try adding or removing /v1, or switch http→https`;
}
return { ok: false, status: r.status, msg: hint };
}
const raw = await r.text().catch(() => '');
let msg = `API error ${r.status}`;
try { msg = JSON.parse(raw)?.error?.message || msg; } catch {}
return { ok: false, status: r.status, msg };
} catch (e) {
if (e.name === 'AbortError') throw e;
return { ok: false, status: 0, msg: e.message };
}
}
function _isAuthError(res) {
if (res.status === 401 || res.status === 403) return true;
const m = (res.msg || '').toLowerCase();
return m.includes('auth') || m.includes('key') || m.includes('token') ||
m.includes('credential') || m.includes('unauthorized') || m.includes('forbidden');
}
const AUTH_MODE_LABELS = { auto: 'Auto-detect', bearer: 'Bearer', raw: 'Key only', 'x-api-key': 'x-api-key' };
testBtn.disabled = true;
resultDiv.style.color = '#9ca3af';
resultDiv.textContent = 'Testing…';
try {
const primaryMode = authSel?.value || 'auto';
resultDiv.textContent = `Testing (${AUTH_MODE_LABELS[primaryMode]})…`;
let result = await _tryAuth(primaryMode);
if (!result.ok && _isAuthError(result)) {
// Auto-cycle through the remaining modes to find one that works
const fallbacks = ['bearer', 'raw', 'x-api-key'].filter(m => m !== primaryMode);
let found = null;
for (const fb of fallbacks) {
resultDiv.textContent = `Trying ${AUTH_MODE_LABELS[fb]}…`;
const r = await _tryAuth(fb);
if (r.ok) { found = fb; result = r; break; }
if (!_isAuthError(r)) { result = r; break; } // non-auth error — stop cycling
}
if (found) {
// Update the dropdown to the working mode
if (authSel) authSel.value = found;
resultDiv.style.color = '#4ade80';
resultDiv.innerHTML = `${SVG_CHECK} Works with <strong>${AUTH_MODE_LABELS[found]}</strong> format — click Save to keep this setting`;
return;
}
}
if (result.ok) {
resultDiv.style.color = '#4ade80';
resultDiv.innerHTML = `${SVG_CHECK} Connection works`;
} else {
throw new Error(result.msg);
}
} catch (err) {
if (err.name === 'AbortError') return;
resultDiv.style.color = '#f87171';
resultDiv.innerHTML = `${SVG_CROSS} ${escHtml(err.message)}`;
} finally {
testBtn.disabled = false;
}
});
panel.querySelector('#ms2-save-general').addEventListener('click', () => {
CFG.apiKey = panel.querySelector('#ms2-s-apikey').value.trim();
CFG.authMode = panel.querySelector('#ms2-s-auth-mode')?.value || 'auto';
CFG.endpoint = epSel.value === 'custom'
? (panel.querySelector('#ms2-s-custom-ep').value.trim() || 'https://openrouter.ai/api/v1')
: epSel.value;
CFG.model = modelSel.value === '__custom__'
? (customModelInput.value.trim() || MODELS[0].id)
: modelSel.value;
toast(`${SVG_CHECK} General settings saved`);
closeAll();
});
panel.querySelector('#ms2-save-relay')?.addEventListener('click', () => {
const rawRelay = (panel.querySelector('#ms2-s-relay-url')?.value || '').trim();
const relay = rawRelay.replace(/\/$/, ''); // strip trailing slash
if (relay && !/^https?:\/\/.+/.test(relay)) {
toast(`${SVG_WARNING} Invalid URL — must start with http:// or https://`, 3500);
return;
}
try {
GM_setValue(P2P_GM_RELAY, relay || '');
toast(`${SVG_CHECK} Relay URL ${relay ? 'saved' : 'reset to default'}`);
} catch (e) {
toast(`${SVG_CROSS} Failed to save relay URL`);
}
});
panel.querySelector('#ms2-save-reply').addEventListener('click', () => {
CFG.defaultTone = panel.querySelector('#ms2-s-default-tone').value;
CFG.defaultInstruct = panel.querySelector('#ms2-s-default-instruct').value.trim();
CFG.autoNotify = panel.querySelector('#ms2-s-autonotify').checked;
if (CFG.autoNotify && isOnChatPage()) startObserver();
else if (!CFG.autoNotify) stopObserver();
toast(`${SVG_CHECK} Reply settings saved`);
closeAll();
});
panel.querySelector('#ms2-s-context').addEventListener('input', e => {
const counter = panel.querySelector('#ms2-ctx-count');
if (!counter) return;
const len = e.target.value.length;
counter.textContent = `${len} / 2000`;
counter.style.color = len >= 1800 ? '#f87171' : len >= 1500 ? '#fb923c' : '#6b7280';
});
panel.querySelector('#ms2-save-context').addEventListener('click', () => {
saveContext(panel.querySelector('#ms2-s-context').value);
toast(`${SVG_CHECK} Context saved`);
});
panel.querySelector('#ms2-ctx-inject-chk')?.addEventListener('change', e => {
setInjectCtx(e.target.checked);
toast(e.target.checked
? `${SVG_CHECK} Scene Context will now be sent to JanitorAI on every generation`
: `${SVG_CROSS} Scene Context injection disabled`, 3000);
});
const personaList = panel.querySelector('#ms2-persona-list');
const personaForm = panel.querySelector('#ms2-persona-form');
const personaAddBtn = panel.querySelector('#ms2-persona-add-btn');
const personaNameIn = panel.querySelector('#ms2-persona-name-input');
const personaDescIn = panel.querySelector('#ms2-persona-desc-input');
const personaSaveBtn = panel.querySelector('#ms2-persona-save-btn');
const personaCancelBtn = panel.querySelector('#ms2-persona-cancel-btn');
let _editingPersonaId = null;
function renderPersonaList() {
if (!personaList) return;
const personas = getPersonaLib();
if (!personas.length) {
personaList.innerHTML = '<div style="font-size:11px;color:#4b5563;padding:4px 2px;">No personas saved yet. Click + Add to create one.</div>';
return;
}
personaList.innerHTML = personas.map(p => `
<div class="ms2-pl-card">
<div style="flex:1;min-width:0;">
<div style="font-size:12px;color:#c4b5fd;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escHtml(p.name)}</div>
<div style="font-size:11px;color:#6b7280;margin-top:2px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;">${escHtml(p.desc)}</div>
</div>
<div style="display:flex;flex-direction:column;gap:3px;flex-shrink:0;">
<button data-pid="${escHtml(p.id)}" class="pl-use" style="background:rgba(16,185,129,0.15);border:1px solid rgba(16,185,129,0.35);border-radius:4px;color:#6ee7b7;font-size:10px;cursor:pointer;padding:2px 6px;">Use</button>
<button data-pid="${escHtml(p.id)}" class="pl-edit" style="background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.3);border-radius:4px;color:#a78bfa;font-size:10px;cursor:pointer;padding:2px 6px;">Edit</button>
<button data-pid="${escHtml(p.id)}" class="pl-del" style="background:none;border:1px solid rgba(255,255,255,0.08);border-radius:4px;color:#6b7280;font-size:10px;cursor:pointer;padding:2px 6px;">${SVG_TRASH}</button>
</div>
</div>`).join('');
}
function openPersonaForm(editId = null) {
_editingPersonaId = editId;
if (!editId) {
personaNameIn.value = '';
personaDescIn.value = '';
personaSaveBtn.textContent = 'Save';
}
personaForm.style.display = '';
personaNameIn.focus();
}
function closePersonaForm() {
personaForm.style.display = 'none';
_editingPersonaId = null;
personaNameIn.value = '';
personaDescIn.value = '';
personaSaveBtn.textContent = 'Save';
}
personaAddBtn?.addEventListener('click', () => openPersonaForm(null));
personaCancelBtn?.addEventListener('click', closePersonaForm);
personaSaveBtn?.addEventListener('click', () => {
const name = personaNameIn.value.trim();
const desc = personaDescIn.value.trim();
if (!name) { toast(`${SVG_WARNING} Enter a name for this persona`); personaNameIn.focus(); return; }
if (!desc) { toast(`${SVG_WARNING} Enter a description for this persona`); personaDescIn.focus(); return; }
const arr = getPersonaLib();
if (_editingPersonaId) {
const idx = arr.findIndex(x => x.id === _editingPersonaId);
if (idx !== -1) arr[idx] = { ...arr[idx], name, desc };
} else {
arr.push({ id: 'pl_' + Date.now().toString(36), name, desc });
}
savePersonaLib(arr);
closePersonaForm();
renderPersonaList();
toast(`${SVG_CHECK} Persona ${_editingPersonaId ? 'updated' : 'saved'}`);
});
personaDescIn?.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); personaSaveBtn.click(); }
});
personaList?.addEventListener('click', e => {
const btn = e.target.closest('button[data-pid]');
if (!btn) return;
const pid = btn.dataset.pid;
const p = getPersonaLib().find(x => x.id === pid);
if (!p) return;
if (btn.classList.contains('pl-use')) {
const ta = panel.querySelector('#ms2-s-context');
if (ta) { ta.value = p.desc; ta.dispatchEvent(new Event('input', { bubbles: true })); }
toast(`${SVG_PERSONA} Persona loaded into Scene Context`);
} else if (btn.classList.contains('pl-edit')) {
_editingPersonaId = p.id;
personaNameIn.value = p.name;
personaDescIn.value = p.desc;
personaForm.style.display = '';
personaSaveBtn.textContent = 'Update';
personaNameIn.focus();
personaForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else if (btn.classList.contains('pl-del')) {
savePersonaLib(getPersonaLib().filter(x => x.id !== p.id));
renderPersonaList();
toast(`${SVG_TRASH} Persona removed`);
}
});
renderPersonaList();
panel.querySelector('#ms2-persona-export-btn')?.addEventListener('click', () => {
const arr = getPersonaLib();
if (!arr.length) { toast(`${SVG_WARNING} No personas to export yet.`); return; }
const json = JSON.stringify({ version: 1, personas: arr }, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'JanitorV5-personas.json';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
toast(`${SVG_SAVE} Exported ${arr.length} persona${arr.length !== 1 ? 's' : ''}`);
});
const importFileInput = panel.querySelector('#ms2-persona-import-file');
panel.querySelector('#ms2-persona-import-btn')?.addEventListener('click', () => {
importFileInput?.click();
});
importFileInput?.addEventListener('change', () => {
const file = importFileInput.files?.[0];
if (!file) return;
importFileInput.value = '';
const reader = new FileReader();
reader.onload = ev => {
try {
const parsed = JSON.parse(ev.target.result);
const incoming = Array.isArray(parsed)
? parsed
: (Array.isArray(parsed?.personas) ? parsed.personas : null);
if (!incoming) { toast(`${SVG_WARNING} Invalid file — expected a personas JSON export.`); return; }
const valid = incoming.filter(p =>
p && typeof p.id === 'string' && typeof p.name === 'string' && typeof p.desc === 'string'
);
if (!valid.length) { toast(`${SVG_WARNING} No valid persona entries found in file.`); return; }
const existing = getPersonaLib();
const existingIds = new Set(existing.map(x => x.id));
const merged = [...existing, ...valid.filter(p => !existingIds.has(p.id))];
savePersonaLib(merged);
renderPersonaList();
const added = merged.length - existing.length;
toast(`${SVG_FOLDER} Imported ${added} new persona${added !== 1 ? 's' : ''}` +
(added < valid.length ? ' (' + (valid.length - added) + ' already existed)' : ''));
} catch {
toast(`${SVG_WARNING} Could not read file — make sure it is a valid JSON export.`);
}
};
reader.readAsText(file);
});
panel.querySelectorAll('[data-tab="context"]').forEach(btn =>
btn.addEventListener('click', () => { renderSumHistory(); })
);
(() => { renderSumHistory(); })();
panel.querySelector('#ms2-ctx-gen-btn')?.addEventListener('click', () => {
doGenerateSummary({ silent: false });
});
panel.querySelector('#ms2-ctx-save-global-btn')?.addEventListener('click', () => {
const text = panel.querySelector('#ms2-s-context')?.value || getContext();
if (!text.trim()) { toast(`${SVG_WARNING} No context text to save`); return; }
saveGlobalMemory(text.trim());
toast(`${SVG_SAVE} Context saved as global memory`);
});
panel.querySelector('#ms2-ctx-load-global-btn')?.addEventListener('click', () => {
const mem = getGlobalMemory().trim();
if (!mem) { toast(`${SVG_WARNING} No global memory saved yet`); return; }
const ta = panel.querySelector('#ms2-s-context');
if (!ta) return;
ta.value = mem;
ta.dispatchEvent(new Event('input', { bubbles: true }));
saveContext(mem);
toast(`${SVG_FOLDER} Global memory loaded into this chat`);
});
const _charId = getCurrentCharId();
const charRow = panel.querySelector('#ms2-ctx-char-row');
if (!_charId && charRow) charRow.style.display = 'none';
panel.querySelector('#ms2-ctx-save-char-btn')?.addEventListener('click', () => {
if (!_charId) { toast(`${SVG_WARNING} No character detected — are you on a chat page?`); return; }
const text = panel.querySelector('#ms2-s-context')?.value || getContext();
if (!text.trim()) { toast(`${SVG_WARNING} No context text to save`); return; }
saveCharGlobalMemory(_charId, text.trim());
toast(`${SVG_MEMORY} Memory saved for this character`);
});
panel.querySelector('#ms2-ctx-load-char-btn')?.addEventListener('click', () => {
if (!_charId) { toast(`${SVG_WARNING} No character detected — are you on a chat page?`); return; }
const mem = getCharGlobalMemory(_charId).trim() || getGlobalMemory().trim();
if (!mem) { toast(`${SVG_WARNING} No saved memory for this character yet`); return; }
const ta = panel.querySelector('#ms2-s-context');
if (!ta) return;
ta.value = mem;
ta.dispatchEvent(new Event('input', { bubbles: true }));
saveContext(mem);
toast(`${SVG_MEMORY} Character memory loaded`);
});
panel.querySelector('#ms2-ctx-autoload-save')?.addEventListener('click', () => {
setAutoLoadGlobal(panel.querySelector('#ms2-ctx-autoload-chk')?.checked || false);
toast(`${SVG_CHECK} Auto-load preference saved`);
});
panel.querySelector('#ms2-ctx-auto-save')?.addEventListener('click', () => {
const every = parseInt(panel.querySelector('#ms2-ctx-auto-every')?.value) || 0;
const auto = panel.querySelector('#ms2-ctx-auto-chk')?.checked || false;
setAutoSumEvery(every);
setAutoSumAuto(auto);
stopAutoSumObserver();
if (every > 0) startAutoSumObserver();
toast(every > 0
? `${SVG_CHECK} Auto-context: every ${every} messages${auto ? ', generates automatically' : ', notifies only'}`
: `${SVG_CHECK} Auto-context disabled`);
});
panel.querySelector('#ms2-ctx-export-hist')?.addEventListener('click', () => {
const hist = getSumHistory();
if (!hist.length) { toast('No summaries to export yet'); return; }
const blob = new Blob([JSON.stringify(hist, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `JanitorV5-summaries-${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
toast(`${SVG_SAVE} Exported ${hist.length} summar${hist.length !== 1 ? 'ies' : 'y'}`);
});
panel.querySelector('#ms2-ctx-clear-hist')?.addEventListener('click', function() {
const clearBtn = this;
showInlineConfirm(panel, {
message: 'Clear all saved summaries?',
insertBefore: clearBtn,
onConfirm: () => {
saveSumHistory([]);
renderSumHistory();
toast('History cleared');
},
});
});
renderPresets(panel);
function showTabHelp(title, bodyHtml) {
document.getElementById('tab-help-backdrop')?.remove();
const backdrop = document.createElement('div');
backdrop.className = 'ms2-backdrop';
backdrop.id = 'tab-help-backdrop';
backdrop.style.zIndex = '10000020';
const modal = document.createElement('div');
modal.className = 'ms2-modal';
modal.style.maxWidth = '520px';
modal.setAttribute('role', 'dialog');
modal.innerHTML = `
<div class="ms2-modal-header">
<div class="ms2-modal-title">${title}</div>
<button class="ms2-modal-close" id="tab-help-close">×</button>
</div>
<div class="ms2-modal-body" style="line-height:1.7;font-size:13px;color:#d1d5db;">${bodyHtml}</div>`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
modal.querySelector('#tab-help-close').addEventListener('click', close);
setTimeout(() => backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }), 300);
addEscapeClose(backdrop);
}
panel.querySelector('#gen-help-btn')?.addEventListener('click', () => showTabHelp(`${SVG_SETTINGS} General Settings`, `
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Two separate AI connections</strong><br>
JanitorV5 keeps your API and JanitorAI's AI completely independent:<br>
• <span style="color:#10b981;">Your API key (this tab)</span> — used by Smart Reply, Shorten, Summarise, and Context Generate. You choose the model and pay (or use free tiers) directly.<br>
• <span style="color:#a78bfa;">JanitorAI's own AI</span> — controlled by the Configure tab. Presets, forbidden words, thinking mode, and scene injection intercept JanitorAI's generation without touching your key.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Which provider to pick?</strong></p>
<ul style="margin:0 0 10px;padding-left:16px;color:#d1d5db;font-size:12px;line-height:1.8;">
<li><strong style="color:#e5e7eb;">OpenRouter</strong> — best default. 300+ models, many permanently free (suffix <code style="color:#a78bfa">:free</code>), one key for everything. Free models like Llama 4 and Qwen 3 handle RP very well. <a href="https://openrouter.ai/keys" target="_blank" style="color:#a78bfa;">openrouter.ai/keys</a></li>
<li><strong style="color:#e5e7eb;">Groq</strong> — fastest raw speed of any provider. Llama 4, Qwen 3, generous free tier. Best when you want instant replies. <a href="https://console.groq.com" target="_blank" style="color:#a78bfa;">console.groq.com</a></li>
<li><strong style="color:#e5e7eb;">Anthropic</strong> — Claude Opus 4 / Sonnet / Haiku. Best prose quality for creative writing. Different API format but handled automatically. <a href="https://console.anthropic.com" target="_blank" style="color:#a78bfa;">console.anthropic.com</a></li>
<li><strong style="color:#e5e7eb;">xAI</strong> — Grok 3 / Grok 4. Strong reasoning, OpenAI-compatible. <a href="https://console.x.ai" target="_blank" style="color:#a78bfa;">console.x.ai</a></li>
<li><strong style="color:#e5e7eb;">OpenAI</strong> — GPT-4o, GPT-4.1, o4-mini. Reliable, widely documented. <a href="https://platform.openai.com/api-keys" target="_blank" style="color:#a78bfa;">platform.openai.com</a></li>
<li><strong style="color:#e5e7eb;">Mistral</strong> — Magistral (reasoning), Devstral (code), Mistral Large. <a href="https://console.mistral.ai" target="_blank" style="color:#a78bfa;">console.mistral.ai</a></li>
</ul>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">API Key security</strong><br>
Stored locally in your browser via GM storage — never sent anywhere except directly to the provider endpoint. JanitorV5 never proxies or logs your key.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Custom endpoint</strong><br>
Select <em>Custom / other proxy URL…</em> to point JanitorV5 at any OpenAI-compatible proxy (LiteRouter, self-hosted Ollama, meganova.ai, etc.). Use <strong>Custom model ID</strong> if your proxy uses short IDs without a provider prefix.</p>
<p style="margin:0;color:#6b7280;font-size:11px;">${SVG_TIP} Free OpenRouter models work surprisingly well for roleplay. Start there before paying — upgrade to Claude or Grok only when you need top-tier prose.</p>
`));
panel.querySelector('#reply-help-btn')?.addEventListener('click', () => showTabHelp(`${SVG_REPLY} Reply Settings`, `
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Default Tone</strong><br>
Sets which tone is pre-selected every time you open Smart Reply. Skip this if you prefer choosing fresh each session — leaving it blank keeps you intentional. Combine it with a Style preset if your character always stays in one voice.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Default Custom Instruction</strong><br>
Pre-fills the instruction box on every open. Good for persistent rules like <em>"never break character"</em>, <em>"max 2 paragraphs"</em>, or <em>"no internal monologue"</em>. These stack on top of the active Style preset — don't repeat what's already in your persona note.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">${SVG_REROLL} Reroll</strong><br>
Re-runs the same tone and instruction with a fresh generation — no re-typing needed. Use it when the first output is technically correct but feels flat. If Reroll keeps producing similar results, change the tone or add a specific instruction to break the pattern.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">${SVG_KEYBOARD} Send</strong><br>
Injects the generated reply directly into JanitorAI's input field. You can edit before sending — the field stays editable after injection.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Notify on new AI message</strong><br>
Shows a subtle top toast when JanitorAI's character responds while you have a modal open or are scrolled away. Doesn't interrupt anything — just a quiet heads-up.</p>
<p style="margin:0;color:#6b7280;font-size:11px;">${SVG_TIP} Best workflow: set a Default Tone, create a Style preset for your character, and use Reroll freely — it's fast and costs almost nothing on free-tier models.</p>
`));
panel.querySelector('#styles-help-btn')?.addEventListener('click', () => showTabHelp(`${SVG_STYLES} Styles — Character Presets`, `
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">What Styles do</strong><br>
A Style preset snapshots your character's entire voice: default tone, persona note, and any partner context. Activating one auto-fills Smart Reply every time you open it — no re-typing needed across sessions or characters.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">When to use multiple presets</strong><br>
Create one preset per character (or per <em>mode</em> of the same character — e.g. "Cold Rvie" vs "Soft Rvie"). Switch between them in Settings without touching the actual reply flow. Pairs well with the Persona Library in the Context tab, which handles scene notes separately.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Persona note vs Default Instruction</strong><br>
• <strong>Persona note (in a Style)</strong> — who your character <em>is</em>: speech patterns, personality, quirks.<br>
• <strong>Default Instruction (Reply tab)</strong> — how to write <em>this reply</em>: length, format, restrictions.<br>
Don't duplicate — keep identity in the preset, behaviour constraints in the instruction.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Quick workflow</strong><br>
+ New Preset → name it → pick tone → write persona note → Save Preset → hit <strong>Use</strong>. Active preset is shown with a highlight. Only one preset is active at a time.</p>
<p style="margin:0;color:#6b7280;font-size:11px;">${SVG_TIP} Keep persona notes concise — 3 to 5 tight sentences beat a wall of text. Models follow shorter, sharper instructions more reliably.</p>
`));
panel.querySelector('#ctx-help-btn')?.addEventListener('click', () => showTabHelp(`${SVG_CONTEXT} Context Tab`, `
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Two layers of context</strong><br>
• <strong>Your API (Smart Reply / Shorten)</strong> — Scene Context is appended to every request automatically.<br>
• <strong>JanitorAI's own AI</strong> — toggle <em>Send to JanitorAI's AI</em> to inject context into JanitorAI's generation prompt on every message. Saves on toggle, no extra steps.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Scene Context box</strong><br>
Write the current situation: location, mood, recent events, unresolved tension. Keep it present-tense and specific. Up to 2000 characters. Each chat URL gets its own stored context — switching chats doesn't overwrite anything.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Generate vs Summarise — which to use</strong><br>
• <strong>${SVG_SPARKLE} Generate</strong> — reads currently <em>visible</em> messages → writes a current-situation note. Fast, narrow, meant for live sessions.<br>
• <strong>FAB → Summarise</strong> — loads <em>full chat history</em> → writes a long-arc summary (who they are, relationship arc, key story beats). Output goes to clipboard; paste into JanitorAI's Chat Memory panel for persistent long-term memory.<br>
Use Generate frequently during a session. Use Summarise when you want to archive the full story or before starting a continuation.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Global memory & character memory</strong><br>
<strong>Save Global / Load Global</strong> — persists across all chats. Good for your general persona or world notes.<br>
<strong>Save for Character / Load for Character</strong> — per-character memory keyed to the current chat URL. Load falls back to global if no character memory exists yet.<br>
<strong>Auto-load</strong> toggle fills an empty new chat automatically when you navigate to it.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">${SVG_PERSONA} Persona Library</strong><br>
Named entries for reusable character descriptions. Hit <strong>Use</strong> to load one into Scene Context instantly. Export the whole library as <code>.json</code> for backups — Import merges by ID, so re-importing never creates duplicates. Use the speed-dial <strong>Personas</strong> button to switch characters mid-session without opening Settings.</p>
<p style="margin:0;color:#6b7280;font-size:11px;">${SVG_TIP} Keep Scene Context short and current — 150–300 characters of sharp situational detail beats a long summary. Let Summarise handle the backstory; let Generate handle the scene.</p>
`));
apRenderPanel(panel);
}
// ─── ADV. PROMPT PANEL ────────────────────────────────────────────────────
function apRenderPanel(panel) {
const presetSel = panel.querySelector('#ap-preset-sel');
const modulesWrap = panel.querySelector('#ap-modules-wrap');
const noPresetMsg = panel.querySelector('#ap-no-preset-msg');
const moduleList = panel.querySelector('#ap-module-list');
const unattachedSel = panel.querySelector('#ap-unattached-sel');
if (!presetSel) return;
panel.querySelector('#ap-help-btn')?.addEventListener('click', () => {
document.getElementById('ap-help-backdrop')?.remove();
const backdrop = document.createElement('div');
backdrop.className = 'ms2-backdrop';
backdrop.id = 'ap-help-backdrop';
backdrop.style.zIndex = '10000020';
const modal = document.createElement('div');
modal.className = 'ms2-modal';
modal.style.maxWidth = '560px';
modal.setAttribute('role', 'dialog');
modal.innerHTML = `
<div class="ms2-modal-header">
<div class="ms2-modal-title">${SVG_CONFIG} How Configure Works</div>
<button class="ms2-modal-close" id="ap-help-close">×</button>
</div>
<div class="ms2-modal-body" style="line-height:1.7;font-size:13px;color:#d1d5db;">
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">What Configure injects into JanitorAI's AI</strong></p>
<ul style="margin:0 0 10px;padding-left:18px;color:#d1d5db;line-height:1.8;">
<li><span style="color:#10b981;">${SVG_CHECK}</span> <strong>Active Preset</strong> — replaces the system prompt on every generation. Only the currently selected preset fires.</li>
<li><span style="color:#10b981;">${SVG_CHECK}</span> <strong>Forbidden Words</strong> — hard-banned from every generation. Unlimited, unlike JanitorAI's built-in 10-word cap.</li>
<li><span style="color:#10b981;">${SVG_CHECK}</span> <strong>Enable Thinking</strong> — adds a <thinking> reasoning step before each reply. Best on Claude 3.5+, o1, Gemini 2.0+, or any model with extended reasoning.</li>
<li><span style="color:#10b981;">${SVG_CHECK}</span> <strong>Scene Context inject</strong> — appended when "Send to JanitorAI's AI" is ON in the Context tab. Works with or without an active preset.</li>
<li><span style="color:#f87171;">${SVG_CROSS}</span> <strong>Smart Reply / Shorten / Summarise</strong> — these use <em>your own</em> API key, not JanitorAI's generation. Configure doesn't touch them.</li>
</ul>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Presets & modules</strong><br>
A <strong>preset</strong> is a stack of instruction <strong>modules</strong>. Each module is one block — writing style, character rules, scene restrictions, etc. Drag to reorder, toggle to enable/disable per-module. Only the active (checked) preset is sent. Switch presets mid-session without reloading anything.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Enable Advanced Prompting toggle</strong><br>
Master switch for the entire Configure system. OFF = nothing is injected, JanitorAI runs unmodified. The status dot goes green on the first successful injection after you turn it ON.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Forbidden Words</strong><br>
Type a word or phrase → press + or Enter. Appears as a removable chip. These stack on top of JanitorAI's own banned words — use them to stop the AI repeating specific phrases, names, or filler openers that keep showing up.</p>
<p style="margin:0 0 10px;"><strong style="color:#c4b5fd;">Enable Thinking</strong><br>
Works independently of any preset — just requires Advanced Prompting to be ON. If the model ignores it or outputs visible <thinking> tags, your model doesn't support extended reasoning; turn this off for that model.</p>
<p style="margin:0;color:#6b7280;font-size:11px;">${SVG_TIP} Write modules as direct instructions, not descriptions: "Write responses under 150 words" beats "The AI should try to be concise." Short, imperative phrasing gets followed more reliably.</p>
</div>`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
modal.querySelector('#ap-help-close').addEventListener('click', close);
setTimeout(() => backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }), 300);
addEscapeClose(backdrop);
});
panel.querySelector('#ap-enabled-chk').addEventListener('change', e => {
AP.enabled = e.target.checked;
});
const thinkingChk = panel.querySelector('#ap-thinking-chk');
if (thinkingChk) {
thinkingChk.checked = getAPThinking();
thinkingChk.addEventListener('change', e => setAPThinking(e.target.checked));
}
const forbiddenInput = panel.querySelector('#ap-forbidden-input');
const forbiddenAddBtn = panel.querySelector('#ap-forbidden-add-btn');
const forbiddenTagsEl = panel.querySelector('#ap-forbidden-tags');
if (forbiddenInput && forbiddenAddBtn && forbiddenTagsEl) {
let forbiddenWords = getAPForbiddenWords().trim().split('\n').filter(Boolean);
function renderCounter() {
const counterEl = panel.querySelector('#ap-forbidden-counter');
if (!counterEl) return;
const scriptCount = forbiddenWords.length;
if (scriptCount === 0) {
counterEl.style.display = 'none';
return;
}
const nativeCount = parseInt(gget('ap_native_ban_count', '-1'));
counterEl.style.display = 'block';
if (nativeCount >= 0) {
counterEl.innerHTML =
`⚡ <strong>${nativeCount + scriptCount}</strong> words active this session` +
` <span style="color:#6b7280">(${nativeCount} native + ${scriptCount} from script)</span>`;
} else {
counterEl.innerHTML =
`⚡ <strong>${scriptCount}</strong> extra word${scriptCount !== 1 ? 's' : ''} queued — ` +
`<span style="color:#6b7280">send a message to see the full count</span>`;
}
}
function renderForbiddenTags() {
forbiddenTagsEl.innerHTML = '';
if (!forbiddenWords.length) {
forbiddenTagsEl.innerHTML = '<span style="font-size:11px;color:#4b5563;padding:2px 4px;">No banned words yet — add one above.</span>';
renderCounter();
return;
}
forbiddenWords.forEach((word, idx) => {
const tag = document.createElement('span');
tag.style.cssText =
'display:inline-flex;align-items:center;gap:3px;' +
'background:rgba(139,92,246,0.15);color:#c4b5fd;' +
'padding:2px 7px;border-radius:4px;font-size:11px;' +
'border:1px solid rgba(139,92,246,0.3);';
tag.innerHTML =
`<span>${escHtml(word)}</span>` +
`<button data-idx="${idx}" style="background:none;border:none;color:#f87171;cursor:pointer;font-size:10px;line-height:1;padding:0 0 0 2px;">×</button>`;
tag.querySelector('button').addEventListener('click', () => {
forbiddenWords.splice(idx, 1);
setAPForbiddenWords(forbiddenWords.join('\n'));
renderForbiddenTags();
});
forbiddenTagsEl.appendChild(tag);
});
renderCounter();
}
function addForbiddenWord() {
const w = forbiddenInput.value.trim();
if (!w) return;
if (!forbiddenWords.includes(w)) {
forbiddenWords.push(w);
setAPForbiddenWords(forbiddenWords.join('\n'));
renderForbiddenTags();
}
forbiddenInput.value = '';
forbiddenInput.focus();
}
forbiddenAddBtn.addEventListener('click', addForbiddenWord);
forbiddenInput.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); addForbiddenWord(); }
});
renderForbiddenTags();
}
function fillPresetSel() {
const presets = AP.getPresets();
presetSel.innerHTML = `<option value="">— Select preset —</option>` +
presets.map(p => `<option value="${escHtml(p.id)}" ${p.id === AP.selected ? 'selected' : ''}>${escHtml(p.name)}</option>`).join('');
}
function refresh() {
fillPresetSel();
const preset = apGetSelected();
const hasPreset = !!preset;
modulesWrap.style.display = hasPreset ? '' : 'none';
noPresetMsg.style.display = hasPreset ? 'none' : '';
if (hasPreset) {
renderModuleList();
fillUnattachedSel();
updateTokenBar();
}
apRefreshSaveBtn();
}
function updateTokenBar() {
const combined = apGetCombinedPrompt();
const tokens = apEstimateTokens(combined || '');
const MAX_DISP = 4096;
const pct = Math.min(100, (tokens / MAX_DISP) * 100);
const fill = panel.querySelector('#ap-token-fill');
const label = panel.querySelector('#ap-token-count');
if (fill) fill.style.width = pct + '%';
if (label) label.textContent = `~${tokens} tokens`;
}
function renderModuleList() {
const preset = apGetSelected();
if (!preset) { moduleList.innerHTML = ''; return; }
const attached = (preset.modules || [])
.filter(m => m.attached !== false)
.sort((a, b) => a.order - b.order);
if (!attached.length) {
moduleList.innerHTML = '<div class="ap-empty">No attached modules. Add one below.</div>';
return;
}
moduleList.innerHTML = '';
attached.forEach(mod => {
const item = document.createElement('div');
item.className = 'ap-module-item' + (mod.enabled ? '' : ' ap-disabled');
item.draggable = true;
item.dataset.mid = mod.id;
item.innerHTML = `
<span class="ap-drag-handle" title="Drag to reorder">⠿</span>
<span class="ap-module-name" title="${escHtml(mod.name)}">${escHtml(mod.name)}</span>
<div class="ap-module-btns">
<button class="ap-module-btn" data-act="edit" title="Edit content">✎</button>
<button class="ap-module-btn ap-del" data-act="unattach" title="Unattach">⊟</button>
<label class="ap-module-switch" title="${mod.enabled ? 'Disable' : 'Enable'}">
<input type="checkbox" ${mod.enabled ? 'checked' : ''}>
<span class="ap-module-thumb"></span>
</label>
</div>`;
item.querySelector('input[type=checkbox]').addEventListener('change', e => {
const p = apGetSelected();
const m = p.modules.find(x => x.id === mod.id);
if (m) { m.enabled = e.target.checked; apMarkDirty(); renderModuleList(); updateTokenBar(); }
});
item.querySelectorAll('[data-act]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
if (btn.dataset.act === 'edit') apOpenModuleEditor(mod.id, refresh);
if (btn.dataset.act === 'unattach') {
const p = apGetSelected();
const m = p.modules.find(x => x.id === mod.id);
if (m) { m.attached = false; m.enabled = false; apMarkDirty(); refresh(); }
}
});
});
item.addEventListener('dragstart', e => {
e.dataTransfer.effectAllowed = 'move';
item.classList.add('ap-dragging');
});
item.addEventListener('dragend', e => {
item.classList.remove('ap-dragging');
moduleList.querySelectorAll('.ap-module-item').forEach(i => i.classList.remove('ap-drag-over'));
if (e.dataTransfer.dropEffect === 'none') return;
const p = apGetSelected();
if (p) {
[...moduleList.querySelectorAll('.ap-module-item')].forEach((el, idx) => {
const m = p.modules.find(x => x.id === el.dataset.mid);
if (m) m.order = idx;
});
apMarkDirty();
}
});
item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('ap-drag-over'); });
item.addEventListener('dragleave', () => item.classList.remove('ap-drag-over'));
item.addEventListener('drop', e => {
e.preventDefault();
item.classList.remove('ap-drag-over');
const dragging = moduleList.querySelector('.ap-dragging');
if (dragging && dragging !== item) {
const all = [...moduleList.children];
dragging.parentNode.insertBefore(
dragging,
all.indexOf(dragging) < all.indexOf(item) ? item.nextSibling : item
);
}
});
moduleList.appendChild(item);
});
}
function fillUnattachedSel() {
const preset = apGetSelected();
if (!preset) { unattachedSel.innerHTML = ''; return; }
const unattached = (preset.modules || []).filter(m => !m.attached);
unattachedSel.innerHTML = unattached.length
? `<option value="">Select module…</option>` + unattached.map(m => `<option value="${escHtml(m.id)}">${escHtml(m.name)}</option>`).join('')
: `<option value="">No unattached modules</option>`;
}
presetSel.addEventListener('change', () => {
if (_apDirty) {
const incoming = presetSel.value;
presetSel.value = AP.selected;
showInlineConfirm(panel, {
message: 'Unsaved changes — discard them?',
insertBefore: presetSel.closest('.ap-row') || presetSel.parentElement,
onConfirm: () => {
AP.selected = incoming;
presetSel.value = incoming;
apLoadFromStorage();
refresh();
},
});
return;
}
AP.selected = presetSel.value;
apLoadFromStorage();
refresh();
});
panel.querySelector('#ap-new-preset-btn').addEventListener('click', () => {
showInlineNameInput(panel, {
placeholder: 'New preset name…',
initialValue: '',
insertAnchor: presetSel.closest('.ap-row') || presetSel.parentElement,
onConfirm: (name) => {
const presets = AP.getPresets();
let n = name, c = 1;
while (presets.some(p => p.name === n)) n = `${name} (${c++})`;
const np = { id: apUUID(), name: n, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), modules: [] };
presets.push(np);
AP.savePresets(presets);
AP.selected = np.id;
apLoadFromStorage();
refresh();
},
});
});
panel.querySelector('#ap-rename-preset-btn').addEventListener('click', () => {
const p = apGetSelected();
if (!p) { toast('Select a preset first'); return; }
showInlineNameInput(panel, {
placeholder: 'Preset name…',
initialValue: p.name,
insertAnchor: presetSel.closest('.ap-row') || presetSel.parentElement,
onConfirm: (name) => {
if (name === p.name) return;
p.name = name;
apMarkDirty();
apSaveWorking();
refresh();
},
});
});
panel.querySelector('#ap-export-btn').addEventListener('click', () => {
const p = apGetSelected();
if (!p) { toast('Select a preset first'); return; }
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' });
const a = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(blob),
download: p.name.replace(/[^a-z0-9]/gi, '_') + '_preset.json',
});
document.body.appendChild(a); a.click();
setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(a.href); }, 1000);
});
panel.querySelector('#ap-import-btn').addEventListener('click', () => {
const inp = Object.assign(document.createElement('input'), { type: 'file', accept: '.json' });
inp.addEventListener('change', async () => {
try {
const data = JSON.parse(await inp.files[0].text());
if (!data.name || !Array.isArray(data.modules)) { toast('Invalid preset file'); return; }
const presets = AP.getPresets();
let n = data.name, c = 1;
while (presets.some(p => p.name === n)) n = `${data.name} (${c++})`;
data.name = n;
data.id = apUUID();
data.createdAt = data.updatedAt = new Date().toISOString();
data.modules.forEach(m => { if (typeof m.attached !== 'boolean') m.attached = true; });
presets.push(data);
AP.savePresets(presets);
AP.selected = data.id;
apLoadFromStorage();
refresh();
toast(`${SVG_CHECK} Preset imported`);
} catch(e) { toast('Import failed: ' + e.message); }
});
inp.click();
});
panel.querySelector('#ap-delete-preset-btn').addEventListener('click', () => {
const p = apGetSelected();
if (!p) { toast('Select a preset first'); return; }
const delBtn = panel.querySelector('#ap-delete-preset-btn');
showInlineConfirm(panel, {
message: `Delete "${p.name}"?`,
insertBefore: delBtn,
onConfirm: () => {
AP.savePresets(AP.getPresets().filter(x => x.id !== p.id));
AP.selected = '';
_apWorking = null;
_apDirty = false;
toast('Preset deleted');
refresh();
},
});
});
panel.querySelector('#ap-attach-btn').addEventListener('click', () => {
const p = apGetSelected();
if (!p) { toast('Select a preset first'); return; }
const id = unattachedSel.value;
if (!id) {
showInlineNameInput(panel, {
placeholder: 'Module name…',
initialValue: '',
insertAnchor: panel.querySelector('#ap-attach-btn'),
onConfirm: (name) => {
const mod = { id: apUUID(), name, content: '', enabled: false, order: p.modules.length, attached: false };
p.modules.push(mod);
apMarkDirty();
apOpenModuleEditor(mod.id, refresh);
refresh();
},
});
return;
}
const m = p.modules.find(x => x.id === id);
if (m) {
m.attached = true;
m.enabled = true;
m.order = p.modules.filter(x => x.attached).length - 1;
apMarkDirty(); refresh();
}
});
panel.querySelector('#ap-del-module-btn').addEventListener('click', () => {
const p = apGetSelected();
const id = unattachedSel.value;
if (!p || !id) return;
const m = p.modules.find(x => x.id === id);
if (!m) return;
const delModBtn = panel.querySelector('#ap-del-module-btn');
showInlineConfirm(panel, {
message: `Delete module "${m.name}"?`,
insertBefore: delModBtn,
onConfirm: () => {
p.modules = p.modules.filter(x => x.id !== id);
apMarkDirty(); refresh();
},
});
});
panel.querySelector('#ap-new-module-btn').addEventListener('click', () => {
const p = apGetSelected();
if (!p) { toast('Select a preset first'); return; }
showInlineNameInput(panel, {
placeholder: 'Module name…',
initialValue: '',
insertAnchor: panel.querySelector('#ap-new-module-btn'),
onConfirm: (name) => {
const mod = { id: apUUID(), name, content: '', enabled: false, order: p.modules.length, attached: false };
p.modules.push(mod);
apMarkDirty();
apOpenModuleEditor(mod.id, refresh);
refresh();
},
});
});
panel.querySelector('#ap-save-btn').addEventListener('click', () => {
apSaveWorking(); toast(`${SVG_CHECK} Preset saved`); refresh();
});
panel.querySelector('#ap-discard-btn').addEventListener('click', () => {
apLoadFromStorage(); refresh();
});
refresh();
}
// ─── ADV. PROMPT — MODULE EDITOR MODAL ────────────────────────────────────
function apOpenModuleEditor(moduleId, onSave) {
const preset = apGetSelected();
if (!preset) return;
const mod = preset.modules.find(m => m.id === moduleId);
if (!mod) return;
document.getElementById('ap-editor-backdrop')?.remove();
const backdrop = document.createElement('div');
backdrop.className = 'ms2-backdrop';
backdrop.id = 'ap-editor-backdrop';
backdrop.style.zIndex = '10000010';
const modal = document.createElement('div');
modal.className = 'ms2-modal';
modal.style.maxWidth = '640px';
modal.setAttribute('role', 'dialog');
modal.innerHTML = `
<div class="ms2-modal-header">
<div class="ms2-modal-title">✎ Edit Module</div>
<button class="ms2-modal-close" id="ap-editor-close">×</button>
</div>
<div class="ms2-modal-body">
<label class="ms2-field-label">Module Name</label>
<input type="text" class="ms2-input" id="ap-mod-name" value="${escHtml(mod.name)}" style="margin-bottom:12px;">
<label class="ms2-field-label">Content
<span id="ap-mod-tokens" style="font-weight:400;text-transform:none;letter-spacing:0;color:#6b7280;margin-left:6px;"></span>
</label>
<textarea class="ms2-input ms2-textarea-lg" id="ap-mod-content" style="min-height:240px;font-family:monospace;font-size:12px;">${escHtml(mod.content)}</textarea>
</div>
<div class="ms2-modal-footer">
<button class="ms2-btn-action ms2-btn-generate" id="ap-editor-apply">Apply</button>
<button class="ms2-btn-action ms2-btn-retry" id="ap-editor-cancel">Cancel</button>
</div>`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
const nameInp = modal.querySelector('#ap-mod-name');
const contentTA = modal.querySelector('#ap-mod-content');
const tokenLabel = modal.querySelector('#ap-mod-tokens');
function updateEditorTokens() {
tokenLabel.textContent = `~${apEstimateTokens(contentTA.value)} tokens`;
}
contentTA.addEventListener('input', updateEditorTokens);
updateEditorTokens();
const close = () => backdrop.remove();
modal.querySelector('#ap-editor-close').addEventListener('click', close);
modal.querySelector('#ap-editor-cancel').addEventListener('click', close);
setTimeout(() => backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }), 300);
addEscapeClose(backdrop);
modal.querySelector('#ap-editor-apply').addEventListener('click', () => {
mod.name = nameInp.value.trim() || mod.name;
mod.content = contentTA.value;
apMarkDirty();
if (onSave) onSave();
close();
});
}
function showInlineConfirm(panel, { message, onConfirm, insertBefore }) {
panel.querySelector('#ap-inline-confirm-wrap')?.remove();
const wrap = document.createElement('div');
wrap.id = 'ap-inline-confirm-wrap';
wrap.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:8px;font-size:12px;';
const label = document.createElement('span');
label.style.cssText = 'flex:1;color:#f87171;';
label.textContent = message;
const yesBtn = document.createElement('button');
yesBtn.className = 'ap-icon-btn';
yesBtn.style.color = '#f87171';
yesBtn.innerHTML = `${SVG_CHECK} Yes`;
const noBtn = document.createElement('button');
noBtn.className = 'ap-icon-btn';
noBtn.textContent = 'Cancel';
wrap.append(label, yesBtn, noBtn);
insertBefore.parentElement.insertBefore(wrap, insertBefore);
yesBtn.addEventListener('click', () => { wrap.remove(); onConfirm(); });
noBtn.addEventListener('click', () => wrap.remove());
}
function showInlineNameInput(panel, { placeholder, initialValue, insertAnchor, onConfirm }) {
panel.querySelector('#ap-inline-name-wrap')?.remove();
const wrap = document.createElement('div');
wrap.id = 'ap-inline-name-wrap';
wrap.style.cssText = 'display:flex;gap:6px;margin-bottom:8px;align-items:center;';
const inp = document.createElement('input');
inp.className = 'ms2-input';
inp.placeholder = placeholder;
inp.value = initialValue || '';
inp.style.cssText = 'margin-bottom:0;flex:1;min-width:0;';
const confirmBtn = document.createElement('button');
confirmBtn.className = 'ap-icon-btn';
confirmBtn.innerHTML = SVG_CHECK;
confirmBtn.title = 'Confirm';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'ap-icon-btn';
cancelBtn.innerHTML = SVG_CROSS;
cancelBtn.title = 'Cancel';
wrap.append(inp, confirmBtn, cancelBtn);
insertAnchor.parentElement.insertBefore(wrap, insertAnchor);
inp.focus();
if (initialValue) inp.select();
const doConfirm = () => {
const val = inp.value.trim();
if (!val) { inp.focus(); return; }
wrap.remove();
onConfirm(val);
};
confirmBtn.addEventListener('click', doConfirm);
cancelBtn.addEventListener('click', () => wrap.remove());
inp.addEventListener('keydown', e => {
if (e.key === 'Enter') doConfirm();
if (e.key === 'Escape') { e.stopPropagation(); wrap.remove(); }
});
// ─── PRESETS PANEL ─────────────────────────────────────────────────────────
}
function renderPresets(panel) {
const listEl = panel.querySelector('#ms2-presets-list');
const newBtn = panel.querySelector('#ms2-new-preset-btn');
const editorEl = panel.querySelector('#ms2-preset-editor-wrap');
if (!listEl) return;
const presets = getPresets();
const activeId = CFG.activePreset;
if (presets.length === 0) {
listEl.innerHTML = '<div class="ms2-presets-empty">No presets yet. Create one to save your character\'s voice.</div>';
} else {
listEl.innerHTML = presets.map(p => {
const tone = TONES.find(t => t.id === p.tone);
const isActive = p.id === activeId;
return `
<div class="ms2-preset-item ${isActive ? 'is-active' : ''}">
<div class="ms2-preset-info">
<div class="ms2-preset-name">${isActive ? '● ' : ''}${escHtml(p.name)}</div>
${tone ? `<div class="ms2-preset-tone">${escHtml(tone.label)}</div>` : ''}
</div>
<div class="ms2-preset-actions">
<button class="ms2-preset-btn ${isActive ? 'ms2-preset-active' : 'ms2-preset-use'}" data-pid="${escHtml(p.id)}" data-action="toggle">${isActive ? `${SVG_CHECK} Active` : 'Use'}</button>
<button class="ms2-preset-btn ms2-preset-edit" data-pid="${escHtml(p.id)}" data-action="edit">Edit</button>
<button class="ms2-preset-btn ms2-preset-del" data-pid="${escHtml(p.id)}" data-action="del">${SVG_CROSS}</button>
</div>
</div>`;
}).join('');
}
listEl.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', () => {
const pid = btn.dataset.pid;
const action = btn.dataset.action;
if (action === 'toggle') {
CFG.activePreset = (CFG.activePreset === pid) ? null : pid;
renderPresets(panel);
toast(CFG.activePreset === pid ? `${SVG_CHECK} Preset activated` : 'Preset deactivated');
} else if (action === 'edit') {
const preset = getPresets().find(p => p.id === pid);
if (preset) showPresetEditor(panel, preset);
} else if (action === 'del') {
showInlineConfirm(panel, {
message: `Delete "${getPresets().find(p => p.id === pid)?.name || 'this preset'}"?`,
insertBefore: btn,
onConfirm: () => {
if (CFG.activePreset === pid) CFG.activePreset = null;
savePresets(getPresets().filter(p => p.id !== pid));
renderPresets(panel);
toast('Preset deleted');
},
});
}
});
});
newBtn.onclick = () => {
if (getPresets().length >= 10) { toast('Maximum 10 presets'); return; }
showPresetEditor(panel, null);
};
}
function showPresetEditor(panel, existingPreset) {
const listEl = panel.querySelector('#ms2-presets-list');
const newBtn = panel.querySelector('#ms2-new-preset-btn');
const editorEl = panel.querySelector('#ms2-preset-editor-wrap');
const p = existingPreset || { id: apUUID(), name: '', tone: '', personaNote: '', characterContext: '' };
const isNew = !existingPreset;
listEl.style.display = 'none';
newBtn.style.display = 'none';
editorEl.style.display = '';
const toneOpts = [{ id: '', label: '— None —' }, ...TONES].map(t =>
`<option value="${escHtml(t.id)}" ${p.tone === t.id ? 'selected' : ''}>${escHtml(t.label)}</option>`
).join('');
editorEl.innerHTML = `
<div class="ms2-label" style="margin-bottom:10px;">${isNew ? 'New Preset' : 'Edit Preset'}</div>
<label class="ms2-field-label">Preset Name *</label>
<input type="text" class="ms2-input" id="ms2-pe-name" value="${escHtml(p.name)}" placeholder="e.g. Rvie's Teasing Mode">
<label class="ms2-field-label">Default Tone</label>
<select class="ms2-select" id="ms2-pe-tone">${toneOpts}</select>
<label class="ms2-field-label">Your Character's Persona Note</label>
<textarea class="ms2-input ms2-textarea-sm" id="ms2-pe-persona" placeholder="Describe how your character talks, acts, and thinks…">${escHtml(p.personaNote || '')}</textarea>
<label class="ms2-field-label">Other Character Context <span style="font-weight:400;">(optional)</span></label>
<textarea class="ms2-input ms2-textarea-sm" id="ms2-pe-charctx" placeholder="Notes about the AI character you're roleplaying with…">${escHtml(p.characterContext || '')}</textarea>
<div class="ms2-settings-actions">
<button class="ms2-btn-save" id="ms2-pe-save">Save Preset</button>
<button class="ms2-btn-cancel" id="ms2-pe-cancel">Cancel</button>
</div>`;
editorEl.querySelector('#ms2-pe-cancel').addEventListener('click', () => {
editorEl.style.display = 'none';
editorEl.innerHTML = '';
listEl.style.display = '';
newBtn.style.display = '';
renderPresets(panel);
});
editorEl.querySelector('#ms2-pe-save').addEventListener('click', () => {
const name = editorEl.querySelector('#ms2-pe-name').value.trim();
if (!name) { toast('Preset name is required'); return; }
const updated = {
id: p.id,
name,
tone: editorEl.querySelector('#ms2-pe-tone').value,
personaNote: editorEl.querySelector('#ms2-pe-persona').value.trim(),
characterContext: editorEl.querySelector('#ms2-pe-charctx').value.trim(),
};
const presets = getPresets();
const idx = presets.findIndex(x => x.id === updated.id);
if (idx >= 0) presets[idx] = updated; else presets.push(updated);
savePresets(presets);
editorEl.style.display = 'none';
editorEl.innerHTML = '';
listEl.style.display = '';
newBtn.style.display = '';
renderPresets(panel);
toast(`${SVG_CHECK} Preset saved`);
});
}
// ─── FAB (draggable, tap = speed-dial, long-press = settings) ─────────────
const LONG_PRESS_MS = 600;
/**
* Creates the floating action button (FAB) and wires all its interaction
* events if it doesn't already exist.
*
* Interaction model:
* - **Tap / click** → opens the speed-dial panel.
* - **Long-press (≥ 500 ms)** → opens the Settings modal.
* - **Drag** → repositions the FAB; final position is persisted to GM storage.
*
* The FAB position is clamped to the visible viewport on resize / orientation
* change so it never becomes unreachable on small screens.
*/
function ensureFAB() {
if (document.getElementById('ms2-fab')) return;
const fab = document.createElement('button');
fab.id = 'ms2-fab';
fab.title = 'Tap: speed-dial | Hold: settings';
fab.innerHTML = `${SVG_SETTINGS}<div id="ms2-fab-ring"></div>`;
fab.style.right = CFG.fabRight + 'px';
fab.style.bottom = CFG.fabBottom + 'px';
document.body.appendChild(fab);
const ring = fab.querySelector('#ms2-fab-ring');
if (!gget('ms2_v2_hintSeen', false)) {
gset('ms2_v2_hintSeen', true);
const hint = document.createElement('div');
hint.id = 'ms2-fab-hint';
hint.textContent = 'Tap for menu • Hold for settings';
document.body.appendChild(hint);
setTimeout(() => {
const r = fab.getBoundingClientRect();
hint.style.right = (document.documentElement.clientWidth - r.left + 6) + 'px';
hint.style.bottom = (document.documentElement.clientHeight - r.top + 6) + 'px';
}, 0);
setTimeout(() => hint.remove(), 3500);
}
let startX, startY, startRight, startBottom;
let wasMoved = false, longFired = false;
let longTimer = null, ringTimer = null, ringDelayTimer = null;
const RING_DELAY = 280;
function startProgress() {
const t0 = performance.now();
const remaining = LONG_PRESS_MS - RING_DELAY;
const step = now => {
const pct = Math.min(100, ((now - t0) / remaining) * 100);
ring.style.background = `conic-gradient(rgba(139,92,246,0.75) ${pct}%, transparent ${pct}%)`;
if (pct < 100) ringTimer = requestAnimationFrame(step);
};
ringTimer = requestAnimationFrame(step);
}
function cancelProgress() {
clearTimeout(ringDelayTimer);
cancelAnimationFrame(ringTimer);
ringDelayTimer = null;
ringTimer = null;
ring.style.background = 'none';
}
function beginDrag(cx, cy) {
wasMoved = false;
longFired = false;
startX = cx;
startY = cy;
startRight = parseInt(fab.style.right, 10) || CFG.fabRight;
startBottom = parseInt(fab.style.bottom, 10) || CFG.fabBottom;
fab.classList.add('ms2-pressing');
ringDelayTimer = setTimeout(() => {
if (!wasMoved && !longFired) startProgress();
}, RING_DELAY);
longTimer = setTimeout(() => {
if (!wasMoved) {
longFired = true;
cancelProgress();
fab.classList.remove('ms2-pressing');
closeDial();
openSettingsModal('general');
}
}, LONG_PRESS_MS);
}
function moveDrag(cx, cy) {
const dx = cx - startX, dy = cy - startY;
if (Math.abs(dx) > 6 || Math.abs(dy) > 6) {
if (!wasMoved) {
wasMoved = true;
clearTimeout(longTimer);
cancelProgress();
fab.classList.remove('ms2-pressing');
fab.classList.add('ms2-dragging');
closeDial();
}
}
if (!wasMoved) return;
const W = document.documentElement.clientWidth, H = document.documentElement.clientHeight, S = 44;
fab.style.right = Math.max(8, Math.min(W - S - 8, startRight - dx)) + 'px';
fab.style.bottom = Math.max(8, Math.min(H - S - 8, startBottom - dy)) + 'px';
}
function endDrag() {
clearTimeout(longTimer);
cancelProgress();
fab.classList.remove('ms2-dragging', 'ms2-pressing');
if (wasMoved) {
CFG.fabRight = parseInt(fab.style.right, 10) || CFG.fabRight;
CFG.fabBottom = parseInt(fab.style.bottom, 10) || CFG.fabBottom;
} else if (!longFired) {
toggleDial();
}
}
fab.addEventListener('mousedown', e => {
e.preventDefault();
beginDrag(e.clientX, e.clientY);
const onMove = ev => moveDrag(ev.clientX, ev.clientY);
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
endDrag();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
fab.addEventListener('touchstart', e => {
e.preventDefault();
beginDrag(e.touches[0].clientX, e.touches[0].clientY);
const onMove = ev => { ev.preventDefault(); moveDrag(ev.touches[0].clientX, ev.touches[0].clientY); };
const onEnd = () => {
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onEnd);
document.removeEventListener('touchcancel', onEnd);
endDrag();
};
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onEnd);
document.addEventListener('touchcancel', onEnd);
}, { passive: false });
}
// ─── SPA NAVIGATION ────────────────────────────────────────────────────────
let _lastPathname = '';
let _lastCharId = null;
/**
* Called on every SPA route change (history pushState / popState intercept).
* Re-evaluates whether chat-specific features (observer, auto-summary) should
* be active for the new URL and starts/stops them accordingly.
*/
function onRouteChange() {
if (location.pathname === _lastPathname) return;
_lastPathname = location.pathname;
setTimeout(() => {
ensureFAB();
if (isOnChatPage()) {
stopObserver();
stopAutoSumObserver();
_lastSeenText = '';
_cachedLastBotIndex = -1;
_cachedLastBotText = '';
if (CFG.autoNotify) startObserver();
if (getAutoSumEvery() > 0) startAutoSumObserver();
clearAccumulated();
const newCharId = getCurrentCharId();
if (newCharId && newCharId !== _lastCharId) {
setTimeout(() => {
const name = extractChatNameFromDOM();
if (name) saveChatName(name, ctxKey());
}, 800);
const pastCount = countSumHistoryForCurrentChat();
const storedName = getChatName(ctxKey())
|| getSumHistory().find(h => h.conv === ctxKey())?.chatName
|| '';
const charLabel = storedName ? ` — <strong>${escHtml(storedName)}</strong>` : '';
const lastSnap = getFabSumLast();
const snapInfo = lastSnap
? (() => {
const mins = Math.round((Date.now() - lastSnap.ts) / 60000);
return mins < 60
? ` · last summary ${mins < 1 ? 'just now' : `${mins}m ago`}`
: ` · last summary ${Math.round(mins / 60)}h ago`;
})()
: '';
if (pastCount > 0) {
toast(
`${SVG_SUMMARISE} Switched chat${charLabel} — <strong>${pastCount}</strong> past summar${pastCount !== 1 ? 'ies' : 'y'}${snapInfo}`,
4500
);
} else if (_lastCharId !== null) {
toast(`${SVG_SUMMARISE} Switched chat${charLabel} — no past summaries yet`, 3000);
}
_sumHistShowAll = false;
}
_lastCharId = newCharId;
if (getAutoLoadGlobal()) {
setTimeout(() => {
if (getContext().trim()) return;
const charId = getCurrentCharId();
const mem = (charId && getCharGlobalMemory(charId).trim())
|| getGlobalMemory().trim();
if (mem) {
saveContext(mem);
toast(`${SVG_FOLDER} Memory auto-loaded for this chat`, 3000);
}
}, 700);
}
} else {
stopObserver();
stopAutoSumObserver();
_cachedLastBotIndex = -1;
_cachedLastBotText = '';
closeDial();
clearAccumulated();
_lastCharId = null;
// Clear the persisted character ID & name when navigating to a neutral page
// (home, explore, search, etc.) — NOT on character card pages, since those
// will immediately overwrite the stored ID anyway when _p2pGetCharId() runs.
const _path = location.pathname;
const _isNeutral = !_path.startsWith('/characters/') && !_path.startsWith('/chats/');
if (_isNeutral) {
try { GM_setValue(P2P_GM_LAST_CHAR, ''); } catch {}
try { GM_setValue(P2P_GM_LAST_CHAR_NAME, ''); } catch {}
}
}
}, 400);
}
if (!history.__ms2_patched) {
const _histPush = history.pushState.bind(history);
const _histReplace = history.replaceState.bind(history);
history.pushState = (...a) => { _histPush(...a); onRouteChange(); };
history.replaceState = (...a) => { _histReplace(...a); onRouteChange(); };
history.__ms2_patched = true;
}
window.addEventListener('popstate', onRouteChange);
window.addEventListener('resize', () => {
const fab = document.getElementById('ms2-fab');
if (!fab) return;
const W = document.documentElement.clientWidth;
const H = document.documentElement.clientHeight;
const S = 44;
const curRight = parseInt(fab.style.right, 10) || CFG.fabRight;
const curBottom = parseInt(fab.style.bottom, 10) || CFG.fabBottom;
fab.style.right = Math.max(8, Math.min(W - S - 8, curRight)) + 'px';
fab.style.bottom = Math.max(8, Math.min(H - S - 8, curBottom)) + 'px';
});
// ─── INIT ──────────────────────────────────────────────────────────────────
/**
* Script bootstrap — called once after `document-idle` + 500 ms delay.
*
* Initialisation order:
* 1. `SelectorEngine.startSelfHealing` + `loadRemote`
* 2. `NetworkInterceptor.init` (patches page fetch + XHR)
* 3. `migrateStorage` (one-time GM storage schema upgrades)
* 4. `initAPInterceptor` (advanced prompt injection)
* 5. `apWatchDeletions` (deleted-message fingerprinting)
* 6. `ensureFAB` (floating action button)
* 7. Conditional observers (auto-notify, auto-summary) if on a chat page
* 8. `jv5Bus` `scriptInitialized` event for external tooling
* 9. `unsafeWindow.__jv5` diagnostic interface
*/
// ─── JV5 SELF-TEST SUITE ─────────────────────────────────────────────────
// Silent background tests. Run once ~4 s after load so they never block init.
// All results go to console.group('[JV5 SelfTest]') — open DevTools to read.
// A global window.__jv5Tests is also set so the Diagnostics script can poll it.
//
// Tests covered:
// 1. AES-GCM-256 encrypt → decrypt round-trip (known-plaintext)
// 2. Wrong-password rejection (must return null, not throw)
// 3. PBKDF2 key-cache hit (second call must be <5 ms)
// 4. Anti-replay: message timestamped 7 h ago must be dropped
// 5. Anti-replay: future message (clock skew >6 h) must be dropped
// 6. IV uniqueness: 20 encryptions must produce 20 distinct IVs
// 7. PSK convenience wrappers (pskEncrypt / pskDecrypt round-trip)
// 8. Corrupted ciphertext (flip one byte) must fail gracefully
// 9. Empty-string body edge case must not throw
// 10. GM storage write → read round-trip
//
// Why silent? Users never see a test UI. If something breaks, the developer
// opens DevTools and sees exactly which assertion failed and why.
const _selfTestResults = [];
function _stAssert(name, pass, detail) {
const status = pass ? 'PASS' : 'FAIL';
_selfTestResults.push({ name, status, detail: detail || '' });
if (!pass) {
console.error(`[JV5 SelfTest] ❌ FAIL — ${name}${detail ? ': ' + detail : ''}`);
}
return pass;
}
async function _runSelfTests() {
const T0 = performance.now();
console.group('[JV5 SelfTest] Running silent background tests…');
const PW = 'test-password-abc';
const ROOM = 'test-room-001';
// ── 1. Round-trip ─────────────────────────────────────────────────────
try {
const plain = { text: 'hello world', user: 'tester', ts: Date.now() };
const enc = await P2PCrypto.encrypt(plain, PW, ROOM);
const dec = await P2PCrypto.decrypt(enc, PW, ROOM);
const ok = dec && dec.text === plain.text && dec.user === plain.user;
_stAssert('encrypt→decrypt round-trip', ok,
ok ? '' : `got: ${JSON.stringify(dec)}`);
} catch (e) {
_stAssert('encrypt→decrypt round-trip', false, e.message);
}
// ── 2. Wrong-password rejection ────────────────────────────────────────
try {
const enc = await P2PCrypto.encrypt({ text: 'secret' }, PW, ROOM);
const dec = await P2PCrypto.decrypt(enc, 'wrong-password', ROOM);
_stAssert('wrong-password returns null', dec === null,
dec !== null ? `expected null, got: ${JSON.stringify(dec)}` : '');
} catch (e) {
_stAssert('wrong-password returns null', false, `threw instead of returning null: ${e.message}`);
}
// ── 3. Key-cache performance ───────────────────────────────────────────
try {
// First call — cache miss (already warmed in test 1, but use a new combo)
const PW2 = 'cache-test-pw'; const R2 = 'cache-test-room';
const t1 = performance.now();
await P2PCrypto._deriveKey(PW2, R2);
const missMs = performance.now() - t1;
const t2 = performance.now();
await P2PCrypto._deriveKey(PW2, R2);
const hitMs = performance.now() - t2;
_stAssert('key-cache hit is fast (<5 ms)', hitMs < 5,
`cache-miss=${missMs.toFixed(1)}ms cache-hit=${hitMs.toFixed(1)}ms`);
} catch (e) {
_stAssert('key-cache hit is fast (<5 ms)', false, e.message);
}
// ── 4. Anti-replay: stale message (7 h old) ───────────────────────────
try {
const stale = { text: 'old message', _ts: Date.now() - 1000 * 60 * 60 * 7, _seq: 1 };
const enc = await P2PCrypto.encrypt(stale, PW, ROOM);
// Manually patch _ts inside the ciphertext by re-encrypting with the stale timestamp
// The encrypt() always writes a fresh _ts, so we must build the envelope manually.
const key = await P2PCrypto._deriveKey(PW, ROOM);
const iv = crypto.getRandomValues(new Uint8Array(12));
const raw = new TextEncoder().encode(JSON.stringify(stale));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, raw);
const staleEnc = { encrypted: true, iv: Array.from(iv), ciphertext: Array.from(new Uint8Array(ct)), v: 1 };
const dec = await P2PCrypto.decrypt(staleEnc, PW, ROOM);
_stAssert('stale message dropped (>6 h old)', dec === null,
dec ? `expected null, got: ${JSON.stringify(dec)}` : '');
} catch (e) {
_stAssert('stale message dropped (>6 h old)', false, e.message);
}
// ── 5. Anti-replay: future message (>6 h clock skew) ─────────────────
try {
const future = { text: 'future msg', _ts: Date.now() + 1000 * 60 * 60 * 7, _seq: 2 };
const key = await P2PCrypto._deriveKey(PW, ROOM);
const iv = crypto.getRandomValues(new Uint8Array(12));
const raw = new TextEncoder().encode(JSON.stringify(future));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, raw);
const futureEnc = { encrypted: true, iv: Array.from(iv), ciphertext: Array.from(new Uint8Array(ct)), v: 1 };
const dec = await P2PCrypto.decrypt(futureEnc, PW, ROOM);
_stAssert('future message dropped (>6 h skew)', dec === null,
dec ? `expected null, got: ${JSON.stringify(dec)}` : '');
} catch (e) {
_stAssert('future message dropped (>6 h skew)', false, e.message);
}
// ── 6. IV uniqueness (20 encryptions) ─────────────────────────────────
try {
const ivSet = new Set();
for (let i = 0; i < 20; i++) {
const enc = await P2PCrypto.encrypt({ text: `msg-${i}` }, PW, ROOM);
ivSet.add(JSON.stringify(enc.iv));
}
_stAssert('IV uniqueness (20 encryptions → 20 distinct IVs)', ivSet.size === 20,
`got ${ivSet.size} unique IVs`);
} catch (e) {
_stAssert('IV uniqueness', false, e.message);
}
// ── 7. PSK convenience wrappers ────────────────────────────────────────
try {
const obj = { text: 'psk test', user: 'u1', ts: Date.now() };
const enc = await pskEncrypt(obj);
const dec = await pskDecrypt(enc);
const ok = dec && dec.text === obj.text && dec.user === obj.user;
_stAssert('pskEncrypt→pskDecrypt round-trip', ok,
ok ? '' : `got: ${JSON.stringify(dec)}`);
} catch (e) {
_stAssert('pskEncrypt→pskDecrypt round-trip', false, e.message);
}
// ── 8. Corrupted ciphertext (must fail gracefully, not throw) ──────────
try {
const enc = await P2PCrypto.encrypt({ text: 'real' }, PW, ROOM);
const bad = { ...enc, ciphertext: enc.ciphertext.map((b, i) => i === 5 ? b ^ 0xff : b) };
const dec = await P2PCrypto.decrypt(bad, PW, ROOM);
_stAssert('corrupted ciphertext returns null (no throw)', dec === null,
dec ? `expected null, got: ${JSON.stringify(dec)}` : '');
} catch (e) {
_stAssert('corrupted ciphertext returns null (no throw)', false, `threw: ${e.message}`);
}
// ── 9. Empty-string body edge case ─────────────────────────────────────
try {
const enc = await P2PCrypto.encrypt({ text: '' }, PW, ROOM);
const dec = await P2PCrypto.decrypt(enc, PW, ROOM);
_stAssert('empty-string body round-trip', dec !== null && dec.text === '',
dec ? '' : 'returned null for empty-string body');
} catch (e) {
_stAssert('empty-string body round-trip', false, e.message);
}
// ── 10. GM storage write → read round-trip ─────────────────────────────
try {
const KEY = '__jv5_selftest_probe__';
const VAL = `probe-${Date.now()}`;
gset(KEY, VAL);
const readBack = gget(KEY, null);
const ok = readBack === VAL;
// Clean up
try { GM_deleteValue(KEY); } catch {}
delete _cfgCache[KEY];
_stAssert('GM storage write→read round-trip', ok,
ok ? '' : `wrote "${VAL}", read back "${readBack}"`);
} catch (e) {
_stAssert('GM storage write→read round-trip', false, e.message);
}
// ── Summary ────────────────────────────────────────────────────────────
const elapsed = (performance.now() - T0).toFixed(0);
const passed = _selfTestResults.filter(r => r.status === 'PASS').length;
const failed = _selfTestResults.filter(r => r.status === 'FAIL').length;
const total = _selfTestResults.length;
if (failed === 0) {
console.log(`[JV5 SelfTest] ✅ All ${total} tests passed in ${elapsed} ms`);
} else {
console.warn(`[JV5 SelfTest] ⚠️ ${failed}/${total} tests FAILED in ${elapsed} ms — see errors above`);
}
console.table(_selfTestResults.map(r => ({ Test: r.name, Status: r.status, Detail: r.detail })));
console.groupEnd();
// Expose results on the __jv5 bridge so Diagnostics script can pick them up
try {
if (unsafeWindow.__jv5) {
unsafeWindow.__jv5.selfTests = {
ran: true,
passed,
failed,
total,
elapsedMs: parseInt(elapsed),
results: _selfTestResults,
ranAt: new Date().toISOString(),
};
}
} catch {}
}
// Run 6 s after load — gives init() (500ms delay) plenty of time to set __jv5 bridge.
// Call window.__jv5.runSelfTests() anytime to re-run manually from the console.
setTimeout(() => { _runSelfTests().catch(e => console.error('[JV5 SelfTest] Runner crashed:', e)); }, 6000);
function init() {
// Initialize advanced engineered systems in order
try { selectorEngine.startSelfHealing(); } catch {}
try { selectorEngine.loadRemote(); } catch {}
try { netInterceptor.init(); } catch {}
try { migrateStorage(); } catch {}
_initRemoteConfig();
initAPInterceptor();
apWatchDeletions();
ensureFAB();
if (isOnChatPage() && CFG.autoNotify) startObserver();
if (isOnChatPage() && getAutoSumEvery() > 0) startAutoSumObserver();
bus.emit('scriptInitialized', { version: '5.6.6' });
// ── Diagnostic interface (read by JV5 Diagnostics userscript) ──────────
try {
unsafeWindow.__jv5 = {
version: '5.6.6',
hasApiKey: () => !!CFG.apiKey,
model: () => CFG.model,
endpoint: () => CFG.endpoint,
authMode: () => CFG.authMode,
apEnabled: () => AP.enabled,
// hasPayload and payloadMsgCount removed — payload storage was eliminated for privacy.
getLatestText: getLatestAIText,
virtuosoWorking: () => document.querySelectorAll(VIRTUOSO_SEL).length > 0,
// Diagnostic bridge — exposes current live selector for the diagnostic panel
liveVirtuosoSel: () => VIRTUOSO_SEL,
liveMsgBodySel: () => MSG_BODY_SEL,
liveScrollerSel: () => SELECTOR_CONFIG.messagesMain,
selectorHits: () => Object.fromEntries(selectorEngine.hits || new Map()),
// v5.6.5: exposes real scroller info for scroll ratio diagnosis
realScrollerInfo: () => {
const s = _findRealScroller();
if (!s) return null;
return {
sel: _buildScrollerSel(s),
scrollHeight: s.scrollHeight,
clientHeight: s.clientHeight,
ratio: (s.scrollHeight / Math.max(s.clientHeight, 1)).toFixed(2),
};
},
// Self-test bridge — call window.__jv5.runSelfTests() from the console
// or read window.__jv5.selfTests for the last result set.
selfTests: null,
runSelfTests: () => _runSelfTests(),
};
} catch { }
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(init, 500));
} else {
setTimeout(init, 500);
}
})();