Merge pull request #3403 from SillyTavern/support-multiple-expressions

Support multiple expressions
This commit is contained in:
Cohee 2025-02-22 20:13:43 +02:00 committed by GitHub
commit fb06e7afa1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 993 additions and 861 deletions

8
public/global.d.ts vendored
View File

@ -40,4 +40,12 @@ declare global {
searchInputCssClass?: string;
}
}
/**
* Translates a text to a target language using a translation provider.
* @param text Text to translate
* @param lang Target language
* @param provider Translation provider
*/
async function translate(text: string, lang: string, provider: string = null): Promise<string>;
}

View File

@ -1602,7 +1602,6 @@
"Character Expressions": "Expressions de personnages",
"Translate text to English before classification": "Traduire le texte en anglais avant de le classer",
"Show default images (emojis) if sprite missing": "Afficher les images par défaut (emojis) si le sprite est manquant",
"Image Type - talkinghead (extras)": "Type d'image - talkinghead (extras)",
"Classifier API": "API de classification",
"Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.",
"Main API": "API principale",

View File

@ -1467,7 +1467,6 @@
"menu within": "내의 메뉴",
"Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.",
"Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.",
"Image Type - talkinghead (extras)": "이미지 유형 - 토킹 헤드 (부가 사항)",
"Classifier API": "분류를 위한 API",
"Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.",
"Local": "로컬",

View File

@ -1349,7 +1349,6 @@
"Character Expressions": "角色表情",
"Translate text to English before classification": "分类之前将文本翻译成英文",
"Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)",
"Image Type - talkinghead (extras)": "图像类型 - 说话头像(附加内容)",
"Classifier API": "分类器 API",
"Select the API for classifying expressions.": "选择用于对表达式进行分类的API。",
"Main API": "主要 API",

View File

@ -1653,7 +1653,6 @@
"HuggingFace Token": "HuggingFace 符元",
"Image Captioning": "圖片註解",
"Generate Caption": "產生圖片註解",
"Image Type - talkinghead (extras)": "圖片類型 - talkinghead額外選項",
"Injection Position": "插入位置",
"Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。",
"Injection Template": "插入範本",

View File

@ -154,8 +154,18 @@ export const extension_settings = {
refine_mode: false,
},
expressions: {
/** @type {number} see `EXPRESSION_API` */
api: undefined,
/** @type {string[]} */
custom: [],
showDefault: false,
translate: false,
/** @type {string} */
fallback_expression: undefined,
/** @type {string} */
llmPrompt: undefined,
allowMultiple: true,
rerollIfSame: false,
},
connectionManager: {
selectedProfile: '',

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
<div id="{{item}}" class="expression_list_item">
{{#each images}}
<div class="expression_list_item interactable" data-expression="{{../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}}

View File

@ -6,17 +6,17 @@
</div>
<div class="inline-drawer-content">
<label class="checkbox_label" for="expression_translate" title="Use the selected API from Chat Translation extension settings.">
<label class="checkbox_label" for="expression_translate" title="Use the selected API from Chat Translation extension settings." data-i18n="[title]Use the selected API from Chat Translation extension settings.">
<input id="expression_translate" type="checkbox">
<span data-i18n="Translate text to English before classification">Translate text to English before classification</span>
</label>
<label class="checkbox_label" for="expressions_show_default">
<input id="expressions_show_default" type="checkbox">
<span data-i18n="Show default images (emojis) if sprite missing">Show default images (emojis) if sprite missing</span>
<label class="checkbox_label" for="expressions_allow_multiple" title="A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected." data-i18n="[title]A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected.">
<input id="expressions_allow_multiple" type="checkbox">
<span data-i18n="Allow multiple sprites per expression">Allow multiple sprites per expression</span>
</label>
<label id="image_type_block" class="checkbox_label" for="image_type_toggle">
<input id="image_type_toggle" type="checkbox">
<span data-i18n="Image Type - talkinghead (extras)">Image Type - talkinghead (extras)</span>
<label class="checkbox_label" for="expressions_reroll_if_same" title="If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned." data-i18n="[title]If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned.">
<input id="expressions_reroll_if_same" type="checkbox">
<span data-i18n="Re-roll if same expression is used again">Re-roll if same sprite is used again</span>
</label>
<div class="expression_api_block m-b-1 m-t-1">
<label for="expression_api" data-i18n="Classifier API">Classifier API</label>
@ -75,8 +75,20 @@
<span data-i18n="Remove all image overrides">Remove all image overrides</span>
</div>
</div>
<p class="hint"><b data-i18n="Hint:">Hint:</b> <i><span data-i18n="Create new folder in the _space">Create new folder in the </span><b>/characters/</b> <span data-i18n="folder of your user data directory and name it as the name of the character.">folder of your user data directory and name it as the name of the character.</span>
<span data-i18n="Put images with expressions there. File names should follow the pattern:">Put images with expressions there. File names should follow the pattern: </span><tt data-i18n="expression_label_pattern">[expression_label].[image_format]</tt></i></p>
<p class="hint">
<b data-i18n="Hint:">Hint:</b>
<i>
<span data-i18n="Create new folder in the _space">Create new folder in the </span><b>/characters/</b> <span data-i18n="folder of your user data directory and name it as the name of the character.">folder of your user data directory and name it as the name of the character.</span>
<span data-i18n="Put images with expressions there. File names should follow the pattern:">Put images with expressions there. File names should follow the pattern: </span><tt data-i18n="expression_label_pattern">[expression_label].[image_format]</tt>
</i>
</p>
<p>
<i>
<span>In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a
dash.
Examples: </span><tt>joy.png</tt>, <tt>joy-1.png</tt>, <tt>joy.expressive.png</tt>
</i>
</p>
<h3 id="image_list_header">
<strong data-i18n="Sprite set:">Sprite set:</strong>&nbsp;<span id="image_list_header_name"></span>
</h3>

View File

@ -111,6 +111,10 @@ img.expression.default {
justify-content: center;
}
.expression_list_image_container {
overflow: hidden;
}
.expression_list_title {
position: absolute;
bottom: 0;
@ -126,6 +130,9 @@ img.expression.default {
flex-direction: column;
line-height: 1;
}
.expression_list_custom {
font-size: 0.66rem;
}
.expression_list_buttons {
position: absolute;
@ -162,11 +169,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;
}
@ -189,3 +209,12 @@ 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)) label[for="expressions_reroll_if_same"] {
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;
}

View File

@ -0,0 +1,12 @@
<div class="m-b-1" data-i18n="upload_expression_request">Please enter a name for the sprite (without extension).</div>
<div class="m-b-1" data-i18n="upload_expression_naming_1">
Sprite names must follow the naming schema for the selected expression: {{expression}}
</div>
<div data-i18n="upload_expression_naming_2">
For multiple expressions, the name must follow the expression name and a valid suffix. Allowed separators are '-' or dot '.'.
</div>
<span class="m-b-1" data-i18n="Examples:">Examples:</span> <tt>{{expression}}.png</tt>, <tt>{{expression}}-1.png</tt>, <tt>{{expression}}.expressive.png</tt>
{{#if clickedFileName}}
<div class="m-t-1" data-i18n="upload_expression_replace">Click 'Replace' to replace the existing expression:</div>
<tt>{{clickedFileName}}</tt>
{{/if}}

View File

@ -605,7 +605,7 @@ const handleOutgoingMessage = createEventHandler(translateOutgoingMessage, () =>
const handleImpersonateReady = createEventHandler(translateImpersonate, () => shouldTranslate(incomingTypes));
const handleMessageEdit = createEventHandler(translateMessageEdit, () => true);
window['translate'] = translate;
globalThis.translate = translate;
jQuery(async () => {
const html = await renderExtensionTemplateAsync('translate', 'index');

View File

@ -27,14 +27,12 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm
import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
import { GoogleTranslateTtsProvider } from './google-translate.js';
export { talkingAnimation };
const UPDATE_INTERVAL = 1000;
const wrapper = new ModuleWorkerWrapper(moduleWorker);
let voiceMapEntries = [];
let voiceMap = {}; // {charName:voiceid, charName2:voiceid2}
let talkingHeadState = false;
let lastChatId = null;
let lastMessage = null;
let lastMessageHash = null;
@ -166,27 +164,6 @@ async function moduleWorker() {
updateUiAudioPlayState();
}
function talkingAnimation(switchValue) {
if (!modules.includes('talkinghead')) {
console.debug('Talking Animation module not loaded');
return;
}
const apiUrl = getApiUrl();
const animationType = switchValue ? 'start' : 'stop';
if (switchValue !== talkingHeadState) {
try {
console.log(animationType + ' Talking Animation');
doExtrasFetch(`${apiUrl}/api/talkinghead/${animationType}_talking`);
talkingHeadState = switchValue;
} catch (error) {
// Handle the error here or simply ignore it to prevent logging
}
}
updateUiAudioPlayState();
}
function resetTtsPlayback() {
// Stop system TTS utterance
cancelTtsPlay();
@ -378,7 +355,6 @@ function onAudioControlClicked() {
// Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful
if (!audioElement.paused || isTtsProcessing()) {
resetTtsPlayback();
talkingAnimation(false);
} else {
// Default play behavior if not processing or playing is to play the last message.
processAndQueueTtsMessage(context.chat[context.chat.length - 1]);
@ -405,7 +381,6 @@ function addAudioControl() {
function completeCurrentAudioJob() {
audioQueueProcessorReady = true;
currentAudioJob = null;
talkingAnimation(false); //stop lip animation
// updateUiPlayState();
wrapper.update();
}
@ -436,7 +411,6 @@ async function processAudioJobQueue() {
audioQueueProcessorReady = false;
currentAudioJob = audioJobQueue.shift();
playAudioData(currentAudioJob);
talkingAnimation(true);
} catch (error) {
toastr.error(error.toString());
console.error(error);

View File

@ -1,6 +1,5 @@
import { isMobile } from '../../RossAscends-mods.js';
import { getPreviewString } from './index.js';
import { talkingAnimation } from './index.js';
import { saveTtsProviderSettings } from './index.js';
export { SystemTtsProvider };
@ -70,7 +69,6 @@ var speechUtteranceChunker = function (utt, settings, callback) {
//placing the speak invocation inside a callback fixes ordering and onend issues.
setTimeout(function () {
speechSynthesis.speak(newUtt);
talkingAnimation(true);
}, 0);
};
@ -240,7 +238,6 @@ class SystemTtsProvider {
//some code to execute when done
resolve(silence);
console.log('System TTS done');
talkingAnimation(false);
});
});
}

View File

@ -561,9 +561,9 @@ async function retrieveFileChunks(queryText, collectionId) {
*/
async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overlapPercent) {
try {
if (settings.translate_files && typeof window['translate'] === 'function') {
if (settings.translate_files && typeof globalThis.translate === 'function') {
console.log(`Vectors: Translating file ${fileName} to English...`);
const translatedText = await window['translate'](fileText, 'en');
const translatedText = await globalThis.translate(fileText, 'en');
fileText = translatedText;
}

View File

@ -1845,14 +1845,15 @@ async function loadContextSettings() {
/**
* Common function to perform fuzzy search with optional caching
* @template T
* @param {string} type - Type of search from fuzzySearchCategories
* @param {any[]} data - Data array to search in
* @param {Array<{name: string, weight: number, getFn?: (obj: any) => string}>} keys - Fuse.js keys configuration
* @param {T[]} data - Data array to search in
* @param {Array<{name: string, weight: number, getFn?: (obj: T) => string}>} keys - Fuse.js keys configuration
* @param {string} searchValue - The search term
* @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
* @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
* @returns {import('fuse.js').FuseResult<T>[]} Results as items with their score
*/
function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {
export function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {
// Check cache if provided
if (fuzzySearchCaches) {
const cache = fuzzySearchCaches[type];

View File

@ -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
const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName;
return {
label: path.parse(pathToSprite).name.toLowerCase(),
label: label,
path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
};
});
@ -141,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);
}
@ -158,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));
}
}
@ -221,6 +228,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);
@ -243,12 +251,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