From 126e4fa6983caee3798e7ba843a5e26a14fe52e0 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Wed, 8 Jan 2025 01:20:25 +0100 Subject: [PATCH 01/63] Display additional images in expression list - Update Expressions List to display additional images per expression - Make additional images appear visually distinct - Fix small issues with custom labels not always being shown - Add tooltip on all expression images - Modify /api/sprites/get endpoint to correctly parse the label from filenames that might be additional files --- .../scripts/extensions/expressions/index.js | 90 +++++++++++++++---- .../extensions/expressions/list-item.html | 14 +-- .../scripts/extensions/expressions/style.css | 21 ++++- src/endpoints/sprites.js | 8 +- 4 files changed, 108 insertions(+), 25 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index e5ba1ed21..1180d2fd1 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -17,6 +17,22 @@ import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandRetur import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; export { MODULE_NAME }; +/** + * @typedef {object} Expression Expression definition with label and file path + * @property {string} label The label of the expression + * @property {string} path The path to the expression image + */ + +/** + * @typedef {object} ExpressionImage An expression image + * @property {string?} [expression=null] - The expression + * @property {boolean?} [isCustom=null] - If the expression is added by user + * @property {string} fileName - The filename with extension + * @property {string} title - The title for the image + * @property {string} imageSrc - The image source / full path + * @property {'success' | 'additional' | 'failure'} type - The type of the image + */ + const MODULE_NAME = 'expressions'; const UPDATE_INTERVAL = 2000; const STREAMING_UPDATE_INTERVAL = 10000; @@ -62,6 +78,9 @@ const EXPRESSION_API = { webllm: 3, }; +/** @type {ExpressionImage} */ +const NO_IMAGE_PLACEHOLDER = { title: 'No Image', type: 'failure', fileName: 'No-Image-Placeholder.svg', imageSrc: '/img/No-Image-Placeholder.svg' }; + let expressionsList = null; let lastCharacter = undefined; let lastMessage = null; @@ -1309,8 +1328,35 @@ async function validateImages(character, forceRedrawCached) { spriteCache[character] = validExpressions; } +/** + * Takes a given sprite as returned from the server, and enriches it with additional data for display/sorting + * @param {Expression} sprite + * @returns {ExpressionImage} + */ +function getExpressionImageData(sprite) { + const fileName = sprite.path.split('/').pop().split('?')[0]; + const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, ''); + return { + expression: sprite.label, + fileName: fileName, + title: fileNameWithoutExtension, + imageSrc: sprite.path, + type: fileNameWithoutExtension == sprite.label ? 'success' : 'additional', + isCustom: extension_settings.expressions.custom?.includes(sprite.label), + }; +} + +/** + * Populate the character expression list with sprites for the given character. + * @param {string} character - The name of the character to populate the list for + * @param {string[]} labels - An array of expression labels that are valid + * @param {Expression[]} sprites - An array of sprites + * @returns {Promise} An array of valid expression labels + */ async function drawSpritesList(character, labels, sprites) { + /** @type {Expression[]} */ let validExpressions = []; + $('#no_chat_expressions').hide(); $('#open_chat_expressions').show(); $('#image_list').empty(); @@ -1321,33 +1367,45 @@ async function drawSpritesList(character, labels, sprites) { return []; } - for (const item of labels.sort()) { - const sprite = sprites.find(x => x.label == item); - const isCustom = extension_settings.expressions.custom.includes(item); + for (const expression of labels.sort()) { + const isCustom = extension_settings.expressions.custom?.includes(expression); + const images = sprites + .filter(s => s.label === expression) + .map(getExpressionImageData) + .sort((a, b) => a.title.localeCompare(b.title)); - if (sprite) { - validExpressions.push(sprite); - const listItem = await getListItem(item, sprite.path, 'success', isCustom); - $('#image_list').append(listItem); - } - else { - const listItem = await getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom); + if (images.length === 0) { + const listItem = await getListItem(expression, { + isCustom, + images: [{ expression, isCustom, ...NO_IMAGE_PLACEHOLDER }], + }); $('#image_list').append(listItem); + continue; } + + // TODO: Fix valid expression lists/caching and group them correctly + validExpressions.push({ label: expression, paths: images }); + + // Render main = first file, additional = rest + let listItem = await getListItem(expression, { + isCustom, + images, + }); + $('#image_list').append(listItem); } return validExpressions; } /** * Renders a list item template for the expressions list. - * @param {string} item Expression name - * @param {string} imageSrc Path to image - * @param {'success' | 'failure'} textClass 'success' or 'failure' - * @param {boolean} isCustom If expression is added by user + * @param {string} expression Expression name + * @param {object} args Arguments object + * @param {ExpressionImage[]} [args.images] Array of image objects + * @param {boolean} [args.isCustom=false] If expression is added by user * @returns {Promise} Rendered list item template */ -async function getListItem(item, imageSrc, textClass, isCustom) { - return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom }); +async function getListItem(expression, { images, isCustom = false } = {}) { + return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false }); } async function getSpritesList(name) { diff --git a/public/scripts/extensions/expressions/list-item.html b/public/scripts/extensions/expressions/list-item.html index aaeec5cec..33c0bfe3d 100644 --- a/public/scripts/extensions/expressions/list-item.html +++ b/public/scripts/extensions/expressions/list-item.html @@ -1,4 +1,5 @@ -
+{{#each images}} +
-
- {{item}} - {{#if isCustom}} +
+ {{../expression}} + {{#if ../isCustom}} (custom) {{/if}}
- +
+ {{this.title}} +
+{{/each}} diff --git a/public/scripts/extensions/expressions/style.css b/public/scripts/extensions/expressions/style.css index 48062c272..db58cb6f0 100644 --- a/public/scripts/extensions/expressions/style.css +++ b/public/scripts/extensions/expressions/style.css @@ -126,6 +126,9 @@ img.expression.default { flex-direction: column; line-height: 1; } +.expression_list_custom { + font-size: 0.66rem; +} .expression_list_buttons { position: absolute; @@ -162,11 +165,24 @@ img.expression.default { row-gap: 1rem; } -#image_list .success { +#image_list .expression_list_item[data-expression-type="success"] .expression_list_title { color: green; } -#image_list .failure { +#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title { + color: darkolivegreen; +} +#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title::before { + content: '➕'; + position: absolute; + top: -7px; + left: -9px; + font-size: 14px; + color: transparent; + text-shadow: 0 0 0 darkolivegreen; +} + +#image_list .expression_list_item[data-expression-type="failure"] .expression_list_title { color: red; } @@ -188,4 +204,3 @@ img.expression.default { align-items: baseline; flex-direction: row; } - diff --git a/src/endpoints/sprites.js b/src/endpoints/sprites.js index 6a2e5e6b9..a87c554bd 100644 --- a/src/endpoints/sprites.js +++ b/src/endpoints/sprites.js @@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) { .map((file) => { const pathToSprite = path.join(spritesPath, file); const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14); + + const fileName = path.parse(pathToSprite).name.toLowerCase(); + // Extract the label from the filename via regex, which can be suffixed with a sub-name, either connected with a dash or a dot. + // Examples: joy.png, joy-1.png, joy.expressive.png, 美しい-17.png + const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName; + return { - label: path.parse(pathToSprite).name.toLowerCase(), + label: label, path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''), }; }); From a072951102f40be153a019ccb4443305654387da Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sun, 26 Jan 2025 00:15:46 +0100 Subject: [PATCH 02/63] Update backend returned sprites list --- .../scripts/extensions/expressions/index.js | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 1180d2fd1..6ff09a9af 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -18,10 +18,10 @@ import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; export { MODULE_NAME }; /** - * @typedef {object} Expression Expression definition with label and file path - * @property {string} label The label of the expression - * @property {string} path The path to the expression image - */ +* @typedef {object} Expression Expression definition with label and file path +* @property {string} label The label of the expression +* @property {ExpressionImage[]} files One or more images to represent this expression +*/ /** * @typedef {object} ExpressionImage An expression image @@ -86,6 +86,7 @@ let lastCharacter = undefined; let lastMessage = null; let lastTalkingState = false; let lastTalkingStateMessage = null; // last message as seen by `updateTalkingState` (tracked separately, different timer) +/** @type {{[characterKey: string]: Expression[]}} */ let spriteCache = {}; let inApiCall = false; let lastServerResponseTime = 0; @@ -1307,6 +1308,11 @@ function removeExpression() { $('#no_chat_expressions').show(); } +/** + * Validate a character's sprites, and redraw the sprites list if not done before or forced to redraw. + * @param {string} character - The character to validate + * @param {boolean} forceRedrawCached - Whether to force redrawing the sprites list even if it's already been drawn before + */ async function validateImages(character, forceRedrawCached) { if (!character) { return; @@ -1330,7 +1336,7 @@ async function validateImages(character, forceRedrawCached) { /** * Takes a given sprite as returned from the server, and enriches it with additional data for display/sorting - * @param {Expression} sprite + * @param {{ path: string, label: string }} sprite * @returns {ExpressionImage} */ function getExpressionImageData(sprite) { @@ -1371,7 +1377,8 @@ async function drawSpritesList(character, labels, sprites) { const isCustom = extension_settings.expressions.custom?.includes(expression); const images = sprites .filter(s => s.label === expression) - .map(getExpressionImageData) + .map(s => s.files) + .flat() .sort((a, b) => a.title.localeCompare(b.title)); if (images.length === 0) { @@ -1384,7 +1391,7 @@ async function drawSpritesList(character, labels, sprites) { } // TODO: Fix valid expression lists/caching and group them correctly - validExpressions.push({ label: expression, paths: images }); + validExpressions.push({ label: expression, files: images }); // Render main = first file, additional = rest let listItem = await getListItem(expression, { @@ -1408,13 +1415,35 @@ async function getListItem(expression, { images, isCustom = false } = {}) { return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false }); } +/** + * Fetches and processes the list of sprites for a given character name. + * Retrieves sprite data from the server and organizes it into labeled groups. + * + * @param {string} name - The character name to fetch sprites for + * @returns {Promise} A promise that resolves to an array of grouped expression objects, each containing a label and associated image data + */ + async function getSpritesList(name) { console.debug('getting sprites list'); try { const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`); + /** @type {{ label: string, path: string }[]} */ let sprites = result.ok ? (await result.json()) : []; - return sprites; + + /** @type {Expression[]} */ + const grouped = sprites.reduce((acc, sprite) => { + const imageData = getExpressionImageData(sprite); + let existingExpression = acc.find(exp => exp.label === sprite.label); + if (existingExpression) { + existingExpression.files.push(imageData); + } else { + acc.push({ label: sprite.label, files: [imageData] }); + } + + return acc; + }, []); + return grouped; } catch (err) { console.log(err); From adede8b6befe1c6c21e8b6ae3b28c44e746580ec Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sun, 26 Jan 2025 19:12:37 +0100 Subject: [PATCH 03/63] Roll on the sprite to use for an expression --- .../scripts/extensions/expressions/index.js | 69 ++++++++++++++----- .../extensions/expressions/list-item.html | 2 +- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 6ff09a9af..f150b8d65 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -360,7 +360,7 @@ async function setImage(img, path) { return new Promise(resolve => { const prevExpressionSrc = img.attr('src'); const expressionClone = img.clone(); - const originalId = img.attr('id'); + const originalId = img.data('filename'); //only swap expressions when necessary if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) { @@ -368,7 +368,7 @@ async function setImage(img, path) { expressionClone.addClass('expression-clone'); //make invisible and remove id to prevent double ids //must be made invisible to start because they share the same Z-index - expressionClone.attr('id', '').css({ opacity: 0 }); + expressionClone.data('filename', '').css({ opacity: 0 }); //add new sprite path to clone src expressionClone.attr('src', path); //add invisible clone to html @@ -404,7 +404,7 @@ async function setImage(img, path) { //remove old expression img.remove(); //replace ID so it becomes the new 'original' expression for next change - expressionClone.attr('id', originalId); + expressionClone.data('filename', originalId); expressionClone.removeClass('expression-animating'); // Reset the expression holder min height and width @@ -1311,9 +1311,9 @@ function removeExpression() { /** * Validate a character's sprites, and redraw the sprites list if not done before or forced to redraw. * @param {string} character - The character to validate - * @param {boolean} forceRedrawCached - Whether to force redrawing the sprites list even if it's already been drawn before + * @param {boolean} [forceRedrawCached=false] - Whether to force redrawing the sprites list even if it's already been drawn before */ -async function validateImages(character, forceRedrawCached) { +async function validateImages(character, forceRedrawCached = false) { if (!character) { return; } @@ -1493,6 +1493,13 @@ async function renderFallbackExpressionPicker() { } } +/** + * Retrieves a unique list of cached expressions. + * Combines the default expressions list with custom user-defined expressions. + * + * @returns {string[]} An array of unique expression labels + */ + function getCachedExpressions() { if (!Array.isArray(expressionsList)) { return []; @@ -1558,7 +1565,14 @@ export async function getExpressionsList() { return [...result, ...extension_settings.expressions.custom].filter(onlyUnique); } -async function setExpression(character, expression, force) { +/** + * Set the expression of a character. + * @param {string} character - The name of the character + * @param {string} expression - The expression to set + * @param {boolean} [force=false] - Whether to force the expression change even if Visual Novel mode is on. + * @returns {Promise} A promise that resolves when the expression has been set. + */ +async function setExpression(character, expression, force = false) { if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) { console.debug('entered setExpressions'); await validateImages(character); @@ -1566,11 +1580,23 @@ async function setExpression(character, expression, force) { const prevExpressionSrc = img.attr('src'); const expressionClone = img.clone(); + /** @type {Expression} */ const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression)); console.debug('checking for expression images to show..'); if (sprite) { console.debug('setting expression from character images folder'); + let spriteFile = sprite.files[0]; + + // Calculate next expression + if (sprite.files.length > 1) { + let possibleFiles = sprite.files; + if (extension_settings.expressions_reroll_if_same) { + possibleFiles = possibleFiles.filter(x => x.imageSrc !== prevExpressionSrc); + } + spriteFile = possibleFiles[Math.floor(Math.random() * possibleFiles.length)]; + } + if (force && isVisualNovelMode()) { const context = getContext(); const group = context.groups.find(x => x.id === context.groupId); @@ -1583,13 +1609,13 @@ async function setExpression(character, expression, force) { } if (groupMember.name == character) { - await setImage($(`.expression-holder[data-avatar="${member}"] img`), sprite.path); + await setImage($(`.expression-holder[data-avatar="${member}"] img`), spriteFile.imageSrc); return; } } } //only swap expressions when necessary - if (prevExpressionSrc !== sprite.path + if (prevExpressionSrc !== spriteFile.imageSrc && !img.hasClass('expression-animating')) { //clone expression expressionClone.addClass('expression-clone'); @@ -1597,7 +1623,7 @@ async function setExpression(character, expression, force) { //must be made invisible to start because they share the same Z-index expressionClone.attr('id', '').css({ opacity: 0 }); //add new sprite path to clone src - expressionClone.attr('src', sprite.path); + expressionClone.attr('src', spriteFile.imageSrc); //add invisible clone to html expressionClone.appendTo($('#expression-holder')); @@ -1645,7 +1671,7 @@ async function setExpression(character, expression, force) { expressionClone.removeClass('default'); expressionClone.off('error'); expressionClone.on('error', function () { - console.debug('Expression image error', sprite.path); + console.debug('Expression image error', spriteFile.imageSrc); $(this).attr('src', ''); $(this).off('error'); if (force && extension_settings.expressions.showDefault) { @@ -1706,7 +1732,7 @@ async function setExpression(character, expression, force) { } function onClickExpressionImage() { - const expression = $(this).attr('id'); + const expression = $(this).data('expression'); setSpriteSlashCommand({}, expression); } @@ -1830,7 +1856,7 @@ async function onClickExpressionUpload(event) { // Prevents the expression from being set event.stopPropagation(); - const id = $(this).closest('.expression_list_item').attr('id'); + const expression = $(this).closest('.expression_list_item').data('expression'); const name = $('#image_list').data('name'); const handleExpressionUploadChange = async (e) => { @@ -1840,9 +1866,20 @@ async function onClickExpressionUpload(event) { return; } + // // If extension_settings.expressions.allowMultiple is false and there's already a main image, ask user: + // let hasMainImage = true; // Check from your item data + // if (!extension_settings.expressions.allowMultiple && hasMainImage) { + // let userChoice = await callPopup('

Replace existing main image?

Press Ok to replace, Cancel to abort.

', 'confirm'); + // if (!userChoice) { + // return; + // } + // // If user chooses replace, remove the old file, then proceed + // // ...existing code to remove old file... + // } + const formData = new FormData(); formData.append('name', name); - formData.append('label', id); + formData.append('label', expression); formData.append('avatar', file); await handleFileUpload('/api/sprites/upload', formData); @@ -1851,7 +1888,7 @@ async function onClickExpressionUpload(event) { e.target.form.reset(); // In Talkinghead mode, when a new talkinghead image is uploaded, refresh the live char. - if (id === 'talkinghead' && isTalkingHeadEnabled() && modules.includes('talkinghead')) { + if (expression === 'talkinghead' && isTalkingHeadEnabled() && modules.includes('talkinghead')) { await loadTalkingHead(); } }; @@ -1987,14 +2024,14 @@ async function onClickExpressionDelete(event) { return; } - const id = $(this).closest('.expression_list_item').attr('id'); + const expression = $(this).closest('.expression_list_item').data('expression'); const name = $('#image_list').data('name'); try { await fetch('/api/sprites/delete', { method: 'POST', headers: getRequestHeaders(), - body: JSON.stringify({ name, label: id }), + body: JSON.stringify({ name, label: expression }), }); } catch (error) { toastr.error('Failed to delete image. Try again later.'); diff --git a/public/scripts/extensions/expressions/list-item.html b/public/scripts/extensions/expressions/list-item.html index 33c0bfe3d..dc5132600 100644 --- a/public/scripts/extensions/expressions/list-item.html +++ b/public/scripts/extensions/expressions/list-item.html @@ -1,5 +1,5 @@ {{#each images}} -
+
+
+ + +
Open a chat to see the character expressions.
From 3282c9426c72d22c0ed82e224eac5f491f391ca2 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 Jan 2025 05:39:51 +0100 Subject: [PATCH 05/63] Upload expressions update --- .../scripts/extensions/expressions/index.js | 85 ++++++++++++++++--- .../extensions/expressions/settings.html | 1 + .../templates/upload-expression.html | 12 +++ src/endpoints/sprites.js | 5 +- 4 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 public/scripts/extensions/expressions/templates/upload-expression.html diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index a076f15a2..9e91bd888 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -15,6 +15,8 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; +import { Popup, POPUP_RESULT } from '../../popup.js'; +import { t } from '../../i18n.js'; export { MODULE_NAME }; /** @@ -1852,11 +1854,23 @@ async function handleFileUpload(url, formData) { } } +/** + * Removes the file extension from a file name + * @param {string} fileName The file name to remove the extension from + * @returns {string} The file name without the extension + */ +function withoutExtension(fileName) { + return fileName.replace(/\.[^/.]+$/, ''); +} + async function onClickExpressionUpload(event) { // Prevents the expression from being set event.stopPropagation(); - const expression = $(this).closest('.expression_list_item').data('expression'); + const expressionListItem = $(this).closest('.expression_list_item'); + + const clickedFileName = expressionListItem.attr('data-expression-type') !== 'failure' ? expressionListItem.attr('data-filename') : null; + const expression = expressionListItem.data('expression'); const name = $('#image_list').data('name'); const handleExpressionUploadChange = async (e) => { @@ -1866,21 +1880,70 @@ async function onClickExpressionUpload(event) { return; } - // // If extension_settings.expressions.allowMultiple is false and there's already a main image, ask user: - // let hasMainImage = true; // Check from your item data - // if (!extension_settings.expressions.allowMultiple && hasMainImage) { - // let userChoice = await callPopup('

Replace existing main image?

Press Ok to replace, Cancel to abort.

', 'confirm'); - // if (!userChoice) { - // return; - // } - // // If user chooses replace, remove the old file, then proceed - // // ...existing code to remove old file... - // } + const existingFiles = spriteCache[name]?.find(x => x.label === expression)?.files || []; + + let spriteName = expression; + + if (extension_settings.expressions.allowMultiple) { + const matchesExisting = existingFiles.some(x => x.fileName === file.name); + const fileNameWithoutExtension = withoutExtension(file.name); + const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`); + const validFileName = filenameValidationRegex.test(fileNameWithoutExtension); + + // If there is no expression yet and it's a valid expression, we just take it + if (!clickedFileName && validFileName) { + spriteName = fileNameWithoutExtension; + } + // If the filename matches the one that was clicked, we just take it and replace it + else if (clickedFileName === file.name) { + spriteName = fileNameWithoutExtension; + } + // If it's a valid filename and there's no existing file with the same name, we just take it + else if (!matchesExisting && validFileName) { + spriteName = fileNameWithoutExtension; + } + else { + /** @type {import('../../popup.js').CustomPopupButton[]} */ + const customButtons = []; + if (clickedFileName) { + customButtons.push({ + text: t`Replace Existing`, + result: POPUP_RESULT.NEGATIVE, + action: () => { + console.debug('Replacing existing sprite'); + spriteName = withoutExtension(clickedFileName); + }, + }); + } + + const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName }); + + spriteName = null; + const result = await Popup.show.input(t`Upload Expression Sprite`, message, + `${expression}-${existingFiles.length}`, { customButtons: customButtons }); + + if (result) { + if (!filenameValidationRegex.test(result)) { + toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`); + return; + } + spriteName = result; + } + } + } else { + spriteName = withoutExtension(clickedFileName); + } + + if (!spriteName) { + toastr.warning(t`Cancelled uploading sprite.`, t`Upload Cancelled`); + return; + } const formData = new FormData(); formData.append('name', name); formData.append('label', expression); formData.append('avatar', file); + formData.append('spriteName', spriteName); await handleFileUpload('/api/sprites/upload', formData); diff --git a/public/scripts/extensions/expressions/settings.html b/public/scripts/extensions/expressions/settings.html index 66c55b3a4..fd5f9c3a6 100644 --- a/public/scripts/extensions/expressions/settings.html +++ b/public/scripts/extensions/expressions/settings.html @@ -87,6 +87,7 @@

Hint: Create new folder in the /characters/ folder of your user data directory and name it as the name of the character. Put images with expressions there. File names should follow the pattern: [expression_label].[image_format]

+ In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a dash. Examples: joy.png, joy-1.png, joy.expressive.png, 美しい-17.png

Sprite set: 

diff --git a/public/scripts/extensions/expressions/templates/upload-expression.html b/public/scripts/extensions/expressions/templates/upload-expression.html new file mode 100644 index 000000000..a7901ae7f --- /dev/null +++ b/public/scripts/extensions/expressions/templates/upload-expression.html @@ -0,0 +1,12 @@ +
Please enter a name for the sprite (without extension).
+
+ Sprite names must follow the naming schema for the selected expression: {{expression}} +
+
+ For multiple expressions, the name must follow the expression name and a valid suffix. Allowed separators are '-' or dot '.'. +
+Examples: {{expression}}.png, {{expression}}-1.png, {{expression}}.expressive.png, 美しい-17.png +{{#if clickedFileName}} +
Click 'Replace' to replace the existing expression:
+{{clickedFileName}} +{{/if}} diff --git a/src/endpoints/sprites.js b/src/endpoints/sprites.js index a87c554bd..4c5c5af85 100644 --- a/src/endpoints/sprites.js +++ b/src/endpoints/sprites.js @@ -227,6 +227,7 @@ router.post('/upload', urlencodedParser, async (request, response) => { const file = request.file; const label = request.body.label; const name = request.body.name; + const spriteName = request.body.spriteName || label; if (!file || !label || !name) { return response.sendStatus(400); @@ -249,12 +250,12 @@ router.post('/upload', urlencodedParser, async (request, response) => { // Remove existing sprite with the same label for (const file of files) { - if (path.parse(file).name === label) { + if (path.parse(file).name === spriteName) { fs.rmSync(path.join(spritesPath, file)); } } - const filename = label + path.parse(file.originalname).ext; + const filename = spriteName + path.parse(file.originalname).ext; const spritePath = path.join(file.destination, file.filename); const pathToFile = path.join(spritesPath, filename); // Copy uploaded file to sprites folder From ef127df623e63014ad272491dacdea23342c19ec Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 Jan 2025 08:33:14 +0100 Subject: [PATCH 06/63] Update sprite delete call --- public/scripts/extensions/expressions/index.js | 11 ++++++++--- src/endpoints/sprites.js | 5 +++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 9e91bd888..338778579 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -2081,20 +2081,25 @@ async function onClickExpressionDelete(event) { // Prevents the expression from being set event.stopPropagation(); - const confirmation = await callPopup('

Are you sure?

Once deleted, it\'s gone forever!', 'confirm'); + const expressionListItem = $(this).closest('.expression_list_item'); + const expression = expressionListItem.data('expression'); + const confirmation = await Popup.show.confirm(t`Delete Expression`, t`Are you sure you want to delete this expression? Once deleted, it\'s gone forever!` + + '

' + + t`Expression: ` + + expressionListItem.attr('data-filename')); if (!confirmation) { return; } - const expression = $(this).closest('.expression_list_item').data('expression'); + const fileName = expressionListItem.attr('data-filename'); const name = $('#image_list').data('name'); try { await fetch('/api/sprites/delete', { method: 'POST', headers: getRequestHeaders(), - body: JSON.stringify({ name, label: expression }), + body: JSON.stringify({ name, label: expression, spriteName: fileName }), }); } catch (error) { toastr.error('Failed to delete image. Try again later.'); diff --git a/src/endpoints/sprites.js b/src/endpoints/sprites.js index 4c5c5af85..4ddd4fe96 100644 --- a/src/endpoints/sprites.js +++ b/src/endpoints/sprites.js @@ -147,8 +147,9 @@ router.get('/get', jsonParser, function (request, response) { router.post('/delete', jsonParser, async (request, response) => { const label = request.body.label; const name = request.body.name; + const spriteName = request.body.spriteName || label; - if (!label || !name) { + if (!spriteName || !name) { return response.sendStatus(400); } @@ -164,7 +165,7 @@ router.post('/delete', jsonParser, async (request, response) => { // Remove existing sprite with the same label for (const file of files) { - if (path.parse(file).name === label) { + if (path.parse(file).name === spriteName) { fs.rmSync(path.join(spritesPath, file)); } } From 65ad79adceb3e170600599c7871dbb5b64658f1b Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 Jan 2025 21:50:13 +0100 Subject: [PATCH 07/63] Fix expression delete --- public/scripts/extensions/expressions/index.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 338778579..26afd614f 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1392,7 +1392,6 @@ async function drawSpritesList(character, labels, sprites) { continue; } - // TODO: Fix valid expression lists/caching and group them correctly validExpressions.push({ label: expression, files: images }); // Render main = first file, additional = rest @@ -2086,13 +2085,12 @@ async function onClickExpressionDelete(event) { const confirmation = await Popup.show.confirm(t`Delete Expression`, t`Are you sure you want to delete this expression? Once deleted, it\'s gone forever!` + '

' - + t`Expression: ` - + expressionListItem.attr('data-filename')); + + t`Expression:` + ' ' + expressionListItem.attr('data-filename')) + ''; if (!confirmation) { return; } - const fileName = expressionListItem.attr('data-filename'); + const fileName = withoutExtension(expressionListItem.attr('data-filename')); const name = $('#image_list').data('name'); try { From 3d6f48786d9bd7a5543397c442fdaa961d0b7782 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 Jan 2025 21:57:40 +0100 Subject: [PATCH 08/63] Refactor expression popups to modern popup --- public/scripts/extensions/expressions/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 26afd614f..5907dd845 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1,6 +1,6 @@ import { Fuse } from '../../../lib.js'; -import { callPopup, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types } from '../../../script.js'; +import { eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types } from '../../../script.js'; import { dragElement, isMobile } from '../../RossAscends-mods.js'; import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; import { loadMovingUIState, power_user } from '../../power-user.js'; @@ -1739,7 +1739,7 @@ function onClickExpressionImage() { async function onClickExpressionAddCustom() { const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression'); - let expressionName = await callPopup(template, 'input'); + let expressionName = await Popup.show.input(null, template); if (!expressionName) { console.debug('No custom expression name provided'); @@ -1786,7 +1786,7 @@ async function onClickExpressionRemoveCustom() { } const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression }); - const confirmation = await callPopup(template, 'confirm'); + const confirmation = await Popup.show.confirm(null, template); if (!confirmation) { console.debug('Custom expression removal cancelled'); From 7063af73637a8a2b239890855c391650ed4daca2 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 Jan 2025 22:10:31 +0100 Subject: [PATCH 09/63] Move new expression settings, add tooltip --- .../extensions/expressions/settings.html | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/public/scripts/extensions/expressions/settings.html b/public/scripts/extensions/expressions/settings.html index fd5f9c3a6..93cfeb22a 100644 --- a/public/scripts/extensions/expressions/settings.html +++ b/public/scripts/extensions/expressions/settings.html @@ -6,7 +6,7 @@
- + +
-
- - -
Open a chat to see the character expressions.
From 84a8a2bc2b7dfe6dea5b198f45b834ad44c9556c Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 Jan 2025 23:01:29 +0100 Subject: [PATCH 10/63] Fix expression sprite sorting, fade additional - Sort alphabetically, but keep the main expression file first - Fade additional sprite images if "allow multiple" is not chosen --- .../scripts/extensions/expressions/index.js | 22 +++++++++++++++---- .../scripts/extensions/expressions/style.css | 9 ++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 5907dd845..1c00a89a8 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1349,7 +1349,7 @@ function getExpressionImageData(sprite) { fileName: fileName, title: fileNameWithoutExtension, imageSrc: sprite.path, - type: fileNameWithoutExtension == sprite.label ? 'success' : 'additional', + type: 'success', isCustom: extension_settings.expressions.custom?.includes(sprite.label), }; } @@ -1380,8 +1380,7 @@ async function drawSpritesList(character, labels, sprites) { const images = sprites .filter(s => s.label === expression) .map(s => s.files) - .flat() - .sort((a, b) => a.title.localeCompare(b.title)); + .flat(); if (images.length === 0) { const listItem = await getListItem(expression, { @@ -1444,6 +1443,21 @@ async function getSpritesList(name) { return acc; }, []); + + // Sort the sprites for each expression alphabetically, but keep the main expression file at the front + for (const expression of grouped) { + expression.files.sort((a, b) => { + if (a.title === expression.label) return -1; + if (b.title === expression.label) return 1; + return a.title.localeCompare(b.title); + }); + + // Mark all besides the first sprite as 'additional' + for (let i = 1; i < expression.files.length; i++) { + expression.files[i].type = 'additional'; + } + } + return grouped; } catch (err) { @@ -1584,7 +1598,7 @@ async function setExpression(character, expression, force = false) { /** @type {Expression} */ const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression)); console.debug('checking for expression images to show..'); - if (sprite) { + if (sprite && sprite.files.length > 0) { console.debug('setting expression from character images folder'); let spriteFile = sprite.files[0]; diff --git a/public/scripts/extensions/expressions/style.css b/public/scripts/extensions/expressions/style.css index db58cb6f0..92d3d9bed 100644 --- a/public/scripts/extensions/expressions/style.css +++ b/public/scripts/extensions/expressions/style.css @@ -204,3 +204,12 @@ img.expression.default { align-items: baseline; flex-direction: row; } + +#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"] { + opacity: 0.3; + transition: opacity var(--animation-duration) ease; +} +#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:hover, +#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:focus { + opacity: unset; +} From d316d51c0be9b1e66ec2b85c74cfc6b102198f0c Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 Jan 2025 23:48:37 +0100 Subject: [PATCH 11/63] Rework expression slash commands - Common naming schema for slash commands, all starting with the name of the expression - moved the original names to aliases - Make char name optional for /expression-last if not in group chat - Removed legacy 'format' argument handling from /expression-classify - Fixed /expression-upload to the new backend call, added optional 'spriteName' argument --- .../scripts/extensions/expressions/index.js | 149 ++++++++++-------- 1 file changed, 84 insertions(+), 65 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 1c00a89a8..15759d280 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1,11 +1,11 @@ import { Fuse } from '../../../lib.js'; -import { eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types } from '../../../script.js'; +import { characters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js'; import { dragElement, isMobile } from '../../RossAscends-mods.js'; import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; import { loadMovingUIState, power_user } from '../../power-user.js'; import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js'; -import { hideMutedSprites } from '../../group-chats.js'; +import { hideMutedSprites, selected_group } from '../../group-chats.js'; import { isJsonSchemaSupported } from '../../textgen-settings.js'; import { debounce_timeout } from '../../constants.js'; import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; @@ -92,6 +92,8 @@ let lastTalkingStateMessage = null; // last message as seen by `updateTalkingSt let spriteCache = {}; let inApiCall = false; let lastServerResponseTime = 0; + +/** @type {{[characterName: string]: string}} */ export let lastExpression = {}; function isTalkingHeadEnabled() { @@ -1033,17 +1035,21 @@ function spriteFolderNameFromCharacter(char) { * @param {object} args * @param {string} args.name Character name or avatar key, passed through findChar * @param {string} args.label Expression label - * @param {string} args.folder Sprite folder path, processed using backslash rules + * @param {string} [args.folder=null] Optional sprite folder path, processed using backslash rules + * @param {string?} [args.spriteName=null] Optional sprite name * @param {string} imageUrl Image URI to fetch and upload - * @returns {Promise} + * @returns {Promise} the sprite name */ -async function uploadSpriteCommand({ name, label, folder }, imageUrl) { +async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) { if (!imageUrl) throw new Error('Image URL is required'); if (!label || typeof label !== 'string') throw new Error('Expression label is required'); label = label.replace(/[^a-z]/gi, '').toLowerCase().trim(); if (!label) throw new Error('Expression label must contain at least one letter'); + spriteName = spriteName || label; + if (!validateExpressionSpriteName(label, spriteName)) throw new Error('Invalid sprite name. Must follow the naming pattern for expression sprites.'); + name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name; const char = findChar({ name }); @@ -1062,7 +1068,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) { const formData = new FormData(); formData.append('name', folder); // this is the folder or character name formData.append('label', label); // this is the expression label - formData.append('avatar', file); // this is the image file + formData.append('avatar', file); // this is the image file + formData.append('spriteName', spriteName); // this is a redundant comment await handleFileUpload('/api/sprites/upload', formData); console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`); @@ -1070,6 +1077,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) { console.error(`[${MODULE_NAME}] Error uploading file:`, error); throw error; } + + return spriteName; } /** @@ -1876,6 +1885,12 @@ function withoutExtension(fileName) { return fileName.replace(/\.[^/.]+$/, ''); } +function validateExpressionSpriteName(expression, spriteName) { + const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`); + const validFileName = filenameValidationRegex.test(spriteName); + return validFileName; +} + async function onClickExpressionUpload(event) { // Prevents the expression from being set event.stopPropagation(); @@ -1900,8 +1915,7 @@ async function onClickExpressionUpload(event) { if (extension_settings.expressions.allowMultiple) { const matchesExisting = existingFiles.some(x => x.fileName === file.name); const fileNameWithoutExtension = withoutExtension(file.name); - const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`); - const validFileName = filenameValidationRegex.test(fileNameWithoutExtension); + const validFileName = validateExpressionSpriteName(expression, fileNameWithoutExtension); // If there is no expression yet and it's a valid expression, we just take it if (!clickedFileName && validFileName) { @@ -1932,15 +1946,15 @@ async function onClickExpressionUpload(event) { const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName }); spriteName = null; - const result = await Popup.show.input(t`Upload Expression Sprite`, message, + const input = await Popup.show.input(t`Upload Expression Sprite`, message, `${expression}-${existingFiles.length}`, { customButtons: customButtons }); - if (result) { - if (!filenameValidationRegex.test(result)) { + if (input) { + if (!validateExpressionSpriteName(expression, input)) { toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`); return; } - spriteName = result; + spriteName = input; } } } else { @@ -2350,23 +2364,23 @@ function migrateSettings() { }; SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'sprite', - aliases: ['emote'], + name: 'expression-set', + aliases: ['sprite', 'emote'], callback: setSpriteSlashCommand, unnamedArgumentList: [ SlashCommandArgument.fromProps({ - description: 'spriteId', + description: 'expression label to set', typeList: [ARGUMENT_TYPE.STRING], isRequired: true, enumProvider: localEnumProviders.expressions, }), ], - helpString: 'Force sets the sprite for the current character.', - returns: 'the currently set sprite label after setting it.', + helpString: 'Force sets the expression for the current character.', + returns: 'The currently set expression label after setting it.', })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'spriteoverride', - aliases: ['costume'], + name: 'expression-folder-override', + aliases: ['spriteoverride', 'costume'], callback: setSpriteSetCommand, unnamedArgumentList: [ new SlashCommandArgument( @@ -2376,55 +2390,52 @@ function migrateSettings() { helpString: 'Sets an override sprite folder for the current character. If the name starts with a slash or a backslash, selects a sub-folder in the character-named folder. Empty value to reset to default.', })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'lastsprite', - callback: (_, name) => { + name: 'expression-last', + aliases: ['lastsprite'], + /** @type {(args: object, name: string) => Promise} */ + callback: async (_, name) => { if (typeof name !== 'string') throw new Error('name must be a string'); + if (!name) { + if (selected_group) { + toastr.error(t`In group chats, you must specify a character name.`, t`No character name specified`); + return ''; + } + name = characters[this_chid]?.avatar; + } + const char = findChar({ name: name }); + if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`); + const sprite = lastExpression[char?.name ?? name] ?? ''; return sprite; }, - returns: 'the last set sprite / expression for the named character.', + returns: 'the last set expression for the named character.', unnamedArgumentList: [ SlashCommandArgument.fromProps({ - description: 'Character name - or unique character identifier (avatar key)', + description: 'Character name - or unique character identifier (avatar key). If not provided, the current character for this chat will be used (does not work in group chats)', typeList: [ARGUMENT_TYPE.STRING], - isRequired: true, enumProvider: commonEnumProviders.characters('character'), forceEnum: true, }), ], - helpString: 'Returns the last set sprite / expression for the named character.', + helpString: 'Returns the last set expression for the named character.', })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'th', + name: 'expression-talkinghead', callback: toggleTalkingHeadCommand, - aliases: ['talkinghead'], + aliases: ['th', 'talkinghead'], helpString: 'Character Expressions: toggles Image Type - talkinghead (extras) on/off.', returns: 'the current state of the Image Type - talkinghead (extras) on/off.', })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'classify-expressions', - aliases: ['expressions'], + name: 'expression-classify', + aliases: ['classify-expressions', 'expressions'], + /** @type {(args: {return: string}) => Promise} */ callback: async (args) => { - /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */ - // @ts-ignore - let returnType = args.return; + let returnType = + /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */ + (args.return); - // Old legacy return type handling - if (args.format) { - toastr.warning(`Legacy argument 'format' with value '${args.format}' is deprecated. Please use 'return' instead. Routing to the correct return type...`, 'Deprecation warning'); - const type = String(args?.format).toLowerCase().trim(); - switch (type) { - case 'json': - returnType = 'object'; - break; - default: - returnType = 'pipe'; - break; - } - } - - // Now the actual new return type handling const list = await getExpressionsList(); return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') }); @@ -2438,22 +2449,13 @@ function migrateSettings() { enumList: slashCommandReturnHelper.enumList({ allowObject: true }), forceEnum: true, }), - // TODO remove some day - SlashCommandNamedArgument.fromProps({ - name: 'format', - description: '!!! DEPRECATED - use "return" instead !!! The format to return the list in: comma-separated plain text or JSON array. Default is plain text.', - typeList: [ARGUMENT_TYPE.STRING], - enumList: [ - new SlashCommandEnumValue('plain', null, enumTypes.enum, ', '), - new SlashCommandEnumValue('json', null, enumTypes.enum, '[]'), - ], - }), ], returns: 'The comma-separated list of available expressions, including custom expressions.', helpString: 'Returns a list of available expressions, including custom expressions.', })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'classify', + name: 'expression-classify', + aliases: ['classify'], callback: classifyCallback, namedArgumentList: [ SlashCommandNamedArgument.fromProps({ @@ -2492,11 +2494,13 @@ function migrateSettings() { `, })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'uploadsprite', + name: 'expression-upload', + aliases: ['uploadsprite'], + /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise} */ callback: async (args, url) => { - await uploadSpriteCommand(args, url); - return ''; + return await uploadSpriteCommand(args, url); }, + returns: 'the resulting sprite name', unnamedArgumentList: [ SlashCommandArgument.fromProps({ description: 'URL of the image to upload', @@ -2510,7 +2514,6 @@ function migrateSettings() { description: 'Character name or avatar key (default is current character)', typeList: [ARGUMENT_TYPE.STRING], isRequired: false, - acceptsMultiple: false, }), SlashCommandNamedArgument.fromProps({ name: 'label', @@ -2518,16 +2521,32 @@ function migrateSettings() { typeList: [ARGUMENT_TYPE.STRING], enumProvider: localEnumProviders.expressions, isRequired: true, - acceptsMultiple: false, }), SlashCommandNamedArgument.fromProps({ name: 'folder', description: 'Override folder to upload into', typeList: [ARGUMENT_TYPE.STRING], isRequired: false, - acceptsMultiple: false, + }), + SlashCommandNamedArgument.fromProps({ + name: 'spriteName', + description: 'Override sprite name to allow multiple sprites per expressions. Has to follow the naming pattern. If unspecified, the label will be used as sprite name.', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: false, }), ], - helpString: '
Upload a sprite from a URL.
Example:
/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png
', + helpString: ` +
+ Upload a sprite from a URL. +
+
+ Example: +
    +
  • +
    /uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png
    +
  • +
+
+ `, })); })(); From 6348d1f19a93e48bbe51771748df019dc44041df Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Tue, 28 Jan 2025 00:17:42 +0100 Subject: [PATCH 12/63] CSS fixes (hide overflow, sprites interactable) --- public/scripts/extensions/expressions/list-item.html | 2 +- public/scripts/extensions/expressions/style.css | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/list-item.html b/public/scripts/extensions/expressions/list-item.html index dc5132600..dd18e32d5 100644 --- a/public/scripts/extensions/expressions/list-item.html +++ b/public/scripts/extensions/expressions/list-item.html @@ -1,5 +1,5 @@ {{#each images}} -
+
-

Hint: Create new folder in the /characters/ folder of your user data directory and name it as the name of the character. - Put images with expressions there. File names should follow the pattern: [expression_label].[image_format]

- In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a dash. Examples: joy.png, joy-1.png, joy.expressive.png, 美しい-17.png +

+ Hint: + + Create new folder in the /characters/ folder of your user data directory and name it as the name of the character. + Put images with expressions there. File names should follow the pattern: [expression_label].[image_format] + +

+

+ + In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a + dash. + Examples: joy.png, joy-1.png, joy.expressive.png + +

Sprite set: 

diff --git a/public/scripts/extensions/expressions/templates/upload-expression.html b/public/scripts/extensions/expressions/templates/upload-expression.html index a7901ae7f..a80ff6b90 100644 --- a/public/scripts/extensions/expressions/templates/upload-expression.html +++ b/public/scripts/extensions/expressions/templates/upload-expression.html @@ -5,7 +5,7 @@
For multiple expressions, the name must follow the expression name and a valid suffix. Allowed separators are '-' or dot '.'.
-Examples: {{expression}}.png, {{expression}}-1.png, {{expression}}.expressive.png, 美しい-17.png +Examples: {{expression}}.png, {{expression}}-1.png, {{expression}}.expressive.png {{#if clickedFileName}}
Click 'Replace' to replace the existing expression:
{{clickedFileName}} diff --git a/src/endpoints/sprites.js b/src/endpoints/sprites.js index ac6790982..0677ddb32 100644 --- a/src/endpoints/sprites.js +++ b/src/endpoints/sprites.js @@ -128,7 +128,7 @@ router.get('/get', jsonParser, function (request, response) { const fileName = path.parse(pathToSprite).name.toLowerCase(); // Extract the label from the filename via regex, which can be suffixed with a sub-name, either connected with a dash or a dot. - // Examples: joy.png, joy-1.png, joy.expressive.png, 美しい-17.png + // Examples: joy.png, joy-1.png, joy.expressive.png const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName; return { From 135bf8a55b87e0637708a7f008317ffb7cec5025 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Thu, 20 Feb 2025 00:12:40 +0200 Subject: [PATCH 21/63] Add progress toast for sprite ZIP upload --- public/scripts/extensions/expressions/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index c4d008750..54025adf7 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1880,7 +1880,9 @@ async function onClickExpressionUploadPackButton() { formData.append('name', name); formData.append('avatar', file); + const uploadToast = toastr.info('Please wait...', 'Upload is processing', { timeOut: 0, extendedTimeOut: 0 }); const { count } = await handleFileUpload('/api/sprites/upload-zip', formData); + toastr.clear(uploadToast); toastr.success(`Uploaded ${count} image(s) for ${name}`); // Reset the input From bad806312dcf530ceb5d65edd9c0558a4e7c810e Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Wed, 19 Feb 2025 23:42:28 +0200 Subject: [PATCH 22/63] Auto-extend session cookie every 30 minutes --- public/scripts/user.js | 26 ++++++++++++++++++++++++++ server.js | 8 +++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/public/scripts/user.js b/public/scripts/user.js index 4aa61967b..705542940 100644 --- a/public/scripts/user.js +++ b/public/scripts/user.js @@ -9,6 +9,9 @@ import { ensureImageFormatSupported, getBase64Async, humanFileSize } from './uti export let currentUser = null; export let accountsEnabled = false; +// Extend the session every 30 minutes +const SESSION_EXTEND_INTERVAL = 30 * 60 * 1000; + /** * Enable or disable user account controls in the UI. * @param {boolean} isEnabled User account controls enabled @@ -894,6 +897,24 @@ async function slugify(text) { } } +/** + * Pings the server to extend the user session. + */ +async function extendUserSession() { + try { + const response = await fetch('/api/ping?extend=1', { + method: 'GET', + headers: getRequestHeaders(), + }); + + if (!response.ok) { + throw new Error('Ping did not succeed', { cause: response.status }); + } + } catch (error) { + console.error('Failed to extend user session', error); + } +} + jQuery(() => { $('#logout_button').on('click', () => { logout(); @@ -904,4 +925,9 @@ jQuery(() => { $('#account_button').on('click', () => { openUserProfile(); }); + setInterval(async () => { + if (currentUser) { + await extendUserSession(); + } + }, SESSION_EXTEND_INTERVAL); }); diff --git a/server.js b/server.js index 18f5cba1a..950907ae8 100644 --- a/server.js +++ b/server.js @@ -556,7 +556,13 @@ app.use('/api/users', usersPublicRouter); // Everything below this line requires authentication app.use(requireLoginMiddleware); -app.get('/api/ping', (_, response) => response.sendStatus(204)); +app.get('/api/ping', (request, response) => { + if (request.query.extend && request.session) { + request.session.touch = Date.now(); + } + + response.sendStatus(204); +}); // File uploads app.use(multer({ dest: uploadsPath, limits: { fieldSize: 10 * 1024 * 1024 } }).single('avatar')); From a40f56840909e7c2b0edb996dac83434ae89bb71 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Thu, 20 Feb 2025 18:51:44 +0100 Subject: [PATCH 23/63] Fix sprite deletion 'no' option --- public/scripts/extensions/expressions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 54025adf7..f1aa7c7d9 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1904,7 +1904,7 @@ async function onClickExpressionDelete(event) { const confirmation = await Popup.show.confirm(t`Delete Expression`, t`Are you sure you want to delete this expression? Once deleted, it\'s gone forever!` + '

' - + t`Expression:` + ' ' + expressionListItem.attr('data-filename')) + ''; + + t`Expression:` + ' ' + expressionListItem.attr('data-filename') + ''); if (!confirmation) { return; } From a58e026a40513a75722ba1aa01a0001b249c4b67 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Thu, 20 Feb 2025 19:02:29 +0100 Subject: [PATCH 24/63] Don't show del popup on placeholder sprite --- public/scripts/extensions/expressions/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index f1aa7c7d9..21b8fa9e4 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1902,6 +1902,10 @@ async function onClickExpressionDelete(event) { const expressionListItem = $(this).closest('.expression_list_item'); const expression = expressionListItem.data('expression'); + if (expressionListItem.attr('data-expression-type') === 'failure') { + return; + } + const confirmation = await Popup.show.confirm(t`Delete Expression`, t`Are you sure you want to delete this expression? Once deleted, it\'s gone forever!` + '

' + t`Expression:` + ' ' + expressionListItem.attr('data-filename') + ''); From 19e2a2f7d24ed1c4a60191bd0c06f764f3dd18da Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Thu, 20 Feb 2025 19:05:20 +0100 Subject: [PATCH 25/63] safety check on upload on sprite name --- public/scripts/extensions/expressions/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 21b8fa9e4..bf2bb52c7 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1699,7 +1699,8 @@ async function onClickExpressionUpload(event) { const handleExpressionUploadChange = async (e) => { const file = e.target.files[0]; - if (!file) { + if (!file || !file.name) { + console.debug('No valid file selected'); return; } From 2834681a4b11a5e7dc45796d37f2b9f0e3978db2 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Thu, 20 Feb 2025 19:25:20 +0100 Subject: [PATCH 26/63] Fix sprite upload replace existing - Also fix form not resetting on cancel of replace popup --- public/scripts/extensions/expressions/index.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index bf2bb52c7..6b7c76532 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1739,12 +1739,13 @@ async function onClickExpressionUpload(event) { }); } + spriteName = null; + const suggestedSpriteName = generateUniqueSpriteName(expression, existingFiles); + const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName }); - spriteName = generateUniqueSpriteName(expression, existingFiles); - const input = await Popup.show.input(t`Upload Expression Sprite`, message, - spriteName, { customButtons: customButtons }); + suggestedSpriteName, { customButtons: customButtons }); if (input) { if (!validateExpressionSpriteName(expression, input)) { @@ -1752,8 +1753,6 @@ async function onClickExpressionUpload(event) { return; } spriteName = input; - } else { - spriteName = null; } } } else { @@ -1762,6 +1761,8 @@ async function onClickExpressionUpload(event) { if (!spriteName) { toastr.warning(t`Cancelled uploading sprite.`, t`Upload Cancelled`); + // Reset the input + e.target.form.reset(); return; } From 35745277808ba35199808ae7158e6c807f4a15de Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Thu, 20 Feb 2025 19:28:54 +0100 Subject: [PATCH 27/63] fade "reroll if same" if no multi sprites enabled --- public/scripts/extensions/expressions/style.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/style.css b/public/scripts/extensions/expressions/style.css index 1d4782e30..688b89378 100644 --- a/public/scripts/extensions/expressions/style.css +++ b/public/scripts/extensions/expressions/style.css @@ -209,7 +209,8 @@ img.expression.default { flex-direction: row; } -#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"] { +#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"], +#expressions_container:has(#expressions_allow_multiple:not(:checked)) label[for="expressions_reroll_if_same"] { opacity: 0.3; transition: opacity var(--animation-duration) ease; } From 8ad7b5dcc58465135ae5c8b7ed522280c892cd65 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Thu, 20 Feb 2025 23:13:23 +0200 Subject: [PATCH 28/63] Check if git repo root in plugin update --- plugins.js | 4 ++-- src/plugin-loader.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins.js b/plugins.js index b296ffd3c..92b04977c 100644 --- a/plugins.js +++ b/plugins.js @@ -8,7 +8,7 @@ import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; -import { default as git } from 'simple-git'; +import { default as git, CheckRepoActions } from 'simple-git'; import { color } from './src/util.js'; const __dirname = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)); @@ -49,7 +49,7 @@ async function updatePlugins() { const pluginPath = path.join(pluginsPath, directory); const pluginRepo = git(pluginPath); - const isRepo = await pluginRepo.checkIsRepo(); + const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT); if (!isRepo) { console.log(`Directory ${color.yellow(directory)} is not a Git repository`); continue; diff --git a/src/plugin-loader.js b/src/plugin-loader.js index 744c3396d..65df1e571 100644 --- a/src/plugin-loader.js +++ b/src/plugin-loader.js @@ -3,7 +3,7 @@ import path from 'node:path'; import url from 'node:url'; import express from 'express'; -import { default as git } from 'simple-git'; +import { default as git, CheckRepoActions } from 'simple-git'; import { sync as commandExistsSync } from 'command-exists'; import { getConfigValue, color } from './util.js'; @@ -256,7 +256,7 @@ async function updatePlugins(pluginsPath) { const pluginPath = path.join(pluginsPath, directory); const pluginRepo = git(pluginPath); - const isRepo = await pluginRepo.checkIsRepo(); + const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT); if (!isRepo) { continue; } From ab4d296b229538e6aaae3ce9c41a21e2b451ded5 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Thu, 20 Feb 2025 23:18:03 +0200 Subject: [PATCH 29/63] fix: return empty strings for branch name and commit hash in error response --- src/endpoints/extensions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/endpoints/extensions.js b/src/endpoints/extensions.js index 9c96065c4..95e5e7eba 100644 --- a/src/endpoints/extensions.js +++ b/src/endpoints/extensions.js @@ -230,7 +230,7 @@ router.post('/version', jsonParser, async (request, response) => { } catch (error) { // it is not a git repo, or has no commits yet, or is a bare repo // not possible to update it, most likely can't get the branch name either - return response.send({ currentBranchName: null, currentCommitHash, isUpToDate: true, remoteUrl: null }); + return response.send({ currentBranchName: '', currentCommitHash: '', isUpToDate: true, remoteUrl: '' }); } const currentBranch = await git.cwd(extensionPath).branch(); From 94f53835f42e3f5305e555802ec870114dda4334 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 00:29:05 +0200 Subject: [PATCH 30/63] Forbid custom expressions to be prefixed with defaults --- public/scripts/extensions/expressions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 6b7c76532..ece85405f 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1552,7 +1552,7 @@ async function onClickExpressionAddCustom() { toastr.warning('Invalid custom expression name provided', 'Add Custom Expression'); return; } - if (DEFAULT_EXPRESSIONS.includes(expressionName)) { + if (DEFAULT_EXPRESSIONS.includes(expressionName) || DEFAULT_EXPRESSIONS.some(x => expressionName.startsWith(x))) { toastr.warning('Expression name already exists', 'Add Custom Expression'); return; } From 179153ae67219dcaa8c8f15a1bdb97ea5d19ccbe Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 00:51:17 +0200 Subject: [PATCH 31/63] Fix group VN mode with reduced motion --- public/scripts/extensions/expressions/index.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index ece85405f..d74faa5d2 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -285,6 +285,12 @@ async function visualNovelUpdateLayers(container) { let images = Array.from($('#visual-novel-wrapper .expression-holder')).sort(sortFunction); let imagesWidth = []; + for (const image of images) { + if (image instanceof HTMLImageElement && !image.complete) { + await new Promise(resolve => image.addEventListener('load', resolve, { once: true })); + } + } + images.forEach(image => { imagesWidth.push($(image).width()); }); @@ -320,9 +326,15 @@ async function visualNovelUpdateLayers(container) { element.show(); const promise = new Promise(resolve => { - element.animate({ left: currentPosition + 'px' }, 500, () => { - resolve(); - }); + if (power_user.reduced_motion) { + element.css('left', currentPosition + 'px'); + requestAnimationFrame(() => resolve()); + } + else { + element.animate({ left: currentPosition + 'px' }, 500, () => { + resolve(); + }); + } }); currentPosition += imagesWidth[index]; From 07160e0e6022aef8edbfdebf6913a25f1257828e Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 01:01:30 +0200 Subject: [PATCH 32/63] Fix group VN mode not updating on kicking group members --- public/scripts/extensions/expressions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index d74faa5d2..5e8094996 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -210,7 +210,7 @@ async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, exp const spriteFile = chooseSpriteForExpression(memberSpriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc }); if (expressionImage.length) { - if (spriteFolderName == memberSpriteFolderName) { + if (!spriteFolderName || spriteFolderName == memberSpriteFolderName) { await validateImages(memberSpriteFolderName, true); setExpressionOverrideHtml(true); // <= force clear expression override input const path = spriteFile?.imageSrc || ''; From aca1cb7f9973683beba19e2f3f9264af30c93e6b Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 01:19:19 +0200 Subject: [PATCH 33/63] Fix first reorder of group VN with reduced motion --- public/scripts/extensions/expressions/index.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 5e8094996..bff903b79 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -4,7 +4,7 @@ import { characters, eventSource, event_types, generateRaw, getRequestHeaders, m import { dragElement, isMobile } from '../../RossAscends-mods.js'; import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js'; -import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js'; +import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar, delay } from '../../utils.js'; import { hideMutedSprites, selected_group } from '../../group-chats.js'; import { isJsonSchemaSupported } from '../../textgen-settings.js'; import { debounce_timeout } from '../../constants.js'; @@ -345,6 +345,12 @@ async function visualNovelUpdateLayers(container) { await Promise.allSettled(setLayerIndicesPromises); } +/** + * Sets the expression for the given character image. + * @param {JQuery} img - The image element to set the image on + * @param {string} path - The path to the image + * @returns {Promise} - A promise that resolves when the image is set + */ async function setImage(img, path) { // Cohee: If something goes wrong, uncomment this to return to the old behavior /* @@ -412,7 +418,9 @@ async function setImage(img, path) { expressionHolder.css('min-width', 100); expressionHolder.css('min-height', 100); - resolve(); + expressionClone.one('load', function () { + resolve(); + }); }); expressionClone.removeClass('expression-clone'); From e35217e7e3f66205957fe54443fb244dbf24f5b7 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 01:32:13 +0200 Subject: [PATCH 34/63] Fix image loading resolve --- public/scripts/extensions/expressions/index.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index bff903b79..f32083671 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -418,9 +418,11 @@ async function setImage(img, path) { expressionHolder.css('min-width', 100); expressionHolder.css('min-height', 100); - expressionClone.one('load', function () { + if (expressionClone.prop('complete')) { resolve(); - }); + } else { + expressionClone.one('load', () => resolve()); + } }); expressionClone.removeClass('expression-clone'); From bdbcf8623e7b6671a14f1a41e0216632cb39cf21 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 01:46:10 +0200 Subject: [PATCH 35/63] Fix force set emote in group --- public/scripts/extensions/expressions/index.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index f32083671..2afcca74b 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -234,6 +234,10 @@ async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, exp setSpritePromises.push(fadeInPromise); } + if (!img) { + continue; + } + img.attr('data-sprite-folder-name', spriteFolderName); img.attr('data-expression', expression); img.attr('data-sprite-filename', spriteFile?.fileName || null); @@ -679,7 +683,8 @@ async function setSpriteSlashCommand({ type }, searchTerm) { return ''; } - const spriteFolderName = getSpriteFolderName(); + const currentLastMessage = selected_group ? getLastCharacterMessage() : null; + const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); let label = searchTerm; @@ -2135,7 +2140,8 @@ function migrateSettings() { const localEnumProviders = { expressions: () => { - const spriteFolderName = getSpriteFolderName(); + const currentLastMessage = selected_group ? getLastCharacterMessage() : null; + const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); const expressions = getCachedExpressions(); return expressions.map(expression => { const spriteCount = spriteCache[spriteFolderName]?.find(x => x.label === expression)?.files.length ?? 0; @@ -2149,7 +2155,8 @@ function migrateSettings() { }); }, sprites: () => { - const spriteFolderName = getSpriteFolderName(); + const currentLastMessage = selected_group ? getLastCharacterMessage() : null; + const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); const sprites = spriteCache[spriteFolderName]?.map(x => x.files)?.flat() ?? []; return sprites.map(x => { return new SlashCommandEnumValue(x.title, From cb6adc30ce0e5e97a30fec160859a21427a4495b Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 02:05:43 +0200 Subject: [PATCH 36/63] Fix null confrimation when no custom expressions --- public/scripts/extensions/expressions/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 2afcca74b..a5177bd9e 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1601,8 +1601,9 @@ async function onClickExpressionAddCustom() { async function onClickExpressionRemoveCustom() { const selectedExpression = String($('#expression_custom').val()); + const noCustomExpressions = extension_settings.expressions.custom.length === 0; - if (!selectedExpression) { + if (!selectedExpression || noCustomExpressions) { console.debug('No custom expression selected'); return; } From aadae85a2a2f5e5fa9e34a2cb9815c1d2a0c066a Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 02:52:45 +0200 Subject: [PATCH 37/63] eventSource: Add autofire on emit for APP_READY --- public/lib/eventemitter.js | 45 ++++++++++++++++++++++++++++++++++++-- public/script.js | 2 +- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/public/lib/eventemitter.js b/public/lib/eventemitter.js index abf2b8dbc..03dd3bd09 100644 --- a/public/lib/eventemitter.js +++ b/public/lib/eventemitter.js @@ -24,10 +24,22 @@ if (typeof Array.prototype.indexOf === 'function') { /* Polyfill EventEmitter. */ -var EventEmitter = function () { +/** + * Creates an event emitter. + * @param {string[]} autoFireAfterEmit Auto-fire event names + */ +var EventEmitter = function (autoFireAfterEmit = []) { this.events = {}; + this.autoFireLastArgs = new Map(); + this.autoFireAfterEmit = new Set(autoFireAfterEmit); }; +/** + * Adds a listener to an event. + * @param {string} event Event name + * @param {function} listener Event listener + * @returns + */ EventEmitter.prototype.on = function (event, listener) { // Unknown event used by external libraries? if (event === undefined) { @@ -40,6 +52,10 @@ EventEmitter.prototype.on = function (event, listener) { } this.events[event].push(listener); + + if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) { + listener.apply(this, this.autoFireLastArgs.get(event)); + } }; /** @@ -60,6 +76,10 @@ EventEmitter.prototype.makeLast = function (event, listener) { } events.push(listener); + + if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) { + listener.apply(this, this.autoFireLastArgs.get(event)); + } } /** @@ -80,8 +100,17 @@ EventEmitter.prototype.makeFirst = function (event, listener) { } events.unshift(listener); + + if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) { + listener.apply(this, this.autoFireLastArgs.get(event)); + } } +/** + * Removes a listener from an event. + * @param {string} event Event name + * @param {function} listener Event listener + */ EventEmitter.prototype.removeListener = function (event, listener) { var idx; @@ -94,6 +123,10 @@ EventEmitter.prototype.removeListener = function (event, listener) { } }; +/** + * Emits an event with optional arguments. + * @param {string} event Event name + */ EventEmitter.prototype.emit = async function (event) { let args = [].slice.call(arguments, 1); if (localStorage.getItem('eventTracing') === 'true') { @@ -118,6 +151,10 @@ EventEmitter.prototype.emit = async function (event) { } } } + + if (this.autoFireAfterEmit.has(event)) { + this.autoFireLastArgs.set(event, args); + } }; EventEmitter.prototype.emitAndWait = function (event) { @@ -144,10 +181,14 @@ EventEmitter.prototype.emitAndWait = function (event) { } } } + + if (this.autoFireAfterEmit.has(event)) { + this.autoFireLastArgs.set(event, args); + } }; EventEmitter.prototype.once = function (event, listener) { - this.on(event, function g () { + this.on(event, function g() { this.removeListener(event, g); listener.apply(this, arguments); }); diff --git a/public/script.js b/public/script.js index d86945523..cf37389fb 100644 --- a/public/script.js +++ b/public/script.js @@ -512,7 +512,7 @@ export const event_types = { TOOL_CALLS_RENDERED: 'tool_calls_rendered', }; -export const eventSource = new EventEmitter(); +export const eventSource = new EventEmitter([event_types.APP_READY]); eventSource.on(event_types.CHAT_CHANGED, processChatSlashCommands); From db500188d87ab9fe376b7bb7aaff7d0b5baea267 Mon Sep 17 00:00:00 2001 From: KevinSun Date: Fri, 21 Feb 2025 20:32:23 +0800 Subject: [PATCH 38/63] feat(middleware): add separate access log middleware with config option --- default/config.yaml | 5 +++ server.js | 6 ++-- src/middleware/accessLogger.js | 59 ++++++++++++++++++++++++++++++++++ src/middleware/whitelist.js | 38 +--------------------- 4 files changed, 69 insertions(+), 39 deletions(-) create mode 100644 src/middleware/accessLogger.js diff --git a/default/config.yaml b/default/config.yaml index d730bfbdb..3dce817cd 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -85,6 +85,11 @@ cookieSecret: '' disableCsrfProtection: false # Disable startup security checks - NOT RECOMMENDED securityOverride: false +# -- LOGGING CONFIGURATION -- +logging: + # Enable access logging to access.log file + # Records new connections with timestamp, IP address and user agent + enableAccessLog: true # -- RATE LIMITING CONFIGURATION -- rateLimiting: # Use X-Real-IP header instead of socket IP for rate limiting diff --git a/server.js b/server.js index b83a79235..e020cbbdf 100644 --- a/server.js +++ b/server.js @@ -57,7 +57,8 @@ import { import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; import basicAuthMiddleware from './src/middleware/basicAuth.js'; -import whitelistMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/whitelist.js'; +import whitelistMiddleware from './src/middleware/whitelist.js'; +import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogger.js'; import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js'; import initRequestProxy from './src/request-proxy.js'; import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; @@ -342,7 +343,8 @@ app.use(CORS); if (listen && basicAuthMode) app.use(basicAuthMiddleware); -app.use(whitelistMiddleware(enableWhitelist, listen)); +app.use(whitelistMiddleware(enableWhitelist)); +app.use(accessLoggerMiddleware()); if (enableCorsProxy) { app.use(bodyParser.json({ diff --git a/src/middleware/accessLogger.js b/src/middleware/accessLogger.js new file mode 100644 index 000000000..26a2b49aa --- /dev/null +++ b/src/middleware/accessLogger.js @@ -0,0 +1,59 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { getRealIpFromHeader } from '../express-common.js'; +import { color, getConfigValue } from '../util.js'; + +const enableAccessLog = getConfigValue('logging.enableAccessLog', true); + +const knownIPs = new Set(); + +export const getAccessLogPath = () => path.join(globalThis.DATA_ROOT, 'access.log'); + +export function migrateAccessLog() { + try { + if (!fs.existsSync('access.log')) { + return; + } + const logPath = getAccessLogPath(); + if (fs.existsSync(logPath)) { + return; + } + fs.renameSync('access.log', logPath); + console.log(color.yellow('Migrated access.log to new location:'), logPath); + } catch (e) { + console.error('Failed to migrate access log:', e); + console.info('Please move access.log to the data directory manually.'); + } +} + +/** + * Creates middleware for logging access and new connections + * @returns {import('express').RequestHandler} + */ +export default function accessLoggerMiddleware() { + return function (req, res, next) { + const clientIp = getRealIpFromHeader(req); + const userAgent = req.headers['user-agent']; + + if (!knownIPs.has(clientIp)) { + // Log new connection + console.info(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`)); + knownIPs.add(clientIp); + + // Write to access log if enabled + if (enableAccessLog) { + const logPath = getAccessLogPath(); + const timestamp = new Date().toISOString(); + const log = `${timestamp} ${clientIp} ${userAgent}\n`; + + fs.appendFile(logPath, log, (err) => { + if (err) { + console.error('Failed to write access log:', err); + } + }); + } + } + + next(); + }; +} diff --git a/src/middleware/whitelist.js b/src/middleware/whitelist.js index cb90328ee..146f38110 100644 --- a/src/middleware/whitelist.js +++ b/src/middleware/whitelist.js @@ -10,9 +10,6 @@ import { color, getConfigValue, safeReadFileSync } from '../util.js'; const whitelistPath = path.join(process.cwd(), './whitelist.txt'); const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false); let whitelist = getConfigValue('whitelist', []); -let knownIPs = new Set(); - -export const getAccessLogPath = () => path.join(globalThis.DATA_ROOT, 'access.log'); if (fs.existsSync(whitelistPath)) { try { @@ -48,30 +45,12 @@ function getForwardedIp(req) { return undefined; } -export function migrateAccessLog() { - try { - if (!fs.existsSync('access.log')) { - return; - } - const logPath = getAccessLogPath(); - if (fs.existsSync(logPath)) { - return; - } - fs.renameSync('access.log', logPath); - console.log(color.yellow('Migrated access.log to new location:'), logPath); - } catch (e) { - console.error('Failed to migrate access log:', e); - console.info('Please move access.log to the data directory manually.'); - } -} - /** * Returns a middleware function that checks if the client IP is in the whitelist. * @param {boolean} whitelistMode If whitelist mode is enabled via config or command line - * @param {boolean} listen If listen mode is enabled via config or command line * @returns {import('express').RequestHandler} The middleware function */ -export default function whitelistMiddleware(whitelistMode, listen) { +export default function whitelistMiddleware(whitelistMode) { const forbiddenWebpage = Handlebars.compile( safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '', ); @@ -81,21 +60,6 @@ export default function whitelistMiddleware(whitelistMode, listen) { const forwardedIp = getForwardedIp(req); const userAgent = req.headers['user-agent']; - if (listen && !knownIPs.has(clientIp)) { - console.info(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`)); - knownIPs.add(clientIp); - - // Write access log - const logPath = getAccessLogPath(); - const timestamp = new Date().toISOString(); - const log = `${timestamp} ${clientIp} ${userAgent}\n`; - fs.appendFile(logPath, log, (err) => { - if (err) { - console.error('Failed to write access log:', err); - } - }); - } - //clientIp = req.connection.remoteAddress.split(':').pop(); if (whitelistMode === true && !whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x))) || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x))) From b17fdcbfd9fdf6e542e83943cc27538a70960113 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 12:46:49 +0000 Subject: [PATCH 39/63] Fix assistant chat export format --- public/scripts/chats.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/chats.js b/public/scripts/chats.js index 042030f2c..a6f2432d2 100644 --- a/public/scripts/chats.js +++ b/public/scripts/chats.js @@ -1487,7 +1487,7 @@ jQuery(function () { ...chat.filter(x => x?.extra?.type !== system_message_types.ASSISTANT_NOTE), ]; - download(JSON.stringify(chatToSave, null, 4), `Assistant - ${humanizedDateTime()}.json`, 'application/json'); + download(chatToSave.map((m) => JSON.stringify(m)).join('\n'), `Assistant - ${humanizedDateTime()}.jsonl`, 'application/json'); }); // Do not change. #attachFile is added by extension. From bfc609c2a8fa0743e72d49278e72ec48cbc416d0 Mon Sep 17 00:00:00 2001 From: KevinSun Date: Fri, 21 Feb 2025 22:17:12 +0800 Subject: [PATCH 40/63] fix(middleware): skip New connection message and `access.log` writes for localhost --- server.js | 2 +- src/middleware/accessLogger.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/server.js b/server.js index e020cbbdf..16c300bba 100644 --- a/server.js +++ b/server.js @@ -344,7 +344,7 @@ app.use(CORS); if (listen && basicAuthMode) app.use(basicAuthMiddleware); app.use(whitelistMiddleware(enableWhitelist)); -app.use(accessLoggerMiddleware()); +app.use(accessLoggerMiddleware(listen)); if (enableCorsProxy) { app.use(bodyParser.json({ diff --git a/src/middleware/accessLogger.js b/src/middleware/accessLogger.js index 26a2b49aa..a392e5595 100644 --- a/src/middleware/accessLogger.js +++ b/src/middleware/accessLogger.js @@ -28,14 +28,15 @@ export function migrateAccessLog() { /** * Creates middleware for logging access and new connections + * @param {boolean} listen If listen mode is enabled via config or command line * @returns {import('express').RequestHandler} */ -export default function accessLoggerMiddleware() { +export default function accessLoggerMiddleware(listen) { return function (req, res, next) { const clientIp = getRealIpFromHeader(req); const userAgent = req.headers['user-agent']; - if (!knownIPs.has(clientIp)) { + if (listen && !knownIPs.has(clientIp)) { // Log new connection console.info(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`)); knownIPs.add(clientIp); From 9c3e8c935b7a7002294dabfdd84b7905455f55f7 Mon Sep 17 00:00:00 2001 From: KevinSun Date: Fri, 21 Feb 2025 23:49:15 +0800 Subject: [PATCH 41/63] refactor(Middleware): only mount accessLogger when listen is enabled --- server.js | 4 +++- src/middleware/accessLogger.js | 5 ++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/server.js b/server.js index 16c300bba..5228c34b9 100644 --- a/server.js +++ b/server.js @@ -344,7 +344,9 @@ app.use(CORS); if (listen && basicAuthMode) app.use(basicAuthMiddleware); app.use(whitelistMiddleware(enableWhitelist)); -app.use(accessLoggerMiddleware(listen)); +if (listen) { + app.use(accessLoggerMiddleware()); +} if (enableCorsProxy) { app.use(bodyParser.json({ diff --git a/src/middleware/accessLogger.js b/src/middleware/accessLogger.js index a392e5595..26a2b49aa 100644 --- a/src/middleware/accessLogger.js +++ b/src/middleware/accessLogger.js @@ -28,15 +28,14 @@ export function migrateAccessLog() { /** * Creates middleware for logging access and new connections - * @param {boolean} listen If listen mode is enabled via config or command line * @returns {import('express').RequestHandler} */ -export default function accessLoggerMiddleware(listen) { +export default function accessLoggerMiddleware() { return function (req, res, next) { const clientIp = getRealIpFromHeader(req); const userAgent = req.headers['user-agent']; - if (listen && !knownIPs.has(clientIp)) { + if (!knownIPs.has(clientIp)) { // Log new connection console.info(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`)); knownIPs.add(clientIp); From f755c3d4cb326376a49cb84c48c97ebf145e9b3f Mon Sep 17 00:00:00 2001 From: KevinSun Date: Fri, 21 Feb 2025 23:56:31 +0800 Subject: [PATCH 42/63] refactor(middleware): rename accessLogger to accessLogWriter --- server.js | 2 +- src/middleware/{accessLogger.js => accessLogWriter.js} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/middleware/{accessLogger.js => accessLogWriter.js} (100%) diff --git a/server.js b/server.js index 5228c34b9..201710cc6 100644 --- a/server.js +++ b/server.js @@ -58,7 +58,7 @@ import { import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; import basicAuthMiddleware from './src/middleware/basicAuth.js'; import whitelistMiddleware from './src/middleware/whitelist.js'; -import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogger.js'; +import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogWriter.js'; import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js'; import initRequestProxy from './src/request-proxy.js'; import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; diff --git a/src/middleware/accessLogger.js b/src/middleware/accessLogWriter.js similarity index 100% rename from src/middleware/accessLogger.js rename to src/middleware/accessLogWriter.js From 29c71fe8f17edfcc37e66a37a2fa723221f63c10 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Fri, 21 Feb 2025 17:30:56 +0100 Subject: [PATCH 43/63] Update reasoning block coloring to CSS vars --- public/style.css | 43 ++++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/public/style.css b/public/style.css index 6278d9960..3b07f593d 100644 --- a/public/style.css +++ b/public/style.css @@ -55,6 +55,9 @@ --interactable-outline-color: var(--white100); --interactable-outline-color-faint: var(--white20a); + --reasoning-mix-rate: 50%; + --reasoning-mix-color: var(--grey30); + /*Default Theme, will be changed by ToolCool Color Picker*/ --SmartThemeBodyColor: rgb(220, 220, 210); @@ -354,7 +357,7 @@ input[type='checkbox']:focus-visible { padding-left: 14px; margin-bottom: 0.5em; overflow-y: auto; - color: var(--SmartThemeEmColor); + color: color-mix(in srgb, var(--SmartThemeBodyColor) var(--reasoning-mix-rate), var(--reasoning-mix-color)); } .mes_reasoning_details { @@ -374,18 +377,6 @@ input[type='checkbox']:focus-visible { margin-bottom: 0; } -.mes_reasoning em, -.mes_reasoning i, -.mes_reasoning u, -.mes_reasoning q, -.mes_reasoning blockquote { - filter: saturate(0.5); -} - -.mes_reasoning_details .mes_reasoning em { - color: color-mix(in srgb, var(--SmartThemeEmColor) 67%, var(--SmartThemeBlurTintColor) 33%); -} - .mes_reasoning_header_block { flex-grow: 1; } @@ -461,26 +452,36 @@ input[type='checkbox']:focus-visible { } .mes_text i, -.mes_text em, +.mes_text em { + color: var(--SmartThemeEmColor); +} .mes_reasoning i, .mes_reasoning em { - color: var(--SmartThemeEmColor); + color: color-mix(in srgb, var(--SmartThemeEmColor) var(--reasoning-mix-rate), var(--reasoning-mix-color)); } .mes_text q i, .mes_text q em { color: inherit; } - -.mes_text u, -.mes_reasoning u { - color: var(--SmartThemeUnderlineColor); +.mes_reasoning q i, +.mes_reasoning q em { + color: color-mix(in srgb, var(--SmartThemeQuoteColor) var(--reasoning-mix-rate), var(--reasoning-mix-color)); } -.mes_text q, -.mes_reasoning q { +.mes_text u { + color: var(--SmartThemeUnderlineColor); +} +.mes_reasoning u { + color: color-mix(in srgb, var(--SmartThemeUnderlineColor) var(--reasoning-mix-rate), var(--reasoning-mix-color)); +} + +.mes_text q { color: var(--SmartThemeQuoteColor); } +.mes_reasoning q { + color: color-mix(in srgb, var(--SmartThemeQuoteColor) var(--reasoning-mix-rate), var(--reasoning-mix-color)); +} .mes_text font[color] em, .mes_text font[color] i, From b0a2f241d20a826ef2e5c3d0cf8e3c88151cb4af Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Fri, 21 Feb 2025 18:01:25 +0100 Subject: [PATCH 44/63] Fix hidden reasoning not allowing manual parsing --- public/scripts/reasoning.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/public/scripts/reasoning.js b/public/scripts/reasoning.js index 44927d83f..2d4711db2 100644 --- a/public/scripts/reasoning.js +++ b/public/scripts/reasoning.js @@ -338,14 +338,15 @@ export class ReasoningHandler { return mesChanged; } - if (this.state === ReasoningState.None) { + if (this.state === ReasoningState.None || this.#isHiddenReasoningModel) { // If streamed message starts with the opening, cut it out and put all inside reasoning if (message.mes.startsWith(power_user.reasoning.prefix) && message.mes.length > power_user.reasoning.prefix.length) { this.#isParsingReasoning = true; // Manually set starting state here, as we might already have received the ending suffix this.state = ReasoningState.Thinking; - this.startTime = this.initialTime; + this.startTime = this.startTime ?? this.initialTime; + this.endTime = null; } } From 0cc0d6763ed95f9a0e331858838aef7f05c39c08 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Fri, 21 Feb 2025 19:40:36 +0100 Subject: [PATCH 45/63] Use hsl instead of color-mix for reasoning css --- public/style.css | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/public/style.css b/public/style.css index 3b07f593d..bafc9e288 100644 --- a/public/style.css +++ b/public/style.css @@ -55,8 +55,9 @@ --interactable-outline-color: var(--white100); --interactable-outline-color-faint: var(--white20a); - --reasoning-mix-rate: 50%; - --reasoning-mix-color: var(--grey30); + --reasoning-body-color: var(--SmartThemeEmColor); + --reasoning-em-color: color-mix(in srgb, var(--SmartThemeEmColor) 67%, var(--SmartThemeBlurTintColor) 33%); + --reasoning-saturation: 0.5; /*Default Theme, will be changed by ToolCool Color Picker*/ @@ -351,13 +352,13 @@ input[type='checkbox']:focus-visible { .mes_reasoning { display: block; - border-left: 2px solid var(--SmartThemeEmColor); + border-left: 2px solid var(--reasoning-body-color); border-radius: 2px; padding: 5px; padding-left: 14px; margin-bottom: 0.5em; overflow-y: auto; - color: color-mix(in srgb, var(--SmartThemeBodyColor) var(--reasoning-mix-rate), var(--reasoning-mix-color)); + color: hsl(from var(--reasoning-body-color) h calc(s * var(--reasoning-saturation)) l); } .mes_reasoning_details { @@ -457,7 +458,7 @@ input[type='checkbox']:focus-visible { } .mes_reasoning i, .mes_reasoning em { - color: color-mix(in srgb, var(--SmartThemeEmColor) var(--reasoning-mix-rate), var(--reasoning-mix-color)); + color: hsl(from var(--reasoning-em-color) h calc(s * var(--reasoning-saturation)) l); } .mes_text q i, @@ -466,21 +467,21 @@ input[type='checkbox']:focus-visible { } .mes_reasoning q i, .mes_reasoning q em { - color: color-mix(in srgb, var(--SmartThemeQuoteColor) var(--reasoning-mix-rate), var(--reasoning-mix-color)); + color: hsl(from var(--SmartThemeQuoteColor) h calc(s * var(--reasoning-saturation)) l); } .mes_text u { color: var(--SmartThemeUnderlineColor); } .mes_reasoning u { - color: color-mix(in srgb, var(--SmartThemeUnderlineColor) var(--reasoning-mix-rate), var(--reasoning-mix-color)); + color: hsl(from var(--SmartThemeUnderlineColor) h calc(s * var(--reasoning-saturation)) l); } .mes_text q { color: var(--SmartThemeQuoteColor); } .mes_reasoning q { - color: color-mix(in srgb, var(--SmartThemeQuoteColor) var(--reasoning-mix-rate), var(--reasoning-mix-color)); + color: hsl(from var(--SmartThemeQuoteColor) h calc(s * var(--reasoning-saturation)) l); } .mes_text font[color] em, From a7d7b6fb0fc4e35ece5aa056a6c3e0bdaf102160 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 22:05:08 +0200 Subject: [PATCH 46/63] Fix group VN updates on switching to chat --- .../scripts/extensions/expressions/index.js | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index a5177bd9e..42606d8c0 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -178,6 +178,7 @@ async function visualNovelRemoveInactive(container) { * @returns {Promise} - An array of promises that resolve when the sprites are set */ async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression) { + const originalExpression = expression; const context = getContext(); const group = context.groups.find(x => x.id == context.groupId); @@ -208,6 +209,10 @@ async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, exp const prevExpressionSrc = expressionImage.find('img').attr('src') || null; + if (!originalExpression && Array.isArray(spriteCache[memberSpriteFolderName]) && spriteCache[memberSpriteFolderName].length > 0) { + expression = await getLastMessageSprite(avatar); + } + const spriteFile = chooseSpriteForExpression(memberSpriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc }); if (expressionImage.length) { if (!spriteFolderName || spriteFolderName == memberSpriteFolderName) { @@ -251,6 +256,23 @@ async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, exp return setSpritePromises; } +/** + * Classifies the text of the latest message and returns the expression label. + * @param {string} avatar - The avatar of the character to get the last message for + * @returns {Promise} - The expression label + */ +async function getLastMessageSprite(avatar) { + const context = getContext(); + const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar)))); + + if (lastMessage) { + const text = lastMessage.mes || ''; + return await getExpressionLabel(text); + } + + return null; +} + async function visualNovelUpdateLayers(container) { const context = getContext(); const group = context.groups.find(x => x.id == context.groupId); @@ -445,7 +467,7 @@ async function setImage(img, path) { }); } -async function moduleWorker() { +async function moduleWorker({ newChat = false } = {}) { const context = getContext(); // non-characters not supported @@ -514,6 +536,10 @@ async function moduleWorker() { offlineMode.css('display', 'none'); } + if (context.groupId && vnMode && newChat) { + await forceUpdateVisualNovelMode(); + } + // Don't bother classifying if current char has no sprites and no default expressions are enabled if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) { return; @@ -2134,7 +2160,7 @@ function migrateSettings() { $('#visual-novel-wrapper').empty(); } - updateFunction(); + updateFunction({ newChat: true }); }); eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced); eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced); From 6e5db5c41ad99bc690bb636d99b787c4747888ee Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 23:03:49 +0200 Subject: [PATCH 47/63] Perplexity: Add new models --- public/index.html | 4 ++++ public/scripts/openai.js | 2 +- public/scripts/tokenizers.js | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/public/index.html b/public/index.html index ea1267429..123c4032d 100644 --- a/public/index.html +++ b/public/index.html @@ -3253,6 +3253,10 @@ + + + + diff --git a/public/scripts/openai.js b/public/scripts/openai.js index 39b3970ad..71b59f93a 100644 --- a/public/scripts/openai.js +++ b/public/scripts/openai.js @@ -4387,7 +4387,7 @@ async function onModelChange() { if (oai_settings.max_context_unlocked) { $('#openai_max_context').attr('max', unlocked_max); } - else if (['sonar', 'sonar-reasoning'].includes(oai_settings.perplexity_model)) { + else if (['sonar', 'sonar-reasoning', 'sonar-reasoning-pro', 'r1-1776'].includes(oai_settings.perplexity_model)) { $('#openai_max_context').attr('max', 127000); } else if (['sonar-pro'].includes(oai_settings.perplexity_model)) { diff --git a/public/scripts/tokenizers.js b/public/scripts/tokenizers.js index 9a4995206..c20ae2706 100644 --- a/public/scripts/tokenizers.js +++ b/public/scripts/tokenizers.js @@ -679,7 +679,7 @@ export function getTokenizerModel() { } if (oai_settings.chat_completion_source === chat_completion_sources.PERPLEXITY) { - if (oai_settings.perplexity_model.includes('sonar-reasoning')) { + if (oai_settings.perplexity_model.includes('sonar-reasoning') || oai_settings.perplexity_model.includes('r1-1776')) { return deepseekTokenizer; } if (oai_settings.perplexity_model.includes('llama-3') || oai_settings.perplexity_model.includes('llama3')) { From d32adb8d1d1a68399bcf92e9cb4942e431b85e2c Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 23:07:33 +0200 Subject: [PATCH 48/63] Fix requestProxyBypass command line default value Closes #3528 --- server.js | 1 - 1 file changed, 1 deletion(-) diff --git a/server.js b/server.js index b83a79235..67d706192 100644 --- a/server.js +++ b/server.js @@ -243,7 +243,6 @@ const cliArguments = yargs(hideBin(process.argv)) describe: 'Request proxy URL (HTTP or SOCKS protocols)', }).option('requestProxyBypass', { type: 'array', - default: null, describe: 'Request proxy bypass list (space separated list of hosts)', }).parseSync(); From afbe21b6b45d2ee2ffcbd1df6fa77a15c573342c Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sat, 22 Feb 2025 01:44:42 +0100 Subject: [PATCH 49/63] Make sendExpressionCall exportable - For compatibility with existing extensions --- public/scripts/extensions/expressions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 42606d8c0..21b8c18a6 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -646,7 +646,7 @@ function getFolderNameByMessage(message) { * @param {boolean} [options.vnMode=null] If true, the expression will be sent in Visual Novel mode. If null, it will be determined by the current chat mode. * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name. */ -async function sendExpressionCall(spriteFolderName, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) { +export async function sendExpressionCall(spriteFolderName, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) { lastExpression[spriteFolderName.split('/')[0]] = expression; if (vnMode === null) { vnMode = isVisualNovelMode(); From 13f76c974ea4361da5ef40a8245e1fd078d79065 Mon Sep 17 00:00:00 2001 From: yokuminto Date: Sat, 22 Feb 2025 16:01:46 +0800 Subject: [PATCH 50/63] reasoning or reasoning_content --- public/index.html | 2 +- public/scripts/openai.js | 10 +++++++++- public/scripts/reasoning.js | 5 +++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/public/index.html b/public/index.html index 123c4032d..d4fc97b5c 100644 --- a/public/index.html +++ b/public/index.html @@ -2000,7 +2000,7 @@
-
+

Groq Model

From 3e26b93971afaf4d8cb166cbd2428585165d98fe Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 22 Feb 2025 14:14:40 +0200 Subject: [PATCH 58/63] Do not register whitelist middleware if whitelist disabled --- server.js | 9 +++++++-- src/middleware/whitelist.js | 7 +++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/server.js b/server.js index 678ec8949..e609c902e 100644 --- a/server.js +++ b/server.js @@ -340,9 +340,14 @@ const CORS = cors({ app.use(CORS); -if (listen && basicAuthMode) app.use(basicAuthMiddleware); +if (listen && basicAuthMode) { + app.use(basicAuthMiddleware); +} + +if (enableWhitelist) { + app.use(whitelistMiddleware()); +} -app.use(whitelistMiddleware(enableWhitelist)); if (listen) { app.use(accessLoggerMiddleware()); } diff --git a/src/middleware/whitelist.js b/src/middleware/whitelist.js index 730770a3e..4674e6607 100644 --- a/src/middleware/whitelist.js +++ b/src/middleware/whitelist.js @@ -47,10 +47,9 @@ function getForwardedIp(req) { /** * Returns a middleware function that checks if the client IP is in the whitelist. - * @param {boolean} whitelistMode If whitelist mode is enabled via config or command line * @returns {import('express').RequestHandler} The middleware function */ -export default function whitelistMiddleware(whitelistMode) { +export default function whitelistMiddleware() { const forbiddenWebpage = Handlebars.compile( safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '', ); @@ -65,8 +64,8 @@ export default function whitelistMiddleware(whitelistMode) { const userAgent = req.headers['user-agent']; //clientIp = req.connection.remoteAddress.split(':').pop(); - if (whitelistMode === true && !whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x))) - || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x))) + if (!whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x))) + || forwardedIp && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x))) ) { // Log the connection attempt with real IP address const ipDetails = forwardedIp From 9b631ed0489c80aeaf11b675b6fb2b151e796f60 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 22 Feb 2025 14:29:04 +0200 Subject: [PATCH 59/63] Support Qwen tokenizer fro Groq --- public/scripts/tokenizers.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/public/scripts/tokenizers.js b/public/scripts/tokenizers.js index c20ae2706..7cb88ab78 100644 --- a/public/scripts/tokenizers.js +++ b/public/scripts/tokenizers.js @@ -694,6 +694,9 @@ export function getTokenizerModel() { } if (oai_settings.chat_completion_source === chat_completion_sources.GROQ) { + if (oai_settings.groq_model.includes('qwen')) { + return qwen2Tokenizer; + } if (oai_settings.groq_model.includes('llama-3') || oai_settings.groq_model.includes('llama3')) { return llama3Tokenizer; } From 15769a7643d3c4e063d60749bd2654ba9dc6bbab Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 22 Feb 2025 14:36:32 +0200 Subject: [PATCH 60/63] Add context sizes for new groq models --- public/scripts/openai.js | 62 +++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/public/scripts/openai.js b/public/scripts/openai.js index 8e77040cb..07223c05b 100644 --- a/public/scripts/openai.js +++ b/public/scripts/openai.js @@ -4115,6 +4115,39 @@ function getMaxContextWindowAI(value) { } } +/** + * Get the maximum context size for the Groq model + * @param {string} model Model identifier + * @param {boolean} isUnlocked Whether context limits are unlocked + * @returns {number} Maximum context size in tokens + */ +function getGroqMaxContext(model, isUnlocked) { + if (isUnlocked) { + return unlocked_max; + } + + const contextMap = { + 'gemma2-9b-it': max_8k, + 'llama-3.3-70b-versatile': max_128k, + 'llama-3.1-8b-instant': max_128k, + 'llama3-70b-8192': max_8k, + 'llama3-8b-8192': max_8k, + 'mixtral-8x7b-32768': max_32k, + 'deepseek-r1-distill-llama-70b': max_128k, + 'llama-3.3-70b-specdec': max_8k, + 'llama-3.2-1b-preview': max_128k, + 'llama-3.2-3b-preview': max_128k, + 'llama-3.2-11b-vision-preview': max_128k, + 'llama-3.2-90b-vision-preview': max_128k, + 'qwen-2.5-32b': max_128k, + 'deepseek-r1-distill-qwen-32b': max_128k, + 'deepseek-r1-distill-llama-70b-specdec': max_128k, + }; + + // Return context size if model found, otherwise default to 128k + return Object.entries(contextMap).find(([key]) => model.includes(key))?.[1] || max_128k; +} + async function onModelChange() { biasCache = undefined; let value = String($(this).val() || ''); @@ -4416,33 +4449,8 @@ async function onModelChange() { } if (oai_settings.chat_completion_source == chat_completion_sources.GROQ) { - if (oai_settings.max_context_unlocked) { - $('#openai_max_context').attr('max', unlocked_max); - } else if (oai_settings.groq_model.includes('gemma2-9b-it')) { - $('#openai_max_context').attr('max', max_8k); - } else if (oai_settings.groq_model.includes('llama-3.3-70b-versatile')) { - $('#openai_max_context').attr('max', max_128k); - } else if (oai_settings.groq_model.includes('llama-3.1-8b-instant')) { - $('#openai_max_context').attr('max', max_128k); - } else if (oai_settings.groq_model.includes('llama3-70b-8192')) { - $('#openai_max_context').attr('max', max_8k); - } else if (oai_settings.groq_model.includes('llama3-8b-8192')) { - $('#openai_max_context').attr('max', max_8k); - } else if (oai_settings.groq_model.includes('mixtral-8x7b-32768')) { - $('#openai_max_context').attr('max', max_32k); - } else if (oai_settings.groq_model.includes('deepseek-r1-distill-llama-70b')) { - $('#openai_max_context').attr('max', max_128k); - } else if (oai_settings.groq_model.includes('llama-3.3-70b-specdec')) { - $('#openai_max_context').attr('max', max_8k); - } else if (oai_settings.groq_model.includes('llama-3.2-1b-preview')) { - $('#openai_max_context').attr('max', max_128k); - } else if (oai_settings.groq_model.includes('llama-3.2-3b-preview')) { - $('#openai_max_context').attr('max', max_128k); - } else if (oai_settings.groq_model.includes('llama-3.2-11b-vision-preview')) { - $('#openai_max_context').attr('max', max_128k); - } else if (oai_settings.groq_model.includes('llama-3.2-90b-vision-preview')) { - $('#openai_max_context').attr('max', max_128k); - } + const maxContext = getGroqMaxContext(oai_settings.groq_model, oai_settings.max_context_unlocked); + $('#openai_max_context').attr('max', maxContext); oai_settings.openai_max_context = Math.min(Number($('#openai_max_context').attr('max')), oai_settings.openai_max_context); $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input'); oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai); From e7d38d95d0df4b1bbf1a19d1a61533bb76288b00 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 22 Feb 2025 14:37:53 +0200 Subject: [PATCH 61/63] Add max context size for llama-guard-3-8b model --- public/scripts/openai.js | 1 + 1 file changed, 1 insertion(+) diff --git a/public/scripts/openai.js b/public/scripts/openai.js index 07223c05b..dd86c8f51 100644 --- a/public/scripts/openai.js +++ b/public/scripts/openai.js @@ -4132,6 +4132,7 @@ function getGroqMaxContext(model, isUnlocked) { 'llama-3.1-8b-instant': max_128k, 'llama3-70b-8192': max_8k, 'llama3-8b-8192': max_8k, + 'llama-guard-3-8b': max_8k, 'mixtral-8x7b-32768': max_32k, 'deepseek-r1-distill-llama-70b': max_128k, 'llama-3.3-70b-specdec': max_8k, From f2cac8e7f749fd797ac6bb5eff3714e56731231f Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:02:34 +0200 Subject: [PATCH 62/63] Revert "Return fallback expression if no group message found" --- public/scripts/extensions/expressions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 986370cf5..21b8c18a6 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -270,7 +270,7 @@ async function getLastMessageSprite(avatar) { return await getExpressionLabel(text); } - return extension_settings.expressions.fallback_expression || DEFAULT_FALLBACK_EXPRESSION; + return null; } async function visualNovelUpdateLayers(container) { From 3cf4be8e859e5f6c7120b8d3f3f070561f074f5e Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:12:19 +0200 Subject: [PATCH 63/63] Remove unused import --- public/scripts/extensions/expressions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 21b8c18a6..2dade5f6a 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -4,7 +4,7 @@ import { characters, eventSource, event_types, generateRaw, getRequestHeaders, m import { dragElement, isMobile } from '../../RossAscends-mods.js'; import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js'; -import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar, delay } from '../../utils.js'; +import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js'; import { hideMutedSprites, selected_group } from '../../group-chats.js'; import { isJsonSchemaSupported } from '../../textgen-settings.js'; import { debounce_timeout } from '../../constants.js';