Add optional below-chat Present Characters panel (#3)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -376,6 +376,11 @@ export function loadSettings() {
|
||||
settingsChanged = true;
|
||||
}
|
||||
|
||||
if (extensionSettings.showAlternatePresentCharactersPanel === undefined) {
|
||||
extensionSettings.showAlternatePresentCharactersPanel = false;
|
||||
settingsChanged = true;
|
||||
}
|
||||
|
||||
// Save migrated settings
|
||||
if (settingsChanged) {
|
||||
saveSettings();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Показывать инвентарь",
|
||||
|
||||
@@ -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 修改它們。",
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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('<div class="rpg-inventory-empty">No character data generated yet</div>');
|
||||
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) + '...');
|
||||
|
||||
|
||||
@@ -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, '&')
|
||||
.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 = `
|
||||
<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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
============================================ */
|
||||
|
||||
@@ -358,6 +358,15 @@
|
||||
Display character portraits with their current thoughts and status.
|
||||
</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">
|
||||
<input type="checkbox" id="rpg-toggle-thoughts-in-chat" />
|
||||
<span data-i18n-key="template.settingsModal.display.showThoughtsInChat">Show Thoughts</span>
|
||||
|
||||
Reference in New Issue
Block a user