Add optional below-chat Present Characters panel (#3)

This commit is contained in:
Tremendoussly
2026-03-08 22:58:42 +01:00
committed by GitHub
parent ae9e44eafb
commit 2f98686e60
16 changed files with 593 additions and 143 deletions
+10
View File
@@ -134,6 +134,7 @@ import {
removeDesktopTabs, removeDesktopTabs,
updateStripWidgets updateStripWidgets
} from './src/systems/ui/desktop.js'; } from './src/systems/ui/desktop.js';
import { removeAlternatePresentCharactersPanel } from './src/systems/ui/alternatePresentCharacters.js';
// Feature modules // Feature modules
import { setupPlotButtons, sendPlotProgression } from './src/systems/features/plotProgression.js'; import { setupPlotButtons, sendPlotProgression } from './src/systems/features/plotProgression.js';
@@ -228,6 +229,7 @@ async function addExtensionSettings() {
$('#rpg-mobile-toggle').remove(); $('#rpg-mobile-toggle').remove();
$('#rpg-collapse-toggle').remove(); $('#rpg-collapse-toggle').remove();
$('#rpg-plot-buttons').remove(); // Remove plot buttons $('#rpg-plot-buttons').remove(); // Remove plot buttons
removeAlternatePresentCharactersPanel();
} else if (extensionSettings.enabled && !wasEnabled) { } else if (extensionSettings.enabled && !wasEnabled) {
// Enabling extension - initialize UI // Enabling extension - initialize UI
await initUI(); await initUI();
@@ -339,6 +341,13 @@ async function initUI() {
extensionSettings.showCharacterThoughts = $(this).prop('checked'); extensionSettings.showCharacterThoughts = $(this).prop('checked');
saveSettings(); saveSettings();
updateSectionVisibility(); updateSectionVisibility();
renderThoughts();
});
$('#rpg-toggle-alt-present-characters').on('change', function() {
extensionSettings.showAlternatePresentCharactersPanel = $(this).prop('checked');
saveSettings();
renderThoughts();
}); });
$('#rpg-toggle-inventory').on('change', function() { $('#rpg-toggle-inventory').on('change', function() {
@@ -1053,6 +1062,7 @@ async function initUI() {
$('#rpg-toggle-user-stats').prop('checked', extensionSettings.showUserStats); $('#rpg-toggle-user-stats').prop('checked', extensionSettings.showUserStats);
$('#rpg-toggle-info-box').prop('checked', extensionSettings.showInfoBox); $('#rpg-toggle-info-box').prop('checked', extensionSettings.showInfoBox);
$('#rpg-toggle-thoughts').prop('checked', extensionSettings.showCharacterThoughts); $('#rpg-toggle-thoughts').prop('checked', extensionSettings.showCharacterThoughts);
$('#rpg-toggle-alt-present-characters').prop('checked', extensionSettings.showAlternatePresentCharactersPanel ?? false);
$('#rpg-toggle-inventory').prop('checked', extensionSettings.showInventory); $('#rpg-toggle-inventory').prop('checked', extensionSettings.showInventory);
$('#rpg-toggle-quests').prop('checked', extensionSettings.showQuests); $('#rpg-toggle-quests').prop('checked', extensionSettings.showQuests);
$('#rpg-toggle-lock-icons').prop('checked', extensionSettings.showLockIcons ?? true); $('#rpg-toggle-lock-icons').prop('checked', extensionSettings.showLockIcons ?? true);
+1
View File
@@ -29,6 +29,7 @@ export const defaultSettings = {
showUserStats: true, showUserStats: true,
showInfoBox: true, showInfoBox: true,
showCharacterThoughts: true, showCharacterThoughts: true,
showAlternatePresentCharactersPanel: false,
showInventory: true, // Show inventory section (v2 system) showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section showQuests: true, // Show quests section
showLockIcons: true, // Show lock/unlock icons on tracker items showLockIcons: true, // Show lock/unlock icons on tracker items
+5
View File
@@ -376,6 +376,11 @@ export function loadSettings() {
settingsChanged = true; settingsChanged = true;
} }
if (extensionSettings.showAlternatePresentCharactersPanel === undefined) {
extensionSettings.showAlternatePresentCharactersPanel = false;
settingsChanged = true;
}
// Save migrated settings // Save migrated settings
if (settingsChanged) { if (settingsChanged) {
saveSettings(); saveSettings();
+1
View File
@@ -18,6 +18,7 @@ export let extensionSettings = {
showUserStats: true, showUserStats: true,
showInfoBox: true, showInfoBox: true,
showCharacterThoughts: true, showCharacterThoughts: true,
showAlternatePresentCharactersPanel: false,
showInventory: true, // Show inventory section (v2 system) showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section showQuests: true, // Show quests section
showThoughtsInChat: true, // Show thoughts overlay in chat showThoughtsInChat: true, // Show thoughts overlay in chat
+2
View File
@@ -34,6 +34,8 @@
"template.settingsModal.display.showInfoBoxNote": "Display location, time, weather, and recent events.", "template.settingsModal.display.showInfoBoxNote": "Display location, time, weather, and recent events.",
"template.settingsModal.display.showPresentCharacters": "Show Present Characters", "template.settingsModal.display.showPresentCharacters": "Show Present Characters",
"template.settingsModal.display.showPresentCharactersNote": "Display character portraits with their current thoughts and status.", "template.settingsModal.display.showPresentCharactersNote": "Display character portraits with their current thoughts and status.",
"template.settingsModal.display.showBelowChatPresentCharacters": "Show Below-Chat Present Characters",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "Display a compact Present Characters panel below the chat.",
"template.settingsModal.display.narratorMode": "Narrator Mode", "template.settingsModal.display.narratorMode": "Narrator Mode",
"template.settingsModal.display.narratorModeNote": "Use character card as narrator. Infer characters from context instead of using fixed character references.", "template.settingsModal.display.narratorModeNote": "Use character card as narrator. Infer characters from context instead of using fixed character references.",
"template.settingsModal.display.showInventory": "Show Inventory", "template.settingsModal.display.showInventory": "Show Inventory",
+2
View File
@@ -35,6 +35,8 @@
"template.settingsModal.display.showInfoBoxNote": "Afficher le lieu, l'heure, la météo et les événements récents.", "template.settingsModal.display.showInfoBoxNote": "Afficher le lieu, l'heure, la météo et les événements récents.",
"template.settingsModal.display.showPresentCharacters": "Afficher Personnages Présents", "template.settingsModal.display.showPresentCharacters": "Afficher Personnages Présents",
"template.settingsModal.display.showPresentCharactersNote": "Afficher les portraits des personnages avec leurs pensées actuelles et leur statut.", "template.settingsModal.display.showPresentCharactersNote": "Afficher les portraits des personnages avec leurs pensées actuelles et leur statut.",
"template.settingsModal.display.showBelowChatPresentCharacters": "Afficher les personnages sous le chat",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "Afficher un panneau compact des personnages présents sous le chat.",
"template.settingsModal.display.narratorMode": "Mode Narrateur", "template.settingsModal.display.narratorMode": "Mode Narrateur",
"template.settingsModal.display.narratorModeNote": "Utiliser la carte de personnage comme narrateur. Déduire les personnages du contexte au lieu d'utiliser des références de personnages fixes.", "template.settingsModal.display.narratorModeNote": "Utiliser la carte de personnage comme narrateur. Déduire les personnages du contexte au lieu d'utiliser des références de personnages fixes.",
"template.settingsModal.display.showInventory": "Afficher Inventaire", "template.settingsModal.display.showInventory": "Afficher Inventaire",
+2
View File
@@ -34,6 +34,8 @@
"template.settingsModal.display.showInfoBoxNote": "Отображение локации, времени, погоды и недавних событий.", "template.settingsModal.display.showInfoBoxNote": "Отображение локации, времени, погоды и недавних событий.",
"template.settingsModal.display.showPresentCharacters": "Показывать персонажей", "template.settingsModal.display.showPresentCharacters": "Показывать персонажей",
"template.settingsModal.display.showPresentCharactersNote": "Показывать портреты персонажей с их текущимы мыслями и статусом.", "template.settingsModal.display.showPresentCharactersNote": "Показывать портреты персонажей с их текущимы мыслями и статусом.",
"template.settingsModal.display.showBelowChatPresentCharacters": "Показывать персонажей под чатом",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "Показывать компактную панель персонажей под чатом.",
"template.settingsModal.display.narratorMode": "Режим расказчика", "template.settingsModal.display.narratorMode": "Режим расказчика",
"template.settingsModal.display.narratorModeNote": "Использовать карточку персонажа в качестве расказчика. Персонажи берутся из контекста вместо фиксированных отсылок.", "template.settingsModal.display.narratorModeNote": "Использовать карточку персонажа в качестве расказчика. Персонажи берутся из контекста вместо фиксированных отсылок.",
"template.settingsModal.display.showInventory": "Показывать инвентарь", "template.settingsModal.display.showInventory": "Показывать инвентарь",
+2
View File
@@ -30,6 +30,8 @@
"template.settingsModal.display.showUserStats": "顯示 user 屬性", "template.settingsModal.display.showUserStats": "顯示 user 屬性",
"template.settingsModal.display.showInfoBox": "顯示資訊框", "template.settingsModal.display.showInfoBox": "顯示資訊框",
"template.settingsModal.display.showPresentCharacters": "顯示在場角色", "template.settingsModal.display.showPresentCharacters": "顯示在場角色",
"template.settingsModal.display.showBelowChatPresentCharacters": "顯示聊天下方的在場角色",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "在聊天下方顯示精簡的在場角色面板。",
"template.settingsModal.display.showInventory": "顯示物品欄", "template.settingsModal.display.showInventory": "顯示物品欄",
"template.settingsModal.display.showLockIcons": "顯示鎖定/解鎖追蹤器", "template.settingsModal.display.showLockIcons": "顯示鎖定/解鎖追蹤器",
"template.settingsModal.display.showLockIconsNote": "在追蹤器項目上顯示鎖定/解鎖圖示,以防止 AI 修改它們。", "template.settingsModal.display.showLockIconsNote": "在追蹤器項目上顯示鎖定/解鎖圖示,以防止 AI 修改它們。",
+2 -1
View File
@@ -9,6 +9,7 @@ import { selected_group, getGroupMembers, groups } from '../../../../../../group
import { extensionSettings, committedTrackerData } from '../../core/state.js'; import { extensionSettings, committedTrackerData } from '../../core/state.js';
import { currentEncounter } from '../features/encounterState.js'; import { currentEncounter } from '../features/encounterState.js';
import { repairJSON } from '../../utils/jsonRepair.js'; import { repairJSON } from '../../utils/jsonRepair.js';
import { isPresentCharactersEnabled } from '../../utils/presentCharacters.js';
import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js'; import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js';
import { applyLocks } from './lockManager.js'; import { applyLocks } from './lockManager.js';
@@ -709,7 +710,7 @@ export async function buildCombatSummaryPrompt(combatLog, result) {
summaryMessage += `- Never quote ${userName} directly. Express their actions and dialogue using ONLY indirect speech (e.g., "${userName} swung their sword" or "${userName} asked for help").\n\n`; summaryMessage += `- Never quote ${userName} directly. Express their actions and dialogue using ONLY indirect speech (e.g., "${userName} swung their sword" or "${userName} asked for help").\n\n`;
// If in Together mode and trackers are enabled, add tracker update instructions // If in Together mode and trackers are enabled, add tracker update instructions
if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts)) { if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || isPresentCharactersEnabled())) {
summaryMessage += `\n--- TRACKER UPDATE ---\n\n`; summaryMessage += `\n--- TRACKER UPDATE ---\n\n`;
summaryMessage += `After the [FIGHT CONCLUDED] summary, update the RPG trackers to reflect ${userName}'s state AFTER the combat encounter. `; summaryMessage += `After the [FIGHT CONCLUDED] summary, update the RPG trackers to reflect ${userName}'s state AFTER the combat encounter. `;
summaryMessage += `Account for any injuries sustained, resources used, emotional state changes, or other consequences of the battle.\n\n`; summaryMessage += `Account for any injuries sustained, resources used, emotional state changes, or other consequences of the battle.\n\n`;
+6 -5
View File
@@ -14,6 +14,7 @@ import {
addLockInstruction addLockInstruction
} from './jsonPromptHelpers.js'; } from './jsonPromptHelpers.js';
import { applyLocks } from './lockManager.js'; import { applyLocks } from './lockManager.js';
import { isPresentCharactersEnabled } from '../../utils/presentCharacters.js';
// Type imports // Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ /** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
@@ -293,7 +294,7 @@ export function generateTrackerExample() {
} }
} }
if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) { if (isPresentCharactersEnabled() && committedTrackerData.characterThoughts) {
try { try {
JSON.parse(committedTrackerData.characterThoughts); JSON.parse(committedTrackerData.characterThoughts);
const lockedData = applyLocks(committedTrackerData.characterThoughts, 'characters'); const lockedData = applyLocks(committedTrackerData.characterThoughts, 'characters');
@@ -329,7 +330,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
let instructions = ''; let instructions = '';
// Check if any trackers are enabled // Check if any trackers are enabled
const hasAnyTrackers = extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts; const hasAnyTrackers = extensionSettings.showUserStats || extensionSettings.showInfoBox || isPresentCharactersEnabled();
// Only add tracker instructions if at least one tracker is enabled // Only add tracker instructions if at least one tracker is enabled
if (hasAnyTrackers) { if (hasAnyTrackers) {
@@ -360,7 +361,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
if (extensionSettings.showInfoBox) { if (extensionSettings.showInfoBox) {
enabledTrackers.push('infoBox'); enabledTrackers.push('infoBox');
} }
if (extensionSettings.showCharacterThoughts) { if (isPresentCharactersEnabled()) {
enabledTrackers.push('characters'); enabledTrackers.push('characters');
} }
@@ -383,7 +384,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
instructions += enabledTrackers.indexOf('infoBox') < enabledTrackers.length - 1 ? ',\n' : '\n'; instructions += enabledTrackers.indexOf('infoBox') < enabledTrackers.length - 1 ? ',\n' : '\n';
} }
if (extensionSettings.showCharacterThoughts) { if (isPresentCharactersEnabled()) {
instructions += ' "characters": '; instructions += ' "characters": ';
const charactersJSON = buildCharactersJSONInstruction(); const charactersJSON = buildCharactersJSONInstruction();
// Add 2 spaces to all lines after the first to properly nest within root object // Add 2 spaces to all lines after the first to properly nest within root object
@@ -1061,7 +1062,7 @@ export function generateContextualSummary() {
} }
// Add Present Characters tracker data if enabled // Add Present Characters tracker data if enabled
if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) { if (isPresentCharactersEnabled() && committedTrackerData.characterThoughts) {
try { try {
const formatted = formatTrackerDataForContext(committedTrackerData.characterThoughts, 'characters', userName); const formatted = formatTrackerDataForContext(committedTrackerData.characterThoughts, 'characters', userName);
if (formatted) { if (formatted) {
+13 -137
View File
@@ -4,20 +4,24 @@
*/ */
import { getContext } from '../../../../../../extensions.js'; import { getContext } from '../../../../../../extensions.js';
import { this_chid, characters } from '../../../../../../../script.js';
import { selected_group, getGroupMembers } from '../../../../../../group-chats.js';
import { import {
extensionSettings, extensionSettings,
lastGeneratedData, lastGeneratedData,
committedTrackerData, committedTrackerData,
$thoughtsContainer, $thoughtsContainer,
FALLBACK_AVATAR_DATA_URI,
addDebugLog addDebugLog
} from '../../core/state.js'; } from '../../core/state.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { saveChatData, saveSettings, getCurrentMessageSwipeTrackerData, setMessageSwipeTrackerField } from '../../core/persistence.js'; import { saveChatData, saveSettings, getCurrentMessageSwipeTrackerData, setMessageSwipeTrackerField } from '../../core/persistence.js';
import { getSafeThumbnailUrl } from '../../utils/avatars.js'; import {
stripBrackets,
extractFieldValue,
toSnakeCase,
getPresentCharactersTrackerData,
resolvePresentCharacterPortrait
} from '../../utils/presentCharacters.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js'; import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { renderAlternatePresentCharacters } from '../ui/alternatePresentCharacters.js';
/** /**
* Helper to generate lock icon HTML if setting is enabled * Helper to generate lock icon HTML if setting is enabled
@@ -81,80 +85,14 @@ function getStatColor(percentage, lowColor, highColor, lowOpacity = 100, highOpa
return `rgba(${r}, ${g}, ${b}, ${a})`; return `rgba(${r}, ${g}, ${b}, ${a})`;
} }
/**
* Strips leading and trailing square brackets from a string value.
* Used to clean placeholder notation that AI might include in responses.
* @param {string} value - The value to clean
* @returns {string} Cleaned value without surrounding brackets
*/
function stripBrackets(value) {
if (typeof value !== 'string') return value;
return value.replace(/^\[|\]$/g, '').trim();
}
/**
* Extracts the actual value from a field that might be locked.
* If the field is an object with {value, locked}, returns the value.
* Otherwise returns the field as-is.
* @param {any} fieldValue - The field value (might be string or {value, locked} object)
* @returns {string} The actual string value
*/
function extractFieldValue(fieldValue) {
if (fieldValue && typeof fieldValue === 'object' && 'value' in fieldValue) {
return fieldValue.value || '';
}
return fieldValue || '';
}
/**
* Converts a field name to snake_case for use as JSON key
* Example: "Test Tracker" -> "test_tracker"
* @param {string} name - Field name to convert
* @returns {string} snake_case version
*/
function toSnakeCase(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Fuzzy name matching that handles:
* - Exact matches: "Sabrina" === "Sabrina"
* - Parenthetical additions: "Sabrina" matches "Sabrina (Margrokha's Avatar)"
* - Title additions: "Sabrina" matches "Princess Sabrina"
* - Word boundaries: "Sabrina" won't match "Sabrina's Mother"
*
* @param {string} cardName - Name from the character card
* @param {string} aiName - Name generated by the AI
* @returns {boolean} True if names match
*/
function namesMatch(cardName, aiName) {
if (!cardName || !aiName) return false;
// 1. Exact match (fast path)
if (cardName.toLowerCase() === aiName.toLowerCase()) return true;
// 2. Strip parentheses and match
const stripParens = (s) => s.replace(/\s*\([^)]*\)/g, '').trim();
const cardCore = stripParens(cardName).toLowerCase();
const aiCore = stripParens(aiName).toLowerCase();
if (cardCore === aiCore) return true;
// 3. Check if card name appears as complete word in AI name
// Escape special regex characters to prevent "Invalid regular expression" errors
const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`);
return wordBoundary.test(aiCore);
}
/** /**
* Renders character thoughts (Present Characters) panel. * Renders character thoughts (Present Characters) panel.
* Displays character cards with avatars, relationship badges, and traits. * Displays character cards with avatars, relationship badges, and traits.
* Includes event listeners for editable character fields. * Includes event listeners for editable character fields.
*/ */
export function renderThoughts({ preserveScroll = false, useCommittedFallback = true } = {}) { export function renderThoughts({ preserveScroll = false, useCommittedFallback = true } = {}) {
renderAlternatePresentCharacters({ useCommittedFallback });
if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) { if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
return; return;
} }
@@ -169,7 +107,7 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback =
} }
// Don't render if no data exists (e.g., after cache clear) // Don't render if no data exists (e.g., after cache clear)
const thoughtsData = lastGeneratedData.characterThoughts || (useCommittedFallback ? committedTrackerData.characterThoughts : null); const thoughtsData = getPresentCharactersTrackerData({ useCommittedFallback });
if (!thoughtsData) { if (!thoughtsData) {
$thoughtsContainer.html('<div class="rpg-inventory-empty">No character data generated yet</div>'); $thoughtsContainer.html('<div class="rpg-inventory-empty">No character data generated yet</div>');
return; return;
@@ -193,7 +131,7 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback =
const hasRelationshipEnabled = relationshipFields.length > 0; const hasRelationshipEnabled = relationshipFields.length > 0;
// Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh) // Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh)
const characterThoughtsData = lastGeneratedData.characterThoughts || (useCommittedFallback ? committedTrackerData.characterThoughts : null) || ''; const characterThoughtsData = getPresentCharactersTrackerData({ useCommittedFallback });
// console.log('[RPG Companion] renderThoughts - Reading from lastGeneratedData:', JSON.stringify(lastGeneratedData.characterThoughts)); // console.log('[RPG Companion] renderThoughts - Reading from lastGeneratedData:', JSON.stringify(lastGeneratedData.characterThoughts));
// console.log('[RPG Companion] renderThoughts - Reading from committedTrackerData:', JSON.stringify(committedTrackerData.characterThoughts)); // console.log('[RPG Companion] renderThoughts - Reading from committedTrackerData:', JSON.stringify(committedTrackerData.characterThoughts));
@@ -416,70 +354,8 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback =
try { try {
debugLog(`[RPG Thoughts] Building HTML for character ${characterIndex}/${presentCharacters.length}:`, char.name); debugLog(`[RPG Thoughts] Building HTML for character ${characterIndex}/${presentCharacters.length}:`, char.name);
// Find character portrait
// Use a base64-encoded SVG placeholder as fallback to avoid 400 errors
let characterPortrait = FALLBACK_AVATAR_DATA_URI;
debugLog(`[RPG Thoughts] Looking up avatar for: ${char.name}`); debugLog(`[RPG Thoughts] Looking up avatar for: ${char.name}`);
const characterPortrait = resolvePresentCharacterPortrait(char.name);
// First, check if user manually uploaded a custom avatar
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[char.name]) {
characterPortrait = extensionSettings.npcAvatars[char.name];
debugLog('[RPG Thoughts] Found custom uploaded avatar');
}
// For group chats, search through group members
if (characterPortrait === FALLBACK_AVATAR_DATA_URI && selected_group) {
debugLog('[RPG Thoughts] In group chat, checking group members...');
try {
const groupMembers = getGroupMembers(selected_group);
debugLog('[RPG Thoughts] Group members count:', groupMembers ? groupMembers.length : 0);
if (groupMembers && groupMembers.length > 0) {
const matchingMember = groupMembers.find(member =>
member && member.name && namesMatch(member.name, char.name)
);
if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar);
if (thumbnailUrl) {
characterPortrait = thumbnailUrl;
debugLog('[RPG Thoughts] Found avatar in group members');
}
}
}
} catch (groupError) {
debugLog('[RPG Thoughts] Error checking group members:', groupError.message);
}
}
// For regular chats or if not found in group, search all characters
if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) {
debugLog('[RPG Thoughts] Searching all characters...');
const matchingCharacter = characters.find(c =>
c && c.name && namesMatch(c.name, char.name)
);
if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar);
if (thumbnailUrl) {
characterPortrait = thumbnailUrl;
debugLog('[RPG Thoughts] Found avatar in all characters');
}
}
}
// If this is the current character in a 1-on-1 chat, use their portrait
if (this_chid !== undefined && characters[this_chid] &&
characters[this_chid].name && namesMatch(characters[this_chid].name, char.name)) {
const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
if (thumbnailUrl) {
characterPortrait = thumbnailUrl;
debugLog('[RPG Thoughts] Found avatar from current character');
}
}
debugLog(`[RPG Thoughts] Final avatar for ${char.name}:`, characterPortrait.substring(0, 50) + '...'); debugLog(`[RPG Thoughts] Final avatar for ${char.name}:`, characterPortrait.substring(0, 50) + '...');
@@ -0,0 +1,158 @@
import { extensionSettings } from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import {
getPresentCharactersTrackerData,
parsePresentCharacters,
resolvePresentCharacterPortrait
} from '../../utils/presentCharacters.js';
const PANEL_ID = 'rpg-alt-present-characters';
function ensureAlternatePresentCharactersPanel() {
let $panel = $(`#${PANEL_ID}`);
if ($panel.length) {
return $panel;
}
$panel = $(`<div id="${PANEL_ID}" class="rpg-alt-present-characters" style="display:none;"></div>`);
const $sendForm = $('#send_form');
const $sheld = $('#sheld');
const $chat = $sheld.find('#chat');
if ($sendForm.length) {
$sendForm.before($panel);
} else if ($chat.length) {
$chat.after($panel);
} else if ($sheld.length) {
$sheld.append($panel);
} else {
$('body').append($panel);
}
return $panel;
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function hexToRgba(hex, opacity = 100) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const a = opacity / 100;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
export function removeAlternatePresentCharactersPanel() {
$(`#${PANEL_ID}`).remove();
}
export function syncAlternatePresentCharactersTheme() {
const $panel = $(`#${PANEL_ID}`);
if (!$panel.length) {
return;
}
const theme = extensionSettings.theme || 'default';
$panel.css({
'--rpg-bg': '',
'--rpg-accent': '',
'--rpg-text': '',
'--rpg-highlight': '',
'--rpg-border': '',
'--rpg-shadow': ''
});
if (theme === 'default') {
$panel.removeAttr('data-theme');
return;
}
$panel.attr('data-theme', theme);
if (theme === 'custom') {
const colors = extensionSettings.customColors || {};
const bgColor = hexToRgba(colors.bg || '#1a1a2e', colors.bgOpacity ?? 100);
const accentColor = hexToRgba(colors.accent || '#16213e', colors.accentOpacity ?? 100);
const textColor = hexToRgba(colors.text || '#eaeaea', colors.textOpacity ?? 100);
const highlightColor = hexToRgba(colors.highlight || '#e94560', colors.highlightOpacity ?? 100);
const shadowColor = hexToRgba(colors.highlight || '#e94560', (colors.highlightOpacity ?? 100) * 0.5);
$panel.css({
'--rpg-bg': bgColor,
'--rpg-accent': accentColor,
'--rpg-text': textColor,
'--rpg-highlight': highlightColor,
'--rpg-border': highlightColor,
'--rpg-shadow': shadowColor
});
}
}
export function renderAlternatePresentCharacters({ useCommittedFallback = true } = {}) {
if (!extensionSettings.enabled || !extensionSettings.showAlternatePresentCharactersPanel) {
removeAlternatePresentCharactersPanel();
return;
}
const characterThoughtsData = getPresentCharactersTrackerData({ useCommittedFallback });
if (!characterThoughtsData) {
const $panel = ensureAlternatePresentCharactersPanel();
$panel.empty().hide();
return;
}
const presentCharacters = parsePresentCharacters(characterThoughtsData);
if (presentCharacters.length === 0) {
const $panel = ensureAlternatePresentCharactersPanel();
$panel.empty().hide();
return;
}
const title = i18n.getTranslation('template.trackerEditorModal.tabs.presentCharacters') || 'Present Characters';
let html = `
<div class="rpg-alt-present-characters__header">
<div class="rpg-alt-present-characters__title">
<i class="fa-solid fa-users" aria-hidden="true"></i>
<span>${escapeHtml(title)}</span>
</div>
<div class="rpg-alt-present-characters__count">${presentCharacters.length}</div>
</div>
<div class="rpg-alt-present-characters__scroll">
<div class="rpg-alt-present-characters__track">
`;
for (const character of presentCharacters) {
const portrait = resolvePresentCharacterPortrait(character.name);
const name = escapeHtml(character.name || '');
html += `
<div class="rpg-alt-present-character" data-character-name="${name}" title="${name}">
<div class="rpg-alt-present-character__portrait">
<img src="${portrait}" alt="${name}" loading="lazy" onerror="this.style.opacity='0.5';this.onerror=null;" />
</div>
<div class="rpg-alt-present-character__meta">
<div class="rpg-alt-present-character__name">${name}</div>
</div>
</div>
`;
}
html += `
</div>
</div>
`;
const $panel = ensureAlternatePresentCharactersPanel();
$panel.html(html).show();
syncAlternatePresentCharactersTheme();
}
+5
View File
@@ -4,6 +4,7 @@
*/ */
import { extensionSettings, $panelContainer } from '../../core/state.js'; import { extensionSettings, $panelContainer } from '../../core/state.js';
import { syncAlternatePresentCharactersTheme } from './alternatePresentCharacters.js';
/** /**
* Converts hex color and opacity percentage to rgba string * Converts hex color and opacity percentage to rgba string
@@ -96,6 +97,8 @@ export function applyTheme() {
$thoughtPanel.attr('data-theme', theme); $thoughtPanel.attr('data-theme', theme);
} }
} }
syncAlternatePresentCharactersTheme();
} }
/** /**
@@ -150,6 +153,8 @@ export function applyCustomTheme() {
if ($thoughtPanel.length) { if ($thoughtPanel.length) {
$thoughtPanel.attr('data-theme', 'custom').css(customStyles); $thoughtPanel.attr('data-theme', 'custom').css(customStyles);
} }
syncAlternatePresentCharactersTheme();
} }
/** /**
+237
View File
@@ -0,0 +1,237 @@
import { this_chid, characters } from '../../../../../../script.js';
import { selected_group, getGroupMembers } from '../../../../../group-chats.js';
import {
extensionSettings,
lastGeneratedData,
committedTrackerData,
FALLBACK_AVATAR_DATA_URI
} from '../core/state.js';
import { getSafeThumbnailUrl } from './avatars.js';
export function stripBrackets(value) {
if (typeof value !== 'string') return value;
return value.replace(/^\[|\]$/g, '').trim();
}
export function extractFieldValue(fieldValue) {
if (fieldValue && typeof fieldValue === 'object' && 'value' in fieldValue) {
return fieldValue.value || '';
}
return fieldValue || '';
}
export function toSnakeCase(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
export function namesMatch(cardName, aiName) {
if (!cardName || !aiName) return false;
if (cardName.toLowerCase() === aiName.toLowerCase()) return true;
const stripParens = (s) => s.replace(/\s*\([^)]*\)/g, '').trim();
const cardCore = stripParens(cardName).toLowerCase();
const aiCore = stripParens(aiName).toLowerCase();
if (cardCore === aiCore) return true;
const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`);
return wordBoundary.test(aiCore);
}
export function isPresentCharactersEnabled() {
return !!(extensionSettings.showCharacterThoughts || extensionSettings.showAlternatePresentCharactersPanel);
}
export function getPresentCharactersTrackerData({ useCommittedFallback = true } = {}) {
return lastGeneratedData.characterThoughts || (useCommittedFallback ? committedTrackerData.characterThoughts : null) || '';
}
export function parsePresentCharacters(characterThoughtsData, { enabledFields = [], enabledCharStats = [] } = {}) {
if (!characterThoughtsData) {
return [];
}
let presentCharacters = [];
try {
const parsed = typeof characterThoughtsData === 'string'
? JSON.parse(characterThoughtsData)
: characterThoughtsData;
const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []);
if (charactersArray.length > 0) {
presentCharacters = charactersArray.map(char => {
const character = {
name: char.name,
emoji: char.emoji || '👤'
};
if (char.details) {
for (const field of enabledFields) {
if (char.details[field.name] !== undefined) {
character[field.name] = stripBrackets(char.details[field.name]);
} else {
const fieldKey = toSnakeCase(field.name);
if (char.details[fieldKey] !== undefined) {
character[field.name] = stripBrackets(char.details[fieldKey]);
}
}
}
}
for (const field of enabledFields) {
if (character[field.name] === undefined) {
const fieldKey = toSnakeCase(field.name);
if (char[fieldKey] !== undefined) {
character[field.name] = stripBrackets(char[fieldKey]);
}
}
}
if (char.Relationship) {
character.Relationship = stripBrackets(char.Relationship);
} else if (char.relationship) {
character.Relationship = stripBrackets(char.relationship.status || char.relationship);
}
if (char.thoughts) {
character.ThoughtsContent = stripBrackets(char.thoughts.content || char.thoughts);
}
if (char.stats && enabledCharStats.length > 0) {
if (Array.isArray(char.stats)) {
for (const statObj of char.stats) {
if (statObj.name && statObj.value !== undefined) {
const matchingStat = enabledCharStats.find(s => s.name === statObj.name);
if (matchingStat) {
character[statObj.name] = statObj.value;
}
}
}
} else {
for (const stat of enabledCharStats) {
if (char.stats[stat.name] !== undefined) {
character[stat.name] = char.stats[stat.name];
}
}
}
}
return character;
});
}
} catch {
// Fall back to the legacy text format below.
}
if (presentCharacters.length > 0 || typeof characterThoughtsData !== 'string') {
return presentCharacters;
}
const lines = characterThoughtsData.split('\n');
let currentCharacter = null;
for (const line of lines) {
if (!line.trim()
|| line.includes('Present Characters')
|| line.includes('---')
|| line.trim().startsWith('```')
|| line.trim() === '- …'
|| line.includes('(Repeat the format')) {
continue;
}
if (line.trim().startsWith('- ')) {
const name = line.trim().substring(2).trim();
if (name && name.toLowerCase() !== 'unavailable') {
currentCharacter = { name };
presentCharacters.push(currentCharacter);
} else {
currentCharacter = null;
}
} else if (line.trim().startsWith('Details:') && currentCharacter) {
const detailsContent = line.substring(line.indexOf(':') + 1).trim();
const parts = detailsContent.split('|').map(p => p.trim());
if (parts.length > 0) {
currentCharacter.emoji = parts[0];
}
for (let i = 0; i < enabledFields.length && i + 1 < parts.length; i++) {
currentCharacter[enabledFields[i].name] = parts[i + 1];
}
} else if (line.trim().startsWith('Relationship:') && currentCharacter) {
currentCharacter.Relationship = line.substring(line.indexOf(':') + 1).trim();
} else if (line.trim().startsWith('Stats:') && currentCharacter && enabledCharStats.length > 0) {
const statsContent = line.substring(line.indexOf(':') + 1).trim();
const statParts = statsContent.split('|').map(p => p.trim());
for (const statPart of statParts) {
const statMatch = statPart.match(/^(.+?):\s*(\d+)%$/);
if (statMatch) {
currentCharacter[statMatch[1].trim()] = parseInt(statMatch[2], 10);
}
}
}
}
return presentCharacters;
}
export function resolvePresentCharacterPortrait(name) {
let characterPortrait = FALLBACK_AVATAR_DATA_URI;
if (!name) {
return characterPortrait;
}
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[name]) {
return extensionSettings.npcAvatars[name];
}
if (selected_group) {
try {
const groupMembers = getGroupMembers(selected_group);
const matchingMember = groupMembers?.find(member =>
member && member.name && namesMatch(member.name, name)
);
if (matchingMember?.avatar && matchingMember.avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar);
if (thumbnailUrl) {
return thumbnailUrl;
}
}
} catch {
// Ignore avatar lookup issues and continue through fallback chain.
}
}
if (characters?.length > 0) {
const matchingCharacter = characters.find(character =>
character && character.name && namesMatch(character.name, name)
);
if (matchingCharacter?.avatar && matchingCharacter.avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar);
if (thumbnailUrl) {
return thumbnailUrl;
}
}
}
if (this_chid !== undefined && characters[this_chid]?.name && namesMatch(characters[this_chid].name, name)) {
const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
if (thumbnailUrl) {
return thumbnailUrl;
}
}
return characterPortrait;
}
+138
View File
@@ -15,6 +15,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
.rpg-panel, .rpg-panel,
#rpg-thought-panel, #rpg-thought-panel,
#rpg-thought-icon, #rpg-thought-icon,
#rpg-alt-present-characters,
.rpg-mobile-toggle { .rpg-mobile-toggle {
--rpg-bg: var(--SmartThemeBlurTintColor, rgba(26, 26, 46, 0.9)); --rpg-bg: var(--SmartThemeBlurTintColor, rgba(26, 26, 46, 0.9));
--rpg-accent: var(--black30a, rgba(22, 33, 62, 0.9)); --rpg-accent: var(--black30a, rgba(22, 33, 62, 0.9));
@@ -3256,6 +3257,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
/* Apply sci-fi theme to thought panel */ /* Apply sci-fi theme to thought panel */
#rpg-thought-panel[data-theme="sci-fi"], #rpg-thought-panel[data-theme="sci-fi"],
#rpg-thought-icon[data-theme="sci-fi"], #rpg-thought-icon[data-theme="sci-fi"],
#rpg-alt-present-characters[data-theme="sci-fi"],
.rpg-mobile-toggle[data-theme="sci-fi"] { .rpg-mobile-toggle[data-theme="sci-fi"] {
--rpg-bg: #0a0e27; --rpg-bg: #0a0e27;
--rpg-accent: #1a1f3a; --rpg-accent: #1a1f3a;
@@ -3304,6 +3306,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
/* Apply fantasy theme to thought panel */ /* Apply fantasy theme to thought panel */
#rpg-thought-panel[data-theme="fantasy"], #rpg-thought-panel[data-theme="fantasy"],
#rpg-thought-icon[data-theme="fantasy"], #rpg-thought-icon[data-theme="fantasy"],
#rpg-alt-present-characters[data-theme="fantasy"],
.rpg-mobile-toggle[data-theme="fantasy"] { .rpg-mobile-toggle[data-theme="fantasy"] {
--rpg-bg: #2b1810; --rpg-bg: #2b1810;
--rpg-accent: #3d2414; --rpg-accent: #3d2414;
@@ -3361,6 +3364,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
/* Apply cyberpunk theme to thought panel */ /* Apply cyberpunk theme to thought panel */
#rpg-thought-panel[data-theme="cyberpunk"], #rpg-thought-panel[data-theme="cyberpunk"],
#rpg-thought-icon[data-theme="cyberpunk"], #rpg-thought-icon[data-theme="cyberpunk"],
#rpg-alt-present-characters[data-theme="cyberpunk"],
.rpg-mobile-toggle[data-theme="cyberpunk"] { .rpg-mobile-toggle[data-theme="cyberpunk"] {
--rpg-bg: #000000; --rpg-bg: #000000;
--rpg-accent: #0d0d0d; --rpg-accent: #0d0d0d;
@@ -5022,6 +5026,140 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
} }
} }
/* ============================================
BELOW-CHAT PRESENT CHARACTERS
============================================ */
#rpg-alt-present-characters {
margin: 0 0 10px;
padding: 8px 10px 8px;
border-radius: 14px;
border: 1px solid var(--rpg-border, rgba(255, 255, 255, 0.14));
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0) 100%),
linear-gradient(135deg, var(--rpg-accent, rgba(34, 40, 60, 0.94)) 0%, var(--rpg-bg, rgba(18, 21, 34, 0.96)) 100%);
box-shadow: 0 12px 28px var(--rpg-shadow, rgba(0, 0, 0, 0.24));
color: var(--rpg-text, var(--SmartThemeBodyColor, #ecf0f1));
backdrop-filter: blur(12px);
}
.rpg-alt-present-characters__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.rpg-alt-present-characters__title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.84rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.rpg-alt-present-characters__title i {
color: var(--rpg-highlight, #e94560);
}
.rpg-alt-present-characters__count {
min-width: 24px;
height: 24px;
padding: 0 7px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--rpg-border, rgba(255, 255, 255, 0.14));
color: var(--rpg-text, var(--SmartThemeBodyColor, #ecf0f1));
font-size: 0.74rem;
font-weight: 700;
}
.rpg-alt-present-characters__scroll {
overflow-x: auto;
overflow-y: hidden;
padding-top: 2px;
scrollbar-width: thin;
transform: scaleY(-1);
}
.rpg-alt-present-characters__track {
display: flex;
gap: 10px;
width: max-content;
min-width: 100%;
transform: scaleY(-1);
padding-bottom: 2px;
}
.rpg-alt-present-character {
flex: 0 0 98px;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 98px;
}
.rpg-alt-present-character__portrait {
position: relative;
aspect-ratio: 11 / 15;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14);
background: var(--rpg-bg, rgba(18, 21, 34, 0.96));
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.18);
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.rpg-alt-present-character__portrait:hover {
transform: translateY(-2px);
border-color: var(--rpg-highlight, #e94560);
box-shadow: 0 12px 20px rgba(0, 0, 0, 0.22);
}
.rpg-alt-present-character__portrait img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.rpg-alt-present-character__meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.rpg-alt-present-character__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-alt-present-character__name {
font-size: 0.78rem;
font-weight: 600;
line-height: 1.1;
text-align: center;
}
@media (max-width: 768px) {
#rpg-alt-present-characters {
margin-bottom: 8px;
padding: 7px 9px 7px;
border-radius: 12px;
}
.rpg-alt-present-character {
flex-basis: 84px;
min-width: 84px;
}
}
/* ============================================ /* ============================================
CHAT THOUGHT OVERLAYS CHAT THOUGHT OVERLAYS
============================================ */ ============================================ */
+9
View File
@@ -358,6 +358,15 @@
Display character portraits with their current thoughts and status. Display character portraits with their current thoughts and status.
</small> </small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-alt-present-characters" />
<span data-i18n-key="template.settingsModal.display.showBelowChatPresentCharacters">Show Below-Chat Present Characters</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showBelowChatPresentCharactersNote">
Display a compact Present Characters panel below the chat.
</small>
<label class="checkbox_label"> <label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-thoughts-in-chat" /> <input type="checkbox" id="rpg-toggle-thoughts-in-chat" />
<span data-i18n-key="template.settingsModal.display.showThoughtsInChat">Show Thoughts</span> <span data-i18n-key="template.settingsModal.display.showThoughtsInChat">Show Thoughts</span>