Repository: SillyTavern/SillyTavern (language: javascript)

Issue:
[FEATURE_REQUEST] Character Expressions: Add option for {{labels}} to ignore labels that do not contain at least one image (Main API / WebLLM)
### Have you searched for similar requests?

Yes

### Is your feature request related to a problem? If so, please describe.

Adding custom labels is global, and {{labels}} contain all labels no matter which card you use. This gets worse the more custom labels you add, as this extension can also be used for actions rather than just emotions.

### Describe the solution you'd like

![Image](https://github.com/user-attachments/assets/6c0a676b-6f3f-4b18-a242-64cc5529876c)

Right under `[ ] Re-roll if same sprite is used again`, add
(edit: slight rewording) `[ ] Exclude imageless labels (Main API / WebLLM only)`.

### Describe alternatives you've considered

_No response_

### Additional context

_No response_

### Priority

Low (Nice-to-have)

### Are you willing to test this on staging/unstable branch if this is implemented?

Yes


Discussion / hints:


🏆 This issue has been marked as a good first issue for contributors to implement! This is a great way to support the project. While also improving your skills, you'll also be credited as a contributor once your PR is merged.
If you're new to SillyTavern [here is the official documentation](https://docs.sillytavern.app/). The official contribution guide can be found [here](https://github.com/SillyTavern/SillyTavern/blob/release/CONTRIBUTING.md). If you need any support, feel free to reach out via [Discord](https://discord.gg/sillytavern), or let us know in this issue or via [discussions](https://github.com/SillyTavern/SillyTavern/discussions).




Fix patch:
diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js
index aa592cfd70..9a62b17373 100644
--- a/public/scripts/extensions/expressions/index.js
+++ b/public/scripts/extensions/expressions/index.js
@@ -4,7 +4,7 @@ import { characters, eventSource, event_types, generateRaw, getRequestHeaders, m
 import { dragElement, isMobile } from '../../RossAscends-mods.js';
 import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
 import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js';
-import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js';
+import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar, isFalseBoolean } from '../../utils.js';
 import { hideMutedSprites, selected_group } from '../../group-chats.js';
 import { isJsonSchemaSupported } from '../../textgen-settings.js';
 import { debounce_timeout } from '../../constants.js';
@@ -678,7 +678,7 @@ async function setSpriteFolderCommand(_, folder) {
     return '';
 }
 
-async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) {
+async function classifyCallback(/** @type {{api: string?, filter: string?, prompt: string?}} */ { api = null, filter = null, prompt = null }, text) {
     if (!text) {
         toastr.error('No text provided');
         return '';
@@ -689,13 +689,14 @@ async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ {
     }
 
     const expressionApi = EXPRESSION_API[api] || extension_settings.expressions.api;
+    const filterAvailable = !isFalseBoolean(filter);
 
     if (!modules.includes('classify') && expressionApi == EXPRESSION_API.extras) {
         toastr.warning('Text classification is disabled or not available');
         return '';
     }
 
-    const label = await getExpressionLabel(text, expressionApi, { customPrompt: prompt });
+    const label = await getExpressionLabel(text, expressionApi, { filterAvailable: filterAvailable, customPrompt: prompt });
     console.debug(`Classification result for "${text}": ${label}`);
     return label;
 }
@@ -988,10 +989,11 @@ function onTextGenSettingsReady(args) {
  * @param {string} text - The text to classify and retrieve the expression label for.
  * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification.
  * @param {object} [options={}] - Optional arguments.
+ * @param {boolean?} [options.filterAvailable=null] - Whether to filter available expressions. If not specified, uses the extension setting.
  * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification.
  * @returns {Promise<string?>} - The label of the expression.
  */
-export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { customPrompt = null } = {}) {
+export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { filterAvailable = null, customPrompt = null } = {}) {
     // Return if text is undefined, saving a costly fetch request
     if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) {
         return extension_settings.expressions.fallback_expression;
@@ -1003,6 +1005,11 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
 
     text = sampleClassifyText(text);
 
+    filterAvailable ??= extension_settings.expressions.filterAvailable;
+    if (filterAvailable && ![EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(expressionsApi)) {
+        console.debug('Filter available is only supported for LLM and WebLLM expressions');
+    }
+
     try {
         switch (expressionsApi) {
             // Local BERT pipeline
@@ -1027,7 +1034,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
                     return extension_settings.expressions.fallback_expression;
                 }
 
-                const expressionsList = await getExpressionsList();
+                const expressionsList = await getExpressionsList({ filterAvailable: filterAvailable });
                 const prompt = substituteParamsExtended(customPrompt, { labels: expressionsList }) || await getLlmPrompt(expressionsList);
                 eventSource.once(event_types.TEXT_COMPLETION_SETTINGS_READY, onTextGenSettingsReady);
                 const emotionResponse = await generateRaw(text, main_api, false, false, prompt);
@@ -1040,7 +1047,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
                     return extension_settings.expressions.fallback_expression;
                 }
 
-                const expressionsList = await getExpressionsList();
+                const expressionsList = await getExpressionsList({ filterAvailable: filterAvailable });
                 const prompt = substituteParamsExtended(customPrompt, { labels: expressionsList }) || await getLlmPrompt(expressionsList);
                 const messages = [
                     { role: 'user', content: text + '\n\n' + prompt },
@@ -1320,12 +1327,28 @@ function getCachedExpressions() {
     return [...expressionsList, ...extension_settings.expressions.custom].filter(onlyUnique);
 }
 
-export async function getExpressionsList() {
-    // Return cached list if available
-    if (Array.isArray(expressionsList)) {
-        return getCachedExpressions();
+export async function getExpressionsList({ filterAvailable = false } = {}) {
+    // If there is no cached list, load and cache it
+    if (!Array.isArray(expressionsList)) {
+        expressionsList = await resolveExpressionsList();
     }
 
+    const expressions = getCachedExpressions();
+
+    // Filtering is only available for llm and webllm APIs
+    if (!filterAvailable || ![EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)) {
+        return expressions;
+    }
+
+    // Get expressions with available sprites
+    const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
+    const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
+
+    return expressions.filter(label => {
+        const expression = spriteCache[spriteFolderName]?.find(x => x.label === label);
+        return (expression?.files.length ?? 0) > 0;
+    });
+
     /**
      * Returns the list of expressions from the API or fallback in offline mode.
      * @returns {Promise<string[]>}
@@ -1372,9 +1395,6 @@ export async function getExpressionsList() {
         expressionsList = DEFAULT_EXPRESSIONS.slice();
         return expressionsList;
     }
-
-    const result = await resolveExpressionsList();
-    return [...result, ...extension_settings.expressions.custom].filter(onlyUnique);
 }
 
 /**
@@ -2102,6 +2122,10 @@ function migrateSettings() {
             extension_settings.expressions.rerollIfSame = !!$(this).prop('checked');
             saveSettingsDebounced();
         });
+        $('#expressions_filter_available').prop('checked', extension_settings.expressions.filterAvailable).on('input', function () {
+            extension_settings.expressions.filterAvailable = !!$(this).prop('checked');
+            saveSettingsDebounced();
+        });
         $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton);
         $(document).on('dragstart', '.expression', (e) => {
             e.preventDefault();
@@ -2279,13 +2303,13 @@ function migrateSettings() {
     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
         name: 'expression-list',
         aliases: ['expressions'],
-        /** @type {(args: {return: string}) => Promise<string>} */
+        /** @type {(args: {return: string, filter: string}) => Promise<string>} */
         callback: async (args) => {
             let returnType =
                 /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
                 (args.return);
 
-            const list = await getExpressionsList();
+            const list = await getExpressionsList({ filterAvailable: !isFalseBoolean(args.filter) });
 
             return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });
         },
@@ -2298,6 +2322,13 @@ function migrateSettings() {
                 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
                 forceEnum: true,
             }),
+            SlashCommandNamedArgument.fromProps({
+                name: 'filter',
+                description: 'Filter the list to only include expressions that have available sprites for the current character.',
+                typeList: [ARGUMENT_TYPE.BOOLEAN],
+                enumList: commonEnumProviders.boolean('trueFalse')(),
+                defaultValue: 'true',
+            }),
         ],
         returns: 'The comma-separated list of available expressions, including custom expressions.',
         helpString: 'Returns a list of available expressions, including custom expressions.',
@@ -2313,6 +2344,13 @@ function migrateSettings() {
                 typeList: [ARGUMENT_TYPE.STRING],
                 enumList: Object.keys(EXPRESSION_API).map(api => new SlashCommandEnumValue(api, null, enumTypes.enum)),
             }),
+            SlashCommandNamedArgument.fromProps({
+                name: 'filter',
+                description: 'Filter the list to only include expressions that have available sprites for the current character.',
+                typeList: [ARGUMENT_TYPE.BOOLEAN],
+                enumList: commonEnumProviders.boolean('trueFalse')(),
+                defaultValue: 'true',
+            }),
             SlashCommandNamedArgument.fromProps({
                 name: 'prompt',
                 description: 'Custom prompt for classification. Only relevant if Classifier API is set to LLM.',
diff --git a/public/scripts/extensions/expressions/settings.html b/public/scripts/extensions/expressions/settings.html
index 06e407d7de..98938cf4db 100644
--- a/public/scripts/extensions/expressions/settings.html
+++ b/public/scripts/extensions/expressions/settings.html
@@ -29,7 +29,11 @@
                 </select>
             </div>
             <div class="expression_llm_prompt_block m-b-1 m-t-1">
-                <label for="expression_llm_prompt" class="title_restorable">
+                <label class="checkbox_label" for="expressions_filter_available" title="When using LLM or WebLLM classifier, only show and use expressions that have sprites assigned to them." data-i18n="[title]When using LLM or WebLLM classifier, only show and use expressions that have sprites assigned to them.">
+                    <input id="expressions_filter_available" type="checkbox">
+                    <span data-i18n="Filter expressions for available sprites">Filter expressions for available sprites</span>
+                </label>
+                <label for="expression_llm_prompt" class="title_restorable m-t-1">
                     <span data-i18n="LLM Prompt">LLM Prompt</span>
                     <div id="expression_llm_prompt_restore" title="Restore default value" class="right_menu_button">
                         <i class="fa-solid fa-clock-rotate-left fa-sm"></i>


Test patch: