Blame Raw
permissionBRICK · 513b8d68 · · 12537 lines (483.4 KB)
9 contributors
1import {
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
14import { humanizedDateTime, favsToHotswap, getMessageTimeStamp, dragElement, isMobile, initRossMods } from './scripts/RossAscends-mods.js';
15import { userStatsHandler, statMesProcess, initStats } from './scripts/stats.js';
16import {
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
27import {
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
38import {
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
54import {
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
77import {
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
99import {
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
116import {
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
129import {
130 initBookmarks,
131 showBookmarksButtons,
132 updateBookmarkDisplay,
133} from './scripts/bookmarks.js';
134
135import {
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
147import {
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';
188import { 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
190import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors } from './scripts/extensions.js';
191import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, initSlashCommandAutoComplete, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js';
192import { initMacroAutoComplete } from './scripts/autocomplete/MacroAutoComplete.js';
193import {
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';
216import { checkOpenRouterAuth, initSecrets, readSecretState } from './scripts/secrets.js';
217import { markdownExclusionExt } from './scripts/showdown-exclusion.js';
218import { markdownUnderscoreExt } from './scripts/showdown-underscore.js';
219import { NOTE_MODULE_NAME, initAuthorsNote, metadata_keys, setFloatingPrompt, shouldWIAddPrompt } from './scripts/authors-note.js';
220import { registerPromptManagerMigration } from './scripts/PromptManager.js';
221import { getRegexedString, regex_placement } from './scripts/extensions/regex/engine.js';
222import { initLogprobs, saveLogprobsForActiveMessage } from './scripts/logprobs.js';
223import { FILTER_STATES, FILTER_TYPES, FilterHelper, isFilterState } from './scripts/filters.js';
224import { getCfgPrompt, getGuidanceScale, initCfg } from './scripts/cfg-scale.js';
225import {
226 force_output_sequence,
227 formatInstructModeChat,
228 formatInstructModePrompt,
229 formatInstructModeExamples,
230 formatInstructModeStoryString,
231 getInstructStoppingSequences,
232} from './scripts/instruct-mode.js';
233import { initLocales, t } from './scripts/i18n.js';
234import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, initTokenizers, saveTokenCache } from './scripts/tokenizers.js';
235import {
236 user_avatar,
237 getUserAvatars,
238 getUserAvatar,
239 setUserAvatar,
240 initPersonas,
241 setPersonaDescription,
242 initUserAvatar,
243 updatePersonaConnectionsAvatarList,
244 isPersonaPanelOpen,
245} from './scripts/personas.js';
246import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_settings } from './scripts/backgrounds.js';
247import { loader } from './scripts/action-loader.js';
248import { BulkEditOverlay } from './scripts/BulkEditOverlay.js';
249import { initTextGenModels } from './scripts/textgen-models.js';
250import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, preserveNeutralChat, restoreNeutralChat, formatCreatorNotes, initChatUtilities, addDOMPurifyHooks } from './scripts/chats.js';
251import { getPresetManager, initPresetManager } from './scripts/preset-manager.js';
252import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js';
253import { currentUser, setUserControls } from './scripts/user.js';
254import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup, fixToastrForDialogs } from './scripts/popup.js';
255import { renderTemplate, renderTemplateAsync } from './scripts/templates.js';
256import { initScrapers } from './scripts/scrapers.js';
257import { initCustomSelectedSamplers, validateDisabledSamplers } from './scripts/samplerSelect.js';
258import { DragAndDropHandler } from './scripts/dragdrop.js';
259import { INTERACTABLE_CONTROL_CLASS, initKeyboard } from './scripts/keyboard.js';
260import { initDynamicStyles } from './scripts/dynamic-styles.js';
261import { initInputMarkdown } from './scripts/input-md-formatting.js';
262import { AbortReason } from './scripts/util/AbortReason.js';
263import { initSystemPrompts } from './scripts/sysprompt.js';
264import { registerExtensionSlashCommands as initExtensionSlashCommands } from './scripts/extensions-slashcommands.js';
265import { ToolManager } from './scripts/tool-calling.js';
266import { addShowdownPatch } from './scripts/util/showdown-patch.js';
267import { applyBrowserFixes } from './scripts/browser-fixes.js';
268import { initServerHistory } from './scripts/server-history.js';
269import { initSettingsSearch } from './scripts/setting-search.js';
270import { initBulkEdit } from './scripts/bulk-edit.js';
271import { getContext } from './scripts/st-context.js';
272import { extractReasoningFromData, extractReasoningSignatureFromData, initReasoning, parseReasoningInSwipes, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningUI } from './scripts/reasoning.js';
273import { accountStorage } from './scripts/util/AccountStorage.js';
274import { initWelcomeScreen, openPermanentAssistantChat, openPermanentAssistantCard, getPermanentAssistantAvatar } from './scripts/welcome-screen.js';
275import { initDataMaid } from './scripts/data-maid.js';
276import { clearItemizedPrompts, deleteItemizedPromptForMessage, deleteItemizedPrompts, findItemizedPromptSet, initItemizedPrompts, itemizedParams, itemizedPrompts, loadItemizedPrompts, promptItemize, replaceItemizedPromptText, saveItemizedPrompts, swapItemizedPrompts } from './scripts/itemized-prompts.js';
277import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js';
278import { event_types, eventSource } from './scripts/events.js';
279import { initAccessibility } from './scripts/a11y.js';
280import { applyStreamFadeIn } from './scripts/util/stream-fadein.js';
281import { initDomHandlers } from './scripts/dom-handlers.js';
282import { SimpleMutex } from './scripts/util/SimpleMutex.js';
283import { AudioPlayer } from './scripts/audio-player.js';
284import { MacroEnvBuilder } from './scripts/macros/engine/MacroEnvBuilder.js';
285import { MacroEngine } from './scripts/macros/engine/MacroEngine.js';
286import { addChatBackupsBrowser } from './scripts/chat-backups.js';
287import { onboardingExperimentalMacroEngine } from './scripts/macros/engine/MacroDiagnostics.js';
288import { compressRequest, setRequestCompressionConfig } from './scripts/request-compression.js';
289import { canJumpToSwipeForMessage, canOpenSwipePickerForMessage, initSwipePicker } from './scripts/swipe-picker.js';
290
291// API OBJECT FOR EXTERNAL WIRING
292globalThis.SillyTavern = {
293 libs,
294 getContext,
295};
296
297export {
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 */
338await new Promise((resolve) => {
339 if (document.readyState === 'complete') {
340 resolve();
341 } else {
342 window.addEventListener('load', resolve);
343 }
344});
345
346// Configure toast library:
347toastr.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
368toastr.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
395export const characterGroupOverlay = new BulkEditOverlay();
396
397// Markdown converter
398export let mesForShowdownParse; //intended to be used as a context to compare showdown strings against
399/** @type {import('showdown').Converter} */
400export let converter;
401
402// array for prompt token calculations
403
404export const systemUserName = 'SillyTavern System';
405export const neutralCharacterName = 'Assistant';
406let default_user_name = 'User';
407export let name1 = default_user_name;
408export let name2 = systemUserName;
409/** @type {ChatMessage[]} */
410export let chat = [];
411
412/**
413 * @type {import('./scripts/constants.js').SWIPE_STATE}
414 */
415export let swipeState = SWIPE_STATE.NONE;
416let chatSaveTimeout;
417let importFlashTimeout;
418export let isChatSaving = false;
419let firstRun = false;
420export let settingsReady = false;
421let currentVersion = '0.0.0';
422export let displayVersion = 'SillyTavern';
423
424let generation_started = new Date();
425/** @type {Character[]} */
426export 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 */
431export let this_chid;
432let saveCharactersPage = 0;
433export const default_avatar = 'img/ai4.png';
434export const system_avatar = 'img/five.png';
435export const comment_avatar = 'img/quill.png';
436export const default_user_avatar = 'img/user-default.png';
437export let CLIENT_VERSION = 'SillyTavern:UNKNOWN:Cohee#1207'; // For Horde header
438let optionsPopper = Popper.createPopper(document.getElementById('options_button'), document.getElementById('options'), {
439 placement: 'top-start',
440});
441let exportPopper = Popper.createPopper(document.getElementById('export_button'), document.getElementById('export_format_popup'), {
442 placement: 'left',
443});
444let isExportPopupOpen = false;
445
446// Saved here for performance reasons
447const messageTemplate = $('#message_template .mes');
448export const chatElement = $('#chat');
449
450let dialogueResolve = null;
451let dialogueCloseStop = false;
452/** @type {ChatMetadata} */
453export let chat_metadata = {};
454/** @type {StreamingProcessor} */
455export let streamingProcessor = null;
456let crop_data = undefined;
457let is_delete_mode = false;
458let fav_ch_checked = false;
459let scrollLock = false;
460export let abortStatusCheck = new AbortController();
461export let charDragDropHandler = null;
462export let chatDragDropHandler = null;
463
464/** @type {debounce_timeout} The debounce timeout used for chat/settings save. debounce_timeout.long: 1.000 ms */
465export const DEFAULT_SAVE_EDIT_TIMEOUT = debounce_timeout.relaxed;
466/** @type {debounce_timeout} The debounce timeout used for printing. debounce_timeout.quick: 100 ms */
467export const DEFAULT_PRINT_TIMEOUT = debounce_timeout.quick;
468
469export const saveSettingsDebounced = debounce((loopCounter = 0) => saveSettings(loopCounter), DEFAULT_SAVE_EDIT_TIMEOUT);
470export 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 */
478export const printCharactersDebounced = debounce(() => { printCharacters(false); }, DEFAULT_PRINT_TIMEOUT);
479
480/**
481 * @enum {number} Extension prompt types
482 */
483export 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 */
493export const extension_prompt_roles = {
494 SYSTEM: 0,
495 USER: 1,
496 ASSISTANT: 2,
497};
498
499export const MAX_INJECTION_DEPTH = 10000;
500
501async 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
520export 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
540export 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
548export const talkativeness_default = 0.5;
549export const depth_prompt_depth_default = 4;
550export const depth_prompt_role_default = 'system';
551const per_page_default = 50;
552
553var 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 */
564export let menu_type = '';
565
566export let selected_button = ''; //which button pressed
567
568//create pole save
569export 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
595export const ANIMATION_DURATION_DEFAULT = 125;
596export let animation_duration = ANIMATION_DURATION_DEFAULT;
597export let animation_easing = 'ease-in-out';
598let popup_type = '';
599let chat_file_for_del = '';
600export let online_status = 'no_connection';
601
602export let is_send_press = false; //Send generation
603export const isGenerating = () => (is_send_press || is_group_generating);
604
605let this_del_mes = -1;
606
607/** @type {string} */
608let this_edit_mes_chname = '';
609/** @type {number|undefined} */
610let this_edit_mes_id = undefined;
611
612//settings
613export let settings;
614export let amount_gen = 80; //default max length of AI generated responses
615export let max_context = 2048;
616
617/** User preference for swipeable messages */
618let swipes = true;
619/** Forcefully hide swipes. */
620export let swipesHidden = false;
621/** @type {{ now: number, direction: string }} */
622export let lastSwipeInfo = { now: performance.now(), direction: SWIPE_DIRECTION.RIGHT };
623export let recentSwipes = 0;
624
625export let extension_prompts = {};
626
627export let main_api;// = "kobold";
628let abortController = new AbortController();
629
630//css
631var css_send_form_display = $('<div id=send_form></div>').css('display');
632
633var kobold_horde_model = '';
634
635export let token;
636
637
638/** The tag of the active character. (NOT the id) */
639export let active_character = '';
640/** The tag of the active group. (Coincidentally also the id) */
641export let active_group = '';
642
643export const entitiesFilter = new FilterHelper(printCharactersDebounced);
644
645export 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
658export 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 */
673export 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
692async 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
791async function fixViewport() {
792 document.body.style.position = 'absolute';
793 await delay(1);
794 document.body.style.position = '';
795}
796
797function initStandaloneMode() {
798 const isPwaMode = window.matchMedia('(display-mode: standalone)').matches;
799 if (isPwaMode) {
800 $('body').addClass('PWA');
801 }
802}
803
804export function cancelStatusCheck(reason = 'Manually cancelled status check') {
805 abortStatusCheck?.abort(new AbortReason(reason));
806 abortStatusCheck = new AbortController();
807 setOnlineStatus('no_connection');
808}
809
810export 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 */
824export 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 */
834export 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 */
843export function setActiveGroup(entityOrKey) {
844 active_group = entityOrKey ? getTagKeyForEntity(entityOrKey) : null;
845 if (active_group) active_character = null;
846}
847
848export function startStatusLoading() {
849 $('.api_loading').show();
850 $('.api_button').addClass('disabled');
851}
852
853export function stopStatusLoading() {
854 $('.api_loading').hide();
855 $('.api_button').removeClass('disabled');
856}
857
858export 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 */
873export 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
909function getBackBlock() {
910 const template = $('#bogus_folder_back_template .bogus_folder_select').clone();
911 return template;
912}
913
914async 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 */
929async 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
937function 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 */
991export 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 */
1084function 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 */
1120export 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 */
1130export 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 */
1140export 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 */
1154export 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
1221export 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
1245export 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
1292export 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
1336async 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 */
1362export 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
1402export 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
1431export 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
1475export 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 */
1497export 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
1532export 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 */
1571export 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 */
1584export 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
1605export 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 */
1618export 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
1675export const reloadChatMutex = new SimpleMutex(reloadCurrentChatUnsafe);
1676export 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 */
1683export 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 */
1705export 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 */
1753export 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('<', '&lt;').replace(new RegExp('(?<!^|\\n\\s*)>', 'g'), '&gt;')
1823 : mes.replaceAll('<', '&lt;').replaceAll('>', '&gt;');
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(/&amp;/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 */
1923export 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 */
1937function 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 */
1974export 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 */
1991export 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 */
2130export 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 */
2140export 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 */
2157export 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
2420export 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 */
2447function 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 */
2464function 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 */
2492export 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 */
2559export 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 */
2653export 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
2664export 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 */
2681function 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
2707let 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 */
2714export 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 */
2756export 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 */
2772export 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 */
2922export 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 */
2966export 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 */
3025export 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 */
3066export 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 */
3081export 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 */
3112function 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
3144function 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 */
3172async 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 */
3198export 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 */
3222export 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 */
3242export 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 */
3282export 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 */
3316export 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 */
3343export 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 */
3417export 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 */
3442export 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
3458export 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
3469function showStopButton() {
3470 $('#mes_stop').css({ 'display': 'flex' });
3471}
3472
3473function 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
3481class 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 */
3865export 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 */
3941export 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 */
4063export 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
4094class 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 */
4188function 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 */
4231export 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 */
5548export 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 */
5569async 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
5619function 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 */
5634function 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
5647export 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 */
5657export 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 */
5724export 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
5735export 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 */
5774function 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 */
5801export 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 */
5815export 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 */
5870export 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 */
5907export 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 */
5922export 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
5930function 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
5954function addChatsPreamble(mesSendString) {
5955 return main_api === 'novel'
5956 ? substituteParams(nai_settings.preamble) + '\n' + mesSendString
5957 : mesSendString;
5958}
5959
5960function 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 */
5975export 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
6024function 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 */
6057export 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 */
6088export 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 */
6113export 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
6128function 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 */
6144function 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 * */
6176function 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 */
6217export 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 */
6252export 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 */
6315function 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 */
6383export 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 */
6544async 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 */
6583export 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 */
6778export 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 */
6837export 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 */
6895export 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 */
6967function 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
6980export 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
6991export 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 */
7016export 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 */
7026export function deactivateSendButtons() {
7027 showStopButton();
7028 hideSwipeButtons();
7029 document.body.dataset.generating = 'true';
7030}
7031
7032export 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 */
7049export 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
7055export function setExternalAbortController(controller) {
7056 abortController = controller;
7057}
7058
7059/**
7060 * Sets a character array index.
7061 * @param {number|string|undefined} value
7062 */
7063export 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
7084export 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 */
7092export 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
7101export function setEditedMessageId(value) {
7102 this_edit_mes_id = value;
7103}
7104
7105export 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
7126export 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
7246async 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
7302export 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 */
7336export 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 */
7428async 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 */
7489export function getThumbnailUrl(type, file, t = false) {
7490 return `/thumbnail?type=${type}&file=${encodeURIComponent(file)}${t ? `&t=${Date.now()}` : ''}`;
7491}
7492
7493export 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 */
7548export 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
7575export 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
7625async 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
7651function 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
7685export 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
7697export 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
7814export 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
7826async 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
7842function 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///////////////////////////////////////////
7854export 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()
7992export 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 */
8062export 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
8080function 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
8136function 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
8158function 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 */
8180export 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 */
8245async 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 */
8293async 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
8337async 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 */
8387export 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 */
8446export 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 */
8472export 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 */
8491export 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
8521async 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
8572export 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
8596export 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 */
8702export 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 */
8789function 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
8849function 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 */
8866export 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 */
8882export 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 */
8905export 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 */
8918export 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 */
8927function 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
8936export 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 */
9007export 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 */
9069export 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 */
9102export 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 */
9123export 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 */
9163export 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 */
9190export 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 */
9253export 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 */
9263export 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 */
9279export 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
9348export async function saveMetadata() {
9349 return await saveChatConditional();
9350}
9351
9352export 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 */
9388export 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
9407export 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
9421export 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
9427export 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 */
9454export 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
9470export 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
9481export function cancelTtsPlay() {
9482 if ('speechSynthesis' in window) {
9483 speechSynthesis.cancel();
9484 }
9485}
9486
9487function updateAlternateGreetingsHintVisibility(root) {
9488 const numberOfGreetings = root.find('.alternate_greetings_list .alternate_greeting').length;
9489 $(root).find('.alternate_grettings_hint').toggle(numberOfGreetings == 0);
9490}
9491
9492async 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
9562function 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 */
9614function 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 */
9688export 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 */
9875function 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 */
9894export 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 */
10377export 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
10391export 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 */
10401export 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 */
10440async 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 */
10454function 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 */
10470async 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
10536async 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
10558export 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 */
10598export 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 */
10671export 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 */
10686export 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 */
10714export 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
10736function 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 */
10752export 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 */
10768export 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 */
10843async 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 */
10862export 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 */
10879function 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 */
10893export 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
10932function 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\`\`\`
10985API: ${getSettingsContents.main_api}
10986API Type: ${getSettingsContents[getSettingsContents.main_api + '_settings'].type}
10987API server: ${getSettingsContents.api_server}
10988Model: ${getContextContents.onlineStatus}
10989Context Template: ${power_user.context.preset}
10990Instruct Template: ${power_user.instruct.preset}
10991API 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
11007function 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
11041jQuery(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});