Supercharge JanitorAI with Smart Reply, auto-summaries, scene context injection, style presets, advanced prompting, and encrypted community chat. Supports OpenRouter, OpenAI, Anthropic, Groq, Gemini & more. PIN-protected API key storage, Double Ratchet forward secrecy, per-room AES-256-GCM encryption, and persona library — all in one script.
// ==UserScript==
// @name JanitorV5
// @namespace https://janitorai.com/
// @version 5.7.9
// @description Supercharge JanitorAI with Smart Reply, auto-summaries, scene context injection, style presets, advanced prompting, and encrypted community chat. Supports OpenRouter, OpenAI, Anthropic, Groq, Gemini & more. PIN-protected API key storage, Double Ratchet forward secrecy, per-room AES-256-GCM encryption, and persona library — all in one script.
// @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 ntfy.sh
// @connect api.cohere.ai
// @connect generativelanguage.googleapis.com
// @connect api.together.xyz
// @connect api.deepseek.com
// @connect inference.cerebras.ai
// @connect lorebary.com
// @connect self
// NOTE FOR REVIEWERS: No @connect wildcard is used. Every outbound domain is
// listed explicitly above. If you use a custom API proxy not listed here,
// Tampermonkey will block the request — add your proxy domain to your own
// local copy, or request it be added via the GitHub issue tracker.
// All custom endpoints are also validated at runtime: HTTPS-only, no private
// IP ranges (SSRF guard), and PIN-protected sessions bind the key to the
// endpoint that was active at unlock time.
// @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.
//
(async function () {
'use strict';
// JanitorV5 Ultimate v5.7.4 — 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 { }
};
// ─── AES-GCM API KEY ENCRYPTION (PIN-PROTECTED AT REST) ──────────────────
// Problem: GM_getValue stores data as plaintext. Any other installed Tampermonkey
// script on janitorai.com that also has GM_getValue can read the API key directly.
//
// Solution: Encrypt the API key with AES-256-GCM using a key derived from a
// user-set session PIN via PBKDF2-SHA-256 (310,000 iterations — OWASP 2024 rec).
// The PIN is NEVER stored — only held in memory for the browser session.
// The ciphertext in GM storage is useless without the PIN.
//
// Flow:
// First time: user pastes key + sets PIN → key encrypted + stored as ciphertext
// Subsequent loads: user enters PIN → key decrypted from ciphertext into memory
// If PIN not set: key stored plaintext (same as before) with a visible warning
//
// GM keys:
// ms2_apiKey_enc — base64(iv + ciphertext + authTag) — set when PIN active
// ms2_apiKey — plaintext fallback — set when no PIN
// ms2_apiKey_salt — 16-byte random salt for PBKDF2 — always set
const _KEY_ENC_GM = 'ms2_apiKey_enc';
const _KEY_SALT_GM = 'ms2_apiKey_salt';
const _KEY_PLAIN_GM = 'ms2_apiKey';
// Upgraded from SHA-256/310k to SHA-512/350k — aligns with community chat KDF strength.
// SHA-512 is ~2× slower than SHA-256 per iteration on 32-bit CPUs, making
// offline brute-force of captured ciphertext more expensive.
const _PBKDF2_ITERS = 350000;
const _PBKDF2_HASH = 'SHA-512';
// In-memory session store — holds the derived CryptoKey for the session.
// The raw PIN string is NEVER retained after key derivation; only the
// CryptoKey (non-extractable) lives here. This limits the blast radius if
// page-level JS can somehow read the closure scope.
const _pinSession = { active: false, cryptoKey: null };
function _b64ToArr(b64) {
const bin = atob(b64); const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); return arr;
}
function _arrToB64(arr) {
// String.fromCharCode(...arr) with spread can stack-overflow on large buffers.
// Process in 8 KB chunks to stay well within the call-stack limit.
const u8 = new Uint8Array(arr);
let str = '';
const CHUNK = 8192;
for (let i = 0; i < u8.length; i += CHUNK) {
str += String.fromCharCode(...u8.subarray(i, i + CHUNK));
}
return btoa(str);
}
async function _pinDeriveKey(pin, saltArr) {
const enc = new TextEncoder();
const rawKey = await crypto.subtle.importKey('raw', enc.encode(pin), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: _PBKDF2_HASH, salt: saltArr, iterations: _PBKDF2_ITERS },
rawKey,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']
);
}
async function _pinEncryptKey(apiKey, pin) {
try {
let saltArr;
const storedSalt = (() => { try { return GM_getValue(_KEY_SALT_GM, ''); } catch { return ''; } })();
saltArr = storedSalt ? _b64ToArr(storedSalt) : crypto.getRandomValues(new Uint8Array(32));
if (!storedSalt) { try { GM_setValue(_KEY_SALT_GM, _arrToB64(saltArr)); } catch {} }
const ck = await _pinDeriveKey(pin, saltArr);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, ck, new TextEncoder().encode(apiKey));
// Store: iv(12) + ciphertext
const blob = new Uint8Array(12 + ct.byteLength);
blob.set(iv, 0); blob.set(new Uint8Array(ct), 12);
try { GM_setValue(_KEY_ENC_GM, _arrToB64(blob)); } catch {}
try { GM_deleteValue(_KEY_PLAIN_GM); } catch {} // remove plaintext
_pinSession.active = true; _pinSession.cryptoKey = ck;
// PIN string is intentionally NOT stored — only the derived CryptoKey is kept
delete _cfgCache[_KEY_PLAIN_GM]; // invalidate plaintext cache
return true;
} catch (e) { console.warn('[JV5-PIN] Encrypt failed:', e.message); return false; }
}
async function _pinDecryptKey(pin) {
try {
const blob64 = (() => { try { return GM_getValue(_KEY_ENC_GM, ''); } catch { return ''; } })();
if (!blob64) return null;
const salt64 = (() => { try { return GM_getValue(_KEY_SALT_GM, ''); } catch { return ''; } })();
if (!salt64) return null;
const blob = _b64ToArr(blob64); const saltArr = _b64ToArr(salt64);
const iv = blob.slice(0, 12); const ct = blob.slice(12);
const ck = await _pinDeriveKey(pin, saltArr);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, ck, ct);
const key = new TextDecoder().decode(plain);
_pinSession.active = true; _pinSession.cryptoKey = ck;
// PIN is discarded after key derivation — only the non-extractable CryptoKey is kept
return key;
} catch { return null; } // wrong PIN → AES-GCM auth tag fails → returns null
}
function _pinIsActive() {
// Returns true if the user has set a PIN (encrypted ciphertext exists in GM)
try { return !!GM_getValue(_KEY_ENC_GM, ''); } catch { return false; }
}
async function _pinRemove() {
// Decrypt current key using the session CryptoKey (no raw PIN needed),
// re-store as plaintext, then wipe encrypted fields.
try {
let key = null;
if (_pinSession.active && _pinSession.cryptoKey) {
try {
const blob64 = (() => { try { return GM_getValue(_KEY_ENC_GM, ''); } catch { return ''; } })();
if (blob64) {
const blob = _b64ToArr(blob64);
const iv = blob.slice(0, 12); const ct = blob.slice(12);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, _pinSession.cryptoKey, ct);
key = new TextDecoder().decode(plain);
}
} catch { /* wrong key or corrupt — proceed with removal anyway */ }
}
if (key) { try { GM_setValue(_KEY_PLAIN_GM, key); } catch {} }
try { GM_deleteValue(_KEY_ENC_GM); } catch {}
try { GM_deleteValue(_KEY_SALT_GM); } catch {}
_pinSession.active = false; _pinSession.cryptoKey = null; _sessionApiKey = ''; _sessionEndpointBinding = '';
return true;
} catch { return false; }
}
// In-memory API key for PIN-protected sessions.
// When PIN is active, the decrypted key lives here ONLY — it is never
// written back to GM storage (which would defeat the encryption).
// Survives only for the current page session; cleared on lock/logout.
let _sessionApiKey = '';
// The endpoint that was active when the API key was decrypted/set this session.
// If the endpoint changes to something different before the next callAPI, we
// warn the user — this prevents a scenario where an attacker (or a bug) swaps
// the endpoint to a hostile server and harvests the key from the Authorization header.
let _sessionEndpointBinding = '';
// Returns the API key — from the in-memory session store when PIN is active,
// otherwise from GM plaintext (no PIN set).
function _getApiKey() {
if (_pinSession.active) return _sessionApiKey;
return gget(_KEY_PLAIN_GM, '');
}
// ─── PIN SESSION IDLE AUTO-LOCK (forced re-auth after inactivity) ──────────
// When a PIN is active, the decrypted API key sits in memory (_sessionApiKey)
// for the rest of the page session — by design ("once per browser session").
// If the user steps away from an unlocked tab, that key stays resident in
// memory indefinitely. This auto-lock clears _sessionApiKey and the derived
// CryptoKey after N minutes of no user activity, forcing PIN re-entry before
// the key can be used again — limiting the window an unlocked key is exposed
// to anything that could otherwise read this closure's memory.
//
// Off by default (idleMin = 0 → disabled). User can enable 5/15/30/60-minute
// auto-lock from the General settings tab, next to the PIN status indicator.
const _PIN_IDLE_GM = 'jv5_pin_idle_min';
let _pinLastActivity = Date.now();
let _pinIdleInterval = null;
/** Reads the configured idle-lock timeout in minutes (0 = disabled). */
function _pinGetIdleMin() {
try {
const v = Number(GM_getValue(_PIN_IDLE_GM, 0));
return Number.isFinite(v) && v >= 0 ? v : 0;
} catch { return 0; }
}
function _pinTouchActivity() {
_pinLastActivity = Date.now();
}
/**
* Forcibly forgets the in-memory decrypted API key and derived CryptoKey,
* requiring PIN re-entry before the key can be used again. No-op if no
* PIN session is currently unlocked.
* @param {string} [reason] - Short reason shown in the toast (e.g. "inactive").
*/
function _pinLockNow(reason) {
if (!_pinSession.active) return;
_pinSession.active = false;
_pinSession.cryptoKey = null;
_sessionApiKey = '';
_sessionEndpointBinding = '';
try {
toastHTML(`${SVG_LOCK} Session locked${reason ? ' (' + reason + ')' : ''} — re-enter your PIN to use the API key`, 4500);
} catch {}
// If Settings is open, refresh the General tab so it reflects the locked state
// (matches the existing pattern used after Set/Remove PIN).
try {
const settingsPanel = document.querySelector('.ms2-settings-v2');
const genPanel = settingsPanel?.querySelector('[data-panel="general"]');
if (settingsPanel && genPanel && typeof _buildGeneralTab === 'function') {
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 mOpts = [..._modelGroupMap.entries()].map(([gN, ms]) =>
`<optgroup label="${escHtml(gN)}">${ms.map(m => `<option value="${escHtml(m.id)}" ${CFG.model===m.id?'selected':''}>${escHtml(m.label)}</option>`).join('')}</optgroup>`
).join('');
const tmp = document.createElement('div');
tmp.innerHTML = _buildGeneralTab('general', mOpts);
genPanel.replaceWith(tmp.firstElementChild);
_rewireGeneralTab(settingsPanel);
}
} catch {}
}
// Periodic idle check — cheap (every 30s), avoids per-event timer churn.
function _pinStartIdleWatcher() {
if (_pinIdleInterval) return;
_pinIdleInterval = setInterval(() => {
const idleMin = _pinGetIdleMin();
if (idleMin <= 0 || !_pinSession.active) return;
if (Date.now() - _pinLastActivity >= idleMin * 60 * 1000) {
_pinLockNow('inactive ' + idleMin + 'm');
}
}, 30000);
}
// Track user activity via lightweight, passive listeners — just a timestamp
// write, no per-event work. Also re-checks immediately when the tab regains
// focus, since background-tab timers can be throttled by the browser.
try {
const _idleEvents = ['mousedown', 'mousemove', 'keydown', 'touchstart', 'wheel', 'scroll'];
for (const ev of _idleEvents) {
document.addEventListener(ev, _pinTouchActivity, { passive: true, capture: true });
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
const idleMin = _pinGetIdleMin();
if (idleMin > 0 && _pinSession.active && Date.now() - _pinLastActivity >= idleMin * 60 * 1000) {
_pinLockNow('inactive ' + idleMin + 'm');
}
_pinTouchActivity();
});
} catch {}
_pinStartIdleWatcher();
// ─── INFO POPOVER SYSTEM ──────────────────────────────────────────────────
// Renders a (!) button that opens a positioned popover with rich explanations.
// Used throughout all Settings tabs to give users plain-language docs.
let _activePopover = null;
function _makeInfoBtn(id) {
return `<button class="jv5-info-btn" data-info-id="${id}" title="Learn more">!</button>`;
}
// Popover content registry — all (!) explanations live here
const _INFO_CONTENT = {
'api-key-security': {
title: `${/*SVG_LOCK — inlined small*/ '🔒'} API Key Storage`,
tags: [{ text: 'Security', cls: 'sec' }],
body: `
<p>Your API key is the password to your AI account. By default it's saved in Tampermonkey's storage — which means <strong>other installed userscripts on this site could read it</strong> if they also have storage access.</p>
<hr class="jv5-info-divider">
<p><strong>PIN Protection (recommended)</strong> — When you set a PIN, your key is encrypted with <strong>AES-256-GCM</strong> before being saved. The encryption key is derived from your PIN using <strong>PBKDF2 at 310,000 rounds</strong> — the same standard used by password managers. The PIN itself is never stored anywhere. Without it, the saved data is useless ciphertext.</p>
<p>You'll be asked to enter your PIN once per browser session.</p>
<hr class="jv5-info-divider">
<p>If you don't set a PIN, the key is stored as plain text — still protected by browser extension isolation, but readable by other scripts.</p>`,
},
'pin-idle-lock': {
title: `${'🔒'} Auto-Lock After Inactivity`,
tags: [{ text: 'Security', cls: 'sec' }],
body: `
<p>Normally, once you enter your PIN your API key stays decrypted in memory for the rest of the browser session — even if you walk away from the tab.</p>
<hr class="jv5-info-divider">
<p><strong>Auto-lock</strong> forgets the decrypted key after the chosen number of minutes with no mouse, keyboard, or touch activity. After it locks, you'll need to re-enter your PIN before the script can use your key again.</p>
<p>This shrinks the window in which an unlocked key sits in memory — useful on shared or unattended machines. It has no effect on the encrypted copy stored on disk, which always requires your PIN.</p>
<hr class="jv5-info-divider">
<p>Set to <strong>Off</strong> (default) to keep the previous "unlock once per session" behavior.</p>`,
},
'api-provider': {
title: '🌐 API Provider',
tags: [{ text: 'Setup', cls: '' }],
body: `
<p>This is where your AI requests get sent. Each provider has different models, pricing, and free tiers.</p>
<p><strong>OpenRouter</strong> — One key for every model (GPT-4o, Claude, Gemini, Llama, etc). Many <code>:free</code> models. Best starting point.</p>
<p><strong>Groq</strong> — Extremely fast inference on Llama 4 / Qwen 3. Generous free tier. Great for quick replies.</p>
<p><strong>Anthropic</strong> — Claude Opus / Sonnet / Haiku directly. Requires its own message format — JV5 handles this automatically.</p>
<p><strong>Custom URL</strong> — Point at any OpenAI-compatible proxy (LiteRouter, Ollama, self-hosted, etc).</p>`,
},
'auth-mode': {
title: '🔑 Auth Header Format',
tags: [{ text: 'Setup', cls: '' }],
body: `
<p>Controls how your API key is sent to the provider in the HTTP request header.</p>
<p><strong>Auto-detect</strong> — Uses <code>Bearer <key></code> for everything. Works with OpenRouter, OpenAI, Groq, Mistral, and most proxies. <em>Use this unless you have a specific reason not to.</em></p>
<p><strong>x-api-key</strong> — Required by native Anthropic API. JV5 sets this automatically when you pick the Anthropic provider.</p>
<p><strong>Key only (raw)</strong> — Sends just the key with no prefix. Used by some niche self-hosted setups.</p>`,
},
'model-select': {
title: '🧠 Model Selection',
tags: [{ text: 'Setup', cls: '' }],
body: `
<p>The AI model that processes your prompts. Different models have different writing styles, context windows, and costs.</p>
<p><strong>Free models</strong> — On OpenRouter, models ending in <code>:free</code> have zero cost but may be slower or have usage caps.</p>
<p><strong>Context window</strong> — Longer context = the AI sees more of your chat history. Models like <code>gemma-3-27b</code> offer 128K tokens free.</p>
<p><strong>Custom model ID</strong> — Type any valid model string your provider supports, e.g. <code>meta-llama/llama-4-maverick:free</code>.</p>`,
},
'relay-url': {
title: '📡 Community Chat Relay',
tags: [{ text: 'Privacy', cls: 'sec' }, { text: 'Advanced', cls: 'warn' }],
body: `
<p>Community Chat uses <strong>ntfy.sh</strong> as a free relay to exchange encrypted messages between JV5 users. Messages are encrypted with AES-256-GCM before leaving your browser — ntfy.sh only sees ciphertext.</p>
<p><strong>Why change this?</strong> — If you run your own <a href="https://ntfy.sh/docs/install/" target="_blank">ntfy instance</a> for full privacy, enter its URL here. Self-hosted = only your server sees the traffic.</p>
<p><strong>Access token (optional)</strong> — If your self-hosted ntfy server requires auth to read/write topics, add an access token below the URL. It's sent as <code>Authorization: Bearer …</code> and is <strong>never</strong> sent to the default ntfy.sh.</p>
<p><strong>Security note:</strong> Private/local addresses (192.168.x, 127.x, localhost) are blocked to prevent accidental misuse.</p>`,
},
'advanced-crypto': {
title: '🔐 Advanced Crypto',
tags: [{ text: 'Security', cls: 'sec' }],
body: `
<p>These toggles control the encryption stack for Community Chat. All three are on by default and work together.</p>
<p><strong>Double Ratchet FS</strong> — Each peer gets their own encryption chain. Old message keys are deleted after use. Even if someone captures all traffic and later gets your PSK, they cannot decrypt past messages.</p>
<p><strong>PBKDF2-SHA-512 KDF</strong> — Uses PBKDF2-SHA-512 at 350,000 iterations + an HKDF domain-separation pass for room password key derivation. Makes brute-forcing captured ciphertext ~2.5× more expensive than the default PBKDF2-SHA-256 path.</p>
<p><strong>HMAC-SHA-512 Signatures</strong> — Admin commands are signed with HMAC-SHA-512 + HKDF domain separation instead of plain HMAC-SHA-256. Captured admin signatures can't be reused in other contexts.</p>
<p>Only disable these if other users on older JV5 versions can't decrypt your messages.</p>`,
},
'community-passphrase': {
title: '🔑 Community Passphrase',
tags: [{ text: 'Privacy', cls: 'sec' }],
body: `
<p>By default, all JV5 users share a common encryption baseline (the PSK) so everyone can read the global chat room.</p>
<p>Setting a <strong>custom passphrase</strong> creates a <em>private sub-group</em>: only users who set the exact same passphrase can read your messages. Everyone else sees encrypted data they can't decrypt.</p>
<p>Your passphrase is hashed with SHA-256 — the raw text is never stored. Clearing the field reverts to the shared PSK seamlessly.</p>
<p><strong>Tip:</strong> Share your passphrase via a private channel (Discord DM, Signal) — never in the JV5 chat itself.</p>`,
},
'smart-reply': {
title: '💬 Smart Reply',
tags: [{ text: 'Feature', cls: '' }],
body: `
<p>Smart Reply generates a continuation or response to the latest AI message using your configured model and tone.</p>
<p><strong>Tone</strong> — Sets the emotional register of the reply (Playful, Assertive, Tender, etc). Overrides the preset's default for this one reply.</p>
<p><strong>Custom Instruction</strong> — A one-line director's note: "resist but secretly enjoy it", "be distracted", "push back". Appended to the prompt — not visible to the AI as character dialogue.</p>
<p><strong>Scene Context</strong> — Your current-situation note (set in the Context tab) is also injected here to keep the AI grounded in where things are.</p>`,
},
'scene-context': {
title: '📄 Scene Context',
tags: [{ text: 'Feature', cls: '' }],
body: `
<p>A short note about the <em>current situation</em> — where characters are, the mood, recent events, unresolved tension. Injected into every Reply and Shorten prompt.</p>
<p><strong>Is this the same as JanitorAI's memory box?</strong> No. JanitorAI's memory is for static backstory and personality. Scene Context is for dynamic, real-time situation tracking that changes as the story progresses.</p>
<p><strong>Send to JanitorAI's AI</strong> — When enabled, Scene Context is also injected into JanitorAI's own generation on every message, not just JV5's Smart Reply.</p>
<p><strong>Auto-generate</strong> — JV5 can write a new Scene Context every N messages by reading the visible chat and summarising the situation automatically.</p>`,
},
'persona-library': {
title: '👤 Persona Library',
tags: [{ text: 'Feature', cls: '' }],
body: `
<p>Save named character descriptions — how they speak, their mood, quirks, tone. Hit <strong>Use</strong> to instantly load one into the Scene Context box.</p>
<p>Useful for: switching between different AI character setups quickly, saving your own player persona for reuse across chats, or keeping a library of narrative modes (serious mode, comedic mode, etc).</p>
<p><strong>Export / Import</strong> — Backs up your full library as a JSON file. Import merges with existing personas, not replace.</p>`,
},
'styles-presets': {
title: '🎨 Style Presets',
tags: [{ text: 'Feature', cls: '' }],
body: `
<p>Presets save a character's full voice configuration: tone, custom instruction, persona note, and any Prompt Modules. The active preset is auto-injected when you open Smart Reply.</p>
<p>Think of a preset as a "saved character mode" — one for your main character, one for a side character, one for a specific story arc.</p>
<p>Use <strong>Export ↑</strong> to back up your presets as JSON, and <strong>Import ↓</strong> to restore them or share with others.</p>`,
},
'advanced-prompting': {
title: '⚡ Advanced Prompting (AP)',
tags: [{ text: 'Feature', cls: '' }, { text: 'Advanced', cls: 'warn' }],
body: `
<p>When enabled, AP intercepts every JanitorAI generation and replaces the <code>llm_prompt</code> field with your active preset's content. Your proxy/jailbreak is left completely untouched — only the character prompt is swapped.</p>
<p><strong>Prompt Modules</strong> — Reusable blocks of text (character description, writing rules, tone notes) that combine to form the full prompt. Drag to reorder. Toggle individual modules on/off without deleting them.</p>
<p><strong>Forbidden Words</strong> — Injected into every generation. Bypasses JanitorAI's 10-word ban limit.</p>
<p><strong>Thinking</strong> — Appends reasoning instructions for models that support extended thinking (Claude 3.5+, o1, Gemini 2.0+).</p>`,
},
'auto-notify': {
title: '🔔 AI Message Notification',
tags: [{ text: 'Feature', cls: '' }],
body: `
<p>When ON, a small toast notification appears at the top of the screen when a new AI message arrives — useful if you've scrolled away or have another tab open.</p>
<p>This is <strong>non-intrusive</strong>: it never auto-opens any modal or interrupts your typing. It simply lets you know a reply landed without you having to watch the screen.</p>
<p>The notification auto-dismisses after 5 seconds.</p>`,
},
'summarise': {
title: '📝 Summarise',
tags: [{ text: 'Feature', cls: '' }],
body: `
<p><strong>FAB → Summarise</strong> reads <em>all</em> visible messages in the current chat and writes a narrative memory entry — covering what happened across the whole story so far.</p>
<p><strong>Context tab → Generate</strong> reads only the <em>currently visible</em> messages and writes a current-situation note (where you are right now, the mood, recent events). Use this more frequently to keep Scene Context fresh.</p>
<p>Both tools save their output to Scene Context and optionally to your character memory store, so future replies stay consistent with the story's history.</p>`,
},
'p2p-e2e': {
title: '🔒 End-to-End Encryption',
tags: [{ text: 'Security', cls: 'sec' }],
body: `
<p>Every Community Chat message is encrypted <strong>before it leaves your browser</strong>. The relay (ntfy.sh) only ever sees opaque ciphertext — it cannot read your messages even if it wanted to.</p>
<p><strong>PSK layer</strong> — All messages are wrapped in AES-256-GCM using a key derived from your device + optional passphrase. This is always on.</p>
<p><strong>Double Ratchet</strong> — For peers you've connected with, each message uses a fresh key derived from an ECDH handshake chain. Past messages stay secret even if your current key is somehow exposed.</p>
<p><strong>Custom passphrase</strong> — Adds a second encryption layer. Only users who know the passphrase can read your messages.</p>`,
},
};
function _showInfoPopover(btn, contentId) {
const wasThisBtn = (_activePopover !== null && btn.classList.contains('active'));
// Close any open popover first
if (_activePopover) {
_activePopover.remove();
_activePopover = null;
document.querySelectorAll('.jv5-info-btn.active').forEach(b => b.classList.remove('active'));
}
// Toggle: same button tapped while already open → just close, don't reopen
if (wasThisBtn) return;
const content = _INFO_CONTENT[contentId];
if (!content) return;
const pop = document.createElement('div');
pop.className = 'jv5-info-popover';
pop.style.cssText = 'position:fixed;z-index:2147483600;';
const tagsHTML = (content.tags || []).map(t =>
`<span class="jv5-info-tag ${t.cls || ''}">${t.text}</span>`
).join(' ');
pop.innerHTML = `
<h4>${content.title}</h4>
${tagsHTML ? `<div style="margin-bottom:8px;">${tagsHTML}</div>` : ''}
${content.body}
`;
document.body.appendChild(pop);
// Use getBoundingClientRect + fixed positioning — works inside scrolled stacked modals
const bRect = btn.getBoundingClientRect();
const pW = pop.offsetWidth || 280;
const pH = pop.offsetHeight || 200;
const vW = window.innerWidth;
const vH = window.innerHeight;
let left = bRect.left + bRect.width / 2 - pW / 2;
let top = bRect.bottom + 8;
if (left + pW > vW - 8) left = vW - pW - 8;
if (left < 8) left = 8;
if (top + pH > vH - 8) top = bRect.top - pH - 8;
if (top < 8) top = 8;
pop.style.left = left + 'px';
pop.style.top = top + 'px';
_activePopover = pop;
btn.classList.add('active');
// Dismiss on outside pointer — 150 ms delay avoids catching the opening touch
const _dismiss = (e) => {
if (pop.contains(e.target) || btn.contains(e.target) || e.target === btn) return;
pop.remove();
if (_activePopover === pop) _activePopover = null;
btn.classList.remove('active');
document.removeEventListener('pointerdown', _dismiss, true);
};
setTimeout(() => document.addEventListener('pointerdown', _dismiss, true), 150);
}
// Wire up all (!) buttons via event delegation on the document
// so they work even inside dynamically rendered modals
document.addEventListener('click', e => {
const btn = e.target.closest('.jv5-info-btn');
if (!btn) return;
e.stopPropagation();
const id = btn.dataset.infoId;
if (id) _showInfoPopover(btn, id);
}, true);
// ─── 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.6 — 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 });
_devLog('[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());
_devLog('[JanitorV5] Remote selector map loaded:', imported, 'validated keys');
}
} catch (e) { _devWarn('[JanitorV5] loadRemote parse error:', e); }
},
onerror: () => _devWarn('[JanitorV5] loadRemote: fetch failed for URL'),
});
} catch (e) { _devWarn('[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) { _devWarn('[JanitorV5 EventBus]', event, e); }
});
}
} // close EventBus class
const bus = new EventBus();
// SECURITY: Only expose jv5Bus to page context in developer mode.
// Without the gate, any page-context script can subscribe to events like
// 'webrtcEnabled' to learn when a user enters a character room.
try {
const _busDevMode = (() => { try { return GM_getValue('jv5_dev_mode', false); } catch { return false; } })();
if (_busDevMode) unsafeWindow.jv5Bus = bus;
} catch {}
// ─── DEV-MODE LOGGER ──────────────────────────────────────────────────────
// All verbose operational output is gated behind jv5_dev_mode = true.
// This prevents page-context scripts (or a malicious extension riding the
// same origin) from overriding console.log to harvest internal state such
// as peer IDs, ratchet events, message counts, and WebRTC topology.
// Enable via browser console: GM_setValue('jv5_dev_mode', true) then reload.
const _DEV_MODE = (() => { try { return GM_getValue('jv5_dev_mode', false); } catch { return false; } })();
// Suppress lint: intentional no-op in production
// eslint-disable-next-line no-console
const _devLog = _DEV_MODE ? (...a) => console.log(...a) : () => {};
const _devWarn = _DEV_MODE ? (...a) => console.warn(...a) : () => {};
// ─── 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();
_devLog('[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();
// ─── SAFE MESSAGE MERGE ───────────────────────────────────────────────────
// Allowlist-based field copy from decrypted payloads into msg objects.
// Prevents prototype pollution: a maliciously crafted payload containing
// __proto__, constructor, or toString would bypass Object.assign protections.
const _MSG_ALLOWED_FIELDS = new Set([
'v','peer','nick','text','ts','room','msgId','type','replyTo',
'ratchet','peerId','encrypted','adminSig','publicKey','ratchetFor',
'reactionTo','emoji','n','iv','ciphertext','ratchetReply',
]);
function _safeMergeMsg(target, src) {
if (!src || typeof src !== 'object' || Array.isArray(src)) return;
for (const key of _MSG_ALLOWED_FIELDS) {
if (Object.prototype.hasOwnProperty.call(src, key)) {
target[key] = src[key];
}
}
}
// ─── P2P RELAY FAILOVER ────────────────────────────────────────
const P2P_RELAYS = ['https://ntfy.sh'];
// SSRF block-list — matches private/loopback/link-local addresses that must
// never be used as relay or verified-user URLs. The info popover documents
// this protection; this regex is the actual enforcement.
// Covers: loopback (127.x, ::1), private (10.x, 172.16-31.x, 192.168.x),
// SSRF block-list — matches private/loopback/link-local addresses that must
// never be used as relay or verified-user URLs. The info popover documents
// this protection; this regex is the actual enforcement.
// Covers: loopback (127.x, ::1), private (10.x, 172.16-31.x, 192.168.x),
// link-local (169.254.x — AWS/GCP metadata endpoint lives here),
// CGNAT (100.64-127.x), unspecified (0.0.0.0), ULA IPv6 (fc00::/7 → fc/fd prefix),
// IPv6-mapped loopback (::ffff:127.x), and bare localhost.
const _SSRF_BLOCK_RE = new RegExp(
'^https?:\\/\\/(localhost|127\\.|0\\.0\\.0\\.0|10\\.\\d+|172\\.(1[6-9]|2\\d|3[01])\\.|192\\.168\\.|169\\.254\\.|100\\.(6[4-9]|[7-9]\\d|1[01]\\d|12[0-7])\\.|::1|\\[::1\\]|\\[::ffff:(127\\.|[0:]+:1)\\]|f[cd][0-9a-f]{2}:)',
'i'
);
// Runtime relay getter — reads user-configured URL from storage, falls back to default.
// Validates: must start with https?, must not target private/loopback/link-local hosts.
function _p2pGetRelay() {
try {
const stored = GM_getValue(P2P_GM_RELAY, '').trim();
if (stored && /^https?:\/\/.+/.test(stored) && !_SSRF_BLOCK_RE.test(stored)) {
return stored.replace(/\/$/, '');
}
} catch {}
return P2P_RELAYS[0];
}
// ─── OPTIONAL RELAY AUTHENTICATION (self-hosted ntfy access tokens) ────────
// ntfy.sh's public instance has no per-topic ACLs, but a self-hosted ntfy
// server can require an access token (ntfy user/token auth) before it will
// serve a topic. If the user has pointed Community Chat at their own relay
// (P2P_GM_RELAY != default) and saved a token, we attach it as a Bearer
// Authorization header on every relay request.
//
// Safety rails:
// • The token is NEVER sent to the default public ntfy.sh instance, even
// if one happens to be stored — it only applies when a custom relay URL
// is configured. This prevents a stale token from leaking to the public
// relay if the user switches back to the default.
// • The relay URL itself is already restricted to https:// and non-private
// hosts by _p2pGetRelay()'s SSRF guard, so the token always travels over
// TLS to a host the user explicitly opted into.
// • This does NOT replace E2E encryption — it only restricts who can read
// the (still end-to-end encrypted) ciphertext stream at the relay layer.
const P2P_GM_RELAY_TOKEN = 'jv4_p2p_relay_token';
function _p2pGetRelayToken() {
try { return GM_getValue(P2P_GM_RELAY_TOKEN, '').trim(); } catch { return ''; }
}
/**
* Builds a headers object for a relay (ntfy) request, merging in an
* `Authorization: Bearer <token>` header when the user has configured a
* non-default relay AND saved an access token for it.
* @param {Object} extra - Additional headers to merge in (e.g. Content-Type).
* @returns {Object} Final headers object for GM_xmlhttpRequest.
*/
function _p2pRelayHeaders(extra = {}) {
const headers = { ...extra };
try {
const relay = _p2pGetRelay();
const token = _p2pGetRelayToken();
if (token && relay !== P2P_RELAYS[0]) {
headers['Authorization'] = 'Bearer ' + token;
}
} catch {}
return headers;
}
const P2P_RELAY = P2P_RELAYS[0];
// ─── POLL-INTERVAL JITTER ───────────────────────────────────────────────────
// Long-poll reconnects and heartbeats normally fire on a perfectly fixed
// cadence (e.g. exactly every 800ms / 30s). To a relay operator watching
// request timing, that's a recognizable fingerprint distinguishing this
// script's traffic from other ntfy clients. Adding small random jitter
// (±~20%, floor 350ms) breaks that fixed cadence without meaningfully
// affecting responsiveness.
function _jitteredPollMs(baseMs) {
const rand = crypto.getRandomValues(new Uint32Array(1))[0] / 0xFFFFFFFF;
const jitter = 0.8 + rand * 0.4; // 0.8x – 1.2x
return Math.max(350, Math.floor(baseMs * jitter));
}
// ─── TOPIC NAME HARDENING ──────────────────────────────────────────────────
// Topic names are hashed (SHA-256, first 20 hex chars) so they're not
// guessable by reading the source. Someone who reads the code still needs to
// SHA-256 the seed strings to find the ntfy topics.
// Char-room topics additionally incorporate the char ID so each room is isolated.
const _topicSeed = 'jv5-community-2026';
const P2P_TOPIC_GLOBAL = await (async () => {
const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(_topicSeed + '-global'));
return 'jv5-' + Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join('').slice(0,20);
})();
const P2P_TOPIC_TYPING = await (async () => {
const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(_topicSeed + '-typing'));
return 'jv5-' + Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join('').slice(0,20);
})();
const P2P_TOPIC_HB = await (async () => {
const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(_topicSeed + '-hb'));
return 'jv5-' + Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join('').slice(0,20);
})();
const P2P_TOPIC_SIGNAL = await (async () => {
const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(_topicSeed + '-webrtc-signal'));
return 'jv5-' + Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join('').slice(0,20);
})();
// SECURITY FIX #9: Reports topic was the only topic NOT hashed, making it trivially
// guessable by anyone reading the source. Now hashed consistently with all other topics.
const P2P_TOPIC_REPORTS = await (async () => {
const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(_topicSeed + '-reports'));
return 'jv5-' + Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join('').slice(0,20);
})();
// 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).
// Padding helpers — used inside P2PCrypto to obscure message length from traffic analysis.
// NOT exposed to unsafeWindow (no external access needed).
function _padMessage(str, blockSize = 256) {
const data = new TextEncoder().encode(str);
// Reserve 2 bytes at the END for the original length — account for them in block math
const contentLen = data.length + 2;
const padLen = (blockSize - (contentLen % blockSize)) % blockSize;
const out = new Uint8Array(data.length + padLen + 2); // total = exact multiple of blockSize
out.set(data);
crypto.getRandomValues(out.subarray(data.length, out.length - 2)); // fill padding with random bytes
out[out.length - 2] = (data.length >> 8) & 0xff;
out[out.length - 1] = data.length & 0xff;
return out;
}
function _unpadMessage(buf) {
if (buf.length < 2) return null;
const len = (buf[buf.length-2] << 8) | buf[buf.length-1];
if (len < 0 || len > buf.length-2) return null;
return new TextDecoder().decode(buf.subarray(0, len));
}
const P2PCrypto = {
// In-memory cache of derived keys per room, with TTL timestamps
_keyCache: new Map(), // cacheKey → { key, expires }
_KEY_TTL_MS: 30 * 60 * 1000, // 30-minute TTL per derived key
// ── Double Ratchet state per peer ──────────────────────────────────────────
// Implements a simplified Signal-protocol-style symmetric ratchet:
// Root key → chain key → per-message keys (HKDF-SHA-256).
// Each message advances the chain; old message keys are deleted immediately.
// Without the current chain state, a captured ciphertext cannot be decrypted
// even if the long-term PSK is later compromised (forward secrecy within session).
//
// We use the symmetric-ratchet half only (not full DH ratchet) because:
// - No server needed — PSK authenticated key exchange via existing ntfy channel
// - ECDH ephemeral key exchange bootstraps the root key at session start
// - After bootstrap, each send/recv advances its own chain independently
_ratchetState: new Map(), // peerId → { rootKey:CryptoKey, sendChain:CryptoKey, recvChain:CryptoKey, sendN:int, recvN:int }
_ephemeralKeys: null, // { publicKey, privateKey } ECDH keypair for this session
async _ensureEphemeralKeys() {
if (this._ephemeralKeys) return this._ephemeralKeys;
this._ephemeralKeys = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
true,
['deriveKey', 'deriveBits']
);
return this._ephemeralKeys;
},
async getEphemeralPublicKeyJwk() {
const kp = await this._ensureEphemeralKeys();
return crypto.subtle.exportKey('jwk', kp.publicKey);
},
// Derives a shared root key from our private key + their public key (ECDH)
// then seeds the send/recv chain keys using HKDF with PSK as ikm context.
async initRatchetWithPeer(peerId, theirPublicKeyJwk, weAreInitiator) {
if (!gget('jv5_ratchet_global', true)) return; // toggle off → skip
try {
const kp = await this._ensureEphemeralKeys();
const theirPub = await crypto.subtle.importKey(
'jwk', theirPublicKeyJwk,
{ name: 'ECDH', namedCurve: 'P-256' },
false, []
);
// ECDH shared secret → 32 bytes
const sharedBits = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: theirPub },
kp.privateKey, 256
);
// HKDF: mix shared secret with PSK bytes as salt for mutual authentication
const pskBytes = new TextEncoder().encode(P2P_PSK);
const hkdfMaterial = await crypto.subtle.importKey(
'raw', sharedBits, { name: 'HKDF' }, false, ['deriveKey', 'deriveBits']
);
// SECURITY FIX #3: Mark all ratchet keys non-extractable (false).
// Extractable:true allowed crypto.subtle.exportKey() to dump the full ratchet
// state, destroying the forward secrecy guarantee.
const rootKey = await crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: pskBytes, info: new TextEncoder().encode('jv5-ratchet-root') },
hkdfMaterial,
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
// Derive send/recv chains (initiator sends on A, receiver on B)
const sendInfo = new TextEncoder().encode(weAreInitiator ? 'jv5-chain-A' : 'jv5-chain-B');
const recvInfo = new TextEncoder().encode(weAreInitiator ? 'jv5-chain-B' : 'jv5-chain-A');
const sendChain = await crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: pskBytes, info: sendInfo },
hkdfMaterial,
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const recvChain = await crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: pskBytes, info: recvInfo },
hkdfMaterial,
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
this._ratchetState.set(peerId, { rootKey, sendChain, recvChain, sendN: 0, recvN: 0 });
} catch (e) {
_devWarn('[Ratchet] Init failed for peer', e.message);
}
},
// Advance a chain key one step via HMAC-SHA-256(chainKey, "advance") → new chain key
// Returns the message key (used once then discarded — forward secrecy).
async _advanceChain(chainKey) {
const enc = new TextEncoder();
// Message key = HMAC(chainKey, 0x01) — used once then discarded
const msgKeyBuf = await crypto.subtle.sign('HMAC', chainKey, enc.encode('\x01'));
// New chain key = HMAC(chainKey, 0x02) — replaces current chain
const newChainBuf = await crypto.subtle.sign('HMAC', chainKey, enc.encode('\x02'));
// Import new chain key for next iteration
const newChain = await crypto.subtle.importKey(
'raw', newChainBuf,
{ name: 'HMAC', hash: 'SHA-256' }, true, ['sign']
);
// Import message key as AES-GCM-256 (SHA-256 HMAC = 32 bytes = exact key length)
const msgKey = await crypto.subtle.importKey(
'raw', new Uint8Array(msgKeyBuf), // explicit Uint8Array — no slice needed, already 32 bytes
{ name: 'AES-GCM' }, false, ['encrypt', 'decrypt']
);
return { msgKey, newChain };
},
async ratchetEncrypt(plainObj, peerId) {
const state = this._ratchetState.get(peerId);
if (!state) return null; // no ratchet session — caller falls back to PSK
try {
const { msgKey, newChain } = await this._advanceChain(state.sendChain);
state.sendChain = newChain;
const n = state.sendN++;
const iv = crypto.getRandomValues(new Uint8Array(12));
const padded = _padMessage(JSON.stringify({ ...plainObj, _rn: n, _ts: Date.now() }));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, msgKey, padded);
return {
encrypted: true, ratchet: true, peerId, n,
iv: Array.from(iv),
ciphertext: Array.from(new Uint8Array(ct)),
v: 2
};
} catch (e) {
_devWarn('[Ratchet] Encrypt failed:', e.message);
return null;
}
},
async ratchetDecrypt(encObj, fromPeerId) {
const state = this._ratchetState.get(fromPeerId);
if (!state || !encObj.ratchet) return null;
try {
const { msgKey, newChain } = await this._advanceChain(state.recvChain);
state.recvChain = newChain;
const iv = new Uint8Array(encObj.iv);
const ct = new Uint8Array(encObj.ciphertext);
const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, msgKey, ct);
const plain = JSON.parse(_unpadMessage(new Uint8Array(dec)));
const now = Date.now();
// SECURITY FIX #8: Replay window tightened from 6 hours to 5 minutes.
// A 6h window allowed captured ratchet messages to be replayed for hours.
if (plain._ts && Math.abs(now - plain._ts) > 1000 * 60 * 5) return null; // stale
delete plain._rn; delete plain._ts;
return plain;
} catch (e) {
_devWarn('[Ratchet] Decrypt failed:', e.message);
return null;
}
},
clearRatchetState(peerId) {
if (peerId) this._ratchetState.delete(peerId);
else { this._ratchetState.clear(); this._ephemeralKeys = null; }
},
// ── Key derivation — PBKDF2 (default) or Argon2id-emulation ──────────────
// True Argon2id requires WASM and is not available in crypto.subtle.
// We emulate its memory-hardness property by:
// 1. Running PBKDF2 with SHA-512 (more expensive per iteration than SHA-256)
// 2. Using 350 000 iterations (≈ 2.5× the default 150 000)
// 3. Stretching with an additional HKDF pass using a domain-separated info tag
// This raises the cost for offline brute-force attacks on captured ciphertext
// without adding a WASM dependency. The toggle is honored at key-derivation time.
async _deriveKey(password, roomId, sessionSalt = null) {
const useArgon2Emulation = gget('jv5_use_argon2', true);
// SECURITY FIX #2: Never store the plaintext password as a Map key —
// an attacker iterating _keyCache.keys() would recover room passphrases directly.
// Hash it first so the cache key reveals nothing about the password.
const pwHashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password));
const pwHashHex = Array.from(new Uint8Array(pwHashBuf)).map(b => b.toString(16).padStart(2,'0')).join('');
const cacheKey = `${roomId}:${pwHashHex}:${sessionSalt || 'default'}:${useArgon2Emulation ? 'a2' : 'p2'}`;
const cached = this._keyCache.get(cacheKey);
if (cached && cached.expires > Date.now()) return cached.key;
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw', enc.encode(password),
{ name: 'PBKDF2' }, false, ['deriveBits', 'deriveKey']
);
// SECURITY FIX #5: Salt was previously derived from the room ID alone — a known,
// public string. An attacker who knows the room ID could precompute rainbow tables
// offline against common passwords. Mixing in the per-install random PSK half
// (_storedHalfB) means precomputation requires knowing that user's private GM value.
const salt = enc.encode('jv5-p2p-v2:' + roomId + ':' + _storedHalfB.slice(0, 16) + (sessionSalt ? ':' + sessionSalt : ''));
let key;
if (useArgon2Emulation) {
// Phase 1: PBKDF2-SHA-512 at 350 000 iterations → 64 raw bytes
const stretchedBits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt, iterations: 350000, hash: 'SHA-512' },
keyMaterial, 512
);
// Phase 2: HKDF domain separation → final AES-GCM-256 key
const hkdfBase = await crypto.subtle.importKey(
'raw', stretchedBits, { name: 'HKDF' }, false, ['deriveKey']
);
key = await crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: enc.encode('jv5-argon2-emu'), info: enc.encode(roomId) },
hkdfBase,
{ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
);
} else {
// Standard PBKDF2-SHA-256 at 150 000 iterations
key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 150000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
);
}
this._keyCache.set(cacheKey, { key, expires: Date.now() + this._KEY_TTL_MS });
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));
const payload = {
...plainObj,
_seq: (this._seqCounter = ((this._seqCounter || 0) + 1)),
_ts: Date.now()
};
const padded = _padMessage(JSON.stringify(payload));
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, padded);
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 unpadded = _unpadMessage(new Uint8Array(decrypted));
if (!unpadded) {
console.warn('[P2PCrypto] Unpad failed — likely corrupt or legacy unpadded message');
return null;
}
const plain = JSON.parse(unpadded);
const now = Date.now();
// SECURITY FIX #8: Replay window tightened from 6 hours to 5 minutes.
if (plain._ts && Math.abs(now - plain._ts) > 1000 * 60 * 5) {
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(); }
};
// P2PCrypto is intentionally NOT exposed to unsafeWindow — no need to
// give page scripts access to the encrypt/decrypt methods or key cache.
// ─── PRE-SHARED KEY (PSK) — GM-SPLIT DERIVATION ────────────────────────────
// SECURITY MODEL: The PSK has two halves:
// • Source half (_pskSrc): embedded in the script. Because this script is
// published, the source half is NOT SECRET — anyone can read it.
// It functions as a "community namespace" separator, not a secret.
// • GM half (_storedHalfB): randomly generated per-user on first run, stored
// in GM storage. This half IS secret — it never appears in the script.
// If an attacker dumps a victim's GM storage they can reconstruct the PSK,
// so users are advised to enable a custom passphrase for room-level privacy.
// Final PSK = SHA-256(srcHalf : gmHalf : 'psk-derive') — 32 hex chars.
// This design means passive relay-level attackers (ntfy.sh operators) cannot
// decrypt messages without knowing the per-user GM half.
// NOTE on topic seed (#10): _topicSeed is used to derive ntfy topic names via
// SHA-256. Because the seed is in the script source, a determined observer who
// knows the seed can subscribe to the same topics and see encrypted envelopes
// (room names, ciphertext volumes, timestamps) even without breaking AES-GCM.
// This is acceptable for a community script but users should be aware of it.
const _pskA = 'jv5\u2010comm';
const _pskB = 'unity\u20102026\u2010K9#mXq';
const _pskSrc = _pskA + _pskB + 'R2pL8wNzAe';
// Half B: load from GM storage, or generate and store a random 32-byte hex string
const _pskGmKey = 'jv5_psk_b';
let _storedHalfB = (() => { try { return GM_getValue(_pskGmKey, null); } catch { return null; } })();
if (!_storedHalfB) {
_storedHalfB = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map(b => b.toString(16).padStart(2, '0')).join('');
try { GM_setValue(_pskGmKey, _storedHalfB); } catch {}
}
// Final PSK = SHA-256(sourceHalf + ':' + gmHalf + ':psk-derive')
// Both halves must be present to arrive at the same key — mutual dependency.
// Use the full 64 hex chars (256 bits) — do NOT truncate.
const P2P_PSK = await (async () => {
const combined = _pskSrc + ':' + _storedHalfB + ':psk-derive';
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(combined));
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join('');
})();
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.
*/
// ── Helper: returns the active PSK for encryption/decryption ──────────────
// If the user has set a custom passphrase, its SHA-256 hash is mixed with
// the GM-split PSK via HKDF so the result is domain-separated from both
// inputs. This means:
// • Custom passphrase users form a private sub-group within any room
// • Clearing the passphrase reverts to the default PSK seamlessly
// • The raw passphrase is never stored — only its SHA-256 hash
// • Neither input half alone is sufficient to derive the active key
// SECURITY FIX #6: Previously the custom PSK was derived then immediately exported
// to a hex string and stored in a plain JS object. A non-extractable CryptoKey is
// opaque to devtools memory snapshots; a plain hex string is not.
// Now we cache the CryptoKey itself (non-extractable) and pass it directly to
// encrypt/decrypt, skipping the export step entirely.
let _activePskCache = null; // { hash: string, cryptoKey: CryptoKey } — invalidated when customHash changes
async function _getActivePskKey() {
const customHash = (() => { try { return GM_getValue('jv5_custom_psk_hash', ''); } catch { return ''; } })();
if (customHash && customHash.length === 64) {
if (_activePskCache && _activePskCache.hash === customHash) return _activePskCache.cryptoKey;
const enc = new TextEncoder();
const ikm = await crypto.subtle.importKey('raw', enc.encode(customHash), { name: 'HKDF' }, false, ['deriveKey']);
const cryptoKey = await crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: enc.encode(P2P_PSK), info: enc.encode('jv5-custom-psk-v1') },
ikm,
{ name: 'AES-GCM', length: 256 },
false, // ← non-extractable: key cannot be exported from the browser
['encrypt', 'decrypt']
);
_activePskCache = { hash: customHash, cryptoKey };
return cryptoKey;
}
_activePskCache = null;
return null; // null → caller uses the default PSK string path
}
// Keep the string-based getter for backward compat with any path that still needs it
async function _getActivePsk() {
const customHash = (() => { try { return GM_getValue('jv5_custom_psk_hash', ''); } catch { return ''; } })();
if (customHash && customHash.length === 64) {
// For the string path, we still need a hex PSK. Derive it but never cache as string.
const enc = new TextEncoder();
const ikm = await crypto.subtle.importKey('raw', enc.encode(customHash), { name: 'HKDF' }, false, ['deriveKey']);
const key = await crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: enc.encode(P2P_PSK), info: enc.encode('jv5-custom-psk-v1') },
ikm, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']
);
const raw = await crypto.subtle.exportKey('raw', key);
return Array.from(new Uint8Array(raw)).map(b => b.toString(16).padStart(2,'0')).join('');
}
return P2P_PSK;
}
async function pskEncrypt(plainObj) {
try {
return await P2PCrypto.encrypt(plainObj, await _getActivePsk(), P2P_PSK_ROOM);
} catch (e) {
console.warn('[JV5-PSK] Encrypt failed:', e.message);
return null;
}
}
/**
* Decrypts a PSK-encrypted message.
* Tries the active PSK first. If that fails and a custom passphrase is set,
* falls back to the default PSK — handles messages sent before the passphrase
* was changed, and prevents silent message loss during transition.
*/
async function pskDecrypt(encObj) {
if (!encObj || !encObj.encrypted) return null;
try {
const result = await P2PCrypto.decrypt(encObj, await _getActivePsk(), P2P_PSK_ROOM);
if (result) return result;
// If custom passphrase is set and active-PSK decryption failed,
// try the default PSK as fallback (graceful degradation for mixed rooms)
const customSet = (() => { try { return GM_getValue('jv5_custom_psk_set', false); } catch { return false; } })();
if (customSet) {
return await P2PCrypto.decrypt(encObj, P2P_PSK, P2P_PSK_ROOM);
}
return null;
} 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 = P2P_TOPIC_SIGNAL;
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
// ICE server config — deliberately avoids Google STUN to reduce IP
// disclosure to Google's infrastructure. Uses Cloudflare + open STUN servers.
// Note: STUN by nature reveals your public IP to the STUN server; this only
// moves the disclosure away from Google. True IP privacy requires a TURN relay
// (which would need a self-hosted server). mDNS candidate filtering below
// further limits local LAN IP leakage inside the SDP.
this.iceServers = [
{ urls: 'stun:stun.cloudflare.com:3478' }, // Cloudflare (no Google)
{ urls: 'stun:stun.nextcloud.com:443' }, // Nextcloud open STUN
{ urls: 'stun:stunserver.stunprotocol.org' }, // stunprotocol.org (non-commercial)
];
}
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');
_devLog('[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)) {
_devLog('[WebRTC] Waiting for offer from a peer (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) {
// Filter out host candidates that contain a raw private/LAN IP.
// mDNS candidates (*.local) are safe — they don't expose your real LAN IP.
// srflx (server-reflexive) candidates contain the public IP, which is
// unavoidable for WebRTC — but we still skip plain host candidates.
const cand = event.candidate.candidate || '';
const isHostWithRealIP = /typ host/.test(cand) && !/\.local\b/.test(cand);
if (isHostWithRealIP) return; // drop LAN IP — keep mDNS + srflx only
this._sendSignalingMessage(remotePeerId, { type: 'ice-candidate', candidate: event.candidate });
}
};
pc.onconnectionstatechange = () => {
const state = pc.connectionState;
_devLog('[WebRTC] initiator peer →', 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);
_devWarn('[WebRTC] connectToPeer failed:', e && e.message);
}
}
_setupDataChannel(dc, remotePeerId) {
this.dataChannels.set(remotePeerId, dc);
dc.onopen = () => {
if (this.onStatusCallback) this.onStatusCallback(`peer-${remotePeerId}-connected`);
};
dc.onmessage = (event) => {
try {
// SECURITY FIX #10: Reject oversized messages to prevent memory DoS.
// A malicious peer could send a huge JSON blob to hang JSON.parse.
if (typeof event.data === 'string' && event.data.length > 65536) {
_devWarn('[WebRTC] Dropped oversized message from peer', remotePeerId, '— size:', event.data.length);
return;
}
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) {
const cand = event.candidate.candidate || '';
const isHostWithRealIP = /typ host/.test(cand) && !/\.local\b/.test(cand);
if (isHostWithRealIP) return;
this._sendSignalingMessage(fromPeer, { type: 'ice-candidate', candidate: event.candidate });
}
};
pc.onconnectionstatechange = () => {
const state = pc.connectionState;
_devLog('[WebRTC] answerer peer →', 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) {}
}
}
async _sendSignalingMessage(toPeer, payload) {
const signalingMsg = {
v: 1,
from: this.localPeerId,
to: toPeer,
room: this.room,
webrtc: true,
payload,
ts: Date.now()
};
// Encrypt signaling (SDP/ICE) before hitting ntfy — prevents relay from
// seeing IP addresses embedded in SDP and ICE candidates.
let body;
try {
const enc = await pskEncrypt(signalingMsg);
// SECURITY FIX #1: Never fall back to plaintext — SDP/ICE contain IP addresses.
// If encryption fails for any reason, drop the message entirely.
if (!enc) {
_devWarn('[WebRTC] Signaling message dropped — PSK encryption failed (refusing plaintext fallback)');
return;
}
enc.psk = true; enc.webrtcSig = true; // flag so receiver knows to decrypt
body = JSON.stringify(enc);
} catch (e) {
_devWarn('[WebRTC] Signaling message dropped — encryption threw:', e && e.message);
return; // SECURITY FIX #1: drop, never send plaintext
}
GM_xmlhttpRequest({
method: 'POST',
url: `${_p2pGetRelay()}/${this.signalingTopic}`,
headers: _p2pRelayHeaders({ 'Content-Type': 'text/plain' }),
data: body
});
}
_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, headers: _p2pRelayHeaders() }
);
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);
// SECURITY FIX #7: Prune the dedup set to prevent unbounded growth.
// A hostile relay flooding fake IDs would otherwise exhaust memory.
if (this._seenSignalingIds.size > 500) this._seenSignalingIds.clear();
// 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) {
let msg = JSON.parse(evt.message);
// Decrypt if PSK-encrypted signaling packet
if (msg.psk && msg.webrtcSig && msg.encrypted) {
const dec = await pskDecrypt(msg);
if (!dec) continue; // drop undecryptable signaling
msg = dec;
}
if (msg.webrtc && msg.to === this.localPeerId && msg.room === this.room) {
// Validate msg.from is a well-formed peer ID before using it as a
// RTCPeerConnection key. Crafted signaling with arbitrary 'from'
// strings could otherwise inject phantom connections or interfere
// with the glare-prevention timestamp-suffix comparison.
const _fromOk = typeof msg.from === 'string'
&& /^[a-z0-9]{4,20}$/i.test(msg.from)
&& msg.from !== this.localPeerId;
if (_fromOk) 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();
// Expose a safe read-only diagnostic proxy — NOT the live webrtcManager object.
// Page scripts can read status/counts but cannot call disable(), connectToPeer(),
// or access the raw RTCPeerConnection objects.
try {
Object.defineProperty(unsafeWindow, 'jv5WebRTC', {
get: () => ({
isEnabled: webrtcManager.isEnabled,
connectedCount: webrtcManager.connectedCount(),
room: webrtcManager.room,
// No disable(), connectToPeer(), peerConnections, dataChannels, or signalingTopic
}),
configurable: false,
enumerable: false,
});
} catch {}
const selectorEngine = new SelectorEngine();
// selectorEngine intentionally NOT exposed to unsafeWindow — page scripts have no business
// reading the live selector map or triggering self-healing externally.
// 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) {
// Use CSPRNG — Math.random() is not cryptographically secure
const rand = Array.from(crypto.getRandomValues(new Uint8Array(8)))
.map(b => b.toString(36).padStart(2, '0')).join('').slice(0, 12);
id = 'p' + rand + 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;
}
async 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)) {
const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(_topicSeed + '-char-' + cid));
return 'jv5-' + Array.from(new Uint8Array(b)).map(x=>x.toString(16).padStart(2,'0')).join('').slice(0,20);
}
}
return P2P_TOPIC_GLOBAL;
}
// ─── NICK SANITISATION ────────────────────────────────────────────────────────
// Applied at parse time (incoming messages) and at storage time (outgoing nick).
// _esc() handles display-time XSS escaping separately — this strips at the data
// layer so that malicious bytes never reach GM storage or in-memory message objects.
function _sanitizeNick(str) {
if (typeof str !== 'string') return 'Anonymous';
return str
.replace(/[\x00-\x1f\x7f]/g, '') // strip control chars and null bytes
.replace(/[<>"'`]/g, '') // strip HTML/attr-injection chars
.trim()
.slice(0, 24)
|| 'Anonymous';
}
// Sanitises the replyTo sub-object embedded inside incoming messages.
// replyTo passes through _safeMergeMsg as a raw nested object, so its own
// fields are not individually validated. Without this, a crafted sender
// could embed oversized strings, proto-pollution keys, or unexpected types
// inside replyTo that reach GM history storage and rendered quote blocks.
function _sanitizeReplyTo(rt) {
if (!rt || typeof rt !== 'object' || Array.isArray(rt)) return null;
const safe = Object.create(null);
if (typeof rt.id === 'string') safe.id = rt.id.slice(0, 10);
if (typeof rt.peer === 'string') safe.peer = rt.peer.slice(0, 64);
safe.nick = _sanitizeNick(rt.nick || 'Someone');
safe.text = typeof rt.text === 'string' ? rt.text.slice(0, 800) : '';
return safe;
}
// ─── HISTORY STORAGE ──────────────────────────────────────────────────────────
function _p2pGetHistory() {
try {
const raw = JSON.parse(GM_getValue(P2P_GM_HISTORY, '[]'));
if (!Array.isArray(raw)) return [];
// Sanitise each stored entry through the same field allowlist used for
// incoming messages. Prevents a tampered GM store from injecting unexpected
// fields into rendered bubbles (e.g. __proto__, constructor, oversized blobs).
return raw.map(entry => {
if (!entry || typeof entry !== 'object') return null;
const safe = Object.create(null);
for (const k of _MSG_ALLOWED_FIELDS) {
if (k in entry) {
const v = entry[k];
// Enforce string fields are strings, numeric fields are numbers
if (k === 'ts' && typeof v === 'number') safe[k] = v;
else if (k === 'v' && typeof v === 'number') safe[k] = v;
else if (k === 'nick') safe[k] = _sanitizeNick(v);
else if (typeof v === 'string' || typeof v === 'boolean' ||
Array.isArray(v) || typeof v === 'number') safe[k] = v;
else if (v !== null && typeof v === 'object') safe[k] = v; // replyTo etc.
}
}
return Object.keys(safe).length ? safe : null;
}).filter(Boolean);
} 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) { _devWarn('[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;
// SECURITY: HTTPS-only + SSRF block. The verified-URL is user-configurable
// and fetched via GM_xmlhttpRequest (no CORS). Without this guard, an
// attacker who can write GM storage could point it at an internal endpoint.
if (!/^https:\/\/.+/.test(P2P_VERIFIED_URL) || _SSRF_BLOCK_RE.test(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) {
// Validate peer ID format — prevents false badges from crafted JSON.
if (typeof e.peer !== 'string' || !/^[a-z0-9]{4,20}$/i.test(e.peer)) continue;
// Sanitize display name — remote JSON is untrusted content.
_chatVerifiedMap.set(e.peer, _sanitizeNick(e.name || e.peer));
}
} catch {}
},
});
} catch {}
}
// ─── PERSISTENT MSGID DEDUP ───────────────────────────────────────────────
// Pre-load recent seen msgIds from GM storage so that messages which arrived
// before a page refresh are not re-displayed as duplicates.
// Only keeps entries from the past 6 hours (matching the freshness window).
const _GM_SEEN_MSGS = 'jv5_seen_msgids';
const _SEEN_MSG_TTL = 1000 * 60 * 60 * 6; // 6 hours
function _loadPersistedSeenIds() {
try {
const raw = GM_getValue(_GM_SEEN_MSGS, '[]');
const arr = JSON.parse(raw);
const now = Date.now();
// Stored as [{id, ts}] — prune stale entries on load
const fresh = arr.filter(e => e && typeof e.ts === 'number' && (now - e.ts) < _SEEN_MSG_TTL);
return new Set(fresh.map(e => e.id));
} catch { return new Set(); }
}
function _persistSeenIds(set) {
try {
const now = Date.now();
const arr = [...set].slice(-500).map(id => ({ id, ts: now }));
GM_setValue(_GM_SEEN_MSGS, JSON.stringify(arr));
} catch {}
}
// ─── ONE-TIME HISTORY MIGRATION (v5.7.6) ─────────────────────────────────
// Pre-5.7.6, outgoing messages were saved to GM history as PSK ciphertext
// blobs (no peer/ts/text/nick fields). Those entries rendered as
// "Anonymous #???? Invalid Date" with blank text on every modal reopen.
// This migration runs once on load and purges encrypted blobs from history
// so users get a clean slate without needing to manually clear storage.
;(() => {
try {
const migKey = 'jv5_hist_migrated_576';
if (GM_getValue(migKey, false)) return; // already done
const raw = JSON.parse(GM_getValue(P2P_GM_HISTORY, '[]'));
if (!Array.isArray(raw)) return;
// Keep only entries that have both peer and text (valid plaintext messages)
const clean = raw.filter(e => e && typeof e === 'object' && e.peer && e.text);
GM_setValue(P2P_GM_HISTORY, JSON.stringify(clean));
GM_setValue(migKey, true);
} catch {}
})();
const chatStore = {
open: false,
listEl: null,
messages: [],
seenMsgIds: _loadPersistedSeenIds(), // pre-seeded from GM storage
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 = _loadPersistedSeenIds(); // reload from GM on reset
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('');
}
// SECURITY FIX #4: Admin password hashing upgraded from raw SHA-256 to PBKDF2-SHA-256.
// SHA-256 is fast — an attacker who dumps GM storage can brute-force common passwords
// in seconds. PBKDF2 with 150k iterations raises the cost by ~150,000×.
// Storage key: 'jv4_p2p_admin_hash_v2' → JSON { salt: base64, hash: hex }
// Backward compat: if only the legacy SHA-256 hash exists, it still works and
// auto-upgrades to PBKDF2 on first successful login.
const _GM_ADMIN_HASH_V2 = 'jv4_p2p_admin_hash_v2';
async function _adminPbkdf2Hash(password) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const enc = new TextEncoder();
const km = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']);
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 150000 },
km, { name: 'AES-GCM', length: 256 }, true, ['encrypt']
);
const raw = await crypto.subtle.exportKey('raw', key);
return {
salt: _arrToB64(salt),
hash: Array.from(new Uint8Array(raw)).map(b => b.toString(16).padStart(2,'0')).join('')
};
}
async function _adminVerifyPbkdf2(password, stored) {
try {
const enc = new TextEncoder();
const salt = _b64ToArr(stored.salt);
const km = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']);
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 150000 },
km, { name: 'AES-GCM', length: 256 }, true, ['encrypt']
);
const raw = await crypto.subtle.exportKey('raw', key);
const hash = Array.from(new Uint8Array(raw)).map(b => b.toString(16).padStart(2,'0')).join('');
// Constant-time hex comparison via HMAC to avoid timing oracle
const hk = await crypto.subtle.importKey('raw', new Uint8Array(32), { name:'HMAC', hash:'SHA-256' }, false, ['sign','verify']);
const a = await crypto.subtle.sign('HMAC', hk, enc.encode(hash));
const b2 = await crypto.subtle.sign('HMAC', hk, enc.encode(stored.hash));
const av = new Uint8Array(a), bv = new Uint8Array(b2);
if (av.length !== bv.length) return false;
let diff = 0; for (let i = 0; i < av.length; i++) diff |= av[i] ^ bv[i];
return diff === 0;
} catch { return false; }
}
// Returns first 12 hex chars of SHA-256 — used as a privacy-preserving reporter fingerprint
function _sha256Short(str) {
// Synchronous best-effort: use cached peer ID hash or fall back to sliced ID
// (Full async SHA-256 not needed for a short display fingerprint)
return typeof str === 'string' ? str.slice(0, 8) + '…' : 'anon';
}
// ─── 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.
//
// Ed25519 toggle (jv5_use_ed25519_admin) switches the HMAC hash from SHA-256
// to SHA-512 AND pre-stretches the key through HKDF with a domain-separation tag.
// This:
// • Doubles the MAC output length (512 vs 256 bits)
// • Adds domain separation so a captured admin signature cannot be replayed
// as a non-admin HMAC in a different context
// • Makes offline brute-force more expensive (SHA-512 is ~2× slower on 32-bit CPUs)
// Note: true Ed25519 (asymmetric) requires the signatory to hold a private key
// and verifiers to use a public key — that would require a key-registration ceremony
// out of scope for a serverless userscript. This is the best practical equivalent
// using symmetric primitives (HMAC) available in crypto.subtle.
async function _adminDeriveKey(hashAlgo) {
const enc = new TextEncoder();
const raw = await crypto.subtle.importKey(
'raw', enc.encode(P2P_ADMIN_HASH),
{ name: 'HMAC', hash: hashAlgo }, false, ['sign', 'verify']
);
if (!gget('jv5_use_ed25519_admin', true)) return raw;
// Strong mode: stretch through HKDF for domain separation
const bits = await crypto.subtle.sign('HMAC', raw, enc.encode('jv5-admin-kdf-v1'));
return crypto.subtle.importKey(
'raw', bits,
{ name: 'HMAC', hash: 'SHA-512' }, false, ['sign', 'verify']
);
}
/**
* Signs a message ID + timestamp.
* Strong mode ON → HMAC-SHA-512 with HKDF-stretched key + domain tag.
* Strong mode OFF → HMAC-SHA-256 (legacy compatible).
*/
async function _signAdminMsg(msgId, ts) {
if (!P2P_ADMIN_HASH) return '';
try {
const useStrong = gget('jv5_use_ed25519_admin', true);
const hashAlgo = useStrong ? 'SHA-512' : 'SHA-256';
const key = await _adminDeriveKey(hashAlgo);
const enc = new TextEncoder();
// Domain-separation tag prevents cross-context reuse of admin signatures
const msg = useStrong
? `jv5-admin-v2:${msgId}:${ts}`
: `${msgId}:${ts}`;
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(msg));
return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
} catch { return ''; }
}
/**
* Verifies an admin HMAC signature using constant-time comparison.
* Automatically detects legacy (SHA-256) vs strong (SHA-512) signatures
* by length: SHA-256 → 64 hex chars, SHA-512 → 128 hex chars.
*/
async function _verifyAdminSig(msgId, ts, sig) {
if (!P2P_ADMIN_HASH || !sig) return false;
try {
// Convert the received hex signature to bytes for crypto.subtle.verify
const _hexToBytes = hex => new Uint8Array(hex.match(/.{1,2}/g)?.map(b => parseInt(b, 16)) || []);
const useStrong = gget('jv5_use_ed25519_admin', true);
const hashAlgo = useStrong ? 'SHA-512' : 'SHA-256';
const key = await _adminDeriveKey(hashAlgo);
const enc = new TextEncoder();
const msgStr = useStrong ? `jv5-admin-v2:${msgId}:${ts}` : `${msgId}:${ts}`;
// Use crypto.subtle.verify — performs a constant-time MAC comparison
// internally, so there's no timing oracle even if the JS engine optimises XOR loops.
const sigBytes = _hexToBytes(sig);
const ok = sigBytes.length > 0 && await crypto.subtle.verify(
'HMAC', key, sigBytes, enc.encode(msgStr)
);
if (ok) return true;
// Fallback: try the opposite toggle mode for backward compat with older senders
const altHash = useStrong ? 'SHA-256' : 'SHA-512';
const altKey = await _adminDeriveKey(altHash);
const altMsg = useStrong ? `${msgId}:${ts}` : `jv5-admin-v2:${msgId}:${ts}`;
return sigBytes.length > 0 && await crypto.subtle.verify(
'HMAC', altKey, sigBytes, enc.encode(altMsg)
);
} catch { return false; }
}
// ─── PERSISTENT ADMIN REPLAY PREVENTION ───────────────────────────────────
// seenMsgIds is in-memory only and gets cleared on modal reset, so a captured
// admin-signed message could be replayed after a page refresh within the 6h window.
// We persist seen admin msgIds to GM storage (capped to prevent unbounded growth).
const _GM_ADMIN_SEEN = 'jv5_admin_seen_ids';
const _adminSeenIds = (() => {
try {
const stored = GM_getValue(_GM_ADMIN_SEEN, '[]');
const parsed = JSON.parse(stored);
return Array.isArray(parsed) ? new Set(parsed) : new Set();
} catch { return new Set(); }
})();
function _isAdminMsgSeen(msgId) {
return _adminSeenIds.has(msgId);
}
function _markAdminMsgSeen(msgId) {
_adminSeenIds.add(msgId);
// Cap to 500 entries; remove oldest 200 when over limit
if (_adminSeenIds.size > 500) {
const arr = [..._adminSeenIds];
arr.slice(0, 200).forEach(id => _adminSeenIds.delete(id));
}
try { GM_setValue(_GM_ADMIN_SEEN, JSON.stringify([..._adminSeenIds])); } catch {}
}
// 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',
async 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 = await _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) => {
// IMPORTANT: do NOT check data.peer here — PSK-encrypted outer envelopes
// intentionally omit peer. Only check data.v === 1 (same as _handleEvent).
// data.peer would be undefined for encrypted payloads → all WebRTC messages
// would be silently dropped. peer is restored post-decrypt by _safeMergeMsg.
if (data && data.v === 1) {
_handleEvent({ message: JSON.stringify(data) }).catch(() => {});
}
});
webrtcManager.setStatusHandler((status) => {
if (status.startsWith('peer-') && status.endsWith('-connected')) {
const connCount = webrtcManager.connectedCount();
_devLog('[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) { clearTimeout(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 {}
// Evict all cached derived keys and ratchet state — nothing lingers in memory
try { P2PCrypto.clearKeyCache(); } catch {}
try { P2PCrypto.clearRatchetState(); } 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: _p2pRelayHeaders({ '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), _jitteredPollMs(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), _jitteredPollMs(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);
// Always advance maxId regardless of dedup state — prevents lastEventId
// from stalling when all events in a chunk are already seen (Fix #processChunk)
if (numId > maxId) maxId = numId;
if (chatStore.seenNtfyIds.has(evt.id)) continue;
chatStore.seenNtfyIds.add(evt.id);
}
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 => _devWarn('[JanitorV5] handleEvent:', e && e.message));
}
})();
}
},
async send(payload) {
const topic = await _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: _p2pRelayHeaders({ '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 (redirect:'manual' means redirects stay as 3xx)
if (r.status >= 200 && r.status < 400) {
_markMessageDelivered(payload.msgId);
}
},
onerror() {
if (responded) return;
responded = true;
clearTimeout(watchdog);
_showSendFailBanner('failed', payload);
},
ontimeout() {
if (responded) return;
responded = true;
_showSendFailBanner('timeout', payload);
},
});
},
sendTyping() {
// SECURITY: nick intentionally omitted — typing indicator goes to a public
// ntfy topic in plaintext. Including nick would let any observer build a
// real-time map of who is online and their chosen display names.
// Recipients display the short peer-ID prefix instead (non-sensitive: already visible in chat).
GM_xmlhttpRequest({
redirect: 'manual',
method: 'POST',
url: `${_p2pGetRelay()}/${P2P_TOPIC_TYPING}`,
headers: _p2pRelayHeaders({ 'Content-Type': 'text/plain' }),
data: JSON.stringify({ v:1, peer: _p2pGetPeerId(), 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;
// nick is no longer in typing payloads (privacy fix). Fall back to the
// peer's known nick from an existing chat bubble, or their short ID.
const knownNick = (() => {
const bubble = document.querySelector(`[data-peer="${CSS.escape(msg.peer)}"]`);
return bubble?.closest('.jv4-p2p-bubble')?.querySelector('.jv4-p2p-nick')?.textContent || null;
})();
const nick = knownNick ? knownNick.slice(0, 20) : `${msg.peer.slice(0, 6)}…`;
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: _p2pRelayHeaders({ '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: _p2pRelayHeaders({ '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 && msg.peer !== _p2pGetPeerId()) {
if (webrtcManager.isEnabled) webrtcManager.connectToPeer(msg.peer).catch(() => {});
// Initiate Double Ratchet ECDH handshake for this new peer (if toggle on).
// We send our ephemeral public key encrypted via PSK so the peer can
// complete the ECDH and seed their ratchet state. The peer replies
// with their own public key so we can seed ours.
if (gget('jv5_ratchet_global', true) && !P2PCrypto._ratchetState.has(msg.peer)) {
P2PCrypto.getEphemeralPublicKeyJwk().then(async pubJwk => {
const handshake = {
v: 1, type: 'ratchet-hello', peer: _p2pGetPeerId(),
nick: _p2pGetNickname(), pubKey: pubJwk, ts: Date.now()
};
const enc = await pskEncrypt(handshake);
if (enc) { enc.psk = true; enc.room = _p2pGetRoom(); chatNet.send(enc); }
}).catch(() => {});
}
}
} catch {}
},
_startHb() {
this._abort('hbXhr');
clearTimeout(this.hbStreamTimer);
if (this.hbSendTimer) { clearTimeout(this.hbSendTimer); this.hbSendTimer = null; }
if (this.abortFlag) return;
this._sendHb();
// Self-rescheduling jittered timeout instead of a fixed setInterval —
// a heartbeat landing on the relay every exactly 30.000s is a clean
// fingerprint; ±20% jitter blurs it without affecting liveness checks.
const scheduleHb = () => {
if (this.abortFlag) return;
this.hbSendTimer = setTimeout(() => { this._sendHb(); scheduleHb(); }, _jitteredPollMs(P2P_HB_SEND_MS));
};
scheduleHb();
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: _p2pRelayHeaders({ '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>}
*/
// Per-peer incoming rate limiter — prevents a single peer from flooding the
// local render loop. Tracks the last N timestamps per peer; drops messages
// that arrive faster than _PEER_RATE_LIMIT allows.
const _peerRxTimes = new Map(); // peerId → [timestamps]
const _PEER_RX_WINDOW_MS = 10000; // rolling window
const _PEER_RX_MAX_MSGS = 15; // max messages per peer per window
const _MAX_MSG_BYTES = 65536; // 64 KB hard cap on raw ntfy message string
function _peerRxAllowed(peerId) {
if (!peerId) return true; // can't rate-limit before peer is known; filter later
const now = Date.now();
const times = (_peerRxTimes.get(peerId) || []).filter(t => now - t < _PEER_RX_WINDOW_MS);
if (times.length >= _PEER_RX_MAX_MSGS) {
_devWarn(`[JV5-RateLimit] Peer ${peerId.slice(0,8)} exceeded ${_PEER_RX_MAX_MSGS} msgs/${_PEER_RX_WINDOW_MS}ms — dropping`);
_peerRxTimes.set(peerId, times);
return false;
}
times.push(now);
_peerRxTimes.set(peerId, times);
// Evict stale entries periodically to avoid unbounded map growth
if (_peerRxTimes.size > 200) {
for (const [pid, ts] of _peerRxTimes) {
if (!ts.some(t => now - t < _PEER_RX_WINDOW_MS)) _peerRxTimes.delete(pid);
}
}
return true;
}
async function _handleEvent(ntfyEvt) {
try {
// Hard cap on raw message size — reject before JSON.parse to avoid
// memory pressure from oversized payloads crafted by bad actors.
if (!ntfyEvt.message || ntfyEvt.message.length > _MAX_MSG_BYTES) return;
const msg = JSON.parse(ntfyEvt.message);
// NOTE: do NOT check !msg.peer here — PSK-encrypted outer envelopes intentionally
// omit peer for privacy. peer is restored after pskDecrypt via _safeMergeMsg below.
if (!msg || 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)
_devWarn('[JV5-PSK] Dropped message: wrong PSK version or corrupted');
return;
}
// Restore routing fields kept plaintext on the outer envelope
const roomTag = msg.room;
// SECURITY: safe field-allowlist merge — prevents prototype pollution from
// crafted decrypted payloads containing __proto__, constructor, toString, etc.
_safeMergeMsg(msg, decrypted);
if (roomTag) msg.room = roomTag;
// nick is no longer on the outer envelope — it comes from inside ciphertext only
msg._wasEncrypted = true;
msg._pskDecrypted = true;
// If the decrypted body itself is a ratchet-encrypted inner layer (v:2),
// decrypt that too using the per-peer ratchet chain.
if (msg.ratchet && msg.v === 2 && msg.peerId && gget('jv5_ratchet_global', true)) {
const ratchetPlain = await P2PCrypto.ratchetDecrypt(msg, msg.peerId);
if (ratchetPlain) _safeMergeMsg(msg, ratchetPlain);
// If ratchetDecrypt fails, msg still has PSK-decrypted data — graceful fallback
}
}
// === 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 = await _promptModal({ title: '🔒 Room Password', placeholder: 'Enter shared room password', type: 'password', confirm: 'Decrypt' });
if (pw) chatStore._roomPasswords[curRoom] = pw;
else return;
}
const decrypted = await P2PCrypto.decrypt(msg, pw, curRoom);
if (!decrypted) return;
_safeMergeMsg(msg, decrypted);
msg._wasEncrypted = true;
}
// ── Post-decryption validation ─────────────────────────────────────────
// Now that all decryption layers are complete, enforce that msg.peer is a
// well-formed string. A crafted ciphertext that decrypts without a peer
// field should not reach the message store or the UI.
if (!msg.peer || typeof msg.peer !== 'string' || msg.peer.length > 64) return;
// Per-peer incoming rate limit — drop floods before any further processing
if (!_peerRxAllowed(msg.peer)) return;
// Sanitise nick at parse time — strips control chars and HTML injection chars
// before the value reaches GM history storage or the in-memory message list.
if (msg.nick) msg.nick = _sanitizeNick(msg.nick);
// Sanitise the replyTo sub-object — its nested fields are NOT covered by
// _safeMergeMsg's top-level allowlist and arrive as raw nested objects.
if (msg.replyTo) msg.replyTo = _sanitizeReplyTo(msg.replyTo);
// ──────────────────────────────────────────────────────────────────────
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;
}
// ── Double Ratchet ECDH handshake handling ─────────────────────────────
// ratchet-hello: a peer just saw us and is sending their ephemeral public key.
// We store it, init our ratchet state (we are the responder), then reply.
// ratchet-reply: the peer confirmed receipt and sent their public key back.
// We init our ratchet state (we are the initiator).
if (msg.type === 'ratchet-hello' && msg.pubKey && msg.peer && msg.peer !== _p2pGetPeerId()) {
if (gget('jv5_ratchet_global', true) && !P2PCrypto._ratchetState.has(msg.peer)) {
P2PCrypto.initRatchetWithPeer(msg.peer, msg.pubKey, false /* responder */).then(async () => {
// Reply with our ephemeral public key so the initiator can complete their side
const pubJwk = await P2PCrypto.getEphemeralPublicKeyJwk();
const reply = {
v: 1, type: 'ratchet-reply', peer: _p2pGetPeerId(),
nick: _p2pGetNickname(), pubKey: pubJwk, ts: Date.now()
};
const enc = await pskEncrypt(reply);
if (enc) { enc.psk = true; enc.room = _p2pGetRoom(); chatNet.send(enc); }
}).catch(() => {});
}
return;
}
if (msg.type === 'ratchet-reply' && msg.pubKey && msg.peer && msg.peer !== _p2pGetPeerId()) {
if (gget('jv5_ratchet_global', true) && !P2PCrypto._ratchetState.has(msg.peer)) {
P2PCrypto.initRatchetWithPeer(msg.peer, msg.pubKey, true /* initiator */).catch(() => {});
}
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));
}
// Persist so dedup survives page refresh (Fix #11)
_persistSeenIds(chatStore.seenMsgIds);
}
// SECURITY: Verify HMAC signature — never trust adminToken in plaintext.
// _verifyAdminSig re-derives the HMAC locally; the secret never leaves GM storage.
// Also check persistent GM-backed seen-ids to prevent replay across modal resets.
if (msg.adminSig && msg.text && msg.text.startsWith('/') && msg.msgId) {
// Persistent replay guard — survives modal open/close and page refresh
if (_isAdminMsgSeen(msg.msgId)) return;
const _isVerifiedAdmin = await _verifyAdminSig(msg.msgId, msg.ts, msg.adminSig);
if (_isVerifiedAdmin) {
_markAdminMsgSeen(msg.msgId); // record before executing
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) {
_devWarn('[JanitorV5] _handleEvent error:', err && err.message);
}
}
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)}?`)) {
// SECURITY: Encrypt report payload with PSK before sending to relay.
// The message text is truncated to 200 chars; reporter peer ID is hashed
// to a short fingerprint so the relay operator cannot easily identify users.
const reportPayload = {
v: 1,
reporter: msg.peer ? _sha256Short(_p2pGetPeerId()) : 'anon', // hashed fingerprint only
reported: msg.peer || '',
nick: (msg.nick || '?').slice(0, 24),
textSnippet: (msg.text || '').slice(0, 200), // truncated — not full message
ts: Date.now(),
};
pskEncrypt(reportPayload).then(enc => {
const body = enc ? JSON.stringify(enc) : JSON.stringify(reportPayload);
GM_xmlhttpRequest({
redirect: 'manual',
method: 'POST',
url: `${_p2pGetRelay()}/${P2P_TOPIC_REPORTS}`,
headers: _p2pRelayHeaders({ 'Content-Type': 'text/plain' }),
data: body,
onload() { topToast('Report submitted'); },
});
});
}
});
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(async adminSig => {
const adminPayload = { v:1, peer: _p2pGetPeerId(), nick: _p2pGetNickname(), text, adminSig, ts: _adminTs, msgId: _adminMid, room: _p2pGetRoom() };
const enc = await pskEncrypt(adminPayload);
if (enc) { enc.psk = true; enc.room = adminPayload.room; chatNet.send(enc); }
else chatNet.send(adminPayload); // fallback
});
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');
}
// === Double Ratchet inner encryption (forward secrecy, per-peer) ===
// If a ratchet session exists with ANY connected peer, we encrypt the payload
// with the ratchet chain first (v:2), then wrap the result in PSK outer layer.
// Peers without a ratchet session (e.g. late joiners) fall back to PSK-only.
// The PSK layer is always applied on top regardless — ratchet is additive.
if (gget('jv5_ratchet_global', true) && P2PCrypto._ratchetState.size > 0) {
// Broadcast: we ratchet-encrypt once per connected peer since each has its own chain.
// For simplicity (no server), we send one ratchet-encrypted copy per peer via WebRTC
// DataChannel, and one PSK-only copy via ntfy for peers without ratchet state.
// This preserves interoperability with peers that haven't completed handshake yet.
for (const [peerId, _state] of P2PCrypto._ratchetState) {
const ratchetEnc = await P2PCrypto.ratchetEncrypt(finalPayload, peerId);
if (ratchetEnc) {
// Wrap ratchet ciphertext in PSK for relay confidentiality
const pskWrapped = await pskEncrypt(ratchetEnc);
if (pskWrapped) {
pskWrapped.psk = true;
pskWrapped.room = room;
pskWrapped.ratchetFor = peerId; // routing hint (non-sensitive: just a peer ID)
webrtcManager.sendToPeer?.(peerId, pskWrapped);
}
}
}
}
// === PSK Encryption (always-on) ===
// Every outgoing message is PSK-encrypted before hitting ntfy.sh.
// ntfy.sh sees only opaque ciphertext. No password prompt needed.
// NOTE: nick is NOT copied to the outer envelope — it stays inside ciphertext only.
// room is kept plaintext for routing (non-sensitive: just 'global' or a hashed ID).
// IMPORTANT: save display fields BEFORE overwriting finalPayload with ciphertext,
// because appendBubble needs peer/ts/msgId/nick/text to render the local outgoing bubble.
const _displayPayload = {
peer: finalPayload.peer, ts: finalPayload.ts, msgId: finalPayload.msgId,
nick: finalPayload.nick, text: finalPayload.text, replyTo: finalPayload.replyTo,
room: finalPayload.room,
};
const isRoomPwEncrypted = P2PCrypto.isRoomEncrypted(room);
{
const encResult = await pskEncrypt(finalPayload);
if (!encResult) {
topToast('Encryption failed — message not sent');
return;
}
encResult.psk = true; // flag: PSK-encrypted
encResult.room = room; // kept plaintext for topic routing only
encResult.msgId = msgId; // kept on outer envelope for delivery ACK — not sensitive (just a random ID)
// encResult.nick intentionally omitted — nickname stays 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 (isRoomPwEncrypted) {
if (!chatStore._roomPasswords) chatStore._roomPasswords = {};
let pw = chatStore._roomPasswords[room];
if (!pw) {
pw = await _promptModal({ title: '🔒 Room Password', placeholder: 'Enter shared room password', type: 'password', confirm: 'Send' });
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);
_persistSeenIds(chatStore.seenMsgIds);
if (chatNet.typingSendTimer) { clearTimeout(chatNet.typingSendTimer); chatNet.typingSendTimer = null; }
// CRITICAL FIX: store plaintext _displayPayload in history, NOT ciphertext finalPayload.
// Storing finalPayload (the PSK-encrypted blob) caused every outgoing message entry in GM
// history to have no peer/ts/text/nick fields. On modal reopen those blobs rendered as
// "Anonymous #???? Invalid Date" with blank text. The relay still only sees ciphertext
// (via chatNet.send below). Tampermonkey's sandboxed GM storage is safe for plaintext.
_p2pAddHistory(_displayPayload);
chatStore.messages.push(_displayPayload);
// Use _displayPayload (plaintext fields) for the local bubble — finalPayload is ciphertext
chatRender.appendBubble(_displayPayload, 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) {
_devLog('[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);
}
async function _sendReaction(reactionTo, emoji) {
const payload = { v:1, peer: _p2pGetPeerId(), nick: _p2pGetNickname(), type: 'reaction', text: emoji, reactionTo, ts: Date.now() };
try {
const enc = await pskEncrypt(payload);
if (enc) {
enc.psk = true;
enc.room = _p2pGetRoom();
// enc.nick intentionally omitted from outer envelope
chatNet.send(enc);
} else {
chatNet.send(payload); // fallback to plaintext if encryption fails
}
} catch {
chatNet.send(payload); // fallback to plaintext if encryption fails
}
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,'"').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;">${_esc(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 = _loadPersistedSeenIds(); // load persisted IDs so inter-session messages don't re-appear
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 = 'min(400px, 100vw - 16px)';
modal.style.width = '100%';
modal.innerHTML = `
<div class="ms2-modal-header">
<div class="ms2-modal-title" style="display:flex;align-items:center;flex:1;min-width:0;gap:6px;">
${SVG_CHAT} Community Chat
<span style="font-weight:400;color:#6b7280;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">· ${_esc(_p2pGetNickname())}</span>
<span id="jv4-admin-badge">👑 ADMIN</span>
${_makeInfoBtn('p2p-e2e')}
</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().catch(e => _devWarn("[JV5] chatNet.connect failed:", e && e.message));
}
};
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('🔐 End-to-end encrypted · Relay sees your connection IP but zero message content · IPs never shared with other users · msgs auto-delete ~30 min');
const list = chatStore.listEl;
if (list) list.scrollTop = list.scrollHeight;
_p2pFetchVerified();
chatNet.connect().catch(e => _devWarn("[JV5] chatNet.connect failed:", e && e.message));
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().catch(e => _devWarn("[JV5] chatNet.connect failed:", e && e.message));
// 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', () => {
// window.prompt() is silently blocked on many mobile browsers — use a proper modal instead
const existing = document.getElementById('jv5-nick-overlay');
if (existing) existing.remove();
const cur = _p2pGetNickname();
const ov = document.createElement('div');
ov.id = 'jv5-nick-overlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:10000060;background:rgba(0,0,0,0.65);display:flex;align-items:center;justify-content:center;';
ov.innerHTML = `
<div style="background:#13131f;border:1px solid rgba(139,92,246,0.5);border-radius:14px;
padding:18px 16px;width:min(300px,calc(100vw - 32px));box-shadow:0 12px 36px rgba(0,0,0,0.75);
font-family:system-ui,sans-serif;">
<div style="font-size:13px;font-weight:700;color:#c4b5fd;margin-bottom:10px;">✏️ Set Nickname</div>
<input id="jv5-nick-input" type="text" maxlength="24"
value="${_esc(cur)}"
placeholder="Enter nickname (max 24 chars)"
style="width:100%;box-sizing:border-box;padding:9px 11px;background:#0d0d1a;
border:1px solid rgba(139,92,246,0.4);border-radius:8px;color:#e2e8f0;
font-size:13px;outline:none;margin-bottom:10px;">
<div style="display:flex;gap:8px;">
<button id="jv5-nick-save" style="flex:1;padding:8px;font-size:12px;font-weight:600;
background:linear-gradient(135deg,#7c3aed,#6d28d9);border:none;border-radius:8px;
color:#fff;cursor:pointer;">Save</button>
<button id="jv5-nick-cancel" style="flex:1;padding:8px;font-size:12px;
background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.12);
border-radius:8px;color:#9ca3af;cursor:pointer;">Cancel</button>
</div>
</div>`;
document.body.appendChild(ov);
setTimeout(() => ov.querySelector('#jv5-nick-input')?.focus(), 80);
const _doSave = () => {
// _sanitizeNick strips control chars and HTML injection chars at storage time,
// not just at display time — prevents malicious bytes from reaching GM storage
// and propagating to all other users' rendered bubbles.
const val = _sanitizeNick(ov.querySelector('#jv5-nick-input')?.value || '');
GM_setValue(P2P_GM_NICKNAME, val);
topToast(`Nickname set to "${val}"`);
const sub = modal.querySelector('.ms2-modal-title span');
if (sub) sub.textContent = `· ${val}`;
ov.remove();
};
ov.querySelector('#jv5-nick-save').addEventListener('click', _doSave);
ov.querySelector('#jv5-nick-cancel').addEventListener('click', () => ov.remove());
ov.querySelector('#jv5-nick-input').addEventListener('keydown', e => { if (e.key === 'Enter') _doSave(); if (e.key === 'Escape') ov.remove(); });
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
});
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;
let verified = false;
// SECURITY FIX #4: Try PBKDF2 (v2) first; fall back to legacy SHA-256 and auto-upgrade.
const v2stored = (() => { try { const s = GM_getValue(_GM_ADMIN_HASH_V2, ''); return s ? JSON.parse(s) : null; } catch { return null; } })();
if (v2stored && v2stored.salt && v2stored.hash) {
verified = await _adminVerifyPbkdf2(pwEl.value, v2stored);
} else if (P2P_ADMIN_HASH) {
// Legacy path — SHA-256 comparison
const hash = await _sha256(pwEl.value);
if (hash === P2P_ADMIN_HASH) {
verified = true;
// Auto-upgrade: store PBKDF2 hash for future logins
try {
const upgraded = await _adminPbkdf2Hash(pwEl.value);
GM_setValue(_GM_ADMIN_HASH_V2, JSON.stringify(upgraded));
} catch {}
}
}
if (verified) {
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 relays messages through <strong>ntfy.sh</strong> (free, open-source relay).
</div>
<div style="background:rgba(16,185,129,0.07);border:1px solid rgba(16,185,129,0.25);border-radius:8px;padding:10px 12px;margin-bottom:10px;">
<div style="font-size:10.5px;font-weight:700;color:#6ee7b7;letter-spacing:.4px;margin-bottom:6px;">🔐 WHAT'S PROTECTED</div>
<div style="font-size:12.5px;color:#d1d5db;line-height:1.65;">
<strong style="color:#e5e7eb;">Your messages</strong> are end-to-end encrypted with AES-GCM-256 before leaving your browser. ntfy.sh only ever sees opaque ciphertext — no message content, no IPs hidden inside messages.<br>
<strong style="color:#e5e7eb;">Your identity</strong> is a random anonymous peer ID — no login, no email, no real name required.<br>
<strong style="color:#e5e7eb;">Other users</strong> see only your chosen nickname and your messages. Your IP is never shared with other users.
</div>
</div>
<div style="background:rgba(251,191,36,0.06);border:1px solid rgba(251,191,36,0.25);border-radius:8px;padding:10px 12px;margin-bottom:10px;">
<div style="font-size:10.5px;font-weight:700;color:#fcd34d;letter-spacing:.4px;margin-bottom:6px;">⚠️ WHAT THE RELAY STILL SEES</div>
<div style="font-size:12.5px;color:#d1d5db;line-height:1.65;">
<strong style="color:#e5e7eb;">Your connection IP</strong> — ntfy.sh sees the IP your browser uses to connect to it. This is the same as any website visit and cannot be avoided. Use a <strong>VPN or Tor</strong> if you want to hide your real IP from the relay.<br>
<strong style="color:#e5e7eb;">Timestamps</strong> — the relay sees when messages arrive, not what they say.<br>
<strong style="color:#e5e7eb;">Message size</strong> — padded to 256-byte blocks, so exact length is obscured.
</div>
</div>
<p style="margin:0 0 10px;font-size:11.5px;color:#6b7280;line-height:1.5;">
Ciphertext is stored on ntfy.sh for ~30 min then auto-deleted. To remove the relay entirely, set a custom relay URL in General settings.
</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 = _sanitizeNick(modal.querySelector('#jv4-consent-nick').value || '');
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 _pinSession.active ? _sessionApiKey : gget('ms2_apiKey', ''); },
set apiKey(v) {
if (_pinSession.active) {
_sessionApiKey = v;
_sessionEndpointBinding = CFG.endpoint; // bind key to current endpoint
} else {
gset('ms2_apiKey', v);
_sessionEndpointBinding = '';
}
},
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);
toastHTML(`${SVG_WARNING} AP prompt injection failed — reply sent without your configured prompt. Check console for details.`, 5000);
}
}
}
// 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>`;
const SVG_LOCK = `<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"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;
const SVG_UNLOCK = `<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"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>`;
const SVG_SHIELD = `<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="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`;
// ─── STYLES ────────────────────────────────────────────────────────────────
GM_addStyle(`
/* ── (!) Info Popover System ── */
.jv5-info-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: 50%; flex-shrink: 0;
background: rgba(99,102,241,0.15); border: 1.5px solid rgba(99,102,241,0.45);
color: #818cf8; font-size: 10px; font-weight: 800; cursor: pointer;
line-height: 1; padding: 0; font-family: system-ui, sans-serif;
transition: background 0.15s, border-color 0.15s, color 0.15s;
vertical-align: middle; position: relative;
/* Expand touch target to 44×44px without affecting layout */
-webkit-tap-highlight-color: transparent;
}
.jv5-info-btn::after {
content: ''; position: absolute;
top: 50%; left: 50%; transform: translate(-50%,-50%);
width: 44px; height: 44px; border-radius: 50%;
pointer-events: none;
}
.jv5-info-btn:hover { background: rgba(99,102,241,0.3); border-color: #818cf8; color: #c7d2fe; }
.jv5-info-btn.active { background: rgba(99,102,241,0.35); border-color: #a5b4fc; color: #e0e7ff; }
.jv5-info-popover {
position: absolute; z-index: 100001;
background: #16162a; border: 1px solid rgba(99,102,241,0.5);
border-radius: 10px; padding: 12px 14px; width: 280px; max-width: calc(100vw - 32px);
box-shadow: 0 8px 28px rgba(0,0,0,0.65);
font-size: 11.5px; color: #c7d2fe; line-height: 1.6;
font-family: system-ui, sans-serif;
animation: ms2-fade 0.15s ease;
}
.jv5-info-popover h4 {
margin: 0 0 7px 0; font-size: 12px; font-weight: 700;
color: #a5b4fc; letter-spacing: 0.03em;
display: flex; align-items: center; gap: 6px;
}
.jv5-info-popover p { margin: 0 0 6px 0; color: #9ca3af; }
.jv5-info-popover p:last-child { margin-bottom: 0; }
.jv5-info-popover strong { color: #e0e7ff; }
.jv5-info-popover code {
background: rgba(99,102,241,0.2); border-radius: 3px;
padding: 1px 5px; font-size: 10.5px; color: #a5b4fc; font-family: monospace;
}
.jv5-info-popover .jv5-info-tag {
display: inline-flex; align-items: center; gap: 3px;
background: rgba(16,185,129,0.15); border: 1px solid rgba(16,185,129,0.35);
border-radius: 20px; padding: 1px 8px; font-size: 10px;
font-weight: 700; color: #6ee7b7; margin-bottom: 6px;
}
.jv5-info-popover .jv5-info-tag.warn {
background: rgba(251,191,36,0.12); border-color: rgba(251,191,36,0.35);
color: #fde68a;
}
.jv5-info-popover .jv5-info-tag.sec {
background: rgba(139,92,246,0.15); border-color: rgba(139,92,246,0.4);
color: #c4b5fd;
}
.jv5-info-divider { border: none; border-top: 1px solid rgba(99,102,241,0.2); margin: 8px 0; }
/* ── PIN / Key Encryption Modal ── */
.jv5-pin-modal {
position: fixed; inset: 0; z-index: 10000050;
background: rgba(0,0,0,0.75); display: flex;
align-items: center; justify-content: center;
animation: ms2-fade 0.15s ease;
}
.jv5-pin-box {
background: #13131f; border: 1px solid rgba(139,92,246,0.5);
border-radius: 14px; padding: 22px 20px; width: min(340px, calc(100vw - 32px));
box-shadow: 0 12px 36px rgba(0,0,0,0.75);
font-family: system-ui, sans-serif;
animation: ms2-up 0.22s cubic-bezier(0.16,1,0.3,1);
}
.jv5-pin-box h3 {
margin: 0 0 4px 0; font-size: 14px; font-weight: 700; color: #c4b5fd;
display: flex; align-items: center; gap: 8px;
}
.jv5-pin-box p { font-size: 11.5px; color: #6b7280; line-height: 1.55; margin: 0 0 14px 0; }
.jv5-pin-box input {
width: 100%; box-sizing: border-box;
padding: 10px 12px; background: #0d0d1a;
border: 1px solid rgba(139,92,246,0.4); border-radius: 8px;
color: #e2e8f0; font-size: 14px; font-family: monospace;
outline: none; margin-bottom: 10px;
transition: border-color 0.15s;
}
.jv5-pin-box input:focus { border-color: #8b5cf6; }
.jv5-pin-box .jv5-pin-err {
font-size: 11px; color: #f87171; margin: -6px 0 8px 0; min-height: 14px;
}
.jv5-pin-box .jv5-pin-row { display: flex; gap: 8px; }
.jv5-pin-box .jv5-pin-row button {
flex: 1; padding: 9px; font-size: 12px; font-weight: 600;
border-radius: 8px; cursor: pointer; font-family: system-ui, sans-serif;
transition: opacity 0.15s;
}
.jv5-pin-btn-confirm {
background: linear-gradient(135deg,#7c3aed,#6d28d9);
border: none; color: #fff;
}
.jv5-pin-btn-confirm:hover { opacity: 0.88; }
.jv5-pin-btn-cancel {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.12); color: #9ca3af;
}
.jv5-pin-status {
font-size: 11px; display: flex; align-items: center; gap: 5px;
padding: 5px 8px; border-radius: 6px; margin-bottom: 10px;
}
.jv5-pin-status.locked {
background: rgba(139,92,246,0.1); border: 1px solid rgba(139,92,246,0.3);
color: #a78bfa;
}
.jv5-pin-status.unlocked {
background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.3);
color: #6ee7b7;
}
.jv5-pin-status.off {
background: rgba(251,191,36,0.08); border: 1px solid rgba(251,191,36,0.25);
color: #fbbf24;
}
/* ── 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: calc(100vw - 16px); max-width: 600px;
max-height: min(88vh, calc(100dvh - env(safe-area-inset-top,0px) - env(safe-area-inset-bottom,0px) - 24px));
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: 11px 14px 10px; 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; flex-wrap: nowrap; overflow: hidden; }
.ms2-modal-close {
background: none; border: none; color: #6b7280; cursor: pointer;
font-size: 20px; line-height: 1; padding: 4px 8px; border-radius: 6px;
transition: color 0.12s, background 0.12s; flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
}
.ms2-modal-close:hover { color: #e5e7eb; background: rgba(255,255,255,0.08); }
.ms2-modal-body { overflow-y: auto; padding: 12px 14px; 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: 10px 14px 12px;
border-top: 1px solid rgba(255,255,255,0.07); flex-wrap: wrap; flex-shrink: 0;
}
/* Mobile: prevent backdrop overflow on small screens */
.ms2-backdrop {
padding: env(safe-area-inset-top,8px) 8px env(safe-area-inset-bottom,8px) !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;
align-items: flex-start !important;
}
@media (max-height: 600px) {
.ms2-modal { max-height: calc(100dvh - 16px); border-radius: 10px; }
.ms2-modal-header { padding: 8px 12px; }
.ms2-modal-body { padding: 8px 12px; }
}
/* ── 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, ''');
}
// Safe toast — plain text only. Never pass user-controlled or network data to toastHTML.
function toast(msg, ms) {
document.querySelector('.ms2-toast')?.remove();
const t = document.createElement('div');
t.className = 'ms2-toast';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t?.remove(), ms || 2200);
return t;
}
// toastHTML — for internal trusted SVG/HTML markup ONLY. Never pass external/network data.
function toastHTML(msg, ms) {
document.querySelector('.ms2-toast')?.remove();
const t = document.createElement('div');
t.className = 'ms2-toast';
t.innerHTML = msg; // trusted internal markup only — never network/user data
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.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t?.remove(), 5000);
}
// topToastHTML — trusted internal markup only
function topToastHTML(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.
*/
// ── SEND ATTEMPT LOG (read by JV5 Diagnostics v6.4+) ───────────────────────
// injectAndSend() runs asynchronously after a Reply/Smart-Reply finishes, often
// with no UI feedback either way. If it silently fails, there was previously no
// record of WHY by the time the user opens the diagnostic. This ring buffer
// captures the last few attempts: which selectors matched, what path was taken
// (button click vs Enter-key fallback), and the final outcome.
const _SEND_LOG_MAX = 5;
const _sendAttemptLog = [];
function _logSendAttempt(entry) {
entry.time = new Date().toISOString();
_sendAttemptLog.push(entry);
if (_sendAttemptLog.length > _SEND_LOG_MAX) _sendAttemptLog.shift();
}
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, _inputSel = 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; _inputSel = sel; break; }
} catch { }
}
if (!input) { _logSendAttempt({ outcome: 'no-input', text: text.slice(0,80) }); 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 = [
// v6.4 — `[class*="switcher"]` proved unreliable: confirmed by JV5
// Diagnostics (click-no-effect) to match a non-send control (likely a
// persona/theme switcher near the compose bar) with aria-label="null".
// Try more specific candidates FIRST, keep switcher as a last resort.
'button:has(svg path[d^="M22 2 11 13"])', // Lucide "Send" icon
'button:has(svg path[d^="M12 19V5"])', // Lucide "ArrowUp" icon (circular send btn)
'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)',
'button[aria-label="button"]:not(:disabled)',
'[class*="switcher"]: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) {
const valBeforeClick = input.hasAttribute('contenteditable') ? input.textContent : input.value;
const ariaLabel = btn.getAttribute('aria-label') || null;
// Full pointer-event sequence — some React handlers bind to
// pointerdown/mousedown rather than (or in addition to) click.
const rect = btn.getBoundingClientRect();
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
const evOpts = { bubbles: true, cancelable: true, view: window, clientX: cx, clientY: cy };
try { btn.dispatchEvent(new PointerEvent('pointerdown', evOpts)); } catch {}
btn.dispatchEvent(new MouseEvent('mousedown', evOpts));
try { btn.dispatchEvent(new PointerEvent('pointerup', evOpts)); } catch {}
btn.dispatchEvent(new MouseEvent('mouseup', evOpts));
btn.click();
// Verify the click actually did something: a real send normally
// clears the input (or the button becomes disabled while sending).
// If neither happens, the click likely didn't register with the
// app's framework — record this so the diagnostic can flag it.
setTimeout(() => {
const valAfter = input.hasAttribute('contenteditable') ? input.textContent : input.value;
const cleared = valAfter === '' && valBeforeClick !== '';
const nowDisabled = btn.disabled || btn.hasAttribute('disabled');
_logSendAttempt({
outcome: (cleared || nowDisabled) ? 'click-sent' : 'click-no-effect',
inputSel: _inputSel, btnSel: sel, ariaLabel,
textLen: text.length, cleared, nowDisabled,
});
}, 400);
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;
_logSendAttempt({
outcome: submitted ? 'enter-sent' : 'enter-no-effect',
inputSel: _inputSel, btnSel: null, textLen: text.length,
});
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') {
// Heuristic key-format detection for common proxy types:
// • lorebary.com / LiteRouter — 64-char lowercase hex key → x-api-key header
// • Anthropic sk-ant- prefix → handled above (already returned)
// • Everything else → Bearer (OpenRouter, OpenAI, xAI, Groq, Mistral…)
const isHex64 = /^[0-9a-f]{64}$/i.test(key);
const isLiterouter = ep && (ep.includes('lorebary.com') || ep.includes('literouter'));
if (isHex64 || isLiterouter) {
resolved = 'x-api-key';
} 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(/\/$/, '');
// ── Endpoint safety checks ──────────────────────────────────────────────
// 1. Must be HTTPS — reject plain HTTP to prevent credential exposure in transit.
// 2. Must not point at a private/loopback/link-local address (SSRF guard).
if (!/^https:\/\/.+/.test(baseEp)) {
throw new Error('Custom endpoint must use HTTPS. Update it in Settings → General.');
}
if (_SSRF_BLOCK_RE.test(baseEp)) {
throw new Error('Custom endpoint points to a private/local address — blocked for security.');
}
// ── Endpoint-key binding check ──────────────────────────────────────────
// If the key was bound to a specific endpoint this session (PIN active) and
// the current endpoint has since been changed, refuse the request. This
// blocks an attacker who edits GM storage mid-session to redirect the key
// to a harvesting server. The user must re-lock and re-unlock the PIN to
// rebind the key to the new endpoint.
if (_pinSession.active && _sessionEndpointBinding && baseEp !== _sessionEndpointBinding.replace(/\/$/, '')) {
throw new Error(
'Endpoint changed since PIN unlock. Re-lock and unlock your PIN to rebind your key to the new endpoint.'
);
}
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,
temperature: opts.temperature ?? 0.82,
})
: 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) toastHTML(`${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) toastHTML(`${SVG_CHECK} Scene Context updated from ${msgs.length} visible messages`);
} catch (err) {
if (!silent) toastHTML(`${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();
toastHTML(`${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(VIRTUOSO_SEL)
.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();
toastHTML(`${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 = toastHTML(`${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) {
toastHTML(`${SVG_WARNING} Could not find chat scroll area — are you in an active chat?`, 4000);
_fabSumRunning = false;
return;
}
} catch (e) {
progressToast?.remove?.();
toastHTML(`${SVG_WARNING} Load failed: ${escHtml(e.message)}`, 4000);
_fabSumRunning = false;
return;
}
_accumMsgs(scrapeChatMessages());
const msgs = _getAccumulated();
if (msgs.length < 1) {
toastHTML(`${SVG_WARNING} No messages found in this chat.`, 3500);
_fabSumRunning = false;
return;
}
const genToast = toastHTML(`${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') toastHTML(`${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,
() => toastHTML(`${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,
() => toastHTML(`${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) {
const _pinActive = _pinIsActive();
const _pinUnlocked = _pinSession.active;
const _pinStatusHTML = _pinActive
? (_pinUnlocked
? `<div class="jv5-pin-status unlocked">${SVG_SHIELD} PIN active — key encrypted & unlocked for this session</div>`
: `<div class="jv5-pin-status locked">${SVG_LOCK} PIN active — key is locked. Enter PIN to unlock.</div>`)
: `<div class="jv5-pin-status off">${SVG_WARNING} No PIN set — key stored as plain text</div>`;
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>
</div>
<!-- API Provider -->
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<label class="ms2-field-label" style="margin-bottom:0;">API Provider</label>
${_makeInfoBtn('api-provider')}
</div>
<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="https://lorebary.com/csproxy" ${CFG.endpoint?.startsWith('https://lorebary.com/csproxy') ? 'selected' : ''}>LiteRouter / Lorebary (custom proxy)</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>
<!-- API Key with PIN security -->
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;margin-top:2px;">
<label class="ms2-field-label" style="margin-bottom:0;">API Key</label>
${_makeInfoBtn('api-key-security')}
</div>
${_pinStatusHTML}
${_pinActive ? `
<div style="display:flex;align-items:center;gap:6px;margin:4px 0 6px;">
<label class="ms2-field-label" style="margin-bottom:0;font-size:10.5px;color:#9ca3af;">Auto-lock after inactivity</label>
<select class="ms2-select" id="ms2-pin-idle-sel" style="width:auto;padding:3px 8px;font-size:11px;">
<option value="0" ${_pinGetIdleMin() === 0 ? 'selected' : ''}>Off</option>
<option value="5" ${_pinGetIdleMin() === 5 ? 'selected' : ''}>5 min</option>
<option value="15" ${_pinGetIdleMin() === 15 ? 'selected' : ''}>15 min</option>
<option value="30" ${_pinGetIdleMin() === 30 ? 'selected' : ''}>30 min</option>
<option value="60" ${_pinGetIdleMin() === 60 ? 'selected' : ''}>60 min</option>
</select>
${_makeInfoBtn('pin-idle-lock')}
</div>` : ''}
<div style="display:flex;gap:6px;align-items:center;">
<input type="text" class="ms2-input" id="ms2-s-apikey"
value="${_pinActive && !_pinUnlocked ? '' : escHtml(CFG.apiKey)}"
placeholder="${_pinActive && !_pinUnlocked ? 'Locked — click Unlock PIN to reveal' : 'Paste your API key…'}"
${_pinActive && !_pinUnlocked ? 'readonly' : ''}
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>
<!-- PIN action row -->
<div style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;">
${!_pinActive ? `
<button id="ms2-pin-set-btn" style="
flex:1;padding:6px 10px;font-size:11px;font-weight:600;border-radius:7px;cursor:pointer;
background:rgba(139,92,246,0.15);border:1px solid rgba(139,92,246,0.45);
color:#c4b5fd;font-family:system-ui,sans-serif;">
${SVG_LOCK} Set PIN Encryption
</button>` : ''}
${_pinActive && !_pinUnlocked ? `
<button id="ms2-pin-unlock-btn" style="
flex:1;padding:6px 10px;font-size:11px;font-weight:600;border-radius:7px;cursor:pointer;
background:rgba(16,185,129,0.12);border:1px solid rgba(16,185,129,0.4);
color:#6ee7b7;font-family:system-ui,sans-serif;">
${SVG_UNLOCK} Unlock PIN
</button>` : ''}
${_pinActive ? `
<button id="ms2-pin-change-btn" style="
flex:1;padding:6px 10px;font-size:11px;font-weight:600;border-radius:7px;cursor:pointer;
background:rgba(99,102,241,0.1);border:1px solid rgba(99,102,241,0.35);
color:#818cf8;font-family:system-ui,sans-serif;">
✎ Change PIN
</button>
<button id="ms2-pin-remove-btn" style="
flex:1;padding:6px 10px;font-size:11px;font-weight:600;border-radius:7px;cursor:pointer;
background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.3);
color:#f87171;font-family:system-ui,sans-serif;">
${SVG_UNLOCK} Remove PIN
</button>` : ''}
</div>
<div id="ms2-api-test-result" style="font-size:11px;margin-top:6px;min-height:16px;"></div>
<!-- Auth Header -->
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;margin-top:8px;">
<label class="ms2-field-label" style="margin-bottom:0;">Auth Header Format</label>
${_makeInfoBtn('auth-mode')}
</div>
<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>
<!-- Model -->
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<label class="ms2-field-label" style="margin-bottom:0;">Model</label>
${_makeInfoBtn('model-select')}
</div>
<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);">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<label class="ms2-field-label" style="color:#67e8f9;margin-bottom:0;">${SVG_CHAT} Community Chat Relay URL</label>
${_makeInfoBtn('relay-url')}
</div>
<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;">
Leave blank for the default (<code>https://ntfy.sh</code>). Only change this if you run your own ntfy instance. Private/local addresses are blocked.
</div>
<label class="ms2-field-label" style="margin-top:6px;">Relay Access Token <span style="color:#6b7280;font-weight:400;">(self-hosted only, optional)</span></label>
<input type="text" class="ms2-input" id="ms2-s-relay-token"
value="${escHtml(_p2pGetRelayToken())}"
placeholder="ntfy access token (e.g. tk_...)"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" style="-webkit-text-security:disc;">
<div style="font-size:10.5px;color:#6b7280;margin-bottom:8px;line-height:1.4;">
Sent as <code>Authorization: Bearer …</code> only to the custom relay URL above — never sent to the default ntfy.sh. Use this if your self-hosted ntfy server requires an access token to read/write its topics.
</div>
<button class="ms2-btn-save" id="ms2-save-relay" style="padding:5px 14px;font-size:11px;">Save Relay URL</button>
</div>
<!-- ADVANCED CRYPTO -->
<div style="margin-top:14px;padding-top:12px;border-top:1px solid rgba(99,102,241,.15);">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;">
<label class="ms2-field-label" style="color:#a78bfa;margin-bottom:0;">🔐 Advanced Crypto</label>
${_makeInfoBtn('advanced-crypto')}
</div>
<div style="font-size:10.5px;color:#6b7280;margin-bottom:10px;line-height:1.5;">
Encryption upgrades for Community Chat. Disable only if you have compatibility issues.
</div>
<!-- Custom Community Passphrase -->
<div style="background:rgba(109,40,217,0.07);border:1px solid rgba(109,40,217,0.25);border-radius:8px;padding:11px 12px;margin-bottom:12px;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px;">
<div style="font-size:11px;font-weight:700;color:#c4b5fd;letter-spacing:.4px;">🔑 Custom Community Passphrase</div>
${_makeInfoBtn('community-passphrase')}
</div>
<div style="font-size:10.5px;color:#6b7280;line-height:1.55;margin-bottom:8px;">
Creates a private sub-group — only users with the <strong style="color:#e5e7eb;">exact same passphrase</strong> can read your messages.
<br><span style="color:#f87171;font-weight:600;">⚠️ Clearing this field reverts to the shared default — others with the passphrase won't see you.</span>
</div>
<div style="position:relative;display:flex;gap:6px;align-items:center;">
<input type="password" id="ms2-s-custom-psk"
class="ms2-input" style="flex:1;font-family:monospace;font-size:12px;padding-right:36px;"
maxlength="128"
placeholder="Leave blank for default (shared with all JV5 users)"
value="${gget('jv5_custom_psk_set', false) ? '••••••••' : ''}">
<button id="ms2-psk-toggle" title="Show / hide" style="
position:absolute;right:8px;background:none;border:none;
color:#6b7280;cursor:pointer;padding:2px;font-size:14px;line-height:1;">👁</button>
</div>
<div style="margin-top:6px;height:3px;border-radius:3px;background:rgba(255,255,255,0.07);overflow:hidden;">
<div id="ms2-psk-strength-bar" style="height:100%;width:0%;background:#ef4444;transition:width .25s,background .25s;border-radius:3px;"></div>
</div>
<div id="ms2-psk-strength-label" style="font-size:10px;color:#6b7280;margin-top:3px;min-height:12px;"></div>
<div style="display:flex;gap:6px;margin-top:8px;">
<button class="ms2-btn-save" id="ms2-save-custom-psk" style="padding:5px 14px;font-size:11px;">Set Passphrase</button>
<button id="ms2-clear-custom-psk" style="
padding:5px 12px;font-size:11px;border-radius:6px;border:1px solid rgba(239,68,68,0.35);
background:rgba(239,68,68,0.08);color:#f87171;cursor:pointer;">Clear (use default)</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<label class="ms2-toggle-switch" title="Double Ratchet forward secrecy">
<input type="checkbox" id="ms2-s-ratchet" ${gget('jv5_ratchet_global', true) ? 'checked' : ''}>
<span class="ms2-toggle-thumb"></span>
</label>
<div style="flex:1;">
<span style="font-size:12px;color:#c4b5fd;font-weight:500;">Double Ratchet FS</span>
<div style="font-size:10.5px;color:#6b7280;">Per-peer ECDH ratchet chain. Old message keys deleted after use — past messages stay safe even if PSK is later exposed.</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<label class="ms2-toggle-switch" title="PBKDF2-SHA-512 key derivation (stronger than default SHA-256 path)">
<input type="checkbox" id="ms2-s-argon2" ${gget('jv5_use_argon2', true) ? 'checked' : ''}>
<span class="ms2-toggle-thumb"></span>
</label>
<div style="flex:1;">
<span style="font-size:12px;color:#c4b5fd;font-weight:500;">PBKDF2-SHA-512 KDF</span>
<div style="font-size:10.5px;color:#6b7280;">PBKDF2-SHA-512 at 350,000 iterations — ~2.5× harder to brute-force than standard. Best available via <code>crypto.subtle</code>.</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<label class="ms2-toggle-switch" title="HMAC-SHA-512 admin signature verification">
<input type="checkbox" id="ms2-s-ed25519" ${gget('jv5_use_ed25519_admin', true) ? 'checked' : ''}>
<span class="ms2-toggle-thumb"></span>
</label>
<div style="flex:1;">
<span style="font-size:12px;color:#c4b5fd;font-weight:500;">HMAC-SHA-512 Admin Signatures</span>
<div style="font-size:10.5px;color:#6b7280;">HMAC-SHA-512 + HKDF for admin commands. Doubles MAC length and prevents captured sigs from being reused in other contexts.</div>
</div>
</div>
<button class="ms2-btn-save" id="ms2-save-crypto" style="padding:5px 14px;font-size:11px;">Save Crypto Settings</button>
<!-- Chat Identity Backup -->
<div style="margin-top:14px;padding-top:12px;border-top:1px solid rgba(99,102,241,.15);">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px;">
<div style="font-size:11px;font-weight:700;color:#fbbf24;letter-spacing:.4px;">💾 Chat Identity Backup</div>
</div>
<div style="font-size:10.5px;color:#6b7280;line-height:1.55;margin-bottom:8px;">
Your chat identity key (<code>jv5_psk_b</code>) is randomly generated on first install and stored in Tampermonkey.
<strong style="color:#e5e7eb;">If you reinstall Tampermonkey or clear its storage, a new key is generated</strong> — your old encrypted history becomes unreadable and other users cannot decrypt your messages.
<br><br>
Back up this key now, then restore it after any reinstall to keep continuity.
</div>
<div style="display:flex;gap:6px;margin-bottom:8px;">
<button id="ms2-psk-backup-btn" style="padding:5px 12px;font-size:11px;border-radius:6px;border:1px solid rgba(251,191,36,0.4);background:rgba(251,191,36,0.08);color:#fbbf24;cursor:pointer;">⬇ Export Key</button>
<button id="ms2-psk-copy-btn" style="padding:5px 12px;font-size:11px;border-radius:6px;border:1px solid rgba(99,102,241,0.4);background:rgba(99,102,241,0.08);color:#a78bfa;cursor:pointer;">📋 Copy to Clipboard</button>
</div>
<div style="font-size:10.5px;color:#6b7280;margin-bottom:4px;">Restore from backup (paste key value below):</div>
<div style="display:flex;gap:6px;">
<input type="text" id="ms2-psk-restore-input"
class="ms2-input" style="flex:1;font-family:monospace;font-size:11px;"
placeholder="Paste exported key here…"
autocomplete="off" spellcheck="false">
<button id="ms2-psk-restore-btn" style="padding:5px 12px;font-size:11px;border-radius:6px;border:1px solid rgba(16,185,129,0.4);background:rgba(16,185,129,0.08);color:#6ee7b7;cursor:pointer;white-space:nowrap;">⬆ Restore</button>
</div>
<div id="ms2-psk-fingerprint" style="font-size:10px;color:#6b7280;margin-top:5px;font-family:monospace;"></div>
</div>
</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;gap:6px;margin-bottom:10px;">
<span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_REPLY} REPLY SETTINGS</span>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<label class="ms2-field-label" style="margin-bottom:0;">Default Tone</label>
</div>
<select class="ms2-select" id="ms2-s-default-tone">${toneOpts}</select>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;margin-top:4px;">
<label class="ms2-field-label" style="margin-bottom:0;">Default Custom Instruction</label>
</div>
<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:6px;">
<div style="display:flex;align-items:center;gap:6px;">
<span class="ms2-toggle-label">Notify on new AI message</span>
${_makeInfoBtn('auto-notify')}
</div>
<label class="ms2-toggle-switch">
<input type="checkbox" id="ms2-s-autonotify" ${CFG.autoNotify ? 'checked' : ''}>
<span class="ms2-toggle-thumb"></span>
</label>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<div class="ms2-tip" style="flex:1;margin:0;">When ON: a subtle toast appears when a new AI message arrives — no auto-opening modals.</div>
${_makeInfoBtn('smart-reply')}
</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;gap:6px;margin-bottom:10px;">
<span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_STYLES} STYLES</span>
${_makeInfoBtn('styles-presets')}
</div>
<div class="ms2-tip">Presets save your character's full voice config — tone, custom instruction, persona, and Prompt Modules. 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;gap:6px;margin-bottom:8px;">
<span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">${SVG_CONTEXT} CONTEXT</span>
${_makeInfoBtn('scene-context')}
</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;gap:6px;margin-bottom:6px;">
<span style="font-size:11px;font-weight:700;color:#a78bfa;letter-spacing:.4px;">GENERATE SCENE CONTEXT</span>
${_makeInfoBtn('summarise')}
</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>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<label class="ms2-field-label" style="margin-bottom:0;">Scene Context <span style="font-weight:400;color:#6b7280;">(injected into every Reply & Shorten)</span></label>
</div>
<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;">
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:10px;color:#4b5563;text-transform:uppercase;letter-spacing:.9px;font-weight:700;">${SVG_PERSONA} Persona Library</span>
${_makeInfoBtn('persona-library')}
</div>
<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.</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>`;
}
function _buildAdvTab(tab0) {
return `
<div class="ms2-tab-panel ${tab0 === 'adv' ? 'active' : ''}" data-panel="adv">
<div class="ms2-toggle-row" style="margin-bottom:10px;">
<div style="display:flex;align-items:center;gap:6px;">
<span class="ms2-toggle-label" style="font-size:12px;font-weight:600;color:#c4b5fd;">Enable Advanced Prompting</span>
${_makeInfoBtn('advanced-prompting')}
</div>
<label class="ms2-toggle-switch">
<input type="checkbox" id="ap-enabled-chk" ${AP.enabled ? 'checked' : ''}>
<span class="ms2-toggle-thumb"></span>
</label>
</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 _rewireGeneralTab(settingsPanel) {
const epSel2 = settingsPanel.querySelector('#ms2-s-ep-sel');
if (!epSel2) return;
const customWrap2 = settingsPanel.querySelector('#ms2-s-custom-ep-wrap');
const customModel2 = settingsPanel.querySelector('#ms2-s-custom-model');
const modelSel2 = settingsPanel.querySelector('#ms2-s-model');
const warnEl2 = settingsPanel.querySelector('#ms2-proxy-model-warn');
if (epSel2 && customWrap2) {
epSel2.addEventListener('change', () => {
const isCustom = epSel2.value === 'custom';
customWrap2.style.display = isCustom ? '' : 'none';
if (warnEl2) warnEl2.style.display = isCustom ? '' : 'none';
});
}
if (modelSel2 && customModel2) {
modelSel2.addEventListener('change', () => {
customModel2.style.display = modelSel2.value === '__custom__' ? '' : 'none';
});
}
// Re-wire PIN buttons on the freshly rendered panel
settingsPanel.querySelector('#ms2-pin-set-btn')?.addEventListener('click', () => _showPinModal('set', () => _rewireGeneralTab(settingsPanel)));
settingsPanel.querySelector('#ms2-pin-unlock-btn')?.addEventListener('click', () => {
_showPinModal('unlock', (_m, dec) => {
if (dec) {
const ki = settingsPanel.querySelector('#ms2-s-apikey');
if (ki) { ki.value = dec; ki.removeAttribute('readonly'); }
const st = settingsPanel.querySelector('.jv5-pin-status');
if (st) { st.className = 'jv5-pin-status unlocked'; st.innerHTML = `${SVG_SHIELD} PIN active — key encrypted & unlocked`; }
settingsPanel.querySelector('#ms2-pin-unlock-btn')?.remove();
}
});
});
settingsPanel.querySelector('#ms2-pin-change-btn')?.addEventListener('click', () => _showPinModal('change', () => toastHTML(`${SVG_SHIELD} PIN changed`)));
settingsPanel.querySelector('#ms2-pin-remove-btn')?.addEventListener('click', () => _showPinModal('remove', () => _rewireGeneralTab(settingsPanel)));
// Idle auto-lock timeout selector
settingsPanel.querySelector('#ms2-pin-idle-sel')?.addEventListener('change', (e) => {
const mins = Number(e.target.value) || 0;
try { GM_setValue(_PIN_IDLE_GM, mins); } catch {}
_pinTouchActivity(); // don't immediately lock if the user just changed this setting
toastHTML(mins > 0
? `${SVG_SHIELD} Auto-lock after ${mins} min of inactivity`
: `${SVG_UNLOCK} Auto-lock disabled`);
});
}
// ─── PIN MODAL ────────────────────────────────────────────────────────────
// Handles Set PIN, Unlock PIN, Change PIN, and Remove PIN flows.
// mode: 'set' | 'unlock' | 'change' | 'remove'
// _promptModal — Promise-based inline modal replacing window.prompt() (blocked on mobile Chrome)
function _promptModal({ title = 'Enter value', placeholder = '', type = 'password', confirm = 'OK' } = {}) {
return new Promise(resolve => {
const ov = document.createElement('div');
ov.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,0.65);display:flex;align-items:center;justify-content:center;';
ov.innerHTML = `
<div style="background:#13131f;border:1px solid rgba(139,92,246,0.5);border-radius:14px;
padding:18px 16px;width:min(300px,calc(100vw - 32px));box-shadow:0 12px 36px rgba(0,0,0,0.75);
font-family:system-ui,sans-serif;">
<div style="font-size:13px;font-weight:700;color:#c4b5fd;margin-bottom:10px;">${title}</div>
<input id="_pm_input" type="${type}" placeholder="${placeholder}"
style="width:100%;box-sizing:border-box;padding:9px 11px;background:#0d0d1a;
border:1px solid rgba(139,92,246,0.4);border-radius:8px;color:#e2e8f0;
font-size:13px;outline:none;margin-bottom:10px;font-family:monospace;">
<div style="display:flex;gap:8px;">
<button id="_pm_ok" style="flex:1;padding:8px;font-size:12px;font-weight:600;
background:linear-gradient(135deg,#7c3aed,#6d28d9);border:none;border-radius:8px;
color:#fff;cursor:pointer;">${confirm}</button>
<button id="_pm_cancel" style="flex:1;padding:8px;font-size:12px;
background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.12);
border-radius:8px;color:#9ca3af;cursor:pointer;">Cancel</button>
</div>
</div>`;
document.body.appendChild(ov);
const inp = ov.querySelector('#_pm_input');
setTimeout(() => inp?.focus(), 80);
const _ok = () => { const v = inp?.value?.trim() || null; ov.remove(); resolve(v); };
const _cancel = () => { ov.remove(); resolve(null); };
ov.querySelector('#_pm_ok').addEventListener('click', _ok);
ov.querySelector('#_pm_cancel').addEventListener('click', _cancel);
inp?.addEventListener('keydown', e => { if (e.key === 'Enter') _ok(); if (e.key === 'Escape') _cancel(); });
ov.addEventListener('click', e => { if (e.target === ov) _cancel(); });
});
}
function _showPinModal(mode, onSuccess) {
const existing = document.querySelector('.jv5-pin-modal');
if (existing) existing.remove();
const modeConfig = {
set: { title: `${SVG_LOCK} Set PIN Encryption`, sub: 'Your API key will be encrypted with AES-256-GCM. The PIN is never stored — you enter it once per browser session.', confirm: 'Encrypt & Save', fields: ['new','confirm'] },
unlock: { title: `${SVG_UNLOCK} Unlock API Key`, sub: 'Enter your PIN to decrypt the API key for this session.', confirm: 'Unlock', fields: ['pin'] },
change: { title: '✎ Change PIN', sub: 'Enter your current PIN, then set a new one. The key will be re-encrypted with the new PIN immediately.', confirm: 'Change PIN', fields: ['current','new','confirm'] },
remove: { title: `${SVG_UNLOCK} Remove PIN`, sub: 'Enter your current PIN to confirm. Your key will be decrypted and stored as plain text again.', confirm: 'Remove PIN', fields: ['current'] },
};
const cfg = modeConfig[mode];
if (!cfg) return;
const overlay = document.createElement('div');
overlay.className = 'jv5-pin-modal';
overlay.innerHTML = `
<div class="jv5-pin-box">
<h3>${cfg.title}</h3>
<p>${cfg.sub}</p>
${cfg.fields.includes('current') ? `<input type="password" id="jv5-pin-current" placeholder="Current PIN" autocomplete="current-password" style="font-family:monospace;">` : ''}
${cfg.fields.includes('pin') ? `<input type="password" id="jv5-pin-input" placeholder="PIN" autocomplete="current-password" style="font-family:monospace;">` : ''}
${cfg.fields.includes('new') ? `<input type="password" id="jv5-pin-new" placeholder="New PIN (min 6 characters)" autocomplete="new-password" style="font-family:monospace;">` : ''}
${cfg.fields.includes('confirm') ? `<input type="password" id="jv5-pin-confirm" placeholder="Confirm new PIN" autocomplete="new-password" style="font-family:monospace;">` : ''}
<div class="jv5-pin-err" id="jv5-pin-err"></div>
<div class="jv5-pin-row">
<button class="jv5-pin-btn-confirm" id="jv5-pin-confirm-btn">${cfg.confirm}</button>
<button class="jv5-pin-btn-cancel" id="jv5-pin-cancel-btn">Cancel</button>
</div>
</div>`;
document.body.appendChild(overlay);
const errEl = overlay.querySelector('#jv5-pin-err');
const confirmBtn = overlay.querySelector('#jv5-pin-confirm-btn');
overlay.querySelector('#jv5-pin-cancel-btn').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
// Focus first input
setTimeout(() => overlay.querySelector('input')?.focus(), 80);
// Enter key submits
overlay.addEventListener('keydown', e => { if (e.key === 'Enter') confirmBtn.click(); });
confirmBtn.addEventListener('click', async () => {
errEl.textContent = '';
confirmBtn.disabled = true;
confirmBtn.textContent = '…';
try {
if (mode === 'set') {
const newPin = overlay.querySelector('#jv5-pin-new')?.value || '';
const confPin = overlay.querySelector('#jv5-pin-confirm')?.value || '';
if (newPin.length < 6) { errEl.textContent = 'PIN must be at least 6 characters.'; return; }
if (newPin !== confPin) { errEl.textContent = 'PINs do not match.'; return; }
const currentKey = CFG.apiKey;
if (!currentKey) { errEl.textContent = 'Paste your API key first, then set a PIN.'; return; }
const ok = await _pinEncryptKey(currentKey, newPin);
if (!ok) { errEl.textContent = 'Encryption failed — try again.'; return; }
overlay.remove();
toastHTML(`${SVG_SHIELD} API key encrypted with PIN`);
onSuccess && onSuccess('set');
} else if (mode === 'unlock') {
const pin = overlay.querySelector('#jv5-pin-input')?.value || '';
if (!pin) { errEl.textContent = 'Enter your PIN.'; return; }
const decrypted = await _pinDecryptKey(pin);
if (decrypted === null) { errEl.textContent = 'Wrong PIN — decryption failed.'; return; }
CFG.apiKey = decrypted;
overlay.remove();
toastHTML(`${SVG_UNLOCK} API key unlocked for this session`);
onSuccess && onSuccess('unlock', decrypted);
} else if (mode === 'change') {
const curPin = overlay.querySelector('#jv5-pin-current')?.value || '';
const newPin = overlay.querySelector('#jv5-pin-new')?.value || '';
const confPin = overlay.querySelector('#jv5-pin-confirm')?.value || '';
if (newPin.length < 6) { errEl.textContent = 'New PIN must be at least 6 characters.'; return; }
if (newPin !== confPin) { errEl.textContent = 'New PINs do not match.'; return; }
// Verify old PIN first
const decrypted = await _pinDecryptKey(curPin);
if (decrypted === null) { errEl.textContent = 'Current PIN is wrong.'; return; }
// Remove old salt so a new one is generated
try { GM_deleteValue(_KEY_SALT_GM); } catch {}
_pinSession.active = false; _pinSession.cryptoKey = null; _sessionApiKey = ''; _sessionEndpointBinding = '';
const ok = await _pinEncryptKey(decrypted, newPin);
if (!ok) { errEl.textContent = 'Re-encryption failed — try again.'; return; }
CFG.apiKey = decrypted;
overlay.remove();
toastHTML(`${SVG_SHIELD} PIN changed — key re-encrypted`);
onSuccess && onSuccess('change');
} else if (mode === 'remove') {
const curPin = overlay.querySelector('#jv5-pin-current')?.value || '';
const decrypted = await _pinDecryptKey(curPin);
if (decrypted === null) { errEl.textContent = 'Wrong PIN — cannot remove.'; return; }
await _pinRemove();
CFG.apiKey = decrypted;
// Store decrypted key as plaintext now that PIN is removed
try { GM_setValue(_KEY_PLAIN_GM, decrypted); } catch {}
overlay.remove();
toastHTML(`${SVG_UNLOCK} PIN removed — key stored as plain text`);
onSuccess && onSuccess('remove');
}
} finally {
if (overlay.isConnected) {
confirmBtn.disabled = false;
confirmBtn.textContent = cfg.confirm;
}
}
});
}
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.7.4 — Security hardening: prototype-pollution guard (allowlist merge) · toast XSS isolation (textContent/toastHTML split) · relay SSRF block (private IP rejection) · admin replay persistence (GM-backed seen-ids) · dev-mode diagnostic gate · @connect wildcard removed · seenMsgIds GM persistence · _esc single-quote fix · report PSK encryption</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(16,185,129,0.07);border:1px solid rgba(16,185,129,0.25);border-radius:8px;padding:10px 12px;margin:8px 0;">
<div style="font-size:11px;font-weight:700;color:#6ee7b7;letter-spacing:.5px;margin-bottom:6px;">🔐 PRIVACY & SECURITY</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong style="color:#6ee7b7;">End-to-end encrypted</strong> — Every message, reaction, admin command, and WebRTC signaling packet is AES-GCM-256 encrypted before it leaves your browser. The relay (ntfy.sh) only ever sees opaque ciphertext.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Connection IP</strong> — ntfy.sh sees the IP your browser uses to connect (same as any website). Message content and ICE candidate IPs are fully encrypted. Use a VPN to hide your connection IP from the relay.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Anonymous identity</strong> — You get a random peer ID. No login, no email, no account. Your real IP is never visible to other chat users.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Traffic analysis mitigation</strong> — Messages are padded to 256-byte blocks before encryption, so an observer cannot infer message length from ciphertext size.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Hashed topic names</strong> — The ntfy channel names are SHA-256 derived and not guessable without the script. Character rooms get a unique hashed topic per character.</div>
<div class="ms2-about-row" style="border:none;padding:2px 0;"><strong>Auto-delete</strong> — Ciphertext is stored on ntfy.sh for ~30 minutes then automatically deleted by the relay.</div>
</div>
<div style="background:rgba(59,130,246,0.07);border:1px solid rgba(59,130,246,0.25);border-radius:8px;padding:10px 12px;margin:8px 0;">
<div style="font-size:11px;font-weight:700;color:#93c5fd;letter-spacing:.5px;margin-bottom:6px;">🌐 OUTBOUND CONNECTIONS — FULL LIST</div>
<div style="font-size:11px;color:#9ca3af;line-height:1.7;">
This script does <strong style="color:#e5e7eb;">not</strong> use a wildcard <code style="background:rgba(0,0,0,0.3);padding:1px 4px;border-radius:3px;">@connect *</code>.
Every domain it can contact is listed explicitly in the script header and below.<br><br>
<strong style="color:#e5e7eb;">AI providers</strong> (only contacted when you trigger a generation):<br>
openrouter.ai · api.openai.com · api.anthropic.com · api.x.ai · api.mistral.ai · api.groq.com · api.cohere.ai · generativelanguage.googleapis.com · api.together.xyz · api.deepseek.com · inference.cerebras.ai · lorebary.com<br><br>
<strong style="color:#e5e7eb;">Community Chat relay</strong> (only when Chat tab is open):<br>
ntfy.sh — receives only AES-256-GCM ciphertext, never plaintext<br><br>
<strong style="color:#e5e7eb;">Nothing else.</strong> No analytics, no telemetry, no third-party trackers.
Custom endpoints you configure are validated at runtime: HTTPS-only, private IP ranges are blocked, and if you use a PIN your key is bound to the endpoint active at unlock time so a tampered config cannot redirect it.
</div>
</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 class="ms2-about-box" style="margin:0;color:#d1d5db;">
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', async () => {
const typedKey = panel.querySelector('#ms2-s-apikey').value.trim();
// If PIN is active and the field is not readonly (user typed a new key), re-encrypt
if (_pinIsActive() && typedKey && typedKey !== CFG.apiKey) {
// New key was typed while PIN is active — re-encrypt using the session CryptoKey
if (_pinSession.active && _pinSession.cryptoKey) {
// Encrypt directly with the existing derived CryptoKey (no PIN re-entry needed)
try {
const saltArr = (() => {
try { const s = GM_getValue(_KEY_SALT_GM, ''); return s ? _b64ToArr(s) : null; } catch { return null; }
})();
if (!saltArr) throw new Error('Salt missing');
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, _pinSession.cryptoKey, new TextEncoder().encode(typedKey));
const blob = new Uint8Array(12 + ct.byteLength);
blob.set(iv, 0); blob.set(new Uint8Array(ct), 12);
GM_setValue(_KEY_ENC_GM, _arrToB64(blob));
CFG.apiKey = typedKey;
} catch {
toastHTML(`${SVG_WARNING} Re-encryption failed — try again`, 3500);
return;
}
} else {
// PIN active but session not unlocked — can't save a new key safely
toastHTML(`${SVG_WARNING} Unlock your PIN first before changing the API key`, 3500);
return;
}
} else if (!_pinIsActive()) {
// No PIN — store plaintext as before
CFG.apiKey = typedKey;
}
CFG.authMode = panel.querySelector('#ms2-s-auth-mode')?.value || 'auto';
const rawEp = (epSel.value === 'custom'
? (panel.querySelector('#ms2-s-custom-ep').value.trim() || 'https://openrouter.ai/api/v1')
: epSel.value).replace(/\/$/, '');
// Validate before saving — reject HTTP and private-range endpoints
if (!/^https:\/\/.+/.test(rawEp)) {
toastHTML(`${SVG_WARNING} Endpoint must start with https:// — not saved`, 4000);
return;
}
if (_SSRF_BLOCK_RE.test(rawEp)) {
toastHTML(`${SVG_WARNING} Endpoint points to a private/local address — blocked`, 4000);
return;
}
CFG.endpoint = rawEp;
CFG.model = modelSel.value === '__custom__'
? (customModelInput.value.trim() || MODELS[0].id)
: modelSel.value;
toastHTML(`${SVG_CHECK} General settings saved`);
closeAll();
});
// ── PIN button handlers ──────────────────────────────────────────────────
panel.querySelector('#ms2-pin-set-btn')?.addEventListener('click', () => {
_showPinModal('set', () => {
// Re-render the General tab to show updated PIN status
const genPanel = panel.querySelector('[data-panel="general"]');
if (genPanel) {
const _modelGroupMap2 = new Map();
for (const m of MODELS) { const g = m.group || 'Other'; if (!_modelGroupMap2.has(g)) _modelGroupMap2.set(g, []); _modelGroupMap2.get(g).push(m); }
const mOpts2 = [..._modelGroupMap2.entries()].map(([gN, ms]) => `<optgroup label="${escHtml(gN)}">${ms.map(m => `<option value="${escHtml(m.id)}" ${CFG.model===m.id?'selected':''}>${escHtml(m.label)}</option>`).join('')}</optgroup>`).join('');
const tmp = document.createElement('div');
tmp.innerHTML = _buildGeneralTab('general', mOpts2);
genPanel.replaceWith(tmp.firstElementChild);
_rewireGeneralTab(panel);
}
});
});
panel.querySelector('#ms2-pin-unlock-btn')?.addEventListener('click', () => {
_showPinModal('unlock', (_mode, decrypted) => {
if (decrypted) {
const keyInput = panel.querySelector('#ms2-s-apikey');
if (keyInput) { keyInput.value = decrypted; keyInput.removeAttribute('readonly'); }
// Refresh PIN status row
const statusEl = panel.querySelector('.jv5-pin-status');
if (statusEl) {
statusEl.className = 'jv5-pin-status unlocked';
statusEl.innerHTML = `${SVG_SHIELD} PIN active — key encrypted & unlocked for this session`;
}
panel.querySelector('#ms2-pin-unlock-btn')?.remove();
}
});
});
panel.querySelector('#ms2-pin-change-btn')?.addEventListener('click', () => {
_showPinModal('change', () => toastHTML(`${SVG_SHIELD} PIN changed`));
});
panel.querySelector('#ms2-pin-remove-btn')?.addEventListener('click', () => {
_showPinModal('remove', () => {
const genPanel = panel.querySelector('[data-panel="general"]');
if (genPanel) {
const _modelGroupMap3 = new Map();
for (const m of MODELS) { const g = m.group || 'Other'; if (!_modelGroupMap3.has(g)) _modelGroupMap3.set(g, []); _modelGroupMap3.get(g).push(m); }
const mOpts3 = [..._modelGroupMap3.entries()].map(([gN, ms]) => `<optgroup label="${escHtml(gN)}">${ms.map(m => `<option value="${escHtml(m.id)}" ${CFG.model===m.id?'selected':''}>${escHtml(m.label)}</option>`).join('')}</optgroup>`).join('');
const tmp = document.createElement('div');
tmp.innerHTML = _buildGeneralTab('general', mOpts3);
genPanel.replaceWith(tmp.firstElementChild);
_rewireGeneralTab(panel);
}
});
});
// Idle auto-lock timeout selector (initial render — _rewireGeneralTab
// re-binds this after any PIN set/change/remove re-render).
panel.querySelector('#ms2-pin-idle-sel')?.addEventListener('change', (e) => {
const mins = Number(e.target.value) || 0;
try { GM_setValue(_PIN_IDLE_GM, mins); } catch {}
_pinTouchActivity(); // don't immediately lock if the user just changed this setting
toastHTML(mins > 0
? `${SVG_SHIELD} Auto-lock after ${mins} min of inactivity`
: `${SVG_UNLOCK} Auto-lock disabled`);
});
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) {
// Basic protocol check
if (!/^https?:\/\/.+/.test(relay)) {
toastHTML(`${SVG_WARNING} Invalid URL — must start with http:// or https://`, 3500);
return;
}
// SECURITY: Block private/loopback addresses to prevent SSRF via
// user-configured relay URL + @connect wildcard combination.
let _parsedHost = '';
try { _parsedHost = new URL(relay).hostname.toLowerCase(); } catch {
toastHTML(`${SVG_WARNING} Invalid URL format`, 3500); return;
}
const _ssrfBlocked = /^(localhost|127\.|0\.0\.0\.0|::1$|fc00:|fd|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(_parsedHost);
if (_ssrfBlocked) {
toastHTML(`${SVG_WARNING} Relay URL cannot point to a private/local address`, 3500);
return;
}
}
try {
GM_setValue(P2P_GM_RELAY, relay || '');
const rawToken = (panel.querySelector('#ms2-s-relay-token')?.value || '').trim();
if (rawToken && (!relay || relay === P2P_RELAYS[0])) {
// Refuse to store a token alongside the default public relay — it
// would never be sent (by design) and likely indicates a mistake.
toastHTML(`${SVG_WARNING} Relay token ignored — set a custom relay URL first`, 4000);
GM_setValue(P2P_GM_RELAY_TOKEN, '');
} else {
GM_setValue(P2P_GM_RELAY_TOKEN, rawToken);
toastHTML(`${SVG_CHECK} Relay URL ${relay ? 'saved' : 'reset to default'}${rawToken ? ' with access token' : ''}`);
}
} catch (e) {
toastHTML(`${SVG_CROSS} Failed to save relay URL`);
}
});
panel.querySelector('#ms2-save-crypto')?.addEventListener('click', () => {
try {
GM_setValue('jv5_ratchet_global', !!panel.querySelector('#ms2-s-ratchet')?.checked);
GM_setValue('jv5_use_argon2', !!panel.querySelector('#ms2-s-argon2')?.checked);
GM_setValue('jv5_use_ed25519_admin', !!panel.querySelector('#ms2-s-ed25519')?.checked);
delete _cfgCache['jv5_ratchet_global'];
delete _cfgCache['jv5_use_argon2'];
delete _cfgCache['jv5_use_ed25519_admin'];
toastHTML(`${SVG_CHECK} Crypto settings saved`);
} catch (e) {
toastHTML(`${SVG_CROSS} Failed to save crypto settings`);
}
});
// ── PSK backup / restore wiring ─────────────────────────────────────────
// Show a fingerprint (first 12 chars) of the current key so the user can
// confirm the right key is active after restoring.
const _pskFingerprintEl = panel.querySelector('#ms2-psk-fingerprint');
(async () => {
try {
const currentKey = GM_getValue(_pskGmKey, null);
if (currentKey && _pskFingerprintEl) {
// Show SHA-256 prefix as a human-readable fingerprint
const hashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(currentKey));
const hashHex = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2,'0')).join('');
_pskFingerprintEl.textContent = `Active key fingerprint: ${hashHex.slice(0, 24)}…`;
}
} catch {}
})();
panel.querySelector('#ms2-psk-backup-btn')?.addEventListener('click', () => {
try {
const key = GM_getValue(_pskGmKey, null);
if (!key) { toastHTML(`${SVG_WARNING} No identity key found`); return; }
const payload = JSON.stringify({ jv5_psk_b: key, exported: new Date().toISOString() });
const blob = new Blob([payload], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `jv5-chat-identity-${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
toastHTML(`${SVG_CHECK} Chat identity key exported — store this file somewhere safe`);
} catch (e) {
toastHTML(`${SVG_CROSS} Export failed: ${escHtml(e.message)}`);
}
});
panel.querySelector('#ms2-psk-copy-btn')?.addEventListener('click', () => {
try {
const key = GM_getValue(_pskGmKey, null);
if (!key) { toastHTML(`${SVG_WARNING} No identity key found`); return; }
navigator.clipboard.writeText(key)
.then(() => toastHTML(`${SVG_CHECK} Key copied to clipboard`))
.catch(() => toastHTML(`${SVG_WARNING} Clipboard blocked — use Export instead`));
} catch (e) {
toastHTML(`${SVG_CROSS} Copy failed: ${escHtml(e.message)}`);
}
});
panel.querySelector('#ms2-psk-restore-btn')?.addEventListener('click', async () => {
try {
const input = panel.querySelector('#ms2-psk-restore-input');
if (!input) return;
let raw = input.value.trim();
if (!raw) { toastHTML(`${SVG_WARNING} Paste your exported key first`); return; }
// Accept either raw hex string or the full JSON export object
if (raw.startsWith('{')) {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.jv5_psk_b === 'string') raw = parsed.jv5_psk_b.trim();
} catch {}
}
// Validate: must be a 64-char hex string (32 bytes)
if (!/^[0-9a-f]{64}$/i.test(raw)) {
toastHTML(`${SVG_WARNING} Invalid key — expected 64 hex characters`);
return;
}
GM_setValue(_pskGmKey, raw);
input.value = '';
// Update fingerprint display
if (_pskFingerprintEl) {
const hashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
const hashHex = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2,'0')).join('');
_pskFingerprintEl.textContent = `Active key fingerprint: ${hashHex.slice(0, 24)}…`;
}
toastHTML(`${SVG_CHECK} Identity key restored — <strong>reload the page</strong> to apply`);
} catch (e) {
toastHTML(`${SVG_CROSS} Restore failed: ${escHtml(e.message)}`);
}
});
// ── Custom passphrase UI wiring ─────────────────────────────────────────
const pskInput = panel.querySelector('#ms2-s-custom-psk');
const pskToggle = panel.querySelector('#ms2-psk-toggle');
const pskBar = panel.querySelector('#ms2-psk-strength-bar');
const pskLabel = panel.querySelector('#ms2-psk-strength-label');
const pskSaveBtn = panel.querySelector('#ms2-save-custom-psk');
const pskClearBtn = panel.querySelector('#ms2-clear-custom-psk');
// Show/hide toggle
pskToggle?.addEventListener('click', () => {
if (!pskInput) return;
const isHidden = pskInput.type === 'password';
pskInput.type = isHidden ? 'text' : 'password';
pskToggle.textContent = isHidden ? '🙈' : '👁';
// If it was showing placeholder dots from a saved passphrase, clear them so user can type
if (pskInput.value === '••••••••') pskInput.value = '';
});
// Strength meter — runs on every keystroke
const _pskStrength = (pw) => {
if (!pw) return { score: 0, label: '', color: '#ef4444' };
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 14) score++;
if (pw.length >= 20) score++;
if (/[A-Z]/.test(pw) && /[a-z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
const tiers = [
{ min: 0, label: '', color: '#ef4444', pct: 0 },
{ min: 1, label: 'Weak', color: '#ef4444', pct: 20 },
{ min: 2, label: 'Fair', color: '#f59e0b', pct: 40 },
{ min: 3, label: 'Moderate', color: '#eab308', pct: 60 },
{ min: 4, label: 'Strong', color: '#22c55e', pct: 80 },
{ min: 6, label: 'Very strong', color: '#10b981', pct: 100 },
];
const tier = [...tiers].reverse().find(t => score >= t.min) || tiers[0];
return { score, label: tier.label, color: tier.color, pct: tier.pct };
};
pskInput?.addEventListener('input', () => {
const val = pskInput.value;
// If user started typing while dots were shown, treat as fresh input
if (!pskBar || !pskLabel) return;
const { label, color, pct } = _pskStrength(val);
pskBar.style.width = pct + '%';
pskBar.style.background = color;
pskLabel.textContent = val ? label : '';
pskLabel.style.color = color;
});
// Set passphrase — hashes before storing, never stores raw string
pskSaveBtn?.addEventListener('click', async () => {
const raw = (pskInput?.value || '').trim();
if (!raw || raw === '••••••••') {
toastHTML(`${SVG_WARNING} Enter a passphrase first`, 2500);
return;
}
if (raw.length < 8) {
toastHTML(`${SVG_WARNING} Passphrase must be at least 8 characters`, 2500);
return;
}
try {
// Store a SHA-256 hash of the passphrase — never the raw string.
// The hash is used as input to PBKDF2 in _deriveKey, so brute-forcing
// the stored hash doesn't directly give the AES key.
const hashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw + ':jv5-psk-user'));
const hashHex = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2,'0')).join('');
GM_setValue('jv5_custom_psk_hash', hashHex);
GM_setValue('jv5_custom_psk_set', true);
// Invalidate key cache and ratchet state — all sessions must re-derive with new PSK
P2PCrypto.clearKeyCache();
P2PCrypto.clearRatchetState();
// Force disconnect/reconnect if chat is open so peers re-handshake
if (chatStore.open) {
chatNet.disconnect();
setTimeout(() => chatNet.connect().catch(() => {}), 800);
}
pskInput.value = '••••••••';
pskInput.type = 'password';
if (pskBar) { pskBar.style.width = '0%'; }
if (pskLabel){ pskLabel.textContent = ''; }
toastHTML(`${SVG_CHECK} Custom passphrase set — only users with the same passphrase can read your messages`);
} catch (e) {
toastHTML(`${SVG_CROSS} Failed to save passphrase`);
}
});
// Clear passphrase — reverts to default GM-split PSK
pskClearBtn?.addEventListener('click', () => {
GM_setValue('jv5_custom_psk_hash', '');
GM_setValue('jv5_custom_psk_set', false);
P2PCrypto.clearKeyCache();
P2PCrypto.clearRatchetState();
if (chatStore.open) {
chatNet.disconnect();
setTimeout(() => chatNet.connect().catch(() => {}), 800);
}
if (pskInput) { pskInput.value = ''; pskInput.type = 'password'; }
if (pskBar) { pskBar.style.width = '0%'; }
if (pskLabel) { pskLabel.textContent = ''; }
toastHTML(`${SVG_CHECK} Passphrase cleared — using default shared PSK`);
});
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();
toastHTML(`${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);
toastHTML(`${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) { toastHTML(`${SVG_WARNING} Enter a name for this persona`); personaNameIn.focus(); return; }
if (!desc) { toastHTML(`${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();
toastHTML(`${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 })); }
toastHTML(`${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();
toastHTML(`${SVG_TRASH} Persona removed`);
}
});
renderPersonaList();
panel.querySelector('#ms2-persona-export-btn')?.addEventListener('click', () => {
const arr = getPersonaLib();
if (!arr.length) { toastHTML(`${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);
toastHTML(`${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) { toastHTML(`${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) { toastHTML(`${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;
toastHTML(`${SVG_FOLDER} Imported ${added} new persona${added !== 1 ? 's' : ''}` +
(added < valid.length ? ' (' + (valid.length - added) + ' already existed)' : ''));
} catch {
toastHTML(`${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()) { toastHTML(`${SVG_WARNING} No context text to save`); return; }
saveGlobalMemory(text.trim());
toastHTML(`${SVG_SAVE} Context saved as global memory`);
});
panel.querySelector('#ms2-ctx-load-global-btn')?.addEventListener('click', () => {
const mem = getGlobalMemory().trim();
if (!mem) { toastHTML(`${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);
toastHTML(`${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) { toastHTML(`${SVG_WARNING} No character detected — are you on a chat page?`); return; }
const text = panel.querySelector('#ms2-s-context')?.value || getContext();
if (!text.trim()) { toastHTML(`${SVG_WARNING} No context text to save`); return; }
saveCharGlobalMemory(_charId, text.trim());
toastHTML(`${SVG_MEMORY} Memory saved for this character`);
});
panel.querySelector('#ms2-ctx-load-char-btn')?.addEventListener('click', () => {
if (!_charId) { toastHTML(`${SVG_WARNING} No character detected — are you on a chat page?`); return; }
const mem = getCharGlobalMemory(_charId).trim() || getGlobalMemory().trim();
if (!mem) { toastHTML(`${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);
toastHTML(`${SVG_MEMORY} Character memory loaded`);
});
panel.querySelector('#ms2-ctx-autoload-save')?.addEventListener('click', () => {
setAutoLoadGlobal(panel.querySelector('#ms2-ctx-autoload-chk')?.checked || false);
toastHTML(`${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);
toastHTML(`${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);
}
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-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();
toastHTML(`${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(); toastHTML(`${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);
toastHTML(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);
toastHTML(`${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) {
toastHTML(
`${SVG_SUMMARISE} Switched chat${charLabel} — <strong>${pastCount}</strong> past summar${pastCount !== 1 ? 'ies' : 'y'}${snapInfo}`,
4500
);
} else if (_lastCharId !== null) {
toastHTML(`${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);
toastHTML(`${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);
}
// ── 1b. Padding obscures message length ───────────────────────────────
try {
const short = { text: 'hi' };
const long = { text: 'x'.repeat(300) };
const encS = await P2PCrypto.encrypt(short, PW, ROOM);
const encL = await P2PCrypto.encrypt(long, PW, ROOM);
// Both should be multiples of 256 bytes (plus GCM tag), so short != long
// but short should be padded to the same first block as medium messages
const sLen = encS.ciphertext.length;
const lLen = encL.ciphertext.length;
_stAssert('padding: short message padded to ≥256 bytes', sLen >= 256,
`ciphertext length was ${sLen}`);
_stAssert('padding: long message padded to multiple of 256', lLen % 256 === 16 /* GCM auth tag only */,
`ciphertext length was ${lLen}`);
} catch (e) {
_stAssert('padding 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();
// ── First-run security warning ─────────────────────────────────────────────
// Shown once on fresh install. Stored in GM so it never repeats.
// Purpose: warn users about forked/modified copies before they use the script.
const _WARN_KEY = 'jv5_install_warn_v1';
if (!GM_getValue(_WARN_KEY, false)) {
GM_setValue(_WARN_KEY, true);
// Delay slightly so the page is ready
setTimeout(() => _showInstallWarning(), 800);
}
ensureFAB();
if (isOnChatPage() && CFG.autoNotify) startObserver();
if (isOnChatPage() && getAutoSumEvery() > 0) startAutoSumObserver();
bus.emit('scriptInitialized', { version: '5.7.4' });
// ── Diagnostic interface (read by JV5 Diagnostics userscript) ──────────
// SECURITY: Only exposed when the user has explicitly enabled developer mode
// in GM storage (jv5_dev_mode = true). This prevents page-context scripts on
// a compromised janitorai.com from calling getLatestAIText() or runSelfTests().
const _devModeEnabled = (() => { try { return GM_getValue('jv5_dev_mode', false); } catch { return false; } })();
try {
unsafeWindow.__jv5 = {
version: '5.7.8',
// Safe read-only selectors — no user data exposed
virtuosoWorking: () => document.querySelectorAll(VIRTUOSO_SEL).length > 0,
liveVirtuosoSel: () => VIRTUOSO_SEL,
liveMsgBodySel: () => MSG_BODY_SEL,
liveScrollerSel: () => SELECTOR_CONFIG.messagesMain,
selectorHits: () => Object.fromEntries(selectorEngine.hits || new Map()),
// Diagnostic-safe: boolean/string metadata, no credentials exposed
hasApiKey: () => !!CFG.apiKey,
model: () => CFG.model,
endpoint: () => CFG.endpoint,
authMode: () => CFG.authMode,
apEnabled: () => AP.enabled,
// Dev-mode-only: functions that could expose AI response content
getLatestText: _devModeEnabled ? getLatestAIText : undefined,
realScrollerInfo: _devModeEnabled ? (() => {
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),
};
}) : undefined,
selfTests: null,
runSelfTests: _devModeEnabled ? (() => _runSelfTests()) : undefined,
// ── Real crypto/defense hooks (v6.4+ diagnostics) ──────────────────
// These call JV5's ACTUAL encryption/validation code paths so the
// diagnostic can never give a false "healthy" result while testing
// a divergent standalone reimplementation.
cryptoFlags: () => ({
argon2Emulation: gget('jv5_use_argon2', true),
ed25519Admin: gget('jv5_use_ed25519_admin', true),
ratchetGlobal: gget('jv5_ratchet_global', true),
}),
// Round-trips a throwaway payload through the REAL PSK pipeline
// (P2PCrypto._deriveKey incl. argon2-emulation/HKDF + padding +
// anti-replay timestamp), using this user's actual derived PSK.
pskRoundTrip: async () => {
try {
const probe = { __jvdiag: true, n: Math.random() };
const enc = await pskEncrypt(probe);
if (!enc || !enc.encrypted) return { ok: false, stage: 'encrypt' };
const dec = await pskDecrypt(enc);
return { ok: !!dec && dec.n === probe.n, stage: dec ? 'ok' : 'decrypt' };
} catch (e) { return { ok: false, stage: 'error', err: e.message }; }
},
// Tests the real SSRF block-list regex against representative inputs
ssrfBlocked: (url) => _SSRF_BLOCK_RE.test(String(url || '')),
// Exercises the prototype-pollution-safe allowlist merge with a
// crafted __proto__ payload — returns true only if the merge is safe.
safeMergeOk: () => {
try {
const target = {};
_safeMergeMsg(target, JSON.parse('{"__proto__":{"jvdiagPwned":true},"text":"x"}'));
return target.jvdiagPwned !== true && ({}).jvdiagPwned !== true && target.text === 'x';
} catch { return false; }
},
// Verifies an HMAC admin signature using the real (possibly SHA-512+HKDF)
// verification path. Returns false on any bad/missing sig — safe to expose.
verifyAdminSig: _devModeEnabled ? ((msgId, ts, sig) => _verifyAdminSig(msgId, ts, sig)) : undefined,
// Last few injectAndSend() outcomes — see _logSendAttempt above.
sendLog: () => _sendAttemptLog.slice(),
// Enable dev mode via browser console: GM_setValue('jv5_dev_mode', true) then reload
};
} catch {}
}
// ─── INSTALL SECURITY WARNING (shown once on first install) ─────────────────
function _showInstallWarning() {
if (!document.body) return;
const backdrop = document.createElement('div');
backdrop.style.cssText = `
position:fixed;inset:0;z-index:2147483647;
background:rgba(0,0,0,0.85);
display:flex;align-items:flex-start;justify-content:center;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
backdrop-filter:blur(4px);
padding:env(safe-area-inset-top,12px) 12px 12px;
overflow-y:auto;
-webkit-overflow-scrolling:touch;
`;
const modal = document.createElement('div');
modal.style.cssText = `
background:#111827;border:1px solid rgba(239,68,68,0.5);
border-radius:14px;padding:0;width:min(440px,100%);
box-shadow:0 20px 50px rgba(0,0,0,0.7),0 0 0 1px rgba(239,68,68,0.15);
overflow:hidden;margin:auto;flex-shrink:0;
`;
modal.innerHTML = `
<div style="background:linear-gradient(135deg,rgba(239,68,68,0.18),rgba(127,29,29,0.25));padding:14px 16px 12px;border-bottom:1px solid rgba(239,68,68,0.2);">
<div style="display:flex;align-items:center;gap:9px;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f87171" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><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>
<div>
<div style="font-size:13.5px;font-weight:700;color:#f87171;letter-spacing:.2px;">Security Notice — Read Before Using</div>
<div style="font-size:10.5px;color:#9ca3af;margin-top:1px;">JanitorV5 by eivls · First Install</div>
</div>
</div>
</div>
<div style="padding:12px 14px 10px;display:flex;flex-direction:column;gap:9px;">
<div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.25);border-radius:9px;padding:10px 12px;">
<div style="font-size:10.5px;font-weight:700;color:#f87171;letter-spacing:.4px;margin-bottom:5px;">⚠️ FORKED OR MODIFIED COPIES ARE DANGEROUS</div>
<div style="font-size:12px;color:#d1d5db;line-height:1.6;">
Anyone can copy and redistribute this script with <strong style="color:#f87171;">malicious changes</strong> — keyloggers, API key theft, message interception — while keeping it looking identical to the original.<br><br>
<strong style="color:#fca5a5;">Only install JanitorV5 from the official GreasyFork page.</strong>
Check that the author is <strong style="color:#e5e7eb;">eivls</strong> and the URL starts with
<code style="background:rgba(255,255,255,0.07);padding:1px 5px;border-radius:4px;font-size:10.5px;color:#a78bfa;">greasyfork.org/scripts/</code>
</div>
</div>
<div style="background:rgba(251,191,36,0.07);border:1px solid rgba(251,191,36,0.2);border-radius:9px;padding:10px 12px;">
<div style="font-size:10.5px;font-weight:700;color:#fcd34d;letter-spacing:.4px;margin-bottom:5px;">🔑 YOUR API KEY</div>
<div style="font-size:12px;color:#d1d5db;line-height:1.6;">
Your API key is stored locally in Tampermonkey's GM storage — <strong>never transmitted anywhere</strong> by this script. A malicious fork could silently exfiltrate it. If you ever pasted your key into a script you didn't verify, <strong style="color:#fcd34d;">regenerate it now</strong> from your provider's dashboard.
</div>
</div>
<div style="background:rgba(16,185,129,0.07);border:1px solid rgba(16,185,129,0.2);border-radius:9px;padding:10px 12px;">
<div style="font-size:10.5px;font-weight:700;color:#6ee7b7;letter-spacing:.4px;margin-bottom:5px;">✅ HOW TO VERIFY YOU HAVE THE REAL SCRIPT</div>
<div style="font-size:12px;color:#d1d5db;line-height:1.65;">
<strong>1.</strong> Open Tampermonkey → this script → check the <em>@downloadURL</em> at the top.<br>
<strong>2.</strong> It must point to <code style="background:rgba(255,255,255,0.07);padding:1px 4px;border-radius:3px;font-size:10.5px;color:#a78bfa;">greasyfork.org</code> — not a personal GitHub, Pastebin, or unknown domain.<br>
<strong>3.</strong> The author listed on GreasyFork must be <strong style="color:#e5e7eb;">eivls</strong> (<a href="https://tiktok.com/@eivls" target="_blank" style="color:#a78bfa;text-decoration:none;">@eivls on TikTok</a>).<br>
<strong>4.</strong> Never install a "better" or "updated" version someone shares in DMs or Discord without verifying the source yourself.
</div>
</div>
<div style="font-size:10.5px;color:#6b7280;line-height:1.5;text-align:center;padding:2px 0;">
This notice appears once and will not show again.<br>Community Chat uses end-to-end encryption — your messages are private from the relay server.
</div>
</div>
<div style="padding:0 14px 14px;display:flex;gap:8px;justify-content:center;">
<button id="jv5-warn-close" style="
background:linear-gradient(135deg,#6d28d9,#4f46e5);
border:none;border-radius:8px;padding:10px 28px;
font-size:13px;font-weight:600;color:#fff;cursor:pointer;
box-shadow:0 4px 14px rgba(109,40,217,0.4);
width:100%;max-width:260px;
-webkit-tap-highlight-color:transparent;
">I understand — continue</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
modal.querySelector('#jv5-warn-close').addEventListener('click', close);
backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(init, 500));
} else {
setTimeout(init, 500);
}
})();