diff --git a/public/css/stats.css b/public/css/stats.css new file mode 100644 index 00000000..8d1c9f2d --- /dev/null +++ b/public/css/stats.css @@ -0,0 +1,112 @@ +.rm_stat_popup_header { + margin-bottom: 0px; +} + +.rm_stats_button { + cursor: pointer; +} + +.rm_stat_block { + display: flex; +} + +.rm_stat_block_data_row:hover { + background-color: var(--grey5020a); + filter: drop-shadow(0px 0px 5px var(--SmartThemeShadowColor)); +} + +.rm_stat_name { + flex: 1; +} + +.rm_stat_values { + flex: 2; + display: flex; + align-items: center; +} + +.rm_stat_block.rm_stat_right_spacing .rm_stat_values { + flex: 1; +} + +.rm_stat_name .rm_stat_header { + height: calc(var(--mainFontSize) * 1.33333333333 + 3px); + padding-bottom: 3px; + border-bottom: 2px solid; +} + +.rm_stat_name .rm_stat_field { + text-align: left; +} + +.rm_stat_field.rm_stat_field_lefty { + text-align: left; + padding-left: 6px; +} + +.rm_stat_field { + flex: 1; + height: calc(var(--mainFontSize) * 1.33333333333); + text-align: right; + overflow: hidden; + padding-left: 2px; + padding-right: 2px; +} + +.rm_stat_field_smaller { + color: var(--grey70); + font-size: smaller; +} + +.rm_stat_header { + margin-bottom: 3px; + font-weight: bold; +} + +.rm_stat_spacer { + height: 12px; +} + +.rm_stat_bar { + width: 100%; + height: calc(var(--mainFontSize) * 1.33333333333 - 4px); + display: flex; + margin-top: 2px; + margin-bottom: 2px; + padding-left: 6px; +} + +.rm_stat_bar_user { + background-color: rgba(130, 178, 140, 0.9); +} + +.rm_stat_bar_char { + background-color: rgba(178, 140, 130, 0.9); +} + +.rm_stat_block.rm_stat_right_spacing { + margin-right: 33.33333333333%; +} + +.rm_stat_avatar_block { + position: absolute; + 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: calc(33.33333333333% - 10px); + display: flex; + justify-content: center; + align-items: center; +} + +.rm_stat_avatar_block .avatar { + scale: 2; + flex: unset; +} + +.rm_stat_footer { + justify-content: right; + color: var(--grey70); + font-size: smaller; + font-style: italic; +} diff --git a/public/css/toggle-dependent.css b/public/css/toggle-dependent.css index 942ada83..a4509b74 100644 --- a/public/css/toggle-dependent.css +++ b/public/css/toggle-dependent.css @@ -146,7 +146,6 @@ body.big-avatars .bogus_folder_select .avatar { body.big-avatars .avatar { width: calc(var(--avatar-base-width) * var(--big-avatar-width-factor)); height: calc(var(--avatar-base-height) * var(--big-avatar-height-factor)); - /* width: unset; */ border-style: none; display: flex; justify-content: center; diff --git a/public/index.html b/public/index.html index b93346a7..91dff6fc 100644 --- a/public/index.html +++ b/public/index.html @@ -41,6 +41,7 @@ + @@ -4211,7 +4212,7 @@
-
- + + diff --git a/public/script.js b/public/script.js index 2279889d..bb136dc2 100644 --- a/public/script.js +++ b/public/script.js @@ -1,5 +1,5 @@ import { humanizedDateTime, favsToHotswap, getMessageTimeStamp, dragElement, isMobile, initRossMods, shouldSendOnEnter } from './scripts/RossAscends-mods.js'; -import { userStatsHandler, statMesProcess, initStats } from './scripts/stats.js'; +import { initStats } from './scripts/stats.js'; import { generateKoboldWithStreaming, kai_settings, @@ -1348,14 +1348,16 @@ function verifyCharactersSearchSortRule() { } } -/** @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 */ @@ -1384,13 +1386,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 * @@ -1449,6 +1461,23 @@ export function getEntitiesList({ doFilter = false, doSort = true } = {}) { return entities; } +/** + * Get one character from the character list via its character key + * + * To retrieve/refresh it from the API, use `getOneCharacter` to update it first. + * + * @param {string} characterKey - The character key / avatar url + * @returns {object} + */ +export function getCharacter(characterKey) { + return characters.find(x => x.avatar === characterKey); +} + +/** + * Gets one character from via API + * + * @param {string} avatarUrl - The avatar url / character key + */ export async function getOneCharacter(avatarUrl) { const response = await fetch('/api/characters/get', { method: 'POST', @@ -4445,7 +4474,7 @@ export async function sendMessageAsUser(messageText, messageBias, insertAt = nul } await populateFileAttachment(message); - statMesProcess(message, 'user', characters, this_chid, ''); + // statMesProcess(message, 'user', characters, this_chid, ''); if (typeof insertAt === 'number' && insertAt >= 0 && insertAt <= chat.length) { chat.splice(insertAt, 0, message); @@ -5595,6 +5624,17 @@ export 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 + */ export function buildAvatarList(block, entities, { templateId = 'inline_avatar_template', empty = true, selectable = false, highlightFavs = true } = {}) { if (empty) { block.empty(); @@ -5602,34 +5642,39 @@ export function buildAvatarList(block, entities, { templateId = 'inline_avatar_t 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'); @@ -6880,10 +6925,10 @@ function onScenarioOverrideRemoveClick() { * @param {string} type * @param {string} inputValue - Value to set the input to. * @param {PopupOptions} options - Options for the popup. - * @typedef {{okButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean, cropAspect?: number }} PopupOptions - Options for the popup. + * @typedef {{okButton?: string, rows?: number, wide?: boolean, wider?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean, cropAspect?: number }} PopupOptions - Options for the popup. * @returns */ -export function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) { +export function callPopup(text, type, inputValue = '', { okButton, rows, wide, wider, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) { function getOkButtonText() { if (['avatarToCrop'].includes(popup_type)) { return okButton ?? 'Accept'; @@ -6913,6 +6958,7 @@ export function callPopup(text, type, inputValue = '', { okButton, rows, wide, l const $shadowPopup = $('#shadow_popup'); $dialoguePopup.toggleClass('wide_dialogue_popup', !!wide) + .toggleClass('wider_dialogue_popup', !!wider) .toggleClass('large_dialogue_popup', !!large) .toggleClass('horizontal_scrolling_dialogue_popup', !!allowHorizontalScrolling) .toggleClass('vertical_scrolling_dialogue_popup', !!allowVerticalScrolling); @@ -10202,10 +10248,6 @@ jQuery(async function () { isManualInput = false; }); - $('.user_stats_button').on('click', function () { - userStatsHandler(); - }); - $('#external_import_button').on('click', async () => { const html = `

Enter the URL of the content to import

Supported sources:
diff --git a/public/scripts/RossAscends-mods.js b/public/scripts/RossAscends-mods.js index 33699b99..e53a74a6 100644 --- a/public/scripts/RossAscends-mods.js +++ b/public/scripts/RossAscends-mods.js @@ -81,19 +81,21 @@ observer.observe(document.documentElement, observerConfig); /** - * Converts generation time from milliseconds to a human-readable format. + * Converts a timespan from milliseconds to a human-readable format. * - * The function takes total generation time as an input, then converts it to a format + * The function takes a total timespan as an input, then converts it to a format * of "_ Days, _ Hours, _ Minutes, _ Seconds". If the generation time does not exceed a * particular measure (like days or hours), that measure will not be included in the output. * - * @param {number} total_gen_time - The total generation time in milliseconds. - * @returns {string} - A human-readable string that represents the time spent generating characters. + * @param {number} timespan - The total timespan in milliseconds. + * @param {object} [options] - Optional parameters + * @param {boolean} [options.short=false] - Flag indicating whether short form should be used. ('2h' instead of '2 Hours') + * @param {number} [options.onlyHighest] - Number of maximum blocks to be returned. (If, and daya is the highest matching unit, only returns days and hours, cutting of minutes and seconds) + * @returns {string} - A human-readable string that represents the timespan. */ -export function humanizeGenTime(total_gen_time) { - +export function humanizeTimespan(timespan, { short = false, onlyHighest = 2 } = {}) { //convert time_spent to humanized format of "_ Hours, _ Minutes, _ Seconds" from milliseconds - let time_spent = total_gen_time || 0; + let time_spent = timespan || 0; time_spent = Math.floor(time_spent / 1000); let seconds = time_spent % 60; time_spent = Math.floor(time_spent / 60); @@ -102,12 +104,36 @@ export function humanizeGenTime(total_gen_time) { let hours = time_spent % 24; time_spent = Math.floor(time_spent / 24); let days = time_spent; - time_spent = ''; - if (days > 0) { time_spent += `${days} Days, `; } - if (hours > 0) { time_spent += `${hours} Hours, `; } - if (minutes > 0) { time_spent += `${minutes} Minutes, `; } - time_spent += `${seconds} Seconds`; - return time_spent; + + let parts = [ + { singular: 'Day', plural: 'Days', short: 'd', value: days }, + { singular: 'Hour', plural: 'Hours', short: 'h', value: hours }, + { singular: 'Minute', plural: 'Minutes', short: 'm', value: minutes }, + { singular: 'Second', plural: 'Seconds', short: 's', value: seconds }, + ]; + + // Build the final string based on the highest significant units and respecting zeros + let resultParts = []; + let count = 0; + for (let part of parts) { + if (part.value > 0) { + resultParts.push(part); + } + + // If we got a match, we count from there. Take a maximum of X elements + if (resultParts.length) count++; + if (count >= onlyHighest) { + break; + } + } + + if (!resultParts.length) { + return short ? '<1s' : 'Instant'; + } + + return resultParts.map(part => { + return short ? `${part.value}${part.short}` : `${part.value} ${part.value === 1 ? part.singular : part.plural}`; + }).join(short ? ' ' : ', '); } /** diff --git a/public/scripts/personas.js b/public/scripts/personas.js index 4b9f7ac7..59b62e40 100644 --- a/public/scripts/personas.js +++ b/public/scripts/personas.js @@ -35,6 +35,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; +} + /** * Returns the URL of the avatar for the given user avatar Id. * @param {string} avatarImg User avatar Id diff --git a/public/scripts/stats.js b/public/scripts/stats.js index 48983766..03a064bf 100644 --- a/public/scripts/stats.js +++ b/public/scripts/stats.js @@ -1,181 +1,522 @@ // statsHelper.js -import { getRequestHeaders, callPopup, characters, this_chid } from '../script.js'; -import { humanizeGenTime } from './RossAscends-mods.js'; +import { getRequestHeaders, callPopup, characters, this_chid, buildAvatarList, characterToEntity, getOneCharacter, getCharacter, user_avatar, personaToEntity, getCurrentChatId } 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'; -let charStats = {}; + +/** @typedef {import('../script.js').Character} Character */ +/** @typedef {import('../../src/endpoints/stats.js').UserStatsCollection} UserStatsCollection */ +/** @typedef {import('../../src/endpoints/stats.js').CharacterStats} CharacterStats */ +/** @typedef {import('../../src/endpoints/stats.js').ChatStats} ChatStats */ +/** @typedef {import('../../src/endpoints/stats.js').MessageStats} MessageStats */ +/** @typedef {import('../../src/endpoints/stats.js').StatsRequestBody} StatsRequestBody */ /** - * Creates an HTML stat block. - * - * @param {string} statName - The name of the stat to be displayed. - * @param {number|string} statValue - The value of the stat to be displayed. - * @returns {string} - An HTML string representing the stat block. + * @typedef {object} AggregateStat + * @property {number} count - The number of stats used for this aggregation - used for recalculating avg + * @property {number} total - Total / Sum + * @property {number} min - Minimum value + * @property {number} max - Maximum value + * @property {number} avg - Average value + * @property {number[]} values - All values listed and saved, so the aggregate stats can be updated if needed when elements get removed + * @property {number?} subCount - The number of stats used when this is aggregated over the totals of aggregated stats, meaning based on any amount of sub/inner values */ -function createStatBlock(statName, statValue) { - return `
-
${statName}:
-
${statValue}
+ +/** + * @typedef {object} StatField A stat block value to print + * @property {any} value - The value to print + * @property {boolean} [isHeader=false] - Flag indicating whether this is a header + * @property {string|null} [info=null] - Optional text that will be shown as an info icon + * @property {string|'info'|null} [title=null] - Optional title for the value - if set to 'info', info will be used as title too + * @property {string[]|null} [classes=null] - Optional list of classes for the stat field + */ + +/** + * @typedef {object} AggBuildOptions Blah + * @property {string | {singular: string, plural: string}} [options.basedOn='chat'] - + * @property {string | {singular: string, plural: string}} [options.basedOnSub='message'] - + * @property {boolean} [options.excludeTotal=false] - Exclude + * @property {((value: *) => string)} [options.transform=null] - + */ + +/** @type {AggBuildOptions} */ +const DEFAULT_AGG_BUILD_OPTIONS = { basedOn: 'chat', basedOnSub: 'message', excludeTotal: false, transform: null }; + +/** + * Gets the fields for an aggregated value + * @param {AggregateStat} agg - + * @param {AggBuildOptions} [options=DEFAULT_AGG_BUILD_OPTIONS] - + * @returns {StatField[]} + */ +function aggregateFields(agg, options = DEFAULT_AGG_BUILD_OPTIONS) { + options = { ...DEFAULT_AGG_BUILD_OPTIONS, ...options }; + const basedOn = (typeof options.basedOn !== 'object' || options.basedOn === null) ? { singular: `${options.basedOn}`, plural: `${options.basedOn}s` } : options.basedOn; + const basedOnSub = (typeof options.basedOnSub !== 'object' || options.basedOnSub === null) ? { singular: `${options.basedOnSub}`, plural: `${options.basedOnSub}s` } : options.basedOnSub; + + /** @param {*|number} val @param {string} name @returns {StatField} */ + const build = (val, name) => { + // Apply transform and rounding + let value = options.transform ? options.transform(val) : val; + value = typeof value === 'number' ? sensibleRound(value) : value; + + // Build title tooltip + let title = `${name}, based on ${agg.count} ${agg.count !== 1 ? basedOn.plural : basedOn.singular}` + if (agg.subCount) title += ` and ${agg.subCount} ${agg.subCount !== 1 ? basedOnSub.plural : basedOnSub.singular}`; + + return { value: value, title: title }; + }; + return [options.excludeTotal ? null : build(agg.total, 'Total'), build(agg.min, 'Minimum'), build(agg.avg, 'Average'), build(agg.max, 'Maximum')]; +} + +/** Gets the stat field object for any value @param {StatField|any} x @returns {StatField} */ +function statField(x) { return (typeof x === 'object' && x !== null && Object.hasOwn(x, 'value')) ? x : { value: x }; } + +/** + * Creates an HTML stat block + * + * @param {StatField|any} name - The name content of the stat to be displayed + * @param {StatField[]|any[]} values - Value or values to be listed for the stat block + * @returns {string} - An HTML string representing the stat block + */ +function createStatBlock(name, ...values) { + /** @param {StatField} stat @returns {string} */ + function buildField(stat) { + const classes = ['rm_stat_field', stat.isHeader ? 'rm_stat_header' : '', ...(stat.classes ?? [])].filter(x => x?.length); + return `
+ ${stat.value === null || stat.value === '' ? '‌' : stat.value} + ${stat.info ? `
` : ''} +
`; + } + + const statName = statField(name); + const statValues = values.flat(Infinity).map(statField); + + const isDataRow = !statName.isHeader && !statValues.some(x => x.isHeader); + const isRightSpacing = statValues.slice(-1)[0]?.classes?.includes('rm_stat_right_spacing'); + // Hack right spacing, which is added via a value just having the class + if (isRightSpacing) { + statValues.pop(); + } + + const classes = ['rm_stat_block', isDataRow ? 'rm_stat_block_data_row' : null, isRightSpacing ? 'rm_stat_right_spacing' : null].filter(x => x?.length); + return `
+
${buildField(statName)}
+
${statValues.map(x => buildField(x)).join('')}
`; } /** - * Verifies and returns a numerical stat value. If the provided stat is not a number, returns 0. - * - * @param {number|string} stat - The stat value to be checked and returned. - * @returns {number} - The stat value if it is a number, otherwise 0. + * Show the stats popup for a given stats report + * @param {string} html - The html report that should be shown in the popup */ -function verifyStatValue(stat) { - return isNaN(Number(stat)) ? 0 : Number(stat); +function showStatsPopup(html) { + callPopup(html, 'text', '', { wider: true, allowVerticalScrolling: true }); } -/** - * Calculates total stats from character statistics. - * - * @returns {Object} - Object containing total statistics. - */ -function calculateTotalStats() { - let totalStats = { - total_gen_time: 0, - user_msg_count: 0, - non_user_msg_count: 0, - user_word_count: 0, - non_user_word_count: 0, - total_swipe_count: 0, - date_last_chat: 0, - date_first_chat: new Date('9999-12-31T23:59:59.999Z').getTime(), - }; - - for (let stats of Object.values(charStats)) { - totalStats.total_gen_time += verifyStatValue(stats.total_gen_time); - totalStats.user_msg_count += verifyStatValue(stats.user_msg_count); - totalStats.non_user_msg_count += verifyStatValue( - stats.non_user_msg_count, - ); - totalStats.user_word_count += verifyStatValue(stats.user_word_count); - totalStats.non_user_word_count += verifyStatValue( - stats.non_user_word_count, - ); - totalStats.total_swipe_count += verifyStatValue( - stats.total_swipe_count, - ); - - if (verifyStatValue(stats.date_last_chat) != 0) { - totalStats.date_last_chat = Math.max( - totalStats.date_last_chat, - stats.date_last_chat, - ); - } - if (verifyStatValue(stats.date_first_chat) != 0) { - totalStats.date_first_chat = Math.min( - totalStats.date_first_chat, - stats.date_first_chat, - ); - } - } - - return totalStats; -} +const HMTL_STAT_SPACER = '
'; +const VAL_RIGHT_SPACING = { value: null, classes: ['rm_stat_right_spacing'] }; +const BASED_ON_MES_PLUS_SWIPE = { singular: 'message and its swipes', plural: 'messages and their swipes' }; +const HOVER_TOOLTIP_SUFFIX = '\n\nHover over any value to see what it is based on.'; +const GEN_TOKEN_WARNING = '(Token count is only correct, if setting \'Message Token Count\' was turned on during generation)'; /** - * Generates an HTML report of stats. + * Generates an HTML report of character stats ("User" or "Character") * - * This function creates an HTML report from the provided stats, including chat age, - * 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 {string} statsType - The type of stats (e.g., "User", "Character"). - * @param {Object} stats - The stats data. Expected keys in this object include: - * total_gen_time - total generation time - * date_first_chat - timestamp of the first chat - * date_last_chat - timestamp of the most recent chat - * user_msg_count - count of user messages - * non_user_msg_count - count of non-user messages - * user_word_count - count of words used by the user - * non_user_word_count - count of words used by the non-user - * total_swipe_count - total swipe count + * @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) { - // Get time string - let timeStirng = humanizeGenTime(stats.total_gen_time); - let chatAge = 'Never'; - if (stats.date_first_chat < Date.now()) { - chatAge = moment - .duration(stats.date_last_chat - stats.date_first_chat) - .humanize(); - } +function createCharacterStatsHtml(statsType, stats) { + const NOW = Date.now(); + const isChar = statsType === 'character'; + + // 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

`; - if (statsType === 'User') { - html += createStatBlock('Chatting Since', `${chatAge} ago`); - } else { - html += createStatBlock('First Interaction', `${chatAge} ago`); - } - html += createStatBlock('Chat Time', timeStirng); - html += createStatBlock('User Messages', stats.user_msg_count); - html += createStatBlock( - 'Character Messages', - stats.non_user_msg_count - stats.total_swipe_count, - ); - html += createStatBlock('User Words', stats.user_word_count); - html += createStatBlock('Character Words', stats.non_user_word_count); - html += createStatBlock('Swipes', stats.total_swipe_count); + let html = `

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

`; + html += `${subHeader}`; - callPopup(html, 'text'); + // Overview + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: isChar ? 'Character Overview' : 'Overview', isHeader: true }); + html += createStatBlock({ value: 'Chats', info: `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: 'Chat file sizes on disk calculated and summed\nThis value might not represent the exact same value your operating system uses.' }, + humanFileSize(stats.chatSize), VAL_RIGHT_SPACING); + html += createStatBlock({ value: 'Most Used Model', info: 'Most used model for generations, both messages and swipes\n(Does not include internal generation commands like /gen or /impersonate)\n\nHover over the value to see the numbers behind.' }, + { value: smartTruncate(mostUsedModel.model, 32), title: 'info', info: `${mostUsedModel.model}\nUsed ${mostUsedModel.count} times to generate ${mostUsedModel.tokens} tokens\n\n${GEN_TOKEN_WARNING}` }, VAL_RIGHT_SPACING); + + html += HMTL_STAT_SPACER; + html += createStatBlock('', + { value: 'First', isHeader: true, info: `Data corresponding to the first chat with ${stats.charName}`, title: 'info' }, + { value: 'Last', isHeader: true, info: `Data corresponding to the last chat with ${stats.charName}`, title: 'info' }, + VAL_RIGHT_SPACING, + ); + html += createStatBlock({ value: 'New Chat', info: 'First/Last time when a new chat was started' }, + { value: humanizedDuration(stats.firstCreateDate, NOW, { wrapper: x => `${x} ago` }), title: stats.firstCreateDate }, + { value: humanizedDuration(stats.lastCreateDate, NOW, { wrapper: x => `${x} ago` }), title: stats.lastCreateDate }, + VAL_RIGHT_SPACING, + ); + html += createStatBlock({ value: 'Chat Ended', info: 'First/Last time when the interaction was done in a chat' }, + { value: humanizedDuration(stats.firstlastInteractionDate, NOW, { wrapper: x => `${x} ago` }), title: stats.firstlastInteractionDate }, + { value: humanizedDuration(stats.lastLastInteractionDate, NOW, { wrapper: x => `${x} ago` }), title: stats.lastLastInteractionDate }, + VAL_RIGHT_SPACING, + ); + + // Aggregated Stats + html += HMTL_STAT_SPACER; + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'Aggregated Stats', isHeader: true, info: 'Values per chat, aggregated over all chats\n\n • Total: Total summed value over all chats\n • Min: Minium value for any chat\n • Avg: Average value over all chats\n • Max: Maximum value for any chat' }); + html += createStatBlock(null, + { value: 'Total', isHeader: true, info: 'Total summed value over all chats', title: 'info' }, + { value: 'Min', isHeader: true, info: 'Minium value for any chat', title: 'info' }, + { value: 'Avg', isHeader: true, info: 'Average value over all chats', title: 'info' }, + { value: 'Max', isHeader: true, info: 'Maximum value for any chat', title: 'info' } + ); + html += createStatBlock({ value: 'Chatting Time', info: 'Chatting time per chat\nCalculated based on chat creation and the last interaction in that chat.' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.chattingTime, { transform: time => humanizeTimespan(time, { short: true }) })); + html += createStatBlock({ value: 'Generation Time', info: 'Generation time per chat\nSummed generation times of all messages and swipes.' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.genTime, { basedOnSub: BASED_ON_MES_PLUS_SWIPE, transform: time => humanizeTimespan(time, { short: true }) })); + html += createStatBlock({ value: 'Generated Tokens', info: `Generated tokens per chat\nSummed token counts of all messages and swipes.\n${GEN_TOKEN_WARNING}` + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.genTokenCount, { basedOnSub: BASED_ON_MES_PLUS_SWIPE })); + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'Swiping Time', info: 'Swiping time per chat\nSummed time spend on generation alternative swipes. Excludes the final message that was chosen to continue the chat.' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.swipeGenTime, { basedOnSub: BASED_ON_MES_PLUS_SWIPE, transform: time => humanizeTimespan(time, { short: true }) })); + html += createStatBlock({ value: 'Swipes', info: 'Swipes per chat\nCounts all generated messages/swipes that were not chosen to continue the chat.' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.swipes, { basedOnSub: BASED_ON_MES_PLUS_SWIPE })); + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'User Response Time', info: 'User response time per chat\nCalculated based on the time between the last action of the message before and the next user message.\nAs \'action\' counts both the message send time and when the last generation of it ended, even if that swipe wasn\'t chosen.' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.userResponseTime, { transform: time => humanizeTimespan(time, { short: true }) })); + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'Messages', info: 'Messages per chat (excluding swipes)' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.messages)); + html += createStatBlock({ value: 'System Messages', info: 'Sytem messages per chat' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.systemMessages)); + html += createStatBlock({ value: 'Messages (User / Char)', classes: ['rm_stat_field_smaller'], info: 'Messages per chat (excluding swipes)\nSplit into user and character, and showing a bar graph with percentages.' + HOVER_TOOLTIP_SUFFIX }, + ...buildBarDescsFromAggregates(stats.userMessages, stats.charMessages)); + html += createStatBlock({ value: '', info: '' }, + ...buildBarsFromAggregates(stats.userName, stats.userMessages, stats.charName, stats.charMessages)); + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'Words', info: 'Word count per chat (excluding swipes)' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.words)); + html += createStatBlock({ value: 'Words (User / Char)', classes: ['rm_stat_field_smaller'], info: 'Word count per chat (excluding swipes)\nSplit into user and character, and showing a bar graph with percentages.' + HOVER_TOOLTIP_SUFFIX }, + ...buildBarDescsFromAggregates(stats.userWords, stats.charWords)); + html += createStatBlock({ value: '', info: '' }, + ...buildBarsFromAggregates(stats.userName, stats.userWords, stats.charName, stats.charWords)); + + // Per Message Stats + html += HMTL_STAT_SPACER; + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'Per Message Stats', isHeader: true, info: 'Values per message, aggregated over all chats\n\n • Min: Minium value for any message\n • Avg: Average value over all messages\n • Max: Maximum value for any message' }); + html += createStatBlock('', + null, + { value: 'Min', isHeader: true, info: 'Minium value for any message', title: 'info' }, + { value: 'Avg', isHeader: true, info: 'Average value over all messages', title: 'info' }, + { value: 'Max', isHeader: true, info: 'Maximum value for any message', title: 'info' } + ); + html += createStatBlock({ value: 'Generation Time', info: 'Generation time per message\nSummed generation times of the message and all swipes.' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.perMessageGenTime, { basedOn: BASED_ON_MES_PLUS_SWIPE, excludeTotal: true, transform: time => humanizeTimespan(time, { short: true }) })); + html += createStatBlock({ value: 'Generated Tokens', info: `Generated tokens per message\nSummed token counts of the message and all swipes.\n${GEN_TOKEN_WARNING}` + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.perMessageGenTokenCount, { basedOn: BASED_ON_MES_PLUS_SWIPE, excludeTotal: true })); + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'Swiping Time', info: 'Swiping time per message\nSummed time spend on generation alternative swipes. Excludes the final message that was chosen to continue the chat.' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.perMessageSwipeGenTime, { basedOn: BASED_ON_MES_PLUS_SWIPE, excludeTotal: true, transform: time => humanizeTimespan(time, { short: true }) })); + html += createStatBlock({ value: 'Swipes', info: 'Swipes per message\nCounts all generated messages/swipes that were not chosen to continue the chat.' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.perMessageSwipeCount, { basedOn: BASED_ON_MES_PLUS_SWIPE, excludeTotal: true })); + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'User Response Time', info: 'User response time per message\nCalculated based on the time between the last action of the message before and the next user message.\nAs \'action\' counts both the message send time and when the last generation of it ended, even if that swipe wasn\'t chosen.' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.perMessageUserResponseTime, { basedOn: 'message', excludeTotal: true, transform: time => humanizeTimespan(time, { short: true }) })); + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'Words', info: 'Word count per message (excluding swipes)' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.perMessageWords, { basedOn: 'message', excludeTotal: true })); + html += createStatBlock({ value: 'Words (User / Char)', classes: ['rm_stat_field_smaller'], info: 'Word count per message (excluding swipes)\nSplit into user and character, and showing a bar graph with percentages.' + HOVER_TOOLTIP_SUFFIX }, + ...buildBarDescsFromAggregates(stats.perMessageUserWords, stats.perMessageCharWords, { basedOn: 'message', excludeTotal: true })); + html += createStatBlock({ value: '', info: '' }, + ...buildBarsFromAggregates(stats.userName, stats.perMessageUserWords, stats.charName, stats.perMessageCharWords, { basedOn: 'message', excludeTotal: true })); + + html += HMTL_STAT_SPACER; + html += ``; + + const avatarBlock = buildAvatarBlock(statsType == 'character' ? stats.characterKey : null); + if (avatarBlock) { + html = avatarBlock + html; + } + + return html; +} + +/** + * Generates an HTML report of chat stats + * + * @param {ChatStats} stats - The stats data + * @returns {string} The html + */ +function createChatStatsHtml(stats) { + const NOW = Date.now(); + + // some pre calculations + const mostUsedModel = findHighestModel(stats.genModels); + + // Create popup HTML with stats + let html = `

Chat Stats

`; + html += `Chat stats for chat '${stats.chatName}'
`; + + // Chat Overview + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'Chat Overview', isHeader: true }); + html += createStatBlock({ value: 'File Size', info: 'Chat file size on disk\nThis value might not represent the exact same value your operating system uses.' }, + humanFileSize(stats.chatSize), VAL_RIGHT_SPACING); + html += createStatBlock({ value: 'Most Used Model', info: 'Most used model for generations, both messages and swipes\n(Does not include internal generation commands like /gen or /impersonate)\n\nHover over the value to see the numbers behind.' }, + { value: smartTruncate(mostUsedModel.model, 32), title: 'info', info: `${mostUsedModel.model}\nUsed ${mostUsedModel.count} times to generate ${mostUsedModel.tokens} tokens\n\n${GEN_TOKEN_WARNING}` }, VAL_RIGHT_SPACING); + + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'Chat Created', info: 'Time when this chat was created' }, + { value: humanizedDuration(stats.createDate, NOW, { wrapper: x => `${x} ago` }), title: stats.createDate }, VAL_RIGHT_SPACING); + html += createStatBlock({ value: 'Chat Ended', info: 'Time when the last interaction was done in this chat' }, + { value: humanizedDuration(stats.lastInteractionDate, NOW, { wrapper: x => `${x} ago` }), title: stats.lastInteractionDate }, VAL_RIGHT_SPACING); + + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'Chatting Time', info: 'Chatting time in this chat\nCalculated based on chat creation and the last interaction in that chat.' }, + humanizeTimespan(stats.chattingTime, { short: true }), VAL_RIGHT_SPACING); + + // Chat Details + html += HMTL_STAT_SPACER; + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'Chat Details', isHeader: true }); + html += createStatBlock(null, + { value: 'Messages', isHeader: true, info: 'Messages in this chat (excluding swipes)', title: 'info' }, + { value: 'Words', isHeader: true, info: 'Words in this chat (excluding swipes)', title: 'info' } + ); + html += createStatBlock({ value: 'Total', info: 'Messages/words in this chat (excluding swipes)' }, + stats.messages, stats.words.total); + html += createStatBlock({ value: 'System', info: 'Sytem messages in this chat (not counted for words)' }, + stats.systemMessages, '-'); + html += createStatBlock({ value: '(User / Char)', classes: ['rm_stat_field_smaller'], info: 'Messages/words in this chat (excluding swipes)\nSplit into user and character, and showing a bar graph with percentages.' }, + ...buildBarDescs(stats.userMessages, stats.charMessages), ...buildBarDescs(stats.userWords.total, stats.charWords.total)); + html += createStatBlock({ value: '', info: '' }, + buildBar(stats.userName, stats.userMessages, stats.charName, stats.charMessages), buildBar(stats.userName, stats.userWords.total, stats.charName, stats.charWords.total)); + + // Aggregated Stats + html += HMTL_STAT_SPACER; + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'Aggregated Stats', isHeader: true, info: 'Values aggregated over this chat, per message\n\n • Total: Total summed value over this chat\n • Min: Minium value for any message\n • Avg: Average value over all messages\n • Max: Maximum value for any message' }); + html += createStatBlock('', + { value: 'Total', isHeader: true, info: 'Total summed value for this chat', title: 'info' }, + { value: 'Min', isHeader: true, info: 'Minium value for any message', title: 'info' }, + { value: 'Avg', isHeader: true, info: 'Average value over all messages', title: 'info' }, + { value: 'Max', isHeader: true, info: 'Maximum value for any message', title: 'info' } + ); + html += createStatBlock({ value: 'Generation Time', info: 'Generation time per message\nSummed generation times of the message and all swipes.' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.genTime, { basedOn: BASED_ON_MES_PLUS_SWIPE, transform: time => humanizeTimespan(time, { short: true }) })); + html += createStatBlock({ value: 'Generated Tokens', info: `Generated tokens per message\nSummed token counts of the message and all swipes.\n${GEN_TOKEN_WARNING}` + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.genTokenCount, { basedOn: BASED_ON_MES_PLUS_SWIPE })); + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'Swiping Time', info: 'Swiping time per message\nSummed time spend on generation alternative swipes. Excludes the final message that was chosen to continue the chat.' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.swipeGenTime, { basedOn: BASED_ON_MES_PLUS_SWIPE, transform: time => humanizeTimespan(time, { short: true }) })); + html += createStatBlock({ value: 'Swipes', info: 'Swipes per message\nCounts all generated messages/swipes that were not chosen to continue the chat.' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.swipes, { basedOn: BASED_ON_MES_PLUS_SWIPE })); + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'User Response Time', info: 'User response time per message\nCalculated based on the time between the last action of the message before and the next user message.\nAs \'action\' counts both the message send time and when the last generation of it ended, even if that swipe wasn\'t chosen.' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.userResponseTime, { basedOn: 'message', transform: time => humanizeTimespan(time, { short: true }) })); + html += HMTL_STAT_SPACER; + html += createStatBlock({ value: 'Words', info: 'Word count per message (excluding swipes)' + HOVER_TOOLTIP_SUFFIX }, + ...aggregateFields(stats.words, { basedOn: 'message' })); + html += createStatBlock({ value: 'Words (User / Char)', classes: ['rm_stat_field_smaller'], info: 'Word count per message (excluding swipes)\nSplit into user and character, and showing a bar graph with percentages.' + HOVER_TOOLTIP_SUFFIX }, + ...buildBarDescsFromAggregates(stats.userWords, stats.charWords, { basedOn: 'message' })); + html += createStatBlock({ value: '', info: '' }, + ...buildBarsFromAggregates(stats.userName, stats.userWords, stats.charName, stats.charWords, { basedOn: 'message' })); + + html += HMTL_STAT_SPACER; + html += ``; + + const avatarBlock = buildAvatarBlock(stats.characterKey); + if (avatarBlock) { + html = avatarBlock + html; + } + + return html; +} + +/** Builds the avatar block for a given character, or user avatar if none is given. @param {string?} [characterKey=null] @returns {string} */ +function buildAvatarBlock(characterKey = null) { + // Hijack avatar list function to draw the user avatar + let entity = null; + if (characterKey) { + const character = getCharacter(characterKey); + const cid = characters.indexOf(x => x === character); + entity = characterToEntity(character, cid); + } else { + const persona = getPersona(user_avatar); + entity = personaToEntity(persona); + } + if (entity) { + const placeHolder = $('
'); + buildAvatarList(placeHolder, [entity]); + return placeHolder.prop('outerHTML'); + } + return ''; +} + +/** + * Finds the model with the highest count and returns its name and values. + * + * @param {{[model: string]: { count: number, tokens: number }}} genModels - Object containing model usages + * @returns {{ model: string, count: number, tokens: number }} - Object containing the name and values of the model with the highest count + */ +function findHighestModel(genModels) { + return Object.entries(genModels).reduce((acc, [model, values]) => { + return values.count > acc.count ? { model: model, count: values.count, tokens: values.tokens } : acc; + }, { model: '', count: 0, tokens: 0 }); +} + +/** @param {string} userName @param {AggregateStat} aggUser @param {string} charName @param {AggregateStat} aggChar @param {AggBuildOptions} options @returns {StatField[]} */ +function buildBarsFromAggregates(userName, aggUser, charName, aggChar, options = DEFAULT_AGG_BUILD_OPTIONS) { + options = { ...DEFAULT_AGG_BUILD_OPTIONS, ...options }; + const fUser = aggregateFields(aggUser, options); + const fChar = aggregateFields(aggChar, options); + const bars = fUser.map((_, i) => buildBar(userName, fUser[i]?.value, charName, fChar[i]?.value)); + return bars; +} +/** @param {string} userName @param {number} userVal @param {string} charName @param {number} charVal @returns {StatField} */ +function buildBar(userName, userVal, charName, charVal) { + const percentUser = (userVal / (userVal + charVal)) * 100; + const percentChar = 100 - percentUser; + const bar = `
+
+
+
`; + return statField(bar); +} +/** @param {AggregateStat} agg1 @param {AggregateStat} agg2 @param {AggBuildOptions} options @returns {StatField[]} */ +function buildBarDescsFromAggregates(agg1, agg2, options = DEFAULT_AGG_BUILD_OPTIONS) { + options = { ...DEFAULT_AGG_BUILD_OPTIONS, ...options }; + const f1 = aggregateFields(agg1, options); + const f2 = aggregateFields(agg2, options); + const values = [f1[0], f2[0], f1[1], f2[1], f1[2], f2[2], f1[3], f2[3]]; + return buildBarDescs(values); +} +/** @param {any[]} values @returns {StatField[]} */ +function buildBarDescs(...values) { + return values.flat(Infinity).map(statField).map((x, i) => i % 2 == 0 ? { classes: [...(x.classes ?? []), 'rm_stat_field_lefty'], ...x } : x); } /** * Handles the user stats by getting them from the server, calculating the total and generating the HTML report. */ -async function userStatsHandler() { +export async function showUserStatsPopup() { // Get stats from server - await getStats(); - - // Calculate total stats - let totalStats = calculateTotalStats(); + const userStats = await getUserStats(); // Create HTML with stats - createHtml('User', totalStats); + const html = createCharacterStatsHtml('user', userStats); + showStatsPopup(html); } /** * Handles the character stats by getting them from the server and generating the HTML report. * - * @param {Object} characters - Object containing character data. - * @param {string} this_chid - The character id. + * @param {string} characterKey - The character key for the character to request stats from */ -async function characterStatsHandler(characters, this_chid) { +export async function showCharacterStatsPopup(characterKey) { // Get stats from server - await getStats(); - // Get character stats - let myStats = charStats[characters[this_chid].avatar]; - if (myStats === undefined) { - myStats = { - total_gen_time: 0, - user_msg_count: 0, - non_user_msg_count: 0, - user_word_count: 0, - non_user_word_count: countWords(characters[this_chid].first_mes), - total_swipe_count: 0, - date_last_chat: 0, - date_first_chat: new Date('9999-12-31T23:59:59.999Z').getTime(), - }; - charStats[characters[this_chid].avatar] = myStats; - updateStats(); + const charStats = await getCharacterStats(characterKey); + if (charStats === null) { + toastr.info(`No stats exist for character ${getCharacter(characterKey)?.name}.`); + return; } + // Create HTML with stats - createHtml('Character', myStats); + const html = createCharacterStatsHtml('character', charStats); + showStatsPopup(html); +} + +/** + * Handles the chats stats by getting them from the server and generating the HTML report. + * + * @param {string} characterKey - The character key for the character to request stats from + * @param {string} chatName - The name of the chat file + */ +export async function showChatStatsPopup(characterKey, chatName) { + // Get stats from server + const chatStats = await getChatStats(characterKey, chatName); + if (chatStats === null) { + toastr.info(`No stats exist for chat '${chatName}' with character ${getCharacter(characterKey)?.name}.`); + return; + } + + // Create HTML with stats + const html = createChatStatsHtml(chatStats); + showStatsPopup(html); +} + +/** + * Fetches the user stats (global stats) from the server. + * @returns {Promise} + */ +async function getUserStats() { + const stats = await callGetStats({ global: true }); + return stats; +} + +/** + * Fetches the char stats for a specific character from the server. + * @param {string} characterKey - The character key for the character to request stats from + * @returns {Promise} + */ +async function getCharacterStats(characterKey) { + const stats = await callGetStats({ characterKey: characterKey }); + return stats; +} + +/** + * Fetches the chat stats for a specific character chat from the server. + * @param {string} characterKey - The character key for the character to request stats from + * @param {string} chatName - The name of the chat file + * @returns {Promise} + */ +async function getChatStats(characterKey, chatName) { + const stats = await callGetStats({ characterKey: characterKey, chatName: chatName }); + return stats; +} + +/** + * Fetches the full stat collection from the server. + * @returns {Promise} + */ +async function getFullStatsCollection() { + const stats = await callGetStats(); + return stats; } /** * Fetches the character stats from the server. + * For retrieving, use the more specific functions `getCharacterStats`, `getUserStats`, `getChatStats` and `getFullStatsCollection`. + * @param {StatsRequestBody} [params={}] Optional parameter for the get request + * @returns {Promise} character stats the full stats collection, depending on what was requested */ -async function getStats() { +async function callGetStats(params = {}) { const response = await fetch('/api/stats/get', { method: 'POST', headers: getRequestHeaders(), - body: JSON.stringify({}), + body: JSON.stringify(params), cache: 'no-cache', }); @@ -183,7 +524,11 @@ async function getStats() { toastr.error('Stats could not be loaded. Try reloading the page.'); throw new Error('Error getting stats'); } - charStats = await response.json(); + + // To use the custom JSON parser, we need to retrieve the body as text first + const bodyText = await response.text(); + const stats = parseJson(bodyText); + return stats; } /** @@ -211,121 +556,16 @@ async function recreateStats() { } } - -/** - * Calculates the generation time based on start and finish times. - * - * @param {string} gen_started - The start time in ISO 8601 format. - * @param {string} gen_finished - The finish time in ISO 8601 format. - * @returns {number} - The difference in time in milliseconds. - */ -function calculateGenTime(gen_started, gen_finished) { - if (gen_started === undefined || gen_finished === undefined) { - return 0; - } - let startDate = new Date(gen_started); - let endDate = new Date(gen_finished); - return endDate.getTime() - startDate.getTime(); -} - -/** - * Sends a POST request to the server to update the statistics. - */ -async function updateStats() { - const response = await fetch('/api/stats/update', { - method: 'POST', - headers: getRequestHeaders(), - body: JSON.stringify(charStats), - }); - - if (response.status !== 200) { - console.error('Failed to update stats'); - console.log(response.status); - } -} - -/** - * Returns the count of words in the given string. - * A word is a sequence of alphanumeric characters (including underscore). - * - * @param {string} str - The string to count words in. - * @returns {number} - Number of words. - */ -function countWords(str) { - const match = str.match(/\b\w+\b/g); - return match ? match.length : 0; -} - -/** - * Handles stat processing for messages. - * - * @param {Object} line - Object containing message data. - * @param {string} type - The type of the message processing (e.g., 'append', 'continue', 'appendFinal', 'swipe'). - * @param {Object} characters - Object containing character data. - * @param {string} this_chid - The character id. - * @param {string} oldMesssage - The old message that's being processed. - */ -async function statMesProcess(line, type, characters, this_chid, oldMesssage) { - if (this_chid === undefined || characters[this_chid] === undefined) { - return; - } - await getStats(); - - let stat = charStats[characters[this_chid].avatar]; - - if (!stat) { - stat = { - total_gen_time: 0, - user_word_count: 0, - non_user_msg_count: 0, - user_msg_count: 0, - total_swipe_count: 0, - date_first_chat: Date.now(), - date_last_chat: Date.now(), - }; - } - - stat.total_gen_time += calculateGenTime( - line.gen_started, - line.gen_finished, - ); - if (line.is_user) { - if (type != 'append' && type != 'continue' && type != 'appendFinal') { - stat.user_msg_count++; - stat.user_word_count += countWords(line.mes); - } else { - let oldLen = oldMesssage.split(' ').length; - stat.user_word_count += countWords(line.mes) - oldLen; - } - } else { - // if continue, don't add a message, get the last message and subtract it from the word count of - // the new message - if (type != 'append' && type != 'continue' && type != 'appendFinal') { - stat.non_user_msg_count++; - stat.non_user_word_count += countWords(line.mes); - } else { - let oldLen = oldMesssage.split(' ').length; - stat.non_user_word_count += countWords(line.mes) - oldLen; - } - } - - if (type === 'swipe') { - stat.total_swipe_count++; - } - stat.date_last_chat = Date.now(); - stat.date_first_chat = Math.min( - stat.date_first_chat ?? new Date('9999-12-31T23:59:59.999Z').getTime(), - Date.now(), - ); - updateStats(); -} - export function initStats() { - $('.rm_stats_button').on('click', function () { - characterStatsHandler(characters, this_chid); + $('.rm_char_stats_button').on('click', async function () { + await showCharacterStatsPopup(characters[this_chid].avatar); + }); + $('.rm_chat_stats_button').on('click', async function () { + await showChatStatsPopup(characters[this_chid].avatar, getCurrentChatId()); + }); + $('.user_stats_button').on('click', async function () { + await showUserStatsPopup(); }); // Wait for debug functions to load, then add the refresh stats function registerDebugFunction('refreshStats', 'Refresh Stat File', 'Recreates the stats file based on existing chat files', recreateStats); } - -export { userStatsHandler, characterStatsHandler, getStats, statMesProcess, charStats }; diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 64e83428..ae87b919 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/public/scripts/utils.js b/public/scripts/utils.js index 33b51ee4..2895f043 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -215,6 +215,49 @@ export function getBase64Async(file) { }); } + + +/** + * Parse JSON data with optional reviver function. + * Converts date strings back to Date objects if found. + * + * @param {string} json - The JSON data to parse + * @param {object} [options] - Optional parameters + * @param {Reviver?} [options.reviver=null] - Custom reviver function to customize parsing + * @param {boolean} [options.disableDefaultReviver=false] - Flag to disable the default date parsing reviver + * @returns {object} - The parsed JSON object + */ +export function parseJson(json, { reviver = null, disableDefaultReviver = false } = {}) { + /** + * @typedef {((this: any, key: string, value: any) => any)} Reviver + * @param {object} this - + * @param {string} key - The key of the current property being processed + * @param {*} value - The value of the current property being processed + * @returns {*} - The processed value + */ + + /** @type {Reviver} The default reviver, that converts Date strings to Date objects */ + function defaultReviver(key, value) { + // Check if the value is a string and can be converted to a Date + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/.test(value)) { + return new Date(value); + } + // Return the original value if it's not a date string + return value; + } + + // The actual reviver based on the ones given + /** @type {Reviver} */ + function actualReviver(key, value) { + if (reviver) value = reviver(key, value); + if (!disableDefaultReviver) value = defaultReviver(key, value); + return value; + }; + + // Parse the JSON data using the specified or custom reviver function + return JSON.parse(json, actualReviver); +} + /** * Parses a file blob as a JSON object. * @param {Blob} file The file to read. @@ -525,24 +568,44 @@ export function trimToStartSentence(input) { } /** - * Format bytes as human-readable text. - * - * @param bytes Number of bytes. - * @param si True to use metric (SI) units, aka powers of 1000. False to use - * binary (IEC), aka powers of 1024. - * @param dp Number of decimal places to display. - * - * @return Formatted string. + * Build a humanized string for a duration + * @param {Date|number} start - Start time (as a Date, or in milliseconds) + * @param {Date|number|null} end - End time (as a Date, or in milliseconds), if null will be replaced with Date.now() + * @param {object} param2 - Optional parameters + * @param {string} [param2.fallback='Never'] - Fallback value no duration can be calculated + * @param {function(string): string} [param2.wrapper=null] - Optional function to wrap/format the resulting humanized duration + * @returns {string} Humanized duration string */ -export function humanFileSize(bytes, si = false, dp = 1) { +export function humanizedDuration(start, end = null, { fallback = 'Never', wrapper = null } = {}) { + const startTime = start instanceof Date ? start.getTime() : start; + const endTime = end instanceof Date ? end.getTime() : end ?? Date.now(); + if (!startTime || endTime > endTime) { + return fallback; + } + // @ts-ignore + const humanized = moment.duration(endTime - start).humanize(); + return wrapper ? wrapper(humanized) : humanized; +} + +/** + * Format bytes as human-readable text + * + * @param bytes Number of bytes + * @param si True to use metric (SI) units, aka powers of 1000. False to use binary (IEC), aka powers of 1024 + * @param ibi If `si` is disabled, setting this to True will return unit names as 'KiB', 'MiB' etc. instead of 'KB', 'MB'. + * @param dp Number of decimal places to display + * + * @return Formatted string + */ +export function humanFileSize(bytes, si = false, ibi = false, dp = 1) { const thresh = si ? 1000 : 1024; if (Math.abs(bytes) < thresh) { return bytes + ' B'; } - const units = si - ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + const units = si || !ibi + ? ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; let u = -1; const r = 10 ** dp; @@ -1449,3 +1512,72 @@ export function includesIgnoreCaseAndAccents(text, searchTerm) { // Check if the normalized text includes the normalized search term return normalizedText.includes(normalizedSearchTerm); } + +/** + * Rounds a number conditionally in a sensible way, based on thresholds + * + * @param {number} number - The number to round + * @param {object} [options={}] - Optional parameters + * @param {{[threshold: number]: number, _: number}} [options.thresholds={ 1: 3, 100: 2, _: 0 }] - Custom rounding thresholds, specified by 'threshold value: rounding decimals'. The default value will be provided with the key '_'. + * @returns {number} - The rounded number + */ +export function sensibleRound(number, { thresholds = { 1: 3, 100: 2, _: 0 } } = {}) { + // Sort thresholds by ascending order of keys + const sortedThresholds = Object.keys(thresholds).map(parseFloat).sort((a, b) => a - b); + + // Find the appropriate threshold for rounding + let decimalPlaces = thresholds._ ?? 0; + for (const threshold of sortedThresholds) { + if (number < threshold) { + decimalPlaces = thresholds[threshold]; + break; + } + } + + return +number.toFixed(decimalPlaces); +} + +/** + * Truncates a given text at the end of word boundaries, filling in an ellipsis. The max total length will not exceed the provided value. + * If the word boundaries will make this string too short, it'll make a hard truncate instead to preserve contextual information. + * @param {string} text - The text to truncate + * @param {number} maxLength - max length to truncate to + * @param {string} [ellipsis='…'] - Ellipsis to add if the string was truncated + * @param {number} [minLength=maxLength/2] - A minimum length below which the word boundary trunaction should not go to. If hit, hard truncation at max length will be used + * @returns {string} + */ +export function smartTruncate(text, maxLength, ellipsis = '…', minLength = maxLength / 2) { + if (text.length <= maxLength) { + return text; + } + if (ellipsis.length > maxLength) { + console.warn(`Cannot truncate to length of ${maxLength}, below the length of the ellipsis '${ellipsis}'.`); + return text; + } + + const isWord = (char) => /^\w$/.test(char); + const maxTruncLength = maxLength - ellipsis.length; + + // If the first character beyond the trunc length is non-word, we can cut at the next word char. + let cutSoon = !isWord(text[maxTruncLength + 1]); + + let index = maxTruncLength + 1; + while (index > 0) { + const char = text[index]; + + // Look for a non-word character to prepare for a cut + if (!isWord(char)) { + cutSoon = true; + } else if (cutSoon) { + break; + } + index--; + } + + // If the cut is too close to start, or no appropriate cut point is found, fall back to hard truncation + if (index < minLength) { + return text.slice(0, maxLength - ellipsis.length) + ellipsis; + } + + return text.slice(0, index + 1) + ellipsis; +} diff --git a/public/style.css b/public/style.css index 7cd1f5dd..57f8e096 100644 --- a/public/style.css +++ b/public/style.css @@ -2074,10 +2074,6 @@ grammarly-extension { text-align: right; } -.rm_stats_button { - cursor: pointer; -} - /* Focus */ #bulk_tag_popup, @@ -2108,11 +2104,6 @@ grammarly-extension { overflow-x: hidden; } -.rm_stat_block { - display: flex; - justify-content: space-between; -} - .large_dialogue_popup { height: 90vh !important; height: 90svh !important; @@ -2126,12 +2117,8 @@ grammarly-extension { min-width: var(--sheldWidth); } -.horizontal_scrolling_dialogue_popup { - overflow-x: unset !important; -} - -.vertical_scrolling_dialogue_popup { - overflow-y: unset !important; +.wider_dialogue_popup { + min-width: 750px; } #bulk_tag_popup_holder, @@ -2140,15 +2127,26 @@ grammarly-extension { display: flex; flex-direction: column; height: 100%; - overflow-y: hidden; padding: 0 10px; } #dialogue_popup_text, .dialogue_popup_text { flex-grow: 1; - overflow-y: auto; height: 100%; + position: relative; + overflow-x: hidden; + overflow-y: hidden; + max-height: calc(90vh - 50px); + max-height: calc(90svh - 50px); +} + +.horizontal_scrolling_dialogue_popup #dialogue_popup_text { + overflow-x: unset; +} + +.vertical_scrolling_dialogue_popup #dialogue_popup_text { + overflow-y: unset; } #dialogue_popup_controls, @@ -2156,6 +2154,7 @@ grammarly-extension { display: flex; align-self: center; gap: 20px; + height: 40px; } #bulk_tag_popup_reset, diff --git a/src/endpoints/stats.js b/src/endpoints/stats.js index 4d20c872..0a56315d 100644 --- a/src/endpoints/stats.js +++ b/src/endpoints/stats.js @@ -3,152 +3,35 @@ const path = require('path'); const express = require('express'); const writeFileAtomic = require('write-file-atomic'); const crypto = require('crypto'); +const sanitize = require('sanitize-filename'); + +const { jsonParser } = require('../express-common'); +const { readAndParseJsonlFile, parseJson, timestampToMoment, humanizedToDate, calculateDuration, minDate, maxDate, now } = require('../util'); +const { getAllUserHandles, getUserDirectories } = require('../users'); const readFile = fs.promises.readFile; const readdir = fs.promises.readdir; -const { jsonParser } = require('../express-common'); -const { getAllUserHandles, getUserDirectories } = require('../users'); - +const MIN_TIMESTAMP = 0; +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_LANGUAGE = 'en'; const STATS_FILE = 'stats.json'; +const CURRENT_STATS_VERSION = '1.2'; -/** - * @type {Map} The stats object for each user. - */ +/** @type {Map} The stats collections for each user, accessable via their key - gets set/built on init */ const STATS = new Map(); -let lastSaveTimestamp = 0; + +let lastSaveDate = MIN_DATE; /** - * Convert a timestamp to an integer timestamp. - * (sorry, it's momentless for now, didn't want to add a package just for this) - * This function can handle several different timestamp formats: - * 1. Unix timestamps (the number of seconds since the Unix Epoch) - * 2. ST "humanized" timestamps, formatted like "YYYY-MM-DD @HHh MMm SSs ms" - * 3. Date strings in the format "Month DD, YYYY H:MMam/pm" - * - * The function returns the timestamp as the number of milliseconds since - * the Unix Epoch, which can be converted to a JavaScript Date object with new Date(). - * - * @param {string|number} timestamp - The timestamp to convert. - * @returns {number} The timestamp in milliseconds since the Unix Epoch, or 0 if the input cannot be parsed. - * - * @example - * // Unix timestamp - * timestampToMoment(1609459200); - * // ST humanized timestamp - * timestampToMoment("2021-01-01 \@00h 00m 00s 000ms"); - * // Date string - * timestampToMoment("January 1, 2021 12:00am"); + * Gets the user stats collection. Creates a new empty one, if it didn't exist before + * @param {string} userHandle - The user handle + * @returns {UserStatsCollection} */ -function timestampToMoment(timestamp) { - if (!timestamp) { - return 0; - } - - if (typeof timestamp === 'number') { - return timestamp; - } - - const pattern1 = - /(\d{4})-(\d{1,2})-(\d{1,2}) @(\d{1,2})h (\d{1,2})m (\d{1,2})s (\d{1,3})ms/; - const replacement1 = ( - match, - year, - month, - day, - hour, - minute, - second, - millisecond, - ) => { - return `${year}-${month.padStart(2, '0')}-${day.padStart( - 2, - '0', - )}T${hour.padStart(2, '0')}:${minute.padStart( - 2, - '0', - )}:${second.padStart(2, '0')}.${millisecond.padStart(3, '0')}Z`; - }; - const isoTimestamp1 = timestamp.replace(pattern1, replacement1); - if (!isNaN(Number(new Date(isoTimestamp1)))) { - return new Date(isoTimestamp1).getTime(); - } - - const pattern2 = /(\w+)\s(\d{1,2}),\s(\d{4})\s(\d{1,2}):(\d{1,2})(am|pm)/i; - const replacement2 = (match, month, day, year, hour, minute, meridiem) => { - const monthNames = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; - const monthNum = monthNames.indexOf(month) + 1; - const hour24 = - meridiem.toLowerCase() === 'pm' - ? (parseInt(hour, 10) % 12) + 12 - : parseInt(hour, 10) % 12; - return `${year}-${monthNum.toString().padStart(2, '0')}-${day.padStart( - 2, - '0', - )}T${hour24.toString().padStart(2, '0')}:${minute.padStart( - 2, - '0', - )}:00Z`; - }; - const isoTimestamp2 = timestamp.replace(pattern2, replacement2); - if (!isNaN(Number(new Date(isoTimestamp2)))) { - return new Date(isoTimestamp2).getTime(); - } - - return 0; -} - -/** - * Collects and aggregates stats for all characters. - * - * @param {string} chatsPath - The path to the directory containing the chat files. - * @param {string} charactersPath - The path to the directory containing the character files. - * @returns {Promise} The aggregated stats object. - */ -async function collectAndCreateStats(chatsPath, charactersPath) { - console.log('Collecting and creating stats...'); - const files = await readdir(charactersPath); - - const pngFiles = files.filter((file) => file.endsWith('.png')); - - let processingPromises = pngFiles.map((file) => - calculateStats(chatsPath, file), - ); - const statsArr = await Promise.all(processingPromises); - - let finalStats = {}; - for (let stat of statsArr) { - finalStats = { ...finalStats, ...stat }; - } - // tag with timestamp on when stats were generated - finalStats.timestamp = Date.now(); - return finalStats; -} - -/** - * Recreates the stats object for a user. - * @param {string} handle User handle - * @param {string} chatsPath Path to the directory containing the chat files. - * @param {string} charactersPath Path to the directory containing the character files. - */ -async function recreateStats(handle, chatsPath, charactersPath) { - const stats = await collectAndCreateStats(chatsPath, charactersPath); - STATS.set(handle, stats); - await saveStatsToFile(); - console.debug('Stats (re)created and saved to file.'); +function getUserStats(userHandle) { + return STATS.get(userHandle) ?? createEmptyStats(userHandle) } /** @@ -156,44 +39,28 @@ async function recreateStats(handle, chatsPath, charactersPath) { * initializes stats by collecting and creating them for each character. */ async function init() { - try { - const userHandles = await getAllUserHandles(); - for (const handle of userHandles) { - const directories = getUserDirectories(handle); - try { - const statsFilePath = path.join(directories.root, STATS_FILE); - const statsFileContent = await readFile(statsFilePath, 'utf-8'); - STATS.set(handle, JSON.parse(statsFileContent)); - } catch (err) { - // If the file doesn't exist or is invalid, initialize stats - if (err.code === 'ENOENT' || err instanceof SyntaxError) { - recreateStats(handle, directories.chats, directories.characters); - } else { - throw err; // Rethrow the error if it's something we didn't expect - } - } - } - } catch (err) { - console.error('Failed to initialize stats:', err); - } - // Save stats every 5 minutes - setInterval(saveStatsToFile, 5 * 60 * 1000); -} -/** - * Saves the current state of charStats to a file, only if the data has changed since the last save. - */ -async function saveStatsToFile() { const userHandles = await getAllUserHandles(); - for (const handle of userHandles) { - const charStats = STATS.get(handle) || {}; - if (charStats.timestamp > lastSaveTimestamp) { - try { - const directories = getUserDirectories(handle); - const statsFilePath = path.join(directories.root, STATS_FILE); - await writeFileAtomic(statsFilePath, JSON.stringify(charStats)); - lastSaveTimestamp = Date.now(); - } catch (error) { - console.log('Failed to save stats to file.', error); + for (const userHandle of userHandles) { + try { + const directories = getUserDirectories(userHandle); + const statsFilePath = path.join(directories.root, STATS_FILE); + const statsFileContent = await readFile(statsFilePath, 'utf-8'); + let userStats = parseJson(statsFileContent); + + // Migrate/recreate stats if the version has changed + if (userStats.version !== CURRENT_STATS_VERSION) { + console.info(`Found outdated stats for user ${userHandle} of version '${userStats.version}'. Recreating stats for current version '${CURRENT_STATS_VERSION}'...`); + userStats = await recreateStats(userHandle); + } + + STATS.set(userHandle, userStats); + } catch (err) { + // If the file doesn't exist or is invalid, initialize stats + if (err.code === 'ENOENT' || err instanceof SyntaxError) { + console.warn(`Error on reading stats file for user ${userHandle}. Trying to recreate it... Error was: ${err.message}`); + recreateStats(userHandle); + } else { + throw err; // Rethrow the error if it's something we didn't expect } } } @@ -212,34 +79,598 @@ async function onExit() { } /** - * Reads the contents of a file and returns the lines in the file as an array. - * - * @param {string} filepath - The path of the file to be read. - * @returns {Array} - The lines in the file. - * @throws Will throw an error if the file cannot be read. + * @typedef {object} MessageLine - The chat message object to process. */ -function readAndParseFile(filepath) { - try { - let file = fs.readFileSync(filepath, 'utf8'); - let lines = file.split('\n'); - return lines; - } catch (error) { - console.error(`Error reading file at ${filepath}: ${error}`); - return []; + +/** + * @typedef {object} UserStatsCollection - An object holding all character stats, and some additional main stats + * @property {string} version - Version number indication the version of this stats data - so it can be automatically migrated/recalculated if any of the calculation logic changes + * @property {CharacterStats} global - global user/profile stats + * @property {{[characterKey: string]: CharacterStats}} stats - All the dynamically saved stats objecs + * @property {Date} _calculated - + * @property {Date} _recalcualted - + */ + +/** + * @typedef {object} CharacterStats + * @property {string} characterKey - + * @property {string} charName - + * @property {string} userName - + * @property {number} chats - The creation date of the chat. + * @property {number} chatSize - The size of all chats + * + * @property {Date} firstCreateDate - + * @property {Date} lastCreateDate - + * @property {Date} firstlastInteractionDate - + * @property {Date} lastLastInteractionDate - + * + * @property {AggregateStat} chattingTime - + * @property {AggregateStat} messages - + * @property {AggregateStat} systemMessages - + * @property {AggregateStat} userMessages - + * @property {AggregateStat} charMessages - + * + * @property {AggregateStat} genTime - + * @property {AggregateStat} genTokenCount - + * @property {AggregateStat} swipeGenTime - + * @property {AggregateStat} swipes - + * @property {AggregateStat} userResponseTime - + * @property {AggregateStat} words - + * @property {AggregateStat} userWords - + * @property {AggregateStat} charWords - + * + * @property {AggregateStat} perMessageGenTime - + * @property {AggregateStat} perMessageGenTokenCount - + * @property {AggregateStat} perMessageSwipeGenTime - + * @property {AggregateStat} perMessageSwipeCount - + * @property {AggregateStat} perMessageUserResponseTime - + * @property {AggregateStat} perMessageWords - + * @property {AggregateStat} perMessageUserWords - + * @property {AggregateStat} perMessageCharWords - + * + * @property {{[model: string]: { count: number, tokens: number}}} genModels - model usages + * @property {ChatStats[]} chatsStats - + * @property {Date} _calculated - + */ + +/** + * @typedef {object} ChatStats + * @property {string} characterKey - + * @property {string} chatName - The unique identifier for the chat. + * @property {number} chatId - hash + * @property {string} charName - Current character name + * @property {string} userName - Current user name + * @property {number} chatSize - + * @property {Date} createDate - The creation date of the chat. (time in ISO 8601 format) + * @property {Date} lastInteractionDate - (time in ISO 8601 format) + * + * @property {number} chattingTime - + * @property {number} messages - + * @property {number} systemMessages - + * @property {number} userMessages - + * @property {number} charMessages - + * + * @property {AggregateStat} genTime - + * @property {AggregateStat} genTokenCount - + * @property {AggregateStat} swipeGenTime - + * @property {AggregateStat} swipes - + * @property {AggregateStat} userResponseTime - + * @property {AggregateStat} words - + * @property {AggregateStat} userWords - + * @property {AggregateStat} charWords - + * + * @property {{[model: string]: { count: number, tokens: number}}} genModels - model usages + * @property {MessageStats[]} messagesStats - An array of MessageStats objects for individual message analysis. + * @property {Date} _calculated - + */ + +/** + * @typedef {object} MessageStats + * @property {boolean} isUser - + * @property {boolean} isChar - + * @property {string} hash - + * @property {Date} sendDate - The time when the message was sent. + * @property {number?} genTime - The total time taken to generate this message and all swipes. + * @property {number?} genTokenCount - + * @property {number?} swipeGenTime - The total generation time for all swipes excluding the first generation. + * @property {number?} swipes - The count of additional swipes minus the first generated message. + * @property {number} words - The number of words in the message. + * @property {Date[]} genEndDates - + * @property {{[model: string]: { count: number, tokens: number}}} genModels - model usages + * @property {Date} _calculated - + */ + +/** + * An object that aggregates stats for a specific value + * + * By adding values to it, it'll automatically recalculate min, max and average + */ +class AggregateStat { + /** @type {number} The number of stats used for this aggregation - used for recalculating avg */ + count = 0; + /** @type {number} Total / Sum */ + total = 0; + /** @type {number} Minimum value */ + min = Number.NaN; + /** @type {number} Maximum value */ + max = 0; + /** @type {number} Average value */ + avg = 0; + /** @type {number[]} All values listed and saved, so the aggregate stats can be updated if needed when elements get removed */ + values = []; + /** @type {number?} The number of stats used when this is aggregated over the totals of aggregated stats, meaning based on any amount of sub/inner values */ + subCount = null; + + constructor() { } + + reset() { + this.count, this.total, this.min, this.max, this.avg = 0, this.subCount = 0; + this.values.length = 0; + } + + /** + * Adds a given value to this aggregation + * If you want to add all values of an `AggregateStat`, use `addAggregated` + * @param {number?} value - The value to add + */ + add(value) { + if (value === null || isNaN(value)) return; + this.count++; + this.total += value; + this.avg = this.total / this.count; + + this.values.push(value); + this.min = Math.min(isNaN(this.min) ? Number.MAX_SAFE_INTEGER : this.min, value); + this.max = Math.max(this.max, value); + } + + /** + * Adds the total of the aggregated value as a single value, and also marks the count as sub values for analysis purposes + * @param {AggregateStat} aggregatedValue - The aggregate stat + */ + addAggregatedAsOne(aggregatedValue) { + this.add(aggregatedValue.total); + this.subCount = (this.subCount ?? 0) + aggregatedValue.count; + } + + /** + * Adds all values of a given aggregation as single values + * @param {AggregateStat} aggregatedValue - The aggregate stat + */ + addAggregated(aggregatedValue) { + aggregatedValue.values.forEach(x => this.add(x)); + } + + /** + * Removes a given value from this aggregation + * If you want to remove all values of an `AggregateStat`, use `removeAggregated` + * @param {number?} value - The value to remove + */ + remove(value) { + if (value === null || isNaN(value)) return; + + this.count--; + this.total -= value; + this.avg = this.count === 0 ? 0 : this.total / this.count; + + const index = this.values.indexOf(value); + if (index === -1) { + console.warn(`Tried to remove aggregation value ${value} that does not exist. This should not happen...`); + return; + } + this.values.splice(index, 1); + + if (value === this.min) { + this.min = this.values.length > 0 ? Math.min(...this.values) : Number.NaN; + } + if (value === this.max) { + this.max = this.values.length > 0 ? Math.max(...this.values) : 0; + } + } + + /** + * Removes all values of a given aggregation as their respective values + * @param {AggregateStat} aggregatedValue - The aggregate stat + */ + removeAggregated(aggregatedValue) { + aggregatedValue.values.forEach(x => this.add(x)); + } + + /** + * Removes the total of the aggregated value as a single value, and also marks the count as sub values for analysis purposes + * @param {AggregateStat} aggregatedValue - The aggregate stat + */ + removeAggregatedAsOne(aggregatedValue) { + this.remove(aggregatedValue.total); + this.subCount = this.subCount ? this.subCount - aggregatedValue.count : null; } } /** - * Calculates the time difference between two dates. * - * @param {string} gen_started - The start time in ISO 8601 format. - * @param {string} gen_finished - The finish time in ISO 8601 format. - * @returns {number} - The difference in time in milliseconds. + * @param {string} userHandle - User handle + * @returns {UserStatsCollection} The aggregated stats object */ -function calculateGenTime(gen_started, gen_finished) { - let startDate = new Date(gen_started); - let endDate = new Date(gen_finished); - return Number(endDate) - Number(startDate); +function createEmptyStats(userHandle) { + const EMPTY_USER_STATS = { _calculated: MIN_DATE, _recalcualted: MIN_DATE, version: CURRENT_STATS_VERSION, global: newCharacterStats(userHandle, 'Global'), stats: {} }; + + // Resetting global user stats + const userStats = { ...EMPTY_USER_STATS }; + STATS.set(userHandle, userStats); + return userStats; +} + +/** + * + * @param {string} userHandle - User handle + * @returns {Promise} The aggregated stats object + */ +async function recreateStats(userHandle) { + console.log('Collecting and creating stats...'); + + const userStats = createEmptyStats(userHandle); + + // Load all char files to process their chat folders + const directories = getUserDirectories(userHandle); + const files = await readdir(directories.characters); + const charFiles = files.filter((file) => file.endsWith('.png')); + let processingPromises = charFiles.map((charFileName, _) => + recreateCharacterStats(userHandle, charFileName) + ); + await Promise.all(processingPromises); + + // Remember the date at which those stats were recalculated from the ground up + userStats._recalcualted = now(); + + await saveStatsToFile(); + console.info(`Stats for user ${userHandle} (re)created and saved to file.`); + + return userStats; +} + +/** + * Recreates stats for a specific character. + * Should be used very carefully, as it still has to recalculate most of the global stats. + * + * @param {string} userHandle - User handle + * @param {string} characterKey - + * @return {CharacterStats?} + */ +function recreateCharacterStats(userHandle, characterKey) { + const userStats = getUserStats(userHandle); + + // If we are replacing on a existing global stats, we need to "remove" all old stats + if (userStats.stats[characterKey]) { + for (const chatStats of userStats.stats[characterKey].chatsStats) { + removeChatFromCharStats(userStats.global, chatStats); + } + delete userStats.stats[characterKey]; + } + + // Then load chats dir for this character to process + const charChatsDir = getCharChatsDir(userHandle, characterKey); + if (!fs.existsSync(charChatsDir)) { + return null; + } + + const chatFiles = fs.readdirSync(charChatsDir); + chatFiles.forEach(chatFile => { + const chatName = chatFile.replace(/\.jsonl$/i, ''); + triggerChatUpdate(userHandle, characterKey, chatName); + }); + + console.info(`(Re)created ${characterKey}'s character stats for user ${userHandle}.`); + return userStats[characterKey]; +}; + +/** + * + * @param {string} userHandle - The user handle + * @param {string} characterKey - The character key + * @returns {string} The chats directory for this specific char + */ +function getCharChatsDir(userHandle, characterKey) { + const charName = characterKey.replace('.png', ''); + const directories = getUserDirectories(userHandle); + const charChatsDir = path.join(directories.chats, charName); + return charChatsDir; +} + +/** + * + * @param {string} userHandle - The user handle + * @param {string} characterKey + * @param {string} chatName + * @returns {{chatName: string, filePath: string, lines: object[]}} + */ +function loadChatFile(userHandle, characterKey, chatName) { + const charChatsDir = getCharChatsDir(userHandle, characterKey); + + const filePath = path.join(charChatsDir, `${sanitize(chatName)}.jsonl`); + const lines = readAndParseJsonlFile(filePath); + return { chatName, filePath, lines }; +} + + +/** + * + * @param {string} userHandle - The user handle + * @param {string} characterKey - The character key + * @param {string} chatName - The name of the chat + * @returns {ChatStats?} + */ +function triggerChatUpdate(userHandle, characterKey, chatName) { + // Load and process chats to get its stats + const loadedChat = loadChatFile(userHandle, characterKey, chatName); + const fsStats = fs.statSync(loadedChat.filePath); + + const chatStats = processChat(characterKey, chatName, loadedChat.lines, { chatSize: fsStats.size }); + if (chatStats === null) { + return null; + } + + const userStats = getUserStats(userHandle); + + // Create empty stats if character stats don't exist yet + userStats.stats[characterKey] ??= newCharacterStats(characterKey); + + // Update both the char stats and the global user stats with this chat + updateCharStatsWithChat(userStats.stats[characterKey], chatStats); + updateCharStatsWithChat(userStats.global, chatStats); + + // For global chats, we always overwrite the char name with a default one + userStats.global.charName = 'Character'; + + userStats._calculated = now(); + return chatStats; +} + +/** + * Recalculates character stats based on the current chat. + * Works with both updating/replacing an existing chat and also adding a new one. + * + * @param {CharacterStats} stats - The stats of the character + * @param {ChatStats} chatStats - The chat stats to add/update + * @returns {boolean} + */ +function updateCharStatsWithChat(stats, chatStats) { + // Check if we need to remove this chat's previous data first + removeChatFromCharStats(stats, chatStats); + + stats.chatsStats.push(chatStats); + + stats.chats++; + stats.chatSize += chatStats.chatSize; + stats.firstCreateDate = minDate(chatStats.createDate, stats.firstCreateDate) ?? stats.firstCreateDate; + stats.lastCreateDate = maxDate(chatStats.createDate, stats.lastCreateDate) ?? stats.lastCreateDate; + stats.firstlastInteractionDate = minDate(chatStats.lastInteractionDate, stats.firstlastInteractionDate) ?? stats.firstlastInteractionDate; + stats.lastLastInteractionDate = maxDate(chatStats.lastInteractionDate, stats.lastLastInteractionDate) ?? stats.lastLastInteractionDate; + + stats.chattingTime.add(chatStats.chattingTime); + stats.messages.add(chatStats.messages); + stats.systemMessages.add(chatStats.systemMessages); + stats.userMessages.add(chatStats.userMessages); + stats.charMessages.add(chatStats.charMessages); + + stats.genTime.addAggregatedAsOne(chatStats.genTime); + stats.genTokenCount.addAggregatedAsOne(chatStats.genTokenCount); + stats.swipeGenTime.addAggregatedAsOne(chatStats.swipeGenTime); + stats.swipes.addAggregatedAsOne(chatStats.swipes); + stats.userResponseTime.addAggregatedAsOne(chatStats.userResponseTime); + stats.words.addAggregatedAsOne(chatStats.words); + stats.userWords.addAggregatedAsOne(chatStats.userWords); + stats.charWords.addAggregatedAsOne(chatStats.charWords); + + stats.perMessageGenTime.addAggregated(chatStats.genTime); + stats.perMessageGenTokenCount.addAggregated(chatStats.genTokenCount); + stats.perMessageSwipeGenTime.addAggregated(chatStats.swipeGenTime); + stats.perMessageSwipeCount.addAggregated(chatStats.swipes); + stats.perMessageUserResponseTime.addAggregated(chatStats.userResponseTime); + stats.perMessageWords.addAggregated(chatStats.words); + stats.perMessageUserWords.addAggregated(chatStats.userWords); + stats.perMessageCharWords.addAggregated(chatStats.charWords); + + 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}'`); + return true; +} + +/** + * Removes the given chat stats from the character stats + * Both removing the saved stats object and also "calculating it out" of all existing values + * @param {CharacterStats} stats - The stats of the character + * @param {ChatStats} chatStats - The chat stats to remove + * @returns {boolean} Whether existed and was removed + */ +function removeChatFromCharStats(stats, chatStats) { + const index = stats.chatsStats.findIndex(x => x.chatName == chatStats.chatName); + if (index === -1) { + return false; + } + this.values.splice(index, 1); + + stats.chats--; + stats.chatSize -= chatStats.chatSize; + stats.firstCreateDate = minDate(chatStats.createDate, stats.firstCreateDate) ?? stats.firstCreateDate; + stats.lastCreateDate = maxDate(chatStats.createDate, stats.lastCreateDate) ?? stats.lastCreateDate; + stats.firstlastInteractionDate = minDate(chatStats.lastInteractionDate, stats.firstlastInteractionDate) ?? stats.firstlastInteractionDate; + stats.lastLastInteractionDate = maxDate(chatStats.lastInteractionDate, stats.lastLastInteractionDate) ?? stats.lastLastInteractionDate; + + stats.chattingTime.remove(chatStats.chattingTime); + stats.messages.remove(chatStats.messages); + stats.systemMessages.remove(chatStats.systemMessages); + stats.userMessages.remove(chatStats.userMessages); + stats.charMessages.remove(chatStats.charMessages); + + stats.genTime.removeAggregatedAsOne(chatStats.genTime); + stats.genTokenCount.removeAggregatedAsOne(chatStats.genTokenCount); + stats.swipeGenTime.removeAggregatedAsOne(chatStats.swipeGenTime); + stats.swipes.removeAggregatedAsOne(chatStats.swipes); + stats.userResponseTime.removeAggregatedAsOne(chatStats.userResponseTime); + stats.words.removeAggregatedAsOne(chatStats.words); + stats.userWords.removeAggregatedAsOne(chatStats.userWords); + stats.charWords.removeAggregatedAsOne(chatStats.charWords); + + stats.perMessageGenTime.removeAggregated(chatStats.genTime); + stats.perMessageGenTokenCount.removeAggregated(chatStats.genTokenCount); + stats.perMessageSwipeGenTime.removeAggregated(chatStats.swipeGenTime); + stats.perMessageSwipeCount.removeAggregated(chatStats.swipes); + stats.perMessageUserResponseTime.removeAggregated(chatStats.userResponseTime); + stats.perMessageWords.removeAggregated(chatStats.words); + stats.perMessageUserWords.removeAggregated(chatStats.userWords); + stats.perMessageCharWords.removeAggregated(chatStats.charWords); + + Object.entries(chatStats.genModels).forEach(([model, data]) => removeModelUsage(stats.genModels, model, data.tokens, data.count)); + + console.debug(`Successfully removed old chat stats for chat ${chatStats.chatName}`); + return true; +} + +/** + * + * @param {string} characterKey + * @param {string} chatName + * @param {object[]} lines + * @param {{chatSize?: number}} [param0={}] - optional parameter that can be set when processing the chat + * @return {ChatStats?} + */ +function processChat(characterKey, chatName, lines, { chatSize = 0 } = {}) { + if (!lines.length) { + console.warn('Processing chat file failed.'); + return null; + } + + /** @type {ChatStats} build the stats object first, then fill */ + const stats = newChatStats(characterKey, chatName); + + // Fill stats that we already can + stats.chatSize = chatSize; + + /** @type {MessageStats?} Always remember the message before, for calculations */ + let lastMessage = null; + + // Process each message + for (const message of lines) { + // Check if this is the first message, the "data storage" + if (message.chat_metadata && message.create_date) { + stats.createDate = humanizedToDate(message.create_date) ?? stats.createDate; + stats.lastInteractionDate = stats.createDate; + stats.chatId = message.chat_metadata['chat_id_hash']; + continue; + } + + const messageStats = processMessage(message); + stats.messagesStats.push(messageStats); + + // Update names to the latest message + stats.charName = messageStats.isChar ? message.name : stats.charName; + stats.userName = messageStats.isUser ? message.name : stats.userName; + + stats.lastInteractionDate = maxDate(stats.lastInteractionDate, messageStats.sendDate, ...messageStats.genEndDates) ?? stats.lastInteractionDate; + + // Aggregate chat stats for each message + // stats.chattingTime - is calculated at the end of message progressing + stats.messages += 1; + stats.systemMessages += message.is_system ? 1 : 0; + stats.userMessages += messageStats.isUser ? 1 : 0; + stats.charMessages += messageStats.isChar ? 1 : 0; + + stats.genTime.add(messageStats.genTime); + stats.genTokenCount.add(messageStats.genTokenCount) + stats.swipeGenTime.add(messageStats.swipeGenTime); + stats.swipes.add(messageStats.swipes); + + // If this is a user message, we calculate the response time from the last interaction of the message before + if (messageStats.isUser && lastMessage !== null) { + const lastInteractionBefore = lastMessage.genEndDates.sort().findLast(x => x < messageStats.sendDate) ?? lastMessage.sendDate; + const responseTime = calculateDuration(lastInteractionBefore, messageStats.sendDate); + stats.userResponseTime.add(responseTime); + } + + stats.words.add(messageStats.words); + stats.userWords.add(messageStats.isUser ? messageStats.words : null); + stats.charWords.add(messageStats.isChar ? messageStats.words : null); + + Object.entries(messageStats.genModels).forEach(([model, data]) => addModelUsage(stats.genModels, model, data.tokens, data.count)); + + // Remember this as the last message, for time calculations + lastMessage = messageStats; + } + + // Set up the final values for chat + stats.chattingTime = calculateDuration(stats.createDate, stats.lastInteractionDate); + + stats._calculated = now(); + return stats; +} + +/** + * Process a chat message and calculate relevant stats + * @param {MessageLine} message - The parsed json message line + * @returns {MessageStats} + */ +function processMessage(message, name = null) { + /** @type {MessageStats} build the stats object first, then fill */ + const stats = newMessageStats(); + + stats.isUser = message.is_user; + stats.isChar = !message.is_user && !message.is_system && (!name || message.name == name); + stats.hash = crypto.createHash('sha256').update(message.mes).digest('hex'); + + // Count all additional swipes (this array stores the original message too) + stats.swipes = message.swipe_info?.length ? message.swipe_info.length - 1 : null; + + // Use utility functions to process each message + stats.words = countWordsInString(message.mes); + stats.sendDate = new Date(timestampToMoment(message.send_date) ?? MIN_TIMESTAMP); + + // Only calculate generation time and token count for model messages + if (!message.is_user) { + if (message.gen_started && message.gen_finished) { + stats.genTokenCount = message.extra?.token_count || 0; + stats.genTime = calculateDuration(message.gen_started, message.gen_finished); + stats.genEndDates.push((new Date(message.gen_finished))); + addModelUsage(stats.genModels, message.extra?.model, message.extra?.token_count); + } + + // Sum up swipes. As swiping time counts everything that was not the last, final chosen message + // We also remember the highest timestamp for this message as the "last action" + message.swipe_info?.filter(x => x.gen_started !== message.gen_started && x.gen_started && x.gen_finished) + .forEach(swipeInfo => { + stats.genTokenCount = (stats.genTokenCount ?? 0) + message.extra?.token_count || 0; + const swipeGenTime = calculateDuration(swipeInfo.gen_started, swipeInfo.gen_finished); + stats.genTime = (stats.genTime ?? 0) + swipeGenTime; + stats.swipeGenTime = (stats.swipeGenTime ?? 0) + swipeGenTime; + stats.genEndDates.push((new Date(swipeInfo.gen_finished))); + addModelUsage(stats.genModels, swipeInfo.extra?.model, swipeInfo.extra?.token_count); + }); + } + + stats._calculated = now(); + return stats; +} + +/** @param {{[model: string]: { count: number, tokens: number}}} obj, @param {string} model, @param {number} tokens @param {number} count */ +function addModelUsage(obj, model, tokens, count = 1) { + if (!model) return; + obj[model] ??= { count: 0, tokens: 0 }; + obj[model].count += (count ?? 1); + obj[model].tokens += (tokens ?? 0); +} + +/** @param {{[model: string]: { count: number, tokens: number}}} obj, @param {string} model, @param {number} tokens @param {number} count */ +function removeModelUsage(obj, model, tokens, count = 1) { + if (!model || !obj[model]) return; + obj[model].count -= (count ?? 1); + obj[model].tokens -= (tokens ?? 0); + if (obj[model].count <= 0) + delete obj[model]; } /** @@ -249,220 +680,212 @@ function calculateGenTime(gen_started, gen_finished) { * @returns {number} - The number of words in the string. */ function countWordsInString(str) { - const match = str.match(/\b\w+\b/g); - return match ? match.length : 0; + const words = Array.from(new Intl.Segmenter(STATS_LANGUAGE ?? 'en', { granularity: 'word' }).segment(str)) + .filter(it => it.isWordLike); + return words.length; } /** - * calculateStats - Calculate statistics for a given character chat directory. - * - * @param {string} chatsPath The directory containing the chat files. - * @param {string} item The name of the character. - * @return {object} An object containing the calculated statistics. + * Creates a new, empty character stats object + * @param {string} characterKey - The character key + * @param {string} charName - The characters' name + * @returns {CharacterStats} */ -const calculateStats = (chatsPath, item) => { - const chatDir = path.join(chatsPath, item.replace('.png', '')); - const stats = { - total_gen_time: 0, - user_word_count: 0, - non_user_word_count: 0, - user_msg_count: 0, - non_user_msg_count: 0, - total_swipe_count: 0, - chat_size: 0, - date_last_chat: 0, - date_first_chat: new Date('9999-12-31T23:59:59.999Z').getTime(), - }; - let uniqueGenStartTimes = new Set(); - - if (fs.existsSync(chatDir)) { - const chats = fs.readdirSync(chatDir); - if (Array.isArray(chats) && chats.length) { - for (const chat of chats) { - const result = calculateTotalGenTimeAndWordCount( - chatDir, - chat, - uniqueGenStartTimes, - ); - stats.total_gen_time += result.totalGenTime || 0; - stats.user_word_count += result.userWordCount || 0; - stats.non_user_word_count += result.nonUserWordCount || 0; - stats.user_msg_count += result.userMsgCount || 0; - stats.non_user_msg_count += result.nonUserMsgCount || 0; - stats.total_swipe_count += result.totalSwipeCount || 0; - - const chatStat = fs.statSync(path.join(chatDir, chat)); - stats.chat_size += chatStat.size; - stats.date_last_chat = Math.max( - stats.date_last_chat, - Math.floor(chatStat.mtimeMs), - ); - stats.date_first_chat = Math.min( - stats.date_first_chat, - result.firstChatTime, - ); - } - } - } - - return { [item]: stats }; -}; - -/** - * Sets the current charStats object. - * @param {string} handle - The user handle. - * @param {Object} stats - The new charStats object. - **/ -function setCharStats(handle, stats) { - stats.timestamp = Date.now(); - STATS.set(handle, stats); -} - -/** - * Calculates the total generation time and word count for a chat with a character. - * - * @param {string} chatDir - The directory path where character chat files are stored. - * @param {string} chat - The name of the chat file. - * @returns {Object} - An object containing the total generation time, user word count, and non-user word count. - * @throws Will throw an error if the file cannot be read or parsed. - */ -function calculateTotalGenTimeAndWordCount( - chatDir, - chat, - uniqueGenStartTimes, -) { - let filepath = path.join(chatDir, chat); - let lines = readAndParseFile(filepath); - - let totalGenTime = 0; - let userWordCount = 0; - let nonUserWordCount = 0; - let nonUserMsgCount = 0; - let userMsgCount = 0; - let totalSwipeCount = 0; - let firstChatTime = new Date('9999-12-31T23:59:59.999Z').getTime(); - - for (let line of lines) { - if (line.length) { - try { - let json = JSON.parse(line); - if (json.mes) { - let hash = crypto - .createHash('sha256') - .update(json.mes) - .digest('hex'); - if (uniqueGenStartTimes.has(hash)) { - continue; - } - if (hash) { - uniqueGenStartTimes.add(hash); - } - } - - if (json.gen_started && json.gen_finished) { - let genTime = calculateGenTime( - json.gen_started, - json.gen_finished, - ); - totalGenTime += genTime; - - if (json.swipes && !json.swipe_info) { - // If there are swipes but no swipe_info, estimate the genTime - totalGenTime += genTime * json.swipes.length; - } - } - - if (json.mes) { - let wordCount = countWordsInString(json.mes); - json.is_user - ? (userWordCount += wordCount) - : (nonUserWordCount += wordCount); - json.is_user ? userMsgCount++ : nonUserMsgCount++; - } - - if (json.swipes && json.swipes.length > 1) { - totalSwipeCount += json.swipes.length - 1; // Subtract 1 to not count the first swipe - for (let i = 1; i < json.swipes.length; i++) { - // Start from the second swipe - let swipeText = json.swipes[i]; - - let wordCount = countWordsInString(swipeText); - json.is_user - ? (userWordCount += wordCount) - : (nonUserWordCount += wordCount); - json.is_user ? userMsgCount++ : nonUserMsgCount++; - } - } - - if (json.swipe_info && json.swipe_info.length > 1) { - for (let i = 1; i < json.swipe_info.length; i++) { - // Start from the second swipe - let swipe = json.swipe_info[i]; - if (swipe.gen_started && swipe.gen_finished) { - totalGenTime += calculateGenTime( - swipe.gen_started, - swipe.gen_finished, - ); - } - } - } - - // If this is the first user message, set the first chat time - if (json.is_user) { - //get min between firstChatTime and timestampToMoment(json.send_date) - firstChatTime = Math.min(timestampToMoment(json.send_date), firstChatTime); - } - } catch (error) { - console.error(`Error parsing line ${line}: ${error}`); - } - } - } +function newCharacterStats(characterKey = '', charName = '') { return { - totalGenTime, - userWordCount, - nonUserWordCount, - userMsgCount, - nonUserMsgCount, - totalSwipeCount, - firstChatTime, + characterKey: characterKey, + charName: charName, + userName: '', + chats: 0, + chatSize: 0, + + firstCreateDate: MAX_DATE, + lastCreateDate: MIN_DATE, + firstlastInteractionDate: MAX_DATE, + lastLastInteractionDate: MIN_DATE, + + chattingTime: new AggregateStat(), + messages: new AggregateStat(), + systemMessages: new AggregateStat(), + userMessages: new AggregateStat(), + charMessages: new AggregateStat(), + + genTime: new AggregateStat(), + genTokenCount: new AggregateStat(), + swipeGenTime: new AggregateStat(), + swipes: new AggregateStat(), + userResponseTime: new AggregateStat(), + words: new AggregateStat(), + userWords: new AggregateStat(), + charWords: new AggregateStat(), + + perMessageGenTime: new AggregateStat(), + perMessageGenTokenCount: new AggregateStat(), + perMessageSwipeGenTime: new AggregateStat(), + perMessageSwipeCount: new AggregateStat(), + perMessageUserResponseTime: new AggregateStat(), + perMessageWords: new AggregateStat(), + perMessageUserWords: new AggregateStat(), + perMessageCharWords: new AggregateStat(), + + genModels: {}, + chatsStats: [], + _calculated: now(), }; } +/** + * Creates a new, empty chat stats object + * @param {string} characterKey - The character key + * @param {string} chatName - The chats' name + * @returns {ChatStats} + */ +function newChatStats(characterKey, chatName) { + return { + characterKey: characterKey, + chatName: chatName, + chatId: 0, + charName: '', + userName: '', + chatSize: 0, + createDate: MAX_DATE, + lastInteractionDate: MIN_DATE, + + chattingTime: 0, + messages: 0, + systemMessages: 0, + userMessages: 0, + charMessages: 0, + + genTime: new AggregateStat(), + genTokenCount: new AggregateStat(), + swipeGenTime: new AggregateStat(), + swipes: new AggregateStat(), + userResponseTime: new AggregateStat(), + words: new AggregateStat(), + userWords: new AggregateStat(), + charWords: new AggregateStat(), + + genModels: {}, + messagesStats: [], + _calculated: now(), + }; +} + +/** + * Creates a new, empty message stats object + * @returns {MessageStats} + */ +function newMessageStats() { + return { + isUser: false, + isChar: false, + hash: '', + sendDate: MIN_DATE, + genTime: null, + genTokenCount: null, + swipeGenTime: null, + swipes: null, + words: 0, + genEndDates: [], + genModels: {}, + _calculated: now(), + }; +} + +/** + * Saves the current state of charStats to a file, only if the data has changed since the last save. + * @param {string?} [userHandle] - Optionally only save file for one user handle + */ +async function saveStatsToFile(userHandle = null) { + const userHandles = userHandle ? [userHandle] : await getAllUserHandles(); + for (const userHandle of userHandles) { + const userStats = getUserStats(userHandle); + if (userStats._calculated > lastSaveDate) { + try { + const directories = getUserDirectories(userHandle); + const statsFilePath = path.join(directories.root, STATS_FILE); + await writeFileAtomic(statsFilePath, JSON.stringify(userStats)); + lastSaveDate = now(); + } catch (error) { + console.log('Failed to save stats to file.', error); + } + } + } +} + const router = express.Router(); /** - * Handle a POST request to get the stats object + * @typedef {object} StatsRequestBody + * @property {boolean?} [global] - Whether the global stats are requested. If true, all other arguments are ignored + * @property {string?} [characterKey] - The character key for the character to request stats from + * @property {string?} [chatName] - The name of the chat file + */ + +/** + * Handle a POST request to get the stats fromm + * + * This function returns the stats object that was calculated and updated based on the chats. + * Depending on the given request filter, it will either return global stats, character stats or chat stats. + * + * @param {Object} request - The HTTP request object. + * @param {Object} response - The HTTP response object. + * @returns {void} */ router.post('/get', jsonParser, function (request, response) { - const stats = STATS.get(request.user.profile.handle) || {}; - response.send(stats); + const send = (data) => response.send(JSON.stringify(data ?? null)); + /** @type {StatsRequestBody} */ + const body = request.body; + + const userHandle = request.user.profile.handle; + const userStats = getUserStats(userHandle); + + if (!!body.global) { + return send(userStats.global); + } + + if (body.characterKey && body.chatName) { + return send(userStats.stats[body.characterKey]?.chatsStats.find(x => x.chatName == body.chatName)); + } + if (body.characterKey) { + return send(userStats.stats[body.characterKey]); + } + + // If no specific filter was requested, we send all stats back + return send(userStats); }); /** * Triggers the recreation of statistics from chat files. + * - If successful: returns a 200 OK status. + * - On failure: returns a 500 Internal Server Error status. + * + * @param {Object} request - Express request object. + * @param {Object} response - Express response object. */ router.post('/recreate', jsonParser, async function (request, response) { + const send = (data) => response.send(JSON.stringify(data ?? {})); + /** @type {StatsRequestBody} */ + const body = request.body; + + const userHandle = request.user.profile.handle; + try { - await recreateStats(request.user.profile.handle, request.user.directories.chats, request.user.directories.characters); - return response.sendStatus(200); + if (body.characterKey) { + recreateCharacterStats(userHandle, body.characterKey); + return send(getUserStats(userHandle).stats[body.characterKey]); + } + await recreateStats(userHandle); + return send(getUserStats(userHandle)); } catch (error) { console.error(error); return response.sendStatus(500); } }); -/** - * Handle a POST request to update the stats object -*/ -router.post('/update', jsonParser, function (request, response) { - if (!request.body) return response.sendStatus(400); - setCharStats(request.user.profile.handle, request.body); - return response.sendStatus(200); -}); - module.exports = { router, - recreateStats, init, onExit, }; diff --git a/src/util.js b/src/util.js index 31d134bb..c28050f2 100644 --- a/src/util.js +++ b/src/util.js @@ -232,6 +232,69 @@ async function getImageBuffers(zipFilePath) { }); } +/** + * Reads the contents of a .jsonl file and returns the lines parsed as json as an array + * + * @param {string} filepath - The path of the file to be read + * @returns {object[]} - The lines in the file + * @throws Will throw an error if the file cannot be read + */ +function readAndParseJsonlFile(filepath) { + try { + // Copied from /chat/get endpoint + const data = fs.readFileSync(filepath, 'utf8'); + const lines = data.split('\n'); + + // Iterate through the array of strings and parse each line as JSON + const jsonData = lines.map((l) => { try { return JSON.parse(l); } catch (_) { return; } }).filter(x => x); + return jsonData; + } catch (error) { + console.error(`Error reading file at ${filepath}: ${error}`); + return []; + } +} + +/** + * Parse JSON data with optional reviver function. + * Converts date strings back to Date objects if found. + * + * @param {string} json - The JSON data to parse + * @param {object} [options] - Optional parameters + * @param {Reviver?} [options.reviver=null] - Custom reviver function to customize parsing + * @param {boolean} [options.disableDefaultReviver=false] - Flag to disable the default date parsing reviver + * @returns {object} - The parsed JSON object + */ +function parseJson(json, { reviver = null, disableDefaultReviver = false } = {}) { + /** + * @typedef {((this: any, key: string, value: any) => any)} Reviver + * @param {object} this - + * @param {string} key - The key of the current property being processed + * @param {*} value - The value of the current property being processed + * @returns {*} - The processed value + */ + + /** @type {Reviver} The default reviver, that converts Date strings to Date objects */ + function defaultReviver(key, value) { + // Check if the value is a string and can be converted to a Date + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/.test(value)) { + return new Date(value); + } + // Return the original value if it's not a date string + return value; + } + + // The actual reviver based on the ones given + /** @type {Reviver} */ + function actualReviver(key, value) { + if (reviver) value = reviver(key, value); + if (!disableDefaultReviver) value = defaultReviver(key, value); + return value; + }; + + // Parse the JSON data using the specified or custom reviver function + return JSON.parse(json, actualReviver); +} + /** * Gets all chunks of data from the given readable stream. * @param {any} readableStream Readable stream to read from @@ -547,6 +610,162 @@ function trimV1(str) { return String(str ?? '').replace(/\/$/, '').replace(/\/v1$/, ''); } +/** + * Convert a timestamp to an integer timestamp. + * (sorry, it's momentless for now, didn't want to add a package just for this) + * This function can handle several different timestamp formats: + * 1. Unix timestamps (the number of seconds since the Unix Epoch) + * 2. ST "humanized" timestamps, formatted like "YYYY-MM-DD @HHh MMm SSs ms" + * 3. Date strings in the format "Month DD, YYYY H:MMam/pm" + * + * The function returns the timestamp as the number of milliseconds since + * the Unix Epoch, which can be converted to a JavaScript Date object with new Date(). + * + * @param {string|number} timestamp - The timestamp to convert. + * @returns {number|null} The timestamp in milliseconds since the Unix Epoch, or null if the input cannot be parsed. + * + * @example + * // Unix timestamp + * timestampToMoment(1609459200); + * // ST humanized timestamp + * timestampToMoment("2021-01-01 @00h 00m 00s 000ms"); + * // Date string + * timestampToMoment("January 1, 2021 12:00am"); + */ +function timestampToMoment(timestamp) { + if (!timestamp) { + return null; + } + + if (typeof timestamp === 'number') { + return timestamp; + } + + const pattern1 = + /(\d{4})-(\d{1,2})-(\d{1,2}) ?@(\d{1,2})h ?(\d{1,2})m ?(\d{1,2})s ?(?:(\d{1,3})ms)?/; + const replacement1 = ( + match, + year, + month, + day, + hour, + minute, + second, + millisecond, + ) => { + return `${year}-${month.padStart(2, '0')}-${day.padStart( + 2, + '0', + )}T${hour.padStart(2, '0')}:${minute.padStart( + 2, + '0', + )}:${second.padStart(2, '0')}.${(millisecond ?? '0').padStart(3, '0')}Z`; + }; + const isoTimestamp1 = timestamp.replace(pattern1, replacement1); + if (!isNaN(Date.parse(isoTimestamp1))) { + return new Date(isoTimestamp1).getTime(); + } + + const pattern2 = /(\w+)\s(\d{1,2}),\s(\d{4})\s(\d{1,2}):(\d{1,2})(am|pm)/i; + const replacement2 = (match, month, day, year, hour, minute, meridiem) => { + const monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + const monthNum = monthNames.indexOf(month) + 1; + const hour24 = + meridiem.toLowerCase() === 'pm' + ? (parseInt(hour, 10) % 12) + 12 + : parseInt(hour, 10) % 12; + return `${year}-${monthNum.toString().padStart(2, '0')}-${day.padStart( + 2, + '0', + )}T${hour24.toString().padStart(2, '0')}:${minute.padStart( + 2, + '0', + )}:00Z`; + }; + const isoTimestamp2 = timestamp.replace(pattern2, replacement2); + if (!isNaN(Date.parse(isoTimestamp2))) { + return new Date(isoTimestamp2).getTime(); + } + + return null; +} + +/** + * Calculates the time difference between two dates. + * + * @param {Date|string} startTime - The start time in ISO 8601 format, or as a Date object + * @param {Date|string} endTime - The finish time in ISO 8601 format, or as a Date object + * @returns {number} - The difference in time in milliseconds, or 0 if invalid dates are provided or if start date is after end date. + */ +function calculateDuration(startTime, endTime) { + const startDate = startTime instanceof Date ? startTime : new Date(startTime); + const endDate = endTime instanceof Date ? endTime : new Date(endTime); + + return startDate > endDate ? 0 : Math.max(endDate.getTime() - startDate.getTime(), 0); +} + +/** @param {string} timestamp @returns {Date|null} */ +function humanizedToDate(timestamp) { + const moment = timestampToMoment(timestamp); + return moment ? new Date(moment) : null; +} + +/** + * Returns the maximum from all supplied dates + * @param {...Date} dates + * @returns {Date?} + */ +function maxDate(...dates) { + dates = dates.flat(Infinity); + if (dates.length == 0) return null; + /** @type {Date?} */ + let max = null; + for (const date of dates) { + if (max === null || date > max) { + max = date; + } + } + return max; +} + +/** + * Returns the minimum from all supplied dates + * @param {...Date} dates + * @returns {Date?} + */ +function minDate(...dates) { + dates = dates.flat(Infinity); + if (dates.length == 0) return null; + /** @type {Date?} */ + let min = null; + for (const date of dates) { + if (min === null || date < min) { + min = date; + } + } + return min; +} + +/** + * Returns the current date and time + * (Why the fuck is `Date.now()` returning a timestamp and no date... I don't get it) + * @returns {Date} + */ +function now() { return new Date(Date.now()); } + /** * Simple TTL memory cache. */ @@ -610,6 +829,8 @@ module.exports = { getBasicAuthHeader, extractFileFromZipBuffer, getImageBuffers, + readAndParseJsonlFile, + parseJson, readAllChunks, delay, deepMerge, @@ -627,6 +848,12 @@ module.exports = { mergeObjectWithYaml, excludeKeysByYaml, trimV1, + timestampToMoment, + calculateDuration, + humanizedToDate, + maxDate, + minDate, + now, Cache, makeHttp2Request, };