| 1 | import { |
| 2 | showdown, |
| 3 | moment, |
| 4 | DOMPurify, |
| 5 | hljs, |
| 6 | Handlebars, |
| 7 | SVGInject, |
| 8 | Popper, |
| 9 | initLibraryShims, |
| 10 | default as libs, |
| 11 | lodash, |
| 12 | } from './lib.js'; |
| 13 | |
| 14 | import { humanizedDateTime, favsToHotswap, getMessageTimeStamp, dragElement, isMobile, initRossMods } from './scripts/RossAscends-mods.js'; |
| 15 | import { userStatsHandler, statMesProcess, initStats } from './scripts/stats.js'; |
| 16 | import { |
| 17 | generateKoboldWithStreaming, |
| 18 | kai_settings, |
| 19 | loadKoboldSettings, |
| 20 | getKoboldGenerationData, |
| 21 | kai_flags, |
| 22 | koboldai_settings, |
| 23 | koboldai_setting_names, |
| 24 | initKoboldSettings, |
| 25 | } from './scripts/kai-settings.js'; |
| 26 | |
| 27 | import { |
| 28 | textgenerationwebui_settings as textgen_settings, |
| 29 | loadTextGenSettings, |
| 30 | generateTextGenWithStreaming, |
| 31 | getTextGenGenerationData, |
| 32 | textgen_types, |
| 33 | parseTextgenLogprobs, |
| 34 | parseTabbyLogprobs, |
| 35 | initTextGenSettings, |
| 36 | } from './scripts/textgen-settings.js'; |
| 37 | |
| 38 | import { |
| 39 | world_info, |
| 40 | getWorldInfoPrompt, |
| 41 | getWorldInfoSettings, |
| 42 | setWorldInfoSettings, |
| 43 | world_names, |
| 44 | importEmbeddedWorldInfo, |
| 45 | checkEmbeddedWorld, |
| 46 | setWorldInfoButtonClass, |
| 47 | wi_anchor_position, |
| 48 | world_info_include_names, |
| 49 | initWorldInfo, |
| 50 | charUpdatePrimaryWorld, |
| 51 | charSetAuxWorlds, |
| 52 | } from './scripts/world-info.js'; |
| 53 | |
| 54 | import { |
| 55 | groups, |
| 56 | selected_group, |
| 57 | saveGroupChat, |
| 58 | getGroups, |
| 59 | generateGroupWrapper, |
| 60 | is_group_generating, |
| 61 | resetSelectedGroup, |
| 62 | select_group_chats, |
| 63 | regenerateGroup, |
| 64 | group_generation_id, |
| 65 | getGroupChat, |
| 66 | renameGroupMember, |
| 67 | createNewGroupChat, |
| 68 | getGroupAvatar, |
| 69 | deleteGroupChat, |
| 70 | renameGroupChat, |
| 71 | importGroupChat, |
| 72 | getGroupBlock, |
| 73 | getGroupCharacterCardsLazy, |
| 74 | getGroupDepthPrompts, |
| 75 | } from './scripts/group-chats.js'; |
| 76 | |
| 77 | import { |
| 78 | collapseNewlines, |
| 79 | loadPowerUserSettings, |
| 80 | playMessageSound, |
| 81 | fixMarkdown, |
| 82 | power_user, |
| 83 | persona_description_positions, |
| 84 | loadMovingUIState, |
| 85 | getCustomStoppingStrings, |
| 86 | MAX_CONTEXT_DEFAULT, |
| 87 | MAX_RESPONSE_DEFAULT, |
| 88 | renderStoryString, |
| 89 | sortEntitiesList, |
| 90 | registerDebugFunction, |
| 91 | flushEphemeralStoppingStrings, |
| 92 | resetMovableStyles, |
| 93 | forceCharacterEditorTokenize, |
| 94 | applyPowerUserSettings, |
| 95 | generatedTextFiltered, |
| 96 | applyStylePins, |
| 97 | } from './scripts/power-user.js'; |
| 98 | |
| 99 | import { |
| 100 | setOpenAIMessageExamples, |
| 101 | setOpenAIMessages, |
| 102 | setupChatCompletionPromptManager, |
| 103 | prepareOpenAIMessages, |
| 104 | sendOpenAIRequest, |
| 105 | loadOpenAISettings, |
| 106 | oai_settings, |
| 107 | openai_messages_count, |
| 108 | chat_completion_sources, |
| 109 | getChatCompletionModel, |
| 110 | proxies, |
| 111 | loadProxyPresets, |
| 112 | selected_proxy, |
| 113 | initOpenAI, |
| 114 | } from './scripts/openai.js'; |
| 115 | |
| 116 | import { |
| 117 | generateNovelWithStreaming, |
| 118 | getNovelGenerationData, |
| 119 | getKayraMaxContextTokens, |
| 120 | loadNovelSettings, |
| 121 | nai_settings, |
| 122 | adjustNovelInstructionPrompt, |
| 123 | parseNovelAILogprobs, |
| 124 | novelai_settings, |
| 125 | novelai_setting_names, |
| 126 | initNovelAISettings, |
| 127 | } from './scripts/nai-settings.js'; |
| 128 | |
| 129 | import { |
| 130 | initBookmarks, |
| 131 | showBookmarksButtons, |
| 132 | updateBookmarkDisplay, |
| 133 | } from './scripts/bookmarks.js'; |
| 134 | |
| 135 | import { |
| 136 | horde_settings, |
| 137 | loadHordeSettings, |
| 138 | generateHorde, |
| 139 | getStatusHorde, |
| 140 | getHordeModels, |
| 141 | adjustHordeGenerationParams, |
| 142 | isHordeGenerationNotAllowed, |
| 143 | MIN_LENGTH, |
| 144 | initHorde, |
| 145 | } from './scripts/horde.js'; |
| 146 | |
| 147 | import { |
| 148 | debounce, |
| 149 | delay, |
| 150 | trimToEndSentence, |
| 151 | countOccurrences, |
| 152 | isOdd, |
| 153 | sortMoments, |
| 154 | timestampToMoment, |
| 155 | download, |
| 156 | isDataURL, |
| 157 | getCharaFilename, |
| 158 | PAGINATION_TEMPLATE, |
| 159 | waitUntilCondition, |
| 160 | escapeRegex, |
| 161 | resetScrollHeight, |
| 162 | onlyUnique, |
| 163 | getBase64Async, |
| 164 | humanFileSize, |
| 165 | Stopwatch, |
| 166 | isValidUrl, |
| 167 | ensureImageFormatSupported, |
| 168 | flashHighlight, |
| 169 | toggleDrawer, |
| 170 | isElementInViewport, |
| 171 | copyText, |
| 172 | escapeHtml, |
| 173 | saveBase64AsFile, |
| 174 | uuidv4, |
| 175 | equalsIgnoreCaseAndAccents, |
| 176 | localizePagination, |
| 177 | renderPaginationDropdown, |
| 178 | paginationDropdownChangeHandler, |
| 179 | importFromExternalUrl, |
| 180 | shiftUpByOne, |
| 181 | shiftDownByOne, |
| 182 | canUseNegativeLookbehind, |
| 183 | trimSpaces, |
| 184 | clamp, |
| 185 | shakeElement, |
| 186 | createTimeout, |
| 187 | } from './scripts/utils.js'; |
| 188 | import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids, MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, OVERSWIPE_BEHAVIOR, SCROLL_BEHAVIOR, SWIPE_DIRECTION, SWIPE_SOURCE, SWIPE_STATE } from './scripts/constants.js'; |
| 189 | |
| 190 | import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors } from './scripts/extensions.js'; |
| 191 | import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, initSlashCommandAutoComplete, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js'; |
| 192 | import { initMacroAutoComplete } from './scripts/autocomplete/MacroAutoComplete.js'; |
| 193 | import { |
| 194 | tag_map, |
| 195 | tags, |
| 196 | filterByTagState, |
| 197 | isBogusFolder, |
| 198 | isBogusFolderOpen, |
| 199 | chooseBogusFolder, |
| 200 | getTagBlock, |
| 201 | loadTagsSettings, |
| 202 | printTagFilters, |
| 203 | getTagKeyForEntity, |
| 204 | printTagList, |
| 205 | createTagMapFromList, |
| 206 | renameTagKey, |
| 207 | importTags, |
| 208 | tag_filter_type, |
| 209 | compareTagsForSort, |
| 210 | initTags, |
| 211 | applyTagsOnCharacterSelect, |
| 212 | applyTagsOnGroupSelect, |
| 213 | tag_import_setting, |
| 214 | applyCharacterTagsToMessageDivs, |
| 215 | } from './scripts/tags.js'; |
| 216 | import { checkOpenRouterAuth, initSecrets, readSecretState } from './scripts/secrets.js'; |
| 217 | import { markdownExclusionExt } from './scripts/showdown-exclusion.js'; |
| 218 | import { markdownUnderscoreExt } from './scripts/showdown-underscore.js'; |
| 219 | import { NOTE_MODULE_NAME, initAuthorsNote, metadata_keys, setFloatingPrompt, shouldWIAddPrompt } from './scripts/authors-note.js'; |
| 220 | import { registerPromptManagerMigration } from './scripts/PromptManager.js'; |
| 221 | import { getRegexedString, regex_placement } from './scripts/extensions/regex/engine.js'; |
| 222 | import { initLogprobs, saveLogprobsForActiveMessage } from './scripts/logprobs.js'; |
| 223 | import { FILTER_STATES, FILTER_TYPES, FilterHelper, isFilterState } from './scripts/filters.js'; |
| 224 | import { getCfgPrompt, getGuidanceScale, initCfg } from './scripts/cfg-scale.js'; |
| 225 | import { |
| 226 | force_output_sequence, |
| 227 | formatInstructModeChat, |
| 228 | formatInstructModePrompt, |
| 229 | formatInstructModeExamples, |
| 230 | formatInstructModeStoryString, |
| 231 | getInstructStoppingSequences, |
| 232 | } from './scripts/instruct-mode.js'; |
| 233 | import { initLocales, t } from './scripts/i18n.js'; |
| 234 | import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, initTokenizers, saveTokenCache } from './scripts/tokenizers.js'; |
| 235 | import { |
| 236 | user_avatar, |
| 237 | getUserAvatars, |
| 238 | getUserAvatar, |
| 239 | setUserAvatar, |
| 240 | initPersonas, |
| 241 | setPersonaDescription, |
| 242 | initUserAvatar, |
| 243 | updatePersonaConnectionsAvatarList, |
| 244 | isPersonaPanelOpen, |
| 245 | } from './scripts/personas.js'; |
| 246 | import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_settings } from './scripts/backgrounds.js'; |
| 247 | import { loader } from './scripts/action-loader.js'; |
| 248 | import { BulkEditOverlay } from './scripts/BulkEditOverlay.js'; |
| 249 | import { initTextGenModels } from './scripts/textgen-models.js'; |
| 250 | import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, preserveNeutralChat, restoreNeutralChat, formatCreatorNotes, initChatUtilities, addDOMPurifyHooks } from './scripts/chats.js'; |
| 251 | import { getPresetManager, initPresetManager } from './scripts/preset-manager.js'; |
| 252 | import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js'; |
| 253 | import { currentUser, setUserControls } from './scripts/user.js'; |
| 254 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup, fixToastrForDialogs } from './scripts/popup.js'; |
| 255 | import { renderTemplate, renderTemplateAsync } from './scripts/templates.js'; |
| 256 | import { initScrapers } from './scripts/scrapers.js'; |
| 257 | import { initCustomSelectedSamplers, validateDisabledSamplers } from './scripts/samplerSelect.js'; |
| 258 | import { DragAndDropHandler } from './scripts/dragdrop.js'; |
| 259 | import { INTERACTABLE_CONTROL_CLASS, initKeyboard } from './scripts/keyboard.js'; |
| 260 | import { initDynamicStyles } from './scripts/dynamic-styles.js'; |
| 261 | import { initInputMarkdown } from './scripts/input-md-formatting.js'; |
| 262 | import { AbortReason } from './scripts/util/AbortReason.js'; |
| 263 | import { initSystemPrompts } from './scripts/sysprompt.js'; |
| 264 | import { registerExtensionSlashCommands as initExtensionSlashCommands } from './scripts/extensions-slashcommands.js'; |
| 265 | import { ToolManager } from './scripts/tool-calling.js'; |
| 266 | import { addShowdownPatch } from './scripts/util/showdown-patch.js'; |
| 267 | import { applyBrowserFixes } from './scripts/browser-fixes.js'; |
| 268 | import { initServerHistory } from './scripts/server-history.js'; |
| 269 | import { initSettingsSearch } from './scripts/setting-search.js'; |
| 270 | import { initBulkEdit } from './scripts/bulk-edit.js'; |
| 271 | import { getContext } from './scripts/st-context.js'; |
| 272 | import { extractReasoningFromData, extractReasoningSignatureFromData, initReasoning, parseReasoningInSwipes, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningUI } from './scripts/reasoning.js'; |
| 273 | import { accountStorage } from './scripts/util/AccountStorage.js'; |
| 274 | import { initWelcomeScreen, openPermanentAssistantChat, openPermanentAssistantCard, getPermanentAssistantAvatar } from './scripts/welcome-screen.js'; |
| 275 | import { initDataMaid } from './scripts/data-maid.js'; |
| 276 | import { clearItemizedPrompts, deleteItemizedPromptForMessage, deleteItemizedPrompts, findItemizedPromptSet, initItemizedPrompts, itemizedParams, itemizedPrompts, loadItemizedPrompts, promptItemize, replaceItemizedPromptText, saveItemizedPrompts, swapItemizedPrompts } from './scripts/itemized-prompts.js'; |
| 277 | import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js'; |
| 278 | import { event_types, eventSource } from './scripts/events.js'; |
| 279 | import { initAccessibility } from './scripts/a11y.js'; |
| 280 | import { applyStreamFadeIn } from './scripts/util/stream-fadein.js'; |
| 281 | import { initDomHandlers } from './scripts/dom-handlers.js'; |
| 282 | import { SimpleMutex } from './scripts/util/SimpleMutex.js'; |
| 283 | import { AudioPlayer } from './scripts/audio-player.js'; |
| 284 | import { MacroEnvBuilder } from './scripts/macros/engine/MacroEnvBuilder.js'; |
| 285 | import { MacroEngine } from './scripts/macros/engine/MacroEngine.js'; |
| 286 | import { addChatBackupsBrowser } from './scripts/chat-backups.js'; |
| 287 | import { onboardingExperimentalMacroEngine } from './scripts/macros/engine/MacroDiagnostics.js'; |
| 288 | import { compressRequest, setRequestCompressionConfig } from './scripts/request-compression.js'; |
| 289 | import { canJumpToSwipeForMessage, canOpenSwipePickerForMessage, initSwipePicker } from './scripts/swipe-picker.js'; |
| 290 | |
| 291 | // API OBJECT FOR EXTERNAL WIRING |
| 292 | globalThis.SillyTavern = { |
| 293 | libs, |
| 294 | getContext, |
| 295 | }; |
| 296 | |
| 297 | export { |
| 298 | user_avatar, |
| 299 | setUserAvatar, |
| 300 | getUserAvatars, |
| 301 | getUserAvatar, |
| 302 | nai_settings, |
| 303 | isOdd, |
| 304 | countOccurrences, |
| 305 | renderTemplate, |
| 306 | promptItemize, |
| 307 | itemizedPrompts, |
| 308 | saveItemizedPrompts, |
| 309 | loadItemizedPrompts, |
| 310 | itemizedParams, |
| 311 | clearItemizedPrompts, |
| 312 | replaceItemizedPromptText, |
| 313 | deleteItemizedPrompts, |
| 314 | findItemizedPromptSet, |
| 315 | koboldai_settings, |
| 316 | koboldai_setting_names, |
| 317 | novelai_settings, |
| 318 | novelai_setting_names, |
| 319 | UNIQUE_APIS, |
| 320 | CONNECT_API_MAP, |
| 321 | system_messages, |
| 322 | system_message_types, |
| 323 | sendSystemMessage, |
| 324 | getSystemMessageByType, |
| 325 | event_types, |
| 326 | eventSource, |
| 327 | /** @deprecated Use setCharacterSettingsOverrides instead. */ |
| 328 | setCharacterSettingsOverrides as setScenarioOverride, |
| 329 | /** @deprecated Use appendMediaToMessage instead. */ |
| 330 | appendMediaToMessage as appendImageToMessage, |
| 331 | /** @deprecated Use getMaxPromptTokens instead. */ |
| 332 | getMaxPromptTokens as getMaxContextSize, |
| 333 | }; |
| 334 | |
| 335 | /** |
| 336 | * Wait for page to load before continuing the app initialization. |
| 337 | */ |
| 338 | await new Promise((resolve) => { |
| 339 | if (document.readyState === 'complete') { |
| 340 | resolve(); |
| 341 | } else { |
| 342 | window.addEventListener('load', resolve); |
| 343 | } |
| 344 | }); |
| 345 | |
| 346 | // Configure toast library: |
| 347 | toastr.options = { |
| 348 | positionClass: 'toast-top-center', |
| 349 | closeButton: false, |
| 350 | progressBar: false, |
| 351 | showDuration: 250, |
| 352 | hideDuration: 250, |
| 353 | timeOut: 4000, |
| 354 | extendedTimeOut: 10000, |
| 355 | showEasing: 'linear', |
| 356 | hideEasing: 'linear', |
| 357 | showMethod: 'fadeIn', |
| 358 | hideMethod: 'fadeOut', |
| 359 | escapeHtml: true, |
| 360 | onHidden: function () { |
| 361 | // If we have any dialog still open, the last "hidden" toastr will remove the toastr-container. We need to keep it alive inside the dialog though |
| 362 | // so the toasts still show up inside there. |
| 363 | fixToastrForDialogs(); |
| 364 | }, |
| 365 | }; |
| 366 | |
| 367 | // Run once during startup |
| 368 | toastr.subscribe(function (args) { |
| 369 | if (args.state !== 'visible') { |
| 370 | return; |
| 371 | } |
| 372 | |
| 373 | const $container = toastr.getContainer(args.options, false); |
| 374 | if (!$container || !$container.length) { |
| 375 | return; |
| 376 | } |
| 377 | |
| 378 | // toastr has already inserted the element at this point |
| 379 | const $toast = args.options.newestOnTop |
| 380 | ? $container.children().first() |
| 381 | : $container.children().last(); |
| 382 | |
| 383 | // Meaning of "clickable": |
| 384 | // Interactable unless tapToDismiss was explicitly false |
| 385 | const isInteractable = args.options.tapToDismiss !== false; |
| 386 | $toast.toggleClass('interactable', isInteractable); |
| 387 | if (isInteractable) { |
| 388 | $toast.attr('title', t`Tap to close`); |
| 389 | } else { |
| 390 | $toast.removeAttr('title'); |
| 391 | $toast.addClass('toast-non-interactable'); |
| 392 | } |
| 393 | }); |
| 394 | |
| 395 | export const characterGroupOverlay = new BulkEditOverlay(); |
| 396 | |
| 397 | // Markdown converter |
| 398 | export let mesForShowdownParse; //intended to be used as a context to compare showdown strings against |
| 399 | /** @type {import('showdown').Converter} */ |
| 400 | export let converter; |
| 401 | |
| 402 | // array for prompt token calculations |
| 403 | |
| 404 | export const systemUserName = 'SillyTavern System'; |
| 405 | export const neutralCharacterName = 'Assistant'; |
| 406 | let default_user_name = 'User'; |
| 407 | export let name1 = default_user_name; |
| 408 | export let name2 = systemUserName; |
| 409 | /** @type {ChatMessage[]} */ |
| 410 | export let chat = []; |
| 411 | |
| 412 | /** |
| 413 | * @type {import('./scripts/constants.js').SWIPE_STATE} |
| 414 | */ |
| 415 | export let swipeState = SWIPE_STATE.NONE; |
| 416 | let chatSaveTimeout; |
| 417 | let importFlashTimeout; |
| 418 | export let isChatSaving = false; |
| 419 | let firstRun = false; |
| 420 | export let settingsReady = false; |
| 421 | let currentVersion = '0.0.0'; |
| 422 | export let displayVersion = 'SillyTavern'; |
| 423 | |
| 424 | let generation_started = new Date(); |
| 425 | /** @type {Character[]} */ |
| 426 | export let characters = []; |
| 427 | /** |
| 428 | * Stringified index of a currently chosen entity in the characters array. |
| 429 | * @type {string|undefined} Yes, we hate it as much as you do. |
| 430 | */ |
| 431 | export let this_chid; |
| 432 | let saveCharactersPage = 0; |
| 433 | export const default_avatar = 'img/ai4.png'; |
| 434 | export const system_avatar = 'img/five.png'; |
| 435 | export const comment_avatar = 'img/quill.png'; |
| 436 | export const default_user_avatar = 'img/user-default.png'; |
| 437 | export let CLIENT_VERSION = 'SillyTavern:UNKNOWN:Cohee#1207'; // For Horde header |
| 438 | let optionsPopper = Popper.createPopper(document.getElementById('options_button'), document.getElementById('options'), { |
| 439 | placement: 'top-start', |
| 440 | }); |
| 441 | let exportPopper = Popper.createPopper(document.getElementById('export_button'), document.getElementById('export_format_popup'), { |
| 442 | placement: 'left', |
| 443 | }); |
| 444 | let isExportPopupOpen = false; |
| 445 | |
| 446 | // Saved here for performance reasons |
| 447 | const messageTemplate = $('#message_template .mes'); |
| 448 | export const chatElement = $('#chat'); |
| 449 | |
| 450 | let dialogueResolve = null; |
| 451 | let dialogueCloseStop = false; |
| 452 | /** @type {ChatMetadata} */ |
| 453 | export let chat_metadata = {}; |
| 454 | /** @type {StreamingProcessor} */ |
| 455 | export let streamingProcessor = null; |
| 456 | let crop_data = undefined; |
| 457 | let is_delete_mode = false; |
| 458 | let fav_ch_checked = false; |
| 459 | let scrollLock = false; |
| 460 | export let abortStatusCheck = new AbortController(); |
| 461 | export let charDragDropHandler = null; |
| 462 | export let chatDragDropHandler = null; |
| 463 | |
| 464 | /** @type {debounce_timeout} The debounce timeout used for chat/settings save. debounce_timeout.long: 1.000 ms */ |
| 465 | export const DEFAULT_SAVE_EDIT_TIMEOUT = debounce_timeout.relaxed; |
| 466 | /** @type {debounce_timeout} The debounce timeout used for printing. debounce_timeout.quick: 100 ms */ |
| 467 | export const DEFAULT_PRINT_TIMEOUT = debounce_timeout.quick; |
| 468 | |
| 469 | export const saveSettingsDebounced = debounce((loopCounter = 0) => saveSettings(loopCounter), DEFAULT_SAVE_EDIT_TIMEOUT); |
| 470 | export const saveCharacterDebounced = debounce(() => $('#create_button').trigger('click'), DEFAULT_SAVE_EDIT_TIMEOUT); |
| 471 | |
| 472 | /** |
| 473 | * Prints the character list in a debounced fashion without blocking, with a delay of 100 milliseconds. |
| 474 | * Use this function instead of a direct `printCharacters()` whenever the reprinting of the character list is not the primary focus. |
| 475 | * |
| 476 | * The printing will also always reprint all filter options of the global list, to keep them up to date. |
| 477 | */ |
| 478 | export const printCharactersDebounced = debounce(() => { printCharacters(false); }, DEFAULT_PRINT_TIMEOUT); |
| 479 | |
| 480 | /** |
| 481 | * @enum {number} Extension prompt types |
| 482 | */ |
| 483 | export const extension_prompt_types = { |
| 484 | NONE: -1, |
| 485 | IN_PROMPT: 0, |
| 486 | IN_CHAT: 1, |
| 487 | BEFORE_PROMPT: 2, |
| 488 | }; |
| 489 | |
| 490 | /** |
| 491 | * @enum {number} Extension prompt roles |
| 492 | */ |
| 493 | export const extension_prompt_roles = { |
| 494 | SYSTEM: 0, |
| 495 | USER: 1, |
| 496 | ASSISTANT: 2, |
| 497 | }; |
| 498 | |
| 499 | export const MAX_INJECTION_DEPTH = 10000; |
| 500 | |
| 501 | async function getClientVersion() { |
| 502 | try { |
| 503 | const response = await fetch('/version'); |
| 504 | const data = await response.json(); |
| 505 | CLIENT_VERSION = data.agent; |
| 506 | displayVersion = `SillyTavern ${data.pkgVersion}`; |
| 507 | currentVersion = data.pkgVersion; |
| 508 | |
| 509 | if (data.gitRevision && data.gitBranch) { |
| 510 | displayVersion += ` '${data.gitBranch}' (${data.gitRevision})`; |
| 511 | } |
| 512 | |
| 513 | $('#version_display').text(displayVersion); |
| 514 | $('#version_display_welcome').text(displayVersion); |
| 515 | } catch (err) { |
| 516 | console.error('Couldn\'t get client version', err); |
| 517 | } |
| 518 | } |
| 519 | |
| 520 | export function reloadMarkdownProcessor() { |
| 521 | converter = new showdown.Converter({ |
| 522 | emoji: true, |
| 523 | literalMidWordUnderscores: true, |
| 524 | parseImgDimensions: true, |
| 525 | tables: true, |
| 526 | underline: true, |
| 527 | simpleLineBreaks: true, |
| 528 | strikethrough: true, |
| 529 | disableForced4SpacesIndentedSublists: true, |
| 530 | extensions: [markdownUnderscoreExt()], |
| 531 | }); |
| 532 | |
| 533 | // Inject the dinkus extension after creating the converter |
| 534 | // Maybe move this into power_user init? |
| 535 | converter.addExtension(markdownExclusionExt(), 'exclusion'); |
| 536 | |
| 537 | return converter; |
| 538 | } |
| 539 | |
| 540 | export function getCurrentChatId() { |
| 541 | if (selected_group) { |
| 542 | return groups.find(x => x.id == selected_group)?.chat_id; |
| 543 | } else if (this_chid !== undefined) { |
| 544 | return characters[this_chid]?.chat; |
| 545 | } |
| 546 | } |
| 547 | |
| 548 | export const talkativeness_default = 0.5; |
| 549 | export const depth_prompt_depth_default = 4; |
| 550 | export const depth_prompt_role_default = 'system'; |
| 551 | const per_page_default = 50; |
| 552 | |
| 553 | var is_advanced_char_open = false; |
| 554 | |
| 555 | /** |
| 556 | * The type of the right menu |
| 557 | * @typedef {'characters' | 'character_edit' | 'create' | 'group_edit' | 'group_create' | '' } MenuType |
| 558 | */ |
| 559 | |
| 560 | /** |
| 561 | * The type of the right menu that is currently open |
| 562 | * @type {MenuType} |
| 563 | */ |
| 564 | export let menu_type = ''; |
| 565 | |
| 566 | export let selected_button = ''; //which button pressed |
| 567 | |
| 568 | //create pole save |
| 569 | export let create_save = { |
| 570 | name: '', |
| 571 | description: '', |
| 572 | creator_notes: '', |
| 573 | post_history_instructions: '', |
| 574 | character_version: '', |
| 575 | system_prompt: '', |
| 576 | tags: '', |
| 577 | creator: '', |
| 578 | personality: '', |
| 579 | first_message: '', |
| 580 | /** @type {FileList|null} */ |
| 581 | avatar: null, |
| 582 | scenario: '', |
| 583 | mes_example: '', |
| 584 | world: '', |
| 585 | talkativeness: talkativeness_default, |
| 586 | alternate_greetings: [], |
| 587 | depth_prompt_prompt: '', |
| 588 | depth_prompt_depth: depth_prompt_depth_default, |
| 589 | depth_prompt_role: depth_prompt_role_default, |
| 590 | extensions: {}, |
| 591 | extra_books: [], |
| 592 | }; |
| 593 | |
| 594 | //animation right menu |
| 595 | export const ANIMATION_DURATION_DEFAULT = 125; |
| 596 | export let animation_duration = ANIMATION_DURATION_DEFAULT; |
| 597 | export let animation_easing = 'ease-in-out'; |
| 598 | let popup_type = ''; |
| 599 | let chat_file_for_del = ''; |
| 600 | export let online_status = 'no_connection'; |
| 601 | |
| 602 | export let is_send_press = false; //Send generation |
| 603 | export const isGenerating = () => (is_send_press || is_group_generating); |
| 604 | |
| 605 | let this_del_mes = -1; |
| 606 | |
| 607 | /** @type {string} */ |
| 608 | let this_edit_mes_chname = ''; |
| 609 | /** @type {number|undefined} */ |
| 610 | let this_edit_mes_id = undefined; |
| 611 | |
| 612 | //settings |
| 613 | export let settings; |
| 614 | export let amount_gen = 80; //default max length of AI generated responses |
| 615 | export let max_context = 2048; |
| 616 | |
| 617 | /** User preference for swipeable messages */ |
| 618 | let swipes = true; |
| 619 | /** Forcefully hide swipes. */ |
| 620 | export let swipesHidden = false; |
| 621 | /** @type {{ now: number, direction: string }} */ |
| 622 | export let lastSwipeInfo = { now: performance.now(), direction: SWIPE_DIRECTION.RIGHT }; |
| 623 | export let recentSwipes = 0; |
| 624 | |
| 625 | export let extension_prompts = {}; |
| 626 | |
| 627 | export let main_api;// = "kobold"; |
| 628 | let abortController = new AbortController(); |
| 629 | |
| 630 | //css |
| 631 | var css_send_form_display = $('<div id=send_form></div>').css('display'); |
| 632 | |
| 633 | var kobold_horde_model = ''; |
| 634 | |
| 635 | export let token; |
| 636 | |
| 637 | |
| 638 | /** The tag of the active character. (NOT the id) */ |
| 639 | export let active_character = ''; |
| 640 | /** The tag of the active group. (Coincidentally also the id) */ |
| 641 | export let active_group = ''; |
| 642 | |
| 643 | export const entitiesFilter = new FilterHelper(printCharactersDebounced); |
| 644 | |
| 645 | export function getRequestHeaders({ omitContentType = false } = {}) { |
| 646 | const headers = { |
| 647 | 'Content-Type': 'application/json', |
| 648 | 'X-CSRF-Token': token, |
| 649 | }; |
| 650 | |
| 651 | if (omitContentType) { |
| 652 | delete headers['Content-Type']; |
| 653 | } |
| 654 | |
| 655 | return headers; |
| 656 | } |
| 657 | |
| 658 | export function getSlideToggleOptions() { |
| 659 | return { |
| 660 | miliseconds: animation_duration * 1.5, |
| 661 | transitionFunction: animation_duration > 0 ? 'ease-in-out' : 'step-start', |
| 662 | }; |
| 663 | } |
| 664 | |
| 665 | $.ajaxPrefilter((options, originalOptions, xhr) => { |
| 666 | xhr.setRequestHeader('X-CSRF-Token', token); |
| 667 | }); |
| 668 | |
| 669 | /** |
| 670 | * Pings the STserver to check if it is reachable. |
| 671 | * @returns {Promise<boolean>} True if the server is reachable, false otherwise. |
| 672 | */ |
| 673 | export async function pingServer() { |
| 674 | try { |
| 675 | const result = await fetch('api/ping', { |
| 676 | method: 'POST', |
| 677 | headers: getRequestHeaders({ omitContentType: true }), |
| 678 | }); |
| 679 | |
| 680 | if (!result.ok) { |
| 681 | return false; |
| 682 | } |
| 683 | |
| 684 | return true; |
| 685 | } catch (error) { |
| 686 | console.error('Error pinging server', error); |
| 687 | return false; |
| 688 | } |
| 689 | } |
| 690 | |
| 691 | //MARK: firstLoadInit |
| 692 | async function firstLoadInit() { |
| 693 | try { |
| 694 | const tokenResponse = await fetch('/csrf-token'); |
| 695 | const tokenData = await tokenResponse.json(); |
| 696 | token = tokenData.token; |
| 697 | } catch { |
| 698 | toastr.error(t`Couldn't get CSRF token. Please refresh the page.`, t`Error`, { timeOut: 0, extendedTimeOut: 0, preventDuplicates: true }); |
| 699 | throw new Error('Initialization failed'); |
| 700 | } |
| 701 | |
| 702 | const initLoaderOverlay = loader.createOverlay(); |
| 703 | initLoaderOverlay.classList.add('splash-screen'); |
| 704 | |
| 705 | const splashLogo = document.createElement('img'); |
| 706 | splashLogo.src = '/img/logo.png'; |
| 707 | splashLogo.alt = 'SillyTavern'; |
| 708 | splashLogo.className = 'splash-logo'; |
| 709 | splashLogo.ariaLabel = t`SillyTavern Logo`; |
| 710 | |
| 711 | const splashMessage = document.createElement('h2'); |
| 712 | splashMessage.className = 'splash-message'; |
| 713 | splashMessage.textContent = t`Initializing…`; |
| 714 | splashMessage.dataset.i18n = 'Initializing…'; |
| 715 | |
| 716 | initLoaderOverlay.prepend(splashLogo); |
| 717 | initLoaderOverlay.appendChild(splashMessage); |
| 718 | |
| 719 | const initLoaderHandle = loader.show({ |
| 720 | slug: 'app-init', |
| 721 | toastMode: loader.ToastMode.NONE, |
| 722 | overlayContent: initLoaderOverlay, |
| 723 | }); |
| 724 | |
| 725 | registerPromptManagerMigration(); |
| 726 | initDomHandlers(); |
| 727 | initStandaloneMode(); |
| 728 | initLibraryShims(); |
| 729 | addShowdownPatch(showdown); |
| 730 | addDOMPurifyHooks(); |
| 731 | reloadMarkdownProcessor(); |
| 732 | applyBrowserFixes(); |
| 733 | await getClientVersion(); |
| 734 | await initSecrets(); |
| 735 | await readSecretState(); |
| 736 | await initLocales(); |
| 737 | initChatUtilities(); |
| 738 | initDefaultSlashCommands(); |
| 739 | initTextGenModels(); |
| 740 | initOpenAI(); |
| 741 | initTextGenSettings(); |
| 742 | initKoboldSettings(); |
| 743 | initNovelAISettings(); |
| 744 | initSystemPrompts(); |
| 745 | await initExtensions(); |
| 746 | initExtensionSlashCommands(); |
| 747 | ToolManager.initToolSlashCommands(); |
| 748 | await initPresetManager(); |
| 749 | await initSystemMessages(); |
| 750 | await getSettings(initLoaderHandle); |
| 751 | await checkOpenRouterAuth(); |
| 752 | initKeyboard(); |
| 753 | initDynamicStyles(); |
| 754 | initTags(); |
| 755 | initBookmarks(); |
| 756 | await getUserAvatars(true, user_avatar); |
| 757 | await getCharacters(); |
| 758 | await getBackgrounds(); |
| 759 | await initTokenizers(); |
| 760 | initBackgrounds(); |
| 761 | initAuthorsNote(); |
| 762 | await initPersonas(); |
| 763 | await initSlashCommandAutoComplete(); |
| 764 | initMacroAutoComplete(); |
| 765 | initWorldInfo(); |
| 766 | initHorde(); |
| 767 | initRossMods(); |
| 768 | initStats(); |
| 769 | initCfg(); |
| 770 | initLogprobs(); |
| 771 | initInputMarkdown(); |
| 772 | initServerHistory(); |
| 773 | initSettingsSearch(); |
| 774 | initBulkEdit(); |
| 775 | initReasoning(); |
| 776 | initWelcomeScreen(); |
| 777 | await initScrapers(); |
| 778 | initCustomSelectedSamplers(); |
| 779 | initDataMaid(); |
| 780 | initItemizedPrompts(); |
| 781 | initAccessibility(); |
| 782 | initSwipePicker(); |
| 783 | addDebugFunctions(); |
| 784 | doDailyExtensionUpdatesCheck(); |
| 785 | await eventSource.emit(event_types.APP_INITIALIZED); |
| 786 | await initLoaderHandle.hide(); |
| 787 | await fixViewport(); |
| 788 | await eventSource.emit(event_types.APP_READY); |
| 789 | } |
| 790 | |
| 791 | async function fixViewport() { |
| 792 | document.body.style.position = 'absolute'; |
| 793 | await delay(1); |
| 794 | document.body.style.position = ''; |
| 795 | } |
| 796 | |
| 797 | function initStandaloneMode() { |
| 798 | const isPwaMode = window.matchMedia('(display-mode: standalone)').matches; |
| 799 | if (isPwaMode) { |
| 800 | $('body').addClass('PWA'); |
| 801 | } |
| 802 | } |
| 803 | |
| 804 | export function cancelStatusCheck(reason = 'Manually cancelled status check') { |
| 805 | abortStatusCheck?.abort(new AbortReason(reason)); |
| 806 | abortStatusCheck = new AbortController(); |
| 807 | setOnlineStatus('no_connection'); |
| 808 | } |
| 809 | |
| 810 | export function displayOnlineStatus() { |
| 811 | if (online_status == 'no_connection') { |
| 812 | $('.online_status_indicator').removeClass('success'); |
| 813 | $('.online_status_text').text($('#API-status-top').attr('no_connection_text')); |
| 814 | } else { |
| 815 | $('.online_status_indicator').addClass('success'); |
| 816 | $('.online_status_text').text(online_status); |
| 817 | } |
| 818 | } |
| 819 | |
| 820 | /** |
| 821 | * Sets the duration of JS animations. |
| 822 | * @param {number} ms Duration in milliseconds. Resets to default if null. |
| 823 | */ |
| 824 | export function setAnimationDuration(ms = null) { |
| 825 | animation_duration = ms ?? ANIMATION_DURATION_DEFAULT; |
| 826 | // Set CSS variable to document |
| 827 | document.documentElement.style.setProperty('--animation-duration', `${animation_duration}ms`); |
| 828 | } |
| 829 | |
| 830 | /** |
| 831 | * Sets the currently active character |
| 832 | * @param {object|number|string} [entityOrKey] - An entity with id property (character, group, tag), or directly an id or tag key. If not provided, the active character is reset to `null`. |
| 833 | */ |
| 834 | export function setActiveCharacter(entityOrKey) { |
| 835 | active_character = entityOrKey ? getTagKeyForEntity(entityOrKey) : null; |
| 836 | if (active_character) active_group = null; |
| 837 | } |
| 838 | |
| 839 | /** |
| 840 | * Sets the currently active group. |
| 841 | * @param {object|number|string} [entityOrKey] - An entity with id property (character, group, tag), or directly an id or tag key. If not provided, the active group is reset to `null`. |
| 842 | */ |
| 843 | export function setActiveGroup(entityOrKey) { |
| 844 | active_group = entityOrKey ? getTagKeyForEntity(entityOrKey) : null; |
| 845 | if (active_group) active_character = null; |
| 846 | } |
| 847 | |
| 848 | export function startStatusLoading() { |
| 849 | $('.api_loading').show(); |
| 850 | $('.api_button').addClass('disabled'); |
| 851 | } |
| 852 | |
| 853 | export function stopStatusLoading() { |
| 854 | $('.api_loading').hide(); |
| 855 | $('.api_button').removeClass('disabled'); |
| 856 | } |
| 857 | |
| 858 | export function resultCheckStatus() { |
| 859 | displayOnlineStatus(); |
| 860 | stopStatusLoading(); |
| 861 | } |
| 862 | |
| 863 | /** |
| 864 | * Switches the currently selected character to the one with the given ID. (character index, not the character key!) |
| 865 | * |
| 866 | * If the character ID doesn't exist, if the chat is being saved, or if a group is being generated, this function does nothing. |
| 867 | * If the character is different from the currently selected one, it will clear the chat and reset any selected character or group. |
| 868 | * @param {number} id The ID of the character to switch to. |
| 869 | * @param {object} [options] Options for the switch. |
| 870 | * @param {boolean} [options.switchMenu=true] Whether to switch the right menu to the character edit menu if the character is already selected. |
| 871 | * @returns {Promise<void>} A promise that resolves when the character is switched. |
| 872 | */ |
| 873 | export async function selectCharacterById(id, { switchMenu = true } = {}) { |
| 874 | if (characters[id] === undefined) { |
| 875 | return; |
| 876 | } |
| 877 | |
| 878 | if (isChatSaving) { |
| 879 | toastr.info(t`Please wait until the chat is saved before switching characters.`, t`Your chat is still saving...`); |
| 880 | return; |
| 881 | } |
| 882 | |
| 883 | if (selected_group && is_group_generating) { |
| 884 | return; |
| 885 | } |
| 886 | |
| 887 | if (selected_group || String(this_chid) !== String(id)) { |
| 888 | //if clicked on a different character from what was currently selected |
| 889 | if (!is_send_press) { |
| 890 | setCharacterId(undefined); |
| 891 | setCharacterName(''); |
| 892 | resetSelectedGroup(); |
| 893 | await clearChat({ clearData: true }); |
| 894 | cancelTtsPlay(); |
| 895 | this_edit_mes_id = undefined; |
| 896 | selected_button = 'character_edit'; |
| 897 | setCharacterId(id); |
| 898 | chat_metadata = {}; |
| 899 | await getChat(); |
| 900 | } |
| 901 | } else { |
| 902 | //if clicked on character that was already selected |
| 903 | switchMenu && (selected_button = 'character_edit'); |
| 904 | await unshallowCharacter(this_chid); |
| 905 | select_selected_character(this_chid, { switchMenu }); |
| 906 | } |
| 907 | } |
| 908 | |
| 909 | function getBackBlock() { |
| 910 | const template = $('#bogus_folder_back_template .bogus_folder_select').clone(); |
| 911 | return template; |
| 912 | } |
| 913 | |
| 914 | async function getEmptyBlock() { |
| 915 | const icons = ['fa-dragon', 'fa-otter', 'fa-kiwi-bird', 'fa-crow', 'fa-frog']; |
| 916 | const texts = [t`Here be dragons`, t`Otterly empty`, t`Kiwibunga`, t`Pump-a-Rum`, t`Croak it`]; |
| 917 | const roll = new Date().getMinutes() % icons.length; |
| 918 | const params = { |
| 919 | text: texts[roll], |
| 920 | icon: icons[roll], |
| 921 | }; |
| 922 | const emptyBlock = await renderTemplateAsync('emptyBlock', params); |
| 923 | return $(emptyBlock); |
| 924 | } |
| 925 | |
| 926 | /** |
| 927 | * @param {number} hidden Number of hidden characters |
| 928 | */ |
| 929 | async function getHiddenBlock(hidden) { |
| 930 | const params = { |
| 931 | text: (hidden > 1 ? t`${hidden} characters hidden.` : t`${hidden} character hidden.`), |
| 932 | }; |
| 933 | const hiddenBlock = await renderTemplateAsync('hiddenBlock', params); |
| 934 | return $(hiddenBlock); |
| 935 | } |
| 936 | |
| 937 | function getCharacterBlock(item, id) { |
| 938 | let this_avatar = default_avatar; |
| 939 | if (item.avatar != 'none') { |
| 940 | this_avatar = getThumbnailUrl('avatar', item.avatar); |
| 941 | } |
| 942 | // Populate the template |
| 943 | const template = $('#character_template .character_select').clone(); |
| 944 | template.attr({ 'data-chid': id, 'id': `CharID${id}` }); |
| 945 | template.find('img').attr('src', this_avatar).attr('alt', item.name); |
| 946 | template.find('.avatar').attr('title', `[Character] ${item.name}\nFile: ${item.avatar}`); |
| 947 | template.find('.ch_name').text(item.name).attr('title', `[Character] ${item.name}`); |
| 948 | if (power_user.show_card_avatar_urls) { |
| 949 | template.find('.ch_avatar_url').text(item.avatar); |
| 950 | } |
| 951 | template.find('.ch_fav_icon').css('display', 'none'); |
| 952 | template.toggleClass('is_fav', item.fav || item.fav == 'true'); |
| 953 | template.find('.ch_fav').val(item.fav); |
| 954 | |
| 955 | const isAssistant = item.avatar === getPermanentAssistantAvatar(); |
| 956 | if (!isAssistant) { |
| 957 | template.find('.ch_assistant').remove(); |
| 958 | } |
| 959 | |
| 960 | const description = item.data?.creator_notes || ''; |
| 961 | if (description) { |
| 962 | template.find('.ch_description').text(description); |
| 963 | } else { |
| 964 | template.find('.ch_description').hide(); |
| 965 | } |
| 966 | |
| 967 | const auxFieldName = power_user.aux_field || 'character_version'; |
| 968 | const auxFieldValue = (item.data && item.data[auxFieldName]) || ''; |
| 969 | if (auxFieldValue) { |
| 970 | template.find('.character_version').text(auxFieldValue); |
| 971 | } else { |
| 972 | template.find('.character_version').hide(); |
| 973 | } |
| 974 | |
| 975 | // Display inline tags |
| 976 | const tagsElement = template.find('.tags'); |
| 977 | printTagList(tagsElement, { forEntityOrKey: id, tagOptions: { isCharacterList: true } }); |
| 978 | |
| 979 | // Add to the list |
| 980 | return template; |
| 981 | } |
| 982 | |
| 983 | /** |
| 984 | * Prints the global character list, optionally doing a full refresh of the list |
| 985 | * Use this function whenever the reprinting of the character list is the primary focus, otherwise using `printCharactersDebounced` is preferred for a cleaner, non-blocking experience. |
| 986 | * |
| 987 | * The printing will also always reprint all filter options of the global list, to keep them up to date. |
| 988 | * |
| 989 | * @param {boolean} fullRefresh - If true, the list is fully refreshed and the navigation is being reset |
| 990 | */ |
| 991 | export async function printCharacters(fullRefresh = false) { |
| 992 | const storageKey = 'Characters_PerPage'; |
| 993 | const listId = '#rm_print_characters_block'; |
| 994 | |
| 995 | let currentScrollTop = $(listId).scrollTop(); |
| 996 | |
| 997 | if (fullRefresh) { |
| 998 | saveCharactersPage = 0; |
| 999 | currentScrollTop = 0; |
| 1000 | await delay(1); |
| 1001 | } |
| 1002 | |
| 1003 | // Before printing the personas, we check if we should enable/disable search sorting |
| 1004 | verifyCharactersSearchSortRule(); |
| 1005 | |
| 1006 | // We are actually always reprinting filters, as it "doesn't hurt", and this way they are always up to date |
| 1007 | printTagFilters(tag_filter_type.character); |
| 1008 | printTagFilters(tag_filter_type.group_members_list); |
| 1009 | printTagFilters(tag_filter_type.group_candidates_list); |
| 1010 | |
| 1011 | // We are also always reprinting the lists on character/group edit window, as these ones doesn't get updated otherwise |
| 1012 | applyTagsOnCharacterSelect(); |
| 1013 | applyTagsOnGroupSelect(); |
| 1014 | |
| 1015 | const entities = getEntitiesList({ doFilter: true }); |
| 1016 | |
| 1017 | const pageSize = Number(accountStorage.getItem(storageKey)) || per_page_default; |
| 1018 | const sizeChangerOptions = [10, 25, 50, 100, 250, 500, 1000]; |
| 1019 | $('#rm_print_characters_pagination').pagination({ |
| 1020 | dataSource: entities, |
| 1021 | pageSize, |
| 1022 | pageRange: 1, |
| 1023 | pageNumber: saveCharactersPage || 1, |
| 1024 | position: 'top', |
| 1025 | showPageNumbers: false, |
| 1026 | showSizeChanger: true, |
| 1027 | prevText: '<', |
| 1028 | nextText: '>', |
| 1029 | formatNavigator: PAGINATION_TEMPLATE, |
| 1030 | formatSizeChanger: renderPaginationDropdown(pageSize, sizeChangerOptions), |
| 1031 | showNavigator: true, |
| 1032 | callback: async function (/** @type {Entity[]} */ data) { |
| 1033 | $(listId).empty(); |
| 1034 | if (power_user.bogus_folders && isBogusFolderOpen()) { |
| 1035 | $(listId).append(getBackBlock()); |
| 1036 | } |
| 1037 | if (!data.length) { |
| 1038 | const emptyBlock = await getEmptyBlock(); |
| 1039 | $(listId).append(emptyBlock); |
| 1040 | } |
| 1041 | let displayCount = 0; |
| 1042 | for (const i of data) { |
| 1043 | switch (i.type) { |
| 1044 | case 'character': |
| 1045 | $(listId).append(getCharacterBlock(i.item, i.id)); |
| 1046 | displayCount++; |
| 1047 | break; |
| 1048 | case 'group': |
| 1049 | $(listId).append(getGroupBlock(i.item)); |
| 1050 | displayCount++; |
| 1051 | break; |
| 1052 | case 'tag': |
| 1053 | $(listId).append(getTagBlock(i.item, i.entities, i.hidden, i.isUseless)); |
| 1054 | break; |
| 1055 | } |
| 1056 | } |
| 1057 | |
| 1058 | const hidden = (characters.length + groups.length) - displayCount; |
| 1059 | if (hidden > 0 && entitiesFilter.hasAnyFilter()) { |
| 1060 | const hiddenBlock = await getHiddenBlock(hidden); |
| 1061 | $(listId).append(hiddenBlock); |
| 1062 | } |
| 1063 | localizePagination($('#rm_print_characters_pagination')); |
| 1064 | |
| 1065 | eventSource.emit(event_types.CHARACTER_PAGE_LOADED); |
| 1066 | }, |
| 1067 | afterSizeSelectorChange: function (e, size) { |
| 1068 | accountStorage.setItem(storageKey, e.target.value); |
| 1069 | paginationDropdownChangeHandler(e, size); |
| 1070 | }, |
| 1071 | afterPaging: function (e) { |
| 1072 | saveCharactersPage = e; |
| 1073 | }, |
| 1074 | afterRender: function () { |
| 1075 | $(listId).scrollTop(currentScrollTop); |
| 1076 | }, |
| 1077 | }); |
| 1078 | |
| 1079 | favsToHotswap(); |
| 1080 | updatePersonaConnectionsAvatarList(); |
| 1081 | } |
| 1082 | |
| 1083 | /** Checks the state of the current search, and adds/removes the search sorting option accordingly */ |
| 1084 | function verifyCharactersSearchSortRule() { |
| 1085 | const searchTerm = entitiesFilter.getFilterData(FILTER_TYPES.SEARCH); |
| 1086 | const searchOption = $('#character_sort_order option[data-field="search"]'); |
| 1087 | const selector = $('#character_sort_order'); |
| 1088 | const isHidden = searchOption.attr('hidden') !== undefined; |
| 1089 | |
| 1090 | // If we have a search term, we are displaying the sorting option for it |
| 1091 | if (searchTerm && isHidden) { |
| 1092 | searchOption.removeAttr('hidden'); |
| 1093 | searchOption.prop('selected', true); |
| 1094 | flashHighlight(selector); |
| 1095 | } |
| 1096 | // If search got cleared, we make sure to hide the option and go back to the one before |
| 1097 | if (!searchTerm && !isHidden) { |
| 1098 | searchOption.attr('hidden', ''); |
| 1099 | $(`#character_sort_order option[data-order="${power_user.sort_order}"][data-field="${power_user.sort_field}"]`).prop('selected', true); |
| 1100 | } |
| 1101 | } |
| 1102 | |
| 1103 | /** |
| 1104 | * @typedef {object} Entity - Object representing a display entity |
| 1105 | * @property {Character|Group|import('./scripts/tags.js').Tag|*} item - The item |
| 1106 | * @property {string|number} id - The id |
| 1107 | * @property {'character'|'group'|'tag'} type - The type of this entity (character, group, tag) |
| 1108 | * @property {Entity[]?} [entities=null] - An optional list of entities relevant for this item |
| 1109 | * @property {number?} [hidden=null] - An optional number representing how many hidden entities this entity contains |
| 1110 | * @property {boolean?} [isUseless=null] - Specifies if the entity is useless (not relevant, but should still be displayed for consistency) and should be displayed greyed out |
| 1111 | */ |
| 1112 | |
| 1113 | /** |
| 1114 | * Converts the given character to its entity representation |
| 1115 | * |
| 1116 | * @param {Character} character - The character |
| 1117 | * @param {string|number} id - The id of this character |
| 1118 | * @returns {Entity} The entity for this character |
| 1119 | */ |
| 1120 | export function characterToEntity(character, id) { |
| 1121 | return { item: character, id, type: 'character' }; |
| 1122 | } |
| 1123 | |
| 1124 | /** |
| 1125 | * Converts the given group to its entity representation |
| 1126 | * |
| 1127 | * @param {Group} group - The group |
| 1128 | * @returns {Entity} The entity for this group |
| 1129 | */ |
| 1130 | export function groupToEntity(group) { |
| 1131 | return { item: group, id: group.id, type: 'group' }; |
| 1132 | } |
| 1133 | |
| 1134 | /** |
| 1135 | * Converts the given tag to its entity representation |
| 1136 | * |
| 1137 | * @param {import('./scripts/tags.js').Tag} tag - The tag |
| 1138 | * @returns {Entity} The entity for this tag |
| 1139 | */ |
| 1140 | export function tagToEntity(tag) { |
| 1141 | return { item: structuredClone(tag), id: tag.id, type: 'tag', entities: [] }; |
| 1142 | } |
| 1143 | |
| 1144 | /** |
| 1145 | * Builds the full list of all entities available |
| 1146 | * |
| 1147 | * They will be correctly marked and filtered. |
| 1148 | * |
| 1149 | * @param {object} param0 - Optional parameters |
| 1150 | * @param {boolean} [param0.doFilter] - Whether this entity list should already be filtered based on the global filters |
| 1151 | * @param {boolean} [param0.doSort] - Whether the entity list should be sorted when returned |
| 1152 | * @returns {Entity[]} All entities |
| 1153 | */ |
| 1154 | export function getEntitiesList({ doFilter = false, doSort = true } = {}) { |
| 1155 | let entities = [ |
| 1156 | ...characters.map((item, index) => characterToEntity(item, index)), |
| 1157 | ...groups.map(item => groupToEntity(item)), |
| 1158 | ...(power_user.bogus_folders ? tags.filter(isBogusFolder).sort(compareTagsForSort).map(item => tagToEntity(item)) : []), |
| 1159 | ]; |
| 1160 | |
| 1161 | // We need to do multiple filter runs in a specific order, otherwise different settings might override each other |
| 1162 | // and screw up tags and search filter, sub lists or similar. |
| 1163 | // The specific filters are written inside the "filterByTagState" method and its different parameters. |
| 1164 | // Generally what we do is the following: |
| 1165 | // 1. First swipe over the list to remove the most obvious things |
| 1166 | // 2. Build sub entity lists for all folders, filtering them similarly to the second swipe |
| 1167 | // 3. We do the last run, where global filters are applied, and the search filters last |
| 1168 | |
| 1169 | // First run filters, that will hide what should never be displayed |
| 1170 | if (doFilter) { |
| 1171 | entities = filterByTagState(entities); |
| 1172 | } |
| 1173 | |
| 1174 | // Run over all entities between first and second filter to save some states |
| 1175 | for (const entity of entities) { |
| 1176 | // For folders, we remember the sub entities so they can be displayed later, even if they might be filtered |
| 1177 | // Those sub entities should be filtered and have the search filters applied too |
| 1178 | if (entity.type === 'tag') { |
| 1179 | let subEntities = filterByTagState(entities, { subForEntity: entity, filterHidden: false }); |
| 1180 | const subCount = subEntities.length; |
| 1181 | subEntities = filterByTagState(entities, { subForEntity: entity }); |
| 1182 | if (doFilter) { |
| 1183 | // sub entities filter "hacked" because folder filter should not be applied there, so even in "only folders" mode characters show up |
| 1184 | subEntities = entitiesFilter.applyFilters(subEntities, { clearScoreCache: false, tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED }, clearFuzzySearchCaches: false }); |
| 1185 | } |
| 1186 | if (doSort) { |
| 1187 | sortEntitiesList(subEntities, false); |
| 1188 | } |
| 1189 | entity.entities = subEntities; |
| 1190 | entity.hidden = subCount - subEntities.length; |
| 1191 | } |
| 1192 | } |
| 1193 | |
| 1194 | // Second run filters, hiding whatever should be filtered later |
| 1195 | if (doFilter) { |
| 1196 | const beforeFinalEntities = filterByTagState(entities, { globalDisplayFilters: true }); |
| 1197 | entities = entitiesFilter.applyFilters(beforeFinalEntities, { clearFuzzySearchCaches: false }); |
| 1198 | |
| 1199 | // Magic for folder filter. If that one is enabled, and no folders are display anymore, we remove that filter to actually show the characters. |
| 1200 | if (isFilterState(entitiesFilter.getFilterData(FILTER_TYPES.FOLDER), FILTER_STATES.SELECTED) && entities.filter(x => x.type == 'tag').length == 0) { |
| 1201 | entities = entitiesFilter.applyFilters(beforeFinalEntities, { tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED }, clearFuzzySearchCaches: false }); |
| 1202 | } |
| 1203 | } |
| 1204 | |
| 1205 | // Final step, updating some properties after the last filter run |
| 1206 | const nonTagEntitiesCount = entities.filter(entity => entity.type !== 'tag').length; |
| 1207 | for (const entity of entities) { |
| 1208 | if (entity.type === 'tag') { |
| 1209 | if (entity.entities?.length == nonTagEntitiesCount) entity.isUseless = true; |
| 1210 | } |
| 1211 | } |
| 1212 | |
| 1213 | // Sort before returning if requested |
| 1214 | if (doSort) { |
| 1215 | sortEntitiesList(entities, false); |
| 1216 | } |
| 1217 | entitiesFilter.clearFuzzySearchCaches(); |
| 1218 | return entities; |
| 1219 | } |
| 1220 | |
| 1221 | export async function getOneCharacter(avatarUrl) { |
| 1222 | const response = await fetch('/api/characters/get', { |
| 1223 | method: 'POST', |
| 1224 | headers: getRequestHeaders(), |
| 1225 | body: JSON.stringify({ |
| 1226 | avatar_url: avatarUrl, |
| 1227 | }), |
| 1228 | }); |
| 1229 | |
| 1230 | if (response.ok) { |
| 1231 | const getData = await response.json(); |
| 1232 | getData.name = DOMPurify.sanitize(getData.name); |
| 1233 | getData.chat = String(getData.chat); |
| 1234 | |
| 1235 | const indexOf = characters.findIndex(x => x.avatar === avatarUrl); |
| 1236 | |
| 1237 | if (indexOf !== -1) { |
| 1238 | characters[indexOf] = getData; |
| 1239 | } else { |
| 1240 | toastr.error(t`Character ${avatarUrl} not found in the list`, t`Error`, { timeOut: 5000, preventDuplicates: true }); |
| 1241 | } |
| 1242 | } |
| 1243 | } |
| 1244 | |
| 1245 | export function getCharacterSource(chId = this_chid) { |
| 1246 | const character = characters[chId]; |
| 1247 | |
| 1248 | if (!character) { |
| 1249 | return ''; |
| 1250 | } |
| 1251 | |
| 1252 | const chubId = characters[chId]?.data?.extensions?.chub?.full_path; |
| 1253 | |
| 1254 | if (chubId) { |
| 1255 | return `https://chub.ai/characters/${chubId}`; |
| 1256 | } |
| 1257 | |
| 1258 | const pygmalionId = characters[chId]?.data?.extensions?.pygmalion_id; |
| 1259 | |
| 1260 | if (pygmalionId) { |
| 1261 | return `https://pygmalion.chat/${pygmalionId}`; |
| 1262 | } |
| 1263 | |
| 1264 | const githubRepo = characters[chId]?.data?.extensions?.github_repo; |
| 1265 | |
| 1266 | if (githubRepo) { |
| 1267 | return `https://github.com/${githubRepo}`; |
| 1268 | } |
| 1269 | |
| 1270 | const sourceUrl = characters[chId]?.data?.extensions?.source_url; |
| 1271 | |
| 1272 | if (sourceUrl) { |
| 1273 | return sourceUrl; |
| 1274 | } |
| 1275 | |
| 1276 | const risuId = characters[chId]?.data?.extensions?.risuai?.source; |
| 1277 | |
| 1278 | if (Array.isArray(risuId) && risuId.length && typeof risuId[0] === 'string' && risuId[0].startsWith('risurealm:')) { |
| 1279 | const realmId = risuId[0].split(':')[1]; |
| 1280 | return `https://realm.risuai.net/character/${realmId}`; |
| 1281 | } |
| 1282 | |
| 1283 | const perchanceSlug = characters[chId]?.data?.extensions?.perchance_data?.slug; |
| 1284 | |
| 1285 | if (perchanceSlug) { |
| 1286 | return `https://perchance.org/ai-character-chat?data=${perchanceSlug}`; |
| 1287 | } |
| 1288 | |
| 1289 | return ''; |
| 1290 | } |
| 1291 | |
| 1292 | export async function getCharacters() { |
| 1293 | const response = await fetch('/api/characters/all', { |
| 1294 | method: 'POST', |
| 1295 | headers: getRequestHeaders(), |
| 1296 | body: JSON.stringify({}), |
| 1297 | }); |
| 1298 | if (response.ok) { |
| 1299 | const previousAvatar = this_chid !== undefined ? characters[this_chid]?.avatar : null; |
| 1300 | characters.splice(0, characters.length); |
| 1301 | const getData = await response.json(); |
| 1302 | for (let i = 0; i < getData.length; i++) { |
| 1303 | characters[i] = getData[i]; |
| 1304 | characters[i].name = DOMPurify.sanitize(characters[i].name); |
| 1305 | |
| 1306 | // For dropped-in cards |
| 1307 | if (!characters[i].chat) { |
| 1308 | characters[i].chat = `${characters[i].name} - ${humanizedDateTime()}`; |
| 1309 | } |
| 1310 | |
| 1311 | characters[i].chat = String(characters[i].chat); |
| 1312 | } |
| 1313 | |
| 1314 | if (previousAvatar) { |
| 1315 | const newCharacterId = characters.findIndex(x => x.avatar === previousAvatar); |
| 1316 | if (newCharacterId >= 0) { |
| 1317 | setCharacterId(newCharacterId); |
| 1318 | await selectCharacterById(newCharacterId, { switchMenu: false }); |
| 1319 | } else { |
| 1320 | await Popup.show.text(t`ERROR: The active character is no longer available.`, t`The page will be refreshed to prevent data loss. Press "OK" to continue.`); |
| 1321 | return location.reload(); |
| 1322 | } |
| 1323 | } |
| 1324 | |
| 1325 | await getGroups(); |
| 1326 | await printCharacters(true); |
| 1327 | } else { |
| 1328 | console.error('Failed to fetch characters:', response.statusText); |
| 1329 | const errorData = await response.json(); |
| 1330 | if (errorData?.overflow) { |
| 1331 | await Popup.show.text(t`Character data length limit reached`, t`To resolve this, set "performance.lazyLoadCharacters" to "true" in config.yaml and restart the server.`); |
| 1332 | } |
| 1333 | } |
| 1334 | } |
| 1335 | |
| 1336 | async function delChat(chatfile) { |
| 1337 | const response = await fetch('/api/chats/delete', { |
| 1338 | method: 'POST', |
| 1339 | headers: getRequestHeaders(), |
| 1340 | body: JSON.stringify({ |
| 1341 | chatfile: chatfile, |
| 1342 | avatar_url: characters[this_chid].avatar, |
| 1343 | }), |
| 1344 | }); |
| 1345 | if (response.ok === true) { |
| 1346 | // choose another chat if current was deleted |
| 1347 | const name = chatfile.replace('.jsonl', ''); |
| 1348 | if (name === characters[this_chid].chat) { |
| 1349 | chat_metadata = {}; |
| 1350 | await replaceCurrentChat(); |
| 1351 | } |
| 1352 | await eventSource.emit(event_types.CHAT_DELETED, name); |
| 1353 | } |
| 1354 | } |
| 1355 | |
| 1356 | /** |
| 1357 | * Deletes a character chat by its name. |
| 1358 | * @param {string} characterId Character ID to delete chat for |
| 1359 | * @param {string} fileName Name of the chat file to delete (without .jsonl extension) |
| 1360 | * @returns {Promise<void>} A promise that resolves when the chat is deleted. |
| 1361 | */ |
| 1362 | export async function deleteCharacterChatByName(characterId, fileName) { |
| 1363 | // Make sure all the data is loaded. |
| 1364 | await unshallowCharacter(characterId); |
| 1365 | |
| 1366 | /** @type {Character} */ |
| 1367 | const character = characters[characterId]; |
| 1368 | if (!character) { |
| 1369 | console.warn(`Character with ID ${characterId} not found.`); |
| 1370 | return; |
| 1371 | } |
| 1372 | |
| 1373 | const response = await fetch('/api/chats/delete', { |
| 1374 | method: 'POST', |
| 1375 | headers: getRequestHeaders(), |
| 1376 | body: JSON.stringify({ |
| 1377 | chatfile: `${fileName}.jsonl`, |
| 1378 | avatar_url: character.avatar, |
| 1379 | }), |
| 1380 | }); |
| 1381 | |
| 1382 | if (!response.ok) { |
| 1383 | console.error('Failed to delete chat for character.'); |
| 1384 | return; |
| 1385 | } |
| 1386 | |
| 1387 | if (fileName === character.chat) { |
| 1388 | const chatsResponse = await fetch('/api/characters/chats', { |
| 1389 | method: 'POST', |
| 1390 | headers: getRequestHeaders(), |
| 1391 | body: JSON.stringify({ avatar_url: character.avatar }), |
| 1392 | }); |
| 1393 | const chats = Object.values(await chatsResponse.json()); |
| 1394 | chats.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes))); |
| 1395 | const newChatName = chats.length && typeof chats[0] === 'object' ? chats[0].file_name.replace('.jsonl', '') : `${character.name} - ${humanizedDateTime()}`; |
| 1396 | await updateRemoteChatName(characterId, newChatName); |
| 1397 | } |
| 1398 | |
| 1399 | await eventSource.emit(event_types.CHAT_DELETED, fileName); |
| 1400 | } |
| 1401 | |
| 1402 | export async function replaceCurrentChat() { |
| 1403 | await clearChat({ clearData: true }); |
| 1404 | |
| 1405 | const chatsResponse = await fetch('/api/characters/chats', { |
| 1406 | method: 'POST', |
| 1407 | headers: getRequestHeaders(), |
| 1408 | body: JSON.stringify({ avatar_url: characters[this_chid].avatar }), |
| 1409 | }); |
| 1410 | |
| 1411 | if (chatsResponse.ok) { |
| 1412 | const chats = Object.values(await chatsResponse.json()); |
| 1413 | chats.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes))); |
| 1414 | |
| 1415 | if (chats.length && typeof chats[0] === 'object') { |
| 1416 | // pick existing chat |
| 1417 | characters[this_chid].chat = chats[0].file_name.replace('.jsonl', ''); |
| 1418 | $('#selected_chat_pole').val(characters[this_chid].chat); |
| 1419 | saveCharacterDebounced(); |
| 1420 | await getChat(); |
| 1421 | } else { |
| 1422 | // start new chat |
| 1423 | characters[this_chid].chat = `${name2} - ${humanizedDateTime()}`; |
| 1424 | $('#selected_chat_pole').val(characters[this_chid].chat); |
| 1425 | saveCharacterDebounced(); |
| 1426 | await getChat(); |
| 1427 | } |
| 1428 | } |
| 1429 | } |
| 1430 | |
| 1431 | export async function showMoreMessages(messagesToLoad = null) { |
| 1432 | const firstDisplayedMesId = chatElement.children('.mes').first().attr('mesid'); |
| 1433 | let messageId = Number(firstDisplayedMesId); |
| 1434 | let count = messagesToLoad || power_user.chat_truncation || Number.MAX_SAFE_INTEGER; |
| 1435 | |
| 1436 | // If there are no messages displayed, or the message somehow has no mesid, we default to one higher than last message id, |
| 1437 | // so the first "new" message being shown will be the last available message |
| 1438 | if (isNaN(messageId)) { |
| 1439 | messageId = getLastMessageId() + 1; |
| 1440 | } |
| 1441 | |
| 1442 | console.debug('Inserting messages before', messageId, 'count', count, 'chat length', chat.length); |
| 1443 | const prevHeight = chatElement.prop('scrollHeight'); |
| 1444 | const showMoreButton = $('#show_more_messages'); |
| 1445 | const isButtonInView = isElementInViewport(showMoreButton[0]); |
| 1446 | |
| 1447 | const firstId = clamp(messageId - count, 0, Infinity); |
| 1448 | const messageElements = []; |
| 1449 | chat.slice(firstId, messageId).forEach((message, id) => { |
| 1450 | messageElements.push(updateMessageElement(message, { messageId: firstId + id })); |
| 1451 | }); |
| 1452 | // This could be faster: https://developer.mozilla.org/en-US/docs/Web/API/Element/insertAdjacentElement |
| 1453 | // Fallback to chatElement if the button isn't where it's expected to be. |
| 1454 | if (showMoreButton[0]) { |
| 1455 | showMoreButton.after(messageElements); |
| 1456 | } else { |
| 1457 | chatElement.prepend(messageElements); |
| 1458 | } |
| 1459 | |
| 1460 | refreshSwipeButtons(); |
| 1461 | |
| 1462 | if (firstId === 0) { |
| 1463 | showMoreButton.remove(); |
| 1464 | } |
| 1465 | |
| 1466 | if (isButtonInView) { |
| 1467 | const newHeight = chatElement.prop('scrollHeight'); |
| 1468 | chatElement.scrollTop(newHeight - prevHeight); |
| 1469 | } |
| 1470 | |
| 1471 | applyStylePins(); |
| 1472 | await eventSource.emit(event_types.MORE_MESSAGES_LOADED); |
| 1473 | } |
| 1474 | |
| 1475 | export async function printMessages() { |
| 1476 | let startIndex = 0; |
| 1477 | let count = power_user.chat_truncation || Number.MAX_SAFE_INTEGER; |
| 1478 | |
| 1479 | if (chat.length > count) { |
| 1480 | startIndex = chat.length - count; |
| 1481 | chatElement.append('<div id="show_more_messages">Show more messages</div>'); |
| 1482 | } |
| 1483 | |
| 1484 | await redisplayChat({ startIndex, fade: false }); |
| 1485 | |
| 1486 | scrollChatToBottom({ waitForFrame: true }); |
| 1487 | delay(debounce_timeout.short).then(() => scrollOnMediaLoad()); |
| 1488 | } |
| 1489 | |
| 1490 | /** |
| 1491 | * Visually updates all chat messages including and after index by removing them, then adding them. |
| 1492 | * @param {object} [options] Options |
| 1493 | * @param {ChatMessage[]} [options.targetChat=chat] All messages in chat before startIndex will remain unchanged. |
| 1494 | * @param {Number} [options.startIndex=0] Everything including and after startIndex will be replaced. |
| 1495 | * @param {Boolean} [options.fade=true] When false, the swipe chevrons will not fade in. |
| 1496 | */ |
| 1497 | export async function redisplayChat({ targetChat = chat, startIndex = 0, fade = true } = {}) { |
| 1498 | const messageElements = chatElement.find('.mes'); |
| 1499 | messageElements.removeClass('last_mes'); |
| 1500 | |
| 1501 | //Remove messages after index. |
| 1502 | messageElements.filter(`.mes[mesid="${startIndex}"]`).nextAll('.mes').addBack().remove(); |
| 1503 | |
| 1504 | const t1 = performance.now(); |
| 1505 | |
| 1506 | const messages = targetChat.slice(startIndex); |
| 1507 | |
| 1508 | if (messages.length > 0) { |
| 1509 | const newMessageElements = messages.map((message, offset) => { |
| 1510 | const i = startIndex + offset; |
| 1511 | const messageElement = updateMessageElement(message, { messageId: i }); |
| 1512 | |
| 1513 | return messageElement[0]; |
| 1514 | }); |
| 1515 | |
| 1516 | //The last_mes has been removed, add it to the new last message. |
| 1517 | newMessageElements.at(-1).classList.add('last_mes'); |
| 1518 | |
| 1519 | //Append to chat in one DOM update. |
| 1520 | chatElement.append(newMessageElements); |
| 1521 | |
| 1522 | applyCharacterTagsToMessageDivs({ mesIds: lodash.range(startIndex, targetChat.length, 1) }); |
| 1523 | } |
| 1524 | |
| 1525 | refreshSwipeButtons(false, fade); |
| 1526 | applyStylePins(); |
| 1527 | updateEditArrowClasses(); |
| 1528 | |
| 1529 | console.info(`Rendered ${targetChat.length - startIndex} messages in ${((performance.now() - t1) / 1000).toFixed(3)} seconds.`); |
| 1530 | } |
| 1531 | |
| 1532 | export function scrollOnMediaLoad() { |
| 1533 | const started = Date.now(); |
| 1534 | const media = chatElement.find('.mes_block img, .mes_block video, .mes_block audio').toArray(); |
| 1535 | let mediaLoaded = 0; |
| 1536 | |
| 1537 | for (const currentElement of media) { |
| 1538 | if (currentElement instanceof HTMLImageElement) { |
| 1539 | if (currentElement.complete) { |
| 1540 | incrementAndCheck(); |
| 1541 | } else { |
| 1542 | currentElement.addEventListener('load', incrementAndCheck); |
| 1543 | currentElement.addEventListener('error', incrementAndCheck); |
| 1544 | } |
| 1545 | } |
| 1546 | if (currentElement instanceof HTMLMediaElement) { |
| 1547 | if (currentElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { |
| 1548 | incrementAndCheck(); |
| 1549 | } else { |
| 1550 | currentElement.addEventListener('loadeddata', incrementAndCheck); |
| 1551 | currentElement.addEventListener('error', incrementAndCheck); |
| 1552 | } |
| 1553 | } |
| 1554 | } |
| 1555 | |
| 1556 | function incrementAndCheck() { |
| 1557 | const MAX_DELAY = 1000; // 1 second |
| 1558 | if ((Date.now() - started) > MAX_DELAY) { |
| 1559 | return; |
| 1560 | } |
| 1561 | mediaLoaded++; |
| 1562 | if (mediaLoaded === media.length) { |
| 1563 | scrollChatToBottom({ waitForFrame: true }); |
| 1564 | } |
| 1565 | } |
| 1566 | } |
| 1567 | |
| 1568 | /** |
| 1569 | * Cancels the debounced chat save if it is currently pending. |
| 1570 | */ |
| 1571 | export function cancelDebouncedChatSave() { |
| 1572 | if (chatSaveTimeout) { |
| 1573 | console.debug('Debounced chat save cancelled'); |
| 1574 | clearTimeout(chatSaveTimeout); |
| 1575 | chatSaveTimeout = null; |
| 1576 | } |
| 1577 | } |
| 1578 | |
| 1579 | /** |
| 1580 | * Visually removes all chat message elements. |
| 1581 | * @param {object} [options] Options |
| 1582 | * @param {boolean} [options.clearData=false] Optionally clear the chat array's contents. |
| 1583 | */ |
| 1584 | export async function clearChat({ clearData = false } = {}) { |
| 1585 | cancelDebouncedChatSave(); |
| 1586 | cancelDebouncedMetadataSave(); |
| 1587 | closeMessageEditor(); |
| 1588 | extension_prompts = {}; |
| 1589 | if (is_delete_mode) { |
| 1590 | $('#dialogue_del_mes_cancel').trigger('click'); |
| 1591 | } |
| 1592 | //This will also remove non '.mes' elements, e.g. '<div id="show_more_messages">Show more messages</div>'. |
| 1593 | chatElement.children().remove(); |
| 1594 | if ($('.zoomed_avatar[forChar]').length) { |
| 1595 | console.debug('saw avatars to remove'); |
| 1596 | $('.zoomed_avatar[forChar]').remove(); |
| 1597 | } else { console.debug('saw no avatars'); } |
| 1598 | |
| 1599 | await saveItemizedPrompts(getCurrentChatId()); |
| 1600 | itemizedPrompts.length = 0; |
| 1601 | |
| 1602 | if (clearData) chat.length = 0; |
| 1603 | } |
| 1604 | |
| 1605 | export async function deleteLastMessage() { |
| 1606 | deleteItemizedPromptForMessage(chat.length - 1); |
| 1607 | chat.length = chat.length - 1; |
| 1608 | chatElement.children('.mes').last().remove(); |
| 1609 | await eventSource.emit(event_types.MESSAGE_DELETED, chat.length); |
| 1610 | } |
| 1611 | |
| 1612 | /** |
| 1613 | * Deletes a message from the chat by its ID, optionally asking for confirmation. |
| 1614 | * @param {number} id The ID of the message to delete. |
| 1615 | * @param {number} [swipeDeletionIndex] Deletes the swipe with that index. |
| 1616 | * @param {boolean} [askConfirmation=false] Whether to ask for confirmation before deleting. |
| 1617 | */ |
| 1618 | export async function deleteMessage(id, swipeDeletionIndex = undefined, askConfirmation = false) { |
| 1619 | const canDeleteSwipe = swipeDeletionIndex !== undefined && swipeDeletionIndex !== null; |
| 1620 | if (canDeleteSwipe) { |
| 1621 | if (swipeDeletionIndex < 0) { |
| 1622 | throw new Error('Swipe index cannot be negative'); |
| 1623 | } |
| 1624 | if (!Array.isArray(chat[id].swipes)) { |
| 1625 | throw new Error('Message has no swipes to delete'); |
| 1626 | } |
| 1627 | if (chat[id].swipes.length <= swipeDeletionIndex) { |
| 1628 | throw new Error('Swipe index out of bounds'); |
| 1629 | } |
| 1630 | } |
| 1631 | |
| 1632 | const minId = getFirstDisplayedMessageId(); |
| 1633 | const messageElement = chatElement.find(`.mes[mesid="${id}"]`); |
| 1634 | if (messageElement.length === 0) { |
| 1635 | return; |
| 1636 | } |
| 1637 | |
| 1638 | let deleteOnlySwipe = canDeleteSwipe; |
| 1639 | if (askConfirmation) { |
| 1640 | const result = await callGenericPopup(t`Are you sure you want to delete this message?`, POPUP_TYPE.CONFIRM, null, { |
| 1641 | okButton: canDeleteSwipe ? t`Delete Swipe` : t`Delete Message`, |
| 1642 | cancelButton: 'Cancel', |
| 1643 | customButtons: canDeleteSwipe ? [t`Delete Message`] : null, |
| 1644 | }); |
| 1645 | if (!result) { |
| 1646 | return; |
| 1647 | } |
| 1648 | deleteOnlySwipe = canDeleteSwipe && result === POPUP_RESULT.AFFIRMATIVE; // Default button, not the custom one |
| 1649 | } |
| 1650 | |
| 1651 | if (deleteOnlySwipe) { |
| 1652 | await deleteSwipe(swipeDeletionIndex, id); |
| 1653 | return; |
| 1654 | } |
| 1655 | |
| 1656 | chat.splice(id, 1); |
| 1657 | messageElement.remove(); |
| 1658 | |
| 1659 | chat_metadata.tainted = true; |
| 1660 | |
| 1661 | const startIndex = [0, minId].includes(id) ? id : null; |
| 1662 | deleteItemizedPromptForMessage(id); |
| 1663 | updateViewMessageIds(startIndex); |
| 1664 | saveChatDebounced(); |
| 1665 | |
| 1666 | if (this_edit_mes_id === id) { |
| 1667 | this_edit_mes_id = undefined; |
| 1668 | } |
| 1669 | |
| 1670 | refreshSwipeButtons(); |
| 1671 | |
| 1672 | await eventSource.emit(event_types.MESSAGE_DELETED, chat.length); |
| 1673 | } |
| 1674 | |
| 1675 | export const reloadChatMutex = new SimpleMutex(reloadCurrentChatUnsafe); |
| 1676 | export const reloadCurrentChat = reloadChatMutex.update.bind(reloadChatMutex); |
| 1677 | |
| 1678 | /** |
| 1679 | * Reloads the current chat unsafely, without mutex protection. |
| 1680 | * Use `reloadCurrentChat` instead to ensure thread safety. |
| 1681 | * @returns {Promise<void>} A promise that resolves when the chat is reloaded. |
| 1682 | */ |
| 1683 | export async function reloadCurrentChatUnsafe() { |
| 1684 | preserveNeutralChat(); |
| 1685 | await clearChat({ clearData: true }); |
| 1686 | |
| 1687 | if (selected_group) { |
| 1688 | await getGroupChat(selected_group, true); |
| 1689 | } else if (this_chid !== undefined) { |
| 1690 | await getChat(); |
| 1691 | } else { |
| 1692 | resetChatState(); |
| 1693 | restoreNeutralChat(); |
| 1694 | await getCharacters(); |
| 1695 | await printMessages(); |
| 1696 | await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId()); |
| 1697 | } |
| 1698 | |
| 1699 | refreshSwipeButtons(); |
| 1700 | } |
| 1701 | |
| 1702 | /** |
| 1703 | * Send the message currently typed into the chat box. |
| 1704 | */ |
| 1705 | export async function sendTextareaMessage() { |
| 1706 | // don't proceed during swipeGenerate() |
| 1707 | if (swipeState == SWIPE_STATE.EDITING) { |
| 1708 | toastr.warning(t`Confirm the edit to start a generation.`, t`You cannot send a message during a swipe-edit.`); |
| 1709 | return; |
| 1710 | } |
| 1711 | if (swipeState !== SWIPE_STATE.NONE) return; // don't proceed if mid-swipe. |
| 1712 | if (is_send_press) return; |
| 1713 | if (isExecutingCommandsFromChatInput) return; |
| 1714 | |
| 1715 | hideSwipeButtons(); //Swipe buttons must be hidden now, otherwise concurrent generations are possible. |
| 1716 | |
| 1717 | let generateType = 'normal'; |
| 1718 | // "Continue on send" is activated when the user hits "send" (or presses enter) on an empty chat box, and the last |
| 1719 | // message was sent from a character (not the user or the system). |
| 1720 | const textareaText = String($('#send_textarea').val()); |
| 1721 | const lastMessage = chat[chat.length - 1]; |
| 1722 | if (power_user.continue_on_send && |
| 1723 | !hasPendingFileAttachment() && |
| 1724 | !textareaText && |
| 1725 | !selected_group && |
| 1726 | chat.length && |
| 1727 | !lastMessage.is_user && |
| 1728 | !lastMessage.is_system |
| 1729 | ) { |
| 1730 | generateType = 'continue'; |
| 1731 | } |
| 1732 | |
| 1733 | if (textareaText && !selected_group && this_chid === undefined && name2 !== neutralCharacterName) { |
| 1734 | await newAssistantChat({ temporary: false }); |
| 1735 | } |
| 1736 | |
| 1737 | let generation = await Generate(generateType); |
| 1738 | showSwipeButtons(); |
| 1739 | return generation; |
| 1740 | } |
| 1741 | |
| 1742 | /** |
| 1743 | * Formats the message text into an HTML string using Markdown and other formatting. |
| 1744 | * @param {string} mes Message text |
| 1745 | * @param {string} ch_name Character name |
| 1746 | * @param {boolean} isSystem If the message was sent by the system |
| 1747 | * @param {boolean} isUser If the message was sent by the user |
| 1748 | * @param {number} messageId Message index in chat array |
| 1749 | * @param {Partial<DOMPurify.Config>} [sanitizerOverrides] DOMPurify sanitizer option overrides |
| 1750 | * @param {boolean} [isReasoning] If the message is reasoning output |
| 1751 | * @returns {string} HTML string |
| 1752 | */ |
| 1753 | export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, sanitizerOverrides = {}, isReasoning = false) { |
| 1754 | if (!mes) { |
| 1755 | return ''; |
| 1756 | } |
| 1757 | |
| 1758 | if (Number(messageId) === 0 && !isSystem && !isUser && !isReasoning) { |
| 1759 | const mesBeforeReplace = mes; |
| 1760 | const chatMessage = chat[messageId]; |
| 1761 | mes = substituteParams(mes, undefined, ch_name); |
| 1762 | if (chatMessage && chatMessage.mes === mesBeforeReplace && chatMessage.extra?.display_text !== mesBeforeReplace) { |
| 1763 | chatMessage.mes = mes; |
| 1764 | } |
| 1765 | } |
| 1766 | |
| 1767 | mesForShowdownParse = mes; |
| 1768 | |
| 1769 | // Force isSystem = false on comment messages so they get formatted properly |
| 1770 | if (ch_name === COMMENT_NAME_DEFAULT && isSystem && !isUser) { |
| 1771 | isSystem = false; |
| 1772 | } |
| 1773 | |
| 1774 | // Let hidden messages have markdown |
| 1775 | if (isSystem && ch_name !== systemUserName) { |
| 1776 | isSystem = false; |
| 1777 | } |
| 1778 | |
| 1779 | // Prompt bias replacement should be applied on the raw message |
| 1780 | const replacedPromptBias = power_user.user_prompt_bias && substituteParams(power_user.user_prompt_bias); |
| 1781 | if (!power_user.show_user_prompt_bias && ch_name && !isUser && !isSystem && replacedPromptBias && mes.startsWith(replacedPromptBias)) { |
| 1782 | mes = mes.slice(replacedPromptBias.length); |
| 1783 | } |
| 1784 | |
| 1785 | if (!isSystem) { |
| 1786 | function getRegexPlacement() { |
| 1787 | try { |
| 1788 | if (isReasoning) { |
| 1789 | return regex_placement.REASONING; |
| 1790 | } |
| 1791 | if (isUser) { |
| 1792 | return regex_placement.USER_INPUT; |
| 1793 | } else if (chat[messageId]?.extra?.type === 'narrator') { |
| 1794 | return regex_placement.SLASH_COMMAND; |
| 1795 | } else { |
| 1796 | return regex_placement.AI_OUTPUT; |
| 1797 | } |
| 1798 | } catch { |
| 1799 | return regex_placement.AI_OUTPUT; |
| 1800 | } |
| 1801 | } |
| 1802 | |
| 1803 | const regexPlacement = getRegexPlacement(); |
| 1804 | const usableMessages = chat.map((x, index) => ({ message: x, index: index })).filter(x => !x.message.is_system); |
| 1805 | const indexOf = usableMessages.findIndex(x => x.index === Number(messageId)); |
| 1806 | const depth = messageId >= 0 && indexOf !== -1 ? (usableMessages.length - indexOf - 1) : undefined; |
| 1807 | |
| 1808 | // Always override the character name |
| 1809 | mes = getRegexedString(mes, regexPlacement, { |
| 1810 | characterOverride: ch_name, |
| 1811 | isMarkdown: true, |
| 1812 | depth: depth, |
| 1813 | }); |
| 1814 | } |
| 1815 | |
| 1816 | if (power_user.auto_fix_generated_markdown) { |
| 1817 | mes = fixMarkdown(mes, true); |
| 1818 | } |
| 1819 | |
| 1820 | if (!isSystem && power_user.encode_tags) { |
| 1821 | mes = canUseNegativeLookbehind() |
| 1822 | ? mes.replaceAll('<', '<').replace(new RegExp('(?<!^|\\n\\s*)>', 'g'), '>') |
| 1823 | : mes.replaceAll('<', '<').replaceAll('>', '>'); |
| 1824 | } |
| 1825 | |
| 1826 | // Make sure reasoning strings are always shown, even if they include "<" or ">" |
| 1827 | [power_user.reasoning.prefix, power_user.reasoning.suffix].forEach((reasoningString) => { |
| 1828 | if (!reasoningString || !reasoningString.trim().length) { |
| 1829 | return; |
| 1830 | } |
| 1831 | // Only replace the first occurrence of the reasoning string |
| 1832 | if (mes.includes(reasoningString)) { |
| 1833 | mes = mes.replace(reasoningString, escapeHtml(reasoningString)); |
| 1834 | } |
| 1835 | }); |
| 1836 | |
| 1837 | if (!isSystem) { |
| 1838 | // Save double quotes in tags as a special character to prevent them from being encoded |
| 1839 | if (!power_user.encode_tags) { |
| 1840 | mes = mes.replace(/<([^>]+)>/g, function (_, contents) { |
| 1841 | return '<' + contents.replace(/"/g, '\ufffe') + '>'; |
| 1842 | }); |
| 1843 | } |
| 1844 | |
| 1845 | mes = mes.replace( |
| 1846 | /<style>[\s\S]*?<\/style>|```[\s\S]*?```|~~~[\s\S]*?~~~|``[\s\S]*?``|`[\s\S]*?`|(".*?")|(\u201C.*?\u201D)|(\u00AB.*?\u00BB)|(\u300C.*?\u300D)|(\u300E.*?\u300F)|(\uFF02.*?\uFF02)/gim, |
| 1847 | function (match, p1, p2, p3, p4, p5, p6) { |
| 1848 | if (p1) { |
| 1849 | // English double quotes |
| 1850 | return `<q>"${p1.slice(1, -1)}"</q>`; |
| 1851 | } else if (p2) { |
| 1852 | // Curly double quotes “ ” |
| 1853 | return `<q>“${p2.slice(1, -1)}”</q>`; |
| 1854 | } else if (p3) { |
| 1855 | // Guillemets « » |
| 1856 | return `<q>«${p3.slice(1, -1)}»</q>`; |
| 1857 | } else if (p4) { |
| 1858 | // Corner brackets 「 」 |
| 1859 | return `<q>「${p4.slice(1, -1)}」</q>`; |
| 1860 | } else if (p5) { |
| 1861 | // White corner brackets 『 』 |
| 1862 | return `<q>『${p5.slice(1, -1)}』</q>`; |
| 1863 | } else if (p6) { |
| 1864 | // Fullwidth quotes " " |
| 1865 | return `<q>"${p6.slice(1, -1)}"</q>`; |
| 1866 | } else { |
| 1867 | // Return the original match if no quotes are found |
| 1868 | return match; |
| 1869 | } |
| 1870 | }, |
| 1871 | ); |
| 1872 | |
| 1873 | // Restore double quotes in tags |
| 1874 | if (!power_user.encode_tags) { |
| 1875 | mes = mes.replace(/\ufffe/g, '"'); |
| 1876 | } |
| 1877 | |
| 1878 | mes = mes.replaceAll('\\begin{align*}', '$$'); |
| 1879 | mes = mes.replaceAll('\\end{align*}', '$$'); |
| 1880 | mes = converter.makeHtml(mes); |
| 1881 | |
| 1882 | mes = mes.replace(/<code(.*)>[\s\S]*?<\/code>/g, function (match) { |
| 1883 | // Firefox creates extra newlines from <br>s in code blocks, so we replace them before converting newlines to <br>s. |
| 1884 | return match.replace(/\n/gm, '\u0000'); |
| 1885 | }); |
| 1886 | mes = mes.replace(/\u0000/g, '\n'); // Restore converted newlines |
| 1887 | mes = mes.trim(); |
| 1888 | |
| 1889 | mes = mes.replace(/<code(.*)>[\s\S]*?<\/code>/g, function (match) { |
| 1890 | return match.replace(/&/g, '&'); |
| 1891 | }); |
| 1892 | } |
| 1893 | |
| 1894 | if (!power_user.allow_name2_display && ch_name && !isUser && !isSystem) { |
| 1895 | mes = mes.replace(new RegExp(`(^|\n)${escapeRegex(ch_name)}:`, 'g'), '$1'); |
| 1896 | } |
| 1897 | |
| 1898 | /** @type {DOMPurify.Config} */ |
| 1899 | const config = { |
| 1900 | RETURN_DOM: false, |
| 1901 | RETURN_DOM_FRAGMENT: false, |
| 1902 | RETURN_TRUSTED_TYPE: false, |
| 1903 | MESSAGE_SANITIZE: true, |
| 1904 | ADD_TAGS: ['custom-style'], |
| 1905 | ...sanitizerOverrides, |
| 1906 | }; |
| 1907 | mes = encodeStyleTags(mes); |
| 1908 | mes = DOMPurify.sanitize(mes, config); |
| 1909 | mes = decodeStyleTags(mes, { prefix: '.mes_text ' }); |
| 1910 | |
| 1911 | return mes; |
| 1912 | } |
| 1913 | |
| 1914 | /** |
| 1915 | * Creates an Image element for the given API/model icon. |
| 1916 | * The image references the matching SVG file from `/img/` and includes a tooltip with API and model info. |
| 1917 | * The caller is responsible for appending the image to the DOM and optionally calling `SVGInject` on it. |
| 1918 | * |
| 1919 | * @param {string} apiName - API identifier matching an SVG file in /img/ (e.g. 'openai', 'openrouter', 'claude') |
| 1920 | * @param {string} [modelName=''] - Model name shown in the tooltip |
| 1921 | * @returns {HTMLImageElement} The image element (not yet in the DOM) |
| 1922 | */ |
| 1923 | export function createModelIcon(apiName, modelName = '') { |
| 1924 | const image = new Image(); |
| 1925 | image.classList.add('icon-svg'); |
| 1926 | image.src = `/img/${apiName}.svg`; |
| 1927 | image.title = modelName ? `${apiName} - ${modelName}` : apiName; |
| 1928 | return image; |
| 1929 | } |
| 1930 | |
| 1931 | /** |
| 1932 | * Inserts or replaces an SVG icon adjacent to the provided message's timestamp. |
| 1933 | * |
| 1934 | * @param {JQuery<HTMLElement>} mes - The message element containing the timestamp where the icon should be inserted or replaced. |
| 1935 | * @param {ChatMessageExtra} extra - Contains the API and model details. |
| 1936 | */ |
| 1937 | function insertSVGIcon(mes, extra) { |
| 1938 | const apiName = extra?.api || ''; |
| 1939 | |
| 1940 | if (!apiName) { |
| 1941 | return; |
| 1942 | } |
| 1943 | |
| 1944 | const insertOrReplaceSVG = (image, className, targetSelector, insertBefore) => { |
| 1945 | image.onload = async function () { |
| 1946 | let existingSVG = insertBefore ? mes.find(targetSelector).prev(`.${className}`) : mes.find(targetSelector).next(`.${className}`); |
| 1947 | if (existingSVG.length) { |
| 1948 | existingSVG.replaceWith(image); |
| 1949 | } else { |
| 1950 | if (insertBefore) mes.find(targetSelector).before(image); |
| 1951 | else mes.find(targetSelector).after(image); |
| 1952 | } |
| 1953 | await SVGInject(image); |
| 1954 | }; |
| 1955 | }; |
| 1956 | |
| 1957 | const insertIcon = (className, targetSelector, insertBefore) => { |
| 1958 | const image = createModelIcon(apiName, extra?.model); |
| 1959 | image.classList.add(className); |
| 1960 | insertOrReplaceSVG(image, className, targetSelector, insertBefore); |
| 1961 | }; |
| 1962 | |
| 1963 | insertIcon('timestamp-icon', '.timestamp'); |
| 1964 | insertIcon('thinking-icon', '.mes_reasoning_header_title', true); |
| 1965 | } |
| 1966 | |
| 1967 | /** |
| 1968 | * Re-renders a message block with updated content. |
| 1969 | * @param {number} messageId Message ID |
| 1970 | * @param {object} message Message object |
| 1971 | * @param {object} [options={}] Optional arguments |
| 1972 | * @param {boolean} [options.rerenderMessage=true] Whether to re-render the message content (inside <c>.mes_text</c>) |
| 1973 | */ |
| 1974 | export function updateMessageBlock(messageId, message, { rerenderMessage = true } = {}) { |
| 1975 | const messageElement = chatElement.find(`[mesid="${messageId}"]`); |
| 1976 | if (rerenderMessage) { |
| 1977 | const text = message?.extra?.display_text ?? message.mes; |
| 1978 | messageElement.find('.mes_text').html(messageFormatting(text, message.name, message.is_system, message.is_user, messageId, {}, false)); |
| 1979 | } |
| 1980 | |
| 1981 | updateReasoningUI(messageElement); |
| 1982 | |
| 1983 | addCopyToCodeBlocks(messageElement); |
| 1984 | appendMediaToMessage(message, messageElement); |
| 1985 | } |
| 1986 | |
| 1987 | /** |
| 1988 | * Ensures that the message media properties are arrays, adding getters/setters for single media items. |
| 1989 | * @param {ChatMessage} mes Message object |
| 1990 | */ |
| 1991 | export function ensureMessageMediaIsArray(mes) { |
| 1992 | /** |
| 1993 | * Determines if a property of an object is a plain property (not a getter/setter or non-enumerable). |
| 1994 | * @param {object} obj Object to check |
| 1995 | * @param {string} name Property name |
| 1996 | * @returns {boolean} True if the property is a plain property, false otherwise |
| 1997 | */ |
| 1998 | function isPlainObjectProperty(obj, name) { |
| 1999 | const hasProperty = Object.hasOwn(obj, name); |
| 2000 | if (hasProperty) { |
| 2001 | const descriptor = Object.getOwnPropertyDescriptor(obj, name); |
| 2002 | return descriptor && descriptor.enumerable && descriptor.configurable && descriptor.writable; |
| 2003 | } |
| 2004 | return false; |
| 2005 | } |
| 2006 | |
| 2007 | /** |
| 2008 | * Determines if a property of an object is a getter (not a plain property). |
| 2009 | * @param {object} obj Object to check |
| 2010 | * @param {string} name Property name |
| 2011 | * @returns {boolean} True if the property is a getter, false otherwise |
| 2012 | */ |
| 2013 | function isGetterObjectProperty(obj, name) { |
| 2014 | const hasProperty = Object.hasOwn(obj, name); |
| 2015 | if (hasProperty) { |
| 2016 | const descriptor = Object.getOwnPropertyDescriptor(obj, name); |
| 2017 | return descriptor && typeof descriptor.get === 'function'; |
| 2018 | } |
| 2019 | return false; |
| 2020 | } |
| 2021 | |
| 2022 | /** |
| 2023 | * Adds a plain property to an object that wraps around an array property. |
| 2024 | * @param {object} obj Object to add property to |
| 2025 | * @param {string} plainProperty Plain property name |
| 2026 | * @param {string} arrayProperty Array property to back the plain property |
| 2027 | * @param {(value: any) => boolean} [filterFn] Optional filter function to apply when getting/setting the plain property |
| 2028 | * @param {(value: any) => any} [mapFn] Optional map function to apply when getting/setting the plain property |
| 2029 | */ |
| 2030 | function addArrayAutoWrapper(obj, plainProperty, arrayProperty, filterFn = () => true, mapFn = (t) => t) { |
| 2031 | // If the plain property is already a getter, do nothing. |
| 2032 | const hasGetterProperty = isGetterObjectProperty(obj, plainProperty); |
| 2033 | if (hasGetterProperty) { |
| 2034 | return; |
| 2035 | } |
| 2036 | |
| 2037 | // Define the plain property as a getter/setter that wraps around the array property. |
| 2038 | Object.defineProperty(obj, plainProperty, { |
| 2039 | // Getting the plain property returns the first item in the array property, or undefined if the array is empty. |
| 2040 | get: function () { |
| 2041 | console.trace(`Attempting to GET an array-wrapped property '${plainProperty}'. Use the array property '${arrayProperty}' instead.`); |
| 2042 | const array = Array.isArray(this[arrayProperty]) ? this[arrayProperty].filter(filterFn).map(mapFn) : []; |
| 2043 | return array.length > 0 ? array[0] : void 0; |
| 2044 | }, |
| 2045 | // Setting the plain property is not supported, as it would be ambiguous. |
| 2046 | set: function () { |
| 2047 | console.trace(`Attempting to SET an array-wrapped property '${plainProperty}'. Use the array property '${arrayProperty}' instead.`); |
| 2048 | }, |
| 2049 | // Exclude the property from JSON serialization and from being listed in for...in loops. |
| 2050 | enumerable: false, |
| 2051 | // Make the property non-configurable to prevent deletion or redefinition. |
| 2052 | configurable: false, |
| 2053 | }); |
| 2054 | } |
| 2055 | |
| 2056 | /** |
| 2057 | * Migrates image swipes from a single image property to an array. |
| 2058 | * @param {ChatMessageExtra} obj |
| 2059 | */ |
| 2060 | function migrateMediaToArray(obj) { |
| 2061 | if (isPlainObjectProperty(obj, 'file')) { |
| 2062 | if (!Array.isArray(obj.files)) { |
| 2063 | obj.files = []; |
| 2064 | } |
| 2065 | const fileValue = obj.file; |
| 2066 | delete obj.file; |
| 2067 | if (fileValue) { |
| 2068 | obj.files.push(fileValue); |
| 2069 | } |
| 2070 | } |
| 2071 | |
| 2072 | if (Array.isArray(obj.image_swipes)) { |
| 2073 | if (!Array.isArray(obj.media)) { |
| 2074 | obj.media = []; |
| 2075 | } |
| 2076 | for (const swipe of obj.image_swipes) { |
| 2077 | if (swipe && typeof swipe === 'string') { |
| 2078 | obj.media_display = MEDIA_DISPLAY.GALLERY; |
| 2079 | obj.media.push({ type: MEDIA_TYPE.IMAGE, url: swipe }); |
| 2080 | } |
| 2081 | } |
| 2082 | delete obj.image_swipes; |
| 2083 | } |
| 2084 | |
| 2085 | if (isPlainObjectProperty(obj, 'image')) { |
| 2086 | if (!Array.isArray(obj.media)) { |
| 2087 | obj.media = []; |
| 2088 | } |
| 2089 | const imageValue = obj.image; |
| 2090 | delete obj.image; |
| 2091 | if (imageValue && typeof imageValue === 'string') { |
| 2092 | obj.media.push({ type: MEDIA_TYPE.IMAGE, url: imageValue }); |
| 2093 | } |
| 2094 | if (obj.media_display === MEDIA_DISPLAY.GALLERY) { |
| 2095 | const selectedIndex = obj.media.findIndex(t => t.url === imageValue); |
| 2096 | if (selectedIndex > -1) { |
| 2097 | obj.media_index = selectedIndex; |
| 2098 | } |
| 2099 | } |
| 2100 | obj.media = obj.media.filter((v, i, a) => i === a.findIndex(t => t.url === v.url)); |
| 2101 | } |
| 2102 | |
| 2103 | if (isPlainObjectProperty(obj, 'video')) { |
| 2104 | if (!Array.isArray(obj.media)) { |
| 2105 | obj.media = []; |
| 2106 | } |
| 2107 | const videoValue = obj.video; |
| 2108 | delete obj.video; |
| 2109 | if (videoValue && typeof videoValue === 'string') { |
| 2110 | obj.media.push({ type: MEDIA_TYPE.VIDEO, url: videoValue }); |
| 2111 | } |
| 2112 | } |
| 2113 | } |
| 2114 | |
| 2115 | if (!mes || !mes.extra || typeof mes.extra !== 'object') { |
| 2116 | return; |
| 2117 | } |
| 2118 | |
| 2119 | migrateMediaToArray(mes.extra); |
| 2120 | addArrayAutoWrapper(mes.extra, 'file', 'files'); |
| 2121 | addArrayAutoWrapper(mes.extra, 'image', 'media', (t) => t.type === MEDIA_TYPE.IMAGE, (t) => t.url); |
| 2122 | addArrayAutoWrapper(mes.extra, 'video', 'media', (t) => t.type === MEDIA_TYPE.VIDEO, (t) => t.url); |
| 2123 | } |
| 2124 | |
| 2125 | /** |
| 2126 | * Gets the media display setting for a message. |
| 2127 | * @param {ChatMessage} mes Message object |
| 2128 | * @returns {MEDIA_DISPLAY} Media display setting |
| 2129 | */ |
| 2130 | export function getMediaDisplay(mes) { |
| 2131 | const value = mes?.extra?.media_display || power_user.media_display || MEDIA_DISPLAY.LIST; |
| 2132 | return Object.values(MEDIA_DISPLAY).includes(value) ? value : MEDIA_DISPLAY.LIST; |
| 2133 | } |
| 2134 | |
| 2135 | /** |
| 2136 | * Gets the media index for a message. |
| 2137 | * @param {ChatMessage} mes Message object |
| 2138 | * @returns {number} Media index |
| 2139 | */ |
| 2140 | export function getMediaIndex(mes) { |
| 2141 | if (!Array.isArray(mes?.extra?.media)) { |
| 2142 | return 0; |
| 2143 | } |
| 2144 | const value = mes.extra?.media_index; |
| 2145 | if (isNaN(value) || value < 0 || value >= mes.extra.media.length) { |
| 2146 | return 0; |
| 2147 | } |
| 2148 | return value; |
| 2149 | } |
| 2150 | |
| 2151 | /** |
| 2152 | * Appends image or file to the message element. |
| 2153 | * @param {ChatMessage} mes Message object |
| 2154 | * @param {JQuery<HTMLElement>} messageElement Message element |
| 2155 | * @param {string} [scrollBehavior] Scroll behavior when adjusting scroll position |
| 2156 | */ |
| 2157 | export function appendMediaToMessage(mes, messageElement, scrollBehavior = SCROLL_BEHAVIOR.ADJUST) { |
| 2158 | ensureMessageMediaIsArray(mes); |
| 2159 | |
| 2160 | const fileWrapper = messageElement.find('.mes_file_wrapper'); |
| 2161 | const mediaWrapper = messageElement.find('.mes_media_wrapper'); |
| 2162 | |
| 2163 | const hasMedia = Array.isArray(mes?.extra?.media) && mes.extra.media.length > 0; |
| 2164 | const hasFiles = Array.isArray(mes?.extra?.files) && mes.extra.files.length > 0; |
| 2165 | const mediaDisplay = hasMedia ? getMediaDisplay(mes) : null; |
| 2166 | const hideMessageText = hasMedia && mes?.extra?.inline_image === false; |
| 2167 | |
| 2168 | const mediaBlocks = []; |
| 2169 | const mediaPromises = []; |
| 2170 | |
| 2171 | const chatHeight = (hasMedia || hasFiles) ? chatElement.prop('scrollHeight') : 0; |
| 2172 | const scrollPosition = (hasMedia || hasFiles) ? chatElement.scrollTop() : 0; |
| 2173 | const doAdjustScroll = () => { |
| 2174 | if (!hasMedia && !hasFiles) { |
| 2175 | return; |
| 2176 | } |
| 2177 | if (scrollBehavior === SCROLL_BEHAVIOR.NONE) { |
| 2178 | return; |
| 2179 | } |
| 2180 | if (scrollBehavior === SCROLL_BEHAVIOR.KEEP) { |
| 2181 | chatElement.scrollTop(scrollPosition); |
| 2182 | return; |
| 2183 | } |
| 2184 | const newChatHeight = chatElement.prop('scrollHeight'); |
| 2185 | const diff = newChatHeight - chatHeight; |
| 2186 | chatElement.scrollTop(scrollPosition + diff); |
| 2187 | }; |
| 2188 | |
| 2189 | // Set media display attribute |
| 2190 | messageElement.attr('data-media-display', mediaDisplay); |
| 2191 | // Toggle text visibility |
| 2192 | messageElement.find('.mes_text').toggleClass('inline_media', hideMessageText); |
| 2193 | |
| 2194 | /** |
| 2195 | * Appends a single image attachment to the message element. |
| 2196 | * @param {MediaAttachment} attachment Image attachment object |
| 2197 | * @param {number} index Index of the image attachment |
| 2198 | * @returns {JQuery<HTMLElement>} The appended image container element |
| 2199 | */ |
| 2200 | function appendImageAttachment(attachment, index) { |
| 2201 | const template = $('#message_image_template .mes_img_container').clone(); |
| 2202 | template.attr('data-index', index); |
| 2203 | |
| 2204 | const image = template.find('.mes_img'); |
| 2205 | image.attr('src', attachment.url); |
| 2206 | image.attr('title', attachment.title || mes.extra.title || ''); |
| 2207 | mediaPromises.push(new Promise((resolve) => { |
| 2208 | function onLoad() { |
| 2209 | image.removeAttr('alt'); |
| 2210 | image.removeClass('error'); |
| 2211 | resolve(); |
| 2212 | } |
| 2213 | function onError() { |
| 2214 | image.attr('alt', ''); |
| 2215 | image.addClass('error'); |
| 2216 | resolve(); |
| 2217 | } |
| 2218 | if (image.prop('complete')) { |
| 2219 | onLoad(); |
| 2220 | } else { |
| 2221 | image.off('load').on('load', onLoad); |
| 2222 | image.off('error').on('error', onError); |
| 2223 | } |
| 2224 | })); |
| 2225 | |
| 2226 | mediaBlocks.push(template); |
| 2227 | return template; |
| 2228 | } |
| 2229 | |
| 2230 | /** |
| 2231 | * Appends a single video attachment to the message element. |
| 2232 | * @param {MediaAttachment} attachment Video attachment object |
| 2233 | * @param {number} index Index of the video attachment |
| 2234 | * @returns {JQuery<HTMLElement>} The appended video container element |
| 2235 | */ |
| 2236 | function appendVideoAttachment(attachment, index) { |
| 2237 | const template = $('#message_video_template .mes_video_container').clone(); |
| 2238 | template.attr('data-index', index); |
| 2239 | |
| 2240 | const video = template.find('.mes_video'); |
| 2241 | video.attr('src', attachment.url); |
| 2242 | video.attr('title', attachment.title || mes.extra.title || ''); |
| 2243 | mediaPromises.push(new Promise((resolve) => { |
| 2244 | function onLoad() { |
| 2245 | resolve(); |
| 2246 | } |
| 2247 | function onError() { |
| 2248 | video.addClass('error'); |
| 2249 | resolve(); |
| 2250 | } |
| 2251 | if (video.prop('readyState') >= HTMLMediaElement.HAVE_CURRENT_DATA) { |
| 2252 | onLoad(); |
| 2253 | } else { |
| 2254 | video.off('loadeddata').on('loadeddata', onLoad); |
| 2255 | video.off('error').on('error', onError); |
| 2256 | } |
| 2257 | })); |
| 2258 | |
| 2259 | mediaBlocks.push(template); |
| 2260 | return template; |
| 2261 | } |
| 2262 | |
| 2263 | /** |
| 2264 | * Appends a single audio attachment to the message element. |
| 2265 | * @param {MediaAttachment} attachment Audio attachment object |
| 2266 | * @param {number} index Index of the audio attachment |
| 2267 | * @returns {JQuery<HTMLElement>} The appended audio container element |
| 2268 | */ |
| 2269 | function appendAudioAttachment(attachment, index) { |
| 2270 | const template = $('#message_audio_template .mes_audio_container').clone(); |
| 2271 | template.attr('data-index', index); |
| 2272 | const audio = template.find('.mes_audio'); |
| 2273 | audio.attr('src', attachment.url); |
| 2274 | audio.attr('title', attachment.title || mes.extra.title || ''); |
| 2275 | |
| 2276 | mediaPromises.push(new Promise((resolve) => { |
| 2277 | function onLoad() { |
| 2278 | resolve(); |
| 2279 | } |
| 2280 | function onError() { |
| 2281 | audio.addClass('error'); |
| 2282 | resolve(); |
| 2283 | } |
| 2284 | if (audio.prop('readyState') >= HTMLMediaElement.HAVE_CURRENT_DATA) { |
| 2285 | onLoad(); |
| 2286 | } else { |
| 2287 | audio.off('loadeddata').on('loadeddata', onLoad); |
| 2288 | audio.off('error').on('error', onError); |
| 2289 | } |
| 2290 | })); |
| 2291 | |
| 2292 | new AudioPlayer(audio.get(0), template.get(0)); |
| 2293 | |
| 2294 | mediaBlocks.push(template); |
| 2295 | return template; |
| 2296 | } |
| 2297 | |
| 2298 | /** |
| 2299 | * Appends a media attachment to the message element. |
| 2300 | * @param {MediaAttachment} attachment Media attachment object |
| 2301 | * @param {number} index Index of the media attachment |
| 2302 | * @returns {JQuery<HTMLElement>} The appended media container element |
| 2303 | */ |
| 2304 | function appendMediaAttachment(attachment, index) { |
| 2305 | if (!attachment.type) { |
| 2306 | attachment.type = MEDIA_TYPE.IMAGE; |
| 2307 | } |
| 2308 | switch (attachment.type) { |
| 2309 | case MEDIA_TYPE.IMAGE: |
| 2310 | return appendImageAttachment(attachment, index); |
| 2311 | case MEDIA_TYPE.VIDEO: |
| 2312 | return appendVideoAttachment(attachment, index); |
| 2313 | case MEDIA_TYPE.AUDIO: |
| 2314 | return appendAudioAttachment(attachment, index); |
| 2315 | } |
| 2316 | |
| 2317 | console.warn(`Unknown media type: ${attachment.type}, defaulting to image.`, attachment); |
| 2318 | return appendImageAttachment(attachment, index); |
| 2319 | } |
| 2320 | |
| 2321 | /** |
| 2322 | * Saves the current playback times of media elements in the message. |
| 2323 | * @returns {Map<string, MediaState>} Media playback times by source URL |
| 2324 | */ |
| 2325 | function saveMediaStates() { |
| 2326 | const states = new Map(); |
| 2327 | const media = mediaWrapper.find('video, audio'); |
| 2328 | media.each((_, element) => { |
| 2329 | if (element instanceof HTMLMediaElement) { |
| 2330 | if (!element.currentSrc || element.readyState === HTMLMediaElement.HAVE_NOTHING) { |
| 2331 | return; |
| 2332 | } |
| 2333 | const state = { currentTime: element.currentTime, paused: element.paused }; |
| 2334 | states.set(element.currentSrc, state); |
| 2335 | } |
| 2336 | }); |
| 2337 | return states; |
| 2338 | } |
| 2339 | |
| 2340 | /** |
| 2341 | * Restores the playback times of media elements in the message. |
| 2342 | * @param {Map<string, MediaState>} states Media playback times by source URL |
| 2343 | */ |
| 2344 | function restoreMediaStates(states) { |
| 2345 | const media = mediaWrapper.find('video, audio'); |
| 2346 | media.each((_, element) => { |
| 2347 | if (element instanceof HTMLMediaElement) { |
| 2348 | const restoreState = () => { |
| 2349 | if (!states.has(element.currentSrc)) { |
| 2350 | return; |
| 2351 | } |
| 2352 | const state = states.get(element.currentSrc); |
| 2353 | element.currentTime = state.currentTime; |
| 2354 | if (!state.paused) { |
| 2355 | element.play(); |
| 2356 | } |
| 2357 | }; |
| 2358 | if (element.readyState < HTMLMediaElement.HAVE_METADATA) { |
| 2359 | element.addEventListener('loadedmetadata', () => restoreState(), { once: true }); |
| 2360 | } else { |
| 2361 | restoreState(); |
| 2362 | } |
| 2363 | } |
| 2364 | }); |
| 2365 | } |
| 2366 | |
| 2367 | // Add media gallery to message |
| 2368 | if (hasMedia && mediaDisplay === MEDIA_DISPLAY.GALLERY) { |
| 2369 | const mediaIndex = getMediaIndex(mes); |
| 2370 | const selectedMedia = mes.extra.media[mediaIndex]; |
| 2371 | |
| 2372 | const galleryControls = $('#message_gallery_controls .mes_img_swipes').clone(); |
| 2373 | const counter = galleryControls.find('.mes_img_swipe_counter'); |
| 2374 | counter.text(`${mediaIndex + 1}/${mes.extra.media.length}`); |
| 2375 | |
| 2376 | const template = appendMediaAttachment(selectedMedia, mediaIndex); |
| 2377 | template.addClass('img_swipes'); |
| 2378 | template.append(galleryControls); |
| 2379 | } |
| 2380 | |
| 2381 | // Add media as a list to message |
| 2382 | if (hasMedia && mediaDisplay === MEDIA_DISPLAY.LIST) { |
| 2383 | for (let index = 0; index < mes.extra.media.length; index++) { |
| 2384 | const attachment = mes.extra.media[index]; |
| 2385 | appendMediaAttachment(attachment, index); |
| 2386 | } |
| 2387 | } |
| 2388 | |
| 2389 | // Remove existing file containers |
| 2390 | fileWrapper.empty(); |
| 2391 | |
| 2392 | // Add files to message |
| 2393 | if (hasFiles) { |
| 2394 | for (let index = 0; index < mes.extra.files.length; index++) { |
| 2395 | const file = mes.extra.files[index]; |
| 2396 | const template = $('#message_file_template .mes_file_container').clone(); |
| 2397 | template.attr('data-index', index); |
| 2398 | template.find('.mes_file_name').text(file.name).attr('title', file.name); |
| 2399 | template.find('.mes_file_size').text(humanFileSize(file.size)).attr('title', file.size); |
| 2400 | fileWrapper.append(template); |
| 2401 | } |
| 2402 | } |
| 2403 | |
| 2404 | // Early return if no media |
| 2405 | if (!hasMedia) { |
| 2406 | mediaWrapper.empty(); |
| 2407 | doAdjustScroll(); |
| 2408 | return; |
| 2409 | } |
| 2410 | |
| 2411 | // TODO: Consider making this awaitable |
| 2412 | Promise.race([Promise.all(mediaPromises), delay(debounce_timeout.short)]).then(() => { |
| 2413 | const states = saveMediaStates(); |
| 2414 | mediaWrapper.empty().append(mediaBlocks); |
| 2415 | restoreMediaStates(states); |
| 2416 | doAdjustScroll(); |
| 2417 | }); |
| 2418 | } |
| 2419 | |
| 2420 | export function addCopyToCodeBlocks(messageElement) { |
| 2421 | const codeBlocks = $(messageElement).find('pre code'); |
| 2422 | for (let i = 0; i < codeBlocks.length; i++) { |
| 2423 | hljs.highlightElement(codeBlocks.get(i)); |
| 2424 | const copyButton = document.createElement('i'); |
| 2425 | copyButton.classList.add('fa-solid', 'fa-copy', 'code-copy', 'interactable'); |
| 2426 | copyButton.title = 'Copy code'; |
| 2427 | codeBlocks.get(i).appendChild(copyButton); |
| 2428 | copyButton.addEventListener('click', function (e) { |
| 2429 | e.stopPropagation(); |
| 2430 | }); |
| 2431 | copyButton.addEventListener('pointerup', async function () { |
| 2432 | const text = codeBlocks.get(i).textContent; |
| 2433 | await copyText(text); |
| 2434 | toastr.info(t`Copied!`, '', { timeOut: 2000 }); |
| 2435 | }); |
| 2436 | } |
| 2437 | } |
| 2438 | |
| 2439 | /** |
| 2440 | * Shows or hides the Prompt display button |
| 2441 | * @param {ChatMessage} message Message object |
| 2442 | * @param {object} options Options |
| 2443 | * @param {number} [options.messageId] Message ID |
| 2444 | * @param {JQuery<HTMLElement>} [options.messageElement] Message element |
| 2445 | * @return {void} |
| 2446 | */ |
| 2447 | function updateMessageItemizedPromptButton(message, { messageId = chat.indexOf(message), messageElement = chatElement.find(`.mes[mesid="${messageId}"]`) }) { |
| 2448 | //if we have itemized messages, and the array isn't null.. |
| 2449 | if (!message.is_user && Array.isArray(itemizedPrompts) && itemizedPrompts.length > 0) { |
| 2450 | const itemizedPrompt = itemizedPrompts.find(x => Number(x.mesId) === Number(messageId)); |
| 2451 | if (itemizedPrompt) { |
| 2452 | messageElement.find('.mes_prompt').show(); |
| 2453 | } |
| 2454 | } |
| 2455 | } |
| 2456 | |
| 2457 | /** |
| 2458 | * Gets messageFormatting for a ChatMessage object. |
| 2459 | * @param {ChatMessage} message |
| 2460 | * @param {object} options Options |
| 2461 | * @param {number} [options.messageId] Message ID |
| 2462 | * @returns {string} Formatted message HTML |
| 2463 | */ |
| 2464 | function getMessageTextHTML(message, { messageId = chat.indexOf(message) }) { |
| 2465 | // if mes.extra.uses_system_ui is true, set an override on the sanitizer options |
| 2466 | /** @type {Partial<DOMPurify.Config>} */ |
| 2467 | const sanitizerOverrides = message.extra?.uses_system_ui ? { MESSAGE_ALLOW_SYSTEM_UI: true } : {}; |
| 2468 | |
| 2469 | return messageFormatting( |
| 2470 | message.extra?.display_text || message.mes, |
| 2471 | message.name, |
| 2472 | message.is_system, |
| 2473 | message.is_user, |
| 2474 | messageId, |
| 2475 | sanitizerOverrides, |
| 2476 | false, |
| 2477 | ); |
| 2478 | } |
| 2479 | |
| 2480 | /** |
| 2481 | * Adds a single message to the chat. |
| 2482 | * @param {ChatMessage} mes Message object |
| 2483 | * @param {object} [options] Options |
| 2484 | * @param {string} [options.type=undefined|'swipe'] Deprecated. Use updateMessageElement instead. |
| 2485 | * @param {number} [options.insertAfter=null] Message ID to insert the new message after |
| 2486 | * @param {boolean} [options.scroll=true] Whether to scroll to the new message |
| 2487 | * @param {number} [options.insertBefore=null] Message ID to insert the new message before |
| 2488 | * @param {number} [options.forceId=null] Force the message ID |
| 2489 | * @param {boolean} [options.showSwipes=true] Whether to refresh the swipe buttons. |
| 2490 | * @returns {JQuery<HTMLElement>} The newly added message element |
| 2491 | */ |
| 2492 | export function addOneMessage(mes, { type = undefined, insertAfter = null, scroll = true, insertBefore = null, forceId = null, showSwipes = true } = {}) { |
| 2493 | // Callers push the new message to chat before calling addOneMessage |
| 2494 | const messageId = (() => { |
| 2495 | if (typeof forceId === 'number') { |
| 2496 | return forceId; |
| 2497 | } |
| 2498 | if (typeof insertBefore === 'number') { |
| 2499 | return insertBefore - 1; |
| 2500 | } |
| 2501 | if (typeof insertAfter === 'number') { |
| 2502 | return insertAfter + 1; |
| 2503 | } |
| 2504 | const index = chat.indexOf(mes); |
| 2505 | if (index !== -1) { |
| 2506 | return index; |
| 2507 | } |
| 2508 | return chat.length - 1; |
| 2509 | })(); |
| 2510 | |
| 2511 | let messageElement; |
| 2512 | |
| 2513 | if (type === 'swipe') { |
| 2514 | // Forbidden black magic |
| 2515 | // This allows to use "continue" on user messages |
| 2516 | mes.swipe_id ??= 0; |
| 2517 | mes.swipes ??= [mes.mes]; |
| 2518 | //This keeps listeners intact. |
| 2519 | messageElement = chatElement.find(`[mesid="${messageId}"]`); |
| 2520 | updateMessageElement(mes, { messageId, messageElement, adjustMediaScroll: scroll ? SCROLL_BEHAVIOR.ADJUST : SCROLL_BEHAVIOR.NONE }); |
| 2521 | } else { |
| 2522 | messageElement = updateMessageElement(mes, { messageId, adjustMediaScroll: scroll ? SCROLL_BEHAVIOR.ADJUST : SCROLL_BEHAVIOR.NONE }); |
| 2523 | if (typeof insertAfter === 'number' && insertAfter >= 0) { |
| 2524 | const target = chatElement.find(`.mes[mesid="${insertAfter}"]`); |
| 2525 | $(messageElement).insertAfter(target); |
| 2526 | } else if (typeof insertBefore === 'number' && insertBefore >= 0) { |
| 2527 | const target = chatElement.find(`.mes[mesid="${insertBefore}"]`); |
| 2528 | $(messageElement).insertBefore(target); |
| 2529 | } else { |
| 2530 | chatElement.append(messageElement); |
| 2531 | } |
| 2532 | } |
| 2533 | |
| 2534 | |
| 2535 | //last_mes should always be updated. |
| 2536 | chatElement.find('.mes').removeClass('last_mes'); |
| 2537 | chatElement.find('.mes').last().addClass('last_mes'); |
| 2538 | |
| 2539 | if (showSwipes) refreshSwipeButtons(); |
| 2540 | // Don't scroll if not inserting last |
| 2541 | if (!insertAfter && !insertBefore && scroll) { |
| 2542 | scrollChatToBottom({ waitForFrame: true }); |
| 2543 | } |
| 2544 | |
| 2545 | applyCharacterTagsToMessageDivs({ mesIds: messageId }); |
| 2546 | updateEditArrowClasses(); |
| 2547 | return messageElement; |
| 2548 | } |
| 2549 | |
| 2550 | /** |
| 2551 | * Creates the element of a single message as if it were the last message or at forceMesId |
| 2552 | * @param {ChatMessage} mes Message object |
| 2553 | * @param {object} [options] Options |
| 2554 | * @param {number} [options.messageId=chat.length - 1] Force the message ID |
| 2555 | * @param {JQuery<HTMLElement>} [options.messageElement=messageTemplate.clone()] This message element will be updated with the ChatMessage object. |
| 2556 | * @param {SCROLL_BEHAVIOR} [options.adjustMediaScroll=SCROLL_BEHAVIOR.NONE] Scroll behavior option passed to appendMediaToMessage. |
| 2557 | * @returns {JQuery<HTMLElement>} Rendered HTMLElement. |
| 2558 | */ |
| 2559 | export function updateMessageElement(mes, { messageId = chat.length - 1, messageElement = messageTemplate.clone(), adjustMediaScroll = SCROLL_BEHAVIOR.NONE } = {}) { |
| 2560 | let avatarImg = getThumbnailUrl('persona', user_avatar); |
| 2561 | |
| 2562 | //for non-user messages |
| 2563 | if (!mes.is_user) { |
| 2564 | if (mes.force_avatar) { |
| 2565 | avatarImg = mes.force_avatar; |
| 2566 | } else if (this_chid === undefined) { |
| 2567 | avatarImg = system_avatar; |
| 2568 | } else if (characters[this_chid] && characters[this_chid].avatar !== 'none') { |
| 2569 | avatarImg = getThumbnailUrl('avatar', characters[this_chid].avatar); |
| 2570 | } else { |
| 2571 | avatarImg = default_avatar; |
| 2572 | } |
| 2573 | //old processing: |
| 2574 | //if message is from system, use the name provided in the message JSONL to proceed, |
| 2575 | //if not system message, use name2 (char's name) to proceed |
| 2576 | //characterName = mes.is_system || mes.force_avatar ? mes.name : name2; |
| 2577 | } else if (mes.is_user && mes.force_avatar) { |
| 2578 | // Special case for persona images. |
| 2579 | avatarImg = mes.force_avatar; |
| 2580 | } |
| 2581 | const momentDate = timestampToMoment(mes.send_date); |
| 2582 | const timestamp = momentDate.isValid() ? momentDate.format('LL LT') : ''; |
| 2583 | const messageHTML = getMessageTextHTML(mes, { messageId }); |
| 2584 | const bookmarkLink = mes?.extra?.bookmark_link; |
| 2585 | const tokenCount = mes.extra?.token_count; |
| 2586 | const { timerValue, timerTitle } = formatGenerationTimer(mes.gen_started, mes.gen_finished, mes.extra?.token_count, mes.extra?.reasoning_duration, mes.extra?.time_to_first_token); |
| 2587 | |
| 2588 | messageElement.attr({ |
| 2589 | 'mesid': messageId, |
| 2590 | 'swipeid': mes.swipe_id ?? 0, |
| 2591 | 'ch_name': mes.name, |
| 2592 | 'is_user': mes.is_user, |
| 2593 | 'is_system': !!mes.is_system, |
| 2594 | 'bookmark_link': bookmarkLink, |
| 2595 | 'force_avatar': !!mes.force_avatar, |
| 2596 | 'timestamp': timestamp, |
| 2597 | // ...(type ?? { type }), |
| 2598 | 'type': mes.extra?.type ?? '', |
| 2599 | }); |
| 2600 | |
| 2601 | messageElement.find('.avatar img').attr('src', avatarImg); |
| 2602 | messageElement.find('.ch_name .name_text').text(mes.name); |
| 2603 | messageElement.find('.timestamp').text(timestamp).attr('title', `${mes.extra?.api ? mes.extra.api + ' - ' : ''}${mes.extra?.model ?? ''}`); |
| 2604 | messageElement.find('.mesIDDisplay').text(`#${messageId}`); |
| 2605 | tokenCount && messageElement.find('.tokenCounterDisplay').text(`${tokenCount}t`); |
| 2606 | mes.title && messageElement.attr('title', mes.title); |
| 2607 | timerValue && messageElement.find('.mes_timer').attr('title', timerTitle).text(timerValue); |
| 2608 | bookmarkLink && updateBookmarkDisplay(messageElement); |
| 2609 | |
| 2610 | if (mes.extra?.bias !== '') { |
| 2611 | const bias = messageFormatting(mes.extra?.bias, '', false, false, -1, {}, false); |
| 2612 | messageElement.find('.mes_bias').html(bias); |
| 2613 | } |
| 2614 | |
| 2615 | updateReasoningUI(messageElement); |
| 2616 | |
| 2617 | if (power_user.timestamp_model_icon && mes.extra?.api) { |
| 2618 | insertSVGIcon(messageElement, mes.extra); |
| 2619 | } |
| 2620 | |
| 2621 | if (mes?.extra?.isSmallSys === true) { |
| 2622 | messageElement.addClass('smallSysMes'); |
| 2623 | } |
| 2624 | |
| 2625 | if (Array.isArray(mes?.extra?.tool_invocations)) { |
| 2626 | messageElement.addClass('toolCall'); |
| 2627 | } |
| 2628 | |
| 2629 | updateMessageItemizedPromptButton(mes, { messageId, messageElement }); |
| 2630 | |
| 2631 | messageElement.find('.avatar img').on('error', function () { |
| 2632 | $(this).hide(); |
| 2633 | $(this).parent().html('<div class="missing-avatar fa-solid fa-user-slash"></div>'); |
| 2634 | }); |
| 2635 | |
| 2636 | appendMediaToMessage(mes, messageElement, adjustMediaScroll); |
| 2637 | messageElement.find('.mes_text').html(messageHTML); |
| 2638 | addCopyToCodeBlocks(messageElement); |
| 2639 | |
| 2640 | // Set the swipes counter for all non-user messages. |
| 2641 | if (!mes.is_user) { |
| 2642 | updateSwipeCounter(messageId, { message: mes, messageElement }); |
| 2643 | } |
| 2644 | |
| 2645 | return messageElement; |
| 2646 | } |
| 2647 | |
| 2648 | /** |
| 2649 | * Returns the URL of the avatar for the given character Id. |
| 2650 | * @param {number|string} characterId Character Id |
| 2651 | * @returns {string} Avatar URL |
| 2652 | */ |
| 2653 | export function getCharacterAvatar(characterId) { |
| 2654 | const character = characters[characterId]; |
| 2655 | const avatarImg = character?.avatar; |
| 2656 | |
| 2657 | if (!avatarImg || avatarImg === 'none') { |
| 2658 | return default_avatar; |
| 2659 | } |
| 2660 | |
| 2661 | return formatCharacterAvatar(avatarImg); |
| 2662 | } |
| 2663 | |
| 2664 | export function formatCharacterAvatar(characterAvatar) { |
| 2665 | return `characters/${characterAvatar}`; |
| 2666 | } |
| 2667 | |
| 2668 | /** |
| 2669 | * Formats the title for the generation timer. |
| 2670 | * @param {MessageTimestamp} gen_started Date when generation was started |
| 2671 | * @param {MessageTimestamp} gen_finished Date when generation was finished |
| 2672 | * @param {number} tokenCount Number of tokens generated (0 if not available) |
| 2673 | * @param {number?} [reasoningDuration=null] Reasoning duration (null if no reasoning was done) |
| 2674 | * @param {number?} [timeToFirstToken=null] Time to first token |
| 2675 | * @returns {Object} Object containing the formatted timer value and title |
| 2676 | * @example |
| 2677 | * const { timerValue, timerTitle } = formatGenerationTimer(gen_started, gen_finished, tokenCount); |
| 2678 | * console.log(timerValue); // 1.2s |
| 2679 | * console.log(timerTitle); // Generation queued: 12:34:56 7 Jan 2021\nReply received: 12:34:57 7 Jan 2021\nTime to generate: 1.2 seconds\nToken rate: 5 t/s |
| 2680 | */ |
| 2681 | function formatGenerationTimer(gen_started, gen_finished, tokenCount, reasoningDuration = null, timeToFirstToken = null) { |
| 2682 | if (!gen_started || !gen_finished) { |
| 2683 | return {}; |
| 2684 | } |
| 2685 | |
| 2686 | const dateFormat = 'HH:mm:ss D MMM YYYY'; |
| 2687 | const start = moment(gen_started); |
| 2688 | const finish = moment(gen_finished); |
| 2689 | const seconds = finish.diff(start, 'seconds', true); |
| 2690 | const timerValue = `${seconds.toFixed(1)}s`; |
| 2691 | const timerTitle = [ |
| 2692 | `Generation queued: ${start.format(dateFormat)}`, |
| 2693 | `Reply received: ${finish.format(dateFormat)}`, |
| 2694 | `Time to generate: ${seconds} seconds`, |
| 2695 | timeToFirstToken ? `Time to first token: ${timeToFirstToken / 1000} seconds` : '', |
| 2696 | reasoningDuration > 0 ? `Time to think: ${reasoningDuration / 1000} seconds` : '', |
| 2697 | tokenCount > 0 ? `Token rate: ${Number(tokenCount / seconds).toFixed(3)} t/s` : '', |
| 2698 | ].filter(x => x).join('\n').trim(); |
| 2699 | |
| 2700 | if (isNaN(seconds) || seconds < 0) { |
| 2701 | return { timerValue: '', timerTitle }; |
| 2702 | } |
| 2703 | |
| 2704 | return { timerValue, timerTitle }; |
| 2705 | } |
| 2706 | |
| 2707 | let requestId = null; |
| 2708 | |
| 2709 | /** |
| 2710 | * Scrolls the chat to the bottom if configured to do so. |
| 2711 | * @param {object} [options] Options |
| 2712 | * @param {boolean} [options.waitForFrame] If true, waits for the animation frame before scrolling |
| 2713 | */ |
| 2714 | export function scrollChatToBottom({ waitForFrame } = {}) { |
| 2715 | if (!power_user.auto_scroll_chat_to_bottom) { |
| 2716 | return; |
| 2717 | } |
| 2718 | |
| 2719 | const doScroll = () => { |
| 2720 | let position = chatElement[0].scrollHeight; |
| 2721 | |
| 2722 | if (power_user.waifuMode) { |
| 2723 | const lastMessage = chatElement.find('.mes').last(); |
| 2724 | if (lastMessage.length) { |
| 2725 | const lastMessagePosition = lastMessage.position().top; |
| 2726 | position = chatElement.scrollTop() + lastMessagePosition; |
| 2727 | } |
| 2728 | } |
| 2729 | |
| 2730 | chatElement.scrollTop(position); |
| 2731 | requestId = null; |
| 2732 | }; |
| 2733 | |
| 2734 | // Do not check truthiness. requestId can loop to zero. |
| 2735 | if (requestId !== null) { |
| 2736 | cancelAnimationFrame(requestId); |
| 2737 | } |
| 2738 | |
| 2739 | if (!waitForFrame) { |
| 2740 | doScroll(); |
| 2741 | return; |
| 2742 | } |
| 2743 | |
| 2744 | // This prevents layout thrashing. |
| 2745 | // https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame#return_value |
| 2746 | // https://gist.github.com/paulirish/5d52fb081b3570c81e3a#file-what-forces-layout-md |
| 2747 | requestId = requestAnimationFrame(() => doScroll()); |
| 2748 | } |
| 2749 | |
| 2750 | /** |
| 2751 | * @deprecated Function is not needed anymore, as the new signature of substituteParams is more flexible. |
| 2752 | * |
| 2753 | * Substitutes {{macro}} parameters in a string. |
| 2754 | * @returns {string} The string with substituted parameters. |
| 2755 | */ |
| 2756 | export function substituteParamsExtended(content, additionalMacro = {}, postProcessFn = (x) => x) { |
| 2757 | return substituteParams(content, { dynamicMacros: additionalMacro, postProcessFn }); |
| 2758 | } |
| 2759 | |
| 2760 | /** |
| 2761 | * Substitutes {{macro}} parameters in a string. |
| 2762 | * @param {string} content - The string to substitute parameters in. |
| 2763 | * @param {string} [_name1] - The name of the user. Uses global name1 if not provided. |
| 2764 | * @param {string} [_name2] - The name of the character. Uses global name2 if not provided. |
| 2765 | * @param {string} [_original] - The original message for {{original}} substitution. |
| 2766 | * @param {string} [_group] - The group members list for {{group}} substitution. |
| 2767 | * @param {boolean} [_replaceCharacterCard] - Whether to replace character card macros. |
| 2768 | * @param {Record<string,any>} [additionalMacro] - Additional environment variables for substitution. |
| 2769 | * @param {(x: string) => string} [postProcessFn] - Post-processing function for each substituted macro. |
| 2770 | * @returns {string} The string with substituted parameters. |
| 2771 | */ |
| 2772 | export function substituteParamsLegacy(content, _name1, _name2, _original, _group, _replaceCharacterCard = true, additionalMacro = {}, postProcessFn = (x) => x) { |
| 2773 | if (!content) { |
| 2774 | return ''; |
| 2775 | } |
| 2776 | |
| 2777 | // If experimental macro engine is enabled, use it. This code will be cleaned up in the future. |
| 2778 | if (power_user?.experimental_macro_engine) { |
| 2779 | return substituteParams(content, { |
| 2780 | name1Override: _name1, |
| 2781 | name2Override: _name2, |
| 2782 | original: _original, |
| 2783 | groupOverride: _group, |
| 2784 | replaceCharacterCard: _replaceCharacterCard ?? true, |
| 2785 | dynamicMacros: additionalMacro ?? {}, |
| 2786 | postProcessFn: postProcessFn ?? ((x) => x), |
| 2787 | }); |
| 2788 | } |
| 2789 | |
| 2790 | // Try to roughly detect experimental macro features to show the onboarding if needed. |
| 2791 | // This does not have to be 100% accurate, only best effort what we can quickly check. |
| 2792 | // Only do this if the warning wasn't shown yet, to prevent needless regex checks. |
| 2793 | if (accountStorage.getItem('slash_command_experimental_engine_warning_shown') !== 'true') { |
| 2794 | let feature = /** @type {string|null} */ (null); |
| 2795 | if (/{{\s*if/.test(content)) feature = '{{if}} macro'; |
| 2796 | else if (/{{\s*\//.test(content)) feature = 'scoped macro'; |
| 2797 | else if (/{{\s*[!?~#/]/.test(content)) feature = 'macro flags'; |
| 2798 | else if (/{{\s*[.$]/.test(content)) feature = 'variable shorthands'; |
| 2799 | else if (/\{\{(?:(?!\}\}).)*\{\{(?=[\s\S]*?\}\}[\s\S]*?\}\})/.test(content)) feature = 'nested macro'; |
| 2800 | else if (/{{(?:greeting|charFirstMessage)(?:::\d+)?}}/i.test(content)) feature = 'greeting macro'; |
| 2801 | |
| 2802 | if (feature) void onboardingExperimentalMacroEngine(feature); |
| 2803 | } |
| 2804 | |
| 2805 | const environment = {}; |
| 2806 | |
| 2807 | if (typeof _original === 'string') { |
| 2808 | let originalSubstituted = false; |
| 2809 | environment.original = () => { |
| 2810 | if (originalSubstituted) { |
| 2811 | return ''; |
| 2812 | } |
| 2813 | |
| 2814 | originalSubstituted = true; |
| 2815 | return _original; |
| 2816 | }; |
| 2817 | } |
| 2818 | |
| 2819 | const getGroupValue = (includeMuted) => { |
| 2820 | if (typeof _group === 'string') { |
| 2821 | return _group; |
| 2822 | } |
| 2823 | |
| 2824 | if (selected_group) { |
| 2825 | const members = groups.find(x => x.id === selected_group)?.members; |
| 2826 | /** @type {string[]} */ |
| 2827 | const disabledMembers = groups.find(x => x.id === selected_group)?.disabled_members ?? []; |
| 2828 | const isMuted = x => includeMuted ? true : !disabledMembers.includes(x); |
| 2829 | const names = Array.isArray(members) |
| 2830 | ? members.filter(isMuted).map(m => characters.find(c => c.avatar === m)?.name).filter(Boolean).join(', ') |
| 2831 | : ''; |
| 2832 | return names; |
| 2833 | } else { |
| 2834 | return _name2 ?? name2; |
| 2835 | } |
| 2836 | }; |
| 2837 | |
| 2838 | const getNotCharValue = () => { |
| 2839 | const currentUser = _name1 ?? name1; |
| 2840 | const currentSpeaker = _name2 ?? name2; |
| 2841 | |
| 2842 | // Single character chat |
| 2843 | if (!selected_group) { |
| 2844 | return currentUser; |
| 2845 | } |
| 2846 | |
| 2847 | // Group chat |
| 2848 | const members = groups.find(x => x.id === selected_group)?.members; |
| 2849 | |
| 2850 | if (!Array.isArray(members)) { |
| 2851 | return currentUser; |
| 2852 | } |
| 2853 | |
| 2854 | const memberNames = members |
| 2855 | .map(m => characters.find(c => c.avatar === m)?.name) |
| 2856 | .filter(Boolean); // Filter out any null/undefined names |
| 2857 | |
| 2858 | // Filter out the current speaker and add the user |
| 2859 | const otherMembers = memberNames.filter(name => name !== currentSpeaker); |
| 2860 | otherMembers.push(currentUser); |
| 2861 | |
| 2862 | return otherMembers.join(', '); |
| 2863 | }; |
| 2864 | |
| 2865 | if (_replaceCharacterCard) { |
| 2866 | const fields = getCharacterCardFields(); |
| 2867 | environment.charPrompt = fields.system || ''; |
| 2868 | environment.charInstruction = environment.charJailbreak = fields.jailbreak || ''; |
| 2869 | environment.description = fields.description || ''; |
| 2870 | environment.personality = fields.personality || ''; |
| 2871 | environment.scenario = fields.scenario || ''; |
| 2872 | environment.persona = fields.persona || ''; |
| 2873 | environment.mesExamples = () => { |
| 2874 | const isInstruct = power_user.instruct.enabled && main_api !== 'openai'; |
| 2875 | const mesExamplesArray = parseMesExamples(fields.mesExamples, isInstruct); |
| 2876 | if (isInstruct) { |
| 2877 | const instructExamples = formatInstructModeExamples(mesExamplesArray, name1, name2); |
| 2878 | return instructExamples.join(''); |
| 2879 | } |
| 2880 | return mesExamplesArray.join(''); |
| 2881 | }; |
| 2882 | environment.mesExamplesRaw = fields.mesExamples || ''; |
| 2883 | environment.charVersion = fields.version || ''; |
| 2884 | environment.char_version = fields.version || ''; |
| 2885 | environment.charDepthPrompt = fields.charDepthPrompt || ''; |
| 2886 | environment.creatorNotes = fields.creatorNotes || ''; |
| 2887 | } |
| 2888 | |
| 2889 | // Must be substituted last so that they're replaced inside {{description}} |
| 2890 | environment.user = _name1 ?? name1; |
| 2891 | environment.char = _name2 ?? name2; |
| 2892 | environment.group = environment.charIfNotGroup = getGroupValue(true); |
| 2893 | environment.groupNotMuted = getGroupValue(false); |
| 2894 | environment.notChar = getNotCharValue(); |
| 2895 | environment.model = getGeneratingModel(); |
| 2896 | |
| 2897 | if (additionalMacro && typeof additionalMacro === 'object') { |
| 2898 | Object.assign(environment, additionalMacro); |
| 2899 | } |
| 2900 | |
| 2901 | return evaluateMacros(content, environment, postProcessFn); |
| 2902 | } |
| 2903 | |
| 2904 | /** @typedef {import('./scripts/macros/engine/MacroRegistry.js').MacroHandler} MacroHandler */ |
| 2905 | |
| 2906 | /** |
| 2907 | * Substitutes {{macros}} in a string using the new macro engine. |
| 2908 | * |
| 2909 | * This will replace all registered macros and dynamic additional macros as environment context. |
| 2910 | * |
| 2911 | * @param {string} content - The string to substitute parameters in. |
| 2912 | * @param {Object} [options={}] - Options for the substitution. |
| 2913 | * @param {string} [options.name1Override] - The name of the user. Uses global name1 if not provided. |
| 2914 | * @param {string} [options.name2Override] - The name of the character. Uses global name2 if not provided. |
| 2915 | * @param {string} [options.original] - The original message for {{original}} substitution. |
| 2916 | * @param {string} [options.groupOverride] - The group members list for {{group}} substitution. |
| 2917 | * @param {boolean} [options.replaceCharacterCard=true] - Whether to replace character card macros. |
| 2918 | * @param {Record<string, import('./scripts/macros/engine/MacroEnv.types.js').DynamicMacroValue>} [options.dynamicMacros={}] - Additional environment variables as dynamic macros for substitution. Registered as macro functions. |
| 2919 | * @param {(x: string) => string} [options.postProcessFn=(x) => x] - Post-processing function for each substituted macro. |
| 2920 | * @returns {string} The string with substituted parameters. |
| 2921 | */ |
| 2922 | export function substituteParams(content, options = {}) { |
| 2923 | if (!content) return ''; |
| 2924 | |
| 2925 | if (typeof content !== 'string') { |
| 2926 | console.warn('substituteParams: content will be coerced to string', content); |
| 2927 | content = String(content); |
| 2928 | } |
| 2929 | |
| 2930 | // Handle legacy signature calls to substituteParams |
| 2931 | // We'll simply re-route them to a temporary legacy function. In the future, we'll remove this and cleanly build the options object ourselves. |
| 2932 | const isOptionsObject = options && typeof options === 'object' && !Array.isArray(options); |
| 2933 | if (!isOptionsObject) { |
| 2934 | return substituteParamsLegacy.call(this, ...arguments); |
| 2935 | } |
| 2936 | |
| 2937 | // Keep the new macro engine behind a feature switch for now |
| 2938 | if (!power_user?.experimental_macro_engine) { |
| 2939 | return substituteParamsLegacy(content, options.name1Override, options.name2Override, options.original, options.groupOverride, options.replaceCharacterCard, options.dynamicMacros, options.postProcessFn); |
| 2940 | } |
| 2941 | |
| 2942 | const ctx = /** @type {import('./scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */ ({ |
| 2943 | content, |
| 2944 | name1Override: options.name1Override, |
| 2945 | name2Override: options.name2Override, |
| 2946 | original: options.original, |
| 2947 | groupOverride: options.groupOverride, |
| 2948 | replaceCharacterCard: options.replaceCharacterCard ?? true, |
| 2949 | dynamicMacros: options.dynamicMacros ?? {}, |
| 2950 | postProcessFn: options.postProcessFn ?? ((x) => x), |
| 2951 | }); |
| 2952 | |
| 2953 | const env = MacroEnvBuilder.buildFromRawEnv(ctx); |
| 2954 | const result = MacroEngine.evaluate(content, env); |
| 2955 | return result; |
| 2956 | } |
| 2957 | |
| 2958 | |
| 2959 | /** |
| 2960 | * Gets stopping sequences for the prompt. |
| 2961 | * @param {boolean} isImpersonate A request is made to impersonate a user |
| 2962 | * @param {boolean} isContinue A request is made to continue the message |
| 2963 | * @param {string} [api] Optional API name to get API-specific stopping sequences for |
| 2964 | * @returns {string[]} Array of stopping strings |
| 2965 | */ |
| 2966 | export function getStoppingStrings(isImpersonate, isContinue, api = main_api) { |
| 2967 | // Only custom stop strings apply to Chat Completion |
| 2968 | if (api === 'openai') { |
| 2969 | return getCustomStoppingStrings(); |
| 2970 | } |
| 2971 | |
| 2972 | const result = []; |
| 2973 | |
| 2974 | if (power_user.context.names_as_stop_strings) { |
| 2975 | const charString = `\n${name2}:`; |
| 2976 | const userString = `\n${name1}:`; |
| 2977 | result.push(isImpersonate ? charString : userString); |
| 2978 | |
| 2979 | result.push(userString); |
| 2980 | |
| 2981 | if (isContinue && Array.isArray(chat) && chat[chat.length - 1]?.is_user) { |
| 2982 | result.push(charString); |
| 2983 | } |
| 2984 | |
| 2985 | // Add group members as stopping strings if generating for a specific group member or user. (Allow slash commands to work around name stopping string restrictions) |
| 2986 | if (selected_group && (name2 || isImpersonate)) { |
| 2987 | const group = groups.find(x => x.id === selected_group); |
| 2988 | |
| 2989 | if (group && Array.isArray(group.members)) { |
| 2990 | const names = group.members |
| 2991 | .map(x => characters.find(y => y.avatar == x)) |
| 2992 | .filter(x => x && x.name && x.name !== name2) |
| 2993 | .map(x => `\n${x.name}:`); |
| 2994 | result.push(...names); |
| 2995 | } |
| 2996 | } |
| 2997 | } |
| 2998 | |
| 2999 | result.push(...getInstructStoppingSequences()); |
| 3000 | result.push(...getCustomStoppingStrings()); |
| 3001 | |
| 3002 | if (power_user.single_line) { |
| 3003 | result.unshift('\n'); |
| 3004 | } |
| 3005 | |
| 3006 | return result.filter(x => x).filter(onlyUnique); |
| 3007 | } |
| 3008 | |
| 3009 | /** |
| 3010 | * Background generation based on the provided prompt. |
| 3011 | * @typedef {object} GenerateQuietPromptParams |
| 3012 | * @prop {string} [quietPrompt] Instruction prompt for the AI |
| 3013 | * @prop {boolean} [quietToLoud] Whether the message should be sent in a foreground (loud) or background (quiet) mode |
| 3014 | * @prop {boolean} [skipWIAN] Whether to skip addition of World Info and Author's Note into the prompt |
| 3015 | * @prop {string} [quietImage] Image to use for the quiet prompt |
| 3016 | * @prop {string} [quietName] Name to use for the quiet prompt (defaults to "System:") |
| 3017 | * @prop {number} [responseLength] Maximum response length. If unset, the global default value is used. |
| 3018 | * @prop {number} [forceChId] Character ID to use for this generation run. Works in groups only. |
| 3019 | * @prop {object} [jsonSchema] JSON schema to use for the structured generation. Usually requires a special instruction. |
| 3020 | * @prop {boolean} [removeReasoning] Parses and removes the reasoning block according to reasoning format preferences |
| 3021 | * @prop {boolean} [trimToSentence] Whether to trim the response to the last complete sentence |
| 3022 | * @param {GenerateQuietPromptParams} params Parameters for the quiet prompt generation |
| 3023 | * @returns {Promise<string>} Generated text. If using structured output, will contain a serialized JSON object. |
| 3024 | */ |
| 3025 | export async function generateQuietPrompt({ quietPrompt = '', quietToLoud = false, skipWIAN = false, quietImage = null, quietName = null, responseLength = null, forceChId = null, jsonSchema = null, removeReasoning = true, trimToSentence = false } = {}) { |
| 3026 | if (arguments.length > 0 && typeof arguments[0] !== 'object') { |
| 3027 | console.trace('generateQuietPrompt called with positional arguments. Please use an object instead.'); |
| 3028 | [quietPrompt, quietToLoud, skipWIAN, quietImage, quietName, responseLength, forceChId, jsonSchema] = arguments; |
| 3029 | } |
| 3030 | |
| 3031 | const responseLengthCustomized = typeof responseLength === 'number' && responseLength > 0; |
| 3032 | let eventHook = () => { }; |
| 3033 | try { |
| 3034 | /** @type {GenerateOptions} */ |
| 3035 | const generateOptions = { |
| 3036 | quiet_prompt: quietPrompt ?? '', |
| 3037 | quietToLoud: quietToLoud ?? false, |
| 3038 | skipWIAN: skipWIAN ?? false, |
| 3039 | force_name2: true, |
| 3040 | quietImage: quietImage ?? null, |
| 3041 | quietName: quietName ?? null, |
| 3042 | force_chid: forceChId ?? null, |
| 3043 | jsonSchema: jsonSchema ?? null, |
| 3044 | }; |
| 3045 | if (responseLengthCustomized) { |
| 3046 | TempResponseLength.save(main_api, responseLength); |
| 3047 | eventHook = TempResponseLength.setupEventHook(main_api); |
| 3048 | } |
| 3049 | let result = await Generate('quiet', generateOptions); |
| 3050 | result = trimToSentence ? trimToEndSentence(result) : result; |
| 3051 | result = removeReasoning ? removeReasoningFromString(result) : result; |
| 3052 | return result; |
| 3053 | } finally { |
| 3054 | if (responseLengthCustomized && TempResponseLength.isCustomized()) { |
| 3055 | TempResponseLength.restore(main_api); |
| 3056 | TempResponseLength.removeEventHook(main_api, eventHook); |
| 3057 | } |
| 3058 | } |
| 3059 | } |
| 3060 | |
| 3061 | /** |
| 3062 | * Executes slash commands and returns the new text and whether the generation was interrupted. |
| 3063 | * @param {string} message Text to be sent |
| 3064 | * @returns {Promise<boolean>} Whether the message sending was interrupted |
| 3065 | */ |
| 3066 | export async function processCommands(message) { |
| 3067 | if (!message || !message.trim().startsWith('/')) { |
| 3068 | return false; |
| 3069 | } |
| 3070 | await executeSlashCommandsOnChatInput(message, { |
| 3071 | clearChatInput: true, |
| 3072 | }); |
| 3073 | return true; |
| 3074 | } |
| 3075 | |
| 3076 | /** |
| 3077 | * Extracts the contents of bias macros from a message. |
| 3078 | * @param {string} message Message text |
| 3079 | * @returns {string} Message bias extracted from the message (or an empty string if not found) |
| 3080 | */ |
| 3081 | export function extractMessageBias(message) { |
| 3082 | if (!message) { |
| 3083 | return ''; |
| 3084 | } |
| 3085 | |
| 3086 | try { |
| 3087 | const biasHandlebars = Handlebars.create(); |
| 3088 | const biasMatches = []; |
| 3089 | biasHandlebars.registerHelper('bias', function (text) { |
| 3090 | biasMatches.push(text); |
| 3091 | return ''; |
| 3092 | }); |
| 3093 | const template = biasHandlebars.compile(message); |
| 3094 | template({}); |
| 3095 | |
| 3096 | if (biasMatches && biasMatches.length > 0) { |
| 3097 | return ` ${biasMatches.join(' ')}`; |
| 3098 | } |
| 3099 | |
| 3100 | return ''; |
| 3101 | } catch { |
| 3102 | return ''; |
| 3103 | } |
| 3104 | } |
| 3105 | |
| 3106 | /** |
| 3107 | * Removes impersonated group member lines from the group member messages. |
| 3108 | * Doesn't do anything if group reply trimming is disabled. |
| 3109 | * @param {string} getMessage Group message |
| 3110 | * @returns Cleaned-up group message |
| 3111 | */ |
| 3112 | function cleanGroupMessage(getMessage) { |
| 3113 | if (power_user.disable_group_trimming) { |
| 3114 | return getMessage; |
| 3115 | } |
| 3116 | |
| 3117 | const group = groups.find((x) => x.id == selected_group); |
| 3118 | |
| 3119 | if (group && Array.isArray(group.members) && group.members) { |
| 3120 | for (let member of group.members) { |
| 3121 | const character = characters.find(x => x.avatar == member); |
| 3122 | |
| 3123 | if (!character) { |
| 3124 | continue; |
| 3125 | } |
| 3126 | |
| 3127 | const name = character.name; |
| 3128 | |
| 3129 | // Skip current speaker. |
| 3130 | if (name === name2) { |
| 3131 | continue; |
| 3132 | } |
| 3133 | |
| 3134 | const regex = new RegExp(`(^|\n)${escapeRegex(name)}:`); |
| 3135 | const nameMatch = getMessage.match(regex); |
| 3136 | if (nameMatch) { |
| 3137 | getMessage = getMessage.substring(0, nameMatch.index); |
| 3138 | } |
| 3139 | } |
| 3140 | } |
| 3141 | return getMessage; |
| 3142 | } |
| 3143 | |
| 3144 | function addPersonaDescriptionExtensionPrompt() { |
| 3145 | const INJECT_TAG = 'PERSONA_DESCRIPTION'; |
| 3146 | setExtensionPrompt(INJECT_TAG, '', extension_prompt_types.IN_PROMPT, 0); |
| 3147 | |
| 3148 | if (!power_user.persona_description || power_user.persona_description_position === persona_description_positions.NONE) { |
| 3149 | return; |
| 3150 | } |
| 3151 | |
| 3152 | const promptPositions = [persona_description_positions.BOTTOM_AN, persona_description_positions.TOP_AN]; |
| 3153 | |
| 3154 | if (promptPositions.includes(power_user.persona_description_position) && shouldWIAddPrompt) { |
| 3155 | const originalAN = extension_prompts[NOTE_MODULE_NAME].value; |
| 3156 | const ANWithDesc = power_user.persona_description_position === persona_description_positions.TOP_AN |
| 3157 | ? `${power_user.persona_description}\n${originalAN}` |
| 3158 | : `${originalAN}\n${power_user.persona_description}`; |
| 3159 | |
| 3160 | setExtensionPrompt(NOTE_MODULE_NAME, ANWithDesc, chat_metadata[metadata_keys.position], chat_metadata[metadata_keys.depth], extension_settings.note.allowWIScan, chat_metadata[metadata_keys.role]); |
| 3161 | } |
| 3162 | |
| 3163 | if (power_user.persona_description_position === persona_description_positions.AT_DEPTH) { |
| 3164 | setExtensionPrompt(INJECT_TAG, power_user.persona_description, extension_prompt_types.IN_CHAT, power_user.persona_description_depth, true, power_user.persona_description_role); |
| 3165 | } |
| 3166 | } |
| 3167 | |
| 3168 | /** |
| 3169 | * Returns all extension prompts combined. |
| 3170 | * @returns {Promise<string>} Combined extension prompts |
| 3171 | */ |
| 3172 | async function getAllExtensionPrompts() { |
| 3173 | const values = []; |
| 3174 | |
| 3175 | for (const prompt of Object.values(extension_prompts)) { |
| 3176 | const value = prompt?.value?.trim(); |
| 3177 | |
| 3178 | if (!value) { |
| 3179 | continue; |
| 3180 | } |
| 3181 | |
| 3182 | const hasFilter = typeof prompt.filter === 'function'; |
| 3183 | if (hasFilter && !await prompt.filter()) { |
| 3184 | continue; |
| 3185 | } |
| 3186 | |
| 3187 | values.push(value); |
| 3188 | } |
| 3189 | |
| 3190 | return substituteParams(values.join('\n')); |
| 3191 | } |
| 3192 | |
| 3193 | /** |
| 3194 | * Wrapper to fetch extension prompts by module name |
| 3195 | * @param {string} moduleName Module name |
| 3196 | * @returns {Promise<string>} Extension prompt |
| 3197 | */ |
| 3198 | export async function getExtensionPromptByName(moduleName) { |
| 3199 | if (!moduleName) { |
| 3200 | return ''; |
| 3201 | } |
| 3202 | |
| 3203 | const prompt = extension_prompts[moduleName]; |
| 3204 | |
| 3205 | if (!prompt) { |
| 3206 | return ''; |
| 3207 | } |
| 3208 | |
| 3209 | const hasFilter = typeof prompt.filter === 'function'; |
| 3210 | |
| 3211 | if (hasFilter && !await prompt.filter()) { |
| 3212 | return ''; |
| 3213 | } |
| 3214 | |
| 3215 | return substituteParams(prompt.value); |
| 3216 | } |
| 3217 | |
| 3218 | /** |
| 3219 | * Gets the maximum depth of extension prompts. |
| 3220 | * @returns {number} Maximum depth of extension prompts |
| 3221 | */ |
| 3222 | export function getExtensionPromptMaxDepth() { |
| 3223 | return MAX_INJECTION_DEPTH; |
| 3224 | /* |
| 3225 | const prompts = Object.values(extension_prompts); |
| 3226 | const maxDepth = Math.max(...prompts.map(x => x.depth ?? 0)); |
| 3227 | // Clamp to 1 <= depth <= MAX_INJECTION_DEPTH |
| 3228 | return Math.max(Math.min(maxDepth, MAX_INJECTION_DEPTH), 1); |
| 3229 | */ |
| 3230 | } |
| 3231 | |
| 3232 | /** |
| 3233 | * Returns the extension prompt for the given position, depth, and role. |
| 3234 | * If multiple prompts are found, they are joined with a separator. |
| 3235 | * @param {number} [position] Position of the prompt |
| 3236 | * @param {number} [depth] Depth of the prompt |
| 3237 | * @param {string} [separator] Separator for joining multiple prompts |
| 3238 | * @param {number} [role] Role of the prompt |
| 3239 | * @param {boolean} [wrap] Wrap start and end with a separator |
| 3240 | * @returns {Promise<string>} Extension prompt |
| 3241 | */ |
| 3242 | export async function getExtensionPrompt(position = extension_prompt_types.IN_PROMPT, depth = undefined, separator = '\n', role = undefined, wrap = true) { |
| 3243 | const filterByFunction = async (prompt) => { |
| 3244 | const hasFilter = typeof prompt.filter === 'function'; |
| 3245 | if (hasFilter && !await prompt.filter()) { |
| 3246 | return false; |
| 3247 | } |
| 3248 | return true; |
| 3249 | }; |
| 3250 | const promptPromises = Object.keys(extension_prompts) |
| 3251 | .sort() |
| 3252 | .map((x) => extension_prompts[x]) |
| 3253 | .filter(x => x.position == position && x.value) |
| 3254 | .filter(x => depth === undefined || x.depth === undefined || x.depth === depth) |
| 3255 | .filter(x => role === undefined || x.role === undefined || x.role === role) |
| 3256 | .filter(filterByFunction); |
| 3257 | const prompts = await Promise.all(promptPromises); |
| 3258 | |
| 3259 | let values = prompts.map(x => x.value.trim()).join(separator); |
| 3260 | if (wrap && values.length && !values.startsWith(separator)) { |
| 3261 | values = separator + values; |
| 3262 | } |
| 3263 | if (wrap && values.length && !values.endsWith(separator)) { |
| 3264 | values = values + separator; |
| 3265 | } |
| 3266 | if (values.length) { |
| 3267 | values = substituteParams(values); |
| 3268 | } |
| 3269 | return values; |
| 3270 | } |
| 3271 | |
| 3272 | /** |
| 3273 | * Base chat replacement function for character card fields. |
| 3274 | * 1. Substitutes macros using substituteParams. |
| 3275 | * 2. Collapses newlines if enabled in power user settings. |
| 3276 | * 3. Removes carriage return characters. |
| 3277 | * @param {string} value Input string |
| 3278 | * @param {string?} name1Override Override for name1 |
| 3279 | * @param {string?} name2Override Override for name2 |
| 3280 | * @returns {string} Processed string |
| 3281 | */ |
| 3282 | export function baseChatReplace(value, name1Override = null, name2Override = null) { |
| 3283 | if (typeof value === 'string' && value.length > 0) { |
| 3284 | value = substituteParams(value, { name1Override, name2Override, replaceCharacterCard: false }); |
| 3285 | |
| 3286 | if (power_user.collapse_newlines) { |
| 3287 | value = collapseNewlines(value); |
| 3288 | } |
| 3289 | |
| 3290 | value = value.replace(/\r/g, ''); |
| 3291 | } |
| 3292 | return value; |
| 3293 | } |
| 3294 | |
| 3295 | /** |
| 3296 | * @typedef {Object} CharacterCardFields |
| 3297 | * @property {string} system System prompt |
| 3298 | * @property {string} mesExamples Message examples |
| 3299 | * @property {string} description Description |
| 3300 | * @property {string} personality Personality |
| 3301 | * @property {string} persona Persona |
| 3302 | * @property {string} scenario Scenario |
| 3303 | * @property {string} jailbreak Jailbreak instructions |
| 3304 | * @property {string} version Character version |
| 3305 | * @property {string} charDepthPrompt Character depth note |
| 3306 | * @property {string} creatorNotes Character creator notes |
| 3307 | * @property {string} firstMessage Character first message / greeting |
| 3308 | * @property {string[]} alternateGreetings Character alternate greetings |
| 3309 | */ |
| 3310 | |
| 3311 | /** |
| 3312 | * Helper to create an object with lazy, memoized getters from a map of field resolvers. |
| 3313 | * @param {Record<string, () => string|string[]>} resolvers Map of field names to resolver functions |
| 3314 | * @returns {CharacterCardFields} Object with lazy getters |
| 3315 | */ |
| 3316 | export function createLazyFields(resolvers) { |
| 3317 | const result = /** @type {CharacterCardFields} */ ({}); |
| 3318 | for (const [key, resolver] of Object.entries(resolvers)) { |
| 3319 | let cached; |
| 3320 | let resolved = false; |
| 3321 | Object.defineProperty(result, key, { |
| 3322 | get() { |
| 3323 | if (!resolved) { |
| 3324 | cached = resolver(); |
| 3325 | resolved = true; |
| 3326 | } |
| 3327 | return cached; |
| 3328 | }, |
| 3329 | enumerable: true, |
| 3330 | configurable: true, |
| 3331 | }); |
| 3332 | } |
| 3333 | return result; |
| 3334 | } |
| 3335 | |
| 3336 | /** |
| 3337 | * Returns the character card fields for the current character as lazy getters. |
| 3338 | * Each field is only processed (baseChatReplace) when first accessed. |
| 3339 | * @param {Object} [options={}] |
| 3340 | * @param {number} [options.chid] Optional character index |
| 3341 | * @returns {CharacterCardFields} Character card fields with lazy evaluation |
| 3342 | */ |
| 3343 | export function getCharacterCardFieldsLazy({ chid = undefined } = {}) { |
| 3344 | const currentChid = chid ?? this_chid; |
| 3345 | const character = characters[currentChid]; |
| 3346 | |
| 3347 | // For group chats, we need to check if group cards should be used |
| 3348 | const useGroupCards = selected_group && character; |
| 3349 | const groupCardsLazy = useGroupCards ? getGroupCharacterCardsLazy(selected_group, Number(currentChid)) : null; |
| 3350 | |
| 3351 | /** @type {Record<string, () => string|string[]>} */ |
| 3352 | const resolvers = { |
| 3353 | persona: () => baseChatReplace(power_user.persona_description?.trim()), |
| 3354 | system: () => { |
| 3355 | if (!character) return ''; |
| 3356 | const systemPrompt = chat_metadata.system_prompt || character.data?.system_prompt || ''; |
| 3357 | return power_user.prefer_character_prompt ? baseChatReplace(systemPrompt.trim()) : ''; |
| 3358 | }, |
| 3359 | jailbreak: () => { |
| 3360 | if (!character) return ''; |
| 3361 | return power_user.prefer_character_jailbreak ? baseChatReplace(character.data?.post_history_instructions?.trim()) : ''; |
| 3362 | }, |
| 3363 | version: () => character?.data?.character_version ?? '', |
| 3364 | charDepthPrompt: () => { |
| 3365 | if (!character) return ''; |
| 3366 | return baseChatReplace(character.data?.extensions?.depth_prompt?.prompt?.trim()); |
| 3367 | }, |
| 3368 | creatorNotes: () => { |
| 3369 | if (!character) return ''; |
| 3370 | return baseChatReplace(character.data?.creator_notes?.trim()); |
| 3371 | }, |
| 3372 | // These four fields may be overridden by group cards |
| 3373 | description: () => { |
| 3374 | if (groupCardsLazy) return groupCardsLazy.description; |
| 3375 | if (!character) return ''; |
| 3376 | return baseChatReplace(character.description?.trim()); |
| 3377 | }, |
| 3378 | personality: () => { |
| 3379 | if (groupCardsLazy) return groupCardsLazy.personality; |
| 3380 | if (!character) return ''; |
| 3381 | return baseChatReplace(character.personality?.trim()); |
| 3382 | }, |
| 3383 | scenario: () => { |
| 3384 | if (groupCardsLazy) return groupCardsLazy.scenario; |
| 3385 | if (!character) return ''; |
| 3386 | const scenarioText = chat_metadata.scenario || character.scenario || ''; |
| 3387 | return baseChatReplace(scenarioText.trim()); |
| 3388 | }, |
| 3389 | mesExamples: () => { |
| 3390 | if (groupCardsLazy) return groupCardsLazy.mesExamples; |
| 3391 | if (!character) return ''; |
| 3392 | const exampleDialog = chat_metadata.mes_example || character.mes_example || ''; |
| 3393 | return baseChatReplace(exampleDialog.trim()); |
| 3394 | }, |
| 3395 | firstMessage: () => { |
| 3396 | if (!character) return ''; |
| 3397 | const firstMes = character.first_mes?.trim() || ''; |
| 3398 | return baseChatReplace(firstMes); |
| 3399 | }, |
| 3400 | alternateGreetings: () => { |
| 3401 | if (!character) return []; |
| 3402 | const altGreetings = character.data?.alternate_greetings; |
| 3403 | if (!Array.isArray(altGreetings)) return []; |
| 3404 | return altGreetings.map(greeting => baseChatReplace(greeting?.trim())); |
| 3405 | }, |
| 3406 | }; |
| 3407 | |
| 3408 | return createLazyFields(resolvers); |
| 3409 | } |
| 3410 | |
| 3411 | /** |
| 3412 | * Returns the character card fields for the current character. |
| 3413 | * @param {Object} [options={}] |
| 3414 | * @param {number} [options.chid] Optional character index |
| 3415 | * @returns {CharacterCardFields} Character card fields |
| 3416 | */ |
| 3417 | export function getCharacterCardFields({ chid = undefined } = {}) { |
| 3418 | const lazy = getCharacterCardFieldsLazy({ chid }); |
| 3419 | |
| 3420 | // Resolve all lazy fields into a plain object |
| 3421 | return { |
| 3422 | system: lazy.system, |
| 3423 | mesExamples: lazy.mesExamples, |
| 3424 | description: lazy.description, |
| 3425 | personality: lazy.personality, |
| 3426 | persona: lazy.persona, |
| 3427 | scenario: lazy.scenario, |
| 3428 | jailbreak: lazy.jailbreak, |
| 3429 | version: lazy.version, |
| 3430 | charDepthPrompt: lazy.charDepthPrompt, |
| 3431 | creatorNotes: lazy.creatorNotes, |
| 3432 | firstMessage: lazy.firstMessage, |
| 3433 | alternateGreetings: lazy.alternateGreetings, |
| 3434 | }; |
| 3435 | } |
| 3436 | |
| 3437 | /** |
| 3438 | * Parses an examples string. |
| 3439 | * @param {string} examplesStr |
| 3440 | * @returns {string[]} Examples array with block heading |
| 3441 | */ |
| 3442 | export function parseMesExamples(examplesStr, isInstruct) { |
| 3443 | if (!examplesStr || examplesStr.length === 0 || examplesStr === '<START>') { |
| 3444 | return []; |
| 3445 | } |
| 3446 | |
| 3447 | if (!examplesStr.startsWith('<START>')) { |
| 3448 | examplesStr = '<START>\n' + examplesStr.trim(); |
| 3449 | } |
| 3450 | |
| 3451 | const exampleSeparator = power_user.context.example_separator ? `${substituteParams(power_user.context.example_separator)}\n` : ''; |
| 3452 | const blockHeading = (main_api === 'openai' || isInstruct) ? '<START>\n' : exampleSeparator; |
| 3453 | const splitExamples = examplesStr.split(/<START>/gi).slice(1).map(block => `${blockHeading}${block.trim()}\n`); |
| 3454 | |
| 3455 | return splitExamples; |
| 3456 | } |
| 3457 | |
| 3458 | export function isStreamingEnabled() { |
| 3459 | return ( |
| 3460 | (main_api == 'openai' && |
| 3461 | oai_settings.stream_openai && |
| 3462 | !(oai_settings.chat_completion_source == chat_completion_sources.OPENAI && ['o1-2024-12-17', 'o1'].includes(oai_settings.openai_model)) |
| 3463 | ) |
| 3464 | || (main_api == 'kobold' && kai_settings.streaming_kobold && kai_flags.can_use_streaming) |
| 3465 | || (main_api == 'novel' && nai_settings.streaming_novel) |
| 3466 | || (main_api == 'textgenerationwebui' && textgen_settings.streaming)); |
| 3467 | } |
| 3468 | |
| 3469 | function showStopButton() { |
| 3470 | $('#mes_stop').css({ 'display': 'flex' }); |
| 3471 | } |
| 3472 | |
| 3473 | function hideStopButton() { |
| 3474 | // prevent NOOP, because hideStopButton() gets called multiple times |
| 3475 | if ($('#mes_stop').css('display') !== 'none') { |
| 3476 | $('#mes_stop').css({ 'display': 'none' }); |
| 3477 | eventSource.emit(event_types.GENERATION_ENDED, chat.length); |
| 3478 | } |
| 3479 | } |
| 3480 | |
| 3481 | class StreamingProcessor { |
| 3482 | /** |
| 3483 | * Creates a new streaming processor. |
| 3484 | * @param {string} type Generation type |
| 3485 | * @param {boolean} forceName2 If true, force the use of name2 |
| 3486 | * @param {Date} timeStarted Date when generation was started |
| 3487 | * @param {string} continueMessage Previous message if the type is 'continue' |
| 3488 | * @param {PromptReasoning} promptReasoning Prompt reasoning instance |
| 3489 | */ |
| 3490 | constructor(type, forceName2, timeStarted, continueMessage, promptReasoning) { |
| 3491 | this.result = ''; |
| 3492 | this.messageId = -1; |
| 3493 | /** @type {HTMLElement} */ |
| 3494 | this.messageDom = null; |
| 3495 | /** @type {HTMLElement} */ |
| 3496 | this.messageTextDom = null; |
| 3497 | /** @type {HTMLElement} */ |
| 3498 | this.messageTimerDom = null; |
| 3499 | /** @type {HTMLElement} */ |
| 3500 | this.messageTokenCounterDom = null; |
| 3501 | /** @type {HTMLTextAreaElement} */ |
| 3502 | this.sendTextarea = document.querySelector('#send_textarea'); |
| 3503 | this.type = type; |
| 3504 | this.force_name2 = forceName2; |
| 3505 | this.isStopped = false; |
| 3506 | this.isFinished = false; |
| 3507 | this.generator = this.nullStreamingGeneration; |
| 3508 | this.abortController = new AbortController(); |
| 3509 | this.firstMessageText = '...'; |
| 3510 | this.timeStarted = timeStarted; |
| 3511 | /** @type {number?} */ |
| 3512 | this.timeToFirstToken = null; |
| 3513 | this.createdAt = new Date(); |
| 3514 | this.continueMessage = type === 'continue' ? continueMessage : ''; |
| 3515 | this.swipes = []; |
| 3516 | /** @type {import('./scripts/logprobs.js').TokenLogprobs[]} */ |
| 3517 | this.messageLogprobs = []; |
| 3518 | this.toolCalls = []; |
| 3519 | // Initialize reasoning in its own handler |
| 3520 | this.reasoningHandler = new ReasoningHandler(timeStarted); |
| 3521 | /** @type {PromptReasoning} */ |
| 3522 | this.promptReasoning = promptReasoning; |
| 3523 | /** @type {string[]} */ |
| 3524 | this.images = []; |
| 3525 | /** @type {string?} */ |
| 3526 | this.reasoningSignature = null; |
| 3527 | } |
| 3528 | |
| 3529 | /** |
| 3530 | * Initializes DOM elements for the current message. |
| 3531 | * @param {number} messageId Current message ID |
| 3532 | * @param {boolean?} continueOnReasoning If continuing on reasoning |
| 3533 | */ |
| 3534 | async #checkDomElements(messageId, continueOnReasoning = null) { |
| 3535 | if (this.messageDom === null || this.messageTextDom === null) { |
| 3536 | this.messageDom = document.querySelector(`#chat .mes[mesid="${messageId}"]`); |
| 3537 | this.messageTextDom = this.messageDom?.querySelector('.mes_text'); |
| 3538 | this.messageTimerDom = this.messageDom?.querySelector('.mes_timer'); |
| 3539 | this.messageTokenCounterDom = this.messageDom?.querySelector('.tokenCounterDisplay'); |
| 3540 | } |
| 3541 | if (continueOnReasoning) { |
| 3542 | await this.reasoningHandler.process(messageId, false, this.promptReasoning); |
| 3543 | } |
| 3544 | this.reasoningHandler.updateDom(messageId); |
| 3545 | } |
| 3546 | |
| 3547 | #updateMessageBlockVisibility() { |
| 3548 | if (this.messageDom instanceof HTMLElement && Array.isArray(this.toolCalls) && this.toolCalls.length > 0) { |
| 3549 | const shouldHide = ['', '...'].includes(this.result) && !this.reasoningHandler.reasoning; |
| 3550 | this.messageDom.classList.toggle('displayNone', shouldHide); |
| 3551 | } |
| 3552 | } |
| 3553 | |
| 3554 | markUIGenStarted() { |
| 3555 | deactivateSendButtons(); |
| 3556 | } |
| 3557 | |
| 3558 | markUIGenStopped() { |
| 3559 | unblockGeneration(); |
| 3560 | } |
| 3561 | |
| 3562 | async onStartStreaming(text) { |
| 3563 | const continueOnReasoning = !!(this.type === 'continue' && this.promptReasoning.prefixReasoning); |
| 3564 | if (continueOnReasoning) { |
| 3565 | this.reasoningHandler.initContinue(this.promptReasoning); |
| 3566 | } |
| 3567 | |
| 3568 | let messageId = -1; |
| 3569 | |
| 3570 | if (this.type == 'impersonate') { |
| 3571 | this.sendTextarea.value = ''; |
| 3572 | this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true })); |
| 3573 | } else { |
| 3574 | await saveReply({ type: this.type, getMessage: text, fromStreaming: true }); |
| 3575 | messageId = chat.length - 1; |
| 3576 | await this.#checkDomElements(messageId, continueOnReasoning); |
| 3577 | this.markUIGenStarted(); |
| 3578 | } |
| 3579 | hideSwipeButtons({ hideCounters: true }); |
| 3580 | scrollChatToBottom({ waitForFrame: true }); |
| 3581 | return messageId; |
| 3582 | } |
| 3583 | |
| 3584 | async onProgressStreaming(messageId, text, isFinal) { |
| 3585 | const isImpersonate = this.type == 'impersonate'; |
| 3586 | const isContinue = this.type == 'continue'; |
| 3587 | |
| 3588 | if (!isImpersonate && !isContinue && Array.isArray(this.swipes) && this.swipes.length > 0) { |
| 3589 | for (let i = 0; i < this.swipes.length; i++) { |
| 3590 | this.swipes[i] = cleanUpMessage({ |
| 3591 | getMessage: this.swipes[i], |
| 3592 | isImpersonate: false, |
| 3593 | isContinue: false, |
| 3594 | displayIncompleteSentences: true, |
| 3595 | stoppingStrings: this.stoppingStrings, |
| 3596 | }); |
| 3597 | } |
| 3598 | } |
| 3599 | |
| 3600 | let processedText = cleanUpMessage({ |
| 3601 | getMessage: text, |
| 3602 | isImpersonate: isImpersonate, |
| 3603 | isContinue: isContinue, |
| 3604 | displayIncompleteSentences: !isFinal, |
| 3605 | stoppingStrings: this.stoppingStrings, |
| 3606 | }); |
| 3607 | |
| 3608 | const charsToBalance = ['*', '"', '```', '~~~']; |
| 3609 | for (const char of charsToBalance) { |
| 3610 | if (!isFinal && isOdd(countOccurrences(processedText, char))) { |
| 3611 | const separator = char.length > 1 ? '\n' : ''; |
| 3612 | processedText = processedText.trimEnd() + separator + char; |
| 3613 | } |
| 3614 | } |
| 3615 | |
| 3616 | if (isImpersonate) { |
| 3617 | this.sendTextarea.value = processedText; |
| 3618 | this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true })); |
| 3619 | } else { |
| 3620 | const mesChanged = chat[messageId].mes !== processedText; |
| 3621 | await this.#checkDomElements(messageId); |
| 3622 | this.#updateMessageBlockVisibility(); |
| 3623 | const currentTime = new Date(); |
| 3624 | chat[messageId].mes = processedText; |
| 3625 | chat[messageId].gen_started = this.timeStarted; |
| 3626 | chat[messageId].gen_finished = currentTime; |
| 3627 | if (!chat[messageId].extra) { |
| 3628 | chat[messageId].extra = {}; |
| 3629 | } |
| 3630 | chat[messageId].extra.time_to_first_token = this.timeToFirstToken; |
| 3631 | |
| 3632 | // Update reasoning |
| 3633 | await this.reasoningHandler.process(messageId, mesChanged, this.promptReasoning); |
| 3634 | processedText = chat[messageId].mes; |
| 3635 | |
| 3636 | // Token count update. |
| 3637 | const tokenCountText = this.reasoningHandler.reasoning + processedText; |
| 3638 | const currentTokenCount = isFinal && power_user.message_token_count_enabled ? await getTokenCountAsync(tokenCountText, 0) : 0; |
| 3639 | if (currentTokenCount) { |
| 3640 | chat[messageId].extra.token_count = currentTokenCount; |
| 3641 | if (this.messageTokenCounterDom instanceof HTMLElement) { |
| 3642 | this.messageTokenCounterDom.textContent = `${currentTokenCount}t`; |
| 3643 | } |
| 3644 | } |
| 3645 | |
| 3646 | if ((this.type == 'swipe' || this.type === 'continue') && Array.isArray(chat[messageId].swipes)) { |
| 3647 | chat[messageId].swipes[chat[messageId].swipe_id] = processedText; |
| 3648 | chat[messageId].swipe_info[chat[messageId].swipe_id] = { |
| 3649 | 'send_date': chat[messageId].send_date, |
| 3650 | 'gen_started': chat[messageId].gen_started, |
| 3651 | 'gen_finished': chat[messageId].gen_finished, |
| 3652 | 'extra': structuredClone(chat[messageId].extra), |
| 3653 | }; |
| 3654 | } |
| 3655 | |
| 3656 | const formattedText = messageFormatting( |
| 3657 | processedText, |
| 3658 | chat[messageId].name, |
| 3659 | chat[messageId].is_system, |
| 3660 | chat[messageId].is_user, |
| 3661 | messageId, |
| 3662 | {}, |
| 3663 | false, |
| 3664 | ); |
| 3665 | if (this.messageTextDom instanceof HTMLElement) { |
| 3666 | if (power_user.stream_fade_in) { |
| 3667 | applyStreamFadeIn(this.messageTextDom, formattedText); |
| 3668 | } else { |
| 3669 | this.messageTextDom.innerHTML = formattedText; |
| 3670 | } |
| 3671 | } |
| 3672 | |
| 3673 | const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount, this.reasoningHandler.getDuration(), this.timeToFirstToken); |
| 3674 | if (this.messageTimerDom instanceof HTMLElement) { |
| 3675 | this.messageTimerDom.textContent = timePassed.timerValue; |
| 3676 | this.messageTimerDom.title = timePassed.timerTitle; |
| 3677 | } |
| 3678 | |
| 3679 | this.setFirstSwipe(messageId); |
| 3680 | } |
| 3681 | |
| 3682 | if (!scrollLock) { |
| 3683 | scrollChatToBottom({ waitForFrame: true }); |
| 3684 | } |
| 3685 | } |
| 3686 | |
| 3687 | /** |
| 3688 | * Finalizes an intermediary message after generation is complete, or a tool call is performed. |
| 3689 | * Performs essential message processing (code blocks, reasoning, swipes, attachments, events) |
| 3690 | * without the heavier finish operations (UI unlock - optional, auto-swipe, sound, save chat). |
| 3691 | * @param {number} messageId - The message ID to finalize. |
| 3692 | * @param {string} text - The message text. |
| 3693 | * @param {Object} options - Additional options for finalization. |
| 3694 | * @param {boolean} options.unlockUI - Whether to unlock the generation UI. |
| 3695 | */ |
| 3696 | async finalizeIntermediaryMessage(messageId, text, { unlockUI = true }) { |
| 3697 | await this.onProgressStreaming(messageId, text, true); |
| 3698 | const messageElement = chatElement.find(`.mes[mesid="${messageId}"]`); |
| 3699 | const message = chat[messageId]; |
| 3700 | addCopyToCodeBlocks(messageElement); |
| 3701 | |
| 3702 | await this.reasoningHandler.finish(messageId); |
| 3703 | |
| 3704 | if (Array.isArray(this.swipes) && this.swipes.length > 0) { |
| 3705 | const swipeInfoExtra = structuredClone(message.extra ?? {}); |
| 3706 | delete swipeInfoExtra.token_count; |
| 3707 | delete swipeInfoExtra.reasoning; |
| 3708 | delete swipeInfoExtra.reasoning_duration; |
| 3709 | const swipeInfo = { |
| 3710 | send_date: message.send_date, |
| 3711 | gen_started: message.gen_started, |
| 3712 | gen_finished: message.gen_finished, |
| 3713 | extra: swipeInfoExtra, |
| 3714 | }; |
| 3715 | const swipeInfoArray = Array(this.swipes.length).fill().map(() => structuredClone(swipeInfo)); |
| 3716 | parseReasoningInSwipes(this.swipes, swipeInfoArray, message.extra?.reasoning_duration); |
| 3717 | message.swipes.push(...this.swipes); |
| 3718 | message.swipe_info.push(...swipeInfoArray); |
| 3719 | } |
| 3720 | |
| 3721 | syncMesToSwipe(messageId); |
| 3722 | saveLogprobsForActiveMessage(this.messageLogprobs.filter(Boolean), this.continueMessage); |
| 3723 | |
| 3724 | if (Array.isArray(this.images) && this.images.length > 0) { |
| 3725 | await processImageAttachment(message, { imageUrls: this.images }); |
| 3726 | appendMediaToMessage(message, $(this.messageDom)); |
| 3727 | } |
| 3728 | |
| 3729 | // Store reasoning signature for models that support multi-turn context |
| 3730 | if (this.reasoningSignature) { |
| 3731 | message.extra = message.extra || {}; |
| 3732 | message.extra.reasoning_signature = this.reasoningSignature; |
| 3733 | } |
| 3734 | |
| 3735 | if (unlockUI) { |
| 3736 | this.markUIGenStopped(); |
| 3737 | } |
| 3738 | |
| 3739 | if (this.type !== 'impersonate') { |
| 3740 | await eventSource.emit(event_types.MESSAGE_RECEIVED, this.messageId, this.type); |
| 3741 | await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, this.messageId, this.type); |
| 3742 | } else { |
| 3743 | await eventSource.emit(event_types.IMPERSONATE_READY, text); |
| 3744 | } |
| 3745 | |
| 3746 | updateSwipeCounter(messageId, { message, messageElement }); |
| 3747 | } |
| 3748 | |
| 3749 | async onFinishStreaming(messageId, text) { |
| 3750 | await this.finalizeIntermediaryMessage(messageId, text, { unlockUI: true }); |
| 3751 | |
| 3752 | const isAborted = this.abortController.signal.aborted; |
| 3753 | if (!isAborted && power_user.auto_swipe && generatedTextFiltered(text)) { |
| 3754 | return await swipe(null, SWIPE_DIRECTION.RIGHT, { source: SWIPE_SOURCE.AUTO_SWIPE, repeated: true, forceMesId: chat.length - 1 }); |
| 3755 | } |
| 3756 | await saveChatConditional(); |
| 3757 | |
| 3758 | playMessageSound(); |
| 3759 | } |
| 3760 | |
| 3761 | onErrorStreaming() { |
| 3762 | this.abortController.abort(); |
| 3763 | this.isStopped = true; |
| 3764 | |
| 3765 | this.markUIGenStopped(); |
| 3766 | |
| 3767 | const noEmitTypes = ['swipe', 'impersonate', 'continue']; |
| 3768 | if (!noEmitTypes.includes(this.type)) { |
| 3769 | eventSource.emit(event_types.MESSAGE_RECEIVED, this.messageId, this.type); |
| 3770 | eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, this.messageId, this.type); |
| 3771 | } |
| 3772 | } |
| 3773 | |
| 3774 | setFirstSwipe(messageId) { |
| 3775 | if (this.type !== 'swipe' && this.type !== 'impersonate') { |
| 3776 | if (Array.isArray(chat[messageId].swipes) && chat[messageId].swipes.length === 1 && chat[messageId].swipe_id === 0) { |
| 3777 | chat[messageId].swipes[0] = chat[messageId].mes; |
| 3778 | chat[messageId].swipe_info[0] = { |
| 3779 | 'send_date': chat[messageId].send_date, |
| 3780 | 'gen_started': chat[messageId].gen_started, |
| 3781 | 'gen_finished': chat[messageId].gen_finished, |
| 3782 | 'extra': structuredClone(chat[messageId].extra), |
| 3783 | }; |
| 3784 | } |
| 3785 | } |
| 3786 | } |
| 3787 | |
| 3788 | onStopStreaming() { |
| 3789 | this.abortController.abort(); |
| 3790 | this.isFinished = true; |
| 3791 | } |
| 3792 | |
| 3793 | /** |
| 3794 | * @returns {AsyncGenerator<{ text: string, swipes: string[], logprobs: import('./scripts/logprobs.js').TokenLogprobs, toolCalls: any[], state: any }, void, void>} |
| 3795 | */ |
| 3796 | async* nullStreamingGeneration() { |
| 3797 | throw new Error('Generation function for streaming is not hooked up'); |
| 3798 | } |
| 3799 | |
| 3800 | async generate() { |
| 3801 | if (this.messageId == -1) { |
| 3802 | this.messageId = await this.onStartStreaming(this.firstMessageText); |
| 3803 | await delay(1); // delay for message to be rendered |
| 3804 | scrollLock = false; |
| 3805 | } |
| 3806 | |
| 3807 | // Stopping strings are expensive to calculate, especially with macros enabled. To remove stopping strings |
| 3808 | // when streaming, we cache the result of getStoppingStrings instead of calling it once per token. |
| 3809 | const isImpersonate = this.type == 'impersonate'; |
| 3810 | const isContinue = this.type == 'continue'; |
| 3811 | this.stoppingStrings = getStoppingStrings(isImpersonate, isContinue, main_api); |
| 3812 | |
| 3813 | try { |
| 3814 | const sw = new Stopwatch(1000 / power_user.streaming_fps); |
| 3815 | const timestamps = []; |
| 3816 | for await (const { text, swipes, logprobs, toolCalls, state } of this.generator()) { |
| 3817 | const now = Date.now(); |
| 3818 | timestamps.push(now); |
| 3819 | if (!this.timeToFirstToken) { |
| 3820 | this.timeToFirstToken = now - this.createdAt.getTime(); |
| 3821 | } |
| 3822 | if (this.isStopped || this.abortController.signal.aborted) { |
| 3823 | return this.result; |
| 3824 | } |
| 3825 | |
| 3826 | this.toolCalls = toolCalls; |
| 3827 | this.result = text; |
| 3828 | this.swipes = Array.from(swipes ?? []); |
| 3829 | if (logprobs) { |
| 3830 | this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs])); |
| 3831 | } |
| 3832 | // Get the updated reasoning string into the handler |
| 3833 | this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning); |
| 3834 | this.images = state?.images ?? []; |
| 3835 | this.reasoningSignature = state?.signature ?? null; |
| 3836 | await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text); |
| 3837 | await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text)); |
| 3838 | } |
| 3839 | const seconds = (timestamps[timestamps.length - 1] - timestamps[0]) / 1000; |
| 3840 | console.warn(`Stream stats: ${timestamps.length} tokens, ${seconds.toFixed(2)} seconds, rate: ${Number(timestamps.length / seconds).toFixed(2)} TPS`); |
| 3841 | } catch (err) { |
| 3842 | // in the case of a self-inflicted abort, we have already cleaned up |
| 3843 | if (!this.isFinished) { |
| 3844 | console.error(err); |
| 3845 | this.onErrorStreaming(); |
| 3846 | } |
| 3847 | return this.result; |
| 3848 | } |
| 3849 | |
| 3850 | this.isFinished = true; |
| 3851 | return this.result; |
| 3852 | } |
| 3853 | } |
| 3854 | |
| 3855 | /** |
| 3856 | * Constructs a prompt to be used for either Text Completion or Chat Completion. Input is format-agnostic. |
| 3857 | * @param {string | object[]} prompt Input prompt. Can be a string or an array of chat-style messages, i.e. [{role: '', content: ''}, ...] |
| 3858 | * @param {string} api API to use. |
| 3859 | * @param {boolean} instructOverride true to override instruct mode, false to use the default value |
| 3860 | * @param {boolean} quietToLoud true to generate a message in system mode, false to generate a message in character mode |
| 3861 | * @param {string} [systemPrompt] System prompt to use. |
| 3862 | * @param {string} [prefill] Prefill for the prompt. |
| 3863 | * @returns {string | object[]} Prompt ready for use in generation. If using TC, this will be a string. If using CC, this will be an array of chat-style messages. |
| 3864 | */ |
| 3865 | export function createRawPrompt(prompt, api, instructOverride, quietToLoud, systemPrompt, prefill) { |
| 3866 | const isInstruct = power_user.instruct.enabled && api !== 'openai' && api !== 'novel' && !instructOverride; |
| 3867 | |
| 3868 | // If the prompt was given as a string, convert to a message-style object assuming user role |
| 3869 | if (typeof prompt === 'string') { |
| 3870 | const message = { role: 'user', content: prompt.trim() }; |
| 3871 | prompt = [message]; |
| 3872 | } else { // checks for message-style object |
| 3873 | if (prompt.length === 0 && !systemPrompt) throw Error('No messages provided'); |
| 3874 | } |
| 3875 | |
| 3876 | // Substitute the prefill if provided |
| 3877 | prefill = substituteParams(prefill ?? ''); |
| 3878 | |
| 3879 | // Format each message in the prompt, accounting for the provided roles |
| 3880 | for (const message of prompt) { |
| 3881 | let name = ''; |
| 3882 | if (message.role === 'user') name = message.name ?? name1; |
| 3883 | if (message.role === 'assistant') name = message.name ?? name2; |
| 3884 | if (message.role === 'system') name = message.name ?? ''; |
| 3885 | const prefix = isInstruct || api === 'openai' ? '' : (name ? `${name}: ` : ''); |
| 3886 | message.content = prefix + substituteParams(message.content ?? ''); |
| 3887 | if (isInstruct) { // instruct formatting for text completion |
| 3888 | const isUser = message.role === 'user'; |
| 3889 | const isNarrator = message.role === 'system'; |
| 3890 | message.content = formatInstructModeChat(name, message.content, isUser, isNarrator, '', name1, name2, false); |
| 3891 | } |
| 3892 | } |
| 3893 | |
| 3894 | // prepend system prompt, if provided |
| 3895 | if (systemPrompt) { |
| 3896 | systemPrompt = substituteParams(systemPrompt); |
| 3897 | systemPrompt = isInstruct ? formatInstructModeStoryString(systemPrompt) : systemPrompt.trim(); |
| 3898 | if (isInstruct && systemPrompt.length > 0 && !systemPrompt.endsWith('\n')) { |
| 3899 | if (power_user.instruct.wrap && !power_user.instruct.story_string_suffix) { |
| 3900 | systemPrompt += '\n'; |
| 3901 | } |
| 3902 | } |
| 3903 | prompt.unshift({ role: 'system', content: systemPrompt }); |
| 3904 | } |
| 3905 | |
| 3906 | // with Chat Completion, the prefill is an additional assistant message at the end. |
| 3907 | if (api === 'openai' && prefill) { |
| 3908 | prompt.push({ role: 'assistant', content: prefill }); |
| 3909 | } |
| 3910 | |
| 3911 | // if text completion, convert to text prompt by concatenating all message contents and adding the prefill as a promptBias. |
| 3912 | if (api !== 'openai') { |
| 3913 | const joiner = isInstruct ? '' : '\n'; |
| 3914 | prompt = prompt.map(message => message.content).join(joiner); |
| 3915 | prompt = api === 'novel' ? adjustNovelInstructionPrompt(prompt) : prompt; |
| 3916 | prompt = prompt + (isInstruct ? formatInstructModePrompt(name2, false, prefill, name1, name2, true, quietToLoud) : `\n${prefill}`); // add last line |
| 3917 | } |
| 3918 | |
| 3919 | return prompt; |
| 3920 | } |
| 3921 | |
| 3922 | /** |
| 3923 | * @typedef {object} GenerateRawParams |
| 3924 | * @prop {string | object[]} [prompt] Prompt to generate a message from. Can be a string or an array of chat-style messages, i.e. [{role: '', content: ''}, ...] |
| 3925 | * @prop {string} [api] API to use. Main API is used if not specified. |
| 3926 | * @prop {boolean} [instructOverride] true to override instruct mode, false to use the default value |
| 3927 | * @prop {boolean} [quietToLoud] true to generate a message in system mode, false to generate a message in character mode |
| 3928 | * @prop {string} [systemPrompt] System prompt to use. |
| 3929 | * @prop {number} [responseLength] Maximum response length. If unset, the global default value is used. |
| 3930 | * @prop {boolean} [trimNames] Whether to allow trimming "{{user}}:" and "{{char}}:" from the response. |
| 3931 | * @prop {string} [prefill] An optional prefill for the prompt. |
| 3932 | * @prop {JsonSchema} [jsonSchema] JSON schema to use for the structured generation. Usually requires a special instruction. |
| 3933 | */ |
| 3934 | |
| 3935 | /** |
| 3936 | * Generates a raw data object using the provided prompt. |
| 3937 | * This used to be part of `generateRaw`, but separating it out allows extensions to access other data such as reasoning message. |
| 3938 | * @param {GenerateRawParams} params Parameters for generating a message |
| 3939 | * @returns {Promise<object | string>} Raw API response data, or a JSON string extracted from the response when `jsonSchema` is provided. |
| 3940 | */ |
| 3941 | export async function generateRawData({ prompt = '', api = null, instructOverride = false, quietToLoud = false, systemPrompt = '', responseLength = null, prefill = '', jsonSchema = null } = {}) { |
| 3942 | if (!api) { |
| 3943 | api = main_api; |
| 3944 | } |
| 3945 | |
| 3946 | const abortController = new AbortController(); |
| 3947 | const responseLengthCustomized = typeof responseLength === 'number' && responseLength > 0; |
| 3948 | let eventHook = () => { }; |
| 3949 | |
| 3950 | // construct final prompt from the input. Can either be a string or an array of chat-style messages. |
| 3951 | prompt = createRawPrompt(prompt, api, instructOverride, quietToLoud, systemPrompt, prefill); |
| 3952 | |
| 3953 | // Allow extensions to stop generation before it happens |
| 3954 | const eventAbortController = new AbortController(); |
| 3955 | const abortHook = () => { |
| 3956 | abortController.abort(new Error('Cancelled by stop event')); |
| 3957 | eventAbortController.abort(new Error('Cancelled by extension')); |
| 3958 | }; |
| 3959 | eventSource.on(event_types.GENERATION_STOPPED, abortHook); |
| 3960 | |
| 3961 | try { |
| 3962 | if (responseLengthCustomized) { |
| 3963 | TempResponseLength.save(api, responseLength); |
| 3964 | } |
| 3965 | /** @type {object|any[]} */ |
| 3966 | let generateData = {}; |
| 3967 | |
| 3968 | // Allow extensions to modify the prompt before generation |
| 3969 | // 1. for text completion |
| 3970 | if (typeof prompt === 'string') { |
| 3971 | const eventData = { prompt: prompt, dryRun: false }; |
| 3972 | await eventSource.emit(event_types.GENERATE_AFTER_COMBINE_PROMPTS, eventData); |
| 3973 | prompt = eventData.prompt; |
| 3974 | } |
| 3975 | // 2. for chat completion |
| 3976 | if (Array.isArray(prompt)) { |
| 3977 | const eventData = { chat: prompt, dryRun: false }; |
| 3978 | await eventSource.emit(event_types.CHAT_COMPLETION_PROMPT_READY, eventData); |
| 3979 | prompt = eventData.chat; |
| 3980 | } |
| 3981 | |
| 3982 | // Check if the generation was aborted during the event |
| 3983 | eventAbortController.signal.throwIfAborted(); |
| 3984 | |
| 3985 | switch (api) { |
| 3986 | case 'kobold': |
| 3987 | case 'koboldhorde': |
| 3988 | if (kai_settings.preset_settings === 'gui') { |
| 3989 | generateData = { prompt: prompt, gui_settings: true, max_length: amount_gen, max_context_length: max_context, api_server: kai_settings.api_server }; |
| 3990 | } else { |
| 3991 | const isHorde = api === 'koboldhorde'; |
| 3992 | const koboldSettings = koboldai_settings[koboldai_setting_names[kai_settings.preset_settings]]; |
| 3993 | generateData = getKoboldGenerationData(prompt.toString(), koboldSettings, amount_gen, max_context, isHorde, 'quiet'); |
| 3994 | } |
| 3995 | TempResponseLength.restore(api); |
| 3996 | break; |
| 3997 | case 'novel': { |
| 3998 | const novelSettings = novelai_settings[novelai_setting_names[nai_settings.preset_settings_novel]]; |
| 3999 | generateData = getNovelGenerationData(prompt, novelSettings, amount_gen, false, false, null, 'quiet'); |
| 4000 | TempResponseLength.restore(api); |
| 4001 | break; |
| 4002 | } |
| 4003 | case 'textgenerationwebui': |
| 4004 | generateData = await getTextGenGenerationData(prompt, amount_gen, false, false, null, 'quiet'); |
| 4005 | TempResponseLength.restore(api); |
| 4006 | break; |
| 4007 | case 'openai': { |
| 4008 | generateData = prompt; // generateData is just the chat message object |
| 4009 | eventHook = TempResponseLength.setupEventHook(api); |
| 4010 | } break; |
| 4011 | } |
| 4012 | |
| 4013 | let data = {}; |
| 4014 | |
| 4015 | if (api === 'koboldhorde') { |
| 4016 | data = await generateHorde(prompt.toString(), generateData, abortController.signal, false); |
| 4017 | } else if (api === 'openai') { |
| 4018 | data = await sendOpenAIRequest('quiet', generateData, abortController.signal, { jsonSchema }); |
| 4019 | } else { |
| 4020 | const generateUrl = getGenerateUrl(api); |
| 4021 | const response = await fetch(generateUrl, { |
| 4022 | method: 'POST', |
| 4023 | headers: getRequestHeaders(), |
| 4024 | cache: 'no-cache', |
| 4025 | body: JSON.stringify(generateData), |
| 4026 | signal: abortController.signal, |
| 4027 | }); |
| 4028 | |
| 4029 | if (!response.ok) { |
| 4030 | throw await response.json(); |
| 4031 | } |
| 4032 | |
| 4033 | data = await response.json(); |
| 4034 | } |
| 4035 | |
| 4036 | // should only happen for text completions |
| 4037 | // other frontend paths do not return data if calling the backend fails, |
| 4038 | // they throw things instead |
| 4039 | if (data.error) { |
| 4040 | throw new Error(data.response); |
| 4041 | } |
| 4042 | |
| 4043 | if (jsonSchema) { |
| 4044 | return extractJsonFromData(data, { mainApi: api, returnInvalidJson: jsonSchema.returnInvalid }); |
| 4045 | } |
| 4046 | |
| 4047 | return data; |
| 4048 | } finally { |
| 4049 | eventSource.removeListener(event_types.GENERATION_STOPPED, abortHook); |
| 4050 | if (responseLengthCustomized && TempResponseLength.isCustomized()) { |
| 4051 | TempResponseLength.restore(api); |
| 4052 | TempResponseLength.removeEventHook(api, eventHook); |
| 4053 | } |
| 4054 | } |
| 4055 | } |
| 4056 | |
| 4057 | /** |
| 4058 | * Generates a message using the provided prompt. |
| 4059 | * If the prompt is an array of chat-style messages and not using chat completion, it will be converted to a text prompt. |
| 4060 | * @param {GenerateRawParams} params Parameters for generating a message |
| 4061 | * @returns {Promise<string>} Generated output: a cleaned-up message string when `jsonSchema` is not provided, or an extracted JSON string conforming to `jsonSchema` when it is. |
| 4062 | */ |
| 4063 | export async function generateRaw({ prompt = '', api = null, instructOverride = false, quietToLoud = false, systemPrompt = '', responseLength = null, trimNames = true, prefill = '', jsonSchema = null } = {}) { |
| 4064 | if (arguments.length > 0 && typeof arguments[0] !== 'object') { |
| 4065 | console.trace('generateRaw called with positional arguments. Please use an object instead.'); |
| 4066 | [prompt, api, instructOverride, quietToLoud, systemPrompt, responseLength, trimNames, prefill, jsonSchema] = arguments; |
| 4067 | } |
| 4068 | |
| 4069 | const data = await generateRawData({ prompt, api, instructOverride, quietToLoud, systemPrompt, responseLength, prefill, jsonSchema }); |
| 4070 | |
| 4071 | // JSON string (matching the provided schema) will already be extracted. |
| 4072 | if (jsonSchema) { |
| 4073 | return data; |
| 4074 | } |
| 4075 | |
| 4076 | // format result, exclude user prompt bias |
| 4077 | const message = cleanUpMessage({ |
| 4078 | getMessage: extractMessageFromData(data, api), |
| 4079 | isImpersonate: false, |
| 4080 | isContinue: false, |
| 4081 | displayIncompleteSentences: true, |
| 4082 | includeUserPromptBias: false, |
| 4083 | trimNames: trimNames, |
| 4084 | trimWrongNames: trimNames, |
| 4085 | }); |
| 4086 | |
| 4087 | if (!message) { |
| 4088 | throw new Error('No message generated'); |
| 4089 | } |
| 4090 | |
| 4091 | return message; |
| 4092 | } |
| 4093 | |
| 4094 | class TempResponseLength { |
| 4095 | static #originalResponseLength = -1; |
| 4096 | static #lastApi = null; |
| 4097 | |
| 4098 | static isCustomized() { |
| 4099 | return this.#originalResponseLength > -1; |
| 4100 | } |
| 4101 | |
| 4102 | /** |
| 4103 | * Save the current response length for the specified API. |
| 4104 | * @param {string} api API identifier |
| 4105 | * @param {number} responseLength New response length |
| 4106 | */ |
| 4107 | static save(api, responseLength) { |
| 4108 | if (api === 'openai') { |
| 4109 | this.#originalResponseLength = oai_settings.openai_max_tokens; |
| 4110 | oai_settings.openai_max_tokens = responseLength; |
| 4111 | } else { |
| 4112 | this.#originalResponseLength = amount_gen; |
| 4113 | amount_gen = responseLength; |
| 4114 | } |
| 4115 | |
| 4116 | this.#lastApi = api; |
| 4117 | console.log('[TempResponseLength] Saved original response length:', TempResponseLength.#originalResponseLength); |
| 4118 | } |
| 4119 | |
| 4120 | /** |
| 4121 | * Restore the original response length for the specified API. |
| 4122 | * @param {string|null} api API identifier |
| 4123 | * @returns {void} |
| 4124 | */ |
| 4125 | static restore(api) { |
| 4126 | if (this.#originalResponseLength === -1) { |
| 4127 | return; |
| 4128 | } |
| 4129 | if (!api && this.#lastApi) { |
| 4130 | api = this.#lastApi; |
| 4131 | } |
| 4132 | if (api === 'openai') { |
| 4133 | oai_settings.openai_max_tokens = this.#originalResponseLength; |
| 4134 | } else { |
| 4135 | amount_gen = this.#originalResponseLength; |
| 4136 | } |
| 4137 | |
| 4138 | console.log('[TempResponseLength] Restored original response length:', this.#originalResponseLength); |
| 4139 | this.#originalResponseLength = -1; |
| 4140 | this.#lastApi = null; |
| 4141 | } |
| 4142 | |
| 4143 | /** |
| 4144 | * Sets up an event hook to restore the original response length when the event is emitted. |
| 4145 | * @param {string} api API identifier |
| 4146 | * @returns {function(): void} Event hook function |
| 4147 | */ |
| 4148 | static setupEventHook(api) { |
| 4149 | const eventHook = () => { |
| 4150 | if (this.isCustomized()) { |
| 4151 | this.restore(api); |
| 4152 | } |
| 4153 | }; |
| 4154 | |
| 4155 | switch (api) { |
| 4156 | case 'openai': |
| 4157 | eventSource.once(event_types.CHAT_COMPLETION_SETTINGS_READY, eventHook); |
| 4158 | break; |
| 4159 | default: |
| 4160 | eventSource.once(event_types.GENERATE_AFTER_DATA, eventHook); |
| 4161 | break; |
| 4162 | } |
| 4163 | |
| 4164 | return eventHook; |
| 4165 | } |
| 4166 | |
| 4167 | /** |
| 4168 | * Removes the event hook for the specified API. |
| 4169 | * @param {string} api API identifier |
| 4170 | * @param {function(): void} eventHook Previously set up event hook |
| 4171 | */ |
| 4172 | static removeEventHook(api, eventHook) { |
| 4173 | switch (api) { |
| 4174 | case 'openai': |
| 4175 | eventSource.removeListener(event_types.CHAT_COMPLETION_SETTINGS_READY, eventHook); |
| 4176 | break; |
| 4177 | default: |
| 4178 | eventSource.removeListener(event_types.GENERATE_AFTER_DATA, eventHook); |
| 4179 | break; |
| 4180 | } |
| 4181 | } |
| 4182 | } |
| 4183 | |
| 4184 | /** |
| 4185 | * Removes last message from the chat DOM. |
| 4186 | * @returns {Promise<void>} Resolves when the message is removed. |
| 4187 | */ |
| 4188 | function removeLastMessage() { |
| 4189 | return new Promise((resolve) => { |
| 4190 | const lastMes = chatElement.children('.mes').last(); |
| 4191 | if (lastMes.length === 0) { |
| 4192 | return resolve(); |
| 4193 | } |
| 4194 | lastMes.hide(animation_duration, function () { |
| 4195 | $(this).remove(); |
| 4196 | resolve(); |
| 4197 | }); |
| 4198 | }); |
| 4199 | } |
| 4200 | |
| 4201 | /** |
| 4202 | * @typedef {object} JsonSchema |
| 4203 | * @property {string} name Name of the schema. |
| 4204 | * @property {object} value JSON schema value. |
| 4205 | * @property {string} [description] Description of the schema. |
| 4206 | * @property {boolean} [strict] If true, the schema will be used in strict mode, meaning that only the fields defined in the schema will be allowed. |
| 4207 | * @property {boolean} [returnInvalid] If true, a string that can't be parsed as a JSON will be returned as is, instead of an empty object. |
| 4208 | * |
| 4209 | * @typedef {object} GenerateOptions |
| 4210 | * @property {boolean} [automatic_trigger] If the generation was triggered automatically (e.g. group auto mode). |
| 4211 | * @property {boolean} [force_name2] If a char name should be forced to add to the prompt's last line (Text Completion, non-Instruct only). |
| 4212 | * @property {string} [quiet_prompt] A system instruction to use for the quiet prompt. |
| 4213 | * @property {boolean} [quietToLoud] Whether the system instruction should be sent in background (quiet) or a foreground (loud) mode. |
| 4214 | * @property {boolean} [skipWIAN] Skip adding World Info and Author's Note to the prompt. |
| 4215 | * @property {number} [force_chid] Force character ID to use for the generation. Only works in groups. |
| 4216 | * @property {AbortSignal} [signal] Abort signal to cancel the generation. If not provided, will create a new AbortController. |
| 4217 | * @property {string} [quietImage] Image URL to use for the quiet prompt (defaults to empty string) |
| 4218 | * @property {string} [quietName] Name to use for the quiet prompt (defaults to "System:") |
| 4219 | * @property {number} [depth] Recursion depth for the generation. Used to prevent infinite loops in tool calls. |
| 4220 | * @property {JsonSchema} [jsonSchema] JSON schema to use for the structured generation. Usually requires a special instruction. |
| 4221 | */ |
| 4222 | |
| 4223 | /** |
| 4224 | * MARK:Generate() |
| 4225 | * Runs a generation using the current chat context. |
| 4226 | * @param {string} type Generation type |
| 4227 | * @param {GenerateOptions} options Generation options |
| 4228 | * @param {boolean} dryRun Whether to actually generate a message or just assemble the prompt |
| 4229 | * @returns {Promise<any>} Returns a promise that resolves when the text is done generating. |
| 4230 | */ |
| 4231 | export async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage, quietName, jsonSchema = null, depth = 0 } = {}, dryRun = false) { |
| 4232 | console.log('Generate entered'); |
| 4233 | setGenerationProgress(0); |
| 4234 | generation_started = new Date(); |
| 4235 | |
| 4236 | // Prevent generation from shallow characters |
| 4237 | await unshallowCharacter(this_chid); |
| 4238 | |
| 4239 | // Occurs every time, even if the generation is aborted due to slash commands execution |
| 4240 | await eventSource.emit(event_types.GENERATION_STARTED, type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage }, dryRun); |
| 4241 | |
| 4242 | // Don't recreate abort controller if signal is passed |
| 4243 | if (!(abortController && signal)) { |
| 4244 | abortController = new AbortController(); |
| 4245 | } |
| 4246 | |
| 4247 | // OpenAI doesn't need instruct mode. Use OAI main prompt instead. |
| 4248 | const isInstruct = power_user.instruct.enabled && main_api !== 'openai'; |
| 4249 | const isImpersonate = type == 'impersonate'; |
| 4250 | |
| 4251 | if (!(dryRun || depth || type == 'regenerate' || type == 'swipe' || type == 'quiet')) { |
| 4252 | const interruptedByCommand = await processCommands(String($('#send_textarea').val())); |
| 4253 | |
| 4254 | if (interruptedByCommand) { |
| 4255 | //$("#send_textarea").val('')[0].dispatchEvent(new Event('input', { bubbles:true })); |
| 4256 | unblockGeneration(type); |
| 4257 | return Promise.resolve(); |
| 4258 | } |
| 4259 | } |
| 4260 | |
| 4261 | // Occurs only if the generation is not aborted due to slash commands execution |
| 4262 | await eventSource.emit(event_types.GENERATION_AFTER_COMMANDS, type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage }, dryRun); |
| 4263 | |
| 4264 | if (main_api == 'kobold' && kai_settings.streaming_kobold && !kai_flags.can_use_streaming) { |
| 4265 | toastr.error(t`Streaming is enabled, but the version of Kobold used does not support token streaming.`, undefined, { timeOut: 10000, preventDuplicates: true }); |
| 4266 | unblockGeneration(type); |
| 4267 | return Promise.resolve(); |
| 4268 | } |
| 4269 | |
| 4270 | if (isHordeGenerationNotAllowed()) { |
| 4271 | unblockGeneration(type); |
| 4272 | return Promise.resolve(); |
| 4273 | } |
| 4274 | |
| 4275 | if (!dryRun) { |
| 4276 | // Ping server to make sure it is still alive |
| 4277 | const pingResult = await pingServer(); |
| 4278 | |
| 4279 | if (!pingResult) { |
| 4280 | unblockGeneration(type); |
| 4281 | toastr.error(t`Verify that the server is running and accessible.`, t`ST Server cannot be reached`); |
| 4282 | throw new Error('Server unreachable'); |
| 4283 | } |
| 4284 | |
| 4285 | // Hide swipes if not in a dry run. |
| 4286 | hideSwipeButtons(); |
| 4287 | // If generated any message, set the flag to indicate it can't be recreated again. |
| 4288 | chat_metadata.tainted = true; |
| 4289 | } |
| 4290 | |
| 4291 | if (selected_group && !is_group_generating) { |
| 4292 | if (!dryRun) { |
| 4293 | // Returns the promise that generateGroupWrapper returns; resolves when generation is done |
| 4294 | return generateGroupWrapper(false, type, { quiet_prompt, force_chid, signal: abortController.signal, quietImage, jsonSchema }); |
| 4295 | } |
| 4296 | |
| 4297 | const characterIndexMap = new Map(characters.map((char, index) => [char.avatar, index])); |
| 4298 | const group = groups.find((x) => x.id === selected_group); |
| 4299 | |
| 4300 | const enabledMembers = group.members.reduce((acc, member) => { |
| 4301 | if (!group.disabled_members.includes(member) && !acc.includes(member)) { |
| 4302 | acc.push(member); |
| 4303 | } |
| 4304 | return acc; |
| 4305 | }, []); |
| 4306 | |
| 4307 | const memberIds = enabledMembers |
| 4308 | .map((member) => characterIndexMap.get(member)) |
| 4309 | .filter((index) => index !== undefined && index !== null); |
| 4310 | |
| 4311 | if (memberIds.length > 0) { |
| 4312 | if (menu_type != 'character_edit') setCharacterId(memberIds[0]); |
| 4313 | setCharacterName(''); |
| 4314 | } else { |
| 4315 | console.log('No enabled members found'); |
| 4316 | unblockGeneration(type); |
| 4317 | return Promise.resolve(); |
| 4318 | } |
| 4319 | } |
| 4320 | |
| 4321 | //#########QUIET PROMPT STUFF############## |
| 4322 | //this function just gives special care to novel quiet instruction prompts |
| 4323 | if (quiet_prompt) { |
| 4324 | quiet_prompt = substituteParams(quiet_prompt); |
| 4325 | quiet_prompt = main_api == 'novel' && !quietToLoud ? adjustNovelInstructionPrompt(quiet_prompt) : quiet_prompt; |
| 4326 | } |
| 4327 | |
| 4328 | const hasBackendConnection = online_status !== 'no_connection'; |
| 4329 | |
| 4330 | // We can't do anything because we're not in a chat right now. (Unless it's a dry run, in which case we need to |
| 4331 | // assemble the prompt so we can count its tokens regardless of whether a chat is active.) |
| 4332 | if (!dryRun && !hasBackendConnection) { |
| 4333 | is_send_press = false; |
| 4334 | return Promise.resolve(); |
| 4335 | } |
| 4336 | |
| 4337 | const lastMessage = chat[chat.length - 1]; |
| 4338 | |
| 4339 | let textareaText; |
| 4340 | if (type !== 'regenerate' && type !== 'swipe' && type !== 'quiet' && !isImpersonate && !dryRun && !depth) { |
| 4341 | is_send_press = true; |
| 4342 | textareaText = String($('#send_textarea').val()); |
| 4343 | $('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles: true })); |
| 4344 | } else { |
| 4345 | textareaText = ''; |
| 4346 | if (chat.length && lastMessage.is_user) { |
| 4347 | //do nothing? why does this check exist? |
| 4348 | } else if (type !== 'quiet' && type !== 'swipe' && !isImpersonate && !dryRun && !depth && chat.length) { |
| 4349 | deleteItemizedPromptForMessage(chat.length - 1); |
| 4350 | chat.length = chat.length - 1; |
| 4351 | await removeLastMessage(); |
| 4352 | await eventSource.emit(event_types.MESSAGE_DELETED, chat.length); |
| 4353 | } |
| 4354 | } |
| 4355 | |
| 4356 | const isContinue = type == 'continue'; |
| 4357 | |
| 4358 | // Rewrite the generation timer to account for the time passed for all the continuations. |
| 4359 | if (isContinue && chat.length) { |
| 4360 | const prevFinished = lastMessage.gen_finished; |
| 4361 | const prevStarted = lastMessage.gen_started; |
| 4362 | |
| 4363 | if (prevFinished && prevStarted) { |
| 4364 | const timePassed = Number(prevFinished) - Number(prevStarted); |
| 4365 | generation_started = new Date(Date.now() - timePassed); |
| 4366 | lastMessage.gen_started = generation_started; |
| 4367 | } |
| 4368 | } |
| 4369 | |
| 4370 | if (!dryRun) { |
| 4371 | deactivateSendButtons(); |
| 4372 | } |
| 4373 | |
| 4374 | let { messageBias, promptBias, isUserPromptBias } = getBiasStrings(textareaText, type); |
| 4375 | |
| 4376 | //********************************* |
| 4377 | //PRE FORMATING STRING |
| 4378 | //********************************* |
| 4379 | |
| 4380 | // These generation types should not attach pending files to the chat |
| 4381 | const noAttachTypes = [ |
| 4382 | 'regenerate', |
| 4383 | 'swipe', |
| 4384 | 'impersonate', |
| 4385 | 'quiet', |
| 4386 | 'continue', |
| 4387 | ]; |
| 4388 | //for normal messages sent from user.. |
| 4389 | if ((textareaText != '' || (hasPendingFileAttachment() && !noAttachTypes.includes(type))) && !automatic_trigger && type !== 'quiet' && !dryRun && !depth) { |
| 4390 | // If user message contains no text other than bias - send as a system message |
| 4391 | if (messageBias && !removeMacros(textareaText)) { |
| 4392 | sendSystemMessage(system_message_types.GENERIC, ' ', { bias: messageBias }); |
| 4393 | } else { |
| 4394 | await sendMessageAsUser(textareaText, messageBias); |
| 4395 | } |
| 4396 | } else if (textareaText == '' && !automatic_trigger && !dryRun && [undefined, 'normal'].includes(type) && main_api == 'openai' && oai_settings.send_if_empty.trim().length > 0 && !depth) { |
| 4397 | // Use send_if_empty if set and the user message is empty. Only when sending messages normally |
| 4398 | await sendMessageAsUser(oai_settings.send_if_empty.trim(), messageBias); |
| 4399 | } |
| 4400 | |
| 4401 | let { |
| 4402 | description, |
| 4403 | personality, |
| 4404 | persona, |
| 4405 | scenario, |
| 4406 | mesExamples, |
| 4407 | system, |
| 4408 | jailbreak, |
| 4409 | charDepthPrompt, |
| 4410 | creatorNotes, |
| 4411 | } = getCharacterCardFields(); |
| 4412 | |
| 4413 | // Depth prompt (character-specific A/N) |
| 4414 | removeDepthPrompts(); |
| 4415 | const groupDepthPrompts = getGroupDepthPrompts(selected_group, Number(this_chid)); |
| 4416 | |
| 4417 | if (selected_group && Array.isArray(groupDepthPrompts) && groupDepthPrompts.length > 0) { |
| 4418 | groupDepthPrompts.forEach((value, index) => { |
| 4419 | const role = getExtensionPromptRoleByName(value.role); |
| 4420 | setExtensionPrompt(inject_ids.DEPTH_PROMPT_INDEX(index), value.text, extension_prompt_types.IN_CHAT, value.depth, extension_settings.note.allowWIScan, role); |
| 4421 | }); |
| 4422 | } else { |
| 4423 | const depthPromptText = charDepthPrompt || ''; |
| 4424 | const depthPromptDepth = characters[this_chid]?.data?.extensions?.depth_prompt?.depth ?? depth_prompt_depth_default; |
| 4425 | const depthPromptRole = getExtensionPromptRoleByName(characters[this_chid]?.data?.extensions?.depth_prompt?.role ?? depth_prompt_role_default); |
| 4426 | setExtensionPrompt(inject_ids.DEPTH_PROMPT, depthPromptText, extension_prompt_types.IN_CHAT, depthPromptDepth, extension_settings.note.allowWIScan, depthPromptRole); |
| 4427 | } |
| 4428 | |
| 4429 | // First message in fresh 1-on-1 chat reacts to user/character settings changes |
| 4430 | if (chat.length) { |
| 4431 | chat[0].mes = substituteParams(chat[0].mes); |
| 4432 | } |
| 4433 | |
| 4434 | // Collect messages with usable content |
| 4435 | const canUseTools = ToolManager.isToolCallingSupported(); |
| 4436 | const canPerformToolCalls = !dryRun && ToolManager.canPerformToolCalls(type) && depth < ToolManager.RECURSE_LIMIT; |
| 4437 | let coreChat = chat.filter(x => !x.is_system || (canUseTools && Array.isArray(x.extra?.tool_invocations))); |
| 4438 | if (type === 'swipe') { |
| 4439 | coreChat.pop(); |
| 4440 | } |
| 4441 | |
| 4442 | coreChat = await Promise.all(coreChat.map(async (/** @type {ChatMessage} */ chatItem, index) => { |
| 4443 | let message = chatItem.mes; |
| 4444 | let regexType = chatItem.is_user ? regex_placement.USER_INPUT : regex_placement.AI_OUTPUT; |
| 4445 | let options = { isPrompt: true, depth: (coreChat.length - index - (isContinue ? 2 : 1)) }; |
| 4446 | |
| 4447 | let regexedMessage = getRegexedString(message, regexType, options); |
| 4448 | regexedMessage = await appendFileContent(chatItem, regexedMessage); |
| 4449 | |
| 4450 | const titles = []; |
| 4451 | if (chatItem?.extra?.append_title && chatItem?.extra?.title) { |
| 4452 | titles.push(chatItem.extra.title); |
| 4453 | } |
| 4454 | if (Array.isArray(chatItem?.extra?.media)) { |
| 4455 | for (const mediaItem of chatItem.extra.media) { |
| 4456 | if (mediaItem?.title && mediaItem?.append_title) { |
| 4457 | titles.push(mediaItem.title); |
| 4458 | } |
| 4459 | } |
| 4460 | } |
| 4461 | if (titles.length > 0) { |
| 4462 | regexedMessage = `${regexedMessage}\n\n${titles.join('\n\n')}`; |
| 4463 | } |
| 4464 | |
| 4465 | return { |
| 4466 | ...chatItem, |
| 4467 | mes: regexedMessage, |
| 4468 | index, |
| 4469 | }; |
| 4470 | })); |
| 4471 | |
| 4472 | const promptReasoning = new PromptReasoning(); |
| 4473 | for (let i = coreChat.length - 1; i >= 0; i--) { |
| 4474 | const depth = coreChat.length - i - (isContinue ? 2 : 1); |
| 4475 | const isPrefix = isContinue && i === coreChat.length - 1; |
| 4476 | |
| 4477 | // In group chats, only include reasoning from the currently generating character |
| 4478 | const isOtherGroupMember = selected_group && coreChat[i].name !== name2; |
| 4479 | |
| 4480 | coreChat[i] = { |
| 4481 | ...coreChat[i], |
| 4482 | mes: isOtherGroupMember |
| 4483 | ? coreChat[i].mes |
| 4484 | : promptReasoning.addToMessage( |
| 4485 | coreChat[i].mes, |
| 4486 | getRegexedString( |
| 4487 | String(coreChat[i].extra?.reasoning ?? ''), |
| 4488 | regex_placement.REASONING, |
| 4489 | { isPrompt: true, depth: depth }, |
| 4490 | ), |
| 4491 | isPrefix, |
| 4492 | coreChat[i].extra?.reasoning_duration, |
| 4493 | ), |
| 4494 | }; |
| 4495 | if (promptReasoning.isLimitReached()) { |
| 4496 | break; |
| 4497 | } |
| 4498 | } |
| 4499 | |
| 4500 | // Determine token limit |
| 4501 | let this_max_context = getMaxPromptTokens(); |
| 4502 | |
| 4503 | if (!dryRun) { |
| 4504 | console.debug('Running extension interceptors'); |
| 4505 | const aborted = await runGenerationInterceptors(coreChat, this_max_context, type); |
| 4506 | |
| 4507 | if (aborted) { |
| 4508 | console.debug('Generation aborted by extension interceptors'); |
| 4509 | unblockGeneration(type); |
| 4510 | return Promise.resolve(); |
| 4511 | } |
| 4512 | } else { |
| 4513 | console.debug('Skipping extension interceptors for dry run'); |
| 4514 | } |
| 4515 | |
| 4516 | // Adjust token limit for Horde |
| 4517 | let adjustedParams; |
| 4518 | if (main_api == 'koboldhorde' && (horde_settings.auto_adjust_context_length || horde_settings.auto_adjust_response_length)) { |
| 4519 | try { |
| 4520 | adjustedParams = await adjustHordeGenerationParams(max_context, amount_gen); |
| 4521 | } catch { |
| 4522 | unblockGeneration(type); |
| 4523 | return Promise.resolve(); |
| 4524 | } |
| 4525 | if (horde_settings.auto_adjust_context_length) { |
| 4526 | this_max_context = (adjustedParams.maxContextLength - adjustedParams.maxLength); |
| 4527 | } |
| 4528 | } |
| 4529 | |
| 4530 | // Fetches the combined prompt for both negative and positive prompts |
| 4531 | const cfgGuidanceScale = getGuidanceScale(); |
| 4532 | const useCfgPrompt = cfgGuidanceScale && cfgGuidanceScale.value !== 1; |
| 4533 | |
| 4534 | // Adjust max context based on CFG prompt to prevent overfitting |
| 4535 | if (useCfgPrompt) { |
| 4536 | const negativePrompt = getCfgPrompt(cfgGuidanceScale, true, true)?.value || ''; |
| 4537 | const positivePrompt = getCfgPrompt(cfgGuidanceScale, false, true)?.value || ''; |
| 4538 | if (negativePrompt || positivePrompt) { |
| 4539 | const previousMaxContext = this_max_context; |
| 4540 | const [negativePromptTokenCount, positivePromptTokenCount] = await Promise.all([getTokenCountAsync(negativePrompt), getTokenCountAsync(positivePrompt)]); |
| 4541 | const decrement = Math.max(negativePromptTokenCount, positivePromptTokenCount); |
| 4542 | this_max_context -= decrement; |
| 4543 | console.log(`Max context reduced by ${decrement} tokens of CFG prompt (${previousMaxContext} -> ${this_max_context})`); |
| 4544 | } |
| 4545 | } |
| 4546 | |
| 4547 | console.log(`Core/all messages: ${coreChat.length}/${chat.length}`); |
| 4548 | |
| 4549 | if ((promptBias && !isUserPromptBias) || power_user.always_force_name2 || main_api == 'novel') { |
| 4550 | force_name2 = true; |
| 4551 | } |
| 4552 | |
| 4553 | if (isImpersonate) { |
| 4554 | force_name2 = false; |
| 4555 | } |
| 4556 | |
| 4557 | let mesExamplesArray = parseMesExamples(mesExamples, isInstruct); |
| 4558 | |
| 4559 | // Set non-WI AN |
| 4560 | setFloatingPrompt(); |
| 4561 | |
| 4562 | // Add WI to prompt (and also inject WI to AN value via hijack) |
| 4563 | // Make quiet prompt available for WIAN |
| 4564 | setExtensionPrompt(inject_ids.QUIET_PROMPT, quiet_prompt || '', extension_prompt_types.IN_PROMPT, 0, true); |
| 4565 | const chatForWI = coreChat.map(x => world_info_include_names ? `${x.name}: ${x.mes}` : x.mes).reverse(); |
| 4566 | /** @type {import('./scripts/world-info.js').WIGlobalScanData} */ |
| 4567 | const globalScanData = { |
| 4568 | personaDescription: persona, |
| 4569 | characterDescription: description, |
| 4570 | characterPersonality: personality, |
| 4571 | characterDepthPrompt: charDepthPrompt, |
| 4572 | scenario: scenario, |
| 4573 | creatorNotes: creatorNotes, |
| 4574 | trigger: GENERATION_TYPE_TRIGGERS.includes(type) ? type : 'normal', |
| 4575 | }; |
| 4576 | const { worldInfoString, worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth, outletEntries } = await getWorldInfoPrompt(chatForWI, this_max_context, dryRun, globalScanData); |
| 4577 | setExtensionPrompt(inject_ids.QUIET_PROMPT, '', extension_prompt_types.IN_PROMPT, 0, true); |
| 4578 | |
| 4579 | // Add message example WI |
| 4580 | for (const example of worldInfoExamples) { |
| 4581 | const exampleMessage = example.content; |
| 4582 | |
| 4583 | if (exampleMessage.length === 0) { |
| 4584 | continue; |
| 4585 | } |
| 4586 | |
| 4587 | const formattedExample = baseChatReplace(exampleMessage); |
| 4588 | const cleanedExample = parseMesExamples(formattedExample, isInstruct); |
| 4589 | |
| 4590 | // Insert depending on before or after position |
| 4591 | if (example.position === wi_anchor_position.before) { |
| 4592 | mesExamplesArray.unshift(...cleanedExample); |
| 4593 | } else { |
| 4594 | mesExamplesArray.push(...cleanedExample); |
| 4595 | } |
| 4596 | } |
| 4597 | |
| 4598 | // At this point, the raw message examples can be created |
| 4599 | const mesExamplesRawArray = [...mesExamplesArray]; |
| 4600 | |
| 4601 | if (mesExamplesArray && isInstruct) { |
| 4602 | mesExamplesArray = formatInstructModeExamples(mesExamplesArray, name1, name2); |
| 4603 | } |
| 4604 | |
| 4605 | if (skipWIAN !== true) { |
| 4606 | console.log('skipWIAN not active, adding WIAN'); |
| 4607 | // Add all depth WI entries to prompt |
| 4608 | flushWIInjections(); |
| 4609 | if (Array.isArray(worldInfoDepth)) { |
| 4610 | worldInfoDepth.forEach((e) => { |
| 4611 | const joinedEntries = e.entries.join('\n'); |
| 4612 | setExtensionPrompt(inject_ids.CUSTOM_WI_DEPTH_ROLE(e.depth, e.role), joinedEntries, extension_prompt_types.IN_CHAT, e.depth, false, e.role); |
| 4613 | }); |
| 4614 | } |
| 4615 | if (outletEntries && typeof outletEntries === 'object' && Object.keys(outletEntries).length > 0) { |
| 4616 | Object.entries(outletEntries).forEach(([key, value]) => { |
| 4617 | setExtensionPrompt(inject_ids.CUSTOM_WI_OUTLET(key), value.join('\n'), extension_prompt_types.NONE, 0); |
| 4618 | }); |
| 4619 | } |
| 4620 | } else { |
| 4621 | console.log('skipping WIAN'); |
| 4622 | } |
| 4623 | |
| 4624 | // Add persona description to prompt |
| 4625 | addPersonaDescriptionExtensionPrompt(); |
| 4626 | |
| 4627 | // Prepare the system prompt for Text Completion APIs |
| 4628 | if (main_api !== 'openai') { |
| 4629 | if (power_user.sysprompt.enabled) { |
| 4630 | system = power_user.prefer_character_prompt && system |
| 4631 | ? substituteParams(system, { original: power_user.sysprompt.content ?? '' }) |
| 4632 | : baseChatReplace(power_user.sysprompt.content); |
| 4633 | system = isInstruct ? substituteParams(system, { original: power_user.sysprompt.content ?? '' }) : system; |
| 4634 | } else { |
| 4635 | // Nullify if it's not enabled |
| 4636 | system = ''; |
| 4637 | } |
| 4638 | } |
| 4639 | |
| 4640 | // Collect before / after story string injections |
| 4641 | const beforeScenarioAnchor = await getExtensionPrompt(extension_prompt_types.BEFORE_PROMPT); |
| 4642 | const afterScenarioAnchor = await getExtensionPrompt(extension_prompt_types.IN_PROMPT); |
| 4643 | |
| 4644 | const storyStringParams = { |
| 4645 | description: description, |
| 4646 | personality: personality, |
| 4647 | persona: power_user.persona_description_position == persona_description_positions.IN_PROMPT ? persona : '', |
| 4648 | scenario: scenario, |
| 4649 | system: system, |
| 4650 | char: name2, |
| 4651 | user: name1, |
| 4652 | wiBefore: worldInfoBefore, |
| 4653 | wiAfter: worldInfoAfter, |
| 4654 | loreBefore: worldInfoBefore, |
| 4655 | loreAfter: worldInfoAfter, |
| 4656 | anchorBefore: beforeScenarioAnchor.trim(), |
| 4657 | anchorAfter: afterScenarioAnchor.trim(), |
| 4658 | mesExamples: mesExamplesArray.join(''), |
| 4659 | mesExamplesRaw: mesExamplesRawArray.join(''), |
| 4660 | }; |
| 4661 | |
| 4662 | // Render the story string and combine with injections |
| 4663 | const storyString = renderStoryString(storyStringParams); |
| 4664 | let combinedStoryString = isInstruct ? formatInstructModeStoryString(storyString) : storyString; |
| 4665 | |
| 4666 | // Inject the story string as in-chat prompt (if needed) |
| 4667 | const applyStoryStringInject = main_api !== 'openai' && power_user.context.story_string_position === extension_prompt_types.IN_CHAT; |
| 4668 | if (applyStoryStringInject) { |
| 4669 | const depth = power_user.context.story_string_depth ?? 1; |
| 4670 | const role = power_user.context.story_string_role ?? extension_prompt_roles.SYSTEM; |
| 4671 | setExtensionPrompt(inject_ids.STORY_STRING, combinedStoryString, extension_prompt_types.IN_CHAT, depth, false, role); |
| 4672 | // Remove to prevent duplication |
| 4673 | combinedStoryString = ''; |
| 4674 | } else { |
| 4675 | setExtensionPrompt(inject_ids.STORY_STRING, '', extension_prompt_types.IN_CHAT, 0); |
| 4676 | } |
| 4677 | |
| 4678 | // Story string rendered, safe to remove |
| 4679 | if (power_user.strip_examples) { |
| 4680 | mesExamplesArray = []; |
| 4681 | } |
| 4682 | |
| 4683 | // Inject all Depth prompts. Chat Completion does it separately |
| 4684 | let injectedIndices = []; |
| 4685 | if (main_api !== 'openai') { |
| 4686 | injectedIndices = await doChatInject(coreChat, isContinue); |
| 4687 | } |
| 4688 | |
| 4689 | if (main_api !== 'openai' && power_user.sysprompt.enabled) { |
| 4690 | jailbreak = power_user.prefer_character_jailbreak && jailbreak |
| 4691 | ? substituteParams(jailbreak, { original: power_user.sysprompt.post_history ?? '' }) |
| 4692 | : baseChatReplace(power_user.sysprompt.post_history); |
| 4693 | |
| 4694 | // Only inject the jb if there is one |
| 4695 | if (jailbreak) { |
| 4696 | // When continuing generation of previous output, last user message precedes the message to continue |
| 4697 | if (isContinue) { |
| 4698 | coreChat.splice(coreChat.length - 1, 0, { mes: jailbreak, is_user: true }); |
| 4699 | } else { |
| 4700 | // This operation will result in the injectedIndices indexes being off by one |
| 4701 | coreChat.push({ mes: jailbreak, is_user: true }); |
| 4702 | // Add +1 to the elements to correct for the new PHI/Jailbreak message. |
| 4703 | injectedIndices.forEach(shiftUpByOne); |
| 4704 | } |
| 4705 | } |
| 4706 | } |
| 4707 | |
| 4708 | let chat2 = []; |
| 4709 | let continue_mag = ''; |
| 4710 | let userMessageIndices = []; |
| 4711 | const lastUserMessageIndex = coreChat.findLastIndex(x => x.is_user); |
| 4712 | |
| 4713 | for (let i = coreChat.length - 1, j = 0; i >= 0; i--, j++) { |
| 4714 | if (main_api == 'openai') { |
| 4715 | chat2[i] = coreChat[j].mes; |
| 4716 | if (i === 0 && isContinue) { |
| 4717 | chat2[i] = chat2[i].slice(0, chat2[i].lastIndexOf(coreChat[j].mes) + coreChat[j].mes.length); |
| 4718 | continue_mag = coreChat[j].mes; |
| 4719 | } |
| 4720 | continue; |
| 4721 | } |
| 4722 | |
| 4723 | chat2[i] = formatMessageHistoryItem(coreChat[j], isInstruct, false); |
| 4724 | |
| 4725 | if (j === 0 && isInstruct) { |
| 4726 | // Reformat with the first output sequence (if any) |
| 4727 | chat2[i] = formatMessageHistoryItem(coreChat[j], isInstruct, force_output_sequence.FIRST); |
| 4728 | } |
| 4729 | |
| 4730 | if (lastUserMessageIndex >= 0 && j === lastUserMessageIndex && isInstruct && !isImpersonate) { |
| 4731 | // Reformat with the last input sequence (if any) |
| 4732 | chat2[i] = formatMessageHistoryItem(coreChat[j], isInstruct, force_output_sequence.LAST); |
| 4733 | } |
| 4734 | |
| 4735 | // Do not suffix the message for continuation |
| 4736 | if (i === 0 && isContinue) { |
| 4737 | // Pick something that's very unlikely to be in a message |
| 4738 | const FORMAT_TOKEN = '\u0000\ufffc\u0000\ufffd'; |
| 4739 | |
| 4740 | if (isInstruct) { |
| 4741 | const originalMessage = String(coreChat[j].mes ?? ''); |
| 4742 | coreChat[j].mes = originalMessage.replaceAll(FORMAT_TOKEN, '') + FORMAT_TOKEN; |
| 4743 | // Reformat with the last output sequence (if any) |
| 4744 | chat2[i] = formatMessageHistoryItem(coreChat[j], isInstruct, force_output_sequence.LAST); |
| 4745 | coreChat[j].mes = originalMessage; |
| 4746 | } |
| 4747 | |
| 4748 | chat2[i] = chat2[i].includes(FORMAT_TOKEN) |
| 4749 | ? chat2[i].slice(0, chat2[i].lastIndexOf(FORMAT_TOKEN)) |
| 4750 | : chat2[i].slice(0, chat2[i].lastIndexOf(coreChat[j].mes) + coreChat[j].mes.length); |
| 4751 | continue_mag = coreChat[j].mes; |
| 4752 | } |
| 4753 | |
| 4754 | if (coreChat[j].is_user) { |
| 4755 | userMessageIndices.push(i); |
| 4756 | } |
| 4757 | } |
| 4758 | |
| 4759 | let addUserAlignment = isInstruct && power_user.instruct.user_alignment_message; |
| 4760 | let userAlignmentMessage = ''; |
| 4761 | |
| 4762 | if (addUserAlignment) { |
| 4763 | const alignmentMessage = { |
| 4764 | name: name1, |
| 4765 | mes: substituteParams(power_user.instruct.user_alignment_message), |
| 4766 | is_user: true, |
| 4767 | }; |
| 4768 | userAlignmentMessage = formatMessageHistoryItem(alignmentMessage, isInstruct, force_output_sequence.FIRST); |
| 4769 | } |
| 4770 | |
| 4771 | let oaiMessages = []; |
| 4772 | let oaiMessageExamples = []; |
| 4773 | |
| 4774 | if (main_api === 'openai') { |
| 4775 | oaiMessages = setOpenAIMessages(coreChat); |
| 4776 | oaiMessageExamples = setOpenAIMessageExamples(mesExamplesArray); |
| 4777 | } |
| 4778 | |
| 4779 | // hack for regeneration of the first message |
| 4780 | if (chat2.length == 0) { |
| 4781 | chat2.push(''); |
| 4782 | } |
| 4783 | |
| 4784 | let examplesString = ''; |
| 4785 | let chatString = addChatsPreamble(addChatsSeparator('')); |
| 4786 | let cyclePrompt = ''; |
| 4787 | |
| 4788 | async function getMessagesTokenCount() { |
| 4789 | const encodeString = [ |
| 4790 | combinedStoryString, |
| 4791 | examplesString, |
| 4792 | userAlignmentMessage, |
| 4793 | chatString, |
| 4794 | modifyLastPromptLine(''), |
| 4795 | cyclePrompt, |
| 4796 | ].join('').replace(/\r/gm, ''); |
| 4797 | return getTokenCountAsync(encodeString, power_user.token_padding); |
| 4798 | } |
| 4799 | |
| 4800 | // Force pinned examples into the context |
| 4801 | let pinExmString; |
| 4802 | if (power_user.pin_examples) { |
| 4803 | pinExmString = examplesString = mesExamplesArray.join(''); |
| 4804 | } |
| 4805 | |
| 4806 | // Only add the chat in context if past the greeting message |
| 4807 | if (isContinue && (chat2.length > 1 || main_api === 'openai')) { |
| 4808 | cyclePrompt = chat2.shift(); |
| 4809 | // Adjust indices to account for the shift |
| 4810 | injectedIndices = injectedIndices.map(shiftDownByOne).filter(x => x >= 0); |
| 4811 | userMessageIndices = userMessageIndices.map(shiftDownByOne).filter(x => x >= 0); |
| 4812 | } |
| 4813 | |
| 4814 | // Collect enough messages to fill the context |
| 4815 | let arrMes = new Array(chat2.length); |
| 4816 | let tokenCount = await getMessagesTokenCount(); |
| 4817 | let lastAddedIndex = 0; |
| 4818 | |
| 4819 | // Pre-allocate all injections first. |
| 4820 | // If it doesn't fit - user shot himself in the foot |
| 4821 | for (const index of injectedIndices) { |
| 4822 | // not needed for OAI prompting |
| 4823 | if (main_api == 'openai') { |
| 4824 | break; |
| 4825 | } |
| 4826 | |
| 4827 | const item = chat2[index]; |
| 4828 | |
| 4829 | if (typeof item !== 'string') { |
| 4830 | continue; |
| 4831 | } |
| 4832 | |
| 4833 | tokenCount += await getTokenCountAsync(item.replace(/\r/gm, '')); |
| 4834 | if (tokenCount < this_max_context) { |
| 4835 | chatString = chatString + item; |
| 4836 | arrMes[index] = item; |
| 4837 | lastAddedIndex = Math.max(lastAddedIndex, index); |
| 4838 | } else { |
| 4839 | break; |
| 4840 | } |
| 4841 | } |
| 4842 | |
| 4843 | for (let i = 0; i < chat2.length; i++) { |
| 4844 | // not needed for OAI prompting |
| 4845 | if (main_api == 'openai') { |
| 4846 | break; |
| 4847 | } |
| 4848 | |
| 4849 | // Skip already injected messages |
| 4850 | if (arrMes[i] !== undefined) { |
| 4851 | continue; |
| 4852 | } |
| 4853 | |
| 4854 | const item = chat2[i]; |
| 4855 | |
| 4856 | if (typeof item !== 'string') { |
| 4857 | continue; |
| 4858 | } |
| 4859 | |
| 4860 | tokenCount += await getTokenCountAsync(item.replace(/\r/gm, '')); |
| 4861 | if (tokenCount < this_max_context) { |
| 4862 | chatString = chatString + item; |
| 4863 | arrMes[i] = item; |
| 4864 | lastAddedIndex = Math.max(lastAddedIndex, i); |
| 4865 | } else { |
| 4866 | break; |
| 4867 | } |
| 4868 | } |
| 4869 | |
| 4870 | // Add user alignment message if last message is not a user message |
| 4871 | const stoppedAtUser = userMessageIndices.includes(lastAddedIndex); |
| 4872 | if (addUserAlignment && !stoppedAtUser) { |
| 4873 | tokenCount += await getTokenCountAsync(userAlignmentMessage.replace(/\r/gm, '')); |
| 4874 | chatString = userAlignmentMessage + chatString; |
| 4875 | arrMes.push(userAlignmentMessage); |
| 4876 | injectedIndices.push(arrMes.length - 1); |
| 4877 | } |
| 4878 | |
| 4879 | // Unsparse the array. Adjust injected indices |
| 4880 | const newArrMes = []; |
| 4881 | const newInjectedIndices = []; |
| 4882 | for (let i = 0; i < arrMes.length; i++) { |
| 4883 | if (arrMes[i] !== undefined) { |
| 4884 | newArrMes.push(arrMes[i]); |
| 4885 | if (injectedIndices.includes(i)) { |
| 4886 | newInjectedIndices.push(newArrMes.length - 1); |
| 4887 | } |
| 4888 | } |
| 4889 | } |
| 4890 | |
| 4891 | arrMes = newArrMes; |
| 4892 | injectedIndices = newInjectedIndices; |
| 4893 | |
| 4894 | if (main_api !== 'openai') { |
| 4895 | setInContextMessages(arrMes.length - injectedIndices.length, type); |
| 4896 | } |
| 4897 | |
| 4898 | // Estimate how many unpinned example messages fit in the context |
| 4899 | tokenCount = await getMessagesTokenCount(); |
| 4900 | let count_exm_add = 0; |
| 4901 | if (!power_user.pin_examples) { |
| 4902 | for (let example of mesExamplesArray) { |
| 4903 | tokenCount += await getTokenCountAsync(example.replace(/\r/gm, '')); |
| 4904 | examplesString += example; |
| 4905 | if (tokenCount < this_max_context) { |
| 4906 | count_exm_add++; |
| 4907 | } else { |
| 4908 | break; |
| 4909 | } |
| 4910 | } |
| 4911 | } |
| 4912 | |
| 4913 | let mesSend = []; |
| 4914 | console.debug('calling runGenerate'); |
| 4915 | |
| 4916 | if (isContinue) { |
| 4917 | // Coping mechanism for OAI spacing |
| 4918 | if (main_api === 'openai' && !cyclePrompt.endsWith(' ')) { |
| 4919 | cyclePrompt += oai_settings.continue_postfix; |
| 4920 | continue_mag += oai_settings.continue_postfix; |
| 4921 | } |
| 4922 | } |
| 4923 | |
| 4924 | const originalType = type; |
| 4925 | |
| 4926 | if (!dryRun) { |
| 4927 | is_send_press = true; |
| 4928 | } |
| 4929 | |
| 4930 | let generatedPromptCache = cyclePrompt || ''; |
| 4931 | if (generatedPromptCache.length == 0 || type === 'continue') { |
| 4932 | console.debug('generating prompt'); |
| 4933 | chatString = ''; |
| 4934 | arrMes = arrMes.reverse(); |
| 4935 | arrMes.forEach(function (item, i, arr) { |
| 4936 | // OAI doesn't need all of this |
| 4937 | if (main_api === 'openai') { |
| 4938 | return; |
| 4939 | } |
| 4940 | |
| 4941 | // Cohee: This removes a newline from the end of the last message in the context |
| 4942 | // Last prompt line will add a newline if it's not a continuation |
| 4943 | // In instruct mode it only removes it if wrap is enabled and it's not a quiet generation |
| 4944 | if (i === arrMes.length - 1 && type !== 'continue') { |
| 4945 | if (!isInstruct || (power_user.instruct.wrap && type !== 'quiet')) { |
| 4946 | item = item.replace(/\n?$/, ''); |
| 4947 | } |
| 4948 | } |
| 4949 | |
| 4950 | mesSend[mesSend.length] = { message: item, extensionPrompts: [] }; |
| 4951 | }); |
| 4952 | } |
| 4953 | |
| 4954 | let mesExmString = ''; |
| 4955 | |
| 4956 | function setPromptString() { |
| 4957 | if (main_api == 'openai') { |
| 4958 | return; |
| 4959 | } |
| 4960 | |
| 4961 | console.debug('--setting Prompt string'); |
| 4962 | mesExmString = pinExmString ?? mesExamplesArray.slice(0, count_exm_add).join(''); |
| 4963 | |
| 4964 | if (mesSend.length) { |
| 4965 | mesSend[mesSend.length - 1].message = modifyLastPromptLine(mesSend[mesSend.length - 1].message); |
| 4966 | } |
| 4967 | } |
| 4968 | |
| 4969 | function modifyLastPromptLine(lastMesString) { |
| 4970 | //#########QUIET PROMPT STUFF PT2############## |
| 4971 | |
| 4972 | // Add quiet generation prompt at depth 0 |
| 4973 | if (quiet_prompt && quiet_prompt.length) { |
| 4974 | // here name1 is forced for all quiet prompts..why? |
| 4975 | const name = name1; |
| 4976 | //checks if we are in instruct, if so, formats the chat as such, otherwise just adds the quiet prompt |
| 4977 | const quietAppend = isInstruct ? formatInstructModeChat(name, quiet_prompt, false, true, '', name1, name2, false) : `\n${quiet_prompt}`; |
| 4978 | |
| 4979 | //This begins to fix quietPrompts (particularly /sysgen) for instruct |
| 4980 | //previously instruct input sequence was being appended to the last chat message w/o '\n' |
| 4981 | //and no output sequence was added after the input's content. |
| 4982 | //TODO: respect output_sequence vs last_output_sequence settings |
| 4983 | //TODO: decide how to prompt this to clarify who is talking 'Narrator', 'System', etc. |
| 4984 | if (isInstruct) { |
| 4985 | lastMesString += quietAppend; // + power_user.instruct.output_sequence + '\n'; |
| 4986 | } else { |
| 4987 | lastMesString += quietAppend; |
| 4988 | } |
| 4989 | |
| 4990 | |
| 4991 | // Ross: bailing out early prevents quiet prompts from respecting other instruct prompt toggles |
| 4992 | // for sysgen, SD, and summary this is desireable as it prevents the AI from responding as char.. |
| 4993 | // but for idle prompting, we want the flexibility of the other prompt toggles, and to respect them as per settings in the extension |
| 4994 | // need a detection for what the quiet prompt is being asked for... |
| 4995 | |
| 4996 | // Bail out early? |
| 4997 | if (!isInstruct && !quietToLoud) { |
| 4998 | return lastMesString; |
| 4999 | } |
| 5000 | } |
| 5001 | |
| 5002 | |
| 5003 | // Get instruct mode line |
| 5004 | if (isInstruct && !isContinue) { |
| 5005 | const name = (quiet_prompt && !quietToLoud && !isImpersonate) ? (quietName ?? 'System') : (isImpersonate ? name1 : name2); |
| 5006 | const isQuiet = quiet_prompt && type == 'quiet'; |
| 5007 | lastMesString += formatInstructModePrompt(name, isImpersonate, promptBias, name1, name2, isQuiet, quietToLoud); |
| 5008 | } |
| 5009 | |
| 5010 | // Get non-instruct impersonation line |
| 5011 | if (!isInstruct && isImpersonate && !isContinue) { |
| 5012 | const name = name1; |
| 5013 | if (!lastMesString.endsWith('\n')) { |
| 5014 | lastMesString += '\n'; |
| 5015 | } |
| 5016 | lastMesString += name + ':'; |
| 5017 | } |
| 5018 | |
| 5019 | // Add character's name |
| 5020 | // Force name append on continue (if not continuing on user message or first message) |
| 5021 | const isContinuingOnFirstMessage = chat.length === 1 && isContinue; |
| 5022 | if (!isInstruct && force_name2 && !isContinuingOnFirstMessage) { |
| 5023 | if (!lastMesString.endsWith('\n')) { |
| 5024 | lastMesString += '\n'; |
| 5025 | } |
| 5026 | if (!isContinue || !(chat[chat.length - 1]?.is_user)) { |
| 5027 | lastMesString += `${name2}:`; |
| 5028 | } |
| 5029 | } |
| 5030 | |
| 5031 | return lastMesString; |
| 5032 | } |
| 5033 | |
| 5034 | async function checkPromptSize() { |
| 5035 | console.debug('---checking Prompt size'); |
| 5036 | setPromptString(); |
| 5037 | const jointMessages = mesSend.map((e) => `${e.extensionPrompts.join('')}${e.message}`).join(''); |
| 5038 | const prompt = [ |
| 5039 | combinedStoryString, |
| 5040 | mesExmString, |
| 5041 | addChatsPreamble(addChatsSeparator(jointMessages)), |
| 5042 | '\n', |
| 5043 | modifyLastPromptLine(''), |
| 5044 | generatedPromptCache, |
| 5045 | ].join('').replace(/\r/gm, ''); |
| 5046 | let thisPromptContextSize = await getTokenCountAsync(prompt, power_user.token_padding); |
| 5047 | |
| 5048 | if (thisPromptContextSize > this_max_context) { //if the prepared prompt is larger than the max context size... |
| 5049 | if (count_exm_add > 0) { // ..and we have example messages.. |
| 5050 | count_exm_add--; // remove the example messages... |
| 5051 | await checkPromptSize(); // and try agin... |
| 5052 | } else if (mesSend.length > 0) { // if the chat history is longer than 0 |
| 5053 | mesSend.shift(); // remove the first (oldest) chat entry.. |
| 5054 | await checkPromptSize(); // and check size again.. |
| 5055 | } else { |
| 5056 | //end |
| 5057 | console.debug(`---mesSend.length = ${mesSend.length}`); |
| 5058 | } |
| 5059 | } |
| 5060 | } |
| 5061 | |
| 5062 | if (generatedPromptCache.length > 0 && main_api !== 'openai') { |
| 5063 | console.debug('---Generated Prompt Cache length: ' + generatedPromptCache.length); |
| 5064 | await checkPromptSize(); |
| 5065 | } else { |
| 5066 | console.debug('---calling setPromptString ' + generatedPromptCache.length); |
| 5067 | setPromptString(); |
| 5068 | } |
| 5069 | |
| 5070 | // For prompt bit itemization |
| 5071 | let mesSendString = ''; |
| 5072 | |
| 5073 | async function getCombinedPrompt(isNegative) { |
| 5074 | // Only return if the guidance scale doesn't exist or the value is 1 |
| 5075 | // Also don't return if constructing the neutral prompt |
| 5076 | if (isNegative && !useCfgPrompt) { |
| 5077 | return; |
| 5078 | } |
| 5079 | |
| 5080 | // OAI has its own prompt manager. No need to do anything here |
| 5081 | if (main_api === 'openai') { |
| 5082 | return ''; |
| 5083 | } |
| 5084 | |
| 5085 | // Deep clone |
| 5086 | let finalMesSend = structuredClone(mesSend); |
| 5087 | |
| 5088 | if (useCfgPrompt) { |
| 5089 | const cfgPrompt = getCfgPrompt(cfgGuidanceScale, isNegative); |
| 5090 | if (cfgPrompt.value) { |
| 5091 | if (cfgPrompt.depth === 0) { |
| 5092 | finalMesSend[finalMesSend.length - 1].message += |
| 5093 | /\s/.test(finalMesSend[finalMesSend.length - 1].message.slice(-1)) |
| 5094 | ? cfgPrompt.value |
| 5095 | : ` ${cfgPrompt.value}`; |
| 5096 | } else { |
| 5097 | // TODO: Make all extension prompts use an array/splice method |
| 5098 | const lengthDiff = mesSend.length - cfgPrompt.depth; |
| 5099 | const cfgDepth = lengthDiff >= 0 ? lengthDiff : 0; |
| 5100 | const cfgMessage = finalMesSend[cfgDepth]; |
| 5101 | if (cfgMessage) { |
| 5102 | if (!Array.isArray(finalMesSend[cfgDepth].extensionPrompts)) { |
| 5103 | finalMesSend[cfgDepth].extensionPrompts = []; |
| 5104 | } |
| 5105 | finalMesSend[cfgDepth].extensionPrompts.push(`${cfgPrompt.value}\n`); |
| 5106 | } |
| 5107 | } |
| 5108 | } |
| 5109 | } |
| 5110 | |
| 5111 | // Add prompt bias after everything else |
| 5112 | // Always run with continue |
| 5113 | if (!isInstruct && !isImpersonate) { |
| 5114 | if (promptBias.trim().length !== 0) { |
| 5115 | finalMesSend[finalMesSend.length - 1].message += |
| 5116 | /\s/.test(finalMesSend[finalMesSend.length - 1].message.slice(-1)) |
| 5117 | ? promptBias.trimStart() |
| 5118 | : ` ${promptBias.trimStart()}`; |
| 5119 | } |
| 5120 | } |
| 5121 | |
| 5122 | // Flattens the multiple prompt objects to a string. |
| 5123 | const combine = () => { |
| 5124 | // Right now, everything is suffixed with a newline |
| 5125 | mesSendString = finalMesSend.map((e) => `${e.extensionPrompts.join('')}${e.message}`).join(''); |
| 5126 | |
| 5127 | // add a custom dingus (if defined) |
| 5128 | mesSendString = addChatsSeparator(mesSendString); |
| 5129 | |
| 5130 | // add chat preamble |
| 5131 | mesSendString = addChatsPreamble(mesSendString); |
| 5132 | |
| 5133 | let combinedPrompt = [ |
| 5134 | combinedStoryString, |
| 5135 | mesExmString, |
| 5136 | mesSendString, |
| 5137 | generatedPromptCache, |
| 5138 | ].join('').replace(/\r/gm, ''); |
| 5139 | |
| 5140 | if (power_user.collapse_newlines) { |
| 5141 | combinedPrompt = collapseNewlines(combinedPrompt); |
| 5142 | } |
| 5143 | |
| 5144 | return combinedPrompt; |
| 5145 | }; |
| 5146 | |
| 5147 | finalMesSend.forEach((item, i) => { |
| 5148 | item.injected = injectedIndices.includes(finalMesSend.length - i - 1); |
| 5149 | }); |
| 5150 | |
| 5151 | let data = { |
| 5152 | api: main_api, |
| 5153 | combinedPrompt: null, |
| 5154 | description, |
| 5155 | personality, |
| 5156 | persona, |
| 5157 | scenario, |
| 5158 | char: name2, |
| 5159 | user: name1, |
| 5160 | worldInfoBefore, |
| 5161 | worldInfoAfter, |
| 5162 | beforeScenarioAnchor, |
| 5163 | afterScenarioAnchor, |
| 5164 | storyString, |
| 5165 | mesExmString, |
| 5166 | mesSendString, |
| 5167 | finalMesSend, |
| 5168 | generatedPromptCache, |
| 5169 | main: system, |
| 5170 | jailbreak, |
| 5171 | naiPreamble: nai_settings.preamble, |
| 5172 | }; |
| 5173 | |
| 5174 | // Before returning the combined prompt, give available context related information to all subscribers. |
| 5175 | await eventSource.emit(event_types.GENERATE_BEFORE_COMBINE_PROMPTS, data); |
| 5176 | |
| 5177 | // If one or multiple subscribers return a value, forfeit the responsibillity of flattening the context. |
| 5178 | return !data.combinedPrompt ? combine() : data.combinedPrompt; |
| 5179 | } |
| 5180 | |
| 5181 | let finalPrompt = await getCombinedPrompt(false); |
| 5182 | |
| 5183 | const eventData = { prompt: finalPrompt, dryRun: dryRun }; |
| 5184 | await eventSource.emit(event_types.GENERATE_AFTER_COMBINE_PROMPTS, eventData); |
| 5185 | finalPrompt = eventData.prompt; |
| 5186 | |
| 5187 | let maxLength = Number(amount_gen); // how many tokens the AI will be requested to generate |
| 5188 | let thisPromptBits = []; |
| 5189 | |
| 5190 | let generate_data; |
| 5191 | switch (main_api) { |
| 5192 | case 'koboldhorde': |
| 5193 | case 'kobold': |
| 5194 | if (main_api == 'koboldhorde' && horde_settings.auto_adjust_response_length) { |
| 5195 | maxLength = Math.min(maxLength, adjustedParams.maxLength); |
| 5196 | maxLength = Math.max(maxLength, MIN_LENGTH); // prevent validation errors |
| 5197 | } |
| 5198 | |
| 5199 | generate_data = { |
| 5200 | prompt: finalPrompt, |
| 5201 | gui_settings: true, |
| 5202 | max_length: maxLength, |
| 5203 | max_context_length: max_context, |
| 5204 | api_server: kai_settings.api_server, |
| 5205 | }; |
| 5206 | |
| 5207 | if (kai_settings.preset_settings != 'gui') { |
| 5208 | const isHorde = main_api == 'koboldhorde'; |
| 5209 | const presetSettings = koboldai_settings[koboldai_setting_names[kai_settings.preset_settings]]; |
| 5210 | const maxContext = (adjustedParams && horde_settings.auto_adjust_context_length) ? adjustedParams.maxContextLength : max_context; |
| 5211 | generate_data = getKoboldGenerationData(finalPrompt, presetSettings, maxLength, maxContext, isHorde, type); |
| 5212 | } |
| 5213 | break; |
| 5214 | case 'textgenerationwebui': { |
| 5215 | const cfgValues = useCfgPrompt ? { guidanceScale: cfgGuidanceScale, negativePrompt: await getCombinedPrompt(true) } : null; |
| 5216 | generate_data = await getTextGenGenerationData(finalPrompt, maxLength, isImpersonate, isContinue, cfgValues, type); |
| 5217 | break; |
| 5218 | } |
| 5219 | case 'novel': { |
| 5220 | const cfgValues = useCfgPrompt ? { guidanceScale: cfgGuidanceScale } : null; |
| 5221 | const presetSettings = novelai_settings[novelai_setting_names[nai_settings.preset_settings_novel]]; |
| 5222 | generate_data = getNovelGenerationData(finalPrompt, presetSettings, maxLength, isImpersonate, isContinue, cfgValues, type); |
| 5223 | break; |
| 5224 | } |
| 5225 | case 'openai': { |
| 5226 | let [prompt, counts] = await prepareOpenAIMessages({ |
| 5227 | name2: name2, |
| 5228 | charDescription: description, |
| 5229 | charPersonality: personality, |
| 5230 | scenario: scenario, |
| 5231 | worldInfoBefore: worldInfoBefore, |
| 5232 | worldInfoAfter: worldInfoAfter, |
| 5233 | extensionPrompts: extension_prompts, |
| 5234 | bias: promptBias, |
| 5235 | type: type, |
| 5236 | quietPrompt: quiet_prompt, |
| 5237 | quietImage: quietImage, |
| 5238 | cyclePrompt: cyclePrompt, |
| 5239 | systemPromptOverride: system, |
| 5240 | jailbreakPromptOverride: jailbreak, |
| 5241 | messages: oaiMessages, |
| 5242 | messageExamples: oaiMessageExamples, |
| 5243 | }, dryRun); |
| 5244 | generate_data = { prompt: prompt }; |
| 5245 | |
| 5246 | // TODO: move these side-effects somewhere else, so this switch-case solely sets generate_data |
| 5247 | // counts will return false if the user has not enabled the token breakdown feature |
| 5248 | if (counts) { |
| 5249 | parseTokenCounts(counts, thisPromptBits); |
| 5250 | } |
| 5251 | |
| 5252 | if (!dryRun) { |
| 5253 | setInContextMessages(openai_messages_count, type); |
| 5254 | } |
| 5255 | break; |
| 5256 | } |
| 5257 | } |
| 5258 | |
| 5259 | await eventSource.emit(event_types.GENERATE_AFTER_DATA, generate_data, dryRun); |
| 5260 | |
| 5261 | if (dryRun) { |
| 5262 | return Promise.resolve(); |
| 5263 | } |
| 5264 | |
| 5265 | /** |
| 5266 | * Saves itemized prompt bits and calls streaming or non-streaming generation API. |
| 5267 | * @returns {Promise<void|*|Awaited<*>|String|{fromStream}|string|undefined|Object>} |
| 5268 | * @throws {Error|object} Error with message text, or Error with response JSON (OAI/Horde), or the actual response JSON (novel|textgenerationwebui|kobold) |
| 5269 | */ |
| 5270 | async function finishGenerating() { |
| 5271 | if (power_user.console_log_prompts) { |
| 5272 | console.log(generate_data.prompt); |
| 5273 | } |
| 5274 | |
| 5275 | console.debug('rungenerate calling API'); |
| 5276 | |
| 5277 | showStopButton(); |
| 5278 | |
| 5279 | //set array object for prompt token itemization of this message |
| 5280 | let currentArrayEntry = Number(thisPromptBits.length - 1); |
| 5281 | let additionalPromptStuff = { |
| 5282 | ...thisPromptBits[currentArrayEntry], |
| 5283 | rawPrompt: generate_data.prompt || generate_data.input, |
| 5284 | mesId: getNextMessageId(type), |
| 5285 | allAnchors: await getAllExtensionPrompts(), |
| 5286 | chatInjects: injectedIndices?.map(index => arrMes[arrMes.length - index - 1])?.join('') || '', |
| 5287 | summarizeString: (extension_prompts['1_memory']?.value || ''), |
| 5288 | authorsNoteString: (extension_prompts['2_floating_prompt']?.value || ''), |
| 5289 | smartContextString: (extension_prompts.chromadb?.value || ''), |
| 5290 | chatVectorsString: (extension_prompts['3_vectors']?.value || ''), |
| 5291 | dataBankVectorsString: (extension_prompts['4_vectors_data_bank']?.value || ''), |
| 5292 | worldInfoString: worldInfoString, |
| 5293 | storyString: storyString, |
| 5294 | beforeScenarioAnchor: beforeScenarioAnchor, |
| 5295 | afterScenarioAnchor: afterScenarioAnchor, |
| 5296 | examplesString: examplesString, |
| 5297 | mesSendString: mesSendString, |
| 5298 | generatedPromptCache: generatedPromptCache, |
| 5299 | promptBias: promptBias, |
| 5300 | finalPrompt: finalPrompt, |
| 5301 | charDescription: description, |
| 5302 | charPersonality: personality, |
| 5303 | scenarioText: scenario, |
| 5304 | this_max_context: this_max_context, |
| 5305 | padding: power_user.token_padding, |
| 5306 | main_api: main_api, |
| 5307 | instruction: main_api !== 'openai' && power_user.sysprompt.enabled ? substituteParams(power_user.prefer_character_prompt && system ? system : power_user.sysprompt.content) : '', |
| 5308 | userPersona: (power_user.persona_description_position == persona_description_positions.IN_PROMPT ? (persona || '') : ''), |
| 5309 | tokenizer: getFriendlyTokenizerName(main_api).tokenizerName || '', |
| 5310 | presetName: getPresetManager()?.getSelectedPresetName() || '', |
| 5311 | messagesCount: main_api !== 'openai' ? mesSend.length : oaiMessages.length, |
| 5312 | examplesCount: main_api !== 'openai' ? (pinExmString ? mesExamplesArray.length : count_exm_add) : oaiMessageExamples.length, |
| 5313 | }; |
| 5314 | |
| 5315 | //console.log(additionalPromptStuff); |
| 5316 | const itemizedIndex = itemizedPrompts.findIndex((item) => item.mesId === additionalPromptStuff.mesId); |
| 5317 | |
| 5318 | if (itemizedIndex !== -1) { |
| 5319 | itemizedPrompts[itemizedIndex] = additionalPromptStuff; |
| 5320 | } else { |
| 5321 | itemizedPrompts.push(additionalPromptStuff); |
| 5322 | } |
| 5323 | |
| 5324 | console.debug(`pushed prompt bits to itemizedPrompts array. Length is now: ${itemizedPrompts.length}`); |
| 5325 | |
| 5326 | if (isStreamingEnabled() && type !== 'quiet') { |
| 5327 | continue_mag = promptReasoning.removePrefix(continue_mag); |
| 5328 | streamingProcessor = new StreamingProcessor(type, force_name2, generation_started, continue_mag, promptReasoning); |
| 5329 | if (isContinue) { |
| 5330 | // Save reply does add cycle text to the prompt, so it's not needed here |
| 5331 | streamingProcessor.firstMessageText = ''; |
| 5332 | } |
| 5333 | |
| 5334 | streamingProcessor.generator = await sendStreamingRequest(type, generate_data, { jsonSchema }); |
| 5335 | |
| 5336 | hideSwipeButtons(); |
| 5337 | let getMessage = await streamingProcessor.generate(); |
| 5338 | let messageChunk = cleanUpMessage({ |
| 5339 | getMessage: getMessage, |
| 5340 | isImpersonate: isImpersonate, |
| 5341 | isContinue: isContinue, |
| 5342 | displayIncompleteSentences: false, |
| 5343 | }); |
| 5344 | |
| 5345 | if (isContinue) { |
| 5346 | getMessage = continue_mag + getMessage; |
| 5347 | } |
| 5348 | |
| 5349 | const isStreamFinished = streamingProcessor && !streamingProcessor.isStopped && streamingProcessor.isFinished; |
| 5350 | const isStreamWithToolCalls = streamingProcessor && Array.isArray(streamingProcessor.toolCalls) && streamingProcessor.toolCalls.length; |
| 5351 | if (canPerformToolCalls && isStreamFinished && isStreamWithToolCalls) { |
| 5352 | const lastMessage = chat[chat.length - 1]; |
| 5353 | const hasToolCalls = ToolManager.hasToolCalls(streamingProcessor.toolCalls); |
| 5354 | const shouldDeleteMessage = type !== 'swipe' && ['', '...'].includes(lastMessage?.mes) && !lastMessage?.extra?.reasoning && ['', '...'].includes(streamingProcessor?.result); |
| 5355 | hasToolCalls && shouldDeleteMessage && await deleteLastMessage(); |
| 5356 | if (hasToolCalls && !shouldDeleteMessage) { |
| 5357 | await streamingProcessor.finalizeIntermediaryMessage(streamingProcessor.messageId, getMessage, { unlockUI: false }); |
| 5358 | } |
| 5359 | const invocationResult = await ToolManager.invokeFunctionTools(streamingProcessor.toolCalls, { |
| 5360 | reasoningText: streamingProcessor.reasoningHandler.reasoning, |
| 5361 | }); |
| 5362 | const shouldStopGeneration = (!invocationResult.invocations.length && shouldDeleteMessage) || invocationResult.stealthCalls.length; |
| 5363 | if (hasToolCalls) { |
| 5364 | if (shouldStopGeneration) { |
| 5365 | if (Array.isArray(invocationResult.errors) && invocationResult.errors.length) { |
| 5366 | ToolManager.showToolCallError(invocationResult.errors); |
| 5367 | } |
| 5368 | unblockGeneration(type); |
| 5369 | streamingProcessor = null; |
| 5370 | return; |
| 5371 | } |
| 5372 | |
| 5373 | streamingProcessor = null; |
| 5374 | depth = depth + 1; |
| 5375 | await ToolManager.saveFunctionToolInvocations(invocationResult.invocations); |
| 5376 | return Generate('normal', { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage, quietName, depth }, dryRun); |
| 5377 | } |
| 5378 | } |
| 5379 | |
| 5380 | if (isStreamFinished) { |
| 5381 | await streamingProcessor.onFinishStreaming(streamingProcessor.messageId, getMessage); |
| 5382 | streamingProcessor = null; |
| 5383 | triggerAutoContinue(messageChunk, isImpersonate); |
| 5384 | return Object.defineProperties(new String(getMessage), { |
| 5385 | 'messageChunk': { value: messageChunk }, |
| 5386 | 'fromStream': { value: true }, |
| 5387 | }); |
| 5388 | } |
| 5389 | } else { |
| 5390 | return await sendGenerationRequest(type, generate_data, { jsonSchema }); |
| 5391 | } |
| 5392 | } |
| 5393 | |
| 5394 | return finishGenerating().then(onSuccess, onError); |
| 5395 | |
| 5396 | /** |
| 5397 | * Handles the successful response from the generation API. |
| 5398 | * @param data |
| 5399 | * @returns {Promise<String|{fromStream}|*|string|string|void|Awaited<*>|undefined>} |
| 5400 | * @throws {Error} Throws an error if the response data contains an error message |
| 5401 | */ |
| 5402 | async function onSuccess(data) { |
| 5403 | if (!data) return; |
| 5404 | |
| 5405 | if (data?.fromStream) { |
| 5406 | return data; |
| 5407 | } |
| 5408 | |
| 5409 | let messageChunk = ''; |
| 5410 | |
| 5411 | // if an error was returned in data (textgenwebui), show it and throw it |
| 5412 | if (data.error) { |
| 5413 | unblockGeneration(type); |
| 5414 | |
| 5415 | if (data?.response) { |
| 5416 | toastr.error(data.response, t`API Error`, { preventDuplicates: true }); |
| 5417 | } |
| 5418 | throw new Error(data?.response); |
| 5419 | } |
| 5420 | |
| 5421 | if (jsonSchema) { |
| 5422 | unblockGeneration(type); |
| 5423 | return extractJsonFromData(data, { returnInvalidJson: jsonSchema.returnInvalid ?? false }); |
| 5424 | } |
| 5425 | |
| 5426 | //const getData = await response.json(); |
| 5427 | let getMessage = extractMessageFromData(data); |
| 5428 | let title = extractTitleFromData(data); |
| 5429 | let reasoning = extractReasoningFromData(data); |
| 5430 | let imageUrls = extractImagesFromData(data); |
| 5431 | const reasoningSignature = extractReasoningSignatureFromData(data); |
| 5432 | kobold_horde_model = title; |
| 5433 | |
| 5434 | const swipes = extractMultiSwipes(data, type); |
| 5435 | |
| 5436 | messageChunk = cleanUpMessage({ |
| 5437 | getMessage: getMessage, |
| 5438 | isImpersonate: isImpersonate, |
| 5439 | isContinue: isContinue, |
| 5440 | displayIncompleteSentences: false, |
| 5441 | }); |
| 5442 | |
| 5443 | |
| 5444 | reasoning = getRegexedString(reasoning, regex_placement.REASONING); |
| 5445 | |
| 5446 | if (power_user.trim_spaces) { |
| 5447 | reasoning = reasoning.trim(); |
| 5448 | } |
| 5449 | |
| 5450 | if (isContinue) { |
| 5451 | continue_mag = promptReasoning.removePrefix(continue_mag); |
| 5452 | getMessage = continue_mag + getMessage; |
| 5453 | } |
| 5454 | |
| 5455 | //Formating |
| 5456 | const displayIncomplete = type === 'quiet' && !quietToLoud; |
| 5457 | getMessage = cleanUpMessage({ |
| 5458 | getMessage: getMessage, |
| 5459 | isImpersonate: isImpersonate, |
| 5460 | isContinue: isContinue, |
| 5461 | displayIncompleteSentences: displayIncomplete, |
| 5462 | }); |
| 5463 | |
| 5464 | if (isImpersonate) { |
| 5465 | $('#send_textarea').val(getMessage)[0].dispatchEvent(new Event('input', { bubbles: true })); |
| 5466 | await eventSource.emit(event_types.IMPERSONATE_READY, getMessage); |
| 5467 | } else if (type == 'quiet') { |
| 5468 | unblockGeneration(type); |
| 5469 | return getMessage; |
| 5470 | } else { |
| 5471 | // Without streaming we'll be having a full message on continuation. Treat it as a last chunk. |
| 5472 | if (originalType !== 'continue') { |
| 5473 | ({ type, getMessage } = await saveReply({ type, getMessage, title, swipes, reasoning, imageUrls, reasoningSignature })); |
| 5474 | } else { |
| 5475 | ({ type, getMessage } = await saveReply({ type: 'appendFinal', getMessage, title, swipes, reasoning, imageUrls, reasoningSignature })); |
| 5476 | } |
| 5477 | |
| 5478 | // This relies on `saveReply` having been called to add the message to the chat, so it must be last. |
| 5479 | parseAndSaveLogprobs(data, continue_mag); |
| 5480 | } |
| 5481 | |
| 5482 | if (canPerformToolCalls) { |
| 5483 | const hasToolCalls = ToolManager.hasToolCalls(data); |
| 5484 | const shouldDeleteMessage = type !== 'swipe' && ['', '...'].includes(getMessage) && !reasoning; |
| 5485 | hasToolCalls && shouldDeleteMessage && await deleteLastMessage(); |
| 5486 | const invocationResult = await ToolManager.invokeFunctionTools(data, { reasoningText: reasoning }); |
| 5487 | const shouldStopGeneration = (!invocationResult.invocations.length && shouldDeleteMessage) || invocationResult.stealthCalls.length; |
| 5488 | if (hasToolCalls) { |
| 5489 | if (shouldStopGeneration) { |
| 5490 | if (Array.isArray(invocationResult.errors) && invocationResult.errors.length) { |
| 5491 | ToolManager.showToolCallError(invocationResult.errors); |
| 5492 | } |
| 5493 | unblockGeneration(type); |
| 5494 | return; |
| 5495 | } |
| 5496 | |
| 5497 | depth = depth + 1; |
| 5498 | await ToolManager.saveFunctionToolInvocations(invocationResult.invocations); |
| 5499 | return Generate('normal', { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage, quietName, depth }, dryRun); |
| 5500 | } |
| 5501 | } |
| 5502 | |
| 5503 | if (type !== 'quiet') { |
| 5504 | playMessageSound(); |
| 5505 | } |
| 5506 | |
| 5507 | const isAborted = abortController && abortController.signal.aborted; |
| 5508 | if (!isAborted && power_user.auto_swipe && generatedTextFiltered(getMessage)) { |
| 5509 | is_send_press = false; |
| 5510 | return await swipe(null, SWIPE_DIRECTION.RIGHT, { source: SWIPE_SOURCE.AUTO_SWIPE, repeated: true, forceMesId: chat.length - 1 }); |
| 5511 | } |
| 5512 | |
| 5513 | console.debug('/api/chats/save called by /Generate'); |
| 5514 | await saveChatConditional(); |
| 5515 | unblockGeneration(type); |
| 5516 | streamingProcessor = null; |
| 5517 | |
| 5518 | if (type !== 'quiet') { |
| 5519 | triggerAutoContinue(messageChunk, isImpersonate); |
| 5520 | } |
| 5521 | |
| 5522 | // Don't break the API chain that expects a single string in return |
| 5523 | return Object.defineProperty(new String(getMessage), 'messageChunk', { value: messageChunk }); |
| 5524 | } |
| 5525 | |
| 5526 | /** |
| 5527 | * Exception handler for finishGenerating |
| 5528 | * @param {Error|object} exception Error or response JSON |
| 5529 | * @throws {Error|object} Re-throws the exception |
| 5530 | */ |
| 5531 | function onError(exception) { |
| 5532 | // if the response JSON was thrown (novel|textgenerationwebui|kobold), show the error message |
| 5533 | if (typeof exception?.error?.message === 'string') { |
| 5534 | toastr.error(exception.error.message, t`Text generation error`, { timeOut: 10000, extendedTimeOut: 20000 }); |
| 5535 | } |
| 5536 | |
| 5537 | unblockGeneration(type); |
| 5538 | console.log(exception); |
| 5539 | streamingProcessor = null; |
| 5540 | throw exception; |
| 5541 | } |
| 5542 | } |
| 5543 | //MARK: Generate() ends |
| 5544 | |
| 5545 | /** |
| 5546 | * Stops the generation and any streaming if it is currently running. |
| 5547 | */ |
| 5548 | export function stopGeneration() { |
| 5549 | let stopped = false; |
| 5550 | if (streamingProcessor) { |
| 5551 | streamingProcessor.onStopStreaming(); |
| 5552 | stopped = true; |
| 5553 | } |
| 5554 | if (abortController) { |
| 5555 | abortController.abort('Clicked stop button'); |
| 5556 | hideStopButton(); |
| 5557 | stopped = true; |
| 5558 | } |
| 5559 | eventSource.emit(event_types.GENERATION_STOPPED); |
| 5560 | return stopped; |
| 5561 | } |
| 5562 | |
| 5563 | /** |
| 5564 | * Injects extension prompts into chat messages. |
| 5565 | * @param {object[]} messages Array of chat messages |
| 5566 | * @param {boolean} isContinue Whether the generation is a continuation. If true, the extension prompts of depth 0 are injected at position 1. |
| 5567 | * @returns {Promise<number[]>} Array of indices where the extension prompts were injected |
| 5568 | */ |
| 5569 | async function doChatInject(messages, isContinue) { |
| 5570 | const injectedMessages = []; |
| 5571 | let totalInsertedMessages = 0; |
| 5572 | messages.reverse(); |
| 5573 | |
| 5574 | const maxDepth = getExtensionPromptMaxDepth(); |
| 5575 | for (let i = 0; i <= maxDepth; i++) { |
| 5576 | // Order of priority (most important go lower) |
| 5577 | const roles = [extension_prompt_roles.SYSTEM, extension_prompt_roles.USER, extension_prompt_roles.ASSISTANT]; |
| 5578 | const names = { |
| 5579 | [extension_prompt_roles.SYSTEM]: '', |
| 5580 | [extension_prompt_roles.USER]: name1, |
| 5581 | [extension_prompt_roles.ASSISTANT]: name2, |
| 5582 | }; |
| 5583 | const roleMessages = []; |
| 5584 | const separator = '\n'; |
| 5585 | const wrap = false; |
| 5586 | |
| 5587 | for (const role of roles) { |
| 5588 | const extensionPrompt = String(await getExtensionPrompt(extension_prompt_types.IN_CHAT, i, separator, role, wrap)).trimStart(); |
| 5589 | const isNarrator = role === extension_prompt_roles.SYSTEM; |
| 5590 | const isUser = role === extension_prompt_roles.USER; |
| 5591 | const name = names[role]; |
| 5592 | |
| 5593 | if (extensionPrompt) { |
| 5594 | roleMessages.push({ |
| 5595 | name: name, |
| 5596 | is_user: isUser, |
| 5597 | mes: extensionPrompt, |
| 5598 | extra: { |
| 5599 | type: isNarrator ? system_message_types.NARRATOR : null, |
| 5600 | }, |
| 5601 | }); |
| 5602 | } |
| 5603 | } |
| 5604 | |
| 5605 | if (roleMessages.length) { |
| 5606 | const depth = isContinue && i === 0 ? 1 : i; |
| 5607 | const injectIdx = Math.min(depth + totalInsertedMessages, messages.length); |
| 5608 | messages.splice(injectIdx, 0, ...roleMessages); |
| 5609 | totalInsertedMessages += roleMessages.length; |
| 5610 | injectedMessages.push(...roleMessages); |
| 5611 | } |
| 5612 | } |
| 5613 | |
| 5614 | const injectedIndices = injectedMessages.map(msg => messages.indexOf(msg)); |
| 5615 | messages.reverse(); |
| 5616 | return injectedIndices; |
| 5617 | } |
| 5618 | |
| 5619 | function flushWIInjections() { |
| 5620 | const depthPrefix = inject_ids.CUSTOM_WI_DEPTH; |
| 5621 | const outletPrefix = inject_ids.CUSTOM_WI_OUTLET(''); |
| 5622 | |
| 5623 | for (const key of Object.keys(extension_prompts)) { |
| 5624 | if (key.startsWith(depthPrefix) || key.startsWith(outletPrefix)) { |
| 5625 | delete extension_prompts[key]; |
| 5626 | } |
| 5627 | } |
| 5628 | } |
| 5629 | |
| 5630 | /** |
| 5631 | * Unblocks the UI after a generation is complete. |
| 5632 | * @param {string} [type] Generation type (optional) |
| 5633 | */ |
| 5634 | function unblockGeneration(type) { |
| 5635 | // Don't unblock if a parallel stream is still running |
| 5636 | if (type === 'quiet' && streamingProcessor && !streamingProcessor.isFinished) { |
| 5637 | return; |
| 5638 | } |
| 5639 | |
| 5640 | is_send_press = false; |
| 5641 | activateSendButtons(); |
| 5642 | setGenerationProgress(0); |
| 5643 | flushEphemeralStoppingStrings(); |
| 5644 | flushWIInjections(); |
| 5645 | } |
| 5646 | |
| 5647 | export function getNextMessageId(type) { |
| 5648 | return type == 'swipe' ? chat.length - 1 : chat.length; |
| 5649 | } |
| 5650 | |
| 5651 | /** |
| 5652 | * Determines if the message should be auto-continued. |
| 5653 | * @param {string} messageChunk Current message chunk |
| 5654 | * @param {boolean} isImpersonate Is the user impersonation |
| 5655 | * @returns {boolean} Whether the message should be auto-continued |
| 5656 | */ |
| 5657 | export function shouldAutoContinue(messageChunk, isImpersonate) { |
| 5658 | if (!power_user.auto_continue.enabled) { |
| 5659 | console.debug('Auto-continue is disabled by user.'); |
| 5660 | return false; |
| 5661 | } |
| 5662 | |
| 5663 | if (typeof messageChunk !== 'string') { |
| 5664 | console.debug('Not triggering auto-continue because message chunk is not a string'); |
| 5665 | return false; |
| 5666 | } |
| 5667 | |
| 5668 | if (isImpersonate) { |
| 5669 | console.log('Continue for impersonation is not implemented yet'); |
| 5670 | return false; |
| 5671 | } |
| 5672 | |
| 5673 | if (is_send_press) { |
| 5674 | console.debug('Auto-continue is disabled because a message is currently being sent.'); |
| 5675 | return false; |
| 5676 | } |
| 5677 | |
| 5678 | if (abortController && abortController.signal.aborted) { |
| 5679 | console.debug('Auto-continue is not triggered because the generation was stopped.'); |
| 5680 | return false; |
| 5681 | } |
| 5682 | |
| 5683 | if (power_user.auto_continue.target_length <= 0) { |
| 5684 | console.log('Auto-continue target length is 0, not triggering auto-continue'); |
| 5685 | return false; |
| 5686 | } |
| 5687 | |
| 5688 | if (main_api === 'openai' && !power_user.auto_continue.allow_chat_completions) { |
| 5689 | console.log('Auto-continue for OpenAI is disabled by user.'); |
| 5690 | return false; |
| 5691 | } |
| 5692 | |
| 5693 | const textareaText = String($('#send_textarea').val()); |
| 5694 | const USABLE_LENGTH = 5; |
| 5695 | |
| 5696 | if (textareaText.length > 0) { |
| 5697 | console.log('Not triggering auto-continue because user input is not empty'); |
| 5698 | return false; |
| 5699 | } |
| 5700 | |
| 5701 | if (messageChunk.trim().length > USABLE_LENGTH && chat.length) { |
| 5702 | const lastMessage = chat[chat.length - 1]; |
| 5703 | const messageLength = getTokenCount(lastMessage.mes); |
| 5704 | const shouldAutoContinue = messageLength < power_user.auto_continue.target_length; |
| 5705 | |
| 5706 | if (shouldAutoContinue) { |
| 5707 | console.log(`Triggering auto-continue. Message tokens: ${messageLength}. Target tokens: ${power_user.auto_continue.target_length}. Message chunk: ${messageChunk}`); |
| 5708 | return true; |
| 5709 | } else { |
| 5710 | console.log(`Not triggering auto-continue. Message tokens: ${messageLength}. Target tokens: ${power_user.auto_continue.target_length}`); |
| 5711 | return false; |
| 5712 | } |
| 5713 | } else { |
| 5714 | console.log('Last generated chunk was empty, not triggering auto-continue'); |
| 5715 | return false; |
| 5716 | } |
| 5717 | } |
| 5718 | |
| 5719 | /** |
| 5720 | * Triggers auto-continue if the message meets the criteria. |
| 5721 | * @param {string} messageChunk Current message chunk |
| 5722 | * @param {boolean} isImpersonate Is the user impersonation |
| 5723 | */ |
| 5724 | export function triggerAutoContinue(messageChunk, isImpersonate) { |
| 5725 | if (selected_group) { |
| 5726 | console.debug('Auto-continue is disabled for group chat'); |
| 5727 | return; |
| 5728 | } |
| 5729 | |
| 5730 | if (shouldAutoContinue(messageChunk, isImpersonate)) { |
| 5731 | $('#option_continue').trigger('click'); |
| 5732 | } |
| 5733 | } |
| 5734 | |
| 5735 | export function getBiasStrings(textareaText, type) { |
| 5736 | if (type == 'impersonate' || type == 'continue') { |
| 5737 | return { messageBias: '', promptBias: '', isUserPromptBias: false }; |
| 5738 | } |
| 5739 | |
| 5740 | let promptBias = ''; |
| 5741 | let messageBias = extractMessageBias(textareaText); |
| 5742 | |
| 5743 | // If user input is not provided, retrieve the bias of the most recent relevant message |
| 5744 | if (!textareaText) { |
| 5745 | for (let i = chat.length - 1; i >= 0; i--) { |
| 5746 | const mes = chat[i]; |
| 5747 | if (type === 'swipe' && chat.length - 1 === i) { |
| 5748 | continue; |
| 5749 | } |
| 5750 | if (mes && (mes.is_user || mes.is_system || mes.extra?.type === system_message_types.NARRATOR)) { |
| 5751 | if (mes.extra?.bias?.trim()?.length > 0) { |
| 5752 | promptBias = mes.extra.bias; |
| 5753 | } |
| 5754 | break; |
| 5755 | } |
| 5756 | } |
| 5757 | } |
| 5758 | |
| 5759 | promptBias = messageBias || promptBias || power_user.user_prompt_bias || ''; |
| 5760 | const isUserPromptBias = promptBias === power_user.user_prompt_bias; |
| 5761 | |
| 5762 | // Substitute params for everything |
| 5763 | messageBias = substituteParams(messageBias); |
| 5764 | promptBias = substituteParams(promptBias); |
| 5765 | |
| 5766 | return { messageBias, promptBias, isUserPromptBias }; |
| 5767 | } |
| 5768 | |
| 5769 | /** |
| 5770 | * @param {Object} chatItem Message history item. |
| 5771 | * @param {boolean} isInstruct Whether instruct mode is enabled. |
| 5772 | * @param {boolean|number} forceOutputSequence Whether to force the first/last output sequence for instruct mode. |
| 5773 | */ |
| 5774 | function formatMessageHistoryItem(chatItem, isInstruct, forceOutputSequence) { |
| 5775 | const isNarratorType = chatItem?.extra?.type === system_message_types.NARRATOR; |
| 5776 | const characterName = chatItem?.name ? chatItem.name : name2; |
| 5777 | const itemName = chatItem.is_user ? chatItem.name : characterName; |
| 5778 | const shouldPrependName = !isNarratorType; |
| 5779 | |
| 5780 | // If this symbol flag is set, completely ignore the message. |
| 5781 | // This can be used to hide messages without affecting the number of messages in the chat. |
| 5782 | if (chatItem.extra?.[IGNORE_SYMBOL]) { |
| 5783 | return ''; |
| 5784 | } |
| 5785 | |
| 5786 | // Don't include a name if it's empty |
| 5787 | let textResult = chatItem?.name && shouldPrependName ? `${itemName}: ${chatItem.mes}\n` : `${chatItem.mes}\n`; |
| 5788 | |
| 5789 | if (isInstruct) { |
| 5790 | textResult = formatInstructModeChat(itemName, chatItem.mes, chatItem.is_user, isNarratorType, chatItem.force_avatar, name1, name2, forceOutputSequence); |
| 5791 | } |
| 5792 | |
| 5793 | return textResult; |
| 5794 | } |
| 5795 | |
| 5796 | /** |
| 5797 | * Removes all {{macros}} from a string. |
| 5798 | * @param {string} str String to remove macros from. |
| 5799 | * @returns {string} String with macros removed. |
| 5800 | */ |
| 5801 | export function removeMacros(str) { |
| 5802 | return (str ?? '').replace(/\{\{[\s\S]*?\}\}/gm, '').trim(); |
| 5803 | } |
| 5804 | |
| 5805 | /** |
| 5806 | * Inserts a user message into the chat history. |
| 5807 | * @param {string} messageText Message text. |
| 5808 | * @param {string} messageBias Message bias. |
| 5809 | * @param {number} [insertAt] Optional index to insert the message at. |
| 5810 | * @param {boolean} [compact] Send as a compact display message. |
| 5811 | * @param {string} [name] Name of the user sending the message. Defaults to name1. |
| 5812 | * @param {string} [avatar] Avatar of the user sending the message. Defaults to user_avatar. |
| 5813 | * @returns {Promise<any>} A promise that resolves to the message when it is inserted. |
| 5814 | */ |
| 5815 | export async function sendMessageAsUser(messageText, messageBias, insertAt = null, compact = false, name = name1, avatar = user_avatar) { |
| 5816 | messageText = getRegexedString(messageText, regex_placement.USER_INPUT); |
| 5817 | |
| 5818 | const message = { |
| 5819 | name: name, |
| 5820 | is_user: true, |
| 5821 | is_system: false, |
| 5822 | send_date: getMessageTimeStamp(), |
| 5823 | mes: substituteParams(messageText), |
| 5824 | extra: { |
| 5825 | isSmallSys: compact, |
| 5826 | }, |
| 5827 | }; |
| 5828 | |
| 5829 | if (power_user.message_token_count_enabled) { |
| 5830 | message.extra.token_count = await getTokenCountAsync(message.mes, 0); |
| 5831 | } |
| 5832 | |
| 5833 | // Lock user avatar to a persona. |
| 5834 | if (avatar in power_user.personas) { |
| 5835 | message.force_avatar = getThumbnailUrl('persona', avatar); |
| 5836 | } |
| 5837 | |
| 5838 | if (messageBias) { |
| 5839 | message.extra.bias = messageBias; |
| 5840 | message.mes = removeMacros(message.mes); |
| 5841 | } |
| 5842 | |
| 5843 | await populateFileAttachment(message); |
| 5844 | statMesProcess(message, 'user', characters, this_chid, ''); |
| 5845 | |
| 5846 | chat_metadata.tainted = true; |
| 5847 | |
| 5848 | if (typeof insertAt === 'number' && insertAt >= 0 && insertAt <= chat.length) { |
| 5849 | chat.splice(insertAt, 0, message); |
| 5850 | await saveChatConditional(); |
| 5851 | await eventSource.emit(event_types.MESSAGE_SENT, insertAt); |
| 5852 | await reloadCurrentChat(); |
| 5853 | await eventSource.emit(event_types.USER_MESSAGE_RENDERED, insertAt); |
| 5854 | } else { |
| 5855 | chat.push(message); |
| 5856 | await saveChatConditional(); |
| 5857 | const chat_id = (chat.length - 1); |
| 5858 | await eventSource.emit(event_types.MESSAGE_SENT, chat_id); |
| 5859 | addOneMessage(message); |
| 5860 | await eventSource.emit(event_types.USER_MESSAGE_RENDERED, chat_id); |
| 5861 | } |
| 5862 | |
| 5863 | return message; |
| 5864 | } |
| 5865 | |
| 5866 | /** |
| 5867 | * Gets the maximum context token limit (the full context window size before subtracting response length). |
| 5868 | * @returns {number} The maximum context token limit for the current API. |
| 5869 | */ |
| 5870 | export function getMaxContextTokens() { |
| 5871 | if (main_api == 'kobold' || main_api == 'koboldhorde' || main_api == 'textgenerationwebui') { |
| 5872 | return max_context; |
| 5873 | } |
| 5874 | if (main_api == 'novel') { |
| 5875 | let this_max_context = Number(max_context); |
| 5876 | if (nai_settings.model_novel.includes('clio')) { |
| 5877 | this_max_context = Math.min(max_context, 8192); |
| 5878 | } |
| 5879 | if (nai_settings.model_novel.includes('kayra')) { |
| 5880 | this_max_context = Math.min(max_context, 8192); |
| 5881 | |
| 5882 | const subscriptionLimit = getKayraMaxContextTokens(); |
| 5883 | if (typeof subscriptionLimit === 'number' && this_max_context > subscriptionLimit) { |
| 5884 | this_max_context = subscriptionLimit; |
| 5885 | console.log(`NovelAI subscription limit reached. Max context size is now ${this_max_context}`); |
| 5886 | } |
| 5887 | } |
| 5888 | if (nai_settings.model_novel.includes('erato')) { |
| 5889 | // subscriber limits coming soon |
| 5890 | this_max_context = Math.min(max_context, 8192); |
| 5891 | |
| 5892 | // Added special tokens and whatnot |
| 5893 | this_max_context -= 10; |
| 5894 | } |
| 5895 | return this_max_context; |
| 5896 | } |
| 5897 | if (main_api == 'openai') { |
| 5898 | return oai_settings.openai_max_context; |
| 5899 | } |
| 5900 | return 1487; |
| 5901 | } |
| 5902 | |
| 5903 | /** |
| 5904 | * Gets the maximum response token limit (the max generation/reply length). |
| 5905 | * @returns {number} The maximum response token limit for the current API. |
| 5906 | */ |
| 5907 | export function getMaxResponseTokens() { |
| 5908 | if (main_api == 'kobold' || main_api == 'koboldhorde' || main_api == 'textgenerationwebui' || main_api == 'novel') { |
| 5909 | return amount_gen; |
| 5910 | } |
| 5911 | if (main_api == 'openai') { |
| 5912 | return oai_settings.openai_max_tokens; |
| 5913 | } |
| 5914 | return 0; |
| 5915 | } |
| 5916 | |
| 5917 | /** |
| 5918 | * Gets the maximum usable prompt size for the current API. |
| 5919 | * @param {number|null} overrideResponseLength Optional override for the response length. |
| 5920 | * @returns {number} Maximum usable prompt size. |
| 5921 | */ |
| 5922 | export function getMaxPromptTokens(overrideResponseLength = null) { |
| 5923 | if (typeof overrideResponseLength !== 'number' || overrideResponseLength <= 0 || isNaN(overrideResponseLength)) { |
| 5924 | overrideResponseLength = null; |
| 5925 | } |
| 5926 | |
| 5927 | return getMaxContextTokens() - (overrideResponseLength || getMaxResponseTokens()); |
| 5928 | } |
| 5929 | |
| 5930 | function parseTokenCounts(counts, thisPromptBits) { |
| 5931 | /** |
| 5932 | * @param {any[]} numbers |
| 5933 | */ |
| 5934 | function getSum(...numbers) { |
| 5935 | return numbers.map(x => Number(x)).filter(x => !Number.isNaN(x)).reduce((acc, val) => acc + val, 0); |
| 5936 | } |
| 5937 | const total = getSum(Object.values(counts)); |
| 5938 | |
| 5939 | thisPromptBits.push({ |
| 5940 | oaiStartTokens: (counts?.start + counts?.controlPrompts) || 0, |
| 5941 | oaiPromptTokens: getSum(counts?.prompt, counts?.charDescription, counts?.charPersonality, counts?.scenario) || 0, |
| 5942 | oaiBiasTokens: counts?.bias || 0, |
| 5943 | oaiNudgeTokens: counts?.nudge || 0, |
| 5944 | oaiJailbreakTokens: counts?.jailbreak || 0, |
| 5945 | oaiImpersonateTokens: counts?.impersonate || 0, |
| 5946 | oaiExamplesTokens: (counts?.dialogueExamples + counts?.examples) || 0, |
| 5947 | oaiConversationTokens: (counts?.conversation + counts?.chatHistory) || 0, |
| 5948 | oaiNsfwTokens: counts?.nsfw || 0, |
| 5949 | oaiMainTokens: counts?.main || 0, |
| 5950 | oaiTotalTokens: total, |
| 5951 | }); |
| 5952 | } |
| 5953 | |
| 5954 | function addChatsPreamble(mesSendString) { |
| 5955 | return main_api === 'novel' |
| 5956 | ? substituteParams(nai_settings.preamble) + '\n' + mesSendString |
| 5957 | : mesSendString; |
| 5958 | } |
| 5959 | |
| 5960 | function addChatsSeparator(mesSendString) { |
| 5961 | if (power_user.context.chat_start) { |
| 5962 | return substituteParams(power_user.context.chat_start + '\n') + mesSendString; |
| 5963 | } else { |
| 5964 | return mesSendString; |
| 5965 | } |
| 5966 | } |
| 5967 | |
| 5968 | /** |
| 5969 | * Duplicates a character. |
| 5970 | * @param {object} [options={}] - Options |
| 5971 | * @param {string} [options.avatar] - Avatar key of the character to duplicate. Uses current character if not provided. |
| 5972 | * @param {boolean} [options.silent=false] - Whether to skip the confirmation popup |
| 5973 | * @returns {Promise<string>} The avatar key of the duplicated character, or empty string if cancelled/failed |
| 5974 | */ |
| 5975 | export async function duplicateCharacter({ avatar = null, silent = false } = {}) { |
| 5976 | // Determine the character to duplicate |
| 5977 | let targetAvatar; |
| 5978 | if (avatar) { |
| 5979 | const character = characters.find(c => c.avatar === avatar); |
| 5980 | if (!character) { |
| 5981 | toastr.warning(t`Character not found: ${avatar}`); |
| 5982 | return ''; |
| 5983 | } |
| 5984 | targetAvatar = avatar; |
| 5985 | } else { |
| 5986 | if (this_chid === undefined || !characters[this_chid]) { |
| 5987 | toastr.warning(t`You must first select a character to duplicate!`); |
| 5988 | return ''; |
| 5989 | } |
| 5990 | targetAvatar = characters[this_chid].avatar; |
| 5991 | } |
| 5992 | |
| 5993 | // Show confirmation unless silent |
| 5994 | if (!silent) { |
| 5995 | const confirmMessage = $(await renderTemplateAsync('duplicateConfirm')); |
| 5996 | const confirm = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM); |
| 5997 | |
| 5998 | if (!confirm) { |
| 5999 | console.log('User cancelled duplication'); |
| 6000 | return ''; |
| 6001 | } |
| 6002 | } |
| 6003 | |
| 6004 | const body = { avatar_url: targetAvatar }; |
| 6005 | const response = await fetch('/api/characters/duplicate', { |
| 6006 | method: 'POST', |
| 6007 | headers: getRequestHeaders(), |
| 6008 | body: JSON.stringify(body), |
| 6009 | }); |
| 6010 | |
| 6011 | if (!response.ok) { |
| 6012 | toastr.error(t`Failed to duplicate character`); |
| 6013 | return ''; |
| 6014 | } |
| 6015 | |
| 6016 | toastr.success(t`Character Duplicated`); |
| 6017 | const data = await response.json(); |
| 6018 | await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: targetAvatar, newAvatar: data.path }); |
| 6019 | await getCharacters(); |
| 6020 | |
| 6021 | return data.path; |
| 6022 | } |
| 6023 | |
| 6024 | function setInContextMessages(msgInContextCount, type) { |
| 6025 | chatElement.find('.mes').removeClass('lastInContext'); |
| 6026 | |
| 6027 | if (type === 'swipe' || type === 'regenerate' || type === 'continue') { |
| 6028 | msgInContextCount++; |
| 6029 | } |
| 6030 | |
| 6031 | const lastMessageBlock = chatElement.find('.mes:not([is_system="true"]), .mes.toolCall').eq(-msgInContextCount); |
| 6032 | lastMessageBlock.addClass('lastInContext'); |
| 6033 | |
| 6034 | if (lastMessageBlock.length === 0) { |
| 6035 | const firstMessageId = getFirstDisplayedMessageId(); |
| 6036 | chatElement.find(`.mes[mesid="${firstMessageId}"]`).addClass('lastInContext'); |
| 6037 | } |
| 6038 | |
| 6039 | // Update last id to chat. No metadata save on purpose, gets hopefully saved via another call |
| 6040 | const lastMessageId = Math.max(0, chat.length - msgInContextCount); |
| 6041 | chat_metadata.lastInContextMessageId = lastMessageId; |
| 6042 | } |
| 6043 | |
| 6044 | /** |
| 6045 | * @typedef {object} AdditionalRequestOptions |
| 6046 | * @property {JsonSchema} [jsonSchema] |
| 6047 | */ |
| 6048 | |
| 6049 | /** |
| 6050 | * Sends a non-streaming request to the API. |
| 6051 | * @param {string} type Generation type |
| 6052 | * @param {object} data Generation data |
| 6053 | * @param {AdditionalRequestOptions} [options] Additional options for the generation request |
| 6054 | * @returns {Promise<object>} Response data from the API |
| 6055 | * @throws {Error|object} |
| 6056 | */ |
| 6057 | export async function sendGenerationRequest(type, data, options = {}) { |
| 6058 | if (main_api === 'openai') { |
| 6059 | return await sendOpenAIRequest(type, data.prompt, abortController.signal, options); |
| 6060 | } |
| 6061 | |
| 6062 | if (main_api === 'koboldhorde') { |
| 6063 | return await generateHorde(data.prompt, data, abortController.signal, true); |
| 6064 | } |
| 6065 | |
| 6066 | const response = await fetch(getGenerateUrl(main_api), { |
| 6067 | method: 'POST', |
| 6068 | headers: getRequestHeaders(), |
| 6069 | cache: 'no-cache', |
| 6070 | body: JSON.stringify(data), |
| 6071 | signal: abortController.signal, |
| 6072 | }); |
| 6073 | |
| 6074 | if (!response.ok) { |
| 6075 | throw await response.json(); |
| 6076 | } |
| 6077 | |
| 6078 | return await response.json(); |
| 6079 | } |
| 6080 | |
| 6081 | /** |
| 6082 | * Sends a streaming request to the API. |
| 6083 | * @param {string} type Generation type |
| 6084 | * @param {object} data Generation data |
| 6085 | * @param {AdditionalRequestOptions} [options] Additional options for the generation request |
| 6086 | * @returns {Promise<any>} Streaming generator |
| 6087 | */ |
| 6088 | export async function sendStreamingRequest(type, data, options = {}) { |
| 6089 | if (abortController?.signal?.aborted) { |
| 6090 | throw new Error('Generation was aborted.'); |
| 6091 | } |
| 6092 | |
| 6093 | switch (main_api) { |
| 6094 | case 'openai': |
| 6095 | return await sendOpenAIRequest(type, data.prompt, streamingProcessor.abortController.signal, options); |
| 6096 | case 'textgenerationwebui': |
| 6097 | return await generateTextGenWithStreaming(data, streamingProcessor.abortController.signal); |
| 6098 | case 'novel': |
| 6099 | return await generateNovelWithStreaming(data, streamingProcessor.abortController.signal); |
| 6100 | case 'kobold': |
| 6101 | return await generateKoboldWithStreaming(data, streamingProcessor.abortController.signal); |
| 6102 | default: |
| 6103 | throw new Error('Streaming is enabled, but the current API does not support streaming.'); |
| 6104 | } |
| 6105 | } |
| 6106 | |
| 6107 | /** |
| 6108 | * Gets the generation endpoint URL for the specified API. |
| 6109 | * @param {string} api API name |
| 6110 | * @returns {string} Generation URL |
| 6111 | * @throws {Error} If the API is unknown |
| 6112 | */ |
| 6113 | export function getGenerateUrl(api) { |
| 6114 | switch (api) { |
| 6115 | case 'kobold': |
| 6116 | return '/api/backends/kobold/generate'; |
| 6117 | case 'koboldhorde': |
| 6118 | return '/api/backends/koboldhorde/generate'; |
| 6119 | case 'textgenerationwebui': |
| 6120 | return '/api/backends/text-completions/generate'; |
| 6121 | case 'novel': |
| 6122 | return '/api/novelai/generate'; |
| 6123 | default: |
| 6124 | throw new Error(`Unknown API: ${api}`); |
| 6125 | } |
| 6126 | } |
| 6127 | |
| 6128 | function extractTitleFromData(data) { |
| 6129 | if (main_api == 'koboldhorde') { |
| 6130 | return data.workerName; |
| 6131 | } |
| 6132 | |
| 6133 | return undefined; |
| 6134 | } |
| 6135 | |
| 6136 | /** |
| 6137 | * Extracts the image from the response data. |
| 6138 | * @param {object} data Response data |
| 6139 | * @param {object} [options] Extraction options |
| 6140 | * @param {string} [options.mainApi] Main API to use |
| 6141 | * @param {string} [options.chatCompletionSource] Chat completion source |
| 6142 | * @returns {string[]} Extracted images or empty array |
| 6143 | */ |
| 6144 | function extractImagesFromData(data, { mainApi = null, chatCompletionSource = null } = {}) { |
| 6145 | switch (mainApi ?? main_api) { |
| 6146 | case 'openai': { |
| 6147 | switch (chatCompletionSource ?? oai_settings.chat_completion_source) { |
| 6148 | case chat_completion_sources.VERTEXAI: |
| 6149 | case chat_completion_sources.MAKERSUITE: { |
| 6150 | const inlineData = data?.responseContent?.parts?.filter(x => x.inlineData && !x.thought)?.map(x => x.inlineData); |
| 6151 | if (Array.isArray(inlineData) && inlineData.length > 0) { |
| 6152 | return inlineData.map(x => `data:${x.mimeType};base64,${x.data}`).filter(isDataURL); |
| 6153 | } |
| 6154 | } break; |
| 6155 | case chat_completion_sources.OPENROUTER: { |
| 6156 | const imageUrl = data?.choices[0]?.message?.images?.filter(x => x.type === 'image_url')?.map(x => x?.image_url?.url); |
| 6157 | if (Array.isArray(imageUrl) && imageUrl.length > 0) { |
| 6158 | return imageUrl.filter(isDataURL); |
| 6159 | } |
| 6160 | // TODO: Handle remote URLs |
| 6161 | } |
| 6162 | } |
| 6163 | } break; |
| 6164 | } |
| 6165 | |
| 6166 | return []; |
| 6167 | } |
| 6168 | |
| 6169 | /** |
| 6170 | * parseAndSaveLogprobs receives the full data response for a non-streaming |
| 6171 | * generation, parses logprobs for all tokens in the message, and saves them |
| 6172 | * to the currently active message. |
| 6173 | * @param {object} data - response data containing all tokens/logprobs |
| 6174 | * @param {string} continueFrom - for 'continue' generations, the prompt |
| 6175 | * */ |
| 6176 | function parseAndSaveLogprobs(data, continueFrom) { |
| 6177 | /** @type {import('./scripts/logprobs.js').TokenLogprobs[] | null} */ |
| 6178 | let logprobs = null; |
| 6179 | |
| 6180 | switch (main_api) { |
| 6181 | case 'novel': |
| 6182 | // parser only handles one token/logprob pair at a time |
| 6183 | logprobs = data.logprobs?.map(parseNovelAILogprobs) || null; |
| 6184 | break; |
| 6185 | case 'openai': |
| 6186 | // OAI and other chat completion APIs must handle this earlier in |
| 6187 | // `sendOpenAIRequest`. `data` for these APIs is just a string with |
| 6188 | // the text of the generated message, logprobs are not included. |
| 6189 | return; |
| 6190 | case 'textgenerationwebui': |
| 6191 | switch (textgen_settings.type) { |
| 6192 | case textgen_types.LLAMACPP: { |
| 6193 | logprobs = data?.completion_probabilities?.map(x => parseTextgenLogprobs(x.content, [x])) || null; |
| 6194 | } break; |
| 6195 | case textgen_types.KOBOLDCPP: |
| 6196 | case textgen_types.VLLM: |
| 6197 | case textgen_types.INFERMATICAI: |
| 6198 | case textgen_types.APHRODITE: |
| 6199 | case textgen_types.MANCER: |
| 6200 | case textgen_types.TABBY: { |
| 6201 | logprobs = parseTabbyLogprobs(data) || null; |
| 6202 | } break; |
| 6203 | } break; |
| 6204 | default: |
| 6205 | return; |
| 6206 | } |
| 6207 | |
| 6208 | saveLogprobsForActiveMessage(logprobs, continueFrom); |
| 6209 | } |
| 6210 | |
| 6211 | /** |
| 6212 | * Extracts the message from the response data. |
| 6213 | * @param {object} data Response data |
| 6214 | * @param {string} activeApi If it's set, ignores active API |
| 6215 | * @returns {string} Extracted message |
| 6216 | */ |
| 6217 | export function extractMessageFromData(data, activeApi = null) { |
| 6218 | function getResult() { |
| 6219 | if (typeof data === 'string') { |
| 6220 | return data; |
| 6221 | } |
| 6222 | |
| 6223 | switch (activeApi ?? main_api) { |
| 6224 | case 'kobold': |
| 6225 | return data.results[0].text; |
| 6226 | case 'koboldhorde': |
| 6227 | return data.text; |
| 6228 | case 'textgenerationwebui': |
| 6229 | return data.choices?.[0]?.text ?? data.choices?.[0]?.message?.content ?? data.content ?? data.response ?? data[0]?.content ?? ''; |
| 6230 | case 'novel': |
| 6231 | return data.output; |
| 6232 | case 'openai': |
| 6233 | return data?.content?.filter(p => p.type === 'text')?.map(p => p.text)?.join('\n\n') ?? data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? data?.text ?? data?.message?.content?.[0]?.text ?? data?.message?.tool_plan ?? ''; |
| 6234 | default: |
| 6235 | return ''; |
| 6236 | } |
| 6237 | } |
| 6238 | |
| 6239 | const result = getResult(); |
| 6240 | return Array.isArray(result) ? result.map(x => x.text).filter(x => x).join('') : result; |
| 6241 | } |
| 6242 | |
| 6243 | /** |
| 6244 | * Extracts JSON from the response data. |
| 6245 | * @param {object} data Response data |
| 6246 | * @param {object} [options] Extraction options |
| 6247 | * @param {string} [options.mainApi] Main API to use |
| 6248 | * @param {string} [options.chatCompletionSource] Chat completion source |
| 6249 | * @param {boolean} [options.returnInvalidJson=false] Whether to return the raw JSON string even if it fails to parse |
| 6250 | * @returns {string} Extracted JSON string from the response data |
| 6251 | */ |
| 6252 | export function extractJsonFromData(data, { mainApi = null, chatCompletionSource = null, returnInvalidJson = false } = {}) { |
| 6253 | mainApi = mainApi ?? main_api; |
| 6254 | chatCompletionSource = chatCompletionSource ?? oai_settings.chat_completion_source; |
| 6255 | |
| 6256 | const tryParse = (/** @type {string} */ value) => { |
| 6257 | try { |
| 6258 | return JSON.parse(value); |
| 6259 | } catch (e) { |
| 6260 | console.debug('Failed to parse content as JSON.', e); |
| 6261 | } |
| 6262 | }; |
| 6263 | |
| 6264 | let result = {}; |
| 6265 | |
| 6266 | switch (mainApi) { |
| 6267 | case 'openai': { |
| 6268 | const text = extractMessageFromData(data, mainApi); |
| 6269 | switch (chatCompletionSource) { |
| 6270 | case chat_completion_sources.CLAUDE: |
| 6271 | result = data?.content?.find(x => x.type === 'tool_use')?.input; |
| 6272 | break; |
| 6273 | case chat_completion_sources.PERPLEXITY: |
| 6274 | result = tryParse(removeReasoningFromString(text)); |
| 6275 | if (!result && returnInvalidJson) { |
| 6276 | return text; |
| 6277 | } |
| 6278 | break; |
| 6279 | case chat_completion_sources.VERTEXAI: |
| 6280 | case chat_completion_sources.MAKERSUITE: |
| 6281 | case chat_completion_sources.DEEPSEEK: |
| 6282 | case chat_completion_sources.AI21: |
| 6283 | case chat_completion_sources.GROQ: |
| 6284 | case chat_completion_sources.POLLINATIONS: |
| 6285 | case chat_completion_sources.AIMLAPI: |
| 6286 | case chat_completion_sources.OPENAI: |
| 6287 | case chat_completion_sources.OPENROUTER: |
| 6288 | case chat_completion_sources.MISTRALAI: |
| 6289 | case chat_completion_sources.CUSTOM: |
| 6290 | case chat_completion_sources.COHERE: |
| 6291 | case chat_completion_sources.XAI: |
| 6292 | case chat_completion_sources.ELECTRONHUB: |
| 6293 | case chat_completion_sources.CHUTES: |
| 6294 | case chat_completion_sources.AZURE_OPENAI: |
| 6295 | case chat_completion_sources.ZAI: |
| 6296 | default: |
| 6297 | result = tryParse(text); |
| 6298 | if (!result && returnInvalidJson) { |
| 6299 | return text; |
| 6300 | } |
| 6301 | break; |
| 6302 | } |
| 6303 | } break; |
| 6304 | } |
| 6305 | |
| 6306 | return JSON.stringify(result ?? {}); |
| 6307 | } |
| 6308 | |
| 6309 | /** |
| 6310 | * Extracts multiswipe swipes from the response data. |
| 6311 | * @param {Object} data Response data |
| 6312 | * @param {string} type Type of generation |
| 6313 | * @returns {string[]} Array of extra swipes |
| 6314 | */ |
| 6315 | function extractMultiSwipes(data, type) { |
| 6316 | const swipes = []; |
| 6317 | |
| 6318 | if (!data) { |
| 6319 | return swipes; |
| 6320 | } |
| 6321 | |
| 6322 | if (type === 'continue' || type === 'impersonate' || type === 'quiet') { |
| 6323 | return swipes; |
| 6324 | } |
| 6325 | |
| 6326 | if (main_api === 'textgenerationwebui' && textgen_settings.type === textgen_types.LLAMACPP) { |
| 6327 | if (!Array.isArray(data)) { |
| 6328 | return swipes; |
| 6329 | } |
| 6330 | |
| 6331 | const multiSwipeCount = data.length - 1; |
| 6332 | if (multiSwipeCount <= 0) { |
| 6333 | return swipes; |
| 6334 | } |
| 6335 | |
| 6336 | for (let i = 1; i < data.length; i++) { |
| 6337 | const text = data?.[i]?.content ?? ''; |
| 6338 | swipes.push(text); |
| 6339 | } |
| 6340 | } |
| 6341 | |
| 6342 | if (main_api === 'openai' || (main_api === 'textgenerationwebui' && [textgen_types.MANCER, textgen_types.VLLM, textgen_types.APHRODITE, textgen_types.TABBY, textgen_types.INFERMATICAI].includes(textgen_settings.type))) { |
| 6343 | if (!Array.isArray(data.choices)) { |
| 6344 | return swipes; |
| 6345 | } |
| 6346 | |
| 6347 | const multiSwipeCount = data.choices.length - 1; |
| 6348 | |
| 6349 | if (multiSwipeCount <= 0) { |
| 6350 | return swipes; |
| 6351 | } |
| 6352 | |
| 6353 | for (let i = 1; i < data.choices.length; i++) { |
| 6354 | const text = data?.choices[i]?.message?.content ?? data?.choices[i]?.text ?? ''; |
| 6355 | swipes.push(text); |
| 6356 | } |
| 6357 | } |
| 6358 | |
| 6359 | const cleanedSwipes = swipes.map(text => cleanUpMessage({ |
| 6360 | getMessage: text, |
| 6361 | isImpersonate: false, |
| 6362 | isContinue: false, |
| 6363 | displayIncompleteSentences: false, |
| 6364 | })); |
| 6365 | |
| 6366 | return cleanedSwipes; |
| 6367 | } |
| 6368 | |
| 6369 | /** |
| 6370 | * Formats a message according to user settings |
| 6371 | * @param {object} [options] - Additional options. |
| 6372 | * @param {string} [options.getMessage] The message to clean up |
| 6373 | * @param {boolean} [options.isImpersonate] Whether this is an impersonated message |
| 6374 | * @param {boolean} [options.isContinue] Whether this is a continued message |
| 6375 | * @param {boolean} [options.displayIncompleteSentences] Whether to keep incomplete sentences at the end. |
| 6376 | * @param {array} [options.stoppingStrings] Array of stopping strings. |
| 6377 | * @param {boolean} [options.includeUserPromptBias] Whether to permit prepending the user prompt bias at the beginning. |
| 6378 | * @param {boolean} [options.trimNames] Whether to allow trimming "{{char}}:" or "{{user}}:" from the beginning. |
| 6379 | * @param {boolean} [options.trimWrongNames] Whether to allow deleting responses prefixed by the incorrect name, depending on isImpersonate |
| 6380 | * |
| 6381 | * @returns {string} The formatted message |
| 6382 | */ |
| 6383 | export function cleanUpMessage({ getMessage, isImpersonate, isContinue, displayIncompleteSentences = false, stoppingStrings = null, includeUserPromptBias = true, trimNames = true, trimWrongNames = true } = {}) { |
| 6384 | if (arguments.length > 0 && typeof arguments[0] !== 'object') { |
| 6385 | console.trace('cleanUpMessage called with positional arguments. Please use an object instead.'); |
| 6386 | [getMessage, isImpersonate, isContinue, displayIncompleteSentences, stoppingStrings, includeUserPromptBias, trimNames, trimWrongNames] = arguments; |
| 6387 | } |
| 6388 | |
| 6389 | if (!getMessage) { |
| 6390 | return ''; |
| 6391 | } |
| 6392 | |
| 6393 | // Add the prompt bias before anything else |
| 6394 | if ( |
| 6395 | includeUserPromptBias && |
| 6396 | power_user.user_prompt_bias && |
| 6397 | !isImpersonate && |
| 6398 | !isContinue && |
| 6399 | power_user.user_prompt_bias.length !== 0 |
| 6400 | ) { |
| 6401 | getMessage = substituteParams(power_user.user_prompt_bias) + getMessage; |
| 6402 | } |
| 6403 | |
| 6404 | // Allow for caching of stopping strings. getStoppingStrings is an expensive function, especially with macros |
| 6405 | // enabled, so for streaming, we call it once and then pass it into each cleanUpMessage call. |
| 6406 | if (!stoppingStrings) { |
| 6407 | stoppingStrings = getStoppingStrings(isImpersonate, isContinue, main_api); |
| 6408 | } |
| 6409 | |
| 6410 | for (const stoppingString of stoppingStrings) { |
| 6411 | if (stoppingString.length) { |
| 6412 | for (let j = stoppingString.length; j > 0; j--) { |
| 6413 | if (getMessage.slice(-j) === stoppingString.slice(0, j)) { |
| 6414 | getMessage = getMessage.slice(0, -j); |
| 6415 | break; |
| 6416 | } |
| 6417 | } |
| 6418 | } |
| 6419 | } |
| 6420 | |
| 6421 | // Regex uses vars, so add before formatting |
| 6422 | getMessage = getRegexedString(getMessage, isImpersonate ? regex_placement.USER_INPUT : regex_placement.AI_OUTPUT); |
| 6423 | |
| 6424 | if (power_user.collapse_newlines) { |
| 6425 | getMessage = collapseNewlines(getMessage); |
| 6426 | } |
| 6427 | |
| 6428 | // trailing invisible whitespace before every newlines, on a multiline string |
| 6429 | // "trailing whitespace on newlines \nevery line of the string \n?sample text" -> |
| 6430 | // "trailing whitespace on newlines\nevery line of the string\nsample text" |
| 6431 | getMessage = getMessage.replace(/[^\S\r\n]+$/gm, ''); |
| 6432 | |
| 6433 | if (trimWrongNames) { |
| 6434 | // If this is an impersonation, delete the entire response if it starts with "{{char}}:" |
| 6435 | // If this isn't an impersonation, delete the entire response if it starts with "{{user}}:" |
| 6436 | // Also delete any trailing text that starts with the wrong name. |
| 6437 | // This only occurs if the corresponding "power_user.allow_nameX_display" is false. |
| 6438 | |
| 6439 | let wrongName = isImpersonate |
| 6440 | ? (!power_user.allow_name2_display ? name2 : '') // char |
| 6441 | : (!power_user.allow_name1_display ? name1 : ''); // user |
| 6442 | |
| 6443 | if (wrongName) { |
| 6444 | // If the message starts with the wrong name, delete the entire response |
| 6445 | let startIndex = getMessage.indexOf(`${wrongName}:`); |
| 6446 | if (startIndex === 0) { |
| 6447 | getMessage = ''; |
| 6448 | console.debug(`Message started with the wrong name: "${wrongName}" - response was deleted.`); |
| 6449 | } |
| 6450 | |
| 6451 | // If there is trailing text starting with the wrong name, trim it off. |
| 6452 | startIndex = getMessage.indexOf(`\n${wrongName}:`); |
| 6453 | if (startIndex >= 0) { |
| 6454 | getMessage = getMessage.substring(0, startIndex); |
| 6455 | } |
| 6456 | } |
| 6457 | } |
| 6458 | |
| 6459 | if (getMessage.indexOf('<|endoftext|>') != -1) { |
| 6460 | getMessage = getMessage.substring(0, getMessage.indexOf('<|endoftext|>')); |
| 6461 | } |
| 6462 | const isInstruct = power_user.instruct.enabled && main_api !== 'openai'; |
| 6463 | const isNotEmpty = (str) => str && str.trim() !== ''; |
| 6464 | if (isInstruct && power_user.instruct.stop_sequence) { |
| 6465 | if (getMessage.indexOf(power_user.instruct.stop_sequence) != -1) { |
| 6466 | getMessage = getMessage.substring(0, getMessage.indexOf(power_user.instruct.stop_sequence)); |
| 6467 | } |
| 6468 | } |
| 6469 | // Hana: Only use the first sequence (should be <|model|>) |
| 6470 | // of the prompt before <|user|> (as KoboldAI Lite does it). |
| 6471 | if (isInstruct && isNotEmpty(power_user.instruct.input_sequence)) { |
| 6472 | if (getMessage.indexOf(power_user.instruct.input_sequence) != -1) { |
| 6473 | getMessage = getMessage.substring(0, getMessage.indexOf(power_user.instruct.input_sequence)); |
| 6474 | } |
| 6475 | } |
| 6476 | |
| 6477 | // Remove instruct sequences leaking to the output |
| 6478 | if (isInstruct && power_user.instruct.sequences_as_stop_strings) { |
| 6479 | const sequences = [ |
| 6480 | { value: power_user.instruct.input_sequence, apply: isImpersonate && isNotEmpty(power_user.instruct.input_sequence) }, |
| 6481 | { value: power_user.instruct.output_sequence, apply: !isImpersonate && isNotEmpty(power_user.instruct.output_sequence) }, |
| 6482 | { value: power_user.instruct.last_output_sequence, apply: !isImpersonate && isNotEmpty(power_user.instruct.last_output_sequence) }, |
| 6483 | ]; |
| 6484 | for (const seq of sequences.filter(s => s.apply)) { |
| 6485 | seq.value.split('\n').filter(line => line.trim() !== '').forEach(line => { getMessage = getMessage.replaceAll(line, ''); }); |
| 6486 | } |
| 6487 | } |
| 6488 | |
| 6489 | // clean-up group message from excessive generations |
| 6490 | if (selected_group) { |
| 6491 | getMessage = cleanGroupMessage(getMessage); |
| 6492 | } |
| 6493 | |
| 6494 | if (!power_user.allow_name2_display) { |
| 6495 | const name2Escaped = escapeRegex(name2); |
| 6496 | getMessage = getMessage.replace(new RegExp(`(^|\n)${name2Escaped}:\\s*`, 'g'), '$1'); |
| 6497 | } |
| 6498 | |
| 6499 | if (isImpersonate) { |
| 6500 | getMessage = getMessage.trim(); |
| 6501 | } |
| 6502 | |
| 6503 | if (power_user.auto_fix_generated_markdown) { |
| 6504 | getMessage = fixMarkdown(getMessage, false); |
| 6505 | } |
| 6506 | |
| 6507 | if (trimNames) { |
| 6508 | // If this is an impersonation, trim "{{user}}:" from the beginning |
| 6509 | // If this isn't an impersonation, trim "{{char}}:" from the beginning. |
| 6510 | // Only applied when the corresponding "power_user.allow_nameX_display" is false. |
| 6511 | const nameToTrim2 = isImpersonate |
| 6512 | ? (!power_user.allow_name1_display ? name1 : '') // user |
| 6513 | : (!power_user.allow_name2_display ? name2 : ''); // char |
| 6514 | |
| 6515 | if (nameToTrim2 && getMessage.startsWith(nameToTrim2 + ':')) { |
| 6516 | getMessage = getMessage.replace(nameToTrim2 + ':', ''); |
| 6517 | getMessage = getMessage.trimStart(); |
| 6518 | } |
| 6519 | } |
| 6520 | |
| 6521 | if (isImpersonate) { |
| 6522 | getMessage = getMessage.trim(); |
| 6523 | } |
| 6524 | |
| 6525 | if (!displayIncompleteSentences && power_user.trim_sentences) { |
| 6526 | getMessage = trimToEndSentence(getMessage); |
| 6527 | } |
| 6528 | |
| 6529 | if (power_user.trim_spaces && !PromptReasoning.getLatestPrefix()) { |
| 6530 | getMessage = getMessage.trim(); |
| 6531 | } |
| 6532 | |
| 6533 | return getMessage; |
| 6534 | } |
| 6535 | |
| 6536 | /** |
| 6537 | * Adds an image to the message. |
| 6538 | * @param {object} message Message object |
| 6539 | * @param {object} sources Image sources |
| 6540 | * @param {string[]} [sources.imageUrls] Image URLs |
| 6541 | * |
| 6542 | * @returns {Promise<void>} |
| 6543 | */ |
| 6544 | async function processImageAttachment(message, { imageUrls }) { |
| 6545 | if (!Array.isArray(imageUrls) || imageUrls.length === 0) { |
| 6546 | return; |
| 6547 | } |
| 6548 | |
| 6549 | for (const [index, imageUrl] of imageUrls.filter(onlyUnique).entries()) { |
| 6550 | if (!imageUrl) { |
| 6551 | continue; |
| 6552 | } |
| 6553 | |
| 6554 | let url = imageUrl; |
| 6555 | if (isDataURL(url)) { |
| 6556 | const fileName = `inline_image_${Date.now().toString()}_${index}`; |
| 6557 | const [mime, base64] = /^data:(.*?);base64,(.*)$/.exec(imageUrl).slice(1); |
| 6558 | url = await saveBase64AsFile(base64, message.name, fileName, mime.split('/')[1]); |
| 6559 | } |
| 6560 | saveImageToMessage({ image: url, inline: true }, message); |
| 6561 | } |
| 6562 | } |
| 6563 | |
| 6564 | /** |
| 6565 | * Saves a resulting message to the chat. |
| 6566 | * @param {SaveReplyParams} params |
| 6567 | * @returns {Promise<SaveReplyResult>} Promise when the message is saved |
| 6568 | * |
| 6569 | * @typedef {object} SaveReplyParams |
| 6570 | * @property {string} type Type of generation |
| 6571 | * @property {string} getMessage Generated message |
| 6572 | * @property {boolean} [fromStreaming] If the message is from streaming |
| 6573 | * @property {string} [title] Message tooltip |
| 6574 | * @property {string[]} [swipes] Extra swipes |
| 6575 | * @property {string} [reasoning] Message reasoning |
| 6576 | * @property {string[]} [imageUrls] Links to images |
| 6577 | * @property {string?} [reasoningSignature] Encrypted signature of the reasoning text |
| 6578 | * |
| 6579 | * @typedef {object} SaveReplyResult |
| 6580 | * @property {string} type Type of generation |
| 6581 | * @property {string} getMessage Generated message |
| 6582 | */ |
| 6583 | export async function saveReply({ type, getMessage, fromStreaming = false, title = '', swipes = [], reasoning = '', imageUrls = [], reasoningSignature = null }) { |
| 6584 | // Backward compatibility |
| 6585 | if (arguments.length > 1 && typeof arguments[0] !== 'object') { |
| 6586 | console.trace('saveReply called with positional arguments. Please use an object instead.'); |
| 6587 | [type, getMessage, fromStreaming, title, swipes, reasoning, imageUrls, reasoningSignature] = arguments; |
| 6588 | } |
| 6589 | |
| 6590 | const lastMessage = chat[chat.length - 1]; |
| 6591 | |
| 6592 | if (type != 'append' && type != 'continue' && type != 'appendFinal' && chat.length && (lastMessage.swipe_id === undefined || |
| 6593 | lastMessage.is_user)) { |
| 6594 | type = 'normal'; |
| 6595 | } |
| 6596 | |
| 6597 | if (chat.length && (!lastMessage.extra || typeof lastMessage.extra !== 'object')) { |
| 6598 | lastMessage.extra = {}; |
| 6599 | } |
| 6600 | |
| 6601 | // Coerce null/undefined to empty string |
| 6602 | if (chat.length && !lastMessage.extra.reasoning) { |
| 6603 | lastMessage.extra.reasoning = ''; |
| 6604 | } |
| 6605 | |
| 6606 | if (!reasoning) { |
| 6607 | reasoning = ''; |
| 6608 | } |
| 6609 | |
| 6610 | let oldMessage = ''; |
| 6611 | const generationFinished = new Date(); |
| 6612 | if (type === 'swipe') { |
| 6613 | oldMessage = lastMessage.mes; |
| 6614 | lastMessage.swipes.length++; |
| 6615 | if (lastMessage.swipe_id === lastMessage.swipes.length - 1) { |
| 6616 | lastMessage.title = title; |
| 6617 | lastMessage.mes = getMessage; |
| 6618 | lastMessage.gen_started = generation_started; |
| 6619 | lastMessage.gen_finished = generationFinished; |
| 6620 | lastMessage.send_date = getMessageTimeStamp(); |
| 6621 | lastMessage.extra.api = getGeneratingApi(); |
| 6622 | lastMessage.extra.model = getGeneratingModel(); |
| 6623 | lastMessage.extra.reasoning = reasoning; |
| 6624 | lastMessage.extra.reasoning_duration = null; |
| 6625 | lastMessage.extra.reasoning_signature = reasoningSignature; |
| 6626 | await processImageAttachment(lastMessage, { imageUrls }); |
| 6627 | if (power_user.message_token_count_enabled) { |
| 6628 | const tokenCountText = (reasoning || '') + lastMessage.mes; |
| 6629 | lastMessage.extra.token_count = await getTokenCountAsync(tokenCountText, 0); |
| 6630 | } |
| 6631 | const chat_id = (chat.length - 1); |
| 6632 | !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type); |
| 6633 | addOneMessage(chat[chat_id], { type: 'swipe' }); |
| 6634 | !fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type); |
| 6635 | } else { |
| 6636 | lastMessage.mes = getMessage; |
| 6637 | } |
| 6638 | } else if (type === 'append' || type === 'continue') { |
| 6639 | console.debug('Trying to append.'); |
| 6640 | oldMessage = lastMessage.mes; |
| 6641 | lastMessage.title = title; |
| 6642 | lastMessage.mes += getMessage; |
| 6643 | lastMessage.gen_started = generation_started; |
| 6644 | lastMessage.gen_finished = generationFinished; |
| 6645 | lastMessage.send_date = getMessageTimeStamp(); |
| 6646 | lastMessage.extra.api = getGeneratingApi(); |
| 6647 | lastMessage.extra.model = getGeneratingModel(); |
| 6648 | lastMessage.extra.reasoning = reasoning; |
| 6649 | lastMessage.extra.reasoning_duration = null; |
| 6650 | lastMessage.extra.reasoning_signature = reasoningSignature; |
| 6651 | await processImageAttachment(lastMessage, { imageUrls }); |
| 6652 | if (power_user.message_token_count_enabled) { |
| 6653 | const tokenCountText = (reasoning || '') + lastMessage.mes; |
| 6654 | lastMessage.extra.token_count = await getTokenCountAsync(tokenCountText, 0); |
| 6655 | } |
| 6656 | const chat_id = (chat.length - 1); |
| 6657 | !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type); |
| 6658 | addOneMessage(chat[chat_id], { type: 'swipe' }); |
| 6659 | !fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type); |
| 6660 | } else if (type === 'appendFinal') { |
| 6661 | oldMessage = lastMessage.mes; |
| 6662 | console.debug('Trying to appendFinal.'); |
| 6663 | lastMessage.title = title; |
| 6664 | lastMessage.mes = getMessage; |
| 6665 | lastMessage.gen_started = generation_started; |
| 6666 | lastMessage.gen_finished = generationFinished; |
| 6667 | lastMessage.send_date = getMessageTimeStamp(); |
| 6668 | lastMessage.extra.api = getGeneratingApi(); |
| 6669 | lastMessage.extra.model = getGeneratingModel(); |
| 6670 | lastMessage.extra.reasoning += reasoning; |
| 6671 | lastMessage.extra.reasoning_signature = reasoningSignature; |
| 6672 | await processImageAttachment(lastMessage, { imageUrls }); |
| 6673 | // We don't know if the reasoning duration extended, so we don't update it here on purpose. |
| 6674 | if (power_user.message_token_count_enabled) { |
| 6675 | const tokenCountText = (reasoning || '') + lastMessage.mes; |
| 6676 | lastMessage.extra.token_count = await getTokenCountAsync(tokenCountText, 0); |
| 6677 | } |
| 6678 | const chat_id = (chat.length - 1); |
| 6679 | !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type); |
| 6680 | addOneMessage(chat[chat_id], { type: 'swipe' }); |
| 6681 | !fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type); |
| 6682 | } else { |
| 6683 | console.debug('entering chat update routine for non-swipe post'); |
| 6684 | const newMessage = {}; |
| 6685 | chat.push(newMessage); |
| 6686 | newMessage.extra = {}; |
| 6687 | newMessage.name = name2; |
| 6688 | newMessage.is_user = false; |
| 6689 | newMessage.send_date = getMessageTimeStamp(); |
| 6690 | newMessage.extra.api = getGeneratingApi(); |
| 6691 | newMessage.extra.model = getGeneratingModel(); |
| 6692 | newMessage.extra.reasoning = reasoning; |
| 6693 | newMessage.extra.reasoning_duration = null; |
| 6694 | newMessage.extra.reasoning_signature = reasoningSignature; |
| 6695 | if (power_user.trim_spaces) { |
| 6696 | getMessage = getMessage.trim(); |
| 6697 | } |
| 6698 | newMessage.mes = getMessage; |
| 6699 | newMessage.title = title; |
| 6700 | newMessage.gen_started = generation_started; |
| 6701 | newMessage.gen_finished = generationFinished; |
| 6702 | |
| 6703 | if (power_user.message_token_count_enabled) { |
| 6704 | const tokenCountText = (reasoning || '') + newMessage.mes; |
| 6705 | newMessage.extra.token_count = await getTokenCountAsync(tokenCountText, 0); |
| 6706 | } |
| 6707 | |
| 6708 | if (selected_group) { |
| 6709 | console.debug('entering chat update for groups'); |
| 6710 | let avatarImg = 'img/ai4.png'; |
| 6711 | if (characters[this_chid].avatar != 'none') { |
| 6712 | avatarImg = getThumbnailUrl('avatar', characters[this_chid].avatar); |
| 6713 | } |
| 6714 | newMessage.force_avatar = avatarImg; |
| 6715 | newMessage.original_avatar = characters[this_chid].avatar; |
| 6716 | newMessage.extra.gen_id = group_generation_id; |
| 6717 | } |
| 6718 | |
| 6719 | await processImageAttachment(newMessage, { imageUrls }); |
| 6720 | const chat_id = (chat.length - 1); |
| 6721 | |
| 6722 | !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type); |
| 6723 | addOneMessage(chat[chat_id]); |
| 6724 | !fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type); |
| 6725 | } |
| 6726 | |
| 6727 | const item = chat[chat.length - 1]; |
| 6728 | if (item.swipe_info === undefined) { |
| 6729 | item.swipe_info = []; |
| 6730 | } |
| 6731 | if (item.swipe_id !== undefined) { |
| 6732 | const swipeId = item.swipe_id; |
| 6733 | item.swipes[swipeId] = item.mes; |
| 6734 | item.swipe_info[swipeId] = { |
| 6735 | send_date: item.send_date, |
| 6736 | gen_started: item.gen_started, |
| 6737 | gen_finished: item.gen_finished, |
| 6738 | extra: structuredClone(item.extra), |
| 6739 | }; |
| 6740 | } else { |
| 6741 | item.swipe_id = 0; |
| 6742 | item.swipes = []; |
| 6743 | item.swipes[0] = item.mes; |
| 6744 | item.swipe_info[0] = { |
| 6745 | send_date: item.send_date, |
| 6746 | gen_started: item.gen_started, |
| 6747 | gen_finished: item.gen_finished, |
| 6748 | extra: structuredClone(item.extra), |
| 6749 | }; |
| 6750 | } |
| 6751 | |
| 6752 | if (Array.isArray(swipes) && swipes.length > 0) { |
| 6753 | const swipeInfoExtra = structuredClone(item.extra ?? {}); |
| 6754 | delete swipeInfoExtra.token_count; |
| 6755 | delete swipeInfoExtra.reasoning; |
| 6756 | delete swipeInfoExtra.reasoning_duration; |
| 6757 | const swipeInfo = { |
| 6758 | send_date: item.send_date, |
| 6759 | gen_started: item.gen_started, |
| 6760 | gen_finished: item.gen_finished, |
| 6761 | extra: swipeInfoExtra, |
| 6762 | }; |
| 6763 | const swipeInfoArray = Array(swipes.length).fill().map(() => structuredClone(swipeInfo)); |
| 6764 | parseReasoningInSwipes(swipes, swipeInfoArray, item.extra?.reasoning_duration); |
| 6765 | item.swipes.push(...swipes); |
| 6766 | item.swipe_info.push(...swipeInfoArray); |
| 6767 | } |
| 6768 | |
| 6769 | statMesProcess(item, type, characters, this_chid, oldMessage); |
| 6770 | return { type, getMessage }; |
| 6771 | } |
| 6772 | |
| 6773 | /** |
| 6774 | * Creates a message's `swipes`, `swipe_id` and `swipe_info` if necessary. |
| 6775 | * @param {ChatMessage} message |
| 6776 | * @returns {boolean} true if the message was updated. |
| 6777 | */ |
| 6778 | export function ensureSwipes(message) { |
| 6779 | let updated = false; |
| 6780 | |
| 6781 | if (!message || typeof message !== 'object') { |
| 6782 | console.trace(`[ensureSwipes] failed. '${message}' is not an object.`); |
| 6783 | return updated; |
| 6784 | } |
| 6785 | |
| 6786 | //Small system messages and user messages should not have swipes. |
| 6787 | if (message?.is_user || message?.extra?.isSmallSys) { |
| 6788 | return updated; |
| 6789 | } |
| 6790 | |
| 6791 | if (!Array.isArray(message.swipes)) { |
| 6792 | message.swipes = [message.mes ?? '']; |
| 6793 | updated = true; |
| 6794 | } |
| 6795 | |
| 6796 | if (typeof message.swipe_id !== 'number') { |
| 6797 | message.swipe_id = 0; |
| 6798 | updated = true; |
| 6799 | } |
| 6800 | |
| 6801 | /** @type {() => SwipeInfo} */ |
| 6802 | const createSwipeInfo = () => ({ |
| 6803 | send_date: message.send_date, |
| 6804 | gen_started: message.gen_started, |
| 6805 | gen_finished: message.gen_finished, |
| 6806 | extra: {}, |
| 6807 | }); |
| 6808 | |
| 6809 | if (!Array.isArray(message.swipe_info)) { |
| 6810 | message.swipe_info = message.swipes.map(_ => createSwipeInfo()); |
| 6811 | updated = true; |
| 6812 | } |
| 6813 | |
| 6814 | for (let i = 0; i < message.swipes.length; i++) { |
| 6815 | if (typeof message.swipes[i] !== 'string') { |
| 6816 | updated = true; |
| 6817 | console.warn('The message had a swipe that is not a string. It has has been set to \'\'.', message); |
| 6818 | message.swipes[i] = ''; |
| 6819 | } |
| 6820 | if (!message.swipe_info[i] || typeof message.swipe_info[i] !== 'object') { |
| 6821 | updated = true; |
| 6822 | console.warn('The message had missing or invalid swipe_info for a swipe. It has been backfilled.', message); |
| 6823 | message.swipe_info[i] = createSwipeInfo(); |
| 6824 | } |
| 6825 | } |
| 6826 | |
| 6827 | return updated; |
| 6828 | } |
| 6829 | |
| 6830 | /** |
| 6831 | * Syncs the current message and all its data into the swipe data at the given message ID (or the last message if no ID is given). |
| 6832 | * |
| 6833 | * If the swipe data is invalid in some way, this function will exit out without doing anything. |
| 6834 | * @param {number?} [messageId=null] - The ID of the message to sync with the swipe data. If no ID is given, the last message is used. |
| 6835 | * @returns {boolean} Whether the message was successfully synced |
| 6836 | */ |
| 6837 | export function syncMesToSwipe(messageId = null) { |
| 6838 | if (!chat.length) { |
| 6839 | return false; |
| 6840 | } |
| 6841 | |
| 6842 | const targetMessageId = messageId ?? chat.length - 1; |
| 6843 | if (targetMessageId >= chat.length || targetMessageId < 0) { |
| 6844 | console.warn(`[syncMesToSwipe] Invalid message ID: ${messageId}`); |
| 6845 | return false; |
| 6846 | } |
| 6847 | |
| 6848 | const targetMessage = chat[targetMessageId]; |
| 6849 | if (!targetMessage) { |
| 6850 | return false; |
| 6851 | } |
| 6852 | |
| 6853 | // No swipe data there yet, exit out |
| 6854 | if (typeof targetMessage.swipe_id !== 'number') { |
| 6855 | return false; |
| 6856 | } |
| 6857 | // If swipes structure is invalid, exit out (for now?) |
| 6858 | if (!Array.isArray(targetMessage.swipe_info) || !Array.isArray(targetMessage.swipes)) { |
| 6859 | return false; |
| 6860 | } |
| 6861 | // If the swipe is not present yet, exit out (will likely be copied later) |
| 6862 | // "" is falsy. An empty string is a valid message. |
| 6863 | if (typeof targetMessage.swipes[targetMessage.swipe_id] !== 'string' || !targetMessage.swipe_info[targetMessage.swipe_id]) { |
| 6864 | return false; |
| 6865 | } |
| 6866 | |
| 6867 | const targetSwipeInfo = targetMessage.swipe_info[targetMessage.swipe_id]; |
| 6868 | if (typeof targetSwipeInfo !== 'object') { |
| 6869 | return false; |
| 6870 | } |
| 6871 | |
| 6872 | // Only sync swipes if the chat is not pristine, so that macros in the greeting can resolve again on swipe |
| 6873 | if (chat_metadata.tainted || chat.length > 1) { |
| 6874 | targetMessage.swipes[targetMessage.swipe_id] = targetMessage.mes; |
| 6875 | } |
| 6876 | |
| 6877 | targetSwipeInfo.send_date = targetMessage.send_date; |
| 6878 | targetSwipeInfo.gen_started = targetMessage.gen_started; |
| 6879 | targetSwipeInfo.gen_finished = targetMessage.gen_finished; |
| 6880 | targetSwipeInfo.extra = structuredClone(targetMessage.extra); |
| 6881 | |
| 6882 | return true; |
| 6883 | } |
| 6884 | |
| 6885 | /** |
| 6886 | * Syncs swipe data back to the message data at the given message ID (or the last message if no ID is given). |
| 6887 | * If the swipe ID is not provided, the current swipe ID in the message object is used. |
| 6888 | * |
| 6889 | * If the swipe data is invalid in some way, this function will exit out without doing anything. |
| 6890 | * @param {number?} [messageId=null] - The ID of the message to sync with the swipe data. If no ID is given, the last message is used. |
| 6891 | * @param {number?} [swipeId=null] - The ID of the swipe to sync. If no ID is given, the current swipe ID in the message object is used. |
| 6892 | * @param {ChatMessage?} [targetMessage=null] - The message object to sync instead of resolving one from `chat`. |
| 6893 | * @returns {boolean} Whether the swipe data was successfully synced to the message |
| 6894 | */ |
| 6895 | export function syncSwipeToMes(messageId = null, swipeId = null, targetMessage = null) { |
| 6896 | if (!targetMessage && !chat.length) { |
| 6897 | return false; |
| 6898 | } |
| 6899 | |
| 6900 | if (!targetMessage) { |
| 6901 | const targetMessageId = messageId ?? chat.length - 1; |
| 6902 | if (targetMessageId >= chat.length || targetMessageId < 0) { |
| 6903 | console.warn(`[syncSwipeToMes] Invalid message ID: ${messageId}`); |
| 6904 | return false; |
| 6905 | } |
| 6906 | |
| 6907 | targetMessage = chat[targetMessageId]; |
| 6908 | } |
| 6909 | |
| 6910 | if (!targetMessage) { |
| 6911 | return false; |
| 6912 | } |
| 6913 | |
| 6914 | if (swipeId !== null) { |
| 6915 | if (isNaN(swipeId) || swipeId < 0) { |
| 6916 | console.warn(`[syncSwipeToMes] Invalid swipe ID: ${swipeId}`); |
| 6917 | return false; |
| 6918 | } |
| 6919 | targetMessage.swipe_id = swipeId; |
| 6920 | } |
| 6921 | |
| 6922 | // No swipe data there yet, exit out |
| 6923 | if (typeof targetMessage.swipe_id !== 'number') { |
| 6924 | return false; |
| 6925 | } |
| 6926 | // If swipes structure is invalid, exit out |
| 6927 | if (!Array.isArray(targetMessage.swipes)) { |
| 6928 | return false; |
| 6929 | } |
| 6930 | |
| 6931 | // Backfill swipe_info if missing. |
| 6932 | if (!Array.isArray(targetMessage.swipe_info)) { |
| 6933 | targetMessage.swipe_info = targetMessage.swipes.map(_ => ({ |
| 6934 | send_date: targetMessage.send_date, |
| 6935 | gen_started: void 0, |
| 6936 | gen_finished: void 0, |
| 6937 | extra: {}, |
| 6938 | })); |
| 6939 | } |
| 6940 | |
| 6941 | const targetSwipeId = targetMessage.swipe_id; |
| 6942 | if (typeof targetMessage.swipes[targetSwipeId] !== 'string') { |
| 6943 | console.warn(`[syncSwipeToMes] Invalid swipe ID: ${targetSwipeId}`); |
| 6944 | return false; |
| 6945 | } |
| 6946 | |
| 6947 | const targetSwipeInfo = targetMessage?.swipe_info?.[targetSwipeId]; |
| 6948 | if (typeof targetSwipeInfo !== 'object') { |
| 6949 | console.warn(`[syncSwipeToMes] Invalid swipe info: ${targetSwipeId}`); |
| 6950 | } |
| 6951 | |
| 6952 | targetMessage.mes = targetMessage.swipes[targetSwipeId]; |
| 6953 | targetMessage.send_date = targetSwipeInfo?.send_date; |
| 6954 | targetMessage.gen_started = targetSwipeInfo?.gen_started; |
| 6955 | targetMessage.gen_finished = targetSwipeInfo?.gen_finished; |
| 6956 | targetMessage.extra = structuredClone(targetSwipeInfo?.extra) ?? {}; |
| 6957 | |
| 6958 | return true; |
| 6959 | } |
| 6960 | |
| 6961 | /** |
| 6962 | * Saves the image to the message object. |
| 6963 | * @param {ParsedImage} img Image object |
| 6964 | * @param {ChatMessage} mes Chat message object |
| 6965 | * @typedef {{ image?: string, title?: string, inline?: boolean }} ParsedImage |
| 6966 | */ |
| 6967 | function saveImageToMessage(img, mes) { |
| 6968 | if (mes && img.image) { |
| 6969 | if (!mes.extra || typeof mes.extra !== 'object') { |
| 6970 | mes.extra = {}; |
| 6971 | } |
| 6972 | if (!Array.isArray(mes.extra.media)) { |
| 6973 | mes.extra.media = []; |
| 6974 | } |
| 6975 | mes.extra.media.push({ url: img.image, type: MEDIA_TYPE.IMAGE, title: img.title, source: MEDIA_SOURCE.API }); |
| 6976 | mes.extra.inline_image = img.inline; |
| 6977 | } |
| 6978 | } |
| 6979 | |
| 6980 | export function getGeneratingApi() { |
| 6981 | switch (main_api) { |
| 6982 | case 'openai': |
| 6983 | return oai_settings.chat_completion_source || 'openai'; |
| 6984 | case 'textgenerationwebui': |
| 6985 | return textgen_settings.type === textgen_types.OOBA ? 'textgenerationwebui' : textgen_settings.type; |
| 6986 | default: |
| 6987 | return main_api; |
| 6988 | } |
| 6989 | } |
| 6990 | |
| 6991 | export function getGeneratingModel(mes) { |
| 6992 | let model = ''; |
| 6993 | switch (main_api) { |
| 6994 | case 'kobold': |
| 6995 | model = online_status; |
| 6996 | break; |
| 6997 | case 'novel': |
| 6998 | model = nai_settings.model_novel; |
| 6999 | break; |
| 7000 | case 'openai': |
| 7001 | model = getChatCompletionModel(); |
| 7002 | break; |
| 7003 | case 'textgenerationwebui': |
| 7004 | model = online_status; |
| 7005 | break; |
| 7006 | case 'koboldhorde': |
| 7007 | model = kobold_horde_model; |
| 7008 | break; |
| 7009 | } |
| 7010 | return model; |
| 7011 | } |
| 7012 | |
| 7013 | /** |
| 7014 | * A function mainly used to switch 'generating' state - setting it to false and activating the buttons again |
| 7015 | */ |
| 7016 | export function activateSendButtons() { |
| 7017 | is_send_press = false; |
| 7018 | hideStopButton(); |
| 7019 | showSwipeButtons(); |
| 7020 | delete document.body.dataset.generating; |
| 7021 | } |
| 7022 | |
| 7023 | /** |
| 7024 | * A function mainly used to switch 'generating' state - setting it to true and deactivating the buttons |
| 7025 | */ |
| 7026 | export function deactivateSendButtons() { |
| 7027 | showStopButton(); |
| 7028 | hideSwipeButtons(); |
| 7029 | document.body.dataset.generating = 'true'; |
| 7030 | } |
| 7031 | |
| 7032 | export function resetChatState() { |
| 7033 | // replaces deleted charcter name with system user since it will be displayed next. |
| 7034 | name2 = (this_chid === undefined && neutralCharacterName) ? neutralCharacterName : systemUserName; |
| 7035 | //unsets expected chid before reloading (related to getCharacters/printCharacters from using old arrays) |
| 7036 | setCharacterId(undefined); |
| 7037 | // sets up system user to tell user about having deleted a character |
| 7038 | chat.splice(0, chat.length, ...SAFETY_CHAT); |
| 7039 | // resets chat metadata |
| 7040 | chat_metadata = {}; |
| 7041 | // resets the characters array, forcing getcharacters to reset |
| 7042 | characters.length = 0; |
| 7043 | } |
| 7044 | |
| 7045 | /** |
| 7046 | * |
| 7047 | * @param {'characters' | 'character_edit' | 'create' | 'group_edit' | 'group_create'} value |
| 7048 | */ |
| 7049 | export function setMenuType(value) { |
| 7050 | menu_type = value; |
| 7051 | // Allow custom CSS to see which menu type is active |
| 7052 | document.getElementById('right-nav-panel').dataset.menuType = menu_type; |
| 7053 | } |
| 7054 | |
| 7055 | export function setExternalAbortController(controller) { |
| 7056 | abortController = controller; |
| 7057 | } |
| 7058 | |
| 7059 | /** |
| 7060 | * Sets a character array index. |
| 7061 | * @param {number|string|undefined} value |
| 7062 | */ |
| 7063 | export function setCharacterId(value) { |
| 7064 | switch (typeof value) { |
| 7065 | case 'bigint': |
| 7066 | case 'number': |
| 7067 | this_chid = String(value); |
| 7068 | break; |
| 7069 | case 'string': |
| 7070 | this_chid = !isNaN(parseInt(value)) ? value : undefined; |
| 7071 | break; |
| 7072 | case 'object': |
| 7073 | this_chid = characters.indexOf(value) !== -1 ? String(characters.indexOf(value)) : undefined; |
| 7074 | break; |
| 7075 | case 'undefined': |
| 7076 | this_chid = undefined; |
| 7077 | break; |
| 7078 | default: |
| 7079 | console.error('Invalid character ID type:', value); |
| 7080 | break; |
| 7081 | } |
| 7082 | } |
| 7083 | |
| 7084 | export function setCharacterName(value) { |
| 7085 | name2 = value; |
| 7086 | } |
| 7087 | |
| 7088 | /** |
| 7089 | * Sets the API connection status of the application |
| 7090 | * @param {string|'no_connection'} value Connection status value |
| 7091 | */ |
| 7092 | export function setOnlineStatus(value) { |
| 7093 | const previousStatus = online_status; |
| 7094 | online_status = value; |
| 7095 | displayOnlineStatus(); |
| 7096 | if (previousStatus !== online_status) { |
| 7097 | eventSource.emitAndWait(event_types.ONLINE_STATUS_CHANGED, online_status); |
| 7098 | } |
| 7099 | } |
| 7100 | |
| 7101 | export function setEditedMessageId(value) { |
| 7102 | this_edit_mes_id = value; |
| 7103 | } |
| 7104 | |
| 7105 | export function setSendButtonState(value) { |
| 7106 | is_send_press = value; |
| 7107 | } |
| 7108 | |
| 7109 | /** |
| 7110 | * Renames the currently selected character, updating relevant references and optionally renaming past chats. |
| 7111 | * |
| 7112 | * If no name is provided, a popup prompts for a new name. If the new name matches the current name, |
| 7113 | * the renaming process is aborted. The function sends a request to the server to rename the character |
| 7114 | * and handles updates to other related fields such as tags, lore, and author notes. |
| 7115 | * |
| 7116 | * If the renaming is successful, the character list is reloaded and the renamed character is selected. |
| 7117 | * Optionally, past chats can be renamed to reflect the new character name. |
| 7118 | * |
| 7119 | * @param {string?} [name=null] - The new name for the character. If not provided, a popup will prompt for it. |
| 7120 | * @param {object} [options] - Additional options. |
| 7121 | * @param {boolean} [options.silent=false] - If true, suppresses popups and warnings. |
| 7122 | * @param {boolean?} [options.renameChats=null] - If true, renames past chats to reflect the new character name. |
| 7123 | * @returns {Promise<boolean>} - Returns true if the character was successfully renamed, false otherwise. |
| 7124 | */ |
| 7125 | |
| 7126 | export async function renameCharacter(name = null, { silent = false, renameChats = null } = {}) { |
| 7127 | if (!name && silent) { |
| 7128 | toastr.warning(t`No character name provided.`, t`Rename Character`); |
| 7129 | return false; |
| 7130 | } |
| 7131 | if (this_chid === undefined) { |
| 7132 | toastr.warning(t`No character selected.`, t`Rename Character`); |
| 7133 | return false; |
| 7134 | } |
| 7135 | |
| 7136 | const oldAvatar = characters[this_chid].avatar; |
| 7137 | const newValue = name || await callGenericPopup('<h3>' + t`New name:` + '</h3>', POPUP_TYPE.INPUT, characters[this_chid].name); |
| 7138 | |
| 7139 | if (!newValue) { |
| 7140 | toastr.warning(t`No character name provided.`, t`Rename Character`); |
| 7141 | return false; |
| 7142 | } |
| 7143 | if (newValue === characters[this_chid].name) { |
| 7144 | toastr.info(t`Same character name provided, so name did not change.`, t`Rename Character`); |
| 7145 | return false; |
| 7146 | } |
| 7147 | |
| 7148 | const body = JSON.stringify({ avatar_url: oldAvatar, new_name: newValue }); |
| 7149 | const response = await fetch('/api/characters/rename', { |
| 7150 | method: 'POST', |
| 7151 | headers: getRequestHeaders(), |
| 7152 | body, |
| 7153 | }); |
| 7154 | |
| 7155 | try { |
| 7156 | if (response.ok) { |
| 7157 | const data = await response.json(); |
| 7158 | const newAvatar = data.avatar; |
| 7159 | |
| 7160 | const oldName = getCharaFilename(null, { manualAvatarKey: oldAvatar }); |
| 7161 | const newName = getCharaFilename(null, { manualAvatarKey: newAvatar }); |
| 7162 | |
| 7163 | // Replace other auxiliary fields where was referenced by avatar key |
| 7164 | // Tag List |
| 7165 | renameTagKey(oldAvatar, newAvatar); |
| 7166 | |
| 7167 | // Additional lore books |
| 7168 | const charLore = world_info.charLore?.find(x => x.name == oldName); |
| 7169 | if (charLore) { |
| 7170 | charLore.name = newName; |
| 7171 | saveSettingsDebounced(); |
| 7172 | } |
| 7173 | |
| 7174 | // Char-bound Author's Notes |
| 7175 | const charNote = extension_settings.note.chara?.find(x => x.name == oldName); |
| 7176 | if (charNote) { |
| 7177 | charNote.name = newName; |
| 7178 | saveSettingsDebounced(); |
| 7179 | } |
| 7180 | |
| 7181 | // Update active character, if the current one was the currently active one |
| 7182 | if (active_character === oldAvatar) { |
| 7183 | active_character = newAvatar; |
| 7184 | saveSettingsDebounced(); |
| 7185 | } |
| 7186 | |
| 7187 | await eventSource.emit(event_types.CHARACTER_RENAMED, oldAvatar, newAvatar); |
| 7188 | |
| 7189 | // Unload current character |
| 7190 | setCharacterId(undefined); |
| 7191 | // Reload characters list |
| 7192 | await getCharacters(); |
| 7193 | |
| 7194 | // Find newly renamed character |
| 7195 | const newChId = characters.findIndex(c => c.avatar == data.avatar); |
| 7196 | |
| 7197 | if (newChId !== -1) { |
| 7198 | // Select the character after the renaming |
| 7199 | await selectCharacterById(newChId); |
| 7200 | |
| 7201 | // Async delay to update UI |
| 7202 | await delay(1); |
| 7203 | |
| 7204 | if (this_chid === undefined) { |
| 7205 | throw new Error('New character not selected'); |
| 7206 | } |
| 7207 | |
| 7208 | // Also rename as a group member |
| 7209 | await renameGroupMember(oldAvatar, newAvatar, newValue.toString()); |
| 7210 | const renamePastChatsConfirm = renameChats !== null |
| 7211 | ? renameChats |
| 7212 | : silent |
| 7213 | ? false |
| 7214 | : await Popup.show.confirm( |
| 7215 | t`Character renamed!`, |
| 7216 | `<p>${t`Past chats will still contain the old character name. Would you like to update the character name in previous chats as well?`}</p> |
| 7217 | <i><b>${t`Sprites folder (if any) should be renamed manually.`}</b></i>`, |
| 7218 | ) == POPUP_RESULT.AFFIRMATIVE; |
| 7219 | |
| 7220 | if (renamePastChatsConfirm) { |
| 7221 | await renamePastChats(oldAvatar, newAvatar, newValue); |
| 7222 | await reloadCurrentChat(); |
| 7223 | toastr.success(t`Character renamed and past chats updated!`, t`Rename Character`); |
| 7224 | } else { |
| 7225 | toastr.success(t`Character renamed!`, t`Rename Character`); |
| 7226 | } |
| 7227 | } else { |
| 7228 | throw new Error('Newly renamed character was lost?'); |
| 7229 | } |
| 7230 | } else { |
| 7231 | throw new Error('Could not rename the character'); |
| 7232 | } |
| 7233 | } catch (error) { |
| 7234 | // Reloading to prevent data corruption |
| 7235 | if (!silent) await Popup.show.text(t`Rename Character`, t`Something went wrong. The page will be reloaded.`); |
| 7236 | else toastr.error(t`Something went wrong. The page will be reloaded.`, t`Rename Character`); |
| 7237 | |
| 7238 | console.log('Renaming character error:', error); |
| 7239 | location.reload(); |
| 7240 | return false; |
| 7241 | } |
| 7242 | |
| 7243 | return true; |
| 7244 | } |
| 7245 | |
| 7246 | async function renamePastChats(oldAvatar, newAvatar, newName) { |
| 7247 | const pastChats = await getPastCharacterChats(); |
| 7248 | |
| 7249 | for (const { file_name } of pastChats) { |
| 7250 | try { |
| 7251 | const fileNameWithoutExtension = file_name.replace('.jsonl', ''); |
| 7252 | const getChatResponse = await fetch('/api/chats/get', { |
| 7253 | method: 'POST', |
| 7254 | headers: getRequestHeaders(), |
| 7255 | body: JSON.stringify({ |
| 7256 | ch_name: newName, |
| 7257 | file_name: fileNameWithoutExtension, |
| 7258 | avatar_url: newAvatar, |
| 7259 | }), |
| 7260 | cache: 'no-cache', |
| 7261 | }); |
| 7262 | |
| 7263 | if (getChatResponse.ok) { |
| 7264 | const currentChat = await getChatResponse.json(); |
| 7265 | |
| 7266 | for (const message of currentChat) { |
| 7267 | if (message.is_user || message.is_system || message.extra?.type == system_message_types.NARRATOR) { |
| 7268 | continue; |
| 7269 | } |
| 7270 | |
| 7271 | if (message.name !== undefined) { |
| 7272 | message.name = newName; |
| 7273 | } |
| 7274 | } |
| 7275 | |
| 7276 | await eventSource.emit(event_types.CHARACTER_RENAMED_IN_PAST_CHAT, currentChat, oldAvatar, newAvatar); |
| 7277 | |
| 7278 | const saveChatRequest = await compressRequest({ |
| 7279 | method: 'POST', |
| 7280 | headers: getRequestHeaders(), |
| 7281 | body: JSON.stringify({ |
| 7282 | ch_name: newName, |
| 7283 | file_name: fileNameWithoutExtension, |
| 7284 | chat: currentChat, |
| 7285 | avatar_url: newAvatar, |
| 7286 | }), |
| 7287 | cache: 'no-cache', |
| 7288 | }); |
| 7289 | const saveChatResponse = await fetch('/api/chats/save', saveChatRequest); |
| 7290 | |
| 7291 | if (!saveChatResponse.ok) { |
| 7292 | throw new Error('Could not save chat'); |
| 7293 | } |
| 7294 | } |
| 7295 | } catch (error) { |
| 7296 | toastr.error(t`Past chat could not be updated: ${file_name}`); |
| 7297 | console.error(error); |
| 7298 | } |
| 7299 | } |
| 7300 | } |
| 7301 | |
| 7302 | export function saveChatDebounced() { |
| 7303 | const chid = this_chid; |
| 7304 | const selectedGroup = selected_group; |
| 7305 | |
| 7306 | cancelDebouncedChatSave(); |
| 7307 | |
| 7308 | chatSaveTimeout = setTimeout(async () => { |
| 7309 | if (selectedGroup !== selected_group) { |
| 7310 | console.warn('Chat save timeout triggered, but group changed. Aborting.'); |
| 7311 | return; |
| 7312 | } |
| 7313 | |
| 7314 | if (chid !== this_chid) { |
| 7315 | console.warn('Chat save timeout triggered, but chid changed. Aborting.'); |
| 7316 | return; |
| 7317 | } |
| 7318 | |
| 7319 | console.debug('Chat save timeout triggered'); |
| 7320 | await saveChatConditional(); |
| 7321 | console.debug('Chat saved'); |
| 7322 | }, DEFAULT_SAVE_EDIT_TIMEOUT); |
| 7323 | } |
| 7324 | |
| 7325 | /** |
| 7326 | * Saves the chat to the server. |
| 7327 | * @param {object} [options] - Additional options. |
| 7328 | * @param {string} [options.chatName] The name of the chat file to save to |
| 7329 | * @param {object} [options.withMetadata] Additional metadata to save with the chat |
| 7330 | * @param {number} [options.mesId] The message ID to save the chat up to |
| 7331 | * @param {boolean} [options.force] Force the saving despite the integrity check result |
| 7332 | * @param {ChatMessage[]} [options.chatData] Chat snapshot to save instead of the current in-memory chat |
| 7333 | * |
| 7334 | * @returns {Promise<void>} |
| 7335 | */ |
| 7336 | export async function saveChat({ chatName, withMetadata, mesId, force = false, chatData = undefined } = {}) { |
| 7337 | if (selected_group) { |
| 7338 | toastr.error(t`Operation was aborted to prevent data corruption.`, t`saveChat called for a group chat`); |
| 7339 | throw new Error('saveChat called for a group chat'); |
| 7340 | } |
| 7341 | |
| 7342 | if (arguments.length > 0 && typeof arguments[0] !== 'object') { |
| 7343 | console.trace('saveChat called with positional arguments. Please use an object instead.'); |
| 7344 | [chatName, withMetadata, mesId, force] = arguments; |
| 7345 | } |
| 7346 | |
| 7347 | const metadata = { ...chat_metadata, ...(withMetadata || {}) }; |
| 7348 | const fileName = chatName ?? characters[this_chid]?.chat; |
| 7349 | |
| 7350 | if (!fileName && name2 === neutralCharacterName) { |
| 7351 | // TODO: Do something for a temporary chat with no character. |
| 7352 | return; |
| 7353 | } |
| 7354 | |
| 7355 | if (!fileName) { |
| 7356 | console.warn('saveChat called without chat_name and no chat file found'); |
| 7357 | return; |
| 7358 | } |
| 7359 | |
| 7360 | characters[this_chid].date_last_chat = Date.now(); |
| 7361 | |
| 7362 | const trimmedChat = Array.isArray(chatData) |
| 7363 | ? chatData |
| 7364 | : (mesId !== undefined && mesId >= 0 && mesId < chat.length) |
| 7365 | ? chat.slice(0, Number(mesId) + 1) |
| 7366 | : chat.slice(); |
| 7367 | |
| 7368 | /** @type {ChatHeader} */ |
| 7369 | const chatHeader = { |
| 7370 | chat_metadata: metadata, |
| 7371 | user_name: 'unused', |
| 7372 | character_name: 'unused', |
| 7373 | }; |
| 7374 | |
| 7375 | try { |
| 7376 | const saveChatRequest = await compressRequest({ |
| 7377 | method: 'POST', |
| 7378 | cache: 'no-cache', |
| 7379 | headers: getRequestHeaders(), |
| 7380 | body: JSON.stringify({ |
| 7381 | ch_name: characters[this_chid].name, |
| 7382 | file_name: fileName, |
| 7383 | chat: [chatHeader, ...trimmedChat], |
| 7384 | avatar_url: characters[this_chid].avatar, |
| 7385 | force: force, |
| 7386 | }), |
| 7387 | }); |
| 7388 | const result = await fetch('/api/chats/save', saveChatRequest); |
| 7389 | |
| 7390 | if (result.ok) { |
| 7391 | return; |
| 7392 | } |
| 7393 | |
| 7394 | const errorData = await result.json(); |
| 7395 | const isIntegrityError = errorData?.error === 'integrity' && !force; |
| 7396 | if (!isIntegrityError) { |
| 7397 | throw new Error(result.statusText); |
| 7398 | } |
| 7399 | |
| 7400 | const popupResult = await Popup.show.input( |
| 7401 | t`ERROR: Chat integrity check failed while saving the file.`, |
| 7402 | t`<p>After you click OK, the page will be reloaded to prevent data corruption.</p> |
| 7403 | <p>To confirm an overwrite (and potentially <b>LOSE YOUR DATA</b>), enter <code>OVERWRITE</code> (in all caps) in the box below before clicking OK.</p>`, |
| 7404 | '', |
| 7405 | { okButton: 'OK', cancelButton: false }, |
| 7406 | ); |
| 7407 | |
| 7408 | const forceSaveConfirmed = popupResult === 'OVERWRITE'; |
| 7409 | |
| 7410 | if (!forceSaveConfirmed) { |
| 7411 | console.warn('Chat integrity check failed, and user did not confirm the overwrite. Reloading the page.'); |
| 7412 | window.location.reload(); |
| 7413 | return; |
| 7414 | } |
| 7415 | |
| 7416 | await saveChat({ chatName, withMetadata, mesId, force: true }); |
| 7417 | } catch (error) { |
| 7418 | console.error(error); |
| 7419 | toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Chat could not be saved`); |
| 7420 | } |
| 7421 | } |
| 7422 | |
| 7423 | /** |
| 7424 | * Processes the avatar image from the input element, allowing the user to crop it if necessary. |
| 7425 | * @param {HTMLInputElement} input - The input element containing the avatar file. |
| 7426 | * @returns {Promise<void>} |
| 7427 | */ |
| 7428 | async function read_avatar_load(input) { |
| 7429 | if (input.files && input.files[0]) { |
| 7430 | if (selected_button == 'create') { |
| 7431 | create_save.avatar = input.files; |
| 7432 | } |
| 7433 | |
| 7434 | crop_data = undefined; |
| 7435 | const file = input.files[0]; |
| 7436 | const fileData = await getBase64Async(file); |
| 7437 | |
| 7438 | if (!power_user.never_resize_avatars) { |
| 7439 | const dlg = new Popup('Set the crop position of the avatar image', POPUP_TYPE.CROP, '', { cropImage: fileData }); |
| 7440 | const croppedImage = await dlg.show(); |
| 7441 | |
| 7442 | if (!croppedImage) { |
| 7443 | return; |
| 7444 | } |
| 7445 | |
| 7446 | crop_data = dlg.cropData; |
| 7447 | $('#avatar_load_preview').attr('src', String(croppedImage)); |
| 7448 | } else { |
| 7449 | $('#avatar_load_preview').attr('src', fileData); |
| 7450 | } |
| 7451 | |
| 7452 | if (menu_type == 'create') { |
| 7453 | return; |
| 7454 | } |
| 7455 | |
| 7456 | await createOrEditCharacter(); |
| 7457 | |
| 7458 | const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0))); |
| 7459 | const avatarKey = formData.get('avatar_url').toString(); |
| 7460 | |
| 7461 | // Bust cache for the avatar thumbnail and character image |
| 7462 | const thumbnailUrl = getThumbnailUrl('avatar', avatarKey); |
| 7463 | await fetch(thumbnailUrl, { method: 'GET', cache: 'reload' }); |
| 7464 | await fetch(`/characters/${avatarKey}`, { method: 'GET', cache: 'reload' }); |
| 7465 | |
| 7466 | // Refresh all visible avatar images that use this thumbnail URL |
| 7467 | // This handles messages, character list, and any other place using the thumbnail |
| 7468 | const avatarImages = document.querySelectorAll(`img[src^="${thumbnailUrl}"]`); |
| 7469 | for (const img of avatarImages) { |
| 7470 | if (img instanceof HTMLImageElement) { |
| 7471 | const originalSrc = img.src; |
| 7472 | img.src = ''; |
| 7473 | img.src = originalSrc; |
| 7474 | } |
| 7475 | } |
| 7476 | console.debug(`Refreshed ${avatarImages.length} avatar images for ${avatarKey}`); |
| 7477 | |
| 7478 | console.log('Avatar refreshed'); |
| 7479 | } |
| 7480 | } |
| 7481 | |
| 7482 | /** |
| 7483 | * Gets the URL for a thumbnail of a specific type and file. |
| 7484 | * @param {import('../src/endpoints/thumbnails.js').ThumbnailType} type The type of the thumbnail to get |
| 7485 | * @param {string} file The file name or path for which to get the thumbnail URL |
| 7486 | * @param {boolean} [t=false] Whether to add a cache-busting timestamp to the URL |
| 7487 | * @returns {string} The URL for the thumbnail |
| 7488 | */ |
| 7489 | export function getThumbnailUrl(type, file, t = false) { |
| 7490 | return `/thumbnail?type=${type}&file=${encodeURIComponent(file)}${t ? `&t=${Date.now()}` : ''}`; |
| 7491 | } |
| 7492 | |
| 7493 | export function buildAvatarList(block, entities, { templateId = 'inline_avatar_template', empty = true, interactable = false, highlightFavs = true } = {}) { |
| 7494 | if (empty) { |
| 7495 | block.empty(); |
| 7496 | } |
| 7497 | |
| 7498 | for (const entity of entities) { |
| 7499 | const id = entity.id; |
| 7500 | |
| 7501 | // Populate the template |
| 7502 | const avatarTemplate = $(`#${templateId} .avatar`).clone(); |
| 7503 | |
| 7504 | let this_avatar = default_avatar; |
| 7505 | if (entity.item.avatar !== undefined && entity.item.avatar != 'none') { |
| 7506 | this_avatar = getThumbnailUrl('avatar', entity.item.avatar); |
| 7507 | } |
| 7508 | |
| 7509 | avatarTemplate.attr('data-type', entity.type); |
| 7510 | avatarTemplate.attr('data-chid', id); |
| 7511 | avatarTemplate.find('img').attr('src', this_avatar).attr('alt', entity.item.name); |
| 7512 | avatarTemplate.attr('title', `[Character] ${entity.item.name}\nFile: ${entity.item.avatar}`); |
| 7513 | if (highlightFavs) { |
| 7514 | avatarTemplate.toggleClass('is_fav', entity.item.fav || entity.item.fav == 'true'); |
| 7515 | avatarTemplate.find('.ch_fav').val(entity.item.fav); |
| 7516 | } |
| 7517 | |
| 7518 | // If this is a group, we need to hack slightly. We still want to keep most of the css classes and layout, but use a group avatar instead. |
| 7519 | if (entity.type === 'group') { |
| 7520 | const grpTemplate = getGroupAvatar(entity.item); |
| 7521 | |
| 7522 | avatarTemplate.addClass(grpTemplate.attr('class')); |
| 7523 | avatarTemplate.empty(); |
| 7524 | avatarTemplate.append(grpTemplate.children()); |
| 7525 | avatarTemplate.attr({ 'data-grid': id, 'data-chid': null }); |
| 7526 | avatarTemplate.attr('title', `[Group] ${entity.item.name}`); |
| 7527 | } else if (entity.type === 'persona') { |
| 7528 | avatarTemplate.attr({ 'data-pid': id, 'data-chid': null }); |
| 7529 | avatarTemplate.find('img').attr('src', getThumbnailUrl('persona', entity.item.avatar)); |
| 7530 | avatarTemplate.attr('title', `[Persona] ${entity.item.name}\nFile: ${entity.item.avatar}`); |
| 7531 | } |
| 7532 | |
| 7533 | if (interactable) { |
| 7534 | avatarTemplate.addClass(INTERACTABLE_CONTROL_CLASS); |
| 7535 | avatarTemplate.toggleClass('character_select', entity.type === 'character'); |
| 7536 | avatarTemplate.toggleClass('group_select', entity.type === 'group'); |
| 7537 | } |
| 7538 | |
| 7539 | block.append(avatarTemplate); |
| 7540 | } |
| 7541 | } |
| 7542 | |
| 7543 | /** |
| 7544 | * Loads all the data of a shallow character. |
| 7545 | * @param {string|undefined} characterId Array index |
| 7546 | * @returns {Promise<void>} Promise that resolves when the character is unshallowed |
| 7547 | */ |
| 7548 | export async function unshallowCharacter(characterId) { |
| 7549 | if (characterId === undefined) { |
| 7550 | console.debug('Undefined character cannot be unshallowed'); |
| 7551 | return; |
| 7552 | } |
| 7553 | |
| 7554 | /** @type {Character} */ |
| 7555 | const character = characters[characterId]; |
| 7556 | if (!character) { |
| 7557 | console.debug('Character not found:', characterId); |
| 7558 | return; |
| 7559 | } |
| 7560 | |
| 7561 | // Character is not shallow |
| 7562 | if (!character.shallow) { |
| 7563 | return; |
| 7564 | } |
| 7565 | |
| 7566 | const avatar = character.avatar; |
| 7567 | if (!avatar) { |
| 7568 | console.debug('Character has no avatar field:', characterId); |
| 7569 | return; |
| 7570 | } |
| 7571 | |
| 7572 | await getOneCharacter(avatar); |
| 7573 | } |
| 7574 | |
| 7575 | export async function getChat() { |
| 7576 | try { |
| 7577 | await unshallowCharacter(this_chid); |
| 7578 | |
| 7579 | const response = await fetch('/api/chats/get', { |
| 7580 | method: 'POST', |
| 7581 | headers: getRequestHeaders(), |
| 7582 | cache: 'no-cache', |
| 7583 | body: JSON.stringify({ |
| 7584 | ch_name: characters[this_chid].name, |
| 7585 | file_name: characters[this_chid].chat, |
| 7586 | avatar_url: characters[this_chid].avatar, |
| 7587 | }), |
| 7588 | }); |
| 7589 | |
| 7590 | if (!response.ok) { |
| 7591 | throw new Error('Chat could not be loaded'); |
| 7592 | } |
| 7593 | |
| 7594 | const data = await response.json(); |
| 7595 | if (Array.isArray(data) && data.length > 0) { |
| 7596 | /** @type {ChatHeader} */ |
| 7597 | const chatHeader = data.shift(); |
| 7598 | chat_metadata = chatHeader?.chat_metadata ?? {}; |
| 7599 | chat.splice(0, chat.length, ...data); |
| 7600 | chat.forEach(ensureMessageMediaIsArray); |
| 7601 | } else { |
| 7602 | // An empty/corrupted chat file |
| 7603 | chat.splice(0, chat.length); |
| 7604 | chat_metadata = {}; |
| 7605 | } |
| 7606 | if (!chat_metadata.integrity) { |
| 7607 | chat_metadata.integrity = uuidv4(); |
| 7608 | } |
| 7609 | await getChatResult(); |
| 7610 | eventSource.emit(event_types.CHAT_LOADED, { detail: { id: this_chid, character: characters[this_chid] } }); |
| 7611 | |
| 7612 | // Focus on the textarea if not already focused on a visible text input |
| 7613 | delay(debounce_timeout.short).then(() => { |
| 7614 | if ($(document.activeElement).is('input:visible, textarea:visible')) { |
| 7615 | return; |
| 7616 | } |
| 7617 | $('#send_textarea').trigger('click').trigger('focus'); |
| 7618 | }); |
| 7619 | } catch (error) { |
| 7620 | await getChatResult(); |
| 7621 | console.log(error); |
| 7622 | } |
| 7623 | } |
| 7624 | |
| 7625 | async function getChatResult() { |
| 7626 | name2 = characters[this_chid].name; |
| 7627 | let freshChat = false; |
| 7628 | if (chat.length === 0) { |
| 7629 | const message = getFirstMessage(); |
| 7630 | if (message.mes) { |
| 7631 | chat.push(message); |
| 7632 | freshChat = true; |
| 7633 | } |
| 7634 | // Make sure the chat appears on the server |
| 7635 | await saveChatConditional(); |
| 7636 | } |
| 7637 | await loadItemizedPrompts(getCurrentChatId()); |
| 7638 | await printMessages(); |
| 7639 | select_selected_character(this_chid); |
| 7640 | |
| 7641 | await eventSource.emit(event_types.CHAT_CHANGED, (getCurrentChatId())); |
| 7642 | if (freshChat) await eventSource.emit(event_types.CHAT_CREATED); |
| 7643 | |
| 7644 | if (chat.length === 1) { |
| 7645 | const chat_id = (chat.length - 1); |
| 7646 | await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, 'first_message'); |
| 7647 | await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, 'first_message'); |
| 7648 | } |
| 7649 | } |
| 7650 | |
| 7651 | function getFirstMessage() { |
| 7652 | const firstMes = characters[this_chid]?.first_mes || ''; |
| 7653 | const alternateGreetings = characters[this_chid]?.data?.alternate_greetings; |
| 7654 | |
| 7655 | const message = { |
| 7656 | name: name2, |
| 7657 | is_user: false, |
| 7658 | is_system: false, |
| 7659 | send_date: getMessageTimeStamp(), |
| 7660 | mes: getRegexedString(firstMes, regex_placement.AI_OUTPUT), |
| 7661 | extra: {}, |
| 7662 | }; |
| 7663 | |
| 7664 | if (Array.isArray(alternateGreetings) && alternateGreetings.length > 0) { |
| 7665 | const swipes = [message.mes, ...(alternateGreetings.map(greeting => getRegexedString(greeting, regex_placement.AI_OUTPUT)))]; |
| 7666 | |
| 7667 | if (!message.mes) { |
| 7668 | swipes.shift(); |
| 7669 | message.mes = swipes[0]; |
| 7670 | } |
| 7671 | |
| 7672 | message.swipe_id = 0; |
| 7673 | message.swipes = swipes; |
| 7674 | message.swipe_info = swipes.map(_ => ({ |
| 7675 | send_date: message.send_date, |
| 7676 | gen_started: void 0, |
| 7677 | gen_finished: void 0, |
| 7678 | extra: {}, |
| 7679 | })); |
| 7680 | } |
| 7681 | |
| 7682 | return message; |
| 7683 | } |
| 7684 | |
| 7685 | export async function openCharacterChat(file_name) { |
| 7686 | await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10); |
| 7687 | await clearChat({ clearData: true }); |
| 7688 | characters[this_chid].chat = file_name; |
| 7689 | chat_metadata = {}; |
| 7690 | await getChat(); |
| 7691 | $('#selected_chat_pole').val(file_name); |
| 7692 | await createOrEditCharacter(new CustomEvent('newChat')); |
| 7693 | } |
| 7694 | |
| 7695 | ////////// OPTIMZED MAIN API CHANGE FUNCTION //////////// |
| 7696 | |
| 7697 | export function changeMainAPI(api = null) { |
| 7698 | const selectedVal = api ?? $('#main_api').val(); |
| 7699 | //console.log(selectedVal); |
| 7700 | const apiElements = { |
| 7701 | 'koboldhorde': { |
| 7702 | apiStreaming: $('#NULL_SELECTOR'), |
| 7703 | apiSettings: $('#kobold_api-settings'), |
| 7704 | apiConnector: $('#kobold_horde'), |
| 7705 | apiPresets: $('#kobold_api-presets'), |
| 7706 | apiRanges: $('#range_block'), |
| 7707 | maxContextElem: $('#max_context_block'), |
| 7708 | amountGenElem: $('#amount_gen_block'), |
| 7709 | }, |
| 7710 | 'kobold': { |
| 7711 | apiStreaming: $('#streaming_kobold_block'), |
| 7712 | apiSettings: $('#kobold_api-settings'), |
| 7713 | apiConnector: $('#kobold_api'), |
| 7714 | apiPresets: $('#kobold_api-presets'), |
| 7715 | apiRanges: $('#range_block'), |
| 7716 | maxContextElem: $('#max_context_block'), |
| 7717 | amountGenElem: $('#amount_gen_block'), |
| 7718 | }, |
| 7719 | 'textgenerationwebui': { |
| 7720 | apiStreaming: $('#streaming_textgenerationwebui_block'), |
| 7721 | apiSettings: $('#textgenerationwebui_api-settings'), |
| 7722 | apiConnector: $('#textgenerationwebui_api'), |
| 7723 | apiPresets: $('#textgenerationwebui_api-presets'), |
| 7724 | apiRanges: $('#range_block_textgenerationwebui'), |
| 7725 | maxContextElem: $('#max_context_block'), |
| 7726 | amountGenElem: $('#amount_gen_block'), |
| 7727 | }, |
| 7728 | 'novel': { |
| 7729 | apiStreaming: $('#streaming_novel_block'), |
| 7730 | apiSettings: $('#novel_api-settings'), |
| 7731 | apiConnector: $('#novel_api'), |
| 7732 | apiPresets: $('#novel_api-presets'), |
| 7733 | apiRanges: $('#range_block_novel'), |
| 7734 | maxContextElem: $('#max_context_block'), |
| 7735 | amountGenElem: $('#amount_gen_block'), |
| 7736 | }, |
| 7737 | 'openai': { |
| 7738 | apiStreaming: $('#NULL_SELECTOR'), |
| 7739 | apiSettings: $('#openai_settings'), |
| 7740 | apiConnector: $('#openai_api'), |
| 7741 | apiPresets: $('#openai_api-presets'), |
| 7742 | apiRanges: $('#range_block_openai'), |
| 7743 | maxContextElem: $('#max_context_block'), |
| 7744 | amountGenElem: $('#amount_gen_block'), |
| 7745 | }, |
| 7746 | }; |
| 7747 | //console.log('--- apiElements--- '); |
| 7748 | //console.log(apiElements); |
| 7749 | |
| 7750 | //first, disable everything so the old elements stop showing |
| 7751 | for (const apiName in apiElements) { |
| 7752 | const apiObj = apiElements[apiName]; |
| 7753 | //do not hide items to then proceed to immediately show them. |
| 7754 | if (selectedVal === apiName) { |
| 7755 | continue; |
| 7756 | } |
| 7757 | apiObj.apiSettings.css('display', 'none'); |
| 7758 | apiObj.apiConnector.css('display', 'none'); |
| 7759 | apiObj.apiRanges.css('display', 'none'); |
| 7760 | apiObj.apiPresets.css('display', 'none'); |
| 7761 | apiObj.apiStreaming.css('display', 'none'); |
| 7762 | } |
| 7763 | |
| 7764 | //then, find and enable the active item. |
| 7765 | //This is split out of the loop so that different apis can share settings divs |
| 7766 | let activeItem = apiElements[selectedVal]; |
| 7767 | |
| 7768 | activeItem.apiStreaming.css('display', 'block'); |
| 7769 | activeItem.apiSettings.css('display', 'block'); |
| 7770 | activeItem.apiConnector.css('display', 'block'); |
| 7771 | activeItem.apiRanges.css('display', 'block'); |
| 7772 | activeItem.apiPresets.css('display', 'block'); |
| 7773 | |
| 7774 | if (selectedVal === 'openai') { |
| 7775 | activeItem.apiPresets.css('display', 'flex'); |
| 7776 | } |
| 7777 | |
| 7778 | if (selectedVal === 'textgenerationwebui' || selectedVal === 'novel') { |
| 7779 | console.debug('enabling amount_gen for ooba/novel'); |
| 7780 | activeItem.amountGenElem.find('input').prop('disabled', false); |
| 7781 | activeItem.amountGenElem.css('opacity', 1.0); |
| 7782 | } |
| 7783 | |
| 7784 | //custom because streaming has been moved up under response tokens, which exists inside common settings block |
| 7785 | if (selectedVal === 'novel') { |
| 7786 | $('#ai_module_block_novel').css('display', 'block'); |
| 7787 | } else { |
| 7788 | $('#ai_module_block_novel').css('display', 'none'); |
| 7789 | } |
| 7790 | |
| 7791 | $('#prompt_cost_block').toggle(selectedVal === 'textgenerationwebui' && textgen_settings.type === textgen_types.OPENROUTER); |
| 7792 | |
| 7793 | // Hide common settings for OpenAI |
| 7794 | console.debug('value?', selectedVal); |
| 7795 | if (selectedVal == 'openai') { |
| 7796 | console.debug('hiding settings?'); |
| 7797 | $('#common-gen-settings-block').css('display', 'none'); |
| 7798 | } else { |
| 7799 | $('#common-gen-settings-block').css('display', 'block'); |
| 7800 | } |
| 7801 | |
| 7802 | main_api = selectedVal; |
| 7803 | setOnlineStatus('no_connection'); |
| 7804 | |
| 7805 | if (main_api == 'koboldhorde') { |
| 7806 | getStatusHorde(); |
| 7807 | getHordeModels(true); |
| 7808 | } |
| 7809 | validateDisabledSamplers(); |
| 7810 | setupChatCompletionPromptManager(oai_settings); |
| 7811 | forceCharacterEditorTokenize(); |
| 7812 | } |
| 7813 | |
| 7814 | export function setUserName(value, { toastPersonaNameChange = true } = {}) { |
| 7815 | name1 = value; |
| 7816 | if (name1 === undefined || name1 == '') |
| 7817 | name1 = default_user_name; |
| 7818 | console.log(`User name changed to ${name1}`); |
| 7819 | $('#your_name').text(name1); |
| 7820 | if (toastPersonaNameChange && power_user.persona_show_notifications && !isPersonaPanelOpen()) { |
| 7821 | toastr.success(t`Your messages will now be sent as ${name1}`, t`Persona Changed`); |
| 7822 | } |
| 7823 | saveSettingsDebounced(); |
| 7824 | } |
| 7825 | |
| 7826 | async function doOnboarding(avatarId) { |
| 7827 | const template = $('#onboarding_template .onboarding'); |
| 7828 | let userName = await callGenericPopup(template, POPUP_TYPE.INPUT, currentUser?.name || name1, { wider: true, cancelButton: false }); |
| 7829 | |
| 7830 | if (userName) { |
| 7831 | userName = String(userName).replace('\n', ' '); |
| 7832 | setUserName(userName); |
| 7833 | console.log(`Binding persona ${avatarId} to name ${userName}`); |
| 7834 | power_user.personas[avatarId] = userName; |
| 7835 | power_user.persona_descriptions[avatarId] = { |
| 7836 | description: '', |
| 7837 | position: persona_description_positions.IN_PROMPT, |
| 7838 | }; |
| 7839 | } |
| 7840 | } |
| 7841 | |
| 7842 | function reloadLoop() { |
| 7843 | const MAX_RELOADS = 5; |
| 7844 | let reloads = Number(sessionStorage.getItem('reloads') || 0); |
| 7845 | if (reloads < MAX_RELOADS) { |
| 7846 | reloads++; |
| 7847 | sessionStorage.setItem('reloads', String(reloads)); |
| 7848 | window.location.reload(); |
| 7849 | } |
| 7850 | } |
| 7851 | |
| 7852 | //MARK: getSettings() |
| 7853 | /////////////////////////////////////////// |
| 7854 | export async function getSettings(initLoaderHandle = null) { |
| 7855 | const response = await fetch('/api/settings/get', { |
| 7856 | method: 'POST', |
| 7857 | headers: getRequestHeaders(), |
| 7858 | body: JSON.stringify({}), |
| 7859 | cache: 'no-cache', |
| 7860 | }); |
| 7861 | |
| 7862 | if (!response.ok) { |
| 7863 | reloadLoop(); |
| 7864 | toastr.error(t`Settings could not be loaded after multiple attempts. Please try again later.`); |
| 7865 | throw new Error('Error getting settings'); |
| 7866 | } |
| 7867 | |
| 7868 | const data = await response.json(); |
| 7869 | if (data.result != 'file not find' && data.settings) { |
| 7870 | settings = JSON.parse(data.settings); |
| 7871 | if (settings.username !== undefined && settings.username !== '') { |
| 7872 | name1 = settings.username; |
| 7873 | $('#your_name').text(name1); |
| 7874 | } |
| 7875 | |
| 7876 | accountStorage.init(settings?.accountStorage); |
| 7877 | await setUserControls(data.enable_accounts); |
| 7878 | setRequestCompressionConfig(data.request_compression); |
| 7879 | |
| 7880 | // Allow subscribers to mutate settings |
| 7881 | await eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings); |
| 7882 | |
| 7883 | //Load AI model config settings |
| 7884 | amount_gen = settings.amount_gen; |
| 7885 | if (settings.max_context !== undefined) |
| 7886 | max_context = parseInt(settings.max_context); |
| 7887 | |
| 7888 | swipes = settings.swipes !== undefined ? !!settings.swipes : true; // enable swipes by default |
| 7889 | $('#swipes-checkbox').prop('checked', swipes); /// swipecode |
| 7890 | refreshSwipeButtons(); |
| 7891 | |
| 7892 | // Kobold |
| 7893 | loadKoboldSettings(data, settings.kai_settings ?? settings, settings); |
| 7894 | |
| 7895 | // Novel |
| 7896 | loadNovelSettings(data, settings.nai_settings ?? settings); |
| 7897 | |
| 7898 | // TextGen |
| 7899 | await loadTextGenSettings(data, settings); |
| 7900 | |
| 7901 | // OpenAI |
| 7902 | loadOpenAISettings(data, settings.oai_settings ?? settings); |
| 7903 | |
| 7904 | // Horde |
| 7905 | loadHordeSettings(settings); |
| 7906 | |
| 7907 | // Load power user settings |
| 7908 | await loadPowerUserSettings(settings, data); |
| 7909 | |
| 7910 | // Apply theme toggles from power user settings |
| 7911 | applyPowerUserSettings(); |
| 7912 | |
| 7913 | // Load character tags |
| 7914 | loadTagsSettings(settings); |
| 7915 | |
| 7916 | // Load background |
| 7917 | loadBackgroundSettings(settings); |
| 7918 | |
| 7919 | // Load proxy presets |
| 7920 | loadProxyPresets(settings); |
| 7921 | |
| 7922 | // Allow subscribers to mutate settings |
| 7923 | await eventSource.emit(event_types.SETTINGS_LOADED_AFTER, settings); |
| 7924 | |
| 7925 | // Set context size after loading power user (may override the max value) |
| 7926 | $('#max_context').val(max_context); |
| 7927 | $('#max_context_counter').val(max_context); |
| 7928 | |
| 7929 | $('#amount_gen').val(amount_gen); |
| 7930 | $('#amount_gen_counter').val(amount_gen); |
| 7931 | |
| 7932 | //Load which API we are using |
| 7933 | if (settings.main_api == undefined) { |
| 7934 | settings.main_api = 'kobold'; |
| 7935 | } |
| 7936 | |
| 7937 | if (settings.main_api == 'poe') { |
| 7938 | settings.main_api = 'openai'; |
| 7939 | } |
| 7940 | |
| 7941 | main_api = settings.main_api; |
| 7942 | $('#main_api').val(main_api); |
| 7943 | $(`#main_api option[value=${main_api}]`).attr('selected', 'true'); |
| 7944 | changeMainAPI(); |
| 7945 | |
| 7946 | //Load User's Name and Avatar |
| 7947 | initUserAvatar(settings.user_avatar); |
| 7948 | setPersonaDescription(); |
| 7949 | |
| 7950 | //Load the active character and group |
| 7951 | active_character = settings.active_character; |
| 7952 | active_group = settings.active_group; |
| 7953 | |
| 7954 | setWorldInfoSettings(settings.world_info_settings ?? settings, data); |
| 7955 | |
| 7956 | selected_button = settings.selected_button; |
| 7957 | |
| 7958 | // TODO: Move me into firstLoadInit when experimental toggle is removed |
| 7959 | // power_user.experimental_macro_engine |
| 7960 | initMacros(); |
| 7961 | |
| 7962 | if (data.enable_extensions) { |
| 7963 | const enableAutoUpdate = Boolean(data.enable_extensions_auto_update); |
| 7964 | const isVersionChanged = settings.currentVersion !== currentVersion; |
| 7965 | await loadExtensionSettings(settings, isVersionChanged, enableAutoUpdate); |
| 7966 | await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED); |
| 7967 | } else { |
| 7968 | Object.assign(extension_settings, (settings.extension_settings ?? {})); |
| 7969 | $('#third_party_extension_button').addClass('disabled'); |
| 7970 | $('#extensions_details').addClass('disabled'); |
| 7971 | $('#extensions_connect').addClass('disabled'); |
| 7972 | $('#extensions_notify_updates').attr('disabled', 'disabled'); |
| 7973 | $('#extensions_autoconnect').attr('disabled', 'disabled'); |
| 7974 | $('#extensions_url').attr('disabled', 'disabled'); |
| 7975 | $('#extensions_api_key').attr('disabled', 'disabled'); |
| 7976 | } |
| 7977 | |
| 7978 | firstRun = !!settings.firstRun; |
| 7979 | |
| 7980 | if (firstRun) { |
| 7981 | await initLoaderHandle?.hide(); |
| 7982 | await doOnboarding(user_avatar); |
| 7983 | firstRun = false; |
| 7984 | } |
| 7985 | } |
| 7986 | await validateDisabledSamplers(); |
| 7987 | settingsReady = true; |
| 7988 | await eventSource.emit(event_types.SETTINGS_LOADED); |
| 7989 | } |
| 7990 | |
| 7991 | //MARK: saveSettings() |
| 7992 | export async function saveSettings(loopCounter = 0) { |
| 7993 | if (!settingsReady) { |
| 7994 | console.warn('Settings not ready, scheduling another save'); |
| 7995 | saveSettingsDebounced(); |
| 7996 | return; |
| 7997 | } |
| 7998 | |
| 7999 | const MAX_RETRIES = 3; |
| 8000 | if (TempResponseLength.isCustomized()) { |
| 8001 | if (loopCounter < MAX_RETRIES) { |
| 8002 | console.warn('Response length is currently being overridden, scheduling another save'); |
| 8003 | saveSettingsDebounced(++loopCounter); |
| 8004 | return; |
| 8005 | } |
| 8006 | console.error('Response length is currently being overridden, but the save loop has reached the maximum number of retries'); |
| 8007 | TempResponseLength.restore(null); |
| 8008 | } |
| 8009 | |
| 8010 | const payload = { |
| 8011 | firstRun: firstRun, |
| 8012 | accountStorage: accountStorage.getState(), |
| 8013 | currentVersion: currentVersion, |
| 8014 | username: name1, |
| 8015 | active_character: active_character, |
| 8016 | active_group: active_group, |
| 8017 | user_avatar: user_avatar, |
| 8018 | amount_gen: amount_gen, |
| 8019 | max_context: max_context, |
| 8020 | main_api: main_api, |
| 8021 | world_info_settings: getWorldInfoSettings(), |
| 8022 | textgenerationwebui_settings: textgen_settings, |
| 8023 | swipes: swipes, |
| 8024 | horde_settings: horde_settings, |
| 8025 | power_user: power_user, |
| 8026 | extension_settings: extension_settings, |
| 8027 | tags: tags, |
| 8028 | tag_map: tag_map, |
| 8029 | nai_settings: nai_settings, |
| 8030 | kai_settings: kai_settings, |
| 8031 | oai_settings: oai_settings, |
| 8032 | background: background_settings, |
| 8033 | proxies: proxies, |
| 8034 | selected_proxy: selected_proxy, |
| 8035 | }; |
| 8036 | |
| 8037 | try { |
| 8038 | const saveSettingsRequest = await compressRequest({ |
| 8039 | method: 'POST', |
| 8040 | headers: getRequestHeaders(), |
| 8041 | body: JSON.stringify(payload), |
| 8042 | cache: 'no-cache', |
| 8043 | }); |
| 8044 | const result = await fetch('/api/settings/save', saveSettingsRequest); |
| 8045 | |
| 8046 | if (!result.ok) { |
| 8047 | throw new Error(`Failed to save settings: ${result.statusText}`); |
| 8048 | } |
| 8049 | |
| 8050 | settings = payload; |
| 8051 | await eventSource.emit(event_types.SETTINGS_UPDATED); |
| 8052 | } catch (error) { |
| 8053 | console.error('Error saving settings:', error); |
| 8054 | toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Settings could not be saved`); |
| 8055 | } |
| 8056 | } |
| 8057 | |
| 8058 | /** |
| 8059 | * Sets the generation parameters from a preset object. |
| 8060 | * @param {{ genamt?: number, max_length?: number }} preset Preset object |
| 8061 | */ |
| 8062 | export function setGenerationParamsFromPreset(preset) { |
| 8063 | const needsUnlock = (preset.max_length ?? max_context) > MAX_CONTEXT_DEFAULT || (preset.genamt ?? amount_gen) > MAX_RESPONSE_DEFAULT; |
| 8064 | $('#max_context_unlocked').prop('checked', needsUnlock).trigger('change'); |
| 8065 | |
| 8066 | if (preset.genamt !== undefined) { |
| 8067 | amount_gen = preset.genamt; |
| 8068 | $('#amount_gen').val(amount_gen); |
| 8069 | $('#amount_gen_counter').val(amount_gen); |
| 8070 | } |
| 8071 | |
| 8072 | if (preset.max_length !== undefined) { |
| 8073 | max_context = preset.max_length; |
| 8074 | $('#max_context').val(max_context); |
| 8075 | $('#max_context_counter').val(max_context); |
| 8076 | } |
| 8077 | } |
| 8078 | |
| 8079 | // Common code for message editor done and auto-save |
| 8080 | function updateMessage(div) { |
| 8081 | const mesBlock = div.closest('.mes_block'); |
| 8082 | let text = mesBlock.find('.edit_textarea').val() |
| 8083 | ?? mesBlock.find('.mes_text').text(); |
| 8084 | const mesElement = div.closest('.mes'); |
| 8085 | const mes = chat[mesElement.attr('mesid')]; |
| 8086 | |
| 8087 | // editing old messages |
| 8088 | mes.extra ??= {}; |
| 8089 | |
| 8090 | let regexPlacement; |
| 8091 | if (mes?.is_user) { |
| 8092 | regexPlacement = regex_placement.USER_INPUT; |
| 8093 | } else if (mes.extra?.type === 'narrator') { |
| 8094 | regexPlacement = regex_placement.SLASH_COMMAND; |
| 8095 | } else { |
| 8096 | regexPlacement = regex_placement.AI_OUTPUT; |
| 8097 | } |
| 8098 | |
| 8099 | // Ignore character override if sent as system |
| 8100 | text = getRegexedString( |
| 8101 | text, |
| 8102 | regexPlacement, |
| 8103 | { |
| 8104 | characterOverride: mes.extra?.type === 'narrator' ? undefined : mes.name, |
| 8105 | isEdit: true, |
| 8106 | }, |
| 8107 | ); |
| 8108 | |
| 8109 | |
| 8110 | if (power_user.trim_spaces) { |
| 8111 | text = text.trim(); |
| 8112 | } |
| 8113 | |
| 8114 | const bias = substituteParams(extractMessageBias(text)); |
| 8115 | text = substituteParams(text); |
| 8116 | if (bias) { |
| 8117 | text = removeMacros(text); |
| 8118 | } |
| 8119 | mes.mes = text; |
| 8120 | if (mes.swipe_id !== undefined) { |
| 8121 | ensureSwipes(mes); |
| 8122 | mes.swipes[mes.swipe_id] = text; |
| 8123 | } |
| 8124 | |
| 8125 | if (mes?.is_system || mes?.is_user || mes.extra?.type === system_message_types.NARRATOR) { |
| 8126 | mes.extra.bias = bias ?? null; |
| 8127 | } else { |
| 8128 | mes.extra.bias = null; |
| 8129 | } |
| 8130 | |
| 8131 | chat_metadata.tainted = true; |
| 8132 | |
| 8133 | return { mesBlock, text, mes, bias }; |
| 8134 | } |
| 8135 | |
| 8136 | function openMessageDelete(fromSlashCommand) { |
| 8137 | closeMessageEditor(); |
| 8138 | hideSwipeButtons(); |
| 8139 | if (fromSlashCommand || (!is_send_press) || (selected_group && !is_group_generating)) { |
| 8140 | $('#dialogue_del_mes').css('display', 'block'); |
| 8141 | $('#send_form').css('display', 'none'); |
| 8142 | $('.del_checkbox').each(function () { |
| 8143 | $(this).css('display', 'grid'); |
| 8144 | $(this).parent().children('.for_checkbox').css('display', 'none'); |
| 8145 | }); |
| 8146 | } else { |
| 8147 | console.debug(` |
| 8148 | ERR -- could not enter del mode |
| 8149 | this_chid: ${this_chid} |
| 8150 | is_send_press: ${is_send_press} |
| 8151 | selected_group: ${selected_group} |
| 8152 | is_group_generating: ${is_group_generating}`); |
| 8153 | } |
| 8154 | this_del_mes = -1; |
| 8155 | is_delete_mode = true; |
| 8156 | } |
| 8157 | |
| 8158 | function messageEditAuto(div) { |
| 8159 | const { mesBlock, text, mes, bias } = updateMessage(div); |
| 8160 | |
| 8161 | mesBlock.find('.mes_text').val(''); |
| 8162 | mesBlock.find('.mes_text').val(messageFormatting( |
| 8163 | text, |
| 8164 | this_edit_mes_chname, |
| 8165 | mes.is_system, |
| 8166 | mes.is_user, |
| 8167 | this_edit_mes_id, |
| 8168 | {}, |
| 8169 | false, |
| 8170 | )); |
| 8171 | mesBlock.find('.mes_bias').empty(); |
| 8172 | mesBlock.find('.mes_bias').append(messageFormatting(bias, '', false, false, -1, {}, false)); |
| 8173 | saveChatDebounced(); |
| 8174 | } |
| 8175 | |
| 8176 | /** |
| 8177 | * Create the message edit UI. |
| 8178 | * @param {number} editMessageId The ID of the message to edit |
| 8179 | */ |
| 8180 | export async function messageEdit(editMessageId) { |
| 8181 | const editMessage = chat[editMessageId]; |
| 8182 | if (!editMessage) { |
| 8183 | console.warn(`Message with id ${editMessageId} not found in chat array.`); |
| 8184 | return; |
| 8185 | } |
| 8186 | |
| 8187 | const messageElement = chatElement.find(`.mes[mesid="${editMessageId}"]`); |
| 8188 | if (messageElement.length === 0) { |
| 8189 | console.warn(`Message element with id ${editMessageId} not found in DOM.`); |
| 8190 | return; |
| 8191 | } |
| 8192 | |
| 8193 | this_edit_mes_id = editMessageId; |
| 8194 | this_edit_mes_chname = editMessage.name || (editMessage.is_user ? name1 : name2); |
| 8195 | |
| 8196 | refreshSwipeButtons(); |
| 8197 | |
| 8198 | const chatScrollPosition = chatElement.scrollTop(); |
| 8199 | const messageBlock = messageElement.find('.mes_block'); |
| 8200 | const messageText = messageBlock.find('.mes_text'); |
| 8201 | |
| 8202 | messageText.empty(); |
| 8203 | messageBlock.find('.mes_buttons').css('display', 'none'); |
| 8204 | messageBlock.find('.mes_edit_buttons').css('display', 'inline-flex'); |
| 8205 | |
| 8206 | // Also edit reasoning, if it exists |
| 8207 | const reasoningEdit = messageBlock.find('.mes_reasoning_edit:visible'); |
| 8208 | if (reasoningEdit.length > 0) { |
| 8209 | reasoningEdit.trigger('click'); |
| 8210 | } |
| 8211 | |
| 8212 | const editTextArea = document.createElement('textarea'); |
| 8213 | editTextArea.id = 'curEditTextarea'; |
| 8214 | editTextArea.className = 'edit_textarea mdHotkeys'; |
| 8215 | editTextArea.dataset.macros = ''; |
| 8216 | messageText.append(editTextArea); |
| 8217 | |
| 8218 | const text = trimSpaces(editMessage.mes || ''); |
| 8219 | const $editTextArea = $(editTextArea); |
| 8220 | $editTextArea.val(text); |
| 8221 | |
| 8222 | const cssAutofit = CSS.supports('field-sizing', 'content'); |
| 8223 | if (!cssAutofit) { |
| 8224 | $editTextArea.height(0); |
| 8225 | $editTextArea.height(editTextArea.scrollHeight); |
| 8226 | } |
| 8227 | |
| 8228 | $editTextArea.trigger('focus'); |
| 8229 | |
| 8230 | // Sets the cursor at the end of the text |
| 8231 | editTextArea.setSelectionRange(text.length, text.length); |
| 8232 | |
| 8233 | if (Number(this_edit_mes_id) === chat.length - 1) { |
| 8234 | chatElement.scrollTop(chatScrollPosition); |
| 8235 | } |
| 8236 | |
| 8237 | updateEditArrowClasses(); |
| 8238 | } |
| 8239 | |
| 8240 | /** |
| 8241 | * Close the open message editor. |
| 8242 | * This deletes the user's unsaved changes. |
| 8243 | * @param {number} [messageId=this_edit_mes_id] |
| 8244 | */ |
| 8245 | async function messageEditCancel(messageId = this_edit_mes_id) { |
| 8246 | let text = chat[messageId].mes; |
| 8247 | let thisMesDiv; |
| 8248 | // If this is the button then select it's parent. Otherwise, select by messageId. |
| 8249 | if (this?.classList?.contains('mes_edit_cancel')) { |
| 8250 | thisMesDiv = $(this).closest('.mes'); |
| 8251 | } else { |
| 8252 | thisMesDiv = chatElement.children('.mes').filter(`[mesid="${messageId}"]`); |
| 8253 | } |
| 8254 | |
| 8255 | const thisMesBlock = thisMesDiv.find('.mes_block'); |
| 8256 | thisMesBlock.find('.mes_text').empty(); |
| 8257 | thisMesDiv.find('.mes_edit_buttons').css('display', 'none'); |
| 8258 | thisMesBlock.find('.mes_buttons').css('display', ''); |
| 8259 | thisMesBlock.find('.mes_text') |
| 8260 | .append(messageFormatting( |
| 8261 | text, |
| 8262 | this_edit_mes_chname, |
| 8263 | chat[messageId].is_system, |
| 8264 | chat[messageId].is_user, |
| 8265 | messageId, |
| 8266 | {}, |
| 8267 | false, |
| 8268 | )); |
| 8269 | appendMediaToMessage(chat[messageId], thisMesDiv); |
| 8270 | addCopyToCodeBlocks(thisMesDiv); |
| 8271 | |
| 8272 | const reasoningEditDone = thisMesBlock.find('.mes_reasoning_edit_cancel:visible'); |
| 8273 | if (reasoningEditDone.length > 0) { |
| 8274 | reasoningEditDone.trigger('click'); |
| 8275 | } |
| 8276 | |
| 8277 | await eventSource.emit(event_types.MESSAGE_UPDATED, messageId); |
| 8278 | if (messageId == this_edit_mes_id) { |
| 8279 | this_edit_mes_id = undefined; |
| 8280 | } else { |
| 8281 | console.warn(`The message editor was closed on message #${messageId} while #${this_edit_mes_id} is being edited.`); |
| 8282 | } |
| 8283 | |
| 8284 | showSwipeButtons(); |
| 8285 | } |
| 8286 | |
| 8287 | /** |
| 8288 | * Swaps chat[sourceId] with chat[targetId]. They must be adjacent. |
| 8289 | * @param {number} sourceId Index of the message to move |
| 8290 | * @param {number} targetId Index of the target message |
| 8291 | * @returns {Promise<boolean>} True if the messages were moved, false otherwise |
| 8292 | */ |
| 8293 | async function messageEditMove(sourceId, targetId) { |
| 8294 | if (is_send_press) { |
| 8295 | console.warn(`The message #${sourceId} was not moved to #${targetId} because a generation is in progress.`); |
| 8296 | return false; |
| 8297 | } |
| 8298 | |
| 8299 | if (Math.abs(sourceId - targetId) !== 1) { |
| 8300 | console.error(`Message #${sourceId} and #${targetId} are not adjacent.`); |
| 8301 | return false; |
| 8302 | } |
| 8303 | |
| 8304 | const targetMessageDiv = chatElement.find(`.mes[mesid="${targetId}"]`); |
| 8305 | const sourceMessageDiv = chatElement.find(`.mes[mesid="${sourceId}"]`); |
| 8306 | |
| 8307 | if (sourceMessageDiv.length === 0 || targetMessageDiv.length === 0) { |
| 8308 | console.error(`Message #${sourceId} or #${targetId} were not found.`); |
| 8309 | return false; |
| 8310 | } |
| 8311 | |
| 8312 | if (sourceId <= targetId) { |
| 8313 | sourceMessageDiv.insertAfter(targetMessageDiv); |
| 8314 | } else { |
| 8315 | sourceMessageDiv.insertBefore(targetMessageDiv); |
| 8316 | } |
| 8317 | |
| 8318 | //Swap Ids. |
| 8319 | targetMessageDiv.attr('mesid', sourceId); |
| 8320 | sourceMessageDiv.attr('mesid', targetId); |
| 8321 | |
| 8322 | // Swap chat array entries. |
| 8323 | [chat[sourceId], chat[targetId]] = [chat[targetId], chat[sourceId]]; |
| 8324 | |
| 8325 | // Update edited message id |
| 8326 | if (this_edit_mes_id === sourceId) { |
| 8327 | this_edit_mes_id = targetId; |
| 8328 | } |
| 8329 | |
| 8330 | swapItemizedPrompts(sourceId, targetId); |
| 8331 | updateViewMessageIds(); |
| 8332 | refreshSwipeButtons(); |
| 8333 | await saveChatConditional(); |
| 8334 | return true; |
| 8335 | } |
| 8336 | |
| 8337 | async function messageEditDone(div) { |
| 8338 | if (!(this_edit_mes_id >= 0)) { |
| 8339 | console.trace('this_edit_mes_id cannot be blank when calling messageEditDone.'); |
| 8340 | return; |
| 8341 | } |
| 8342 | |
| 8343 | let { mesBlock, text, mes, bias } = updateMessage(div); |
| 8344 | |
| 8345 | await eventSource.emit(event_types.MESSAGE_EDITED, this_edit_mes_id); |
| 8346 | text = chat[this_edit_mes_id]?.mes ?? text; |
| 8347 | mesBlock.find('.mes_text').empty(); |
| 8348 | mesBlock.find('.mes_edit_buttons').css('display', 'none'); |
| 8349 | mesBlock.find('.mes_buttons').css('display', ''); |
| 8350 | mesBlock.find('.mes_text').append( |
| 8351 | messageFormatting( |
| 8352 | text, |
| 8353 | this_edit_mes_chname, |
| 8354 | mes.is_system, |
| 8355 | mes.is_user, |
| 8356 | this_edit_mes_id, |
| 8357 | {}, |
| 8358 | false, |
| 8359 | ), |
| 8360 | ); |
| 8361 | mesBlock.find('.mes_bias').empty(); |
| 8362 | mesBlock.find('.mes_bias').append(messageFormatting(bias, '', false, false, -1, {}, false)); |
| 8363 | appendMediaToMessage(mes, div.closest('.mes')); |
| 8364 | addCopyToCodeBlocks(div.closest('.mes')); |
| 8365 | |
| 8366 | const reasoningEditDone = mesBlock.find('.mes_reasoning_edit_done:visible'); |
| 8367 | if (reasoningEditDone.length > 0) { |
| 8368 | reasoningEditDone.trigger('click'); |
| 8369 | } |
| 8370 | |
| 8371 | await eventSource.emit(event_types.MESSAGE_UPDATED, this_edit_mes_id); |
| 8372 | this_edit_mes_id = undefined; |
| 8373 | await saveChatConditional(); |
| 8374 | showSwipeButtons(); |
| 8375 | } |
| 8376 | |
| 8377 | /** |
| 8378 | * Fetches the chat content for each chat file from the server and compiles them into a dictionary. |
| 8379 | * The function iterates over a provided list of chat metadata and requests the actual chat content |
| 8380 | * for each chat, either as an individual chat or a group chat based on the context. |
| 8381 | * |
| 8382 | * @param {Array} data - An array containing metadata about each chat such as file_name. |
| 8383 | * @param {boolean} isGroupChat - A flag indicating if the chat is a group chat. |
| 8384 | * @returns {Promise<Object>} chat_dict - A dictionary where each key is a file_name and the value is the |
| 8385 | * corresponding chat content fetched from the server. |
| 8386 | */ |
| 8387 | export async function getChatsFromFiles(data, isGroupChat) { |
| 8388 | const context = getContext(); |
| 8389 | let chat_dict = {}; |
| 8390 | let chat_list = Object.values(data).sort((a, b) => a.file_name.localeCompare(b.file_name)).reverse(); |
| 8391 | |
| 8392 | let chat_promise = chat_list.map(({ file_name }) => { |
| 8393 | return new Promise(async (res, rej) => { |
| 8394 | try { |
| 8395 | const endpoint = isGroupChat ? '/api/chats/group/get' : '/api/chats/get'; |
| 8396 | const requestBody = isGroupChat |
| 8397 | ? JSON.stringify({ id: file_name }) |
| 8398 | : JSON.stringify({ |
| 8399 | ch_name: characters[context.characterId].name, |
| 8400 | file_name: file_name.replace('.jsonl', ''), |
| 8401 | avatar_url: characters[context.characterId].avatar, |
| 8402 | }); |
| 8403 | |
| 8404 | const chatResponse = await fetch(endpoint, { |
| 8405 | method: 'POST', |
| 8406 | headers: getRequestHeaders(), |
| 8407 | body: requestBody, |
| 8408 | cache: 'no-cache', |
| 8409 | }); |
| 8410 | |
| 8411 | if (!chatResponse.ok) { |
| 8412 | return res(); |
| 8413 | // continue; |
| 8414 | } |
| 8415 | |
| 8416 | const currentChat = await chatResponse.json(); |
| 8417 | if (!isGroupChat) { |
| 8418 | // remove the first message, which is metadata, only for individual chats |
| 8419 | currentChat.shift(); |
| 8420 | } |
| 8421 | chat_dict[file_name] = currentChat; |
| 8422 | } catch (error) { |
| 8423 | console.error(error); |
| 8424 | } |
| 8425 | |
| 8426 | return res(); |
| 8427 | }); |
| 8428 | }); |
| 8429 | |
| 8430 | await Promise.all(chat_promise); |
| 8431 | |
| 8432 | return chat_dict; |
| 8433 | } |
| 8434 | |
| 8435 | /** |
| 8436 | * Fetches the metadata of all past chats related to a specific character based on its avatar URL. |
| 8437 | * The function sends a POST request to the server to retrieve all chats for the character. It then |
| 8438 | * processes the received data, sorts it by the file name, and returns the sorted data. |
| 8439 | * |
| 8440 | * @param {null|number} [characterId=null] - When set, the function will use this character id instead of this_chid. |
| 8441 | * |
| 8442 | * @returns {Promise<Array>} - An array containing metadata of all past chats of the character, sorted |
| 8443 | * in descending order by file name. Returns an empty array if the fetch request is unsuccessful or the |
| 8444 | * response is an object with an `error` property set to `true`. |
| 8445 | */ |
| 8446 | export async function getPastCharacterChats(characterId = null) { |
| 8447 | characterId = characterId ?? parseInt(this_chid); |
| 8448 | if (!characters[characterId]) return []; |
| 8449 | |
| 8450 | const response = await fetch('/api/characters/chats', { |
| 8451 | method: 'POST', |
| 8452 | body: JSON.stringify({ avatar_url: characters[characterId].avatar }), |
| 8453 | headers: getRequestHeaders(), |
| 8454 | }); |
| 8455 | |
| 8456 | if (!response.ok) { |
| 8457 | return []; |
| 8458 | } |
| 8459 | |
| 8460 | const data = await response.json(); |
| 8461 | if (typeof data === 'object' && data.error === true) { |
| 8462 | return []; |
| 8463 | } |
| 8464 | |
| 8465 | const chats = Object.values(data); |
| 8466 | return chats.sort((a, b) => a.file_name.localeCompare(b.file_name)).reverse(); |
| 8467 | } |
| 8468 | |
| 8469 | /** |
| 8470 | * Helper for `displayPastChats`, to make the same info consistently available for other functions |
| 8471 | */ |
| 8472 | export function getCurrentChatDetails() { |
| 8473 | if (!characters[this_chid] && !selected_group) { |
| 8474 | return { sessionName: '', group: null, characterName: '', avatarImgURL: '' }; |
| 8475 | } |
| 8476 | |
| 8477 | const group = selected_group ? groups.find(x => x.id === selected_group) : null; |
| 8478 | const currentChat = selected_group ? group?.chat_id : characters[this_chid].chat; |
| 8479 | const displayName = selected_group ? group?.name : characters[this_chid].name; |
| 8480 | const avatarImg = selected_group ? group?.avatar_url : getThumbnailUrl('avatar', characters[this_chid].avatar); |
| 8481 | return { sessionName: currentChat, group: group, characterName: displayName, avatarImgURL: avatarImg }; |
| 8482 | } |
| 8483 | |
| 8484 | /** |
| 8485 | * Displays the past chats for a character or a group based on the selected context. |
| 8486 | * The function first fetches the chats, processes them, and then displays them in |
| 8487 | * the HTML. It also has a built-in search functionality that allows filtering the |
| 8488 | * displayed chats based on a search query. |
| 8489 | * @param {string[]} hightlightNames - An array of chat names to highlight |
| 8490 | */ |
| 8491 | export async function displayPastChats(hightlightNames = []) { |
| 8492 | $('#select_chat_div').empty(); |
| 8493 | $('#select_chat_search').val('').off('input'); |
| 8494 | |
| 8495 | const chatDetails = getCurrentChatDetails(); |
| 8496 | const currentChat = chatDetails.sessionName; |
| 8497 | const displayName = chatDetails.characterName; |
| 8498 | const avatarImg = chatDetails.avatarImgURL; |
| 8499 | |
| 8500 | await displayChats('', currentChat, displayName, avatarImg, selected_group, hightlightNames); |
| 8501 | |
| 8502 | const debouncedDisplay = debounce((searchQuery) => { |
| 8503 | displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, []); |
| 8504 | }); |
| 8505 | |
| 8506 | // Define the search input listener |
| 8507 | $('#select_chat_search').off('input').on('input', function () { |
| 8508 | const searchQuery = $(this).val(); |
| 8509 | debouncedDisplay(searchQuery); |
| 8510 | }); |
| 8511 | |
| 8512 | // UX convenience: Focus the search field when the Manage Chat Files view opens. |
| 8513 | setTimeout(function () { |
| 8514 | const textSearchElement = $('#select_chat_search'); |
| 8515 | textSearchElement.trigger('click').trigger('focus').trigger('select'); |
| 8516 | }, 200); |
| 8517 | |
| 8518 | addChatBackupsBrowser(); |
| 8519 | } |
| 8520 | |
| 8521 | async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) { |
| 8522 | try { |
| 8523 | const response = await fetch('/api/chats/search', { |
| 8524 | method: 'POST', |
| 8525 | headers: getRequestHeaders(), |
| 8526 | body: JSON.stringify({ |
| 8527 | query: searchQuery, |
| 8528 | avatar_url: selected_group ? null : characters[this_chid].avatar, |
| 8529 | group_id: selected_group || null, |
| 8530 | }), |
| 8531 | }); |
| 8532 | |
| 8533 | if (!response.ok) { |
| 8534 | throw new Error('Search failed'); |
| 8535 | } |
| 8536 | |
| 8537 | const filteredData = await response.json(); |
| 8538 | $('#select_chat_div').empty(); |
| 8539 | |
| 8540 | filteredData.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes))); |
| 8541 | |
| 8542 | for (const chat of filteredData) { |
| 8543 | const isSelected = currentChat === chat.file_name; |
| 8544 | const template = $('#past_chat_template .select_chat_block_wrapper').clone(); |
| 8545 | template.find('.select_chat_block').attr('file_name', chat.file_name); |
| 8546 | template.find('.avatar img').attr('src', avatarImg); |
| 8547 | template.find('.select_chat_block_filename').text(chat.file_name); |
| 8548 | template.find('.chat_file_size').text(`(${chat.file_size},`); |
| 8549 | template.find('.chat_messages_num').text(`${chat.message_count} 💬)`); |
| 8550 | template.find('.select_chat_block_mes').text(chat.preview_message); |
| 8551 | template.find('.PastChat_cross').attr('file_name', chat.file_name); |
| 8552 | template.find('.chat_messages_date').text(timestampToMoment(chat.last_mes).format('lll')); |
| 8553 | |
| 8554 | if (isSelected) { |
| 8555 | template.find('.select_chat_block').attr('highlight', String(true)); |
| 8556 | } |
| 8557 | |
| 8558 | $('#select_chat_div').append(template); |
| 8559 | |
| 8560 | if (Array.isArray(highlightNames) && highlightNames.includes(chat.file_name)) { |
| 8561 | const templateOffset = template.offset().top - template.parent().offset().top; |
| 8562 | $('#select_chat_div').scrollTop(templateOffset); |
| 8563 | flashHighlight(template, debounce_timeout.extended); |
| 8564 | } |
| 8565 | } |
| 8566 | } catch (error) { |
| 8567 | console.error('Error loading chats:', error); |
| 8568 | toastr.error('Could not load chat data. Try reloading the page.'); |
| 8569 | } |
| 8570 | } |
| 8571 | |
| 8572 | export function selectRightMenuWithAnimation(selectedMenuId) { |
| 8573 | const displayModes = { |
| 8574 | 'rm_group_chats_block': 'flex', |
| 8575 | 'rm_api_block': 'grid', |
| 8576 | 'rm_characters_block': 'flex', |
| 8577 | }; |
| 8578 | $('#result_info').toggle(selectedMenuId === 'rm_ch_create_block'); |
| 8579 | document.querySelectorAll('#right-nav-panel .right_menu').forEach((menu) => { |
| 8580 | $(menu).css('display', 'none'); |
| 8581 | |
| 8582 | if (selectedMenuId && selectedMenuId.replace('#', '') === menu.id) { |
| 8583 | const mode = displayModes[menu.id] ?? 'block'; |
| 8584 | $(menu).css('display', mode); |
| 8585 | $(menu).css('opacity', 0.0); |
| 8586 | $(menu).transition({ |
| 8587 | opacity: 1.0, |
| 8588 | duration: animation_duration, |
| 8589 | easing: animation_easing, |
| 8590 | complete: function () { }, |
| 8591 | }); |
| 8592 | } |
| 8593 | }); |
| 8594 | } |
| 8595 | |
| 8596 | export function select_rm_info(type, charId, previousCharId = null) { |
| 8597 | if (!type) { |
| 8598 | toastr.error(t`Invalid process (no 'type')`); |
| 8599 | return; |
| 8600 | } |
| 8601 | if (type !== 'group_create') { |
| 8602 | var displayName = String(charId).replace('.png', ''); |
| 8603 | } |
| 8604 | |
| 8605 | if (type === 'char_delete') { |
| 8606 | toastr.warning(t`Character Deleted: ${displayName}`); |
| 8607 | } |
| 8608 | if (type === 'char_create') { |
| 8609 | toastr.success(t`Character Created: ${displayName}`); |
| 8610 | } |
| 8611 | if (type === 'group_create') { |
| 8612 | toastr.success(t`Group Created`); |
| 8613 | } |
| 8614 | if (type === 'group_delete') { |
| 8615 | toastr.warning(t`Group Deleted`); |
| 8616 | } |
| 8617 | |
| 8618 | if (type === 'char_import') { |
| 8619 | toastr.success(t`Character Imported: ${displayName}`); |
| 8620 | } |
| 8621 | |
| 8622 | selectRightMenuWithAnimation('rm_characters_block'); |
| 8623 | |
| 8624 | // Set a timeout so multiple flashes don't overlap |
| 8625 | clearTimeout(importFlashTimeout); |
| 8626 | importFlashTimeout = setTimeout(function () { |
| 8627 | if (type === 'char_import' || type === 'char_create' || type === 'char_import_no_toast') { |
| 8628 | // Find the page at which the character is located |
| 8629 | const avatarFileName = charId; |
| 8630 | const charData = getEntitiesList({ doFilter: true }); |
| 8631 | const charIndex = charData.findIndex((x) => x?.item?.avatar?.startsWith(avatarFileName)); |
| 8632 | |
| 8633 | if (charIndex === -1) { |
| 8634 | console.log(`Could not find character ${charId} in the list`); |
| 8635 | return; |
| 8636 | } |
| 8637 | |
| 8638 | try { |
| 8639 | const perPage = Number(accountStorage.getItem('Characters_PerPage')) || per_page_default; |
| 8640 | const page = Math.floor(charIndex / perPage) + 1; |
| 8641 | const selector = `#rm_print_characters_block [title*="${avatarFileName}"]`; |
| 8642 | $('#rm_print_characters_pagination').pagination('go', page); |
| 8643 | |
| 8644 | waitUntilCondition(() => document.querySelector(selector) !== null).then(() => { |
| 8645 | const element = $(selector).parent(); |
| 8646 | |
| 8647 | if (element.length === 0) { |
| 8648 | console.log(`Could not find element for character ${charId}`); |
| 8649 | return; |
| 8650 | } |
| 8651 | |
| 8652 | const scrollOffset = element.offset().top - element.parent().offset().top; |
| 8653 | element.parent().scrollTop(scrollOffset); |
| 8654 | flashHighlight(element, 5000); |
| 8655 | }); |
| 8656 | } catch (e) { |
| 8657 | console.error(e); |
| 8658 | } |
| 8659 | } |
| 8660 | |
| 8661 | if (type === 'group_create') { |
| 8662 | // Find the page at which the character is located |
| 8663 | const charData = getEntitiesList({ doFilter: true }); |
| 8664 | const charIndex = charData.findIndex((x) => String(x?.item?.id) === String(charId)); |
| 8665 | |
| 8666 | if (charIndex === -1) { |
| 8667 | console.log(`Could not find group ${charId} in the list`); |
| 8668 | return; |
| 8669 | } |
| 8670 | |
| 8671 | const perPage = Number(accountStorage.getItem('Characters_PerPage')) || per_page_default; |
| 8672 | const page = Math.floor(charIndex / perPage) + 1; |
| 8673 | $('#rm_print_characters_pagination').pagination('go', page); |
| 8674 | const selector = `#rm_print_characters_block [grid="${charId}"]`; |
| 8675 | try { |
| 8676 | waitUntilCondition(() => document.querySelector(selector) !== null).then(() => { |
| 8677 | const element = $(selector); |
| 8678 | const scrollOffset = element.offset().top - element.parent().offset().top; |
| 8679 | element.parent().scrollTop(scrollOffset); |
| 8680 | flashHighlight(element, 5000); |
| 8681 | }); |
| 8682 | } catch (e) { |
| 8683 | console.error(e); |
| 8684 | } |
| 8685 | } |
| 8686 | }, 250); |
| 8687 | |
| 8688 | if (previousCharId) { |
| 8689 | const newId = characters.findIndex((x) => x.avatar == previousCharId); |
| 8690 | if (newId >= 0) { |
| 8691 | setCharacterId(newId); |
| 8692 | } |
| 8693 | } |
| 8694 | } |
| 8695 | |
| 8696 | /** |
| 8697 | * Selects the right menu for displaying the character editor. |
| 8698 | * @param {string} chid Character array index |
| 8699 | * @param {object} [param1] Options for the switch |
| 8700 | * @param {boolean} [param1.switchMenu=true] Whether to switch the menu |
| 8701 | */ |
| 8702 | export function select_selected_character(chid, { switchMenu = true } = {}) { |
| 8703 | //character select |
| 8704 | //console.log('select_selected_character() -- starting with input of -- ' + chid + ' (name:' + characters[chid].name + ')'); |
| 8705 | select_rm_create({ switchMenu }); |
| 8706 | switchMenu && setMenuType('character_edit'); |
| 8707 | $('#delete_button').css('display', 'flex'); |
| 8708 | $('#export_button').css('display', 'flex'); |
| 8709 | |
| 8710 | //create text poles |
| 8711 | $('#rm_button_back').css('display', 'none'); |
| 8712 | //$("#character_import_button").css("display", "none"); |
| 8713 | $('#create_button').attr('value', 'Save'); // what is the use case for this? |
| 8714 | $('#dupe_button').show(); |
| 8715 | $('#create_button_label').css('display', 'none'); |
| 8716 | $('#char_connections_button').show(); |
| 8717 | |
| 8718 | // Hide the chat scenario button if we're peeking the group member defs |
| 8719 | $('#set_chat_character_settings').toggle(!selected_group); |
| 8720 | |
| 8721 | // Don't update the navbar name if we're peeking the group member defs |
| 8722 | if (!selected_group) { |
| 8723 | $('#rm_button_selected_ch').children('h2').text(characters[chid].name); |
| 8724 | } |
| 8725 | |
| 8726 | $('#add_avatar_button').val(''); |
| 8727 | |
| 8728 | $('#character_popup-button-h3').text(characters[chid].name); |
| 8729 | $('#character_name_pole').val(characters[chid].name); |
| 8730 | $('#description_textarea').val(characters[chid].description); |
| 8731 | $('#character_world').val(characters[chid].data?.extensions?.world || ''); |
| 8732 | $('#creator_notes_textarea').val(characters[chid].data?.creator_notes || characters[chid].creatorcomment); |
| 8733 | $('#creator_notes_spoiler').html(formatCreatorNotes(characters[chid].data?.creator_notes || characters[chid].creatorcomment, characters[chid].avatar)); |
| 8734 | $('#character_version_textarea').val(characters[chid].data?.character_version || ''); |
| 8735 | $('#system_prompt_textarea').val(characters[chid].data?.system_prompt || ''); |
| 8736 | $('#post_history_instructions_textarea').val(characters[chid].data?.post_history_instructions || ''); |
| 8737 | $('#tags_textarea').val(Array.isArray(characters[chid].data?.tags) ? characters[chid].data.tags.join(', ') : ''); |
| 8738 | $('#creator_textarea').val(characters[chid].data?.creator); |
| 8739 | $('#character_version_textarea').val(characters[chid].data?.character_version || ''); |
| 8740 | $('#personality_textarea').val(characters[chid].personality); |
| 8741 | $('#firstmessage_textarea').val(characters[chid].first_mes); |
| 8742 | $('#scenario_pole').val(characters[chid].scenario); |
| 8743 | $('#depth_prompt_prompt').val(characters[chid].data?.extensions?.depth_prompt?.prompt ?? ''); |
| 8744 | $('#depth_prompt_depth').val(characters[chid].data?.extensions?.depth_prompt?.depth ?? depth_prompt_depth_default); |
| 8745 | $('#depth_prompt_role').val(characters[chid].data?.extensions?.depth_prompt?.role ?? depth_prompt_role_default); |
| 8746 | $('#talkativeness_slider').val(characters[chid].talkativeness || talkativeness_default); |
| 8747 | $('#mes_example_textarea').val(characters[chid].mes_example); |
| 8748 | $('#selected_chat_pole').val(characters[chid].chat); |
| 8749 | $('#create_date_pole').val(timestampToMoment(characters[chid].create_date).toISOString()); |
| 8750 | $('#avatar_url_pole').val(characters[chid].avatar); |
| 8751 | $('#chat_import_avatar_url').val(characters[chid].avatar); |
| 8752 | $('#chat_import_character_name').val(characters[chid].name); |
| 8753 | $('#character_json_data').val(characters[chid].json_data); |
| 8754 | |
| 8755 | updateFavButtonState(characters[chid].fav || characters[chid].fav == 'true'); |
| 8756 | |
| 8757 | const avatarUrl = characters[chid].avatar != 'none' ? getThumbnailUrl('avatar', characters[chid].avatar) : default_avatar; |
| 8758 | $('#avatar_load_preview').attr('src', avatarUrl); |
| 8759 | $('.open_alternate_greetings').data('chid', chid); |
| 8760 | $('#set_character_world').data('chid', chid); |
| 8761 | setWorldInfoButtonClass(chid); |
| 8762 | checkEmbeddedWorld(chid); |
| 8763 | |
| 8764 | $('#name_div').removeClass('displayBlock'); |
| 8765 | $('#name_div').addClass('displayNone'); |
| 8766 | $('#renameCharButton').css('display', ''); |
| 8767 | |
| 8768 | $('#form_create').attr('actiontype', 'editcharacter'); |
| 8769 | $('.form_create_bottom_buttons_block .chat_lorebook_button').show(); |
| 8770 | |
| 8771 | const externalMediaState = isExternalMediaAllowed(); |
| 8772 | $('#character_open_media_overrides').toggle(!selected_group); |
| 8773 | $('#character_media_allowed_icon').toggle(externalMediaState); |
| 8774 | $('#character_media_forbidden_icon').toggle(!externalMediaState); |
| 8775 | |
| 8776 | // Update some stuff about the char management dropdown |
| 8777 | $('#character_source').attr('disabled', !getCharacterSource(chid) ? '' : null); |
| 8778 | |
| 8779 | eventSource.emit(event_types.CHARACTER_EDITOR_OPENED, chid); |
| 8780 | |
| 8781 | saveSettingsDebounced(); |
| 8782 | } |
| 8783 | |
| 8784 | /** |
| 8785 | * Selects the right menu for creating a new character. |
| 8786 | * @param {object} [options] Options for the switch |
| 8787 | * @param {boolean} [options.switchMenu=true] Whether to switch the menu |
| 8788 | */ |
| 8789 | function select_rm_create({ switchMenu = true } = {}) { |
| 8790 | switchMenu && setMenuType('create'); |
| 8791 | |
| 8792 | //console.log('select_rm_Create() -- selected button: '+selected_button); |
| 8793 | if (selected_button == 'create' && create_save.avatar) { |
| 8794 | const addAvatarInput = /** @type {HTMLInputElement} */ ($('#add_avatar_button').get(0)); |
| 8795 | addAvatarInput.files = create_save.avatar; |
| 8796 | read_avatar_load(addAvatarInput); |
| 8797 | } |
| 8798 | |
| 8799 | switchMenu && selectRightMenuWithAnimation('rm_ch_create_block'); |
| 8800 | |
| 8801 | $('#set_chat_character_settings').hide(); |
| 8802 | $('#delete_button_div').css('display', 'none'); |
| 8803 | $('#delete_button').css('display', 'none'); |
| 8804 | $('#export_button').css('display', 'none'); |
| 8805 | $('#create_button_label').css('display', ''); |
| 8806 | $('#create_button').attr('value', 'Create'); |
| 8807 | $('#dupe_button').hide(); |
| 8808 | $('#char_connections_button').hide(); |
| 8809 | |
| 8810 | //create text poles |
| 8811 | $('#rm_button_back').css('display', ''); |
| 8812 | $('#character_import_button').css('display', ''); |
| 8813 | $('#character_popup-button-h3').text('Create character'); |
| 8814 | $('#character_name_pole').val(create_save.name); |
| 8815 | $('#description_textarea').val(create_save.description); |
| 8816 | $('#character_world').val(create_save.world); |
| 8817 | $('#creator_notes_textarea').val(create_save.creator_notes); |
| 8818 | $('#creator_notes_spoiler').html(formatCreatorNotes(create_save.creator_notes, '')); |
| 8819 | $('#post_history_instructions_textarea').val(create_save.post_history_instructions); |
| 8820 | $('#system_prompt_textarea').val(create_save.system_prompt); |
| 8821 | $('#tags_textarea').val(create_save.tags); |
| 8822 | $('#creator_textarea').val(create_save.creator); |
| 8823 | $('#character_version_textarea').val(create_save.character_version); |
| 8824 | $('#personality_textarea').val(create_save.personality); |
| 8825 | $('#firstmessage_textarea').val(create_save.first_message); |
| 8826 | $('#talkativeness_slider').val(create_save.talkativeness); |
| 8827 | $('#scenario_pole').val(create_save.scenario); |
| 8828 | $('#depth_prompt_prompt').val(create_save.depth_prompt_prompt); |
| 8829 | $('#depth_prompt_depth').val(create_save.depth_prompt_depth); |
| 8830 | $('#depth_prompt_role').val(create_save.depth_prompt_role); |
| 8831 | $('#mes_example_textarea').val(create_save.mes_example); |
| 8832 | $('#character_json_data').val(''); |
| 8833 | $('#avatar_div').css('display', 'flex'); |
| 8834 | $('#avatar_load_preview').attr('src', default_avatar); |
| 8835 | $('#renameCharButton').css('display', 'none'); |
| 8836 | $('#name_div').removeClass('displayNone'); |
| 8837 | $('#name_div').addClass('displayBlock'); |
| 8838 | $('.open_alternate_greetings').data('chid', -1); |
| 8839 | $('#set_character_world').data('chid', -1); |
| 8840 | setWorldInfoButtonClass(undefined, !!create_save.world); |
| 8841 | updateFavButtonState(false); |
| 8842 | checkEmbeddedWorld(); |
| 8843 | |
| 8844 | $('#form_create').attr('actiontype', 'createcharacter'); |
| 8845 | $('.form_create_bottom_buttons_block .chat_lorebook_button').hide(); |
| 8846 | $('#character_open_media_overrides').hide(); |
| 8847 | } |
| 8848 | |
| 8849 | function select_rm_characters() { |
| 8850 | const doFullRefresh = menu_type === 'characters'; |
| 8851 | setMenuType('characters'); |
| 8852 | selectRightMenuWithAnimation('rm_characters_block'); |
| 8853 | printCharacters(doFullRefresh); |
| 8854 | } |
| 8855 | |
| 8856 | /** |
| 8857 | * Sets a prompt injection to insert custom text into any outgoing prompt. For use in UI extensions. |
| 8858 | * @param {string} key Prompt injection id. |
| 8859 | * @param {string} value Prompt injection value. |
| 8860 | * @param {number} position Insertion position. 0 is after story string, 1 is in-chat with custom depth. |
| 8861 | * @param {number} depth Insertion depth. 0 represets the last message in context. Expected values up to MAX_INJECTION_DEPTH. |
| 8862 | * @param {number} role Extension prompt role. Defaults to SYSTEM. |
| 8863 | * @param {boolean} scan Should the prompt be included in the world info scan. |
| 8864 | * @param {(function(): Promise<boolean>|boolean)} filter Filter function to determine if the prompt should be injected. |
| 8865 | */ |
| 8866 | export function setExtensionPrompt(key, value, position, depth, scan = false, role = extension_prompt_roles.SYSTEM, filter = null) { |
| 8867 | extension_prompts[key] = { |
| 8868 | value: String(value), |
| 8869 | position: Number(position), |
| 8870 | depth: Number(depth), |
| 8871 | scan: !!scan, |
| 8872 | role: Number(role ?? extension_prompt_roles.SYSTEM), |
| 8873 | filter: filter, |
| 8874 | }; |
| 8875 | } |
| 8876 | |
| 8877 | /** |
| 8878 | * Gets a enum value of the extension prompt role by its name. |
| 8879 | * @param {string} roleName The name of the extension prompt role. |
| 8880 | * @returns {number} The role id of the extension prompt. |
| 8881 | */ |
| 8882 | export function getExtensionPromptRoleByName(roleName) { |
| 8883 | // If the role is already a valid number, return it |
| 8884 | if (typeof roleName === 'number' && Object.values(extension_prompt_roles).includes(roleName)) { |
| 8885 | return roleName; |
| 8886 | } |
| 8887 | |
| 8888 | switch (roleName) { |
| 8889 | case 'system': |
| 8890 | return extension_prompt_roles.SYSTEM; |
| 8891 | case 'user': |
| 8892 | return extension_prompt_roles.USER; |
| 8893 | case 'assistant': |
| 8894 | return extension_prompt_roles.ASSISTANT; |
| 8895 | } |
| 8896 | |
| 8897 | // Skill issue? |
| 8898 | return extension_prompt_roles.SYSTEM; |
| 8899 | } |
| 8900 | |
| 8901 | /** |
| 8902 | * Removes all char A/N prompt injections from the chat. |
| 8903 | * To clean up when switching from groups to solo and vice versa. |
| 8904 | */ |
| 8905 | export function removeDepthPrompts() { |
| 8906 | for (const key of Object.keys(extension_prompts)) { |
| 8907 | if (key.startsWith(inject_ids.DEPTH_PROMPT)) { |
| 8908 | delete extension_prompts[key]; |
| 8909 | } |
| 8910 | } |
| 8911 | } |
| 8912 | |
| 8913 | /** |
| 8914 | * Adds or updates the metadata for the currently active chat. |
| 8915 | * @param {Object} newValues An object with collection of new values to be added into the metadata. |
| 8916 | * @param {boolean} reset Should a metadata be reset by this call. |
| 8917 | */ |
| 8918 | export function updateChatMetadata(newValues, reset) { |
| 8919 | chat_metadata = reset ? { ...newValues } : { ...chat_metadata, ...newValues }; |
| 8920 | } |
| 8921 | |
| 8922 | |
| 8923 | /** |
| 8924 | * Updates the state of the favorite button based on the provided state. |
| 8925 | * @param {boolean} state Whether the favorite button should be on or off. |
| 8926 | */ |
| 8927 | function updateFavButtonState(state) { |
| 8928 | // Update global state of the flag |
| 8929 | // TODO: This is bad and needs to be refactored. |
| 8930 | fav_ch_checked = state; |
| 8931 | $('#fav_checkbox').prop('checked', state); |
| 8932 | $('#favorite_button').toggleClass('fav_on', state); |
| 8933 | $('#favorite_button').toggleClass('fav_off', !state); |
| 8934 | } |
| 8935 | |
| 8936 | export async function setCharacterSettingsOverrides() { |
| 8937 | if (!selected_group && (this_chid === undefined || !characters[this_chid])) { |
| 8938 | console.warn('setCharacterSettingsOverrides() -- no selected group or character'); |
| 8939 | return; |
| 8940 | } |
| 8941 | |
| 8942 | const scenarioOverrideValue = chat_metadata.scenario || ''; |
| 8943 | const exampleMessagesValue = chat_metadata.mes_example || ''; |
| 8944 | const systemPromptValue = chat_metadata.system_prompt || ''; |
| 8945 | const isGroup = !!selected_group; |
| 8946 | |
| 8947 | const $template = $(await renderTemplateAsync('scenarioOverride')); |
| 8948 | $template.find('[data-group="true"]').toggle(isGroup); |
| 8949 | $template.find('[data-character="true"]').toggle(!isGroup); |
| 8950 | const pendingChanges = { |
| 8951 | scenario: scenarioOverrideValue, |
| 8952 | examples: exampleMessagesValue, |
| 8953 | system_prompt: systemPromptValue, |
| 8954 | }; |
| 8955 | |
| 8956 | // Keep edits local until the popup is closed/confirmed |
| 8957 | const $scenario = $template.find('.chat_scenario'); |
| 8958 | $scenario.val(scenarioOverrideValue).on('input', function () { |
| 8959 | pendingChanges.scenario = String($(this).val()); |
| 8960 | }); |
| 8961 | const $examples = $template.find('.chat_examples'); |
| 8962 | $examples.val(exampleMessagesValue).on('input', function () { |
| 8963 | pendingChanges.examples = String($(this).val()); |
| 8964 | }); |
| 8965 | const $systemPrompt = $template.find('.chat_system_prompt'); |
| 8966 | $systemPrompt.val(systemPromptValue).on('input', function () { |
| 8967 | pendingChanges.system_prompt = String($(this).val()); |
| 8968 | }); |
| 8969 | |
| 8970 | $template.find('.remove_scenario_override').on('click', async function () { |
| 8971 | const confirm = await Popup.show.confirm(t`Are you sure you want to remove all overrides?`, t`This action cannot be undone.`); |
| 8972 | if (!confirm) { |
| 8973 | return; |
| 8974 | } |
| 8975 | |
| 8976 | $scenario.val(''); |
| 8977 | pendingChanges.scenario = ''; |
| 8978 | $examples.val(''); |
| 8979 | pendingChanges.examples = ''; |
| 8980 | $systemPrompt.val(''); |
| 8981 | pendingChanges.system_prompt = ''; |
| 8982 | }); |
| 8983 | |
| 8984 | // Wait for popup close/confirm. |
| 8985 | await callGenericPopup($template, POPUP_TYPE.TEXT, '', { |
| 8986 | wide: true, |
| 8987 | large: true, |
| 8988 | allowVerticalScrolling: true, |
| 8989 | }); |
| 8990 | |
| 8991 | chat_metadata.scenario = pendingChanges.scenario; |
| 8992 | chat_metadata.mes_example = pendingChanges.examples; |
| 8993 | chat_metadata.system_prompt = pendingChanges.system_prompt; |
| 8994 | await saveMetadata(); |
| 8995 | } |
| 8996 | |
| 8997 | /** |
| 8998 | * Displays a blocking popup with a given text and type. |
| 8999 | * @param {JQuery<HTMLElement>|string|Element} text - Text to display in the popup. |
| 9000 | * @param {string} type |
| 9001 | * @param {string} inputValue - Value to set the input to. |
| 9002 | * @param {PopupOptions} options - Options for the popup. |
| 9003 | * @typedef {{okButton?: string, rows?: number, wide?: boolean, wider?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean, cropAspect?: number }} PopupOptions - Options for the popup. |
| 9004 | * @returns {Promise<any>} A promise that resolves when the popup is closed. |
| 9005 | * @deprecated Use `callGenericPopup` instead. |
| 9006 | */ |
| 9007 | export function callPopup(text, type, inputValue = '', { okButton, rows, wide, wider, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) { |
| 9008 | function getOkButtonText() { |
| 9009 | if (['text', 'char_not_selected'].includes(popup_type)) { |
| 9010 | $dialoguePopupCancel.css('display', 'none'); |
| 9011 | return okButton ?? t`Ok`; |
| 9012 | } else if (['delete_extension'].includes(popup_type)) { |
| 9013 | return okButton ?? t`Ok`; |
| 9014 | } else if (['new_chat', 'confirm'].includes(popup_type)) { |
| 9015 | return okButton ?? t`Yes`; |
| 9016 | } else if (['input'].includes(popup_type)) { |
| 9017 | return okButton ?? t`Save`; |
| 9018 | } |
| 9019 | return okButton ?? t`Delete`; |
| 9020 | } |
| 9021 | |
| 9022 | dialogueCloseStop = true; |
| 9023 | if (type) { |
| 9024 | popup_type = type; |
| 9025 | } |
| 9026 | |
| 9027 | const $dialoguePopup = $('#dialogue_popup'); |
| 9028 | const $dialoguePopupCancel = $('#dialogue_popup_cancel'); |
| 9029 | const $dialoguePopupOk = $('#dialogue_popup_ok'); |
| 9030 | const $dialoguePopupInput = $('#dialogue_popup_input'); |
| 9031 | const $dialoguePopupText = $('#dialogue_popup_text'); |
| 9032 | const $shadowPopup = $('#shadow_popup'); |
| 9033 | |
| 9034 | $dialoguePopup.toggleClass('wide_dialogue_popup', !!wide) |
| 9035 | .toggleClass('wider_dialogue_popup', !!wider) |
| 9036 | .toggleClass('large_dialogue_popup', !!large) |
| 9037 | .toggleClass('horizontal_scrolling_dialogue_popup', !!allowHorizontalScrolling) |
| 9038 | .toggleClass('vertical_scrolling_dialogue_popup', !!allowVerticalScrolling); |
| 9039 | |
| 9040 | $dialoguePopupCancel.css('display', 'inline-block'); |
| 9041 | $dialoguePopupOk.text(getOkButtonText()); |
| 9042 | $dialoguePopupInput.toggle(popup_type === 'input').val(inputValue).attr('rows', rows ?? 1); |
| 9043 | $dialoguePopupText.empty().append(text); |
| 9044 | $shadowPopup.css('display', 'block'); |
| 9045 | |
| 9046 | if (popup_type == 'input') { |
| 9047 | $dialoguePopupInput.trigger('focus'); |
| 9048 | } |
| 9049 | |
| 9050 | $shadowPopup.transition({ |
| 9051 | opacity: 1, |
| 9052 | duration: animation_duration, |
| 9053 | easing: animation_easing, |
| 9054 | }); |
| 9055 | |
| 9056 | return new Promise((resolve) => { |
| 9057 | dialogueResolve = resolve; |
| 9058 | }); |
| 9059 | } |
| 9060 | |
| 9061 | /** |
| 9062 | * Update the swipe counter for mesId. |
| 9063 | * By default, the swipe counter's opacity will appear greyed out. The opacity is changed with CSS. |
| 9064 | * @param {Number} mesId |
| 9065 | * @param {object} [options] Options |
| 9066 | * @param {ChatMessage} [options.message=undefined] Swipe numbers from this message will be used instead of mesId. |
| 9067 | * @param {JQuery<HTMLElement>} [options.messageElement=undefined] Target Element. Passing in the message's element will save a DOM query. |
| 9068 | */ |
| 9069 | export async function updateSwipeCounter(mesId, { message = undefined, messageElement = undefined } = {}) { |
| 9070 | message ??= chat[mesId]; |
| 9071 | messageElement ??= chatElement.children('.mes').filter(`[mesid="${mesId}"]`); |
| 9072 | |
| 9073 | //If the message does not have swipes, create them. |
| 9074 | if (ensureSwipes(message)) { |
| 9075 | syncMesToSwipe(mesId); |
| 9076 | } |
| 9077 | |
| 9078 | const swipeCounterText = formatSwipeCounter((message?.swipe_id + 1), message?.swipes?.length); |
| 9079 | const swipeCounter = messageElement.find('.swipes-counter'); |
| 9080 | const swipePickerButton = messageElement.find('.mes_swipe_picker'); |
| 9081 | const canOpenSwipePicker = canOpenSwipePickerForMessage(mesId); |
| 9082 | const canJumpToSwipe = canJumpToSwipeForMessage(mesId); |
| 9083 | |
| 9084 | swipeCounter |
| 9085 | .text(swipeCounterText) |
| 9086 | .prop('hidden', false) |
| 9087 | .toggleClass('swipe-picker-enabled', canOpenSwipePicker) |
| 9088 | .toggleClass(INTERACTABLE_CONTROL_CLASS, canOpenSwipePicker) |
| 9089 | .attr('role', canOpenSwipePicker ? 'button' : null) |
| 9090 | .attr('title', canJumpToSwipe ? t`Click to jump to a swipe` : canOpenSwipePicker ? t`Click to view swipe history` : null); |
| 9091 | swipePickerButton.toggle(canOpenSwipePicker); |
| 9092 | |
| 9093 | if (!canOpenSwipePicker) { |
| 9094 | swipeCounter.removeAttr('tabindex'); |
| 9095 | } |
| 9096 | } |
| 9097 | |
| 9098 | /** |
| 9099 | * Returns true if messages are generally swipeable. |
| 9100 | * @returns {boolean} |
| 9101 | */ |
| 9102 | export function isSwipingAllowed() { |
| 9103 | return ( |
| 9104 | //Swipe cannot be called on an empty chat. |
| 9105 | chat.length !== 0 && |
| 9106 | //The swipes setting must be enabled, and swipes can't be hidden. |
| 9107 | swipes && !swipesHidden && |
| 9108 | //Cannot swipe while generating. |
| 9109 | !isGenerating() && |
| 9110 | //If mid-swipe, the message cannot be swiped. |
| 9111 | swipeState === SWIPE_STATE.NONE |
| 9112 | ); |
| 9113 | } |
| 9114 | |
| 9115 | /** |
| 9116 | * Returns true if the message is swipeable. |
| 9117 | * This does not check if messages are generally swipeable. See isSwipingAllowed(). |
| 9118 | * This does not check if the swipes exist or are valid. |
| 9119 | * @param {number} messageId The message Id to check. |
| 9120 | * @param {ChatMessage} [message=undefined] If undefined, then the message checks will be skipped. |
| 9121 | * @returns {boolean} |
| 9122 | */ |
| 9123 | export function isMessageSwipeable(messageId, message = undefined) { |
| 9124 | message ??= chat[messageId]; |
| 9125 | |
| 9126 | //If the message does not have swipes, create them. |
| 9127 | if (ensureSwipes(message)) { |
| 9128 | syncMesToSwipe(messageId); |
| 9129 | } |
| 9130 | |
| 9131 | if ( |
| 9132 | //Only messages below the currently edited message can be swiped, if it's not mid-swipe edit. |
| 9133 | ((messageId > (this_edit_mes_id ?? -1)) && (swipeState != SWIPE_STATE.EDITING)) && |
| 9134 | |
| 9135 | //If the message is the last message, and it exists. |
| 9136 | (messageId == chat.length - 1) && |
| 9137 | (message && |
| 9138 | //Small system messages cannot be swiped. |
| 9139 | !(message?.extra?.isSmallSys) && |
| 9140 | //Some messages, like the welcome screen, are not swipeable. |
| 9141 | !(message?.extra?.swipeable === false) && |
| 9142 | //User messages are not swipeable. |
| 9143 | !message.is_user |
| 9144 | ) |
| 9145 | ) { |
| 9146 | // The message is swipeable. |
| 9147 | return true; |
| 9148 | } else { |
| 9149 | // The message is not swipeable. |
| 9150 | return false; |
| 9151 | } |
| 9152 | } |
| 9153 | |
| 9154 | /** |
| 9155 | * Returns the message's behavior when swiped past it's last branch. |
| 9156 | * This does not check if the message can currently be swiped. See isMessageSwipeable(). |
| 9157 | * This does not check if messages are generally swipeable. See isSwipingAllowed(). |
| 9158 | * This does not check if the swipes exist or are valid. |
| 9159 | * @param {number} messageId The message Id to check. |
| 9160 | * @param {ChatMessage} [message=undefined] If defined, this will be used instead of chat[messageId]. |
| 9161 | * @returns {OVERSWIPE_BEHAVIOR} |
| 9162 | */ |
| 9163 | export function getOverswipeBehavior(messageId, message = undefined) { |
| 9164 | message ??= chat[messageId]; |
| 9165 | |
| 9166 | const isPristine = !chat_metadata?.tainted; |
| 9167 | const isGreeting = messageId === 0; |
| 9168 | |
| 9169 | //Do not override explicitly set overswipe_behavior. |
| 9170 | if (typeof message?.extra?.overswipe_behavior == 'string') return message.extra.overswipe_behavior; |
| 9171 | //Some messages, like the welcome screen, are not swipeable. |
| 9172 | else if (message?.extra?.swipeable === false) return OVERSWIPE_BEHAVIOR.NONE; |
| 9173 | //Small System messages can't be swiped. |
| 9174 | else if (message?.extra?.isSmallSys) return OVERSWIPE_BEHAVIOR.NONE; |
| 9175 | //The first message in a priistine chat will loop. It's chevrons will always be visible https://github.com/SillyTavern/SillyTavern/pull/4712#issuecomment-3557893373 |
| 9176 | else if (isGreeting && isPristine) return OVERSWIPE_BEHAVIOR.PRISTINE_GREETING; |
| 9177 | //Non-user and non-prompt hidden messages will regenerate. |
| 9178 | else if (!message?.is_user && !message?.is_system) return OVERSWIPE_BEHAVIOR.REGENERATE; |
| 9179 | //By default, all other messages will loop. Their swipe chevrons will only be shown if there is more than one swipe. |
| 9180 | else { return OVERSWIPE_BEHAVIOR.LOOP; } |
| 9181 | } |
| 9182 | |
| 9183 | /** |
| 9184 | * Refreshes all swipe buttons and updates their swipe counters. |
| 9185 | * This has been optimized for bulk updates by minimizing DOM queries. |
| 9186 | * @param {boolean} updateCounters When true, the swipe counters will also be updated. Typically redundant because addOneMessage updates the counters. |
| 9187 | * @param {boolean} fade By default, the chevrons fade in and out. |
| 9188 | * @returns |
| 9189 | */ |
| 9190 | export function refreshSwipeButtons(updateCounters = false, fade = true) { |
| 9191 | //Never show swipe buttons on an empty chat. |
| 9192 | if (chat?.length === 0) return false; |
| 9193 | |
| 9194 | //If swipes are disabled or hidden, hide all swipe buttons. |
| 9195 | if (!isSwipingAllowed()) { |
| 9196 | $('body').addClass('hideAllSwipeButtons'); |
| 9197 | return; |
| 9198 | //Don't hide all swipe buttons. |
| 9199 | } else { |
| 9200 | //CSS will hide all messages. |
| 9201 | $('body').removeClass('hideAllSwipeButtons'); |
| 9202 | } |
| 9203 | //Non-messages can appear in chat. '.mes' is required. |
| 9204 | const messageElements = chatElement.children('.mes[mesid]'); |
| 9205 | |
| 9206 | const firstDisplayedMesId = Number(messageElements.first().attr('mesid')); |
| 9207 | |
| 9208 | //Group each message. |
| 9209 | messageElements.each((index, div) => { |
| 9210 | //This assumes the messages are in order and their Id's are accurate. |
| 9211 | const messageId = firstDisplayedMesId + index; |
| 9212 | //Number($(div).attr('mesid')); Would not misscount due to a missing div, but is much slower. |
| 9213 | |
| 9214 | const message = chat[messageId]; |
| 9215 | |
| 9216 | //Chevrons should not fade-in during printMessages. //https://github.com/SillyTavern/SillyTavern/pull/4712#issuecomment-3539315919 |
| 9217 | div.classList.toggle('fade', fade); |
| 9218 | |
| 9219 | if (isMessageSwipeable(messageId, message)) { |
| 9220 | //If a right swipe would trigger a generation or loop to the first swipe. |
| 9221 | const isLastSwipe = (message?.swipes?.length ?? 1) - 1 <= (message?.swipe_id ?? 0); |
| 9222 | const hasSwipes = (message?.swipes?.length > 1); |
| 9223 | const overswipe = getOverswipeBehavior(messageId, message); |
| 9224 | const swipePickerButton = $(div).find('.mes_swipe_picker'); |
| 9225 | const canOpenSwipePicker = canOpenSwipePickerForMessage(messageId); |
| 9226 | |
| 9227 | // Chevrons should always be shown on pristine greetings: https://github.com/SillyTavern/SillyTavern/pull/4712#issuecomment-3557893373 |
| 9228 | const pristineGreeting = overswipe == OVERSWIPE_BEHAVIOR.PRISTINE_GREETING; |
| 9229 | |
| 9230 | //The swipe button will be shown if an overswipe would trigger REGENERATE or EDIT_GENERATE. |
| 9231 | const isOverswipeable = isLastSwipe && |
| 9232 | overswipe == OVERSWIPE_BEHAVIOR.REGENERATE || |
| 9233 | overswipe == OVERSWIPE_BEHAVIOR.EDIT_GENERATE; |
| 9234 | |
| 9235 | div.classList.toggle('last_swipe', isOverswipeable); |
| 9236 | |
| 9237 | //If there's only one swipe, the left arrow should not be shown. |
| 9238 | div.classList.toggle('swipes_visible', hasSwipes || pristineGreeting); |
| 9239 | swipePickerButton.toggle(canOpenSwipePicker); |
| 9240 | |
| 9241 | //updateSwipeCounter does not need to be awaited, It can run a bit later. |
| 9242 | if (updateCounters) updateSwipeCounter(messageId, { message, messageElement: $(div) }); |
| 9243 | } else { |
| 9244 | //Hide all messages that are not swipeable. |
| 9245 | div.classList.remove('swipes_visible', 'last_swipe'); |
| 9246 | $(div).find('.mes_swipe_picker').toggle(canOpenSwipePickerForMessage(messageId)); |
| 9247 | } |
| 9248 | }); |
| 9249 | } |
| 9250 | /** |
| 9251 | * This function is misleadingly named. It allows generation then refreshes the swipe buttons and counters. |
| 9252 | */ |
| 9253 | export function showSwipeButtons() { |
| 9254 | swipesHidden = false; |
| 9255 | refreshSwipeButtons(); |
| 9256 | } |
| 9257 | |
| 9258 | /** |
| 9259 | * This function is misleadingly named. It blocks generation then refreshes the swipe buttons and counters. |
| 9260 | * @param {object} [options] Options |
| 9261 | * @param {boolean} [options.hideCounters=false] Also hide the swipes counter. |
| 9262 | */ |
| 9263 | export function hideSwipeButtons({ hideCounters = false } = {}) { |
| 9264 | swipesHidden = true; |
| 9265 | refreshSwipeButtons(); |
| 9266 | |
| 9267 | if (hideCounters === true) { |
| 9268 | chatElement.find('.last_mes .swipes-counter').prop('hidden', true); |
| 9269 | } |
| 9270 | } |
| 9271 | |
| 9272 | /** |
| 9273 | * Deletes a swipe from the chat. |
| 9274 | * |
| 9275 | * @param {number?} [swipeId = null] - The ID of the swipe to delete. If not provided, the current swipe will be deleted. |
| 9276 | * @param {number?} [messageId = chat.length - 1] - The ID of the message to delete from. If not provided, the last message will be targeted. |
| 9277 | * @returns {Promise<number>|undefined} - The ID of the new swipe after deletion. |
| 9278 | */ |
| 9279 | export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) { |
| 9280 | if (swipeId != null) { |
| 9281 | swipeId = Number(swipeId); |
| 9282 | if (!Number.isInteger(swipeId) || swipeId < 0) { |
| 9283 | toastr.warning(t`Invalid swipe ID.`); |
| 9284 | return; |
| 9285 | } |
| 9286 | } |
| 9287 | |
| 9288 | const message = chat[messageId]; |
| 9289 | if (!message || !Array.isArray(message.swipes) || !message.swipes.length) { |
| 9290 | toastr.warning(t`No messages to delete swipes from.`); |
| 9291 | return; |
| 9292 | } |
| 9293 | |
| 9294 | if (message.swipes.length <= 1) { |
| 9295 | toastr.warning(t`Can't delete the last swipe.`); |
| 9296 | return; |
| 9297 | } |
| 9298 | |
| 9299 | swipeId = Number(swipeId ?? message.swipe_id); |
| 9300 | const currentSwipeId = clamp(Number(message.swipe_id ?? 0), 0, message.swipes.length - 1); |
| 9301 | |
| 9302 | if (swipeId < 0 || swipeId >= message.swipes.length) { |
| 9303 | toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`); |
| 9304 | return; |
| 9305 | } |
| 9306 | |
| 9307 | message.swipes.splice(swipeId, 1); |
| 9308 | |
| 9309 | if (Array.isArray(message.swipe_info) && message.swipe_info.length) { |
| 9310 | message.swipe_info.splice(swipeId, 1); |
| 9311 | } |
| 9312 | |
| 9313 | let newSwipeId; |
| 9314 | if (swipeId < currentSwipeId) { |
| 9315 | newSwipeId = currentSwipeId - 1; |
| 9316 | } else if (swipeId > currentSwipeId) { |
| 9317 | newSwipeId = currentSwipeId; |
| 9318 | } else { |
| 9319 | // Select the next swipe, or the one before if it was the last one. |
| 9320 | newSwipeId = Math.min(swipeId, message.swipes.length - 1); |
| 9321 | } |
| 9322 | |
| 9323 | chat_metadata.tainted = true; |
| 9324 | |
| 9325 | messageId = Number(messageId); |
| 9326 | swipeId = Number(swipeId); |
| 9327 | message.swipe_id = newSwipeId; |
| 9328 | await eventSource.emit(event_types.MESSAGE_SWIPE_DELETED, { messageId, swipeId, newSwipeId }); |
| 9329 | |
| 9330 | if (swipeId === currentSwipeId) { |
| 9331 | const direction = (swipeId <= newSwipeId) ? SWIPE_DIRECTION.RIGHT : SWIPE_DIRECTION.LEFT; |
| 9332 | // Animate swipe and swap displayed message when the currently visible swipe was deleted. |
| 9333 | await swipe(null, direction, { source: SWIPE_SOURCE.DELETE, repeated: false, forceMesId: messageId, forceSwipeId: newSwipeId }); |
| 9334 | } else { |
| 9335 | await updateSwipeCounter(messageId); |
| 9336 | if (messageId !== chat.length - 1) { |
| 9337 | await updateSwipeCounter(chat.length - 1); |
| 9338 | } |
| 9339 | refreshSwipeButtons(); |
| 9340 | saveChatDebounced(); |
| 9341 | } |
| 9342 | |
| 9343 | await saveChatConditional(); |
| 9344 | |
| 9345 | return newSwipeId; |
| 9346 | } |
| 9347 | |
| 9348 | export async function saveMetadata() { |
| 9349 | return await saveChatConditional(); |
| 9350 | } |
| 9351 | |
| 9352 | export async function saveChatConditional() { |
| 9353 | try { |
| 9354 | await waitUntilCondition(() => !isChatSaving, DEFAULT_SAVE_EDIT_TIMEOUT, 100); |
| 9355 | } catch { |
| 9356 | console.warn('Timeout waiting for chat to save'); |
| 9357 | return; |
| 9358 | } |
| 9359 | |
| 9360 | try { |
| 9361 | cancelDebouncedChatSave(); |
| 9362 | |
| 9363 | isChatSaving = true; |
| 9364 | |
| 9365 | if (selected_group) { |
| 9366 | await saveGroupChat(selected_group, true); |
| 9367 | } else { |
| 9368 | await saveChat(); |
| 9369 | } |
| 9370 | |
| 9371 | // Save token and prompts cache to IndexedDB storage |
| 9372 | saveTokenCache(); |
| 9373 | saveItemizedPrompts(getCurrentChatId()); |
| 9374 | } catch (error) { |
| 9375 | console.error('Error saving chat', error); |
| 9376 | } finally { |
| 9377 | isChatSaving = false; |
| 9378 | } |
| 9379 | } |
| 9380 | |
| 9381 | /** |
| 9382 | * Saves the chat to the server. |
| 9383 | * @param {FormData} formData Form data to send to the server. |
| 9384 | * @param {object} [options={}] Options for the import |
| 9385 | * @param {boolean} [options.refresh] Whether to refresh the group chat list after import |
| 9386 | * @returns {Promise<string[]>} List of imported file names. |
| 9387 | */ |
| 9388 | export async function importCharacterChat(formData, { refresh = true } = {}) { |
| 9389 | const fetchResult = await fetch('/api/chats/import', { |
| 9390 | method: 'POST', |
| 9391 | body: formData, |
| 9392 | headers: getRequestHeaders({ omitContentType: true }), |
| 9393 | cache: 'no-cache', |
| 9394 | }); |
| 9395 | |
| 9396 | if (fetchResult.ok) { |
| 9397 | const data = await fetchResult.json(); |
| 9398 | if (data.res && refresh) { |
| 9399 | await displayPastChats(); |
| 9400 | } |
| 9401 | return data?.fileNames || []; |
| 9402 | } |
| 9403 | |
| 9404 | return []; |
| 9405 | } |
| 9406 | |
| 9407 | export function updateViewMessageIds(startIndex = null) { |
| 9408 | const minId = startIndex ?? getFirstDisplayedMessageId(); |
| 9409 | |
| 9410 | chatElement.find('.mes').each(function (index, element) { |
| 9411 | $(element).attr('mesid', minId + index); |
| 9412 | $(element).find('.mesIDDisplay').text(`#${minId + index}`); |
| 9413 | }); |
| 9414 | |
| 9415 | chatElement.find('.mes').removeClass('last_mes'); |
| 9416 | chatElement.find('.mes').last().addClass('last_mes'); |
| 9417 | |
| 9418 | updateEditArrowClasses(); |
| 9419 | } |
| 9420 | |
| 9421 | export function getFirstDisplayedMessageId() { |
| 9422 | const allIds = Array.from(document.querySelectorAll('#chat .mes')).map(el => Number(el.getAttribute('mesid'))).filter(x => !isNaN(x)); |
| 9423 | const minId = Math.min(...allIds); |
| 9424 | return minId; |
| 9425 | } |
| 9426 | |
| 9427 | export function updateEditArrowClasses() { |
| 9428 | if (!(this_edit_mes_id >= 0)) { |
| 9429 | return; |
| 9430 | } |
| 9431 | |
| 9432 | const message = chatElement.children('.mes').filter(`.mes[mesid="${this_edit_mes_id}"]`); |
| 9433 | |
| 9434 | const downButton = message.find('.mes_edit_down'); |
| 9435 | const upButton = message.find('.mes_edit_up'); |
| 9436 | const copyButton = message.find('.mes_edit_copy'); |
| 9437 | const deleteButton = message.find('.mes_edit_delete'); |
| 9438 | const lastId = Number(chatElement.find('.mes').last().attr('mesid')); |
| 9439 | const firstId = Number(chatElement.find('.mes').first().attr('mesid')); |
| 9440 | |
| 9441 | copyButton.removeClass('disabled'); |
| 9442 | deleteButton.removeClass('disabled'); |
| 9443 | |
| 9444 | // The last message cannot be moved down. |
| 9445 | downButton.toggleClass('disabled', lastId === Number(this_edit_mes_id)); |
| 9446 | // The first message cannot be moved up. |
| 9447 | upButton.toggleClass('disabled', firstId === Number(this_edit_mes_id)); |
| 9448 | } |
| 9449 | |
| 9450 | /** |
| 9451 | * Closes the message editor. |
| 9452 | * @param {'message'|'reasoning'|'all'} what What to close. Default is 'all'. |
| 9453 | */ |
| 9454 | export function closeMessageEditor(what = 'all') { |
| 9455 | if (what === 'message' || what === 'all') { |
| 9456 | if (this_edit_mes_id >= 0) { |
| 9457 | chatElement.find(`.mes[mesid="${this_edit_mes_id}"] .mes_edit_cancel`).trigger('click'); |
| 9458 | } |
| 9459 | } |
| 9460 | if (what === 'reasoning' || what === 'all') { |
| 9461 | document.querySelectorAll('.reasoning_edit_textarea').forEach((el) => { |
| 9462 | const cancelButton = el.closest('.mes')?.querySelector('.mes_reasoning_edit_cancel'); |
| 9463 | if (cancelButton instanceof HTMLElement) { |
| 9464 | cancelButton.click(); |
| 9465 | } |
| 9466 | }); |
| 9467 | } |
| 9468 | } |
| 9469 | |
| 9470 | export function setGenerationProgress(progress) { |
| 9471 | if (!progress) { |
| 9472 | $('#send_textarea').css({ 'background': '', 'transition': '' }); |
| 9473 | } else { |
| 9474 | $('#send_textarea').css({ |
| 9475 | 'background': `linear-gradient(90deg, #008000d6 ${progress}%, transparent ${progress}%)`, |
| 9476 | 'transition': '0.25s ease-in-out', |
| 9477 | }); |
| 9478 | } |
| 9479 | } |
| 9480 | |
| 9481 | export function cancelTtsPlay() { |
| 9482 | if ('speechSynthesis' in window) { |
| 9483 | speechSynthesis.cancel(); |
| 9484 | } |
| 9485 | } |
| 9486 | |
| 9487 | function updateAlternateGreetingsHintVisibility(root) { |
| 9488 | const numberOfGreetings = root.find('.alternate_greetings_list .alternate_greeting').length; |
| 9489 | $(root).find('.alternate_grettings_hint').toggle(numberOfGreetings == 0); |
| 9490 | } |
| 9491 | |
| 9492 | async function openCharacterWorldPopup() { |
| 9493 | const chid = $('#set_character_world').data('chid'); |
| 9494 | if (menu_type != 'create' && chid === undefined) { |
| 9495 | toastr.error('Does not have an Id for this character in world select menu.'); |
| 9496 | return; |
| 9497 | } |
| 9498 | |
| 9499 | // TODO: Maybe make this utility function not use the window context? |
| 9500 | const fileName = getCharaFilename(chid); |
| 9501 | const charName = (menu_type == 'create' ? create_save.name : characters[chid]?.data?.name) || 'Nameless'; |
| 9502 | const worldId = (menu_type == 'create' ? create_save.world : characters[chid]?.data?.extensions?.world) || ''; |
| 9503 | const template = $('#character_world_template .character_world').clone(); |
| 9504 | template.find('.character_name').text(charName); |
| 9505 | |
| 9506 | // --- Event Handlers --- |
| 9507 | async function handlePrimaryWorldSelect() { |
| 9508 | const selectedValue = $(this).val(); |
| 9509 | const worldIndex = selectedValue !== '' ? Number(selectedValue) : NaN; |
| 9510 | const name = !isNaN(worldIndex) ? world_names[worldIndex] : ''; |
| 9511 | await charUpdatePrimaryWorld(name); |
| 9512 | } |
| 9513 | |
| 9514 | function handleExtrasWorldSelect(evt) { |
| 9515 | const el = evt?.currentTarget ?? this; |
| 9516 | const selectedValues = $(el).val(); |
| 9517 | const selected = Array.isArray(selectedValues) ? selectedValues : []; |
| 9518 | const fileName = getCharaFilename(null, {}); |
| 9519 | const nextList = selected.map(i => world_names[i]).filter(Boolean); |
| 9520 | charSetAuxWorlds(fileName, nextList); |
| 9521 | } |
| 9522 | |
| 9523 | // --- Populate Dropdowns --- |
| 9524 | // Append to primary dropdown. |
| 9525 | const primarySelect = template.find('.character_world_info_selector'); |
| 9526 | world_names.forEach((item, i) => { |
| 9527 | primarySelect.append(new Option(item, String(i), item === worldId, item === worldId)); |
| 9528 | }); |
| 9529 | |
| 9530 | // Append to extras dropdown. |
| 9531 | const extrasSelect = template.find('.character_extra_world_info_selector'); |
| 9532 | const existingCharLore = world_info.charLore?.find((e) => e.name === fileName); |
| 9533 | world_names.forEach((item, i) => { |
| 9534 | const array = (menu_type == 'create' ? create_save.extra_books : existingCharLore?.extraBooks); |
| 9535 | const isSelected = !!array?.includes(item); |
| 9536 | extrasSelect.append(new Option(item, String(i), isSelected, isSelected)); |
| 9537 | }); |
| 9538 | |
| 9539 | const popup = new Popup(template, POPUP_TYPE.TEXT, '', { |
| 9540 | onOpen: function (popup) { |
| 9541 | const popupDialog = $(popup.dlg); |
| 9542 | |
| 9543 | primarySelect.on('change', handlePrimaryWorldSelect); |
| 9544 | extrasSelect.on('change', handleExtrasWorldSelect); |
| 9545 | |
| 9546 | // Not needed on mobile. |
| 9547 | if (!isMobile()) { |
| 9548 | extrasSelect.select2({ |
| 9549 | width: '100%', |
| 9550 | placeholder: t`No auxiliary Lorebooks set. Click here to select.`, |
| 9551 | allowClear: true, |
| 9552 | closeOnSelect: false, |
| 9553 | dropdownParent: popupDialog, |
| 9554 | }); |
| 9555 | } |
| 9556 | }, |
| 9557 | }); |
| 9558 | |
| 9559 | await popup.show(); |
| 9560 | } |
| 9561 | |
| 9562 | function openAlternateGreetings() { |
| 9563 | const chid = $('.open_alternate_greetings').data('chid'); |
| 9564 | |
| 9565 | if (menu_type != 'create' && chid === undefined) { |
| 9566 | toastr.error('Does not have an Id for this character in editor menu.'); |
| 9567 | return; |
| 9568 | } else { |
| 9569 | // If the character does not have alternate greetings, create an empty array |
| 9570 | if (characters[chid] && !Array.isArray(characters[chid].data.alternate_greetings)) { |
| 9571 | characters[chid].data.alternate_greetings = []; |
| 9572 | } |
| 9573 | } |
| 9574 | |
| 9575 | const template = $('#alternate_greetings_template .alternate_grettings').clone(); |
| 9576 | const getArray = () => menu_type == 'create' ? create_save.alternate_greetings : characters[chid].data.alternate_greetings; |
| 9577 | const popup = new Popup(template, POPUP_TYPE.TEXT, '', { |
| 9578 | wide: true, |
| 9579 | large: true, |
| 9580 | allowVerticalScrolling: true, |
| 9581 | onClose: async () => { |
| 9582 | if (menu_type !== 'create') { |
| 9583 | await createOrEditCharacter(); |
| 9584 | } |
| 9585 | }, |
| 9586 | }); |
| 9587 | |
| 9588 | for (let index = 0; index < getArray().length; index++) { |
| 9589 | addAlternateGreeting(template, getArray()[index], index, getArray, popup); |
| 9590 | } |
| 9591 | |
| 9592 | template.find('.add_alternate_greeting').on('click', function () { |
| 9593 | const array = getArray(); |
| 9594 | const index = array.length; |
| 9595 | array.push(''); |
| 9596 | addAlternateGreeting(template, '', index, getArray, popup); |
| 9597 | updateAlternateGreetingsHintVisibility(template); |
| 9598 | const list = template.find('.alternate_greetings_list'); |
| 9599 | list.scrollTop(list.prop('scrollHeight')); |
| 9600 | }); |
| 9601 | |
| 9602 | popup.show(); |
| 9603 | updateAlternateGreetingsHintVisibility(template); |
| 9604 | } |
| 9605 | |
| 9606 | /** |
| 9607 | * Adds an alternate greeting to the template. |
| 9608 | * @param {JQuery<HTMLElement>} template |
| 9609 | * @param {string} greeting |
| 9610 | * @param {number} index |
| 9611 | * @param {() => any[]} getArray |
| 9612 | * @param {Popup} popup |
| 9613 | */ |
| 9614 | function addAlternateGreeting(template, greeting, index, getArray, popup) { |
| 9615 | const greetingBlock = $('#alternate_greeting_form_template .alternate_greeting').clone(); |
| 9616 | greetingBlock.attr('data-index', index); |
| 9617 | greetingBlock.find('.alternate_greeting_text') |
| 9618 | .attr('id', `alternate_greeting_${index}`) |
| 9619 | .on('input', async function () { |
| 9620 | const value = $(this).val(); |
| 9621 | const array = getArray(); |
| 9622 | array[index] = value; |
| 9623 | }).val(greeting); |
| 9624 | greetingBlock.find('.editor_maximize').attr('data-for', `alternate_greeting_${index}`); |
| 9625 | greetingBlock.find('.greeting_index').text(index + 1); |
| 9626 | greetingBlock.find('.delete_alternate_greeting').on('click', async function (event) { |
| 9627 | event.preventDefault(); |
| 9628 | event.stopPropagation(); |
| 9629 | |
| 9630 | const confirm = await callGenericPopup(t`Are you sure you want to delete this alternate greeting?`, POPUP_TYPE.CONFIRM); |
| 9631 | if (!confirm) { |
| 9632 | return; |
| 9633 | } |
| 9634 | |
| 9635 | const array = getArray(); |
| 9636 | array.splice(index, 1); |
| 9637 | |
| 9638 | // We need to reopen the popup to update the index numbers |
| 9639 | await popup.complete(POPUP_RESULT.AFFIRMATIVE); |
| 9640 | openAlternateGreetings(); |
| 9641 | }); |
| 9642 | greetingBlock.find('.move_up_alternate_greeting').on('click', function (event) { |
| 9643 | handleMoveAlternateGreeting(event, -1); |
| 9644 | }); |
| 9645 | greetingBlock.find('.move_down_alternate_greeting').on('click', function (event) { |
| 9646 | handleMoveAlternateGreeting(event, 1); |
| 9647 | }); |
| 9648 | |
| 9649 | /** |
| 9650 | * Handles moving an alternate greeting up or down in the list. |
| 9651 | * @param {JQuery.ClickEvent} event - The click event |
| 9652 | * @param {number} direction - Direction to move: -1 for up, 1 for down |
| 9653 | */ |
| 9654 | function handleMoveAlternateGreeting(event, direction) { |
| 9655 | event.preventDefault(); |
| 9656 | event.stopPropagation(); |
| 9657 | |
| 9658 | const array = getArray(); |
| 9659 | const index = Number(greetingBlock.attr('data-index')); |
| 9660 | const newIndex = index + direction; |
| 9661 | |
| 9662 | // Check bounds |
| 9663 | if (direction === -1 && index <= 0) { |
| 9664 | return; |
| 9665 | } |
| 9666 | if (direction === 1 && index >= array.length - 1) { |
| 9667 | return; |
| 9668 | } |
| 9669 | |
| 9670 | // Swap the greetings |
| 9671 | [array[index], array[newIndex]] = [array[newIndex], array[index]]; |
| 9672 | |
| 9673 | // Update current greeting |
| 9674 | greetingBlock.find('.alternate_greeting_text').val(array[index]); |
| 9675 | |
| 9676 | // Update adjacent greeting |
| 9677 | const adjacentGreetingBlock = template.find(`.alternate_greeting[data-index="${newIndex}"]`); |
| 9678 | adjacentGreetingBlock.find('.alternate_greeting_text').val(array[newIndex]); |
| 9679 | } |
| 9680 | |
| 9681 | template.find('.alternate_greetings_list').append(greetingBlock); |
| 9682 | } |
| 9683 | |
| 9684 | /** |
| 9685 | * Creates or edits a character based on the form data. |
| 9686 | * @param {Event} [e] Event that triggered the function call. |
| 9687 | */ |
| 9688 | export async function createOrEditCharacter(e) { |
| 9689 | if (!settingsReady) { |
| 9690 | console.warn('Settings not ready, aborting character creation/editing.'); |
| 9691 | return; |
| 9692 | } |
| 9693 | |
| 9694 | $('#rm_info_avatar').html(''); |
| 9695 | const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0))); |
| 9696 | formData.set('fav', String(fav_ch_checked)); |
| 9697 | const isNewChat = e instanceof CustomEvent && e.type === 'newChat'; |
| 9698 | |
| 9699 | const rawFile = formData.get('avatar'); |
| 9700 | if (rawFile instanceof File) { |
| 9701 | const convertedFile = await ensureImageFormatSupported(rawFile); |
| 9702 | formData.set('avatar', convertedFile); |
| 9703 | } |
| 9704 | |
| 9705 | const headers = getRequestHeaders({ omitContentType: true }); |
| 9706 | |
| 9707 | if ($('#form_create').attr('actiontype') == 'createcharacter') { |
| 9708 | if (String($('#character_name_pole').val()).length === 0) { |
| 9709 | toastr.error(t`Name is required`); |
| 9710 | return; |
| 9711 | } |
| 9712 | if (is_group_generating || is_send_press) { |
| 9713 | toastr.error(t`Cannot create characters while generating. Stop the request and try again.`, t`Creation aborted`); |
| 9714 | return; |
| 9715 | } |
| 9716 | try { |
| 9717 | //if the character name text area isn't empty (only posible when creating a new character) |
| 9718 | let url = '/api/characters/create'; |
| 9719 | |
| 9720 | if (crop_data != undefined) { |
| 9721 | url += `?crop=${encodeURIComponent(JSON.stringify(crop_data))}`; |
| 9722 | } |
| 9723 | |
| 9724 | formData.delete('alternate_greetings'); |
| 9725 | for (const value of create_save.alternate_greetings) { |
| 9726 | formData.append('alternate_greetings', value); |
| 9727 | } |
| 9728 | |
| 9729 | formData.append('extensions', JSON.stringify(create_save.extensions)); |
| 9730 | |
| 9731 | const fetchResult = await fetch(url, { |
| 9732 | method: 'POST', |
| 9733 | headers: headers, |
| 9734 | body: formData, |
| 9735 | cache: 'no-cache', |
| 9736 | }); |
| 9737 | |
| 9738 | if (!fetchResult.ok) { |
| 9739 | throw new Error('Fetch result is not ok'); |
| 9740 | } |
| 9741 | |
| 9742 | const avatarId = await fetchResult.text(); |
| 9743 | |
| 9744 | $('#character_cross').trigger('click'); //closes the advanced character editing popup |
| 9745 | const fields = [ |
| 9746 | { id: '#character_name_pole', callback: value => create_save.name = value }, |
| 9747 | { id: '#description_textarea', callback: value => create_save.description = value }, |
| 9748 | { id: '#creator_notes_textarea', callback: value => create_save.creator_notes = value }, |
| 9749 | { id: '#character_version_textarea', callback: value => create_save.character_version = value }, |
| 9750 | { id: '#post_history_instructions_textarea', callback: value => create_save.post_history_instructions = value }, |
| 9751 | { id: '#system_prompt_textarea', callback: value => create_save.system_prompt = value }, |
| 9752 | { id: '#tags_textarea', callback: value => create_save.tags = value }, |
| 9753 | { id: '#creator_textarea', callback: value => create_save.creator = value }, |
| 9754 | { id: '#personality_textarea', callback: value => create_save.personality = value }, |
| 9755 | { id: '#firstmessage_textarea', callback: value => create_save.first_message = value }, |
| 9756 | { id: '#talkativeness_slider', callback: value => create_save.talkativeness = value, defaultValue: talkativeness_default }, |
| 9757 | { id: '#scenario_pole', callback: value => create_save.scenario = value }, |
| 9758 | { id: '#depth_prompt_prompt', callback: value => create_save.depth_prompt_prompt = value }, |
| 9759 | { id: '#depth_prompt_depth', callback: value => create_save.depth_prompt_depth = value, defaultValue: depth_prompt_depth_default }, |
| 9760 | { id: '#depth_prompt_role', callback: value => create_save.depth_prompt_role = value, defaultValue: depth_prompt_role_default }, |
| 9761 | { id: '#mes_example_textarea', callback: value => create_save.mes_example = value }, |
| 9762 | { id: '#character_json_data', callback: () => { } }, |
| 9763 | { id: '#alternate_greetings_template', callback: value => create_save.alternate_greetings = value, defaultValue: [] }, |
| 9764 | { id: '#character_world', callback: value => create_save.world = value }, |
| 9765 | { id: '#_character_extensions_fake', callback: value => create_save.extensions = {} }, |
| 9766 | ]; |
| 9767 | |
| 9768 | fields.forEach(field => { |
| 9769 | const fieldValue = field.defaultValue !== undefined ? field.defaultValue : ''; |
| 9770 | $(field.id).val(fieldValue); |
| 9771 | field.callback && field.callback(fieldValue); |
| 9772 | }); |
| 9773 | |
| 9774 | if (Array.isArray(create_save.extra_books) && create_save.extra_books.length > 0) { |
| 9775 | const fileName = getCharaFilename(null, { manualAvatarKey: avatarId }); |
| 9776 | const charLore = world_info.charLore ?? []; |
| 9777 | charLore.push({ name: fileName, extraBooks: create_save.extra_books }); |
| 9778 | Object.assign(world_info, { charLore: charLore }); |
| 9779 | saveSettingsDebounced(); |
| 9780 | } |
| 9781 | create_save.extra_books = []; |
| 9782 | |
| 9783 | $('#character_popup-button-h3').text('Create character'); |
| 9784 | |
| 9785 | create_save.avatar = null; |
| 9786 | |
| 9787 | $('#add_avatar_button').replaceWith( |
| 9788 | $('#add_avatar_button').val('').clone(true), |
| 9789 | ); |
| 9790 | |
| 9791 | let oldSelectedChar = null; |
| 9792 | if (this_chid !== undefined) { |
| 9793 | oldSelectedChar = characters[this_chid].avatar; |
| 9794 | } |
| 9795 | |
| 9796 | console.log(`new avatar id: ${avatarId}`); |
| 9797 | createTagMapFromList('#tagList', avatarId); |
| 9798 | await getCharacters(); |
| 9799 | |
| 9800 | select_rm_info('char_create', avatarId, oldSelectedChar); |
| 9801 | |
| 9802 | crop_data = undefined; |
| 9803 | } catch (error) { |
| 9804 | console.error('Error creating character', error); |
| 9805 | toastr.error(t`Failed to create character`); |
| 9806 | } |
| 9807 | } else { |
| 9808 | try { |
| 9809 | let url = '/api/characters/edit'; |
| 9810 | |
| 9811 | if (crop_data != undefined) { |
| 9812 | url += `?crop=${encodeURIComponent(JSON.stringify(crop_data))}`; |
| 9813 | } |
| 9814 | |
| 9815 | formData.delete('alternate_greetings'); |
| 9816 | const chid = $('.open_alternate_greetings').data('chid'); |
| 9817 | if (characters[chid] && Array.isArray(characters[chid]?.data?.alternate_greetings)) { |
| 9818 | for (const value of characters[chid].data.alternate_greetings) { |
| 9819 | formData.append('alternate_greetings', value); |
| 9820 | } |
| 9821 | } |
| 9822 | |
| 9823 | const fetchResult = await fetch(url, { |
| 9824 | method: 'POST', |
| 9825 | headers: headers, |
| 9826 | body: formData, |
| 9827 | cache: 'no-cache', |
| 9828 | }); |
| 9829 | |
| 9830 | if (!fetchResult.ok) { |
| 9831 | throw new Error('Fetch result is not ok'); |
| 9832 | } |
| 9833 | |
| 9834 | await getOneCharacter(formData.get('avatar_url')); |
| 9835 | favsToHotswap(); // Update fav state |
| 9836 | |
| 9837 | $('#add_avatar_button').replaceWith( |
| 9838 | $('#add_avatar_button').val('').clone(true), |
| 9839 | ); |
| 9840 | $('#create_button').attr('value', 'Save'); |
| 9841 | crop_data = undefined; |
| 9842 | await eventSource.emit(event_types.CHARACTER_EDITED, { detail: { id: this_chid, character: characters[this_chid] } }); |
| 9843 | |
| 9844 | // Recreate the chat if it hasn't been used at least once (i.e. with continue). |
| 9845 | const message = getFirstMessage(); |
| 9846 | const shouldRegenerateMessage = |
| 9847 | !isNewChat && |
| 9848 | message.mes && |
| 9849 | !selected_group && |
| 9850 | !chat_metadata.tainted && |
| 9851 | (chat.length === 0 || (chat.length === 1 && !chat[0].is_user && !chat[0].is_system)); |
| 9852 | |
| 9853 | if (shouldRegenerateMessage) { |
| 9854 | chat.splice(0, chat.length, message); |
| 9855 | const messageId = (chat.length - 1); |
| 9856 | await eventSource.emit(event_types.MESSAGE_RECEIVED, messageId, 'first_message'); |
| 9857 | await clearChat(); |
| 9858 | await printMessages(); |
| 9859 | await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, messageId, 'first_message'); |
| 9860 | await saveChatConditional(); |
| 9861 | } |
| 9862 | } catch (error) { |
| 9863 | console.log(error); |
| 9864 | toastr.error(t`Something went wrong while saving the character, or the image file provided was in an invalid format. Double check that the image is not a webp.`); |
| 9865 | } |
| 9866 | } |
| 9867 | } |
| 9868 | |
| 9869 | /** |
| 9870 | * Formats a counter for a swipe view. |
| 9871 | * @param {number} current The current number of items. |
| 9872 | * @param {number} total The total number of items. |
| 9873 | * @returns {string} The formatted counter. |
| 9874 | */ |
| 9875 | function formatSwipeCounter(current, total) { |
| 9876 | if (isNaN(current) && isNaN(total)) { |
| 9877 | return ''; |
| 9878 | } |
| 9879 | return `${!isNaN(current) ? current : '?'}\u200b/\u200b${!isNaN(total) ? total : '?'}`; |
| 9880 | } |
| 9881 | |
| 9882 | /** |
| 9883 | * Handles the swipe event. |
| 9884 | * @param {SwipeEvent} event Event. |
| 9885 | * @param {SWIPE_DIRECTION} direction The direction to swipe. |
| 9886 | * @param {object} params Additional parameters. |
| 9887 | * @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. |
| 9888 | * @param {boolean} [params.repeated] Is the swipe event repeated. |
| 9889 | * @param {ChatMessage} [params.message=chat[chat.length - 1]] The chat message to swipe. |
| 9890 | * @param {number} [params.forceMesId] The message id to swipe. |
| 9891 | * @param {number} [params.forceSwipeId] The target swipe_id. When out of range, it will be looped or clamped. |
| 9892 | * @param {number} [params.forceDuration] Overwrites the default swipe duration. |
| 9893 | */ |
| 9894 | export async function swipe(event, direction, { source, repeated, message = chat[chat.length - 1], forceMesId, forceSwipeId, forceDuration } = {}) { |
| 9895 | if (chat.length === 0) { |
| 9896 | console.warn('Swipe was called on an empty chat.'); |
| 9897 | return; |
| 9898 | } |
| 9899 | |
| 9900 | let messageIndex; |
| 9901 | |
| 9902 | //Only set messageIndex if message exists because -1 is truthy. |
| 9903 | if (message) { |
| 9904 | messageIndex = chat.indexOf(message); |
| 9905 | if (messageIndex === -1 && typeof (forceMesId) != 'number') { |
| 9906 | console.error(`The message must exist in chat. ${message};`); |
| 9907 | return; |
| 9908 | } |
| 9909 | } |
| 9910 | |
| 9911 | const mesId = Number(forceMesId ?? event?.currentTarget?.closest('.mes')?.getAttribute('mesid') ?? messageIndex ?? chat.length - 1); |
| 9912 | |
| 9913 | if ([SWIPE_SOURCE.DELETE, SWIPE_SOURCE.BACK, SWIPE_SOURCE.AUTO_SWIPE, SWIPE_SOURCE.SLASH_COMMAND, SWIPE_SOURCE.SWIPE_PICKER].includes(source)) { |
| 9914 | console.info(`The ${direction} swipe source on message #${mesId} is ${source}, Most checks have been bypassed. `); |
| 9915 | } else { |
| 9916 | //Only show an error if swipes are not hidden and a message is generating. |
| 9917 | if (isGenerating() && (swipes && !swipesHidden && (swipeState === SWIPE_STATE.NONE))) { |
| 9918 | toastr.warning(t`Cannot swipe while generating. Stop the request and try again.`, t`Swipe aborted`); |
| 9919 | return; |
| 9920 | } |
| 9921 | //Only allow one concurrent swipe. |
| 9922 | if (!isSwipingAllowed()) { |
| 9923 | console.info('The swipe has been ignored messages cannot currently be swiped.'); |
| 9924 | return; |
| 9925 | } |
| 9926 | if (!isMessageSwipeable(mesId, message)) { |
| 9927 | console.info(`Message #${mesId} cannot be swiped. ${message}`); |
| 9928 | return; |
| 9929 | } |
| 9930 | } |
| 9931 | |
| 9932 | // Cancel pending save to prevent accidental swipe_id overwrites. |
| 9933 | cancelDebouncedChatSave(); |
| 9934 | |
| 9935 | swipeState = SWIPE_STATE.SWIPING; |
| 9936 | let generation; |
| 9937 | |
| 9938 | const thisMesDiv = chatElement.children('.mes').filter(`[mesid="${mesId}"]`); |
| 9939 | const thisMesText = thisMesDiv.find('.mes_block .mes_text'); |
| 9940 | const thisMesDivHeight = thisMesDiv[0]?.scrollHeight; |
| 9941 | const thisMesTextHeight = thisMesText[0]?.scrollHeight; |
| 9942 | if (![thisMesDiv.length, thisMesText.length].every(num => num > 0)) { |
| 9943 | console.error(`Message #${mesId}'s DOM element is not valid.`); |
| 9944 | return; |
| 9945 | } |
| 9946 | const originalSwipeId = Number(chat[mesId]?.swipe_id ?? 0); |
| 9947 | let newSwipeId = Number(forceSwipeId ?? originalSwipeId); |
| 9948 | |
| 9949 | /** |
| 9950 | * Calculates the next swipe duration with how many swipes have been repeated. |
| 9951 | * @param {number} animation_duration |
| 9952 | * @returns {number} The adjusted swipe duration. |
| 9953 | */ |
| 9954 | function getSwipeDuration(animation_duration) { |
| 9955 | const now = performance.now(); |
| 9956 | const resetTime = animation_duration * 2 + 300; |
| 9957 | |
| 9958 | //Reset the counter if the last swipe was more than half a second ago. |
| 9959 | if (now - lastSwipeInfo.now >= resetTime || direction !== lastSwipeInfo.direction) recentSwipes = 0; |
| 9960 | recentSwipes++; |
| 9961 | lastSwipeInfo = { now, direction }; |
| 9962 | |
| 9963 | //At 4 swipes, animation_duration will be halved. |
| 9964 | const sigmoid = 1 / (1 + Math.exp(recentSwipes - 4)); |
| 9965 | |
| 9966 | return animation_duration * sigmoid; |
| 9967 | } |
| 9968 | |
| 9969 | const swipeDuration = forceDuration ?? getSwipeDuration(animation_duration); |
| 9970 | |
| 9971 | //The offscreen messages may be visible if the user resizes the viewport during a swipe. |
| 9972 | const thisMesDivWidth = thisMesDiv.width() + 30; |
| 9973 | let swipeRange = (direction === SWIPE_DIRECTION.RIGHT) ? -thisMesDivWidth : thisMesDivWidth; |
| 9974 | |
| 9975 | /** |
| 9976 | * Waits for the generation to end, reverts the swipe if swipe_id has not changed. |
| 9977 | * @param {boolean} revert Attept to revert the swipe without saving. |
| 9978 | */ |
| 9979 | async function endSwipe(revert = false) { |
| 9980 | //Wait for the generation to end. |
| 9981 | try { |
| 9982 | //`mes_buttons` need to be hidden until the animation completes. |
| 9983 | if (generation) { |
| 9984 | document.body.dataset.swiping = 'true'; |
| 9985 | await generation; |
| 9986 | } |
| 9987 | } catch (error) { |
| 9988 | console.warn(`Swipe failed, Swiping back. ${error}`); |
| 9989 | } |
| 9990 | |
| 9991 | //Clamp Id between swipes. |
| 9992 | let clampedId = clamp(chat[mesId].swipe_id, 0, Math.max(0, chat[mesId].swipes.length - 1)); |
| 9993 | |
| 9994 | await updateSwipeCounter(mesId); |
| 9995 | //Fallback. |
| 9996 | if (mesId != chat.length - 1) { |
| 9997 | await updateSwipeCounter(chat.length - 1); |
| 9998 | } |
| 9999 | |
| 10000 | // If swipe_id has not changed, give the user feedback. |
| 10001 | if (clampedId == originalSwipeId && source != SWIPE_SOURCE.DELETE) { |
| 10002 | try { |
| 10003 | //Shake 700/140=5px |
| 10004 | shakeElement(thisMesDiv, -swipeRange / 140, animation_duration, 'ease-in'); |
| 10005 | //Flash red. |
| 10006 | const flashTime = Math.max(animation_duration * 2, 100); |
| 10007 | await Promise.race([thisMesDiv.find('.swipes-counter').animate({ color: 'red' }, flashTime).animate({ color: '' }).promise(), createTimeout(flashTime * 4, `The shake animation did not end within ${flashTime * 4}ms`)].filter(Boolean)); |
| 10008 | } catch (error) { |
| 10009 | console.warn(error); |
| 10010 | } |
| 10011 | } |
| 10012 | |
| 10013 | //If the id is not within bounds, Swipe back. |
| 10014 | if (chat[mesId]?.swipe_id !== clampedId || revert) { |
| 10015 | // Prevent recursion. |
| 10016 | if (source != SWIPE_SOURCE.BACK) { |
| 10017 | source = SWIPE_SOURCE.BACK; |
| 10018 | chat[mesId].swipe_id = clampedId; |
| 10019 | |
| 10020 | //Update the chat. |
| 10021 | await loadFromSwipeId(mesId, chat[mesId].swipe_id); |
| 10022 | await redisplayChat({ startIndex: mesId }); |
| 10023 | } else { |
| 10024 | await Popup.show.confirm( |
| 10025 | t`ERROR: <code>syncSwipeToMes</code> has failed to revert the failed ${direction} swipe on message #${mesId}.`, |
| 10026 | t`<p>After you click OK, the chat will be reloaded to prevent data corruption.</p>`, |
| 10027 | { okButton: 'OK', cancelButton: false }, |
| 10028 | ); |
| 10029 | console.trace(`Error! Recursion detected when reverting failed ${direction} swipe on message #${mesId}. Something has broken.`); |
| 10030 | await reloadCurrentChat(); |
| 10031 | } |
| 10032 | //Out of bounds swipes should not be saved. |
| 10033 | } else if (source != SWIPE_SOURCE.BACK) { |
| 10034 | //Save the chat if swipe_id has changed. |
| 10035 | saveChatDebounced(); |
| 10036 | } |
| 10037 | |
| 10038 | //Allow for another swipe. |
| 10039 | swipeState = SWIPE_STATE.NONE; |
| 10040 | delete document.body.dataset.swiping; |
| 10041 | showSwipeButtons(); |
| 10042 | } |
| 10043 | |
| 10044 | async function standardSwipe(newSwipeId) { |
| 10045 | //If swipe_id has changed, or the source is being deleted. |
| 10046 | if (newSwipeId !== originalSwipeId || source == SWIPE_SOURCE.DELETE || source == SWIPE_SOURCE.BACK) { |
| 10047 | //Update the chat. |
| 10048 | await loadFromSwipeId(mesId, newSwipeId); |
| 10049 | //Transition to the new chat. |
| 10050 | await animateSwipe(); |
| 10051 | } |
| 10052 | await endSwipe(); |
| 10053 | } |
| 10054 | |
| 10055 | /** |
| 10056 | * Removes a message's extra and gen times. |
| 10057 | * @param {ChatMessage} message |
| 10058 | */ |
| 10059 | function clearMessageData(message) { |
| 10060 | if (message.extra && typeof message.extra === 'object') { |
| 10061 | delete message.extra.memory; |
| 10062 | delete message.extra.display_text; |
| 10063 | delete message.extra.media; |
| 10064 | delete message.extra.inline_image; |
| 10065 | delete message.extra.files; |
| 10066 | delete message.extra.fileLength; |
| 10067 | delete message.extra.generationType; |
| 10068 | delete message.extra.negative; |
| 10069 | delete message.extra.title; |
| 10070 | delete message.extra.append_title; |
| 10071 | } |
| 10072 | delete message.gen_started; |
| 10073 | delete message.gen_finished; |
| 10074 | } |
| 10075 | |
| 10076 | /** |
| 10077 | * Sets the message to the newSwipeId and loads it. |
| 10078 | * @param {number} mesId |
| 10079 | * @param {number} newSwipeId |
| 10080 | */ |
| 10081 | async function loadFromSwipeId(mesId, newSwipeId) { |
| 10082 | //Update the swipe_id. |
| 10083 | chat[mesId].swipe_id = newSwipeId; |
| 10084 | |
| 10085 | clearMessageData(chat[mesId]); |
| 10086 | |
| 10087 | //Load from swipes. |
| 10088 | if (syncSwipeToMes(mesId, newSwipeId) == false) { |
| 10089 | let errorMessage = t`When swiping ${direction} on message ${mesId}, syncSwipeToMes has returned false. Attempting to swipe back!`; |
| 10090 | toastr.error(errorMessage); |
| 10091 | |
| 10092 | chat[mesId].swipe_id = originalSwipeId; |
| 10093 | await endSwipe(true); |
| 10094 | } |
| 10095 | return true; |
| 10096 | } |
| 10097 | |
| 10098 | /** |
| 10099 | * Animates a swipe for all messages >= mesId. |
| 10100 | * @param {number} mesId |
| 10101 | * @param {object} params |
| 10102 | * @param {string} [params.xStart='opx'] |
| 10103 | * @param {string} [params.xEnd='0px'] |
| 10104 | * @param {number} [params.duration=animation_duration] |
| 10105 | * @param {string} [params.classes=''] Additional CSS classes to target during the swipe. |
| 10106 | * @param {boolean} [params.freeze=true] When true, do not remove the class from the animation, leaving it stuck at xEnd. |
| 10107 | * @returns {Promise<boolean|Function>} endSlide unfreezes the messages from xEnd. |
| 10108 | */ |
| 10109 | async function animateSwipeTransition(mesId, { xStart = '0px', xEnd = '0px', duration = animation_duration, classes = '', freeze = false } = {}) { |
| 10110 | // If the animation_duration is zero, the 'animationend' promise will never resolve. |
| 10111 | //Skip the animation if it's faster than 50ms. |
| 10112 | if (duration <= 50) return; |
| 10113 | |
| 10114 | //Select MAXIMUM_ANIMATED messages after mesId. Ideally, only visible messages would be animated. |
| 10115 | const MAXIMUM_ANIMATED = 100; |
| 10116 | |
| 10117 | const messages = chatElement.children('.mes'); |
| 10118 | const firstDisplayedMesId = Number(messages.first().attr('mesid')); |
| 10119 | |
| 10120 | const swipedMessagesDiv = messages.filter((index, div) => { |
| 10121 | // const messageId = Number($(div).attr('mesid')); //Slower. |
| 10122 | //This assumes the messages are in order and their Id's are accurate. |
| 10123 | const divMessageId = firstDisplayedMesId + index; |
| 10124 | |
| 10125 | return (divMessageId < mesId + MAXIMUM_ANIMATED && divMessageId >= mesId); |
| 10126 | }); |
| 10127 | if (swipedMessagesDiv.length > 0) { |
| 10128 | let swipeClasses = '.mes_block, .mesAvatarWrapper'; |
| 10129 | swipeClasses += classes; |
| 10130 | |
| 10131 | //Select only the target classes. |
| 10132 | const swipedElementsDiv = swipedMessagesDiv.children(swipeClasses); |
| 10133 | if (swipedElementsDiv.length > 0) { |
| 10134 | //This is a global variable, only one swipe transition can occur concurrently. |
| 10135 | document.documentElement.style.setProperty('--slide-mes-x-start', xStart); |
| 10136 | document.documentElement.style.setProperty('--slide-mes-x-end', xEnd); |
| 10137 | document.documentElement.style.setProperty('--slide-mes-x-duration', `${duration}ms`); |
| 10138 | |
| 10139 | //The class must be removed to unfreze previous slides. |
| 10140 | swipedElementsDiv.removeClass('slide'); |
| 10141 | //CSS starts the animation. |
| 10142 | void swipedElementsDiv[0].offsetWidth; |
| 10143 | swipedElementsDiv.addClass('slide'); |
| 10144 | |
| 10145 | const endSlide = () => { |
| 10146 | //Remove the style when done. |
| 10147 | swipedElementsDiv.removeClass('slide'); |
| 10148 | |
| 10149 | document.documentElement.style.setProperty('--slide-mes-x-start', ''); |
| 10150 | document.documentElement.style.setProperty('--slide-mes-x-end', ''); |
| 10151 | document.documentElement.style.setProperty('--slide-mes-duration', ''); |
| 10152 | return true; |
| 10153 | }; |
| 10154 | //Wait for the animation's end. https://developer.mozilla.org/en-US/docs/Web/API/Animation/finished |
| 10155 | const animations = swipedElementsDiv[0]?.getAnimations() ?? []; |
| 10156 | const animation = animations.filter((a) => a instanceof globalThis.CSSAnimation && a.animationName == 'slide')[0]; |
| 10157 | try { |
| 10158 | await Promise.race([animation?.finished, createTimeout(duration * 2, `The ${duration}ms swipe animation has not ended after ${duration * 2}ms. It has been skipped.`)].filter(Boolean)); |
| 10159 | } catch (error) { |
| 10160 | console.warn(error); |
| 10161 | } |
| 10162 | |
| 10163 | //If not frozen, end the slide now. |
| 10164 | return freeze ? endSlide : endSlide(); |
| 10165 | } |
| 10166 | } |
| 10167 | console.warn(`No animatable messages were found after message #${mesId}.`); |
| 10168 | return false; |
| 10169 | } |
| 10170 | |
| 10171 | function getMessageBottomHeight(thisMesDiv) { |
| 10172 | const thisMesRect = thisMesDiv[0].getBoundingClientRect(); |
| 10173 | //Scroll position + Chat height = Bottom of chat height. |
| 10174 | const chatBottom = chatElement.scrollTop() - chatElement.height(); |
| 10175 | //Message offset from viewport top + height = Bottom of message offset. |
| 10176 | const messageBottom = thisMesRect.top + thisMesDiv.height(); |
| 10177 | // Bottom of chat + Bottom of message offset = target scroll position. |
| 10178 | const scrollHeight = (chatBottom + messageBottom); |
| 10179 | return scrollHeight; |
| 10180 | } |
| 10181 | |
| 10182 | function expandNewMessage(thisMesDiv) { |
| 10183 | //Only scroll if the view is not near the bottom. |
| 10184 | const is_animation_scroll = (chatElement.scrollTop() >= (chatElement.prop('scrollHeight') - chatElement.outerHeight()) - 10); |
| 10185 | |
| 10186 | let new_height = thisMesDivHeight - (thisMesTextHeight - thisMesText[0].scrollHeight); |
| 10187 | if (new_height < 103) new_height = 103; |
| 10188 | |
| 10189 | //Keep the swipe buttons at the same height when scrolling is finished. |
| 10190 | |
| 10191 | //Expand new message. |
| 10192 | thisMesDiv.animate({ height: new_height + 'px' }, { |
| 10193 | duration: 0, //used to be 100 //Disabled on Cohee's request. https://github.com/SillyTavern/SillyTavern/pull/4610/files#r2408731744 |
| 10194 | queue: false, |
| 10195 | progress: function (animation, progress, remainingMs) { |
| 10196 | if (is_animation_scroll) chatElement.scrollTop(getMessageBottomHeight(thisMesDiv)); |
| 10197 | }, |
| 10198 | complete: function () { |
| 10199 | thisMesDiv.css('height', 'auto'); |
| 10200 | //Correct height auto offset. |
| 10201 | if (is_animation_scroll) chatElement.scrollTop(getMessageBottomHeight(thisMesDiv)); |
| 10202 | }, |
| 10203 | }); |
| 10204 | } |
| 10205 | |
| 10206 | /** |
| 10207 | * Anime a swipe, optionally running a generation. |
| 10208 | * @param {boolean} run_generate |
| 10209 | * @param {boolean} [skipSwipeOut=false] |
| 10210 | */ |
| 10211 | async function animateSwipe(run_generate = false, skipSwipeOut = false) { |
| 10212 | if (!skipSwipeOut) { |
| 10213 | //Swipe out. |
| 10214 | await animateSwipeTransition(mesId, { xEnd: `${swipeRange}px`, duration: swipeDuration }); |
| 10215 | } |
| 10216 | |
| 10217 | |
| 10218 | if (run_generate) { |
| 10219 | await updateSwipeCounter(mesId); |
| 10220 | //shows "..." while generating |
| 10221 | thisMesDiv.find('.mes_text').html('...'); |
| 10222 | // resets the timer |
| 10223 | thisMesDiv.find('.mes_timer').html(''); |
| 10224 | thisMesDiv.find('.tokenCounterDisplay').text(''); |
| 10225 | updateReasoningUI(thisMesDiv, { reset: true }); |
| 10226 | } else { |
| 10227 | //console.log('showing previously generated swipe candidate, or "..."'); |
| 10228 | //console.log('onclick right swipe calling addOneMessage'); |
| 10229 | |
| 10230 | //Only scroll when swiping the last message. |
| 10231 | const scroll = (mesId == chat.length - 1); |
| 10232 | //The swipe buttons will be refreshed in endSwipe(), refreshing them now will cause flickering. |
| 10233 | addOneMessage(chat[mesId], { type: 'swipe', forceId: mesId, scroll: scroll, showSwipes: false }); |
| 10234 | |
| 10235 | if (power_user.message_token_count_enabled) { |
| 10236 | if (!chat[mesId].extra) { |
| 10237 | chat[mesId].extra = {}; |
| 10238 | } |
| 10239 | |
| 10240 | const tokenCountText = (chat[mesId]?.extra?.reasoning || '') + chat[mesId].mes; |
| 10241 | const tokenCount = await getTokenCountAsync(tokenCountText, 0); |
| 10242 | chat[mesId].extra.token_count = tokenCount; |
| 10243 | thisMesDiv.find('.tokenCounterDisplay').text(`${tokenCount}t`); |
| 10244 | } |
| 10245 | } |
| 10246 | |
| 10247 | //Animate expanding to the new message height. |
| 10248 | thisMesDiv.css('height', thisMesDivHeight); |
| 10249 | expandNewMessage(thisMesDiv); |
| 10250 | |
| 10251 | if (run_generate) { |
| 10252 | appendMediaToMessage(chat[mesId], thisMesDiv); |
| 10253 | } |
| 10254 | |
| 10255 | await eventSource.emit(event_types.MESSAGE_SWIPED, (mesId)); |
| 10256 | |
| 10257 | if (run_generate && !is_send_press) { |
| 10258 | is_send_press = true; |
| 10259 | generation = Generate('swipe'); |
| 10260 | } |
| 10261 | |
| 10262 | //Swipe in from the opposite side. |
| 10263 | await animateSwipeTransition(mesId, { xStart: `${-swipeRange}px`, xEnd: `${0}px`, duration: swipeDuration }); |
| 10264 | } |
| 10265 | |
| 10266 | if (mesId === Number(this_edit_mes_id)) { |
| 10267 | closeMessageEditor(); |
| 10268 | } |
| 10269 | if (isStreamingEnabled() && streamingProcessor) { |
| 10270 | streamingProcessor.onStopStreaming(); |
| 10271 | } |
| 10272 | |
| 10273 | if (isHordeGenerationNotAllowed()) { |
| 10274 | return unblockGeneration(); |
| 10275 | } |
| 10276 | |
| 10277 | //If the swipe is not being deleted. |
| 10278 | if (source != SWIPE_SOURCE.DELETE && source != SWIPE_SOURCE.BACK) { |
| 10279 | // Make sure ad-hoc changes to extras are saved before swiping away |
| 10280 | syncMesToSwipe(mesId); |
| 10281 | |
| 10282 | if (chat[mesId].swipe_id === undefined) { // if there is no swipe-message in the last spot of the chat array |
| 10283 | chat[mesId].swipe_id = 0; // set it to id 0 |
| 10284 | chat[mesId].swipes = []; // empty the array |
| 10285 | chat[mesId].swipe_info = []; |
| 10286 | chat[mesId].swipes[0] = chat[mesId].mes; //assign swipe array with last chat[mesId] from chat |
| 10287 | chat[mesId].swipe_info[0] = { |
| 10288 | 'send_date': chat[mesId].send_date, |
| 10289 | 'gen_started': chat[mesId].gen_started, |
| 10290 | 'gen_finished': chat[mesId].gen_finished, |
| 10291 | 'extra': structuredClone(chat[mesId].extra), |
| 10292 | }; |
| 10293 | } |
| 10294 | // If the user is holding down the key and we're at the last or first swipe, don't do anything. |
| 10295 | let isLastSwipe = (direction === SWIPE_DIRECTION.RIGHT) ? (chat[mesId].swipe_id === Math.max(0, chat[mesId].swipes.length - 1)) : chat[mesId].swipe_id === 0; |
| 10296 | if (source === SWIPE_SOURCE.KEYBOARD && repeated && isLastSwipe) { |
| 10297 | await endSwipe(); |
| 10298 | return; |
| 10299 | } |
| 10300 | } else if (source == SWIPE_SOURCE.DELETE || source == SWIPE_SOURCE.BACK) { |
| 10301 | //If the swipe is being deleted or reverted. |
| 10302 | await standardSwipe(newSwipeId); |
| 10303 | return; |
| 10304 | } |
| 10305 | |
| 10306 | //If swiping left. |
| 10307 | if (direction === SWIPE_DIRECTION.LEFT) { |
| 10308 | if (forceSwipeId == null) newSwipeId--; |
| 10309 | //Loop to last swipe if negative. |
| 10310 | if (newSwipeId < 0) { |
| 10311 | newSwipeId = Math.max(0, chat[mesId].swipes.length - 1); |
| 10312 | } |
| 10313 | //Limit swipe_id to swipes. |
| 10314 | if (newSwipeId > chat[mesId].swipes.length - 1) { |
| 10315 | toastr.warning(`The swipe_id for message #${mesId} was ${newSwipeId}. It has been reset to ${chat[mesId].swipes.length - 1}.`); |
| 10316 | chat[mesId].swipe_id = chat[mesId].swipes.length - 1; |
| 10317 | await endSwipe(); |
| 10318 | return; |
| 10319 | } |
| 10320 | await standardSwipe(newSwipeId); |
| 10321 | return; |
| 10322 | } else if (direction === SWIPE_DIRECTION.RIGHT) { |
| 10323 | //If swiping right. |
| 10324 | // make new slot in array |
| 10325 | if (forceSwipeId == null) newSwipeId++; |
| 10326 | |
| 10327 | //Minimum of zero. |
| 10328 | if (newSwipeId < 0) { |
| 10329 | toastr.warning(`The swipe_id for message #${mesId} was ${newSwipeId}. It has been reset to zero.`); |
| 10330 | chat[mesId].swipe_id = 0; |
| 10331 | await endSwipe(); |
| 10332 | return; |
| 10333 | } |
| 10334 | |
| 10335 | //If overswiping. |
| 10336 | if (newSwipeId >= chat[mesId].swipes.length) { |
| 10337 | newSwipeId = chat[mesId].swipes.length; |
| 10338 | |
| 10339 | //Update the swipe_id. |
| 10340 | chat[mesId].swipe_id = newSwipeId; |
| 10341 | |
| 10342 | const overswipe = getOverswipeBehavior(mesId); |
| 10343 | |
| 10344 | //Cancel the generation. |
| 10345 | if (overswipe == OVERSWIPE_BEHAVIOR.NONE) { |
| 10346 | //Cancel swipe. |
| 10347 | chat[mesId].swipe_id = originalSwipeId; |
| 10348 | await endSwipe(); |
| 10349 | return; |
| 10350 | } else if (overswipe == OVERSWIPE_BEHAVIOR.REGENERATE) { |
| 10351 | //Regenerate the message |
| 10352 | clearMessageData(chat[mesId]); |
| 10353 | let run_generate = true; |
| 10354 | //Generate. |
| 10355 | await animateSwipe(run_generate); |
| 10356 | await endSwipe(); |
| 10357 | return; |
| 10358 | } else if (overswipe == OVERSWIPE_BEHAVIOR.LOOP || overswipe == OVERSWIPE_BEHAVIOR.PRISTINE_GREETING) { |
| 10359 | // Loop to the first swipe. |
| 10360 | newSwipeId = 0; |
| 10361 | } |
| 10362 | } |
| 10363 | await standardSwipe(newSwipeId); |
| 10364 | return; |
| 10365 | } |
| 10366 | } |
| 10367 | |
| 10368 | /** |
| 10369 | * @deprecated Use `swipe` instead. |
| 10370 | * Handles the swipe to the left event. |
| 10371 | * @param {SwipeEvent} [event] Event. |
| 10372 | * @param {object} params Additional parameters. |
| 10373 | * @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. |
| 10374 | * @param {boolean} [params.repeated] Is the swipe event repeated. |
| 10375 | * @param {object} [params.message] The chat message to swipe. |
| 10376 | */ |
| 10377 | export async function swipe_left(event, { source, repeated, message } = {}) { |
| 10378 | await swipe.call(this, event, SWIPE_DIRECTION.LEFT, { source: source, repeated: repeated, message: message }); |
| 10379 | } |
| 10380 | |
| 10381 | /** |
| 10382 | * @deprecated Use `swipe` instead. |
| 10383 | * Handles the swipe to the right event. |
| 10384 | * @param {SwipeEvent} [event] Event. |
| 10385 | * @param {object} params Additional parameters. |
| 10386 | * @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. |
| 10387 | * @param {boolean} [params.repeated] Is the swipe event repeated. |
| 10388 | * @param {object} [params.message] The chat message to swipe. |
| 10389 | */ |
| 10390 | //MARK: swipe_right |
| 10391 | export async function swipe_right(event = null, { source, repeated, message } = {}) { |
| 10392 | await swipe.call(this, event, SWIPE_DIRECTION.RIGHT, { source: source, repeated: repeated, message: message }); |
| 10393 | } |
| 10394 | |
| 10395 | /** |
| 10396 | * Imports supported files dropped into the app window. |
| 10397 | * @param {File[]} files Array of files to process |
| 10398 | * @param {Map<File, string>} [data] Extra data to pass to the import function |
| 10399 | * @returns {Promise<void>} |
| 10400 | */ |
| 10401 | export async function processDroppedFiles(files, data = new Map()) { |
| 10402 | const allowedMimeTypes = [ |
| 10403 | 'application/json', |
| 10404 | 'image/png', |
| 10405 | 'application/yaml', |
| 10406 | 'application/x-yaml', |
| 10407 | 'text/yaml', |
| 10408 | 'text/x-yaml', |
| 10409 | ]; |
| 10410 | |
| 10411 | const allowedExtensions = [ |
| 10412 | 'charx', |
| 10413 | 'byaf', |
| 10414 | ]; |
| 10415 | |
| 10416 | const avatarFileNames = []; |
| 10417 | for (const file of files) { |
| 10418 | const extension = file.name.split('.').pop().toLowerCase(); |
| 10419 | if (allowedMimeTypes.some(x => file.type.startsWith(x)) || allowedExtensions.includes(extension)) { |
| 10420 | const preservedName = data instanceof Map && data.get(file); |
| 10421 | const avatarFileName = await importCharacter(file, { preserveFileName: preservedName }); |
| 10422 | if (avatarFileName !== undefined) { |
| 10423 | avatarFileNames.push(avatarFileName); |
| 10424 | } |
| 10425 | } else { |
| 10426 | toastr.warning(t`Unsupported file type: ` + file.name); |
| 10427 | } |
| 10428 | } |
| 10429 | |
| 10430 | if (avatarFileNames.length > 0) { |
| 10431 | await importCharactersTags(avatarFileNames); |
| 10432 | selectImportedChar(avatarFileNames[avatarFileNames.length - 1]); |
| 10433 | } |
| 10434 | } |
| 10435 | |
| 10436 | /** |
| 10437 | * Imports tags for the given characters |
| 10438 | * @param {string[]} avatarFileNames character avatar filenames whose tags are to import |
| 10439 | */ |
| 10440 | async function importCharactersTags(avatarFileNames) { |
| 10441 | await getCharacters(); |
| 10442 | for (let i = 0; i < avatarFileNames.length; i++) { |
| 10443 | if (power_user.tag_import_setting !== tag_import_setting.NONE) { |
| 10444 | const importedCharacter = characters.find(character => character.avatar === avatarFileNames[i]); |
| 10445 | await importTags(importedCharacter); |
| 10446 | } |
| 10447 | } |
| 10448 | } |
| 10449 | |
| 10450 | /** |
| 10451 | * Selects the given imported char |
| 10452 | * @param {string} charId char to select |
| 10453 | */ |
| 10454 | function selectImportedChar(charId) { |
| 10455 | let oldSelectedChar = null; |
| 10456 | if (this_chid !== undefined) { |
| 10457 | oldSelectedChar = characters[this_chid].avatar; |
| 10458 | } |
| 10459 | select_rm_info('char_import_no_toast', charId, oldSelectedChar); |
| 10460 | } |
| 10461 | |
| 10462 | /** |
| 10463 | * Imports a character from a file. |
| 10464 | * @param {File} file File to import |
| 10465 | * @param {object} [options] - Options |
| 10466 | * @param {string} [options.preserveFileName] Whether to preserve original file name |
| 10467 | * @param {Boolean} [options.importTags=false] Whether to import tags |
| 10468 | * @returns {Promise<string>} |
| 10469 | */ |
| 10470 | async function importCharacter(file, { preserveFileName = '', importTags = false } = {}) { |
| 10471 | if (is_group_generating || is_send_press) { |
| 10472 | toastr.error(t`Cannot import characters while generating. Stop the request and try again.`, t`Import aborted`); |
| 10473 | throw new Error('Cannot import character while generating'); |
| 10474 | } |
| 10475 | |
| 10476 | const ext = file.name.match(/\.(\w+)$/); |
| 10477 | if (!ext || !(['json', 'png', 'yaml', 'yml', 'charx', 'byaf'].includes(ext[1].toLowerCase()))) { |
| 10478 | return; |
| 10479 | } |
| 10480 | |
| 10481 | const exists = preserveFileName ? characters.find(character => character.avatar === preserveFileName) : undefined; |
| 10482 | |
| 10483 | const format = ext[1].toLowerCase(); |
| 10484 | $('#character_import_file_type').val(format); |
| 10485 | const formData = new FormData(); |
| 10486 | formData.append('avatar', file); |
| 10487 | formData.append('file_type', format); |
| 10488 | formData.append('user_name', name1); |
| 10489 | if (preserveFileName) formData.append('preserved_name', preserveFileName); |
| 10490 | |
| 10491 | try { |
| 10492 | const result = await fetch('/api/characters/import', { |
| 10493 | method: 'POST', |
| 10494 | body: formData, |
| 10495 | headers: getRequestHeaders({ omitContentType: true }), |
| 10496 | cache: 'no-cache', |
| 10497 | }); |
| 10498 | |
| 10499 | if (!result.ok) { |
| 10500 | throw new Error(`Failed to import character: ${result.statusText}`); |
| 10501 | } |
| 10502 | |
| 10503 | const data = await result.json(); |
| 10504 | |
| 10505 | if (data.error) { |
| 10506 | throw new Error(`Server returned an error: ${data.error}`); |
| 10507 | } |
| 10508 | |
| 10509 | if (data.file_name !== undefined) { |
| 10510 | let avatarFileName = `${data.file_name}.png`; |
| 10511 | |
| 10512 | // Refresh existing thumbnail |
| 10513 | if (exists && this_chid !== undefined) { |
| 10514 | await fetch(getThumbnailUrl('avatar', avatarFileName), { cache: 'reload' }); |
| 10515 | } |
| 10516 | |
| 10517 | $('#character_search_bar').val('').trigger('input'); |
| 10518 | |
| 10519 | if (exists) { |
| 10520 | toastr.success(t`Character Replaced: ${String(data.file_name).replace('.png', '')}`); |
| 10521 | } else { |
| 10522 | toastr.success(t`Character Created: ${String(data.file_name).replace('.png', '')}`); |
| 10523 | } |
| 10524 | if (importTags) { |
| 10525 | await importCharactersTags([avatarFileName]); |
| 10526 | selectImportedChar(data.file_name); |
| 10527 | } |
| 10528 | return avatarFileName; |
| 10529 | } |
| 10530 | } catch (error) { |
| 10531 | console.error('Error importing character', error); |
| 10532 | toastr.error(t`The file is likely invalid or corrupted.`, t`Could not import character`); |
| 10533 | } |
| 10534 | } |
| 10535 | |
| 10536 | async function importFromURL(items, files) { |
| 10537 | for (const item of items) { |
| 10538 | if (item.type === 'text/uri-list') { |
| 10539 | const uriList = await new Promise((resolve) => { |
| 10540 | item.getAsString((uriList) => { resolve(uriList); }); |
| 10541 | }); |
| 10542 | const uris = uriList.split('\n').filter(uri => uri.trim() !== ''); |
| 10543 | try { |
| 10544 | for (const uri of uris) { |
| 10545 | const request = await fetch(uri); |
| 10546 | const data = await request.blob(); |
| 10547 | const fileName = request.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || uri.split('/').pop() || 'file.png'; |
| 10548 | const file = new File([data], fileName, { type: data.type }); |
| 10549 | files.push(file); |
| 10550 | } |
| 10551 | } catch (error) { |
| 10552 | console.error('Failed to import from URL', error); |
| 10553 | } |
| 10554 | } |
| 10555 | } |
| 10556 | } |
| 10557 | |
| 10558 | export async function doNewChat({ deleteCurrentChat = false } = {}) { |
| 10559 | //Make a new chat for selected character |
| 10560 | if ((!selected_group && this_chid == undefined) || menu_type == 'create') { |
| 10561 | return; |
| 10562 | } |
| 10563 | |
| 10564 | //Fix it; New chat doesn't create while open create character menu |
| 10565 | await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10); |
| 10566 | await clearChat({ clearData: true }); |
| 10567 | |
| 10568 | chat_file_for_del = getCurrentChatDetails()?.sessionName; |
| 10569 | |
| 10570 | // Make it easier to find in backups |
| 10571 | if (deleteCurrentChat) { |
| 10572 | await saveChatConditional(); |
| 10573 | } |
| 10574 | |
| 10575 | if (selected_group) { |
| 10576 | await createNewGroupChat(selected_group); |
| 10577 | if (deleteCurrentChat) await deleteGroupChat(selected_group, chat_file_for_del, { jumpToNewChat: false }); // don't jump, new chat was already created and jumped to above |
| 10578 | } else { |
| 10579 | //RossAscends: added character name to new chat filenames and replaced Date.now() with humanizedDateTime; |
| 10580 | chat_metadata = {}; |
| 10581 | characters[this_chid].chat = `${name2} - ${humanizedDateTime()}`; |
| 10582 | $('#selected_chat_pole').val(characters[this_chid].chat); |
| 10583 | await getChat(); |
| 10584 | await createOrEditCharacter(new CustomEvent('newChat')); |
| 10585 | if (deleteCurrentChat) await delChat(chat_file_for_del + '.jsonl'); |
| 10586 | } |
| 10587 | } |
| 10588 | |
| 10589 | /** |
| 10590 | * Renames a group or character chat. |
| 10591 | * @param {object} param Parameters for renaming chat |
| 10592 | * @param {string} [param.characterId] Character ID to rename chat for |
| 10593 | * @param {string} [param.groupId] Group ID to rename chat for |
| 10594 | * @param {string} param.oldFileName Old name of the chat (no JSONL extension) |
| 10595 | * @param {string} param.newFileName New name for the chat (no JSONL extension) |
| 10596 | * @param {boolean} [param.loader=true] Whether to show loader during the operation |
| 10597 | */ |
| 10598 | export async function renameGroupOrCharacterChat({ characterId, groupId, oldFileName, newFileName, loader: showLoader }) { |
| 10599 | const currentChatId = getCurrentChatId(); |
| 10600 | const body = { |
| 10601 | is_group: !!groupId, |
| 10602 | avatar_url: characters[characterId]?.avatar, |
| 10603 | original_file: `${oldFileName}.jsonl`, |
| 10604 | renamed_file: `${newFileName.trim()}.jsonl`, |
| 10605 | }; |
| 10606 | |
| 10607 | if (body.original_file === body.renamed_file) { |
| 10608 | console.debug('Chat rename cancelled, old and new names are the same'); |
| 10609 | return; |
| 10610 | } |
| 10611 | if (equalsIgnoreCaseAndAccents(body.original_file, body.renamed_file)) { |
| 10612 | toastr.warning(t`Name not accepted, as it is the same as before (ignoring case and accents).`, t`Rename Chat`); |
| 10613 | return; |
| 10614 | } |
| 10615 | |
| 10616 | const loaderHandle = showLoader ? loader.show({ |
| 10617 | slug: 'chat-rename', |
| 10618 | title: t`Rename Chat`, |
| 10619 | message: t`Renaming chat…`, |
| 10620 | toastMode: loader.ToastMode.STATIC, |
| 10621 | }) : null; |
| 10622 | |
| 10623 | try { |
| 10624 | const response = await fetch('/api/chats/rename', { |
| 10625 | method: 'POST', |
| 10626 | body: JSON.stringify(body), |
| 10627 | headers: getRequestHeaders(), |
| 10628 | }); |
| 10629 | |
| 10630 | if (!response.ok) { |
| 10631 | throw new Error('Unsuccessful request.'); |
| 10632 | } |
| 10633 | |
| 10634 | const data = await response.json(); |
| 10635 | |
| 10636 | if (data.error) { |
| 10637 | throw new Error('Server returned an error.'); |
| 10638 | } |
| 10639 | |
| 10640 | if (data.sanitizedFileName) { |
| 10641 | newFileName = data.sanitizedFileName; |
| 10642 | } |
| 10643 | |
| 10644 | if (groupId) { |
| 10645 | await renameGroupChat(groupId, oldFileName, newFileName); |
| 10646 | } else if (characterId !== undefined && String(characterId) === String(this_chid) && characters[characterId]?.chat === oldFileName) { |
| 10647 | characters[characterId].chat = newFileName; |
| 10648 | $('#selected_chat_pole').val(characters[characterId].chat); |
| 10649 | await createOrEditCharacter(); |
| 10650 | } |
| 10651 | |
| 10652 | if (currentChatId) { |
| 10653 | await reloadCurrentChat(); |
| 10654 | } |
| 10655 | |
| 10656 | const eventData = { avatarId: body.avatar_url, groupId, oldFileName: body.original_file, newFileName: body.renamed_file }; |
| 10657 | await eventSource.emit(event_types.CHAT_RENAMED, eventData); |
| 10658 | } catch { |
| 10659 | await delay(500); |
| 10660 | await callGenericPopup('An error has occurred. Chat was not renamed.', POPUP_TYPE.TEXT); |
| 10661 | } finally { |
| 10662 | await loaderHandle?.hide(); |
| 10663 | } |
| 10664 | } |
| 10665 | |
| 10666 | /** |
| 10667 | * Renames the currently selected chat. |
| 10668 | * @param {string} oldFileName Old name of the chat (no JSONL extension) |
| 10669 | * @param {string} newName New name for the chat (no JSONL extension) |
| 10670 | */ |
| 10671 | export async function renameChat(oldFileName, newName) { |
| 10672 | return await renameGroupOrCharacterChat({ |
| 10673 | characterId: this_chid, |
| 10674 | groupId: selected_group, |
| 10675 | oldFileName: oldFileName, |
| 10676 | newFileName: newName, |
| 10677 | loader: true, |
| 10678 | }); |
| 10679 | } |
| 10680 | |
| 10681 | /** |
| 10682 | * Closes the current chat, clearing all associated data and resetting the UI. |
| 10683 | * If a message generation is in progress, it prompts the user to stop it first. |
| 10684 | * @returns {Promise<boolean>} True if the chat was successfully closed, false otherwise. |
| 10685 | */ |
| 10686 | export async function closeCurrentChat() { |
| 10687 | if (is_send_press == false) { |
| 10688 | await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10); |
| 10689 | await clearChat({ clearData: true }); |
| 10690 | resetSelectedGroup(); |
| 10691 | setCharacterId(undefined); |
| 10692 | setCharacterName(''); |
| 10693 | setActiveCharacter(null); |
| 10694 | setActiveGroup(null); |
| 10695 | this_edit_mes_id = undefined; |
| 10696 | chat_metadata = {}; |
| 10697 | selected_button = 'characters'; |
| 10698 | $('#rm_button_selected_ch').children('h2').text(''); |
| 10699 | select_rm_characters(); |
| 10700 | await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId()); |
| 10701 | return true; |
| 10702 | } else { |
| 10703 | toastr.info(t`Please stop the message generation first.`); |
| 10704 | return false; |
| 10705 | } |
| 10706 | } |
| 10707 | |
| 10708 | /** |
| 10709 | * Forces the update of the chat name for a remote character. |
| 10710 | * @param {string|number} characterId Character ID to update chat name for |
| 10711 | * @param {string} newName New name for the chat |
| 10712 | * @returns {Promise<void>} |
| 10713 | */ |
| 10714 | export async function updateRemoteChatName(characterId, newName) { |
| 10715 | const character = characters[characterId]; |
| 10716 | if (!character) { |
| 10717 | console.warn(`Character not found for ID: ${characterId}`); |
| 10718 | return; |
| 10719 | } |
| 10720 | character.chat = newName; |
| 10721 | const mergeRequest = { |
| 10722 | avatar: character.avatar, |
| 10723 | chat: newName, |
| 10724 | }; |
| 10725 | const mergeResponse = await fetch('/api/characters/merge-attributes', { |
| 10726 | method: 'POST', |
| 10727 | headers: getRequestHeaders(), |
| 10728 | body: JSON.stringify(mergeRequest), |
| 10729 | }); |
| 10730 | if (!mergeResponse.ok) { |
| 10731 | console.error('Failed to save extension field', mergeResponse.statusText); |
| 10732 | } |
| 10733 | } |
| 10734 | |
| 10735 | |
| 10736 | function doCharListDisplaySwitch() { |
| 10737 | power_user.charListGrid = !power_user.charListGrid; |
| 10738 | document.body.classList.toggle('charListGrid', power_user.charListGrid); |
| 10739 | saveSettingsDebounced(); |
| 10740 | } |
| 10741 | |
| 10742 | /** |
| 10743 | * Function to handle the deletion of a character, given a specific popup type and character ID. |
| 10744 | * If popup type equals "del_ch", it will proceed with deletion otherwise it will exit the function. |
| 10745 | * It fetches the delete character route, sending necessary parameters, and in case of success, |
| 10746 | * it proceeds to delete character from UI and saves settings. |
| 10747 | * In case of error during the fetch request, it logs the error details. |
| 10748 | * |
| 10749 | * @param {string} this_chid - The character ID to be deleted. |
| 10750 | * @param {boolean} delete_chats - Whether to delete chats or not. |
| 10751 | */ |
| 10752 | export async function handleDeleteCharacter(this_chid, delete_chats) { |
| 10753 | if (!characters[this_chid]) { |
| 10754 | return; |
| 10755 | } |
| 10756 | |
| 10757 | await deleteCharacter(characters[this_chid].avatar, { deleteChats: delete_chats }); |
| 10758 | } |
| 10759 | |
| 10760 | /** |
| 10761 | * Deletes a character completely, including associated chats if specified |
| 10762 | * |
| 10763 | * @param {string|string[]} characterKey - The key (avatar) of the character to be deleted |
| 10764 | * @param {Object} [options] - Optional parameters for the deletion |
| 10765 | * @param {boolean} [options.deleteChats=true] - Whether to delete associated chats or not |
| 10766 | * @return {Promise<boolean>} - A promise that resolves when the character is successfully deleted |
| 10767 | */ |
| 10768 | export async function deleteCharacter(characterKey, { deleteChats = true } = {}) { |
| 10769 | if (!Array.isArray(characterKey)) { |
| 10770 | characterKey = [characterKey]; |
| 10771 | } |
| 10772 | |
| 10773 | const inTempChat = this_chid === undefined && name2 === neutralCharacterName; |
| 10774 | if (inTempChat) { |
| 10775 | const confirmClose = await Popup.show.confirm( |
| 10776 | t`You are currently in a temporary chat.`, |
| 10777 | t`Deleting this character will close the chat and you will lose any unsaved messages. Do you want to proceed?`, |
| 10778 | ); |
| 10779 | if (!confirmClose) { |
| 10780 | return false; |
| 10781 | } |
| 10782 | } |
| 10783 | |
| 10784 | const closeChatResult = await closeCurrentChat(); |
| 10785 | if (!closeChatResult) { |
| 10786 | return false; |
| 10787 | } |
| 10788 | |
| 10789 | let deleted = false; |
| 10790 | |
| 10791 | for (const key of characterKey) { |
| 10792 | const character = characters.find(x => x.avatar == key); |
| 10793 | if (!character) { |
| 10794 | toastr.warning(t`Character ${key} not found. Skipping deletion.`); |
| 10795 | continue; |
| 10796 | } |
| 10797 | |
| 10798 | const chid = characters.indexOf(character); |
| 10799 | const pastChats = await getPastCharacterChats(chid); |
| 10800 | |
| 10801 | const msg = { avatar_url: character.avatar, delete_chats: deleteChats }; |
| 10802 | |
| 10803 | const response = await fetch('/api/characters/delete', { |
| 10804 | method: 'POST', |
| 10805 | headers: getRequestHeaders(), |
| 10806 | body: JSON.stringify(msg), |
| 10807 | cache: 'no-cache', |
| 10808 | }); |
| 10809 | |
| 10810 | if (!response.ok) { |
| 10811 | toastr.error(`${response.status} ${response.statusText}`, t`Failed to delete character`); |
| 10812 | continue; |
| 10813 | } |
| 10814 | |
| 10815 | accountStorage.removeItem(`AlertWI_${character.avatar}`); |
| 10816 | accountStorage.removeItem(`AlertRegex_${character.avatar}`); |
| 10817 | accountStorage.removeItem(`mediaWarningShown:${character.avatar}`); |
| 10818 | delete tag_map[character.avatar]; |
| 10819 | select_rm_info('char_delete', character.name); |
| 10820 | |
| 10821 | if (deleteChats) { |
| 10822 | for (const chat of pastChats) { |
| 10823 | const name = chat.file_name.replace('.jsonl', ''); |
| 10824 | await eventSource.emit(event_types.CHAT_DELETED, name); |
| 10825 | } |
| 10826 | } |
| 10827 | |
| 10828 | await eventSource.emit(event_types.CHARACTER_DELETED, { id: chid, character: character }); |
| 10829 | deleted = true; |
| 10830 | } |
| 10831 | |
| 10832 | await removeCharacterFromUI(); |
| 10833 | return deleted; |
| 10834 | } |
| 10835 | |
| 10836 | /** |
| 10837 | * Function to delete a character from UI after character deletion API success. |
| 10838 | * It manages necessary UI changes such as closing advanced editing popup, unsetting |
| 10839 | * character ID, resetting characters array and chat metadata, deselecting character's tab |
| 10840 | * panel, removing character name from navigation tabs, clearing chat, fetching updated list of characters. |
| 10841 | * It also ensures to save the settings after all the operations. |
| 10842 | */ |
| 10843 | async function removeCharacterFromUI() { |
| 10844 | preserveNeutralChat(); |
| 10845 | await clearChat(); |
| 10846 | $('#character_cross').trigger('click'); |
| 10847 | resetChatState(); |
| 10848 | $(document.getElementById('rm_button_selected_ch')).children('h2').text(''); |
| 10849 | restoreNeutralChat(); |
| 10850 | await getCharacters(); |
| 10851 | await printMessages(); |
| 10852 | saveSettingsDebounced(); |
| 10853 | await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId()); |
| 10854 | } |
| 10855 | |
| 10856 | /** |
| 10857 | * Creates a new assistant chat. |
| 10858 | * @param {object} params - Parameters for the new assistant chat |
| 10859 | * @param {boolean} [params.temporary=false] I need a temporary secretary |
| 10860 | * @returns {Promise<void>} - A promise that resolves when the new assistant chat is created |
| 10861 | */ |
| 10862 | export async function newAssistantChat({ temporary = false } = {}) { |
| 10863 | await clearChat(); |
| 10864 | if (!temporary) { |
| 10865 | return openPermanentAssistantChat(); |
| 10866 | } |
| 10867 | chat.splice(0, chat.length); |
| 10868 | chat_metadata = {}; |
| 10869 | setCharacterName(neutralCharacterName); |
| 10870 | sendSystemMessage(system_message_types.ASSISTANT_NOTE); |
| 10871 | } |
| 10872 | |
| 10873 | /** |
| 10874 | * Event handler to open a navbar drawer when a drawer open button is clicked. |
| 10875 | * Handles click events on .drawer-opener elements. |
| 10876 | * Opens the drawer associated with the clicked button according to the data-target attribute. |
| 10877 | * @returns {void} |
| 10878 | */ |
| 10879 | function doDrawerOpenClick() { |
| 10880 | const targetDrawerID = $(this).attr('data-target'); |
| 10881 | const drawer = $(`#${targetDrawerID}`); |
| 10882 | const drawerToggle = drawer.find('.drawer-toggle'); |
| 10883 | const drawerWasOpenAlready = drawerToggle.parent().find('.drawer-content').hasClass('openDrawer'); |
| 10884 | if (drawerWasOpenAlready || drawer.hasClass('resizing')) { return; } |
| 10885 | doNavbarIconClick.call(drawerToggle); |
| 10886 | } |
| 10887 | |
| 10888 | /** |
| 10889 | * Event handler to open or close a navbar drawer when a navbar icon is clicked. |
| 10890 | * Handles click events on .drawer-toggle elements. |
| 10891 | * @returns {Promise<void>} |
| 10892 | */ |
| 10893 | export async function doNavbarIconClick() { |
| 10894 | const icon = $(this).find('.drawer-icon'); |
| 10895 | const drawer = $(this).parent().find('.drawer-content'); |
| 10896 | const drawerWasOpenAlready = $(this).parent().find('.drawer-content').hasClass('openDrawer'); |
| 10897 | const targetDrawerID = $(this).parent().find('.drawer-content').attr('id'); |
| 10898 | |
| 10899 | if (!drawerWasOpenAlready) { |
| 10900 | const $openDrawers = $('.openDrawer:not(.pinnedOpen)'); |
| 10901 | const $openIcons = $('.openIcon:not(.drawerPinnedOpen)'); |
| 10902 | for (const iconEl of $openIcons) { |
| 10903 | $(iconEl).toggleClass('closedIcon openIcon'); |
| 10904 | } |
| 10905 | for (const el of $openDrawers) { |
| 10906 | $(el).toggleClass('closedDrawer openDrawer'); |
| 10907 | } |
| 10908 | if ($openDrawers.length && animation_duration) { |
| 10909 | await delay(animation_duration); |
| 10910 | } |
| 10911 | icon.toggleClass('openIcon closedIcon'); |
| 10912 | drawer.toggleClass('openDrawer closedDrawer'); |
| 10913 | |
| 10914 | if (targetDrawerID === 'right-nav-panel') { |
| 10915 | favsToHotswap(); |
| 10916 | $('#rm_print_characters_block').trigger('scroll'); |
| 10917 | } |
| 10918 | |
| 10919 | // Set the height of "autoSetHeight" textareas within the drawer to their scroll height |
| 10920 | if (!CSS.supports('field-sizing', 'content')) { |
| 10921 | const textareas = $(this).closest('.drawer').find('.drawer-content textarea.autoSetHeight'); |
| 10922 | for (const textarea of textareas) { |
| 10923 | await resetScrollHeight($(textarea)); |
| 10924 | } |
| 10925 | } |
| 10926 | } else if (drawerWasOpenAlready) { |
| 10927 | icon.toggleClass('closedIcon openIcon'); |
| 10928 | drawer.toggleClass('closedDrawer openDrawer'); |
| 10929 | } |
| 10930 | } |
| 10931 | |
| 10932 | function addDebugFunctions() { |
| 10933 | const doBackfill = async () => { |
| 10934 | for (const message of chat) { |
| 10935 | // System messages are not counted |
| 10936 | if (message.is_system) { |
| 10937 | continue; |
| 10938 | } |
| 10939 | |
| 10940 | if (!message.extra) { |
| 10941 | message.extra = {}; |
| 10942 | } |
| 10943 | |
| 10944 | const tokenCountText = (message?.extra?.reasoning || '') + message.mes; |
| 10945 | message.extra.token_count = await getTokenCountAsync(tokenCountText, 0); |
| 10946 | } |
| 10947 | |
| 10948 | await saveChatConditional(); |
| 10949 | await reloadCurrentChat(); |
| 10950 | }; |
| 10951 | |
| 10952 | registerDebugFunction('forceOnboarding', 'Force onboarding', 'Forces the onboarding process to restart.', async () => { |
| 10953 | firstRun = true; |
| 10954 | await saveSettings(); |
| 10955 | location.reload(); |
| 10956 | }); |
| 10957 | |
| 10958 | registerDebugFunction('backfillTokenCounts', 'Backfill token counters', |
| 10959 | `Recalculates token counts of all messages in the current chat to refresh the counters. |
| 10960 | Useful when you switch between models that have different tokenizers. |
| 10961 | This is a visual change only. Your chat will be reloaded.`, doBackfill); |
| 10962 | |
| 10963 | registerDebugFunction('generationTest', 'Send a generation request', 'Generates text using the currently selected API.', async () => { |
| 10964 | const text = prompt('Input text:', 'Hello'); |
| 10965 | toastr.info('Working on it...'); |
| 10966 | const message = await generateRaw({ prompt: text }); |
| 10967 | alert(message); |
| 10968 | }); |
| 10969 | registerDebugFunction('toggleEventTracing', 'Toggle event tracing', 'Useful to see what triggered a certain event.', () => { |
| 10970 | localStorage.setItem('eventTracing', localStorage.getItem('eventTracing') === 'true' ? 'false' : 'true'); |
| 10971 | toastr.info('Event tracing is now ' + (localStorage.getItem('eventTracing') === 'true' ? 'enabled' : 'disabled')); |
| 10972 | }); |
| 10973 | |
| 10974 | registerDebugFunction('toggleRegenerateWarning', 'Toggle Ctrl+Enter regeneration confirmation', 'Toggle the warning when regenerating a message with a Ctrl+Enter hotkey.', () => { |
| 10975 | accountStorage.setItem('RegenerateWithCtrlEnter', accountStorage.getItem('RegenerateWithCtrlEnter') === 'true' ? 'false' : 'true'); |
| 10976 | toastr.info('Regenerate warning is now ' + (accountStorage.getItem('RegenerateWithCtrlEnter') === 'true' ? 'disabled' : 'enabled')); |
| 10977 | }); |
| 10978 | |
| 10979 | registerDebugFunction('copySetup', 'Copy ST setup to clipboard [WIP]', 'Useful data when reporting bugs', async () => { |
| 10980 | const getContextContents = getContext(); |
| 10981 | const getSettingsContents = settings; |
| 10982 | //console.log(getSettingsContents); |
| 10983 | const logMessage = ` |
| 10984 | \`\`\` |
| 10985 | API: ${getSettingsContents.main_api} |
| 10986 | API Type: ${getSettingsContents[getSettingsContents.main_api + '_settings'].type} |
| 10987 | API server: ${getSettingsContents.api_server} |
| 10988 | Model: ${getContextContents.onlineStatus} |
| 10989 | Context Template: ${power_user.context.preset} |
| 10990 | Instruct Template: ${power_user.instruct.preset} |
| 10991 | API Settings: ${JSON.stringify(getSettingsContents[getSettingsContents.main_api + '_settings'], null, 2)} |
| 10992 | \`\`\` |
| 10993 | `; |
| 10994 | |
| 10995 | //console.log(getSettingsContents) |
| 10996 | //console.log(logMessage); |
| 10997 | |
| 10998 | try { |
| 10999 | await copyText(logMessage); |
| 11000 | toastr.info('Your ST API setup data has been copied to the clipboard.'); |
| 11001 | } catch (error) { |
| 11002 | toastr.error('Failed to copy ST Setup to clipboard:', error); |
| 11003 | } |
| 11004 | }); |
| 11005 | } |
| 11006 | |
| 11007 | function initCharacterSearch() { |
| 11008 | const debouncedCharacterSearch = debounce((searchQuery) => { |
| 11009 | entitiesFilter.setFilterData(FILTER_TYPES.SEARCH, searchQuery); |
| 11010 | }); |
| 11011 | |
| 11012 | const searchForm = $('#form_character_search_form'); |
| 11013 | const searchInput = $('#character_search_bar'); |
| 11014 | const searchButton = $('#rm_button_search'); |
| 11015 | |
| 11016 | const storageKey = 'characterSearchFormVisible'; |
| 11017 | |
| 11018 | searchInput.on('input', function () { |
| 11019 | const searchQuery = String($(this).val()); |
| 11020 | debouncedCharacterSearch(searchQuery); |
| 11021 | }); |
| 11022 | |
| 11023 | searchButton.on('click', function () { |
| 11024 | const newVisibility = !searchForm.is(':visible'); |
| 11025 | searchForm.toggle(newVisibility); |
| 11026 | searchButton.toggleClass('active', newVisibility); |
| 11027 | accountStorage.setItem(storageKey, String(newVisibility)); |
| 11028 | if (newVisibility) { |
| 11029 | searchInput.trigger('focus'); |
| 11030 | } |
| 11031 | }); |
| 11032 | |
| 11033 | eventSource.on(event_types.APP_READY, () => { |
| 11034 | const isVisible = accountStorage.getItem(storageKey) === 'true'; |
| 11035 | searchForm.toggle(isVisible); |
| 11036 | searchButton.toggleClass('active', isVisible); |
| 11037 | }); |
| 11038 | } |
| 11039 | |
| 11040 | // MARK: DOM Handlers Start |
| 11041 | jQuery(async function () { |
| 11042 | setTimeout(function () { |
| 11043 | $('#groupControlsToggle').trigger('click'); |
| 11044 | $('#groupCurrentMemberListToggle .inline-drawer-icon').trigger('click'); |
| 11045 | }, 200); |
| 11046 | |
| 11047 | $(document).on('click', '.api_loading', () => cancelStatusCheck('Canceled because connecting was manually canceled')); |
| 11048 | |
| 11049 | //////////INPUT BAR FOCUS-KEEPING LOGIC///////////// |
| 11050 | let S_TAPreviouslyFocused = false; |
| 11051 | $('#send_textarea').on('focusin focus click', () => { |
| 11052 | S_TAPreviouslyFocused = true; |
| 11053 | }); |
| 11054 | $('#send_but, #option_regenerate, #option_continue, #mes_continue, #mes_impersonate').on('click', () => { |
| 11055 | if (S_TAPreviouslyFocused) { |
| 11056 | $('#send_textarea').trigger('focus'); |
| 11057 | } |
| 11058 | }); |
| 11059 | $(document).on('click', event => { |
| 11060 | if ($(':focus').attr('id') !== 'send_textarea') { |
| 11061 | var validIDs = ['options_button', 'send_but', 'mes_impersonate', 'mes_continue', 'send_textarea', 'option_regenerate', 'option_continue']; |
| 11062 | if (!validIDs.includes($(event.target).attr('id'))) { |
| 11063 | S_TAPreviouslyFocused = false; |
| 11064 | } |
| 11065 | } else { |
| 11066 | S_TAPreviouslyFocused = true; |
| 11067 | } |
| 11068 | }); |
| 11069 | |
| 11070 | ///////////////// |
| 11071 | |
| 11072 | $('#swipes-checkbox').on('change', function () { |
| 11073 | swipes = !!$('#swipes-checkbox').prop('checked'); |
| 11074 | if (swipes) { |
| 11075 | //console.log('toggle change calling showswipebtns'); |
| 11076 | showSwipeButtons(); |
| 11077 | } else { |
| 11078 | hideSwipeButtons(); |
| 11079 | } |
| 11080 | saveSettingsDebounced(); |
| 11081 | }); |
| 11082 | |
| 11083 | ///// SWIPE BUTTON CLICKS /////// |
| 11084 | |
| 11085 | //limit swiping to only last message clicks |
| 11086 | $(document).on('click', '.last_mes .swipe_right', async (e, data) => await swipe(e, SWIPE_DIRECTION.RIGHT, data)); |
| 11087 | $(document).on('click', '.last_mes .swipe_left', async (e, data) => await swipe(e, SWIPE_DIRECTION.LEFT, data)); |
| 11088 | |
| 11089 | initCharacterSearch(); |
| 11090 | |
| 11091 | $('#mes_impersonate').on('click', function () { |
| 11092 | $('#option_impersonate').trigger('click'); |
| 11093 | }); |
| 11094 | |
| 11095 | $('#mes_continue').on('click', function () { |
| 11096 | $('#option_continue').trigger('click'); |
| 11097 | }); |
| 11098 | |
| 11099 | const userInputGenerateMutex = new SimpleMutex(sendTextareaMessage); |
| 11100 | $('#send_but').on('click', async function () { |
| 11101 | await userInputGenerateMutex.update(); |
| 11102 | }); |
| 11103 | |
| 11104 | //menu buttons setup |
| 11105 | |
| 11106 | $('#rm_button_settings').on('click', function () { |
| 11107 | selected_button = 'settings'; |
| 11108 | selectRightMenuWithAnimation('rm_api_block'); |
| 11109 | }); |
| 11110 | $('#rm_button_characters').on('click', function () { |
| 11111 | selected_button = 'characters'; |
| 11112 | select_rm_characters(); |
| 11113 | }); |
| 11114 | $('#rm_button_back').on('click', function () { |
| 11115 | selected_button = 'characters'; |
| 11116 | select_rm_characters(); |
| 11117 | }); |
| 11118 | $('#rm_button_create').on('click', function () { |
| 11119 | selected_button = 'create'; |
| 11120 | select_rm_create(); |
| 11121 | }); |
| 11122 | $('#rm_button_selected_ch').on('click', function () { |
| 11123 | if (selected_group) { |
| 11124 | select_group_chats(selected_group, false); |
| 11125 | } else { |
| 11126 | selected_button = 'character_edit'; |
| 11127 | select_selected_character(this_chid); |
| 11128 | } |
| 11129 | $('#character_search_bar').val('').trigger('input'); |
| 11130 | }); |
| 11131 | |
| 11132 | $(document).on('click', '.character_select', async function () { |
| 11133 | const id = Number($(this).attr('data-chid')); |
| 11134 | await selectCharacterById(id); |
| 11135 | }); |
| 11136 | |
| 11137 | $(document).on('click', '.bogus_folder_select', function () { |
| 11138 | const tagId = $(this).attr('tagid'); |
| 11139 | console.debug('Bogus folder clicked', tagId); |
| 11140 | chooseBogusFolder($(this), tagId); |
| 11141 | }); |
| 11142 | |
| 11143 | const cssAutofit = CSS.supports('field-sizing', 'content'); |
| 11144 | if (!cssAutofit) { |
| 11145 | /** |
| 11146 | * Sets the scroll height of the edit textarea to fit the content. |
| 11147 | * @param {HTMLTextAreaElement} e Textarea element to auto-fit |
| 11148 | */ |
| 11149 | function autoFitEditTextArea(e) { |
| 11150 | const scrollTop = chatElement.scrollTop(); |
| 11151 | e.style.height = '0px'; |
| 11152 | const newHeight = e.scrollHeight + 4; |
| 11153 | e.style.height = `${newHeight}px`; |
| 11154 | chatElement.scrollTop(scrollTop); |
| 11155 | } |
| 11156 | const autoFitEditTextAreaDebounced = debounce(autoFitEditTextArea, debounce_timeout.short); |
| 11157 | document.addEventListener('input', e => { |
| 11158 | if (e.target instanceof HTMLTextAreaElement && e.target.classList.contains('edit_textarea')) { |
| 11159 | const scrollbarShown = e.target.clientWidth < e.target.offsetWidth && e.target.offsetHeight >= window.innerHeight * 0.75; |
| 11160 | const immediately = (e.target.scrollHeight > e.target.offsetHeight && !scrollbarShown) || e.target.value === ''; |
| 11161 | immediately ? autoFitEditTextArea(e.target) : autoFitEditTextAreaDebounced(e.target); |
| 11162 | } |
| 11163 | }); |
| 11164 | } |
| 11165 | |
| 11166 | const chatElementScroll = document.getElementById('chat'); |
| 11167 | const chatScrollHandler = function () { |
| 11168 | if (power_user.waifuMode) { |
| 11169 | scrollLock = true; |
| 11170 | return; |
| 11171 | } |
| 11172 | |
| 11173 | const scrollIsAtBottom = Math.abs(chatElementScroll.scrollHeight - chatElementScroll.clientHeight - chatElementScroll.scrollTop) < 5; |
| 11174 | |
| 11175 | // Resume autoscroll if the user scrolls to the bottom |
| 11176 | if (scrollLock && scrollIsAtBottom) { |
| 11177 | scrollLock = false; |
| 11178 | } |
| 11179 | |
| 11180 | // Cancel autoscroll if the user scrolls up |
| 11181 | if (!scrollLock && !scrollIsAtBottom) { |
| 11182 | scrollLock = true; |
| 11183 | } |
| 11184 | }; |
| 11185 | chatElementScroll.addEventListener('scroll', chatScrollHandler, { passive: true }); |
| 11186 | |
| 11187 | $(document).on('click', '.mes', function () { |
| 11188 | //when a 'delete message' parent div is clicked |
| 11189 | // and we are in delete mode and del_checkbox is visible |
| 11190 | if (!is_delete_mode || !$(this).children('.del_checkbox').is(':visible')) { |
| 11191 | return; |
| 11192 | } |
| 11193 | $('.mes').children('.del_checkbox').each(function () { |
| 11194 | $(this).prop('checked', false); |
| 11195 | $(this).parent().removeClass('selected'); |
| 11196 | }); |
| 11197 | $(this).addClass('selected'); //sets the bg of the mes selected for deletion |
| 11198 | var i = Number($(this).attr('mesid')); //checks the message ID in the chat |
| 11199 | this_del_mes = i; |
| 11200 | //as long as the current message ID is less than the total chat length |
| 11201 | while (i < chat.length) { |
| 11202 | //sets the bg of the all msgs BELOW the selected .mes |
| 11203 | $(`.mes[mesid="${i}"]`).addClass('selected'); |
| 11204 | $(`.mes[mesid="${i}"]`).children('.del_checkbox').prop('checked', true); |
| 11205 | i++; |
| 11206 | } |
| 11207 | }); |
| 11208 | |
| 11209 | /** |
| 11210 | * Handles the deletion of a chat file, including group chats. |
| 11211 | * |
| 11212 | * @param {string} chatFile - The name of the chat file to delete. |
| 11213 | * @param {object} group - The group object if the chat is part of a group. |
| 11214 | * @param {boolean} [fromSlashCommand=false] - Whether the deletion was triggered from a slash command. |
| 11215 | * @returns {Promise<void>} |
| 11216 | */ |
| 11217 | async function handleDeleteChat(chatFile, group, fromSlashCommand = false) { |
| 11218 | // Close past chat popup. |
| 11219 | $('#select_chat_cross').trigger('click'); |
| 11220 | |
| 11221 | const loaderHandle = loader.show({ |
| 11222 | slug: 'chat-delete', |
| 11223 | title: t`Delete Chat`, |
| 11224 | message: t`Deleting chat…`, |
| 11225 | toastMode: loader.ToastMode.STATIC, |
| 11226 | }); |
| 11227 | |
| 11228 | try { |
| 11229 | if (group) { |
| 11230 | await deleteGroupChat(group, chatFile); |
| 11231 | } else { |
| 11232 | await delChat(`${chatFile}.jsonl`); |
| 11233 | } |
| 11234 | } catch (error) { |
| 11235 | loaderHandle.hide(); |
| 11236 | throw error; |
| 11237 | } |
| 11238 | |
| 11239 | if (fromSlashCommand) { // When called from `/delchat` command, don't re-open the history view. |
| 11240 | $('#options').hide(); // Hide option popup menu. |
| 11241 | await loaderHandle.hide(); |
| 11242 | } else { // Open the history view again after 2 seconds (delay to avoid edge cases for deleting last chat). |
| 11243 | setTimeout(async function () { |
| 11244 | $('#option_select_chat').trigger('click'); |
| 11245 | $('#options').hide(); // Hide option popup menu. |
| 11246 | await loaderHandle.hide(); |
| 11247 | }, 2000); |
| 11248 | } |
| 11249 | } |
| 11250 | |
| 11251 | $(document).on('click', '.PastChat_cross', async function (e, { fromSlashCommand = false } = {}) { |
| 11252 | e.stopPropagation(); |
| 11253 | const deleteFileName = $(this).attr('file_name'); |
| 11254 | console.debug('detected cross click for' + deleteFileName); |
| 11255 | |
| 11256 | // Skip confirmation if called from a slash command. |
| 11257 | if (fromSlashCommand) { |
| 11258 | await handleDeleteChat(deleteFileName, selected_group, true); |
| 11259 | return; |
| 11260 | } |
| 11261 | |
| 11262 | const result = await callGenericPopup('<h3>' + t`Delete the Chat File?` + '</h3>', POPUP_TYPE.CONFIRM); |
| 11263 | if (result === POPUP_RESULT.AFFIRMATIVE) { |
| 11264 | await handleDeleteChat(deleteFileName, selected_group, false); |
| 11265 | } |
| 11266 | }); |
| 11267 | |
| 11268 | $('#advanced_div').on('click', function () { |
| 11269 | if (!is_advanced_char_open) { |
| 11270 | is_advanced_char_open = true; |
| 11271 | $('#character_popup').css({ 'display': 'flex', 'opacity': 0.0 }).addClass('open'); |
| 11272 | $('#character_popup').transition({ |
| 11273 | opacity: 1.0, |
| 11274 | duration: animation_duration, |
| 11275 | easing: animation_easing, |
| 11276 | }); |
| 11277 | } else { |
| 11278 | is_advanced_char_open = false; |
| 11279 | $('#character_popup').css('display', 'none').removeClass('open'); |
| 11280 | } |
| 11281 | }); |
| 11282 | |
| 11283 | $('#character_cross').on('click', function () { |
| 11284 | is_advanced_char_open = false; |
| 11285 | $('#character_popup').transition({ |
| 11286 | opacity: 0, |
| 11287 | duration: animation_duration, |
| 11288 | easing: animation_easing, |
| 11289 | }); |
| 11290 | setTimeout(function () { $('#character_popup').css('display', 'none'); }, animation_duration); |
| 11291 | }); |
| 11292 | |
| 11293 | $('#character_popup_ok').on('click', function () { |
| 11294 | is_advanced_char_open = false; |
| 11295 | $('#character_popup').css('display', 'none'); |
| 11296 | }); |
| 11297 | |
| 11298 | $('#dialogue_popup_ok').on('click', async function (_e) { |
| 11299 | dialogueCloseStop = false; |
| 11300 | $('#shadow_popup').transition({ |
| 11301 | opacity: 0, |
| 11302 | duration: animation_duration, |
| 11303 | easing: animation_easing, |
| 11304 | }); |
| 11305 | setTimeout(function () { |
| 11306 | if (dialogueCloseStop) return; |
| 11307 | $('#shadow_popup').css('display', 'none'); |
| 11308 | $('#dialogue_popup').removeClass('large_dialogue_popup'); |
| 11309 | $('#dialogue_popup').removeClass('wide_dialogue_popup'); |
| 11310 | }, animation_duration); |
| 11311 | |
| 11312 | if (dialogueResolve) { |
| 11313 | if (popup_type == 'input') { |
| 11314 | dialogueResolve($('#dialogue_popup_input').val()); |
| 11315 | $('#dialogue_popup_input').val(''); |
| 11316 | } else { |
| 11317 | dialogueResolve(true); |
| 11318 | } |
| 11319 | |
| 11320 | dialogueResolve = null; |
| 11321 | } |
| 11322 | }); |
| 11323 | |
| 11324 | $('#dialogue_popup_cancel').on('click', function (e) { |
| 11325 | dialogueCloseStop = false; |
| 11326 | $('#shadow_popup').transition({ |
| 11327 | opacity: 0, |
| 11328 | duration: animation_duration, |
| 11329 | easing: animation_easing, |
| 11330 | }); |
| 11331 | setTimeout(function () { |
| 11332 | if (dialogueCloseStop) return; |
| 11333 | $('#shadow_popup').css('display', 'none'); |
| 11334 | $('#dialogue_popup').removeClass('large_dialogue_popup'); |
| 11335 | }, animation_duration); |
| 11336 | |
| 11337 | popup_type = ''; |
| 11338 | |
| 11339 | if (dialogueResolve) { |
| 11340 | dialogueResolve(false); |
| 11341 | dialogueResolve = null; |
| 11342 | } |
| 11343 | }); |
| 11344 | |
| 11345 | $('#add_avatar_button').on('change', function () { |
| 11346 | const inputElement = /** @type {HTMLInputElement} */ (this); |
| 11347 | read_avatar_load(inputElement); |
| 11348 | }); |
| 11349 | |
| 11350 | $('#form_create').on('submit', (e) => createOrEditCharacter(e.originalEvent)); |
| 11351 | |
| 11352 | $('#delete_button').on('click', async function () { |
| 11353 | if (this_chid === undefined || !characters[this_chid]) { |
| 11354 | toastr.warning('No character selected.'); |
| 11355 | return; |
| 11356 | } |
| 11357 | |
| 11358 | let deleteChats = false; |
| 11359 | |
| 11360 | const confirm = await Popup.show.confirm(t`Delete the character?`, await renderTemplateAsync('deleteConfirm'), { |
| 11361 | onClose: () => { deleteChats = !!$('#del_char_checkbox').prop('checked'); }, |
| 11362 | }); |
| 11363 | if (!confirm) { |
| 11364 | return; |
| 11365 | } |
| 11366 | |
| 11367 | await deleteCharacter(characters[this_chid].avatar, { deleteChats: deleteChats }); |
| 11368 | }); |
| 11369 | |
| 11370 | //////// OPTIMIZED ALL CHAR CREATION/EDITING TEXTAREA LISTENERS /////////////// |
| 11371 | |
| 11372 | $('#character_name_pole').on('input', function () { |
| 11373 | if (menu_type == 'create') { |
| 11374 | create_save.name = String($('#character_name_pole').val()); |
| 11375 | } |
| 11376 | }); |
| 11377 | |
| 11378 | const elementsToUpdate = { |
| 11379 | '#description_textarea': function () { create_save.description = String($('#description_textarea').val()); }, |
| 11380 | '#creator_notes_textarea': function () { create_save.creator_notes = String($('#creator_notes_textarea').val()); }, |
| 11381 | '#character_version_textarea': function () { create_save.character_version = String($('#character_version_textarea').val()); }, |
| 11382 | '#system_prompt_textarea': function () { create_save.system_prompt = String($('#system_prompt_textarea').val()); }, |
| 11383 | '#post_history_instructions_textarea': function () { create_save.post_history_instructions = String($('#post_history_instructions_textarea').val()); }, |
| 11384 | '#creator_textarea': function () { create_save.creator = String($('#creator_textarea').val()); }, |
| 11385 | '#tags_textarea': function () { create_save.tags = String($('#tags_textarea').val()); }, |
| 11386 | '#personality_textarea': function () { create_save.personality = String($('#personality_textarea').val()); }, |
| 11387 | '#scenario_pole': function () { create_save.scenario = String($('#scenario_pole').val()); }, |
| 11388 | '#mes_example_textarea': function () { create_save.mes_example = String($('#mes_example_textarea').val()); }, |
| 11389 | '#firstmessage_textarea': function () { create_save.first_message = String($('#firstmessage_textarea').val()); }, |
| 11390 | '#talkativeness_slider': function () { create_save.talkativeness = Number($('#talkativeness_slider').val()); }, |
| 11391 | '#depth_prompt_prompt': function () { create_save.depth_prompt_prompt = String($('#depth_prompt_prompt').val()); }, |
| 11392 | '#depth_prompt_depth': function () { create_save.depth_prompt_depth = Number($('#depth_prompt_depth').val()); }, |
| 11393 | '#depth_prompt_role': function () { create_save.depth_prompt_role = String($('#depth_prompt_role').val()); }, |
| 11394 | }; |
| 11395 | |
| 11396 | Object.keys(elementsToUpdate).forEach(function (id) { |
| 11397 | $(id).on('input', function () { |
| 11398 | if (menu_type == 'create') { |
| 11399 | elementsToUpdate[id](); |
| 11400 | } else { |
| 11401 | saveCharacterDebounced(); |
| 11402 | } |
| 11403 | }); |
| 11404 | }); |
| 11405 | |
| 11406 | $('#creator_notes_textarea').on('input', function () { |
| 11407 | const notes = String($('#creator_notes_textarea').val()); |
| 11408 | const avatar = menu_type === 'create' ? '' : characters[this_chid]?.avatar; |
| 11409 | $('#creator_notes_spoiler').html(formatCreatorNotes(notes, avatar)); |
| 11410 | }); |
| 11411 | |
| 11412 | $('#favorite_button').on('click', function () { |
| 11413 | updateFavButtonState(!fav_ch_checked); |
| 11414 | if (menu_type != 'create') { |
| 11415 | saveCharacterDebounced(); |
| 11416 | } |
| 11417 | }); |
| 11418 | |
| 11419 | /* $("#renameCharButton").on('click', renameCharacter); */ |
| 11420 | |
| 11421 | $(document).on('click', '.renameChatButton', async function (e) { |
| 11422 | e.stopPropagation(); |
| 11423 | const oldFileName = $(this).closest('.select_chat_block_wrapper').find('.select_chat_block_filename').text(); |
| 11424 | |
| 11425 | const popupText = await renderTemplateAsync('chatRename'); |
| 11426 | const newName = await callGenericPopup(popupText, POPUP_TYPE.INPUT, oldFileName); |
| 11427 | |
| 11428 | if (!newName || typeof newName !== 'string' || newName == oldFileName) { |
| 11429 | console.log('no new name found, aborting'); |
| 11430 | return; |
| 11431 | } |
| 11432 | |
| 11433 | await renameChat(oldFileName, newName); |
| 11434 | |
| 11435 | await delay(250); |
| 11436 | $('#option_select_chat').trigger('click'); |
| 11437 | $('#options').hide(); |
| 11438 | }); |
| 11439 | |
| 11440 | $(document).on('click', '.exportChatButton, .exportRawChatButton', async function (e) { |
| 11441 | e.stopPropagation(); |
| 11442 | const format = $(this).data('format') || 'txt'; |
| 11443 | await saveChatConditional(); |
| 11444 | const filename = $(this).closest('.select_chat_block_wrapper').find('.select_chat_block_filename').text(); |
| 11445 | console.log(`exporting ${filename} in ${format} format`); |
| 11446 | |
| 11447 | const body = { |
| 11448 | is_group: !!selected_group, |
| 11449 | avatar_url: characters[this_chid]?.avatar, |
| 11450 | file: `${filename}.jsonl`, |
| 11451 | exportfilename: `${filename}.${format}`, |
| 11452 | format: format, |
| 11453 | }; |
| 11454 | console.log(body); |
| 11455 | try { |
| 11456 | const response = await fetch('/api/chats/export', { |
| 11457 | method: 'POST', |
| 11458 | body: JSON.stringify(body), |
| 11459 | headers: getRequestHeaders(), |
| 11460 | }); |
| 11461 | const data = await response.json(); |
| 11462 | if (!response.ok) { |
| 11463 | // display error message |
| 11464 | console.log(data.message); |
| 11465 | await delay(250); |
| 11466 | toastr.error(`Error: ${data.message}`); |
| 11467 | return; |
| 11468 | } else { |
| 11469 | const mimeType = format == 'txt' ? 'text/plain' : 'application/octet-stream'; |
| 11470 | // success, handle response data |
| 11471 | console.log(data); |
| 11472 | await delay(250); |
| 11473 | toastr.success(data.message); |
| 11474 | download(data.result, body.exportfilename, mimeType); |
| 11475 | } |
| 11476 | } catch (error) { |
| 11477 | // display error message |
| 11478 | console.log(`An error has occurred: ${error.message}`); |
| 11479 | await delay(250); |
| 11480 | toastr.error(`Error: ${error.message}`); |
| 11481 | } |
| 11482 | }); |
| 11483 | |
| 11484 | |
| 11485 | const button = $('#options_button'); |
| 11486 | const menu = $('#options'); |
| 11487 | let isOptionsMenuVisible = false; |
| 11488 | |
| 11489 | function showMenu() { |
| 11490 | showBookmarksButtons(); |
| 11491 | menu.fadeIn(animation_duration); |
| 11492 | optionsPopper.update(); |
| 11493 | isOptionsMenuVisible = true; |
| 11494 | } |
| 11495 | |
| 11496 | function hideMenu() { |
| 11497 | menu.fadeOut(animation_duration); |
| 11498 | optionsPopper.update(); |
| 11499 | isOptionsMenuVisible = false; |
| 11500 | } |
| 11501 | |
| 11502 | function isMouseOverButtonOrMenu() { |
| 11503 | return menu.is(':hover, :focus-within') || button.is(':hover, :focus'); |
| 11504 | } |
| 11505 | |
| 11506 | button.on('click', function () { |
| 11507 | if (isOptionsMenuVisible) { |
| 11508 | hideMenu(); |
| 11509 | } else { |
| 11510 | showMenu(); |
| 11511 | } |
| 11512 | }); |
| 11513 | $(document).on('click', function () { |
| 11514 | if (!isOptionsMenuVisible) return; |
| 11515 | if (!isMouseOverButtonOrMenu()) { hideMenu(); } |
| 11516 | }); |
| 11517 | |
| 11518 | /* $('#set_chat_character_settings').on('click', setScenarioOverride); */ |
| 11519 | |
| 11520 | ///////////// OPTIMIZED LISTENERS FOR LEFT SIDE OPTIONS POPUP MENU ////////////////////// |
| 11521 | $('#options [id]').on('click', async function (event, customData) { |
| 11522 | const fromSlashCommand = customData?.fromSlashCommand || false; |
| 11523 | var id = $(this).attr('id'); |
| 11524 | |
| 11525 | // Check whether a custom prompt was provided via custom data (for example through a slash command) |
| 11526 | const additionalPrompt = customData?.additionalPrompt?.trim() || undefined; |
| 11527 | const buildOrFillAdditionalArgs = (args = {}) => ({ |
| 11528 | ...args, |
| 11529 | ...(additionalPrompt !== undefined && { quiet_prompt: additionalPrompt, quietToLoud: true }), |
| 11530 | }); |
| 11531 | |
| 11532 | if (id == 'option_select_chat') { |
| 11533 | if (this_chid === undefined && !is_send_press && !selected_group) { |
| 11534 | await openPermanentAssistantCard(); |
| 11535 | } |
| 11536 | if ((selected_group && !is_group_generating) || (this_chid !== undefined && !is_send_press) || fromSlashCommand) { |
| 11537 | await displayPastChats(); |
| 11538 | //this is just to avoid the shadow for past chat view when using /delchat |
| 11539 | //however, the dialog popup still gets one.. |
| 11540 | if (!fromSlashCommand) { |
| 11541 | console.log('displaying shadow'); |
| 11542 | $('#shadow_select_chat_popup').css('display', 'block'); |
| 11543 | $('#shadow_select_chat_popup').css('opacity', 0.0); |
| 11544 | $('#shadow_select_chat_popup').transition({ |
| 11545 | opacity: 1.0, |
| 11546 | duration: animation_duration, |
| 11547 | easing: animation_easing, |
| 11548 | }); |
| 11549 | } |
| 11550 | } |
| 11551 | } else if (id == 'option_start_new_chat') { |
| 11552 | if ((selected_group || this_chid !== undefined) && !is_send_press) { |
| 11553 | let deleteCurrentChat = false; |
| 11554 | const result = await Popup.show.confirm(t`Start new chat?`, await renderTemplateAsync('newChatConfirm'), { |
| 11555 | onClose: () => { deleteCurrentChat = !!$('#del_chat_checkbox').prop('checked'); }, |
| 11556 | }); |
| 11557 | if (!result) { |
| 11558 | return; |
| 11559 | } |
| 11560 | |
| 11561 | await doNewChat({ deleteCurrentChat: deleteCurrentChat }); |
| 11562 | } |
| 11563 | if (!selected_group && this_chid === undefined && !is_send_press) { |
| 11564 | const alreadyInTempChat = this_chid === undefined && name2 === neutralCharacterName; |
| 11565 | await newAssistantChat({ temporary: alreadyInTempChat }); |
| 11566 | } |
| 11567 | } else if (id == 'option_regenerate') { |
| 11568 | //Attempting to regenerate a user message will instead generate a new message. |
| 11569 | if (chat.length && chat.length - 1 === this_edit_mes_id && chat[this_edit_mes_id]?.is_user == false) { |
| 11570 | toastr.warning(t`Finish the edit before starting a generation.`, t`You cannot regenerate the message you are editing.`); |
| 11571 | return; |
| 11572 | } |
| 11573 | if (is_send_press == false) { |
| 11574 | if (selected_group) { |
| 11575 | regenerateGroup(); |
| 11576 | } else { |
| 11577 | is_send_press = true; |
| 11578 | Generate('regenerate', buildOrFillAdditionalArgs()); |
| 11579 | } |
| 11580 | } |
| 11581 | } else if (id == 'option_impersonate') { |
| 11582 | if (is_send_press == false || fromSlashCommand) { |
| 11583 | is_send_press = true; |
| 11584 | Generate('impersonate', buildOrFillAdditionalArgs()); |
| 11585 | } |
| 11586 | } else if (id == 'option_continue') { |
| 11587 | if (swipeState == SWIPE_STATE.EDITING) { |
| 11588 | toastr.warning(t`Confirm the edit to start a generation.`, t`You cannot send a message during a swipe-edit.`); |
| 11589 | return; |
| 11590 | } |
| 11591 | if (chat.length && chat.length - 1 === this_edit_mes_id) { |
| 11592 | toastr.warning(t`Finish the edit before starting a generation.`, t`You cannot continue the message you are editing.`); |
| 11593 | return; |
| 11594 | } |
| 11595 | |
| 11596 | if (is_send_press == false || fromSlashCommand) { |
| 11597 | is_send_press = true; |
| 11598 | Generate('continue', buildOrFillAdditionalArgs()); |
| 11599 | } |
| 11600 | } else if (id == 'option_delete_mes') { |
| 11601 | setTimeout(() => openMessageDelete(fromSlashCommand), animation_duration); |
| 11602 | } else if (id == 'option_close_chat') { |
| 11603 | await closeCurrentChat(); |
| 11604 | } else if (id === 'option_settings') { |
| 11605 | //var checkBox = document.getElementById("waifuMode"); |
| 11606 | var topBar = document.getElementById('top-bar'); |
| 11607 | var topSettingsHolder = document.getElementById('top-settings-holder'); |
| 11608 | var divchat = document.getElementById('chat'); |
| 11609 | |
| 11610 | //if (checkBox.checked) { |
| 11611 | if (topBar.style.display === 'none') { |
| 11612 | topBar.style.display = ''; // or "inline-block" if that's the original display value |
| 11613 | topSettingsHolder.style.display = ''; // or "inline-block" if that's the original display value |
| 11614 | |
| 11615 | divchat.style.borderRadius = ''; |
| 11616 | divchat.style.backgroundColor = ''; |
| 11617 | } else { |
| 11618 | divchat.style.borderRadius = '10px'; // Adjust the value to control the roundness of the corners |
| 11619 | divchat.style.backgroundColor = ''; // Set the background color to your preference |
| 11620 | |
| 11621 | topBar.style.display = 'none'; |
| 11622 | topSettingsHolder.style.display = 'none'; |
| 11623 | } |
| 11624 | //} |
| 11625 | } |
| 11626 | hideMenu(); |
| 11627 | }); |
| 11628 | |
| 11629 | $('#newChatFromManageScreenButton').on('click', async function () { |
| 11630 | await doNewChat({ deleteCurrentChat: false }); |
| 11631 | $('#select_chat_cross').trigger('click'); |
| 11632 | }); |
| 11633 | |
| 11634 | ////////////////////////////////////////////////////////////////////////////////////////////// |
| 11635 | |
| 11636 | //functionality for the cancel delete messages button, reverts to normal display of input form |
| 11637 | $('#dialogue_del_mes_cancel').on('click', function () { |
| 11638 | $('#dialogue_del_mes').css('display', 'none'); |
| 11639 | $('#send_form').css('display', css_send_form_display); |
| 11640 | $('.del_checkbox').each(function () { |
| 11641 | $(this).css('display', 'none'); |
| 11642 | $(this).parent().children('.for_checkbox').css('display', 'block'); |
| 11643 | $(this).parent().removeClass('selected'); |
| 11644 | $(this).prop('checked', false); |
| 11645 | }); |
| 11646 | showSwipeButtons(); |
| 11647 | this_del_mes = -1; |
| 11648 | is_delete_mode = false; |
| 11649 | }); |
| 11650 | |
| 11651 | //confirms message deletion with the "ok" button |
| 11652 | $('#dialogue_del_mes_ok').on('click', async function () { |
| 11653 | $('#dialogue_del_mes').css('display', 'none'); |
| 11654 | $('#send_form').css('display', css_send_form_display); |
| 11655 | $('.del_checkbox').each(function () { |
| 11656 | $(this).css('display', 'none'); |
| 11657 | $(this).parent().children('.for_checkbox').css('display', 'block'); |
| 11658 | $(this).parent().removeClass('selected'); |
| 11659 | $(this).prop('checked', false); |
| 11660 | }); |
| 11661 | |
| 11662 | if (this_del_mes >= 0) { |
| 11663 | for (let i = (chat.length - 1); i >= this_del_mes; i--) { |
| 11664 | deleteItemizedPromptForMessage(i); |
| 11665 | } |
| 11666 | chatElement.find(`.mes[mesid="${this_del_mes}"]`).nextAll('div').remove(); |
| 11667 | chatElement.find(`.mes[mesid="${this_del_mes}"]`).remove(); |
| 11668 | chat.length = this_del_mes; |
| 11669 | chat_metadata.tainted = true; |
| 11670 | await saveChatConditional(); |
| 11671 | chatElement.scrollTop(chatElement[0].scrollHeight); |
| 11672 | await eventSource.emit(event_types.MESSAGE_DELETED, chat.length); |
| 11673 | chatElement.find('.mes').removeClass('last_mes'); |
| 11674 | chatElement.find('.mes').last().addClass('last_mes'); |
| 11675 | } else { |
| 11676 | console.log('this_del_mes is not >= 0, not deleting'); |
| 11677 | } |
| 11678 | |
| 11679 | showSwipeButtons(); |
| 11680 | this_del_mes = -1; |
| 11681 | is_delete_mode = false; |
| 11682 | }); |
| 11683 | |
| 11684 | $('#main_api').on('change', async function () { |
| 11685 | cancelStatusCheck('Canceled because main api changed'); |
| 11686 | changeMainAPI(); |
| 11687 | saveSettingsDebounced(); |
| 11688 | await eventSource.emit(event_types.MAIN_API_CHANGED, { apiId: main_api }); |
| 11689 | }); |
| 11690 | |
| 11691 | ////////////////// OPTIMIZED RANGE SLIDER LISTENERS//////////////// |
| 11692 | |
| 11693 | var sliderLocked = true; |
| 11694 | var sliderTimer; |
| 11695 | |
| 11696 | $('input[type=\'range\']').on('touchstart', function () { |
| 11697 | // Unlock the slider after 300ms |
| 11698 | setTimeout(function () { |
| 11699 | sliderLocked = false; |
| 11700 | $(this).css('background-color', 'var(--SmartThemeQuoteColor)'); |
| 11701 | }.bind(this), 300); |
| 11702 | }); |
| 11703 | |
| 11704 | $('input[type=\'range\']').on('touchend', function () { |
| 11705 | clearTimeout(sliderTimer); |
| 11706 | $(this).css('background-color', ''); |
| 11707 | sliderLocked = true; |
| 11708 | }); |
| 11709 | |
| 11710 | $('input[type=\'range\']').on('touchmove', function (event) { |
| 11711 | if (sliderLocked) { |
| 11712 | event.preventDefault(); |
| 11713 | } |
| 11714 | }); |
| 11715 | |
| 11716 | const sliders = [ |
| 11717 | { |
| 11718 | sliderId: '#amount_gen', |
| 11719 | counterId: '#amount_gen_counter', |
| 11720 | format: (val) => `${val}`, |
| 11721 | setValue: (val) => { amount_gen = Number(val); }, |
| 11722 | }, |
| 11723 | { |
| 11724 | sliderId: '#max_context', |
| 11725 | counterId: '#max_context_counter', |
| 11726 | format: (val) => `${val}`, |
| 11727 | setValue: (val) => { max_context = Number(val); }, |
| 11728 | }, |
| 11729 | ]; |
| 11730 | |
| 11731 | sliders.forEach(slider => { |
| 11732 | $(document).on('input', slider.sliderId, function () { |
| 11733 | const value = $(this).val(); |
| 11734 | const formattedValue = slider.format(value); |
| 11735 | slider.setValue(value); |
| 11736 | $(slider.counterId).val(formattedValue); |
| 11737 | saveSettingsDebounced(); |
| 11738 | }); |
| 11739 | }); |
| 11740 | |
| 11741 | ////////////////////////////////////////////////////////////// |
| 11742 | |
| 11743 | $('#select_chat_cross').on('click', function () { |
| 11744 | $('#shadow_select_chat_popup').transition({ |
| 11745 | opacity: 0, |
| 11746 | duration: animation_duration, |
| 11747 | easing: animation_easing, |
| 11748 | }); |
| 11749 | setTimeout(function () { $('#shadow_select_chat_popup').css('display', 'none'); }, animation_duration); |
| 11750 | }); |
| 11751 | |
| 11752 | $(document).on('pointerup', '.mes_copy', async function () { |
| 11753 | if (this_chid !== undefined || selected_group || name2 === neutralCharacterName) { |
| 11754 | try { |
| 11755 | const messageId = $(this).closest('.mes').attr('mesid'); |
| 11756 | const text = chat[messageId].mes; |
| 11757 | await copyText(text); |
| 11758 | toastr.info('Copied!', '', { timeOut: 2000 }); |
| 11759 | } catch (err) { |
| 11760 | console.error('Failed to copy: ', err); |
| 11761 | } |
| 11762 | } |
| 11763 | }); |
| 11764 | |
| 11765 | //******************** |
| 11766 | //***Message Editor*** |
| 11767 | $(document).on('click', '.mes_edit', async function () { |
| 11768 | if (is_delete_mode) { |
| 11769 | return; |
| 11770 | } |
| 11771 | if (this_chid !== undefined || selected_group || name2 === neutralCharacterName) { |
| 11772 | // Previously system messages we're allowed to be edited |
| 11773 | /*const message = $(this).closest(".mes"); |
| 11774 | |
| 11775 | if (message.data("isSystem")) { |
| 11776 | return; |
| 11777 | }*/ |
| 11778 | |
| 11779 | if (this_edit_mes_id >= 0) { |
| 11780 | let mes_edited = chatElement.find(`[mesid="${this_edit_mes_id}"]`).find('.mes_edit_done'); |
| 11781 | if (Number(edit_mes_id) == chat.length - 1) { //if the generating swipe (...) |
| 11782 | let run_edit = true; |
| 11783 | if (chat[edit_mes_id].swipe_id !== undefined) { |
| 11784 | if (chat[edit_mes_id].swipes.length === chat[edit_mes_id].swipe_id) { |
| 11785 | run_edit = false; |
| 11786 | } |
| 11787 | } |
| 11788 | if (run_edit) { |
| 11789 | hideSwipeButtons(); |
| 11790 | } |
| 11791 | } |
| 11792 | await messageEditDone(mes_edited); |
| 11793 | } |
| 11794 | var edit_mes_id = Number($(this).closest('.mes').attr('mesid')); |
| 11795 | |
| 11796 | await messageEdit(edit_mes_id); |
| 11797 | } |
| 11798 | }); |
| 11799 | |
| 11800 | $(document).on('input', '#curEditTextarea', function () { |
| 11801 | if (power_user.auto_save_msg_edits === true) { |
| 11802 | messageEditAuto($(this)); |
| 11803 | } |
| 11804 | }); |
| 11805 | |
| 11806 | $(document).on('click', '.extraMesButtonsHint', function (e) { |
| 11807 | const $hint = $(e.target); |
| 11808 | const $buttons = $hint.siblings('.extraMesButtons'); |
| 11809 | |
| 11810 | $hint.transition({ |
| 11811 | opacity: 0, |
| 11812 | duration: animation_duration, |
| 11813 | easing: animation_easing, |
| 11814 | complete: function () { |
| 11815 | $hint.hide(); |
| 11816 | $buttons |
| 11817 | .addClass('visible') |
| 11818 | .css({ |
| 11819 | opacity: 0, |
| 11820 | display: 'flex', |
| 11821 | }) |
| 11822 | .transition({ |
| 11823 | opacity: 1, |
| 11824 | duration: animation_duration, |
| 11825 | easing: animation_easing, |
| 11826 | }); |
| 11827 | }, |
| 11828 | }); |
| 11829 | }); |
| 11830 | |
| 11831 | $(document).on('click', function (e) { |
| 11832 | // Expanded options don't need to be closed |
| 11833 | if (power_user.expand_message_actions) { |
| 11834 | return; |
| 11835 | } |
| 11836 | |
| 11837 | // Check if the click was outside the relevant elements |
| 11838 | if (!$(e.target).closest('.extraMesButtons, .extraMesButtonsHint').length) { |
| 11839 | const $visibleButtons = $('.extraMesButtons.visible'); |
| 11840 | |
| 11841 | if (!$visibleButtons.length) { |
| 11842 | return; |
| 11843 | } |
| 11844 | |
| 11845 | const $hiddenHints = $('.extraMesButtonsHint:hidden'); |
| 11846 | |
| 11847 | // Transition out the .extraMesButtons first |
| 11848 | $visibleButtons.transition({ |
| 11849 | opacity: 0, |
| 11850 | duration: animation_duration, |
| 11851 | easing: animation_easing, |
| 11852 | complete: function () { |
| 11853 | // Hide the .extraMesButtons after the transition |
| 11854 | $(this) |
| 11855 | .hide() |
| 11856 | .removeClass('visible'); |
| 11857 | |
| 11858 | // Transition the .extraMesButtonsHint back in |
| 11859 | $hiddenHints |
| 11860 | .show() |
| 11861 | .transition({ |
| 11862 | opacity: 0.3, |
| 11863 | duration: animation_duration, |
| 11864 | easing: animation_easing, |
| 11865 | complete: function () { |
| 11866 | $(this).css('opacity', ''); |
| 11867 | }, |
| 11868 | }); |
| 11869 | }, |
| 11870 | }); |
| 11871 | } |
| 11872 | }); |
| 11873 | |
| 11874 | $(document).on('click', '.mes_edit_cancel', async function () { |
| 11875 | await messageEditCancel.call(this, this_edit_mes_id); |
| 11876 | }); |
| 11877 | |
| 11878 | $(document).on('click', '.mes_edit_up', async function () { |
| 11879 | if (this_edit_mes_id <= 0) { |
| 11880 | return; |
| 11881 | } |
| 11882 | const targetId = Number(this_edit_mes_id) - 1; |
| 11883 | await messageEditMove(this_edit_mes_id, targetId); |
| 11884 | }); |
| 11885 | |
| 11886 | $(document).on('click', '.mes_edit_down', async function () { |
| 11887 | if (this_edit_mes_id >= chat.length - 1) { |
| 11888 | return; |
| 11889 | } |
| 11890 | |
| 11891 | const targetId = Number(this_edit_mes_id) + 1; |
| 11892 | await messageEditMove(this_edit_mes_id, targetId); |
| 11893 | }); |
| 11894 | |
| 11895 | $(document).on('click', '.mes_edit_copy', async function () { |
| 11896 | const confirmation = await callGenericPopup(t`Create a copy of this message?`, POPUP_TYPE.CONFIRM); |
| 11897 | if (!confirmation) { |
| 11898 | return; |
| 11899 | } |
| 11900 | |
| 11901 | hideSwipeButtons(); |
| 11902 | const oldScroll = chatElement[0].scrollTop; |
| 11903 | const clone = structuredClone(chat[this_edit_mes_id]); |
| 11904 | clone.send_date = Date.now(); |
| 11905 | const this_edit_mes_element = $(this).closest('.mes'); |
| 11906 | clone.mes = this_edit_mes_element.find('.edit_textarea').val().toString(); |
| 11907 | |
| 11908 | if (power_user.trim_spaces) { |
| 11909 | clone.mes = clone.mes.trim(); |
| 11910 | } |
| 11911 | |
| 11912 | chat.splice(Number(this_edit_mes_id) + 1, 0, clone); |
| 11913 | const newMessageElement = updateMessageElement(clone); |
| 11914 | this_edit_mes_element.after(newMessageElement); |
| 11915 | |
| 11916 | updateViewMessageIds(); |
| 11917 | await saveChatConditional(); |
| 11918 | chatElement[0].scrollTop = oldScroll; |
| 11919 | showSwipeButtons(); |
| 11920 | }); |
| 11921 | |
| 11922 | $(document).on('click', '.mes_edit_delete', async function (event, customData) { |
| 11923 | const fromSlashCommand = customData?.fromSlashCommand || false; |
| 11924 | const message = chat[this_edit_mes_id]; |
| 11925 | const selectedSwipe = message.swipe_id ?? undefined; |
| 11926 | const swipesArray = Array.isArray(message.swipes) ? message.swipes : []; |
| 11927 | const canDeleteSwipe = power_user.confirm_message_delete && !fromSlashCommand && !message.is_user && swipesArray.length > 1 && this_edit_mes_id === chat.length - 1 && selectedSwipe !== undefined; |
| 11928 | await deleteMessage(Number(this_edit_mes_id), canDeleteSwipe ? selectedSwipe : undefined, power_user.confirm_message_delete && fromSlashCommand !== true); |
| 11929 | }); |
| 11930 | |
| 11931 | $(document).on('click', '.mes_edit_done', async function () { |
| 11932 | await messageEditDone($(this)); |
| 11933 | }); |
| 11934 | |
| 11935 | //Select chat |
| 11936 | |
| 11937 | //**************************CHARACTER IMPORT EXPORT*************************// |
| 11938 | $('#character_import_button').on('click', function () { |
| 11939 | $('#character_import_file').trigger('click'); |
| 11940 | }); |
| 11941 | |
| 11942 | $('#character_import_file').on('change', async function (e) { |
| 11943 | $('#rm_info_avatar').html(''); |
| 11944 | |
| 11945 | if (!(e.target instanceof HTMLInputElement)) { |
| 11946 | return; |
| 11947 | } |
| 11948 | |
| 11949 | if (!e.target.files.length) { |
| 11950 | return; |
| 11951 | } |
| 11952 | |
| 11953 | const avatarFileNames = []; |
| 11954 | for (const file of e.target.files) { |
| 11955 | const avatarFileName = await importCharacter(file); |
| 11956 | if (avatarFileName !== undefined) { |
| 11957 | avatarFileNames.push(avatarFileName); |
| 11958 | } |
| 11959 | } |
| 11960 | |
| 11961 | if (avatarFileNames.length > 0) { |
| 11962 | await importCharactersTags(avatarFileNames); |
| 11963 | selectImportedChar(avatarFileNames[avatarFileNames.length - 1]); |
| 11964 | } |
| 11965 | |
| 11966 | // Clear the file input value to allow re-uploading the same file |
| 11967 | e.target.value = ''; |
| 11968 | }); |
| 11969 | |
| 11970 | $('#export_button').on('click', function () { |
| 11971 | isExportPopupOpen = !isExportPopupOpen; |
| 11972 | $('#export_format_popup').toggle(isExportPopupOpen); |
| 11973 | exportPopper.update(); |
| 11974 | }); |
| 11975 | |
| 11976 | $(document).on('click', '.export_format', async function () { |
| 11977 | const format = $(this).data('format'); |
| 11978 | |
| 11979 | if (!format) { |
| 11980 | return; |
| 11981 | } |
| 11982 | |
| 11983 | $('#export_format_popup').hide(); |
| 11984 | isExportPopupOpen = false; |
| 11985 | exportPopper.update(); |
| 11986 | |
| 11987 | // Save before exporting |
| 11988 | await createOrEditCharacter(); |
| 11989 | const body = { format, avatar_url: characters[this_chid].avatar }; |
| 11990 | |
| 11991 | const response = await fetch('/api/characters/export', { |
| 11992 | method: 'POST', |
| 11993 | headers: getRequestHeaders(), |
| 11994 | body: JSON.stringify(body), |
| 11995 | }); |
| 11996 | |
| 11997 | if (response.ok) { |
| 11998 | const filename = characters[this_chid].avatar.replace('.png', `.${format}`); |
| 11999 | const blob = await response.blob(); |
| 12000 | const a = document.createElement('a'); |
| 12001 | a.href = URL.createObjectURL(blob); |
| 12002 | a.setAttribute('download', filename); |
| 12003 | document.body.appendChild(a); |
| 12004 | a.click(); |
| 12005 | URL.revokeObjectURL(a.href); |
| 12006 | document.body.removeChild(a); |
| 12007 | } |
| 12008 | }); |
| 12009 | //**************************CHAT IMPORT EXPORT*************************// |
| 12010 | $('#chat_import_button').on('click', function () { |
| 12011 | $('#chat_import_file').trigger('click'); |
| 12012 | }); |
| 12013 | |
| 12014 | $('#chat_import_file').on('change', async function (e) { |
| 12015 | const targetElement = e.target; |
| 12016 | const formElement = document.getElementById('form_import_chat'); |
| 12017 | if (!(targetElement instanceof HTMLInputElement) || !(formElement instanceof HTMLFormElement)) { |
| 12018 | return; |
| 12019 | } |
| 12020 | |
| 12021 | const importedFileNames = []; |
| 12022 | |
| 12023 | for (const file of targetElement.files) { |
| 12024 | const ext = file.name.match(/\.(\w+)$/); |
| 12025 | const format = ext?.[1]?.toLowerCase(); |
| 12026 | |
| 12027 | if (!['json', 'jsonl'].includes(format)) { |
| 12028 | toastr.warning(t`Only JSON and JSONL files are supported for chat imports.`); |
| 12029 | continue; |
| 12030 | } |
| 12031 | |
| 12032 | if (selected_group && format === 'json') { |
| 12033 | toastr.warning(t`Only SillyTavern's own format is supported for group chat imports. Sorry!`); |
| 12034 | continue; |
| 12035 | } |
| 12036 | |
| 12037 | const formData = new FormData(formElement); |
| 12038 | formData.set('file_type', format); |
| 12039 | formData.set('avatar', file); |
| 12040 | formData.set('user_name', name1); |
| 12041 | |
| 12042 | const importFn = selected_group ? importGroupChat : importCharacterChat; |
| 12043 | const result = await importFn(formData, { refresh: false }); |
| 12044 | importedFileNames.push(...result); |
| 12045 | } |
| 12046 | |
| 12047 | if (importedFileNames.length > 0) { |
| 12048 | toastr.success(t`Successfully imported ${importedFileNames.length} chat(s).`); |
| 12049 | } |
| 12050 | |
| 12051 | await displayPastChats(importedFileNames); |
| 12052 | |
| 12053 | targetElement.value = ''; |
| 12054 | }); |
| 12055 | |
| 12056 | $('#rm_button_group_chats').on('click', function () { |
| 12057 | selected_button = 'group_chats'; |
| 12058 | select_group_chats(null, false); |
| 12059 | }); |
| 12060 | |
| 12061 | $('#rm_button_back_from_group').on('click', function () { |
| 12062 | selected_button = 'characters'; |
| 12063 | select_rm_characters(); |
| 12064 | }); |
| 12065 | |
| 12066 | $('#dupe_button').on('click', async function () { |
| 12067 | await duplicateCharacter(); |
| 12068 | }); |
| 12069 | |
| 12070 | $(document).on('click', '.mes_stop', function () { |
| 12071 | stopGeneration(); |
| 12072 | }); |
| 12073 | |
| 12074 | $(document).on('click', '#form_sheld .stscript_continue', function () { |
| 12075 | pauseScriptExecution(); |
| 12076 | }); |
| 12077 | |
| 12078 | $(document).on('click', '#form_sheld .stscript_pause', function () { |
| 12079 | pauseScriptExecution(); |
| 12080 | }); |
| 12081 | |
| 12082 | $(document).on('click', '#form_sheld .stscript_stop', function () { |
| 12083 | stopScriptExecution(); |
| 12084 | }); |
| 12085 | |
| 12086 | $(document).on('click', '.drawer-opener', doDrawerOpenClick); |
| 12087 | |
| 12088 | $('.drawer-toggle').on('click', doNavbarIconClick); |
| 12089 | |
| 12090 | $('html').on('touchstart mousedown', async function (e) { |
| 12091 | const clickTarget = $(e.target); |
| 12092 | |
| 12093 | if (isExportPopupOpen |
| 12094 | && clickTarget.closest('#export_button').length == 0 |
| 12095 | && clickTarget.closest('#export_format_popup').length == 0) { |
| 12096 | $('#export_format_popup').hide(); |
| 12097 | isExportPopupOpen = false; |
| 12098 | exportPopper.update(); |
| 12099 | } |
| 12100 | |
| 12101 | const forbiddenTargets = [ |
| 12102 | '#character_cross', |
| 12103 | '#avatar-and-name-block', |
| 12104 | '#shadow_popup', |
| 12105 | '.popup', |
| 12106 | '#world_popup', |
| 12107 | '.ui-widget', |
| 12108 | '.text_pole', |
| 12109 | '#toast-container', |
| 12110 | '.select2-results', |
| 12111 | ]; |
| 12112 | |
| 12113 | for (const id of forbiddenTargets) { |
| 12114 | if (clickTarget.closest(id).length > 0) { |
| 12115 | return; |
| 12116 | } |
| 12117 | } |
| 12118 | |
| 12119 | // This autocloses open drawers that are not pinned if a click happens inside the app which does not target them. |
| 12120 | const targetParentHasOpenDrawer = clickTarget.parents('.openDrawer').length; |
| 12121 | if (!clickTarget.hasClass('drawer-icon') && !clickTarget.hasClass('openDrawer')) { |
| 12122 | const $openDrawers = $('.openDrawer').not('.pinnedOpen'); |
| 12123 | if ($openDrawers.length && targetParentHasOpenDrawer === 0) { |
| 12124 | // Toggle icon and drawer classes |
| 12125 | $('.openIcon').not('.drawerPinnedOpen').toggleClass('closedIcon openIcon'); |
| 12126 | $openDrawers.toggleClass('closedDrawer openDrawer'); |
| 12127 | } |
| 12128 | } |
| 12129 | }); |
| 12130 | |
| 12131 | $(document).on('click', '.inline-drawer-toggle', async function (e) { |
| 12132 | if ($(e.target).hasClass('text_pole')) { |
| 12133 | return; |
| 12134 | } |
| 12135 | const drawer = $(this).closest('.inline-drawer'); |
| 12136 | const icon = drawer.find('>.inline-drawer-header .inline-drawer-icon'); |
| 12137 | const drawerContent = drawer.find('>.inline-drawer-content'); |
| 12138 | icon.toggleClass('down up'); |
| 12139 | icon.toggleClass('fa-circle-chevron-down fa-circle-chevron-up'); |
| 12140 | drawer.trigger('inline-drawer-toggle'); |
| 12141 | drawerContent.stop().slideToggle({ |
| 12142 | complete: () => { |
| 12143 | $(this).css('height', ''); |
| 12144 | }, |
| 12145 | }); |
| 12146 | |
| 12147 | // Set the height of "autoSetHeight" textareas within the inline-drawer to their scroll height |
| 12148 | if (!CSS.supports('field-sizing', 'content')) { |
| 12149 | const textareas = drawerContent.find('textarea.autoSetHeight'); |
| 12150 | for (const textarea of textareas) { |
| 12151 | await resetScrollHeight($(textarea)); |
| 12152 | } |
| 12153 | } |
| 12154 | }); |
| 12155 | |
| 12156 | $(document).on('click', '.inline-drawer-maximize', function () { |
| 12157 | const icon = $(this).find('.inline-drawer-icon, .floating_panel_maximize'); |
| 12158 | icon.toggleClass('fa-window-maximize fa-window-restore'); |
| 12159 | const drawerContent = $(this).closest('.drawer-content'); |
| 12160 | drawerContent.toggleClass('maximized'); |
| 12161 | const drawerId = drawerContent.attr('id'); |
| 12162 | resetMovableStyles(drawerId); |
| 12163 | }); |
| 12164 | |
| 12165 | $(document).on('click', '.mes .avatar', function () { |
| 12166 | const messageElement = $(this).closest('.mes'); |
| 12167 | const thumbURL = $(this).children('img').attr('src'); |
| 12168 | const charsPath = '/characters/'; |
| 12169 | const targetAvatarImg = thumbURL.substring(thumbURL.lastIndexOf('=') + 1); |
| 12170 | const charname = targetAvatarImg.replace('.png', ''); |
| 12171 | const isValidCharacter = characters.some(x => x.avatar === decodeURIComponent(targetAvatarImg)); |
| 12172 | |
| 12173 | // Remove existing zoomed avatars for characters that are not the clicked character when moving UI is not enabled |
| 12174 | if (!power_user.movingUI) { |
| 12175 | $('.zoomed_avatar').each(function () { |
| 12176 | const currentForChar = $(this).attr('forChar'); |
| 12177 | if (currentForChar !== charname && typeof currentForChar !== 'undefined') { |
| 12178 | console.debug(`Removing zoomed avatar for character: ${currentForChar}`); |
| 12179 | $(this).remove(); |
| 12180 | } |
| 12181 | }); |
| 12182 | } |
| 12183 | |
| 12184 | const avatarSrc = (isDataURL(thumbURL) || /^\/?img\/(?:.+)/.test(thumbURL)) ? thumbURL : charsPath + targetAvatarImg; |
| 12185 | if ($(`.zoomed_avatar[forChar="${charname}"]`).length) { |
| 12186 | console.debug('removing container as it already existed'); |
| 12187 | $(`.zoomed_avatar[forChar="${charname}"]`).fadeOut(animation_duration, () => { |
| 12188 | $(`.zoomed_avatar[forChar="${charname}"]`).remove(); |
| 12189 | }); |
| 12190 | } else { |
| 12191 | console.debug('making new container from template'); |
| 12192 | const template = $('#zoomed_avatar_template').html(); |
| 12193 | const newElement = $(template); |
| 12194 | newElement.attr('forChar', charname); |
| 12195 | newElement.attr('id', `zoomFor_${charname}`); |
| 12196 | newElement.addClass('draggable'); |
| 12197 | newElement.find('.drag-grabber').attr('id', `zoomFor_${charname}header`); |
| 12198 | |
| 12199 | $('body').append(newElement); |
| 12200 | newElement.fadeIn(animation_duration); |
| 12201 | const zoomedAvatarImgElement = $(`.zoomed_avatar[forChar="${charname}"] img`); |
| 12202 | if (messageElement.attr('is_user') == 'true' || (messageElement.attr('is_system') == 'true' && !isValidCharacter)) { |
| 12203 | //handle user and system avatars |
| 12204 | const isValidPersona = decodeURIComponent(targetAvatarImg) in power_user.personas; |
| 12205 | if (isValidPersona) { |
| 12206 | const personaSrc = getUserAvatar(targetAvatarImg); |
| 12207 | zoomedAvatarImgElement.attr('src', personaSrc); |
| 12208 | zoomedAvatarImgElement.attr('data-izoomify-url', personaSrc); |
| 12209 | } else { |
| 12210 | zoomedAvatarImgElement.attr('src', thumbURL); |
| 12211 | zoomedAvatarImgElement.attr('data-izoomify-url', thumbURL); |
| 12212 | } |
| 12213 | } else if (messageElement.attr('is_user') == 'false') { //handle char avatars |
| 12214 | zoomedAvatarImgElement.attr('src', avatarSrc); |
| 12215 | zoomedAvatarImgElement.attr('data-izoomify-url', avatarSrc); |
| 12216 | } |
| 12217 | loadMovingUIState(); |
| 12218 | $(`.zoomed_avatar[forChar="${charname}"]`).css('display', 'flex'); |
| 12219 | dragElement(newElement); |
| 12220 | |
| 12221 | if (power_user.zoomed_avatar_magnification) { |
| 12222 | $('.zoomed_avatar_container').izoomify(); |
| 12223 | } |
| 12224 | |
| 12225 | $('.zoomed_avatar, .zoomed_avatar .dragClose').on('click touchend', (e) => { |
| 12226 | if (e.target.closest('.dragClose')) { |
| 12227 | $(`.zoomed_avatar[forChar="${charname}"]`).fadeOut(animation_duration, () => { |
| 12228 | $(`.zoomed_avatar[forChar="${charname}"]`).remove(); |
| 12229 | }); |
| 12230 | } |
| 12231 | }); |
| 12232 | |
| 12233 | zoomedAvatarImgElement.on('dragstart', (e) => { |
| 12234 | console.log('saw drag on avatar!'); |
| 12235 | e.preventDefault(); |
| 12236 | return false; |
| 12237 | }); |
| 12238 | } |
| 12239 | }); |
| 12240 | |
| 12241 | document.addEventListener('click', function (e) { |
| 12242 | if (!(e.target instanceof HTMLElement)) return; |
| 12243 | if (e.target.matches('#OpenAllWIEntries')) { |
| 12244 | document.querySelectorAll('#world_popup_entries_list .inline-drawer').forEach((/** @type {HTMLElement} */ drawer) => { |
| 12245 | delay(0).then(() => toggleDrawer(drawer, true)); |
| 12246 | }); |
| 12247 | } else if (e.target.matches('#CloseAllWIEntries')) { |
| 12248 | document.querySelectorAll('#world_popup_entries_list .inline-drawer').forEach((/** @type {HTMLElement} */ drawer) => { |
| 12249 | toggleDrawer(drawer, false); |
| 12250 | }); |
| 12251 | } |
| 12252 | }); |
| 12253 | |
| 12254 | $(document).on('click', '.open_alternate_greetings', openAlternateGreetings); |
| 12255 | /* $('#set_character_world').on('click', openCharacterWorldPopup); */ |
| 12256 | |
| 12257 | $(document).on('focus', 'input.auto-select, textarea.auto-select', function () { |
| 12258 | if (!power_user.enable_auto_select_input) return; |
| 12259 | const control = $(this)[0]; |
| 12260 | if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) { |
| 12261 | control.select(); |
| 12262 | console.debug('Auto-selecting content of input control', control); |
| 12263 | } |
| 12264 | }); |
| 12265 | |
| 12266 | $(document).on('keydown', function (e) { |
| 12267 | if (e.key === 'Escape' && !e.originalEvent.isComposing) { |
| 12268 | const isEditVisible = $('#curEditTextarea').is(':visible') || $('.reasoning_edit_textarea').length > 0; |
| 12269 | if (isEditVisible && power_user.auto_save_msg_edits === false) { |
| 12270 | closeMessageEditor('all'); |
| 12271 | $('#send_textarea').trigger('focus'); |
| 12272 | return; |
| 12273 | } |
| 12274 | if (isEditVisible && power_user.auto_save_msg_edits === true) { |
| 12275 | chatElement.find(`.mes[mesid="${this_edit_mes_id}"] .mes_edit_done`).trigger('click'); |
| 12276 | closeMessageEditor('reasoning'); |
| 12277 | $('#send_textarea').trigger('focus'); |
| 12278 | return; |
| 12279 | } |
| 12280 | if (this_edit_mes_id === undefined && $('#mes_stop').is(':visible')) { |
| 12281 | $('#mes_stop').trigger('click'); |
| 12282 | if (chat.length === 0) return; |
| 12283 | const lastMessage = chat[chat.length - 1]; |
| 12284 | if (Array.isArray(lastMessage.swipes) && lastMessage.swipe_id == lastMessage.swipes.length) { |
| 12285 | $('.last_mes .swipe_left').trigger('click'); |
| 12286 | } |
| 12287 | } |
| 12288 | } |
| 12289 | }); |
| 12290 | |
| 12291 | $('#char-management-dropdown').on('change', async (e) => { |
| 12292 | const targetElement = /** @type {HTMLSelectElement} */ (e.target); |
| 12293 | const target = $(targetElement.selectedOptions).attr('id'); |
| 12294 | switch (target) { |
| 12295 | case 'set_character_world': |
| 12296 | await openCharacterWorldPopup(); |
| 12297 | break; |
| 12298 | case 'set_chat_character_settings': |
| 12299 | await setCharacterSettingsOverrides(); |
| 12300 | break; |
| 12301 | case 'renameCharButton': |
| 12302 | await renameCharacter(); |
| 12303 | break; |
| 12304 | case 'import_character_info': |
| 12305 | await importEmbeddedWorldInfo(); |
| 12306 | saveCharacterDebounced(); |
| 12307 | break; |
| 12308 | case 'character_source': { |
| 12309 | const source = getCharacterSource(this_chid); |
| 12310 | if (source && isValidUrl(source)) { |
| 12311 | const url = new URL(source); |
| 12312 | const confirm = await Popup.show.confirm('Open Source', `<span>Do you want to open the link to ${url.hostname} in a new tab?</span><var>${url}</var>`); |
| 12313 | if (confirm) { |
| 12314 | window.open(source, '_blank'); |
| 12315 | } |
| 12316 | } else { |
| 12317 | toastr.info('This character doesn\'t seem to have a source.'); |
| 12318 | } |
| 12319 | } break; |
| 12320 | case 'replace_update': { |
| 12321 | let onlineUrl = getCharacterSource(this_chid); |
| 12322 | |
| 12323 | const POPUP_RESULT_URL = POPUP_RESULT.CUSTOM1, POPUP_RESULT_FILE = POPUP_RESULT.CUSTOM2; |
| 12324 | const result = await Popup.show.confirm(t`Replace Character`, |
| 12325 | `<p>${t`Choose a new character card to replace this character with.`}</p>` + |
| 12326 | `<p>${t`You can also replace this character with the one from the online source.`}${onlineUrl ? `<br />This character was downloaded from: <var>${onlineUrl}</var>` : ''}</p>` + |
| 12327 | `<p>${t`All chats, assets and group memberships will be preserved, but local changes to the character data will be lost.`}<br />${t`Proceed?`}</p>`, |
| 12328 | { |
| 12329 | okButton: false, |
| 12330 | customButtons: [{ |
| 12331 | text: t`Replace with URL`, |
| 12332 | result: POPUP_RESULT_URL, |
| 12333 | classes: ['popup-button-ok'], |
| 12334 | }, { |
| 12335 | text: t`Replace with File`, |
| 12336 | result: POPUP_RESULT_FILE, |
| 12337 | classes: ['popup-button-ok'], |
| 12338 | }], |
| 12339 | defaultResult: onlineUrl ? POPUP_RESULT_URL : POPUP_RESULT_FILE, |
| 12340 | }); |
| 12341 | |
| 12342 | // Remember the chat currently selected, so we can reload it after the replacement |
| 12343 | const currentChatFile = characters[this_chid].chat; |
| 12344 | async function postReplace() { |
| 12345 | await openCharacterChat(currentChatFile); |
| 12346 | } |
| 12347 | |
| 12348 | switch (result) { |
| 12349 | case POPUP_RESULT_FILE: { |
| 12350 | async function uploadReplacementCard(e) { |
| 12351 | const file = e.target.files[0]; |
| 12352 | if (!file) { |
| 12353 | return; |
| 12354 | } |
| 12355 | |
| 12356 | try { |
| 12357 | const data = new Map(); |
| 12358 | data.set(file, characters[this_chid].avatar); |
| 12359 | await processDroppedFiles([file], data); |
| 12360 | await postReplace(); |
| 12361 | } catch { |
| 12362 | toastr.error('Failed to replace the character card.', 'Something went wrong'); |
| 12363 | } |
| 12364 | } |
| 12365 | $('#character_replace_file').off('change').on('change', uploadReplacementCard).trigger('click'); |
| 12366 | break; |
| 12367 | } |
| 12368 | case POPUP_RESULT_URL: { |
| 12369 | const inputUrl = await Popup.show.input(t`Replace Character from URL`, |
| 12370 | `<p>${t`Enter the URL of the character card to replace this character with.`}</p>` + |
| 12371 | (onlineUrl ? `<p>${t`This character was downloaded from: <var>${onlineUrl}</var>`}</p>` : ''), |
| 12372 | onlineUrl); |
| 12373 | if (!inputUrl) { |
| 12374 | break; |
| 12375 | } |
| 12376 | onlineUrl = inputUrl; |
| 12377 | await importFromExternalUrl(onlineUrl, { preserveFileName: characters[this_chid].avatar }); |
| 12378 | await postReplace(); |
| 12379 | break; |
| 12380 | } |
| 12381 | } |
| 12382 | } break; |
| 12383 | case 'import_tags': { |
| 12384 | await importTags(characters[this_chid], { importSetting: tag_import_setting.ASK }); |
| 12385 | } break; |
| 12386 | /*case 'delete_button': |
| 12387 | popup_type = "del_ch"; |
| 12388 | callPopup(` |
| 12389 | <h3>Delete the character?</h3> |
| 12390 | <b>THIS IS PERMANENT!<br><br> |
| 12391 | THIS WILL ALSO DELETE ALL<br> |
| 12392 | OF THE CHARACTER'S CHAT FILES.<br><br></b>` |
| 12393 | ); |
| 12394 | break;*/ |
| 12395 | default: |
| 12396 | await eventSource.emit(event_types.CHARACTER_MANAGEMENT_DROPDOWN, target); |
| 12397 | } |
| 12398 | $('#char-management-dropdown').prop('selectedIndex', 0); |
| 12399 | }); |
| 12400 | |
| 12401 | $(window).on('beforeunload', () => { |
| 12402 | cancelTtsPlay(); |
| 12403 | if (streamingProcessor) { |
| 12404 | console.log('Page reloaded. Aborting streaming...'); |
| 12405 | streamingProcessor.onStopStreaming(); |
| 12406 | } |
| 12407 | }); |
| 12408 | |
| 12409 | |
| 12410 | var isManualInput = false; |
| 12411 | var valueBeforeManualInput; |
| 12412 | |
| 12413 | $(document).on('input', '.range-block-counter input, .neo-range-input', function () { |
| 12414 | valueBeforeManualInput = $(this).val(); |
| 12415 | console.log(valueBeforeManualInput); |
| 12416 | }); |
| 12417 | |
| 12418 | $(document).on('change', '.range-block-counter input, .neo-range-input', function (e) { |
| 12419 | if (!(e.target instanceof HTMLElement)) { |
| 12420 | return; |
| 12421 | } |
| 12422 | e.target.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true })); |
| 12423 | }); |
| 12424 | |
| 12425 | $(document).on('keydown', '.range-block-counter input, .neo-range-input', function (e) { |
| 12426 | const masterSelector = '#' + $(this).data('for'); |
| 12427 | const masterElement = $(masterSelector); |
| 12428 | if (e.key === 'Enter') { |
| 12429 | let manualInput = Number($(this).val()); |
| 12430 | if (isManualInput) { |
| 12431 | //disallow manual inputs outside acceptable range |
| 12432 | if (manualInput >= Number($(this).attr('min')) && manualInput <= Number($(this).attr('max'))) { |
| 12433 | //if value is ok, assign to slider and update handle text and position |
| 12434 | //newSlider.val(manualInput) |
| 12435 | //handleSlideEvent.call(newSlider, null, { value: parseFloat(manualInput) }, 'manual'); |
| 12436 | valueBeforeManualInput = manualInput; |
| 12437 | $(masterElement).val($(this).val()).trigger('input', { forced: true }); |
| 12438 | } else { |
| 12439 | //if value not ok, warn and reset to last known valid value |
| 12440 | toastr.warning(`Invalid value. Must be between ${$(this).attr('min')} and ${$(this).attr('max')}`); |
| 12441 | //newSlider.val(valueBeforeManualInput) |
| 12442 | $(this).val(valueBeforeManualInput); |
| 12443 | } |
| 12444 | } |
| 12445 | } |
| 12446 | }); |
| 12447 | |
| 12448 | $(document).on('keyup', '.range-block-counter input, .neo-range-input', function () { |
| 12449 | valueBeforeManualInput = $(this).val(); |
| 12450 | isManualInput = true; |
| 12451 | }); |
| 12452 | |
| 12453 | //trigger slider changes when user clicks away |
| 12454 | $(document).on('mouseup blur', '.range-block-counter input, .neo-range-input', function () { |
| 12455 | const masterSelector = '#' + $(this).data('for'); |
| 12456 | const masterElement = $(masterSelector); |
| 12457 | let manualInput = Number($(this).val()); |
| 12458 | if (isManualInput) { |
| 12459 | //if value is between correct range for the slider |
| 12460 | if (manualInput >= Number($(this).attr('min')) && manualInput <= Number($(this).attr('max'))) { |
| 12461 | valueBeforeManualInput = manualInput; |
| 12462 | //set the slider value to input value |
| 12463 | $(masterElement).val($(this).val()).trigger('input', { forced: true }); |
| 12464 | } else { |
| 12465 | //if value not ok, warn and reset to last known valid value |
| 12466 | toastr.warning(`Invalid value. Must be between ${$(this).attr('min')} and ${$(this).attr('max')}`); |
| 12467 | $(this).val(valueBeforeManualInput); |
| 12468 | } |
| 12469 | } |
| 12470 | isManualInput = false; |
| 12471 | }); |
| 12472 | |
| 12473 | $('.user_stats_button').on('click', function () { |
| 12474 | userStatsHandler(); |
| 12475 | }); |
| 12476 | |
| 12477 | $(document).on('click', '.external_import_button, #external_import_button', async () => { |
| 12478 | const html = await renderTemplateAsync('importCharacters'); |
| 12479 | const input = await callGenericPopup(html, POPUP_TYPE.INPUT, '', { allowVerticalScrolling: true, wider: true, okButton: $('#popup_template').attr('popup-button-import'), rows: 4 }); |
| 12480 | |
| 12481 | if (!input) { |
| 12482 | console.debug('Custom content import cancelled'); |
| 12483 | return; |
| 12484 | } |
| 12485 | |
| 12486 | // break input into one input per line |
| 12487 | const inputs = String(input).split('\n').map(x => x.trim()).filter(x => x.length > 0); |
| 12488 | |
| 12489 | for (const url of inputs) { |
| 12490 | await importFromExternalUrl(url); |
| 12491 | } |
| 12492 | }); |
| 12493 | |
| 12494 | charDragDropHandler = new DragAndDropHandler('body', async (files, event) => { |
| 12495 | if (!files.length) { |
| 12496 | await importFromURL(event.originalEvent.dataTransfer.items, files); |
| 12497 | } |
| 12498 | await processDroppedFiles(files); |
| 12499 | }, { noAnimation: true }); |
| 12500 | |
| 12501 | chatDragDropHandler = new DragAndDropHandler('#select_chat_popup', async (_, event) => { |
| 12502 | const importFile = document.getElementById('chat_import_file'); |
| 12503 | if (importFile instanceof HTMLInputElement) { |
| 12504 | importFile.files = event.originalEvent.dataTransfer.files; |
| 12505 | $(importFile).trigger('change'); |
| 12506 | } |
| 12507 | }); |
| 12508 | |
| 12509 | $('#charListGridToggle').on('click', async () => { |
| 12510 | doCharListDisplaySwitch(); |
| 12511 | }); |
| 12512 | |
| 12513 | $('#hideCharPanelAvatarButton').on('click', () => { |
| 12514 | $('#avatar-and-name-block').slideToggle(); |
| 12515 | }); |
| 12516 | |
| 12517 | $(document).on('click', '#show_more_messages', async function (event) { |
| 12518 | event.stopPropagation(); |
| 12519 | event.preventDefault(); |
| 12520 | await showMoreMessages(); |
| 12521 | }); |
| 12522 | |
| 12523 | $(document).on('click', '.open_characters_library', async function () { |
| 12524 | await getCharacters(); |
| 12525 | await eventSource.emit(event_types.OPEN_CHARACTER_LIBRARY); |
| 12526 | }); |
| 12527 | |
| 12528 | // Added here to prevent execution before script.js is loaded and get rid of quirky timeouts |
| 12529 | await firstLoadInit(); |
| 12530 | |
| 12531 | window.addEventListener('beforeunload', (e) => { |
| 12532 | if (isChatSaving || this_edit_mes_id >= 0) { |
| 12533 | e.preventDefault(); |
| 12534 | e.returnValue = true; |
| 12535 | } |
| 12536 | }); |
| 12537 | }); |