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';
|
import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
|
||||||
export { MODULE_NAME };
|
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 MODULE_NAME = 'expressions';
|
||||||
const UPDATE_INTERVAL = 2000;
|
const UPDATE_INTERVAL = 2000;
|
||||||
const STREAMING_UPDATE_INTERVAL = 10000;
|
const STREAMING_UPDATE_INTERVAL = 10000;
|
||||||
@ -62,6 +78,9 @@ const EXPRESSION_API = {
|
|||||||
webllm: 3,
|
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 expressionsList = null;
|
||||||
let lastCharacter = undefined;
|
let lastCharacter = undefined;
|
||||||
let lastMessage = null;
|
let lastMessage = null;
|
||||||
@ -1309,8 +1328,35 @@ async function validateImages(character, forceRedrawCached) {
|
|||||||
spriteCache[character] = validExpressions;
|
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) {
|
async function drawSpritesList(character, labels, sprites) {
|
||||||
|
/** @type {Expression[]} */
|
||||||
let validExpressions = [];
|
let validExpressions = [];
|
||||||
|
|
||||||
$('#no_chat_expressions').hide();
|
$('#no_chat_expressions').hide();
|
||||||
$('#open_chat_expressions').show();
|
$('#open_chat_expressions').show();
|
||||||
$('#image_list').empty();
|
$('#image_list').empty();
|
||||||
@ -1321,33 +1367,45 @@ async function drawSpritesList(character, labels, sprites) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of labels.sort()) {
|
for (const expression of labels.sort()) {
|
||||||
const sprite = sprites.find(x => x.label == item);
|
const isCustom = extension_settings.expressions.custom?.includes(expression);
|
||||||
const isCustom = extension_settings.expressions.custom.includes(item);
|
const images = sprites
|
||||||
|
.filter(s => s.label === expression)
|
||||||
|
.map(getExpressionImageData)
|
||||||
|
.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
|
||||||
if (sprite) {
|
if (images.length === 0) {
|
||||||
validExpressions.push(sprite);
|
const listItem = await getListItem(expression, {
|
||||||
const listItem = await getListItem(item, sprite.path, 'success', isCustom);
|
isCustom,
|
||||||
$('#image_list').append(listItem);
|
images: [{ expression, isCustom, ...NO_IMAGE_PLACEHOLDER }],
|
||||||
}
|
});
|
||||||
else {
|
|
||||||
const listItem = await getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom);
|
|
||||||
$('#image_list').append(listItem);
|
$('#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;
|
return validExpressions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a list item template for the expressions list.
|
* Renders a list item template for the expressions list.
|
||||||
* @param {string} item Expression name
|
* @param {string} expression Expression name
|
||||||
* @param {string} imageSrc Path to image
|
* @param {object} args Arguments object
|
||||||
* @param {'success' | 'failure'} textClass 'success' or 'failure'
|
* @param {ExpressionImage[]} [args.images] Array of image objects
|
||||||
* @param {boolean} isCustom If expression is added by user
|
* @param {boolean} [args.isCustom=false] If expression is added by user
|
||||||
* @returns {Promise<string>} Rendered list item template
|
* @returns {Promise<string>} Rendered list item template
|
||||||
*/
|
*/
|
||||||
async function getListItem(item, imageSrc, textClass, isCustom) {
|
async function getListItem(expression, { images, isCustom = false } = {}) {
|
||||||
return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom });
|
return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSpritesList(name) {
|
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="expression_list_buttons">
|
||||||
<div class="menu_button expression_list_upload" title="Upload image">
|
<div class="menu_button expression_list_upload" title="Upload image">
|
||||||
<i class="fa-solid fa-upload"></i>
|
<i class="fa-solid fa-upload"></i>
|
||||||
@ -7,11 +8,14 @@
|
|||||||
<i class="fa-solid fa-trash"></i>
|
<i class="fa-solid fa-trash"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="expression_list_title {{textClass}}">
|
<div class="expression_list_title">
|
||||||
<span>{{item}}</span>
|
<span>{{../expression}}</span>
|
||||||
{{#if isCustom}}
|
{{#if ../isCustom}}
|
||||||
<small class="expression_list_custom">(custom)</small>
|
<small class="expression_list_custom">(custom)</small>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
@ -126,6 +126,9 @@ img.expression.default {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
.expression_list_custom {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
}
|
||||||
|
|
||||||
.expression_list_buttons {
|
.expression_list_buttons {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -162,11 +165,24 @@ img.expression.default {
|
|||||||
row-gap: 1rem;
|
row-gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#image_list .success {
|
#image_list .expression_list_item[data-expression-type="success"] .expression_list_title {
|
||||||
color: green;
|
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;
|
color: red;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,4 +204,3 @@ img.expression.default {
|
|||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) {
|
|||||||
.map((file) => {
|
.map((file) => {
|
||||||
const pathToSprite = path.join(spritesPath, file);
|
const pathToSprite = path.join(spritesPath, file);
|
||||||
const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14);
|
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 {
|
return {
|
||||||
label: path.parse(pathToSprite).name.toLowerCase(),
|
label: label,
|
||||||
path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
|
path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user