Merge branch 'staging' into immutable-config
| @@ -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/ | ||
| @@ -71,8 +71,6 @@ autheliaAuth: false | |||
| 71 | # the username and passwords for basic auth are the same as those | 71 | # the username and passwords for basic auth are the same as those |
| 72 | # for the individual accounts | 72 | # for the individual accounts |
| 73 | perUserBasicAuth: false | 73 | perUserBasicAuth: false |
| 74 | # Minimum log level to display in the terminal (DEBUG = 0, INFO = 1, WARN = 2, ERROR = 3) | ||
| 75 | minLogLevel: 0 | ||
| 76 | 74 | ||
| 77 | # User session timeout *in seconds* (defaults to 24 hours). | 75 | # User session timeout *in seconds* (defaults to 24 hours). |
| 78 | ## Set to a positive number to expire session after a certain time of inactivity | 76 | ## Set to a positive number to expire session after a certain time of inactivity |
| @@ -83,6 +81,13 @@ sessionTimeout: -1 | |||
| 83 | disableCsrfProtection: false | 81 | disableCsrfProtection: false |
| 84 | # Disable startup security checks - NOT RECOMMENDED | 82 | # Disable startup security checks - NOT RECOMMENDED |
| 85 | securityOverride: false | 83 | securityOverride: 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 | ||
| 86 | # -- RATE LIMITING CONFIGURATION -- | 91 | # -- RATE LIMITING CONFIGURATION -- |
| 87 | rateLimiting: | 92 | rateLimiting: |
| 88 | # Use X-Real-IP header instead of socket IP for rate limiting | 93 | # Use X-Real-IP header instead of socket IP for rate limiting |
| @@ -8,7 +8,7 @@ import path from 'node:path'; | |||
| 8 | import process from 'node:process'; | 8 | import process from 'node:process'; |
| 9 | import { fileURLToPath } from 'node:url'; | 9 | import { fileURLToPath } from 'node:url'; |
| 10 | 10 | ||
| 11 | import { default as git } from 'simple-git'; | 11 | import { default as git, CheckRepoActions } from 'simple-git'; |
| 12 | import { color } from './src/util.js'; | 12 | import { color } from './src/util.js'; |
| 13 | 13 | ||
| 14 | const __dirname = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)); | 14 | const __dirname = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)); |
| @@ -49,7 +49,7 @@ async function updatePlugins() { | |||
| 49 | const pluginPath = path.join(pluginsPath, directory); | 49 | const pluginPath = path.join(pluginsPath, directory); |
| 50 | const pluginRepo = git(pluginPath); | 50 | const pluginRepo = git(pluginPath); |
| 51 | 51 | ||
| 52 | const isRepo = await pluginRepo.checkIsRepo(); | 52 | const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT); |
| 53 | if (!isRepo) { | 53 | if (!isRepo) { |
| 54 | console.log(`Directory ${color.yellow(directory)} is not a Git repository`); | 54 | console.log(`Directory ${color.yellow(directory)} is not a Git repository`); |
| 55 | continue; | 55 | continue; |
| @@ -104,6 +104,11 @@ const keyMigrationMap = [ | |||
| 104 | newKey: 'extensions.models.textToSpeech', | 104 | newKey: 'extensions.models.textToSpeech', |
| 105 | migrate: (value) => value, | 105 | migrate: (value) => value, |
| 106 | }, | 106 | }, |
| 107 | { | ||
| 108 | oldKey: 'minLogLevel', | ||
| 109 | newKey: 'logging.minLogLevel', | ||
| 110 | migrate: (value) => value, | ||
| 111 | }, | ||
| 107 | // uncommend one release after 1.12.13 | 112 | // uncommend one release after 1.12.13 |
| 108 | /* | 113 | /* |
| 109 | { | 114 | { |
| @@ -40,4 +40,12 @@ declare global { | |||
| 40 | searchInputCssClass?: string; | 40 | searchInputCssClass?: string; |
| 41 | } | 41 | } |
| 42 | } | 42 | } |
| 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>; | ||
| 43 | } | 51 | } |
| @@ -2000,7 +2000,7 @@ | |||
| 2000 | </span> | 2000 | </span> |
| 2001 | </div> | 2001 | </div> |
| 2002 | </div> | 2002 | </div> |
| 2003 | <div class="range-block" data-source="deepseek,openrouter"> | 2003 | <div class="range-block" data-source="deepseek,openrouter,custom"> |
| 2004 | <label for="openai_show_thoughts" class="checkbox_label widthFreeExpand"> | 2004 | <label for="openai_show_thoughts" class="checkbox_label widthFreeExpand"> |
| 2005 | <input id="openai_show_thoughts" type="checkbox" /> | 2005 | <input id="openai_show_thoughts" type="checkbox" /> |
| 2006 | <span> | 2006 | <span> |
| @@ -3186,21 +3186,33 @@ | |||
| 3186 | </div> | 3186 | </div> |
| 3187 | <h4 data-i18n="Groq Model">Groq Model</h4> | 3187 | <h4 data-i18n="Groq Model">Groq Model</h4> |
| 3188 | <select id="model_groq_select"> | 3188 | <select id="model_groq_select"> |
| 3189 | <optgroup label="Production Models"> | 3189 | <optgroup label="Alibaba Cloud"> |
| 3190 | <option value="gemma2-9b-it">gemma2-9b-it</option> | 3190 | <option value="qwen-2.5-32b">qwen-2.5-32b</option> |
| 3191 | <option value="llama-3.3-70b-versatile">llama-3.3-70b-versatile</option> | 3191 | <option value="qwen-2.5-coder-32b">qwen-2.5-coder-32b</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> | ||
| 3196 | </optgroup> | 3192 | </optgroup> |
| 3197 | <optgroup label="Preview Models"> | 3193 | <optgroup label="DeepSeek / Alibaba Cloud"> |
| 3194 | <option value="deepseek-r1-distill-qwen-32b">deepseek-r1-distill-qwen-32b</option> | ||
| 3195 | </optgroup> | ||
| 3196 | <optgroup label="DeepSeek / Meta"> | ||
| 3198 | <option value="deepseek-r1-distill-llama-70b">deepseek-r1-distill-llama-70b</option> | 3197 | <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> | ||
| 3200 | <option value="llama-3.2-1b-preview">llama-3.2-1b-preview </option> | 3205 | <option value="llama-3.2-1b-preview">llama-3.2-1b-preview </option> |
| 3201 | <option value="llama-3.2-3b-preview">llama-3.2-3b-preview </option> | 3206 | <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> | ||
| 3203 | <option value="llama-3.2-90b-vision-preview">llama-3.2-90b-vision-preview </option> | 3207 | <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> | ||
| 3204 | </optgroup> | 3216 | </optgroup> |
| 3205 | </select> | 3217 | </select> |
| 3206 | </div> | 3218 | </div> |
| @@ -3253,6 +3265,10 @@ | |||
| 3253 | <option value="sonar">sonar</option> | 3265 | <option value="sonar">sonar</option> |
| 3254 | <option value="sonar-pro">sonar-pro</option> | 3266 | <option value="sonar-pro">sonar-pro</option> |
| 3255 | <option value="sonar-reasoning">sonar-reasoning</option> | 3267 | <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> | ||
| 3256 | </optgroup> | 3272 | </optgroup> |
| 3257 | <optgroup label="Deprecated Models"> | 3273 | <optgroup label="Deprecated Models"> |
| 3258 | <!-- These are scheduled for deprecation after 2/22/2025 --> | 3274 | <!-- These are scheduled for deprecation after 2/22/2025 --> |
| @@ -24,10 +24,22 @@ if (typeof Array.prototype.indexOf === 'function') { | |||
| 24 | 24 | ||
| 25 | 25 | ||
| 26 | /* Polyfill EventEmitter. */ | 26 | /* 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 = []) { | ||
| 28 | this.events = {}; | 32 | this.events = {}; |
| 33 | this.autoFireLastArgs = new Map(); | ||
| 34 | this.autoFireAfterEmit = new Set(autoFireAfterEmit); | ||
| 29 | }; | 35 | }; |
| 30 | 36 | ||
| 37 | /** | ||
| 38 | * Adds a listener to an event. | ||
| 39 | * @param {string} event Event name | ||
| 40 | * @param {function} listener Event listener | ||
| 41 | * @returns | ||
| 42 | */ | ||
| 31 | EventEmitter.prototype.on = function (event, listener) { | 43 | EventEmitter.prototype.on = function (event, listener) { |
| 32 | // Unknown event used by external libraries? | 44 | // Unknown event used by external libraries? |
| 33 | if (event === undefined) { | 45 | if (event === undefined) { |
| @@ -40,6 +52,10 @@ EventEmitter.prototype.on = function (event, listener) { | |||
| 40 | } | 52 | } |
| 41 | 53 | ||
| 42 | this.events[event].push(listener); | 54 | 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 | } | ||
| 43 | }; | 59 | }; |
| 44 | 60 | ||
| 45 | /** | 61 | /** |
| @@ -60,6 +76,10 @@ EventEmitter.prototype.makeLast = function (event, listener) { | |||
| 60 | } | 76 | } |
| 61 | 77 | ||
| 62 | events.push(listener); | 78 | events.push(listener); |
| 79 | |||
| 80 | if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) { | ||
| 81 | listener.apply(this, this.autoFireLastArgs.get(event)); | ||
| 82 | } | ||
| 63 | } | 83 | } |
| 64 | 84 | ||
| 65 | /** | 85 | /** |
| @@ -80,8 +100,17 @@ EventEmitter.prototype.makeFirst = function (event, listener) { | |||
| 80 | } | 100 | } |
| 81 | 101 | ||
| 82 | events.unshift(listener); | 102 | events.unshift(listener); |
| 103 | |||
| 104 | if (this.autoFireAfterEmit.has(event) && this.autoFireLastArgs.has(event)) { | ||
| 105 | listener.apply(this, this.autoFireLastArgs.get(event)); | ||
| 106 | } | ||
| 83 | } | 107 | } |
| 84 | 108 | ||
| 109 | /** | ||
| 110 | * Removes a listener from an event. | ||
| 111 | * @param {string} event Event name | ||
| 112 | * @param {function} listener Event listener | ||
| 113 | */ | ||
| 85 | EventEmitter.prototype.removeListener = function (event, listener) { | 114 | EventEmitter.prototype.removeListener = function (event, listener) { |
| 86 | var idx; | 115 | var idx; |
| 87 | 116 | ||
| @@ -94,6 +123,10 @@ EventEmitter.prototype.removeListener = function (event, listener) { | |||
| 94 | } | 123 | } |
| 95 | }; | 124 | }; |
| 96 | 125 | ||
| 126 | /** | ||
| 127 | * Emits an event with optional arguments. | ||
| 128 | * @param {string} event Event name | ||
| 129 | */ | ||
| 97 | EventEmitter.prototype.emit = async function (event) { | 130 | EventEmitter.prototype.emit = async function (event) { |
| 98 | let args = [].slice.call(arguments, 1); | 131 | let args = [].slice.call(arguments, 1); |
| 99 | if (localStorage.getItem('eventTracing') === 'true') { | 132 | if (localStorage.getItem('eventTracing') === 'true') { |
| @@ -118,6 +151,10 @@ EventEmitter.prototype.emit = async function (event) { | |||
| 118 | } | 151 | } |
| 119 | } | 152 | } |
| 120 | } | 153 | } |
| 154 | |||
| 155 | if (this.autoFireAfterEmit.has(event)) { | ||
| 156 | this.autoFireLastArgs.set(event, args); | ||
| 157 | } | ||
| 121 | }; | 158 | }; |
| 122 | 159 | ||
| 123 | EventEmitter.prototype.emitAndWait = function (event) { | 160 | EventEmitter.prototype.emitAndWait = function (event) { |
| @@ -144,6 +181,10 @@ EventEmitter.prototype.emitAndWait = function (event) { | |||
| 144 | } | 181 | } |
| 145 | } | 182 | } |
| 146 | } | 183 | } |
| 184 | |||
| 185 | if (this.autoFireAfterEmit.has(event)) { | ||
| 186 | this.autoFireLastArgs.set(event, args); | ||
| 187 | } | ||
| 147 | }; | 188 | }; |
| 148 | 189 | ||
| 149 | EventEmitter.prototype.once = function (event, listener) { | 190 | EventEmitter.prototype.once = function (event, listener) { |
| @@ -1602,7 +1602,6 @@ | |||
| 1602 | "Character Expressions": "Expressions de personnages", | 1602 | "Character Expressions": "Expressions de personnages", |
| 1603 | "Translate text to English before classification": "Traduire le texte en anglais avant de le classer", | 1603 | "Translate text to English before classification": "Traduire le texte en anglais avant de le classer", |
| 1604 | "Show default images (emojis) if sprite missing": "Afficher les images par défaut (emojis) si le sprite est manquant", | 1604 | "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)", | ||
| 1606 | "Classifier API": "API de classification", | 1605 | "Classifier API": "API de classification", |
| 1607 | "Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.", | 1606 | "Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.", |
| 1608 | "Main API": "API principale", | 1607 | "Main API": "API principale", |
| @@ -1467,7 +1467,6 @@ | |||
| 1467 | "menu within": "내의 메뉴", | 1467 | "menu within": "내의 메뉴", |
| 1468 | "Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.", | 1468 | "Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.", |
| 1469 | "Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.", | 1469 | "Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.", |
| 1470 | "Image Type - talkinghead (extras)": "이미지 유형 - 토킹 헤드 (부가 사항)", | ||
| 1471 | "Classifier API": "분류를 위한 API", | 1470 | "Classifier API": "분류를 위한 API", |
| 1472 | "Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.", | 1471 | "Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.", |
| 1473 | "Local": "로컬", | 1472 | "Local": "로컬", |
| @@ -1349,7 +1349,6 @@ | |||
| 1349 | "Character Expressions": "角色表情", | 1349 | "Character Expressions": "角色表情", |
| 1350 | "Translate text to English before classification": "分类之前将文本翻译成英文", | 1350 | "Translate text to English before classification": "分类之前将文本翻译成英文", |
| 1351 | "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)", | 1351 | "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)", |
| 1352 | "Image Type - talkinghead (extras)": "图像类型 - 说话头像(附加内容)", | ||
| 1353 | "Classifier API": "分类器 API", | 1352 | "Classifier API": "分类器 API", |
| 1354 | "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。", | 1353 | "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。", |
| 1355 | "Main API": "主要 API", | 1354 | "Main API": "主要 API", |
| @@ -1653,7 +1653,6 @@ | |||
| 1653 | "HuggingFace Token": "HuggingFace 符元", | 1653 | "HuggingFace Token": "HuggingFace 符元", |
| 1654 | "Image Captioning": "圖片註解", | 1654 | "Image Captioning": "圖片註解", |
| 1655 | "Generate Caption": "產生圖片註解", | 1655 | "Generate Caption": "產生圖片註解", |
| 1656 | "Image Type - talkinghead (extras)": "圖片類型 - talkinghead(額外選項)", | ||
| 1657 | "Injection Position": "插入位置", | 1656 | "Injection Position": "插入位置", |
| 1658 | "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。", | 1657 | "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。", |
| 1659 | "Injection Template": "插入範本", | 1658 | "Injection Template": "插入範本", |
| @@ -512,7 +512,7 @@ export const event_types = { | |||
| 512 | TOOL_CALLS_RENDERED: 'tool_calls_rendered', | 512 | TOOL_CALLS_RENDERED: 'tool_calls_rendered', |
| 513 | }; | 513 | }; |
| 514 | 514 | ||
| 515 | export const eventSource = new EventEmitter(); | 515 | export const eventSource = new EventEmitter([event_types.APP_READY]); |
| 516 | 516 | ||
| 517 | eventSource.on(event_types.CHAT_CHANGED, processChatSlashCommands); | 517 | eventSource.on(event_types.CHAT_CHANGED, processChatSlashCommands); |
| 518 | 518 | ||
| @@ -1487,7 +1487,7 @@ jQuery(function () { | |||
| 1487 | ...chat.filter(x => x?.extra?.type !== system_message_types.ASSISTANT_NOTE), | 1487 | ...chat.filter(x => x?.extra?.type !== system_message_types.ASSISTANT_NOTE), |
| 1488 | ]; | 1488 | ]; |
| 1489 | 1489 | ||
| 1490 | download(JSON.stringify(chatToSave, null, 4), `Assistant - ${humanizedDateTime()}.json`, 'application/json'); | 1490 | download(chatToSave.map((m) => JSON.stringify(m)).join('\n'), `Assistant - ${humanizedDateTime()}.jsonl`, 'application/json'); |
| 1491 | }); | 1491 | }); |
| 1492 | 1492 | ||
| 1493 | // Do not change. #attachFile is added by extension. | 1493 | // Do not change. #attachFile is added by extension. |
| @@ -154,8 +154,18 @@ export const extension_settings = { | |||
| 154 | refine_mode: false, | 154 | refine_mode: false, |
| 155 | }, | 155 | }, |
| 156 | expressions: { | 156 | expressions: { |
| 157 | /** @type {number} see `EXPRESSION_API` */ | ||
| 158 | api: undefined, | ||
| 157 | /** @type {string[]} */ | 159 | /** @type {string[]} */ |
| 158 | custom: [], | 160 | 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, | ||
| 159 | }, | 169 | }, |
| 160 | connectionManager: { | 170 | connectionManager: { |
| 161 | selectedProfile: '', | 171 | selectedProfile: '', |
| @@ -1,11 +1,11 @@ | |||
| 1 | import { Fuse } from '../../../lib.js'; | 1 | import { Fuse } from '../../../lib.js'; |
| 2 | 2 | ||
| 3 | import { callPopup, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types } from '../../../script.js'; | 3 | import { characters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js'; |
| 4 | import { dragElement, isMobile } from '../../RossAscends-mods.js'; | 4 | import { dragElement, isMobile } from '../../RossAscends-mods.js'; |
| 5 | import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; | 5 | import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; |
| 6 | import { loadMovingUIState, power_user } from '../../power-user.js'; | 6 | import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js'; |
| 7 | import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js'; | 7 | import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js'; |
| 8 | import { hideMutedSprites } from '../../group-chats.js'; | 8 | import { hideMutedSprites, selected_group } from '../../group-chats.js'; |
| 9 | import { isJsonSchemaSupported } from '../../textgen-settings.js'; | 9 | import { isJsonSchemaSupported } from '../../textgen-settings.js'; |
| 10 | import { debounce_timeout } from '../../constants.js'; | 10 | import { debounce_timeout } from '../../constants.js'; |
| 11 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; | 11 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; |
| @@ -15,16 +15,32 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm | |||
| 15 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; | 15 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 16 | import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; | 16 | import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; |
| 17 | import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; | 17 | import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; |
| 18 | import { Popup, POPUP_RESULT } from '../../popup.js'; | ||
| 19 | import { t } from '../../i18n.js'; | ||
| 18 | export { MODULE_NAME }; | 20 | export { MODULE_NAME }; |
| 19 | 21 | ||
| 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 | |||
| 20 | const MODULE_NAME = 'expressions'; | 38 | const MODULE_NAME = 'expressions'; |
| 21 | const UPDATE_INTERVAL = 2000; | 39 | const UPDATE_INTERVAL = 2000; |
| 22 | const STREAMING_UPDATE_INTERVAL = 10000; | 40 | const STREAMING_UPDATE_INTERVAL = 10000; |
| 23 | const TALKINGCHECK_UPDATE_INTERVAL = 500; | ||
| 24 | const DEFAULT_FALLBACK_EXPRESSION = 'joy'; | 41 | const DEFAULT_FALLBACK_EXPRESSION = 'joy'; |
| 25 | const 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}}'; | 42 | const 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}}'; |
| 26 | const DEFAULT_EXPRESSIONS = [ | 43 | const DEFAULT_EXPRESSIONS = [ |
| 27 | 'talkinghead', | ||
| 28 | 'admiration', | 44 | 'admiration', |
| 29 | 'amusement', | 45 | 'amusement', |
| 30 | 'anger', | 46 | 'anger', |
| @@ -54,6 +70,12 @@ const DEFAULT_EXPRESSIONS = [ | |||
| 54 | 'surprise', | 70 | 'surprise', |
| 55 | 'neutral', | 71 | 'neutral', |
| 56 | ]; | 72 | ]; |
| 73 | |||
| 74 | const OPTION_NO_FALLBACK = '#none'; | ||
| 75 | const OPTION_EMOJI_FALLBACK = '#emoji'; | ||
| 76 | const RESET_SPRITE_LABEL = '#reset'; | ||
| 77 | |||
| 78 | |||
| 57 | /** @enum {number} */ | 79 | /** @enum {number} */ |
| 58 | const EXPRESSION_API = { | 80 | const EXPRESSION_API = { |
| 59 | local: 0, | 81 | local: 0, |
| @@ -65,35 +87,29 @@ const EXPRESSION_API = { | |||
| 65 | let expressionsList = null; | 87 | let expressionsList = null; |
| 66 | let lastCharacter = undefined; | 88 | let lastCharacter = undefined; |
| 67 | let lastMessage = null; | 89 | let 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) | ||
| 70 | let spriteCache = {}; | 91 | let spriteCache = {}; |
| 71 | let inApiCall = false; | 92 | let inApiCall = false; |
| 72 | let lastServerResponseTime = 0; | 93 | let lastServerResponseTime = 0; |
| 73 | export let lastExpression = {}; | ||
| 74 | |||
| 75 | function isTalkingHeadEnabled() { | ||
| 76 | return extension_settings.expressions.talkinghead && extension_settings.expressions.api == EXPRESSION_API.extras; | ||
| 77 | } | ||
| 78 | 94 | ||
| 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 | } | ||
| 86 | 97 | ||
| 87 | /** | 98 | /** |
| 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). | ||
| 93 | */ | 103 | */ |
| 94 | function toggleTalkingHeadCommand(_) { | 104 | function getPlaceholderImage(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 | }; | ||
| 97 | } | 113 | } |
| 98 | 114 | ||
| 99 | function isVisualNovelMode() { | 115 | function isVisualNovelMode() { |
| @@ -108,21 +124,21 @@ async function forceUpdateVisualNovelMode() { | |||
| 108 | 124 | ||
| 109 | const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick); | 125 | const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick); |
| 110 | 126 | ||
| 111 | async function updateVisualNovelMode(name, expression) { | 127 | async function updateVisualNovelMode(spriteFolderName, expression) { |
| 112 | const container = $('#visual-novel-wrapper'); | 128 | const vnContainer = $('#visual-novel-wrapper'); |
| 113 | 129 | ||
| 114 | await visualNovelRemoveInactive(container); | 130 | await visualNovelRemoveInactive(vnContainer); |
| 115 | 131 | ||
| 116 | const setSpritePromises = await visualNovelSetCharacterSprites(container, name, expression); | 132 | const setSpritePromises = await visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression); |
| 117 | 133 | ||
| 118 | // calculate layer indices based on recent messages | 134 | // calculate layer indices based on recent messages |
| 119 | await visualNovelUpdateLayers(container); | 135 | await visualNovelUpdateLayers(vnContainer); |
| 120 | 136 | ||
| 121 | await Promise.allSettled(setSpritePromises); | 137 | await Promise.allSettled(setSpritePromises); |
| 122 | 138 | ||
| 123 | // update again based on new sprites | 139 | // update again based on new sprites |
| 124 | if (setSpritePromises.length > 0) { | 140 | if (setSpritePromises.length > 0) { |
| 125 | await visualNovelUpdateLayers(container); | 141 | await visualNovelUpdateLayers(vnContainer); |
| 126 | } | 142 | } |
| 127 | } | 143 | } |
| 128 | 144 | ||
| @@ -153,52 +169,60 @@ async function visualNovelRemoveInactive(container) { | |||
| 153 | await Promise.allSettled(removeInactiveCharactersPromises); | 169 | await Promise.allSettled(removeInactiveCharactersPromises); |
| 154 | } | 170 | } |
| 155 | 171 | ||
| 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; | ||
| 157 | const context = getContext(); | 182 | const context = getContext(); |
| 158 | const group = context.groups.find(x => x.id == context.groupId); | 183 | const group = context.groups.find(x => x.id == context.groupId); |
| 159 | const labels = await getExpressionsList(); | ||
| 160 | 184 | ||
| 161 | const createCharacterPromises = []; | ||
| 162 | const setSpritePromises = []; | 185 | const setSpritePromises = []; |
| 163 | 186 | ||
| 164 | for (const avatar of group.members) { | 187 | for (const avatar of group.members) { |
| 165 | const isDisabled = group.disabled_members.includes(avatar); | ||
| 166 | |||
| 167 | // skip disabled characters | 188 | // skip disabled characters |
| 189 | const isDisabled = group.disabled_members.includes(avatar); | ||
| 168 | if (isDisabled && hideMutedSprites) { | 190 | if (isDisabled && hideMutedSprites) { |
| 169 | continue; | 191 | continue; |
| 170 | } | 192 | } |
| 171 | 193 | ||
| 172 | const character = context.characters.find(x => x.avatar == avatar); | 194 | const character = context.characters.find(x => x.avatar == avatar); |
| 173 | |||
| 174 | if (!character) { | 195 | if (!character) { |
| 175 | continue; | 196 | continue; |
| 176 | } | 197 | } |
| 177 | 198 | ||
| 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); | ||
| 179 | 204 | ||
| 180 | // download images if not downloaded yet | 205 | // download images if not downloaded yet |
| 181 | if (spriteCache[spriteFolderName] === undefined) { | 206 | if (spriteCache[memberSpriteFolderName] === undefined) { |
| 182 | spriteCache[spriteFolderName] = await getSpritesList(spriteFolderName); | 207 | spriteCache[memberSpriteFolderName] = await getSpritesList(memberSpriteFolderName); |
| 183 | } | 208 | } |
| 184 | 209 | ||
| 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; | ||
| 190 | 211 | ||
| 191 | if (expressionImage.length > 0) { | 212 | if (!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 : ''; | ||
| 196 | 215 | ||
| 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'); | ||
| 199 | await setImage(img, path); | 223 | await setImage(img, path); |
| 200 | } | 224 | } |
| 201 | expressionImage.toggleClass('hidden', noSprites); | 225 | expressionImage.toggleClass('hidden', !spriteFile); |
| 202 | } else { | 226 | } else { |
| 203 | const template = $('#expression-holder').clone(); | 227 | const template = $('#expression-holder').clone(); |
| 204 | template.attr('id', `expression-${avatar}`); | 228 | template.attr('id', `expression-${avatar}`); |
| @@ -206,21 +230,49 @@ async function visualNovelSetCharacterSprites(container, name, expression) { | |||
| 206 | template.find('.drag-grabber').attr('id', `expression-${avatar}header`); | 230 | template.find('.drag-grabber').attr('id', `expression-${avatar}header`); |
| 207 | $('#visual-novel-wrapper').append(template); | 231 | $('#visual-novel-wrapper').append(template); |
| 208 | dragElement($(template[0])); | 232 | dragElement($(template[0])); |
| 209 | template.toggleClass('hidden', noSprites); | 233 | template.toggleClass('hidden', !spriteFile); |
| 210 | await setImage(template.find('img'), defaultSpritePath || ''); | 234 | img = template.find('img'); |
| 235 | await setImage(img, spriteFile?.imageSrc || ''); | ||
| 211 | const fadeInPromise = new Promise(resolve => { | 236 | const fadeInPromise = new Promise(resolve => { |
| 212 | template.fadeIn(250, () => resolve()); | 237 | template.fadeIn(250, () => resolve()); |
| 213 | }); | 238 | }); |
| 214 | createCharacterPromises.push(fadeInPromise); | 239 | setSpritePromises.push(fadeInPromise); |
| 215 | const setSpritePromise = setLastMessageSprite(template.find('img'), avatar, labels); | ||
| 216 | setSpritePromises.push(setSpritePromise); | ||
| 217 | } | 240 | } |
| 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 }); | ||
| 218 | } | 254 | } |
| 219 | 255 | ||
| 220 | await Promise.allSettled(createCharacterPromises); | ||
| 221 | return setSpritePromises; | 256 | return setSpritePromises; |
| 222 | } | 257 | } |
| 223 | 258 | ||
| 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 | |||
| 224 | async function visualNovelUpdateLayers(container) { | 276 | async function visualNovelUpdateLayers(container) { |
| 225 | const context = getContext(); | 277 | const context = getContext(); |
| 226 | const group = context.groups.find(x => x.id == context.groupId); | 278 | const group = context.groups.find(x => x.id == context.groupId); |
| @@ -256,11 +308,17 @@ async function visualNovelUpdateLayers(container) { | |||
| 256 | const containerWidth = container.width(); | 308 | const containerWidth = container.width(); |
| 257 | const pivotalPoint = containerWidth * 0.5; | 309 | const pivotalPoint = containerWidth * 0.5; |
| 258 | 310 | ||
| 259 | let images = $('#visual-novel-wrapper .expression-holder'); | 311 | let images = Array.from($('#visual-novel-wrapper .expression-holder')).sort(sortFunction); |
| 260 | let imagesWidth = []; | 312 | let imagesWidth = []; |
| 261 | 313 | ||
| 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()); | ||
| 264 | }); | 322 | }); |
| 265 | 323 | ||
| 266 | let totalWidth = imagesWidth.reduce((a, b) => a + b, 0); | 324 | let totalWidth = imagesWidth.reduce((a, b) => a + b, 0); |
| @@ -274,7 +332,7 @@ async function visualNovelUpdateLayers(container) { | |||
| 274 | currentPosition = 0; // Reset the initial position to 0 | 332 | currentPosition = 0; // Reset the initial position to 0 |
| 275 | } | 333 | } |
| 276 | 334 | ||
| 277 | images.sort(sortFunction).each((index, current) => { | 335 | images.forEach((current, index) => { |
| 278 | const element = $(current); | 336 | const element = $(current); |
| 279 | const elementID = element.attr('id'); | 337 | const elementID = element.attr('id'); |
| 280 | 338 | ||
| @@ -294,9 +352,15 @@ async function visualNovelUpdateLayers(container) { | |||
| 294 | element.show(); | 352 | element.show(); |
| 295 | 353 | ||
| 296 | const promise = new Promise(resolve => { | 354 | const promise = new Promise(resolve => { |
| 355 | if (power_user.reduced_motion) { | ||
| 356 | element.css('left', currentPosition + 'px'); | ||
| 357 | requestAnimationFrame(() => resolve()); | ||
| 358 | } | ||
| 359 | else { | ||
| 297 | element.animate({ left: currentPosition + 'px' }, 500, () => { | 360 | element.animate({ left: currentPosition + 'px' }, 500, () => { |
| 298 | resolve(); | 361 | resolve(); |
| 299 | }); | 362 | }); |
| 363 | } | ||
| 300 | }); | 364 | }); |
| 301 | 365 | ||
| 302 | currentPosition += imagesWidth[index]; | 366 | currentPosition += imagesWidth[index]; |
| @@ -307,23 +371,12 @@ async function visualNovelUpdateLayers(container) { | |||
| 307 | await Promise.allSettled(setLayerIndicesPromises); | 371 | await Promise.allSettled(setLayerIndicesPromises); |
| 308 | } | 372 | } |
| 309 | 373 | ||
| 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 | |||
| 327 | async function setImage(img, path) { | 380 | async function setImage(img, path) { |
| 328 | // Cohee: If something goes wrong, uncomment this to return to the old behavior | 381 | // Cohee: If something goes wrong, uncomment this to return to the old behavior |
| 329 | /* | 382 | /* |
| @@ -340,7 +393,7 @@ async function setImage(img, path) { | |||
| 340 | return new Promise(resolve => { | 393 | return new Promise(resolve => { |
| 341 | const prevExpressionSrc = img.attr('src'); | 394 | const prevExpressionSrc = img.attr('src'); |
| 342 | const expressionClone = img.clone(); | 395 | const expressionClone = img.clone(); |
| 343 | const originalId = img.attr('id'); | 396 | const originalId = img.data('filename'); |
| 344 | 397 | ||
| 345 | //only swap expressions when necessary | 398 | //only swap expressions when necessary |
| 346 | if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) { | 399 | if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) { |
| @@ -348,7 +401,7 @@ async function setImage(img, path) { | |||
| 348 | expressionClone.addClass('expression-clone'); | 401 | expressionClone.addClass('expression-clone'); |
| 349 | //make invisible and remove id to prevent double ids | 402 | //make invisible and remove id to prevent double ids |
| 350 | //must be made invisible to start because they share the same Z-index | 403 | //must be made invisible to start because they share the same Z-index |
| 351 | expressionClone.attr('id', '').css({ opacity: 0 }); | 404 | expressionClone.data('filename', '').css({ opacity: 0 }); |
| 352 | //add new sprite path to clone src | 405 | //add new sprite path to clone src |
| 353 | expressionClone.attr('src', path); | 406 | expressionClone.attr('src', path); |
| 354 | //add invisible clone to html | 407 | //add invisible clone to html |
| @@ -384,14 +437,18 @@ async function setImage(img, path) { | |||
| 384 | //remove old expression | 437 | //remove old expression |
| 385 | img.remove(); | 438 | img.remove(); |
| 386 | //replace ID so it becomes the new 'original' expression for next change | 439 | //replace ID so it becomes the new 'original' expression for next change |
| 387 | expressionClone.attr('id', originalId); | 440 | expressionClone.data('filename', originalId); |
| 388 | expressionClone.removeClass('expression-animating'); | 441 | expressionClone.removeClass('expression-animating'); |
| 389 | 442 | ||
| 390 | // Reset the expression holder min height and width | 443 | // Reset the expression holder min height and width |
| 391 | expressionHolder.css('min-width', 100); | 444 | expressionHolder.css('min-width', 100); |
| 392 | expressionHolder.css('min-height', 100); | 445 | expressionHolder.css('min-height', 100); |
| 393 | 446 | ||
| 447 | if (expressionClone.prop('complete')) { | ||
| 394 | resolve(); | 448 | resolve(); |
| 449 | } else { | ||
| 450 | expressionClone.one('load', () => resolve()); | ||
| 451 | } | ||
| 395 | }); | 452 | }); |
| 396 | 453 | ||
| 397 | expressionClone.removeClass('expression-clone'); | 454 | expressionClone.removeClass('expression-clone'); |
| @@ -410,216 +467,9 @@ async function setImage(img, path) { | |||
| 410 | }); | 467 | }); |
| 411 | } | 468 | } |
| 412 | 469 | ||
| 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() { | ||
| 613 | const context = getContext(); | 471 | const context = getContext(); |
| 614 | 472 | ||
| 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 | |||
| 623 | // non-characters not supported | 473 | // non-characters not supported |
| 624 | if (!context.groupId && context.characterId === undefined) { | 474 | if (!context.groupId && context.characterId === undefined) { |
| 625 | removeExpression(); | 475 | removeExpression(); |
| @@ -646,7 +496,7 @@ async function moduleWorker() { | |||
| 646 | } | 496 | } |
| 647 | 497 | ||
| 648 | const currentLastMessage = getLastCharacterMessage(); | 498 | const currentLastMessage = getLastCharacterMessage(); |
| 649 | let spriteFolderName = context.groupId ? getSpriteFolderName(currentLastMessage, currentLastMessage.name) : getSpriteFolderName(); | 499 | let spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name); |
| 650 | 500 | ||
| 651 | // character has no expressions or it is not loaded | 501 | // character has no expressions or it is not loaded |
| 652 | if (Object.keys(spriteCache).length === 0) { | 502 | if (Object.keys(spriteCache).length === 0) { |
| @@ -686,6 +536,10 @@ async function moduleWorker() { | |||
| 686 | offlineMode.css('display', 'none'); | 536 | offlineMode.css('display', 'none'); |
| 687 | } | 537 | } |
| 688 | 538 | ||
| 539 | if (context.groupId && vnMode && newChat) { | ||
| 540 | await forceUpdateVisualNovelMode(); | ||
| 541 | } | ||
| 542 | |||
| 689 | // Don't bother classifying if current char has no sprites and no default expressions are enabled | 543 | // Don't bother classifying if current char has no sprites and no default expressions are enabled |
| 690 | if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) { | 544 | if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) { |
| 691 | return; | 545 | return; |
| @@ -732,11 +586,11 @@ async function moduleWorker() { | |||
| 732 | const force = !!context.groupId; | 586 | const force = !!context.groupId; |
| 733 | 587 | ||
| 734 | // Character won't be angry on you for swiping | 588 | // Character won't be angry on you for swiping |
| 735 | if (currentLastMessage.mes == '...' && expressionsList.includes(getFallbackExpression())) { | 589 | if (currentLastMessage.mes == '...' && expressionsList.includes(extension_settings.expressions.fallback_expression)) { |
| 736 | expression = getFallbackExpression(); | 590 | expression = extension_settings.expressions.fallback_expression; |
| 737 | } | 591 | } |
| 738 | 592 | ||
| 739 | await sendExpressionCall(spriteFolderName, expression, force, vnMode); | 593 | await sendExpressionCall(spriteFolderName, expression, { force: force, vnMode: vnMode }); |
| 740 | } | 594 | } |
| 741 | catch (error) { | 595 | catch (error) { |
| 742 | console.log(error); | 596 | console.log(error); |
| @@ -749,91 +603,6 @@ async function moduleWorker() { | |||
| 749 | } | 603 | } |
| 750 | } | 604 | } |
| 751 | 605 | ||
| 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 | |||
| 837 | function getSpriteFolderName(characterMessage = null, characterName = null) { | 606 | function getSpriteFolderName(characterMessage = null, characterName = null) { |
| 838 | const context = getContext(); | 607 | const context = getContext(); |
| 839 | let spriteFolderName = characterName ?? context.name2; | 608 | let spriteFolderName = characterName ?? context.name2; |
| @@ -848,33 +617,6 @@ function getSpriteFolderName(characterMessage = null, characterName = null) { | |||
| 848 | return spriteFolderName; | 617 | return spriteFolderName; |
| 849 | } | 618 | } |
| 850 | 619 | ||
| 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 | |||
| 878 | function getFolderNameByMessage(message) { | 620 | function getFolderNameByMessage(message) { |
| 879 | const context = getContext(); | 621 | const context = getContext(); |
| 880 | let avatarPath = ''; | 622 | let avatarPath = ''; |
| @@ -894,48 +636,55 @@ function getFolderNameByMessage(message) { | |||
| 894 | return folderName; | 636 | return folderName; |
| 895 | } | 637 | } |
| 896 | 638 | ||
| 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) { | ||
| 900 | vnMode = isVisualNovelMode(); | 652 | vnMode = isVisualNovelMode(); |
| 901 | } | 653 | } |
| 902 | 654 | ||
| 903 | if (vnMode) { | 655 | if (vnMode) { |
| 904 | await updateVisualNovelMode(name, expression); | 656 | await updateVisualNovelMode(spriteFolderName, expression); |
| 905 | } else { | 657 | } else { |
| 906 | setExpression(name, expression, force); | 658 | setExpression(spriteFolderName, expression, { force: force, overrideSpriteFile: overrideSpriteFile }); |
| 907 | } | 659 | } |
| 908 | } | 660 | } |
| 909 | 661 | ||
| 910 | async function setSpriteSetCommand(_, folder) { | 662 | async function setSpriteFolderCommand(_, folder) { |
| 911 | if (!folder) { | 663 | if (!folder) { |
| 912 | console.log('Clearing sprite set'); | 664 | console.log('Clearing sprite set'); |
| 913 | folder = ''; | 665 | folder = ''; |
| 914 | } | 666 | } |
| 915 | 667 | ||
| 916 | if (folder.startsWith('/') || folder.startsWith('\\')) { | 668 | if (folder.startsWith('/') || folder.startsWith('\\')) { |
| 917 | folder = folder.slice(1); | ||
| 918 | |||
| 919 | const currentLastMessage = getLastCharacterMessage(); | 669 | const currentLastMessage = getLastCharacterMessage(); |
| 670 | folder = folder.slice(1); | ||
| 920 | folder = `${currentLastMessage.name}/${folder}`; | 671 | folder = `${currentLastMessage.name}/${folder}`; |
| 921 | } | 672 | } |
| 922 | 673 | ||
| 923 | $('#expression_override').val(folder.trim()); | 674 | $('#expression_override').val(folder.trim()); |
| 924 | onClickExpressionOverrideButton(); | 675 | 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); | ||
| 929 | return ''; | 678 | return ''; |
| 930 | } | 679 | } |
| 931 | 680 | ||
| 932 | async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) { | 681 | async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) { |
| 933 | if (!text) { | 682 | if (!text) { |
| 934 | toastr.warning('No text provided'); | 683 | toastr.error('No text provided'); |
| 935 | return ''; | 684 | return ''; |
| 936 | } | 685 | } |
| 937 | if (api && !Object.keys(EXPRESSION_API).includes(api)) { | 686 | if (api && !Object.keys(EXPRESSION_API).includes(api)) { |
| 938 | toastr.warning('Invalid API provided'); | 687 | toastr.error('Invalid API provided'); |
| 939 | return ''; | 688 | return ''; |
| 940 | } | 689 | } |
| 941 | 690 | ||
| @@ -951,37 +700,69 @@ async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { | |||
| 951 | return label; | 700 | return label; |
| 952 | } | 701 | } |
| 953 | 702 | ||
| 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`); | ||
| 957 | return ''; | 709 | return ''; |
| 958 | } | 710 | } |
| 959 | 711 | ||
| 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; | ||
| 961 | 719 | ||
| 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')) { | ||
| 968 | await validateImages(spriteFolderName); | 720 | await validateImages(spriteFolderName); |
| 969 | 721 | ||
| 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 | } | ||
| 974 | 740 | ||
| 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`); | ||
| 977 | return ''; | 754 | return ''; |
| 978 | } | 755 | } |
| 979 | 756 | ||
| 980 | label = spriteItem.label; | 757 | label = matchedSprite.expression; |
| 758 | spriteFile = matchedSprite.fileName; | ||
| 759 | break; | ||
| 760 | } | ||
| 761 | default: throw Error('Invalid sprite set type: ' + type); | ||
| 981 | } | 762 | } |
| 982 | 763 | ||
| 983 | const vnMode = isVisualNovelMode(); | 764 | await sendExpressionCall(spriteFolderName, label, { force: true, overrideSpriteFile: spriteFile }); |
| 984 | await sendExpressionCall(spriteFolderName, label, true, vnMode); | 765 | |
| 985 | return label; | 766 | return label; |
| 986 | } | 767 | } |
| 987 | 768 | ||
| @@ -999,6 +780,21 @@ function spriteFolderNameFromCharacter(char) { | |||
| 999 | } | 780 | } |
| 1000 | 781 | ||
| 1001 | /** | 782 | /** |
| 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 | /** | ||
| 1002 | * Slash command callback for /uploadsprite | 798 | * Slash command callback for /uploadsprite |
| 1003 | * | 799 | * |
| 1004 | * label= is required | 800 | * label= is required |
| @@ -1011,16 +807,29 @@ function spriteFolderNameFromCharacter(char) { | |||
| 1011 | * @param {object} args | 807 | * @param {object} args |
| 1012 | * @param {string} args.name Character name or avatar key, passed through findChar | 808 | * @param {string} args.name Character name or avatar key, passed through findChar |
| 1013 | * @param {string} args.label Expression label | 809 | * @param {string} args.label Expression label |
| 1014 | * @param {string} args.folder Sprite folder path, processed using backslash rules | 810 | * @param {string} [args.folder=null] Optional sprite folder path, processed using backslash rules |
| 811 | * @param {string?} [args.spriteName=null] Optional sprite name | ||
| 1015 | * @param {string} imageUrl Image URI to fetch and upload | 812 | * @param {string} imageUrl Image URI to fetch and upload |
| 1016 | * @returns {Promise<void>} | 813 | * @returns {Promise<string>} the sprite name |
| 1017 | */ | 814 | */ |
| 1018 | async function uploadSpriteCommand({ name, label, folder }, imageUrl) { | 815 | async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) { |
| 1019 | if (!imageUrl) throw new Error('Image URL is required'); | 816 | if (!imageUrl) throw new Error('Image URL is required'); |
| 1020 | if (!label || typeof label !== 'string') throw new Error('Expression label is required'); | 817 | if (!label || typeof label !== 'string') { |
| 818 | toastr.error(t`Expression label is required`, t`Error Uploading Sprite`); | ||
| 819 | return ''; | ||
| 820 | } | ||
| 1021 | 821 | ||
| 1022 | label = label.replace(/[^a-z]/gi, '').toLowerCase().trim(); | 822 | 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 | } | ||
| 1024 | 833 | ||
| 1025 | name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name; | 834 | name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name; |
| 1026 | const char = findChar({ name }); | 835 | const char = findChar({ name }); |
| @@ -1041,6 +850,7 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) { | |||
| 1041 | formData.append('name', folder); // this is the folder or character name | 850 | formData.append('name', folder); // this is the folder or character name |
| 1042 | formData.append('label', label); // this is the expression label | 851 | formData.append('label', label); // this is the expression label |
| 1043 | formData.append('avatar', file); // this is the image file | 852 | formData.append('avatar', file); // this is the image file |
| 853 | formData.append('spriteName', spriteName); // this is a redundant comment | ||
| 1044 | 854 | ||
| 1045 | await handleFileUpload('/api/sprites/upload', formData); | 855 | await handleFileUpload('/api/sprites/upload', formData); |
| 1046 | console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`); | 856 | console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`); |
| @@ -1048,6 +858,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) { | |||
| 1048 | console.error(`[${MODULE_NAME}] Error uploading file:`, error); | 858 | console.error(`[${MODULE_NAME}] Error uploading file:`, error); |
| 1049 | throw error; | 859 | throw error; |
| 1050 | } | 860 | } |
| 861 | |||
| 862 | return spriteName; | ||
| 1051 | } | 863 | } |
| 1052 | 864 | ||
| 1053 | /** | 865 | /** |
| @@ -1159,7 +971,7 @@ function getJsonSchema(emotions) { | |||
| 1159 | function onTextGenSettingsReady(args) { | 971 | function onTextGenSettingsReady(args) { |
| 1160 | // Only call if inside an API call | 972 | // Only call if inside an API call |
| 1161 | if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) { | 973 | if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) { |
| 1162 | const emotions = DEFAULT_EXPRESSIONS.filter((e) => e != 'talkinghead'); | 974 | const emotions = DEFAULT_EXPRESSIONS; |
| 1163 | Object.assign(args, { | 975 | Object.assign(args, { |
| 1164 | top_k: 1, | 976 | top_k: 1, |
| 1165 | stop: [], | 977 | stop: [], |
| @@ -1177,16 +989,16 @@ function onTextGenSettingsReady(args) { | |||
| 1177 | * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification. | 989 | * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification. |
| 1178 | * @param {object} [options={}] - Optional arguments. | 990 | * @param {object} [options={}] - Optional arguments. |
| 1179 | * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification. | 991 | * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification. |
| 1180 | * @returns {Promise<string>} - The label of the expression. | 992 | * @returns {Promise<string?>} - The label of the expression. |
| 1181 | */ | 993 | */ |
| 1182 | export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { customPrompt = null } = {}) { | 994 | export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { customPrompt = null } = {}) { |
| 1183 | // Return if text is undefined, saving a costly fetch request | 995 | // Return if text is undefined, saving a costly fetch request |
| 1184 | if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) { | 996 | if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) { |
| 1185 | return getFallbackExpression(); | 997 | return extension_settings.expressions.fallback_expression; |
| 1186 | } | 998 | } |
| 1187 | 999 | ||
| 1188 | if (extension_settings.expressions.translate && typeof window['translate'] === 'function') { | 1000 | if (extension_settings.expressions.translate && typeof globalThis.translate === 'function') { |
| 1189 | text = await window['translate'](text, 'en'); | 1001 | text = await globalThis.translate(text, 'en'); |
| 1190 | } | 1002 | } |
| 1191 | 1003 | ||
| 1192 | text = sampleClassifyText(text); | 1004 | text = sampleClassifyText(text); |
| @@ -1212,7 +1024,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin | |||
| 1212 | await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250); | 1024 | await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250); |
| 1213 | } catch (error) { | 1025 | } catch (error) { |
| 1214 | console.warn('No LLM connection. Using fallback expression', error); | 1026 | console.warn('No LLM connection. Using fallback expression', error); |
| 1215 | return getFallbackExpression(); | 1027 | return extension_settings.expressions.fallback_expression; |
| 1216 | } | 1028 | } |
| 1217 | 1029 | ||
| 1218 | const expressionsList = await getExpressionsList(); | 1030 | const expressionsList = await getExpressionsList(); |
| @@ -1225,7 +1037,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin | |||
| 1225 | case EXPRESSION_API.webllm: { | 1037 | case EXPRESSION_API.webllm: { |
| 1226 | if (!isWebLlmSupported()) { | 1038 | if (!isWebLlmSupported()) { |
| 1227 | console.warn('WebLLM is not supported. Using fallback expression'); | 1039 | console.warn('WebLLM is not supported. Using fallback expression'); |
| 1228 | return getFallbackExpression(); | 1040 | return extension_settings.expressions.fallback_expression; |
| 1229 | } | 1041 | } |
| 1230 | 1042 | ||
| 1231 | const expressionsList = await getExpressionsList(); | 1043 | const expressionsList = await getExpressionsList(); |
| @@ -1258,9 +1070,9 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin | |||
| 1258 | } break; | 1070 | } break; |
| 1259 | } | 1071 | } |
| 1260 | } catch (error) { | 1072 | } catch (error) { |
| 1261 | toastr.info('Could not classify expression. Check the console or your backend for more information.'); | 1073 | toastr.error('Could not classify expression. Check the console or your backend for more information.'); |
| 1262 | console.error(error); | 1074 | console.error(error); |
| 1263 | return getFallbackExpression(); | 1075 | return extension_settings.expressions.fallback_expression; |
| 1264 | } | 1076 | } |
| 1265 | } | 1077 | } |
| 1266 | 1078 | ||
| @@ -1288,75 +1100,155 @@ function removeExpression() { | |||
| 1288 | $('#no_chat_expressions').show(); | 1100 | $('#no_chat_expressions').show(); |
| 1289 | } | 1101 | } |
| 1290 | 1102 | ||
| 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) { | ||
| 1293 | return; | 1110 | return; |
| 1294 | } | 1111 | } |
| 1295 | 1112 | ||
| 1296 | const labels = await getExpressionsList(); | 1113 | const labels = await getExpressionsList(); |
| 1297 | 1114 | ||
| 1298 | if (spriteCache[character]) { | 1115 | if (spriteCache[spriteFolderName]) { |
| 1299 | if (forceRedrawCached && $('#image_list').data('name') !== character) { | 1116 | if (forceRedrawCached && $('#image_list').data('name') !== spriteFolderName) { |
| 1300 | console.debug('force redrawing character sprites list'); | 1117 | console.debug('force redrawing character sprites list'); |
| 1301 | await drawSpritesList(character, labels, spriteCache[character]); | 1118 | await drawSpritesList(spriteFolderName, labels, spriteCache[spriteFolderName]); |
| 1302 | } | 1119 | } |
| 1303 | 1120 | ||
| 1304 | return; | 1121 | return; |
| 1305 | } | 1122 | } |
| 1306 | 1123 | ||
| 1307 | const sprites = await getSpritesList(character); | 1124 | const sprites = await getSpritesList(spriteFolderName); |
| 1308 | let validExpressions = await drawSpritesList(character, labels, sprites); | 1125 | let validExpressions = await drawSpritesList(spriteFolderName, labels, sprites); |
| 1309 | spriteCache[character] = validExpressions; | 1126 | spriteCache[spriteFolderName] = 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 | }; | ||
| 1310 | } | 1145 | } |
| 1311 | 1146 | ||
| 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[]} */ | ||
| 1313 | let validExpressions = []; | 1156 | let validExpressions = []; |
| 1157 | |||
| 1314 | $('#no_chat_expressions').hide(); | 1158 | $('#no_chat_expressions').hide(); |
| 1315 | $('#open_chat_expressions').show(); | 1159 | $('#open_chat_expressions').show(); |
| 1316 | $('#image_list').empty(); | 1160 | $('#image_list').empty(); |
| 1317 | $('#image_list').data('name', character); | 1161 | $('#image_list').data('name', spriteFolderName); |
| 1318 | $('#image_list_header_name').text(character); | 1162 | $('#image_list_header_name').text(spriteFolderName); |
| 1319 | 1163 | ||
| 1320 | if (!Array.isArray(labels)) { | 1164 | if (!Array.isArray(labels)) { |
| 1321 | return []; | 1165 | return []; |
| 1322 | } | 1166 | } |
| 1323 | 1167 | ||
| 1324 | for (const item of labels.sort()) { | 1168 | for (const expression 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) |
| 1329 | validExpressions.push(sprite); | 1173 | .flat(); |
| 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 | }); | ||
| 1331 | $('#image_list').append(listItem); | 1180 | $('#image_list').append(listItem); |
| 1181 | continue; | ||
| 1332 | } | 1182 | } |
| 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 | }); | ||
| 1335 | $('#image_list').append(listItem); | 1191 | $('#image_list').append(listItem); |
| 1336 | } | 1192 | } |
| 1337 | } | ||
| 1338 | return validExpressions; | 1193 | return validExpressions; |
| 1339 | } | 1194 | } |
| 1340 | 1195 | ||
| 1341 | /** | 1196 | /** |
| 1342 | * Renders a list item template for the expressions list. | 1197 | * Renders a list item template for the expressions list. |
| 1343 | * @param {string} item Expression name | 1198 | * @param {string} expression Expression name |
| 1344 | * @param {string} imageSrc Path to image | 1199 | * @param {object} args Arguments object |
| 1345 | * @param {'success' | 'failure'} textClass 'success' or 'failure' | 1200 | * @param {ExpressionImage[]} [args.images] Array of image objects |
| 1346 | * @param {boolean} isCustom If expression is added by user | 1201 | * @param {boolean} [args.isCustom=false] If expression is added by user |
| 1347 | * @returns {Promise<string>} Rendered list item template | 1202 | * @returns {Promise<string>} Rendered list item template |
| 1348 | */ | 1203 | */ |
| 1349 | async function getListItem(item, imageSrc, textClass, isCustom) { | 1204 | async function getListItem(expression, { images, isCustom = false } = {}) { |
| 1350 | return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom }); | 1205 | return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false }); |
| 1351 | } | 1206 | } |
| 1352 | 1207 | ||
| 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 | |||
| 1353 | async function getSpritesList(name) { | 1216 | async function getSpritesList(name) { |
| 1354 | console.debug('getting sprites list'); | 1217 | console.debug('getting sprites list'); |
| 1355 | 1218 | ||
| 1356 | try { | 1219 | try { |
| 1357 | const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`); | 1220 | const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`); |
| 1221 | /** @type {{ label: string, path: string }[]} */ | ||
| 1358 | let sprites = result.ok ? (await result.json()) : []; | 1222 | 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; | ||
| 1360 | } | 1252 | } |
| 1361 | catch (err) { | 1253 | catch (err) { |
| 1362 | console.log(err); | 1254 | console.log(err); |
| @@ -1395,17 +1287,31 @@ async function renderFallbackExpressionPicker() { | |||
| 1395 | const defaultPicker = $('#expression_fallback'); | 1287 | const defaultPicker = $('#expression_fallback'); |
| 1396 | defaultPicker.empty(); | 1288 | defaultPicker.empty(); |
| 1397 | 1289 | ||
| 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); | ||
| 1399 | 1293 | ||
| 1400 | for (const expression of expressions) { | 1294 | 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) { | ||
| 1401 | const option = document.createElement('option'); | 1300 | const option = document.createElement('option'); |
| 1402 | option.value = expression; | 1301 | option.value = value; |
| 1403 | option.text = expression; | 1302 | option.text = label; |
| 1404 | option.selected = expression == fallbackExpression; | 1303 | option.selected = isSelected; |
| 1405 | defaultPicker.append(option); | 1304 | defaultPicker.append(option); |
| 1406 | } | 1305 | } |
| 1407 | } | 1306 | } |
| 1408 | 1307 | ||
| 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 | |||
| 1409 | function getCachedExpressions() { | 1315 | function getCachedExpressions() { |
| 1410 | if (!Array.isArray(expressionsList)) { | 1316 | if (!Array.isArray(expressionsList)) { |
| 1411 | return []; | 1317 | return []; |
| @@ -1463,7 +1369,7 @@ export async function getExpressionsList() { | |||
| 1463 | } | 1369 | } |
| 1464 | 1370 | ||
| 1465 | // If there was no specific list, or an error, just return the default expressions | 1371 | // If there was no specific list, or an error, just return the default expressions |
| 1466 | expressionsList = DEFAULT_EXPRESSIONS.filter(e => e !== 'talkinghead').slice(); | 1372 | expressionsList = DEFAULT_EXPRESSIONS.slice(); |
| 1467 | return expressionsList; | 1373 | return expressionsList; |
| 1468 | } | 1374 | } |
| 1469 | 1375 | ||
| @@ -1471,38 +1377,88 @@ export async function getExpressionsList() { | |||
| 1471 | return [...result, ...extension_settings.expressions.custom].filter(onlyUnique); | 1377 | return [...result, ...extension_settings.expressions.custom].filter(onlyUnique); |
| 1472 | } | 1378 | } |
| 1473 | 1379 | ||
| 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); | ||
| 1478 | const img = $('img.expression'); | 1438 | const img = $('img.expression'); |
| 1479 | const prevExpressionSrc = img.attr('src'); | 1439 | const prevExpressionSrc = img.attr('src'); |
| 1480 | const expressionClone = img.clone(); | 1440 | const expressionClone = img.clone(); |
| 1481 | 1441 | ||
| 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 | |||
| 1487 | if (force && isVisualNovelMode()) { | 1444 | if (force && isVisualNovelMode()) { |
| 1488 | const context = getContext(); | 1445 | const context = getContext(); |
| 1489 | const group = context.groups.find(x => x.id === context.groupId); | 1446 | const group = context.groups.find(x => x.id === context.groupId); |
| 1490 | 1447 | ||
| 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 | } | ||
| 1497 | 1450 | ||
| 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); | ||
| 1500 | return; | 1456 | return; |
| 1501 | } | 1457 | } |
| 1502 | } | 1458 | } |
| 1503 | } | 1459 | |
| 1504 | //only swap expressions when necessary | 1460 | //only swap expressions when necessary |
| 1505 | if (prevExpressionSrc !== sprite.path | 1461 | if (prevExpressionSrc !== spriteFile.imageSrc |
| 1506 | && !img.hasClass('expression-animating')) { | 1462 | && !img.hasClass('expression-animating')) { |
| 1507 | //clone expression | 1463 | //clone expression |
| 1508 | expressionClone.addClass('expression-clone'); | 1464 | expressionClone.addClass('expression-clone'); |
| @@ -1510,7 +1466,12 @@ async function setExpression(character, expression, force) { | |||
| 1510 | //must be made invisible to start because they share the same Z-index | 1466 | //must be made invisible to start because they share the same Z-index |
| 1511 | expressionClone.attr('id', '').css({ opacity: 0 }); | 1467 | expressionClone.attr('id', '').css({ opacity: 0 }); |
| 1512 | //add new sprite path to clone src | 1468 | //add new sprite path to clone src |
| 1513 | expressionClone.attr('src', sprite.path); | 1469 | expressionClone.attr('src', spriteFile.imageSrc); |
| 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); | ||
| 1514 | //add invisible clone to html | 1475 | //add invisible clone to html |
| 1515 | expressionClone.appendTo($('#expression-holder')); | 1476 | expressionClone.appendTo($('#expression-holder')); |
| 1516 | 1477 | ||
| @@ -1552,80 +1513,85 @@ async function setExpression(character, expression, force) { | |||
| 1552 | expressionHolder.css('min-height', 100); | 1513 | expressionHolder.css('min-height', 100); |
| 1553 | }); | 1514 | }); |
| 1554 | 1515 | ||
| 1555 | |||
| 1556 | expressionClone.removeClass('expression-clone'); | 1516 | expressionClone.removeClass('expression-clone'); |
| 1557 | 1517 | ||
| 1558 | expressionClone.removeClass('default'); | 1518 | expressionClone.removeClass('default'); |
| 1559 | expressionClone.off('error'); | 1519 | expressionClone.off('error'); |
| 1560 | expressionClone.on('error', function () { | 1520 | expressionClone.on('error', function (error) { |
| 1561 | console.debug('Expression image error', sprite.path); | 1521 | console.debug('Expression image error', spriteFile.imageSrc, error); |
| 1562 | $(this).attr('src', ''); | 1522 | $(this).attr('src', ''); |
| 1563 | $(this).off('error'); | 1523 | $(this).off('error'); |
| 1564 | if (force && extension_settings.expressions.showDefault) { | 1524 | if (force && extension_settings.expressions.showDefault) { |
| 1565 | setDefault(); | 1525 | setDefaultEmojiForImage(img, expression); |
| 1566 | } | 1526 | } |
| 1567 | }); | 1527 | }); |
| 1568 | } | 1528 | } |
| 1529 | |||
| 1530 | console.info('Expression set', { expression: spriteFile.expression, file: spriteFile.fileName }); | ||
| 1569 | } | 1531 | } |
| 1570 | else { | 1532 | else { |
| 1571 | if (extension_settings.expressions.showDefault) { | 1533 | img.attr('data-sprite-folder-name', spriteFolderName); |
| 1572 | setDefault(); | ||
| 1573 | } | ||
| 1574 | } | ||
| 1575 | 1534 | ||
| 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 = ''; | ||
| 1584 | 1536 | ||
| 1537 | if (extension_settings.expressions.showDefault && expression !== RESET_SPRITE_LABEL) { | ||
| 1538 | setDefaultEmojiForImage(img, expression); | ||
| 1585 | } else { | 1539 | } 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 | } | ||
| 1601 | } | 1541 | } |
| 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. | ||
| 1604 | } | 1543 | } |
| 1605 | 1544 | ||
| 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 | } | ||
| 1614 | } | 1546 | } |
| 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; | ||
| 1617 | } | 1557 | } |
| 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'); | ||
| 1618 | } | 1565 | } |
| 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'); | ||
| 1619 | } | 1578 | } |
| 1620 | 1579 | ||
| 1621 | function onClickExpressionImage() { | 1580 | function 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); | ||
| 1624 | } | 1590 | } |
| 1625 | 1591 | ||
| 1626 | async function onClickExpressionAddCustom() { | 1592 | async function onClickExpressionAddCustom() { |
| 1627 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression'); | 1593 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression'); |
| 1628 | let expressionName = await callPopup(template, 'input'); | 1594 | let expressionName = await Popup.show.input(null, template); |
| 1629 | 1595 | ||
| 1630 | if (!expressionName) { | 1596 | if (!expressionName) { |
| 1631 | console.debug('No custom expression name provided'); | 1597 | console.debug('No custom expression name provided'); |
| @@ -1636,19 +1602,15 @@ async function onClickExpressionAddCustom() { | |||
| 1636 | 1602 | ||
| 1637 | // a-z, 0-9, dashes and underscores only | 1603 | // a-z, 0-9, dashes and underscores only |
| 1638 | if (!/^[a-z0-9-_]+$/.test(expressionName)) { | 1604 | if (!/^[a-z0-9-_]+$/.test(expressionName)) { |
| 1639 | toastr.info('Invalid custom expression name provided'); | 1605 | toastr.warning('Invalid custom expression name provided', 'Add Custom Expression'); |
| 1640 | return; | 1606 | return; |
| 1641 | } | 1607 | } |
| 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'); | ||
| 1646 | return; | 1610 | return; |
| 1647 | } | 1611 | } |
| 1648 | |||
| 1649 | // Check if expression name already exists in custom expressions | ||
| 1650 | if (extension_settings.expressions.custom.includes(expressionName)) { | 1612 | if (extension_settings.expressions.custom.includes(expressionName)) { |
| 1651 | toastr.info('Custom expression already exists'); | 1613 | toastr.warning('Custom expression already exists', 'Add Custom Expression'); |
| 1652 | return; | 1614 | return; |
| 1653 | } | 1615 | } |
| 1654 | 1616 | ||
| @@ -1665,14 +1627,15 @@ async function onClickExpressionAddCustom() { | |||
| 1665 | 1627 | ||
| 1666 | async function onClickExpressionRemoveCustom() { | 1628 | async function onClickExpressionRemoveCustom() { |
| 1667 | const selectedExpression = String($('#expression_custom').val()); | 1629 | const selectedExpression = String($('#expression_custom').val()); |
| 1630 | const noCustomExpressions = extension_settings.expressions.custom.length === 0; | ||
| 1668 | 1631 | ||
| 1669 | if (!selectedExpression) { | 1632 | if (!selectedExpression || noCustomExpressions) { |
| 1670 | console.debug('No custom expression selected'); | 1633 | console.debug('No custom expression selected'); |
| 1671 | return; | 1634 | return; |
| 1672 | } | 1635 | } |
| 1673 | 1636 | ||
| 1674 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression }); | 1637 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression }); |
| 1675 | const confirmation = await callPopup(template, 'confirm'); | 1638 | const confirmation = await Popup.show.confirm(null, template); |
| 1676 | 1639 | ||
| 1677 | if (!confirmation) { | 1640 | if (!confirmation) { |
| 1678 | console.debug('Custom expression removal cancelled'); | 1641 | console.debug('Custom expression removal cancelled'); |
| @@ -1682,8 +1645,8 @@ async function onClickExpressionRemoveCustom() { | |||
| 1682 | // Remove custom expression from settings | 1645 | // Remove custom expression from settings |
| 1683 | const index = extension_settings.expressions.custom.indexOf(selectedExpression); | 1646 | const index = extension_settings.expressions.custom.indexOf(selectedExpression); |
| 1684 | extension_settings.expressions.custom.splice(index, 1); | 1647 | extension_settings.expressions.custom.splice(index, 1); |
| 1685 | if (selectedExpression == getFallbackExpression()) { | 1648 | if (selectedExpression == extension_settings.expressions.fallback_expression) { |
| 1686 | toastr.warning(`Deleted custom expression '${selectedExpression}' that was also selected as the fallback expression.\nFallback expression has been reset to '${DEFAULT_FALLBACK_EXPRESSION}'.`); | 1649 | 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'); |
| 1687 | extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION; | 1650 | extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION; |
| 1688 | } | 1651 | } |
| 1689 | await renderAdditionalExpressionSettings(); | 1652 | await renderAdditionalExpressionSettings(); |
| @@ -1707,12 +1670,35 @@ function onExpressionApiChanged() { | |||
| 1707 | } | 1670 | } |
| 1708 | } | 1671 | } |
| 1709 | 1672 | ||
| 1710 | function onExpressionFallbackChanged() { | 1673 | async 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 }); | ||
| 1715 | } | 1699 | } |
| 1700 | |||
| 1701 | saveSettingsDebounced(); | ||
| 1716 | } | 1702 | } |
| 1717 | 1703 | ||
| 1718 | async function handleFileUpload(url, formData) { | 1704 | async function handleFileUpload(url, formData) { |
| @@ -1739,34 +1725,111 @@ async function handleFileUpload(url, formData) { | |||
| 1739 | } | 1725 | } |
| 1740 | } | 1726 | } |
| 1741 | 1727 | ||
| 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 | |||
| 1742 | async function onClickExpressionUpload(event) { | 1743 | async function onClickExpressionUpload(event) { |
| 1743 | // Prevents the expression from being set | 1744 | // Prevents the expression from being set |
| 1744 | event.stopPropagation(); | 1745 | event.stopPropagation(); |
| 1745 | 1746 | ||
| 1746 | const id = $(this).closest('.expression_list_item').attr('id'); | 1747 | const expressionListItem = $(this).closest('.expression_list_item'); |
| 1748 | |||
| 1749 | const clickedFileName = expressionListItem.attr('data-expression-type') !== 'failure' ? expressionListItem.attr('data-filename') : null; | ||
| 1750 | const expression = expressionListItem.data('expression'); | ||
| 1747 | const name = $('#image_list').data('name'); | 1751 | const name = $('#image_list').data('name'); |
| 1748 | 1752 | ||
| 1749 | const handleExpressionUploadChange = async (e) => { | 1753 | const handleExpressionUploadChange = async (e) => { |
| 1750 | const file = e.target.files[0]; | 1754 | const file = e.target.files[0]; |
| 1751 | 1755 | ||
| 1752 | if (!file) { | 1756 | 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(); | ||
| 1753 | return; | 1820 | return; |
| 1754 | } | 1821 | } |
| 1755 | 1822 | ||
| 1756 | const formData = new FormData(); | 1823 | const formData = new FormData(); |
| 1757 | formData.append('name', name); | 1824 | formData.append('name', name); |
| 1758 | formData.append('label', id); | 1825 | formData.append('label', expression); |
| 1759 | formData.append('avatar', file); | 1826 | formData.append('avatar', file); |
| 1827 | formData.append('spriteName', spriteName); | ||
| 1760 | 1828 | ||
| 1761 | await handleFileUpload('/api/sprites/upload', formData); | 1829 | await handleFileUpload('/api/sprites/upload', formData); |
| 1762 | 1830 | ||
| 1763 | // Reset the input | 1831 | // Reset the input |
| 1764 | e.target.form.reset(); | 1832 | 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 | } | ||
| 1770 | }; | 1833 | }; |
| 1771 | 1834 | ||
| 1772 | $('#expression_upload') | 1835 | $('#expression_upload') |
| @@ -1822,8 +1885,9 @@ async function onClickExpressionOverrideButton() { | |||
| 1822 | inApiCall = true; | 1885 | inApiCall = true; |
| 1823 | $('#visual-novel-wrapper').empty(); | 1886 | $('#visual-novel-wrapper').empty(); |
| 1824 | await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true); | 1887 | await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true); |
| 1888 | const name = overridePath.length === 0 ? currentLastMessage.name : overridePath; | ||
| 1825 | const expression = await getExpressionLabel(currentLastMessage.mes); | 1889 | const expression = await getExpressionLabel(currentLastMessage.mes); |
| 1826 | await sendExpressionCall(overridePath.length === 0 ? currentLastMessage.name : overridePath, expression, true); | 1890 | await sendExpressionCall(name, expression, { force: true }); |
| 1827 | forceUpdateVisualNovelMode(); | 1891 | forceUpdateVisualNovelMode(); |
| 1828 | } catch (error) { | 1892 | } catch (error) { |
| 1829 | console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`); | 1893 | console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`); |
| @@ -1849,7 +1913,7 @@ async function onClickExpressionOverrideRemoveAllButton() { | |||
| 1849 | const currentLastMessage = getLastCharacterMessage(); | 1913 | const currentLastMessage = getLastCharacterMessage(); |
| 1850 | await validateImages(currentLastMessage.name, true); | 1914 | await validateImages(currentLastMessage.name, true); |
| 1851 | const expression = await getExpressionLabel(currentLastMessage.mes); | 1915 | const expression = await getExpressionLabel(currentLastMessage.mes); |
| 1852 | await sendExpressionCall(currentLastMessage.name, expression, true); | 1916 | await sendExpressionCall(currentLastMessage.name, expression, { force: true }); |
| 1853 | forceUpdateVisualNovelMode(); | 1917 | forceUpdateVisualNovelMode(); |
| 1854 | 1918 | ||
| 1855 | console.debug(extension_settings.expressionOverrides); | 1919 | console.debug(extension_settings.expressionOverrides); |
| @@ -1872,16 +1936,13 @@ async function onClickExpressionUploadPackButton() { | |||
| 1872 | formData.append('name', name); | 1936 | formData.append('name', name); |
| 1873 | formData.append('avatar', file); | 1937 | formData.append('avatar', file); |
| 1874 | 1938 | ||
| 1939 | const uploadToast = toastr.info('Please wait...', 'Upload is processing', { timeOut: 0, extendedTimeOut: 0 }); | ||
| 1875 | const { count } = await handleFileUpload('/api/sprites/upload-zip', formData); | 1940 | const { count } = await handleFileUpload('/api/sprites/upload-zip', formData); |
| 1941 | toastr.clear(uploadToast); | ||
| 1876 | toastr.success(`Uploaded ${count} image(s) for ${name}`); | 1942 | toastr.success(`Uploaded ${count} image(s) for ${name}`); |
| 1877 | 1943 | ||
| 1878 | // Reset the input | 1944 | // Reset the input |
| 1879 | e.target.form.reset(); | 1945 | e.target.form.reset(); |
| 1880 | |||
| 1881 | // In Talkinghead mode, refresh the live char. | ||
| 1882 | if (isTalkingHeadEnabled() && modules.includes('talkinghead')) { | ||
| 1883 | await loadTalkingHead(); | ||
| 1884 | } | ||
| 1885 | }; | 1946 | }; |
| 1886 | 1947 | ||
| 1887 | $('#expression_upload_pack') | 1948 | $('#expression_upload_pack') |
| @@ -1894,20 +1955,28 @@ async function onClickExpressionDelete(event) { | |||
| 1894 | // Prevents the expression from being set | 1955 | // Prevents the expression from being set |
| 1895 | event.stopPropagation(); | 1956 | event.stopPropagation(); |
| 1896 | 1957 | ||
| 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 | } | ||
| 1898 | 1964 | ||
| 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>'); | ||
| 1899 | if (!confirmation) { | 1968 | if (!confirmation) { |
| 1900 | return; | 1969 | return; |
| 1901 | } | 1970 | } |
| 1902 | 1971 | ||
| 1903 | const id = $(this).closest('.expression_list_item').attr('id'); | 1972 | const fileName = withoutExtension(expressionListItem.attr('data-filename')); |
| 1904 | const name = $('#image_list').data('name'); | 1973 | const name = $('#image_list').data('name'); |
| 1905 | 1974 | ||
| 1906 | try { | 1975 | try { |
| 1907 | await fetch('/api/sprites/delete', { | 1976 | await fetch('/api/sprites/delete', { |
| 1908 | method: 'POST', | 1977 | method: 'POST', |
| 1909 | headers: getRequestHeaders(), | 1978 | headers: getRequestHeaders(), |
| 1910 | body: JSON.stringify({ name, label: id }), | 1979 | body: JSON.stringify({ name, label: expression, spriteName: fileName }), |
| 1911 | }); | 1980 | }); |
| 1912 | } catch (error) { | 1981 | } catch (error) { |
| 1913 | toastr.error('Failed to delete image. Try again later.'); | 1982 | toastr.error('Failed to delete image. Try again later.'); |
| @@ -1984,6 +2053,16 @@ function migrateSettings() { | |||
| 1984 | extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT; | 2053 | extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT; |
| 1985 | saveSettingsDebounced(); | 2054 | saveSettingsDebounced(); |
| 1986 | } | 2055 | } |
| 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 | } | ||
| 1987 | } | 2066 | } |
| 1988 | 2067 | ||
| 1989 | (async function () { | 2068 | (async function () { |
| @@ -2010,13 +2089,19 @@ function migrateSettings() { | |||
| 2010 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings'); | 2089 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings'); |
| 2011 | $('#expressions_container').append(template); | 2090 | $('#expressions_container').append(template); |
| 2012 | $('#expression_override_button').on('click', onClickExpressionOverrideButton); | 2091 | $('#expression_override_button').on('click', onClickExpressionOverrideButton); |
| 2013 | $('#expressions_show_default').on('input', onExpressionsShowDefaultInput); | ||
| 2014 | $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton); | 2092 | $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton); |
| 2015 | $('#expressions_show_default').prop('checked', extension_settings.expressions.showDefault).trigger('input'); | ||
| 2016 | $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () { | 2093 | $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () { |
| 2017 | extension_settings.expressions.translate = !!$(this).prop('checked'); | 2094 | extension_settings.expressions.translate = !!$(this).prop('checked'); |
| 2018 | saveSettingsDebounced(); | 2095 | saveSettingsDebounced(); |
| 2019 | }); | 2096 | }); |
| 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 | }); | ||
| 2020 | $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton); | 2105 | $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton); |
| 2021 | $(document).on('dragstart', '.expression', (e) => { | 2106 | $(document).on('dragstart', '.expression', (e) => { |
| 2022 | e.preventDefault(); | 2107 | e.preventDefault(); |
| @@ -2025,21 +2110,15 @@ function migrateSettings() { | |||
| 2025 | $(document).on('click', '.expression_list_item', onClickExpressionImage); | 2110 | $(document).on('click', '.expression_list_item', onClickExpressionImage); |
| 2026 | $(document).on('click', '.expression_list_upload', onClickExpressionUpload); | 2111 | $(document).on('click', '.expression_list_upload', onClickExpressionUpload); |
| 2027 | $(document).on('click', '.expression_list_delete', onClickExpressionDelete); | 2112 | $(document).on('click', '.expression_list_delete', onClickExpressionDelete); |
| 2028 | $(window).on('resize', updateVisualNovelModeDebounced); | 2113 | $(window).on('resize', () => updateVisualNovelModeDebounced()); |
| 2029 | $('#open_chat_expressions').hide(); | 2114 | $('#open_chat_expressions').hide(); |
| 2030 | 2115 | ||
| 2031 | $('#image_type_toggle').on('click', function () { | ||
| 2032 | if (this instanceof HTMLInputElement) { | ||
| 2033 | setTalkingHeadState(this.checked); | ||
| 2034 | } | ||
| 2035 | }); | ||
| 2036 | |||
| 2037 | await renderAdditionalExpressionSettings(); | 2116 | await renderAdditionalExpressionSettings(); |
| 2038 | $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras); | 2117 | $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras); |
| 2039 | $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)); | 2118 | $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)); |
| 2040 | $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? ''); | 2119 | $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? ''); |
| 2041 | $('#expression_llm_prompt').on('input', function () { | 2120 | $('#expression_llm_prompt').on('input', function () { |
| 2042 | extension_settings.expressions.llmPrompt = $(this).val(); | 2121 | extension_settings.expressions.llmPrompt = String($(this).val()); |
| 2043 | saveSettingsDebounced(); | 2122 | saveSettingsDebounced(); |
| 2044 | }); | 2123 | }); |
| 2045 | $('#expression_llm_prompt_restore').on('click', function () { | 2124 | $('#expression_llm_prompt_restore').on('click', function () { |
| @@ -2054,34 +2133,6 @@ function migrateSettings() { | |||
| 2054 | $('#expression_api').on('change', onExpressionApiChanged); | 2133 | $('#expression_api').on('change', onExpressionApiChanged); |
| 2055 | } | 2134 | } |
| 2056 | 2135 | ||
| 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 | |||
| 2085 | addExpressionImage(); | 2136 | addExpressionImage(); |
| 2086 | addVisualNovelMode(); | 2137 | addVisualNovelMode(); |
| 2087 | migrateSettings(); | 2138 | migrateSettings(); |
| @@ -2090,11 +2141,6 @@ function migrateSettings() { | |||
| 2090 | const updateFunction = wrapper.update.bind(wrapper); | 2141 | const updateFunction = wrapper.update.bind(wrapper); |
| 2091 | setInterval(updateFunction, UPDATE_INTERVAL); | 2142 | setInterval(updateFunction, UPDATE_INTERVAL); |
| 2092 | moduleWorker(); | 2143 | 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(); | ||
| 2098 | dragElement($('#expression-holder')); | 2144 | dragElement($('#expression-holder')); |
| 2099 | eventSource.on(event_types.CHAT_CHANGED, () => { | 2145 | eventSource.on(event_types.CHAT_CHANGED, () => { |
| 2100 | // character changed | 2146 | // character changed |
| @@ -2108,110 +2154,137 @@ function migrateSettings() { | |||
| 2108 | imgElement.src = ''; | 2154 | imgElement.src = ''; |
| 2109 | } | 2155 | } |
| 2110 | 2156 | ||
| 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 | |||
| 2117 | setExpressionOverrideHtml(); | 2157 | setExpressionOverrideHtml(); |
| 2118 | 2158 | ||
| 2119 | if (isVisualNovelMode()) { | 2159 | if (isVisualNovelMode()) { |
| 2120 | $('#visual-novel-wrapper').empty(); | 2160 | $('#visual-novel-wrapper').empty(); |
| 2121 | } | 2161 | } |
| 2122 | 2162 | ||
| 2123 | updateFunction(); | 2163 | updateFunction({ newChat: true }); |
| 2124 | }); | 2164 | }); |
| 2125 | eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced); | 2165 | eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced); |
| 2126 | eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced); | 2166 | 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 | }); | ||
| 2132 | 2167 | ||
| 2133 | const localEnumProviders = { | 2168 | const localEnumProviders = { |
| 2134 | expressions: () => getCachedExpressions().map(expression => { | 2169 | expressions: () => { |
| 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; | ||
| 2135 | const isCustom = extension_settings.expressions.custom?.includes(expression); | 2175 | 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 | }, | ||
| 2138 | }; | 2195 | }; |
| 2139 | 2196 | ||
| 2140 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2197 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2141 | name: 'sprite', | 2198 | name: 'expression-set', |
| 2142 | aliases: ['emote'], | 2199 | aliases: ['sprite', 'emote'], |
| 2143 | callback: setSpriteSlashCommand, | 2200 | 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 | ], | ||
| 2144 | unnamedArgumentList: [ | 2211 | unnamedArgumentList: [ |
| 2145 | SlashCommandArgument.fromProps({ | 2212 | SlashCommandArgument.fromProps({ |
| 2146 | description: 'spriteId', | 2213 | description: 'expression label to set', |
| 2147 | typeList: [ARGUMENT_TYPE.STRING], | 2214 | typeList: [ARGUMENT_TYPE.STRING], |
| 2148 | isRequired: true, | 2215 | 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 | }, | ||
| 2150 | }), | 2225 | }), |
| 2151 | ], | 2226 | ], |
| 2152 | helpString: 'Force sets the sprite for the current character.', | 2227 | helpString: 'Force sets the expression for the current character.', |
| 2153 | returns: 'the currently set sprite label after setting it.', | 2228 | returns: 'The currently set expression label after setting it.', |
| 2154 | })); | 2229 | })); |
| 2155 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2230 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2156 | name: 'spriteoverride', | 2231 | name: 'expression-folder-override', |
| 2157 | aliases: ['costume'], | 2232 | aliases: ['spriteoverride', 'costume'], |
| 2158 | callback: setSpriteSetCommand, | 2233 | callback: setSpriteFolderCommand, |
| 2159 | unnamedArgumentList: [ | 2234 | unnamedArgumentList: [ |
| 2160 | new SlashCommandArgument( | 2235 | new SlashCommandArgument( |
| 2161 | 'optional folder', [ARGUMENT_TYPE.STRING], false, | 2236 | 'optional folder', [ARGUMENT_TYPE.STRING], false, |
| 2162 | ), | 2237 | ), |
| 2163 | ], | 2238 | ], |
| 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 | `, | ||
| 2165 | })); | 2248 | })); |
| 2166 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2249 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2167 | name: 'lastsprite', | 2250 | name: 'expression-last', |
| 2168 | callback: (_, name) => { | 2251 | aliases: ['lastsprite'], |
| 2252 | /** @type {(args: object, name: string) => Promise<string>} */ | ||
| 2253 | callback: async (_, name) => { | ||
| 2169 | if (typeof name !== 'string') throw new Error('name must be a string'); | 2254 | 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 | |||
| 2170 | const char = findChar({ name: name }); | 2263 | const char = findChar({ name: name }); |
| 2264 | if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`); | ||
| 2265 | |||
| 2171 | const sprite = lastExpression[char?.name ?? name] ?? ''; | 2266 | const sprite = lastExpression[char?.name ?? name] ?? ''; |
| 2172 | return sprite; | 2267 | return sprite; |
| 2173 | }, | 2268 | }, |
| 2174 | returns: 'the last set sprite / expression for the named character.', | 2269 | returns: 'the last set expression for the named character.', |
| 2175 | unnamedArgumentList: [ | 2270 | unnamedArgumentList: [ |
| 2176 | SlashCommandArgument.fromProps({ | 2271 | SlashCommandArgument.fromProps({ |
| 2177 | description: 'Character name - or unique character identifier (avatar key)', | 2272 | 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)', |
| 2178 | typeList: [ARGUMENT_TYPE.STRING], | 2273 | typeList: [ARGUMENT_TYPE.STRING], |
| 2179 | isRequired: true, | ||
| 2180 | enumProvider: commonEnumProviders.characters('character'), | 2274 | enumProvider: commonEnumProviders.characters('character'), |
| 2181 | }), | 2275 | }), |
| 2182 | ], | 2276 | ], |
| 2183 | helpString: 'Returns the last set sprite / expression for the named character.', | 2277 | helpString: 'Returns the last set 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.', | ||
| 2191 | })); | 2278 | })); |
| 2192 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2279 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2193 | name: 'classify-expressions', | 2280 | name: 'expression-list', |
| 2194 | aliases: ['expressions'], | 2281 | aliases: ['expressions'], |
| 2282 | /** @type {(args: {return: string}) => Promise<string>} */ | ||
| 2195 | callback: async (args) => { | 2283 | callback: async (args) => { |
| 2284 | let returnType = | ||
| 2196 | /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */ | 2285 | /** @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 | } | ||
| 2213 | 2287 | ||
| 2214 | // Now the actual new return type handling | ||
| 2215 | const list = await getExpressionsList(); | 2288 | const list = await getExpressionsList(); |
| 2216 | 2289 | ||
| 2217 | return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') }); | 2290 | return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') }); |
| @@ -2225,22 +2298,13 @@ function migrateSettings() { | |||
| 2225 | enumList: slashCommandReturnHelper.enumList({ allowObject: true }), | 2298 | enumList: slashCommandReturnHelper.enumList({ allowObject: true }), |
| 2226 | forceEnum: true, | 2299 | forceEnum: true, |
| 2227 | }), | 2300 | }), |
| 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 | }), | ||
| 2238 | ], | 2301 | ], |
| 2239 | returns: 'The comma-separated list of available expressions, including custom expressions.', | 2302 | returns: 'The comma-separated list of available expressions, including custom expressions.', |
| 2240 | helpString: 'Returns a list of available expressions, including custom expressions.', | 2303 | helpString: 'Returns a list of available expressions, including custom expressions.', |
| 2241 | })); | 2304 | })); |
| 2242 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2305 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2243 | name: 'classify', | 2306 | name: 'expression-classify', |
| 2307 | aliases: ['classify'], | ||
| 2244 | callback: classifyCallback, | 2308 | callback: classifyCallback, |
| 2245 | namedArgumentList: [ | 2309 | namedArgumentList: [ |
| 2246 | SlashCommandNamedArgument.fromProps({ | 2310 | SlashCommandNamedArgument.fromProps({ |
| @@ -2279,11 +2343,13 @@ function migrateSettings() { | |||
| 2279 | `, | 2343 | `, |
| 2280 | })); | 2344 | })); |
| 2281 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2345 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2282 | name: 'uploadsprite', | 2346 | name: 'expression-upload', |
| 2347 | aliases: ['uploadsprite'], | ||
| 2348 | /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */ | ||
| 2283 | callback: async (args, url) => { | 2349 | callback: async (args, url) => { |
| 2284 | await uploadSpriteCommand(args, url); | 2350 | return await uploadSpriteCommand(args, url); |
| 2285 | return ''; | ||
| 2286 | }, | 2351 | }, |
| 2352 | returns: 'the resulting sprite name', | ||
| 2287 | unnamedArgumentList: [ | 2353 | unnamedArgumentList: [ |
| 2288 | SlashCommandArgument.fromProps({ | 2354 | SlashCommandArgument.fromProps({ |
| 2289 | description: 'URL of the image to upload', | 2355 | description: 'URL of the image to upload', |
| @@ -2297,7 +2363,6 @@ function migrateSettings() { | |||
| 2297 | description: 'Character name or avatar key (default is current character)', | 2363 | description: 'Character name or avatar key (default is current character)', |
| 2298 | typeList: [ARGUMENT_TYPE.STRING], | 2364 | typeList: [ARGUMENT_TYPE.STRING], |
| 2299 | isRequired: false, | 2365 | isRequired: false, |
| 2300 | acceptsMultiple: false, | ||
| 2301 | }), | 2366 | }), |
| 2302 | SlashCommandNamedArgument.fromProps({ | 2367 | SlashCommandNamedArgument.fromProps({ |
| 2303 | name: 'label', | 2368 | name: 'label', |
| @@ -2305,16 +2370,32 @@ function migrateSettings() { | |||
| 2305 | typeList: [ARGUMENT_TYPE.STRING], | 2370 | typeList: [ARGUMENT_TYPE.STRING], |
| 2306 | enumProvider: localEnumProviders.expressions, | 2371 | enumProvider: localEnumProviders.expressions, |
| 2307 | isRequired: true, | 2372 | isRequired: true, |
| 2308 | acceptsMultiple: false, | ||
| 2309 | }), | 2373 | }), |
| 2310 | SlashCommandNamedArgument.fromProps({ | 2374 | SlashCommandNamedArgument.fromProps({ |
| 2311 | name: 'folder', | 2375 | name: 'folder', |
| 2312 | description: 'Override folder to upload into', | 2376 | description: 'Override folder to upload into', |
| 2313 | typeList: [ARGUMENT_TYPE.STRING], | 2377 | typeList: [ARGUMENT_TYPE.STRING], |
| 2314 | isRequired: false, | 2378 | 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, | ||
| 2316 | }), | 2385 | }), |
| 2317 | ], | 2386 | ], |
| 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 | `, | ||
| 2319 | })); | 2400 | })); |
| 2320 | })(); | 2401 | })(); |
| @@ -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}}"> | ||
| 2 | <div class="expression_list_buttons"> | 3 | <div class="expression_list_buttons"> |
| 3 | <div class="menu_button expression_list_upload" title="Upload image"> | 4 | <div class="menu_button expression_list_upload" title="Upload image"> |
| 4 | <i class="fa-solid fa-upload"></i> | 5 | <i class="fa-solid fa-upload"></i> |
| @@ -7,11 +8,14 @@ | |||
| 7 | <i class="fa-solid fa-trash"></i> | 8 | <i class="fa-solid fa-trash"></i> |
| 8 | </div> | 9 | </div> |
| 9 | </div> | 10 | </div> |
| 10 | <div class="expression_list_title {{textClass}}"> | 11 | <div class="expression_list_title"> |
| 11 | <span>{{item}}</span> | 12 | <span>{{../expression}}</span> |
| 12 | {{#if isCustom}} | 13 | {{#if ../isCustom}} |
| 13 | <small class="expression_list_custom">(custom)</small> | 14 | <small class="expression_list_custom">(custom)</small> |
| 14 | {{/if}} | 15 | {{/if}} |
| 15 | </div> | 16 | </div> |
| 16 | <img class="expression_list_image" src="{{imageSrc}}" /> | 17 | <div class="expression_list_image_container" title="{{this.title}}"> |
| 18 | <img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" /> | ||
| 17 | </div> | 19 | </div> |
| 20 | </div> | ||
| 21 | {{/each}} | ||
| @@ -6,17 +6,17 @@ | |||
| 6 | </div> | 6 | </div> |
| 7 | 7 | ||
| 8 | <div class="inline-drawer-content"> | 8 | <div class="inline-drawer-content"> |
| 9 | <label class="checkbox_label" for="expression_translate" title="Use the selected API from Chat Translation extension settings."> | 9 | <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."> |
| 10 | <input id="expression_translate" type="checkbox"> | 10 | <input id="expression_translate" type="checkbox"> |
| 11 | <span data-i18n="Translate text to English before classification">Translate text to English before classification</span> | 11 | <span data-i18n="Translate text to English before classification">Translate text to English before classification</span> |
| 12 | </label> | 12 | </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."> |
| 14 | <input id="expressions_show_default" type="checkbox"> | 14 | <input id="expressions_allow_multiple" type="checkbox"> |
| 15 | <span data-i18n="Show default images (emojis) if sprite missing">Show default images (emojis) if sprite missing</span> | 15 | <span data-i18n="Allow multiple sprites per expression">Allow multiple sprites per expression</span> |
| 16 | </label> | 16 | </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."> |
| 18 | <input id="image_type_toggle" type="checkbox"> | 18 | <input id="expressions_reroll_if_same" type="checkbox"> |
| 19 | <span data-i18n="Image Type - talkinghead (extras)">Image Type - talkinghead (extras)</span> | 19 | <span data-i18n="Re-roll if same expression is used again">Re-roll if same sprite is used again</span> |
| 20 | </label> | 20 | </label> |
| 21 | <div class="expression_api_block m-b-1 m-t-1"> | 21 | <div class="expression_api_block m-b-1 m-t-1"> |
| 22 | <label for="expression_api" data-i18n="Classifier API">Classifier API</label> | 22 | <label for="expression_api" data-i18n="Classifier API">Classifier API</label> |
| @@ -75,8 +75,20 @@ | |||
| 75 | <span data-i18n="Remove all image overrides">Remove all image overrides</span> | 75 | <span data-i18n="Remove all image overrides">Remove all image overrides</span> |
| 76 | </div> | 76 | </div> |
| 77 | </div> | 77 | </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> | ||
| 80 | <h3 id="image_list_header"> | 92 | <h3 id="image_list_header"> |
| 81 | <strong data-i18n="Sprite set:">Sprite set:</strong> <span id="image_list_header_name"></span> | 93 | <strong data-i18n="Sprite set:">Sprite set:</strong> <span id="image_list_header_name"></span> |
| 82 | </h3> | 94 | </h3> |
| @@ -111,6 +111,10 @@ img.expression.default { | |||
| 111 | justify-content: center; | 111 | justify-content: center; |
| 112 | } | 112 | } |
| 113 | 113 | ||
| 114 | .expression_list_image_container { | ||
| 115 | overflow: hidden; | ||
| 116 | } | ||
| 117 | |||
| 114 | .expression_list_title { | 118 | .expression_list_title { |
| 115 | position: absolute; | 119 | position: absolute; |
| 116 | bottom: 0; | 120 | bottom: 0; |
| @@ -126,6 +130,9 @@ img.expression.default { | |||
| 126 | flex-direction: column; | 130 | flex-direction: column; |
| 127 | line-height: 1; | 131 | line-height: 1; |
| 128 | } | 132 | } |
| 133 | .expression_list_custom { | ||
| 134 | font-size: 0.66rem; | ||
| 135 | } | ||
| 129 | 136 | ||
| 130 | .expression_list_buttons { | 137 | .expression_list_buttons { |
| 131 | position: absolute; | 138 | position: absolute; |
| @@ -162,11 +169,24 @@ img.expression.default { | |||
| 162 | row-gap: 1rem; | 169 | row-gap: 1rem; |
| 163 | } | 170 | } |
| 164 | 171 | ||
| 165 | #image_list .success { | 172 | #image_list .expression_list_item[data-expression-type="success"] .expression_list_title { |
| 166 | color: green; | 173 | color: green; |
| 167 | } | 174 | } |
| 168 | 175 | ||
| 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 { | ||
| 170 | color: red; | 190 | color: red; |
| 171 | } | 191 | } |
| 172 | 192 | ||
| @@ -189,3 +209,12 @@ img.expression.default { | |||
| 189 | flex-direction: row; | 209 | flex-direction: row; |
| 190 | } | 210 | } |
| 191 | 211 | ||
| 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 | } | ||
| @@ -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}} | ||
| @@ -605,7 +605,7 @@ const handleOutgoingMessage = createEventHandler(translateOutgoingMessage, () => | |||
| 605 | const handleImpersonateReady = createEventHandler(translateImpersonate, () => shouldTranslate(incomingTypes)); | 605 | const handleImpersonateReady = createEventHandler(translateImpersonate, () => shouldTranslate(incomingTypes)); |
| 606 | const handleMessageEdit = createEventHandler(translateMessageEdit, () => true); | 606 | const handleMessageEdit = createEventHandler(translateMessageEdit, () => true); |
| 607 | 607 | ||
| 608 | window['translate'] = translate; | 608 | globalThis.translate = translate; |
| 609 | 609 | ||
| 610 | jQuery(async () => { | 610 | jQuery(async () => { |
| 611 | const html = await renderExtensionTemplateAsync('translate', 'index'); | 611 | const html = await renderExtensionTemplateAsync('translate', 'index'); |
| @@ -27,14 +27,12 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm | |||
| 27 | import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; | 27 | import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 28 | import { POPUP_TYPE, callGenericPopup } from '../../popup.js'; | 28 | import { POPUP_TYPE, callGenericPopup } from '../../popup.js'; |
| 29 | import { GoogleTranslateTtsProvider } from './google-translate.js'; | 29 | import { GoogleTranslateTtsProvider } from './google-translate.js'; |
| 30 | export { talkingAnimation }; | ||
| 31 | 30 | ||
| 32 | const UPDATE_INTERVAL = 1000; | 31 | const UPDATE_INTERVAL = 1000; |
| 33 | const wrapper = new ModuleWorkerWrapper(moduleWorker); | 32 | const wrapper = new ModuleWorkerWrapper(moduleWorker); |
| 34 | 33 | ||
| 35 | let voiceMapEntries = []; | 34 | let voiceMapEntries = []; |
| 36 | let voiceMap = {}; // {charName:voiceid, charName2:voiceid2} | 35 | let voiceMap = {}; // {charName:voiceid, charName2:voiceid2} |
| 37 | let talkingHeadState = false; | ||
| 38 | let lastChatId = null; | 36 | let lastChatId = null; |
| 39 | let lastMessage = null; | 37 | let lastMessage = null; |
| 40 | let lastMessageHash = null; | 38 | let lastMessageHash = null; |
| @@ -166,27 +164,6 @@ async function moduleWorker() { | |||
| 166 | updateUiAudioPlayState(); | 164 | updateUiAudioPlayState(); |
| 167 | } | 165 | } |
| 168 | 166 | ||
| 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 | |||
| 190 | function resetTtsPlayback() { | 167 | function resetTtsPlayback() { |
| 191 | // Stop system TTS utterance | 168 | // Stop system TTS utterance |
| 192 | cancelTtsPlay(); | 169 | cancelTtsPlay(); |
| @@ -378,7 +355,6 @@ function onAudioControlClicked() { | |||
| 378 | // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful | 355 | // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful |
| 379 | if (!audioElement.paused || isTtsProcessing()) { | 356 | if (!audioElement.paused || isTtsProcessing()) { |
| 380 | resetTtsPlayback(); | 357 | resetTtsPlayback(); |
| 381 | talkingAnimation(false); | ||
| 382 | } else { | 358 | } else { |
| 383 | // Default play behavior if not processing or playing is to play the last message. | 359 | // Default play behavior if not processing or playing is to play the last message. |
| 384 | processAndQueueTtsMessage(context.chat[context.chat.length - 1]); | 360 | processAndQueueTtsMessage(context.chat[context.chat.length - 1]); |
| @@ -405,7 +381,6 @@ function addAudioControl() { | |||
| 405 | function completeCurrentAudioJob() { | 381 | function completeCurrentAudioJob() { |
| 406 | audioQueueProcessorReady = true; | 382 | audioQueueProcessorReady = true; |
| 407 | currentAudioJob = null; | 383 | currentAudioJob = null; |
| 408 | talkingAnimation(false); //stop lip animation | ||
| 409 | // updateUiPlayState(); | 384 | // updateUiPlayState(); |
| 410 | wrapper.update(); | 385 | wrapper.update(); |
| 411 | } | 386 | } |
| @@ -436,7 +411,6 @@ async function processAudioJobQueue() { | |||
| 436 | audioQueueProcessorReady = false; | 411 | audioQueueProcessorReady = false; |
| 437 | currentAudioJob = audioJobQueue.shift(); | 412 | currentAudioJob = audioJobQueue.shift(); |
| 438 | playAudioData(currentAudioJob); | 413 | playAudioData(currentAudioJob); |
| 439 | talkingAnimation(true); | ||
| 440 | } catch (error) { | 414 | } catch (error) { |
| 441 | toastr.error(error.toString()); | 415 | toastr.error(error.toString()); |
| 442 | console.error(error); | 416 | console.error(error); |
| @@ -1,6 +1,5 @@ | |||
| 1 | import { isMobile } from '../../RossAscends-mods.js'; | 1 | import { isMobile } from '../../RossAscends-mods.js'; |
| 2 | import { getPreviewString } from './index.js'; | 2 | import { getPreviewString } from './index.js'; |
| 3 | import { talkingAnimation } from './index.js'; | ||
| 4 | import { saveTtsProviderSettings } from './index.js'; | 3 | import { saveTtsProviderSettings } from './index.js'; |
| 5 | export { SystemTtsProvider }; | 4 | export { SystemTtsProvider }; |
| 6 | 5 | ||
| @@ -70,7 +69,6 @@ var speechUtteranceChunker = function (utt, settings, callback) { | |||
| 70 | //placing the speak invocation inside a callback fixes ordering and onend issues. | 69 | //placing the speak invocation inside a callback fixes ordering and onend issues. |
| 71 | setTimeout(function () { | 70 | setTimeout(function () { |
| 72 | speechSynthesis.speak(newUtt); | 71 | speechSynthesis.speak(newUtt); |
| 73 | talkingAnimation(true); | ||
| 74 | }, 0); | 72 | }, 0); |
| 75 | }; | 73 | }; |
| 76 | 74 | ||
| @@ -240,7 +238,6 @@ class SystemTtsProvider { | |||
| 240 | //some code to execute when done | 238 | //some code to execute when done |
| 241 | resolve(silence); | 239 | resolve(silence); |
| 242 | console.log('System TTS done'); | 240 | console.log('System TTS done'); |
| 243 | talkingAnimation(false); | ||
| 244 | }); | 241 | }); |
| 245 | }); | 242 | }); |
| 246 | } | 243 | } |
| @@ -561,9 +561,9 @@ async function retrieveFileChunks(queryText, collectionId) { | |||
| 561 | */ | 561 | */ |
| 562 | async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overlapPercent) { | 562 | async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overlapPercent) { |
| 563 | try { | 563 | try { |
| 564 | if (settings.translate_files && typeof window['translate'] === 'function') { | 564 | if (settings.translate_files && typeof globalThis.translate === 'function') { |
| 565 | console.log(`Vectors: Translating file ${fileName} to English...`); | 565 | console.log(`Vectors: Translating file ${fileName} to English...`); |
| 566 | const translatedText = await window['translate'](fileText, 'en'); | 566 | const translatedText = await globalThis.translate(fileText, 'en'); |
| 567 | fileText = translatedText; | 567 | fileText = translatedText; |
| 568 | } | 568 | } |
| 569 | 569 | ||
| @@ -2167,6 +2167,14 @@ function getStreamingReply(data, state) { | |||
| 2167 | state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || ''); | 2167 | state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || ''); |
| 2168 | } | 2168 | } |
| 2169 | return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? ''; | 2169 | 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 ?? ''; | ||
| 2170 | } else { | 2178 | } else { |
| 2171 | return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? ''; | 2179 | return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? ''; |
| 2172 | } | 2180 | } |
| @@ -4107,6 +4115,40 @@ function getMaxContextWindowAI(value) { | |||
| 4107 | } | 4115 | } |
| 4108 | } | 4116 | } |
| 4109 | 4117 | ||
| 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 | |||
| 4110 | async function onModelChange() { | 4152 | async function onModelChange() { |
| 4111 | biasCache = undefined; | 4153 | biasCache = undefined; |
| 4112 | let value = String($(this).val() || ''); | 4154 | let value = String($(this).val() || ''); |
| @@ -4387,7 +4429,7 @@ async function onModelChange() { | |||
| 4387 | if (oai_settings.max_context_unlocked) { | 4429 | if (oai_settings.max_context_unlocked) { |
| 4388 | $('#openai_max_context').attr('max', unlocked_max); | 4430 | $('#openai_max_context').attr('max', unlocked_max); |
| 4389 | } | 4431 | } |
| 4390 | else if (['sonar', 'sonar-reasoning'].includes(oai_settings.perplexity_model)) { | 4432 | else if (['sonar', 'sonar-reasoning', 'sonar-reasoning-pro', 'r1-1776'].includes(oai_settings.perplexity_model)) { |
| 4391 | $('#openai_max_context').attr('max', 127000); | 4433 | $('#openai_max_context').attr('max', 127000); |
| 4392 | } | 4434 | } |
| 4393 | else if (['sonar-pro'].includes(oai_settings.perplexity_model)) { | 4435 | else if (['sonar-pro'].includes(oai_settings.perplexity_model)) { |
| @@ -4408,33 +4450,8 @@ async function onModelChange() { | |||
| 4408 | } | 4450 | } |
| 4409 | 4451 | ||
| 4410 | if (oai_settings.chat_completion_source == chat_completion_sources.GROQ) { | 4452 | 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); |
| 4412 | $('#openai_max_context').attr('max', unlocked_max); | 4454 | $('#openai_max_context').attr('max', maxContext); |
| 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 | } | ||
| 4438 | oai_settings.openai_max_context = Math.min(Number($('#openai_max_context').attr('max')), oai_settings.openai_max_context); | 4455 | oai_settings.openai_max_context = Math.min(Number($('#openai_max_context').attr('max')), oai_settings.openai_max_context); |
| 4439 | $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input'); | 4456 | $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input'); |
| 4440 | oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai); | 4457 | oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai); |
| @@ -1845,14 +1845,15 @@ async function loadContextSettings() { | |||
| 1845 | 1845 | ||
| 1846 | /** | 1846 | /** |
| 1847 | * Common function to perform fuzzy search with optional caching | 1847 | * Common function to perform fuzzy search with optional caching |
| 1848 | * @template T | ||
| 1848 | * @param {string} type - Type of search from fuzzySearchCategories | 1849 | * @param {string} type - Type of search from fuzzySearchCategories |
| 1849 | * @param {any[]} data - Data array to search in | 1850 | * @param {T[]} data - Data array to search in |
| 1850 | * @param {Array<{name: string, weight: number, getFn?: (obj: any) => string}>} keys - Fuse.js keys configuration | 1851 | * @param {Array<{name: string, weight: number, getFn?: (obj: T) => string}>} keys - Fuse.js keys configuration |
| 1851 | * @param {string} searchValue - The search term | 1852 | * @param {string} searchValue - The search term |
| 1852 | * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches | 1853 | * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches |
| 1853 | * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score | 1854 | * @returns {import('fuse.js').FuseResult<T>[]} Results as items with their score |
| 1854 | */ | 1855 | */ |
| 1855 | function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) { | 1856 | export function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) { |
| 1856 | // Check cache if provided | 1857 | // Check cache if provided |
| 1857 | if (fuzzySearchCaches) { | 1858 | if (fuzzySearchCaches) { |
| 1858 | const cache = fuzzySearchCaches[type]; | 1859 | const cache = fuzzySearchCaches[type]; |
| @@ -76,6 +76,11 @@ export function extractReasoningFromData(data) { | |||
| 76 | return data?.choices?.[0]?.message?.reasoning ?? ''; | 76 | return data?.choices?.[0]?.message?.reasoning ?? ''; |
| 77 | case chat_completion_sources.MAKERSUITE: | 77 | case chat_completion_sources.MAKERSUITE: |
| 78 | return data?.responseContent?.parts?.filter(part => part.thought)?.map(part => part.text)?.join('\n\n') ?? ''; | 78 | 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 | } | ||
| 79 | } | 84 | } |
| 80 | break; | 85 | break; |
| 81 | } | 86 | } |
| @@ -338,14 +343,15 @@ export class ReasoningHandler { | |||
| 338 | return mesChanged; | 343 | return mesChanged; |
| 339 | } | 344 | } |
| 340 | 345 | ||
| 341 | if (this.state === ReasoningState.None) { | 346 | if (this.state === ReasoningState.None || this.#isHiddenReasoningModel) { |
| 342 | // If streamed message starts with the opening, cut it out and put all inside reasoning | 347 | // If streamed message starts with the opening, cut it out and put all inside reasoning |
| 343 | if (message.mes.startsWith(power_user.reasoning.prefix) && message.mes.length > power_user.reasoning.prefix.length) { | 348 | if (message.mes.startsWith(power_user.reasoning.prefix) && message.mes.length > power_user.reasoning.prefix.length) { |
| 344 | this.#isParsingReasoning = true; | 349 | this.#isParsingReasoning = true; |
| 345 | 350 | ||
| 346 | // Manually set starting state here, as we might already have received the ending suffix | 351 | // Manually set starting state here, as we might already have received the ending suffix |
| 347 | this.state = ReasoningState.Thinking; | 352 | this.state = ReasoningState.Thinking; |
| 348 | this.startTime = this.initialTime; | 353 | this.startTime = this.startTime ?? this.initialTime; |
| 354 | this.endTime = null; | ||
| 349 | } | 355 | } |
| 350 | } | 356 | } |
| 351 | 357 | ||
| @@ -679,7 +679,7 @@ export function getTokenizerModel() { | |||
| 679 | } | 679 | } |
| 680 | 680 | ||
| 681 | if (oai_settings.chat_completion_source === chat_completion_sources.PERPLEXITY) { | 681 | if (oai_settings.chat_completion_source === chat_completion_sources.PERPLEXITY) { |
| 682 | if (oai_settings.perplexity_model.includes('sonar-reasoning')) { | 682 | if (oai_settings.perplexity_model.includes('sonar-reasoning') || oai_settings.perplexity_model.includes('r1-1776')) { |
| 683 | return deepseekTokenizer; | 683 | return deepseekTokenizer; |
| 684 | } | 684 | } |
| 685 | if (oai_settings.perplexity_model.includes('llama-3') || oai_settings.perplexity_model.includes('llama3')) { | 685 | if (oai_settings.perplexity_model.includes('llama-3') || oai_settings.perplexity_model.includes('llama3')) { |
| @@ -694,6 +694,9 @@ export function getTokenizerModel() { | |||
| 694 | } | 694 | } |
| 695 | 695 | ||
| 696 | if (oai_settings.chat_completion_source === chat_completion_sources.GROQ) { | 696 | if (oai_settings.chat_completion_source === chat_completion_sources.GROQ) { |
| 697 | if (oai_settings.groq_model.includes('qwen')) { | ||
| 698 | return qwen2Tokenizer; | ||
| 699 | } | ||
| 697 | if (oai_settings.groq_model.includes('llama-3') || oai_settings.groq_model.includes('llama3')) { | 700 | if (oai_settings.groq_model.includes('llama-3') || oai_settings.groq_model.includes('llama3')) { |
| 698 | return llama3Tokenizer; | 701 | return llama3Tokenizer; |
| 699 | } | 702 | } |
| @@ -55,6 +55,10 @@ | |||
| 55 | --interactable-outline-color: var(--white100); | 55 | --interactable-outline-color: var(--white100); |
| 56 | --interactable-outline-color-faint: var(--white20a); | 56 | --interactable-outline-color-faint: var(--white20a); |
| 57 | 57 | ||
| 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 | |||
| 58 | 62 | ||
| 59 | /*Default Theme, will be changed by ToolCool Color Picker*/ | 63 | /*Default Theme, will be changed by ToolCool Color Picker*/ |
| 60 | --SmartThemeBodyColor: rgb(220, 220, 210); | 64 | --SmartThemeBodyColor: rgb(220, 220, 210); |
| @@ -348,13 +352,13 @@ input[type='checkbox']:focus-visible { | |||
| 348 | 352 | ||
| 349 | .mes_reasoning { | 353 | .mes_reasoning { |
| 350 | display: block; | 354 | display: block; |
| 351 | border-left: 2px solid var(--SmartThemeEmColor); | 355 | border-left: 2px solid var(--reasoning-body-color); |
| 352 | border-radius: 2px; | 356 | border-radius: 2px; |
| 353 | padding: 5px; | 357 | padding: 5px; |
| 354 | padding-left: 14px; | 358 | padding-left: 14px; |
| 355 | margin-bottom: 0.5em; | 359 | margin-bottom: 0.5em; |
| 356 | overflow-y: auto; | 360 | overflow-y: auto; |
| 357 | color: var(--SmartThemeEmColor); | 361 | color: hsl(from var(--reasoning-body-color) h calc(s * var(--reasoning-saturation)) l); |
| 358 | } | 362 | } |
| 359 | 363 | ||
| 360 | .mes_reasoning_details { | 364 | .mes_reasoning_details { |
| @@ -374,18 +378,6 @@ input[type='checkbox']:focus-visible { | |||
| 374 | margin-bottom: 0; | 378 | margin-bottom: 0; |
| 375 | } | 379 | } |
| 376 | 380 | ||
| 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 | |||
| 389 | .mes_reasoning_header_block { | 381 | .mes_reasoning_header_block { |
| 390 | flex-grow: 1; | 382 | flex-grow: 1; |
| 391 | } | 383 | } |
| @@ -461,26 +453,36 @@ input[type='checkbox']:focus-visible { | |||
| 461 | } | 453 | } |
| 462 | 454 | ||
| 463 | .mes_text i, | 455 | .mes_text i, |
| 464 | .mes_text em, | 456 | .mes_text em { |
| 457 | color: var(--SmartThemeEmColor); | ||
| 458 | } | ||
| 465 | .mes_reasoning i, | 459 | .mes_reasoning i, |
| 466 | .mes_reasoning em { | 460 | .mes_reasoning em { |
| 467 | color: var(--SmartThemeEmColor); | 461 | color: hsl(from var(--reasoning-em-color) h calc(s * var(--reasoning-saturation)) l); |
| 468 | } | 462 | } |
| 469 | 463 | ||
| 470 | .mes_text q i, | 464 | .mes_text q i, |
| 471 | .mes_text q em { | 465 | .mes_text q em { |
| 472 | color: inherit; | 466 | color: inherit; |
| 473 | } | 467 | } |
| 468 | .mes_reasoning q i, | ||
| 469 | .mes_reasoning q em { | ||
| 470 | color: hsl(from var(--SmartThemeQuoteColor) h calc(s * var(--reasoning-saturation)) l); | ||
| 471 | } | ||
| 474 | 472 | ||
| 475 | .mes_text u, | 473 | .mes_text u { |
| 476 | .mes_reasoning u { | ||
| 477 | color: var(--SmartThemeUnderlineColor); | 474 | color: var(--SmartThemeUnderlineColor); |
| 478 | } | 475 | } |
| 476 | .mes_reasoning u { | ||
| 477 | color: hsl(from var(--SmartThemeUnderlineColor) h calc(s * var(--reasoning-saturation)) l); | ||
| 478 | } | ||
| 479 | 479 | ||
| 480 | .mes_text q, | 480 | .mes_text q { |
| 481 | .mes_reasoning q { | ||
| 482 | color: var(--SmartThemeQuoteColor); | 481 | color: var(--SmartThemeQuoteColor); |
| 483 | } | 482 | } |
| 483 | .mes_reasoning q { | ||
| 484 | color: hsl(from var(--SmartThemeQuoteColor) h calc(s * var(--reasoning-saturation)) l); | ||
| 485 | } | ||
| 484 | 486 | ||
| 485 | .mes_text font[color] em, | 487 | .mes_text font[color] em, |
| 486 | .mes_text font[color] i, | 488 | .mes_text font[color] i, |
| @@ -57,7 +57,8 @@ import { | |||
| 57 | 57 | ||
| 58 | import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; | 58 | import getWebpackServeMiddleware from './src/middleware/webpack-serve.js'; |
| 59 | import basicAuthMiddleware from './src/middleware/basicAuth.js'; | 59 | import basicAuthMiddleware from './src/middleware/basicAuth.js'; |
| 60 | import whitelistMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/whitelist.js'; | 60 | import whitelistMiddleware from './src/middleware/whitelist.js'; |
| 61 | import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './src/middleware/accessLogWriter.js'; | ||
| 61 | import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js'; | 62 | import multerMonkeyPatch from './src/middleware/multerMonkeyPatch.js'; |
| 62 | import initRequestProxy from './src/request-proxy.js'; | 63 | import initRequestProxy from './src/request-proxy.js'; |
| 63 | import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; | 64 | import getCacheBusterMiddleware from './src/middleware/cacheBuster.js'; |
| @@ -243,7 +244,6 @@ const cliArguments = yargs(hideBin(process.argv)) | |||
| 243 | describe: 'Request proxy URL (HTTP or SOCKS protocols)', | 244 | describe: 'Request proxy URL (HTTP or SOCKS protocols)', |
| 244 | }).option('requestProxyBypass', { | 245 | }).option('requestProxyBypass', { |
| 245 | type: 'array', | 246 | type: 'array', |
| 246 | default: null, | ||
| 247 | describe: 'Request proxy bypass list (space separated list of hosts)', | 247 | describe: 'Request proxy bypass list (space separated list of hosts)', |
| 248 | }).parseSync(); | 248 | }).parseSync(); |
| 249 | 249 | ||
| @@ -340,9 +340,17 @@ const CORS = cors({ | |||
| 340 | 340 | ||
| 341 | app.use(CORS); | 341 | app.use(CORS); |
| 342 | 342 | ||
| 343 | if (listen && basicAuthMode) app.use(basicAuthMiddleware); | 343 | if (listen && basicAuthMode) { |
| 344 | app.use(basicAuthMiddleware); | ||
| 345 | } | ||
| 346 | |||
| 347 | if (enableWhitelist) { | ||
| 348 | app.use(whitelistMiddleware()); | ||
| 349 | } | ||
| 344 | 350 | ||
| 345 | app.use(whitelistMiddleware(enableWhitelist, listen)); | 351 | if (listen) { |
| 352 | app.use(accessLoggerMiddleware()); | ||
| 353 | } | ||
| 346 | 354 | ||
| 347 | if (enableCorsProxy) { | 355 | if (enableCorsProxy) { |
| 348 | app.use(bodyParser.json({ | 356 | app.use(bodyParser.json({ |
| @@ -230,7 +230,7 @@ router.post('/version', jsonParser, async (request, response) => { | |||
| 230 | } catch (error) { | 230 | } catch (error) { |
| 231 | // it is not a git repo, or has no commits yet, or is a bare repo | 231 | // it is not a git repo, or has no commits yet, or is a bare repo |
| 232 | // not possible to update it, most likely can't get the branch name either | 232 | // not possible to update it, most likely can't get the branch name either |
| 233 | return response.send({ currentBranchName: null, currentCommitHash, isUpToDate: true, remoteUrl: null }); | 233 | return response.send({ currentBranchName: '', currentCommitHash: '', isUpToDate: true, remoteUrl: '' }); |
| 234 | } | 234 | } |
| 235 | 235 | ||
| 236 | const currentBranch = await git.cwd(extensionPath).branch(); | 236 | const currentBranch = await git.cwd(extensionPath).branch(); |
| @@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) { | |||
| 125 | .map((file) => { | 125 | .map((file) => { |
| 126 | const pathToSprite = path.join(spritesPath, file); | 126 | const pathToSprite = path.join(spritesPath, file); |
| 127 | const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14); | 127 | 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 | |||
| 128 | return { | 134 | return { |
| 129 | label: path.parse(pathToSprite).name.toLowerCase(), | 135 | label: label, |
| 130 | path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''), | 136 | path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''), |
| 131 | }; | 137 | }; |
| 132 | }); | 138 | }); |
| @@ -141,8 +147,9 @@ router.get('/get', jsonParser, function (request, response) { | |||
| 141 | router.post('/delete', jsonParser, async (request, response) => { | 147 | router.post('/delete', jsonParser, async (request, response) => { |
| 142 | const label = request.body.label; | 148 | const label = request.body.label; |
| 143 | const name = request.body.name; | 149 | const name = request.body.name; |
| 150 | const spriteName = request.body.spriteName || label; | ||
| 144 | 151 | ||
| 145 | if (!label || !name) { | 152 | if (!spriteName || !name) { |
| 146 | return response.sendStatus(400); | 153 | return response.sendStatus(400); |
| 147 | } | 154 | } |
| 148 | 155 | ||
| @@ -158,7 +165,7 @@ router.post('/delete', jsonParser, async (request, response) => { | |||
| 158 | 165 | ||
| 159 | // Remove existing sprite with the same label | 166 | // Remove existing sprite with the same label |
| 160 | for (const file of files) { | 167 | for (const file of files) { |
| 161 | if (path.parse(file).name === label) { | 168 | if (path.parse(file).name === spriteName) { |
| 162 | fs.rmSync(path.join(spritesPath, file)); | 169 | fs.rmSync(path.join(spritesPath, file)); |
| 163 | } | 170 | } |
| 164 | } | 171 | } |
| @@ -221,6 +228,7 @@ router.post('/upload', urlencodedParser, async (request, response) => { | |||
| 221 | const file = request.file; | 228 | const file = request.file; |
| 222 | const label = request.body.label; | 229 | const label = request.body.label; |
| 223 | const name = request.body.name; | 230 | const name = request.body.name; |
| 231 | const spriteName = request.body.spriteName || label; | ||
| 224 | 232 | ||
| 225 | if (!file || !label || !name) { | 233 | if (!file || !label || !name) { |
| 226 | return response.sendStatus(400); | 234 | return response.sendStatus(400); |
| @@ -243,12 +251,12 @@ router.post('/upload', urlencodedParser, async (request, response) => { | |||
| 243 | 251 | ||
| 244 | // Remove existing sprite with the same label | 252 | // Remove existing sprite with the same label |
| 245 | for (const file of files) { | 253 | for (const file of files) { |
| 246 | if (path.parse(file).name === label) { | 254 | if (path.parse(file).name === spriteName) { |
| 247 | fs.rmSync(path.join(spritesPath, file)); | 255 | fs.rmSync(path.join(spritesPath, file)); |
| 248 | } | 256 | } |
| 249 | } | 257 | } |
| 250 | 258 | ||
| 251 | const filename = label + path.parse(file.originalname).ext; | 259 | const filename = spriteName + path.parse(file.originalname).ext; |
| 252 | const spritePath = path.join(file.destination, file.filename); | 260 | const spritePath = path.join(file.destination, file.filename); |
| 253 | const pathToFile = path.join(spritesPath, filename); | 261 | const pathToFile = path.join(spritesPath, filename); |
| 254 | // Copy uploaded file to sprites folder | 262 | // Copy uploaded file to sprites folder |
| @@ -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 | } | ||
| @@ -10,9 +10,6 @@ import { color, getConfigValue, safeReadFileSync } from '../util.js'; | |||
| 10 | const whitelistPath = path.join(process.cwd(), './whitelist.txt'); | 10 | const whitelistPath = path.join(process.cwd(), './whitelist.txt'); |
| 11 | const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false, 'boolean'); | 11 | const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false, 'boolean'); |
| 12 | let whitelist = getConfigValue('whitelist', []); | 12 | let whitelist = getConfigValue('whitelist', []); |
| 13 | let knownIPs = new Set(); | ||
| 14 | |||
| 15 | export const getAccessLogPath = () => path.join(globalThis.DATA_ROOT, 'access.log'); | ||
| 16 | 13 | ||
| 17 | if (fs.existsSync(whitelistPath)) { | 14 | if (fs.existsSync(whitelistPath)) { |
| 18 | try { | 15 | try { |
| @@ -48,67 +45,41 @@ function getForwardedIp(req) { | |||
| 48 | return undefined; | 45 | return undefined; |
| 49 | } | 46 | } |
| 50 | 47 | ||
| 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 | |||
| 68 | /** | 48 | /** |
| 69 | * Returns a middleware function that checks if the client IP is in the whitelist. | 49 | * 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 | ||
| 72 | * @returns {import('express').RequestHandler} The middleware function | 50 | * @returns {import('express').RequestHandler} The middleware function |
| 73 | */ | 51 | */ |
| 74 | export default function whitelistMiddleware(whitelistMode, listen) { | 52 | export default function whitelistMiddleware() { |
| 75 | const forbiddenWebpage = Handlebars.compile( | 53 | const forbiddenWebpage = Handlebars.compile( |
| 76 | safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '', | 54 | safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '', |
| 77 | ); | 55 | ); |
| 78 | 56 | ||
| 57 | const noLogPaths = [ | ||
| 58 | '/favicon.ico', | ||
| 59 | ]; | ||
| 60 | |||
| 79 | return function (req, res, next) { | 61 | return function (req, res, next) { |
| 80 | const clientIp = getIpFromRequest(req); | 62 | const clientIp = getIpFromRequest(req); |
| 81 | const forwardedIp = getForwardedIp(req); | 63 | const forwardedIp = getForwardedIp(req); |
| 82 | const userAgent = req.headers['user-agent']; | 64 | const userAgent = req.headers['user-agent']; |
| 83 | 65 | ||
| 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 | |||
| 99 | //clientIp = req.connection.remoteAddress.split(':').pop(); | 66 | //clientIp = req.connection.remoteAddress.split(':').pop(); |
| 100 | if (whitelistMode === true && !whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x))) | 67 | if (!whitelist.some(x => ipMatching.matches(clientIp, ipMatching.getMatch(x))) |
| 101 | || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x))) | 68 | || forwardedIp && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x))) |
| 102 | ) { | 69 | ) { |
| 103 | // Log the connection attempt with real IP address | 70 | // Log the connection attempt with real IP address |
| 104 | const ipDetails = forwardedIp | 71 | const ipDetails = forwardedIp |
| 105 | ? `${clientIp} (forwarded from ${forwardedIp})` | 72 | ? `${clientIp} (forwarded from ${forwardedIp})` |
| 106 | : clientIp; | 73 | : clientIp; |
| 74 | |||
| 75 | if (!noLogPaths.includes(req.path)) { | ||
| 107 | console.warn( | 76 | console.warn( |
| 108 | color.red( | 77 | color.red( |
| 109 | `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`, | 78 | `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`, |
| 110 | ), | 79 | ), |
| 111 | ); | 80 | ); |
| 81 | } | ||
| 82 | |||
| 112 | return res.status(403).send(forbiddenWebpage({ ipDetails })); | 83 | return res.status(403).send(forbiddenWebpage({ ipDetails })); |
| 113 | } | 84 | } |
| 114 | next(); | 85 | next(); |
| @@ -3,7 +3,7 @@ import path from 'node:path'; | |||
| 3 | import url from 'node:url'; | 3 | import url from 'node:url'; |
| 4 | 4 | ||
| 5 | import express from 'express'; | 5 | import express from 'express'; |
| 6 | import { default as git } from 'simple-git'; | 6 | import { default as git, CheckRepoActions } from 'simple-git'; |
| 7 | import { sync as commandExistsSync } from 'command-exists'; | 7 | import { sync as commandExistsSync } from 'command-exists'; |
| 8 | import { getConfigValue, color } from './util.js'; | 8 | import { getConfigValue, color } from './util.js'; |
| 9 | 9 | ||
| @@ -256,7 +256,7 @@ async function updatePlugins(pluginsPath) { | |||
| 256 | const pluginPath = path.join(pluginsPath, directory); | 256 | const pluginPath = path.join(pluginsPath, directory); |
| 257 | const pluginRepo = git(pluginPath); | 257 | const pluginRepo = git(pluginPath); |
| 258 | 258 | ||
| 259 | const isRepo = await pluginRepo.checkIsRepo(); | 259 | const isRepo = await pluginRepo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT); |
| 260 | if (!isRepo) { | 260 | if (!isRepo) { |
| 261 | continue; | 261 | continue; |
| 262 | } | 262 | } |
| @@ -805,7 +805,7 @@ export function stringToBool(str) { | |||
| 805 | * Setup the minimum log level | 805 | * Setup the minimum log level |
| 806 | */ | 806 | */ |
| 807 | export function setupLogLevel() { | 807 | export function setupLogLevel() { |
| 808 | const logLevel = getConfigValue('minLogLevel', LOG_LEVELS.DEBUG, 'number'); | 808 | const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number'); |
| 809 | 809 | ||
| 810 | globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {}; | 810 | globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => {}; |
| 811 | globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {}; | 811 | globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => {}; |