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( /