Merge branch 'staging' into immutable-config

a73dfa75867d0ea32cc304f495024bac6f262a05

Cohee <18619528+Cohee1207@users.noreply.github.com>

35 files changed, +1163 -885Showing whitespace changes
default/!DO-NOT-EDIT-THESE-FILES.txt+13 -0
@@ -0,0 +1,13 @@
1+These are master copies of the default content files and are managed by SillyTavern.
2+
3+Editing any of these files would not only have no effect, but will also cause merge conflicts during update pulls.
4+
5+You should edit their respective copies instead, for example:
6+
7+1. /default/config.yaml => /config.yaml
8+2. /default/public/css/user.css => /public/css/user.css
9+etc.
10+
11+Any questions? You're always welcome at our official documentation website:
12+
13+https://docs.sillytavern.app/
default/config.yaml+7 -2
@@ -71,8 +71,6 @@ autheliaAuth: false
7171# the username and passwords for basic auth are the same as those
7272# for the individual accounts
7373perUserBasicAuth: false
74-# Minimum log level to display in the terminal (DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3)
75-minLogLevel: 0
7674
7775# User session timeout *in seconds* (defaults to 24 hours).
7876## Set to a positive number to expire session after a certain time of inactivity
@@ -83,6 +81,13 @@ sessionTimeout: -1
8381disableCsrfProtection: false
8482# Disable startup security checks - NOT RECOMMENDED
8583securityOverride: false
84+# -- LOGGING CONFIGURATION --
85+logging:
86+ # Enable access logging to access.log file
87+ # Records new connections with timestamp, IP address and user agent
88+ enableAccessLog: true
89+ # Minimum log level to display in the terminal (DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3)
90+ minLogLevel: 0
8691# -- RATE LIMITING CONFIGURATION --
8792rateLimiting:
8893 # Use X-Real-IP header instead of socket IP for rate limiting
plugins.js+2 -2
@@ -8,7 +8,7 @@ import path from 'node:path';
88import process from 'node:process';
99import { fileURLToPath } from 'node:url';
1010
1111import { default as git, CheckRepoActions } from 'simple-git';
1212import { color } from './src/util.js';
1313
1414const __dirname = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url));
@@ -49,7 +49,7 @@ async function updatePlugins() {
4949 const pluginPath = path.join(pluginsPath, directory);
5050 const pluginRepo = git(pluginPath);
5151
5252 const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
5353 if (!isRepo) {
5454 console.log(`Directory ${color.yellow(directory)} is not a Git repository`);
5555 continue;
post-install.js+5 -0
@@ -104,6 +104,11 @@ const keyMigrationMap = [
104104 newKey: 'extensions.models.textToSpeech',
105105 migrate: (value) => value,
106106 },
107+ {
108+ oldKey: 'minLogLevel',
109+ newKey: 'logging.minLogLevel',
110+ migrate: (value) => value,
111+ },
107112 // uncommend one release after 1.12.13
108113 /*
109114 {
public/global.d.ts+8 -0
@@ -40,4 +40,12 @@ declare global {
4040 searchInputCssClass?: string;
4141 }
4242 }
43+
44+ /**
45+ * Translates a text to a target language using a translation provider.
46+ * @param text Text to translate
47+ * @param lang Target language
48+ * @param provider Translation provider
49+ */
50+ async function translate(text: string, lang: string, provider: string = null): Promise<string>;
4351}
public/index.html+27 -11
@@ -2000,7 +2000,7 @@
20002000 </span>
20012001 </div>
20022002 </div>
20032003 <div class="range-block" data-source="deepseek,openrouter,custom">
20042004 <label for="openai_show_thoughts" class="checkbox_label widthFreeExpand">
20052005 <input id="openai_show_thoughts" type="checkbox" />
20062006 <span>
@@ -3186,21 +3186,33 @@
31863186 </div>
31873187 <h4 data-i18n="Groq Model">Groq Model</h4>
31883188 <select id="model_groq_select">
31893189 <optgroup label="ProductionAlibaba ModelsCloud">
31903190 <option value="gemma2qwen-9b2.5-it32b">gemma2qwen-9b2.5-it32b</option>
31913191 <option value="llamaqwen-32.35-70bcoder-versatile32b">llamaqwen-32.35-70bcoder-versatile32b</option>
3192- <option value="llama-3.1-8b-instant">llama-3.1-8b-instant</option>
3193- <option value="llama3-70b-8192">llama3-70b-8192</option>
3194- <option value="llama3-8b-8192">llama3-8b-8192</option>
3195- <option value="mixtral-8x7b-32768">mixtral-8x7b-32768</option>
31963192 </optgroup>
31973193 <optgroup label="PreviewDeepSeek Models/ Alibaba Cloud">
3194+ <option value="deepseek-r1-distill-qwen-32b">deepseek-r1-distill-qwen-32b</option>
3195+ </optgroup>
3196+ <optgroup label="DeepSeek / Meta">
31983197 <option value="deepseek-r1-distill-llama-70b">deepseek-r1-distill-llama-70b</option>
3199- <option value="llama-3.3-70b-specdec">llama-3.3-70b-specdec</option>
3198+ </optgroup>
3199+ <optgroup label="Google">
3200+ <option value="gemma2-9b-it">gemma2-9b-it</option>
3201+ </optgroup>
3202+ <optgroup label="Meta">
3203+ <option value="llama-3.1-8b-instant">llama-3.1-8b-instant </option>
3204+ <option value="llama-3.2-11b-vision-preview">llama-3.2-11b-vision-preview </option>
32003205 <option value="llama-3.2-1b-preview">llama-3.2-1b-preview </option>
32013206 <option value="llama-3.2-3b-preview">llama-3.2-3b-preview </option>
3202- <option value="llama-3.2-11b-vision-preview">llama-3.2-11b-vision-preview</option>
32033207 <option value="llama-3.2-90b-vision-preview">llama-3.2-90b-vision-preview </option>
3208+ <option value="llama-3.3-70b-specdec">llama-3.3-70b-specdec </option>
3209+ <option value="llama-3.3-70b-versatile">llama-3.3-70b-versatile </option>
3210+ <option value="llama-guard-3-8b">llama-guard-3-8b </option>
3211+ <option value="llama3-70b-8192">llama3-70b-8192 </option>
3212+ <option value="llama3-8b-8192">llama3-8b-8192 </option>
3213+ </optgroup>
3214+ <optgroup label="Mistral AI">
3215+ <option value="mixtral-8x7b-32768">mixtral-8x7b-32768</option>
32043216 </optgroup>
32053217 </select>
32063218 </div>
@@ -3253,6 +3265,10 @@
32533265 <option value="sonar">sonar</option>
32543266 <option value="sonar-pro">sonar-pro</option>
32553267 <option value="sonar-reasoning">sonar-reasoning</option>
3268+ <option value="sonar-reasoning-pro">sonar-reasoning-pro</option>
3269+ </optgroup>
3270+ <optgroup label="Offline Models">
3271+ <option value="r1-1776">r1-1776</option>
32563272 </optgroup>
32573273 <optgroup label="Deprecated Models">
32583274 <!-- These are scheduled for deprecation after 2/22/2025 -->
public/lib/eventemitter.js+42 -1
@@ -24,10 +24,22 @@ if (typeof Array.prototype.indexOf === 'function') {
2424
2525
2626/* Polyfill EventEmitter. */
27-var EventEmitter = function () {
27+/**
28+ * Creates an event emitter.
29+ * @param {string[]} autoFireAfterEmit Auto-fire event names
30+ */
31+var EventEmitter = function (autoFireAfterEmit = []) {
2832 this.events = {};
33+ this.autoFireLastArgs = new Map();
34+ this.autoFireAfterEmit = new Set(autoFireAfterEmit);
2935};
3036
37+/**
38+ * Adds a listener to an event.
39+ * @param {string} event Event name
40+ * @param {function} listener Event listener
41+ * @returns
42+ */
3143EventEmitter.prototype.on = function (event, listener) {
3244 // Unknown event used by external libraries?
3345 if (event === undefined) {
@@ -40,6 +52,10 @@ EventEmitter.prototype.on = function (event, listener) {
4052 }
4153
4254 this.events[event].push(listener);
55+
56+ if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) {
57+ listener.apply(this, this.autoFireLastArgs.get(event));
58+ }
4359};
4460
4561/**
@@ -60,6 +76,10 @@ EventEmitter.prototype.makeLast = function (event, listener) {
6076 }
6177
6278 events.push(listener);
79+
80+ if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) {
81+ listener.apply(this, this.autoFireLastArgs.get(event));
82+ }
6383}
6484
6585/**
@@ -80,8 +100,17 @@ EventEmitter.prototype.makeFirst = function (event, listener) {
80100 }
81101
82102 events.unshift(listener);
103+
104+ if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) {
105+ listener.apply(this, this.autoFireLastArgs.get(event));
106+ }
83107}
84108
109+/**
110+ * Removes a listener from an event.
111+ * @param {string} event Event name
112+ * @param {function} listener Event listener
113+ */
85114EventEmitter.prototype.removeListener = function (event, listener) {
86115 var idx;
87116
@@ -94,6 +123,10 @@ EventEmitter.prototype.removeListener = function (event, listener) {
94123 }
95124};
96125
126+/**
127+ * Emits an event with optional arguments.
128+ * @param {string} event Event name
129+ */
97130EventEmitter.prototype.emit = async function (event) {
98131 let args = [].slice.call(arguments, 1);
99132 if (localStorage.getItem('eventTracing') === 'true') {
@@ -118,6 +151,10 @@ EventEmitter.prototype.emit = async function (event) {
118151 }
119152 }
120153 }
154+
155+ if (this.autoFireAfterEmit.has(event)) {
156+ this.autoFireLastArgs.set(event, args);
157+ }
121158};
122159
123160EventEmitter.prototype.emitAndWait = function (event) {
@@ -144,6 +181,10 @@ EventEmitter.prototype.emitAndWait = function (event) {
144181 }
145182 }
146183 }
184+
185+ if (this.autoFireAfterEmit.has(event)) {
186+ this.autoFireLastArgs.set(event, args);
187+ }
147188};
148189
149190EventEmitter.prototype.once = function (event, listener) {
public/locales/fr-fr.json+0 -1
@@ -1602,7 +1602,6 @@
16021602 "Character Expressions": "Expressions de personnages",
16031603 "Translate text to English before classification": "Traduire le texte en anglais avant de le classer",
16041604 "Show default images (emojis) if sprite missing": "Afficher les images par défaut (emojis) si le sprite est manquant",
1605- "Image Type - talkinghead (extras)": "Type d'image - talkinghead (extras)",
16061605 "Classifier API": "API de classification",
16071606 "Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.",
16081607 "Main API": "API principale",
public/locales/ko-kr.json+0 -1
@@ -1467,7 +1467,6 @@
14671467 "menu within": "내의 메뉴",
14681468 "Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.",
14691469 "Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.",
1470- "Image Type - talkinghead (extras)": "이미지 유형 - 토킹 헤드 (부가 사항)",
14711470 "Classifier API": "분류를 위한 API",
14721471 "Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.",
14731472 "Local": "로컬",
public/locales/zh-cn.json+0 -1
@@ -1349,7 +1349,6 @@
13491349 "Character Expressions": "角色表情",
13501350 "Translate text to English before classification": "分类之前将文本翻译成英文",
13511351 "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)",
1352- "Image Type - talkinghead (extras)": "图像类型 - 说话头像(附加内容)",
13531352 "Classifier API": "分类器 API",
13541353 "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。",
13551354 "Main API": "主要 API",
public/locales/zh-tw.json+0 -1
@@ -1653,7 +1653,6 @@
16531653 "HuggingFace Token": "HuggingFace 符元",
16541654 "Image Captioning": "圖片註解",
16551655 "Generate Caption": "產生圖片註解",
1656- "Image Type - talkinghead (extras)": "圖片類型 - talkinghead(額外選項)",
16571656 "Injection Position": "插入位置",
16581657 "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。",
16591658 "Injection Template": "插入範本",
public/script.js+1 -1
@@ -512,7 +512,7 @@ export const event_types = {
512512 TOOL_CALLS_RENDERED: 'tool_calls_rendered',
513513};
514514
515515export const eventSource = new EventEmitter([event_types.APP_READY]);
516516
517517eventSource.on(event_types.CHAT_CHANGED, processChatSlashCommands);
518518
public/scripts/chats.js+1 -1
@@ -1487,7 +1487,7 @@ jQuery(function () {
14871487 ...chat.filter(x => x?.extra?.type !== system_message_types.ASSISTANT_NOTE),
14881488 ];
14891489
14901490 download(JSONchatToSave.stringifymap(chatToSave,(m) null,=> 4JSON.stringify(m)).join('\n'), `Assistant - ${humanizedDateTime()}.jsonjsonl`, 'application/json');
14911491 });
14921492
14931493 // Do not change. #attachFile is added by extension.
public/scripts/extensions.js+10 -0
@@ -154,8 +154,18 @@ export const extension_settings = {
154154 refine_mode: false,
155155 },
156156 expressions: {
157+ /** @type {number} see `EXPRESSION_API` */
158+ api: undefined,
157159 /** @type {string[]} */
158160 custom: [],
161+ showDefault: false,
162+ translate: false,
163+ /** @type {string} */
164+ fallback_expression: undefined,
165+ /** @type {string} */
166+ llmPrompt: undefined,
167+ allowMultiple: true,
168+ rerollIfSame: false,
159169 },
160170 connectionManager: {
161171 selectedProfile: '',
public/scripts/extensions/expressions/index.js+788 -707
@@ -1,11 +1,11 @@
11import { Fuse } from '../../../lib.js';
22
33import { callPopupcharacters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js';
44import { dragElement, isMobile } from '../../RossAscends-mods.js';
55import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
66import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js';
77import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js';
88import { hideMutedSprites, selected_group } from '../../group-chats.js';
99import { isJsonSchemaSupported } from '../../textgen-settings.js';
1010import { debounce_timeout } from '../../constants.js';
1111import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
@@ -15,16 +15,32 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm
1515import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
1616import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';
1717import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
18+import { Popup, POPUP_RESULT } from '../../popup.js';
19+import { t } from '../../i18n.js';
1820export { MODULE_NAME };
1921
22+/**
23+* @typedef {object} Expression Expression definition with label and file path
24+* @property {string} label The label of the expression
25+* @property {ExpressionImage[]} files One or more images to represent this expression
26+*/
27+
28+/**
29+ * @typedef {object} ExpressionImage An expression image
30+ * @property {string} expression - The expression
31+ * @property {boolean} [isCustom=false] - If the expression is added by user
32+ * @property {string} fileName - The filename with extension
33+ * @property {string} title - The title for the image
34+ * @property {string} imageSrc - The image source / full path
35+ * @property {'success' | 'additional' | 'failure'} type - The type of the image
36+ */
37+
2038const MODULE_NAME = 'expressions';
2139const UPDATE_INTERVAL = 2000;
2240const STREAMING_UPDATE_INTERVAL = 10000;
23-const TALKINGCHECK_UPDATE_INTERVAL = 500;
2441const DEFAULT_FALLBACK_EXPRESSION = 'joy';
2542const DEFAULT_LLM_PROMPT = 'Ignore previous instructions. Classify the emotion of the last message. Output just one word, e.g. "joy" or "anger". Choose only one of the following labels: {{labels}}';
2643const DEFAULT_EXPRESSIONS = [
27- 'talkinghead',
2844 'admiration',
2945 'amusement',
3046 'anger',
@@ -54,6 +70,12 @@ const DEFAULT_EXPRESSIONS = [
5470 'surprise',
5571 'neutral',
5672];
73+
74+const OPTION_NO_FALLBACK = '#none';
75+const OPTION_EMOJI_FALLBACK = '#emoji';
76+const RESET_SPRITE_LABEL = '#reset';
77+
78+
5779/** @enum {number} */
5880const EXPRESSION_API = {
5981 local: 0,
@@ -65,35 +87,29 @@ const EXPRESSION_API = {
6587let expressionsList = null;
6688let lastCharacter = undefined;
6789let lastMessage = null;
68-let lastTalkingState = false;
90+/** @type {{[characterKey: string]: Expression[]}} */
69-let lastTalkingStateMessage = null; // last message as seen by `updateTalkingState` (tracked separately, different timer)
7091let spriteCache = {};
7192let inApiCall = false;
7293let lastServerResponseTime = 0;
73-export let lastExpression = {};
74-
75-function isTalkingHeadEnabled() {
76- return extension_settings.expressions.talkinghead && extension_settings.expressions.api == EXPRESSION_API.extras;
77-}
7894
79-/**
95+/** @type {{[characterName: string]: string}} */
80- * Returns the fallback expression if explicitly chosen, otherwise the default one
96+export let lastExpression = {};
81- * @returns {string} expression name
82- */
83-function getFallbackExpression() {
84- return extension_settings.expressions.fallback_expression ?? DEFAULT_FALLBACK_EXPRESSION;
85-}
8697
8798/**
88- * Toggles Talkinghead mode on/off.
99+ * Returns a placeholder image object for a given expression
89- *
100+ * @param {string} expression - The expression label
90- * Implements the `/th` slash command, which is meant to be bound to a Quick Reply button
101+ * @param {boolean} [isCustom=false] - Whether the expression is custom
91- * as a quick way to switch Talkinghead on or off (e.g. to conserve GPU resources when AFK
102+ * @returns {ExpressionImage} The placeholder image object
92- * for a long time).
93103 */
94104function toggleTalkingHeadCommandgetPlaceholderImage(_expression, isCustom = false) {
95- setTalkingHeadState(!extension_settings.expressions.talkinghead);
105+ return {
96- return String(extension_settings.expressions.talkinghead);
106+ expression: expression,
107+ isCustom: isCustom,
108+ title: 'No Image',
109+ type: 'failure',
110+ fileName: 'No-Image-Placeholder.svg',
111+ imageSrc: '/img/No-Image-Placeholder.svg',
112+ };
97113}
98114
99115function isVisualNovelMode() {
@@ -108,21 +124,21 @@ async function forceUpdateVisualNovelMode() {
108124
109125const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick);
110126
111127async function updateVisualNovelMode(namespriteFolderName, expression) {
112128 const containervnContainer = $('#visual-novel-wrapper');
113129
114130 await visualNovelRemoveInactive(containervnContainer);
115131
116132 const setSpritePromises = await visualNovelSetCharacterSprites(containervnContainer, namespriteFolderName, expression);
117133
118134 // calculate layer indices based on recent messages
119135 await visualNovelUpdateLayers(containervnContainer);
120136
121137 await Promise.allSettled(setSpritePromises);
122138
123139 // update again based on new sprites
124140 if (setSpritePromises.length > 0) {
125141 await visualNovelUpdateLayers(containervnContainer);
126142 }
127143}
128144
@@ -153,52 +169,60 @@ async function visualNovelRemoveInactive(container) {
153169 await Promise.allSettled(removeInactiveCharactersPromises);
154170}
155171
156-async function visualNovelSetCharacterSprites(container, name, expression) {
172+/**
173+ * Sets the character sprites for visual novel mode based on the provided container, name, and expression.
174+ *
175+ * @param {JQuery<HTMLElement>} vnContainer - The container element where the sprites will be set
176+ * @param {string} spriteFolderName - The name of the sprite folder
177+ * @param {string} expression - The expression to set for the characters
178+ * @returns {Promise<Array>} - An array of promises that resolve when the sprites are set
179+ */
180+async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression) {
181+ const originalExpression = expression;
157182 const context = getContext();
158183 const group = context.groups.find(x => x.id == context.groupId);
159- const labels = await getExpressionsList();
160184
161- const createCharacterPromises = [];
162185 const setSpritePromises = [];
163186
164187 for (const avatar of group.members) {
165- const isDisabled = group.disabled_members.includes(avatar);
166-
167188 // skip disabled characters
189+ const isDisabled = group.disabled_members.includes(avatar);
168190 if (isDisabled && hideMutedSprites) {
169191 continue;
170192 }
171193
172194 const character = context.characters.find(x => x.avatar == avatar);
173-
174195 if (!character) {
175196 continue;
176197 }
177198
178- const spriteFolderName = getSpriteFolderName({ original_avatar: character.avatar }, character.name);
199+ const expressionImage = vnContainer.find(`.expression-holder[data-avatar="${avatar}"]`);
200+ /** @type {JQuery<HTMLElement>} */
201+ let img;
202+
203+ const memberSpriteFolderName = getSpriteFolderName({ original_avatar: character.avatar }, character.name);
179204
180205 // download images if not downloaded yet
181206 if (spriteCache[spriteFolderNamememberSpriteFolderName] === undefined) {
182207 spriteCache[spriteFolderNamememberSpriteFolderName] = await getSpritesList(spriteFolderNamememberSpriteFolderName);
183208 }
184209
185- const sprites = spriteCache[spriteFolderName];
210+ const prevExpressionSrc = expressionImage.find('img').attr('src') || null;
186- const expressionImage = container.find(`.expression-holder[data-avatar="${avatar}"]`);
187- const defaultExpression = getFallbackExpression();
188- const defaultSpritePath = sprites.find(x => x.label === defaultExpression)?.path;
189- const noSprites = sprites.length === 0;
190211
191212 if (expressionImage!originalExpression && Array.isArray(spriteCache[memberSpriteFolderName]) && spriteCache[memberSpriteFolderName].length > 0) {
192- if (name == spriteFolderName) {
213+ expression = await getLastMessageSprite(avatar);
193- await validateImages(spriteFolderName, true);
214+ }
194- setExpressionOverrideHtml(true); // <= force clear expression override input
195- const currentSpritePath = labels.includes(expression) ? sprites.find(x => x.label === expression)?.path : '';
196215
197- const path = currentSpritePath || defaultSpritePath || '';
216+ const spriteFile = chooseSpriteForExpression(memberSpriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc });
198- const img = expressionImage.find('img');
217+ if (expressionImage.length) {
218+ if (!spriteFolderName || spriteFolderName == memberSpriteFolderName) {
219+ await validateImages(memberSpriteFolderName, true);
220+ setExpressionOverrideHtml(true); // <= force clear expression override input
221+ const path = spriteFile?.imageSrc || '';
222+ img = expressionImage.find('img');
199223 await setImage(img, path);
200224 }
201225 expressionImage.toggleClass('hidden', noSprites!spriteFile);
202226 } else {
203227 const template = $('#expression-holder').clone();
204228 template.attr('id', `expression-${avatar}`);
@@ -206,21 +230,49 @@ async function visualNovelSetCharacterSprites(container, name, expression) {
206230 template.find('.drag-grabber').attr('id', `expression-${avatar}header`);
207231 $('#visual-novel-wrapper').append(template);
208232 dragElement($(template[0]));
209233 template.toggleClass('hidden', noSprites!spriteFile);
210234 awaitimg setImage(= template.find('img'), defaultSpritePath || '');
235+ await setImage(img, spriteFile?.imageSrc || '');
211236 const fadeInPromise = new Promise(resolve => {
212237 template.fadeIn(250, () => resolve());
213238 });
214239 createCharacterPromisessetSpritePromises.push(fadeInPromise);
215- const setSpritePromise = setLastMessageSprite(template.find('img'), avatar, labels);
216- setSpritePromises.push(setSpritePromise);
217240 }
241+
242+ if (!img) {
243+ continue;
244+ }
245+
246+ img.attr('data-sprite-folder-name', spriteFolderName);
247+ img.attr('data-expression', expression);
248+ img.attr('data-sprite-filename', spriteFile?.fileName || null);
249+ img.attr('title', expression);
250+
251+ if (spriteFile) console.info(`Expression set for group member ${character.name}`, { expression: spriteFile.expression, file: spriteFile.fileName });
252+ else if (expressionImage.length) console.info(`Expression unset for group member ${character.name} - No sprite found`, { expression: expression });
253+ else console.info(`Expression not available for group member ${character.name}`, { expression: expression });
218254 }
219255
220- await Promise.allSettled(createCharacterPromises);
221256 return setSpritePromises;
222257}
223258
259+/**
260+ * Classifies the text of the latest message and returns the expression label.
261+ * @param {string} avatar - The avatar of the character to get the last message for
262+ * @returns {Promise<string>} - The expression label
263+ */
264+async function getLastMessageSprite(avatar) {
265+ const context = getContext();
266+ const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar))));
267+
268+ if (lastMessage) {
269+ const text = lastMessage.mes || '';
270+ return await getExpressionLabel(text);
271+ }
272+
273+ return null;
274+}
275+
224276async function visualNovelUpdateLayers(container) {
225277 const context = getContext();
226278 const group = context.groups.find(x => x.id == context.groupId);
@@ -256,11 +308,17 @@ async function visualNovelUpdateLayers(container) {
256308 const containerWidth = container.width();
257309 const pivotalPoint = containerWidth * 0.5;
258310
259311 let images = Array.from($('#visual-novel-wrapper .expression-holder')).sort(sortFunction);
260312 let imagesWidth = [];
261313
262- images.sort(sortFunction).each(function () {
314+ for (const image of images) {
263- imagesWidth.push($(this).width());
315+ if (image instanceof HTMLImageElement && !image.complete) {
316+ await new Promise(resolve => image.addEventListener('load', resolve, { once: true }));
317+ }
318+ }
319+
320+ images.forEach(image => {
321+ imagesWidth.push($(image).width());
264322 });
265323
266324 let totalWidth = imagesWidth.reduce((a, b) => a + b, 0);
@@ -274,7 +332,7 @@ async function visualNovelUpdateLayers(container) {
274332 currentPosition = 0; // Reset the initial position to 0
275333 }
276334
277335 images.sort(sortFunction).eachforEach((indexcurrent, currentindex) => {
278336 const element = $(current);
279337 const elementID = element.attr('id');
280338
@@ -294,9 +352,15 @@ async function visualNovelUpdateLayers(container) {
294352 element.show();
295353
296354 const promise = new Promise(resolve => {
355+ if (power_user.reduced_motion) {
356+ element.css('left', currentPosition + 'px');
357+ requestAnimationFrame(() => resolve());
358+ }
359+ else {
297360 element.animate({ left: currentPosition + 'px' }, 500, () => {
298361 resolve();
299362 });
363+ }
300364 });
301365
302366 currentPosition += imagesWidth[index];
@@ -307,23 +371,12 @@ async function visualNovelUpdateLayers(container) {
307371 await Promise.allSettled(setLayerIndicesPromises);
308372}
309373
310-async function setLastMessageSprite(img, avatar, labels) {
374+/**
311- const context = getContext();
375+ * Sets the expression for the given character image.
312- const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar))));
376+ * @param {JQuery<HTMLElement>} img - The image element to set the image on
313-
377+ * @param {string} path - The path to the image
314- if (lastMessage) {
378+ * @returns {Promise<void>} - A promise that resolves when the image is set
315- const text = lastMessage.mes || '';
379+ */
316- const spriteFolderName = getSpriteFolderName(lastMessage, lastMessage.name);
317- const sprites = spriteCache[spriteFolderName] || [];
318- const label = await getExpressionLabel(text);
319- const path = labels.includes(label) ? sprites.find(x => x.label === label)?.path : '';
320-
321- if (path) {
322- setImage(img, path);
323- }
324- }
325-}
326-
327380async function setImage(img, path) {
328381 // Cohee: If something goes wrong, uncomment this to return to the old behavior
329382 /*
@@ -340,7 +393,7 @@ async function setImage(img, path) {
340393 return new Promise(resolve => {
341394 const prevExpressionSrc = img.attr('src');
342395 const expressionClone = img.clone();
343396 const originalId = img.attrdata('idfilename');
344397
345398 //only swap expressions when necessary
346399 if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) {
@@ -348,7 +401,7 @@ async function setImage(img, path) {
348401 expressionClone.addClass('expression-clone');
349402 //make invisible and remove id to prevent double ids
350403 //must be made invisible to start because they share the same Z-index
351404 expressionClone.attrdata('idfilename', '').css({ opacity: 0 });
352405 //add new sprite path to clone src
353406 expressionClone.attr('src', path);
354407 //add invisible clone to html
@@ -384,14 +437,18 @@ async function setImage(img, path) {
384437 //remove old expression
385438 img.remove();
386439 //replace ID so it becomes the new 'original' expression for next change
387440 expressionClone.attrdata('idfilename', originalId);
388441 expressionClone.removeClass('expression-animating');
389442
390443 // Reset the expression holder min height and width
391444 expressionHolder.css('min-width', 100);
392445 expressionHolder.css('min-height', 100);
393446
447+ if (expressionClone.prop('complete')) {
394448 resolve();
449+ } else {
450+ expressionClone.one('load', () => resolve());
451+ }
395452 });
396453
397454 expressionClone.removeClass('expression-clone');
@@ -410,216 +467,9 @@ async function setImage(img, path) {
410467 });
411468}
412469
413-function onExpressionsShowDefaultInput() {
470+async function moduleWorker({ newChat = false } = {}) {
414- const value = $(this).prop('checked');
415- extension_settings.expressions.showDefault = value;
416- saveSettingsDebounced();
417-
418- const existingImageSrc = $('img.expression').prop('src');
419- if (existingImageSrc !== undefined) { //if we have an image in src
420- if (!value && existingImageSrc.includes('/img/default-expressions/')) { //and that image is from /img/ (default)
421- $('img.expression').prop('src', ''); //remove it
422- lastMessage = null;
423- }
424- if (value) {
425- lastMessage = null;
426- }
427- }
428-}
429-
430-/**
431- * Stops animating Talkinghead.
432- */
433-async function unloadTalkingHead() {
434- if (!modules.includes('talkinghead')) {
435- console.debug('talkinghead module is disabled');
436- return;
437- }
438- console.debug('expressions: Stopping Talkinghead');
439-
440- try {
441- const url = new URL(getApiUrl());
442- url.pathname = '/api/talkinghead/unload';
443- const loadResponse = await doExtrasFetch(url);
444- if (!loadResponse.ok) {
445- throw new Error(loadResponse.statusText);
446- }
447- //console.log(`Response: ${loadResponseText}`);
448- } catch (error) {
449- //console.error(`Error unloading - ${error}`);
450- }
451-}
452-
453-/**
454- * Posts `talkinghead.png` of the current character to the talkinghead module in SillyTavern-extras, to start animating it.
455- */
456-async function loadTalkingHead() {
457- if (!modules.includes('talkinghead')) {
458- console.debug('talkinghead module is disabled');
459- return;
460- }
461- console.debug('expressions: Starting Talkinghead');
462-
463- const spriteFolderName = getSpriteFolderName();
464-
465- const talkingheadPath = `/characters/${encodeURIComponent(spriteFolderName)}/talkinghead.png`;
466- const emotionsSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_emotions.json`;
467- const animatorSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_animator.json`;
468-
469- try {
470- const spriteResponse = await fetch(talkingheadPath);
471-
472- if (!spriteResponse.ok) {
473- throw new Error(spriteResponse.statusText);
474- }
475-
476- const spriteBlob = await spriteResponse.blob();
477- const spriteFile = new File([spriteBlob], 'talkinghead.png', { type: 'image/png' });
478- const formData = new FormData();
479- formData.append('file', spriteFile);
480-
481- const url = new URL(getApiUrl());
482- url.pathname = '/api/talkinghead/load';
483-
484- const loadResponse = await doExtrasFetch(url, {
485- method: 'POST',
486- body: formData,
487- });
488-
489- if (!loadResponse.ok) {
490- throw new Error(loadResponse.statusText);
491- }
492-
493- const loadResponseText = await loadResponse.text();
494- console.log(`Load talkinghead response: ${loadResponseText}`);
495-
496- // Optional: per-character emotion templates
497- let emotionsSettings;
498- try {
499- const emotionsResponse = await fetch(emotionsSettingsPath);
500- if (emotionsResponse.ok) {
501- emotionsSettings = await emotionsResponse.json();
502- console.log(`Loaded ${emotionsSettingsPath}`);
503- } else {
504- throw new Error();
505- }
506- }
507- catch (error) {
508- emotionsSettings = {}; // blank -> use server defaults (to unload the previous character's customizations)
509- console.log(`No valid config at ${emotionsSettingsPath}, using server defaults`);
510- }
511- try {
512- const url = new URL(getApiUrl());
513- url.pathname = '/api/talkinghead/load_emotion_templates';
514- const apiResult = await doExtrasFetch(url, {
515- method: 'POST',
516- headers: {
517- 'Content-Type': 'application/json',
518- 'Bypass-Tunnel-Reminder': 'bypass',
519- },
520- body: JSON.stringify(emotionsSettings),
521- });
522-
523- if (!apiResult.ok) {
524- throw new Error(apiResult.statusText);
525- }
526- }
527- catch (error) {
528- // it's ok if not supported
529- console.log('Failed to send _emotions.json (backend too old?), ignoring');
530- }
531-
532- // Optional: per-character animator and postprocessor config
533- let animatorSettings;
534- try {
535- const animatorResponse = await fetch(animatorSettingsPath);
536- if (animatorResponse.ok) {
537- animatorSettings = await animatorResponse.json();
538- console.log(`Loaded ${animatorSettingsPath}`);
539- } else {
540- throw new Error();
541- }
542- }
543- catch (error) {
544- animatorSettings = {}; // blank -> use server defaults (to unload the previous character's customizations)
545- console.log(`No valid config at ${animatorSettingsPath}, using server defaults`);
546- }
547- try {
548- const url = new URL(getApiUrl());
549- url.pathname = '/api/talkinghead/load_animator_settings';
550- const apiResult = await doExtrasFetch(url, {
551- method: 'POST',
552- headers: {
553- 'Content-Type': 'application/json',
554- 'Bypass-Tunnel-Reminder': 'bypass',
555- },
556- body: JSON.stringify(animatorSettings),
557- });
558-
559- if (!apiResult.ok) {
560- throw new Error(apiResult.statusText);
561- }
562- }
563- catch (error) {
564- // it's ok if not supported
565- console.log('Failed to send _animator.json (backend too old?), ignoring');
566- }
567- } catch (error) {
568- console.error(`Error loading talkinghead image: ${talkingheadPath} - ${error}`);
569- }
570-}
571-
572-function handleImageChange() {
573- const imgElement = document.querySelector('img#expression-image.expression');
574-
575- if (!imgElement || !(imgElement instanceof HTMLImageElement)) {
576- console.log('Cannot find addExpressionImage()');
577- return;
578- }
579-
580- if (isTalkingHeadEnabled() && modules.includes('talkinghead')) {
581- const talkingheadResultFeedSrc = `${getApiUrl()}/api/talkinghead/result_feed`;
582- $('#expression-holder').css({ display: '' });
583- if (imgElement.src !== talkingheadResultFeedSrc) {
584- const expressionImageElement = document.querySelector('.expression_list_image');
585-
586- if (expressionImageElement && expressionImageElement instanceof HTMLImageElement) {
587- doExtrasFetch(expressionImageElement.src, {
588- method: 'HEAD',
589- })
590- .then(response => {
591- if (response.ok) {
592- imgElement.src = talkingheadResultFeedSrc;
593- }
594- })
595- .catch(error => {
596- console.error(error);
597- });
598- }
599- }
600- } else {
601- imgElement.src = ''; // remove in case char doesn't have expressions
602-
603- // When switching Talkinghead off, force-set the character to the last known expression, if any.
604- // This preserves the same expression Talkinghead had at the moment it was switched off.
605- const charName = getContext().name2;
606- const last = lastExpression[charName];
607- const targetExpression = last ? last : getFallbackExpression();
608- setExpression(charName, targetExpression, true);
609- }
610-}
611-
612-async function moduleWorker() {
613471 const context = getContext();
614472
615- // Hide and disable Talkinghead while not in extras
616- $('#image_type_block').toggle(extension_settings.expressions.api == EXPRESSION_API.extras);
617-
618- if (extension_settings.expressions.api != EXPRESSION_API.extras && extension_settings.expressions.talkinghead) {
619- $('#image_type_toggle').prop('checked', false);
620- setTalkingHeadState(false);
621- }
622-
623473 // non-characters not supported
624474 if (!context.groupId && context.characterId === undefined) {
625475 removeExpression();
@@ -646,7 +496,7 @@ async function moduleWorker() {
646496 }
647497
648498 const currentLastMessage = getLastCharacterMessage();
649499 let spriteFolderName = context.groupId ? getSpriteFolderName(currentLastMessage, currentLastMessage.name) : getSpriteFolderName();
650500
651501 // character has no expressions or it is not loaded
652502 if (Object.keys(spriteCache).length === 0) {
@@ -686,6 +536,10 @@ async function moduleWorker() {
686536 offlineMode.css('display', 'none');
687537 }
688538
539+ if (context.groupId && vnMode && newChat) {
540+ await forceUpdateVisualNovelMode();
541+ }
542+
689543 // Don't bother classifying if current char has no sprites and no default expressions are enabled
690544 if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) {
691545 return;
@@ -732,11 +586,11 @@ async function moduleWorker() {
732586 const force = !!context.groupId;
733587
734588 // Character won't be angry on you for swiping
735589 if (currentLastMessage.mes == '...' && expressionsList.includes(getFallbackExpression()extension_settings.expressions.fallback_expression)) {
736590 expression = getFallbackExpression()extension_settings.expressions.fallback_expression;
737591 }
738592
739593 await sendExpressionCall(spriteFolderName, expression, { force: force, vnMode: vnMode });
740594 }
741595 catch (error) {
742596 console.log(error);
@@ -749,91 +603,6 @@ async function moduleWorker() {
749603 }
750604}
751605
752-/**
753- * Starts/stops Talkinghead talking animation.
754- *
755- * Talking starts only when all the following conditions are met:
756- * - The LLM is currently streaming its output.
757- * - The AI's current last message is non-empty, and also not just '...' (as produced by a swipe).
758- * - The AI's current last message has changed from what we saw during the previous call.
759- *
760- * In all other cases, talking stops.
761- *
762- * A Talkinghead API call is made only when the talking state changes.
763- *
764- * Note that also the TTS system, if enabled, starts/stops the Talkinghead talking animation.
765- * See `talkingAnimation` in `SillyTavern/public/scripts/extensions/tts/index.js`.
766- */
767-async function updateTalkingState() {
768- // Don't bother if Talkinghead is disabled or not loaded.
769- if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {
770- return;
771- }
772-
773- const context = getContext();
774- const currentLastMessage = getLastCharacterMessage();
775-
776- try {
777- // TODO: Not sure if we need also "&& !context.groupId" here - the classify check in `moduleWorker`
778- // (that similarly checks the streaming processor state) does that for some reason.
779- // Talkinghead isn't currently designed to work with groups.
780- const lastMessageChanged = !((lastCharacter === context.characterId || lastCharacter === context.groupId) && lastTalkingStateMessage === currentLastMessage.mes);
781- const url = new URL(getApiUrl());
782- let newTalkingState;
783- if (context.streamingProcessor && !context.streamingProcessor.isFinished &&
784- currentLastMessage.mes.length !== 0 && currentLastMessage.mes !== '...' && lastMessageChanged) {
785- url.pathname = '/api/talkinghead/start_talking';
786- newTalkingState = true;
787- } else {
788- url.pathname = '/api/talkinghead/stop_talking';
789- newTalkingState = false;
790- }
791- try {
792- // Call the Talkinghead API only if the talking state changed.
793- if (newTalkingState !== lastTalkingState) {
794- console.debug(`updateTalkingState: calling ${url.pathname}`);
795- await doExtrasFetch(url);
796- }
797- }
798- catch (error) {
799- // it's ok if not supported
800- }
801- finally {
802- lastTalkingState = newTalkingState;
803- }
804- }
805- catch (error) {
806- // console.log(error);
807- }
808- finally {
809- lastTalkingStateMessage = currentLastMessage.mes;
810- }
811-}
812-
813-/**
814- * Checks whether the current character has a talkinghead image available.
815- * @returns {Promise<boolean>} True if the character has a talkinghead image available, false otherwise.
816- */
817-async function isTalkingHeadAvailable() {
818- let spriteFolderName = getSpriteFolderName();
819-
820- try {
821- await validateImages(spriteFolderName);
822-
823- let talkingheadObj = spriteCache[spriteFolderName].find(obj => obj.label === 'talkinghead');
824- let talkingheadPath = talkingheadObj ? talkingheadObj.path : null;
825-
826- if (talkingheadPath != null) {
827- return true;
828- } else {
829- await unloadTalkingHead();
830- return false;
831- }
832- } catch (err) {
833- return err;
834- }
835-}
836-
837606function getSpriteFolderName(characterMessage = null, characterName = null) {
838607 const context = getContext();
839608 let spriteFolderName = characterName ?? context.name2;
@@ -848,33 +617,6 @@ function getSpriteFolderName(characterMessage = null, characterName = null) {
848617 return spriteFolderName;
849618}
850619
851-function setTalkingHeadState(newState) {
852- console.debug(`expressions: New talkinghead state: ${newState}`);
853- extension_settings.expressions.talkinghead = newState; // Store setting
854- saveSettingsDebounced();
855-
856- if ([EXPRESSION_API.local, EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)) {
857- return;
858- }
859-
860- isTalkingHeadAvailable().then(result => {
861- if (result) {
862- //console.log("talkinghead exists!");
863-
864- if (extension_settings.expressions.talkinghead) {
865- loadTalkingHead();
866- } else {
867- unloadTalkingHead();
868- }
869- handleImageChange(); // Change image as needed
870-
871-
872- } else {
873- //console.log("talkinghead does not exist.");
874- }
875- });
876-}
877-
878620function getFolderNameByMessage(message) {
879621 const context = getContext();
880622 let avatarPath = '';
@@ -894,48 +636,55 @@ function getFolderNameByMessage(message) {
894636 return folderName;
895637}
896638
897-async function sendExpressionCall(name, expression, force, vnMode) {
639+/**
898- lastExpression[name.split('/')[0]] = expression;
640+ * Update the expression for the given character.
899- if (!vnMode) {
641+ *
642+ * @param {string} spriteFolderName The character name, optionally with a sprite folder override, e.g. "folder/expression".
643+ * @param {string} expression The expression label, e.g. "amusement", "joy", etc.
644+ * @param {Object} [options] Additional options
645+ * @param {boolean} [options.force=false] If true, the expression will be sent even if it is the same as the current expression.
646+ * @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.
647+ * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
648+ */
649+export async function sendExpressionCall(spriteFolderName, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) {
650+ lastExpression[spriteFolderName.split('/')[0]] = expression;
651+ if (vnMode === null) {
900652 vnMode = isVisualNovelMode();
901653 }
902654
903655 if (vnMode) {
904656 await updateVisualNovelMode(namespriteFolderName, expression);
905657 } else {
906658 setExpression(namespriteFolderName, expression, { force: force, overrideSpriteFile: overrideSpriteFile });
907659 }
908660}
909661
910662async function setSpriteSetCommandsetSpriteFolderCommand(_, folder) {
911663 if (!folder) {
912664 console.log('Clearing sprite set');
913665 folder = '';
914666 }
915667
916668 if (folder.startsWith('/') || folder.startsWith('\\')) {
917- folder = folder.slice(1);
918-
919669 const currentLastMessage = getLastCharacterMessage();
670+ folder = folder.slice(1);
920671 folder = `${currentLastMessage.name}/${folder}`;
921672 }
922673
923674 $('#expression_override').val(folder.trim());
924675 onClickExpressionOverrideButton();
925- // removeExpression();
676+
926- // moduleWorker();
677+ // No need to resend the expression, the folder override will automatically update the currently displayed one.
927- const vnMode = isVisualNovelMode();
928- await sendExpressionCall(folder, lastExpression, true, vnMode);
929678 return '';
930679}
931680
932681async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) {
933682 if (!text) {
934683 toastr.warningerror('No text provided');
935684 return '';
936685 }
937686 if (api && !Object.keys(EXPRESSION_API).includes(api)) {
938687 toastr.warningerror('Invalid API provided');
939688 return '';
940689 }
941690
@@ -951,37 +700,69 @@ async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ {
951700 return label;
952701}
953702
954-async function setSpriteSlashCommand(_, spriteId) {
703+/** @type {(args: {type: 'expression' | 'sprite'}, searchTerm: string) => Promise<string>} */
955- if (!spriteId) {
704+async function setSpriteSlashCommand({ type }, searchTerm) {
956- console.log('No sprite id provided');
705+ type ??= 'expression';
706+ searchTerm = searchTerm.trim().toLowerCase();
707+ if (!searchTerm) {
708+ toastr.error(t`No expression or sprite name provided`, t`Set Sprite`);
957709 return '';
958710 }
959711
960- spriteId = spriteId.trim().toLowerCase();
712+ const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
713+ const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
714+
715+ let label = searchTerm;
716+
717+ /** @type {string?} */
718+ let spriteFile = null;
961719
962- // In Talkinghead mode, don't check for the existence of the sprite
963- // (emotion names are the same as for sprites, but it only needs "talkinghead.png").
964- const currentLastMessage = getLastCharacterMessage();
965- const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
966- let label = spriteId;
967- if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {
968720 await validateImages(spriteFolderName);
969721
970- // Fuzzy search for sprite
722+ // Handle reset as a special term and just reset the sprite via expression call
971- const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
723+ if (searchTerm === RESET_SPRITE_LABEL) {
972- const results = fuse.search(spriteId);
724+ await sendExpressionCall(spriteFolderName, label, { force: true });
973- const spriteItem = results[0]?.item;
725+ return lastExpression[spriteFolderName] ?? '';
726+ }
727+
728+ switch (type) {
729+ case 'expression': {
730+ // Fuzzy search for expression
731+ const existingExpressions = getCachedExpressions().map(x => ({ label: x }));
732+ const results = performFuzzySearch('expression-expressions', existingExpressions, [
733+ { name: 'label', weight: 1 },
734+ ], searchTerm);
735+ const matchedExpression = results[0]?.item;
736+ if (!matchedExpression) {
737+ toastr.warning(t`No expression found for search term ${searchTerm}`, t`Set Sprite`);
738+ return '';
739+ }
974740
975- if (!spriteItem) {
741+ label = matchedExpression.label;
976- console.log('No sprite found for search term ' + spriteId);
742+ break;
743+ }
744+ case 'sprite': {
745+ // Fuzzy search for sprite file
746+ const sprites = spriteCache[spriteFolderName].map(x => x.files).flat();
747+ const results = performFuzzySearch('expression-expressions', sprites, [
748+ { name: 'title', weight: 1 },
749+ { name: 'fileName', weight: 1 },
750+ ], searchTerm);
751+ const matchedSprite = results[0]?.item;
752+ if (!matchedSprite) {
753+ toastr.warning(t`No sprite file found for search term ${searchTerm}`, t`Set Sprite`);
977754 return '';
978755 }
979756
980757 label = spriteItemmatchedSprite.labelexpression;
758+ spriteFile = matchedSprite.fileName;
759+ break;
760+ }
761+ default: throw Error('Invalid sprite set type: ' + type);
981762 }
982763
983- const vnMode = isVisualNovelMode();
764+ await sendExpressionCall(spriteFolderName, label, { force: true, overrideSpriteFile: spriteFile });
984- await sendExpressionCall(spriteFolderName, label, true, vnMode);
765+
985766 return label;
986767}
987768
@@ -999,6 +780,21 @@ function spriteFolderNameFromCharacter(char) {
999780}
1000781
1001782/**
783+ * Generates a unique sprite name by appending an index to the given expression. *
784+ * @param {string} expression - The base expression to be used as the prefix for the sprite name.
785+ * @param {ExpressionImage[]} existingFiles - An array of existing file objects, each containing a fileName property.
786+ * @returns {string} - A unique sprite name with the format "expression-index".
787+ */
788+function generateUniqueSpriteName(expression, existingFiles) {
789+ let index = existingFiles.length;
790+ let newSpriteName;
791+ do {
792+ newSpriteName = `${expression}-${index++}`;
793+ } while (existingFiles.some(file => withoutExtension(file.fileName) === newSpriteName));
794+ return newSpriteName;
795+}
796+
797+/**
1002798 * Slash command callback for /uploadsprite
1003799 *
1004800 * label= is required
@@ -1011,16 +807,29 @@ function spriteFolderNameFromCharacter(char) {
1011807 * @param {object} args
1012808 * @param {string} args.name Character name or avatar key, passed through findChar
1013809 * @param {string} args.label Expression label
1014810 * @param {string} [args.folder=null] SpriteOptional sprite folder path, processed using backslash rules
811+ * @param {string?} [args.spriteName=null] Optional sprite name
1015812 * @param {string} imageUrl Image URI to fetch and upload
1016813 * @returns {Promise<voidstring>} the sprite name
1017814 */
1018815async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) {
1019816 if (!imageUrl) throw new Error('Image URL is required');
1020817 if (!label || typeof label !== 'string') throw new Error('Expression label is required');{
818+ toastr.error(t`Expression label is required`, t`Error Uploading Sprite`);
819+ return '';
820+ }
1021821
1022822 label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();
1023- if (!label) throw new Error('Expression label must contain at least one letter');
823+ if (!label) {
824+ toastr.error(t`Expression label must contain at least one letter`, t`Error Uploading Sprite`);
825+ return '';
826+ }
827+
828+ spriteName = spriteName || label;
829+ if (!validateExpressionSpriteName(label, spriteName)) {
830+ toastr.error(t`Invalid sprite name. Must follow the naming pattern for expression sprites.`, t`Error Uploading Sprite`);
831+ return '';
832+ }
1024833
1025834 name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;
1026835 const char = findChar({ name });
@@ -1041,6 +850,7 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
1041850 formData.append('name', folder); // this is the folder or character name
1042851 formData.append('label', label); // this is the expression label
1043852 formData.append('avatar', file); // this is the image file
853+ formData.append('spriteName', spriteName); // this is a redundant comment
1044854
1045855 await handleFileUpload('/api/sprites/upload', formData);
1046856 console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);
@@ -1048,6 +858,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
1048858 console.error(`[${MODULE_NAME}] Error uploading file:`, error);
1049859 throw error;
1050860 }
861+
862+ return spriteName;
1051863}
1052864
1053865/**
@@ -1159,7 +971,7 @@ function getJsonSchema(emotions) {
1159971function onTextGenSettingsReady(args) {
1160972 // Only call if inside an API call
1161973 if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) {
1162- const emotions = DEFAULT_EXPRESSIONS.filter((e) => e != 'talkinghead');
974+ const emotions = DEFAULT_EXPRESSIONS;
1163975 Object.assign(args, {
1164976 top_k: 1,
1165977 stop: [],
@@ -1177,16 +989,16 @@ function onTextGenSettingsReady(args) {
1177989 * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification.
1178990 * @param {object} [options={}] - Optional arguments.
1179991 * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification.
1180992 * @returns {Promise<string?>} - The label of the expression.
1181993 */
1182994export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { customPrompt = null } = {}) {
1183995 // Return if text is undefined, saving a costly fetch request
1184996 if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) {
1185997 return getFallbackExpression()extension_settings.expressions.fallback_expression;
1186998 }
1187999
11881000 if (extension_settings.expressions.translate && typeof window['globalThis.translate'] === 'function') {
11891001 text = await window['globalThis.translate'](text, 'en');
11901002 }
11911003
11921004 text = sampleClassifyText(text);
@@ -1212,7 +1024,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
12121024 await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250);
12131025 } catch (error) {
12141026 console.warn('No LLM connection. Using fallback expression', error);
12151027 return getFallbackExpression()extension_settings.expressions.fallback_expression;
12161028 }
12171029
12181030 const expressionsList = await getExpressionsList();
@@ -1225,7 +1037,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
12251037 case EXPRESSION_API.webllm: {
12261038 if (!isWebLlmSupported()) {
12271039 console.warn('WebLLM is not supported. Using fallback expression');
12281040 return getFallbackExpression()extension_settings.expressions.fallback_expression;
12291041 }
12301042
12311043 const expressionsList = await getExpressionsList();
@@ -1258,9 +1070,9 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
12581070 } break;
12591071 }
12601072 } catch (error) {
12611073 toastr.infoerror('Could not classify expression. Check the console or your backend for more information.');
12621074 console.error(error);
12631075 return getFallbackExpression()extension_settings.expressions.fallback_expression;
12641076 }
12651077}
12661078
@@ -1288,75 +1100,155 @@ function removeExpression() {
12881100 $('#no_chat_expressions').show();
12891101}
12901102
1291-async function validateImages(character, forceRedrawCached) {
1103+/**
1292- if (!character) {
1104+ * Validate a character's sprites, and redraw the sprites list if not done before or forced to redraw.
1105+ * @param {string} spriteFolderName - The character sprite folder to validate
1106+ * @param {boolean} [forceRedrawCached=false] - Whether to force redrawing the sprites list even if it's already been drawn before
1107+ */
1108+async function validateImages(spriteFolderName, forceRedrawCached = false) {
1109+ if (!spriteFolderName) {
12931110 return;
12941111 }
12951112
12961113 const labels = await getExpressionsList();
12971114
12981115 if (spriteCache[characterspriteFolderName]) {
12991116 if (forceRedrawCached && $('#image_list').data('name') !== characterspriteFolderName) {
13001117 console.debug('force redrawing character sprites list');
13011118 await drawSpritesList(characterspriteFolderName, labels, spriteCache[characterspriteFolderName]);
13021119 }
13031120
13041121 return;
13051122 }
13061123
13071124 const sprites = await getSpritesList(characterspriteFolderName);
13081125 let validExpressions = await drawSpritesList(characterspriteFolderName, labels, sprites);
13091126 spriteCache[characterspriteFolderName] = validExpressions;
1127+}
1128+
1129+/**
1130+ * Takes a given sprite as returned from the server, and enriches it with additional data for display/sorting
1131+ * @param {{ path: string, label: string }} sprite
1132+ * @returns {ExpressionImage}
1133+ */
1134+function getExpressionImageData(sprite) {
1135+ const fileName = sprite.path.split('/').pop().split('?')[0];
1136+ const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, '');
1137+ return {
1138+ expression: sprite.label,
1139+ fileName: fileName,
1140+ title: fileNameWithoutExtension,
1141+ imageSrc: sprite.path,
1142+ type: 'success',
1143+ isCustom: extension_settings.expressions.custom?.includes(sprite.label),
1144+ };
13101145}
13111146
1312-async function drawSpritesList(character, labels, sprites) {
1147+/**
1148+ * Populate the character expression list with sprites for the given character.
1149+ * @param {string} spriteFolderName - The name of the character to populate the list for
1150+ * @param {string[]} labels - An array of expression labels that are valid
1151+ * @param {Expression[]} sprites - An array of sprites
1152+ * @returns {Promise<Expression[]>} An array of valid expression labels
1153+ */
1154+async function drawSpritesList(spriteFolderName, labels, sprites) {
1155+ /** @type {Expression[]} */
13131156 let validExpressions = [];
1157+
13141158 $('#no_chat_expressions').hide();
13151159 $('#open_chat_expressions').show();
13161160 $('#image_list').empty();
13171161 $('#image_list').data('name', characterspriteFolderName);
13181162 $('#image_list_header_name').text(characterspriteFolderName);
13191163
13201164 if (!Array.isArray(labels)) {
13211165 return [];
13221166 }
13231167
13241168 for (const itemexpression of labels.sort()) {
1325- const sprite = sprites.find(x => x.label == item);
1169+ const isCustom = extension_settings.expressions.custom?.includes(expression);
1326- const isCustom = extension_settings.expressions.custom.includes(item);
1170+ const images = sprites
1327-
1171+ .filter(s => s.label === expression)
1328- if (sprite) {
1172+ .map(s => s.files)
13291173 validExpressions.pushflat(sprite);
1330- const listItem = await getListItem(item, sprite.path, 'success', isCustom);
1174+
1175+ if (images.length === 0) {
1176+ const listItem = await getListItem(expression, {
1177+ isCustom,
1178+ images: [getPlaceholderImage(expression, isCustom)],
1179+ });
13311180 $('#image_list').append(listItem);
1181+ continue;
13321182 }
1333- else {
1183+
1334- const listItem = await getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom);
1184+ validExpressions.push({ label: expression, files: images });
1185+
1186+ // Render main = first file, additional = rest
1187+ let listItem = await getListItem(expression, {
1188+ isCustom,
1189+ images,
1190+ });
13351191 $('#image_list').append(listItem);
13361192 }
1337- }
13381193 return validExpressions;
13391194}
13401195
13411196/**
13421197 * Renders a list item template for the expressions list.
13431198 * @param {string} itemexpression Expression name
13441199 * @param {stringobject} imageSrc Pathargs toArguments imageobject
1345- * @param {'success' | 'failure'} textClass 'success' or 'failure'
1200+ * @param {ExpressionImage[]} [args.images] Array of image objects
13461201 * @param {boolean} [args.isCustom=false] If expression is added by user
13471202 * @returns {Promise<string>} Rendered list item template
13481203 */
13491204async function getListItem(itemexpression, imageSrc,{ textClassimages, isCustom = false } = {}) {
13501205 return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { itemexpression, imageSrcimages, textClass,isCustom: isCustom ?? false });
13511206}
13521207
1208+/**
1209+ * Fetches and processes the list of sprites for a given character name.
1210+ * Retrieves sprite data from the server and organizes it into labeled groups.
1211+ *
1212+ * @param {string} name - The character name to fetch sprites for
1213+ * @returns {Promise<Expression[]>} A promise that resolves to an array of grouped expression objects, each containing a label and associated image data
1214+ */
1215+
13531216async function getSpritesList(name) {
13541217 console.debug('getting sprites list');
13551218
13561219 try {
13571220 const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`);
1221+ /** @type {{ label: string, path: string }[]} */
13581222 let sprites = result.ok ? (await result.json()) : [];
1359- return sprites;
1223+
1224+ /** @type {Expression[]} */
1225+ const grouped = sprites.reduce((acc, sprite) => {
1226+ const imageData = getExpressionImageData(sprite);
1227+ let existingExpression = acc.find(exp => exp.label === sprite.label);
1228+ if (existingExpression) {
1229+ existingExpression.files.push(imageData);
1230+ } else {
1231+ acc.push({ label: sprite.label, files: [imageData] });
1232+ }
1233+
1234+ return acc;
1235+ }, []);
1236+
1237+ // Sort the sprites for each expression alphabetically, but keep the main expression file at the front
1238+ for (const expression of grouped) {
1239+ expression.files.sort((a, b) => {
1240+ if (a.title === expression.label) return -1;
1241+ if (b.title === expression.label) return 1;
1242+ return a.title.localeCompare(b.title);
1243+ });
1244+
1245+ // Mark all besides the first sprite as 'additional'
1246+ for (let i = 1; i < expression.files.length; i++) {
1247+ expression.files[i].type = 'additional';
1248+ }
1249+ }
1250+
1251+ return grouped;
13601252 }
13611253 catch (err) {
13621254 console.log(err);
@@ -1395,17 +1287,31 @@ async function renderFallbackExpressionPicker() {
13951287 const defaultPicker = $('#expression_fallback');
13961288 defaultPicker.empty();
13971289
1398- const fallbackExpression = getFallbackExpression();
1290+
1291+ addOption(OPTION_NO_FALLBACK, '[ No fallback ]', !extension_settings.expressions.fallback_expression);
1292+ addOption(OPTION_EMOJI_FALLBACK, '[ Default emojis ]', !!extension_settings.expressions.showDefault);
13991293
14001294 for (const expression of expressions) {
1295+ addOption(expression, expression, expression == extension_settings.expressions.fallback_expression);
1296+ }
1297+
1298+ /** @type {(value: string, label: string, isSelected: boolean) => void} */
1299+ function addOption(value, label, isSelected) {
14011300 const option = document.createElement('option');
14021301 option.value = expressionvalue;
14031302 option.text = expressionlabel;
14041303 option.selected = expression == fallbackExpressionisSelected;
14051304 defaultPicker.append(option);
14061305 }
14071306}
14081307
1308+/**
1309+ * Retrieves a unique list of cached expressions.
1310+ * Combines the default expressions list with custom user-defined expressions.
1311+ *
1312+ * @returns {string[]} An array of unique expression labels
1313+ */
1314+
14091315function getCachedExpressions() {
14101316 if (!Array.isArray(expressionsList)) {
14111317 return [];
@@ -1463,7 +1369,7 @@ export async function getExpressionsList() {
14631369 }
14641370
14651371 // If there was no specific list, or an error, just return the default expressions
14661372 expressionsList = DEFAULT_EXPRESSIONS.filter(e => e !== 'talkinghead').slice();
14671373 return expressionsList;
14681374 }
14691375
@@ -1471,38 +1377,88 @@ export async function getExpressionsList() {
14711377 return [...result, ...extension_settings.expressions.custom].filter(onlyUnique);
14721378}
14731379
1474-async function setExpression(character, expression, force) {
1380+/**
1475- if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {
1381+ * Selects a sprite from the given sprite folder for the given expression.
1476- console.debug('entered setExpressions');
1382+ *
1477- await validateImages(character);
1383+ * If multiple sprites are allowed for the expression, it will randomly select one.
1384+ * If the rerollIfSame option is enabled, it will only select a different sprite if the previous sprite was the same.
1385+ * If the overrideSpriteFile option is set, it will look for the sprite with the given file name instead of randomly selecting one.
1386+ *
1387+ * @param {string} spriteFolderName - The name of the sprite folder
1388+ * @param {string} expression - The expression to find the sprite for
1389+ * @param {object} [options] - Options to select the sprite
1390+ * @param {string} [options.prevExpressionSrc=null] - The source of the previous expression
1391+ * @param {string} [options.overrideSpriteFile=null] - The file name of the sprite to select
1392+ * @returns {ExpressionImage?} - The selected sprite
1393+ */
1394+function chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc = null, overrideSpriteFile = null } = {}) {
1395+ if (!spriteCache[spriteFolderName]) return null;
1396+ if (expression === RESET_SPRITE_LABEL) return null;
1397+
1398+ // Search for sprites of that expression - or fallback expression sprites if enabled
1399+ let sprite = spriteCache[spriteFolderName].find(x => x.label === expression);
1400+ if (!(sprite?.files.length > 0) && extension_settings.expressions.fallback_expression) {
1401+ sprite = spriteCache[spriteFolderName].find(x => x.label === extension_settings.expressions.fallback_expression);
1402+ console.debug('Expression', expression, 'not found. Using fallback expression', extension_settings.expressions.fallback_expression);
1403+ }
1404+ if (!(sprite?.files.length > 0)) return null;
1405+
1406+ let spriteFile = sprite.files[0];
1407+
1408+ // If a specific sprite file should be set, we are looking it up here
1409+ if (overrideSpriteFile) {
1410+ const searched = sprite.files.find(x => x.fileName === overrideSpriteFile);
1411+ if (searched) spriteFile = searched;
1412+ else toastr.warning(t`Couldn't find sprite file ${overrideSpriteFile} for expression ${expression}.`, t`Sprite Not Found`);
1413+ }
1414+ // Else calculate next expression, if multiple are allowed
1415+ else if (extension_settings.expressions.allowMultiple && sprite.files.length > 1) {
1416+ let possibleFiles = sprite.files;
1417+ if (extension_settings.expressions.rerollIfSame) {
1418+ possibleFiles = possibleFiles.filter(x => !prevExpressionSrc || x.imageSrc !== prevExpressionSrc);
1419+ }
1420+ spriteFile = possibleFiles[Math.floor(Math.random() * possibleFiles.length)];
1421+ }
1422+
1423+ return spriteFile;
1424+
1425+}
1426+
1427+/**
1428+ * Set the expression of a character.
1429+ * @param {string} spriteFolderName - The name of the character (folder name - can also be a costume override)
1430+ * @param {string} expression - The expression or sprite name to set
1431+ * @param {Object} options - Optional parameters
1432+ * @param {boolean} [options.force=false] - Whether to force the expression change even if Visual Novel mode is on
1433+ * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
1434+ * @returns {Promise<void>} A promise that resolves when the expression has been set.
1435+ */
1436+async function setExpression(spriteFolderName, expression, { force = false, overrideSpriteFile = null } = {}) {
1437+ await validateImages(spriteFolderName);
14781438 const img = $('img.expression');
14791439 const prevExpressionSrc = img.attr('src');
14801440 const expressionClone = img.clone();
14811441
1482- const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression));
1442+ const spriteFile = chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc, overrideSpriteFile: overrideSpriteFile });
1483- console.debug('checking for expression images to show..');
1443+ if (spriteFile) {
1484- if (sprite) {
1485- console.debug('setting expression from character images folder');
1486-
14871444 if (force && isVisualNovelMode()) {
14881445 const context = getContext();
14891446 const group = context.groups.find(x => x.id === context.groupId);
14901447
1491- for (const member of group.members) {
1448+ // If it's a folder, make sure we find the group member based on the actual name
1492- const groupMember = context.characters.find(x => x.avatar === member);
1449+ const memberName = spriteFolderName.split('/')[0] ?? spriteFolderName;
1493-
1494- if (!groupMember) {
1495- continue;
1496- }
14971450
1498- if (groupMember.name == character) {
1451+ const groupMember = group.members
1499- await setImage($(`.expression-holder[data-avatar="${member}"] img`), sprite.path);
1452+ .map(member => context.characters.find(x => x.avatar === member))
1453+ .find(groupMember => groupMember && groupMember.name === memberName);
1454+ if (groupMember) {
1455+ await setImage($(`.expression-holder[data-avatar="${groupMember.avatar}"] img`), spriteFile.imageSrc);
15001456 return;
15011457 }
15021458 }
1503- }
1459+
15041460 //only swap expressions when necessary
15051461 if (prevExpressionSrc !== spritespriteFile.pathimageSrc
15061462 && !img.hasClass('expression-animating')) {
15071463 //clone expression
15081464 expressionClone.addClass('expression-clone');
@@ -1510,7 +1466,12 @@ async function setExpression(character, expression, force) {
15101466 //must be made invisible to start because they share the same Z-index
15111467 expressionClone.attr('id', '').css({ opacity: 0 });
15121468 //add new sprite path to clone src
15131469 expressionClone.attr('src', spritespriteFile.pathimageSrc);
1470+ //set relevant data tags
1471+ expressionClone.attr('data-sprite-folder-name', spriteFolderName);
1472+ expressionClone.attr('data-expression', expression);
1473+ expressionClone.attr('data-sprite-filename', spriteFile.fileName);
1474+ expressionClone.attr('title', expression);
15141475 //add invisible clone to html
15151476 expressionClone.appendTo($('#expression-holder'));
15161477
@@ -1552,80 +1513,85 @@ async function setExpression(character, expression, force) {
15521513 expressionHolder.css('min-height', 100);
15531514 });
15541515
1555-
15561516 expressionClone.removeClass('expression-clone');
15571517
15581518 expressionClone.removeClass('default');
15591519 expressionClone.off('error');
15601520 expressionClone.on('error', function (error) {
15611521 console.debug('Expression image error', spritespriteFile.pathimageSrc, error);
15621522 $(this).attr('src', '');
15631523 $(this).off('error');
15641524 if (force && extension_settings.expressions.showDefault) {
1565- setDefault();
1525+ setDefaultEmojiForImage(img, expression);
15661526 }
15671527 });
15681528 }
1529+
1530+ console.info('Expression set', { expression: spriteFile.expression, file: spriteFile.fileName });
15691531 }
15701532 else {
1571- if (extension_settings.expressions.showDefault) {
1533+ img.attr('data-sprite-folder-name', spriteFolderName);
1572- setDefault();
1573- }
1574- }
15751534
1576- function setDefault() {
1535+ img.off('error');
1577- console.debug('setting default');
1578- const defImgUrl = `/img/default-expressions/${expression}.png`;
1579- //console.log(defImgUrl);
1580- img.attr('src', defImgUrl);
1581- img.addClass('default');
1582- }
1583- document.getElementById('expression-holder').style.display = '';
15841536
1537+ if (extension_settings.expressions.showDefault && expression !== RESET_SPRITE_LABEL) {
1538+ setDefaultEmojiForImage(img, expression);
15851539 } else {
1586- // Set the Talkinghead emotion to the specified expression
1540+ setNoneForImage(img, expression);
1587- // TODO: For now, Talkinghead emote only supported when VN mode is off; see also updateVisualNovelMode.
1588- try {
1589- let result = await isTalkingHeadAvailable();
1590- if (result) {
1591- const url = new URL(getApiUrl());
1592- url.pathname = '/api/talkinghead/set_emotion';
1593- await doExtrasFetch(url, {
1594- method: 'POST',
1595- headers: {
1596- 'Content-Type': 'application/json',
1597- },
1598- body: JSON.stringify({ emotion_name: expression }),
1599- });
1600- }
16011541 }
1602- catch (error) {
1542+ console.debug('Expression unset - No sprite found', { expression: expression });
1603- // `set_emotion` is not present in old versions, so let it 404.
16041543 }
16051544
1606- try {
1545+ document.getElementById('expression-holder').style.display = '';
1607- // Find the <img> element with id="expression-image" and class="expression"
1608- const imgElement = document.querySelector('img#expression-image.expression');
1609- //console.log("searching");
1610- if (imgElement && imgElement instanceof HTMLImageElement) {
1611- //console.log("setting value");
1612- imgElement.src = getApiUrl() + '/api/talkinghead/result_feed';
1613- }
16141546}
1615- catch (error) {
1547+
1616- //console.log("The fetch failed!");
1548+/**
1549+ * Sets the default expression image for the given image element and expression
1550+ * @param {JQuery<HTMLElement>} img - The image element to set the default expression for
1551+ * @param {string} expression - The expression label to use for the default image
1552+ */
1553+function setDefaultEmojiForImage(img, expression) {
1554+ if (extension_settings.expressions.custom?.includes(expression)) {
1555+ console.debug(`Can't set default emoji for a custom expression (${expression}). setting to ${DEFAULT_FALLBACK_EXPRESSION} instead.`);
1556+ expression = DEFAULT_FALLBACK_EXPRESSION;
16171557 }
1558+
1559+ const defImgUrl = `/img/default-expressions/${expression}.png`;
1560+ img.attr('src', defImgUrl);
1561+ img.attr('data-expression', expression);
1562+ img.attr('data-sprite-filename', null);
1563+ img.attr('title', expression);
1564+ img.addClass('default');
16181565}
1566+
1567+/**
1568+ * Sets the image element to display no expression by clearing its source attribute.
1569+ * @param {JQuery<HTMLElement>} img - The image element to clear the expression for
1570+ * @param {string} expression - The expression label to use
1571+ */
1572+function setNoneForImage(img, expression) {
1573+ img.attr('src', '');
1574+ img.attr('data-expression', expression);
1575+ img.attr('data-sprite-filename', null);
1576+ img.attr('title', expression);
1577+ img.removeClass('default');
16191578}
16201579
16211580function onClickExpressionImage() {
1622- const expression = $(this).attr('id');
1581+ // If there is no expression image and we clicked on the placeholder, we remove the sprite by calling via the expression label
1623- setSpriteSlashCommand({}, expression);
1582+ if ($(this).attr('data-expression-type') === 'failure') {
1583+ const label = $(this).attr('data-expression');
1584+ setSpriteSlashCommand({ type: 'expression' }, label);
1585+ return;
1586+ }
1587+
1588+ const spriteFile = $(this).attr('data-filename');
1589+ setSpriteSlashCommand({ type: 'sprite' }, spriteFile);
16241590}
16251591
16261592async function onClickExpressionAddCustom() {
16271593 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression');
16281594 let expressionName = await callPopupPopup.show.input(templatenull, 'input'template);
16291595
16301596 if (!expressionName) {
16311597 console.debug('No custom expression name provided');
@@ -1636,19 +1602,15 @@ async function onClickExpressionAddCustom() {
16361602
16371603 // a-z, 0-9, dashes and underscores only
16381604 if (!/^[a-z0-9-_]+$/.test(expressionName)) {
16391605 toastr.infowarning('Invalid custom expression name provided', 'Add Custom Expression');
16401606 return;
16411607 }
1642-
1608+ if (DEFAULT_EXPRESSIONS.includes(expressionName) || DEFAULT_EXPRESSIONS.some(x => expressionName.startsWith(x))) {
1643- // Check if expression name already exists in default expressions
1609+ toastr.warning('Expression name already exists', 'Add Custom Expression');
1644- if (DEFAULT_EXPRESSIONS.includes(expressionName)) {
1645- toastr.info('Expression name already exists');
16461610 return;
16471611 }
1648-
1649- // Check if expression name already exists in custom expressions
16501612 if (extension_settings.expressions.custom.includes(expressionName)) {
16511613 toastr.infowarning('Custom expression already exists', 'Add Custom Expression');
16521614 return;
16531615 }
16541616
@@ -1665,14 +1627,15 @@ async function onClickExpressionAddCustom() {
16651627
16661628async function onClickExpressionRemoveCustom() {
16671629 const selectedExpression = String($('#expression_custom').val());
1630+ const noCustomExpressions = extension_settings.expressions.custom.length === 0;
16681631
16691632 if (!selectedExpression || noCustomExpressions) {
16701633 console.debug('No custom expression selected');
16711634 return;
16721635 }
16731636
16741637 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression });
16751638 const confirmation = await callPopupPopup.show.confirm(templatenull, 'confirm'template);
16761639
16771640 if (!confirmation) {
16781641 console.debug('Custom expression removal cancelled');
@@ -1682,8 +1645,8 @@ async function onClickExpressionRemoveCustom() {
16821645 // Remove custom expression from settings
16831646 const index = extension_settings.expressions.custom.indexOf(selectedExpression);
16841647 extension_settings.expressions.custom.splice(index, 1);
16851648 if (selectedExpression == getFallbackExpression()extension_settings.expressions.fallback_expression) {
16861649 toastr.warning(`Deleted custom expression '${selectedExpression}' that was also selected as the fallback expression.\nFallback expression has been reset to '${DEFAULT_FALLBACK_EXPRESSION}'.`, 'Remove Custom Expression');
16871650 extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION;
16881651 }
16891652 await renderAdditionalExpressionSettings();
@@ -1707,12 +1670,35 @@ function onExpressionApiChanged() {
17071670 }
17081671}
17091672
17101673async function onExpressionFallbackChanged() {
1711- const expression = this.value;
1674+ /** @type {HTMLSelectElement} */
1712- if (expression) {
1675+ const select = this;
1713- extension_settings.expressions.fallback_expression = expression;
1676+ const selectedValue = select.value;
1714- saveSettingsDebounced();
1677+
1678+ switch (selectedValue) {
1679+ case OPTION_NO_FALLBACK:
1680+ extension_settings.expressions.fallback_expression = null;
1681+ extension_settings.expressions.showDefault = false;
1682+ break;
1683+ case OPTION_EMOJI_FALLBACK:
1684+ extension_settings.expressions.fallback_expression = null;
1685+ extension_settings.expressions.showDefault = true;
1686+ break;
1687+ default:
1688+ extension_settings.expressions.fallback_expression = selectedValue;
1689+ extension_settings.expressions.showDefault = false;
1690+ break;
1691+ }
1692+
1693+ const img = $('img.expression');
1694+ const spriteFolderName = img.attr('data-sprite-folder-name');
1695+ const expression = img.attr('data-expression');
1696+
1697+ if (spriteFolderName && expression) {
1698+ await sendExpressionCall(spriteFolderName, expression, { force: true });
17151699 }
1700+
1701+ saveSettingsDebounced();
17161702}
17171703
17181704async function handleFileUpload(url, formData) {
@@ -1739,34 +1725,111 @@ async function handleFileUpload(url, formData) {
17391725 }
17401726}
17411727
1728+/**
1729+ * Removes the file extension from a file name
1730+ * @param {string} fileName The file name to remove the extension from
1731+ * @returns {string} The file name without the extension
1732+ */
1733+function withoutExtension(fileName) {
1734+ return fileName.replace(/\.[^/.]+$/, '');
1735+}
1736+
1737+function validateExpressionSpriteName(expression, spriteName) {
1738+ const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);
1739+ const validFileName = filenameValidationRegex.test(spriteName);
1740+ return validFileName;
1741+}
1742+
17421743async function onClickExpressionUpload(event) {
17431744 // Prevents the expression from being set
17441745 event.stopPropagation();
17451746
17461747 const idexpressionListItem = $(this).closest('.expression_list_item').attr('id');
1748+
1749+ const clickedFileName = expressionListItem.attr('data-expression-type') !== 'failure' ? expressionListItem.attr('data-filename') : null;
1750+ const expression = expressionListItem.data('expression');
17471751 const name = $('#image_list').data('name');
17481752
17491753 const handleExpressionUploadChange = async (e) => {
17501754 const file = e.target.files[0];
17511755
17521756 if (!file || !file.name) {
1757+ console.debug('No valid file selected');
1758+ return;
1759+ }
1760+
1761+ const existingFiles = spriteCache[name]?.find(x => x.label === expression)?.files || [];
1762+
1763+ let spriteName = expression;
1764+
1765+ if (extension_settings.expressions.allowMultiple) {
1766+ const matchesExisting = existingFiles.some(x => x.fileName === file.name);
1767+ const fileNameWithoutExtension = withoutExtension(file.name);
1768+ const validFileName = validateExpressionSpriteName(expression, fileNameWithoutExtension);
1769+
1770+ // If there is no expression yet and it's a valid expression, we just take it
1771+ if (!clickedFileName && validFileName) {
1772+ spriteName = fileNameWithoutExtension;
1773+ }
1774+ // If the filename matches the one that was clicked, we just take it and replace it
1775+ else if (clickedFileName === file.name) {
1776+ spriteName = fileNameWithoutExtension;
1777+ }
1778+ // If it's a valid filename and there's no existing file with the same name, we just take it
1779+ else if (!matchesExisting && validFileName) {
1780+ spriteName = fileNameWithoutExtension;
1781+ }
1782+ else {
1783+ /** @type {import('../../popup.js').CustomPopupButton[]} */
1784+ const customButtons = [];
1785+ if (clickedFileName) {
1786+ customButtons.push({
1787+ text: t`Replace Existing`,
1788+ result: POPUP_RESULT.NEGATIVE,
1789+ action: () => {
1790+ console.debug('Replacing existing sprite');
1791+ spriteName = withoutExtension(clickedFileName);
1792+ },
1793+ });
1794+ }
1795+
1796+ spriteName = null;
1797+ const suggestedSpriteName = generateUniqueSpriteName(expression, existingFiles);
1798+
1799+ const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName });
1800+
1801+ const input = await Popup.show.input(t`Upload Expression Sprite`, message,
1802+ suggestedSpriteName, { customButtons: customButtons });
1803+
1804+ if (input) {
1805+ if (!validateExpressionSpriteName(expression, input)) {
1806+ toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`);
1807+ return;
1808+ }
1809+ spriteName = input;
1810+ }
1811+ }
1812+ } else {
1813+ spriteName = withoutExtension(clickedFileName);
1814+ }
1815+
1816+ if (!spriteName) {
1817+ toastr.warning(t`Cancelled uploading sprite.`, t`Upload Cancelled`);
1818+ // Reset the input
1819+ e.target.form.reset();
17531820 return;
17541821 }
17551822
17561823 const formData = new FormData();
17571824 formData.append('name', name);
17581825 formData.append('label', idexpression);
17591826 formData.append('avatar', file);
1827+ formData.append('spriteName', spriteName);
17601828
17611829 await handleFileUpload('/api/sprites/upload', formData);
17621830
17631831 // Reset the input
17641832 e.target.form.reset();
1765-
1766- // In Talkinghead mode, when a new talkinghead image is uploaded, refresh the live char.
1767- if (id === 'talkinghead' && isTalkingHeadEnabled() && modules.includes('talkinghead')) {
1768- await loadTalkingHead();
1769- }
17701833 };
17711834
17721835 $('#expression_upload')
@@ -1822,8 +1885,9 @@ async function onClickExpressionOverrideButton() {
18221885 inApiCall = true;
18231886 $('#visual-novel-wrapper').empty();
18241887 await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);
1888+ const name = overridePath.length === 0 ? currentLastMessage.name : overridePath;
18251889 const expression = await getExpressionLabel(currentLastMessage.mes);
18261890 await sendExpressionCall(overridePath.length === 0 ? currentLastMessage.name : overridePath, expression, { force: true });
18271891 forceUpdateVisualNovelMode();
18281892 } catch (error) {
18291893 console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);
@@ -1849,7 +1913,7 @@ async function onClickExpressionOverrideRemoveAllButton() {
18491913 const currentLastMessage = getLastCharacterMessage();
18501914 await validateImages(currentLastMessage.name, true);
18511915 const expression = await getExpressionLabel(currentLastMessage.mes);
18521916 await sendExpressionCall(currentLastMessage.name, expression, { force: true });
18531917 forceUpdateVisualNovelMode();
18541918
18551919 console.debug(extension_settings.expressionOverrides);
@@ -1872,16 +1936,13 @@ async function onClickExpressionUploadPackButton() {
18721936 formData.append('name', name);
18731937 formData.append('avatar', file);
18741938
1939+ const uploadToast = toastr.info('Please wait...', 'Upload is processing', { timeOut: 0, extendedTimeOut: 0 });
18751940 const { count } = await handleFileUpload('/api/sprites/upload-zip', formData);
1941+ toastr.clear(uploadToast);
18761942 toastr.success(`Uploaded ${count} image(s) for ${name}`);
18771943
18781944 // Reset the input
18791945 e.target.form.reset();
1880-
1881- // In Talkinghead mode, refresh the live char.
1882- if (isTalkingHeadEnabled() && modules.includes('talkinghead')) {
1883- await loadTalkingHead();
1884- }
18851946 };
18861947
18871948 $('#expression_upload_pack')
@@ -1894,20 +1955,28 @@ async function onClickExpressionDelete(event) {
18941955 // Prevents the expression from being set
18951956 event.stopPropagation();
18961957
1897- const confirmation = await callPopup('<h3>Are you sure?</h3>Once deleted, it\'s gone forever!', 'confirm');
1958+ const expressionListItem = $(this).closest('.expression_list_item');
1959+ const expression = expressionListItem.data('expression');
1960+
1961+ if (expressionListItem.attr('data-expression-type') === 'failure') {
1962+ return;
1963+ }
18981964
1965+ 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!`
1966+ + '<br /><br />'
1967+ + t`Expression:` + ' <tt>' + expressionListItem.attr('data-filename') + '</tt>');
18991968 if (!confirmation) {
19001969 return;
19011970 }
19021971
19031972 const idfileName = $(this).closestwithoutExtension('.expression_list_item')expressionListItem.attr('iddata-filename'));
19041973 const name = $('#image_list').data('name');
19051974
19061975 try {
19071976 await fetch('/api/sprites/delete', {
19081977 method: 'POST',
19091978 headers: getRequestHeaders(),
19101979 body: JSON.stringify({ name, label: idexpression, spriteName: fileName }),
19111980 });
19121981 } catch (error) {
19131982 toastr.error('Failed to delete image. Try again later.');
@@ -1984,6 +2053,16 @@ function migrateSettings() {
19842053 extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT;
19852054 saveSettingsDebounced();
19862055 }
2056+
2057+ if (extension_settings.expressions.allowMultiple === undefined) {
2058+ extension_settings.expressions.allowMultiple = true;
2059+ saveSettingsDebounced();
2060+ }
2061+
2062+ if (extension_settings.expressions.showDefault && extension_settings.expressions.fallback_expression !== undefined) {
2063+ extension_settings.expressions.showDefault = false;
2064+ saveSettingsDebounced();
2065+ }
19872066}
19882067
19892068(async function () {
@@ -2010,13 +2089,19 @@ function migrateSettings() {
20102089 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');
20112090 $('#expressions_container').append(template);
20122091 $('#expression_override_button').on('click', onClickExpressionOverrideButton);
2013- $('#expressions_show_default').on('input', onExpressionsShowDefaultInput);
20142092 $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton);
2015- $('#expressions_show_default').prop('checked', extension_settings.expressions.showDefault).trigger('input');
20162093 $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () {
20172094 extension_settings.expressions.translate = !!$(this).prop('checked');
20182095 saveSettingsDebounced();
20192096 });
2097+ $('#expressions_allow_multiple').prop('checked', extension_settings.expressions.allowMultiple).on('input', function () {
2098+ extension_settings.expressions.allowMultiple = !!$(this).prop('checked');
2099+ saveSettingsDebounced();
2100+ });
2101+ $('#expressions_reroll_if_same').prop('checked', extension_settings.expressions.rerollIfSame).on('input', function () {
2102+ extension_settings.expressions.rerollIfSame = !!$(this).prop('checked');
2103+ saveSettingsDebounced();
2104+ });
20202105 $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton);
20212106 $(document).on('dragstart', '.expression', (e) => {
20222107 e.preventDefault();
@@ -2025,21 +2110,15 @@ function migrateSettings() {
20252110 $(document).on('click', '.expression_list_item', onClickExpressionImage);
20262111 $(document).on('click', '.expression_list_upload', onClickExpressionUpload);
20272112 $(document).on('click', '.expression_list_delete', onClickExpressionDelete);
20282113 $(window).on('resize', () => updateVisualNovelModeDebounced());
20292114 $('#open_chat_expressions').hide();
20302115
2031- $('#image_type_toggle').on('click', function () {
2032- if (this instanceof HTMLInputElement) {
2033- setTalkingHeadState(this.checked);
2034- }
2035- });
2036-
20372116 await renderAdditionalExpressionSettings();
20382117 $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras);
20392118 $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api));
20402119 $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? '');
20412120 $('#expression_llm_prompt').on('input', function () {
20422121 extension_settings.expressions.llmPrompt = String($(this).val());
20432122 saveSettingsDebounced();
20442123 });
20452124 $('#expression_llm_prompt_restore').on('click', function () {
@@ -2054,34 +2133,6 @@ function migrateSettings() {
20542133 $('#expression_api').on('change', onExpressionApiChanged);
20552134 }
20562135
2057- // Pause Talkinghead to save resources when the ST tab is not visible or the window is minimized.
2058- // We currently do this via loading/unloading. Could be improved by adding new pause/unpause endpoints to Extras.
2059- document.addEventListener('visibilitychange', function (event) {
2060- let pageIsVisible;
2061- if (document.hidden) {
2062- console.debug('expressions: SillyTavern is now hidden');
2063- pageIsVisible = false;
2064- } else {
2065- console.debug('expressions: SillyTavern is now visible');
2066- pageIsVisible = true;
2067- }
2068-
2069- if (isTalkingHeadEnabled() && modules.includes('talkinghead')) {
2070- isTalkingHeadAvailable().then(result => {
2071- if (result) {
2072- if (pageIsVisible) {
2073- loadTalkingHead();
2074- } else {
2075- unloadTalkingHead();
2076- }
2077- handleImageChange(); // Change image as needed
2078- } else {
2079- //console.log("talkinghead does not exist.");
2080- }
2081- });
2082- }
2083- });
2084-
20852136 addExpressionImage();
20862137 addVisualNovelMode();
20872138 migrateSettings();
@@ -2090,11 +2141,6 @@ function migrateSettings() {
20902141 const updateFunction = wrapper.update.bind(wrapper);
20912142 setInterval(updateFunction, UPDATE_INTERVAL);
20922143 moduleWorker();
2093- // For setting the Talkinghead talking animation on/off quickly enough for realtime use, we need another timer on a shorter schedule.
2094- const wrapperTalkingState = new ModuleWorkerWrapper(updateTalkingState);
2095- const updateTalkingStateFunction = wrapperTalkingState.update.bind(wrapperTalkingState);
2096- setInterval(updateTalkingStateFunction, TALKINGCHECK_UPDATE_INTERVAL);
2097- updateTalkingState();
20982144 dragElement($('#expression-holder'));
20992145 eventSource.on(event_types.CHAT_CHANGED, () => {
21002146 // character changed
@@ -2108,110 +2154,137 @@ function migrateSettings() {
21082154 imgElement.src = '';
21092155 }
21102156
2111- //set checkbox to global var
2112- $('#image_type_toggle').prop('checked', extension_settings.expressions.talkinghead);
2113- if (extension_settings.expressions.talkinghead) {
2114- setTalkingHeadState(extension_settings.expressions.talkinghead);
2115- }
2116-
21172157 setExpressionOverrideHtml();
21182158
21192159 if (isVisualNovelMode()) {
21202160 $('#visual-novel-wrapper').empty();
21212161 }
21222162
21232163 updateFunction({ newChat: true });
21242164 });
21252165 eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);
21262166 eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);
2127- eventSource.on(event_types.EXTRAS_CONNECTED, () => {
2128- if (extension_settings.expressions.talkinghead) {
2129- setTalkingHeadState(extension_settings.expressions.talkinghead);
2130- }
2131- });
21322167
21332168 const localEnumProviders = {
21342169 expressions: () => getCachedExpressions().map(expression => {
2170+ const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
2171+ const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
2172+ const expressions = getCachedExpressions();
2173+ return expressions.map(expression => {
2174+ const spriteCount = spriteCache[spriteFolderName]?.find(x => x.label === expression)?.files.length ?? 0;
21352175 const isCustom = extension_settings.expressions.custom?.includes(expression);
2136- return new SlashCommandEnumValue(expression, null, isCustom ? enumTypes.name : enumTypes.enum, isCustom ? 'C' : 'D');
2176+ const subtitle = spriteCount == 0 ? '❌ No sprites available for this expression' :
2137- }),
2177+ spriteCount > 1 ? `${spriteCount} sprites` : null;
2178+ return new SlashCommandEnumValue(expression,
2179+ subtitle,
2180+ isCustom ? enumTypes.name : enumTypes.enum,
2181+ isCustom ? 'C' : 'D');
2182+ });
2183+ },
2184+ sprites: () => {
2185+ const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
2186+ const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
2187+ const sprites = spriteCache[spriteFolderName]?.map(x => x.files)?.flat() ?? [];
2188+ return sprites.map(x => {
2189+ return new SlashCommandEnumValue(x.title,
2190+ x.title !== x.expression ? x.expression : null,
2191+ x.isCustom ? enumTypes.name : enumTypes.enum,
2192+ x.isCustom ? 'C' : 'D');
2193+ });
2194+ },
21382195 };
21392196
21402197 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
21412198 name: 'spriteexpression-set',
21422199 aliases: ['sprite', 'emote'],
21432200 callback: setSpriteSlashCommand,
2201+ namedArgumentList: [
2202+ SlashCommandNamedArgument.fromProps({
2203+ name: 'type',
2204+ description: 'Whether to set an expression or a specific sprite.',
2205+ typeList: [ARGUMENT_TYPE.STRING],
2206+ isRequired: false,
2207+ defaultValue: 'expression',
2208+ enumList: ['expression', 'sprite'],
2209+ }),
2210+ ],
21442211 unnamedArgumentList: [
21452212 SlashCommandArgument.fromProps({
21462213 description: 'spriteIdexpression label to set',
21472214 typeList: [ARGUMENT_TYPE.STRING],
21482215 isRequired: true,
2149- enumProvider: localEnumProviders.expressions,
2216+ enumProvider: (executor, _) => {
2217+ // Check if command is used to set a sprite, then use those enums
2218+ const type = executor.namedArgumentList.find(it => it.name == 'type')?.value || 'expression';
2219+ if (type == 'sprite') return localEnumProviders.sprites();
2220+ else return [
2221+ ...localEnumProviders.expressions(),
2222+ new SlashCommandEnumValue(RESET_SPRITE_LABEL, 'Resets the expression (to either default or no sprite)', enumTypes.enum, '❌'),
2223+ ];
2224+ },
21502225 }),
21512226 ],
21522227 helpString: 'Force sets the spriteexpression for the current character.',
21532228 returns: 'theThe currently set spriteexpression label after setting it.',
21542229 }));
21552230 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
21562231 name: 'spriteoverrideexpression-folder-override',
21572232 aliases: ['spriteoverride', 'costume'],
21582233 callback: setSpriteSetCommandsetSpriteFolderCommand,
21592234 unnamedArgumentList: [
21602235 new SlashCommandArgument(
21612236 'optional folder', [ARGUMENT_TYPE.STRING], false,
21622237 ),
21632238 ],
2164- 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.',
2239+ helpString: `
2240+ <div>
2241+ Sets an override sprite folder for the current character.<br />
2242+ In groups, this will apply to the character who last sent a message.
2243+ </div>
2244+ <div>
2245+ 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.
2246+ </div>
2247+ `,
21652248 }));
21662249 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
21672250 name: 'lastspriteexpression-last',
2168- callback: (_, name) => {
2251+ aliases: ['lastsprite'],
2252+ /** @type {(args: object, name: string) => Promise<string>} */
2253+ callback: async (_, name) => {
21692254 if (typeof name !== 'string') throw new Error('name must be a string');
2255+ if (!name) {
2256+ if (selected_group) {
2257+ toastr.error(t`In group chats, you must specify a character name.`, t`No character name specified`);
2258+ return '';
2259+ }
2260+ name = characters[this_chid]?.avatar;
2261+ }
2262+
21702263 const char = findChar({ name: name });
2264+ if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`);
2265+
21712266 const sprite = lastExpression[char?.name ?? name] ?? '';
21722267 return sprite;
21732268 },
21742269 returns: 'the last set sprite / expression for the named character.',
21752270 unnamedArgumentList: [
21762271 SlashCommandArgument.fromProps({
21772272 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)',
21782273 typeList: [ARGUMENT_TYPE.STRING],
2179- isRequired: true,
21802274 enumProvider: commonEnumProviders.characters('character'),
21812275 }),
21822276 ],
21832277 helpString: 'Returns the last set sprite / expression for the named character.',
2184- }));
2185- SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2186- name: 'th',
2187- callback: toggleTalkingHeadCommand,
2188- aliases: ['talkinghead'],
2189- helpString: 'Character Expressions: toggles <i>Image Type - talkinghead (extras)</i> on/off.',
2190- returns: 'the current state of the <i>Image Type - talkinghead (extras)</i> on/off.',
21912278 }));
21922279 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
21932280 name: 'classifyexpression-expressionslist',
21942281 aliases: ['expressions'],
2282+ /** @type {(args: {return: string}) => Promise<string>} */
21952283 callback: async (args) => {
2284+ let returnType =
21962285 /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
2197- // @ts-ignore
2286+ (args.return);
2198- let returnType = args.return;
2199-
2200- // Old legacy return type handling
2201- if (args.format) {
2202- toastr.warning(`Legacy argument 'format' with value '${args.format}' is deprecated. Please use 'return' instead. Routing to the correct return type...`, 'Deprecation warning');
2203- const type = String(args?.format).toLowerCase().trim();
2204- switch (type) {
2205- case 'json':
2206- returnType = 'object';
2207- break;
2208- default:
2209- returnType = 'pipe';
2210- break;
2211- }
2212- }
22132287
2214- // Now the actual new return type handling
22152288 const list = await getExpressionsList();
22162289
22172290 return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });
@@ -2225,22 +2298,13 @@ function migrateSettings() {
22252298 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
22262299 forceEnum: true,
22272300 }),
2228- // TODO remove some day
2229- SlashCommandNamedArgument.fromProps({
2230- name: 'format',
2231- description: '!!! DEPRECATED - use "return" instead !!! The format to return the list in: comma-separated plain text or JSON array. Default is plain text.',
2232- typeList: [ARGUMENT_TYPE.STRING],
2233- enumList: [
2234- new SlashCommandEnumValue('plain', null, enumTypes.enum, ', '),
2235- new SlashCommandEnumValue('json', null, enumTypes.enum, '[]'),
2236- ],
2237- }),
22382301 ],
22392302 returns: 'The comma-separated list of available expressions, including custom expressions.',
22402303 helpString: 'Returns a list of available expressions, including custom expressions.',
22412304 }));
22422305 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
22432306 name: 'expression-classify',
2307+ aliases: ['classify'],
22442308 callback: classifyCallback,
22452309 namedArgumentList: [
22462310 SlashCommandNamedArgument.fromProps({
@@ -2279,11 +2343,13 @@ function migrateSettings() {
22792343 `,
22802344 }));
22812345 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
22822346 name: 'uploadspriteexpression-upload',
2347+ aliases: ['uploadsprite'],
2348+ /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */
22832349 callback: async (args, url) => {
22842350 return await uploadSpriteCommand(args, url);
2285- return '';
22862351 },
2352+ returns: 'the resulting sprite name',
22872353 unnamedArgumentList: [
22882354 SlashCommandArgument.fromProps({
22892355 description: 'URL of the image to upload',
@@ -2297,7 +2363,6 @@ function migrateSettings() {
22972363 description: 'Character name or avatar key (default is current character)',
22982364 typeList: [ARGUMENT_TYPE.STRING],
22992365 isRequired: false,
2300- acceptsMultiple: false,
23012366 }),
23022367 SlashCommandNamedArgument.fromProps({
23032368 name: 'label',
@@ -2305,16 +2370,32 @@ function migrateSettings() {
23052370 typeList: [ARGUMENT_TYPE.STRING],
23062371 enumProvider: localEnumProviders.expressions,
23072372 isRequired: true,
2308- acceptsMultiple: false,
23092373 }),
23102374 SlashCommandNamedArgument.fromProps({
23112375 name: 'folder',
23122376 description: 'Override folder to upload into',
23132377 typeList: [ARGUMENT_TYPE.STRING],
23142378 isRequired: false,
2315- acceptsMultiple: false,
2379+ }),
2380+ SlashCommandNamedArgument.fromProps({
2381+ name: 'spriteName',
2382+ 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.',
2383+ typeList: [ARGUMENT_TYPE.STRING],
2384+ isRequired: false,
23162385 }),
23172386 ],
2318- helpString: '<div>Upload a sprite from a URL.</div><div>Example:</div><pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre>',
2387+ helpString: `
2388+ <div>
2389+ Upload a sprite from a URL.
2390+ </div>
2391+ <div>
2392+ <strong>Example:</strong>
2393+ <ul>
2394+ <li>
2395+ <pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre>
2396+ </li>
2397+ </ul>
2398+ </div>
2399+ `,
23192400 }));
23202401})();
public/scripts/extensions/expressions/list-item.html+9 -5
@@ -1,4 +1,5 @@
1-<div id="{{item}}" class="expression_list_item">
1+{{#each images}}
2+<div class="expression_list_item interactable" data-expression="{{../expression}}" data-expression-type="{{this.type}}" data-filename="{{this.fileName}}">
23 <div class="expression_list_buttons">
34 <div class="menu_button expression_list_upload" title="Upload image">
45 <i class="fa-solid fa-upload"></i>
@@ -7,11 +8,14 @@
78 <i class="fa-solid fa-trash"></i>
89 </div>
910 </div>
1011 <div class="expression_list_title {{textClass}}">
1112 <span>{{item../expression}}</span>
1213 {{#if ../isCustom}}
1314 <small class="expression_list_custom">(custom)</small>
1415 {{/if}}
1516 </div>
1617 <imgdiv class="expression_list_imageexpression_list_image_container" srctitle="{{imageSrcthis.title}}" />
18+ <img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" />
1719 </div>
20+</div>
21+{{/each}}
public/scripts/extensions/expressions/settings.html+21 -9
@@ -6,17 +6,17 @@
66 </div>
77
88 <div class="inline-drawer-content">
99 <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.">
1010 <input id="expression_translate" type="checkbox">
1111 <span data-i18n="Translate text to English before classification">Translate text to English before classification</span>
1212 </label>
13- <label class="checkbox_label" for="expressions_show_default">
13+ <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.">
1414 <input id="expressions_show_defaultexpressions_allow_multiple" type="checkbox">
1515 <span data-i18n="Show default imagesAllow (emojis)multiple ifsprites spriteper missingexpression">Show default imagesAllow (emojis)multiple ifsprites spriteper missingexpression</span>
1616 </label>
17- <label id="image_type_block" class="checkbox_label" for="image_type_toggle">
17+ <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.">
1818 <input id="image_type_toggleexpressions_reroll_if_same" type="checkbox">
1919 <span data-i18n="ImageRe-roll Typeif -same talkingheadexpression (extras)is used again">ImageRe-roll Typeif -same talkingheadsprite (extras)is used again</span>
2020 </label>
2121 <div class="expression_api_block m-b-1 m-t-1">
2222 <label for="expression_api" data-i18n="Classifier API">Classifier API</label>
@@ -75,8 +75,20 @@
7575 <span data-i18n="Remove all image overrides">Remove all image overrides</span>
7676 </div>
7777 </div>
78- <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>
78+ <p class="hint">
79- <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>
79+ <b data-i18n="Hint:">Hint:</b>
80+ <i>
81+ <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>
82+ <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>
83+ </i>
84+ </p>
85+ <p>
86+ <i>
87+ <span>In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a
88+ dash.
89+ Examples: </span><tt>joy.png</tt>, <tt>joy-1.png</tt>, <tt>joy.expressive.png</tt>
90+ </i>
91+ </p>
8092 <h3 id="image_list_header">
8193 <strong data-i18n="Sprite set:">Sprite set:</strong>&nbsp;<span id="image_list_header_name"></span>
8294 </h3>
public/scripts/extensions/expressions/style.css+31 -2
@@ -111,6 +111,10 @@ img.expression.default {
111111 justify-content: center;
112112}
113113
114+.expression_list_image_container {
115+ overflow: hidden;
116+}
117+
114118.expression_list_title {
115119 position: absolute;
116120 bottom: 0;
@@ -126,6 +130,9 @@ img.expression.default {
126130 flex-direction: column;
127131 line-height: 1;
128132}
133+.expression_list_custom {
134+ font-size: 0.66rem;
135+}
129136
130137.expression_list_buttons {
131138 position: absolute;
@@ -162,11 +169,24 @@ img.expression.default {
162169 row-gap: 1rem;
163170}
164171
165172#image_list .expression_list_item[data-expression-type="success"] .expression_list_title {
166173 color: green;
167174}
168175
169-#image_list .failure {
176+#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title {
177+ color: darkolivegreen;
178+}
179+#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title::before {
180+ content: '➕';
181+ position: absolute;
182+ top: -7px;
183+ left: -9px;
184+ font-size: 14px;
185+ color: transparent;
186+ text-shadow: 0 0 0 darkolivegreen;
187+}
188+
189+#image_list .expression_list_item[data-expression-type="failure"] .expression_list_title {
170190 color: red;
171191}
172192
@@ -189,3 +209,12 @@ img.expression.default {
189209 flex-direction: row;
190210}
191211
212+#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"],
213+#expressions_container:has(#expressions_allow_multiple:not(:checked)) label[for="expressions_reroll_if_same"] {
214+ opacity: 0.3;
215+ transition: opacity var(--animation-duration) ease;
216+}
217+#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:hover,
218+#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:focus {
219+ opacity: unset;
220+}
public/scripts/extensions/expressions/templates/upload-expression.html+12 -0
@@ -0,0 +1,12 @@
1+<div class="m-b-1" data-i18n="upload_expression_request">Please enter a name for the sprite (without extension).</div>
2+<div class="m-b-1" data-i18n="upload_expression_naming_1">
3+ Sprite names must follow the naming schema for the selected expression: {{expression}}
4+</div>
5+<div data-i18n="upload_expression_naming_2">
6+ For multiple expressions, the name must follow the expression name and a valid suffix. Allowed separators are '-' or dot '.'.
7+</div>
8+<span class="m-b-1" data-i18n="Examples:">Examples:</span> <tt>{{expression}}.png</tt>, <tt>{{expression}}-1.png</tt>, <tt>{{expression}}.expressive.png</tt>
9+{{#if clickedFileName}}
10+<div class="m-t-1" data-i18n="upload_expression_replace">Click 'Replace' to replace the existing expression:</div>
11+<tt>{{clickedFileName}}</tt>
12+{{/if}}
public/scripts/extensions/translate/index.js+1 -1
@@ -605,7 +605,7 @@ const handleOutgoingMessage = createEventHandler(translateOutgoingMessage, () =>
605605const handleImpersonateReady = createEventHandler(translateImpersonate, () => shouldTranslate(incomingTypes));
606606const handleMessageEdit = createEventHandler(translateMessageEdit, () => true);
607607
608608window['globalThis.translate'] = translate;
609609
610610jQuery(async () => {
611611 const html = await renderExtensionTemplateAsync('translate', 'index');
public/scripts/extensions/tts/index.js+0 -26
@@ -27,14 +27,12 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm
2727import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
2828import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
2929import { GoogleTranslateTtsProvider } from './google-translate.js';
30-export { talkingAnimation };
3130
3231const UPDATE_INTERVAL = 1000;
3332const wrapper = new ModuleWorkerWrapper(moduleWorker);
3433
3534let voiceMapEntries = [];
3635let voiceMap = {}; // {charName:voiceid, charName2:voiceid2}
37-let talkingHeadState = false;
3836let lastChatId = null;
3937let lastMessage = null;
4038let lastMessageHash = null;
@@ -166,27 +164,6 @@ async function moduleWorker() {
166164 updateUiAudioPlayState();
167165}
168166
169-function talkingAnimation(switchValue) {
170- if (!modules.includes('talkinghead')) {
171- console.debug('Talking Animation module not loaded');
172- return;
173- }
174-
175- const apiUrl = getApiUrl();
176- const animationType = switchValue ? 'start' : 'stop';
177-
178- if (switchValue !== talkingHeadState) {
179- try {
180- console.log(animationType + ' Talking Animation');
181- doExtrasFetch(`${apiUrl}/api/talkinghead/${animationType}_talking`);
182- talkingHeadState = switchValue;
183- } catch (error) {
184- // Handle the error here or simply ignore it to prevent logging
185- }
186- }
187- updateUiAudioPlayState();
188-}
189-
190167function resetTtsPlayback() {
191168 // Stop system TTS utterance
192169 cancelTtsPlay();
@@ -378,7 +355,6 @@ function onAudioControlClicked() {
378355 // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful
379356 if (!audioElement.paused || isTtsProcessing()) {
380357 resetTtsPlayback();
381- talkingAnimation(false);
382358 } else {
383359 // Default play behavior if not processing or playing is to play the last message.
384360 processAndQueueTtsMessage(context.chat[context.chat.length - 1]);
@@ -405,7 +381,6 @@ function addAudioControl() {
405381function completeCurrentAudioJob() {
406382 audioQueueProcessorReady = true;
407383 currentAudioJob = null;
408- talkingAnimation(false); //stop lip animation
409384 // updateUiPlayState();
410385 wrapper.update();
411386}
@@ -436,7 +411,6 @@ async function processAudioJobQueue() {
436411 audioQueueProcessorReady = false;
437412 currentAudioJob = audioJobQueue.shift();
438413 playAudioData(currentAudioJob);
439- talkingAnimation(true);
440414 } catch (error) {
441415 toastr.error(error.toString());
442416 console.error(error);
public/scripts/extensions/tts/system.js+0 -3
@@ -1,6 +1,5 @@
11import { isMobile } from '../../RossAscends-mods.js';
22import { getPreviewString } from './index.js';
3-import { talkingAnimation } from './index.js';
43import { saveTtsProviderSettings } from './index.js';
54export { SystemTtsProvider };
65
@@ -70,7 +69,6 @@ var speechUtteranceChunker = function (utt, settings, callback) {
7069 //placing the speak invocation inside a callback fixes ordering and onend issues.
7170 setTimeout(function () {
7271 speechSynthesis.speak(newUtt);
73- talkingAnimation(true);
7472 }, 0);
7573};
7674
@@ -240,7 +238,6 @@ class SystemTtsProvider {
240238 //some code to execute when done
241239 resolve(silence);
242240 console.log('System TTS done');
243- talkingAnimation(false);
244241 });
245242 });
246243 }
public/scripts/extensions/vectors/index.js+2 -2
@@ -561,9 +561,9 @@ async function retrieveFileChunks(queryText, collectionId) {
561561 */
562562async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overlapPercent) {
563563 try {
564564 if (settings.translate_files && typeof window['globalThis.translate'] === 'function') {
565565 console.log(`Vectors: Translating file ${fileName} to English...`);
566566 const translatedText = await window['globalThis.translate'](fileText, 'en');
567567 fileText = translatedText;
568568 }
569569
public/scripts/openai.js+45 -28
@@ -2167,6 +2167,14 @@ function getStreamingReply(data, state) {
21672167 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');
21682168 }
21692169 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
2170+ } else if (oai_settings.chat_completion_source === chat_completion_sources.CUSTOM) {
2171+ if (oai_settings.show_thoughts) {
2172+ state.reasoning +=
2173+ data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content ??
2174+ data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning ??
2175+ '';
2176+ }
2177+ return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
21702178 } else {
21712179 return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? '';
21722180 }
@@ -4107,6 +4115,40 @@ function getMaxContextWindowAI(value) {
41074115 }
41084116}
41094117
4118+/**
4119+ * Get the maximum context size for the Groq model
4120+ * @param {string} model Model identifier
4121+ * @param {boolean} isUnlocked Whether context limits are unlocked
4122+ * @returns {number} Maximum context size in tokens
4123+ */
4124+function getGroqMaxContext(model, isUnlocked) {
4125+ if (isUnlocked) {
4126+ return unlocked_max;
4127+ }
4128+
4129+ const contextMap = {
4130+ 'gemma2-9b-it': max_8k,
4131+ 'llama-3.3-70b-versatile': max_128k,
4132+ 'llama-3.1-8b-instant': max_128k,
4133+ 'llama3-70b-8192': max_8k,
4134+ 'llama3-8b-8192': max_8k,
4135+ 'llama-guard-3-8b': max_8k,
4136+ 'mixtral-8x7b-32768': max_32k,
4137+ 'deepseek-r1-distill-llama-70b': max_128k,
4138+ 'llama-3.3-70b-specdec': max_8k,
4139+ 'llama-3.2-1b-preview': max_128k,
4140+ 'llama-3.2-3b-preview': max_128k,
4141+ 'llama-3.2-11b-vision-preview': max_128k,
4142+ 'llama-3.2-90b-vision-preview': max_128k,
4143+ 'qwen-2.5-32b': max_128k,
4144+ 'deepseek-r1-distill-qwen-32b': max_128k,
4145+ 'deepseek-r1-distill-llama-70b-specdec': max_128k,
4146+ };
4147+
4148+ // Return context size if model found, otherwise default to 128k
4149+ return Object.entries(contextMap).find(([key]) => model.includes(key))?.[1] || max_128k;
4150+}
4151+
41104152async function onModelChange() {
41114153 biasCache = undefined;
41124154 let value = String($(this).val() || '');
@@ -4387,7 +4429,7 @@ async function onModelChange() {
43874429 if (oai_settings.max_context_unlocked) {
43884430 $('#openai_max_context').attr('max', unlocked_max);
43894431 }
43904432 else if (['sonar', 'sonar-reasoning', 'sonar-reasoning-pro', 'r1-1776'].includes(oai_settings.perplexity_model)) {
43914433 $('#openai_max_context').attr('max', 127000);
43924434 }
43934435 else if (['sonar-pro'].includes(oai_settings.perplexity_model)) {
@@ -4408,33 +4450,8 @@ async function onModelChange() {
44084450 }
44094451
44104452 if (oai_settings.chat_completion_source == chat_completion_sources.GROQ) {
4411- if (oai_settings.max_context_unlocked) {
4453+ const maxContext = getGroqMaxContext(oai_settings.groq_model, oai_settings.max_context_unlocked);
44124454 $('#openai_max_context').attr('max', unlocked_maxmaxContext);
4413- } else if (oai_settings.groq_model.includes('gemma2-9b-it')) {
4414- $('#openai_max_context').attr('max', max_8k);
4415- } else if (oai_settings.groq_model.includes('llama-3.3-70b-versatile')) {
4416- $('#openai_max_context').attr('max', max_128k);
4417- } else if (oai_settings.groq_model.includes('llama-3.1-8b-instant')) {
4418- $('#openai_max_context').attr('max', max_128k);
4419- } else if (oai_settings.groq_model.includes('llama3-70b-8192')) {
4420- $('#openai_max_context').attr('max', max_8k);
4421- } else if (oai_settings.groq_model.includes('llama3-8b-8192')) {
4422- $('#openai_max_context').attr('max', max_8k);
4423- } else if (oai_settings.groq_model.includes('mixtral-8x7b-32768')) {
4424- $('#openai_max_context').attr('max', max_32k);
4425- } else if (oai_settings.groq_model.includes('deepseek-r1-distill-llama-70b')) {
4426- $('#openai_max_context').attr('max', max_128k);
4427- } else if (oai_settings.groq_model.includes('llama-3.3-70b-specdec')) {
4428- $('#openai_max_context').attr('max', max_8k);
4429- } else if (oai_settings.groq_model.includes('llama-3.2-1b-preview')) {
4430- $('#openai_max_context').attr('max', max_128k);
4431- } else if (oai_settings.groq_model.includes('llama-3.2-3b-preview')) {
4432- $('#openai_max_context').attr('max', max_128k);
4433- } else if (oai_settings.groq_model.includes('llama-3.2-11b-vision-preview')) {
4434- $('#openai_max_context').attr('max', max_128k);
4435- } else if (oai_settings.groq_model.includes('llama-3.2-90b-vision-preview')) {
4436- $('#openai_max_context').attr('max', max_128k);
4437- }
44384455 oai_settings.openai_max_context = Math.min(Number($('#openai_max_context').attr('max')), oai_settings.openai_max_context);
44394456 $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
44404457 oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai);
public/scripts/power-user.js+5 -4
@@ -1845,14 +1845,15 @@ async function loadContextSettings() {
18451845
18461846/**
18471847 * Common function to perform fuzzy search with optional caching
1848+ * @template T
18481849 * @param {string} type - Type of search from fuzzySearchCategories
18491850 * @param {anyT[]} data - Data array to search in
18501851 * @param {Array<{name: string, weight: number, getFn?: (obj: anyT) => string}>} keys - Fuse.js keys configuration
18511852 * @param {string} searchValue - The search term
18521853 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
18531854 * @returns {import('fuse.js').FuseResult<anyT>[]} Results as items with their score
18541855 */
18551856export function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {
18561857 // Check cache if provided
18571858 if (fuzzySearchCaches) {
18581859 const cache = fuzzySearchCaches[type];
public/scripts/reasoning.js+8 -2
@@ -76,6 +76,11 @@ export function extractReasoningFromData(data) {
7676 return data?.choices?.[0]?.message?.reasoning ?? '';
7777 case chat_completion_sources.MAKERSUITE:
7878 return data?.responseContent?.parts?.filter(part => part.thought)?.map(part => part.text)?.join('\n\n') ?? '';
79+ case chat_completion_sources.CUSTOM: {
80+ return data?.choices?.[0]?.message?.reasoning_content
81+ ?? data?.choices?.[0]?.message?.reasoning
82+ ?? '';
83+ }
7984 }
8085 break;
8186 }
@@ -338,14 +343,15 @@ export class ReasoningHandler {
338343 return mesChanged;
339344 }
340345
341346 if (this.state === ReasoningState.None || this.#isHiddenReasoningModel) {
342347 // If streamed message starts with the opening, cut it out and put all inside reasoning
343348 if (message.mes.startsWith(power_user.reasoning.prefix) && message.mes.length > power_user.reasoning.prefix.length) {
344349 this.#isParsingReasoning = true;
345350
346351 // Manually set starting state here, as we might already have received the ending suffix
347352 this.state = ReasoningState.Thinking;
348353 this.startTime = this.startTime ?? this.initialTime;
354+ this.endTime = null;
349355 }
350356 }
351357
public/scripts/tokenizers.js+4 -1
@@ -679,7 +679,7 @@ export function getTokenizerModel() {
679679 }
680680
681681 if (oai_settings.chat_completion_source === chat_completion_sources.PERPLEXITY) {
682682 if (oai_settings.perplexity_model.includes('sonar-reasoning') || oai_settings.perplexity_model.includes('r1-1776')) {
683683 return deepseekTokenizer;
684684 }
685685 if (oai_settings.perplexity_model.includes('llama-3') || oai_settings.perplexity_model.includes('llama3')) {
@@ -694,6 +694,9 @@ export function getTokenizerModel() {
694694 }
695695
696696 if (oai_settings.chat_completion_source === chat_completion_sources.GROQ) {
697+ if (oai_settings.groq_model.includes('qwen')) {
698+ return qwen2Tokenizer;
699+ }
697700 if (oai_settings.groq_model.includes('llama-3') || oai_settings.groq_model.includes('llama3')) {
698701 return llama3Tokenizer;
699702 }
public/style.css+22 -20
@@ -55,6 +55,10 @@
5555 --interactable-outline-color: var(--white100);
5656 --interactable-outline-color-faint: var(--white20a);
5757
58+ --reasoning-body-color: var(--SmartThemeEmColor);
59+ --reasoning-em-color: color-mix(in srgb, var(--SmartThemeEmColor) 67%, var(--SmartThemeBlurTintColor) 33%);
60+ --reasoning-saturation: 0.5;
61+
5862
5963 /*Default Theme, will be changed by ToolCool Color Picker*/
6064 --SmartThemeBodyColor: rgb(220, 220, 210);
@@ -348,13 +352,13 @@ input[type='checkbox']:focus-visible {
348352
349353.mes_reasoning {
350354 display: block;
351355 border-left: 2px solid var(--SmartThemeEmColorreasoning-body-color);
352356 border-radius: 2px;
353357 padding: 5px;
354358 padding-left: 14px;
355359 margin-bottom: 0.5em;
356360 overflow-y: auto;
357- color: var(--SmartThemeEmColor);
361+ color: hsl(from var(--reasoning-body-color) h calc(s * var(--reasoning-saturation)) l);
358362}
359363
360364.mes_reasoning_details {
@@ -374,18 +378,6 @@ input[type='checkbox']:focus-visible {
374378 margin-bottom: 0;
375379}
376380
377-.mes_reasoning em,
378-.mes_reasoning i,
379-.mes_reasoning u,
380-.mes_reasoning q,
381-.mes_reasoning blockquote {
382- filter: saturate(0.5);
383-}
384-
385-.mes_reasoning_details .mes_reasoning em {
386- color: color-mix(in srgb, var(--SmartThemeEmColor) 67%, var(--SmartThemeBlurTintColor) 33%);
387-}
388-
389381.mes_reasoning_header_block {
390382 flex-grow: 1;
391383}
@@ -461,26 +453,36 @@ input[type='checkbox']:focus-visible {
461453}
462454
463455.mes_text i,
464456.mes_text em, {
457+ color: var(--SmartThemeEmColor);
458+}
465459.mes_reasoning i,
466460.mes_reasoning em {
467- color: var(--SmartThemeEmColor);
461+ color: hsl(from var(--reasoning-em-color) h calc(s * var(--reasoning-saturation)) l);
468462}
469463
470464.mes_text q i,
471465.mes_text q em {
472466 color: inherit;
473467}
468+.mes_reasoning q i,
469+.mes_reasoning q em {
470+ color: hsl(from var(--SmartThemeQuoteColor) h calc(s * var(--reasoning-saturation)) l);
471+}
474472
475473.mes_text u, {
476-.mes_reasoning u {
477474 color: var(--SmartThemeUnderlineColor);
478475}
476+.mes_reasoning u {
477+ color: hsl(from var(--SmartThemeUnderlineColor) h calc(s * var(--reasoning-saturation)) l);
478+}
479479
480480.mes_text q, {
481-.mes_reasoning q {
482481 color: var(--SmartThemeQuoteColor);
483482}
483+.mes_reasoning q {
484+ color: hsl(from var(--SmartThemeQuoteColor) h calc(s * var(--reasoning-saturation)) l);
485+}
484486
485487.mes_text font[color] em,
486488.mes_text font[color] i,
server.js+12 -4
@@ -57,7 +57,8 @@ import {
5757
5858import getWebpackServeMiddleware from './src/middleware/webpack-serve.js';
5959import basicAuthMiddleware from './src/middleware/basicAuth.js';
6060import whitelistMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/whitelist.js';
61+import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogWriter.js';
6162import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js';
6263import initRequestProxy from './src/request-proxy.js';
6364import getCacheBusterMiddleware from './src/middleware/cacheBuster.js';
@@ -243,7 +244,6 @@ const cliArguments = yargs(hideBin(process.argv))
243244 describe: 'Request proxy URL (HTTP or SOCKS protocols)',
244245 }).option('requestProxyBypass', {
245246 type: 'array',
246- default: null,
247247 describe: 'Request proxy bypass list (space separated list of hosts)',
248248 }).parseSync();
249249
@@ -340,9 +340,17 @@ const CORS = cors({
340340
341341app.use(CORS);
342342
343343if (listen && basicAuthMode) app.use(basicAuthMiddleware);{
344+ app.use(basicAuthMiddleware);
345+}
346+
347+if (enableWhitelist) {
348+ app.use(whitelistMiddleware());
349+}
344350
345-app.use(whitelistMiddleware(enableWhitelist, listen));
351+if (listen) {
352+ app.use(accessLoggerMiddleware());
353+}
346354
347355if (enableCorsProxy) {
348356 app.use(bodyParser.json({
src/endpoints/extensions.js+1 -1
@@ -230,7 +230,7 @@ router.post('/version', jsonParser, async (request, response) => {
230230 } catch (error) {
231231 // it is not a git repo, or has no commits yet, or is a bare repo
232232 // not possible to update it, most likely can't get the branch name either
233233 return response.send({ currentBranchName: null'', currentCommitHash: '', isUpToDate: true, remoteUrl: null'' });
234234 }
235235
236236 const currentBranch = await git.cwd(extensionPath).branch();
src/endpoints/sprites.js+13 -5
@@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) {
125125 .map((file) => {
126126 const pathToSprite = path.join(spritesPath, file);
127127 const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14);
128+
129+ const fileName = path.parse(pathToSprite).name.toLowerCase();
130+ // Extract the label from the filename via regex, which can be suffixed with a sub-name, either connected with a dash or a dot.
131+ // Examples: joy.png, joy-1.png, joy.expressive.png
132+ const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName;
133+
128134 return {
129- label: path.parse(pathToSprite).name.toLowerCase(),
135+ label: label,
130136 path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
131137 };
132138 });
@@ -141,8 +147,9 @@ router.get('/get', jsonParser, function (request, response) {
141147router.post('/delete', jsonParser, async (request, response) => {
142148 const label = request.body.label;
143149 const name = request.body.name;
150+ const spriteName = request.body.spriteName || label;
144151
145152 if (!labelspriteName || !name) {
146153 return response.sendStatus(400);
147154 }
148155
@@ -158,7 +165,7 @@ router.post('/delete', jsonParser, async (request, response) => {
158165
159166 // Remove existing sprite with the same label
160167 for (const file of files) {
161168 if (path.parse(file).name === labelspriteName) {
162169 fs.rmSync(path.join(spritesPath, file));
163170 }
164171 }
@@ -221,6 +228,7 @@ router.post('/upload', urlencodedParser, async (request, response) => {
221228 const file = request.file;
222229 const label = request.body.label;
223230 const name = request.body.name;
231+ const spriteName = request.body.spriteName || label;
224232
225233 if (!file || !label || !name) {
226234 return response.sendStatus(400);
@@ -243,12 +251,12 @@ router.post('/upload', urlencodedParser, async (request, response) => {
243251
244252 // Remove existing sprite with the same label
245253 for (const file of files) {
246254 if (path.parse(file).name === labelspriteName) {
247255 fs.rmSync(path.join(spritesPath, file));
248256 }
249257 }
250258
251259 const filename = labelspriteName + path.parse(file.originalname).ext;
252260 const spritePath = path.join(file.destination, file.filename);
253261 const pathToFile = path.join(spritesPath, filename);
254262 // Copy uploaded file to sprites folder
src/middleware/accessLogWriter.js+59 -0
@@ -0,0 +1,59 @@
1+import path from 'node:path';
2+import fs from 'node:fs';
3+import { getRealIpFromHeader } from '../express-common.js';
4+import { color, getConfigValue } from '../util.js';
5+
6+const enableAccessLog = getConfigValue('logging.enableAccessLog', true);
7+
8+const knownIPs = new Set();
9+
10+export const getAccessLogPath = () => path.join(globalThis.DATA_ROOT, 'access.log');
11+
12+export function migrateAccessLog() {
13+ try {
14+ if (!fs.existsSync('access.log')) {
15+ return;
16+ }
17+ const logPath = getAccessLogPath();
18+ if (fs.existsSync(logPath)) {
19+ return;
20+ }
21+ fs.renameSync('access.log', logPath);
22+ console.log(color.yellow('Migrated access.log to new location:'), logPath);
23+ } catch (e) {
24+ console.error('Failed to migrate access log:', e);
25+ console.info('Please move access.log to the data directory manually.');
26+ }
27+}
28+
29+/**
30+ * Creates middleware for logging access and new connections
31+ * @returns {import('express').RequestHandler}
32+ */
33+export default function accessLoggerMiddleware() {
34+ return function (req, res, next) {
35+ const clientIp = getRealIpFromHeader(req);
36+ const userAgent = req.headers['user-agent'];
37+
38+ if (!knownIPs.has(clientIp)) {
39+ // Log new connection
40+ console.info(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));
41+ knownIPs.add(clientIp);
42+
43+ // Write to access log if enabled
44+ if (enableAccessLog) {
45+ const logPath = getAccessLogPath();
46+ const timestamp = new Date().toISOString();
47+ const log = `${timestamp} ${clientIp} ${userAgent}\n`;
48+
49+ fs.appendFile(logPath, log, (err) => {
50+ if (err) {
51+ console.error('Failed to write access log:', err);
52+ }
53+ });
54+ }
55+ }
56+
57+ next();
58+ };
59+}
src/middleware/whitelist.js+11 -40
@@ -10,9 +10,6 @@ import { color, getConfigValue, safeReadFileSync } from '../util.js';
1010const whitelistPath = path.join(process.cwd(), './whitelist.txt');
1111const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false, 'boolean');
1212let whitelist = getConfigValue('whitelist', []);
13-let knownIPs = new Set();
14-
15-export const getAccessLogPath = () => path.join(globalThis.DATA_ROOT, 'access.log');
1613
1714if (fs.existsSync(whitelistPath)) {
1815 try {
@@ -48,67 +45,41 @@ function getForwardedIp(req) {
4845 return undefined;
4946}
5047
51-export function migrateAccessLog() {
52- try {
53- if (!fs.existsSync('access.log')) {
54- return;
55- }
56- const logPath = getAccessLogPath();
57- if (fs.existsSync(logPath)) {
58- return;
59- }
60- fs.renameSync('access.log', logPath);
61- console.log(color.yellow('Migrated access.log to new location:'), logPath);
62- } catch (e) {
63- console.error('Failed to migrate access log:', e);
64- console.info('Please move access.log to the data directory manually.');
65- }
66-}
67-
6848/**
6949 * Returns a middleware function that checks if the client IP is in the whitelist.
70- * @param {boolean} whitelistMode If whitelist mode is enabled via config or command line
71- * @param {boolean} listen If listen mode is enabled via config or command line
7250 * @returns {import('express').RequestHandler} The middleware function
7351 */
7452export default function whitelistMiddleware(whitelistMode, listen) {
7553 const forbiddenWebpage = Handlebars.compile(
7654 safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '',
7755 );
7856
57+ const noLogPaths = [
58+ '/favicon.ico',
59+ ];
60+
7961 return function (req, res, next) {
8062 const clientIp = getIpFromRequest(req);
8163 const forwardedIp = getForwardedIp(req);
8264 const userAgent = req.headers['user-agent'];
8365
84- if (listen && !knownIPs.has(clientIp)) {
85- console.info(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`));
86- knownIPs.add(clientIp);
87-
88- // Write access log
89- const logPath = getAccessLogPath();
90- const timestamp = new Date().toISOString();
91- const log = `${timestamp} ${clientIp} ${userAgent}\n`;
92- fs.appendFile(logPath, log, (err) => {
93- if (err) {
94- console.error('Failed to write access log:', err);
95- }
96- });
97- }
98-
9966 //clientIp = req.connection.remoteAddress.split(':').pop();
10067 if (whitelistMode === true && !whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x)))
10168 || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x)))
10269 ) {
10370 // Log the connection attempt with real IP address
10471 const ipDetails = forwardedIp
10572 ? `${clientIp} (forwarded from ${forwardedIp})`
10673 : clientIp;
74+
75+ if (!noLogPaths.includes(req.path)) {
10776 console.warn(
10877 color.red(
10978 `Blocked connection from ${clientIp}; User Agent: ${userAgent}\n\tTo allow this connection, add its IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your SillyTavern installation.\n`,
11079 ),
11180 );
81+ }
82+
11283 return res.status(403).send(forbiddenWebpage({ ipDetails }));
11384 }
11485 next();
src/plugin-loader.js+2 -2
@@ -3,7 +3,7 @@ import path from 'node:path';
33import url from 'node:url';
44
55import express from 'express';
66import { default as git, CheckRepoActions } from 'simple-git';
77import { sync as commandExistsSync } from 'command-exists';
88import { getConfigValue, color } from './util.js';
99
@@ -256,7 +256,7 @@ async function updatePlugins(pluginsPath) {
256256 const pluginPath = path.join(pluginsPath, directory);
257257 const pluginRepo = git(pluginPath);
258258
259259 const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
260260 if (!isRepo) {
261261 continue;
262262 }
src/util.js+1 -1
@@ -805,7 +805,7 @@ export function stringToBool(str) {
805805 * Setup the minimum log level
806806 */
807807export function setupLogLevel() {
808808 const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number');
809809
810810 globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {};
811811 globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {};