import {
showdown,
moment,
Fuse,
DOMPurify,
hljs,
localforage,
Handlebars,
DiffMatchPatch,
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,
formatKoboldUrl,
getKoboldGenerationData,
kai_flags,
setKoboldFlags,
} from './scripts/kai-settings.js';
import {
textgenerationwebui_settings as textgen_settings,
loadTextGenSettings,
generateTextGenWithStreaming,
getTextGenGenerationData,
textgen_types,
getTextGenServer,
validateTextGenUrl,
parseTextgenLogprobs,
parseTabbyLogprobs,
} from './scripts/textgen-settings.js';
import {
world_info,
getWorldInfoPrompt,
getWorldInfoSettings,
setWorldInfoSettings,
world_names,
importEmbeddedWorldInfo,
checkEmbeddedWorld,
setWorldInfoButtonClass,
importWorldInfo,
wi_anchor_position,
world_info_include_names,
initWorldInfo,
} 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,
context_presets,
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,
getNovelTier,
loadNovelPreset,
loadNovelSettings,
nai_settings,
adjustNovelInstructionPrompt,
loadNovelSubscriptionData,
parseNovelAILogprobs,
} from './scripts/nai-settings.js';
import {
initBookmarks,
showBookmarksButtons,
updateBookmarkDisplay,
} from './scripts/bookmarks.js';
import {
horde_settings,
loadHordeSettings,
generateHorde,
checkHordeStatus,
getHordeModels,
adjustHordeGenerationParams,
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,
isTrueBoolean,
toggleDrawer,
isElementInViewport,
copyText,
escapeHtml,
saveBase64AsFile,
uuidv4,
equalsIgnoreCaseAndAccents,
localizePagination,
renderPaginationDropdown,
paginationDropdownChangeHandler,
} from './scripts/utils.js';
import { debounce_timeout, IGNORE_SYMBOL } from './scripts/constants.js';
import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors, saveMetadataDebounced } from './scripts/extensions.js';
import { COMMENT_NAME_DEFAULT, executeSlashCommandsOnChatInput, getSlashCommandsHelp, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, processChatSlashCommands, stopScriptExecution } 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,
} from './scripts/tags.js';
import {
SECRET_KEYS,
initSecrets,
readSecretState,
secret_state,
writeSecret,
} from './scripts/secrets.js';
import { EventEmitter } from './lib/eventemitter.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,
getInstructStoppingSequences,
autoSelectInstructPreset,
formatInstructModeSystemPrompt,
selectInstructPreset,
instruct_presets,
selectContextPreset,
} from './scripts/instruct-mode.js';
import { initLocales, t } from './scripts/i18n.js';
import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, initTokenizers, saveTokenCache, TOKENIZER_SUPPORTED_KEY } 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, CharacterContextMenu } from './scripts/BulkEditOverlay.js';
import {
loadFeatherlessModels,
loadMancerModels,
loadOllamaModels,
loadTogetherAIModels,
loadInfermaticAIModels,
loadOpenRouterModels,
loadVllmModels,
loadAphroditeModels,
loadDreamGenModels,
initTextGenModels,
loadTabbyModels,
loadGenericModels,
} from './scripts/textgen-models.js';
import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId, preserveNeutralChat, restoreNeutralChat, formatCreatorNotes, initChatUtilities } 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 { SlashCommandParser } from './scripts/slash-commands/SlashCommandParser.js';
import { SlashCommand } from './scripts/slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './scripts/slash-commands/SlashCommandArgument.js';
import { SlashCommandBrowser } from './scripts/slash-commands/SlashCommandBrowser.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 { SlashCommandEnumValue, enumTypes } from './scripts/slash-commands/SlashCommandEnumValue.js';
import { commonEnumProviders, enumIcons } from './scripts/slash-commands/SlashCommandCommonEnumsProvider.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 { deriveTemplatesFromChatTemplate } from './scripts/chat-templates.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';
// API OBJECT FOR EXTERNAL WIRING
globalThis.SillyTavern = {
libs,
getContext,
};
//exporting functions and vars for mods
export {
user_avatar,
setUserAvatar,
getUserAvatars,
getUserAvatar,
nai_settings,
isOdd,
countOccurrences,
renderTemplate,
};
/**
* 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 = {
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`);
},
};
// Allow target="_blank" in links
DOMPurify.addHook('afterSanitizeAttributes', function (node) {
if ('target' in node) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener');
}
});
DOMPurify.addHook('uponSanitizeAttribute', (node, data, config) => {
if (!config['MESSAGE_SANITIZE']) {
return;
}
/* Retain the classes on UI elements of messages that interact with the main UI */
const permittedNodeTypes = ['BUTTON', 'DIV'];
if (config['MESSAGE_ALLOW_SYSTEM_UI'] && node.classList.contains('menu_button') && permittedNodeTypes.includes(node.nodeName)) {
return;
}
switch (data.attrName) {
case 'class': {
if (data.attrValue) {
data.attrValue = data.attrValue.split(' ').map((v) => {
if (v.startsWith('fa-') || v.startsWith('note-') || v === 'monospace') {
return v;
}
return 'custom-' + v;
}).join(' ');
}
break;
}
}
});
DOMPurify.addHook('uponSanitizeElement', (node, _, config) => {
if (!config['MESSAGE_SANITIZE']) {
return;
}
// Replace line breaks with
in unknown elements
if (node instanceof HTMLUnknownElement) {
node.innerHTML = node.innerHTML.trim().replaceAll('\n', '
');
}
const isMediaAllowed = isExternalMediaAllowed();
if (isMediaAllowed) {
return;
}
if (!(node instanceof Element)) {
return;
}
let mediaBlocked = false;
switch (node.tagName) {
case 'AUDIO':
case 'VIDEO':
case 'SOURCE':
case 'TRACK':
case 'EMBED':
case 'OBJECT':
case 'IMG': {
const isExternalUrl = (url) => (url.indexOf('://') > 0 || url.indexOf('//') === 0) && !url.startsWith(window.location.origin);
const src = node.getAttribute('src');
const data = node.getAttribute('data');
const srcset = node.getAttribute('srcset');
if (srcset) {
const srcsetUrls = srcset.split(',');
for (const srcsetUrl of srcsetUrls) {
const [url] = srcsetUrl.trim().split(' ');
if (isExternalUrl(url)) {
console.warn('External media blocked', url);
node.remove();
mediaBlocked = true;
break;
}
}
}
if (src && isExternalUrl(src)) {
console.warn('External media blocked', src);
mediaBlocked = true;
node.remove();
}
if (data && isExternalUrl(data)) {
console.warn('External media blocked', data);
mediaBlocked = true;
node.remove();
}
if (mediaBlocked && (node instanceof HTMLMediaElement)) {
node.autoplay = false;
node.pause();
}
}
break;
}
if (mediaBlocked) {
const entityId = getCurrentEntityId();
const warningShownKey = `mediaWarningShown:${entityId}`;
if (accountStorage.getItem(warningShownKey) === null) {
const warningToast = toastr.warning(
t`Use the 'Ext. Media' button to allow it. Click on this message to dismiss.`,
t`External media has been blocked`,
{
timeOut: 0,
preventDuplicates: true,
onclick: () => toastr.clear(warningToast),
},
);
accountStorage.setItem(warningShownKey, 'true');
}
}
});
// Event source init
//MARK: event_types
export const event_types = {
APP_READY: 'app_ready',
EXTRAS_CONNECTED: 'extras_connected',
MESSAGE_SWIPED: 'message_swiped',
MESSAGE_SENT: 'message_sent',
MESSAGE_RECEIVED: 'message_received',
MESSAGE_EDITED: 'message_edited',
MESSAGE_DELETED: 'message_deleted',
MESSAGE_UPDATED: 'message_updated',
MESSAGE_FILE_EMBEDDED: 'message_file_embedded',
MESSAGE_REASONING_EDITED: 'message_reasoning_edited',
MESSAGE_REASONING_DELETED: 'message_reasoning_deleted',
MORE_MESSAGES_LOADED: 'more_messages_loaded',
IMPERSONATE_READY: 'impersonate_ready',
CHAT_CHANGED: 'chat_id_changed',
GENERATION_AFTER_COMMANDS: 'GENERATION_AFTER_COMMANDS',
GENERATION_STARTED: 'generation_started',
GENERATION_STOPPED: 'generation_stopped',
GENERATION_ENDED: 'generation_ended',
SD_PROMPT_PROCESSING: 'sd_prompt_processing',
EXTENSIONS_FIRST_LOAD: 'extensions_first_load',
EXTENSION_SETTINGS_LOADED: 'extension_settings_loaded',
SETTINGS_LOADED: 'settings_loaded',
SETTINGS_UPDATED: 'settings_updated',
GROUP_UPDATED: 'group_updated',
MOVABLE_PANELS_RESET: 'movable_panels_reset',
SETTINGS_LOADED_BEFORE: 'settings_loaded_before',
SETTINGS_LOADED_AFTER: 'settings_loaded_after',
CHATCOMPLETION_SOURCE_CHANGED: 'chatcompletion_source_changed',
CHATCOMPLETION_MODEL_CHANGED: 'chatcompletion_model_changed',
OAI_PRESET_CHANGED_BEFORE: 'oai_preset_changed_before',
OAI_PRESET_CHANGED_AFTER: 'oai_preset_changed_after',
OAI_PRESET_EXPORT_READY: 'oai_preset_export_ready',
OAI_PRESET_IMPORT_READY: 'oai_preset_import_ready',
WORLDINFO_SETTINGS_UPDATED: 'worldinfo_settings_updated',
WORLDINFO_UPDATED: 'worldinfo_updated',
CHARACTER_EDITED: 'character_edited',
CHARACTER_PAGE_LOADED: 'character_page_loaded',
CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE: 'character_group_overlay_state_change_before',
CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER: 'character_group_overlay_state_change_after',
USER_MESSAGE_RENDERED: 'user_message_rendered',
CHARACTER_MESSAGE_RENDERED: 'character_message_rendered',
FORCE_SET_BACKGROUND: 'force_set_background',
CHAT_DELETED: 'chat_deleted',
CHAT_CREATED: 'chat_created',
GROUP_CHAT_DELETED: 'group_chat_deleted',
GROUP_CHAT_CREATED: 'group_chat_created',
GENERATE_BEFORE_COMBINE_PROMPTS: 'generate_before_combine_prompts',
GENERATE_AFTER_COMBINE_PROMPTS: 'generate_after_combine_prompts',
GENERATE_AFTER_DATA: 'generate_after_data',
GROUP_MEMBER_DRAFTED: 'group_member_drafted',
GROUP_WRAPPER_STARTED: 'group_wrapper_started',
GROUP_WRAPPER_FINISHED: 'group_wrapper_finished',
WORLD_INFO_ACTIVATED: 'world_info_activated',
TEXT_COMPLETION_SETTINGS_READY: 'text_completion_settings_ready',
CHAT_COMPLETION_SETTINGS_READY: 'chat_completion_settings_ready',
CHAT_COMPLETION_PROMPT_READY: 'chat_completion_prompt_ready',
CHARACTER_FIRST_MESSAGE_SELECTED: 'character_first_message_selected',
// TODO: Naming convention is inconsistent with other events
CHARACTER_DELETED: 'characterDeleted',
CHARACTER_DUPLICATED: 'character_duplicated',
CHARACTER_RENAMED: 'character_renamed',
CHARACTER_RENAMED_IN_PAST_CHAT: 'character_renamed_in_past_chat',
/** @deprecated The event is aliased to STREAM_TOKEN_RECEIVED. */
SMOOTH_STREAM_TOKEN_RECEIVED: 'stream_token_received',
STREAM_TOKEN_RECEIVED: 'stream_token_received',
STREAM_REASONING_DONE: 'stream_reasoning_done',
FILE_ATTACHMENT_DELETED: 'file_attachment_deleted',
WORLDINFO_FORCE_ACTIVATE: 'worldinfo_force_activate',
OPEN_CHARACTER_LIBRARY: 'open_character_library',
ONLINE_STATUS_CHANGED: 'online_status_changed',
IMAGE_SWIPED: 'image_swiped',
CONNECTION_PROFILE_LOADED: 'connection_profile_loaded',
CONNECTION_PROFILE_CREATED: 'connection_profile_created',
CONNECTION_PROFILE_DELETED: 'connection_profile_deleted',
CONNECTION_PROFILE_UPDATED: 'connection_profile_updated',
TOOL_CALLS_PERFORMED: 'tool_calls_performed',
TOOL_CALLS_RENDERED: 'tool_calls_rendered',
CHARACTER_MANAGEMENT_DROPDOWN: 'charManagementDropdown',
SECRET_WRITTEN: 'secret_written',
SECRET_DELETED: 'secret_deleted',
SECRET_ROTATED: 'secret_rotated',
SECRET_EDITED: 'secret_edited',
};
export const eventSource = new EventEmitter([event_types.APP_READY]);
eventSource.on(event_types.CHAT_CHANGED, processChatSlashCommands);
export const characterGroupOverlay = new BulkEditOverlay();
const characterContextMenu = new CharacterContextMenu(characterGroupOverlay);
eventSource.on(event_types.CHARACTER_PAGE_LOADED, characterGroupOverlay.onPageLoad);
console.debug('Character context menu initialized', characterContextMenu);
// 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
console.debug('initializing Prompt Itemization Array on Startup');
const promptStorage = localforage.createInstance({ name: 'SillyTavern_Prompts' });
export let itemizedPrompts = [];
export const systemUserName = 'SillyTavern System';
export const neutralCharacterName = 'Assistant';
let default_user_name = 'User';
export let name1 = default_user_name;
export let name2 = systemUserName;
export let chat = [];
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 generatedPromptCache = '';
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');
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;
/** @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 {string} System message types
*/
export const system_message_types = {
HELP: 'help',
WELCOME: 'welcome',
EMPTY: 'empty',
GENERIC: 'generic',
NARRATOR: 'narrator',
COMMENT: 'comment',
SLASH_COMMANDS: 'slash_commands',
FORMATTING: 'formatting',
HOTKEYS: 'hotkeys',
MACROS: 'macros',
WELCOME_PROMPT: 'welcome_prompt',
ASSISTANT_NOTE: 'assistant_note',
ASSISTANT_MESSAGE: 'assistant_message',
};
/**
* @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;
// Initialized in getSystemMessages()
const SAFETY_CHAT = [];
export let system_messages = {};
async function getSystemMessages() {
system_messages = {
help: {
name: systemUserName,
force_avatar: system_avatar,
is_user: false,
is_system: true,
mes: await renderTemplateAsync('help'),
},
slash_commands: {
name: systemUserName,
force_avatar: system_avatar,
is_user: false,
is_system: true,
mes: '',
},
hotkeys: {
name: systemUserName,
force_avatar: system_avatar,
is_user: false,
is_system: true,
mes: await renderTemplateAsync('hotkeys'),
},
formatting: {
name: systemUserName,
force_avatar: system_avatar,
is_user: false,
is_system: true,
mes: await renderTemplateAsync('formatting'),
},
macros: {
name: systemUserName,
force_avatar: system_avatar,
is_user: false,
is_system: true,
mes: await renderTemplateAsync('macros'),
},
welcome:
{
name: systemUserName,
force_avatar: system_avatar,
is_user: false,
is_system: true,
uses_system_ui: true,
mes: await renderTemplateAsync('welcome', { displayVersion }),
},
empty: {
name: systemUserName,
force_avatar: system_avatar,
is_user: false,
is_system: true,
mes: 'No one hears you. Hint: add more members to the group!',
},
generic: {
name: systemUserName,
force_avatar: system_avatar,
is_user: false,
is_system: true,
mes: 'Generic system message. User `text` parameter to override the contents',
},
welcome_prompt: {
name: systemUserName,
force_avatar: system_avatar,
is_user: false,
is_system: true,
uses_system_ui: true,
mes: await renderTemplateAsync('welcomePrompt'),
extra: {
isSmallSys: true,
},
},
assistant_note: {
name: systemUserName,
force_avatar: system_avatar,
is_user: false,
is_system: true,
mes: await renderTemplateAsync('assistantNote'),
uses_system_ui: true,
extra: {
isSmallSys: true,
},
},
};
const safetyMessage = {
name: systemUserName,
force_avatar: system_avatar,
is_system: true,
is_user: false,
create_date: 0,
mes: t`You deleted a character/chat and arrived back here for safety reasons! Pick another character!`,
};
SAFETY_CHAT.splice(0, SAFETY_CHAT.length, safetyMessage);
}
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: {},
};
//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 api_server = '';
export let is_send_press = false; //Send generation
let this_del_mes = -1;
//message editing
var this_edit_mes_chname = '';
var this_edit_mes_id;
//settings
export let settings;
export let koboldai_settings;
export let koboldai_setting_names;
var preset_settings = 'gui';
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";
//novel settings
export let novelai_settings;
export let novelai_setting_names;
/** @type {AbortController} */
let abortController;
//css
var css_send_form_display = $('
').css('display');
var kobold_horde_model = '';
export let token;
var PromptArrayItemForRawPromptDisplay;
var priorPromptArrayItemForRawPromptDisplay;
/** 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() {
return {
'Content-Type': 'application/json',
'X-CSRF-Token': token,
};
}
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();
initStandaloneMode();
initLibraryShims();
addShowdownPatch(showdown);
reloadMarkdownProcessor();
applyBrowserFixes();
await getClientVersion();
await initSecrets();
await readSecretState();
await initLocales();
initChatUtilities();
initDefaultSlashCommands();
initTextGenModels();
initOpenAI();
initSystemPrompts();
initExtensions();
initExtensionSlashCommands();
ToolManager.initToolSlashCommands();
await initPresetManager();
await getSystemMessages();
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();
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;
}
/**
* Gets the itemized prompts for a chat.
* @param {string} chatId Chat ID to load
*/
export async function loadItemizedPrompts(chatId) {
try {
if (!chatId) {
itemizedPrompts = [];
return;
}
itemizedPrompts = await promptStorage.getItem(chatId);
if (!itemizedPrompts) {
itemizedPrompts = [];
}
} catch {
console.log('Error loading itemized prompts for chat', chatId);
itemizedPrompts = [];
}
}
/**
* Saves the itemized prompts for a chat.
* @param {string} chatId Chat ID to save itemized prompts for
*/
export async function saveItemizedPrompts(chatId) {
try {
if (!chatId) {
return;
}
await promptStorage.setItem(chatId, itemizedPrompts);
} catch {
console.log('Error saving itemized prompts for chat', chatId);
}
}
/**
* Replaces the itemized prompt text for a message.
* @param {number} mesId Message ID to get itemized prompt for
* @param {string} promptText New raw prompt text
* @returns
*/
export async function replaceItemizedPromptText(mesId, promptText) {
if (!Array.isArray(itemizedPrompts)) {
itemizedPrompts = [];
}
const itemizedPrompt = itemizedPrompts.find(x => x.mesId === mesId);
if (!itemizedPrompt) {
return;
}
itemizedPrompt.rawPrompt = promptText;
}
/**
* Deletes the itemized prompts for a chat.
* @param {string} chatId Chat ID to delete itemized prompts for
*/
export async function deleteItemizedPrompts(chatId) {
try {
if (!chatId) {
return;
}
await promptStorage.removeItem(chatId);
} catch {
console.log('Error deleting itemized prompts for chat', chatId);
}
}
/**
* Empties the itemized prompts array and caches.
*/
export async function clearItemizedPrompts() {
try {
await promptStorage.clear();
itemizedPrompts = [];
} catch {
console.log('Error clearing itemized prompts');
}
}
async function getStatusHorde() {
try {
const hordeStatus = await checkHordeStatus();
setOnlineStatus(hordeStatus ? t`Connected` : 'no_connection');
}
catch {
setOnlineStatus('no_connection');
}
return resultCheckStatus();
}
async function getStatusKobold() {
let endpoint = api_server;
if (!endpoint) {
console.warn('No endpoint for status check');
setOnlineStatus('no_connection');
return resultCheckStatus();
}
try {
const response = await fetch('/api/backends/kobold/status', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
main_api,
api_server: endpoint,
}),
signal: abortStatusCheck.signal,
});
const data = await response.json();
setOnlineStatus(data?.model ?? 'no_connection');
if (!data.koboldUnitedVersion) {
throw new Error('Missing mandatory Kobold version in data:', data);
}
// Determine instruct mode preset
autoSelectInstructPreset(online_status);
// determine if we can use stop sequence and streaming
setKoboldFlags(data.koboldUnitedVersion, data.koboldCppVersion);
// We didn't get a 200 status code, but the endpoint has an explanation. Which means it DID connect, but I digress.
if (online_status === 'no_connection' && data.response) {
toastr.error(data.response, t`API Error`, { timeOut: 5000, preventDuplicates: true });
}
} catch (err) {
console.error('Error getting status', err);
setOnlineStatus('no_connection');
}
return resultCheckStatus();
}
async function getStatusTextgen() {
const url = '/api/backends/text-completions/status';
const endpoint = getTextGenServer();
if (!endpoint) {
console.warn('No endpoint for status check');
setOnlineStatus('no_connection');
return resultCheckStatus();
}
if ([textgen_types.GENERIC, textgen_types.OOBA].includes(textgen_settings.type) && textgen_settings.bypass_status_check) {
setOnlineStatus(t`Status check bypassed`);
return resultCheckStatus();
}
try {
const response = await fetch(url, {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
api_server: endpoint,
api_type: textgen_settings.type,
}),
signal: abortStatusCheck.signal,
});
const data = await response.json();
if (textgen_settings.type === textgen_types.MANCER) {
loadMancerModels(data?.data);
setOnlineStatus(textgen_settings.mancer_model);
} else if (textgen_settings.type === textgen_types.TOGETHERAI) {
loadTogetherAIModels(data?.data);
setOnlineStatus(textgen_settings.togetherai_model);
} else if (textgen_settings.type === textgen_types.OLLAMA) {
loadOllamaModels(data?.data);
setOnlineStatus(textgen_settings.ollama_model || t`Connected`);
} else if (textgen_settings.type === textgen_types.INFERMATICAI) {
loadInfermaticAIModels(data?.data);
setOnlineStatus(textgen_settings.infermaticai_model);
} else if (textgen_settings.type === textgen_types.DREAMGEN) {
loadDreamGenModels(data?.data);
setOnlineStatus(textgen_settings.dreamgen_model);
} else if (textgen_settings.type === textgen_types.OPENROUTER) {
loadOpenRouterModels(data?.data);
setOnlineStatus(textgen_settings.openrouter_model);
} else if (textgen_settings.type === textgen_types.VLLM) {
loadVllmModels(data?.data);
setOnlineStatus(textgen_settings.vllm_model);
} else if (textgen_settings.type === textgen_types.APHRODITE) {
loadAphroditeModels(data?.data);
setOnlineStatus(textgen_settings.aphrodite_model);
} else if (textgen_settings.type === textgen_types.FEATHERLESS) {
loadFeatherlessModels(data?.data);
setOnlineStatus(textgen_settings.featherless_model);
} else if (textgen_settings.type === textgen_types.TABBY) {
loadTabbyModels(data?.data);
setOnlineStatus(textgen_settings.tabby_model || data?.result);
} else if (textgen_settings.type === textgen_types.GENERIC) {
loadGenericModels(data?.data);
setOnlineStatus(textgen_settings.generic_model || data?.result || t`Connected`);
} else {
setOnlineStatus(data?.result);
}
if (!online_status) {
setOnlineStatus('no_connection');
}
// Determine instruct mode preset
autoSelectInstructPreset(online_status);
const supportsTokenization = response.headers.get('x-supports-tokenization') === 'true';
supportsTokenization ? sessionStorage.setItem(TOKENIZER_SUPPORTED_KEY, 'true') : sessionStorage.removeItem(TOKENIZER_SUPPORTED_KEY);
const wantsInstructDerivation = (power_user.instruct.enabled && power_user.instruct.derived);
const wantsContextDerivation = power_user.context_derived;
const wantsContextSize = power_user.context_size_derived;
const supportsChatTemplate = [textgen_types.KOBOLDCPP, textgen_types.LLAMACPP].includes(textgen_settings.type);
if (supportsChatTemplate && (wantsInstructDerivation || wantsContextDerivation || wantsContextSize)) {
const response = await fetch('/api/backends/text-completions/props', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
api_server: endpoint,
api_type: textgen_settings.type,
}),
});
if (response.ok) {
const data = await response.json();
if (data) {
const { chat_template, chat_template_hash } = data;
if (wantsContextSize && 'default_generation_settings' in data) {
const backend_max_context = data['default_generation_settings']['n_ctx'];
const old_value = max_context;
if (max_context !== backend_max_context) {
setGenerationParamsFromPreset({ max_length: backend_max_context });
}
if (old_value !== max_context) {
console.log(`Auto-switched max context from ${old_value} to ${max_context}`);
toastr.info(`${old_value} ⇒ ${max_context}`, 'Context Size Changed');
}
}
console.log(`We have chat template ${chat_template.split('\n')[0]}...`);
const templates = await deriveTemplatesFromChatTemplate(chat_template, chat_template_hash);
if (templates) {
const { context, instruct } = templates;
if (wantsContextDerivation) {
selectContextPreset(context, { isAuto: true });
}
if (wantsInstructDerivation) {
selectInstructPreset(instruct, { isAuto: true });
}
}
}
}
}
// We didn't get a 200 status code, but the endpoint has an explanation. Which means it DID connect, but I digress.
if (online_status === 'no_connection' && data.response) {
toastr.error(data.response, t`API Error`, { timeOut: 5000, preventDuplicates: true });
}
} catch (err) {
if (err instanceof AbortReason) {
console.info('Status check aborted.', err.reason);
} else {
console.error('Error getting status', err);
}
setOnlineStatus('no_connection');
}
return resultCheckStatus();
}
async function getStatusNovel() {
try {
const result = await loadNovelSubscriptionData();
if (!result) {
throw new Error('Could not load subscription data');
}
setOnlineStatus(getNovelTier());
} catch {
setOnlineStatus('no_connection');
}
resultCheckStatus();
}
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}`;
}
return '';
}
export async function getCharacters() {
const response = await fetch('/api/characters/all', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({}),
});
if (response.ok === true) {
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);
}
}
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 = $('#chat').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 = $('#chat').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 = $('#chat').prop('scrollHeight');
$('#chat').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;
$('#chat').append('Show more messages
');
}
for (let i = startIndex; i < chat.length; i++) {
const item = chat[i];
addOneMessage(item, { scroll: false, forceId: i, showSwipes: false });
}
// Scroll to bottom when all images are loaded
const images = document.querySelectorAll('#chat .mes img');
let imagesLoaded = 0;
for (let i = 0; i < images.length; i++) {
const image = images[i];
if (image instanceof HTMLImageElement) {
if (image.complete) {
incrementAndCheck();
} else {
image.addEventListener('load', incrementAndCheck);
}
}
}
$('#chat .mes').removeClass('last_mes');
$('#chat .mes').last().addClass('last_mes');
hideSwipeButtons();
showSwipeButtons();
scrollChatToBottom();
applyStylePins();
function incrementAndCheck() {
imagesLoaded++;
if (imagesLoaded === images.length) {
scrollChatToBottom();
}
}
}
/**
* 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');
}
$('#chat').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 = [];
}
export async function deleteLastMessage() {
chat.length = chat.length - 1;
$('#chat').children('.mes').last().remove();
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());
}
hideSwipeButtons();
showSwipeButtons();
}
/**
* Send the message currently typed into the chat box.
*/
export async function sendTextareaMessage() {
if (is_send_press) return;
if (isExecutingCommandsFromChatInput) return;
if (this_edit_mes_id) return; // don't proceed if editing a message
let generateType;
// "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 });
}
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 = 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(
/