diff --git a/public/css/stats.css b/public/css/stats.css index 232d73164..5c421f0c7 100644 --- a/public/css/stats.css +++ b/public/css/stats.css @@ -1,3 +1,7 @@ +.rm_stat_popup_header { + margin-bottom: 0px; +} + .rm_stats_button { cursor: pointer; } @@ -82,10 +86,10 @@ .rm_stat_avatar_block { position: absolute; - top: calc(1.17em + 7px + 20px); + top: calc(10px + 1.17em + 12.5px + 2* 7px); right: 0px; height: calc(8px + calc(calc(var(--mainFontSize) * 1.33333333333) * 7) + calc(12px * 3)); - width: 33.33333333333%; + width: calc(33.33333333333% - 10px); display: flex; justify-content: center; align-items: center; diff --git a/public/script.js b/public/script.js index e1ef45440..716dbb9d6 100644 --- a/public/script.js +++ b/public/script.js @@ -1359,14 +1359,16 @@ async function printCharacters(fullRefresh = false) { favsToHotswap(); } -/** @typedef {object} Character - A character */ -/** @typedef {object} Group - A group */ +/** @typedef {{name: string, avatar: string, fav: boolean}} Character - A character */ +/** @typedef {{id: string, name: string, avatar: string, fav: boolean}} Group - A group */ +/** @typedef {import('./scripts/tags.js').Tag} Tag */ +/** @typedef {import('./scripts/personas.js').Persona} Persona - A persona */ /** * @typedef {object} Entity - Object representing a display entity - * @property {Character|Group|import('./scripts/tags.js').Tag|*} item - The item + * @property {Character|Group|Tag|Persona} item - The item * @property {string|number} id - The id - * @property {string} type - The type of this entity (character, group, tag) + * @property {'character'|'group'|'tag'|'persona'} type - The type of this entity (character, group, tag, persona) * @property {Entity[]} [entities] - An optional list of entities relevant for this item * @property {number} [hidden] - An optional number representing how many hidden entities this entity contains */ @@ -1395,13 +1397,23 @@ export function groupToEntity(group) { /** * Converts the given tag to its entity representation * - * @param {import('./scripts/tags.js').Tag} tag - The tag + * @param {Tag} tag - The tag * @returns {Entity} The entity for this tag */ export function tagToEntity(tag) { return { item: structuredClone(tag), id: tag.id, type: 'tag', entities: [] }; } +/** + * Converts the given persona based to its entity representation + * + * @param {Persona} persona - The avatar id for the persona + * @returns {Entity} The entity for this persona + */ +export function personaToEntity(persona) { + return { item: persona, id: persona.avatar, type: 'persona' }; +} + /** * Builds the full list of all entities available * @@ -5619,6 +5631,17 @@ function getThumbnailUrl(type, file) { return `/thumbnail?type=${type}&file=${encodeURIComponent(file)}`; } +/** + * Build an avatar list for entities inside a given html block element + * + * @param {JQuery} block - The block to build the avatar list in + * @param {Entity[]} entities - A list of entities for which to build the avatar for + * @param {object} options - Optional options + * @param {string} [options.templateId='inline_avatar_template'] - The template from which the inline avatars are built off + * @param {boolean} [options.empty=true] - Whether the block will be emptied before drawing the avatars + * @param {boolean} [options.selectable=false] - Whether the avatars should be selectable/clickable. If set, the click handler has to be implemented externally on the classes/items + * @param {boolean} [options.highlightFavs=true] - Whether favorites should be highlighted + */ function buildAvatarList(block, entities, { templateId = 'inline_avatar_template', empty = true, selectable = false, highlightFavs = true } = {}) { if (empty) { block.empty(); @@ -5626,34 +5649,39 @@ function buildAvatarList(block, entities, { templateId = 'inline_avatar_template for (const entity of entities) { const id = entity.id; + const item = entity.item; // Populate the template const avatarTemplate = $(`#${templateId} .avatar`).clone(); - - let this_avatar = default_avatar; - if (entity.item.avatar !== undefined && entity.item.avatar != 'none') { - this_avatar = getThumbnailUrl('avatar', entity.item.avatar); - } - avatarTemplate.attr('data-type', entity.type); - avatarTemplate.attr({ 'chid': id, 'id': `CharID${id}` }); - avatarTemplate.find('img').attr('src', this_avatar).attr('alt', entity.item.name); - avatarTemplate.attr('title', `[Character] ${entity.item.name}\nFile: ${entity.item.avatar}`); - if (highlightFavs) { - avatarTemplate.toggleClass('is_fav', entity.item.fav || entity.item.fav == 'true'); - avatarTemplate.find('.ch_fav').val(entity.item.fav); + + switch (entity.type) { + case 'character': + avatarTemplate.attr({ 'chid': id, 'id': `CharID${id}` }); + const charAvatar = item.avatar && item.avatar != 'none' ? getThumbnailUrl('avatar', item.avatar) : default_avatar; + avatarTemplate.find('img').attr('src', charAvatar).attr('alt', item.name); + avatarTemplate.attr('title', `[Character] ${item.name}\nFile: ${item.avatar}`); + break; + case 'group': + const grpTemplate = getGroupAvatar(item); + avatarTemplate.attr({ 'gid': id, 'id': `GroupID${id}` }); + avatarTemplate.addClass(grpTemplate.attr('class')); + avatarTemplate.empty(); + avatarTemplate.append(grpTemplate.children()); + avatarTemplate.attr('title', `[Group] ${item.name}`); + break; + case 'persona': + avatarTemplate.attr({ 'pid': id, 'id': `PersonaID${id}` }); + const personaAvatar = getUserAvatar(item.avatar) + avatarTemplate.find('img').attr('src', personaAvatar).attr('alt', item.name); + avatarTemplate.attr('title', `[Persona] ${item.name}`); + break; } - // If this is a group, we need to hack slightly. We still want to keep most of the css classes and layout, but use a group avatar instead. - if (entity.type === 'group') { - const grpTemplate = getGroupAvatar(entity.item); - - avatarTemplate.addClass(grpTemplate.attr('class')); - avatarTemplate.empty(); - avatarTemplate.append(grpTemplate.children()); - avatarTemplate.attr('title', `[Group] ${entity.item.name}`); + if (highlightFavs && 'fav' in item) { + avatarTemplate.toggleClass('is_fav', item.fav); + avatarTemplate.find('.ch_fav').val(item.fav ? 'true' : 'false'); } - if (selectable) { avatarTemplate.addClass('selectable'); avatarTemplate.toggleClass('character_select', entity.type === 'character'); diff --git a/public/scripts/personas.js b/public/scripts/personas.js index aa2aadb3b..b74044964 100644 --- a/public/scripts/personas.js +++ b/public/scripts/personas.js @@ -27,6 +27,31 @@ function switchPersonaGridView() { $('#user_avatar_block').toggleClass('gridView', state); } +/** + * @typedef {object} Persona - A persona + * @property {string} id - The id of the persona - currently same as avatar + * @property {string} name - The name of the persona + * @property {string} avatar - the avatar / avatar id representing the persona + * @property {boolean} fav - Whether this persona is favorited + * @property {{description: string, position: number}} description - The persona description, containing its text and the position where its placed + * */ + +/** + * Builds an object represting the given persona + * @param {string} avatar - The avatar id + * @returns {Persona} The persona object, wit all its data + */ +export function getPersona(avatar) { + const persona = { + id: avatar, + name: power_user.personas[avatar], + avatar: avatar, + fav: false, + description: power_user.persona_descriptions[avatar] + }; + return persona; +} + /** * Uploads an avatar file to the server * @param {string} url URL for the avatar file diff --git a/public/scripts/stats.js b/public/scripts/stats.js index b3e79531e..d97d2ddd5 100644 --- a/public/scripts/stats.js +++ b/public/scripts/stats.js @@ -1,6 +1,7 @@ // statsHelper.js -import { getRequestHeaders, callPopup, characters, this_chid, buildAvatarList, characterToEntity, getOneCharacter, getCharacter } from '../script.js'; +import { getRequestHeaders, callPopup, characters, this_chid, buildAvatarList, characterToEntity, getOneCharacter, getCharacter, user_avatar, personaToEntity } from '../script.js'; import { humanizeTimespan } from './RossAscends-mods.js'; +import { getPersona } from './personas.js'; import { registerDebugFunction } from './power-user.js'; import { humanFileSize, humanizedDuration, parseJson, sensibleRound, smartTruncate } from './utils.js'; @@ -106,6 +107,14 @@ function createStatBlock(name, ...values) { `; } +/** + * Show the stats popup for a given stats report + * @param {string} html - The html report that should be shown in the popup + */ +function showStatsPopup(html) { + callPopup(html, 'text', '', { wider: true, allowVerticalScrolling: true }); +} + /** * Generates an HTML report of stats. * @@ -113,12 +122,13 @@ function createStatBlock(name, ...values) { * chat time, number of user messages and character messages, word count, and swipe count. * The stat blocks are tailored depending on the stats type ("User" or "Character"). * - * @param {'User'|'Character'} statsType - The type of stats (e.g., "User", "Character") + * @param {'user'|'character'} statsType - The type of stats (e.g., "User", "Character") * @param {CharacterStats} stats - The stats data + * @returns {string} The html */ -function createHtml(statsType, stats) { +function createCharStatsHtml(statsType, stats) { const NOW = Date.now(); - const character = getCharacter(stats.characterKey); + const isChar = statsType === 'character'; const HMTL_STAT_SPACER = '
'; const VAL_RIGHT_SPACING = { value: null, classes: ['rm_stat_right_spacing'] }; @@ -128,11 +138,21 @@ function createHtml(statsType, stats) { // some pre calculations const mostUsedModel = findHighestModel(stats.genModels); + const charactersCount = !isChar ? (new Set(stats.chatsStats.map(x => x.charName))).size : null; + + let subHeader = (() => { + switch (statsType) { + case 'character': return `Overall character stats based on all chats for ${stats.charName}`; + case 'user': return `Global stats based on all chats of ${charactersCount} characters`; + default: return ''; + }; + })(); // Create popup HTML with stats - let html = `

${statsType} Stats - ${stats.charName}

`; + let html = `

${isChar ? 'Character' : 'User'} Stats - ${isChar ? stats.charName : stats.userName}

`; + html += `${subHeader}`; html += HMTL_STAT_SPACER; - html += createStatBlock({ value: 'Character Overview', isHeader: true }); + html += createStatBlock({ value: isChar ? 'Character Overview' : 'Overview', isHeader: true }); html += createStatBlock({ value: 'Chats', info: `The number of existing chats with ${stats.charName}.\nFor the sake of statistics, Branches count as chats and all their messages will be included.` }, stats.chats, VAL_RIGHT_SPACING); html += createStatBlock({ value: 'File Size', info: 'The chat file sizes on disk calculated and summed.\nThis value might not represent the exact same value your operating system uses.' }, @@ -222,17 +242,25 @@ function createHtml(statsType, stats) { html += HMTL_STAT_SPACER; // Hijack avatar list function to draw the user avatar - if (character) { + let entity = null; + switch (statsType) { + case 'character': + const character = getCharacter(stats.characterKey); + const cid = characters.indexOf(x => x === character); + entity = characterToEntity(character, cid); + break; + case 'user': + const persona = getPersona(user_avatar); + entity = personaToEntity(persona); + break; + } + if (entity) { const placeHolder = $('
'); - const cid = characters.indexOf(x => x === character); - const entity = characterToEntity(character, cid); buildAvatarList(placeHolder, [entity]); html = placeHolder.prop('outerHTML') + html; } - callPopup(html, 'text', '', { wider: true, allowVerticalScrolling: true }); - - return; + return html; /** @param {AggregateStat} agg1 @param {AggregateStat} agg2 @param {AggBuildOptions} options @returns {StatField[]} */ function buildBarsFromAggregates(agg1, agg2, options = DEFAULT_AGG_BUILD_OPTIONS) { @@ -285,7 +313,8 @@ async function showUserStatsPopup() { const globalStats = await getGlobalStats(); // Create HTML with stats - createHtml('User', globalStats); + const html = createCharStatsHtml('user', globalStats); + showStatsPopup(html); } /** @@ -302,7 +331,8 @@ async function showCharacterStatsPopup(characterKey) { } // Create HTML with stats - createHtml('Character', charStats); + const html = createCharStatsHtml('character', charStats); + showStatsPopup(html); } diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 2c54f6592..67e558c8b 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -119,6 +119,8 @@ const TAG_FOLDER_DEFAULT_TYPE = 'NONE'; * @property {string} [class] - An optional css class added to the control representing this tag when printed. Used for custom tags in the filters. * @property {string} [icon] - An optional css class of an icon representing this tag when printed. This will replace the tag name with the icon. Used for custom tags in the filters. * @property {string} [title] - An optional title for the tooltip of this tag. If there is no tooltip specified, and "icon" is chosen, the tooltip will be the "name" property. + * + * @property {string} [avatar=undefined] - For compatibility with other items, tags have an 'avatar' property, which is not used or filled currently */ /** diff --git a/src/endpoints/stats.js b/src/endpoints/stats.js index 59be09d37..ca476583d 100644 --- a/src/endpoints/stats.js +++ b/src/endpoints/stats.js @@ -17,7 +17,7 @@ const MAX_TIMESTAMP = new Date('9999-12-31T23:59:59.999Z').getTime(); const MIN_DATE = new Date(MIN_TIMESTAMP); const MAX_DATE = new Date(MAX_TIMESTAMP); const STATS_FILE = 'stats.json'; -const CURRENT_STATS_VERSION = '1.2'; +const CURRENT_STATS_VERSION = '1.3'; /** @type {Map} The stats collections for each user, accessable via their key - gets set/built on init */ const STATS = new Map(); @@ -415,9 +415,8 @@ function triggerChatUpdate(userHandle, characterKey, chatName) { updateCharStatsWithChat(userStats.stats[characterKey], chatStats); updateCharStatsWithChat(userStats.global, chatStats); - // Update name (if it might have changed) - userStats.stats[characterKey].charName = chatStats.charName; - userStats.stats[characterKey].userName = chatStats.userName; + // For global chats, we always overwrite the char name with a default one + userStats.global.charName = 'Character'; userStats._calculated = now(); return chatStats; @@ -470,8 +469,12 @@ function updateCharStatsWithChat(stats, chatStats) { Object.entries(chatStats.genModels).forEach(([model, data]) => addModelUsage(stats.genModels, model, data.tokens, data.count)); + // Update name (if it might have changed) + stats.charName = chatStats.charName || stats.charName; + stats.userName = chatStats.userName || stats.userName; + stats._calculated = now(); - console.debug(`Successfully updated ${stats.charName}'s stats with chat ${chatStats.chatName}`); + console.debug(`Successfully updated ${stats.charName}'s stats with chat '${chatStats.chatName}'`); return true; } @@ -685,7 +688,7 @@ function countWordsInString(str) { function newCharacterStats(characterKey = '', charName = '') { return { charName: charName, - userName: 'User', + userName: '', characterKey: characterKey, chats: 0, chatSize: 0,