import {
showdown,
moment,
DOMPurify,
hljs,
Handlebars,
SVGInject,
Popper,
initLibraryShims,
default as libs,
} from './lib.js';
import { humanizedDateTime, favsToHotswap, getMessageTimeStamp, dragElement, isMobile, initRossMods } from './scripts/RossAscends-mods.js';
import { userStatsHandler, statMesProcess, initStats } from './scripts/stats.js';
import {
generateKoboldWithStreaming,
kai_settings,
loadKoboldSettings,
getKoboldGenerationData,
kai_flags,
koboldai_settings,
koboldai_setting_names,
initKoboldSettings,
} from './scripts/kai-settings.js';
import {
textgenerationwebui_settings as textgen_settings,
loadTextGenSettings,
generateTextGenWithStreaming,
getTextGenGenerationData,
textgen_types,
parseTextgenLogprobs,
parseTabbyLogprobs,
initTextGenSettings,
} from './scripts/textgen-settings.js';
import {
world_info,
getWorldInfoPrompt,
getWorldInfoSettings,
setWorldInfoSettings,
world_names,
importEmbeddedWorldInfo,
checkEmbeddedWorld,
setWorldInfoButtonClass,
wi_anchor_position,
world_info_include_names,
initWorldInfo,
charUpdatePrimaryWorld,
charSetAuxWorlds,
} from './scripts/world-info.js';
import {
groups,
selected_group,
saveGroupChat,
getGroups,
generateGroupWrapper,
is_group_generating,
resetSelectedGroup,
select_group_chats,
regenerateGroup,
group_generation_id,
getGroupChat,
renameGroupMember,
createNewGroupChat,
getGroupAvatar,
editGroup,
deleteGroupChat,
renameGroupChat,
importGroupChat,
getGroupBlock,
getGroupCharacterCards,
getGroupDepthPrompts,
} from './scripts/group-chats.js';
import {
collapseNewlines,
loadPowerUserSettings,
playMessageSound,
fixMarkdown,
power_user,
persona_description_positions,
loadMovingUIState,
getCustomStoppingStrings,
MAX_CONTEXT_DEFAULT,
MAX_RESPONSE_DEFAULT,
renderStoryString,
sortEntitiesList,
registerDebugFunction,
flushEphemeralStoppingStrings,
resetMovableStyles,
forceCharacterEditorTokenize,
applyPowerUserSettings,
generatedTextFiltered,
applyStylePins,
} from './scripts/power-user.js';
import {
setOpenAIMessageExamples,
setOpenAIMessages,
setupChatCompletionPromptManager,
prepareOpenAIMessages,
sendOpenAIRequest,
loadOpenAISettings,
oai_settings,
openai_messages_count,
chat_completion_sources,
getChatCompletionModel,
proxies,
loadProxyPresets,
selected_proxy,
initOpenAI,
} from './scripts/openai.js';
import {
generateNovelWithStreaming,
getNovelGenerationData,
getKayraMaxContextTokens,
loadNovelSettings,
nai_settings,
adjustNovelInstructionPrompt,
parseNovelAILogprobs,
novelai_settings,
novelai_setting_names,
initNovelAISettings,
} from './scripts/nai-settings.js';
import {
initBookmarks,
showBookmarksButtons,
updateBookmarkDisplay,
} from './scripts/bookmarks.js';
import {
horde_settings,
loadHordeSettings,
generateHorde,
getStatusHorde,
getHordeModels,
adjustHordeGenerationParams,
isHordeGenerationNotAllowed,
MIN_LENGTH,
initHorde,
} from './scripts/horde.js';
import {
debounce,
delay,
trimToEndSentence,
countOccurrences,
isOdd,
sortMoments,
timestampToMoment,
download,
isDataURL,
getCharaFilename,
PAGINATION_TEMPLATE,
waitUntilCondition,
escapeRegex,
resetScrollHeight,
onlyUnique,
getBase64Async,
humanFileSize,
Stopwatch,
isValidUrl,
ensureImageFormatSupported,
flashHighlight,
toggleDrawer,
isElementInViewport,
copyText,
escapeHtml,
saveBase64AsFile,
uuidv4,
equalsIgnoreCaseAndAccents,
localizePagination,
renderPaginationDropdown,
paginationDropdownChangeHandler,
importFromExternalUrl,
shiftUpByOne,
shiftDownByOne,
canUseNegativeLookbehind,
trimSpaces,
clamp,
} from './scripts/utils.js';
import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids, MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, SCROLL_BEHAVIOR, SWIPE_DIRECTION } from './scripts/constants.js';
import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors } from './scripts/extensions.js';
import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js';
import {
tag_map,
tags,
filterByTagState,
isBogusFolder,
isBogusFolderOpen,
chooseBogusFolder,
getTagBlock,
loadTagsSettings,
printTagFilters,
getTagKeyForEntity,
printTagList,
createTagMapFromList,
renameTagKey,
importTags,
tag_filter_type,
compareTagsForSort,
initTags,
applyTagsOnCharacterSelect,
applyTagsOnGroupSelect,
tag_import_setting,
applyCharacterTagsToMessageDivs,
} from './scripts/tags.js';
import { initSecrets, readSecretState } from './scripts/secrets.js';
import { markdownExclusionExt } from './scripts/showdown-exclusion.js';
import { markdownUnderscoreExt } from './scripts/showdown-underscore.js';
import { NOTE_MODULE_NAME, initAuthorsNote, metadata_keys, setFloatingPrompt, shouldWIAddPrompt } from './scripts/authors-note.js';
import { registerPromptManagerMigration } from './scripts/PromptManager.js';
import { getRegexedString, regex_placement } from './scripts/extensions/regex/engine.js';
import { initLogprobs, saveLogprobsForActiveMessage } from './scripts/logprobs.js';
import { FILTER_STATES, FILTER_TYPES, FilterHelper, isFilterState } from './scripts/filters.js';
import { getCfgPrompt, getGuidanceScale, initCfg } from './scripts/cfg-scale.js';
import {
force_output_sequence,
formatInstructModeChat,
formatInstructModePrompt,
formatInstructModeExamples,
formatInstructModeStoryString,
getInstructStoppingSequences,
} from './scripts/instruct-mode.js';
import { initLocales, t } from './scripts/i18n.js';
import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, initTokenizers, saveTokenCache } from './scripts/tokenizers.js';
import {
user_avatar,
getUserAvatars,
getUserAvatar,
setUserAvatar,
initPersonas,
setPersonaDescription,
initUserAvatar,
updatePersonaConnectionsAvatarList,
isPersonaPanelOpen,
} from './scripts/personas.js';
import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_settings } from './scripts/backgrounds.js';
import { hideLoader, showLoader } from './scripts/loader.js';
import { BulkEditOverlay } from './scripts/BulkEditOverlay.js';
import { initTextGenModels } from './scripts/textgen-models.js';
import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, preserveNeutralChat, restoreNeutralChat, formatCreatorNotes, initChatUtilities, addDOMPurifyHooks } from './scripts/chats.js';
import { getPresetManager, initPresetManager } from './scripts/preset-manager.js';
import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js';
import { currentUser, setUserControls } from './scripts/user.js';
import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup, fixToastrForDialogs } from './scripts/popup.js';
import { renderTemplate, renderTemplateAsync } from './scripts/templates.js';
import { initScrapers } from './scripts/scrapers.js';
import { initCustomSelectedSamplers, validateDisabledSamplers } from './scripts/samplerSelect.js';
import { DragAndDropHandler } from './scripts/dragdrop.js';
import { INTERACTABLE_CONTROL_CLASS, initKeyboard } from './scripts/keyboard.js';
import { initDynamicStyles } from './scripts/dynamic-styles.js';
import { initInputMarkdown } from './scripts/input-md-formatting.js';
import { AbortReason } from './scripts/util/AbortReason.js';
import { initSystemPrompts } from './scripts/sysprompt.js';
import { registerExtensionSlashCommands as initExtensionSlashCommands } from './scripts/extensions-slashcommands.js';
import { ToolManager } from './scripts/tool-calling.js';
import { addShowdownPatch } from './scripts/util/showdown-patch.js';
import { applyBrowserFixes } from './scripts/browser-fixes.js';
import { initServerHistory } from './scripts/server-history.js';
import { initSettingsSearch } from './scripts/setting-search.js';
import { initBulkEdit } from './scripts/bulk-edit.js';
import { getContext } from './scripts/st-context.js';
import { extractReasoningFromData, initReasoning, parseReasoningInSwipes, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningUI } from './scripts/reasoning.js';
import { accountStorage } from './scripts/util/AccountStorage.js';
import { initWelcomeScreen, openPermanentAssistantChat, openPermanentAssistantCard, getPermanentAssistantAvatar } from './scripts/welcome-screen.js';
import { initDataMaid } from './scripts/data-maid.js';
import { clearItemizedPrompts, deleteItemizedPrompts, findItemizedPromptSet, initItemizedPrompts, itemizedParams, itemizedPrompts, loadItemizedPrompts, promptItemize, replaceItemizedPromptText, saveItemizedPrompts } from './scripts/itemized-prompts.js';
import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js';
import { event_types, eventSource } from './scripts/events.js';
import { initAccessibility } from './scripts/a11y.js';
import { applyStreamFadeIn } from './scripts/util/stream-fadein.js';
import { initDomHandlers } from './scripts/dom-handlers.js';
import { SimpleMutex } from './scripts/util/SimpleMutex.js';
import { AudioPlayer } from './scripts/audio-player.js';
// API OBJECT FOR EXTERNAL WIRING
globalThis.SillyTavern = {
libs,
getContext,
};
export {
user_avatar,
setUserAvatar,
getUserAvatars,
getUserAvatar,
nai_settings,
isOdd,
countOccurrences,
renderTemplate,
promptItemize,
itemizedPrompts,
saveItemizedPrompts,
loadItemizedPrompts,
itemizedParams,
clearItemizedPrompts,
replaceItemizedPromptText,
deleteItemizedPrompts,
findItemizedPromptSet,
koboldai_settings,
koboldai_setting_names,
novelai_settings,
novelai_setting_names,
UNIQUE_APIS,
CONNECT_API_MAP,
system_messages,
system_message_types,
sendSystemMessage,
getSystemMessageByType,
event_types,
eventSource,
/** @deprecated Use setCharacterSettingsOverrides instead. */
setCharacterSettingsOverrides as setScenarioOverride,
/** @deprecated Use appendMediaToMessage instead. */
appendMediaToMessage as appendImageToMessage,
};
/**
* Wait for page to load before continuing the app initialization.
*/
await new Promise((resolve) => {
if (document.readyState === 'complete') {
resolve();
} else {
window.addEventListener('load', resolve);
}
});
// Configure toast library:
toastr.options = {
positionClass: 'toast-top-center',
closeButton: false,
progressBar: false,
showDuration: 250,
hideDuration: 250,
timeOut: 4000,
extendedTimeOut: 10000,
showEasing: 'linear',
hideEasing: 'linear',
showMethod: 'fadeIn',
hideMethod: 'fadeOut',
escapeHtml: true,
onHidden: function () {
// 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
// so the toasts still show up inside there.
fixToastrForDialogs();
},
onShown: function () {
// Set tooltip to the notification message
$(this).attr('title', t`Tap to close`);
},
};
export const characterGroupOverlay = new BulkEditOverlay();
// Markdown converter
export let mesForShowdownParse; //intended to be used as a context to compare showdown strings against
/** @type {import('showdown').Converter} */
export let converter;
// array for prompt token calculations
export const systemUserName = 'SillyTavern System';
export const neutralCharacterName = 'Assistant';
let default_user_name = 'User';
export let name1 = default_user_name;
export let name2 = systemUserName;
/** @type {ChatMessage[]} */
export let chat = [];
export let isSwipingAllowed = true; //false when a swipe is in progress, or swiping is blocked.
let chatSaveTimeout;
let importFlashTimeout;
export let isChatSaving = false;
let chat_create_date = '';
let firstRun = false;
let settingsReady = false;
let currentVersion = '0.0.0';
export let displayVersion = 'SillyTavern';
let generation_started = new Date();
/** @type {import('./scripts/char-data.js').v1CharData[]} */
export let characters = [];
/**
* Stringified index of a currently chosen entity in the characters array.
* @type {string|undefined} Yes, we hate it as much as you do.
*/
export let this_chid;
let saveCharactersPage = 0;
export const default_avatar = 'img/ai4.png';
export const system_avatar = 'img/five.png';
export const comment_avatar = 'img/quill.png';
export const default_user_avatar = 'img/user-default.png';
export let CLIENT_VERSION = 'SillyTavern:UNKNOWN:Cohee#1207'; // For Horde header
let optionsPopper = Popper.createPopper(document.getElementById('options_button'), document.getElementById('options'), {
placement: 'top-start',
});
let exportPopper = Popper.createPopper(document.getElementById('export_button'), document.getElementById('export_format_popup'), {
placement: 'left',
});
let isExportPopupOpen = false;
// Saved here for performance reasons
const messageTemplate = $('#message_template .mes');
export const chatElement = $('#chat');
let dialogueResolve = null;
let dialogueCloseStop = false;
export let chat_metadata = {};
/** @type {StreamingProcessor} */
export let streamingProcessor = null;
let crop_data = undefined;
let is_delete_mode = false;
let fav_ch_checked = false;
let scrollLock = false;
export let abortStatusCheck = new AbortController();
export let charDragDropHandler = null;
export let chatDragDropHandler = null;
/** @type {debounce_timeout} The debounce timeout used for chat/settings save. debounce_timeout.long: 1.000 ms */
export const DEFAULT_SAVE_EDIT_TIMEOUT = debounce_timeout.relaxed;
/** @type {debounce_timeout} The debounce timeout used for printing. debounce_timeout.quick: 100 ms */
export const DEFAULT_PRINT_TIMEOUT = debounce_timeout.quick;
export const saveSettingsDebounced = debounce((loopCounter = 0) => saveSettings(loopCounter), DEFAULT_SAVE_EDIT_TIMEOUT);
export const saveCharacterDebounced = debounce(() => $('#create_button').trigger('click'), DEFAULT_SAVE_EDIT_TIMEOUT);
/**
* Prints the character list in a debounced fashion without blocking, with a delay of 100 milliseconds.
* Use this function instead of a direct `printCharacters()` whenever the reprinting of the character list is not the primary focus.
*
* The printing will also always reprint all filter options of the global list, to keep them up to date.
*/
export const printCharactersDebounced = debounce(() => { printCharacters(false); }, DEFAULT_PRINT_TIMEOUT);
/**
* @enum {number} Extension prompt types
*/
export const extension_prompt_types = {
NONE: -1,
IN_PROMPT: 0,
IN_CHAT: 1,
BEFORE_PROMPT: 2,
};
/**
* @enum {number} Extension prompt roles
*/
export const extension_prompt_roles = {
SYSTEM: 0,
USER: 1,
ASSISTANT: 2,
};
export const MAX_INJECTION_DEPTH = 10000;
async function getClientVersion() {
try {
const response = await fetch('/version');
const data = await response.json();
CLIENT_VERSION = data.agent;
displayVersion = `SillyTavern ${data.pkgVersion}`;
currentVersion = data.pkgVersion;
if (data.gitRevision && data.gitBranch) {
displayVersion += ` '${data.gitBranch}' (${data.gitRevision})`;
}
$('#version_display').text(displayVersion);
$('#version_display_welcome').text(displayVersion);
} catch (err) {
console.error('Couldn\'t get client version', err);
}
}
export function reloadMarkdownProcessor() {
converter = new showdown.Converter({
emoji: true,
literalMidWordUnderscores: true,
parseImgDimensions: true,
tables: true,
underline: true,
simpleLineBreaks: true,
strikethrough: true,
disableForced4SpacesIndentedSublists: true,
extensions: [markdownUnderscoreExt()],
});
// Inject the dinkus extension after creating the converter
// Maybe move this into power_user init?
converter.addExtension(markdownExclusionExt(), 'exclusion');
return converter;
}
export function getCurrentChatId() {
if (selected_group) {
return groups.find(x => x.id == selected_group)?.chat_id;
}
else if (this_chid !== undefined) {
return characters[this_chid]?.chat;
}
}
export const talkativeness_default = 0.5;
export const depth_prompt_depth_default = 4;
export const depth_prompt_role_default = 'system';
const per_page_default = 50;
var is_advanced_char_open = false;
/**
* The type of the right menu
* @typedef {'characters' | 'character_edit' | 'create' | 'group_edit' | 'group_create' | '' } MenuType
*/
/**
* The type of the right menu that is currently open
* @type {MenuType}
*/
export let menu_type = '';
export let selected_button = ''; //which button pressed
//create pole save
export let create_save = {
name: '',
description: '',
creator_notes: '',
post_history_instructions: '',
character_version: '',
system_prompt: '',
tags: '',
creator: '',
personality: '',
first_message: '',
/** @type {FileList|null} */
avatar: null,
scenario: '',
mes_example: '',
world: '',
talkativeness: talkativeness_default,
alternate_greetings: [],
depth_prompt_prompt: '',
depth_prompt_depth: depth_prompt_depth_default,
depth_prompt_role: depth_prompt_role_default,
extensions: {},
extra_books: [],
};
//animation right menu
export const ANIMATION_DURATION_DEFAULT = 125;
export let animation_duration = ANIMATION_DURATION_DEFAULT;
export let animation_easing = 'ease-in-out';
let popup_type = '';
let chat_file_for_del = '';
export let online_status = 'no_connection';
export let is_send_press = false; //Send generation
let this_del_mes = -1;
/** @type {string} */
let this_edit_mes_chname = '';
/** @type {number|undefined} */
let this_edit_mes_id = undefined;
//settings
export let settings;
export let amount_gen = 80; //default max length of AI generated responses
export let max_context = 2048;
var swipes = true;
export let extension_prompts = {};
export let main_api;// = "kobold";
/** @type {AbortController} */
let abortController;
//css
var css_send_form_display = $('
').css('display');
var kobold_horde_model = '';
export let token;
/** The tag of the active character. (NOT the id) */
export let active_character = '';
/** The tag of the active group. (Coincidentally also the id) */
export let active_group = '';
export const entitiesFilter = new FilterHelper(printCharactersDebounced);
export function getRequestHeaders({ omitContentType = false } = {}) {
const headers = {
'Content-Type': 'application/json',
'X-CSRF-Token': token,
};
if (omitContentType) {
delete headers['Content-Type'];
}
return headers;
}
export function getSlideToggleOptions() {
return {
miliseconds: animation_duration * 1.5,
transitionFunction: animation_duration > 0 ? 'ease-in-out' : 'step-start',
};
}
$.ajaxPrefilter((options, originalOptions, xhr) => {
xhr.setRequestHeader('X-CSRF-Token', token);
});
/**
* Pings the STserver to check if it is reachable.
* @returns {Promise} True if the server is reachable, false otherwise.
*/
export async function pingServer() {
try {
const result = await fetch('api/ping', {
method: 'POST',
headers: getRequestHeaders(),
});
if (!result.ok) {
return false;
}
return true;
} catch (error) {
console.error('Error pinging server', error);
return false;
}
}
//MARK: firstLoadInit
async function firstLoadInit() {
try {
const tokenResponse = await fetch('/csrf-token');
const tokenData = await tokenResponse.json();
token = tokenData.token;
} catch {
toastr.error(t`Couldn't get CSRF token. Please refresh the page.`, t`Error`, { timeOut: 0, extendedTimeOut: 0, preventDuplicates: true });
throw new Error('Initialization failed');
}
showLoader();
registerPromptManagerMigration();
initDomHandlers();
initStandaloneMode();
initLibraryShims();
addShowdownPatch(showdown);
addDOMPurifyHooks();
reloadMarkdownProcessor();
applyBrowserFixes();
await getClientVersion();
await initSecrets();
await readSecretState();
await initLocales();
initChatUtilities();
initDefaultSlashCommands();
initTextGenModels();
initOpenAI();
initTextGenSettings();
initKoboldSettings();
initNovelAISettings();
initSystemPrompts();
initExtensions();
initExtensionSlashCommands();
ToolManager.initToolSlashCommands();
await initPresetManager();
await initSystemMessages();
await getSettings();
initKeyboard();
initDynamicStyles();
initTags();
initBookmarks();
initMacros();
await getUserAvatars(true, user_avatar);
await getCharacters();
await getBackgrounds();
await initTokenizers();
initBackgrounds();
initAuthorsNote();
await initPersonas();
initWorldInfo();
initHorde();
initRossMods();
initStats();
initCfg();
initLogprobs();
initInputMarkdown();
initServerHistory();
initSettingsSearch();
initBulkEdit();
initReasoning();
initWelcomeScreen();
await initScrapers();
initCustomSelectedSamplers();
initDataMaid();
initItemizedPrompts();
initAccessibility();
addDebugFunctions();
doDailyExtensionUpdatesCheck();
await hideLoader();
await fixViewport();
await eventSource.emit(event_types.APP_READY);
}
async function fixViewport() {
document.body.style.position = 'absolute';
await delay(1);
document.body.style.position = '';
}
function initStandaloneMode() {
const isPwaMode = window.matchMedia('(display-mode: standalone)').matches;
if (isPwaMode) {
$('body').addClass('PWA');
}
}
function cancelStatusCheck(reason = 'Manually cancelled status check') {
abortStatusCheck?.abort(new AbortReason(reason));
abortStatusCheck = new AbortController();
setOnlineStatus('no_connection');
}
export function displayOnlineStatus() {
if (online_status == 'no_connection') {
$('.online_status_indicator').removeClass('success');
$('.online_status_text').text($('#API-status-top').attr('no_connection_text'));
} else {
$('.online_status_indicator').addClass('success');
$('.online_status_text').text(online_status);
}
}
/**
* Sets the duration of JS animations.
* @param {number} ms Duration in milliseconds. Resets to default if null.
*/
export function setAnimationDuration(ms = null) {
animation_duration = ms ?? ANIMATION_DURATION_DEFAULT;
// Set CSS variable to document
document.documentElement.style.setProperty('--animation-duration', `${animation_duration}ms`);
}
/**
* Sets the currently active character
* @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`.
*/
export function setActiveCharacter(entityOrKey) {
active_character = entityOrKey ? getTagKeyForEntity(entityOrKey) : null;
if (active_character) active_group = null;
}
/**
* Sets the currently active group.
* @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`.
*/
export function setActiveGroup(entityOrKey) {
active_group = entityOrKey ? getTagKeyForEntity(entityOrKey) : null;
if (active_group) active_character = null;
}
export function startStatusLoading() {
$('.api_loading').show();
$('.api_button').addClass('disabled');
}
export function stopStatusLoading() {
$('.api_loading').hide();
$('.api_button').removeClass('disabled');
}
export function resultCheckStatus() {
displayOnlineStatus();
stopStatusLoading();
}
/**
* Switches the currently selected character to the one with the given ID. (character index, not the character key!)
*
* If the character ID doesn't exist, if the chat is being saved, or if a group is being generated, this function does nothing.
* If the character is different from the currently selected one, it will clear the chat and reset any selected character or group.
* @param {number} id The ID of the character to switch to.
* @param {object} [options] Options for the switch.
* @param {boolean} [options.switchMenu=true] Whether to switch the right menu to the character edit menu if the character is already selected.
* @returns {Promise} A promise that resolves when the character is switched.
*/
export async function selectCharacterById(id, { switchMenu = true } = {}) {
if (characters[id] === undefined) {
return;
}
if (isChatSaving) {
toastr.info(t`Please wait until the chat is saved before switching characters.`, t`Your chat is still saving...`);
return;
}
if (selected_group && is_group_generating) {
return;
}
if (selected_group || String(this_chid) !== String(id)) {
//if clicked on a different character from what was currently selected
if (!is_send_press) {
await clearChat();
cancelTtsPlay();
resetSelectedGroup();
this_edit_mes_id = undefined;
selected_button = 'character_edit';
setCharacterId(id);
chat.length = 0;
chat_metadata = {};
await getChat();
}
} else {
//if clicked on character that was already selected
switchMenu && (selected_button = 'character_edit');
await unshallowCharacter(this_chid);
select_selected_character(this_chid, { switchMenu });
}
}
function getBackBlock() {
const template = $('#bogus_folder_back_template .bogus_folder_select').clone();
return template;
}
async function getEmptyBlock() {
const icons = ['fa-dragon', 'fa-otter', 'fa-kiwi-bird', 'fa-crow', 'fa-frog'];
const texts = [t`Here be dragons`, t`Otterly empty`, t`Kiwibunga`, t`Pump-a-Rum`, t`Croak it`];
const roll = new Date().getMinutes() % icons.length;
const params = {
text: texts[roll],
icon: icons[roll],
};
const emptyBlock = await renderTemplateAsync('emptyBlock', params);
return $(emptyBlock);
}
/**
* @param {number} hidden Number of hidden characters
*/
async function getHiddenBlock(hidden) {
const params = {
text: (hidden > 1 ? t`${hidden} characters hidden.` : t`${hidden} character hidden.`),
};
const hiddenBlock = await renderTemplateAsync('hiddenBlock', params);
return $(hiddenBlock);
}
function getCharacterBlock(item, id) {
let this_avatar = default_avatar;
if (item.avatar != 'none') {
this_avatar = getThumbnailUrl('avatar', item.avatar);
}
// Populate the template
const template = $('#character_template .character_select').clone();
template.attr({ 'data-chid': id, 'id': `CharID${id}` });
template.find('img').attr('src', this_avatar).attr('alt', item.name);
template.find('.avatar').attr('title', `[Character] ${item.name}\nFile: ${item.avatar}`);
template.find('.ch_name').text(item.name).attr('title', `[Character] ${item.name}`);
if (power_user.show_card_avatar_urls) {
template.find('.ch_avatar_url').text(item.avatar);
}
template.find('.ch_fav_icon').css('display', 'none');
template.toggleClass('is_fav', item.fav || item.fav == 'true');
template.find('.ch_fav').val(item.fav);
const isAssistant = item.avatar === getPermanentAssistantAvatar();
if (!isAssistant) {
template.find('.ch_assistant').remove();
}
const description = item.data?.creator_notes || '';
if (description) {
template.find('.ch_description').text(description);
}
else {
template.find('.ch_description').hide();
}
const auxFieldName = power_user.aux_field || 'character_version';
const auxFieldValue = (item.data && item.data[auxFieldName]) || '';
if (auxFieldValue) {
template.find('.character_version').text(auxFieldValue);
}
else {
template.find('.character_version').hide();
}
// Display inline tags
const tagsElement = template.find('.tags');
printTagList(tagsElement, { forEntityOrKey: id, tagOptions: { isCharacterList: true } });
// Add to the list
return template;
}
/**
* Prints the global character list, optionally doing a full refresh of the list
* 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.
*
* The printing will also always reprint all filter options of the global list, to keep them up to date.
*
* @param {boolean} fullRefresh - If true, the list is fully refreshed and the navigation is being reset
*/
export async function printCharacters(fullRefresh = false) {
const storageKey = 'Characters_PerPage';
const listId = '#rm_print_characters_block';
let currentScrollTop = $(listId).scrollTop();
if (fullRefresh) {
saveCharactersPage = 0;
currentScrollTop = 0;
await delay(1);
}
// Before printing the personas, we check if we should enable/disable search sorting
verifyCharactersSearchSortRule();
// We are actually always reprinting filters, as it "doesn't hurt", and this way they are always up to date
printTagFilters(tag_filter_type.character);
printTagFilters(tag_filter_type.group_member);
// We are also always reprinting the lists on character/group edit window, as these ones doesn't get updated otherwise
applyTagsOnCharacterSelect();
applyTagsOnGroupSelect();
const entities = getEntitiesList({ doFilter: true });
const pageSize = Number(accountStorage.getItem(storageKey)) || per_page_default;
const sizeChangerOptions = [10, 25, 50, 100, 250, 500, 1000];
$('#rm_print_characters_pagination').pagination({
dataSource: entities,
pageSize,
pageRange: 1,
pageNumber: saveCharactersPage || 1,
position: 'top',
showPageNumbers: false,
showSizeChanger: true,
prevText: '<',
nextText: '>',
formatNavigator: PAGINATION_TEMPLATE,
formatSizeChanger: renderPaginationDropdown(pageSize, sizeChangerOptions),
showNavigator: true,
callback: async function (/** @type {Entity[]} */ data) {
$(listId).empty();
if (power_user.bogus_folders && isBogusFolderOpen()) {
$(listId).append(getBackBlock());
}
if (!data.length) {
const emptyBlock = await getEmptyBlock();
$(listId).append(emptyBlock);
}
let displayCount = 0;
for (const i of data) {
switch (i.type) {
case 'character':
$(listId).append(getCharacterBlock(i.item, i.id));
displayCount++;
break;
case 'group':
$(listId).append(getGroupBlock(i.item));
displayCount++;
break;
case 'tag':
$(listId).append(getTagBlock(i.item, i.entities, i.hidden, i.isUseless));
break;
}
}
const hidden = (characters.length + groups.length) - displayCount;
if (hidden > 0 && entitiesFilter.hasAnyFilter()) {
const hiddenBlock = await getHiddenBlock(hidden);
$(listId).append(hiddenBlock);
}
localizePagination($('#rm_print_characters_pagination'));
eventSource.emit(event_types.CHARACTER_PAGE_LOADED);
},
afterSizeSelectorChange: function (e, size) {
accountStorage.setItem(storageKey, e.target.value);
paginationDropdownChangeHandler(e, size);
},
afterPaging: function (e) {
saveCharactersPage = e;
},
afterRender: function () {
$(listId).scrollTop(currentScrollTop);
},
});
favsToHotswap();
updatePersonaConnectionsAvatarList();
}
/** Checks the state of the current search, and adds/removes the search sorting option accordingly */
function verifyCharactersSearchSortRule() {
const searchTerm = entitiesFilter.getFilterData(FILTER_TYPES.SEARCH);
const searchOption = $('#character_sort_order option[data-field="search"]');
const selector = $('#character_sort_order');
const isHidden = searchOption.attr('hidden') !== undefined;
// If we have a search term, we are displaying the sorting option for it
if (searchTerm && isHidden) {
searchOption.removeAttr('hidden');
searchOption.prop('selected', true);
flashHighlight(selector);
}
// If search got cleared, we make sure to hide the option and go back to the one before
if (!searchTerm && !isHidden) {
searchOption.attr('hidden', '');
$(`#character_sort_order option[data-order="${power_user.sort_order}"][data-field="${power_user.sort_field}"]`).prop('selected', true);
}
}
/** @typedef {object} Character - A character */
/** @typedef {object} Group - A group */
/**
* @typedef {object} Entity - Object representing a display entity
* @property {Character|Group|import('./scripts/tags.js').Tag|*} item - The item
* @property {string|number} id - The id
* @property {'character'|'group'|'tag'} type - The type of this entity (character, group, tag)
* @property {Entity[]?} [entities=null] - An optional list of entities relevant for this item
* @property {number?} [hidden=null] - An optional number representing how many hidden entities this entity contains
* @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
*/
/**
* Converts the given character to its entity representation
*
* @param {Character} character - The character
* @param {string|number} id - The id of this character
* @returns {Entity} The entity for this character
*/
export function characterToEntity(character, id) {
return { item: character, id, type: 'character' };
}
/**
* Converts the given group to its entity representation
*
* @param {Group} group - The group
* @returns {Entity} The entity for this group
*/
export function groupToEntity(group) {
return { item: group, id: group.id, type: 'group' };
}
/**
* Converts the given tag to its entity representation
*
* @param {import('./scripts/tags.js').Tag} tag - The tag
* @returns {Entity} The entity for this tag
*/
export function tagToEntity(tag) {
return { item: structuredClone(tag), id: tag.id, type: 'tag', entities: [] };
}
/**
* Builds the full list of all entities available
*
* They will be correctly marked and filtered.
*
* @param {object} param0 - Optional parameters
* @param {boolean} [param0.doFilter] - Whether this entity list should already be filtered based on the global filters
* @param {boolean} [param0.doSort] - Whether the entity list should be sorted when returned
* @returns {Entity[]} All entities
*/
export function getEntitiesList({ doFilter = false, doSort = true } = {}) {
let entities = [
...characters.map((item, index) => characterToEntity(item, index)),
...groups.map(item => groupToEntity(item)),
...(power_user.bogus_folders ? tags.filter(isBogusFolder).sort(compareTagsForSort).map(item => tagToEntity(item)) : []),
];
// We need to do multiple filter runs in a specific order, otherwise different settings might override each other
// and screw up tags and search filter, sub lists or similar.
// The specific filters are written inside the "filterByTagState" method and its different parameters.
// Generally what we do is the following:
// 1. First swipe over the list to remove the most obvious things
// 2. Build sub entity lists for all folders, filtering them similarly to the second swipe
// 3. We do the last run, where global filters are applied, and the search filters last
// First run filters, that will hide what should never be displayed
if (doFilter) {
entities = filterByTagState(entities);
}
// Run over all entities between first and second filter to save some states
for (const entity of entities) {
// For folders, we remember the sub entities so they can be displayed later, even if they might be filtered
// Those sub entities should be filtered and have the search filters applied too
if (entity.type === 'tag') {
let subEntities = filterByTagState(entities, { subForEntity: entity, filterHidden: false });
const subCount = subEntities.length;
subEntities = filterByTagState(entities, { subForEntity: entity });
if (doFilter) {
// sub entities filter "hacked" because folder filter should not be applied there, so even in "only folders" mode characters show up
subEntities = entitiesFilter.applyFilters(subEntities, { clearScoreCache: false, tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED }, clearFuzzySearchCaches: false });
}
if (doSort) {
sortEntitiesList(subEntities, false);
}
entity.entities = subEntities;
entity.hidden = subCount - subEntities.length;
}
}
// Second run filters, hiding whatever should be filtered later
if (doFilter) {
const beforeFinalEntities = filterByTagState(entities, { globalDisplayFilters: true });
entities = entitiesFilter.applyFilters(beforeFinalEntities, { clearFuzzySearchCaches: false });
// Magic for folder filter. If that one is enabled, and no folders are display anymore, we remove that filter to actually show the characters.
if (isFilterState(entitiesFilter.getFilterData(FILTER_TYPES.FOLDER), FILTER_STATES.SELECTED) && entities.filter(x => x.type == 'tag').length == 0) {
entities = entitiesFilter.applyFilters(beforeFinalEntities, { tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED }, clearFuzzySearchCaches: false });
}
}
// Final step, updating some properties after the last filter run
const nonTagEntitiesCount = entities.filter(entity => entity.type !== 'tag').length;
for (const entity of entities) {
if (entity.type === 'tag') {
if (entity.entities?.length == nonTagEntitiesCount) entity.isUseless = true;
}
}
// Sort before returning if requested
if (doSort) {
sortEntitiesList(entities, false);
}
entitiesFilter.clearFuzzySearchCaches();
return entities;
}
export async function getOneCharacter(avatarUrl) {
const response = await fetch('/api/characters/get', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
avatar_url: avatarUrl,
}),
});
if (response.ok) {
const getData = await response.json();
getData['name'] = DOMPurify.sanitize(getData['name']);
getData['chat'] = String(getData['chat']);
const indexOf = characters.findIndex(x => x.avatar === avatarUrl);
if (indexOf !== -1) {
characters[indexOf] = getData;
} else {
toastr.error(t`Character ${avatarUrl} not found in the list`, t`Error`, { timeOut: 5000, preventDuplicates: true });
}
}
}
function getCharacterSource(chId = this_chid) {
const character = characters[chId];
if (!character) {
return '';
}
const chubId = characters[chId]?.data?.extensions?.chub?.full_path;
if (chubId) {
return `https://chub.ai/characters/${chubId}`;
}
const pygmalionId = characters[chId]?.data?.extensions?.pygmalion_id;
if (pygmalionId) {
return `https://pygmalion.chat/${pygmalionId}`;
}
const githubRepo = characters[chId]?.data?.extensions?.github_repo;
if (githubRepo) {
return `https://github.com/${githubRepo}`;
}
const sourceUrl = characters[chId]?.data?.extensions?.source_url;
if (sourceUrl) {
return sourceUrl;
}
const risuId = characters[chId]?.data?.extensions?.risuai?.source;
if (Array.isArray(risuId) && risuId.length && typeof risuId[0] === 'string' && risuId[0].startsWith('risurealm:')) {
const realmId = risuId[0].split(':')[1];
return `https://realm.risuai.net/character/${realmId}`;
}
const perchanceSlug = characters[chId]?.data?.extensions?.perchance_data?.slug;
if (perchanceSlug) {
return `https://perchance.org/ai-character-chat?data=${perchanceSlug}`;
}
return '';
}
export async function getCharacters() {
const response = await fetch('/api/characters/all', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({}),
});
if (response.ok) {
const previousAvatar = this_chid !== undefined ? characters[this_chid]?.avatar : null;
characters.splice(0, characters.length);
const getData = await response.json();
for (let i = 0; i < getData.length; i++) {
characters[i] = getData[i];
characters[i]['name'] = DOMPurify.sanitize(characters[i]['name']);
// For dropped-in cards
if (!characters[i]['chat']) {
characters[i]['chat'] = `${characters[i]['name']} - ${humanizedDateTime()}`;
}
characters[i]['chat'] = String(characters[i]['chat']);
}
if (previousAvatar) {
const newCharacterId = characters.findIndex(x => x.avatar === previousAvatar);
if (newCharacterId >= 0) {
setCharacterId(newCharacterId);
await selectCharacterById(newCharacterId, { switchMenu: false });
} else {
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.`);
return location.reload();
}
}
await getGroups();
await printCharacters(true);
} else {
console.error('Failed to fetch characters:', response.statusText);
const errorData = await response.json();
if (errorData?.overflow) {
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.`);
}
}
}
async function delChat(chatfile) {
const response = await fetch('/api/chats/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
chatfile: chatfile,
avatar_url: characters[this_chid].avatar,
}),
});
if (response.ok === true) {
// choose another chat if current was deleted
const name = chatfile.replace('.jsonl', '');
if (name === characters[this_chid].chat) {
chat_metadata = {};
await replaceCurrentChat();
}
await eventSource.emit(event_types.CHAT_DELETED, name);
}
}
/**
* Deletes a character chat by its name.
* @param {string} characterId Character ID to delete chat for
* @param {string} fileName Name of the chat file to delete (without .jsonl extension)
* @returns {Promise} A promise that resolves when the chat is deleted.
*/
export async function deleteCharacterChatByName(characterId, fileName) {
// Make sure all the data is loaded.
await unshallowCharacter(characterId);
/** @type {import('./scripts/char-data.js').v1CharData} */
const character = characters[characterId];
if (!character) {
console.warn(`Character with ID ${characterId} not found.`);
return;
}
const response = await fetch('/api/chats/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
chatfile: `${fileName}.jsonl`,
avatar_url: character.avatar,
}),
});
if (!response.ok) {
console.error('Failed to delete chat for character.');
return;
}
if (fileName === character.chat) {
const chatsResponse = await fetch('/api/characters/chats', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ avatar_url: character.avatar }),
});
const chats = Object.values(await chatsResponse.json());
chats.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)));
const newChatName = chats.length && typeof chats[0] === 'object' ? chats[0].file_name.replace('.jsonl', '') : `${character.name} - ${humanizedDateTime()}`;
await updateRemoteChatName(characterId, newChatName);
}
await eventSource.emit(event_types.CHAT_DELETED, fileName);
}
export async function replaceCurrentChat() {
await clearChat();
chat.length = 0;
const chatsResponse = await fetch('/api/characters/chats', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ avatar_url: characters[this_chid].avatar }),
});
if (chatsResponse.ok) {
const chats = Object.values(await chatsResponse.json());
chats.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)));
// pick existing chat
if (chats.length && typeof chats[0] === 'object') {
characters[this_chid].chat = chats[0].file_name.replace('.jsonl', '');
$('#selected_chat_pole').val(characters[this_chid].chat);
saveCharacterDebounced();
await getChat();
}
// start new chat
else {
characters[this_chid].chat = `${name2} - ${humanizedDateTime()}`;
$('#selected_chat_pole').val(characters[this_chid].chat);
saveCharacterDebounced();
await getChat();
}
}
}
export async function showMoreMessages(messagesToLoad = null) {
const firstDisplayedMesId = chatElement.children('.mes').first().attr('mesid');
let messageId = Number(firstDisplayedMesId);
let count = messagesToLoad || power_user.chat_truncation || Number.MAX_SAFE_INTEGER;
// If there are no messages displayed, or the message somehow has no mesid, we default to one higher than last message id,
// so the first "new" message being shown will be the last available message
if (isNaN(messageId)) {
messageId = getLastMessageId() + 1;
}
console.debug('Inserting messages before', messageId, 'count', count, 'chat length', chat.length);
const prevHeight = chatElement.prop('scrollHeight');
const isButtonInView = isElementInViewport($('#show_more_messages')[0]);
while (messageId > 0 && count > 0) {
let newMessageId = messageId - 1;
addOneMessage(chat[newMessageId], { insertBefore: messageId >= chat.length ? null : messageId, scroll: false, forceId: newMessageId });
count--;
messageId--;
}
if (messageId == 0) {
$('#show_more_messages').remove();
}
if (isButtonInView) {
const newHeight = chatElement.prop('scrollHeight');
chatElement.scrollTop(newHeight - prevHeight);
}
applyStylePins();
await eventSource.emit(event_types.MORE_MESSAGES_LOADED);
}
export async function printMessages() {
let startIndex = 0;
let count = power_user.chat_truncation || Number.MAX_SAFE_INTEGER;
if (chat.length > count) {
startIndex = chat.length - count;
chatElement.append('Show more messages
');
}
for (let i = startIndex; i < chat.length; i++) {
const item = chat[i];
addOneMessage(item, { scroll: false, forceId: i, showSwipes: false });
}
chatElement.find('.mes').removeClass('last_mes');
chatElement.find('.mes').last().addClass('last_mes');
refreshSwipeButtons();
applyStylePins();
scrollChatToBottom();
delay(debounce_timeout.short).then(() => scrollOnMediaLoad());
}
function scrollOnMediaLoad() {
const started = Date.now();
const media = chatElement.find('.mes_block img, .mes_block video, .mes_block audio').toArray();
let mediaLoaded = 0;
for (const currentElement of media) {
if (currentElement instanceof HTMLImageElement) {
if (currentElement.complete) {
incrementAndCheck();
} else {
currentElement.addEventListener('load', incrementAndCheck);
currentElement.addEventListener('error', incrementAndCheck);
}
}
if (currentElement instanceof HTMLMediaElement) {
if (currentElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
incrementAndCheck();
} else {
currentElement.addEventListener('loadeddata', incrementAndCheck);
currentElement.addEventListener('error', incrementAndCheck);
}
}
}
function incrementAndCheck() {
const MAX_DELAY = 1000; // 1 second
if ((Date.now() - started) > MAX_DELAY) {
return;
}
mediaLoaded++;
if (mediaLoaded === media.length) {
scrollChatToBottom({ waitForFrame: true });
}
}
}
/**
* Cancels the debounced chat save if it is currently pending.
*/
export function cancelDebouncedChatSave() {
if (chatSaveTimeout) {
console.debug('Debounced chat save cancelled');
clearTimeout(chatSaveTimeout);
chatSaveTimeout = null;
}
}
export async function clearChat() {
cancelDebouncedChatSave();
cancelDebouncedMetadataSave();
closeMessageEditor();
extension_prompts = {};
if (is_delete_mode) {
$('#dialogue_del_mes_cancel').trigger('click');
}
chatElement.children().remove();
if ($('.zoomed_avatar[forChar]').length) {
console.debug('saw avatars to remove');
$('.zoomed_avatar[forChar]').remove();
} else { console.debug('saw no avatars'); }
await saveItemizedPrompts(getCurrentChatId());
itemizedPrompts.length = 0;
}
export async function deleteLastMessage() {
chat.length = chat.length - 1;
chatElement.children('.mes').last().remove();
await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);
}
/**
* Deletes a message from the chat by its ID, optionally asking for confirmation.
* @param {number} id The ID of the message to delete.
* @param {number} [swipeDeletionIndex] Deletes the swipe with that index.
* @param {boolean} [askConfirmation=false] Whether to ask for confirmation before deleting.
*/
export async function deleteMessage(id, swipeDeletionIndex = undefined, askConfirmation = false) {
const canDeleteSwipe = swipeDeletionIndex !== undefined && swipeDeletionIndex !== null;
if (canDeleteSwipe) {
if (swipeDeletionIndex < 0) {
throw new Error('Swipe index cannot be negative');
}
if (!Array.isArray(chat[id].swipes)) {
throw new Error('Message has no swipes to delete');
}
if (chat[id].swipes.length <= swipeDeletionIndex) {
throw new Error('Swipe index out of bounds');
}
}
const minId = getFirstDisplayedMessageId();
const messageElement = chatElement.find(`.mes[mesid="${id}"]`);
if (messageElement.length === 0) {
return;
}
let deleteOnlySwipe = canDeleteSwipe;
if (askConfirmation) {
const result = await callGenericPopup(t`Are you sure you want to delete this message?`, POPUP_TYPE.CONFIRM, null, {
okButton: canDeleteSwipe ? t`Delete Swipe` : t`Delete Message`,
cancelButton: 'Cancel',
customButtons: canDeleteSwipe ? [t`Delete Message`] : null,
});
if (!result) {
return;
}
deleteOnlySwipe = canDeleteSwipe && result === POPUP_RESULT.AFFIRMATIVE; // Default button, not the custom one
}
if (deleteOnlySwipe) {
await deleteSwipe(swipeDeletionIndex, id);
return;
}
chat.splice(id, 1);
messageElement.remove();
chat_metadata['tainted'] = true;
const startIndex = [0, minId].includes(id) ? id : null;
updateViewMessageIds(startIndex);
saveChatDebounced();
if (this_edit_mes_id === id) {
this_edit_mes_id = undefined;
}
refreshSwipeButtons();
await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);
}
export async function reloadCurrentChat() {
preserveNeutralChat();
await clearChat();
chat.length = 0;
if (selected_group) {
await getGroupChat(selected_group, true);
}
else if (this_chid !== undefined) {
await getChat();
}
else {
resetChatState();
restoreNeutralChat();
await getCharacters();
await printMessages();
await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId());
}
refreshSwipeButtons();
}
/**
* Send the message currently typed into the chat box.
*/
export async function sendTextareaMessage() {
if (is_send_press) return;
if (isExecutingCommandsFromChatInput) return;
let generateType = 'normal';
// "Continue on send" is activated when the user hits "send" (or presses enter) on an empty chat box, and the last
// message was sent from a character (not the user or the system).
const textareaText = String($('#send_textarea').val());
if (power_user.continue_on_send &&
!hasPendingFileAttachment() &&
!textareaText &&
!selected_group &&
chat.length &&
!chat[chat.length - 1]['is_user'] &&
!chat[chat.length - 1]['is_system']
) {
generateType = 'continue';
}
if (textareaText && !selected_group && this_chid === undefined && name2 !== neutralCharacterName) {
await newAssistantChat({ temporary: false });
}
return await Generate(generateType);
}
/**
* Formats the message text into an HTML string using Markdown and other formatting.
* @param {string} mes Message text
* @param {string} ch_name Character name
* @param {boolean} isSystem If the message was sent by the system
* @param {boolean} isUser If the message was sent by the user
* @param {number} messageId Message index in chat array
* @param {object} [sanitizerOverrides] DOMPurify sanitizer option overrides
* @param {boolean} [isReasoning] If the message is reasoning output
* @returns {string} HTML string
*/
export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, sanitizerOverrides = {}, isReasoning = false) {
if (!mes) {
return '';
}
if (Number(messageId) === 0 && !isSystem && !isUser && !isReasoning) {
const mesBeforeReplace = mes;
const chatMessage = chat[messageId];
mes = substituteParams(mes, undefined, ch_name);
if (chatMessage && chatMessage.mes === mesBeforeReplace && chatMessage.extra?.display_text !== mesBeforeReplace) {
chatMessage.mes = mes;
}
}
mesForShowdownParse = mes;
// Force isSystem = false on comment messages so they get formatted properly
if (ch_name === COMMENT_NAME_DEFAULT && isSystem && !isUser) {
isSystem = false;
}
// Let hidden messages have markdown
if (isSystem && ch_name !== systemUserName) {
isSystem = false;
}
// Prompt bias replacement should be applied on the raw message
const replacedPromptBias = power_user.user_prompt_bias && substituteParams(power_user.user_prompt_bias);
if (!power_user.show_user_prompt_bias && ch_name && !isUser && !isSystem && replacedPromptBias && mes.startsWith(replacedPromptBias)) {
mes = mes.slice(replacedPromptBias.length);
}
if (!isSystem) {
function getRegexPlacement() {
try {
if (isReasoning) {
return regex_placement.REASONING;
}
if (isUser) {
return regex_placement.USER_INPUT;
} else if (chat[messageId]?.extra?.type === 'narrator') {
return regex_placement.SLASH_COMMAND;
} else {
return regex_placement.AI_OUTPUT;
}
} catch {
return regex_placement.AI_OUTPUT;
}
}
const regexPlacement = getRegexPlacement();
const usableMessages = chat.map((x, index) => ({ message: x, index: index })).filter(x => !x.message.is_system);
const indexOf = usableMessages.findIndex(x => x.index === Number(messageId));
const depth = messageId >= 0 && indexOf !== -1 ? (usableMessages.length - indexOf - 1) : undefined;
// Always override the character name
mes = getRegexedString(mes, regexPlacement, {
characterOverride: ch_name,
isMarkdown: true,
depth: depth,
});
}
if (power_user.auto_fix_generated_markdown) {
mes = fixMarkdown(mes, true);
}
if (!isSystem && power_user.encode_tags) {
mes = canUseNegativeLookbehind()
? mes.replaceAll('<', '<').replace(new RegExp('(?', 'g'), '>')
: mes.replaceAll('<', '<').replaceAll('>', '>');
}
// Make sure reasoning strings are always shown, even if they include "<" or ">"
[power_user.reasoning.prefix, power_user.reasoning.suffix].forEach((reasoningString) => {
if (!reasoningString || !reasoningString.trim().length) {
return;
}
// Only replace the first occurrence of the reasoning string
if (mes.includes(reasoningString)) {
mes = mes.replace(reasoningString, escapeHtml(reasoningString));
}
});
if (!isSystem) {
// Save double quotes in tags as a special character to prevent them from being encoded
if (!power_user.encode_tags) {
mes = mes.replace(/<([^>]+)>/g, function (_, contents) {
return '<' + contents.replace(/"/g, '\ufffe') + '>';
});
}
mes = mes.replace(
/