diff --git a/index.js b/index.js
index 1f80678..af28207 100644
--- a/index.js
+++ b/index.js
@@ -134,6 +134,7 @@ import {
removeDesktopTabs,
updateStripWidgets
} from './src/systems/ui/desktop.js';
+import { removeAlternatePresentCharactersPanel } from './src/systems/ui/alternatePresentCharacters.js';
// Feature modules
import { setupPlotButtons, sendPlotProgression } from './src/systems/features/plotProgression.js';
@@ -228,6 +229,7 @@ async function addExtensionSettings() {
$('#rpg-mobile-toggle').remove();
$('#rpg-collapse-toggle').remove();
$('#rpg-plot-buttons').remove(); // Remove plot buttons
+ removeAlternatePresentCharactersPanel();
} else if (extensionSettings.enabled && !wasEnabled) {
// Enabling extension - initialize UI
await initUI();
@@ -339,6 +341,13 @@ async function initUI() {
extensionSettings.showCharacterThoughts = $(this).prop('checked');
saveSettings();
updateSectionVisibility();
+ renderThoughts();
+ });
+
+ $('#rpg-toggle-alt-present-characters').on('change', function() {
+ extensionSettings.showAlternatePresentCharactersPanel = $(this).prop('checked');
+ saveSettings();
+ renderThoughts();
});
$('#rpg-toggle-inventory').on('change', function() {
@@ -1053,6 +1062,7 @@ async function initUI() {
$('#rpg-toggle-user-stats').prop('checked', extensionSettings.showUserStats);
$('#rpg-toggle-info-box').prop('checked', extensionSettings.showInfoBox);
$('#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-quests').prop('checked', extensionSettings.showQuests);
$('#rpg-toggle-lock-icons').prop('checked', extensionSettings.showLockIcons ?? true);
diff --git a/src/core/config.js b/src/core/config.js
index 09b9c71..5a1e7ca 100644
--- a/src/core/config.js
+++ b/src/core/config.js
@@ -29,6 +29,7 @@ export const defaultSettings = {
showUserStats: true,
showInfoBox: true,
showCharacterThoughts: true,
+ showAlternatePresentCharactersPanel: false,
showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section
showLockIcons: true, // Show lock/unlock icons on tracker items
diff --git a/src/core/persistence.js b/src/core/persistence.js
index cb0bc93..719c638 100644
--- a/src/core/persistence.js
+++ b/src/core/persistence.js
@@ -376,6 +376,11 @@ export function loadSettings() {
settingsChanged = true;
}
+ if (extensionSettings.showAlternatePresentCharactersPanel === undefined) {
+ extensionSettings.showAlternatePresentCharactersPanel = false;
+ settingsChanged = true;
+ }
+
// Save migrated settings
if (settingsChanged) {
saveSettings();
diff --git a/src/core/state.js b/src/core/state.js
index e85f3aa..58ae860 100644
--- a/src/core/state.js
+++ b/src/core/state.js
@@ -18,6 +18,7 @@ export let extensionSettings = {
showUserStats: true,
showInfoBox: true,
showCharacterThoughts: true,
+ showAlternatePresentCharactersPanel: false,
showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section
showThoughtsInChat: true, // Show thoughts overlay in chat
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 7c4ac74..c2a77b5 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -34,6 +34,8 @@
"template.settingsModal.display.showInfoBoxNote": "Display location, time, weather, and recent events.",
"template.settingsModal.display.showPresentCharacters": "Show Present Characters",
"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.narratorModeNote": "Use character card as narrator. Infer characters from context instead of using fixed character references.",
"template.settingsModal.display.showInventory": "Show Inventory",
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index f96d213..5e53570 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -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.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.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.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",
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index d72c164..1563159 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -34,6 +34,8 @@
"template.settingsModal.display.showInfoBoxNote": "Отображение локации, времени, погоды и недавних событий.",
"template.settingsModal.display.showPresentCharacters": "Показывать персонажей",
"template.settingsModal.display.showPresentCharactersNote": "Показывать портреты персонажей с их текущимы мыслями и статусом.",
+ "template.settingsModal.display.showBelowChatPresentCharacters": "Показывать персонажей под чатом",
+ "template.settingsModal.display.showBelowChatPresentCharactersNote": "Показывать компактную панель персонажей под чатом.",
"template.settingsModal.display.narratorMode": "Режим расказчика",
"template.settingsModal.display.narratorModeNote": "Использовать карточку персонажа в качестве расказчика. Персонажи берутся из контекста вместо фиксированных отсылок.",
"template.settingsModal.display.showInventory": "Показывать инвентарь",
diff --git a/src/i18n/zh-tw.json b/src/i18n/zh-tw.json
index 37db1c2..56e75d8 100644
--- a/src/i18n/zh-tw.json
+++ b/src/i18n/zh-tw.json
@@ -30,6 +30,8 @@
"template.settingsModal.display.showUserStats": "顯示 user 屬性",
"template.settingsModal.display.showInfoBox": "顯示資訊框",
"template.settingsModal.display.showPresentCharacters": "顯示在場角色",
+ "template.settingsModal.display.showBelowChatPresentCharacters": "顯示聊天下方的在場角色",
+ "template.settingsModal.display.showBelowChatPresentCharactersNote": "在聊天下方顯示精簡的在場角色面板。",
"template.settingsModal.display.showInventory": "顯示物品欄",
"template.settingsModal.display.showLockIcons": "顯示鎖定/解鎖追蹤器",
"template.settingsModal.display.showLockIconsNote": "在追蹤器項目上顯示鎖定/解鎖圖示,以防止 AI 修改它們。",
diff --git a/src/systems/generation/encounterPrompts.js b/src/systems/generation/encounterPrompts.js
index 2e041d1..e858e3f 100644
--- a/src/systems/generation/encounterPrompts.js
+++ b/src/systems/generation/encounterPrompts.js
@@ -9,6 +9,7 @@ import { selected_group, getGroupMembers, groups } from '../../../../../../group
import { extensionSettings, committedTrackerData } from '../../core/state.js';
import { currentEncounter } from '../features/encounterState.js';
import { repairJSON } from '../../utils/jsonRepair.js';
+import { isPresentCharactersEnabled } from '../../utils/presentCharacters.js';
import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.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`;
// 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 += `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`;
diff --git a/src/systems/generation/promptBuilder.js b/src/systems/generation/promptBuilder.js
index 743c120..5869ec7 100644
--- a/src/systems/generation/promptBuilder.js
+++ b/src/systems/generation/promptBuilder.js
@@ -14,6 +14,7 @@ import {
addLockInstruction
} from './jsonPromptHelpers.js';
import { applyLocks } from './lockManager.js';
+import { isPresentCharactersEnabled } from '../../utils/presentCharacters.js';
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
@@ -293,7 +294,7 @@ export function generateTrackerExample() {
}
}
- if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) {
+ if (isPresentCharactersEnabled() && committedTrackerData.characterThoughts) {
try {
JSON.parse(committedTrackerData.characterThoughts);
const lockedData = applyLocks(committedTrackerData.characterThoughts, 'characters');
@@ -329,7 +330,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
let instructions = '';
// 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
if (hasAnyTrackers) {
@@ -360,7 +361,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
if (extensionSettings.showInfoBox) {
enabledTrackers.push('infoBox');
}
- if (extensionSettings.showCharacterThoughts) {
+ if (isPresentCharactersEnabled()) {
enabledTrackers.push('characters');
}
@@ -383,7 +384,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
instructions += enabledTrackers.indexOf('infoBox') < enabledTrackers.length - 1 ? ',\n' : '\n';
}
- if (extensionSettings.showCharacterThoughts) {
+ if (isPresentCharactersEnabled()) {
instructions += ' "characters": ';
const charactersJSON = buildCharactersJSONInstruction();
// 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
- if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) {
+ if (isPresentCharactersEnabled() && committedTrackerData.characterThoughts) {
try {
const formatted = formatTrackerDataForContext(committedTrackerData.characterThoughts, 'characters', userName);
if (formatted) {
diff --git a/src/systems/rendering/thoughts.js b/src/systems/rendering/thoughts.js
index dbed30e..70e18fb 100644
--- a/src/systems/rendering/thoughts.js
+++ b/src/systems/rendering/thoughts.js
@@ -4,20 +4,24 @@
*/
import { getContext } from '../../../../../../extensions.js';
-import { this_chid, characters } from '../../../../../../../script.js';
-import { selected_group, getGroupMembers } from '../../../../../../group-chats.js';
import {
extensionSettings,
lastGeneratedData,
committedTrackerData,
$thoughtsContainer,
- FALLBACK_AVATAR_DATA_URI,
addDebugLog
} from '../../core/state.js';
import { i18n } from '../../core/i18n.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 { renderAlternatePresentCharacters } from '../ui/alternatePresentCharacters.js';
/**
* 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})`;
}
-/**
- * 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.
* Displays character cards with avatars, relationship badges, and traits.
* Includes event listeners for editable character fields.
*/
export function renderThoughts({ preserveScroll = false, useCommittedFallback = true } = {}) {
+ renderAlternatePresentCharacters({ useCommittedFallback });
+
if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
return;
}
@@ -169,7 +107,7 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback =
}
// 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) {
$thoughtsContainer.html('
No character data generated yet
');
return;
@@ -193,7 +131,7 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback =
const hasRelationshipEnabled = relationshipFields.length > 0;
// 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 committedTrackerData:', JSON.stringify(committedTrackerData.characterThoughts));
@@ -416,70 +354,8 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback =
try {
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}`);
-
- // 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');
- }
- }
+ const characterPortrait = resolvePresentCharacterPortrait(char.name);
debugLog(`[RPG Thoughts] Final avatar for ${char.name}:`, characterPortrait.substring(0, 50) + '...');
diff --git a/src/systems/ui/alternatePresentCharacters.js b/src/systems/ui/alternatePresentCharacters.js
new file mode 100644
index 0000000..2c75d76
--- /dev/null
+++ b/src/systems/ui/alternatePresentCharacters.js
@@ -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 = $(``);
+
+ 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, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+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 = `
+
+
+ `;
+
+ const $panel = ensureAlternatePresentCharactersPanel();
+ $panel.html(html).show();
+ syncAlternatePresentCharactersTheme();
+}
diff --git a/src/systems/ui/theme.js b/src/systems/ui/theme.js
index 0bac819..61de7a4 100644
--- a/src/systems/ui/theme.js
+++ b/src/systems/ui/theme.js
@@ -4,6 +4,7 @@
*/
import { extensionSettings, $panelContainer } from '../../core/state.js';
+import { syncAlternatePresentCharactersTheme } from './alternatePresentCharacters.js';
/**
* Converts hex color and opacity percentage to rgba string
@@ -96,6 +97,8 @@ export function applyTheme() {
$thoughtPanel.attr('data-theme', theme);
}
}
+
+ syncAlternatePresentCharactersTheme();
}
/**
@@ -150,6 +153,8 @@ export function applyCustomTheme() {
if ($thoughtPanel.length) {
$thoughtPanel.attr('data-theme', 'custom').css(customStyles);
}
+
+ syncAlternatePresentCharactersTheme();
}
/**
diff --git a/src/utils/presentCharacters.js b/src/utils/presentCharacters.js
new file mode 100644
index 0000000..7a9ad2d
--- /dev/null
+++ b/src/utils/presentCharacters.js
@@ -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;
+}
diff --git a/style.css b/style.css
index 3375c37..5f88b0d 100644
--- a/style.css
+++ b/style.css
@@ -15,6 +15,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
.rpg-panel,
#rpg-thought-panel,
#rpg-thought-icon,
+#rpg-alt-present-characters,
.rpg-mobile-toggle {
--rpg-bg: var(--SmartThemeBlurTintColor, rgba(26, 26, 46, 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 */
#rpg-thought-panel[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-bg: #0a0e27;
--rpg-accent: #1a1f3a;
@@ -3304,6 +3306,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
/* Apply fantasy theme to thought panel */
#rpg-thought-panel[data-theme="fantasy"],
#rpg-thought-icon[data-theme="fantasy"],
+#rpg-alt-present-characters[data-theme="fantasy"],
.rpg-mobile-toggle[data-theme="fantasy"] {
--rpg-bg: #2b1810;
--rpg-accent: #3d2414;
@@ -3361,6 +3364,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
/* Apply cyberpunk theme to thought panel */
#rpg-thought-panel[data-theme="cyberpunk"],
#rpg-thought-icon[data-theme="cyberpunk"],
+#rpg-alt-present-characters[data-theme="cyberpunk"],
.rpg-mobile-toggle[data-theme="cyberpunk"] {
--rpg-bg: #000000;
--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
============================================ */
diff --git a/template.html b/template.html
index 0590f1b..bfc071c 100644
--- a/template.html
+++ b/template.html
@@ -358,6 +358,15 @@
Display character portraits with their current thoughts and status.
+
+
+ Display a compact Present Characters panel below the chat.
+
+