mirror of
https://github.com/mattjaybe/SillyTavern-EchoChamber.git
synced 2026-04-26 10:31:42 +00:00
- Content Settings: Fixed Persona not working when enabled - Content Settings: Added support for Author's Note, can be enabled - Settings> Chat Participation: Fixed backspace and input issue with AI Replies per Comment
5405 lines
No EOL
255 KiB
JavaScript
5405 lines
No EOL
255 KiB
JavaScript
// EchoChamber Extension - Import-free version using SillyTavern.getContext()
|
||
// No ES6 imports - uses the stable SillyTavern global object
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
// Module identification
|
||
const MODULE_NAME = 'discord_chat';
|
||
const EXTENSION_NAME = 'EchoChamber';
|
||
|
||
// Get BASE_URL from script tag
|
||
const scripts = document.querySelectorAll('script[src*="index.js"]');
|
||
let BASE_URL = '';
|
||
for (const script of scripts) {
|
||
if (script.src.includes('EchoChamber') || script.src.includes('DiscordChat')) {
|
||
BASE_URL = script.src.split('/').slice(0, -1).join('/');
|
||
break;
|
||
}
|
||
}
|
||
|
||
const defaultSettings = {
|
||
enabled: true,
|
||
paused: false,
|
||
source: 'default',
|
||
preset: '',
|
||
url: 'http://localhost:11434',
|
||
model: '',
|
||
openai_url: 'http://localhost:1234/v1',
|
||
openai_key: '',
|
||
openai_model: 'local-model',
|
||
openai_preset: 'custom',
|
||
userCount: 5,
|
||
fontSize: 15,
|
||
chatHeight: 250,
|
||
style: 'twitch',
|
||
position: 'bottom',
|
||
panelWidth: 350,
|
||
opacity: 85,
|
||
collapsed: false,
|
||
autoUpdateOnMessages: true,
|
||
includeUserInput: false,
|
||
contextDepth: 4,
|
||
includePastEchoChambers: false,
|
||
includePersona: false,
|
||
includeAuthorsNote: false,
|
||
includeCharacterDescription: false,
|
||
includeSummary: false,
|
||
includeWorldInfo: false,
|
||
wiBudget: 0,
|
||
livestream: false,
|
||
livestreamBatchSize: 20,
|
||
livestreamMode: 'manual',
|
||
livestreamMinWait: 5,
|
||
livestreamMaxWait: 60,
|
||
custom_styles: {},
|
||
deleted_styles: [],
|
||
style_order: null,
|
||
chatEnabled: true,
|
||
chatUsername: 'Streamer (You)',
|
||
chatAvatarColor: '#3b82f6',
|
||
chatReplyCount: 3,
|
||
floatLeft: null,
|
||
floatTop: null,
|
||
floatWidth: null,
|
||
floatHeight: null,
|
||
};
|
||
|
||
let settings = JSON.parse(JSON.stringify(defaultSettings));
|
||
let discordBar = null;
|
||
let discordContent = null;
|
||
let discordQuickBar = null;
|
||
let abortController = null;
|
||
let generateTimeout = null;
|
||
let debounceTimeout = null;
|
||
let eventsBound = false; // Prevent duplicate event listener registration
|
||
let userCancelled = false; // Track user-initiated cancellations
|
||
let isLoadingChat = false; // Track when we're loading/switching chats to prevent auto-generation
|
||
let isGenerating = false; // Track when generation is in progress to prevent concurrent requests
|
||
|
||
// Livestream state
|
||
let livestreamQueue = []; // Queue of messages to display
|
||
let livestreamTimer = null; // Timer for displaying next message
|
||
let livestreamActive = false; // Whether livestream is currently displaying messages
|
||
|
||
// Floating panel state (replaces cross-window pop-out approach)
|
||
let floatingPanelOpen = false; // Whether the in-page floating panel is visible
|
||
let popoutDiscordContent = null; // Points to #ec_float_content when panel is open
|
||
|
||
// ============================================================
|
||
// CONFIRMATION MODAL (replaces native browser confirm())
|
||
// ============================================================
|
||
|
||
/**
|
||
* Shows a custom glassmorphism confirmation modal.
|
||
* Returns a Promise<boolean> — resolves true on Confirm, false on Cancel.
|
||
*/
|
||
function showConfirmModal(message) {
|
||
return new Promise((resolve) => {
|
||
// Remove any existing modal
|
||
jQuery('#ec_confirm_modal').remove();
|
||
|
||
const modalHtml = `
|
||
<div id="ec_confirm_modal" class="ec_confirm_modal_overlay">
|
||
<div class="ec_confirm_modal_card">
|
||
<div class="ec_confirm_modal_icon">
|
||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||
</div>
|
||
<div class="ec_confirm_modal_message">${message}</div>
|
||
<div class="ec_confirm_modal_actions">
|
||
<button class="ec_confirm_modal_btn ec_confirm_cancel" id="ec_confirm_cancel">Cancel</button>
|
||
<button class="ec_confirm_modal_btn ec_confirm_ok" id="ec_confirm_ok">Clear</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
jQuery('body').append(modalHtml);
|
||
|
||
// Animate in
|
||
requestAnimationFrame(() => {
|
||
jQuery('#ec_confirm_modal').addClass('ec_confirm_visible');
|
||
});
|
||
|
||
const cleanup = (result) => {
|
||
const overlay = jQuery('#ec_confirm_modal');
|
||
overlay.removeClass('ec_confirm_visible');
|
||
setTimeout(() => overlay.remove(), 200);
|
||
resolve(result);
|
||
};
|
||
|
||
jQuery('#ec_confirm_ok').on('click', () => cleanup(true));
|
||
jQuery('#ec_confirm_cancel').on('click', () => cleanup(false));
|
||
// Click backdrop to cancel
|
||
jQuery('#ec_confirm_modal').on('click', function (e) {
|
||
if (e.target === this) cleanup(false);
|
||
});
|
||
// ESC key to cancel
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') { document.removeEventListener('keydown', onKey); cleanup(false); }
|
||
if (e.key === 'Enter') { document.removeEventListener('keydown', onKey); cleanup(true); }
|
||
};
|
||
document.addEventListener('keydown', onKey);
|
||
});
|
||
}
|
||
|
||
// Simple debounce
|
||
function debounce(func, wait) {
|
||
return function (...args) {
|
||
clearTimeout(debounceTimeout);
|
||
debounceTimeout = setTimeout(() => func.apply(this, args), wait);
|
||
};
|
||
}
|
||
|
||
const generateDebounced = debounce(() => generateDiscordChat(), 500);
|
||
|
||
function updateReplyButtonState(isGen) {
|
||
const btns = jQuery('#ec_reply_submit, #ec_float_reply_submit');
|
||
if (isGen) {
|
||
btns.addClass('ec_reply_stop').attr('title', 'Stop action (cancel generation)');
|
||
btns.html('<i class="fa-solid fa-hourglass"></i>');
|
||
} else {
|
||
btns.removeClass('ec_reply_stop').attr('title', 'Send message');
|
||
btns.html('<i class="fa-solid fa-paper-plane"></i>');
|
||
}
|
||
}
|
||
|
||
function cancelGenerationContext() {
|
||
log('Cancel generation triggered');
|
||
clearTimeout(debounceTimeout);
|
||
if (abortController) {
|
||
log('Aborting generation...');
|
||
userCancelled = true;
|
||
jQuery('#ec_cancel_btn').html('<i class="fa-solid fa-hourglass"></i> Stopping...').css('pointer-events', 'none');
|
||
jQuery('.ec_reply_stop').html('<i class="fa-solid fa-hourglass"></i>');
|
||
abortController.abort();
|
||
log('AbortController.abort() called, signal.aborted:', abortController.signal.aborted);
|
||
|
||
// Also trigger SillyTavern's built-in stop generation
|
||
const stopButton = jQuery('#mes_stop');
|
||
if (stopButton.length && !stopButton.is('.disabled')) {
|
||
log('Triggering SillyTavern stop button');
|
||
stopButton.trigger('click');
|
||
}
|
||
} else {
|
||
log('No abortController, showing cancel message');
|
||
userCancelled = true;
|
||
setStatus('');
|
||
setDiscordText(`<div class="discord_status ec_cancelled"><i class="fa-solid fa-hand"></i> Processing cancelled</div>`);
|
||
setTimeout(() => {
|
||
const cancelledMsg = jQuery('.ec_cancelled');
|
||
if (cancelledMsg.length) {
|
||
cancelledMsg.addClass('fade-out');
|
||
setTimeout(() => cancelledMsg.remove(), 500);
|
||
}
|
||
}, 3000);
|
||
updateReplyButtonState(false);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// UTILITY FUNCTIONS
|
||
// ============================================================
|
||
|
||
// Debug logging disabled for production
|
||
// Enable by uncommenting the console calls below
|
||
function log(...args) { /* console.log(`[${EXTENSION_NAME}]`, ...args); */ }
|
||
function warn(...args) { /* console.warn(`[${EXTENSION_NAME}]`, ...args); */ }
|
||
function error(...args) { console.error(`[${EXTENSION_NAME}]`, ...args); } // Keep errors visible
|
||
|
||
/**
|
||
* Resolve a SillyTavern macro string to its current value BEFORE embedding
|
||
* it in prompts sent to external APIs (Ollama, OpenAI, Profile).
|
||
* Those code paths bypass ST's internal substituteParams pipeline and would
|
||
* forward the literal macro token (e.g. "{{persona}}") to the remote model.
|
||
*
|
||
* Resolution order:
|
||
* 1. context.substituteParams(macro) — stable public ST API, covers all macros.
|
||
* 2. Source-specific fallbacks for {{persona}} and {{authorsNote}}.
|
||
* 3. Empty string (graceful degradation — never crashes).
|
||
*/
|
||
function resolveSTMacro(context, macro) {
|
||
// 1. ST's own substituteParams — handles every registered macro
|
||
if (typeof context.substituteParams === 'function') {
|
||
try {
|
||
const resolved = context.substituteParams(macro);
|
||
// substituteParams returns the literal token when no value is registered
|
||
if (resolved !== macro) return resolved || '';
|
||
} catch (e) {
|
||
log('substituteParams failed for', macro, e);
|
||
}
|
||
}
|
||
|
||
// 2. Source-specific fallbacks
|
||
try {
|
||
if (macro === '{{persona}}') {
|
||
// powerUser.personas is a dict keyed by persona name;
|
||
// default_persona holds the currently active persona name.
|
||
const pu = context.powerUser;
|
||
if (pu && pu.personas) {
|
||
const activeKey = pu.default_persona || context.name1 || '';
|
||
const desc = pu.personas[activeKey] && pu.personas[activeKey].description;
|
||
if (desc) return desc;
|
||
// Last resort: first persona that has a description
|
||
for (const key of Object.keys(pu.personas)) {
|
||
if (pu.personas[key] && pu.personas[key].description)
|
||
return pu.personas[key].description;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (macro === '{{authorsNote}}') {
|
||
// '{{authorsNote}}' IS a registered substituteParams macro in ST.
|
||
// substituteParams resolves it directly (macro names are case-insensitive),
|
||
// so this fallback branch is a safety net for edge cases only.
|
||
//
|
||
// ST's Author's Note extension (MODULE_NAME = 'note_to_self') stores
|
||
// per-chat text in chatMetadata as an OBJECT:
|
||
// chatMetadata['note_to_self'] = { note: "text", position, depth, interval }
|
||
// The actual text is at .note — not the object itself (which would
|
||
// stringify to '[object Object]' and make .trim() throw a TypeError).
|
||
//
|
||
// The default/global Author's Note (applied to all chats) lives in:
|
||
// extensionSettings['note_to_self'].default_note
|
||
const cm = context.chatMetadata;
|
||
if (cm) {
|
||
// Per-chat Author's Note object
|
||
if (cm.note_to_self && typeof cm.note_to_self === 'object' && cm.note_to_self.note) {
|
||
return cm.note_to_self.note;
|
||
}
|
||
// Some ST versions may store it as a plain string directly
|
||
if (cm.note_to_self && typeof cm.note_to_self === 'string' && cm.note_to_self.trim()) {
|
||
return cm.note_to_self;
|
||
}
|
||
// Alternate key used in some older builds
|
||
if (cm.authornote_prompt && typeof cm.authornote_prompt === 'string') {
|
||
return cm.authornote_prompt;
|
||
}
|
||
}
|
||
// Global default Author's Note (extensionSettings fallback)
|
||
const es = context.extensionSettings;
|
||
if (es && es.note_to_self) {
|
||
if (typeof es.note_to_self === 'object') {
|
||
return es.note_to_self.default_note || es.note_to_self.note || es.note_to_self.content || '';
|
||
}
|
||
if (typeof es.note_to_self === 'string') return es.note_to_self;
|
||
}
|
||
return '';
|
||
}
|
||
} catch (e) {
|
||
log('Fallback macro resolution failed for', macro, e);
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* Extract text content from any API response format.
|
||
* Handles: Anthropic content arrays (extended thinking), OpenAI format,
|
||
* raw strings, and unknown shapes with deep extraction.
|
||
*/
|
||
function extractTextFromResponse(response) {
|
||
if (!response) return '';
|
||
|
||
// 1. Response is already a plain string
|
||
if (typeof response === 'string') return response;
|
||
|
||
// 2. Response itself is an array of content blocks (e.g. extractData returned the content array directly)
|
||
if (Array.isArray(response)) {
|
||
const textParts = response
|
||
.filter(block => block && block.type === 'text' && typeof block.text === 'string')
|
||
.map(block => block.text);
|
||
if (textParts.length > 0) return textParts.join('\n');
|
||
// Fallback: maybe it's an array of strings
|
||
const stringParts = response.filter(item => typeof item === 'string');
|
||
if (stringParts.length > 0) return stringParts.join('\n');
|
||
return JSON.stringify(response);
|
||
}
|
||
|
||
// 3. response.content exists
|
||
if (response.content !== undefined && response.content !== null) {
|
||
// 3a. content is a string
|
||
if (typeof response.content === 'string') return response.content;
|
||
// 3b. content is an array of content blocks (Anthropic extended thinking format)
|
||
if (Array.isArray(response.content)) {
|
||
const textParts = response.content
|
||
.filter(block => block && block.type === 'text' && typeof block.text === 'string')
|
||
.map(block => block.text);
|
||
if (textParts.length > 0) return textParts.join('\n');
|
||
}
|
||
}
|
||
|
||
// 4. OpenAI choices format
|
||
if (response.choices?.[0]?.message?.content) {
|
||
const choiceContent = response.choices[0].message.content;
|
||
if (typeof choiceContent === 'string') return choiceContent;
|
||
if (Array.isArray(choiceContent)) {
|
||
const textParts = choiceContent
|
||
.filter(block => block && block.type === 'text' && typeof block.text === 'string')
|
||
.map(block => block.text);
|
||
if (textParts.length > 0) return textParts.join('\n');
|
||
}
|
||
}
|
||
|
||
// 5. Other common fields
|
||
if (typeof response.text === 'string') return response.text;
|
||
if (typeof response.message === 'string') return response.message;
|
||
if (response.message?.content && typeof response.message.content === 'string') return response.message.content;
|
||
|
||
// 6. Last resort - stringify
|
||
console.error('[EchoChamber] Could not extract text from response, stringifying:', response);
|
||
return JSON.stringify(response);
|
||
}
|
||
|
||
function setDiscordText(html) {
|
||
if (!discordContent) return;
|
||
|
||
const chatBlock = jQuery('#chat');
|
||
const originalScrollBottom = chatBlock.length ?
|
||
chatBlock[0].scrollHeight - (chatBlock.scrollTop() + chatBlock.outerHeight()) : 0;
|
||
|
||
discordContent.html(html);
|
||
|
||
// Scroll to top of the EchoChamber panel
|
||
if (discordContent[0]) {
|
||
discordContent[0].scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
|
||
if (chatBlock.length) {
|
||
const newScrollTop = chatBlock[0].scrollHeight - (chatBlock.outerHeight() + originalScrollBottom);
|
||
chatBlock.scrollTop(newScrollTop);
|
||
}
|
||
|
||
// Sync to floating panel if open
|
||
if (floatingPanelOpen && popoutDiscordContent) {
|
||
popoutDiscordContent.innerHTML = html;
|
||
popoutDiscordContent.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
}
|
||
|
||
function setStatus(html) {
|
||
// Target both main panel and floating panel overlays
|
||
const overlays = jQuery('.ec_status_overlay');
|
||
if (overlays.length > 0) {
|
||
if (html) {
|
||
overlays.html(html).addClass('active');
|
||
} else {
|
||
overlays.removeClass('active');
|
||
setTimeout(() => {
|
||
overlays.each(function () {
|
||
if (!jQuery(this).hasClass('active')) jQuery(this).empty();
|
||
});
|
||
}, 200);
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyFontSize(size) {
|
||
let styleEl = jQuery('#discord_font_size_style');
|
||
if (styleEl.length === 0) {
|
||
styleEl = jQuery('<style id="discord_font_size_style"></style>').appendTo('head');
|
||
}
|
||
styleEl.text(`
|
||
.discord_container { font-size: ${size}px !important; }
|
||
.discord_username { font-size: ${size / 15}rem !important; }
|
||
.discord_content { font-size: ${(size / 15) * 0.95}rem !important; }
|
||
.discord_timestamp { font-size: ${(size / 15) * 0.75}rem !important; }
|
||
`);
|
||
}
|
||
|
||
/**
|
||
* Hide Pop Out option on mobile devices
|
||
*/
|
||
function updatePopoutVisibility() {
|
||
const isMobile = window.innerWidth <= 768;
|
||
|
||
// Hide in settings dropdown
|
||
const positionSelect = jQuery('#discord_position');
|
||
if (positionSelect.length) {
|
||
positionSelect.find('option[value="popout"]').prop('hidden', isMobile).toggleClass('mobile-hidden', isMobile);
|
||
}
|
||
|
||
// Hide in toolbar layout menu
|
||
jQuery('.ec_layout_menu .ec_menu_item[data-val="popout"]').toggle(!isMobile);
|
||
|
||
// Hide in overflow menu
|
||
jQuery('.ec_of_pos_chip[data-val="popout"]').toggle(!isMobile);
|
||
}
|
||
|
||
function syncUserMenu(count) {
|
||
settings.userCount = count;
|
||
const countNum = parseInt(count);
|
||
jQuery('.ec_user_menu .ec_menu_item').each(function () {
|
||
const itemVal = parseInt(jQuery(this).data('val'));
|
||
jQuery(this).toggleClass('selected', itemVal === countNum);
|
||
});
|
||
jQuery('#discord_user_count').val(count);
|
||
// Also update the overflow menu selection if present
|
||
jQuery('.ec_of_chip[data-action="users"]').each(function () {
|
||
const chipVal = parseInt(jQuery(this).data('val'));
|
||
jQuery(this).toggleClass('ec_of_selected', chipVal === countNum);
|
||
});
|
||
}
|
||
|
||
function syncFontMenu(size) {
|
||
settings.fontSize = size;
|
||
applyFontSize(size);
|
||
jQuery('.ec_font_menu .ec_menu_item').each(function () {
|
||
jQuery(this).toggleClass('selected', jQuery(this).data('val') == size);
|
||
});
|
||
jQuery('#discord_font_size').val(size);
|
||
}
|
||
|
||
function applyAvatarColor(color) {
|
||
// Set the CSS variable on the document root so all user-message elements pick it up
|
||
document.documentElement.style.setProperty('--ec-user-avatar-color', color);
|
||
}
|
||
|
||
function formatMessage(username, content, isUser = false) {
|
||
// Use DOMPurify from SillyTavern's shared libraries
|
||
const { DOMPurify } = SillyTavern.libs;
|
||
|
||
let color;
|
||
if (isUser) {
|
||
// Use the user's configured avatar color (CSS variable set on body)
|
||
color = settings.chatAvatarColor || '#3b82f6';
|
||
} else {
|
||
let hash = 0;
|
||
for (let i = 0; i < username.length; i++) {
|
||
hash = username.charCodeAt(i) + ((hash << 5) - hash);
|
||
}
|
||
color = `hsl(${Math.abs(hash) % 360}, 75%, 70%)`;
|
||
}
|
||
const now = new Date();
|
||
const time = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
|
||
// Sanitize both username and content using DOMPurify
|
||
const safeUsername = DOMPurify.sanitize(username, { ALLOWED_TAGS: [] });
|
||
const safeContent = DOMPurify.sanitize(content, { ALLOWED_TAGS: [] });
|
||
|
||
// Apply markdown-style formatting after sanitization
|
||
const formattedContent = safeContent
|
||
.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||
.replace(/__(.*?)__/g, '<u>$1</u>')
|
||
.replace(/_(.*?)_/g, '<em>$1</em>')
|
||
.replace(/~~(.*?)~~/g, '<del>$1</del>')
|
||
.replace(/`(.+?)`/g, '<code>$1</code>');
|
||
|
||
const userClass = isUser ? ' ec_user_message' : '';
|
||
|
||
return `
|
||
<div class="discord_message${userClass}">
|
||
<div class="discord_avatar" style="background-color: ${color};">${safeUsername.substring(0, 1).toUpperCase()}</div>
|
||
<div class="discord_body">
|
||
<div class="discord_header">
|
||
<span class="discord_username" style="color: ${color};">${safeUsername}</span>
|
||
<span class="discord_timestamp">${time}</span>
|
||
</div>
|
||
<div class="discord_content">${formattedContent}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function onChatEvent(clear, autoGenerate = true) {
|
||
if (clear) {
|
||
setDiscordText('');
|
||
clearCachedCommentary();
|
||
stopLivestream();
|
||
}
|
||
// Cancel any pending generation
|
||
if (abortController) abortController.abort();
|
||
clearTimeout(debounceTimeout);
|
||
|
||
// Only auto-generate if triggered by a new message, not by loading a chat
|
||
if (autoGenerate) {
|
||
if (settings.livestream && settings.livestreamMode === 'onMessage') {
|
||
// New ST turn always triggers a fresh EchoChamber batch.
|
||
// generateDiscordChat → startLivestream will call stopLivestream() first,
|
||
// cleanly interrupting any in-progress drip and replacing it with new content.
|
||
generateDebounced();
|
||
} else if (!settings.livestream) {
|
||
// Regular mode
|
||
generateDebounced();
|
||
}
|
||
// If livestream is in onComplete mode, it handles its own generation cycle
|
||
} else {
|
||
// When loading a chat, restore cached commentary
|
||
stopLivestream();
|
||
restoreCachedCommentary();
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// METADATA MANAGEMENT FOR PERSISTENCE
|
||
// ============================================================
|
||
|
||
function getChatMetadata() {
|
||
const context = SillyTavern.getContext();
|
||
const chatId = context.chatId;
|
||
if (!chatId) return null;
|
||
|
||
if (!context.extensionSettings[MODULE_NAME]) {
|
||
context.extensionSettings[MODULE_NAME] = {};
|
||
}
|
||
if (!context.extensionSettings[MODULE_NAME].chatMetadata) {
|
||
context.extensionSettings[MODULE_NAME].chatMetadata = {};
|
||
}
|
||
|
||
return context.extensionSettings[MODULE_NAME].chatMetadata[chatId] || null;
|
||
}
|
||
|
||
function saveChatMetadata(data) {
|
||
const context = SillyTavern.getContext();
|
||
const chatId = context.chatId;
|
||
if (!chatId) {
|
||
log('Cannot save metadata: no chatId');
|
||
return;
|
||
}
|
||
|
||
if (!context.extensionSettings[MODULE_NAME]) {
|
||
context.extensionSettings[MODULE_NAME] = {};
|
||
}
|
||
if (!context.extensionSettings[MODULE_NAME].chatMetadata) {
|
||
context.extensionSettings[MODULE_NAME].chatMetadata = {};
|
||
}
|
||
|
||
context.extensionSettings[MODULE_NAME].chatMetadata[chatId] = data;
|
||
log('Saved metadata for chatId:', chatId, 'data keys:', Object.keys(data));
|
||
context.saveSettingsDebounced();
|
||
}
|
||
|
||
function clearCachedCommentary() {
|
||
saveChatMetadata(null);
|
||
log('Cleared cached commentary for current chat');
|
||
}
|
||
|
||
function restoreCachedCommentary() {
|
||
const metadata = getChatMetadata();
|
||
log('Attempting to restore cached commentary, metadata:', metadata);
|
||
|
||
if (!metadata) {
|
||
setDiscordText('');
|
||
log('No cached commentary found');
|
||
return;
|
||
}
|
||
|
||
// Check if we need to resume a livestream that was interrupted
|
||
if (settings.livestream && metadata.fullGeneratedHtml && !metadata.livestreamComplete) {
|
||
// Livestream was in progress - figure out what's been shown vs what's remaining
|
||
const fullMessages = parseLivestreamMessages(metadata.fullGeneratedHtml);
|
||
const displayedHtml = metadata.generatedHtml || '';
|
||
const displayedMessages = displayedHtml ? parseLivestreamMessages(displayedHtml) : [];
|
||
|
||
log('Livestream restore check: full messages:', fullMessages.length, 'displayed:', displayedMessages.length);
|
||
|
||
if (fullMessages.length > displayedMessages.length) {
|
||
// There are remaining messages to show
|
||
// First, display what was already shown (if any)
|
||
if (displayedHtml) {
|
||
setDiscordText(displayedHtml);
|
||
}
|
||
|
||
// Calculate remaining messages (they're at the end of fullMessages since we prepend)
|
||
// Messages are prepended, so displayed ones are at the start of the container
|
||
// We need to find which ones from fullMessages haven't been shown yet
|
||
const remainingCount = fullMessages.length - displayedMessages.length;
|
||
const remainingMessages = fullMessages.slice(0, remainingCount); // First N are the ones not yet shown
|
||
|
||
log('Resuming livestream with', remainingMessages.length, 'remaining messages');
|
||
|
||
// Resume the livestream with remaining messages
|
||
livestreamQueue = remainingMessages;
|
||
livestreamActive = true;
|
||
|
||
// Start displaying remaining messages
|
||
displayNextLivestreamMessage();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Normal restore - either not livestream mode, or livestream was complete, or no fullGeneratedHtml
|
||
if (metadata.generatedHtml) {
|
||
setDiscordText(metadata.generatedHtml);
|
||
log('Restored cached commentary from metadata, length:', metadata.generatedHtml.length);
|
||
} else if (metadata.fullGeneratedHtml) {
|
||
// Livestream complete but generatedHtml not set - use full
|
||
setDiscordText(metadata.fullGeneratedHtml);
|
||
log('Restored from fullGeneratedHtml, length:', metadata.fullGeneratedHtml.length);
|
||
} else {
|
||
setDiscordText('');
|
||
log('No commentary to restore');
|
||
}
|
||
}
|
||
|
||
function getActiveCharacters(includeDisabled = false) {
|
||
const context = SillyTavern.getContext();
|
||
|
||
// Check if we're in a group chat
|
||
if (context.groupId && context.groups) {
|
||
const group = context.groups.find(g => g.id === context.groupId);
|
||
if (group && group.members) {
|
||
const characters = group.members
|
||
.map(memberId => context.characters.find(c => c.avatar === memberId))
|
||
.filter(char => char !== undefined);
|
||
|
||
if (includeDisabled) {
|
||
return characters;
|
||
}
|
||
|
||
// Filter out disabled characters
|
||
return characters.filter(char => !group.disabled_members?.includes(char.avatar));
|
||
}
|
||
}
|
||
|
||
// Single character chat - return character at current index
|
||
if (context.characterId !== undefined && context.characters[context.characterId]) {
|
||
return [context.characters[context.characterId]];
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
// ============================================================
|
||
// LIVESTREAM FUNCTIONS
|
||
// ============================================================
|
||
|
||
function stopLivestream() {
|
||
if (livestreamTimer || livestreamQueue.length > 0) {
|
||
console.warn(`[EchoChamber] stopLivestream called! Queue had ${livestreamQueue.length} messages remaining. Caller:`, new Error().stack?.split('\n')[2]?.trim());
|
||
}
|
||
if (livestreamTimer) {
|
||
clearTimeout(livestreamTimer);
|
||
livestreamTimer = null;
|
||
}
|
||
livestreamQueue = [];
|
||
livestreamActive = false;
|
||
log('Livestream stopped');
|
||
}
|
||
|
||
// Pauses the livestream ticker without clearing the queue — safe to resume from
|
||
function pauseLivestream() {
|
||
if (livestreamTimer) {
|
||
clearTimeout(livestreamTimer);
|
||
livestreamTimer = null;
|
||
}
|
||
}
|
||
|
||
// Resumes the livestream ticker if messages remain in the queue
|
||
function resumeLivestream() {
|
||
if (livestreamActive && livestreamQueue.length > 0) {
|
||
const minWait = (settings.livestreamMinWait || 5) * 1000;
|
||
const maxWait = (settings.livestreamMaxWait || 60) * 1000;
|
||
const delay = Math.random() * (maxWait - minWait) + minWait;
|
||
log('Resuming livestream after reply. Next message in', (delay / 1000).toFixed(1), 's. Queue:', livestreamQueue.length);
|
||
livestreamTimer = setTimeout(() => displayNextLivestreamMessage(), delay);
|
||
}
|
||
}
|
||
|
||
function startLivestream(messages) {
|
||
stopLivestream(); // Clear any existing livestream
|
||
|
||
if (!messages || messages.length === 0) {
|
||
log('No messages to livestream');
|
||
return;
|
||
}
|
||
|
||
livestreamQueue = [...messages];
|
||
livestreamActive = true;
|
||
|
||
log('Starting livestream with', livestreamQueue.length, 'messages');
|
||
|
||
// Display first message immediately
|
||
displayNextLivestreamMessage();
|
||
}
|
||
|
||
function displayNextLivestreamMessage() {
|
||
if (livestreamQueue.length === 0) {
|
||
livestreamActive = false;
|
||
console.warn('[EchoChamber] Livestream completed - all messages displayed');
|
||
log('Livestream completed');
|
||
|
||
// Mark livestream as complete in metadata
|
||
const metadata = getChatMetadata();
|
||
if (metadata) {
|
||
metadata.livestreamComplete = true;
|
||
saveChatMetadata(metadata);
|
||
}
|
||
|
||
// If in onComplete mode, trigger next batch generation
|
||
if (settings.livestream && settings.livestreamMode === 'onComplete') {
|
||
log('Livestream onComplete mode: triggering next batch');
|
||
generateDebounced();
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const message = livestreamQueue.shift();
|
||
console.warn(`[EchoChamber] Displaying livestream message. Remaining in queue: ${livestreamQueue.length}`);
|
||
|
||
// Get or create the container
|
||
let container = discordContent ? discordContent.find('.discord_container') : null;
|
||
|
||
if (!container || !container.length) {
|
||
// No container exists yet — create a fresh empty one.
|
||
// Do NOT wrap existing panel content: it belongs to a previous turn
|
||
// and wrapping it would cause stale messages to appear alongside new ones.
|
||
discordContent.html('<div class="discord_container" style="padding-top: 10px;"></div>');
|
||
container = discordContent.find('.discord_container');
|
||
}
|
||
|
||
// Remove animation class from existing messages first
|
||
container.find('.ec_livestream_message').removeClass('ec_livestream_message');
|
||
|
||
// Create and prepend new message
|
||
const tempWrapper = jQuery('<div class="ec_livestream_message"></div>').append(jQuery(message));
|
||
container.prepend(tempWrapper);
|
||
|
||
// Do NOT auto-scroll during livestream — user may be reading further down
|
||
|
||
// Sync to floating panel if open
|
||
if (floatingPanelOpen && popoutDiscordContent) {
|
||
try {
|
||
let popoutContainer = popoutDiscordContent.querySelector('.discord_container');
|
||
if (!popoutContainer) {
|
||
// Create container in popout too
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'discord_container';
|
||
wrapper.style.paddingTop = '10px';
|
||
wrapper.innerHTML = popoutDiscordContent.innerHTML;
|
||
popoutDiscordContent.innerHTML = '';
|
||
popoutDiscordContent.appendChild(wrapper);
|
||
popoutContainer = wrapper;
|
||
}
|
||
|
||
// Remove animation class from popout messages
|
||
popoutContainer.querySelectorAll('.ec_livestream_message').forEach(el => {
|
||
el.classList.remove('ec_livestream_message');
|
||
});
|
||
|
||
// Create clone for popout
|
||
const popoutWrapper = document.createElement('div');
|
||
popoutWrapper.className = 'ec_livestream_message';
|
||
popoutWrapper.innerHTML = message;
|
||
popoutContainer.insertBefore(popoutWrapper, popoutContainer.firstChild);
|
||
|
||
// Do NOT auto-scroll floating panel during livestream
|
||
} catch (popoutErr) {
|
||
// Ignore popout errors, don't let them break the livestream
|
||
log('Popout sync error (ignored):', popoutErr);
|
||
}
|
||
}
|
||
|
||
// Update saved HTML with current displayed state (don't let this break livestream)
|
||
try {
|
||
const currentDisplayedHtml = discordContent.html();
|
||
const metadata = getChatMetadata();
|
||
if (metadata) {
|
||
metadata.generatedHtml = currentDisplayedHtml;
|
||
// Keep fullGeneratedHtml and livestreamComplete status
|
||
saveChatMetadata(metadata);
|
||
}
|
||
} catch (metaErr) {
|
||
log('Metadata save error (ignored):', metaErr);
|
||
}
|
||
|
||
} catch (err) {
|
||
error('Error displaying livestream message:', err);
|
||
// Continue to next message even if this one failed
|
||
}
|
||
|
||
// Schedule next message with random delay between user-configured min/max seconds
|
||
const minWait = (settings.livestreamMinWait || 5) * 1000;
|
||
const maxWait = (settings.livestreamMaxWait || 60) * 1000;
|
||
const randomValue = Math.random();
|
||
const delay = randomValue * (maxWait - minWait) + minWait;
|
||
log('Next livestream message in', (delay / 1000).toFixed(1), 'seconds (random:', randomValue.toFixed(3), '). Queue:', livestreamQueue.length, 'remaining');
|
||
|
||
livestreamTimer = setTimeout(() => displayNextLivestreamMessage(), delay);
|
||
}
|
||
|
||
function parseLivestreamMessages(html) {
|
||
// Parse the generated HTML to extract individual messages
|
||
const tempDiv = document.createElement('div');
|
||
tempDiv.innerHTML = html;
|
||
|
||
const messages = [];
|
||
const messageElements = tempDiv.querySelectorAll('.discord_message');
|
||
|
||
messageElements.forEach(el => {
|
||
messages.push(el.outerHTML);
|
||
});
|
||
|
||
log('Parsed', messages.length, 'messages from generated HTML');
|
||
return messages;
|
||
}
|
||
|
||
// ============================================================
|
||
// FLOATING PANEL FUNCTIONS
|
||
// ============================================================
|
||
|
||
/**
|
||
* Makes a jQuery element draggable within the viewport using a designated handle.
|
||
*/
|
||
function makeDraggable(element, handle) {
|
||
let isDragging = false;
|
||
let startX, startY, origLeft, origTop;
|
||
|
||
handle[0].addEventListener('mousedown', (e) => {
|
||
// Only drag on primary button; ignore clicks on interactive children
|
||
if (e.button !== 0) return;
|
||
if (e.target.closest('select, input, button, .ec_float_btn')) return;
|
||
isDragging = true;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
const rect = element[0].getBoundingClientRect();
|
||
origLeft = rect.left;
|
||
origTop = rect.top;
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
e.preventDefault();
|
||
});
|
||
|
||
function onMouseMove(e) {
|
||
if (!isDragging) return;
|
||
const dx = e.clientX - startX;
|
||
const dy = e.clientY - startY;
|
||
const panelW = element[0].offsetWidth;
|
||
const panelH = element[0].offsetHeight;
|
||
const newLeft = Math.max(0, Math.min(window.innerWidth - panelW, origLeft + dx));
|
||
const newTop = Math.max(0, Math.min(window.innerHeight - 40, origTop + dy));
|
||
element.css({ left: newLeft + 'px', top: newTop + 'px' });
|
||
}
|
||
|
||
function onMouseUp() {
|
||
if (!isDragging) return;
|
||
isDragging = false;
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
// Persist final position so it can be restored on next open / reload
|
||
settings.floatLeft = parseInt(element.css('left')) || 0;
|
||
settings.floatTop = parseInt(element.css('top')) || 0;
|
||
saveSettings();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Attaches resize logic to all four corner handles of the floating panel.
|
||
* Each handle element carries a data-corner attribute: nw, ne, sw, se.
|
||
*/
|
||
function makeFloatingPanelResizable(panel) {
|
||
panel.find('.ec_float_resize_handle').each(function () {
|
||
const handle = this;
|
||
const corner = handle.dataset.corner;
|
||
let active = false;
|
||
let startX, startY, startW, startH, startLeft, startTop;
|
||
|
||
handle.addEventListener('mousedown', (e) => {
|
||
active = true;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
startW = panel[0].offsetWidth;
|
||
startH = panel[0].offsetHeight;
|
||
startLeft = parseInt(panel.css('left')) || 0;
|
||
startTop = parseInt(panel.css('top')) || 0;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
});
|
||
|
||
function onMove(e) {
|
||
if (!active) return;
|
||
const dx = e.clientX - startX;
|
||
const dy = e.clientY - startY;
|
||
const MIN_W = 300, MIN_H = 200;
|
||
const MAX_W = window.innerWidth - 20;
|
||
const MAX_H = window.innerHeight - 20;
|
||
|
||
let newW = startW, newH = startH, newL = startLeft, newT = startTop;
|
||
|
||
if (corner === 'se') {
|
||
newW = Math.max(MIN_W, Math.min(MAX_W, startW + dx));
|
||
newH = Math.max(MIN_H, Math.min(MAX_H, startH + dy));
|
||
} else if (corner === 'sw') {
|
||
newW = Math.max(MIN_W, Math.min(MAX_W, startW - dx));
|
||
newH = Math.max(MIN_H, Math.min(MAX_H, startH + dy));
|
||
newL = startLeft + (startW - newW);
|
||
} else if (corner === 'ne') {
|
||
newW = Math.max(MIN_W, Math.min(MAX_W, startW + dx));
|
||
newH = Math.max(MIN_H, Math.min(MAX_H, startH - dy));
|
||
newT = startTop + (startH - newH);
|
||
} else if (corner === 'nw') {
|
||
newW = Math.max(MIN_W, Math.min(MAX_W, startW - dx));
|
||
newH = Math.max(MIN_H, Math.min(MAX_H, startH - dy));
|
||
newL = startLeft + (startW - newW);
|
||
newT = startTop + (startH - newH);
|
||
}
|
||
|
||
panel.css({
|
||
width: newW + 'px', height: newH + 'px',
|
||
left: newL + 'px', top: newT + 'px'
|
||
});
|
||
}
|
||
|
||
function onUp() {
|
||
if (!active) return;
|
||
active = false;
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
// Persist final size and position so they can be restored on next open / reload
|
||
settings.floatLeft = parseInt(panel.css('left')) || 0;
|
||
settings.floatTop = parseInt(panel.css('top')) || 0;
|
||
settings.floatWidth = parseInt(panel.css('width')) || 420;
|
||
settings.floatHeight = parseInt(panel.css('height')) || 620;
|
||
saveSettings();
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Opens the in-page floating EchoChamber panel.
|
||
* Lives in the same document as ST so all CSS variables, dynamic styles,
|
||
* and DOM references work without any cross-window plumbing.
|
||
*/
|
||
function openPopoutWindow() {
|
||
// If panel is already open, bring it to the front
|
||
if (floatingPanelOpen && jQuery('#ec_floating_panel').length) {
|
||
jQuery('#ec_floating_panel').css('z-index', 9999);
|
||
return;
|
||
}
|
||
|
||
const currentContent = discordContent ? discordContent.html() : '';
|
||
|
||
const chatInputHtml = settings.chatEnabled ? `
|
||
<div class="ec_reply_container ec_float_reply_bar" id="ec_float_reply_bar">
|
||
<div class="ec_reply_wrapper">
|
||
<input type="text" class="ec_reply_input" placeholder="Type a message to participate..." id="ec_float_reply_field">
|
||
<div class="ec_reply_send" id="ec_float_reply_submit" title="Send message"><i class="fa-solid fa-paper-plane"></i></div>
|
||
</div>
|
||
</div>` : '';
|
||
|
||
const panelHtml = `
|
||
<div id="ec_floating_panel" class="ec_floating_panel">
|
||
|
||
<!-- Row 1: drag handle — title, live indicator, dock button -->
|
||
<div class="ec_float_header" id="ec_float_drag_handle">
|
||
<div class="ec_float_header_left">
|
||
<span class="ec_float_title">EchoChamber</span>
|
||
<div class="ec_live_indicator" id="ec_float_live_indicator">
|
||
<i class="fa-solid fa-circle"></i> LIVE
|
||
</div>
|
||
</div>
|
||
<div class="ec_float_header_right">
|
||
<div class="ec_float_btn" id="ec_float_dock_btn" title="Dock — close floating panel">
|
||
<i class="fa-solid fa-arrow-up-right-from-square" style="transform:rotate(180deg)"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Row 2: toolbar — style selector left (50%), icon buttons right (50%) -->
|
||
<div class="ec_float_toolbar">
|
||
<div class="ec_float_toolbar_left">
|
||
<div class="ec_btn ec_float_style_btn" id="ec_float_style_indicator" title="Change Style">
|
||
<i class="fa-solid fa-masks-theater"></i>
|
||
<span class="ec_float_style_label">Style</span>
|
||
<i class="fa-solid fa-caret-down ec_dropdown_arrow"></i>
|
||
<div class="ec_popup_menu ec_style_menu ec_float_style_menu"></div>
|
||
</div>
|
||
</div>
|
||
<div class="ec_float_toolbar_right">
|
||
<div class="ec_btn ec_float_tool" title="Regenerate Chat">
|
||
<i class="fa-solid fa-rotate-right"></i>
|
||
</div>
|
||
<div class="ec_btn ec_float_tool" title="User Count">
|
||
<i class="fa-solid fa-users"></i>
|
||
<div class="ec_popup_menu ec_user_menu"></div>
|
||
</div>
|
||
<div class="ec_btn ec_float_tool" title="Font Size">
|
||
<i class="fa-solid fa-font"></i>
|
||
<div class="ec_popup_menu ec_font_menu"></div>
|
||
</div>
|
||
<div class="ec_btn ec_float_tool" title="Clear Chat & Cache">
|
||
<i class="fa-solid fa-trash-can"></i>
|
||
</div>
|
||
<div class="ec_btn ec_float_tool" title="Settings">
|
||
<i class="fa-solid fa-gear"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Chat content and overlay -->
|
||
<div id="ec_float_status_overlay" class="ec_status_overlay"></div>
|
||
<div id="ec_float_content" class="ec_float_content">${currentContent}</div>
|
||
|
||
${chatInputHtml}
|
||
|
||
<!-- Four-corner resize handles -->
|
||
<div class="ec_float_resize_handle" data-corner="nw"></div>
|
||
<div class="ec_float_resize_handle" data-corner="ne"></div>
|
||
<div class="ec_float_resize_handle" data-corner="sw"></div>
|
||
<div class="ec_float_resize_handle" data-corner="se"></div>
|
||
</div>`;
|
||
|
||
jQuery('body').append(panelHtml);
|
||
|
||
const panel = jQuery('#ec_floating_panel');
|
||
|
||
// Position and size panel — restore saved values, or fall back to defaults
|
||
const panelW = settings.floatWidth || 420;
|
||
const panelH = settings.floatHeight || 620;
|
||
const defaultLeft = Math.max(20, window.innerWidth - panelW - 24);
|
||
const defaultTop = 60;
|
||
// Clamp restored position so the panel stays fully on-screen even after a viewport resize
|
||
const restoredLeft = settings.floatLeft != null ? settings.floatLeft : defaultLeft;
|
||
const restoredTop = settings.floatTop != null ? settings.floatTop : defaultTop;
|
||
const startLeft = Math.max(0, Math.min(window.innerWidth - panelW, restoredLeft));
|
||
const startTop = Math.max(0, Math.min(window.innerHeight - 40, restoredTop));
|
||
panel.css({ left: startLeft + 'px', top: startTop + 'px', width: panelW + 'px', height: panelH + 'px' });
|
||
|
||
// Register as the sync target for setDiscordText / displayNextLivestreamMessage
|
||
floatingPanelOpen = true;
|
||
popoutDiscordContent = document.getElementById('ec_float_content');
|
||
|
||
// Attach drag and resize
|
||
makeDraggable(panel, jQuery('#ec_float_drag_handle'));
|
||
makeFloatingPanelResizable(panel);
|
||
|
||
// ---- Populate toolbar popup menus ----
|
||
|
||
// User Count menu — same items as main panel, same ec_menu_item delegation handles clicks
|
||
const floatUserMenu = panel.find('.ec_float_toolbar .ec_user_menu');
|
||
const currentUsers = parseInt(settings.userCount) || 5;
|
||
for (let i = 1; i <= 20; i++) {
|
||
floatUserMenu.append(`<div class="ec_menu_item${i === currentUsers ? ' selected' : ''}" data-val="${i}">${i} users</div>`);
|
||
}
|
||
|
||
// Font Size menu
|
||
const floatFontMenu = panel.find('.ec_float_toolbar .ec_font_menu');
|
||
const currentFont = settings.fontSize || 15;
|
||
for (let i = 8; i <= 24; i++) {
|
||
floatFontMenu.append(`<div class="ec_menu_item${i === currentFont ? ' selected' : ''}" data-val="${i}">${i}px</div>`);
|
||
}
|
||
|
||
// Style button — populate its popup menu and show the current style name
|
||
const floatStyleMenu = jQuery('#ec_float_style_indicator .ec_float_style_menu');
|
||
populateStyleMenu(floatStyleMenu);
|
||
updateFloatStyleLabel();
|
||
|
||
// ---- Float style button: use a body-appended fixed-position menu to escape overflow:hidden ----
|
||
jQuery('#ec_float_style_menu_body').remove();
|
||
const floatStyleMenuBody = jQuery('<div id="ec_float_style_menu_body" class="ec_popup_menu ec_style_menu ec_indicator_menu"></div>');
|
||
jQuery('body').append(floatStyleMenuBody);
|
||
populateStyleMenu(floatStyleMenuBody);
|
||
|
||
jQuery('#ec_float_style_indicator').on('click.floatstyle', function (e) {
|
||
e.stopPropagation();
|
||
const trigger = jQuery(this);
|
||
const wasActive = trigger.hasClass('active');
|
||
|
||
// Close all other menus
|
||
jQuery('.ec_btn').removeClass('open active');
|
||
jQuery('.ec_popup_menu').hide().css({ top: '', bottom: '', left: '', right: '', position: '' });
|
||
jQuery('#ec_style_menu_body').hide();
|
||
jQuery('.ec_style_dropdown_trigger').removeClass('active');
|
||
|
||
if (!wasActive) {
|
||
trigger.addClass('active open');
|
||
const rect = trigger[0].getBoundingClientRect();
|
||
// Open downward (floating panel is not at bottom position)
|
||
floatStyleMenuBody.css({
|
||
position: 'fixed',
|
||
top: rect.bottom + 'px',
|
||
bottom: 'auto',
|
||
left: rect.left + 'px',
|
||
width: Math.max(rect.width, 180) + 'px',
|
||
display: 'block',
|
||
maxHeight: Math.min(300, window.innerHeight - rect.bottom - 10) + 'px',
|
||
overflowY: 'auto'
|
||
});
|
||
} else {
|
||
trigger.removeClass('active open');
|
||
floatStyleMenuBody.hide();
|
||
}
|
||
});
|
||
|
||
|
||
// Collapse the main panel when floating panel opens (it's now redundant)
|
||
if (discordBar && !settings.collapsed) {
|
||
settings.collapsed = true;
|
||
discordBar.addClass('ec_collapsed');
|
||
updatePanelIcons();
|
||
saveSettings();
|
||
}
|
||
|
||
// Sync live indicator to current state
|
||
updateLiveIndicator();
|
||
|
||
// Live indicator click — mirrors main panel behaviour
|
||
jQuery('#ec_float_live_indicator').on('click', function () {
|
||
if (jQuery(this).hasClass('ec_live_loading')) {
|
||
userCancelled = true;
|
||
clearTimeout(debounceTimeout);
|
||
if (abortController) abortController.abort();
|
||
updateLiveIndicator();
|
||
} else {
|
||
toggleLivestream(!settings.livestream);
|
||
}
|
||
});
|
||
|
||
// Dock / close button
|
||
jQuery('#ec_float_dock_btn').on('click', closePopoutWindow);
|
||
|
||
// Chat Participation
|
||
if (settings.chatEnabled) {
|
||
const handleFloatSubmit = async () => {
|
||
if (isGenerating) {
|
||
cancelGenerationContext();
|
||
return;
|
||
}
|
||
const input = jQuery('#ec_float_reply_field');
|
||
const text = input.val().trim();
|
||
if (!text) return;
|
||
input.val('');
|
||
|
||
const myMsg = formatMessage(settings.chatUsername || 'Streamer (You)', text, true);
|
||
|
||
// Prepend to floating panel
|
||
const floatContainer = jQuery('#ec_float_content .discord_container');
|
||
if (floatContainer.length) {
|
||
floatContainer.prepend(myMsg);
|
||
} else {
|
||
jQuery('#ec_float_content').html(`<div class="discord_container">${myMsg}</div>`);
|
||
}
|
||
jQuery('#ec_float_content')[0].scrollTo({ top: 0, behavior: 'smooth' });
|
||
|
||
// Also prepend to main panel so they stay in sync
|
||
const mainContainer = jQuery('#discordContent .discord_container');
|
||
if (mainContainer.length) {
|
||
mainContainer.prepend(myMsg);
|
||
} else {
|
||
jQuery('#discordContent').html(`<div class="discord_container">${myMsg}</div>`);
|
||
}
|
||
jQuery('#discordContent')[0].scrollTo({ top: 0, behavior: 'smooth' });
|
||
|
||
// Parse @mention and generate targeted reply
|
||
const atMatch = text.match(/^@([^\s]+)/);
|
||
await generateSingleReply(text, atMatch ? atMatch[1] : null);
|
||
};
|
||
|
||
jQuery('#ec_float_reply_submit').on('click', handleFloatSubmit);
|
||
jQuery('#ec_float_reply_field').on('keypress', function (e) {
|
||
if (e.which === 13) handleFloatSubmit();
|
||
});
|
||
|
||
// Clicking a username in the float panel tags them in the float input
|
||
jQuery('#ec_float_content').on('click', '.discord_username', function () {
|
||
jQuery('#ec_float_reply_field').val('@' + jQuery(this).text() + ' ').focus();
|
||
});
|
||
}
|
||
|
||
log('Floating panel opened');
|
||
}
|
||
|
||
/**
|
||
* Closes and removes the floating panel, and re-expands the main panel.
|
||
*/
|
||
function closePopoutWindow() {
|
||
jQuery('#ec_floating_panel').remove();
|
||
jQuery('#ec_float_style_menu_body').remove(); // cleanup body-appended float style menu
|
||
floatingPanelOpen = false;
|
||
popoutDiscordContent = null;
|
||
|
||
// Re-expand the main panel when floating panel is docked/closed
|
||
if (discordBar && settings.collapsed) {
|
||
settings.collapsed = false;
|
||
discordBar.removeClass('ec_collapsed');
|
||
updatePanelIcons();
|
||
saveSettings();
|
||
}
|
||
|
||
log('Floating panel closed');
|
||
}
|
||
|
||
/**
|
||
* Updates the style label inside the floating panel's style button.
|
||
* Reads the current style name from settings and updates the button span text.
|
||
* Also refreshes the body-appended fixed-position menu's selection highlight.
|
||
*/
|
||
function updateFloatStyleLabel() {
|
||
const floatBtn = jQuery('#ec_float_style_indicator');
|
||
if (!floatBtn.length) return;
|
||
const styles = getAllStyles();
|
||
const currentStyle = styles.find(s => s.val === settings.style);
|
||
const styleName = currentStyle ? currentStyle.label : (settings.style || 'Default');
|
||
floatBtn.find('.ec_float_style_label').text(styleName);
|
||
// Refresh selection highlight in the body-appended float style menu
|
||
jQuery('#ec_float_style_menu_body .ec_menu_item').each(function () {
|
||
jQuery(this).toggleClass('selected', jQuery(this).data('val') === settings.style);
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// GENERATION FUNCTIONS
|
||
// ============================================================
|
||
|
||
function saveGeneratedCommentary(html, messageCommentaries, fullHtml = null, livestreamComplete = true) {
|
||
const chatId = SillyTavern.getContext().chatId;
|
||
log('Saving generated commentary for chatId:', chatId, 'html length:', html?.length);
|
||
const metadata = {
|
||
generatedHtml: html,
|
||
messageCommentaries: messageCommentaries || {},
|
||
timestamp: Date.now(),
|
||
livestreamComplete: livestreamComplete
|
||
};
|
||
// Save fullGeneratedHtml for livestream resume capability
|
||
if (fullHtml) {
|
||
metadata.fullGeneratedHtml = fullHtml;
|
||
}
|
||
saveChatMetadata(metadata);
|
||
log('Saved generated commentary to metadata, livestreamComplete:', livestreamComplete);
|
||
}
|
||
|
||
// ============================================================
|
||
// SINGLE REPLY GENERATION (targeted user interaction)
|
||
// ============================================================
|
||
|
||
let isReplying = false;
|
||
|
||
async function generateSingleReply(replyText, targetUsername) {
|
||
if (isReplying) return;
|
||
isReplying = true;
|
||
|
||
// Pause any active livestream so reply messages slot in cleanly without racing
|
||
const wasLivestreaming = settings.livestream && livestreamActive;
|
||
if (wasLivestreaming) pauseLivestream();
|
||
|
||
const context = SillyTavern.getContext();
|
||
const chat = context.chat;
|
||
if (!chat || chat.length === 0) { isReplying = false; return; }
|
||
|
||
const cleanMessage = (text) => {
|
||
if (!text) return '';
|
||
// Strip all thinking/reasoning tags: thinking, think, thought, reasoning, reason
|
||
let cleaned = text.replace(/<(thinking|think|thought|reasoning|reason)>[\s\S]*?<\/\1>/gi, '').trim();
|
||
cleaned = cleaned.replace(/<[^>]*>/g, '');
|
||
const txt = document.createElement("textarea");
|
||
txt.innerHTML = cleaned;
|
||
return txt.value;
|
||
};
|
||
|
||
// Build context history based on settings
|
||
let historyMessages;
|
||
|
||
if (settings.includeUserInput) {
|
||
const depth = Math.max(2, Math.min(500, settings.contextDepth || 4));
|
||
const visibleChat = chat.filter(msg => !msg.is_system);
|
||
let startIdx = visibleChat.length - 1;
|
||
|
||
for (let i = visibleChat.length - 1; i >= 0 && (visibleChat.length - i) <= depth; i--) { startIdx = i; }
|
||
for (let i = startIdx; i >= 0; i--) {
|
||
if (visibleChat[i].is_user) { startIdx = i; break; }
|
||
}
|
||
|
||
historyMessages = visibleChat.slice(startIdx);
|
||
if (historyMessages.length > depth) historyMessages = historyMessages.slice(-depth);
|
||
} else {
|
||
const visibleChat = chat.filter(msg => !msg.is_system);
|
||
historyMessages = visibleChat.slice(-1);
|
||
}
|
||
|
||
const metadata = getChatMetadata();
|
||
const messageCommentaries = (metadata && metadata.messageCommentaries) || {};
|
||
|
||
// Extract recent EchoChamber conversation from the DOM for conversational continuity.
|
||
const ecMessages = [];
|
||
jQuery('#discordContent .discord_message').slice(0, 8).each(function () {
|
||
const uname = jQuery(this).find('.discord_username').first().text().trim();
|
||
const content = jQuery(this).find('.discord_content').first().text().trim();
|
||
if (uname && content) ecMessages.push(`${uname}: ${content}`);
|
||
});
|
||
const ecHistory = ecMessages.reverse().join('\n');
|
||
|
||
const stylePrompt = await loadChatStyle(settings.style || 'twitch');
|
||
const chatUsername = settings.chatUsername || 'Streamer (You)';
|
||
const atUsername = `@${chatUsername}`;
|
||
const replyCount = Math.max(1, Math.min(12, settings.chatReplyCount || 3));
|
||
const baseMaxTok = targetUsername ? 200 : Math.max(200, replyCount * 80);
|
||
const maxTok = settings.chatOverrideTokens ? (settings.chatMaxTokens || 3000) : baseMaxTok;
|
||
|
||
let additionalSystemContext = '';
|
||
const systemContextParts = [];
|
||
|
||
if (settings.includePersona) {
|
||
const personaName = context.name1 || 'User';
|
||
// Resolve macro here so Ollama/OpenAI/Profile sources receive the actual
|
||
// text, not the literal '{{persona}}' token which they cannot substitute.
|
||
const personaText = resolveSTMacro(context, '{{persona}}');
|
||
if (personaText.trim()) {
|
||
systemContextParts.push(`<user_persona name="${personaName}">\n${personaText}\n</user_persona>`);
|
||
}
|
||
}
|
||
|
||
if (settings.includeAuthorsNote) {
|
||
const anText = resolveSTMacro(context, '{{authorsNote}}');
|
||
if (anText.trim()) {
|
||
systemContextParts.push(`<authors_note>\n${anText}\n</authors_note>`);
|
||
}
|
||
}
|
||
|
||
if (settings.includeCharacterDescription) {
|
||
const activeCharacters = getActiveCharacters();
|
||
if (activeCharacters.length > 0) {
|
||
const charDescriptions = activeCharacters
|
||
.filter(char => char.description)
|
||
.map(char => `<character name="${char.name}">\n${char.description}\n</character>`)
|
||
.join('\n\n');
|
||
if (charDescriptions) systemContextParts.push(charDescriptions);
|
||
}
|
||
}
|
||
|
||
if (settings.includeSummary) {
|
||
try {
|
||
const memorySettings = context.extensionSettings?.memory;
|
||
if (memorySettings) {
|
||
const chatWithSummary = context.chat?.slice().reverse().find(m => m.extra?.memory);
|
||
if (chatWithSummary?.extra?.memory) {
|
||
systemContextParts.push(`<summary>\n${chatWithSummary.extra.memory}\n</summary>`);
|
||
}
|
||
}
|
||
} catch (e) { log('Could not get summary:', e); }
|
||
}
|
||
|
||
if (settings.includeWorldInfo) {
|
||
try {
|
||
const getWorldInfoFn = context.getWorldInfoPrompt || (typeof window !== 'undefined' && window.getWorldInfoPrompt);
|
||
const currentChat = context.chat || chat;
|
||
if (typeof getWorldInfoFn === 'function' && currentChat && currentChat.length > 0) {
|
||
const chatForWI = currentChat.map(x => x.mes || x.message || x).filter(m => m && typeof m === 'string');
|
||
const wiBudgetValue = (settings.wiBudget && settings.wiBudget > 0) ? settings.wiBudget : Number.MAX_SAFE_INTEGER;
|
||
const result = await getWorldInfoFn(chatForWI, wiBudgetValue, false);
|
||
const worldInfoString = result?.worldInfoString || result;
|
||
if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) {
|
||
systemContextParts.push(`<world_info>\n${worldInfoString.trim()}\n</world_info>`);
|
||
}
|
||
} else if (context.activatedWorldInfo && Array.isArray(context.activatedWorldInfo) && context.activatedWorldInfo.length > 0) {
|
||
const worldInfoContent = context.activatedWorldInfo.filter(entry => entry && entry.content).map(entry => entry.content).join('\n\n');
|
||
if (worldInfoContent.trim()) systemContextParts.push(`<world_info>\n${worldInfoContent.trim()}\n</world_info>`);
|
||
}
|
||
} catch (e) { log('Error getting world info:', e); }
|
||
}
|
||
|
||
if (systemContextParts.length > 0) {
|
||
additionalSystemContext = '\n\n<lore>\n' + systemContextParts.join('\n\n') + '\n</lore>';
|
||
}
|
||
|
||
const systemMessage = targetUsername
|
||
? `<role>\nYou are "${targetUsername}", a viewer in an active chatroom who was just directly addressed. Focus tightly on what "${atUsername}" just said to you — that is the primary context. Respond naturally in 1 sentence max. Use "${atUsername}" when addressing them. STRICTLY follow the provided chat style format.\n</role>${additionalSystemContext}\n\n<chat_history>`
|
||
: `<role>\nYou write short chatroom messages from different viewers reacting to "${atUsername}" (the streamer) and the ongoing conversation. Focus on the most recent exchange. Keep messages brief and casual. STRICTLY follow the provided chat style format.\n</role>${additionalSystemContext}\n\n<chat_history>`;
|
||
|
||
const chatHistoryMessages = [];
|
||
if (settings.includePastEchoChambers && metadata && metadata.messageCommentaries) {
|
||
for (let i = 0; i < historyMessages.length; i++) {
|
||
const msg = historyMessages[i];
|
||
const msgIndex = chat.indexOf(msg);
|
||
const role = msg.is_user ? 'user' : 'assistant';
|
||
let content = cleanMessage(msg.mes);
|
||
if (messageCommentaries[msgIndex]) content += `\n\n[Previous EchoChamber commentary: ${messageCommentaries[msgIndex]}]`;
|
||
chatHistoryMessages.push({ role, content });
|
||
}
|
||
} else {
|
||
for (const msg of historyMessages) {
|
||
const role = msg.is_user ? 'user' : 'assistant';
|
||
const content = cleanMessage(msg.mes);
|
||
chatHistoryMessages.push({ role, content });
|
||
}
|
||
}
|
||
|
||
const userPrompt = targetUsername
|
||
? `</chat_history>\n\n<recent_chatroom_history>\n${ecHistory}\n</recent_chatroom_history>\n\n<streamer_reply>\n"${atUsername}" just said to you: "${replyText}"\n</streamer_reply>\n\n<instructions>\nChat style format:\n${stylePrompt}\n\nWrite EXACTLY 1 short reply from "${targetUsername}" reacting to what "${atUsername}" just said. Your reply MUST begin by addressing them as "${atUsername}". No other chatters.\n\nFormat (follow exactly):\n${targetUsername}: ${atUsername} [your reply here]\n\nOutput only that single line.\n</instructions>`
|
||
: `</chat_history>\n\n<recent_chatroom_history>\n${ecHistory}\n</recent_chatroom_history>\n\n<streamer_reply>\n"${atUsername}" posted: "${replyText}"\n</streamer_reply>\n\n<instructions>\nChat style format:\n${stylePrompt}\n\nWrite EXACTLY ${replyCount} short repl${replyCount === 1 ? 'y' : 'ies'} from different chatters reacting to the MOST RECENT context above. Some may address "${atUsername}" directly using "${atUsername}".\n\nFormat:\nusername: message\n\nOutput only the messages, nothing else.\n</instructions>`;
|
||
|
||
abortController = new AbortController();
|
||
userCancelled = false;
|
||
isGenerating = true;
|
||
updateReplyButtonState(true);
|
||
|
||
const typingName = targetUsername || 'Chat';
|
||
// In Livestream mode: use the LIVE indicator turning orange instead of a status popup
|
||
if (settings.livestream) {
|
||
updateLiveIndicator('loading');
|
||
} else {
|
||
setStatus(`<span><i class="fa-solid fa-circle-notch fa-spin"></i> ${typingName} is typing...</span>`);
|
||
}
|
||
|
||
const messagesPayload = [{ role: 'system', content: systemMessage }, ...chatHistoryMessages, { role: 'user', content: userPrompt }];
|
||
|
||
try {
|
||
let result = '';
|
||
|
||
if (settings.source === 'ollama') {
|
||
const baseUrl = settings.url.replace(/\/$/, '');
|
||
const modelToUse = settings.model;
|
||
if (!modelToUse) { setStatus(''); isReplying = false; return; }
|
||
const resp = await fetch(`${baseUrl}/api/chat`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
model: modelToUse,
|
||
messages: messagesPayload,
|
||
stream: false,
|
||
options: { num_ctx: context.main?.context_size || 4096, num_predict: maxTok }
|
||
}),
|
||
signal: abortController.signal
|
||
});
|
||
if (!resp.ok) throw new Error(`Ollama error: ${resp.status}`);
|
||
const data = await resp.json();
|
||
result = data.message?.content || data.response || '';
|
||
|
||
} else if (settings.source === 'openai') {
|
||
const baseUrl = settings.openai_url.replace(/\/$/, '');
|
||
const resp = await fetch(`${baseUrl}/chat/completions`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(settings.openai_key ? { 'Authorization': `Bearer ${settings.openai_key}` } : {})
|
||
},
|
||
body: JSON.stringify({
|
||
model: settings.openai_model || 'local-model',
|
||
messages: messagesPayload,
|
||
temperature: 0.85,
|
||
max_tokens: maxTok,
|
||
stream: false
|
||
}),
|
||
signal: abortController.signal
|
||
});
|
||
if (!resp.ok) throw new Error(`OpenAI error: ${resp.status}`);
|
||
const data = await resp.json();
|
||
result = extractTextFromResponse(data);
|
||
|
||
} else if (settings.source === 'profile') {
|
||
const cm = context.extensionSettings?.connectionManager;
|
||
const profile = cm?.profiles?.find(p => p.name === settings.preset);
|
||
if (!profile || !context.ConnectionManagerRequestService) throw new Error('Profile not available');
|
||
const resp = await context.ConnectionManagerRequestService.sendRequest(
|
||
profile.id,
|
||
messagesPayload,
|
||
maxTok,
|
||
{ stream: false, signal: abortController.signal, extractData: true, includePreset: true, includeInstruct: true }
|
||
);
|
||
result = extractTextFromResponse(resp);
|
||
|
||
} else {
|
||
// Default ST generateRaw
|
||
const { generateRaw } = context;
|
||
if (generateRaw) {
|
||
result = await generateRaw({
|
||
prompt: messagesPayload,
|
||
quietToLoud: false
|
||
});
|
||
}
|
||
}
|
||
|
||
setStatus('');
|
||
if (!result || !result.trim()) { isReplying = false; return; }
|
||
|
||
// Parse "username: message" lines and prepend each to existing chat
|
||
const lines = result.trim().split('\n').filter(l => l.trim() && l.includes(':'));
|
||
const container = jQuery('#discordContent .discord_container');
|
||
// Reverse so the first message in the AI response ends up on top
|
||
[...lines].reverse().forEach(line => {
|
||
const colonIdx = line.indexOf(':');
|
||
if (colonIdx < 1) return;
|
||
const uname = line.substring(0, colonIdx).trim();
|
||
const content = line.substring(colonIdx + 1).trim();
|
||
if (!uname || !content) return;
|
||
const msgHtml = formatMessage(uname, content);
|
||
if (container.length) {
|
||
container.prepend(msgHtml);
|
||
} else {
|
||
jQuery('#discordContent').html(`<div class="discord_container">${msgHtml}</div>`);
|
||
}
|
||
});
|
||
|
||
// Mirror AI replies into the floating panel if it is open
|
||
if (floatingPanelOpen && popoutDiscordContent) {
|
||
const floatContainer = jQuery('#ec_float_content .discord_container');
|
||
[...lines].reverse().forEach(line => {
|
||
const colonIdx = line.indexOf(':');
|
||
if (colonIdx < 1) return;
|
||
const uname = line.substring(0, colonIdx).trim();
|
||
const content = line.substring(colonIdx + 1).trim();
|
||
if (!uname || !content) return;
|
||
const msgHtml = formatMessage(uname, content);
|
||
if (floatContainer.length) {
|
||
floatContainer.prepend(msgHtml);
|
||
} else {
|
||
jQuery('#ec_float_content').html(`<div class="discord_container">${msgHtml}</div>`);
|
||
}
|
||
});
|
||
jQuery('#ec_float_content')[0].scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
|
||
// Persist the updated panel HTML to the cache so it survives page refresh
|
||
savePanelState();
|
||
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError' && !userCancelled) error('generateSingleReply error:', e);
|
||
setStatus('');
|
||
if (settings.livestream) updateLiveIndicator();
|
||
} finally {
|
||
isReplying = false;
|
||
isGenerating = false;
|
||
updateReplyButtonState(false);
|
||
// Resume the livestream ticker now that the reply exchange is complete
|
||
if (wasLivestreaming) resumeLivestream();
|
||
// Ensure indicator is restored after reply (whether livestream or not)
|
||
if (settings.livestream) updateLiveIndicator();
|
||
}
|
||
}
|
||
|
||
// GENERATION
|
||
// ============================================================
|
||
|
||
// Saves the current panel HTML to chat metadata so user replies survive reload
|
||
function savePanelState() {
|
||
if (!discordContent) return;
|
||
const currentHtml = discordContent.html();
|
||
if (!currentHtml || !currentHtml.trim()) return;
|
||
const existing = getChatMetadata();
|
||
const messageCommentaries = (existing && existing.messageCommentaries) || {};
|
||
saveChatMetadata({
|
||
generatedHtml: currentHtml,
|
||
messageCommentaries,
|
||
timestamp: Date.now(),
|
||
livestreamComplete: true
|
||
});
|
||
log('Panel state saved after reply exchange');
|
||
}
|
||
|
||
async function generateDiscordChat(showOverlay = false) {
|
||
if (!settings.enabled) {
|
||
if (discordBar) discordBar.hide();
|
||
return;
|
||
}
|
||
|
||
// If paused, don't generate but keep panel visible
|
||
if (settings.paused) {
|
||
return;
|
||
}
|
||
|
||
// If already generating, abort the previous request first
|
||
if (isGenerating && abortController) {
|
||
abortController.abort();
|
||
// Wait a tiny bit for the abort to process
|
||
await new Promise(resolve => setTimeout(resolve, 50));
|
||
}
|
||
|
||
if (discordBar) discordBar.show();
|
||
|
||
const context = SillyTavern.getContext();
|
||
const chat = context.chat;
|
||
if (!chat || chat.length === 0) return;
|
||
|
||
// Mark generation as in progress
|
||
isGenerating = true;
|
||
updateReplyButtonState(true);
|
||
|
||
// Create new AbortController BEFORE setting up the Cancel button
|
||
userCancelled = false;
|
||
abortController = new AbortController();
|
||
|
||
// In Livestream mode (background auto-generation): suppress the Processing/Cancel popup
|
||
// overlay — the LIVE indicator turning orange provides the visual feedback instead.
|
||
// When triggered explicitly by the user (showOverlay=true, e.g. Regenerate Chat button),
|
||
// always show the full overlay regardless of Livestream state.
|
||
if (settings.livestream && !showOverlay) {
|
||
updateLiveIndicator('loading');
|
||
} else {
|
||
setStatus(`
|
||
<span><i class="fa-solid fa-circle-notch fa-spin"></i> Processing...</span>
|
||
<div class="ec_status_btn" id="ec_cancel_btn" title="Cancel Generation">
|
||
<i class="fa-solid fa-ban"></i> Cancel
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
// Use event delegation to ensure the handler works even if button is recreated
|
||
jQuery(document).off('click', '#ec_cancel_btn').on('click', '#ec_cancel_btn', function (e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
log('Cancel button clicked');
|
||
|
||
// Clear debounce timeout in case generation hasn't started yet
|
||
clearTimeout(debounceTimeout);
|
||
|
||
if (abortController) {
|
||
log('Aborting generation...');
|
||
userCancelled = true;
|
||
jQuery('#ec_cancel_btn').html('<i class="fa-solid fa-hourglass"></i> Stopping...').css('pointer-events', 'none');
|
||
abortController.abort();
|
||
log('AbortController.abort() called, signal.aborted:', abortController.signal.aborted);
|
||
|
||
// Also trigger SillyTavern's built-in stop generation
|
||
const stopButton = jQuery('#mes_stop');
|
||
if (stopButton.length && !stopButton.is('.disabled')) {
|
||
log('Triggering SillyTavern stop button');
|
||
stopButton.trigger('click');
|
||
}
|
||
} else {
|
||
log('No abortController, showing cancel message');
|
||
// If abortController doesn't exist yet, just clear the status
|
||
userCancelled = true;
|
||
setStatus('');
|
||
setDiscordText(`<div class="discord_status ec_cancelled"><i class="fa-solid fa-hand"></i> Processing cancelled</div>`);
|
||
setTimeout(() => {
|
||
const cancelledMsg = jQuery('.ec_cancelled');
|
||
if (cancelledMsg.length) {
|
||
cancelledMsg.addClass('fade-out');
|
||
setTimeout(() => cancelledMsg.remove(), 500);
|
||
}
|
||
}, 3000);
|
||
}
|
||
});
|
||
|
||
const cleanMessage = (text) => {
|
||
if (!text) return '';
|
||
// Strip all thinking/reasoning tags: thinking, think, thought, reasoning, reason
|
||
let cleaned = text.replace(/<(thinking|think|thought|reasoning|reason)>[\s\S]*?<\/\1>/gi, '').trim();
|
||
cleaned = cleaned.replace(/<[^>]*>/g, '');
|
||
const txt = document.createElement("textarea");
|
||
txt.innerHTML = cleaned;
|
||
return txt.value;
|
||
};
|
||
|
||
// Build context history based on settings
|
||
// includeUserInput OFF: Only the last message (AI response)
|
||
// includeUserInput ON: Use contextDepth to include multiple exchanges
|
||
// Note: Filter out hidden messages (is_system === true)
|
||
let historyMessages;
|
||
|
||
if (settings.includeUserInput) {
|
||
// Allow context depth up to 500 messages (no artificial cap)
|
||
const depth = Math.max(2, Math.min(500, settings.contextDepth || 4));
|
||
// Filter out hidden messages first
|
||
const visibleChat = chat.filter(msg => !msg.is_system);
|
||
|
||
// Find the starting user message based on depth
|
||
let startIdx = visibleChat.length - 1;
|
||
|
||
// Walk backwards to find how far back we need to go
|
||
for (let i = visibleChat.length - 1; i >= 0 && (visibleChat.length - i) <= depth; i--) {
|
||
startIdx = i;
|
||
}
|
||
|
||
// Now find the nearest user message at or before startIdx
|
||
for (let i = startIdx; i >= 0; i--) {
|
||
if (visibleChat[i].is_user) {
|
||
startIdx = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
historyMessages = visibleChat.slice(startIdx);
|
||
// Limit to depth messages
|
||
if (historyMessages.length > depth) {
|
||
historyMessages = historyMessages.slice(-depth);
|
||
}
|
||
log('includeUserInput ON - depth:', depth, 'startIdx:', startIdx, 'count:', historyMessages.length, '(excluding hidden)');
|
||
} else {
|
||
// Only the last message (AI response), excluding hidden messages
|
||
const visibleChat = chat.filter(msg => !msg.is_system);
|
||
historyMessages = visibleChat.slice(-1);
|
||
log('includeUserInput OFF - using last visible message only');
|
||
}
|
||
|
||
// Build history with past commentary if enabled
|
||
const metadata = getChatMetadata();
|
||
const messageCommentaries = (metadata && metadata.messageCommentaries) || {};
|
||
|
||
log('History messages:', historyMessages.map(m => ({ name: m.name, is_user: m.is_user })), 'count:', historyMessages.length);
|
||
|
||
// Determine user count and message count
|
||
const isNarratorStyle = ['nsfw_ava', 'nsfw_kai', 'hypebot'].includes(settings.style);
|
||
|
||
let actualUserCount; // Number of different users
|
||
let messageCount; // Number of messages to generate
|
||
|
||
if (settings.livestream && !showOverlay) {
|
||
// Background auto-generation in livestream mode: use batch size for the queue
|
||
// Narrator styles (single character) still generate multiple messages in livestream mode
|
||
actualUserCount = 1; // Always 1 user for narrator styles; for others use userCount
|
||
if (isNarratorStyle) {
|
||
// Narrator styles: single character but generate multiple messages for livestream
|
||
actualUserCount = 1;
|
||
messageCount = Math.max(5, Math.min(50, parseInt(settings.livestreamBatchSize) || 20));
|
||
} else {
|
||
actualUserCount = Math.max(1, Math.min(20, parseInt(settings.userCount) || 5));
|
||
messageCount = Math.max(5, Math.min(50, parseInt(settings.livestreamBatchSize) || 20));
|
||
}
|
||
log('Livestream mode - users:', actualUserCount, 'messages:', messageCount);
|
||
} else {
|
||
// Regular mode (or explicit regen in livestream mode): user count determines both
|
||
actualUserCount = isNarratorStyle ? 1 : (parseInt(settings.userCount) || 5);
|
||
messageCount = actualUserCount;
|
||
}
|
||
|
||
const userCount = Math.max(1, Math.min(50, messageCount));
|
||
log('generateDiscordChat - userCount:', userCount, isNarratorStyle ? '(narrator style)' : '', settings.livestream ? '(livestream batch)' : '');
|
||
|
||
const stylePrompt = await loadChatStyle(settings.style || 'twitch');
|
||
|
||
// Build additional context for system message (persona, characters, summary, world info)
|
||
let additionalSystemContext = '';
|
||
const systemContextParts = [];
|
||
|
||
// Include persona if enabled — resolve macro eagerly so Ollama/OpenAI/Profile
|
||
// sources receive the actual text, not the unresolved '{{persona}}' token.
|
||
if (settings.includePersona) {
|
||
const personaName = context.name1 || 'User';
|
||
const personaText = resolveSTMacro(context, '{{persona}}');
|
||
if (personaText.trim()) {
|
||
systemContextParts.push(`<user_persona name="${personaName}">\n${personaText}\n</user_persona>`);
|
||
log('Added persona text to system message, length:', personaText.length);
|
||
} else {
|
||
log('Persona enabled but no text found — check ST persona settings');
|
||
}
|
||
}
|
||
|
||
// Include Author's Note if enabled
|
||
if (settings.includeAuthorsNote) {
|
||
const anText = resolveSTMacro(context, '{{authorsNote}}');
|
||
if (anText.trim()) {
|
||
systemContextParts.push(`<authors_note>\n${anText}\n</authors_note>`);
|
||
log('Added authors note to system message, length:', anText.length);
|
||
} else {
|
||
log("Authors note enabled but no text found — check Author's Note extension");
|
||
}
|
||
}
|
||
|
||
// Include character descriptions if enabled
|
||
if (settings.includeCharacterDescription) {
|
||
const activeCharacters = getActiveCharacters();
|
||
if (activeCharacters.length > 0) {
|
||
const charDescriptions = activeCharacters
|
||
.filter(char => char.description)
|
||
.map(char => `<character name="${char.name}">\n${char.description}\n</character>`)
|
||
.join('\n\n');
|
||
if (charDescriptions) {
|
||
systemContextParts.push(charDescriptions);
|
||
log('Added character descriptions for', activeCharacters.length, 'characters');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Include summary if enabled (from Summarize extension)
|
||
if (settings.includeSummary) {
|
||
try {
|
||
// Try to get summary from chat metadata or extension settings
|
||
const memorySettings = context.extensionSettings?.memory;
|
||
if (memorySettings) {
|
||
// Look for summary in recent chat messages
|
||
const chatWithSummary = context.chat?.slice().reverse().find(m => m.extra?.memory);
|
||
if (chatWithSummary?.extra?.memory) {
|
||
systemContextParts.push(`<summary>\n${chatWithSummary.extra.memory}\n</summary>`);
|
||
log('Added summary from chat memory');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
log('Could not get summary:', e);
|
||
}
|
||
}
|
||
|
||
// Include world info (lorebook) if enabled - fetch using getWorldInfoPrompt like RPG Companion
|
||
if (settings.includeWorldInfo) {
|
||
try {
|
||
// Use SillyTavern's getWorldInfoPrompt to get activated lorebook entries
|
||
const getWorldInfoFn = context.getWorldInfoPrompt || (typeof window !== 'undefined' && window.getWorldInfoPrompt);
|
||
const currentChat = context.chat || chat;
|
||
|
||
if (typeof getWorldInfoFn === 'function' && currentChat && currentChat.length > 0) {
|
||
const chatForWI = currentChat.map(x => x.mes || x.message || x).filter(m => m && typeof m === 'string');
|
||
// Use user-configured budget; 0 means "let SillyTavern decide" (pass a huge value so ST's own budget applies)
|
||
const wiBudgetValue = (settings.wiBudget && settings.wiBudget > 0) ? settings.wiBudget : Number.MAX_SAFE_INTEGER;
|
||
const result = await getWorldInfoFn(chatForWI, wiBudgetValue, false);
|
||
const worldInfoString = result?.worldInfoString || result;
|
||
|
||
if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) {
|
||
systemContextParts.push(`<world_info>\n${worldInfoString.trim()}\n</world_info>`);
|
||
log('Added world info, length:', worldInfoString.length);
|
||
} else {
|
||
log('World info enabled but getWorldInfoPrompt returned empty');
|
||
}
|
||
} else {
|
||
// Fallback to activatedWorldInfo
|
||
if (context.activatedWorldInfo && Array.isArray(context.activatedWorldInfo) && context.activatedWorldInfo.length > 0) {
|
||
const worldInfoContent = context.activatedWorldInfo
|
||
.filter(entry => entry && entry.content)
|
||
.map(entry => entry.content)
|
||
.join('\n\n');
|
||
if (worldInfoContent.trim()) {
|
||
systemContextParts.push(`<world_info>\n${worldInfoContent.trim()}\n</world_info>`);
|
||
log('Added world info from activatedWorldInfo, entries:', context.activatedWorldInfo.length);
|
||
}
|
||
} else {
|
||
log('World info enabled but no getWorldInfoPrompt function and no activatedWorldInfo');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
log('Error getting world info:', e);
|
||
}
|
||
}
|
||
|
||
if (systemContextParts.length > 0) {
|
||
additionalSystemContext = '\n\n<lore>\n' + systemContextParts.join('\n\n') + '\n</lore>';
|
||
}
|
||
|
||
// Build the system message with base prompt and additional context
|
||
const systemMessage = `<role>
|
||
You are an excellent creator of fake chat feeds that react dynamically to the user's conversation context.
|
||
</role>${additionalSystemContext}
|
||
|
||
<chat_history>`;
|
||
|
||
// Build dynamic count instruction based on style type and mode
|
||
let countInstruction = '';
|
||
if (isNarratorStyle && settings.livestream && !showOverlay) {
|
||
// Narrator styles in livestream mode: single character but multiple messages
|
||
countInstruction = `IMPORTANT: You MUST generate EXACTLY ${messageCount} messages. Not fewer, not more - exactly ${messageCount} messages from the same narrator/character.\n\n`;
|
||
} else if (!isNarratorStyle) {
|
||
if (settings.livestream && !showOverlay) {
|
||
countInstruction = `IMPORTANT: You MUST generate EXACTLY ${messageCount} chat messages from EXACTLY ${actualUserCount} different users. Each user can post multiple messages. Not fewer, not more - exactly ${messageCount} messages from ${actualUserCount} users.\n\n`;
|
||
} else {
|
||
countInstruction = `IMPORTANT: You MUST generate EXACTLY ${userCount} chat messages. Not fewer, not more - exactly ${userCount}.\n\n`;
|
||
}
|
||
}
|
||
|
||
// Build the chat history as proper message array for APIs that support it
|
||
// This creates user/assistant turns from the conversation
|
||
const chatHistoryMessages = [];
|
||
|
||
if (settings.includePastEchoChambers && metadata && metadata.messageCommentaries) {
|
||
// Include past generated commentary interleaved with messages
|
||
for (let i = 0; i < historyMessages.length; i++) {
|
||
const msg = historyMessages[i];
|
||
const msgIndex = chat.indexOf(msg);
|
||
const role = msg.is_user ? 'user' : 'assistant';
|
||
let content = cleanMessage(msg.mes);
|
||
|
||
// Add commentary if it exists for this message
|
||
if (messageCommentaries[msgIndex]) {
|
||
content += `\n\n[Previous EchoChamber commentary: ${messageCommentaries[msgIndex]}]`;
|
||
}
|
||
|
||
chatHistoryMessages.push({ role, content });
|
||
}
|
||
log('Including past EchoChambers commentary in chat history');
|
||
} else {
|
||
// Build chat history with proper user/assistant roles (no names, just message content)
|
||
for (const msg of historyMessages) {
|
||
const role = msg.is_user ? 'user' : 'assistant';
|
||
const content = cleanMessage(msg.mes);
|
||
chatHistoryMessages.push({ role, content });
|
||
}
|
||
}
|
||
|
||
// Build the final user prompt (instructions only, context is in chat history)
|
||
let userReplyContext = "";
|
||
if (window.lastEchoReply) {
|
||
userReplyContext = `\n\n<streamer_reply>\nIMPORTANT: The streamer (the user who controls this character) has just directly replied to the chat: "${window.lastEchoReply}". The chat reactions you generate MUST acknowledge and react to this reply. Some chatters should respond directly to the streamer's message.\n</streamer_reply>`;
|
||
window.lastEchoReply = null; // Clear it so it doesn't repeat
|
||
}
|
||
const instructionsPrompt = `</chat_history>${userReplyContext}
|
||
|
||
<instructions>
|
||
${countInstruction}${stylePrompt}
|
||
</instructions>
|
||
|
||
<task>
|
||
Based on the chat history above, generate fake chat feed reactions. Remember to think about them step-by-step first.
|
||
STRICTLY follow the format defined in the instruction. ${isNarratorStyle && settings.livestream && !showOverlay ? `Output exactly ${messageCount} messages.` : isNarratorStyle ? '' : settings.livestream && !showOverlay ? `Output exactly ${messageCount} messages from ${actualUserCount} users.` : `Output exactly ${userCount} messages.`} Do NOT continue the story or roleplay as the characters. The created by you people are allowed to interact with each other over your generated feed. Do NOT output preamble like "Here are the messages". Just output the content directly.
|
||
</task>`;
|
||
|
||
// Calculate appropriate max_tokens based on message count
|
||
// Each message typically needs 50-100 tokens, so we allocate ~200 per message with a minimum of 2048 for safety
|
||
const calculatedMaxTokens = Math.max(2048, userCount * 200 + 1024);
|
||
log('Calculated max_tokens:', calculatedMaxTokens, 'for', userCount, 'messages');
|
||
|
||
try {
|
||
let result = '';
|
||
|
||
if (settings.source === 'profile' && settings.preset) {
|
||
// PROFILE GENERATION - Build proper message array with chat history
|
||
const cm = context.extensionSettings?.connectionManager;
|
||
const profile = cm?.profiles?.find(p => p.name === settings.preset);
|
||
if (!profile) throw new Error(`Profile '${settings.preset}' not found`);
|
||
|
||
// Use ConnectionManagerRequestService
|
||
if (!context.ConnectionManagerRequestService) throw new Error('ConnectionManagerRequestService not available');
|
||
|
||
// Build message array: system, chat history, then instructions
|
||
const messages = [
|
||
{ role: 'system', content: systemMessage }
|
||
];
|
||
|
||
// Add chat history as proper user/assistant turns
|
||
for (const histMsg of chatHistoryMessages) {
|
||
messages.push({ role: histMsg.role, content: histMsg.content });
|
||
}
|
||
|
||
// Add final instruction as user message
|
||
messages.push({ role: 'user', content: instructionsPrompt });
|
||
|
||
log(`Generating with profile: ${profile.name}, max_tokens: ${calculatedMaxTokens}, messages: ${messages.length}`);
|
||
const response = await context.ConnectionManagerRequestService.sendRequest(
|
||
profile.id,
|
||
messages,
|
||
calculatedMaxTokens, // Dynamic max_tokens based on message count
|
||
{
|
||
stream: false,
|
||
signal: abortController.signal,
|
||
extractData: true,
|
||
includePreset: true,
|
||
includeInstruct: true
|
||
}
|
||
);
|
||
|
||
// DEBUG: Log the actual response shape from sendRequest
|
||
console.error('[EchoChamber DEBUG] sendRequest response type:', typeof response);
|
||
console.error('[EchoChamber DEBUG] isArray:', Array.isArray(response));
|
||
console.error('[EchoChamber DEBUG] response keys:', response ? Object.keys(response) : 'null/undefined');
|
||
if (response?.content) {
|
||
console.error('[EchoChamber DEBUG] content type:', typeof response.content, 'isArray:', Array.isArray(response.content));
|
||
if (Array.isArray(response.content)) {
|
||
console.error('[EchoChamber DEBUG] content blocks:', response.content.map(b => ({ type: b.type, hasText: !!b.text, textLen: b.text?.length })));
|
||
}
|
||
}
|
||
|
||
// Parse response - handle all possible formats from different API backends
|
||
result = extractTextFromResponse(response);
|
||
|
||
} else if (settings.source === 'ollama') {
|
||
const baseUrl = settings.url.replace(/\/$/, '');
|
||
let modelToUse = settings.model;
|
||
if (!modelToUse) {
|
||
warn('No Ollama model selected');
|
||
return;
|
||
}
|
||
|
||
// Build message array for Ollama chat endpoint (multi-turn)
|
||
const messages = [
|
||
{ role: 'system', content: systemMessage }
|
||
];
|
||
|
||
// Add chat history as proper user/assistant turns
|
||
for (const histMsg of chatHistoryMessages) {
|
||
messages.push({ role: histMsg.role, content: histMsg.content });
|
||
}
|
||
|
||
// Add final instruction as user message
|
||
messages.push({ role: 'user', content: instructionsPrompt });
|
||
|
||
log(`Generating with Ollama: ${modelToUse}, messages: ${messages.length}`);
|
||
|
||
// Use Ollama's chat endpoint for proper multi-turn conversation
|
||
const response = await fetch(`${baseUrl}/api/chat`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
model: modelToUse,
|
||
messages: messages,
|
||
stream: false,
|
||
options: { num_ctx: context.main?.context_size || 4096, num_predict: calculatedMaxTokens }
|
||
}),
|
||
signal: abortController.signal
|
||
});
|
||
if (!response.ok) throw new Error(`Ollama API Error(${response.status})`);
|
||
const data = await response.json();
|
||
result = data.message?.content || data.response || '';
|
||
} else if (settings.source === 'openai') {
|
||
const baseUrl = settings.openai_url.replace(/\/$/, '');
|
||
const targetEndpoint = `${baseUrl}/chat/completions`;
|
||
|
||
// Build message array: system, chat history, then instructions
|
||
const messages = [
|
||
{ role: 'system', content: systemMessage }
|
||
];
|
||
|
||
// Add chat history as proper user/assistant turns
|
||
for (const histMsg of chatHistoryMessages) {
|
||
messages.push({ role: histMsg.role, content: histMsg.content });
|
||
}
|
||
|
||
// Add final instruction as user message
|
||
messages.push({ role: 'user', content: instructionsPrompt });
|
||
|
||
const payload = {
|
||
model: settings.openai_model || 'local-model',
|
||
messages: messages,
|
||
temperature: 0.7,
|
||
max_tokens: calculatedMaxTokens,
|
||
stream: false
|
||
};
|
||
|
||
log(`Generating with OpenAI compatible: ${settings.openai_model}, messages: ${messages.length}`);
|
||
const response = await fetch(targetEndpoint, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(settings.openai_key ? { 'Authorization': `Bearer ${settings.openai_key}` } : {})
|
||
},
|
||
body: JSON.stringify(payload),
|
||
signal: abortController.signal
|
||
});
|
||
if (!response.ok) throw new Error(`API Error: ${response.status}`);
|
||
const data = await response.json();
|
||
result = extractTextFromResponse(data);
|
||
} else {
|
||
// Default ST generation using context - build message array like RPG Companion
|
||
const { generateRaw } = context;
|
||
if (generateRaw) {
|
||
// Build message array: system, chat history, then instructions
|
||
const messages = [
|
||
{ role: 'system', content: systemMessage }
|
||
];
|
||
|
||
// Add chat history as proper user/assistant turns
|
||
for (const histMsg of chatHistoryMessages) {
|
||
messages.push({ role: histMsg.role, content: histMsg.content });
|
||
}
|
||
|
||
// Add final instruction as user message
|
||
messages.push({ role: 'user', content: instructionsPrompt });
|
||
|
||
log(`Generating with ST generateRaw, messages: ${messages.length}`);
|
||
|
||
// Temporarily intercept fetch to capture the raw API response.
|
||
// This is needed because SillyTavern's generateRaw uses extractMessageFromData
|
||
// which calls .find() to get the FIRST type:'text' block. With Claude extended
|
||
// thinking, the first text block is just '\n\n' (empty), and the actual content
|
||
// is in a later text block. generateRaw then throws "No message generated".
|
||
// By capturing the raw response, we can extract the text ourselves on failure.
|
||
let capturedRawData = null;
|
||
const originalFetch = window.fetch;
|
||
window.fetch = async function (...args) {
|
||
const response = await originalFetch.apply(this, args);
|
||
try {
|
||
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
|
||
if (url.includes('/api/backends/chat-completions/generate') ||
|
||
url.includes('/api/backends/') && url.includes('/generate')) {
|
||
const clone = response.clone();
|
||
capturedRawData = await clone.json();
|
||
}
|
||
} catch (e) { /* ignore clone/parse errors */ }
|
||
return response;
|
||
};
|
||
|
||
try {
|
||
result = await generateRaw({ prompt: messages, quietToLoud: false });
|
||
|
||
// generateRaw's cleanUpMessage may mangle our output or return near-empty
|
||
// content when extended thinking is used (first text block is just '\n\n').
|
||
// Always check if captured raw data has more content.
|
||
if (capturedRawData) {
|
||
const rawExtracted = extractTextFromResponse(capturedRawData);
|
||
const rawTrimmed = rawExtracted?.trim() || '';
|
||
const resultTrimmed = result?.trim() || '';
|
||
if (rawTrimmed.length > resultTrimmed.length + 50) {
|
||
console.warn('[EchoChamber] generateRaw returned truncated/mangled result (' +
|
||
resultTrimmed.length + ' chars). Using raw API data instead (' + rawTrimmed.length + ' chars).');
|
||
result = rawExtracted;
|
||
}
|
||
}
|
||
} catch (genErr) {
|
||
if (genErr.message?.includes('No message generated') && capturedRawData) {
|
||
console.warn('[EchoChamber] generateRaw failed to parse response (likely extended thinking format). Extracting from raw API data.');
|
||
result = extractTextFromResponse(capturedRawData);
|
||
if (!result || !result.trim()) {
|
||
throw new Error('Could not extract text from API response');
|
||
}
|
||
} else {
|
||
throw genErr;
|
||
}
|
||
} finally {
|
||
window.fetch = originalFetch; // Always restore original fetch
|
||
}
|
||
} else {
|
||
throw new Error('generateRaw not available in context');
|
||
}
|
||
}
|
||
|
||
// Check if generation was aborted before parsing
|
||
if (abortController.signal.aborted || userCancelled) {
|
||
log('Generation was cancelled, skipping result parsing');
|
||
throw new Error('Generation cancelled by user');
|
||
}
|
||
|
||
// Safety: ensure result is a string before string operations
|
||
if (typeof result !== 'string') {
|
||
console.error('[EchoChamber] result is not a string after extraction! Type:', typeof result, 'Value:', result);
|
||
result = extractTextFromResponse(result) || String(result);
|
||
}
|
||
console.error('[EchoChamber DEBUG] Final result (first 200 chars):', result?.substring?.(0, 200));
|
||
|
||
// Parse result - strip thinking/reasoning tags and discordchat wrapper
|
||
let cleanResult = result
|
||
.replace(/<(thinking|think|thought|reasoning|reason)>[\s\S]*?<\/\1>/gi, '')
|
||
.replace(/<\/?discordchat>/gi, '')
|
||
.trim();
|
||
const lines = cleanResult.split('\n');
|
||
let htmlBuffer = '<div class="discord_container" style="padding-top: 10px;">';
|
||
let messageCount = 0;
|
||
let currentMsg = null;
|
||
let parsedMessages = [];
|
||
|
||
for (const line of lines) {
|
||
const trimmedLine = line.trim();
|
||
if (!trimmedLine) {
|
||
if (currentMsg && !currentMsg.content.endsWith('\n\n')) currentMsg.content += '\n\n';
|
||
continue;
|
||
}
|
||
if (/^[\.\…\-\_]+$/.test(trimmedLine)) continue;
|
||
|
||
// More flexible regex: matches "Name: Msg", "Name (Info): Msg", "@Name: Msg", etc.
|
||
// Captures everything before the LAST colon followed by optional space as the username
|
||
const match = trimmedLine.match(/^(?:[\d\.\-\*]*\s*)?(.+?):\s*(.+)$/);
|
||
if (match) {
|
||
let name = match[1].trim().replace(/[\*_\"`]/g, '');
|
||
// Limit displayed name to reasonable length
|
||
if (name.length > 40) name = name.substring(0, 40);
|
||
let content = match[2].trim();
|
||
currentMsg = { name, content };
|
||
parsedMessages.push(currentMsg);
|
||
} else if (currentMsg) {
|
||
currentMsg.content += ' ' + trimmedLine;
|
||
} else {
|
||
// Last resort: use entire line as content with generic name
|
||
currentMsg = { name: 'User', content: trimmedLine };
|
||
parsedMessages.push(currentMsg);
|
||
}
|
||
}
|
||
|
||
for (const msg of parsedMessages) {
|
||
if (messageCount >= userCount) break;
|
||
if (msg.content.trim().length < 2) continue;
|
||
htmlBuffer += formatMessage(msg.name, msg.content.trim());
|
||
messageCount++;
|
||
}
|
||
|
||
console.warn(`[EchoChamber] Parsed ${parsedMessages.length} messages, displayed ${messageCount}/${userCount}`);
|
||
log(`Parsed ${parsedMessages.length} messages, displayed ${messageCount}/${userCount}`);
|
||
|
||
htmlBuffer += '</div>';
|
||
setStatus('');
|
||
|
||
if (messageCount === 0) {
|
||
setDiscordText('<div class=\"discord_status\">No valid chat lines generated.</div>');
|
||
} else {
|
||
// Use livestream queue only for background auto-generation (not explicit regen)
|
||
if (settings.livestream && !showOverlay) {
|
||
// Parse individual messages for livestream queue
|
||
const messages = parseLivestreamMessages(htmlBuffer);
|
||
console.warn('[EchoChamber] Livestream mode: queuing', messages.length, 'messages for display');
|
||
log('Livestream mode: queuing', messages.length, 'messages');
|
||
|
||
// Save to metadata for persistence - save full html and mark as incomplete
|
||
const lastMsgIndex = chat.length - 1;
|
||
const updatedCommentaries = { ...(messageCommentaries || {}) };
|
||
updatedCommentaries[lastMsgIndex] = cleanResult;
|
||
saveGeneratedCommentary('', updatedCommentaries, htmlBuffer, false);
|
||
|
||
// Start livestream display — new messages will prepend on top of any
|
||
// existing panel content (previous turns remain visible below new ones).
|
||
startLivestream(messages);
|
||
|
||
// Generation phase complete. Switch indicator from orange (loading)
|
||
// back to red (live/active). The queue will continue to drip messages.
|
||
updateLiveIndicator();
|
||
} else {
|
||
// Regular mode OR explicit regen in livestream mode: display all at once.
|
||
// If livestream was running, stop it cleanly first so the queue doesn't interfere.
|
||
if (settings.livestream) stopLivestream();
|
||
|
||
setDiscordText(htmlBuffer);
|
||
|
||
// Save to metadata for persistence
|
||
const lastMsgIndex = chat.length - 1;
|
||
const updatedCommentaries = { ...(messageCommentaries || {}) };
|
||
updatedCommentaries[lastMsgIndex] = cleanResult;
|
||
saveGeneratedCommentary(htmlBuffer, updatedCommentaries);
|
||
}
|
||
}
|
||
|
||
// Mark generation as complete
|
||
isGenerating = false;
|
||
updateReplyButtonState(false);
|
||
|
||
} catch (err) {
|
||
// Mark generation as complete (even on error)
|
||
isGenerating = false;
|
||
updateReplyButtonState(false);
|
||
// Only clear status overlay if we were using it (non-livestream or explicit overlay)
|
||
if (!settings.livestream || showOverlay) setStatus('');
|
||
if (settings.livestream && !showOverlay) updateLiveIndicator();
|
||
const isAbort = err.name === 'AbortError' || err.message?.includes('aborted') || userCancelled;
|
||
if (isAbort || userCancelled) {
|
||
// User cancelled - show toast notification, keep previous content
|
||
if (typeof toastr !== 'undefined') {
|
||
toastr.info('Generation cancelled', 'EchoChamber');
|
||
}
|
||
log('Generation cancelled by user');
|
||
} else {
|
||
// Actual error occurred - show error toast, keep previous content
|
||
error('Generation failed:', err);
|
||
if (typeof toastr !== 'undefined') {
|
||
toastr.error(err.message || 'Unknown error occurred', 'EchoChamber Generation Error');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// PROMPT LOADING
|
||
// ============================================================
|
||
|
||
let promptCache = {};
|
||
const STYLE_FILES = {
|
||
'twitch': 'discordtwitch.md', 'verbose': 'thoughtfulverbose.md', 'twitter': 'twitterx.md', 'news': 'breakingnews.md',
|
||
'mst3k': 'mst3k.md', 'nsfw_ava': 'nsfwava.md', 'nsfw_kai': 'nsfwkai.md', 'hypebot': 'hypebot.md',
|
||
'doomscrollers': 'doomscrollers.md', 'darkroast': 'darkroast.md', 'dumbanddumber': 'dumbanddumber.md', 'ao3wattpad': 'ao3wattpad.md'
|
||
};
|
||
const BUILT_IN_STYLES = [
|
||
{ val: 'twitch', label: 'Discord / Twitch' }, { val: 'verbose', label: 'Thoughtful' },
|
||
{ val: 'twitter', label: 'Twitter / X' }, { val: 'news', label: 'Breaking News' },
|
||
{ val: 'mst3k', label: 'MST3K' }, { val: 'nsfw_ava', label: 'Ava NSFW' },
|
||
{ val: 'nsfw_kai', label: 'Kai NSFW' }, { val: 'hypebot', label: 'HypeBot' },
|
||
{ val: 'doomscrollers', label: 'Doomscrollers' }, { val: 'darkroast', label: 'Dark Roast' },
|
||
{ val: 'dumbanddumber', label: 'Dumb & Dumber' },
|
||
{ val: 'ao3wattpad', label: 'AO3 / Wattpad' }
|
||
];
|
||
|
||
// Default order: built-ins with nsfw_ava and nsfw_kai moved to the bottom (just above custom styles)
|
||
const DEFAULT_STYLE_ORDER = [
|
||
'twitch', 'verbose', 'twitter', 'news', 'mst3k',
|
||
'hypebot', 'doomscrollers', 'darkroast', 'dumbanddumber', 'ao3wattpad',
|
||
'nsfw_ava', 'nsfw_kai'
|
||
];
|
||
|
||
function getAllStyles() {
|
||
// Build a map of all available style id -> style object
|
||
const styleMap = {};
|
||
BUILT_IN_STYLES.forEach(s => { styleMap[s.val] = s; });
|
||
if (settings.custom_styles) {
|
||
Object.keys(settings.custom_styles).forEach(id => {
|
||
styleMap[id] = { val: id, label: settings.custom_styles[id].name };
|
||
});
|
||
}
|
||
|
||
// Determine the order to use
|
||
const savedOrder = Array.isArray(settings.style_order) && settings.style_order.length
|
||
? settings.style_order
|
||
: DEFAULT_STYLE_ORDER;
|
||
|
||
// Start with styles that appear in the saved order (skip deleted)
|
||
const result = [];
|
||
const seen = new Set();
|
||
for (const id of savedOrder) {
|
||
if (styleMap[id] && !(settings.deleted_styles && settings.deleted_styles.includes(id))) {
|
||
result.push(styleMap[id]);
|
||
seen.add(id);
|
||
}
|
||
}
|
||
|
||
// Append any remaining styles not yet in the order (e.g. newly added custom styles)
|
||
for (const id of Object.keys(styleMap)) {
|
||
if (!seen.has(id) && !(settings.deleted_styles && settings.deleted_styles.includes(id))) {
|
||
result.push(styleMap[id]);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// Reads the current visual order from the style editor list and saves it to settings.
|
||
function saveStyleOrder() {
|
||
const order = [];
|
||
jQuery('#ec_style_list .ec_style_item').each(function () {
|
||
const id = jQuery(this).data('id');
|
||
if (id) order.push(id);
|
||
});
|
||
if (order.length) {
|
||
settings.style_order = order;
|
||
saveSettings();
|
||
}
|
||
}
|
||
|
||
// Resolves SillyTavern macro tokens in a style prompt string.
|
||
// Called fresh every time a style is loaded so {{user}}/{{char}} always
|
||
// reflect the currently active persona and character.
|
||
function resolveMacros(text) {
|
||
if (!text) return text;
|
||
try {
|
||
const ctx = SillyTavern.getContext();
|
||
const userName = ctx.name1 || 'User';
|
||
const charName = ctx.characterName || ctx.name2 || 'Character';
|
||
return text
|
||
.replace(/\{\{user\}\}/gi, userName)
|
||
.replace(/\{\{char\}\}/gi, charName);
|
||
} catch (e) {
|
||
return text;
|
||
}
|
||
}
|
||
|
||
async function loadChatStyle(style) {
|
||
let prompt;
|
||
if (settings.custom_styles && settings.custom_styles[style]) {
|
||
prompt = settings.custom_styles[style].prompt;
|
||
} else if (promptCache[style]) {
|
||
prompt = promptCache[style];
|
||
} else {
|
||
const filename = STYLE_FILES[style] || 'discordtwitch.md';
|
||
try {
|
||
const response = await fetch(`${BASE_URL}/chat-styles/${filename}?v=${Date.now()}`);
|
||
if (!response.ok) throw new Error('Fetch failed');
|
||
prompt = await response.text();
|
||
promptCache[style] = prompt; // cache raw text; macros resolved at call time
|
||
} catch (e) {
|
||
warn('Failed to load style:', style, e);
|
||
prompt = `Generate chat messages. Output: username: message`;
|
||
}
|
||
}
|
||
return resolveMacros(prompt);
|
||
}
|
||
|
||
// ============================================================
|
||
// SETTINGS MANAGEMENT
|
||
// ============================================================
|
||
|
||
function saveSettings() {
|
||
const context = SillyTavern.getContext();
|
||
// Preserve chatMetadata when saving settings
|
||
const existingMetadata = context.extensionSettings[MODULE_NAME]?.chatMetadata;
|
||
|
||
// Create a clean copy of settings without chatMetadata
|
||
const settingsToSave = Object.assign({}, settings);
|
||
delete settingsToSave.chatMetadata;
|
||
|
||
context.extensionSettings[MODULE_NAME] = settingsToSave;
|
||
if (existingMetadata) {
|
||
context.extensionSettings[MODULE_NAME].chatMetadata = existingMetadata;
|
||
}
|
||
context.saveSettingsDebounced();
|
||
}
|
||
|
||
function loadSettings() {
|
||
const context = SillyTavern.getContext();
|
||
|
||
if (!context.extensionSettings[MODULE_NAME]) {
|
||
context.extensionSettings[MODULE_NAME] = JSON.parse(JSON.stringify(defaultSettings));
|
||
}
|
||
|
||
// Don't copy chatMetadata into settings - it should stay in extensionSettings only
|
||
const savedSettings = Object.assign({}, context.extensionSettings[MODULE_NAME]);
|
||
delete savedSettings.chatMetadata;
|
||
|
||
settings = Object.assign({}, defaultSettings, savedSettings);
|
||
settings.userCount = parseInt(settings.userCount) || 5;
|
||
settings.opacity = parseInt(settings.opacity) || 85;
|
||
|
||
// Update UI
|
||
jQuery('#discord_enabled').prop('checked', settings.enabled);
|
||
jQuery('#discord_user_count').val(settings.userCount);
|
||
jQuery('#discord_source').val(settings.source);
|
||
jQuery('#discord_url').val(settings.url);
|
||
jQuery('#discord_openai_url').val(settings.openai_url);
|
||
jQuery('#discord_openai_key').val(settings.openai_key);
|
||
jQuery('#discord_openai_model').val(settings.openai_model);
|
||
jQuery('#discord_openai_preset').val(settings.openai_preset || 'custom');
|
||
jQuery('#discord_preset_select').val(settings.preset || '');
|
||
jQuery('#discord_font_size').val(settings.fontSize || 15);
|
||
jQuery('#discord_position').val(settings.position || 'bottom');
|
||
jQuery('#discord_style').val(settings.style || 'twitch');
|
||
jQuery('#discord_opacity').val(settings.opacity);
|
||
jQuery('#discord_opacity_val').text(settings.opacity + '%');
|
||
jQuery('#discord_auto_update').prop('checked', settings.autoUpdateOnMessages !== false);
|
||
jQuery('#discord_include_user').prop('checked', settings.includeUserInput);
|
||
jQuery('#discord_context_depth').val(settings.contextDepth || 4);
|
||
jQuery('#discord_include_past_echo').prop('checked', settings.includePastEchoChambers || false);
|
||
jQuery('#discord_include_persona').prop('checked', settings.includePersona || false);
|
||
jQuery('#discord_include_authors_note').prop('checked', settings.includeAuthorsNote || false);
|
||
jQuery('#discord_include_character_description').prop('checked', settings.includeCharacterDescription || false);
|
||
jQuery('#discord_include_summary').prop('checked', settings.includeSummary || false);
|
||
jQuery('#discord_include_world_info').prop('checked', settings.includeWorldInfo || false);
|
||
jQuery('#discord_wi_budget').val(settings.wiBudget || 0);
|
||
jQuery('#discord_wi_budget_container').toggle(settings.includeWorldInfo || false);
|
||
|
||
// Livestream settings
|
||
jQuery('#discord_livestream').prop('checked', settings.livestream || false);
|
||
jQuery('#discord_livestream_batch_size').val(settings.livestreamBatchSize || 20);
|
||
jQuery('#discord_livestream_min_wait').val(settings.livestreamMinWait || 5);
|
||
jQuery('#discord_livestream_max_wait').val(settings.livestreamMaxWait || 60);
|
||
jQuery('#discord_livestream_settings').toggle(settings.livestream || false);
|
||
|
||
// Set livestream mode radio button
|
||
const livestreamMode = settings.livestreamMode || 'manual';
|
||
if (livestreamMode === 'manual') {
|
||
jQuery('#discord_livestream_manual').prop('checked', true);
|
||
} else if (livestreamMode === 'onMessage') {
|
||
jQuery('#discord_livestream_onmessage').prop('checked', true);
|
||
} else {
|
||
jQuery('#discord_livestream_oncomplete').prop('checked', true);
|
||
}
|
||
|
||
// Show/hide context depth based on include user input setting
|
||
jQuery('#discord_context_depth_container').toggle(settings.includeUserInput);
|
||
|
||
// Chat Participation settings
|
||
jQuery('#discord_chat_enabled').prop('checked', settings.chatEnabled !== false);
|
||
jQuery('#discord_chat_username').val(settings.chatUsername || 'Streamer (You)');
|
||
jQuery('#discord_chat_avatar_color').val(settings.chatAvatarColor || '#3b82f6');
|
||
jQuery('#discord_chat_reply_count').val(settings.chatReplyCount || 3);
|
||
jQuery('.ec_reply_container').toggle(settings.chatEnabled !== false);
|
||
applyAvatarColor(settings.chatAvatarColor || '#3b82f6');
|
||
|
||
applyFontSize(settings.fontSize || 15);
|
||
updateSourceVisibility();
|
||
updateAllDropdowns();
|
||
|
||
if (discordBar) {
|
||
updateApplyLayout();
|
||
updateToggleIcon();
|
||
}
|
||
}
|
||
|
||
function updateSourceVisibility() {
|
||
jQuery('#discord_ollama_settings').hide();
|
||
jQuery('#discord_openai_settings').hide();
|
||
jQuery('#discord_profile_settings').hide();
|
||
|
||
const source = settings.source || 'default';
|
||
if (source === 'ollama') jQuery('#discord_ollama_settings').show();
|
||
else if (source === 'openai') jQuery('#discord_openai_settings').show();
|
||
else if (source === 'profile') jQuery('#discord_profile_settings').show();
|
||
}
|
||
|
||
function updateAllDropdowns() {
|
||
const styles = getAllStyles();
|
||
|
||
// Update settings panel dropdown
|
||
const sSelect = jQuery('#discord_style');
|
||
const currentVal = sSelect.val();
|
||
sSelect.empty();
|
||
styles.forEach(s => sSelect.append(`<option value="${s.val}">${s.label}</option>`));
|
||
sSelect.val(currentVal || settings.style);
|
||
|
||
// Update QuickBar style menu if exists
|
||
const styleMenu = jQuery('.ec_style_menu');
|
||
if (styleMenu.length) {
|
||
populateStyleMenu(styleMenu);
|
||
}
|
||
|
||
// Populate connection profiles dropdown
|
||
populateConnectionProfiles();
|
||
}
|
||
|
||
function populateConnectionProfiles() {
|
||
const select = jQuery('#discord_preset_select');
|
||
if (!select.length) return;
|
||
|
||
select.empty();
|
||
select.append('<option value="">-- Select Profile --</option>');
|
||
|
||
try {
|
||
const context = SillyTavern.getContext();
|
||
const connectionManager = context.extensionSettings?.connectionManager;
|
||
|
||
if (connectionManager?.profiles?.length) {
|
||
connectionManager.profiles.forEach(profile => {
|
||
const isSelected = settings.preset === profile.name ? ' selected' : '';
|
||
select.append(`<option value="${profile.name}"${isSelected}>${profile.name}</option>`);
|
||
});
|
||
log(`Loaded ${connectionManager.profiles.length} connection profiles`);
|
||
} else {
|
||
select.append('<option value="" disabled>No profiles found</option>');
|
||
log('No connection profiles available');
|
||
}
|
||
} catch (err) {
|
||
warn('Error loading connection profiles:', err);
|
||
select.append('<option value="" disabled>Error loading profiles</option>');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// STYLE EDITOR MODAL
|
||
// ============================================================
|
||
|
||
let styleEditorModal = null;
|
||
let currentEditingStyle = null;
|
||
|
||
function createStyleEditorModal() {
|
||
if (jQuery('#ec_style_editor_modal').length) return;
|
||
|
||
const modalHtml = `
|
||
<div id="ec_style_editor_modal" class="ec_modal_overlay">
|
||
<div class="ec_modal_content">
|
||
<div class="ec_modal_header">
|
||
<h3><i class="fa-solid fa-palette"></i> Style Editor</h3>
|
||
<button class="ec_modal_close" id="ec_style_editor_close">×</button>
|
||
</div>
|
||
<div class="ec_modal_body">
|
||
<div class="ec_style_sidebar">
|
||
<div class="ec_style_sidebar_header">
|
||
<button class="menu_button ec_btn_new_style" id="ec_style_new" title="Create New Style">
|
||
<i class="fa-solid fa-plus"></i> <span>New</span>
|
||
</button>
|
||
</div>
|
||
<div class="ec_style_list" id="ec_style_list"></div>
|
||
<div class="ec_style_order_hint"><i class="fa-solid fa-grip-vertical"></i> Drag to reorder</div>
|
||
</div>
|
||
<div class="ec_style_main" id="ec_style_main">
|
||
<div class="ec_empty_state">
|
||
<i class="fa-solid fa-palette"></i>
|
||
<div>Select a style to edit or create a new one</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ec_modal_footer">
|
||
<div class="ec_modal_footer_left">
|
||
<button class="menu_button ec_btn_danger" id="ec_style_delete" style="display:none;">
|
||
<i class="fa-solid fa-trash"></i> Delete
|
||
</button>
|
||
<button class="menu_button ec_btn_export" id="ec_style_export" style="display:none;">
|
||
<i class="fa-solid fa-download"></i> Export
|
||
</button>
|
||
</div>
|
||
<div class="ec_modal_footer_right">
|
||
<button class="menu_button ec_btn_cancel" id="ec_style_cancel">
|
||
<i class="fa-solid fa-xmark"></i> Cancel
|
||
</button>
|
||
<button class="menu_button ec_btn_save" id="ec_style_save" style="display:none;">
|
||
<i class="fa-solid fa-floppy-disk"></i> Save
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
jQuery('body').append(modalHtml);
|
||
styleEditorModal = jQuery('#ec_style_editor_modal');
|
||
|
||
// Bind events
|
||
jQuery('#ec_style_editor_close, #ec_style_cancel').on('click', closeStyleEditor);
|
||
jQuery('#ec_style_new').on('click', createNewStyle);
|
||
jQuery('#ec_style_save').on('click', saveStyleFromEditor);
|
||
jQuery('#ec_style_delete').on('click', deleteStyleFromEditor);
|
||
jQuery('#ec_style_export').on('click', () => exportStyle(currentEditingStyle));
|
||
|
||
// Close on overlay click
|
||
styleEditorModal.on('click', function (e) {
|
||
if (e.target === this) closeStyleEditor();
|
||
});
|
||
}
|
||
|
||
function openStyleEditor() {
|
||
createStyleEditorModal();
|
||
populateStyleList();
|
||
currentEditingStyle = null;
|
||
showEmptyState();
|
||
styleEditorModal.addClass('active');
|
||
}
|
||
|
||
function closeStyleEditor() {
|
||
if (styleEditorModal) {
|
||
styleEditorModal.removeClass('active');
|
||
}
|
||
currentEditingStyle = null;
|
||
updateAllDropdowns();
|
||
}
|
||
|
||
function populateStyleList() {
|
||
const list = jQuery('#ec_style_list');
|
||
list.empty();
|
||
|
||
const styles = getAllStyles();
|
||
const { DOMPurify } = SillyTavern.libs;
|
||
|
||
styles.forEach(style => {
|
||
const isCustom = settings.custom_styles && settings.custom_styles[style.val];
|
||
const typeClass = isCustom ? 'custom' : 'builtin';
|
||
const icon = isCustom ? 'fa-user' : 'fa-cube';
|
||
|
||
// Sanitize style label to prevent XSS
|
||
const safeLabel = DOMPurify.sanitize(style.label, { ALLOWED_TAGS: [] });
|
||
const safeVal = DOMPurify.sanitize(style.val, { ALLOWED_TAGS: [] });
|
||
|
||
const item = jQuery(`
|
||
<div class="ec_style_item ${typeClass}" data-id="${safeVal}" draggable="true">
|
||
<span class="ec_drag_handle" title="Drag to reorder"><i class="fa-solid fa-grip-vertical"></i></span>
|
||
<i class="fa-solid ${icon} ec_style_type_icon"></i>
|
||
<span class="ec_style_label">${safeLabel}</span>
|
||
</div>
|
||
`);
|
||
|
||
// Click on the item (but not the drag handle) selects it
|
||
item.on('click', function (e) {
|
||
if (!jQuery(e.target).closest('.ec_drag_handle').length) {
|
||
selectStyleInEditor(style.val);
|
||
}
|
||
});
|
||
|
||
list.append(item);
|
||
});
|
||
|
||
// --- Drag-and-drop reordering ---
|
||
let dragSrc = null;
|
||
|
||
list.find('.ec_style_item').each(function () {
|
||
const el = this;
|
||
|
||
el.addEventListener('dragstart', function (e) {
|
||
dragSrc = el;
|
||
el.classList.add('ec_dragging');
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.dataTransfer.setData('text/plain', el.dataset.id);
|
||
});
|
||
|
||
el.addEventListener('dragend', function () {
|
||
el.classList.remove('ec_dragging');
|
||
list.find('.ec_style_item').removeClass('ec_drag_over');
|
||
saveStyleOrder();
|
||
// Refresh the dropdowns so the new order is reflected immediately
|
||
updateAllDropdowns();
|
||
});
|
||
|
||
el.addEventListener('dragover', function (e) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
if (el !== dragSrc) {
|
||
list.find('.ec_style_item').removeClass('ec_drag_over');
|
||
el.classList.add('ec_drag_over');
|
||
}
|
||
});
|
||
|
||
el.addEventListener('dragleave', function () {
|
||
el.classList.remove('ec_drag_over');
|
||
});
|
||
|
||
el.addEventListener('drop', function (e) {
|
||
e.preventDefault();
|
||
el.classList.remove('ec_drag_over');
|
||
if (!dragSrc || dragSrc === el) return;
|
||
|
||
// Insert dragSrc before or after this element based on pointer position
|
||
const rect = el.getBoundingClientRect();
|
||
const midY = rect.top + rect.height / 2;
|
||
if (e.clientY < midY) {
|
||
list[0].insertBefore(dragSrc, el);
|
||
} else {
|
||
list[0].insertBefore(dragSrc, el.nextSibling);
|
||
}
|
||
|
||
// Restore active state if needed
|
||
if (currentEditingStyle) {
|
||
jQuery('.ec_style_item').removeClass('active');
|
||
jQuery(`.ec_style_item[data-id="${currentEditingStyle}"]`).addClass('active');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function showEmptyState() {
|
||
jQuery('#ec_style_main').html(`
|
||
<div class="ec_empty_state">
|
||
<i class="fa-solid fa-palette"></i>
|
||
<div>Select a style to edit or create a new one</div>
|
||
</div>
|
||
`);
|
||
jQuery('#ec_style_save, #ec_style_delete, #ec_style_export').hide();
|
||
}
|
||
|
||
async function selectStyleInEditor(styleId) {
|
||
currentEditingStyle = styleId;
|
||
|
||
// Update sidebar selection
|
||
jQuery('.ec_style_item').removeClass('active');
|
||
jQuery(`.ec_style_item[data-id="${styleId}"]`).addClass('active');
|
||
|
||
const isCustom = settings.custom_styles && settings.custom_styles[styleId];
|
||
const style = getAllStyles().find(s => s.val === styleId);
|
||
const styleName = style ? style.label : styleId;
|
||
|
||
// Load content (raw, before macro substitution so the editor shows the original tokens)
|
||
let content = '';
|
||
if (isCustom) {
|
||
content = settings.custom_styles[styleId].prompt || '';
|
||
} else {
|
||
// Load raw from cache/fetch without resolving macros so tokens stay visible in editor
|
||
const filename = STYLE_FILES[styleId] || 'discordtwitch.md';
|
||
try {
|
||
if (promptCache[styleId]) {
|
||
content = promptCache[styleId];
|
||
} else {
|
||
const resp = await fetch(`${BASE_URL}/chat-styles/${filename}?v=${Date.now()}`);
|
||
content = resp.ok ? await resp.text() : '';
|
||
if (content) promptCache[styleId] = content;
|
||
}
|
||
} catch (e) {
|
||
content = '';
|
||
}
|
||
}
|
||
|
||
// Resolve macro preview values for the hint bar
|
||
const ctx = SillyTavern.getContext();
|
||
const previewUser = ctx.name1 || 'User';
|
||
const previewChar = ctx.characterName || ctx.name2 || 'Character';
|
||
|
||
// Escape styleName for safe HTML insertion
|
||
const safeStyleName = styleName.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||
|
||
// Render editor (textarea content set separately to avoid HTML injection issues)
|
||
jQuery('#ec_style_main').html(`
|
||
<div class="ec_style_name_row">
|
||
<input type="text" class="ec_style_name_input" id="ec_style_name"
|
||
value="${safeStyleName}" placeholder="Style Name" ${!isCustom ? 'readonly' : ''}>
|
||
${!isCustom ? '<small style="opacity:0.6;">(Built-in styles cannot be renamed)</small>' : ''}
|
||
</div>
|
||
<textarea class="ec_style_textarea" id="ec_style_content"
|
||
placeholder="Enter the prompt/instructions for this style..."></textarea>
|
||
<div style="font-size:0.75em; opacity:0.65; margin-top:6px; padding:6px 8px; background:rgba(0,0,0,0.15); border-radius:4px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
|
||
<span><i class="fa-solid fa-code" style="margin-right:4px;"></i><b>Macros:</b></span>
|
||
<span><code style="background:rgba(255,255,255,0.08); padding:1px 5px; border-radius:3px;">{{user}}</code> → <em>${previewUser}</em></span>
|
||
<span><code style="background:rgba(255,255,255,0.08); padding:1px 5px; border-radius:3px;">{{char}}</code> → <em>${previewChar}</em></span>
|
||
</div>
|
||
`);
|
||
|
||
// Set textarea content safely (avoids HTML parsing issues with special characters)
|
||
jQuery('#ec_style_content').val(content);
|
||
|
||
// Show appropriate buttons
|
||
jQuery('#ec_style_save, #ec_style_export').show();
|
||
jQuery('#ec_style_delete').toggle(!!isCustom);
|
||
}
|
||
|
||
// ============================================================
|
||
// TEMPLATE CREATOR MODAL
|
||
// ============================================================
|
||
|
||
let templateCreatorModal = null;
|
||
|
||
const defaultAdvancedTemplate = `You will be acting as a chat feed audience. Your goal is to simulate messages reacting to the unfolding events.
|
||
|
||
<usernames>
|
||
- Generate NEW random usernames each time
|
||
- Make them creative and varied
|
||
- Align them with the conversation context
|
||
</usernames>
|
||
|
||
<personalities>
|
||
- Mix different personality types and reactions
|
||
- Include enthusiasts, skeptics, comedians, and analysts
|
||
- Vary the tone and engagement level
|
||
</personalities>
|
||
|
||
<style>
|
||
- Keep messages short and natural
|
||
- React to events as they happen
|
||
- Use platform-appropriate language and emojis
|
||
</style>
|
||
|
||
<interactions>
|
||
- Users may respond to each other
|
||
- Reference what others said
|
||
- Create natural conversation flow
|
||
</interactions>
|
||
|
||
You must format your responses using the following format:
|
||
<format>
|
||
username: message
|
||
</format>
|
||
`;
|
||
|
||
function createTemplateCreatorModal() {
|
||
if (jQuery('#ec_template_creator_modal').length) return;
|
||
|
||
const modalHtml = `
|
||
<div id="ec_template_creator_modal" class="ec_modal_overlay">
|
||
<div class="ec_modal_content ec_template_creator">
|
||
<div class="ec_modal_header">
|
||
<h3><i class="fa-solid fa-wand-magic-sparkles"></i> Create New Style</h3>
|
||
<button class="ec_modal_close" id="ec_template_close">×</button>
|
||
</div>
|
||
<div class="ec_template_tabs">
|
||
<button class="ec_tab_btn active" data-tab="easy"><i class="fa-solid fa-magic"></i> Easy Mode</button>
|
||
<button class="ec_tab_btn" data-tab="advanced"><i class="fa-solid fa-code"></i> Advanced</button>
|
||
</div>
|
||
<div class="ec_modal_body ec_template_body">
|
||
<!-- Easy Mode -->
|
||
<div class="ec_tab_content active" data-tab="easy">
|
||
<div class="ec_form_group">
|
||
<label>Style Name</label>
|
||
<input type="text" id="ec_tpl_name" placeholder="My Custom Chat" />
|
||
</div>
|
||
<div class="ec_form_group">
|
||
<label>Style Type</label>
|
||
<select id="ec_tpl_type">
|
||
<option value="chat">Chat (Multiple Users)</option>
|
||
<option value="narrator">Narrator (Single Voice)</option>
|
||
</select>
|
||
</div>
|
||
<div class="ec_form_group">
|
||
<label>Output Format</label>
|
||
<input type="text" id="ec_tpl_format" placeholder="username: message" value="username: message" />
|
||
<small>How each message should be formatted</small>
|
||
</div>
|
||
<div class="ec_form_group">
|
||
<label>Identity / Setting</label>
|
||
<textarea id="ec_tpl_identity" rows="2" placeholder="Who are the participants? What's the context?"></textarea>
|
||
<small>e.g., "Discord users reacting live to events" or "A sarcastic AI commentator"</small>
|
||
</div>
|
||
<div class="ec_form_group">
|
||
<label>Personality Guidelines</label>
|
||
<textarea id="ec_tpl_personality" rows="3" placeholder="Describe the tone, vocabulary, and behavior"></textarea>
|
||
<small>e.g., "Chaotic, uses emojis, internet slang, varying excitement levels"</small>
|
||
</div>
|
||
<div class="ec_form_group">
|
||
<label>Tone</label>
|
||
<select id="ec_tpl_tone">
|
||
<option value="custom">Custom (enter below)</option>
|
||
<option value="chaotic">Chaotic / Energetic</option>
|
||
<option value="calm">Calm / Thoughtful</option>
|
||
<option value="sarcastic">Sarcastic / Witty</option>
|
||
<option value="wholesome">Wholesome / Supportive</option>
|
||
<option value="cynical">Cynical / Tired</option>
|
||
<option value="explicit">Explicit / NSFW</option>
|
||
</select>
|
||
<input type="text" id="ec_tpl_custom_tone" placeholder="Enter your custom tone description..." style="margin-top: 8px;" />
|
||
</div>
|
||
<div class="ec_form_row">
|
||
<div class="ec_form_group">
|
||
<label>Message Length</label>
|
||
<select id="ec_tpl_length">
|
||
<option value="short">Short (1-2 sentences)</option>
|
||
<option value="medium">Medium (2-3 sentences)</option>
|
||
<option value="long">Long (paragraphs)</option>
|
||
</select>
|
||
</div>
|
||
<div class="ec_form_group">
|
||
<label>User Interactions</label>
|
||
<select id="ec_tpl_interact">
|
||
<option value="yes">Users respond to each other</option>
|
||
<option value="no">Independent messages</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="ec_form_group">
|
||
<label>Style Elements (select all that apply)</label>
|
||
<div class="ec_checkbox_row">
|
||
<label><input type="checkbox" id="ec_tpl_emoji" checked /> Emojis</label>
|
||
<label><input type="checkbox" id="ec_tpl_slang" checked /> Internet Slang</label>
|
||
<label><input type="checkbox" id="ec_tpl_lowercase" /> Lowercase preferred</label>
|
||
<label><input type="checkbox" id="ec_tpl_typos" /> Occasional typos</label>
|
||
</div>
|
||
<div class="ec_checkbox_row" style="margin-top: 8px;">
|
||
<label><input type="checkbox" id="ec_tpl_allcaps" /> ALL CAPS moments</label>
|
||
<label><input type="checkbox" id="ec_tpl_hashtags" /> Hashtags</label>
|
||
<label><input type="checkbox" id="ec_tpl_mentions" /> @mentions</label>
|
||
<label><input type="checkbox" id="ec_tpl_formal" /> Formal grammar</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Advanced Mode -->
|
||
<div class="ec_tab_content" data-tab="advanced">
|
||
<div class="ec_form_group">
|
||
<label>Style Name</label>
|
||
<input type="text" id="ec_tpl_adv_name" placeholder="My Custom Chat" />
|
||
</div>
|
||
<div class="ec_form_group ec_full_height">
|
||
<label>System Prompt</label>
|
||
<div class="ec_prompt_actions">
|
||
<button class="menu_button ec_small_btn" id="ec_tpl_copy"><i class="fa-solid fa-copy"></i> Copy</button>
|
||
<button class="menu_button ec_small_btn" id="ec_tpl_paste"><i class="fa-solid fa-paste"></i> Paste</button>
|
||
<button class="menu_button ec_small_btn" id="ec_tpl_clear"><i class="fa-solid fa-eraser"></i> Clear</button>
|
||
<button class="menu_button ec_small_btn" id="ec_tpl_reset"><i class="fa-solid fa-rotate-left"></i> Reset</button>
|
||
</div>
|
||
<textarea id="ec_tpl_adv_prompt" placeholder="Write your complete system prompt here..."></textarea>
|
||
<small>The extension will prepend "Generate X messages" based on user count setting.</small>
|
||
<div style="font-size:0.75em; opacity:0.65; margin-top:6px; padding:6px 8px; background:rgba(0,0,0,0.15); border-radius:4px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
|
||
<span><i class="fa-solid fa-code" style="margin-right:4px;"></i><b>Macros:</b></span>
|
||
<span><code style="background:rgba(255,255,255,0.08); padding:1px 5px; border-radius:3px;">{{user}}</code> → <em id="ec_tpl_adv_user_preview">?</em></span>
|
||
<span><code style="background:rgba(255,255,255,0.08); padding:1px 5px; border-radius:3px;">{{char}}</code> → <em id="ec_tpl_adv_char_preview">?</em></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ec_modal_footer">
|
||
<div class="ec_modal_footer_left"></div>
|
||
<div class="ec_modal_footer_right">
|
||
<button class="menu_button ec_btn_cancel" id="ec_template_cancel">
|
||
<i class="fa-solid fa-xmark"></i> Cancel
|
||
</button>
|
||
<button class="menu_button ec_btn_create" id="ec_template_create">
|
||
<i class="fa-solid fa-wand-magic-sparkles"></i> Create
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
jQuery('body').append(modalHtml);
|
||
templateCreatorModal = jQuery('#ec_template_creator_modal');
|
||
|
||
// Tab switching
|
||
templateCreatorModal.on('click', '.ec_tab_btn', function () {
|
||
const tab = jQuery(this).data('tab');
|
||
templateCreatorModal.find('.ec_tab_btn').removeClass('active');
|
||
templateCreatorModal.find('.ec_tab_content').removeClass('active');
|
||
jQuery(this).addClass('active');
|
||
templateCreatorModal.find(`.ec_tab_content[data-tab="${tab}"]`).addClass('active');
|
||
});
|
||
|
||
// Tone dropdown - show/hide custom input
|
||
templateCreatorModal.on('change', '#ec_tpl_tone', function () {
|
||
const isCustom = jQuery(this).val() === 'custom';
|
||
jQuery('#ec_tpl_custom_tone').toggle(isCustom);
|
||
if (isCustom) jQuery('#ec_tpl_custom_tone').focus();
|
||
});
|
||
|
||
// Advanced mode buttons
|
||
jQuery('#ec_tpl_clear').on('click', function () {
|
||
jQuery('#ec_tpl_adv_prompt').val('').focus();
|
||
});
|
||
|
||
jQuery('#ec_tpl_copy').on('click', async function () {
|
||
try {
|
||
const text = jQuery('#ec_tpl_adv_prompt').val();
|
||
await navigator.clipboard.writeText(text);
|
||
if (typeof toastr !== 'undefined') toastr.success('Prompt copied to clipboard');
|
||
} catch (err) {
|
||
if (typeof toastr !== 'undefined') toastr.error('Could not copy to clipboard');
|
||
}
|
||
});
|
||
|
||
jQuery('#ec_tpl_paste').on('click', async function () {
|
||
try {
|
||
const text = await navigator.clipboard.readText();
|
||
jQuery('#ec_tpl_adv_prompt').val(text);
|
||
} catch (err) {
|
||
if (typeof toastr !== 'undefined') toastr.error('Could not access clipboard');
|
||
}
|
||
});
|
||
|
||
jQuery('#ec_tpl_reset').on('click', function () {
|
||
jQuery('#ec_tpl_adv_prompt').val(defaultAdvancedTemplate);
|
||
});
|
||
|
||
// Close handlers
|
||
jQuery('#ec_template_close, #ec_template_cancel').on('click', closeTemplateCreator);
|
||
jQuery('#ec_template_create').on('click', createStyleFromTemplate);
|
||
|
||
templateCreatorModal.on('click', function (e) {
|
||
if (e.target === this) closeTemplateCreator();
|
||
});
|
||
}
|
||
|
||
function openTemplateCreator() {
|
||
createTemplateCreatorModal();
|
||
// Reset form
|
||
templateCreatorModal.find('input[type="text"], textarea').val('');
|
||
templateCreatorModal.find('select').each(function () {
|
||
this.selectedIndex = 0;
|
||
});
|
||
templateCreatorModal.find('input[type="checkbox"]').prop('checked', false);
|
||
jQuery('#ec_tpl_emoji, #ec_tpl_slang').prop('checked', true);
|
||
jQuery('#ec_tpl_format').val('username: message');
|
||
|
||
// Set tone to chaotic (not custom) and hide custom input
|
||
jQuery('#ec_tpl_tone').val('chaotic');
|
||
jQuery('#ec_tpl_custom_tone').hide().val('');
|
||
|
||
// Pre-populate Advanced mode with template
|
||
jQuery('#ec_tpl_adv_prompt').val(defaultAdvancedTemplate);
|
||
|
||
// Populate macro preview values with current character/persona names
|
||
try {
|
||
const ctx = SillyTavern.getContext();
|
||
jQuery('#ec_tpl_adv_user_preview').text(ctx.name1 || 'User');
|
||
jQuery('#ec_tpl_adv_char_preview').text(ctx.characterName || ctx.name2 || 'Character');
|
||
} catch (e) { /* context may not be ready */ }
|
||
|
||
// Reset to Easy tab
|
||
templateCreatorModal.find('.ec_tab_btn').removeClass('active').first().addClass('active');
|
||
templateCreatorModal.find('.ec_tab_content').removeClass('active').first().addClass('active');
|
||
|
||
templateCreatorModal.addClass('active');
|
||
}
|
||
|
||
function closeTemplateCreator() {
|
||
if (templateCreatorModal) templateCreatorModal.removeClass('active');
|
||
}
|
||
|
||
function createStyleFromTemplate() {
|
||
const activeTab = templateCreatorModal.find('.ec_tab_btn.active').data('tab');
|
||
let styleName, stylePrompt;
|
||
|
||
if (activeTab === 'advanced') {
|
||
// Advanced mode - use raw prompt
|
||
styleName = jQuery('#ec_tpl_adv_name').val().trim() || 'Custom Style';
|
||
stylePrompt = jQuery('#ec_tpl_adv_prompt').val().trim();
|
||
if (!stylePrompt) {
|
||
if (typeof toastr !== 'undefined') toastr.warning('Please enter a system prompt.');
|
||
return;
|
||
}
|
||
} else {
|
||
// Easy mode - build prompt from form
|
||
styleName = jQuery('#ec_tpl_name').val().trim() || 'Custom Style';
|
||
const type = jQuery('#ec_tpl_type').val();
|
||
const format = jQuery('#ec_tpl_format').val().trim() || 'username: message';
|
||
const identity = jQuery('#ec_tpl_identity').val().trim();
|
||
const personality = jQuery('#ec_tpl_personality').val().trim();
|
||
const toneSelect = jQuery('#ec_tpl_tone').val();
|
||
const customTone = jQuery('#ec_tpl_custom_tone').val().trim();
|
||
const length = jQuery('#ec_tpl_length').val();
|
||
const interact = jQuery('#ec_tpl_interact').val() === 'yes';
|
||
const useEmoji = jQuery('#ec_tpl_emoji').is(':checked');
|
||
const useSlang = jQuery('#ec_tpl_slang').is(':checked');
|
||
const useLowercase = jQuery('#ec_tpl_lowercase').is(':checked');
|
||
const useTypos = jQuery('#ec_tpl_typos').is(':checked');
|
||
const useAllCaps = jQuery('#ec_tpl_allcaps').is(':checked');
|
||
const useHashtags = jQuery('#ec_tpl_hashtags').is(':checked');
|
||
const useMentions = jQuery('#ec_tpl_mentions').is(':checked');
|
||
const useFormal = jQuery('#ec_tpl_formal').is(':checked');
|
||
|
||
// Build the prompt
|
||
const toneDescriptions = {
|
||
chaotic: 'chaotic, energetic, and excitable',
|
||
calm: 'calm, thoughtful, and reflective',
|
||
sarcastic: 'sarcastic, witty, and playfully mocking',
|
||
wholesome: 'wholesome, supportive, and kind',
|
||
cynical: 'cynical, tired, and darkly humorous',
|
||
explicit: 'explicit, unfiltered, and provocative'
|
||
};
|
||
|
||
const lengthDescriptions = {
|
||
short: '1-2 sentences maximum',
|
||
medium: '2-3 complete sentences',
|
||
long: '1-3 paragraphs with 3-5 sentences each'
|
||
};
|
||
|
||
// Get tone description - use custom if selected
|
||
const toneDescription = toneSelect === 'custom' && customTone
|
||
? customTone
|
||
: (toneDescriptions[toneSelect] || 'varied and natural');
|
||
|
||
// Build prompt with XML format
|
||
let prompt = '';
|
||
|
||
// Opening
|
||
if (identity) {
|
||
prompt += `${identity}\n\n`;
|
||
} else {
|
||
prompt += `You will be acting as a ${type === 'chat' ? 'chat feed audience' : 'narrator'}. Your goal is to simulate ${type === 'chat' ? 'messages' : 'commentary'} reacting to the unfolding events.\n\n`;
|
||
}
|
||
|
||
// Usernames section
|
||
if (type === 'chat') {
|
||
prompt += `<usernames>\n`;
|
||
prompt += `- Generate NEW random usernames each time\n`;
|
||
prompt += `- Make them creative, varied, and contextually appropriate\n`;
|
||
prompt += `- Align them with the conversation context\n`;
|
||
prompt += `</usernames>\n\n`;
|
||
}
|
||
|
||
// Personality section
|
||
if (personality) {
|
||
prompt += `<personalities>\n`;
|
||
prompt += `- ${personality}\n`;
|
||
prompt += `- Messages should be ${toneDescription}\n`;
|
||
prompt += `</personalities>\n\n`;
|
||
} else {
|
||
prompt += `<personalities>\n`;
|
||
prompt += `- Messages should be ${toneDescription}\n`;
|
||
prompt += `- Mix different personality types and reactions\n`;
|
||
prompt += `- Vary the tone and engagement level\n`;
|
||
prompt += `</personalities>\n\n`;
|
||
}
|
||
|
||
// Style section
|
||
const styleElements = [];
|
||
if (useEmoji) styleElements.push('Use emojis');
|
||
if (useSlang) styleElements.push('Use internet slang');
|
||
if (useLowercase) styleElements.push('Prefer lowercase');
|
||
if (useTypos) styleElements.push('Include occasional typos');
|
||
if (useAllCaps) styleElements.push('Use ALL CAPS for emphasis occasionally');
|
||
if (useHashtags) styleElements.push('Include hashtags');
|
||
if (useMentions) styleElements.push('Use @mentions between users');
|
||
if (useFormal) styleElements.push('Use proper grammar and punctuation');
|
||
styleElements.push(`Each message should be ${lengthDescriptions[length]}`);
|
||
|
||
prompt += `<style>\n`;
|
||
styleElements.forEach(element => prompt += `- ${element}\n`);
|
||
prompt += `</style>\n\n`;
|
||
|
||
// Interactions section
|
||
if (type === 'chat') {
|
||
prompt += `<interactions>\n`;
|
||
if (interact) {
|
||
prompt += `- Users may respond to each other\n`;
|
||
prompt += `- Users can agree, disagree, or build on previous comments\n`;
|
||
prompt += `- Reference what others said\n`;
|
||
} else {
|
||
prompt += `- Each message is independent\n`;
|
||
prompt += `- No direct replies between users\n`;
|
||
}
|
||
prompt += `</interactions>\n\n`;
|
||
}
|
||
|
||
// Format instruction at the end
|
||
prompt += `You must format your responses using the following format:\n`;
|
||
prompt += `<format>\n`;
|
||
prompt += `${format}\n`;
|
||
prompt += `</format>`;
|
||
|
||
stylePrompt = prompt.trim();
|
||
}
|
||
|
||
// Validate input types
|
||
if (typeof styleName !== 'string' || typeof stylePrompt !== 'string') {
|
||
if (typeof toastr !== 'undefined') toastr.error('Invalid input type');
|
||
return;
|
||
}
|
||
|
||
// Create the style
|
||
const id = 'custom_' + Date.now();
|
||
if (!settings.custom_styles) settings.custom_styles = {};
|
||
settings.custom_styles[id] = {
|
||
name: styleName,
|
||
prompt: stylePrompt
|
||
};
|
||
saveSettings();
|
||
|
||
closeTemplateCreator();
|
||
|
||
// Refresh style list and select new style
|
||
populateStyleList();
|
||
selectStyleInEditor(id);
|
||
|
||
// Sanitize style name for display
|
||
const { DOMPurify } = SillyTavern.libs;
|
||
const safeStyleName = DOMPurify.sanitize(styleName, { ALLOWED_TAGS: [] });
|
||
if (typeof toastr !== 'undefined') toastr.success(`Style "${safeStyleName}" created!`);
|
||
}
|
||
|
||
function createNewStyle() {
|
||
openTemplateCreator();
|
||
}
|
||
|
||
function saveStyleFromEditor() {
|
||
if (!currentEditingStyle) return;
|
||
|
||
const name = jQuery('#ec_style_name').val().trim();
|
||
const content = jQuery('#ec_style_content').val();
|
||
|
||
// Validate input types
|
||
if (typeof name !== 'string' || typeof content !== 'string') {
|
||
if (typeof toastr !== 'undefined') toastr.error('Invalid input type');
|
||
return;
|
||
}
|
||
|
||
if (!name) {
|
||
if (typeof toastr !== 'undefined') toastr.error('Style name cannot be empty');
|
||
return;
|
||
}
|
||
|
||
const isCustom = settings.custom_styles && settings.custom_styles[currentEditingStyle];
|
||
|
||
if (isCustom) {
|
||
// Update existing custom style
|
||
settings.custom_styles[currentEditingStyle].name = name;
|
||
settings.custom_styles[currentEditingStyle].prompt = content;
|
||
} else {
|
||
// Save modified built-in as new custom style
|
||
// Check if content differs from original
|
||
const id = 'custom_' + currentEditingStyle + '_' + Date.now();
|
||
if (!settings.custom_styles) settings.custom_styles = {};
|
||
settings.custom_styles[id] = {
|
||
name: name + ' (Custom)',
|
||
prompt: content
|
||
};
|
||
currentEditingStyle = id;
|
||
}
|
||
|
||
saveSettings();
|
||
populateStyleList();
|
||
|
||
// Sanitize currentEditingStyle for safe DOM query
|
||
const { DOMPurify } = SillyTavern.libs;
|
||
const safeId = DOMPurify.sanitize(currentEditingStyle, { ALLOWED_TAGS: [] });
|
||
jQuery(`.ec_style_item[data-id="${safeId}"]`).addClass('active');
|
||
|
||
const safeName = DOMPurify.sanitize(name, { ALLOWED_TAGS: [] });
|
||
if (typeof toastr !== 'undefined') toastr.success(`Style "${safeName}" saved!`);
|
||
log('Style saved:', currentEditingStyle);
|
||
}
|
||
|
||
function deleteStyleFromEditor() {
|
||
if (!currentEditingStyle) return;
|
||
|
||
const isCustom = settings.custom_styles && settings.custom_styles[currentEditingStyle];
|
||
|
||
if (isCustom) {
|
||
if (!confirm('Delete this custom style? This cannot be undone.')) return;
|
||
delete settings.custom_styles[currentEditingStyle];
|
||
} else {
|
||
if (!confirm('Hide this built-in style? You can restore it by clearing deleted styles.')) return;
|
||
if (!settings.deleted_styles) settings.deleted_styles = [];
|
||
settings.deleted_styles.push(currentEditingStyle);
|
||
}
|
||
|
||
saveSettings();
|
||
currentEditingStyle = null;
|
||
populateStyleList();
|
||
showEmptyState();
|
||
|
||
if (typeof toastr !== 'undefined') toastr.info('Style removed');
|
||
}
|
||
|
||
function exportStyle(styleId) {
|
||
if (!styleId) return;
|
||
|
||
const content = jQuery('#ec_style_content').val();
|
||
const name = jQuery('#ec_style_name').val() || styleId;
|
||
|
||
const blob = new Blob([content], { type: 'text/markdown' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${name.toLowerCase().replace(/[^a-z0-9]/g, '_')}.md`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
|
||
if (typeof toastr !== 'undefined') toastr.success('Style exported!');
|
||
}
|
||
|
||
// ============================================================
|
||
// SETTINGS MODAL
|
||
// ============================================================
|
||
|
||
function openSettingsModal() {
|
||
// Remove any existing modal
|
||
jQuery('#ec_settings_modal').remove();
|
||
|
||
// Hide floating panel on mobile when opening settings modal
|
||
const isMobileDevice = window.innerWidth <= 768;
|
||
if (isMobileDevice && floatingPanelOpen) {
|
||
jQuery('#ec_floating_panel').hide();
|
||
}
|
||
|
||
const styles = getAllStyles();
|
||
const s = settings;
|
||
|
||
// Build style options
|
||
const styleOptions = styles.map(st =>
|
||
`<option value="${st.val}"${s.style === st.val ? ' selected' : ''}>${st.label}</option>`
|
||
).join('');
|
||
|
||
// Build source options with sub-panel visibility
|
||
const ollamaVisible = s.source === 'ollama' ? '' : 'display:none;';
|
||
const openaiVisible = s.source === 'openai' ? '' : 'display:none;';
|
||
const profileVisible = s.source === 'profile' ? '' : 'display:none;';
|
||
const contextDepthVisible = s.includeUserInput ? '' : 'display:none;';
|
||
const wibudgetVisible = s.includeWorldInfo ? '' : 'display:none;';
|
||
const livestreamVisible = s.livestream ? '' : 'display:none;';
|
||
|
||
const modal = jQuery(`
|
||
<div id="ec_settings_modal" role="dialog" aria-modal="true" aria-label="EchoChamber Settings">
|
||
<div class="ecm_backdrop"></div>
|
||
<div class="ecm_card">
|
||
|
||
<div class="ecm_header">
|
||
<div class="ecm_header_title"><i class="fa-solid fa-gear"></i> EchoChamber Settings</div>
|
||
<div class="ecm_close_btn" id="ecm_close" title="Close"><i class="fa-solid fa-xmark"></i></div>
|
||
</div>
|
||
|
||
<div class="ecm_layout">
|
||
|
||
<!-- SIDEBAR (desktop only) -->
|
||
<nav class="ecm_sidebar" aria-label="Settings navigation">
|
||
<ul class="ecm_nav_list">
|
||
<li><a class="ecm_nav_item" data-target="ecm-sect-general" role="button" tabindex="0">
|
||
<i class="fa-solid fa-power-off"></i><span>General</span>
|
||
</a></li>
|
||
<li><a class="ecm_nav_item" data-target="ecm-sect-engine" role="button" tabindex="0">
|
||
<i class="fa-solid fa-microchip"></i><span>Generation Engine</span>
|
||
</a></li>
|
||
<li><a class="ecm_nav_item" data-target="ecm-sect-display" role="button" tabindex="0">
|
||
<i class="fa-solid fa-sliders"></i><span>Display</span>
|
||
</a></li>
|
||
<li><a class="ecm_nav_item" data-target="ecm-sect-content" role="button" tabindex="0">
|
||
<i class="fa-solid fa-list-check"></i><span>Content Settings</span>
|
||
</a></li>
|
||
<li><a class="ecm_nav_item" data-target="ecm-sect-livestream" role="button" tabindex="0">
|
||
<i class="fa-solid fa-tower-broadcast"></i><span>Livestream</span>
|
||
</a></li>
|
||
<li><a class="ecm_nav_item" data-target="ecm-sect-chat" role="button" tabindex="0">
|
||
<i class="fa-solid fa-reply"></i><span>Chat Participation</span>
|
||
</a></li>
|
||
</ul>
|
||
</nav>
|
||
|
||
<!-- RIGHT CONTENT PANE -->
|
||
<div class="ecm_content" id="ecm_content_pane">
|
||
|
||
<!-- GENERAL -->
|
||
<section class="ecm_section ecm_acc" data-acc-open id="ecm-sect-general">
|
||
<button class="ecm_acc_header" aria-expanded="false" type="button">
|
||
<span class="ecm_acc_title"><i class="fa-solid fa-power-off"></i> General</span>
|
||
<i class="ecm_acc_chevron fa-solid fa-chevron-down"></i>
|
||
</button>
|
||
<div class="ecm_acc_body" hidden>
|
||
<label class="ecm_row ecm_toggle_row" for="ecm_enabled">
|
||
<span class="ecm_label">Enable EchoChamber</span>
|
||
<input id="ecm_enabled" type="checkbox" class="ecm_toggle"${s.enabled ? ' checked' : ''}>
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- GENERATION ENGINE -->
|
||
<section class="ecm_section ecm_acc" id="ecm-sect-engine">
|
||
<button class="ecm_acc_header" aria-expanded="false" type="button">
|
||
<span class="ecm_acc_title"><i class="fa-solid fa-microchip"></i> Generation Engine</span>
|
||
<i class="ecm_acc_chevron fa-solid fa-chevron-down"></i>
|
||
</button>
|
||
<div class="ecm_acc_body" hidden>
|
||
<div class="ecm_row">
|
||
<label class="ecm_label" for="ecm_source">Source</label>
|
||
<select id="ecm_source" class="ecm_select">
|
||
<option value="default"${s.source === 'default' ? ' selected' : ''}>Default (Main API)</option>
|
||
<option value="profile"${s.source === 'profile' ? ' selected' : ''}>Connection Profile</option>
|
||
<option value="ollama"${s.source === 'ollama' ? ' selected' : ''}>Ollama (Local)</option>
|
||
<option value="openai"${s.source === 'openai' ? ' selected' : ''}>OpenAI Compatible</option>
|
||
</select>
|
||
</div>
|
||
<div id="ecm_ollama_settings" class="ecm_subpanel ecm_subpanel_green" style="${ollamaVisible}">
|
||
<div class="ecm_subpanel_title"><i class="fa-solid fa-terminal"></i> Ollama</div>
|
||
<input id="ecm_url" type="text" class="ecm_input ecm_input_gap" placeholder="http://localhost:11434" value="${s.url || ''}">
|
||
<select id="ecm_model_select" class="ecm_select"></select>
|
||
<div id="ecm_ollama_status" class="ecm_hint"></div>
|
||
</div>
|
||
<div id="ecm_openai_settings" class="ecm_subpanel ecm_subpanel_blue" style="${openaiVisible}">
|
||
<div class="ecm_subpanel_title"><i class="fa-solid fa-cloud"></i> OpenAI Compatible</div>
|
||
<select id="ecm_openai_preset" class="ecm_select ecm_input_gap">
|
||
<option value="custom"${s.openai_preset === 'custom' ? ' selected' : ''}>Custom</option>
|
||
<option value="lmstudio"${s.openai_preset === 'lmstudio' ? ' selected' : ''}>LM Studio (:1234)</option>
|
||
<option value="kobold"${s.openai_preset === 'kobold' ? ' selected' : ''}>KoboldCPP (:5001)</option>
|
||
<option value="textgen"${s.openai_preset === 'textgen' ? ' selected' : ''}>TextGenWebUI (:5000)</option>
|
||
<option value="vllm"${s.openai_preset === 'vllm' ? ' selected' : ''}>vLLM (:8000)</option>
|
||
</select>
|
||
<input id="ecm_openai_url" type="text" class="ecm_input ecm_input_gap" placeholder="http://localhost:1234/v1" value="${s.openai_url || ''}">
|
||
<input id="ecm_openai_key" type="password" class="ecm_input ecm_input_gap" placeholder="API Key (Optional)" value="${s.openai_key || ''}">
|
||
<input id="ecm_openai_model" type="text" class="ecm_input" placeholder="Model name" value="${s.openai_model || ''}">
|
||
</div>
|
||
<div id="ecm_profile_settings" class="ecm_subpanel ecm_subpanel_purple" style="${profileVisible}">
|
||
<div class="ecm_subpanel_title"><i class="fa-solid fa-link"></i> Connection Profile</div>
|
||
<select id="ecm_preset_select" class="ecm_select"></select>
|
||
<div class="ecm_hint"><i class="fa-solid fa-shield-halved"></i> Uses your existing ST credentials securely.</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- DISPLAY -->
|
||
<section class="ecm_section ecm_acc" id="ecm-sect-display">
|
||
<button class="ecm_acc_header" aria-expanded="false" type="button">
|
||
<span class="ecm_acc_title"><i class="fa-solid fa-sliders"></i> Display</span>
|
||
<i class="ecm_acc_chevron fa-solid fa-chevron-down"></i>
|
||
</button>
|
||
<div class="ecm_acc_body" hidden>
|
||
<div class="ecm_row">
|
||
<label class="ecm_label" for="ecm_style">Style</label>
|
||
<select id="ecm_style" class="ecm_select">${styleOptions}</select>
|
||
</div>
|
||
<div class="ecm_row">
|
||
<label class="ecm_label" for="ecm_position">Position</label>
|
||
<select id="ecm_position" class="ecm_select">
|
||
<option value="bottom"${s.position === 'bottom' ? ' selected' : ''}>Bottom</option>
|
||
<option value="top"${s.position === 'top' ? ' selected' : ''}>Top</option>
|
||
<option value="left"${s.position === 'left' ? ' selected' : ''}>Left</option>
|
||
<option value="right"${s.position === 'right' ? ' selected' : ''}>Right</option>
|
||
</select>
|
||
</div>
|
||
<div class="ecm_row">
|
||
<label class="ecm_label" for="ecm_user_count">Users</label>
|
||
<select id="ecm_user_count" class="ecm_select">
|
||
<!-- Generating options 1-20 inline -->
|
||
${Array.from({ length: 20 }, (_, i) => i + 1).map(n => `<option value="${n}"${(s.userCount || 5) == n ? ' selected' : ''}>${n}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="ecm_row">
|
||
<label class="ecm_label" for="ecm_font_size">Font Size (px)</label>
|
||
<select id="ecm_font_size" class="ecm_select">
|
||
<!-- Generating options 8-24 inline -->
|
||
${Array.from({ length: 17 }, (_, i) => i + 8).map(n => `<option value="${n}"${(s.fontSize || 15) == n ? ' selected' : ''}>${n}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="ecm_row">
|
||
<label class="ecm_label" for="ecm_opacity">Opacity <span id="ecm_opacity_val">${s.opacity || 85}%</span></label>
|
||
<input id="ecm_opacity" type="range" class="ecm_slider" min="10" max="100" step="5" value="${s.opacity || 85}">
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- CONTENT SETTINGS -->
|
||
<section class="ecm_section ecm_acc" id="ecm-sect-content">
|
||
<button class="ecm_acc_header" aria-expanded="false" type="button">
|
||
<span class="ecm_acc_title"><i class="fa-solid fa-list-check"></i> Content Settings</span>
|
||
<i class="ecm_acc_chevron fa-solid fa-chevron-down"></i>
|
||
</button>
|
||
<div class="ecm_acc_body" hidden>
|
||
<label class="ecm_row ecm_toggle_row" for="ecm_auto_update">
|
||
<span class="ecm_label"><i class="fa-solid fa-sync ecm_icon"></i> Auto-update On Messages</span>
|
||
<input id="ecm_auto_update" type="checkbox" class="ecm_toggle"${s.autoUpdateOnMessages !== false ? ' checked' : ''}>
|
||
</label>
|
||
<label class="ecm_row ecm_toggle_row" for="ecm_include_user">
|
||
<span class="ecm_label"><i class="fa-solid fa-comments ecm_icon"></i> Include Chat History</span>
|
||
<input id="ecm_include_user" type="checkbox" class="ecm_toggle"${s.includeUserInput ? ' checked' : ''}>
|
||
</label>
|
||
<div id="ecm_context_depth_container" class="ecm_subrow" style="${contextDepthVisible}">
|
||
<label class="ecm_label" for="ecm_context_depth">Context Depth</label>
|
||
<input id="ecm_context_depth" type="number" class="ecm_input ecm_input_sm" min="2" max="20" value="${s.contextDepth || 4}">
|
||
</div>
|
||
<label class="ecm_row ecm_toggle_row" for="ecm_include_past_echo">
|
||
<span class="ecm_label"><i class="fa-solid fa-layer-group ecm_icon"></i> Include Past EchoChambers</span>
|
||
<input id="ecm_include_past_echo" type="checkbox" class="ecm_toggle"${s.includePastEchoChambers ? ' checked' : ''}>
|
||
</label>
|
||
<div class="ecm_subpanel_plain">
|
||
<div class="ecm_subpanel_title"><i class="fa-solid fa-file-lines"></i> Include in Context</div>
|
||
<label class="ecm_row ecm_toggle_row" for="ecm_include_persona">
|
||
<span class="ecm_label"><i class="fa-solid fa-face-smile ecm_icon"></i> Persona</span>
|
||
<input id="ecm_include_persona" type="checkbox" class="ecm_toggle"${s.includePersona ? ' checked' : ''}>
|
||
</label>
|
||
<label class="ecm_row ecm_toggle_row" for="ecm_include_authors_note">
|
||
<span class="ecm_label"><i class="fa-solid fa-pen-nib ecm_icon"></i> Author's Note</span>
|
||
<input id="ecm_include_authors_note" type="checkbox" class="ecm_toggle"${s.includeAuthorsNote ? ' checked' : ''}>
|
||
</label>
|
||
<label class="ecm_row ecm_toggle_row" for="ecm_include_character_description">
|
||
<span class="ecm_label"><i class="fa-solid fa-address-card ecm_icon"></i> Character Description(s)</span>
|
||
<input id="ecm_include_character_description" type="checkbox" class="ecm_toggle"${s.includeCharacterDescription ? ' checked' : ''}>
|
||
</label>
|
||
<label class="ecm_row ecm_toggle_row" for="ecm_include_summary">
|
||
<span class="ecm_label"><i class="fa-solid fa-scroll ecm_icon"></i> Summary (from Summarize ext.)</span>
|
||
<input id="ecm_include_summary" type="checkbox" class="ecm_toggle"${s.includeSummary ? ' checked' : ''}>
|
||
</label>
|
||
<label class="ecm_row ecm_toggle_row" for="ecm_include_world_info">
|
||
<span class="ecm_label"><i class="fa-solid fa-globe ecm_icon"></i> World Info / Lorebook</span>
|
||
<input id="ecm_include_world_info" type="checkbox" class="ecm_toggle"${s.includeWorldInfo ? ' checked' : ''}>
|
||
</label>
|
||
<div id="ecm_wi_budget_container" class="ecm_subrow" style="${wibudgetVisible}">
|
||
<label class="ecm_label" for="ecm_wi_budget"><i class="fa-solid fa-coins ecm_icon"></i> WI Token Budget</label>
|
||
<input id="ecm_wi_budget" type="number" class="ecm_input ecm_input_sm" min="0" max="200000" step="1000" value="${s.wiBudget || 0}">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- LIVESTREAM -->
|
||
<section class="ecm_section ecm_acc" id="ecm-sect-livestream">
|
||
<button class="ecm_acc_header" aria-expanded="false" type="button">
|
||
<span class="ecm_acc_title"><i class="fa-solid fa-tower-broadcast"></i> Livestream</span>
|
||
<i class="ecm_acc_chevron fa-solid fa-chevron-down"></i>
|
||
</button>
|
||
<div class="ecm_acc_body" hidden>
|
||
<label class="ecm_row ecm_toggle_row" for="ecm_livestream">
|
||
<span class="ecm_label">Enable Livestream Mode</span>
|
||
<input id="ecm_livestream" type="checkbox" class="ecm_toggle"${s.livestream ? ' checked' : ''}>
|
||
</label>
|
||
<div id="ecm_livestream_settings" style="${livestreamVisible}">
|
||
<div class="ecm_row">
|
||
<label class="ecm_label" for="ecm_livestream_batch_size"><i class="fa-solid fa-boxes-stacked ecm_icon"></i> Batch Size</label>
|
||
<input id="ecm_livestream_batch_size" type="number" class="ecm_input ecm_input_sm" min="5" max="50" value="${s.livestreamBatchSize || 20}">
|
||
</div>
|
||
<div class="ecm_row">
|
||
<label class="ecm_label" for="ecm_livestream_min_wait"><i class="fa-solid fa-clock ecm_icon"></i> Min Wait (s)</label>
|
||
<input id="ecm_livestream_min_wait" type="number" class="ecm_input ecm_input_sm" min="1" max="120" value="${s.livestreamMinWait || 5}">
|
||
</div>
|
||
<div class="ecm_row">
|
||
<label class="ecm_label" for="ecm_livestream_max_wait"><i class="fa-solid fa-clock ecm_icon"></i> Max Wait (s)</label>
|
||
<input id="ecm_livestream_max_wait" type="number" class="ecm_input ecm_input_sm" min="1" max="300" value="${s.livestreamMaxWait || 60}">
|
||
</div>
|
||
<div class="ecm_subpanel_plain">
|
||
<div class="ecm_subpanel_title"><i class="fa-solid fa-clapperboard"></i> Generation Mode</div>
|
||
<label class="ecm_radio_row" for="ecm_ls_manual">
|
||
<input type="radio" id="ecm_ls_manual" name="ecm_livestream_mode" value="manual"${(s.livestreamMode || 'manual') === 'manual' ? ' checked' : ''}><span>Manual (Regenerate Chat)</span>
|
||
</label>
|
||
<label class="ecm_radio_row" for="ecm_ls_onmessage">
|
||
<input type="radio" id="ecm_ls_onmessage" name="ecm_livestream_mode" value="onMessage"${s.livestreamMode === 'onMessage' ? ' checked' : ''}><span>On New Message</span>
|
||
</label>
|
||
<label class="ecm_radio_row" for="ecm_ls_oncomplete">
|
||
<input type="radio" id="ecm_ls_oncomplete" name="ecm_livestream_mode" value="onComplete"${s.livestreamMode === 'onComplete' ? ' checked' : ''}><span>After Batch Completes</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- CHAT PARTICIPATION -->
|
||
<section class="ecm_section ecm_acc" id="ecm-sect-chat">
|
||
<button class="ecm_acc_header" aria-expanded="false" type="button">
|
||
<span class="ecm_acc_title"><i class="fa-solid fa-reply"></i> Chat Participation</span>
|
||
<i class="ecm_acc_chevron fa-solid fa-chevron-down"></i>
|
||
</button>
|
||
<div class="ecm_acc_body" hidden>
|
||
<label class="ecm_row ecm_toggle_row" for="ecm_chat_enabled">
|
||
<span class="ecm_label">Enable Chat Participation</span>
|
||
<input id="ecm_chat_enabled" type="checkbox" class="ecm_toggle"${s.chatEnabled !== false ? ' checked' : ''}>
|
||
</label>
|
||
<div class="ecm_row">
|
||
<label class="ecm_label" for="ecm_chat_username"><i class="fa-solid fa-user ecm_icon"></i> Your Username</label>
|
||
<input id="ecm_chat_username" type="text" class="ecm_input" placeholder="Streamer (You)" value="${s.chatUsername || 'Streamer (You)'}">
|
||
</div>
|
||
<div class="ecm_row">
|
||
<label class="ecm_label" for="ecm_chat_avatar_color"><i class="fa-solid fa-circle ecm_icon"></i> Avatar Color</label>
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<input id="ecm_chat_avatar_color" type="color" class="ecm_color_input" value="${s.chatAvatarColor || '#3b82f6'}" style="width:44px;height:36px;padding:2px;border-radius:6px;border:1px solid var(--ec-border);cursor:pointer;background:transparent;">
|
||
<span class="ecm_hint" style="margin:0;">Your avatar circle color</span>
|
||
</div>
|
||
</div>
|
||
<div class="ecm_row">
|
||
<label class="ecm_label" for="ecm_chat_reply_count"><i class="fa-solid fa-comments ecm_icon"></i> AI Replies per Comment</label>
|
||
<input id="ecm_chat_reply_count" type="number" class="ecm_input" min="1" max="12" value="${s.chatReplyCount || 3}" style="width:70px;">
|
||
</div>
|
||
<div class="ecm_hint">Number of AI responses after a general comment (1–12). Direct @mentions always get a single reply.</div>
|
||
</div>
|
||
</section>
|
||
|
||
</div><!-- /ecm_content -->
|
||
</div><!-- /ecm_layout -->
|
||
|
||
<div class="ecm_footer">
|
||
<div class="ecm_footer_left">
|
||
<button class="ecm_footer_btn" id="ecm_style_manager_btn"><i class="fa-solid fa-palette"></i> Style Manager</button>
|
||
<button class="ecm_footer_btn" id="ecm_import_btn"><i class="fa-solid fa-file-import"></i> Import</button>
|
||
<input type="file" id="ecm_import_file" accept=".md" style="display:none;">
|
||
</div>
|
||
<div class="ecm_footer_right">
|
||
<button class="ecm_done_btn" id="ecm_done"><i class="fa-solid fa-check"></i> Done</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /ecm_card -->
|
||
</div>`);
|
||
|
||
jQuery('body').append(modal);
|
||
|
||
|
||
// Populate Ollama model select from existing settings panel
|
||
const existingModels = jQuery('#discord_model_select option');
|
||
existingModels.each(function () {
|
||
const opt = jQuery(this).clone();
|
||
if (jQuery(this).val() === s.model) opt.prop('selected', true);
|
||
jQuery('#ecm_model_select').append(opt);
|
||
});
|
||
|
||
// Populate connection profile select from existing settings panel
|
||
const existingProfiles = jQuery('#discord_preset_select option');
|
||
existingProfiles.each(function () {
|
||
const opt = jQuery(this).clone();
|
||
if (jQuery(this).val() === s.preset) opt.prop('selected', true);
|
||
jQuery('#ecm_preset_select').append(opt);
|
||
});
|
||
|
||
// Show/animate in
|
||
requestAnimationFrame(() => {
|
||
modal.addClass('ecm_visible');
|
||
|
||
// Mobile: force explicit viewport sizing to fix iOS/Android modal display issues
|
||
// (same fix used in SillyTavern-Larson for its settings overlay)
|
||
if (window.innerWidth <= 768) {
|
||
const modalEl = modal[0];
|
||
const cardEl = modal.find('.ecm_card')[0];
|
||
const layoutEl = modal.find('.ecm_layout')[0];
|
||
if (modalEl) {
|
||
modalEl.style.height = '100dvh';
|
||
modalEl.style.width = '100%';
|
||
modalEl.style.inset = '0';
|
||
modalEl.style.display = 'flex';
|
||
modalEl.style.alignItems = 'center';
|
||
modalEl.style.justifyContent = 'center';
|
||
}
|
||
if (cardEl) {
|
||
cardEl.style.position = 'relative';
|
||
cardEl.style.top = 'auto';
|
||
cardEl.style.left = 'auto';
|
||
cardEl.style.bottom = 'auto';
|
||
cardEl.style.right = 'auto';
|
||
cardEl.style.transform = 'none';
|
||
cardEl.style.margin = 'auto';
|
||
cardEl.style.maxHeight = '90dvh';
|
||
cardEl.style.overflowY = 'auto';
|
||
cardEl.style.overflowX = 'hidden';
|
||
}
|
||
if (layoutEl) {
|
||
// Layout uses CSS flex scrolling on mobile - no override needed
|
||
}
|
||
}
|
||
});
|
||
|
||
// ---- Event Bindings ----
|
||
|
||
// Close handlers (click + touchend for mobile)
|
||
modal.find('.ecm_backdrop, #ecm_close, #ecm_done').on('click touchend', function (e) {
|
||
if (e.type === 'touchend') e.preventDefault();
|
||
closeSettingsModal();
|
||
});
|
||
// Stop click propagation on card so backdrop click-to-close doesn't fire when clicking inside
|
||
modal.find('.ecm_card').on('click', e => e.stopPropagation());
|
||
|
||
// Style Manager button — close settings modal then open style editor
|
||
modal.find('#ecm_style_manager_btn').on('click', function () {
|
||
closeSettingsModal();
|
||
setTimeout(() => openStyleEditor(), 320);
|
||
});
|
||
|
||
// Import button — trigger the hidden file input
|
||
modal.find('#ecm_import_btn').on('click', function () {
|
||
modal.find('#ecm_import_file').click();
|
||
});
|
||
|
||
// Import file selected — same logic as settings panel import
|
||
modal.find('#ecm_import_file').on('change', function () {
|
||
const file = this.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = function (e) {
|
||
const content = e.target.result;
|
||
const name = file.name.replace(/\.md$/i, '');
|
||
const id = 'custom_' + name.toLowerCase().replace(/[^a-z0-9]/g, '_') + '_' + Date.now();
|
||
if (!settings.custom_styles) settings.custom_styles = {};
|
||
settings.custom_styles[id] = { name: name, prompt: content };
|
||
saveSettings();
|
||
updateAllDropdowns();
|
||
if (typeof toastr !== 'undefined') toastr.success(`Imported style: ${name}`);
|
||
};
|
||
reader.readAsText(file);
|
||
this.value = '';
|
||
});
|
||
|
||
// Escape key
|
||
jQuery(document).on('keydown.ecm', function (e) {
|
||
if (e.key === 'Escape') closeSettingsModal();
|
||
});
|
||
|
||
// ---- Two-pane vs accordion depending on viewport ----
|
||
const isMobile = () => window.innerWidth <= 768;
|
||
const contentPane = modal.find('#ecm_content_pane')[0];
|
||
|
||
if (!isMobile()) {
|
||
// DESKTOP: reveal all section bodies immediately
|
||
modal.find('.ecm_acc_body').each(function () { this.hidden = false; });
|
||
|
||
// ---- Sidebar navigation ----
|
||
const navItems = modal.find('.ecm_nav_item');
|
||
navItems.first().addClass('ecm_nav_active');
|
||
let isAutoScrolling = false;
|
||
let scrollTimeout;
|
||
|
||
navItems.on('click', function (e) {
|
||
e.preventDefault();
|
||
const target = this.dataset.target;
|
||
const sect = document.getElementById(target);
|
||
if (sect && contentPane) {
|
||
isAutoScrolling = true;
|
||
navItems.removeClass('ecm_nav_active');
|
||
jQuery(this).addClass('ecm_nav_active');
|
||
|
||
sect.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
|
||
clearTimeout(scrollTimeout);
|
||
scrollTimeout = setTimeout(() => {
|
||
isAutoScrolling = false;
|
||
}, 800); // Re-enable observer after smooth scroll completes
|
||
}
|
||
});
|
||
|
||
// ---- ScrollSpy via IntersectionObserver ----
|
||
if (contentPane && window.IntersectionObserver) {
|
||
const sects = modal.find('.ecm_section[id]').toArray();
|
||
const spy = new IntersectionObserver((entries) => {
|
||
if (isAutoScrolling) return; // Skip highlights if manually clicked nav
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting) {
|
||
const id = entry.target.id;
|
||
navItems.removeClass('ecm_nav_active');
|
||
modal.find(`.ecm_nav_item[data-target="${id}"]`).addClass('ecm_nav_active');
|
||
}
|
||
});
|
||
}, { root: contentPane, rootMargin: '-5% 0px -80% 0px', threshold: 0 });
|
||
sects.forEach(s => spy.observe(s));
|
||
// Disconnect when modal closes
|
||
modal.on('ecm:close', () => spy.disconnect());
|
||
}
|
||
} else {
|
||
// MOBILE: accordion — open data-acc-open sections by default
|
||
modal.find('.ecm_acc[data-acc-open]').each(function () {
|
||
const btn = jQuery(this).find('.ecm_acc_header')[0];
|
||
const body = jQuery(this).find('.ecm_acc_body')[0];
|
||
if (btn && body) { btn.setAttribute('aria-expanded', 'true'); body.hidden = false; }
|
||
});
|
||
// Accordion toggle handler (mobile only) — click + touchend for mobile
|
||
modal.find('.ecm_acc_header').on('click touchend', function (e) {
|
||
if (e.type === 'touchend') e.preventDefault();
|
||
const expanded = this.getAttribute('aria-expanded') === 'true';
|
||
const body = this.nextElementSibling;
|
||
// On phones (≤480px) close all other open sections before opening a new one
|
||
if (!expanded && window.innerWidth <= 480) {
|
||
modal.find('.ecm_acc_header[aria-expanded="true"]').each(function () {
|
||
if (this !== e.currentTarget) {
|
||
this.setAttribute('aria-expanded', 'false');
|
||
if (this.nextElementSibling) this.nextElementSibling.hidden = true;
|
||
}
|
||
});
|
||
}
|
||
this.setAttribute('aria-expanded', String(!expanded));
|
||
if (body) body.hidden = expanded;
|
||
});
|
||
}
|
||
|
||
|
||
// Helper: sync a settings-panel element after modal change
|
||
function syncToPanel(panelId, value, isProp = false) {
|
||
const el = jQuery(`#${panelId}`);
|
||
if (!el.length) return;
|
||
if (isProp) el.prop('checked', value).trigger('change');
|
||
else el.val(value).trigger('change');
|
||
}
|
||
|
||
// General
|
||
modal.on('change', '#ecm_enabled', function () {
|
||
syncToPanel('discord_enabled', this.checked, true);
|
||
});
|
||
|
||
// Generation Engine
|
||
modal.on('change', '#ecm_source', function () {
|
||
syncToPanel('discord_source', jQuery(this).val());
|
||
// Show/hide sub-panels
|
||
const src = jQuery(this).val();
|
||
modal.find('#ecm_ollama_settings').toggle(src === 'ollama');
|
||
modal.find('#ecm_openai_settings').toggle(src === 'openai');
|
||
modal.find('#ecm_profile_settings').toggle(src === 'profile');
|
||
});
|
||
modal.on('change', '#ecm_url', function () { syncToPanel('discord_url', jQuery(this).val()); });
|
||
modal.on('change', '#ecm_model_select', function () { syncToPanel('discord_model_select', jQuery(this).val()); });
|
||
modal.on('change', '#ecm_openai_preset', function () { syncToPanel('discord_openai_preset', jQuery(this).val()); });
|
||
modal.on('change', '#ecm_openai_url', function () { syncToPanel('discord_openai_url', jQuery(this).val()); });
|
||
modal.on('change', '#ecm_openai_key', function () { syncToPanel('discord_openai_key', jQuery(this).val()); });
|
||
modal.on('change', '#ecm_openai_model', function () { syncToPanel('discord_openai_model', jQuery(this).val()); });
|
||
modal.on('change', '#ecm_preset_select', function () { syncToPanel('discord_preset_select', jQuery(this).val()); });
|
||
|
||
// Display
|
||
modal.on('change', '#ecm_style', function () {
|
||
const val = jQuery(this).val();
|
||
settings.style = val;
|
||
saveSettings();
|
||
updateStyleIndicator();
|
||
jQuery('#discord_style').val(val);
|
||
// Also update panel style menu selection
|
||
jQuery('.ec_menu_item[data-val]').each(function () {
|
||
const mi = jQuery(this);
|
||
const inStyleMenu = mi.closest('.ec_style_menu').length > 0;
|
||
if (inStyleMenu) mi.toggleClass('selected', mi.data('val') === val);
|
||
});
|
||
});
|
||
modal.on('change', '#ecm_position', function () {
|
||
const val = jQuery(this).val();
|
||
settings.position = val;
|
||
saveSettings();
|
||
updateApplyLayout();
|
||
jQuery('#discord_position').val(val);
|
||
});
|
||
modal.on('change', '#ecm_user_count', function () {
|
||
settings.userCount = parseInt(jQuery(this).val()) || 5;
|
||
saveSettings();
|
||
jQuery('#discord_user_count').val(settings.userCount);
|
||
// Update panel user menu
|
||
jQuery('.ec_menu_item[data-val]').filter(function () {
|
||
return jQuery(this).closest('.ec_user_menu').length > 0;
|
||
}).each(function () {
|
||
jQuery(this).toggleClass('selected', parseInt(jQuery(this).data('val')) === settings.userCount);
|
||
});
|
||
});
|
||
modal.on('change', '#ecm_font_size', function () {
|
||
settings.fontSize = parseInt(jQuery(this).val()) || 15;
|
||
applyFontSize(settings.fontSize);
|
||
saveSettings();
|
||
jQuery('#discord_font_size').val(settings.fontSize);
|
||
});
|
||
modal.on('input change', '#ecm_opacity', function () {
|
||
settings.opacity = parseInt(jQuery(this).val()) || 85;
|
||
modal.find('#ecm_opacity_val').text(settings.opacity + '%');
|
||
jQuery('#discord_opacity').val(settings.opacity).trigger('input');
|
||
});
|
||
|
||
// Content Settings
|
||
modal.on('change', '#ecm_auto_update', function () { syncToPanel('discord_auto_update', this.checked, true); });
|
||
modal.on('change', '#ecm_include_user', function () {
|
||
const checked = this.checked;
|
||
modal.find('#ecm_context_depth_container').toggle(checked);
|
||
syncToPanel('discord_include_user', checked, true);
|
||
});
|
||
modal.on('change', '#ecm_context_depth', function () { syncToPanel('discord_context_depth', jQuery(this).val()); });
|
||
modal.on('change', '#ecm_include_past_echo', function () { syncToPanel('discord_include_past_echo', this.checked, true); });
|
||
modal.on('change', '#ecm_include_persona', function () { syncToPanel('discord_include_persona', this.checked, true); });
|
||
modal.on('change', '#ecm_include_authors_note', function () { syncToPanel('discord_include_authors_note', this.checked, true); });
|
||
modal.on('change', '#ecm_include_character_description', function () { syncToPanel('discord_include_character_description', this.checked, true); });
|
||
modal.on('change', '#ecm_include_summary', function () { syncToPanel('discord_include_summary', this.checked, true); });
|
||
modal.on('change', '#ecm_include_world_info', function () {
|
||
const checked = this.checked;
|
||
modal.find('#ecm_wi_budget_container').toggle(checked);
|
||
syncToPanel('discord_include_world_info', checked, true);
|
||
});
|
||
modal.on('change', '#ecm_wi_budget', function () { syncToPanel('discord_wi_budget', jQuery(this).val()); });
|
||
|
||
// Livestream
|
||
modal.on('change', '#ecm_livestream', function () {
|
||
const checked = this.checked;
|
||
modal.find('#ecm_livestream_settings').toggle(checked);
|
||
toggleLivestream(checked);
|
||
});
|
||
modal.on('change', '#ecm_livestream_batch_size', function () { syncToPanel('discord_livestream_batch_size', jQuery(this).val()); });
|
||
modal.on('change', '#ecm_livestream_min_wait', function () { syncToPanel('discord_livestream_min_wait', jQuery(this).val()); });
|
||
modal.on('change', '#ecm_livestream_max_wait', function () { syncToPanel('discord_livestream_max_wait', jQuery(this).val()); });
|
||
modal.on('change', 'input[name="ecm_livestream_mode"]', function () {
|
||
const val = jQuery(this).val();
|
||
settings.livestreamMode = val;
|
||
saveSettings();
|
||
jQuery(`#discord_livestream_${val === 'manual' ? 'manual' : val === 'onMessage' ? 'onmessage' : 'oncomplete'} `).prop('checked', true);
|
||
});
|
||
|
||
// Chat Participation
|
||
modal.on('change', '#ecm_chat_enabled', function () {
|
||
const enabled = this.checked;
|
||
settings.chatEnabled = enabled;
|
||
syncToPanel('discord_chat_enabled', enabled, true);
|
||
jQuery('.ec_reply_container').toggle(enabled);
|
||
saveSettings();
|
||
});
|
||
modal.on('input', '#ecm_chat_username', function () {
|
||
const username = jQuery(this).val().trim() || 'Streamer (You)';
|
||
settings.chatUsername = username;
|
||
syncToPanel('discord_chat_username', username);
|
||
saveSettings();
|
||
});
|
||
modal.on('input change', '#ecm_chat_avatar_color', function () {
|
||
const color = jQuery(this).val();
|
||
settings.chatAvatarColor = color;
|
||
applyAvatarColor(color);
|
||
jQuery('#discord_chat_avatar_color').val(color);
|
||
saveSettings();
|
||
});
|
||
modal.on('change', '#ecm_chat_reply_count', function () {
|
||
// Only clamp on commit (blur / Enter) so mid-edit backspacing doesn't
|
||
// immediately replace an empty field with the fallback value of 3.
|
||
const val = Math.max(1, Math.min(12, parseInt(jQuery(this).val()) || 3));
|
||
settings.chatReplyCount = val;
|
||
jQuery(this).val(val);
|
||
jQuery('#discord_chat_reply_count').val(val);
|
||
saveSettings();
|
||
});
|
||
}
|
||
|
||
function closeSettingsModal() {
|
||
const modal = jQuery('#ec_settings_modal');
|
||
modal.trigger('ecm:close');
|
||
modal.removeClass('ecm_visible');
|
||
jQuery(document).off('keydown.ecm');
|
||
setTimeout(() => modal.remove(), 300);
|
||
}
|
||
|
||
// Sync modal inputs from the current settings object (called when panel changes while modal is open)
|
||
function syncModalFromSettings() {
|
||
const modal = jQuery('#ec_settings_modal');
|
||
if (!modal.length) return;
|
||
const s = settings;
|
||
modal.find('#ecm_enabled').prop('checked', s.enabled);
|
||
modal.find('#ecm_source').val(s.source);
|
||
modal.find('#ecm_url').val(s.url);
|
||
modal.find('#ecm_openai_url').val(s.openai_url);
|
||
modal.find('#ecm_openai_key').val(s.openai_key);
|
||
modal.find('#ecm_openai_model').val(s.openai_model);
|
||
modal.find('#ecm_openai_preset').val(s.openai_preset);
|
||
modal.find('#ecm_model_select').val(s.model);
|
||
modal.find('#ecm_preset_select').val(s.preset);
|
||
modal.find('#ecm_style').val(s.style);
|
||
modal.find('#ecm_position').val(s.position);
|
||
modal.find('#ecm_user_count').val(s.userCount);
|
||
modal.find('#ecm_font_size').val(s.fontSize);
|
||
modal.find('#ecm_opacity').val(s.opacity);
|
||
modal.find('#ecm_opacity_val').text((s.opacity || 85) + '%');
|
||
modal.find('#ecm_auto_update').prop('checked', s.autoUpdateOnMessages !== false);
|
||
modal.find('#ecm_include_user').prop('checked', s.includeUserInput);
|
||
modal.find('#ecm_context_depth').val(s.contextDepth);
|
||
modal.find('#ecm_context_depth_container').toggle(!!s.includeUserInput);
|
||
modal.find('#ecm_include_past_echo').prop('checked', s.includePastEchoChambers);
|
||
modal.find('#ecm_include_persona').prop('checked', s.includePersona);
|
||
modal.find('#ecm_include_authors_note').prop('checked', s.includeAuthorsNote);
|
||
modal.find('#ecm_include_character_description').prop('checked', s.includeCharacterDescription);
|
||
modal.find('#ecm_include_summary').prop('checked', s.includeSummary);
|
||
modal.find('#ecm_include_world_info').prop('checked', s.includeWorldInfo);
|
||
modal.find('#ecm_wi_budget').val(s.wiBudget);
|
||
modal.find('#ecm_wi_budget_container').toggle(!!s.includeWorldInfo);
|
||
modal.find('#ecm_livestream').prop('checked', s.livestream);
|
||
modal.find('#ecm_livestream_settings').toggle(!!s.livestream);
|
||
modal.find('#ecm_livestream_batch_size').val(s.livestreamBatchSize);
|
||
modal.find('#ecm_livestream_min_wait').val(s.livestreamMinWait);
|
||
modal.find('#ecm_livestream_max_wait').val(s.livestreamMaxWait);
|
||
const mode = s.livestreamMode || 'manual';
|
||
modal.find(`input[name = "ecm_livestream_mode"][value = "${mode}"]`).prop('checked', true);
|
||
modal.find('#ecm_chat_enabled').prop('checked', s.chatEnabled !== false);
|
||
modal.find('#ecm_chat_username').val(s.chatUsername || 'Streamer (You)');
|
||
modal.find('#ecm_chat_avatar_color').val(s.chatAvatarColor || '#3b82f6');
|
||
modal.find('#ecm_chat_reply_count').val(s.chatReplyCount || 3);
|
||
modal.find('#ecm_ollama_settings').toggle(s.source === 'ollama');
|
||
modal.find('#ecm_openai_settings').toggle(s.source === 'openai');
|
||
modal.find('#ecm_profile_settings').toggle(s.source === 'profile');
|
||
}
|
||
|
||
// ============================================================
|
||
// UI RENDERING
|
||
// ============================================================
|
||
|
||
function renderPanel() {
|
||
jQuery('#discordBar').remove();
|
||
|
||
discordBar = jQuery('<div id="discordBar"></div>');
|
||
discordQuickBar = jQuery('<div id="discordQuickSettings"></div>');
|
||
|
||
// Header Left - Power button (enable/disable), Collapse arrow, and Live indicator
|
||
const leftGroup = jQuery('<div class="ec_header_left"></div>');
|
||
const powerBtn = jQuery('<div class="ec_power_btn" title="Enable/Disable EchoChamber"><i class="fa-solid fa-power-off"></i></div>');
|
||
const collapseBtn = jQuery('<div class="ec_collapse_btn" title="Collapse/Expand Panel"><i class="fa-solid fa-chevron-down"></i></div>');
|
||
const liveIndicator = jQuery('<div class="ec_live_indicator" id="ec_live_indicator"><i class="fa-solid fa-circle"></i> LIVE</div>');
|
||
leftGroup.append(powerBtn).append(collapseBtn).append(liveIndicator);
|
||
|
||
// Header Right - All icon buttons (Refresh first, then layout, users, font)
|
||
const rightGroup = jQuery('<div class="ec_header_right"></div>');
|
||
const createBtn = (icon, title, menuClass) => {
|
||
const btn = jQuery(`<div class="ec_btn" title = "${title}" > <i class="${icon}"></i></div> `);
|
||
if (menuClass) btn.append(`<div class="ec_popup_menu ${menuClass}" ></div> `);
|
||
return btn;
|
||
};
|
||
|
||
const refreshBtn = createBtn('fa-solid fa-rotate-right', 'Regenerate Chat', null);
|
||
const layoutBtn = createBtn('fa-solid fa-table-columns', 'Panel Position', 'ec_layout_menu');
|
||
const usersBtn = createBtn('fa-solid fa-users', 'User Count', 'ec_user_menu');
|
||
const fontBtn = createBtn('fa-solid fa-font', 'Font Size', 'ec_font_menu');
|
||
const clearBtn = createBtn('fa-solid fa-trash-can', 'Clear Chat & Cache', null);
|
||
const settingsBtn = createBtn('fa-solid fa-gear', 'Settings', null);
|
||
|
||
// Overflow button - shown in compact/mobile mode instead of individual buttons
|
||
const overflowBtn = jQuery('<div class="ec_btn ec_overflow_btn" title="Actions"><i class="fa-solid fa-ellipsis-vertical"></i></div>');
|
||
// Build overflow menu detached from button and appended to body to avoid clipping
|
||
jQuery('#ec_overflow_menu_body').remove();
|
||
const overflowMenu = jQuery('<div id="ec_overflow_menu_body" class="ec_popup_menu ec_overflow_menu"></div>');
|
||
jQuery('body').append(overflowMenu);
|
||
|
||
// Refresh is first on the left, then layout, users, font, clear, and settings button last
|
||
rightGroup.append(refreshBtn).append(layoutBtn).append(usersBtn).append(fontBtn).append(clearBtn).append(settingsBtn).append(overflowBtn);
|
||
|
||
discordQuickBar.append(leftGroup).append(rightGroup);
|
||
|
||
// Style Indicator - shows current style name AND acts as dropdown
|
||
const styleIndicator = jQuery('<div class="ec_style_indicator ec_style_dropdown_trigger" id="ec_style_indicator"></div>');
|
||
// Create style menu and append to body to avoid clipping issues
|
||
jQuery('#ec_style_menu_body').remove(); // Remove any existing
|
||
const styleMenu = jQuery('<div id="ec_style_menu_body" class="ec_popup_menu ec_style_menu ec_indicator_menu"></div>');
|
||
jQuery('body').append(styleMenu);
|
||
updateStyleIndicator(styleIndicator);
|
||
populateStyleMenu(styleMenu);
|
||
|
||
// Status overlay - separate from content so it persists across updates
|
||
const statusOverlay = jQuery('<div class="ec_status_overlay"></div>');
|
||
|
||
discordContent = jQuery('<div id="discordContent"></div>');
|
||
|
||
const replyContainer = jQuery(`
|
||
<div class="ec_reply_container" >
|
||
<div class="ec_reply_wrapper">
|
||
<input type="text" class="ec_reply_input" placeholder="Type a message to participate..." id="ec_reply_field">
|
||
<div class="ec_reply_send" id="ec_reply_submit" title="Send message"><i class="fa-solid fa-paper-plane"></i></div>
|
||
</div>
|
||
</div>
|
||
`);
|
||
|
||
const resizeHandle = jQuery('<div class="ec_resize_handle"></div>');
|
||
|
||
// Update the append order:
|
||
discordBar.append(discordQuickBar).append(styleIndicator).append(statusOverlay).append(discordContent).append(replyContainer).append(resizeHandle);
|
||
|
||
// Populate Layout Menu
|
||
const layoutMenu = layoutBtn.find('.ec_layout_menu');
|
||
const currentPos = settings.position || 'bottom';
|
||
['Top', 'Bottom', 'Left', 'Right'].forEach(pos => {
|
||
const icon = pos === 'Top' ? 'up' : pos === 'Bottom' ? 'down' : pos === 'Left' ? 'left' : 'right';
|
||
const isSelected = pos.toLowerCase() === currentPos ? ' selected' : '';
|
||
layoutMenu.append(`<div class="ec_menu_item${isSelected}" data-val="${pos.toLowerCase()}" > <i class="fa-solid fa-arrow-${icon}"></i> ${pos}</div> `);
|
||
});
|
||
// Add Pop Out option
|
||
const popoutSelected = currentPos === 'popout' ? ' selected' : '';
|
||
layoutMenu.append(`<div class="ec_menu_item${popoutSelected}" data-val="popout" > <i class="fa-solid fa-arrow-up-right-from-square"></i> Pop Out</div> `);
|
||
|
||
// Populate User Count Menu with current selection highlighted
|
||
const userMenu = usersBtn.find('.ec_user_menu');
|
||
const currentUsers = settings.userCount || 5;
|
||
for (let i = 1; i <= 20; i++) {
|
||
const isSelected = i === currentUsers ? ' selected' : '';
|
||
userMenu.append(`<div class="ec_menu_item${isSelected}" data-val="${i}" > ${i} users</div> `);
|
||
}
|
||
|
||
// Populate Font Size Menu with current selection highlighted
|
||
const fontMenu = fontBtn.find('.ec_font_menu');
|
||
const currentFont = settings.fontSize || 15;
|
||
for (let i = 8; i <= 24; i++) {
|
||
const isSelected = i === currentFont ? ' selected' : '';
|
||
fontMenu.append(`<div class="ec_menu_item${isSelected}" data-val="${i}" > ${i}px</div> `);
|
||
}
|
||
|
||
|
||
updateApplyLayout();
|
||
log('Panel rendered');
|
||
|
||
// ---- Overflow menu population (accordion layout) ----
|
||
const currentPosOF = settings.position || 'bottom';
|
||
const currentUsersOF = settings.userCount || 5;
|
||
const currentFontOF = settings.fontSize || 15;
|
||
const posIcons = { top: 'up', bottom: 'down', left: 'left', right: 'right' };
|
||
|
||
// Helper: build an accordion section
|
||
function makeAccordion(id, icon, label, bodyContent) {
|
||
const acc = jQuery(`
|
||
<div class="ec_of_accordion" >
|
||
<div class="ec_of_acc_header" data-acc="${id}">
|
||
<span><i class="fa-solid ${icon}"></i> ${label}</span>
|
||
<i class="fa-solid fa-chevron-right ec_of_chevron"></i>
|
||
</div>
|
||
<div class="ec_of_acc_body" id="ec_of_${id}_body"></div>
|
||
</div> `);
|
||
acc.find('.ec_of_acc_body').append(bodyContent);
|
||
return acc;
|
||
}
|
||
|
||
// Direct action: Regenerate Chat (always visible at top)
|
||
overflowMenu.append(`<div class="ec_of_item ec_of_action" data-action="refresh"><i class="fa-solid fa-rotate-right"></i><span>Regenerate Chat</span></div>`);
|
||
overflowMenu.append(`<div class="ec_of_divider_line"></div>`);
|
||
|
||
// Accordion: Panel Position
|
||
const posBodies = jQuery('<div class="ec_of_chip_wrap"></div>');
|
||
['top', 'bottom', 'left', 'right'].forEach(pos => {
|
||
const sel = pos === currentPosOF ? ' ec_of_selected' : '';
|
||
posBodies.append(`<div class="ec_of_chip ec_of_pos_chip${sel}" data-action="position" data-val="${pos}"><i class="fa-solid fa-arrow-${posIcons[pos]}"></i> ${pos.charAt(0).toUpperCase() + pos.slice(1)}</div>`);
|
||
});
|
||
posBodies.append(`<div class="ec_of_chip ec_of_pos_chip" data-action="position" data-val="popout"><i class="fa-solid fa-arrow-up-right-from-square"></i> Pop Out</div>`);
|
||
overflowMenu.append(makeAccordion('position', 'fa-table-columns', 'Panel Position', posBodies));
|
||
|
||
// Accordion: User Count (2–20 by twos)
|
||
const userBodies = jQuery('<div class="ec_of_chip_wrap"></div>');
|
||
for (let n = 2; n <= 20; n += 2) {
|
||
const sel = n === currentUsersOF ? ' ec_of_selected' : '';
|
||
userBodies.append(`<div class="ec_of_chip ec_of_num${sel}" data-action="users" data-val="${n}">${n}</div>`);
|
||
}
|
||
overflowMenu.append(makeAccordion('users', 'fa-users', 'User Count', userBodies));
|
||
|
||
// Accordion: Font Size
|
||
const fontBodies = jQuery('<div class="ec_of_chip_wrap"></div>');
|
||
[10, 11, 12, 13, 14, 15, 16, 18, 20].forEach(n => {
|
||
const sel = n === currentFontOF ? ' ec_of_selected' : '';
|
||
fontBodies.append(`<div class="ec_of_chip ec_of_num${sel}" data-action="font" data-val="${n}">${n}px</div>`);
|
||
});
|
||
overflowMenu.append(makeAccordion('font', 'fa-font', 'Font Size', fontBodies));
|
||
|
||
// Bottom actions
|
||
overflowMenu.append(`<div class="ec_of_divider_line"></div>`);
|
||
overflowMenu.append(`<div class="ec_of_item ec_of_action ec_of_danger" data-action="clear"><i class="fa-solid fa-trash-can"></i><span>Clear Chat & Cache</span></div>`);
|
||
overflowMenu.append(`<div class="ec_of_item ec_of_action" data-action="settings"><i class="fa-solid fa-gear"></i><span>Settings</span></div>`);
|
||
|
||
// ---- Compact mode: measure-then-decide approach ----
|
||
// To get accurate button widths, we must measure when buttons are visible.
|
||
// Strategy: briefly remove ec_compact so buttons are measurable, take the reading,
|
||
// then re-apply compact only if genuinely needed. CSS transition is fast enough
|
||
// that this causes no visible flash (elements are in-flow but the toggle is <1ms).
|
||
let naturalRightW = 0;
|
||
let compactDebounce = null;
|
||
|
||
const checkCompact = () => {
|
||
clearTimeout(compactDebounce);
|
||
compactDebounce = setTimeout(() => {
|
||
if (!discordQuickBar || !discordQuickBar[0]) return;
|
||
const bar = discordQuickBar[0];
|
||
const barW = bar.offsetWidth;
|
||
if (barW < 50) return; // not laid out yet
|
||
|
||
const leftEl = bar.querySelector('.ec_header_left');
|
||
const rightEl = bar.querySelector('.ec_header_right');
|
||
if (!leftEl || !rightEl) return;
|
||
|
||
// Temporarily remove compact so we can measure real button widths
|
||
const wasCompact = discordQuickBar.hasClass('ec_compact');
|
||
discordQuickBar.removeClass('ec_compact');
|
||
|
||
// Force a sync reflow so offsetWidth reflects button visibility
|
||
void bar.offsetWidth;
|
||
|
||
// Measure actual rendered widths of the 6 regular buttons
|
||
let measured = 0;
|
||
rightEl.querySelectorAll('.ec_btn:not(.ec_overflow_btn)').forEach(b => {
|
||
measured += b.offsetWidth;
|
||
});
|
||
if (measured > 20) naturalRightW = measured;
|
||
|
||
const leftW = leftEl.offsetWidth;
|
||
const rightW = naturalRightW > 0 ? naturalRightW : 6 * 28;
|
||
// Required: left + right groups must fit in bar with 4px margin
|
||
// (bar padding is already inside offsetWidth, space-between handles gaps)
|
||
const required = leftW + rightW + 4;
|
||
const needsCompact = required > barW;
|
||
|
||
// Apply the correct state
|
||
if (needsCompact) {
|
||
discordQuickBar.addClass('ec_compact');
|
||
}
|
||
// If wasCompact and now not needsCompact, we already removed it above — correct.
|
||
}, 150);
|
||
};
|
||
|
||
if (typeof ResizeObserver !== 'undefined') {
|
||
const ro = new ResizeObserver(checkCompact);
|
||
setTimeout(() => {
|
||
if (discordQuickBar && discordQuickBar[0]) {
|
||
ro.observe(discordQuickBar[0]);
|
||
checkCompact(); // initial check at 400ms (100 setup + 150 debounce = 250)
|
||
}
|
||
}, 250);
|
||
// Second check at 700ms in case browser hasn't fully settled layout yet
|
||
setTimeout(checkCompact, 700);
|
||
} else {
|
||
jQuery(window).on('resize.eccpact', checkCompact);
|
||
setTimeout(checkCompact, 250);
|
||
setTimeout(checkCompact, 700);
|
||
}
|
||
}
|
||
|
||
|
||
|
||
function populateStyleMenu(menu) {
|
||
menu.empty();
|
||
const styles = getAllStyles();
|
||
const { DOMPurify } = SillyTavern.libs;
|
||
styles.forEach(s => {
|
||
const isSelected = s.val === settings.style ? ' selected' : '';
|
||
const safeVal = DOMPurify.sanitize(s.val, { ALLOWED_TAGS: [] });
|
||
const safeLabel = DOMPurify.sanitize(s.label, { ALLOWED_TAGS: [] });
|
||
menu.append(`<div class="ec_menu_item${isSelected}" data-val="${safeVal}" > <i class="fa-solid fa-masks-theater"></i> ${safeLabel}</div> `);
|
||
});
|
||
}
|
||
|
||
function updateStyleIndicator(indicator) {
|
||
const el = indicator || jQuery('#ec_style_indicator');
|
||
if (!el.length) return;
|
||
|
||
const styles = getAllStyles();
|
||
const currentStyle = styles.find(s => s.val === settings.style);
|
||
const styleName = currentStyle ? currentStyle.label : (settings.style || 'Default');
|
||
|
||
// Sanitize style name to prevent XSS
|
||
const { DOMPurify } = SillyTavern.libs;
|
||
const safeStyleName = DOMPurify.sanitize(styleName, { ALLOWED_TAGS: [] });
|
||
|
||
// Keep existing menu if present (main panel uses ec_indicator_menu appended to body)
|
||
const existingMenu = el.find('.ec_indicator_menu');
|
||
el.html(`<i class="fa-solid fa-masks-theater" ></i> <span>Style: ${safeStyleName}</span> <i class="fa-solid fa-caret-down ec_dropdown_arrow"></i>`);
|
||
if (existingMenu.length) el.append(existingMenu);
|
||
|
||
// When updating the main indicator, also sync the floating panel style button
|
||
if (!indicator) {
|
||
updateFloatStyleLabel();
|
||
}
|
||
}
|
||
|
||
function updateApplyLayout() {
|
||
if (!discordBar) return;
|
||
|
||
// If fully disabled (via settings checkbox), hide the panel entirely
|
||
if (!settings.enabled) {
|
||
discordBar.hide();
|
||
return;
|
||
}
|
||
|
||
// Show panel if enabled
|
||
discordBar.show();
|
||
|
||
const pos = settings.position || 'bottom';
|
||
|
||
// Remove all position classes
|
||
discordBar.removeClass('ec_top ec_bottom ec_left ec_right ec_collapsed');
|
||
discordBar.addClass(`ec_${pos} `);
|
||
|
||
// Detach and re-append depending on mode
|
||
discordBar.detach();
|
||
|
||
// Reset inline styles
|
||
discordBar.css({ top: '', bottom: '', left: '', right: '', width: '', height: '' });
|
||
discordContent.attr('style', '');
|
||
|
||
// Apply opacity to backgrounds
|
||
const opacity = (settings.opacity || 85) / 100;
|
||
const bgWithOpacity = `rgba(20, 20, 25, ${opacity})`;
|
||
const headerBgWithOpacity = `rgba(0, 0, 0, ${opacity * 0.3})`;
|
||
discordBar.css('background', bgWithOpacity);
|
||
discordQuickBar.css('background', headerBgWithOpacity);
|
||
|
||
if (pos === 'bottom') {
|
||
// On mobile, insert BEFORE send_form; on desktop, insert AFTER
|
||
const sendForm = jQuery('#send_form');
|
||
const isMobile = window.innerWidth <= 768;
|
||
|
||
if (sendForm.length) {
|
||
if (isMobile) {
|
||
sendForm.before(discordBar);
|
||
} else {
|
||
sendForm.after(discordBar);
|
||
}
|
||
} else {
|
||
// Fallback: try form_sheld
|
||
const formSheld = jQuery('#form_sheld');
|
||
if (formSheld.length) {
|
||
formSheld.append(discordBar);
|
||
} else {
|
||
jQuery('body').append(discordBar);
|
||
}
|
||
}
|
||
// Reset styles for flow layout
|
||
discordBar.css({ width: '100%', height: '' });
|
||
// Restore saved content height (fixes ' px' space bug with template literal)
|
||
discordContent.css({
|
||
'height': `${settings.chatHeight || 200}px`,
|
||
'flex-grow': '0'
|
||
});
|
||
log('Bottom panel placed, content height:', settings.chatHeight);
|
||
} else {
|
||
// Top, Left, Right all append to body (fixed positioning via CSS)
|
||
jQuery('body').append(discordBar);
|
||
|
||
if (pos === 'top') {
|
||
discordContent.css({
|
||
'height': `${settings.chatHeight || 200}px`,
|
||
'flex-grow': '0'
|
||
});
|
||
log('Top panel placed, content height:', settings.chatHeight);
|
||
} else {
|
||
// Side layouts — restore saved panel width
|
||
discordBar.css('width', `${settings.panelWidth || 350}px`);
|
||
discordContent.css({
|
||
'height': '100%',
|
||
'flex-grow': '1'
|
||
});
|
||
}
|
||
}
|
||
|
||
// Apply Collapsed State
|
||
if (settings.collapsed) {
|
||
discordBar.addClass('ec_collapsed');
|
||
} else {
|
||
discordBar.removeClass('ec_collapsed');
|
||
}
|
||
|
||
// Add paused visual state class (panel stays visible, generation is paused)
|
||
if (settings.paused) {
|
||
discordBar.addClass('ec_disabled');
|
||
} else {
|
||
discordBar.removeClass('ec_disabled');
|
||
}
|
||
|
||
updatePanelIcons();
|
||
}
|
||
|
||
function updatePanelIcons() {
|
||
if (!discordBar) return;
|
||
|
||
// Update power button - shows paused state
|
||
const powerBtn = discordBar.find('.ec_power_btn');
|
||
if (!settings.paused) {
|
||
powerBtn.css('color', 'var(--ec-accent)');
|
||
powerBtn.attr('title', 'Toggle On/Off (Currently ON)');
|
||
} else {
|
||
powerBtn.css('color', 'rgba(255, 255, 255, 0.3)');
|
||
powerBtn.attr('title', 'Toggle On/Off (Currently OFF)');
|
||
}
|
||
|
||
// Update collapse button - shows collapsed state with arrow direction
|
||
const collapseBtn = discordBar.find('.ec_collapse_btn i');
|
||
const pos = settings.position || 'bottom';
|
||
if (settings.collapsed) {
|
||
// When collapsed, arrow points toward expansion direction
|
||
if (pos === 'bottom') collapseBtn.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left fa-chevron-right').addClass('fa-chevron-up');
|
||
else if (pos === 'top') collapseBtn.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left fa-chevron-right').addClass('fa-chevron-down');
|
||
else if (pos === 'left') collapseBtn.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left fa-chevron-right').addClass('fa-chevron-right');
|
||
else if (pos === 'right') collapseBtn.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left fa-chevron-right').addClass('fa-chevron-left');
|
||
discordBar.find('.ec_collapse_btn').css('opacity', '0.5');
|
||
} else {
|
||
// When expanded, arrow points toward collapse direction
|
||
if (pos === 'bottom') collapseBtn.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left fa-chevron-right').addClass('fa-chevron-down');
|
||
else if (pos === 'top') collapseBtn.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left fa-chevron-right').addClass('fa-chevron-up');
|
||
else if (pos === 'left') collapseBtn.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left fa-chevron-right').addClass('fa-chevron-left');
|
||
else if (pos === 'right') collapseBtn.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left fa-chevron-right').addClass('fa-chevron-right');
|
||
discordBar.find('.ec_collapse_btn').css('opacity', '1');
|
||
}
|
||
|
||
updateLiveIndicator();
|
||
}
|
||
|
||
function updateLiveIndicator(state) {
|
||
const indicator = jQuery('#ec_live_indicator');
|
||
// Also target the floating panel's live indicator if it exists in the same document
|
||
const popoutInd = document.getElementById('ec_float_live_indicator');
|
||
|
||
if (!indicator.length && !popoutInd) return;
|
||
|
||
let className = 'ec_live_indicator ';
|
||
let title = '';
|
||
|
||
if (state === 'loading') {
|
||
className += 'ec_live_loading';
|
||
title = 'Processing… click to cancel';
|
||
} else if (settings.livestream) {
|
||
className += 'ec_live_on';
|
||
title = 'LIVE — click to pause';
|
||
} else {
|
||
className += 'ec_live_off';
|
||
title = 'Click to enable Livestream';
|
||
}
|
||
|
||
if (indicator.length) {
|
||
indicator.removeClass('ec_live_off ec_live_on ec_live_loading');
|
||
indicator.addClass(className.replace('ec_live_indicator ', ''));
|
||
indicator.attr('title', title);
|
||
}
|
||
|
||
if (popoutInd) {
|
||
popoutInd.className = className;
|
||
popoutInd.title = title;
|
||
}
|
||
}
|
||
|
||
// Shared toggle logic — called by both the LIVE indicator and the settings checkbox
|
||
async function toggleLivestream(enable) {
|
||
const wasEnabled = settings.livestream;
|
||
settings.livestream = enable;
|
||
saveSettings();
|
||
|
||
// Keep settings panel checkbox in sync
|
||
jQuery('#discord_livestream').prop('checked', enable);
|
||
jQuery('#discord_livestream_settings').toggle(enable);
|
||
|
||
updateLiveIndicator(enable ? null : null);
|
||
|
||
if (enable) {
|
||
if (livestreamActive && livestreamQueue.length > 0) {
|
||
// Queue still has messages — just resume the ticker
|
||
resumeLivestream();
|
||
updateLiveIndicator();
|
||
} else {
|
||
// Need a fresh batch — generate silently in the background
|
||
updateLiveIndicator('loading');
|
||
|
||
// Kick off background generation without blocking the UI
|
||
// generateDiscordChat handles livestream display flow internally
|
||
generateDiscordChat().finally(() => {
|
||
updateLiveIndicator();
|
||
});
|
||
}
|
||
} else {
|
||
// Pause: stop the tick but keep the queue intact for resuming later
|
||
pauseLivestream();
|
||
// Abort any in-progress generation
|
||
if (abortController) {
|
||
abortController.abort();
|
||
abortController = null;
|
||
}
|
||
updateLiveIndicator();
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// RESIZE LOGIC
|
||
// ============================================================
|
||
|
||
function initResizeLogic() {
|
||
let isResizing = false;
|
||
let startX, startY, startSize;
|
||
|
||
jQuery(document).on('mousedown touchstart', '.ec_resize_handle', function (e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
isResizing = true;
|
||
startX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX;
|
||
startY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
|
||
const pos = settings.position;
|
||
|
||
if (pos === 'left' || pos === 'right') {
|
||
startSize = settings.panelWidth || 350;
|
||
jQuery('body').css('cursor', 'ew-resize');
|
||
} else {
|
||
// Use saved setting as start size (more reliable than DOM read)
|
||
startSize = settings.chatHeight || 200;
|
||
jQuery('body').css('cursor', 'ns-resize');
|
||
}
|
||
|
||
log('Resize started:', pos, 'startSize:', startSize, 'startY:', startY);
|
||
jQuery(this).addClass('resizing');
|
||
});
|
||
|
||
jQuery(document).on('mousemove touchmove', function (e) {
|
||
if (!isResizing) return;
|
||
|
||
const clientX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX;
|
||
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
|
||
const deltaX = clientX - startX;
|
||
const deltaY = clientY - startY;
|
||
const pos = settings.position;
|
||
|
||
if (pos === 'bottom') {
|
||
// Bottom panel: drag up = bigger, drag down = smaller
|
||
const newHeight = Math.max(80, Math.min(600, startSize - deltaY));
|
||
discordContent.css('height', newHeight + 'px');
|
||
settings.chatHeight = newHeight;
|
||
} else if (pos === 'top') {
|
||
// Top panel: drag down = bigger, drag up = smaller
|
||
const newHeight = Math.max(80, Math.min(600, startSize + deltaY));
|
||
discordContent.css('height', newHeight + 'px');
|
||
settings.chatHeight = newHeight;
|
||
} else if (pos === 'left') {
|
||
const newWidth = Math.max(200, Math.min(window.innerWidth - 50, startSize + deltaX));
|
||
discordBar.css('width', newWidth + 'px');
|
||
settings.panelWidth = newWidth;
|
||
} else if (pos === 'right') {
|
||
const newWidth = Math.max(200, Math.min(window.innerWidth - 50, startSize - deltaX));
|
||
discordBar.css('width', newWidth + 'px');
|
||
settings.panelWidth = newWidth;
|
||
}
|
||
});
|
||
|
||
jQuery(document).on('mouseup touchend', function () {
|
||
if (isResizing) {
|
||
isResizing = false;
|
||
jQuery('.ec_resize_handle').removeClass('resizing');
|
||
jQuery('body').css('cursor', '');
|
||
log('Resize ended, chatHeight:', settings.chatHeight);
|
||
saveSettings();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// EVENT HANDLERS
|
||
// ============================================================
|
||
|
||
function bindEventHandlers() {
|
||
// Prevent duplicate event listener registration
|
||
if (eventsBound) return;
|
||
eventsBound = true;
|
||
|
||
// Handle clicking a username to tag them
|
||
jQuery(document).on('click', '.discord_username', function () {
|
||
const username = jQuery(this).text();
|
||
const input = jQuery('#ec_reply_field');
|
||
input.val(`@${username} `).focus();
|
||
// Scroll the reply input into view for convenience
|
||
const replyContainer = document.querySelector('.ec_reply_container');
|
||
if (replyContainer) replyContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
});
|
||
|
||
// LIVE indicator click — when loading (orange), cancels in-progress generation.
|
||
// Otherwise, toggles livestream on/off.
|
||
jQuery(document).on('click', '#ec_live_indicator', function () {
|
||
if (jQuery('#ec_live_indicator').hasClass('ec_live_loading')) {
|
||
// Orange = processing — treat click as a cancel
|
||
userCancelled = true;
|
||
clearTimeout(debounceTimeout);
|
||
if (abortController) {
|
||
abortController.abort();
|
||
}
|
||
updateLiveIndicator(); // Restore to ec_live_on state
|
||
} else {
|
||
toggleLivestream(!settings.livestream);
|
||
}
|
||
});
|
||
|
||
// Handle sending the message
|
||
const submitReply = async () => {
|
||
if (isGenerating) {
|
||
cancelGenerationContext();
|
||
return;
|
||
}
|
||
if (!settings.chatEnabled) return;
|
||
const input = jQuery('#ec_reply_field');
|
||
const text = input.val().trim();
|
||
if (!text) return;
|
||
|
||
// Clear input immediately for feel
|
||
input.val('');
|
||
|
||
// Show user's message immediately using their configured username
|
||
const myMsg = formatMessage(settings.chatUsername || 'Streamer (You)', text, true);
|
||
const container = jQuery('#discordContent .discord_container');
|
||
if (container.length) {
|
||
container.prepend(myMsg);
|
||
} else {
|
||
jQuery('#discordContent').html(`<div class="discord_container" > ${myMsg}</div> `);
|
||
}
|
||
|
||
// Scroll to top so the user sees their message and the incoming reply
|
||
jQuery('#discordContent').scrollTop(0);
|
||
|
||
// Parse @username mention if present, then generate a targeted single reply
|
||
const atMatch = text.match(/^@([^\s]+)/);
|
||
const targetUsername = atMatch ? atMatch[1] : null;
|
||
|
||
// Generate a quick response from only the mentioned chatter (no full refresh)
|
||
await generateSingleReply(text, targetUsername);
|
||
};
|
||
|
||
jQuery(document).on('click', '#ec_reply_submit', submitReply);
|
||
jQuery(document).on('keypress', '#ec_reply_field', function (e) {
|
||
if (e.which == 13) submitReply();
|
||
});
|
||
|
||
// Chat Participation settings handlers
|
||
jQuery(document).on('change', '#discord_chat_enabled', function () {
|
||
settings.chatEnabled = this.checked;
|
||
jQuery('.ec_reply_container').toggle(this.checked);
|
||
saveSettings();
|
||
});
|
||
|
||
jQuery(document).on('input', '#discord_chat_username', function () {
|
||
settings.chatUsername = jQuery(this).val().trim() || 'Streamer (You)';
|
||
saveSettings();
|
||
});
|
||
|
||
jQuery(document).on('input change', '#discord_chat_avatar_color', function () {
|
||
const color = jQuery(this).val();
|
||
settings.chatAvatarColor = color;
|
||
applyAvatarColor(color);
|
||
// Sync to modal if open
|
||
jQuery('#ecm_chat_avatar_color').val(color);
|
||
saveSettings();
|
||
});
|
||
|
||
jQuery(document).on('change', '#discord_chat_reply_count', function () {
|
||
// Only clamp on commit (blur / Enter) so mid-edit backspacing doesn't
|
||
// immediately replace an empty field with the fallback value of 3.
|
||
const val = Math.max(1, Math.min(12, parseInt(jQuery(this).val()) || 3));
|
||
settings.chatReplyCount = val;
|
||
jQuery(this).val(val);
|
||
// Sync to modal if open
|
||
jQuery('#ecm_chat_reply_count').val(val);
|
||
saveSettings();
|
||
});
|
||
|
||
// Power Button - toggles paused state (keeps panel visible, just pauses generation)
|
||
jQuery(document).on('click', '.ec_power_btn', function () {
|
||
settings.paused = !settings.paused;
|
||
|
||
if (settings.paused) {
|
||
// Pause: stop any ongoing generation (but keep panel visible)
|
||
stopLivestream();
|
||
if (abortController) {
|
||
abortController.abort();
|
||
abortController = null;
|
||
}
|
||
discordBar.addClass('ec_disabled');
|
||
} else {
|
||
// Unpause: remove disabled state
|
||
discordBar.removeClass('ec_disabled');
|
||
}
|
||
|
||
updatePanelIcons();
|
||
saveSettings();
|
||
});
|
||
|
||
// Collapse Button - only toggles panel collapse state (visual only)
|
||
jQuery(document).on('click', '.ec_collapse_btn', function () {
|
||
settings.collapsed = !settings.collapsed;
|
||
|
||
// Immediately apply/remove collapsed class
|
||
if (settings.collapsed) {
|
||
discordBar.addClass('ec_collapsed');
|
||
} else {
|
||
discordBar.removeClass('ec_collapsed');
|
||
}
|
||
|
||
updatePanelIcons();
|
||
saveSettings();
|
||
});
|
||
|
||
// Menu Button Clicks
|
||
jQuery(document).on('click touchend', '.ec_btn', function (e) {
|
||
if (e.type === 'touchend') e.preventDefault();
|
||
const btn = jQuery(this);
|
||
const wasActive = btn.hasClass('active');
|
||
|
||
jQuery('.ec_btn').removeClass('open active');
|
||
jQuery('.ec_popup_menu').hide().css({ top: '', bottom: '', left: '', right: '', position: '' });
|
||
|
||
// Handle dropdowns (like user count, font size, AND chat styles indicator)
|
||
if (btn.hasClass('ec_overflow_btn')) {
|
||
if (!wasActive) {
|
||
btn.addClass('open active');
|
||
const popup = jQuery('#ec_overflow_menu_body');
|
||
const btnRect = btn[0].getBoundingClientRect();
|
||
const isBottom = jQuery('#discordBar').hasClass('ec_bottom');
|
||
const menuW = 260; // approx max-width; real width measured after show
|
||
|
||
// Temporarily show off-screen to measure actual width
|
||
popup.css({ visibility: 'hidden', display: 'block', top: '-9999px', left: '-9999px' });
|
||
const actualW = popup[0].offsetWidth;
|
||
const actualH = popup[0].offsetHeight;
|
||
|
||
// Horizontal: right-align to button, then clamp to viewport
|
||
let left = btnRect.right - actualW;
|
||
if (left < 8) left = 8;
|
||
if (left + actualW > window.innerWidth - 8) left = window.innerWidth - actualW - 8;
|
||
|
||
// Vertical: open below button normally, above when panel is at bottom
|
||
let top;
|
||
if (isBottom) {
|
||
top = btnRect.top - actualH - 6;
|
||
} else {
|
||
top = btnRect.bottom + 6;
|
||
}
|
||
|
||
popup.css({
|
||
visibility: '',
|
||
display: 'block',
|
||
position: 'fixed',
|
||
top: top + 'px',
|
||
left: left + 'px',
|
||
right: 'auto',
|
||
bottom: 'auto',
|
||
});
|
||
}
|
||
} else if (btn.find('.ec_popup_menu').length > 0) {
|
||
if (!wasActive) {
|
||
btn.addClass('open active');
|
||
const popup = btn.find('.ec_popup_menu');
|
||
popup.show();
|
||
// Clamp the popup so it never overflows the left edge of the viewport
|
||
const rect = popup[0].getBoundingClientRect();
|
||
if (rect.left < 8) {
|
||
popup.css({ right: 'auto', left: (8 - rect.left) + 'px' });
|
||
}
|
||
}
|
||
} else if (btn.find('.fa-rotate-right').length) {
|
||
btn.find('i').addClass('fa-spin');
|
||
setTimeout(() => btn.find('i').removeClass('fa-spin'), 1000);
|
||
// Always show the full Processing/Cancel overlay for explicit user-triggered regeneration,
|
||
// even when Livestream is enabled (showOverlay=true bypasses the silent-background path).
|
||
generateDiscordChat(true);
|
||
} else if (btn.find('.fa-trash-can').length) {
|
||
// Clear button clicked — show custom confirmation modal
|
||
showConfirmModal('Clear all generated chat messages and cached commentary?').then(confirmed => {
|
||
if (confirmed) {
|
||
setDiscordText('');
|
||
clearCachedCommentary();
|
||
if (typeof toastr !== 'undefined') toastr.success('Chat and cache cleared');
|
||
}
|
||
});
|
||
} else if (btn.find('.fa-gear').length) {
|
||
// Settings button clicked
|
||
openSettingsModal();
|
||
}
|
||
e.stopPropagation();
|
||
});
|
||
|
||
// Overflow menu action clicks
|
||
jQuery(document).on('click touchend', '.ec_of_action[data-action]', function (e) {
|
||
if (e.type === 'touchend') e.preventDefault();
|
||
e.stopPropagation();
|
||
const action = jQuery(this).data('action');
|
||
jQuery('.ec_btn').removeClass('open active');
|
||
jQuery('.ec_popup_menu').hide().css({ top: '', bottom: '', left: '', right: '', position: '' });
|
||
if (action === 'refresh') {
|
||
jQuery('.ec_btn[title="Regenerate Chat"] i').addClass('fa-spin');
|
||
setTimeout(() => jQuery('.ec_btn[title="Regenerate Chat"] i').removeClass('fa-spin'), 1000);
|
||
generateDiscordChat(true);
|
||
} else if (action === 'clear') {
|
||
showConfirmModal('Clear all generated chat messages and cached commentary?').then(confirmed => {
|
||
if (confirmed) {
|
||
setDiscordText('');
|
||
clearCachedCommentary();
|
||
if (typeof toastr !== 'undefined') toastr.success('Chat and cache cleared');
|
||
}
|
||
});
|
||
} else if (action === 'settings') {
|
||
openSettingsModal();
|
||
}
|
||
});
|
||
|
||
// Accordion toggle inside overflow menu
|
||
jQuery(document).on('click', '.ec_of_acc_header', function (e) {
|
||
e.stopPropagation();
|
||
const header = jQuery(this);
|
||
const body = header.next('.ec_of_acc_body');
|
||
const chevron = header.find('.ec_of_chevron');
|
||
const isOpen = body.hasClass('ec_of_open');
|
||
// Close all accordions first
|
||
jQuery('.ec_of_acc_body').removeClass('ec_of_open');
|
||
jQuery('.ec_of_chevron').removeClass('ec_of_rotated');
|
||
// Open this one if it was closed
|
||
if (!isOpen) {
|
||
body.addClass('ec_of_open');
|
||
chevron.addClass('ec_of_rotated');
|
||
}
|
||
});
|
||
|
||
// Overflow chip clicks (position, users, font)
|
||
jQuery(document).on('click', '.ec_of_chip[data-action]', function (e) {
|
||
e.stopPropagation();
|
||
const chip = jQuery(this);
|
||
const action = chip.data('action');
|
||
const val = chip.data('val');
|
||
|
||
if (action === 'position') {
|
||
if (val === 'popout') {
|
||
openPopoutWindow();
|
||
} else {
|
||
settings.position = val;
|
||
saveSettings();
|
||
updateApplyLayout();
|
||
jQuery('#discord_position').val(val);
|
||
// Update selection highlight within this accordion body
|
||
chip.closest('.ec_of_acc_body').find('.ec_of_chip[data-action="position"]').removeClass('ec_of_selected');
|
||
chip.addClass('ec_of_selected');
|
||
}
|
||
jQuery('.ec_btn').removeClass('open active');
|
||
jQuery('.ec_popup_menu').hide().css({ top: '', bottom: '', left: '', right: '', position: '' });
|
||
} else if (action === 'users') {
|
||
settings.userCount = parseInt(val);
|
||
saveSettings();
|
||
jQuery('#discord_user_count').val(settings.userCount);
|
||
chip.closest('.ec_of_acc_body').find('.ec_of_chip').removeClass('ec_of_selected');
|
||
chip.addClass('ec_of_selected');
|
||
jQuery('.ec_user_menu .ec_menu_item').each(function () {
|
||
jQuery(this).toggleClass('selected', parseInt(jQuery(this).data('val')) === settings.userCount);
|
||
});
|
||
} else if (action === 'font') {
|
||
const size = parseInt(val);
|
||
settings.fontSize = size;
|
||
applyFontSize(size);
|
||
saveSettings();
|
||
jQuery('#discord_font_size').val(size);
|
||
chip.closest('.ec_of_acc_body').find('.ec_of_chip').removeClass('ec_of_selected');
|
||
chip.addClass('ec_of_selected');
|
||
jQuery('.ec_font_menu .ec_menu_item').each(function () {
|
||
jQuery(this).toggleClass('selected', parseInt(jQuery(this).data('val')) === size);
|
||
});
|
||
}
|
||
});
|
||
|
||
// Style Indicator Dropdown Click - menu is in body, position dynamically
|
||
jQuery(document).on('click', '.ec_style_dropdown_trigger', function (e) {
|
||
const trigger = jQuery(this);
|
||
const wasActive = trigger.hasClass('active');
|
||
const menu = jQuery('#ec_style_menu_body');
|
||
|
||
// Close other menus
|
||
jQuery('.ec_btn').removeClass('open active');
|
||
jQuery('.ec_popup_menu').not('#ec_style_menu_body').hide().css({ top: '', bottom: '', left: '', right: '', position: '' });
|
||
|
||
if (!wasActive) {
|
||
trigger.addClass('active');
|
||
// Position menu - check if panel is at bottom position
|
||
const rect = trigger[0].getBoundingClientRect();
|
||
const isBottomPosition = settings.position === 'bottom';
|
||
const menuHeight = menu.outerHeight() || 300; // Estimate if not visible
|
||
|
||
if (isBottomPosition) {
|
||
// Open upward when panel is at bottom
|
||
menu.css({
|
||
position: 'fixed',
|
||
bottom: (window.innerHeight - rect.top) + 'px',
|
||
top: 'auto',
|
||
left: rect.left + 'px',
|
||
width: Math.max(rect.width, 200) + 'px',
|
||
display: 'block',
|
||
maxHeight: (rect.top - 20) + 'px',
|
||
overflowY: 'auto'
|
||
});
|
||
} else {
|
||
// Open downward for other positions
|
||
menu.css({
|
||
position: 'fixed',
|
||
top: rect.bottom + 'px',
|
||
bottom: 'auto',
|
||
left: rect.left + 'px',
|
||
width: Math.max(rect.width, 200) + 'px',
|
||
display: 'block',
|
||
maxHeight: (window.innerHeight - rect.bottom - 20) + 'px',
|
||
overflowY: 'auto'
|
||
});
|
||
}
|
||
} else {
|
||
trigger.removeClass('active');
|
||
menu.hide();
|
||
}
|
||
e.stopPropagation();
|
||
});
|
||
|
||
jQuery(document).on('click', function () {
|
||
jQuery('.ec_btn').removeClass('open active');
|
||
jQuery('.ec_popup_menu').hide().css({ top: '', bottom: '', left: '', right: '', position: '' });
|
||
jQuery('#ec_style_menu_body').hide();
|
||
jQuery('#ec_float_style_menu_body').hide();
|
||
jQuery('.ec_style_dropdown_trigger').removeClass('active');
|
||
});
|
||
|
||
// Track touch start position to distinguish taps from scrolls in popup menus
|
||
let menuTouchStartY = 0;
|
||
let menuTouchStartX = 0;
|
||
jQuery(document).on('touchstart', '.ec_popup_menu', function (e) {
|
||
const touch = e.originalEvent.touches[0];
|
||
menuTouchStartY = touch.clientY;
|
||
menuTouchStartX = touch.clientX;
|
||
});
|
||
// Prevent scroll gestures inside a popup menu from bubbling up to .ec_btn and closing the menu
|
||
jQuery(document).on('touchend', '.ec_popup_menu', function (e) {
|
||
const touch = e.originalEvent.changedTouches[0];
|
||
const deltaY = Math.abs(touch.clientY - menuTouchStartY);
|
||
const deltaX = Math.abs(touch.clientX - menuTouchStartX);
|
||
if (deltaY > 10 || deltaX > 10) {
|
||
e.stopPropagation(); // Scroll — keep menu open
|
||
}
|
||
});
|
||
|
||
// Menu Item Clicks (touchend added for mobile support)
|
||
jQuery(document).on('click touchend', '.ec_menu_item', function (e) {
|
||
if (e.type === 'touchend') {
|
||
// If the finger moved more than 10px, treat as a scroll — ignore
|
||
const touch = e.originalEvent.changedTouches[0];
|
||
const deltaY = Math.abs(touch.clientY - menuTouchStartY);
|
||
const deltaX = Math.abs(touch.clientX - menuTouchStartX);
|
||
if (deltaY > 10 || deltaX > 10) {
|
||
e.stopPropagation();
|
||
return; // Scroll gesture — don't select
|
||
}
|
||
e.preventDefault();
|
||
}
|
||
e.stopPropagation();
|
||
const parent = jQuery(this).closest('.ec_popup_menu');
|
||
const val = jQuery(this).data('val');
|
||
|
||
if (parent.hasClass('ec_style_menu')) {
|
||
settings.style = val;
|
||
saveSettings();
|
||
jQuery('#discord_style').val(val);
|
||
// Update style menu selection
|
||
parent.find('.ec_menu_item').removeClass('selected');
|
||
jQuery(this).addClass('selected');
|
||
updateStyleIndicator();
|
||
updateFloatStyleLabel();
|
||
// Show toast notification about style change
|
||
const styleObj = getAllStyles().find(s => s.val === val);
|
||
const styleName = styleObj ? styleObj.label : val;
|
||
if (typeof toastr !== 'undefined') toastr.info(`Style: ${styleName} `);
|
||
} else if (parent.hasClass('ec_layout_menu')) {
|
||
if (val === 'popout') {
|
||
// Open popout window
|
||
openPopoutWindow();
|
||
// Don't change the position setting, just close menu
|
||
} else {
|
||
settings.position = val;
|
||
saveSettings();
|
||
updateApplyLayout();
|
||
jQuery('#discord_position').val(val);
|
||
}
|
||
} else if (parent.hasClass('ec_user_menu')) {
|
||
syncUserMenu(parseInt(val));
|
||
saveSettings();
|
||
} else if (parent.hasClass('ec_font_menu')) {
|
||
syncFontMenu(parseInt(val));
|
||
saveSettings();
|
||
}
|
||
|
||
if (!parent.hasClass('ec_user_menu') && !parent.hasClass('ec_font_menu')) {
|
||
parent.find('.ec_menu_item').removeClass('selected');
|
||
jQuery(this).addClass('selected');
|
||
}
|
||
|
||
// Close all menus and reset all active states
|
||
jQuery('.ec_btn').removeClass('open active');
|
||
jQuery('.ec_popup_menu').hide().css({ top: '', bottom: '', left: '', right: '', position: '' });
|
||
jQuery('#ec_style_menu_body').hide();
|
||
jQuery('#ec_float_style_menu_body').hide();
|
||
jQuery('.ec_style_dropdown_trigger').removeClass('active');
|
||
});
|
||
|
||
// Settings Panel Bindings - this fully enables/disables the extension (shows/hides panel)
|
||
jQuery('#discord_enabled').on('change', function () {
|
||
settings.enabled = jQuery(this).prop('checked');
|
||
|
||
if (!settings.enabled) {
|
||
// Full disable: stop generation and hide panel
|
||
stopLivestream();
|
||
if (abortController) {
|
||
abortController.abort();
|
||
abortController = null;
|
||
}
|
||
if (discordBar) discordBar.hide();
|
||
} else {
|
||
// Enable: remove paused state and reapply layout (which shows the panel)
|
||
settings.paused = false;
|
||
if (discordBar) {
|
||
discordBar.removeClass('ec_disabled');
|
||
}
|
||
updateApplyLayout();
|
||
}
|
||
|
||
saveSettings();
|
||
updatePanelIcons();
|
||
syncModalFromSettings();
|
||
});
|
||
|
||
jQuery('#discord_style').on('change', function () {
|
||
const val = jQuery(this).val();
|
||
settings.style = val;
|
||
saveSettings();
|
||
updateStyleIndicator();
|
||
if (discordQuickBar) discordQuickBar.find('.ec_style_select').val(val);
|
||
syncModalFromSettings();
|
||
});
|
||
|
||
jQuery('#discord_source').on('change', function () {
|
||
settings.source = jQuery(this).val();
|
||
saveSettings();
|
||
updateSourceVisibility();
|
||
syncModalFromSettings();
|
||
});
|
||
|
||
jQuery('#discord_position').on('change', function () {
|
||
const newPosition = jQuery(this).val();
|
||
if (newPosition === 'popout') {
|
||
// Open popout window
|
||
openPopoutWindow();
|
||
// Reset to previous position (don't actually set position to 'popout')
|
||
jQuery(this).val(settings.position || 'bottom');
|
||
} else {
|
||
settings.position = newPosition;
|
||
saveSettings();
|
||
updateApplyLayout();
|
||
syncModalFromSettings();
|
||
}
|
||
});
|
||
|
||
jQuery('#discord_user_count').on('change', function () {
|
||
settings.userCount = parseInt(jQuery(this).val()) || 5;
|
||
saveSettings();
|
||
syncModalFromSettings();
|
||
});
|
||
|
||
jQuery('#discord_font_size').on('change', function () {
|
||
settings.fontSize = parseInt(jQuery(this).val()) || 15;
|
||
applyFontSize(settings.fontSize);
|
||
saveSettings();
|
||
syncModalFromSettings();
|
||
});
|
||
|
||
jQuery('#discord_opacity').on('input change', function () {
|
||
settings.opacity = parseInt(jQuery(this).val()) || 85;
|
||
jQuery('#discord_opacity_val').text(settings.opacity + '%');
|
||
if (discordBar && discordQuickBar) {
|
||
const opacity = settings.opacity / 100;
|
||
const bgWithOpacity = `rgba(20, 20, 25, ${opacity})`;
|
||
const headerBgWithOpacity = `rgba(0, 0, 0, ${opacity * 0.3})`;
|
||
discordBar.css('background', bgWithOpacity);
|
||
discordQuickBar.css('background', headerBgWithOpacity);
|
||
}
|
||
saveSettings();
|
||
syncModalFromSettings();
|
||
});
|
||
|
||
// Connection Profile selection
|
||
jQuery('#discord_preset_select').on('change', function () {
|
||
settings.preset = jQuery(this).val();
|
||
saveSettings();
|
||
log('Selected connection profile:', settings.preset);
|
||
});
|
||
|
||
jQuery('#discord_openai_url').on('change', function () {
|
||
settings.openai_url = jQuery(this).val();
|
||
saveSettings();
|
||
log('OpenAI URL:', settings.openai_url);
|
||
});
|
||
|
||
// OpenAI Compatible - Key
|
||
jQuery('#discord_openai_key').on('change', function () {
|
||
settings.openai_key = jQuery(this).val();
|
||
saveSettings();
|
||
log('OpenAI Key saved');
|
||
});
|
||
|
||
// OpenAI Compatible - Model
|
||
jQuery('#discord_openai_model').on('change', function () {
|
||
settings.openai_model = jQuery(this).val();
|
||
saveSettings();
|
||
log('OpenAI Model:', settings.openai_model);
|
||
});
|
||
|
||
// OpenAI Compatible - Preset
|
||
jQuery('#discord_openai_preset').on('change', function () {
|
||
settings.openai_preset = jQuery(this).val();
|
||
saveSettings();
|
||
log('OpenAI Preset:', settings.openai_preset);
|
||
});
|
||
|
||
// Ollama - URL
|
||
jQuery('#discord_url').on('change', function () {
|
||
settings.url = jQuery(this).val();
|
||
saveSettings();
|
||
log('Ollama URL:', settings.url);
|
||
});
|
||
|
||
// Ollama - Model selection
|
||
jQuery('#discord_model_select').on('change', function () {
|
||
settings.model = jQuery(this).val();
|
||
saveSettings();
|
||
log('Ollama Model:', settings.model);
|
||
});
|
||
|
||
// Include User Input toggle
|
||
jQuery('#discord_include_user').on('change', function () {
|
||
settings.includeUserInput = jQuery(this).prop('checked');
|
||
// Show/hide context depth dropdown
|
||
jQuery('#discord_context_depth_container').toggle(settings.includeUserInput);
|
||
saveSettings();
|
||
log('Include user input:', settings.includeUserInput);
|
||
});
|
||
|
||
// Context Depth selection
|
||
jQuery('#discord_context_depth').on('change', function () {
|
||
settings.contextDepth = parseInt(jQuery(this).val()) || 4;
|
||
saveSettings();
|
||
log('Context depth:', settings.contextDepth);
|
||
});
|
||
|
||
// Auto-update On Messages toggle
|
||
jQuery('#discord_auto_update').on('change', function () {
|
||
settings.autoUpdateOnMessages = jQuery(this).prop('checked');
|
||
saveSettings();
|
||
log('Auto-update on messages:', settings.autoUpdateOnMessages);
|
||
});
|
||
|
||
// Include Past Generated EchoChambers toggle
|
||
jQuery('#discord_include_past_echo').on('change', function () {
|
||
settings.includePastEchoChambers = jQuery(this).prop('checked');
|
||
saveSettings();
|
||
log('Include past EchoChambers:', settings.includePastEchoChambers);
|
||
});
|
||
|
||
// Include Persona toggle
|
||
jQuery('#discord_include_persona').on('change', function () {
|
||
settings.includePersona = jQuery(this).prop('checked');
|
||
saveSettings();
|
||
log('Include persona:', settings.includePersona);
|
||
});
|
||
|
||
// Include Author's Note toggle
|
||
jQuery('#discord_include_authors_note').on('change', function () {
|
||
settings.includeAuthorsNote = jQuery(this).prop('checked');
|
||
saveSettings();
|
||
log('Include authors note:', settings.includeAuthorsNote);
|
||
});
|
||
|
||
// Include Character Description toggle
|
||
jQuery('#discord_include_character_description').on('change', function () {
|
||
settings.includeCharacterDescription = jQuery(this).prop('checked');
|
||
saveSettings();
|
||
log('Include character description:', settings.includeCharacterDescription);
|
||
});
|
||
|
||
// Include Summary toggle
|
||
jQuery('#discord_include_summary').on('change', function () {
|
||
settings.includeSummary = jQuery(this).prop('checked');
|
||
saveSettings();
|
||
log('Include summary:', settings.includeSummary);
|
||
});
|
||
|
||
// Include World Info toggle
|
||
jQuery('#discord_include_world_info').on('change', function () {
|
||
settings.includeWorldInfo = jQuery(this).prop('checked');
|
||
jQuery('#discord_wi_budget_container').toggle(settings.includeWorldInfo);
|
||
saveSettings();
|
||
log('Include world info:', settings.includeWorldInfo);
|
||
});
|
||
|
||
// World Info budget input
|
||
jQuery('#discord_wi_budget').on('change', function () {
|
||
settings.wiBudget = Math.max(0, parseInt(jQuery(this).val()) || 0);
|
||
jQuery(this).val(settings.wiBudget);
|
||
saveSettings();
|
||
log('WI budget:', settings.wiBudget, settings.wiBudget === 0 ? '(unlimited - use ST budget)' : 'tokens');
|
||
});
|
||
|
||
// Livestream toggle (settings panel) — delegates to shared toggleLivestream()
|
||
jQuery('#discord_livestream').on('change', function () {
|
||
toggleLivestream(jQuery(this).prop('checked'));
|
||
});
|
||
|
||
// Livestream batch size
|
||
jQuery('#discord_livestream_batch_size').on('change', function () {
|
||
settings.livestreamBatchSize = parseInt(jQuery(this).val()) || 20;
|
||
saveSettings();
|
||
log('Livestream batch size:', settings.livestreamBatchSize);
|
||
});
|
||
|
||
// Livestream minimum wait time
|
||
jQuery('#discord_livestream_min_wait').on('change', function () {
|
||
settings.livestreamMinWait = parseInt(jQuery(this).val()) || 5;
|
||
saveSettings();
|
||
log('Livestream min wait:', settings.livestreamMinWait);
|
||
});
|
||
|
||
// Livestream maximum wait time
|
||
jQuery('#discord_livestream_max_wait').on('change', function () {
|
||
settings.livestreamMaxWait = parseInt(jQuery(this).val()) || 60;
|
||
saveSettings();
|
||
log('Livestream max wait:', settings.livestreamMaxWait);
|
||
});
|
||
|
||
// Livestream mode radio buttons
|
||
jQuery('input[name=\"discord_livestream_mode\"]').on('change', function () {
|
||
settings.livestreamMode = jQuery(this).val();
|
||
saveSettings();
|
||
log('Livestream mode:', settings.livestreamMode);
|
||
});
|
||
|
||
// Style Editor button
|
||
jQuery(document).on('click', '#discord_open_style_editor', function () {
|
||
openStyleEditor();
|
||
});
|
||
|
||
// Import Style file
|
||
jQuery(document).on('click', '#discord_import_btn', function () {
|
||
jQuery('#discord_import_file').click();
|
||
});
|
||
|
||
// Export Style button
|
||
jQuery(document).on('click', '#discord_export_btn', async function () {
|
||
const currentStyle = settings.style || 'twitch';
|
||
const styles = getAllStyles();
|
||
const styleObj = styles.find(s => s.val === currentStyle);
|
||
const styleName = styleObj ? styleObj.label : currentStyle;
|
||
|
||
// Get the prompt content
|
||
let content = '';
|
||
if (settings.custom_styles && settings.custom_styles[currentStyle]) {
|
||
content = settings.custom_styles[currentStyle].prompt;
|
||
} else {
|
||
content = await loadChatStyle(currentStyle);
|
||
}
|
||
|
||
const blob = new Blob([content], { type: 'text/markdown' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `echochamber_${styleName.toLowerCase().replace(/[^a-z0-9]/g, '_')}.md`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
|
||
if (typeof toastr !== 'undefined') toastr.success(`Style "${styleName}" exported!`);
|
||
});
|
||
|
||
jQuery(document).on('change', '#discord_import_file', function () {
|
||
const file = this.files[0];
|
||
if (!file) return;
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = function (e) {
|
||
const content = e.target.result;
|
||
const name = file.name.replace(/\.md$/i, '');
|
||
const id = 'custom_' + name.toLowerCase().replace(/[^a-z0-9]/g, '_') + '_' + Date.now();
|
||
|
||
if (!settings.custom_styles) settings.custom_styles = {};
|
||
settings.custom_styles[id] = { name: name, prompt: content };
|
||
saveSettings();
|
||
updateAllDropdowns();
|
||
|
||
if (typeof toastr !== 'undefined') toastr.success(`Imported style: ${name} `);
|
||
log('Imported style:', id);
|
||
};
|
||
reader.readAsText(file);
|
||
this.value = ''; // Reset to allow re-importing same file
|
||
});
|
||
|
||
// SillyTavern Events
|
||
const context = SillyTavern.getContext();
|
||
if (context.eventSource && context.eventTypes) {
|
||
// Only auto-generate on new message if autoUpdateOnMessages is enabled
|
||
context.eventSource.on(context.eventTypes.MESSAGE_RECEIVED, () => {
|
||
// Don't auto-generate if there's no chat or it's empty (fresh chat)
|
||
const ctx = SillyTavern.getContext();
|
||
if (!ctx.chat || ctx.chat.length === 0) return;
|
||
|
||
// Don't auto-generate if we're currently loading/switching chats
|
||
if (isLoadingChat) return;
|
||
|
||
// Don't auto-generate if character editor is open (editing character cards)
|
||
const characterEditor = document.querySelector('#character_popup');
|
||
const isCharacterEditorOpen = characterEditor && characterEditor.style.display !== 'none' && characterEditor.offsetParent !== null;
|
||
if (isCharacterEditorOpen) return;
|
||
|
||
// Don't auto-generate if we're in the character creation/management area
|
||
const charCreatePanel = document.querySelector('#rm_ch_create_block');
|
||
const isCreatingCharacter = charCreatePanel && charCreatePanel.style.display !== 'none' && charCreatePanel.offsetParent !== null;
|
||
if (isCreatingCharacter) return;
|
||
|
||
// Don't auto-generate if there's no valid chatId (indicates we're not in an actual conversation)
|
||
if (!ctx.chatId) return;
|
||
|
||
// Only trigger on AI character messages, not user messages
|
||
const lastMessage = ctx.chat[ctx.chat.length - 1];
|
||
if (!lastMessage || lastMessage.is_user) {
|
||
// This is a user message or no message - don't auto-generate
|
||
return;
|
||
}
|
||
|
||
// Determine if we should auto-generate
|
||
let shouldAutoGenerate = false;
|
||
|
||
if (settings.livestream && settings.livestreamMode === 'onMessage') {
|
||
// Livestream in onMessage mode takes priority
|
||
shouldAutoGenerate = true;
|
||
} else if (!settings.livestream && settings.autoUpdateOnMessages === true) {
|
||
// Regular auto-update (only if livestream is off)
|
||
shouldAutoGenerate = true;
|
||
}
|
||
|
||
onChatEvent(false, shouldAutoGenerate);
|
||
});
|
||
// On chat change (loading a conversation), clear display and try to restore from metadata
|
||
context.eventSource.on(context.eventTypes.CHAT_CHANGED, () => {
|
||
// Set flag to prevent MESSAGE_RECEIVED from triggering during chat load
|
||
isLoadingChat = true;
|
||
onChatEvent(false, false);
|
||
// Clear the flag after a short delay to allow legitimate new messages
|
||
setTimeout(() => { isLoadingChat = false; }, 1000);
|
||
});
|
||
context.eventSource.on(context.eventTypes.GENERATION_STOPPED, () => setStatus(''));
|
||
// Refresh profiles when settings change (handles async loading)
|
||
context.eventSource.on(context.eventTypes.SETTINGS_UPDATED, () => populateConnectionProfiles());
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// INITIALIZATION
|
||
// ============================================================
|
||
|
||
// ============================================================
|
||
// SETTINGS PANEL ACCORDION
|
||
// ============================================================
|
||
|
||
function initEcSettingsAccordions() {
|
||
// Wire up all accordion header buttons
|
||
document.querySelectorAll('.ec-s-section-header').forEach(btn => {
|
||
btn.addEventListener('click', function () {
|
||
const expanded = this.getAttribute('aria-expanded') === 'true';
|
||
const body = this.nextElementSibling;
|
||
this.setAttribute('aria-expanded', String(!expanded));
|
||
if (body) body.hidden = expanded;
|
||
});
|
||
});
|
||
|
||
// Open sections marked with data-default-open
|
||
document.querySelectorAll('.ec-s-section[data-default-open]').forEach(section => {
|
||
const btn = section.querySelector('.ec-s-section-header');
|
||
const body = section.querySelector('.ec-s-section-body');
|
||
if (btn && body) {
|
||
btn.setAttribute('aria-expanded', 'true');
|
||
body.hidden = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function init() {
|
||
log('Initializing...');
|
||
|
||
// Wait for SillyTavern to be ready
|
||
if (typeof SillyTavern === 'undefined' || !SillyTavern.getContext) {
|
||
warn('SillyTavern not ready, retrying in 500ms...');
|
||
setTimeout(init, 500);
|
||
return;
|
||
}
|
||
|
||
const context = SillyTavern.getContext();
|
||
log('Context available:', !!context);
|
||
|
||
// Note: FontAwesome is already included by SillyTavern - do not inject a duplicate
|
||
|
||
// Load settings HTML template
|
||
try {
|
||
if (context.renderExtensionTemplateAsync) {
|
||
// Try to find the correct module name from script path
|
||
const scripts = document.querySelectorAll('script[src*="index.js"]');
|
||
let moduleName = 'third-party/SillyTavern-EchoChamber';
|
||
for (const script of scripts) {
|
||
const match = script.src.match(/extensions\/(.+?)\/index\.js/);
|
||
if (match && (match[1].includes('EchoChamber') || match[1].includes('DiscordChat'))) {
|
||
moduleName = match[1];
|
||
break;
|
||
}
|
||
}
|
||
log('Detected module name:', moduleName);
|
||
|
||
const settingsHtml = await context.renderExtensionTemplateAsync(moduleName, 'settings');
|
||
jQuery('#extensions_settings').append(settingsHtml);
|
||
log('Settings template loaded');
|
||
initEcSettingsAccordions();
|
||
}
|
||
} catch (err) {
|
||
error('Failed to load settings template:', err);
|
||
}
|
||
|
||
// Initialize - load settings FIRST so panel can use them
|
||
loadSettings();
|
||
renderPanel();
|
||
|
||
// Toggle chat participation container based on loaded setting (must be after renderPanel)
|
||
jQuery('.ec_reply_container').toggle(settings.chatEnabled !== false);
|
||
|
||
// Update Pop Out visibility based on screen size
|
||
updatePopoutVisibility();
|
||
|
||
// Update on window resize
|
||
jQuery(window).on('resize', debounce(() => {
|
||
updatePopoutVisibility();
|
||
}, 250));
|
||
initResizeLogic();
|
||
bindEventHandlers();
|
||
|
||
// Restore cached commentary if there's an active chat
|
||
if (context.chatId) {
|
||
restoreCachedCommentary();
|
||
}
|
||
|
||
log('Initialization complete');
|
||
}
|
||
|
||
// Start when DOM is ready
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
|
||
})(); |