mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
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
This commit is contained in:
@ -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<Expression[]>} 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<string>} 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) {
|
||||
|
@ -1,4 +1,5 @@
|
||||
<div id="{{item}}" class="expression_list_item">
|
||||
{{#each images}}
|
||||
<div class="expression_list_item" data-epression="{{../expression}}" data-expression-type="{{this.type}}" data-filename="{{this.fileName}}">
|
||||
<div class="expression_list_buttons">
|
||||
<div class="menu_button expression_list_upload" title="Upload image">
|
||||
<i class="fa-solid fa-upload"></i>
|
||||
@ -7,11 +8,14 @@
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="expression_list_title {{textClass}}">
|
||||
<span>{{item}}</span>
|
||||
{{#if isCustom}}
|
||||
<div class="expression_list_title">
|
||||
<span>{{../expression}}</span>
|
||||
{{#if ../isCustom}}
|
||||
<small class="expression_list_custom">(custom)</small>
|
||||
{{/if}}
|
||||
</div>
|
||||
<img class="expression_list_image" src="{{imageSrc}}" />
|
||||
<div class="expression_list_image_container" title="{{this.title}}">
|
||||
<img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" />
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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}` : ''),
|
||||
};
|
||||
});
|
||||
|
Reference in New Issue
Block a user