From 2f98686e60e253de90163e855d6033e90e0343fa Mon Sep 17 00:00:00 2001 From: Tremendoussly Date: Sun, 8 Mar 2026 22:58:42 +0100 Subject: [PATCH] Add optional below-chat Present Characters panel (#3) --- index.js | 10 + src/core/config.js | 1 + src/core/persistence.js | 5 + src/core/state.js | 1 + src/i18n/en.json | 2 + src/i18n/fr.json | 2 + src/i18n/ru.json | 2 + src/i18n/zh-tw.json | 2 + src/systems/generation/encounterPrompts.js | 3 +- src/systems/generation/promptBuilder.js | 11 +- src/systems/rendering/thoughts.js | 150 +----------- src/systems/ui/alternatePresentCharacters.js | 158 +++++++++++++ src/systems/ui/theme.js | 5 + src/utils/presentCharacters.js | 237 +++++++++++++++++++ style.css | 138 +++++++++++ template.html | 9 + 16 files changed, 593 insertions(+), 143 deletions(-) create mode 100644 src/systems/ui/alternatePresentCharacters.js create mode 100644 src/utils/presentCharacters.js 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 = ` +
+
+ + ${escapeHtml(title)} +
+
${presentCharacters.length}
+
+
+
+ `; + + for (const character of presentCharacters) { + const portrait = resolvePresentCharacterPortrait(character.name); + const name = escapeHtml(character.name || ''); + + html += ` +
+
+ ${name} +
+
+
${name}
+
+
+ `; + } + + 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. + +