Files
st/web-app/public/script.js
2026-02-10 17:48:27 +08:00

12267 lines
469 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
showdown,
moment,
DOMPurify,
hljs,
Handlebars,
SVGInject,
Popper,
initLibraryShims,
default as libs,
} from './lib.js';
import { humanizedDateTime, favsToHotswap, getMessageTimeStamp, dragElement, isMobile, initRossMods } from './scripts/RossAscends-mods.js';
import { userStatsHandler, statMesProcess, initStats } from './scripts/stats.js';
import {
generateKoboldWithStreaming,
kai_settings,
loadKoboldSettings,
getKoboldGenerationData,
kai_flags,
koboldai_settings,
koboldai_setting_names,
initKoboldSettings,
} from './scripts/kai-settings.js';
import {
textgenerationwebui_settings as textgen_settings,
loadTextGenSettings,
generateTextGenWithStreaming,
getTextGenGenerationData,
textgen_types,
parseTextgenLogprobs,
parseTabbyLogprobs,
initTextGenSettings,
} from './scripts/textgen-settings.js';
import {
world_info,
getWorldInfoPrompt,
getWorldInfoSettings,
setWorldInfoSettings,
world_names,
importEmbeddedWorldInfo,
checkEmbeddedWorld,
setWorldInfoButtonClass,
wi_anchor_position,
world_info_include_names,
initWorldInfo,
charUpdatePrimaryWorld,
charSetAuxWorlds,
} from './scripts/world-info.js';
import {
groups,
selected_group,
saveGroupChat,
getGroups,
generateGroupWrapper,
is_group_generating,
resetSelectedGroup,
select_group_chats,
regenerateGroup,
group_generation_id,
getGroupChat,
renameGroupMember,
createNewGroupChat,
getGroupAvatar,
deleteGroupChat,
renameGroupChat,
importGroupChat,
getGroupBlock,
getGroupCharacterCardsLazy,
getGroupDepthPrompts,
} from './scripts/group-chats.js';
import {
collapseNewlines,
loadPowerUserSettings,
playMessageSound,
fixMarkdown,
power_user,
persona_description_positions,
loadMovingUIState,
getCustomStoppingStrings,
MAX_CONTEXT_DEFAULT,
MAX_RESPONSE_DEFAULT,
renderStoryString,
sortEntitiesList,
registerDebugFunction,
flushEphemeralStoppingStrings,
resetMovableStyles,
forceCharacterEditorTokenize,
applyPowerUserSettings,
generatedTextFiltered,
applyStylePins,
} from './scripts/power-user.js';
import {
setOpenAIMessageExamples,
setOpenAIMessages,
setupChatCompletionPromptManager,
prepareOpenAIMessages,
sendOpenAIRequest,
loadOpenAISettings,
oai_settings,
openai_messages_count,
chat_completion_sources,
getChatCompletionModel,
proxies,
loadProxyPresets,
selected_proxy,
initOpenAI,
} from './scripts/openai.js';
import {
generateNovelWithStreaming,
getNovelGenerationData,
getKayraMaxContextTokens,
loadNovelSettings,
nai_settings,
adjustNovelInstructionPrompt,
parseNovelAILogprobs,
novelai_settings,
novelai_setting_names,
initNovelAISettings,
} from './scripts/nai-settings.js';
import {
initBookmarks,
showBookmarksButtons,
updateBookmarkDisplay,
} from './scripts/bookmarks.js';
import {
horde_settings,
loadHordeSettings,
generateHorde,
getStatusHorde,
getHordeModels,
adjustHordeGenerationParams,
isHordeGenerationNotAllowed,
MIN_LENGTH,
initHorde,
} from './scripts/horde.js';
import {
debounce,
delay,
trimToEndSentence,
countOccurrences,
isOdd,
sortMoments,
timestampToMoment,
download,
isDataURL,
getCharaFilename,
PAGINATION_TEMPLATE,
waitUntilCondition,
escapeRegex,
resetScrollHeight,
onlyUnique,
getBase64Async,
humanFileSize,
Stopwatch,
isValidUrl,
ensureImageFormatSupported,
flashHighlight,
toggleDrawer,
isElementInViewport,
copyText,
escapeHtml,
saveBase64AsFile,
uuidv4,
equalsIgnoreCaseAndAccents,
localizePagination,
renderPaginationDropdown,
paginationDropdownChangeHandler,
importFromExternalUrl,
shiftUpByOne,
shiftDownByOne,
canUseNegativeLookbehind,
trimSpaces,
clamp,
shakeElement,
createTimeout,
} from './scripts/utils.js';
import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids, MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, OVERSWIPE_BEHAVIOR, SCROLL_BEHAVIOR, SWIPE_DIRECTION, SWIPE_SOURCE, SWIPE_STATE } from './scripts/constants.js';
import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors } from './scripts/extensions.js';
import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, initSlashCommandAutoComplete, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js';
import {
tag_map,
tags,
filterByTagState,
isBogusFolder,
isBogusFolderOpen,
chooseBogusFolder,
getTagBlock,
loadTagsSettings,
printTagFilters,
getTagKeyForEntity,
printTagList,
createTagMapFromList,
renameTagKey,
importTags,
tag_filter_type,
compareTagsForSort,
initTags,
applyTagsOnCharacterSelect,
applyTagsOnGroupSelect,
tag_import_setting,
applyCharacterTagsToMessageDivs,
} from './scripts/tags.js';
import { initSecrets, readSecretState } from './scripts/secrets.js';
import { markdownExclusionExt } from './scripts/showdown-exclusion.js';
import { markdownUnderscoreExt } from './scripts/showdown-underscore.js';
import { NOTE_MODULE_NAME, initAuthorsNote, metadata_keys, setFloatingPrompt, shouldWIAddPrompt } from './scripts/authors-note.js';
import { registerPromptManagerMigration } from './scripts/PromptManager.js';
import { getRegexedString, regex_placement } from './scripts/extensions/regex/engine.js';
import { initLogprobs, saveLogprobsForActiveMessage } from './scripts/logprobs.js';
import { FILTER_STATES, FILTER_TYPES, FilterHelper, isFilterState } from './scripts/filters.js';
import { getCfgPrompt, getGuidanceScale, initCfg } from './scripts/cfg-scale.js';
import {
force_output_sequence,
formatInstructModeChat,
formatInstructModePrompt,
formatInstructModeExamples,
formatInstructModeStoryString,
getInstructStoppingSequences,
} from './scripts/instruct-mode.js';
import { initLocales, t } from './scripts/i18n.js';
import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, initTokenizers, saveTokenCache } from './scripts/tokenizers.js';
import {
user_avatar,
getUserAvatars,
getUserAvatar,
setUserAvatar,
initPersonas,
setPersonaDescription,
initUserAvatar,
updatePersonaConnectionsAvatarList,
isPersonaPanelOpen,
} from './scripts/personas.js';
import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_settings } from './scripts/backgrounds.js';
import { hideLoader, showLoader } from './scripts/loader.js';
import { BulkEditOverlay } from './scripts/BulkEditOverlay.js';
import { initTextGenModels } from './scripts/textgen-models.js';
import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, preserveNeutralChat, restoreNeutralChat, formatCreatorNotes, initChatUtilities, addDOMPurifyHooks } from './scripts/chats.js';
import { getPresetManager, initPresetManager } from './scripts/preset-manager.js';
import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js';
import { currentUser, setUserControls } from './scripts/user.js';
import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup, fixToastrForDialogs } from './scripts/popup.js';
import { renderTemplate, renderTemplateAsync } from './scripts/templates.js';
import { initScrapers } from './scripts/scrapers.js';
import { initCustomSelectedSamplers, validateDisabledSamplers } from './scripts/samplerSelect.js';
import { DragAndDropHandler } from './scripts/dragdrop.js';
import { INTERACTABLE_CONTROL_CLASS, initKeyboard } from './scripts/keyboard.js';
import { initDynamicStyles } from './scripts/dynamic-styles.js';
import { initInputMarkdown } from './scripts/input-md-formatting.js';
import { AbortReason } from './scripts/util/AbortReason.js';
import { initSystemPrompts } from './scripts/sysprompt.js';
import { registerExtensionSlashCommands as initExtensionSlashCommands } from './scripts/extensions-slashcommands.js';
import { ToolManager } from './scripts/tool-calling.js';
import { addShowdownPatch } from './scripts/util/showdown-patch.js';
import { applyBrowserFixes } from './scripts/browser-fixes.js';
import { initServerHistory } from './scripts/server-history.js';
import { initSettingsSearch } from './scripts/setting-search.js';
import { initBulkEdit } from './scripts/bulk-edit.js';
import { getContext } from './scripts/st-context.js';
import { extractReasoningFromData, extractReasoningSignatureFromData, initReasoning, parseReasoningInSwipes, PromptReasoning, ReasoningHandler, removeReasoningFromString, updateReasoningUI } from './scripts/reasoning.js';
import { accountStorage } from './scripts/util/AccountStorage.js';
import { initWelcomeScreen, openPermanentAssistantChat, openPermanentAssistantCard, getPermanentAssistantAvatar } from './scripts/welcome-screen.js';
import { initDataMaid } from './scripts/data-maid.js';
import { clearItemizedPrompts, deleteItemizedPrompts, findItemizedPromptSet, initItemizedPrompts, itemizedParams, itemizedPrompts, loadItemizedPrompts, promptItemize, replaceItemizedPromptText, saveItemizedPrompts } from './scripts/itemized-prompts.js';
import { getSystemMessageByType, initSystemMessages, SAFETY_CHAT, sendSystemMessage, system_message_types, system_messages } from './scripts/system-messages.js';
import { event_types, eventSource } from './scripts/events.js';
import { initAccessibility } from './scripts/a11y.js';
import { applyStreamFadeIn } from './scripts/util/stream-fadein.js';
import { initDomHandlers } from './scripts/dom-handlers.js';
import { SimpleMutex } from './scripts/util/SimpleMutex.js';
import { AudioPlayer } from './scripts/audio-player.js';
import { MacroEnvBuilder } from './scripts/macros/engine/MacroEnvBuilder.js';
import { MacroEngine } from './scripts/macros/engine/MacroEngine.js';
import { addChatBackupsBrowser } from './scripts/chat-backups.js';
// API OBJECT FOR EXTERNAL WIRING
globalThis.SillyTavern = {
libs,
getContext,
};
export {
user_avatar,
setUserAvatar,
getUserAvatars,
getUserAvatar,
nai_settings,
isOdd,
countOccurrences,
renderTemplate,
promptItemize,
itemizedPrompts,
saveItemizedPrompts,
loadItemizedPrompts,
itemizedParams,
clearItemizedPrompts,
replaceItemizedPromptText,
deleteItemizedPrompts,
findItemizedPromptSet,
koboldai_settings,
koboldai_setting_names,
novelai_settings,
novelai_setting_names,
UNIQUE_APIS,
CONNECT_API_MAP,
system_messages,
system_message_types,
sendSystemMessage,
getSystemMessageByType,
event_types,
eventSource,
/** @deprecated Use setCharacterSettingsOverrides instead. */
setCharacterSettingsOverrides as setScenarioOverride,
/** @deprecated Use appendMediaToMessage instead. */
appendMediaToMessage as appendImageToMessage,
};
/**
* Wait for page to load before continuing the app initialization.
*/
await new Promise((resolve) => {
if (document.readyState === 'complete') {
resolve();
} else {
window.addEventListener('load', resolve);
}
});
// Configure toast library:
toastr.options = {
positionClass: 'toast-top-center',
closeButton: false,
progressBar: false,
showDuration: 250,
hideDuration: 250,
timeOut: 4000,
extendedTimeOut: 10000,
showEasing: 'linear',
hideEasing: 'linear',
showMethod: 'fadeIn',
hideMethod: 'fadeOut',
escapeHtml: true,
onHidden: function () {
// If we have any dialog still open, the last "hidden" toastr will remove the toastr-container. We need to keep it alive inside the dialog though
// so the toasts still show up inside there.
fixToastrForDialogs();
},
onShown: function () {
// Set tooltip to the notification message
$(this).attr('title', t`Tap to close`);
},
};
export const characterGroupOverlay = new BulkEditOverlay();
// Markdown converter
export let mesForShowdownParse; //intended to be used as a context to compare showdown strings against
/** @type {import('showdown').Converter} */
export let converter;
// array for prompt token calculations
export const systemUserName = 'SillyTavern System';
export const neutralCharacterName = 'Assistant';
let default_user_name = 'User';
export let name1 = default_user_name;
export let name2 = systemUserName;
/** @type {ChatMessage[]} */
export let chat = [];
/**
* @type {import('./scripts/constants.js').SWIPE_STATE}
*/
export let swipeState = SWIPE_STATE.NONE;
let chatSaveTimeout;
let importFlashTimeout;
export let isChatSaving = false;
let firstRun = false;
let settingsReady = false;
let currentVersion = '0.0.0';
export let displayVersion = 'SillyTavern';
let generation_started = new Date();
/** @type {Character[]} */
export let characters = [];
/**
* Stringified index of a currently chosen entity in the characters array.
* @type {string|undefined} Yes, we hate it as much as you do.
*/
export let this_chid;
let saveCharactersPage = 0;
export const default_avatar = 'img/ai4.png';
export const system_avatar = 'img/five.png';
export const comment_avatar = 'img/quill.png';
export const default_user_avatar = 'img/user-default.png';
export let CLIENT_VERSION = 'SillyTavern:UNKNOWN:Cohee#1207'; // For Horde header
let optionsPopper = Popper.createPopper(document.getElementById('options_button'), document.getElementById('options'), {
placement: 'top-start',
});
let exportPopper = Popper.createPopper(document.getElementById('export_button'), document.getElementById('export_format_popup'), {
placement: 'left',
});
let isExportPopupOpen = false;
// Saved here for performance reasons
const messageTemplate = $('#message_template .mes');
export const chatElement = $('#chat');
let dialogueResolve = null;
let dialogueCloseStop = false;
/** @type {ChatMetadata} */
export let chat_metadata = {};
/** @type {StreamingProcessor} */
export let streamingProcessor = null;
let crop_data = undefined;
let is_delete_mode = false;
let fav_ch_checked = false;
let scrollLock = false;
export let abortStatusCheck = new AbortController();
export let charDragDropHandler = null;
export let chatDragDropHandler = null;
/** @type {debounce_timeout} The debounce timeout used for chat/settings save. debounce_timeout.long: 1.000 ms */
export const DEFAULT_SAVE_EDIT_TIMEOUT = debounce_timeout.relaxed;
/** @type {debounce_timeout} The debounce timeout used for printing. debounce_timeout.quick: 100 ms */
export const DEFAULT_PRINT_TIMEOUT = debounce_timeout.quick;
export const saveSettingsDebounced = debounce((loopCounter = 0) => saveSettings(loopCounter), DEFAULT_SAVE_EDIT_TIMEOUT);
export const saveCharacterDebounced = debounce(() => $('#create_button').trigger('click'), DEFAULT_SAVE_EDIT_TIMEOUT);
/**
* Prints the character list in a debounced fashion without blocking, with a delay of 100 milliseconds.
* Use this function instead of a direct `printCharacters()` whenever the reprinting of the character list is not the primary focus.
*
* The printing will also always reprint all filter options of the global list, to keep them up to date.
*/
export const printCharactersDebounced = debounce(() => { printCharacters(false); }, DEFAULT_PRINT_TIMEOUT);
/**
* @enum {number} Extension prompt types
*/
export const extension_prompt_types = {
NONE: -1,
IN_PROMPT: 0,
IN_CHAT: 1,
BEFORE_PROMPT: 2,
};
/**
* @enum {number} Extension prompt roles
*/
export const extension_prompt_roles = {
SYSTEM: 0,
USER: 1,
ASSISTANT: 2,
};
export const MAX_INJECTION_DEPTH = 10000;
async function getClientVersion() {
try {
const response = await fetch('/version');
const data = await response.json();
CLIENT_VERSION = data.agent;
displayVersion = `SillyTavern ${data.pkgVersion}`;
currentVersion = data.pkgVersion;
if (data.gitRevision && data.gitBranch) {
displayVersion += ` '${data.gitBranch}' (${data.gitRevision})`;
}
$('#version_display').text(displayVersion);
$('#version_display_welcome').text(displayVersion);
} catch (err) {
console.error('Couldn\'t get client version', err);
}
}
export function reloadMarkdownProcessor() {
converter = new showdown.Converter({
emoji: true,
literalMidWordUnderscores: true,
parseImgDimensions: true,
tables: true,
underline: true,
simpleLineBreaks: true,
strikethrough: true,
disableForced4SpacesIndentedSublists: true,
extensions: [markdownUnderscoreExt()],
});
// Inject the dinkus extension after creating the converter
// Maybe move this into power_user init?
converter.addExtension(markdownExclusionExt(), 'exclusion');
return converter;
}
export function getCurrentChatId() {
if (selected_group) {
return groups.find(x => x.id == selected_group)?.chat_id;
}
else if (this_chid !== undefined) {
return characters[this_chid]?.chat;
}
}
export const talkativeness_default = 0.5;
export const depth_prompt_depth_default = 4;
export const depth_prompt_role_default = 'system';
const per_page_default = 50;
var is_advanced_char_open = false;
/**
* The type of the right menu
* @typedef {'characters' | 'character_edit' | 'create' | 'group_edit' | 'group_create' | '' } MenuType
*/
/**
* The type of the right menu that is currently open
* @type {MenuType}
*/
export let menu_type = '';
export let selected_button = ''; //which button pressed
//create pole save
export let create_save = {
name: '',
description: '',
creator_notes: '',
post_history_instructions: '',
character_version: '',
system_prompt: '',
tags: '',
creator: '',
personality: '',
first_message: '',
/** @type {FileList|null} */
avatar: null,
scenario: '',
mes_example: '',
world: '',
talkativeness: talkativeness_default,
alternate_greetings: [],
depth_prompt_prompt: '',
depth_prompt_depth: depth_prompt_depth_default,
depth_prompt_role: depth_prompt_role_default,
extensions: {},
extra_books: [],
};
//animation right menu
export const ANIMATION_DURATION_DEFAULT = 125;
export let animation_duration = ANIMATION_DURATION_DEFAULT;
export let animation_easing = 'ease-in-out';
let popup_type = '';
let chat_file_for_del = '';
export let online_status = 'no_connection';
export let is_send_press = false; //Send generation
export const isGenerating = () => (is_send_press || is_group_generating);
let this_del_mes = -1;
/** @type {string} */
let this_edit_mes_chname = '';
/** @type {number|undefined} */
let this_edit_mes_id = undefined;
//settings
export let settings;
export let amount_gen = 80; //default max length of AI generated responses
export let max_context = 2048;
/** User preference for swipeable messages */
let swipes = true;
/** Forcefully hide swipes. */
export let swipesHidden = false;
/** @type {{ now: number, direction: string }} */
export let lastSwipeInfo = { now: performance.now(), direction: SWIPE_DIRECTION.RIGHT };
export let recentSwipes = 0;
export let extension_prompts = {};
export let main_api;// = "kobold";
/** @type {AbortController} */
let abortController;
//css
var css_send_form_display = $('<div id=send_form></div>').css('display');
var kobold_horde_model = '';
export let token;
/** The tag of the active character. (NOT the id) */
export let active_character = '';
/** The tag of the active group. (Coincidentally also the id) */
export let active_group = '';
export const entitiesFilter = new FilterHelper(printCharactersDebounced);
export function getRequestHeaders({ omitContentType = false } = {}) {
const headers = {
'Content-Type': 'application/json',
'X-CSRF-Token': token,
};
if (omitContentType) {
delete headers['Content-Type'];
}
return headers;
}
export function getSlideToggleOptions() {
return {
miliseconds: animation_duration * 1.5,
transitionFunction: animation_duration > 0 ? 'ease-in-out' : 'step-start',
};
}
$.ajaxPrefilter((options, originalOptions, xhr) => {
xhr.setRequestHeader('X-CSRF-Token', token);
});
/**
* Pings the STserver to check if it is reachable.
* @returns {Promise<boolean>} True if the server is reachable, false otherwise.
*/
export async function pingServer() {
try {
const result = await fetch('api/ping', {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true }),
});
if (!result.ok) {
return false;
}
return true;
} catch (error) {
console.error('Error pinging server', error);
return false;
}
}
//MARK: firstLoadInit
async function firstLoadInit() {
try {
const tokenResponse = await fetch('/csrf-token');
const tokenData = await tokenResponse.json();
token = tokenData.token;
} catch {
toastr.error(t`Couldn't get CSRF token. Please refresh the page.`, t`Error`, { timeOut: 0, extendedTimeOut: 0, preventDuplicates: true });
throw new Error('Initialization failed');
}
showLoader();
registerPromptManagerMigration();
initDomHandlers();
initStandaloneMode();
initLibraryShims();
addShowdownPatch(showdown);
addDOMPurifyHooks();
reloadMarkdownProcessor();
applyBrowserFixes();
await getClientVersion();
await initSecrets();
await readSecretState();
await initLocales();
initChatUtilities();
initDefaultSlashCommands();
initTextGenModels();
initOpenAI();
initTextGenSettings();
initKoboldSettings();
initNovelAISettings();
initSystemPrompts();
initExtensions();
initExtensionSlashCommands();
ToolManager.initToolSlashCommands();
await initPresetManager();
await initSystemMessages();
await getSettings();
initKeyboard();
initDynamicStyles();
initTags();
initBookmarks();
initMacros();
await getUserAvatars(true, user_avatar);
await getCharacters();
await getBackgrounds();
await initTokenizers();
initBackgrounds();
initAuthorsNote();
await initPersonas();
await initSlashCommandAutoComplete();
initWorldInfo();
initHorde();
initRossMods();
initStats();
initCfg();
initLogprobs();
initInputMarkdown();
initServerHistory();
initSettingsSearch();
initBulkEdit();
initReasoning();
initWelcomeScreen();
await initScrapers();
initCustomSelectedSamplers();
initDataMaid();
initItemizedPrompts();
initAccessibility();
addDebugFunctions();
doDailyExtensionUpdatesCheck();
await hideLoader();
await fixViewport();
await eventSource.emit(event_types.APP_READY);
}
async function fixViewport() {
document.body.style.position = 'absolute';
await delay(1);
document.body.style.position = '';
}
function initStandaloneMode() {
const isPwaMode = window.matchMedia('(display-mode: standalone)').matches;
if (isPwaMode) {
$('body').addClass('PWA');
}
}
export function cancelStatusCheck(reason = 'Manually cancelled status check') {
abortStatusCheck?.abort(new AbortReason(reason));
abortStatusCheck = new AbortController();
setOnlineStatus('no_connection');
}
export function displayOnlineStatus() {
if (online_status == 'no_connection') {
$('.online_status_indicator').removeClass('success');
$('.online_status_text').text($('#API-status-top').attr('no_connection_text'));
} else {
$('.online_status_indicator').addClass('success');
$('.online_status_text').text(online_status);
}
}
/**
* Sets the duration of JS animations.
* @param {number} ms Duration in milliseconds. Resets to default if null.
*/
export function setAnimationDuration(ms = null) {
animation_duration = ms ?? ANIMATION_DURATION_DEFAULT;
// Set CSS variable to document
document.documentElement.style.setProperty('--animation-duration', `${animation_duration}ms`);
}
/**
* Sets the currently active character
* @param {object|number|string} [entityOrKey] - An entity with id property (character, group, tag), or directly an id or tag key. If not provided, the active character is reset to `null`.
*/
export function setActiveCharacter(entityOrKey) {
active_character = entityOrKey ? getTagKeyForEntity(entityOrKey) : null;
if (active_character) active_group = null;
}
/**
* Sets the currently active group.
* @param {object|number|string} [entityOrKey] - An entity with id property (character, group, tag), or directly an id or tag key. If not provided, the active group is reset to `null`.
*/
export function setActiveGroup(entityOrKey) {
active_group = entityOrKey ? getTagKeyForEntity(entityOrKey) : null;
if (active_group) active_character = null;
}
export function startStatusLoading() {
$('.api_loading').show();
$('.api_button').addClass('disabled');
}
export function stopStatusLoading() {
$('.api_loading').hide();
$('.api_button').removeClass('disabled');
}
export function resultCheckStatus() {
displayOnlineStatus();
stopStatusLoading();
}
/**
* Switches the currently selected character to the one with the given ID. (character index, not the character key!)
*
* If the character ID doesn't exist, if the chat is being saved, or if a group is being generated, this function does nothing.
* If the character is different from the currently selected one, it will clear the chat and reset any selected character or group.
* @param {number} id The ID of the character to switch to.
* @param {object} [options] Options for the switch.
* @param {boolean} [options.switchMenu=true] Whether to switch the right menu to the character edit menu if the character is already selected.
* @returns {Promise<void>} 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} Entity - Object representing a display entity
* @property {Character|Group|import('./scripts/tags.js').Tag|*} item - The item
* @property {string|number} id - The id
* @property {'character'|'group'|'tag'} type - The type of this entity (character, group, tag)
* @property {Entity[]?} [entities=null] - An optional list of entities relevant for this item
* @property {number?} [hidden=null] - An optional number representing how many hidden entities this entity contains
* @property {boolean?} [isUseless=null] - Specifies if the entity is useless (not relevant, but should still be displayed for consistency) and should be displayed greyed out
*/
/**
* Converts the given character to its entity representation
*
* @param {Character} character - The character
* @param {string|number} id - The id of this character
* @returns {Entity} The entity for this character
*/
export function characterToEntity(character, id) {
return { item: character, id, type: 'character' };
}
/**
* Converts the given group to its entity representation
*
* @param {Group} group - The group
* @returns {Entity} The entity for this group
*/
export function groupToEntity(group) {
return { item: group, id: group.id, type: 'group' };
}
/**
* Converts the given tag to its entity representation
*
* @param {import('./scripts/tags.js').Tag} tag - The tag
* @returns {Entity} The entity for this tag
*/
export function tagToEntity(tag) {
return { item: structuredClone(tag), id: tag.id, type: 'tag', entities: [] };
}
/**
* Builds the full list of all entities available
*
* They will be correctly marked and filtered.
*
* @param {object} param0 - Optional parameters
* @param {boolean} [param0.doFilter] - Whether this entity list should already be filtered based on the global filters
* @param {boolean} [param0.doSort] - Whether the entity list should be sorted when returned
* @returns {Entity[]} All entities
*/
export function getEntitiesList({ doFilter = false, doSort = true } = {}) {
let entities = [
...characters.map((item, index) => characterToEntity(item, index)),
...groups.map(item => groupToEntity(item)),
...(power_user.bogus_folders ? tags.filter(isBogusFolder).sort(compareTagsForSort).map(item => tagToEntity(item)) : []),
];
// We need to do multiple filter runs in a specific order, otherwise different settings might override each other
// and screw up tags and search filter, sub lists or similar.
// The specific filters are written inside the "filterByTagState" method and its different parameters.
// Generally what we do is the following:
// 1. First swipe over the list to remove the most obvious things
// 2. Build sub entity lists for all folders, filtering them similarly to the second swipe
// 3. We do the last run, where global filters are applied, and the search filters last
// First run filters, that will hide what should never be displayed
if (doFilter) {
entities = filterByTagState(entities);
}
// Run over all entities between first and second filter to save some states
for (const entity of entities) {
// For folders, we remember the sub entities so they can be displayed later, even if they might be filtered
// Those sub entities should be filtered and have the search filters applied too
if (entity.type === 'tag') {
let subEntities = filterByTagState(entities, { subForEntity: entity, filterHidden: false });
const subCount = subEntities.length;
subEntities = filterByTagState(entities, { subForEntity: entity });
if (doFilter) {
// sub entities filter "hacked" because folder filter should not be applied there, so even in "only folders" mode characters show up
subEntities = entitiesFilter.applyFilters(subEntities, { clearScoreCache: false, tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED }, clearFuzzySearchCaches: false });
}
if (doSort) {
sortEntitiesList(subEntities, false);
}
entity.entities = subEntities;
entity.hidden = subCount - subEntities.length;
}
}
// Second run filters, hiding whatever should be filtered later
if (doFilter) {
const beforeFinalEntities = filterByTagState(entities, { globalDisplayFilters: true });
entities = entitiesFilter.applyFilters(beforeFinalEntities, { clearFuzzySearchCaches: false });
// Magic for folder filter. If that one is enabled, and no folders are display anymore, we remove that filter to actually show the characters.
if (isFilterState(entitiesFilter.getFilterData(FILTER_TYPES.FOLDER), FILTER_STATES.SELECTED) && entities.filter(x => x.type == 'tag').length == 0) {
entities = entitiesFilter.applyFilters(beforeFinalEntities, { tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED }, clearFuzzySearchCaches: false });
}
}
// Final step, updating some properties after the last filter run
const nonTagEntitiesCount = entities.filter(entity => entity.type !== 'tag').length;
for (const entity of entities) {
if (entity.type === 'tag') {
if (entity.entities?.length == nonTagEntitiesCount) entity.isUseless = true;
}
}
// Sort before returning if requested
if (doSort) {
sortEntitiesList(entities, false);
}
entitiesFilter.clearFuzzySearchCaches();
return entities;
}
export async function getOneCharacter(avatarUrl) {
const response = await fetch('/api/characters/get', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
avatar_url: avatarUrl,
}),
});
if (response.ok) {
const getData = await response.json();
getData['name'] = DOMPurify.sanitize(getData['name']);
getData['chat'] = String(getData['chat']);
const indexOf = characters.findIndex(x => x.avatar === avatarUrl);
if (indexOf !== -1) {
characters[indexOf] = getData;
} else {
toastr.error(t`Character ${avatarUrl} not found in the list`, t`Error`, { timeOut: 5000, preventDuplicates: true });
}
}
}
function getCharacterSource(chId = this_chid) {
const character = characters[chId];
if (!character) {
return '';
}
const chubId = characters[chId]?.data?.extensions?.chub?.full_path;
if (chubId) {
return `https://chub.ai/characters/${chubId}`;
}
const pygmalionId = characters[chId]?.data?.extensions?.pygmalion_id;
if (pygmalionId) {
return `https://pygmalion.chat/${pygmalionId}`;
}
const githubRepo = characters[chId]?.data?.extensions?.github_repo;
if (githubRepo) {
return `https://github.com/${githubRepo}`;
}
const sourceUrl = characters[chId]?.data?.extensions?.source_url;
if (sourceUrl) {
return sourceUrl;
}
const risuId = characters[chId]?.data?.extensions?.risuai?.source;
if (Array.isArray(risuId) && risuId.length && typeof risuId[0] === 'string' && risuId[0].startsWith('risurealm:')) {
const realmId = risuId[0].split(':')[1];
return `https://realm.risuai.net/character/${realmId}`;
}
const perchanceSlug = characters[chId]?.data?.extensions?.perchance_data?.slug;
if (perchanceSlug) {
return `https://perchance.org/ai-character-chat?data=${perchanceSlug}`;
}
return '';
}
export async function getCharacters() {
const response = await fetch('/api/characters/all', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({}),
});
if (response.ok) {
const previousAvatar = this_chid !== undefined ? characters[this_chid]?.avatar : null;
characters.splice(0, characters.length);
const getData = await response.json();
for (let i = 0; i < getData.length; i++) {
characters[i] = getData[i];
characters[i]['name'] = DOMPurify.sanitize(characters[i]['name']);
// For dropped-in cards
if (!characters[i]['chat']) {
characters[i]['chat'] = `${characters[i]['name']} - ${humanizedDateTime()}`;
}
characters[i]['chat'] = String(characters[i]['chat']);
}
if (previousAvatar) {
const newCharacterId = characters.findIndex(x => x.avatar === previousAvatar);
if (newCharacterId >= 0) {
setCharacterId(newCharacterId);
await selectCharacterById(newCharacterId, { switchMenu: false });
} else {
await Popup.show.text(t`ERROR: The active character is no longer available.`, t`The page will be refreshed to prevent data loss. Press "OK" to continue.`);
return location.reload();
}
}
await getGroups();
await printCharacters(true);
} else {
console.error('Failed to fetch characters:', response.statusText);
const errorData = await response.json();
if (errorData?.overflow) {
await Popup.show.text(t`Character data length limit reached`, t`To resolve this, set "performance.lazyLoadCharacters" to "true" in config.yaml and restart the server.`);
}
}
}
async function delChat(chatfile) {
const response = await fetch('/api/chats/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
chatfile: chatfile,
avatar_url: characters[this_chid].avatar,
}),
});
if (response.ok === true) {
// choose another chat if current was deleted
const name = chatfile.replace('.jsonl', '');
if (name === characters[this_chid].chat) {
chat_metadata = {};
await replaceCurrentChat();
}
await eventSource.emit(event_types.CHAT_DELETED, name);
}
}
/**
* Deletes a character chat by its name.
* @param {string} characterId Character ID to delete chat for
* @param {string} fileName Name of the chat file to delete (without .jsonl extension)
* @returns {Promise<void>} 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 {Character} */
const character = characters[characterId];
if (!character) {
console.warn(`Character with ID ${characterId} not found.`);
return;
}
const response = await fetch('/api/chats/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
chatfile: `${fileName}.jsonl`,
avatar_url: character.avatar,
}),
});
if (!response.ok) {
console.error('Failed to delete chat for character.');
return;
}
if (fileName === character.chat) {
const chatsResponse = await fetch('/api/characters/chats', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ avatar_url: character.avatar }),
});
const chats = Object.values(await chatsResponse.json());
chats.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)));
const newChatName = chats.length && typeof chats[0] === 'object' ? chats[0].file_name.replace('.jsonl', '') : `${character.name} - ${humanizedDateTime()}`;
await updateRemoteChatName(characterId, newChatName);
}
await eventSource.emit(event_types.CHAT_DELETED, fileName);
}
export async function replaceCurrentChat() {
await clearChat();
chat.length = 0;
const chatsResponse = await fetch('/api/characters/chats', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ avatar_url: characters[this_chid].avatar }),
});
if (chatsResponse.ok) {
const chats = Object.values(await chatsResponse.json());
chats.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)));
// pick existing chat
if (chats.length && typeof chats[0] === 'object') {
characters[this_chid].chat = chats[0].file_name.replace('.jsonl', '');
$('#selected_chat_pole').val(characters[this_chid].chat);
saveCharacterDebounced();
await getChat();
}
// start new chat
else {
characters[this_chid].chat = `${name2} - ${humanizedDateTime()}`;
$('#selected_chat_pole').val(characters[this_chid].chat);
saveCharacterDebounced();
await getChat();
}
}
}
export async function showMoreMessages(messagesToLoad = null) {
const firstDisplayedMesId = chatElement.children('.mes').first().attr('mesid');
let messageId = Number(firstDisplayedMesId);
let count = messagesToLoad || power_user.chat_truncation || Number.MAX_SAFE_INTEGER;
// If there are no messages displayed, or the message somehow has no mesid, we default to one higher than last message id,
// so the first "new" message being shown will be the last available message
if (isNaN(messageId)) {
messageId = getLastMessageId() + 1;
}
console.debug('Inserting messages before', messageId, 'count', count, 'chat length', chat.length);
const prevHeight = chatElement.prop('scrollHeight');
const isButtonInView = isElementInViewport($('#show_more_messages')[0]);
while (messageId > 0 && count > 0) {
let newMessageId = messageId - 1;
addOneMessage(chat[newMessageId], { insertBefore: messageId >= chat.length ? null : messageId, scroll: false, forceId: newMessageId, showSwipes: false });
count--;
messageId--;
}
refreshSwipeButtons();
if (messageId == 0) {
$('#show_more_messages').remove();
}
if (isButtonInView) {
const newHeight = chatElement.prop('scrollHeight');
chatElement.scrollTop(newHeight - prevHeight);
}
applyStylePins();
await eventSource.emit(event_types.MORE_MESSAGES_LOADED);
}
export async function printMessages() {
let startIndex = 0;
let count = power_user.chat_truncation || Number.MAX_SAFE_INTEGER;
if (chat.length > count) {
startIndex = chat.length - count;
chatElement.append('<div id="show_more_messages">Show more messages</div>');
}
for (let i = startIndex; i < chat.length; i++) {
const item = chat[i];
addOneMessage(item, { scroll: false, forceId: i, showSwipes: false });
}
chatElement.find('.mes').removeClass('last_mes');
chatElement.find('.mes').last().addClass('last_mes');
refreshSwipeButtons(false, false);
applyStylePins();
scrollChatToBottom({ waitForFrame: true });
delay(debounce_timeout.short).then(() => scrollOnMediaLoad());
}
export function scrollOnMediaLoad() {
const started = Date.now();
const media = chatElement.find('.mes_block img, .mes_block video, .mes_block audio').toArray();
let mediaLoaded = 0;
for (const currentElement of media) {
if (currentElement instanceof HTMLImageElement) {
if (currentElement.complete) {
incrementAndCheck();
} else {
currentElement.addEventListener('load', incrementAndCheck);
currentElement.addEventListener('error', incrementAndCheck);
}
}
if (currentElement instanceof HTMLMediaElement) {
if (currentElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
incrementAndCheck();
} else {
currentElement.addEventListener('loadeddata', incrementAndCheck);
currentElement.addEventListener('error', incrementAndCheck);
}
}
}
function incrementAndCheck() {
const MAX_DELAY = 1000; // 1 second
if ((Date.now() - started) > MAX_DELAY) {
return;
}
mediaLoaded++;
if (mediaLoaded === media.length) {
scrollChatToBottom({ waitForFrame: true });
}
}
}
/**
* Cancels the debounced chat save if it is currently pending.
*/
export function cancelDebouncedChatSave() {
if (chatSaveTimeout) {
console.debug('Debounced chat save cancelled');
clearTimeout(chatSaveTimeout);
chatSaveTimeout = null;
}
}
export async function clearChat() {
cancelDebouncedChatSave();
cancelDebouncedMetadataSave();
closeMessageEditor();
extension_prompts = {};
if (is_delete_mode) {
$('#dialogue_del_mes_cancel').trigger('click');
}
//This will also remove non '.mes' elements, e.g. '<div id="show_more_messages">Show more messages</div>'.
chatElement.children().remove();
if ($('.zoomed_avatar[forChar]').length) {
console.debug('saw avatars to remove');
$('.zoomed_avatar[forChar]').remove();
} else { console.debug('saw no avatars'); }
await saveItemizedPrompts(getCurrentChatId());
itemizedPrompts.length = 0;
}
export async function deleteLastMessage() {
chat.length = chat.length - 1;
chatElement.children('.mes').last().remove();
await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);
}
/**
* Deletes a message from the chat by its ID, optionally asking for confirmation.
* @param {number} id The ID of the message to delete.
* @param {number} [swipeDeletionIndex] Deletes the swipe with that index.
* @param {boolean} [askConfirmation=false] Whether to ask for confirmation before deleting.
*/
export async function deleteMessage(id, swipeDeletionIndex = undefined, askConfirmation = false) {
const canDeleteSwipe = swipeDeletionIndex !== undefined && swipeDeletionIndex !== null;
if (canDeleteSwipe) {
if (swipeDeletionIndex < 0) {
throw new Error('Swipe index cannot be negative');
}
if (!Array.isArray(chat[id].swipes)) {
throw new Error('Message has no swipes to delete');
}
if (chat[id].swipes.length <= swipeDeletionIndex) {
throw new Error('Swipe index out of bounds');
}
}
const minId = getFirstDisplayedMessageId();
const messageElement = chatElement.find(`.mes[mesid="${id}"]`);
if (messageElement.length === 0) {
return;
}
let deleteOnlySwipe = canDeleteSwipe;
if (askConfirmation) {
const result = await callGenericPopup(t`Are you sure you want to delete this message?`, POPUP_TYPE.CONFIRM, null, {
okButton: canDeleteSwipe ? t`Delete Swipe` : t`Delete Message`,
cancelButton: 'Cancel',
customButtons: canDeleteSwipe ? [t`Delete Message`] : null,
});
if (!result) {
return;
}
deleteOnlySwipe = canDeleteSwipe && result === POPUP_RESULT.AFFIRMATIVE; // Default button, not the custom one
}
if (deleteOnlySwipe) {
await deleteSwipe(swipeDeletionIndex, id);
return;
}
chat.splice(id, 1);
messageElement.remove();
chat_metadata['tainted'] = true;
const startIndex = [0, minId].includes(id) ? id : null;
updateViewMessageIds(startIndex);
saveChatDebounced();
if (this_edit_mes_id === id) {
this_edit_mes_id = undefined;
}
refreshSwipeButtons();
await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);
}
export async function reloadCurrentChat() {
preserveNeutralChat();
await clearChat();
chat.length = 0;
if (selected_group) {
await getGroupChat(selected_group, true);
}
else if (this_chid !== undefined) {
await getChat();
}
else {
resetChatState();
restoreNeutralChat();
await getCharacters();
await printMessages();
await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId());
}
refreshSwipeButtons();
}
/**
* Send the message currently typed into the chat box.
*/
export async function sendTextareaMessage() {
// don't proceed during swipeGenerate()
if (swipeState == SWIPE_STATE.EDITING) {
toastr.warning(t`Confirm the edit to start a generation.`, t`You cannot send a message during a swipe-edit.`);
return;
}
if (swipeState !== SWIPE_STATE.NONE) return; // don't proceed if mid-swipe.
if (is_send_press) return;
if (isExecutingCommandsFromChatInput) return;
hideSwipeButtons(); //Swipe buttons must be hidden now, otherwise concurrent generations are possible.
let generateType = 'normal';
// "Continue on send" is activated when the user hits "send" (or presses enter) on an empty chat box, and the last
// message was sent from a character (not the user or the system).
const textareaText = String($('#send_textarea').val());
if (power_user.continue_on_send &&
!hasPendingFileAttachment() &&
!textareaText &&
!selected_group &&
chat.length &&
!chat[chat.length - 1]['is_user'] &&
!chat[chat.length - 1]['is_system']
) {
generateType = 'continue';
}
if (textareaText && !selected_group && this_chid === undefined && name2 !== neutralCharacterName) {
await newAssistantChat({ temporary: false });
}
let generation = await Generate(generateType);
showSwipeButtons();
return generation;
}
/**
* Formats the message text into an HTML string using Markdown and other formatting.
* @param {string} mes Message text
* @param {string} ch_name Character name
* @param {boolean} isSystem If the message was sent by the system
* @param {boolean} isUser If the message was sent by the user
* @param {number} messageId Message index in chat array
* @param {object} [sanitizerOverrides] DOMPurify sanitizer option overrides
* @param {boolean} [isReasoning] If the message is reasoning output
* @returns {string} HTML string
*/
export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, sanitizerOverrides = {}, isReasoning = false) {
if (!mes) {
return '';
}
if (Number(messageId) === 0 && !isSystem && !isUser && !isReasoning) {
const mesBeforeReplace = mes;
const chatMessage = chat[messageId];
mes = substituteParams(mes, undefined, ch_name);
if (chatMessage && chatMessage.mes === mesBeforeReplace && chatMessage.extra?.display_text !== mesBeforeReplace) {
chatMessage.mes = mes;
}
}
mesForShowdownParse = mes;
// Force isSystem = false on comment messages so they get formatted properly
if (ch_name === COMMENT_NAME_DEFAULT && isSystem && !isUser) {
isSystem = false;
}
// Let hidden messages have markdown
if (isSystem && ch_name !== systemUserName) {
isSystem = false;
}
// Prompt bias replacement should be applied on the raw message
const replacedPromptBias = power_user.user_prompt_bias && substituteParams(power_user.user_prompt_bias);
if (!power_user.show_user_prompt_bias && ch_name && !isUser && !isSystem && replacedPromptBias && mes.startsWith(replacedPromptBias)) {
mes = mes.slice(replacedPromptBias.length);
}
if (!isSystem) {
function getRegexPlacement() {
try {
if (isReasoning) {
return regex_placement.REASONING;
}
if (isUser) {
return regex_placement.USER_INPUT;
} else if (chat[messageId]?.extra?.type === 'narrator') {
return regex_placement.SLASH_COMMAND;
} else {
return regex_placement.AI_OUTPUT;
}
} catch {
return regex_placement.AI_OUTPUT;
}
}
const regexPlacement = getRegexPlacement();
const usableMessages = chat.map((x, index) => ({ message: x, index: index })).filter(x => !x.message.is_system);
const indexOf = usableMessages.findIndex(x => x.index === Number(messageId));
const depth = messageId >= 0 && indexOf !== -1 ? (usableMessages.length - indexOf - 1) : undefined;
// Always override the character name
mes = getRegexedString(mes, regexPlacement, {
characterOverride: ch_name,
isMarkdown: true,
depth: depth,
});
}
if (power_user.auto_fix_generated_markdown) {
mes = fixMarkdown(mes, true);
}
if (!isSystem && power_user.encode_tags) {
mes = canUseNegativeLookbehind()
? mes.replaceAll('<', '&lt;').replace(new RegExp('(?<!^|\\n\\s*)>', 'g'), '&gt;')
: mes.replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
// 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(
/<style>[\s\S]*?<\/style>|```[\s\S]*?```|~~~[\s\S]*?~~~|``[\s\S]*?``|`[\s\S]*?`|(".*?")|(\u201C.*?\u201D)|(\u00AB.*?\u00BB)|(\u300C.*?\u300D)|(\u300E.*?\u300F)|(\uFF02.*?\uFF02)/gim,
function (match, p1, p2, p3, p4, p5, p6) {
if (p1) {
// English double quotes
return `<q>"${p1.slice(1, -1)}"</q>`;
} else if (p2) {
// Curly double quotes “ ”
return `<q>“${p2.slice(1, -1)}”</q>`;
} else if (p3) {
// Guillemets « »
return `<q>«${p3.slice(1, -1)}»</q>`;
} else if (p4) {
// Corner brackets 「 」
return `<q>「${p4.slice(1, -1)}」</q>`;
} else if (p5) {
// White corner brackets 『 』
return `<q>『${p5.slice(1, -1)}』</q>`;
} else if (p6) {
// Fullwidth quotes
return `<q>${p6.slice(1, -1)}</q>`;
} else {
// Return the original match if no quotes are found
return match;
}
},
);
// Restore double quotes in tags
if (!power_user.encode_tags) {
mes = mes.replace(/\ufffe/g, '"');
}
mes = mes.replaceAll('\\begin{align*}', '$$');
mes = mes.replaceAll('\\end{align*}', '$$');
mes = converter.makeHtml(mes);
mes = mes.replace(/<code(.*)>[\s\S]*?<\/code>/g, function (match) {
// Firefox creates extra newlines from <br>s in code blocks, so we replace them before converting newlines to <br>s.
return match.replace(/\n/gm, '\u0000');
});
mes = mes.replace(/\u0000/g, '\n'); // Restore converted newlines
mes = mes.trim();
mes = mes.replace(/<code(.*)>[\s\S]*?<\/code>/g, function (match) {
return match.replace(/&amp;/g, '&');
});
}
if (!power_user.allow_name2_display && ch_name && !isUser && !isSystem) {
mes = mes.replace(new RegExp(`(^|\n)${escapeRegex(ch_name)}:`, 'g'), '$1');
}
/** @type {import('dompurify').Config & { RETURN_DOM_FRAGMENT: false; RETURN_DOM: false }} */
const config = {
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
RETURN_TRUSTED_TYPE: false,
MESSAGE_SANITIZE: true,
ADD_TAGS: ['custom-style'],
...sanitizerOverrides,
};
mes = encodeStyleTags(mes);
mes = DOMPurify.sanitize(mes, config);
mes = decodeStyleTags(mes, { prefix: '.mes_text ' });
return mes;
}
/**
* Inserts or replaces an SVG icon adjacent to the provided message's timestamp.
*
* If the `extra.api` is "openai" and `extra.model` contains the substring "claude",
* the function fetches the "claude.svg". Otherwise, it fetches the SVG named after
* the value in `extra.api`.
*
* @param {JQuery<HTMLElement>} mes - The message element containing the timestamp where the icon should be inserted or replaced.
* @param {Object} extra - Contains the API and model details.
* @param {string} extra.api - The name of the API, used to determine which SVG to fetch.
* @param {string} extra.model - The model name, used to check for the substring "claude".
*/
function insertSVGIcon(mes, extra) {
// Determine the SVG filename
let modelName;
// Claude on OpenRouter or Anthropic
if (extra.api === 'openai' && extra.model?.toLowerCase().includes('claude')) {
modelName = 'claude';
}
// OpenAI on OpenRouter
else if (extra.api === 'openai' && extra.model?.toLowerCase().includes('openai')) {
modelName = 'openai';
}
// OpenRouter website model or other models
else if (extra.api === 'openai' && (extra.model === null || extra.model?.toLowerCase().includes('/'))) {
modelName = 'openrouter';
}
// Everything else
else {
modelName = extra.api;
}
const insertOrReplaceSVG = (image, className, targetSelector, insertBefore) => {
image.onload = async function () {
let existingSVG = insertBefore ? mes.find(targetSelector).prev(`.${className}`) : mes.find(targetSelector).next(`.${className}`);
if (existingSVG.length) {
existingSVG.replaceWith(image);
} else {
if (insertBefore) mes.find(targetSelector).before(image);
else mes.find(targetSelector).after(image);
}
await SVGInject(image);
};
};
const createModelImage = (className, targetSelector, insertBefore) => {
const image = new Image();
image.classList.add('icon-svg', className);
image.src = `/img/${modelName}.svg`;
image.title = `${extra?.api ? extra.api + ' - ' : ''}${extra?.model ?? ''}`;
insertOrReplaceSVG(image, className, targetSelector, insertBefore);
};
createModelImage('timestamp-icon', '.timestamp');
createModelImage('thinking-icon', '.mes_reasoning_header_title', true);
}
function getMessageFromTemplate({
mesId,
swipeId,
characterName,
isUser,
avatarImg,
bias,
isSystem,
title,
timerValue,
timerTitle,
bookmarkLink,
forceAvatar,
timestamp,
tokenCount,
extra,
type,
}) {
const mes = messageTemplate.clone();
mes.attr({
'mesid': mesId,
'swipeid': swipeId,
'ch_name': characterName,
'is_user': isUser,
'is_system': !!isSystem,
'bookmark_link': bookmarkLink,
'force_avatar': !!forceAvatar,
'timestamp': timestamp,
...(type ? { type } : {}),
});
mes.find('.avatar img').attr('src', avatarImg);
mes.find('.ch_name .name_text').text(characterName);
mes.find('.mes_bias').html(bias);
mes.find('.timestamp').text(timestamp).attr('title', `${extra?.api ? extra.api + ' - ' : ''}${extra?.model ?? ''}`);
mes.find('.mesIDDisplay').text(`#${mesId}`);
tokenCount && mes.find('.tokenCounterDisplay').text(`${tokenCount}t`);
title && mes.attr('title', title);
timerValue && mes.find('.mes_timer').attr('title', timerTitle).text(timerValue);
bookmarkLink && updateBookmarkDisplay(mes);
updateReasoningUI(mes);
if (power_user.timestamp_model_icon && extra?.api) {
insertSVGIcon(mes, extra);
}
return mes;
}
/**
* Re-renders a message block with updated content.
* @param {number} messageId Message ID
* @param {object} message Message object
* @param {object} [options={}] Optional arguments
* @param {boolean} [options.rerenderMessage=true] Whether to re-render the message content (inside <c>.mes_text</c>)
*/
export function updateMessageBlock(messageId, message, { rerenderMessage = true } = {}) {
const messageElement = chatElement.find(`[mesid="${messageId}"]`);
if (rerenderMessage) {
const text = message?.extra?.display_text ?? message.mes;
messageElement.find('.mes_text').html(messageFormatting(text, message.name, message.is_system, message.is_user, messageId, {}, false));
}
updateReasoningUI(messageElement);
addCopyToCodeBlocks(messageElement);
appendMediaToMessage(message, messageElement);
}
/**
* Ensures that the message media properties are arrays, adding getters/setters for single media items.
* @param {ChatMessage} mes Message object
*/
export function ensureMessageMediaIsArray(mes) {
/**
* Determines if a property of an object is a plain property (not a getter/setter or non-enumerable).
* @param {object} obj Object to check
* @param {string} name Property name
* @returns {boolean} True if the property is a plain property, false otherwise
*/
function isPlainObjectProperty(obj, name) {
const hasProperty = Object.hasOwn(obj, name);
if (hasProperty) {
const descriptor = Object.getOwnPropertyDescriptor(obj, name);
return descriptor && descriptor.enumerable && descriptor.configurable && descriptor.writable;
}
return false;
}
/**
* Determines if a property of an object is a getter (not a plain property).
* @param {object} obj Object to check
* @param {string} name Property name
* @returns {boolean} True if the property is a getter, false otherwise
*/
function isGetterObjectProperty(obj, name) {
const hasProperty = Object.hasOwn(obj, name);
if (hasProperty) {
const descriptor = Object.getOwnPropertyDescriptor(obj, name);
return descriptor && typeof descriptor.get === 'function';
}
return false;
}
/**
* Adds a plain property to an object that wraps around an array property.
* @param {object} obj Object to add property to
* @param {string} plainProperty Plain property name
* @param {string} arrayProperty Array property to back the plain property
* @param {(value: any) => boolean} [filterFn] Optional filter function to apply when getting/setting the plain property
* @param {(value: any) => any} [mapFn] Optional map function to apply when getting/setting the plain property
*/
function addArrayAutoWrapper(obj, plainProperty, arrayProperty, filterFn = () => true, mapFn = (t) => t) {
// If the plain property is already a getter, do nothing.
const hasGetterProperty = isGetterObjectProperty(obj, plainProperty);
if (hasGetterProperty) {
return;
}
// Define the plain property as a getter/setter that wraps around the array property.
Object.defineProperty(obj, plainProperty, {
// Getting the plain property returns the first item in the array property, or undefined if the array is empty.
get: function () {
console.trace(`Attempting to GET an array-wrapped property '${plainProperty}'. Use the array property '${arrayProperty}' instead.`);
const array = Array.isArray(this[arrayProperty]) ? this[arrayProperty].filter(filterFn).map(mapFn) : [];
return array.length > 0 ? array[0] : void 0;
},
// Setting the plain property is not supported, as it would be ambiguous.
set: function () {
console.trace(`Attempting to SET an array-wrapped property '${plainProperty}'. Use the array property '${arrayProperty}' instead.`);
},
// Exclude the property from JSON serialization and from being listed in for...in loops.
enumerable: false,
// Make the property non-configurable to prevent deletion or redefinition.
configurable: false,
});
}
/**
* Migrates image swipes from a single image property to an array.
* @param {ChatMessageExtra} obj
*/
function migrateMediaToArray(obj) {
if (isPlainObjectProperty(obj, 'file')) {
if (!Array.isArray(obj.files)) {
obj.files = [];
}
const fileValue = obj.file;
delete obj.file;
if (fileValue) {
obj.files.push(fileValue);
}
}
if (Array.isArray(obj.image_swipes)) {
if (!Array.isArray(obj.media)) {
obj.media = [];
}
for (const swipe of obj.image_swipes) {
if (swipe && typeof swipe === 'string') {
obj.media_display = MEDIA_DISPLAY.GALLERY;
obj.media.push({ type: MEDIA_TYPE.IMAGE, url: swipe });
}
}
delete obj.image_swipes;
}
if (isPlainObjectProperty(obj, 'image')) {
if (!Array.isArray(obj.media)) {
obj.media = [];
}
const imageValue = obj.image;
delete obj.image;
if (imageValue && typeof imageValue === 'string') {
obj.media.push({ type: MEDIA_TYPE.IMAGE, url: imageValue });
}
if (obj.media_display === MEDIA_DISPLAY.GALLERY) {
const selectedIndex = obj.media.findIndex(t => t.url === imageValue);
if (selectedIndex > -1) {
obj.media_index = selectedIndex;
}
}
obj.media = obj.media.filter((v, i, a) => i === a.findIndex(t => t.url === v.url));
}
if (isPlainObjectProperty(obj, 'video')) {
if (!Array.isArray(obj.media)) {
obj.media = [];
}
const videoValue = obj.video;
delete obj.video;
if (videoValue && typeof videoValue === 'string') {
obj.media.push({ type: MEDIA_TYPE.VIDEO, url: videoValue });
}
}
}
if (!mes || !mes.extra || typeof mes.extra !== 'object') {
return;
}
migrateMediaToArray(mes.extra);
addArrayAutoWrapper(mes.extra, 'file', 'files');
addArrayAutoWrapper(mes.extra, 'image', 'media', (t) => t.type === MEDIA_TYPE.IMAGE, (t) => t.url);
addArrayAutoWrapper(mes.extra, 'video', 'media', (t) => t.type === MEDIA_TYPE.VIDEO, (t) => t.url);
}
/**
* Gets the media display setting for a message.
* @param {ChatMessage} mes Message object
* @returns {MEDIA_DISPLAY} Media display setting
*/
export function getMediaDisplay(mes) {
const value = mes?.extra?.media_display || power_user.media_display || MEDIA_DISPLAY.LIST;
return Object.values(MEDIA_DISPLAY).includes(value) ? value : MEDIA_DISPLAY.LIST;
}
/**
* Gets the media index for a message.
* @param {ChatMessage} mes Message object
* @returns {number} Media index
*/
export function getMediaIndex(mes) {
if (!Array.isArray(mes?.extra?.media)) {
return 0;
}
const value = mes.extra?.media_index;
if (isNaN(value) || value < 0 || value >= mes.extra.media.length) {
return 0;
}
return value;
}
/**
* Appends image or file to the message element.
* @param {ChatMessage} mes Message object
* @param {JQuery<HTMLElement>} messageElement Message element
* @param {string} [scrollBehavior] Scroll behavior when adjusting scroll position
*/
export function appendMediaToMessage(mes, messageElement, scrollBehavior = SCROLL_BEHAVIOR.ADJUST) {
ensureMessageMediaIsArray(mes);
const fileWrapper = messageElement.find('.mes_file_wrapper');
const mediaWrapper = messageElement.find('.mes_media_wrapper');
const hasMedia = Array.isArray(mes?.extra?.media) && mes.extra.media.length > 0;
const hasFiles = Array.isArray(mes?.extra?.files) && mes.extra.files.length > 0;
const mediaDisplay = hasMedia ? getMediaDisplay(mes) : null;
const hideMessageText = hasMedia && mes?.extra?.inline_image === false;
const mediaBlocks = [];
const mediaPromises = [];
const chatHeight = (hasMedia || hasFiles) ? chatElement.prop('scrollHeight') : 0;
const scrollPosition = (hasMedia || hasFiles) ? chatElement.scrollTop() : 0;
const doAdjustScroll = () => {
if (!hasMedia && !hasFiles) {
return;
}
if (scrollBehavior === SCROLL_BEHAVIOR.NONE) {
return;
}
if (scrollBehavior === SCROLL_BEHAVIOR.KEEP) {
chatElement.scrollTop(scrollPosition);
return;
}
const newChatHeight = chatElement.prop('scrollHeight');
const diff = newChatHeight - chatHeight;
chatElement.scrollTop(scrollPosition + diff);
};
// Set media display attribute
messageElement.attr('data-media-display', mediaDisplay);
// Toggle text visibility
messageElement.find('.mes_text').toggleClass('inline_media', hideMessageText);
/**
* Appends a single image attachment to the message element.
* @param {MediaAttachment} attachment Image attachment object
* @param {number} index Index of the image attachment
* @returns {JQuery<HTMLElement>} The appended image container element
*/
function appendImageAttachment(attachment, index) {
const template = $('#message_image_template .mes_img_container').clone();
template.attr('data-index', index);
const image = template.find('.mes_img');
image.attr('src', attachment.url);
image.attr('title', attachment.title || mes.extra.title || '');
mediaPromises.push(new Promise((resolve) => {
function onLoad() {
image.removeAttr('alt');
image.removeClass('error');
resolve();
}
function onError() {
image.attr('alt', '');
image.addClass('error');
resolve();
}
if (image.prop('complete')) {
onLoad();
} else {
image.off('load').on('load', onLoad);
image.off('error').on('error', onError);
}
}));
mediaBlocks.push(template);
return template;
}
/**
* Appends a single video attachment to the message element.
* @param {MediaAttachment} attachment Video attachment object
* @param {number} index Index of the video attachment
* @returns {JQuery<HTMLElement>} The appended video container element
*/
function appendVideoAttachment(attachment, index) {
const template = $('#message_video_template .mes_video_container').clone();
template.attr('data-index', index);
const video = template.find('.mes_video');
video.attr('src', attachment.url);
video.attr('title', attachment.title || mes.extra.title || '');
mediaPromises.push(new Promise((resolve) => {
function onLoad() {
resolve();
}
function onError() {
video.addClass('error');
resolve();
}
if (video.prop('readyState') >= HTMLMediaElement.HAVE_CURRENT_DATA) {
onLoad();
} else {
video.off('loadeddata').on('loadeddata', onLoad);
video.off('error').on('error', onError);
}
}));
mediaBlocks.push(template);
return template;
}
/**
* Appends a single audio attachment to the message element.
* @param {MediaAttachment} attachment Audio attachment object
* @param {number} index Index of the audio attachment
* @returns {JQuery<HTMLElement>} The appended audio container element
*/
function appendAudioAttachment(attachment, index) {
const template = $('#message_audio_template .mes_audio_container').clone();
template.attr('data-index', index);
const audio = template.find('.mes_audio');
audio.attr('src', attachment.url);
audio.attr('title', attachment.title || mes.extra.title || '');
mediaPromises.push(new Promise((resolve) => {
function onLoad() {
resolve();
}
function onError() {
audio.addClass('error');
resolve();
}
if (audio.prop('readyState') >= HTMLMediaElement.HAVE_CURRENT_DATA) {
onLoad();
} else {
audio.off('loadeddata').on('loadeddata', onLoad);
audio.off('error').on('error', onError);
}
}));
new AudioPlayer(audio.get(0), template.get(0));
mediaBlocks.push(template);
return template;
}
/**
* Appends a media attachment to the message element.
* @param {MediaAttachment} attachment Media attachment object
* @param {number} index Index of the media attachment
* @returns {JQuery<HTMLElement>} The appended media container element
*/
function appendMediaAttachment(attachment, index) {
if (!attachment.type) {
attachment.type = MEDIA_TYPE.IMAGE;
}
switch (attachment.type) {
case MEDIA_TYPE.IMAGE:
return appendImageAttachment(attachment, index);
case MEDIA_TYPE.VIDEO:
return appendVideoAttachment(attachment, index);
case MEDIA_TYPE.AUDIO:
return appendAudioAttachment(attachment, index);
}
console.warn(`Unknown media type: ${attachment.type}, defaulting to image.`, attachment);
return appendImageAttachment(attachment, index);
}
/**
* Saves the current playback times of media elements in the message.
* @returns {Map<string, MediaState>} Media playback times by source URL
*/
function saveMediaStates() {
const states = new Map();
const media = mediaWrapper.find('video, audio');
media.each((_, element) => {
if (element instanceof HTMLMediaElement) {
if (!element.currentSrc || element.readyState === HTMLMediaElement.HAVE_NOTHING) {
return;
}
const state = { currentTime: element.currentTime, paused: element.paused };
states.set(element.currentSrc, state);
}
});
return states;
}
/**
* Restores the playback times of media elements in the message.
* @param {Map<string, MediaState>} states Media playback times by source URL
*/
function restoreMediaStates(states) {
const media = mediaWrapper.find('video, audio');
media.each((_, element) => {
if (element instanceof HTMLMediaElement) {
const restoreState = () => {
if (!states.has(element.currentSrc)) {
return;
}
const state = states.get(element.currentSrc);
element.currentTime = state.currentTime;
if (!state.paused) {
element.play();
}
};
if (element.readyState < HTMLMediaElement.HAVE_METADATA) {
element.addEventListener('loadedmetadata', () => restoreState(), { once: true });
} else {
restoreState();
}
}
});
}
// Add media gallery to message
if (hasMedia && mediaDisplay === MEDIA_DISPLAY.GALLERY) {
const mediaIndex = getMediaIndex(mes);
const selectedMedia = mes.extra.media[mediaIndex];
const galleryControls = $('#message_gallery_controls .mes_img_swipes').clone();
const counter = galleryControls.find('.mes_img_swipe_counter');
counter.text(`${mediaIndex + 1}/${mes.extra.media.length}`);
const template = appendMediaAttachment(selectedMedia, mediaIndex);
template.addClass('img_swipes');
template.append(galleryControls);
}
// Add media as a list to message
if (hasMedia && mediaDisplay === MEDIA_DISPLAY.LIST) {
for (let index = 0; index < mes.extra.media.length; index++) {
const attachment = mes.extra.media[index];
appendMediaAttachment(attachment, index);
}
}
// Remove existing file containers
fileWrapper.empty();
// Add files to message
if (hasFiles) {
for (let index = 0; index < mes.extra.files.length; index++) {
const file = mes.extra.files[index];
const template = $('#message_file_template .mes_file_container').clone();
template.attr('data-index', index);
template.find('.mes_file_name').text(file.name).attr('title', file.name);
template.find('.mes_file_size').text(humanFileSize(file.size)).attr('title', file.size);
fileWrapper.append(template);
}
}
// Early return if no media
if (!hasMedia) {
mediaWrapper.empty();
doAdjustScroll();
return;
}
// TODO: Consider making this awaitable
Promise.race([Promise.all(mediaPromises), delay(debounce_timeout.short)]).then(() => {
const states = saveMediaStates();
mediaWrapper.empty().append(mediaBlocks);
restoreMediaStates(states);
doAdjustScroll();
});
}
export function addCopyToCodeBlocks(messageElement) {
const codeBlocks = $(messageElement).find('pre code');
for (let i = 0; i < codeBlocks.length; i++) {
hljs.highlightElement(codeBlocks.get(i));
const copyButton = document.createElement('i');
copyButton.classList.add('fa-solid', 'fa-copy', 'code-copy', 'interactable');
copyButton.title = 'Copy code';
codeBlocks.get(i).appendChild(copyButton);
copyButton.addEventListener('click', function (e) {
e.stopPropagation();
});
copyButton.addEventListener('pointerup', async function () {
const text = codeBlocks.get(i).textContent;
await copyText(text);
toastr.info(t`Copied!`, '', { timeOut: 2000 });
});
}
}
/**
* Adds a single message to the chat.
* @param {ChatMessage} mes Message object
* @param {object} [options] Options
* @param {string} [options.type='normal'] Message type
* @param {number} [options.insertAfter=null] Message ID to insert the new message after
* @param {boolean} [options.scroll=true] Whether to scroll to the new message
* @param {number} [options.insertBefore=null] Message ID to insert the new message before
* @param {number} [options.forceId=null] Force the message ID
* @param {boolean} [options.showSwipes=true] Whether to refresh the swipe buttons.
* @returns {void}
*/
export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll = true, insertBefore = null, forceId = null, showSwipes = true } = {}) {
let messageText = mes['mes'];
const momentDate = timestampToMoment(mes.send_date);
const timestamp = momentDate.isValid() ? momentDate.format('LL LT') : '';
if (mes?.extra?.display_text) {
messageText = mes.extra.display_text;
}
// Forbidden black magic
// This allows to use "continue" on user messages
if (type === 'swipe' && mes.swipe_id === undefined) {
mes.swipe_id = 0;
mes.swipes = [mes.mes];
}
let avatarImg = getThumbnailUrl('persona', user_avatar);
const isSystem = mes.is_system;
const title = mes.title;
//for non-user mesages
if (!mes['is_user']) {
if (mes.force_avatar) {
avatarImg = mes.force_avatar;
} else if (this_chid === undefined) {
avatarImg = system_avatar;
} else {
if (characters[this_chid].avatar !== 'none') {
avatarImg = getThumbnailUrl('avatar', characters[this_chid].avatar);
} else {
avatarImg = default_avatar;
}
}
//old processing:
//if messge is from sytem, use the name provided in the message JSONL to proceed,
//if not system message, use name2 (char's name) to proceed
//characterName = mes.is_system || mes.force_avatar ? mes.name : name2;
} else if (mes['is_user'] && mes['force_avatar']) {
// Special case for persona images.
avatarImg = mes['force_avatar'];
}
// if mes.extra.uses_system_ui is true, set an override on the sanitizer options
const sanitizerOverrides = mes.extra?.uses_system_ui ? { MESSAGE_ALLOW_SYSTEM_UI: true } : {};
messageText = messageFormatting(
messageText,
mes.name,
isSystem,
mes.is_user,
chat.indexOf(mes),
sanitizerOverrides,
false,
);
const bias = messageFormatting(mes.extra?.bias ?? '', '', false, false, -1, {}, false);
let bookmarkLink = mes?.extra?.bookmark_link ?? '';
let params = {
mesId: forceId ?? chat.length - 1,
swipeId: mes.swipe_id ?? 0,
characterName: mes.name,
isUser: mes.is_user,
avatarImg: avatarImg,
bias: bias,
isSystem: isSystem,
title: title,
bookmarkLink: bookmarkLink,
forceAvatar: mes.force_avatar,
timestamp: timestamp,
extra: mes.extra,
tokenCount: mes.extra?.token_count ?? 0,
type: mes.extra?.type ?? '',
...formatGenerationTimer(mes.gen_started, mes.gen_finished, mes.extra?.token_count, mes.extra?.reasoning_duration, mes.extra?.time_to_first_token),
};
const renderedMessage = getMessageFromTemplate(params);
if (type !== 'swipe') {
if (!insertAfter && !insertBefore) {
chatElement.append(renderedMessage);
}
else if (insertAfter) {
const target = chatElement.find(`.mes[mesid="${insertAfter}"]`);
$(renderedMessage).insertAfter(target);
} else {
const target = chatElement.find(`.mes[mesid="${insertBefore}"]`);
$(renderedMessage).insertBefore(target);
}
}
// Callers push the new message to chat before calling addOneMessage
const newMessageId = typeof forceId == 'number' ? forceId : chat.length - 1;
const newMessage = chatElement.find(`[mesid="${newMessageId}"]`);
const isSmallSys = mes?.extra?.isSmallSys;
if (isSmallSys === true) {
newMessage.addClass('smallSysMes');
}
if (Array.isArray(mes?.extra?.tool_invocations)) {
newMessage.addClass('toolCall');
}
//shows or hides the Prompt display button
let mesIdToFind = type === 'swipe' ? params.mesId - 1 : params.mesId; //Number(newMessage.attr('mesId'));
//if we have itemized messages, and the array isn't null..
if (params.isUser === false && Array.isArray(itemizedPrompts) && itemizedPrompts.length > 0) {
const itemizedPrompt = itemizedPrompts.find(x => Number(x.mesId) === Number(mesIdToFind));
if (itemizedPrompt) {
newMessage.find('.mes_prompt').show();
}
}
newMessage.find('.avatar img').on('error', function () {
$(this).hide();
$(this).parent().html('<div class="missing-avatar fa-solid fa-user-slash"></div>');
});
if (type === 'swipe') {
const swipeMessage = chatElement.find(`[mesid="${newMessageId}"]`);
swipeMessage.attr('swipeid', params.swipeId);
swipeMessage.find('.mes_text').html(messageText).attr('title', title);
swipeMessage.find('.timestamp').text(timestamp).attr('title', `${params.extra.api} - ${params.extra.model}`);
updateReasoningUI(swipeMessage);
appendMediaToMessage(mes, swipeMessage, scroll ? SCROLL_BEHAVIOR.ADJUST : SCROLL_BEHAVIOR.NONE);
if (power_user.timestamp_model_icon && params.extra?.api) {
insertSVGIcon(swipeMessage, params.extra);
}
if (mes.swipe_id == mes.swipes.length - 1) {
swipeMessage.find('.mes_timer').text(params.timerValue).attr('title', params.timerTitle);
swipeMessage.find('.tokenCounterDisplay').text(`${params.tokenCount}t`);
} else {
swipeMessage.find('.mes_timer').empty();
swipeMessage.find('.tokenCounterDisplay').empty();
}
} else {
chatElement.find(`[mesid="${newMessageId}"] .mes_text`).append(messageText);
appendMediaToMessage(mes, newMessage, scroll ? SCROLL_BEHAVIOR.ADJUST : SCROLL_BEHAVIOR.NONE);
}
addCopyToCodeBlocks(newMessage);
// Set the swipes counter for all non-user messages.
if (!params.isUser) {
updateSwipeCounter(newMessageId);
}
//last_mes should always be updated.
chatElement.find('.mes').removeClass('last_mes');
chatElement.find('.mes').last().addClass('last_mes');
if (showSwipes) {
refreshSwipeButtons();
}
// Don't scroll if not inserting last
if (!insertAfter && !insertBefore && scroll) {
scrollChatToBottom({ waitForFrame: true });
}
applyCharacterTagsToMessageDivs({ mesIds: newMessageId });
updateEditArrowClasses();
}
/**
* Returns the URL of the avatar for the given character Id.
* @param {number|string} characterId Character Id
* @returns {string} Avatar URL
*/
export function getCharacterAvatar(characterId) {
const character = characters[characterId];
const avatarImg = character?.avatar;
if (!avatarImg || avatarImg === 'none') {
return default_avatar;
}
return formatCharacterAvatar(avatarImg);
}
export function formatCharacterAvatar(characterAvatar) {
return `characters/${characterAvatar}`;
}
/**
* Formats the title for the generation timer.
* @param {MessageTimestamp} gen_started Date when generation was started
* @param {MessageTimestamp} gen_finished Date when generation was finished
* @param {number} tokenCount Number of tokens generated (0 if not available)
* @param {number?} [reasoningDuration=null] Reasoning duration (null if no reasoning was done)
* @param {number?} [timeToFirstToken=null] Time to first token
* @returns {Object} Object containing the formatted timer value and title
* @example
* const { timerValue, timerTitle } = formatGenerationTimer(gen_started, gen_finished, tokenCount);
* console.log(timerValue); // 1.2s
* console.log(timerTitle); // Generation queued: 12:34:56 7 Jan 2021\nReply received: 12:34:57 7 Jan 2021\nTime to generate: 1.2 seconds\nToken rate: 5 t/s
*/
function formatGenerationTimer(gen_started, gen_finished, tokenCount, reasoningDuration = null, timeToFirstToken = null) {
if (!gen_started || !gen_finished) {
return {};
}
const dateFormat = 'HH:mm:ss D MMM YYYY';
const start = moment(gen_started);
const finish = moment(gen_finished);
const seconds = finish.diff(start, 'seconds', true);
const timerValue = `${seconds.toFixed(1)}s`;
const timerTitle = [
`Generation queued: ${start.format(dateFormat)}`,
`Reply received: ${finish.format(dateFormat)}`,
`Time to generate: ${seconds} seconds`,
timeToFirstToken ? `Time to first token: ${timeToFirstToken / 1000} seconds` : '',
reasoningDuration > 0 ? `Time to think: ${reasoningDuration / 1000} seconds` : '',
tokenCount > 0 ? `Token rate: ${Number(tokenCount / seconds).toFixed(3)} t/s` : '',
].filter(x => x).join('\n').trim();
if (isNaN(seconds) || seconds < 0) {
return { timerValue: '', timerTitle };
}
return { timerValue, timerTitle };
}
let requestId = null;
/**
* Scrolls the chat to the bottom if configured to do so.
* @param {object} [options] Options
* @param {boolean} [options.waitForFrame] If true, waits for the animation frame before scrolling
*/
export function scrollChatToBottom({ waitForFrame } = {}) {
if (!power_user.auto_scroll_chat_to_bottom) {
return;
}
const doScroll = () => {
let position = chatElement[0].scrollHeight;
if (power_user.waifuMode) {
const lastMessage = chatElement.find('.mes').last();
if (lastMessage.length) {
const lastMessagePosition = lastMessage.position().top;
position = chatElement.scrollTop() + lastMessagePosition;
}
}
chatElement.scrollTop(position);
requestId = null;
};
// Do not check truthiness. requestId can loop to zero.
if (requestId !== null) {
cancelAnimationFrame(requestId);
}
if (!waitForFrame) {
doScroll();
return;
}
// This prevents layout thrashing.
// https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame#return_value
// https://gist.github.com/paulirish/5d52fb081b3570c81e3a#file-what-forces-layout-md
requestId = requestAnimationFrame(() => doScroll());
}
/**
* @deprecated Function is not needed anymore, as the new signature of substituteParams is more flexible.
*
* Substitutes {{macro}} parameters in a string.
* @returns {string} The string with substituted parameters.
*/
export function substituteParamsExtended(content, additionalMacro = {}, postProcessFn = (x) => x) {
return substituteParams(content, { dynamicMacros: additionalMacro, postProcessFn });
}
/**
* Substitutes {{macro}} parameters in a string.
* @param {string} content - The string to substitute parameters in.
* @param {string} [_name1] - The name of the user. Uses global name1 if not provided.
* @param {string} [_name2] - The name of the character. Uses global name2 if not provided.
* @param {string} [_original] - The original message for {{original}} substitution.
* @param {string} [_group] - The group members list for {{group}} substitution.
* @param {boolean} [_replaceCharacterCard] - Whether to replace character card macros.
* @param {Record<string,any>} [additionalMacro] - Additional environment variables for substitution.
* @param {(x: string) => string} [postProcessFn] - Post-processing function for each substituted macro.
* @returns {string} The string with substituted parameters.
*/
export function substituteParamsLegacy(content, _name1, _name2, _original, _group, _replaceCharacterCard = true, additionalMacro = {}, postProcessFn = (x) => x) {
if (!content) {
return '';
}
// If experimental macro engine is enabled, use it. This code will be cleaned up in the future.
if (power_user?.experimental_macro_engine) {
return substituteParams(content, {
name1Override: _name1,
name2Override: _name2,
original: _original,
groupOverride: _group,
replaceCharacterCard: _replaceCharacterCard ?? true,
dynamicMacros: additionalMacro ?? {},
postProcessFn: postProcessFn ?? ((x) => x),
});
}
const environment = {};
if (typeof _original === 'string') {
let originalSubstituted = false;
environment.original = () => {
if (originalSubstituted) {
return '';
}
originalSubstituted = true;
return _original;
};
}
const getGroupValue = (includeMuted) => {
if (typeof _group === 'string') {
return _group;
}
if (selected_group) {
const members = groups.find(x => x.id === selected_group)?.members;
/** @type {string[]} */
const disabledMembers = groups.find(x => x.id === selected_group)?.disabled_members ?? [];
const isMuted = x => includeMuted ? true : !disabledMembers.includes(x);
const names = Array.isArray(members)
? members.filter(isMuted).map(m => characters.find(c => c.avatar === m)?.name).filter(Boolean).join(', ')
: '';
return names;
} else {
return _name2 ?? name2;
}
};
const getNotCharValue = () => {
const currentUser = _name1 ?? name1;
const currentSpeaker = _name2 ?? name2;
// Single character chat
if (!selected_group) {
return currentUser;
}
// Group chat
const members = groups.find(x => x.id === selected_group)?.members;
if (!Array.isArray(members)) {
return currentUser;
}
const memberNames = members
.map(m => characters.find(c => c.avatar === m)?.name)
.filter(Boolean); // Filter out any null/undefined names
// Filter out the current speaker and add the user
const otherMembers = memberNames.filter(name => name !== currentSpeaker);
otherMembers.push(currentUser);
return otherMembers.join(', ');
};
if (_replaceCharacterCard) {
const fields = getCharacterCardFields();
environment.charPrompt = fields.system || '';
environment.charInstruction = environment.charJailbreak = fields.jailbreak || '';
environment.description = fields.description || '';
environment.personality = fields.personality || '';
environment.scenario = fields.scenario || '';
environment.persona = fields.persona || '';
environment.mesExamples = () => {
const isInstruct = power_user.instruct.enabled && main_api !== 'openai';
const mesExamplesArray = parseMesExamples(fields.mesExamples, isInstruct);
if (isInstruct) {
const instructExamples = formatInstructModeExamples(mesExamplesArray, name1, name2);
return instructExamples.join('');
}
return mesExamplesArray.join('');
};
environment.mesExamplesRaw = fields.mesExamples || '';
environment.charVersion = fields.version || '';
environment.char_version = fields.version || '';
environment.charDepthPrompt = fields.charDepthPrompt || '';
environment.creatorNotes = fields.creatorNotes || '';
}
// Must be substituted last so that they're replaced inside {{description}}
environment.user = _name1 ?? name1;
environment.char = _name2 ?? name2;
environment.group = environment.charIfNotGroup = getGroupValue(true);
environment.groupNotMuted = getGroupValue(false);
environment.notChar = getNotCharValue();
environment.model = getGeneratingModel();
if (additionalMacro && typeof additionalMacro === 'object') {
Object.assign(environment, additionalMacro);
}
return evaluateMacros(content, environment, postProcessFn);
}
/** @typedef {import('./scripts/macros/engine/MacroRegistry.js').MacroHandler} MacroHandler */
/**
* Substitutes {{macros}} in a string using the new macro engine.
*
* This will replace all registered macros and dynamic additional macros as environment context.
*
* @param {string} content - The string to substitute parameters in.
* @param {Object} [options={}] - Options for the substitution.
* @param {string} [options.name1Override] - The name of the user. Uses global name1 if not provided.
* @param {string} [options.name2Override] - The name of the character. Uses global name2 if not provided.
* @param {string} [options.original] - The original message for {{original}} substitution.
* @param {string} [options.groupOverride] - The group members list for {{group}} substitution.
* @param {boolean} [options.replaceCharacterCard=true] - Whether to replace character card macros.
* @param {Record<string,string|MacroHandler>} [options.dynamicMacros={}] - Additional environment variables as dynamic macros for substitution. Registered as macro functions.
* @param {(x: string) => string} [options.postProcessFn=(x) => x] - Post-processing function for each substituted macro.
* @returns {string} The string with substituted parameters.
*/
export function substituteParams(content, options = {}) {
if (!content) return '';
// Handle legacy signature calls to substituteParams
// We'll simply re-route them to a temporary legacy function. In the future, we'll remove this and cleanly build the options object ourselves.
const isOptionsObject = options && typeof options === 'object' && !Array.isArray(options);
if (!isOptionsObject) {
return substituteParamsLegacy.call(this, ...arguments);
}
// Keep the new macro engine behind a feature switch for now
if (!power_user?.experimental_macro_engine) {
return substituteParamsLegacy(content, options.name1Override, options.name2Override, options.original, options.groupOverride, options.replaceCharacterCard, options.dynamicMacros, options.postProcessFn);
}
const ctx = /** @type {import('./scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */ ({
content,
name1Override: options.name1Override,
name2Override: options.name2Override,
original: options.original,
groupOverride: options.groupOverride,
replaceCharacterCard: options.replaceCharacterCard ?? true,
dynamicMacros: options.dynamicMacros ?? {},
postProcessFn: options.postProcessFn ?? ((x) => x),
});
const env = MacroEnvBuilder.buildFromRawEnv(ctx);
const result = MacroEngine.evaluate(content, env);
return result;
}
/**
* Gets stopping sequences for the prompt.
* @param {boolean} isImpersonate A request is made to impersonate a user
* @param {boolean} isContinue A request is made to continue the message
* @returns {string[]} Array of stopping strings
*/
export function getStoppingStrings(isImpersonate, isContinue) {
const result = [];
if (power_user.context.names_as_stop_strings) {
const charString = `\n${name2}:`;
const userString = `\n${name1}:`;
result.push(isImpersonate ? charString : userString);
result.push(userString);
if (isContinue && Array.isArray(chat) && chat[chat.length - 1]?.is_user) {
result.push(charString);
}
// Add group members as stopping strings if generating for a specific group member or user. (Allow slash commands to work around name stopping string restrictions)
if (selected_group && (name2 || isImpersonate)) {
const group = groups.find(x => x.id === selected_group);
if (group && Array.isArray(group.members)) {
const names = group.members
.map(x => characters.find(y => y.avatar == x))
.filter(x => x && x.name && x.name !== name2)
.map(x => `\n${x.name}:`);
result.push(...names);
}
}
}
result.push(...getInstructStoppingSequences());
result.push(...getCustomStoppingStrings());
if (power_user.single_line) {
result.unshift('\n');
}
return result.filter(x => x).filter(onlyUnique);
}
/**
* Background generation based on the provided prompt.
* @typedef {object} GenerateQuietPromptParams
* @prop {string} [quietPrompt] Instruction prompt for the AI
* @prop {boolean} [quietToLoud] Whether the message should be sent in a foreground (loud) or background (quiet) mode
* @prop {boolean} [skipWIAN] Whether to skip addition of World Info and Author's Note into the prompt
* @prop {string} [quietImage] Image to use for the quiet prompt
* @prop {string} [quietName] Name to use for the quiet prompt (defaults to "System:")
* @prop {number} [responseLength] Maximum response length. If unset, the global default value is used.
* @prop {number} [forceChId] Character ID to use for this generation run. Works in groups only.
* @prop {object} [jsonSchema] JSON schema to use for the structured generation. Usually requires a special instruction.
* @prop {boolean} [removeReasoning] Parses and removes the reasoning block according to reasoning format preferences
* @prop {boolean} [trimToSentence] Whether to trim the response to the last complete sentence
* @param {GenerateQuietPromptParams} params Parameters for the quiet prompt generation
* @returns {Promise<string>} Generated text. If using structured output, will contain a serialized JSON object.
*/
export async function generateQuietPrompt({ quietPrompt = '', quietToLoud = false, skipWIAN = false, quietImage = null, quietName = null, responseLength = null, forceChId = null, jsonSchema = null, removeReasoning = true, trimToSentence = false } = {}) {
if (arguments.length > 0 && typeof arguments[0] !== 'object') {
console.trace('generateQuietPrompt called with positional arguments. Please use an object instead.');
[quietPrompt, quietToLoud, skipWIAN, quietImage, quietName, responseLength, forceChId, jsonSchema] = arguments;
}
const responseLengthCustomized = typeof responseLength === 'number' && responseLength > 0;
let eventHook = () => { };
try {
/** @type {GenerateOptions} */
const generateOptions = {
quiet_prompt: quietPrompt ?? '',
quietToLoud: quietToLoud ?? false,
skipWIAN: skipWIAN ?? false,
force_name2: true,
quietImage: quietImage ?? null,
quietName: quietName ?? null,
force_chid: forceChId ?? null,
jsonSchema: jsonSchema ?? null,
};
if (responseLengthCustomized) {
TempResponseLength.save(main_api, responseLength);
eventHook = TempResponseLength.setupEventHook(main_api);
}
let result = await Generate('quiet', generateOptions);
result = trimToSentence ? trimToEndSentence(result) : result;
result = removeReasoning ? removeReasoningFromString(result) : result;
return result;
} finally {
if (responseLengthCustomized && TempResponseLength.isCustomized()) {
TempResponseLength.restore(main_api);
TempResponseLength.removeEventHook(main_api, eventHook);
}
}
}
/**
* Executes slash commands and returns the new text and whether the generation was interrupted.
* @param {string} message Text to be sent
* @returns {Promise<boolean>} Whether the message sending was interrupted
*/
export async function processCommands(message) {
if (!message || !message.trim().startsWith('/')) {
return false;
}
await executeSlashCommandsOnChatInput(message, {
clearChatInput: true,
});
return true;
}
/**
* Extracts the contents of bias macros from a message.
* @param {string} message Message text
* @returns {string} Message bias extracted from the message (or an empty string if not found)
*/
export function extractMessageBias(message) {
if (!message) {
return '';
}
try {
const biasHandlebars = Handlebars.create();
const biasMatches = [];
biasHandlebars.registerHelper('bias', function (text) {
biasMatches.push(text);
return '';
});
const template = biasHandlebars.compile(message);
template({});
if (biasMatches && biasMatches.length > 0) {
return ` ${biasMatches.join(' ')}`;
}
return '';
} catch {
return '';
}
}
/**
* Removes impersonated group member lines from the group member messages.
* Doesn't do anything if group reply trimming is disabled.
* @param {string} getMessage Group message
* @returns Cleaned-up group message
*/
function cleanGroupMessage(getMessage) {
if (power_user.disable_group_trimming) {
return getMessage;
}
const group = groups.find((x) => x.id == selected_group);
if (group && Array.isArray(group.members) && group.members) {
for (let member of group.members) {
const character = characters.find(x => x.avatar == member);
if (!character) {
continue;
}
const name = character.name;
// Skip current speaker.
if (name === name2) {
continue;
}
const regex = new RegExp(`(^|\n)${escapeRegex(name)}:`);
const nameMatch = getMessage.match(regex);
if (nameMatch) {
getMessage = getMessage.substring(0, nameMatch.index);
}
}
}
return getMessage;
}
function addPersonaDescriptionExtensionPrompt() {
const INJECT_TAG = 'PERSONA_DESCRIPTION';
setExtensionPrompt(INJECT_TAG, '', extension_prompt_types.IN_PROMPT, 0);
if (!power_user.persona_description || power_user.persona_description_position === persona_description_positions.NONE) {
return;
}
const promptPositions = [persona_description_positions.BOTTOM_AN, persona_description_positions.TOP_AN];
if (promptPositions.includes(power_user.persona_description_position) && shouldWIAddPrompt) {
const originalAN = extension_prompts[NOTE_MODULE_NAME].value;
const ANWithDesc = power_user.persona_description_position === persona_description_positions.TOP_AN
? `${power_user.persona_description}\n${originalAN}`
: `${originalAN}\n${power_user.persona_description}`;
setExtensionPrompt(NOTE_MODULE_NAME, ANWithDesc, chat_metadata[metadata_keys.position], chat_metadata[metadata_keys.depth], extension_settings.note.allowWIScan, chat_metadata[metadata_keys.role]);
}
if (power_user.persona_description_position === persona_description_positions.AT_DEPTH) {
setExtensionPrompt(INJECT_TAG, power_user.persona_description, extension_prompt_types.IN_CHAT, power_user.persona_description_depth, true, power_user.persona_description_role);
}
}
/**
* Returns all extension prompts combined.
* @returns {Promise<string>} Combined extension prompts
*/
async function getAllExtensionPrompts() {
const values = [];
for (const prompt of Object.values(extension_prompts)) {
const value = prompt?.value?.trim();
if (!value) {
continue;
}
const hasFilter = typeof prompt.filter === 'function';
if (hasFilter && !await prompt.filter()) {
continue;
}
values.push(value);
}
return substituteParams(values.join('\n'));
}
/**
* Wrapper to fetch extension prompts by module name
* @param {string} moduleName Module name
* @returns {Promise<string>} Extension prompt
*/
export async function getExtensionPromptByName(moduleName) {
if (!moduleName) {
return '';
}
const prompt = extension_prompts[moduleName];
if (!prompt) {
return '';
}
const hasFilter = typeof prompt.filter === 'function';
if (hasFilter && !await prompt.filter()) {
return '';
}
return substituteParams(prompt.value);
}
/**
* Gets the maximum depth of extension prompts.
* @returns {number} Maximum depth of extension prompts
*/
export function getExtensionPromptMaxDepth() {
return MAX_INJECTION_DEPTH;
/*
const prompts = Object.values(extension_prompts);
const maxDepth = Math.max(...prompts.map(x => x.depth ?? 0));
// Clamp to 1 <= depth <= MAX_INJECTION_DEPTH
return Math.max(Math.min(maxDepth, MAX_INJECTION_DEPTH), 1);
*/
}
/**
* Returns the extension prompt for the given position, depth, and role.
* If multiple prompts are found, they are joined with a separator.
* @param {number} [position] Position of the prompt
* @param {number} [depth] Depth of the prompt
* @param {string} [separator] Separator for joining multiple prompts
* @param {number} [role] Role of the prompt
* @param {boolean} [wrap] Wrap start and end with a separator
* @returns {Promise<string>} Extension prompt
*/
export async function getExtensionPrompt(position = extension_prompt_types.IN_PROMPT, depth = undefined, separator = '\n', role = undefined, wrap = true) {
const filterByFunction = async (prompt) => {
const hasFilter = typeof prompt.filter === 'function';
if (hasFilter && !await prompt.filter()) {
return false;
}
return true;
};
const promptPromises = Object.keys(extension_prompts)
.sort()
.map((x) => extension_prompts[x])
.filter(x => x.position == position && x.value)
.filter(x => depth === undefined || x.depth === undefined || x.depth === depth)
.filter(x => role === undefined || x.role === undefined || x.role === role)
.filter(filterByFunction);
const prompts = await Promise.all(promptPromises);
let values = prompts.map(x => x.value.trim()).join(separator);
if (wrap && values.length && !values.startsWith(separator)) {
values = separator + values;
}
if (wrap && values.length && !values.endsWith(separator)) {
values = values + separator;
}
if (values.length) {
values = substituteParams(values);
}
return values;
}
/**
* Base chat replacement function for character card fields.
* 1. Substitutes macros using substituteParams.
* 2. Collapses newlines if enabled in power user settings.
* 3. Removes carriage return characters.
* @param {string} value Input string
* @param {string?} name1Override Override for name1
* @param {string?} name2Override Override for name2
* @returns {string} Processed string
*/
export function baseChatReplace(value, name1Override = null, name2Override = null) {
if (typeof value === 'string' && value.length > 0) {
value = substituteParams(value, { name1Override, name2Override, replaceCharacterCard: false });
if (power_user.collapse_newlines) {
value = collapseNewlines(value);
}
value = value.replace(/\r/g, '');
}
return value;
}
/**
* @typedef {Object} CharacterCardFields
* @property {string} system System prompt
* @property {string} mesExamples Message examples
* @property {string} description Description
* @property {string} personality Personality
* @property {string} persona Persona
* @property {string} scenario Scenario
* @property {string} jailbreak Jailbreak instructions
* @property {string} version Character version
* @property {string} charDepthPrompt Character depth note
* @property {string} creatorNotes Character creator notes
*/
/**
* Helper to create an object with lazy, memoized getters from a map of field resolvers.
* @param {Record<string, () => string>} resolvers Map of field names to resolver functions
* @returns {CharacterCardFields} Object with lazy getters
*/
export function createLazyFields(resolvers) {
const result = /** @type {CharacterCardFields} */ ({});
for (const [key, resolver] of Object.entries(resolvers)) {
let cached;
let resolved = false;
Object.defineProperty(result, key, {
get() {
if (!resolved) {
cached = resolver();
resolved = true;
}
return cached;
},
enumerable: true,
configurable: true,
});
}
return result;
}
/**
* Returns the character card fields for the current character as lazy getters.
* Each field is only processed (baseChatReplace) when first accessed.
* @param {Object} [options={}]
* @param {number} [options.chid] Optional character index
* @returns {CharacterCardFields} Character card fields with lazy evaluation
*/
export function getCharacterCardFieldsLazy({ chid = undefined } = {}) {
const currentChid = chid ?? this_chid;
const character = characters[currentChid];
// For group chats, we need to check if group cards should be used
const useGroupCards = selected_group && character;
const groupCardsLazy = useGroupCards ? getGroupCharacterCardsLazy(selected_group, Number(currentChid)) : null;
/** @type {Record<string, () => string>} */
const resolvers = {
persona: () => baseChatReplace(power_user.persona_description?.trim()),
system: () => {
if (!character) return '';
const systemPrompt = chat_metadata['system_prompt'] || character.data?.system_prompt || '';
return power_user.prefer_character_prompt ? baseChatReplace(systemPrompt.trim()) : '';
},
jailbreak: () => {
if (!character) return '';
return power_user.prefer_character_jailbreak ? baseChatReplace(character.data?.post_history_instructions?.trim()) : '';
},
version: () => character?.data?.character_version ?? '',
charDepthPrompt: () => {
if (!character) return '';
return baseChatReplace(character.data?.extensions?.depth_prompt?.prompt?.trim());
},
creatorNotes: () => {
if (!character) return '';
return baseChatReplace(character.data?.creator_notes?.trim());
},
// These four fields may be overridden by group cards
description: () => {
if (groupCardsLazy) return groupCardsLazy.description;
if (!character) return '';
return baseChatReplace(character.description?.trim());
},
personality: () => {
if (groupCardsLazy) return groupCardsLazy.personality;
if (!character) return '';
return baseChatReplace(character.personality?.trim());
},
scenario: () => {
if (groupCardsLazy) return groupCardsLazy.scenario;
if (!character) return '';
const scenarioText = chat_metadata['scenario'] || character.scenario || '';
return baseChatReplace(scenarioText.trim());
},
mesExamples: () => {
if (groupCardsLazy) return groupCardsLazy.mesExamples;
if (!character) return '';
const exampleDialog = chat_metadata['mes_example'] || character.mes_example || '';
return baseChatReplace(exampleDialog.trim());
},
};
return createLazyFields(resolvers);
}
/**
* Returns the character card fields for the current character.
* @param {Object} [options={}]
* @param {number} [options.chid] Optional character index
* @returns {CharacterCardFields} Character card fields
*/
export function getCharacterCardFields({ chid = undefined } = {}) {
const lazy = getCharacterCardFieldsLazy({ chid });
// Resolve all lazy fields into a plain object
return {
system: lazy.system,
mesExamples: lazy.mesExamples,
description: lazy.description,
personality: lazy.personality,
persona: lazy.persona,
scenario: lazy.scenario,
jailbreak: lazy.jailbreak,
version: lazy.version,
charDepthPrompt: lazy.charDepthPrompt,
creatorNotes: lazy.creatorNotes,
};
}
/**
* Parses an examples string.
* @param {string} examplesStr
* @returns {string[]} Examples array with block heading
*/
export function parseMesExamples(examplesStr, isInstruct) {
if (!examplesStr || examplesStr.length === 0 || examplesStr === '<START>') {
return [];
}
if (!examplesStr.startsWith('<START>')) {
examplesStr = '<START>\n' + examplesStr.trim();
}
const exampleSeparator = power_user.context.example_separator ? `${substituteParams(power_user.context.example_separator)}\n` : '';
const blockHeading = (main_api === 'openai' || isInstruct) ? '<START>\n' : exampleSeparator;
const splitExamples = examplesStr.split(/<START>/gi).slice(1).map(block => `${blockHeading}${block.trim()}\n`);
return splitExamples;
}
export function isStreamingEnabled() {
return (
(main_api == 'openai' &&
oai_settings.stream_openai &&
!(oai_settings.chat_completion_source == chat_completion_sources.OPENAI && ['o1-2024-12-17', 'o1'].includes(oai_settings.openai_model))
)
|| (main_api == 'kobold' && kai_settings.streaming_kobold && kai_flags.can_use_streaming)
|| (main_api == 'novel' && nai_settings.streaming_novel)
|| (main_api == 'textgenerationwebui' && textgen_settings.streaming));
}
function showStopButton() {
$('#mes_stop').css({ 'display': 'flex' });
}
function hideStopButton() {
// prevent NOOP, because hideStopButton() gets called multiple times
if ($('#mes_stop').css('display') !== 'none') {
$('#mes_stop').css({ 'display': 'none' });
eventSource.emit(event_types.GENERATION_ENDED, chat.length);
}
}
class StreamingProcessor {
/**
* Creates a new streaming processor.
* @param {string} type Generation type
* @param {boolean} forceName2 If true, force the use of name2
* @param {Date} timeStarted Date when generation was started
* @param {string} continueMessage Previous message if the type is 'continue'
* @param {PromptReasoning} promptReasoning Prompt reasoning instance
*/
constructor(type, forceName2, timeStarted, continueMessage, promptReasoning) {
this.result = '';
this.messageId = -1;
/** @type {HTMLElement} */
this.messageDom = null;
/** @type {HTMLElement} */
this.messageTextDom = null;
/** @type {HTMLElement} */
this.messageTimerDom = null;
/** @type {HTMLElement} */
this.messageTokenCounterDom = null;
/** @type {HTMLTextAreaElement} */
this.sendTextarea = document.querySelector('#send_textarea');
this.type = type;
this.force_name2 = forceName2;
this.isStopped = false;
this.isFinished = false;
this.generator = this.nullStreamingGeneration;
this.abortController = new AbortController();
this.firstMessageText = '...';
this.timeStarted = timeStarted;
/** @type {number?} */
this.timeToFirstToken = null;
this.createdAt = new Date();
this.continueMessage = type === 'continue' ? continueMessage : '';
this.swipes = [];
/** @type {import('./scripts/logprobs.js').TokenLogprobs[]} */
this.messageLogprobs = [];
this.toolCalls = [];
// Initialize reasoning in its own handler
this.reasoningHandler = new ReasoningHandler(timeStarted);
/** @type {PromptReasoning} */
this.promptReasoning = promptReasoning;
/** @type {string[]} */
this.images = [];
/** @type {string?} */
this.reasoningSignature = null;
}
/**
* Initializes DOM elements for the current message.
* @param {number} messageId Current message ID
* @param {boolean?} continueOnReasoning If continuing on reasoning
*/
async #checkDomElements(messageId, continueOnReasoning = null) {
if (this.messageDom === null || this.messageTextDom === null) {
this.messageDom = document.querySelector(`#chat .mes[mesid="${messageId}"]`);
this.messageTextDom = this.messageDom?.querySelector('.mes_text');
this.messageTimerDom = this.messageDom?.querySelector('.mes_timer');
this.messageTokenCounterDom = this.messageDom?.querySelector('.tokenCounterDisplay');
}
if (continueOnReasoning) {
await this.reasoningHandler.process(messageId, false, this.promptReasoning);
}
this.reasoningHandler.updateDom(messageId);
}
#updateMessageBlockVisibility() {
if (this.messageDom instanceof HTMLElement && Array.isArray(this.toolCalls) && this.toolCalls.length > 0) {
const shouldHide = ['', '...'].includes(this.result) && !this.reasoningHandler.reasoning;
this.messageDom.classList.toggle('displayNone', shouldHide);
}
}
markUIGenStarted() {
deactivateSendButtons();
}
markUIGenStopped() {
unblockGeneration();
}
async onStartStreaming(text) {
const continueOnReasoning = !!(this.type === 'continue' && this.promptReasoning.prefixReasoning);
if (continueOnReasoning) {
this.reasoningHandler.initContinue(this.promptReasoning);
}
let messageId = -1;
if (this.type == 'impersonate') {
this.sendTextarea.value = '';
this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true }));
} else {
await saveReply({ type: this.type, getMessage: text, fromStreaming: true });
messageId = chat.length - 1;
await this.#checkDomElements(messageId, continueOnReasoning);
this.markUIGenStarted();
}
hideSwipeButtons({ hideCounters: true });
scrollChatToBottom({ waitForFrame: true });
return messageId;
}
async onProgressStreaming(messageId, text, isFinal) {
const isImpersonate = this.type == 'impersonate';
const isContinue = this.type == 'continue';
if (!isImpersonate && !isContinue && Array.isArray(this.swipes) && this.swipes.length > 0) {
for (let i = 0; i < this.swipes.length; i++) {
this.swipes[i] = cleanUpMessage({
getMessage: this.swipes[i],
isImpersonate: false,
isContinue: false,
displayIncompleteSentences: true,
stoppingStrings: this.stoppingStrings,
});
}
}
let processedText = cleanUpMessage({
getMessage: text,
isImpersonate: isImpersonate,
isContinue: isContinue,
displayIncompleteSentences: !isFinal,
stoppingStrings: this.stoppingStrings,
});
const charsToBalance = ['*', '"', '```', '~~~'];
for (const char of charsToBalance) {
if (!isFinal && isOdd(countOccurrences(processedText, char))) {
const separator = char.length > 1 ? '\n' : '';
processedText = processedText.trimEnd() + separator + char;
}
}
if (isImpersonate) {
this.sendTextarea.value = processedText;
this.sendTextarea.dispatchEvent(new Event('input', { bubbles: true }));
} else {
const mesChanged = chat[messageId]['mes'] !== processedText;
await this.#checkDomElements(messageId);
this.#updateMessageBlockVisibility();
const currentTime = new Date();
chat[messageId]['mes'] = processedText;
chat[messageId]['gen_started'] = this.timeStarted;
chat[messageId]['gen_finished'] = currentTime;
if (!chat[messageId]['extra']) {
chat[messageId]['extra'] = {};
}
chat[messageId]['extra']['time_to_first_token'] = this.timeToFirstToken;
// Update reasoning
await this.reasoningHandler.process(messageId, mesChanged, this.promptReasoning);
processedText = chat[messageId]['mes'];
// Token count update.
const tokenCountText = this.reasoningHandler.reasoning + processedText;
const currentTokenCount = isFinal && power_user.message_token_count_enabled ? await getTokenCountAsync(tokenCountText, 0) : 0;
if (currentTokenCount) {
chat[messageId]['extra']['token_count'] = currentTokenCount;
if (this.messageTokenCounterDom instanceof HTMLElement) {
this.messageTokenCounterDom.textContent = `${currentTokenCount}t`;
}
}
if ((this.type == 'swipe' || this.type === 'continue') && Array.isArray(chat[messageId]['swipes'])) {
chat[messageId]['swipes'][chat[messageId]['swipe_id']] = processedText;
chat[messageId]['swipe_info'][chat[messageId]['swipe_id']] = {
'send_date': chat[messageId]['send_date'],
'gen_started': chat[messageId]['gen_started'],
'gen_finished': chat[messageId]['gen_finished'],
'extra': structuredClone(chat[messageId]['extra']),
};
}
const formattedText = messageFormatting(
processedText,
chat[messageId].name,
chat[messageId].is_system,
chat[messageId].is_user,
messageId,
{},
false,
);
if (this.messageTextDom instanceof HTMLElement) {
if (power_user.stream_fade_in) {
applyStreamFadeIn(this.messageTextDom, formattedText);
} else {
this.messageTextDom.innerHTML = formattedText;
}
}
const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount, this.reasoningHandler.getDuration(), this.timeToFirstToken);
if (this.messageTimerDom instanceof HTMLElement) {
this.messageTimerDom.textContent = timePassed.timerValue;
this.messageTimerDom.title = timePassed.timerTitle;
}
this.setFirstSwipe(messageId);
}
if (!scrollLock) {
scrollChatToBottom({ waitForFrame: true });
}
}
async onFinishStreaming(messageId, text) {
await this.onProgressStreaming(messageId, text, true);
const messageElement = chatElement.find(`.mes[mesid="${messageId}"]`);
const message = chat[messageId];
addCopyToCodeBlocks(messageElement);
await this.reasoningHandler.finish(messageId);
if (Array.isArray(this.swipes) && this.swipes.length > 0) {
const swipeInfoExtra = structuredClone(message.extra ?? {});
delete swipeInfoExtra.token_count;
delete swipeInfoExtra.reasoning;
delete swipeInfoExtra.reasoning_duration;
const swipeInfo = {
send_date: message.send_date,
gen_started: message.gen_started,
gen_finished: message.gen_finished,
extra: swipeInfoExtra,
};
const swipeInfoArray = Array(this.swipes.length).fill().map(() => structuredClone(swipeInfo));
parseReasoningInSwipes(this.swipes, swipeInfoArray, message.extra?.reasoning_duration);
message.swipes.push(...this.swipes);
message.swipe_info.push(...swipeInfoArray);
}
syncMesToSwipe(messageId);
saveLogprobsForActiveMessage(this.messageLogprobs.filter(Boolean), this.continueMessage);
if (Array.isArray(this.images) && this.images.length > 0) {
await processImageAttachment(message, { imageUrls: this.images });
appendMediaToMessage(message, $(this.messageDom));
}
// Store reasoning signature for models that support multi-turn context
if (this.reasoningSignature) {
message.extra = message.extra || {};
message.extra.reasoning_signature = this.reasoningSignature;
}
this.markUIGenStopped();
if (this.type !== 'impersonate') {
await eventSource.emit(event_types.MESSAGE_RECEIVED, this.messageId, this.type);
await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, this.messageId, this.type);
} else {
await eventSource.emit(event_types.IMPERSONATE_READY, text);
}
updateSwipeCounter(messageId, { message, messageElement });
const isAborted = this.abortController.signal.aborted;
if (!isAborted && power_user.auto_swipe && generatedTextFiltered(text)) {
return await swipe(null, SWIPE_DIRECTION.RIGHT, { source: SWIPE_SOURCE.AUTO_SWIPE, repeated: true, forceMesId: chat.length - 1 });
}
await saveChatConditional();
playMessageSound();
}
onErrorStreaming() {
this.abortController.abort();
this.isStopped = true;
this.markUIGenStopped();
const noEmitTypes = ['swipe', 'impersonate', 'continue'];
if (!noEmitTypes.includes(this.type)) {
eventSource.emit(event_types.MESSAGE_RECEIVED, this.messageId, this.type);
eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, this.messageId, this.type);
}
}
setFirstSwipe(messageId) {
if (this.type !== 'swipe' && this.type !== 'impersonate') {
if (Array.isArray(chat[messageId]['swipes']) && chat[messageId]['swipes'].length === 1 && chat[messageId]['swipe_id'] === 0) {
chat[messageId]['swipes'][0] = chat[messageId]['mes'];
chat[messageId]['swipe_info'][0] = {
'send_date': chat[messageId]['send_date'],
'gen_started': chat[messageId]['gen_started'],
'gen_finished': chat[messageId]['gen_finished'],
'extra': structuredClone(chat[messageId]['extra']),
};
}
}
}
onStopStreaming() {
this.abortController.abort();
this.isFinished = true;
}
/**
* @returns {Generator<{ text: string, swipes: string[], logprobs: import('./scripts/logprobs.js').TokenLogprobs, toolCalls: any[], state: any }, void, void>}
*/
*nullStreamingGeneration() {
throw new Error('Generation function for streaming is not hooked up');
}
async generate() {
if (this.messageId == -1) {
this.messageId = await this.onStartStreaming(this.firstMessageText);
await delay(1); // delay for message to be rendered
scrollLock = false;
}
// Stopping strings are expensive to calculate, especially with macros enabled. To remove stopping strings
// when streaming, we cache the result of getStoppingStrings instead of calling it once per token.
const isImpersonate = this.type == 'impersonate';
const isContinue = this.type == 'continue';
this.stoppingStrings = getStoppingStrings(isImpersonate, isContinue);
try {
const sw = new Stopwatch(1000 / power_user.streaming_fps);
const timestamps = [];
for await (const { text, swipes, logprobs, toolCalls, state } of this.generator()) {
const now = Date.now();
timestamps.push(now);
if (!this.timeToFirstToken) {
this.timeToFirstToken = now - this.createdAt.getTime();
}
if (this.isStopped || this.abortController.signal.aborted) {
return this.result;
}
this.toolCalls = toolCalls;
this.result = text;
this.swipes = Array.from(swipes ?? []);
if (logprobs) {
this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs]));
}
// Get the updated reasoning string into the handler
this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning);
this.images = state?.images ?? [];
this.reasoningSignature = state?.signature ?? null;
await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);
await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text));
}
const seconds = (timestamps[timestamps.length - 1] - timestamps[0]) / 1000;
console.warn(`Stream stats: ${timestamps.length} tokens, ${seconds.toFixed(2)} seconds, rate: ${Number(timestamps.length / seconds).toFixed(2)} TPS`);
}
catch (err) {
// in the case of a self-inflicted abort, we have already cleaned up
if (!this.isFinished) {
console.error(err);
this.onErrorStreaming();
}
return this.result;
}
this.isFinished = true;
return this.result;
}
}
/**
* Constructs a prompt to be used for either Text Completion or Chat Completion. Input is format-agnostic.
* @param {string | object[]} prompt Input prompt. Can be a string or an array of chat-style messages, i.e. [{role: '', content: ''}, ...]
* @param {string} api API to use.
* @param {boolean} instructOverride true to override instruct mode, false to use the default value
* @param {boolean} quietToLoud true to generate a message in system mode, false to generate a message in character mode
* @param {string} [systemPrompt] System prompt to use.
* @param {string} [prefill] Prefill for the prompt.
* @returns {string | object[]} Prompt ready for use in generation. If using TC, this will be a string. If using CC, this will be an array of chat-style messages.
*/
export function createRawPrompt(prompt, api, instructOverride, quietToLoud, systemPrompt, prefill) {
const isInstruct = power_user.instruct.enabled && api !== 'openai' && api !== 'novel' && !instructOverride;
// If the prompt was given as a string, convert to a message-style object assuming user role
if (typeof prompt === 'string') {
const message = api === 'openai'
? { role: 'user', content: prompt.trim() }
: { role: 'system', content: prompt };
prompt = [message];
} else { // checks for message-style object
if (prompt.length === 0 && !systemPrompt) throw Error('No messages provided');
}
// Substitute the prefill if provided
prefill = substituteParams(prefill ?? '');
// Format each message in the prompt, accounting for the provided roles
for (const message of prompt) {
let name = '';
if (message.role === 'user') name = message.name ?? name1;
if (message.role === 'assistant') name = message.name ?? name2;
if (message.role === 'system') name = message.name ?? '';
const prefix = isInstruct || api === 'openai' ? '' : (name ? `${name}: ` : '');
message.content = prefix + substituteParams(message.content ?? '');
if (isInstruct) { // instruct formatting for text completion
const isUser = message.role === 'user';
const isNarrator = message.role === 'system';
message.content = formatInstructModeChat(name, message.content, isUser, isNarrator, '', name1, name2, false);
}
}
// prepend system prompt, if provided
if (systemPrompt) {
systemPrompt = substituteParams(systemPrompt);
systemPrompt = isInstruct ? (formatInstructModeStoryString(systemPrompt) + '\n') : systemPrompt.trim();
prompt.unshift({ role: 'system', content: systemPrompt });
}
// with Chat Completion, the prefill is an additional assistant message at the end.
if (api === 'openai' && prefill) {
prompt.push({ role: 'assistant', content: prefill });
}
// if text completion, convert to text prompt by concatenating all message contents and adding the prefill as a promptBias.
if (api !== 'openai') {
const joiner = isInstruct ? '' : '\n';
prompt = prompt.map(message => message.content).join(joiner);
prompt = api === 'novel' ? adjustNovelInstructionPrompt(prompt) : prompt;
prompt = prompt + (isInstruct ? formatInstructModePrompt(name2, false, prefill, name1, name2, true, quietToLoud) : `\n${prefill}`); // add last line
}
return prompt;
}
/**
* Generates a message using the provided prompt.
* If the prompt is an array of chat-style messages and not using chat completion, it will be converted to a text prompt.
* @typedef {object} GenerateRawParams
* @prop {string | object[]} [prompt] Prompt to generate a message from. Can be a string or an array of chat-style messages, i.e. [{role: '', content: ''}, ...]
* @prop {string} [api] API to use. Main API is used if not specified.
* @prop {boolean} [instructOverride] true to override instruct mode, false to use the default value
* @prop {boolean} [quietToLoud] true to generate a message in system mode, false to generate a message in character mode
* @prop {string} [systemPrompt] System prompt to use.
* @prop {number} [responseLength] Maximum response length. If unset, the global default value is used.
* @prop {boolean} [trimNames] Whether to allow trimming "{{user}}:" and "{{char}}:" from the response.
* @prop {string} [prefill] An optional prefill for the prompt.
* @prop {object} [jsonSchema] JSON schema to use for the structured generation. Usually requires a special instruction.
* @param {GenerateRawParams} params Parameters for generating a message
* @returns {Promise<string>} Generated message
*/
export async function generateRaw({ prompt = '', api = null, instructOverride = false, quietToLoud = false, systemPrompt = '', responseLength = null, trimNames = true, prefill = '', jsonSchema = null } = {}) {
if (arguments.length > 0 && typeof arguments[0] !== 'object') {
console.trace('generateRaw called with positional arguments. Please use an object instead.');
[prompt, api, instructOverride, quietToLoud, systemPrompt, responseLength, trimNames, prefill, jsonSchema] = arguments;
}
if (!api) {
api = main_api;
}
const abortController = new AbortController();
const responseLengthCustomized = typeof responseLength === 'number' && responseLength > 0;
let eventHook = () => { };
// construct final prompt from the input. Can either be a string or an array of chat-style messages.
prompt = createRawPrompt(prompt, api, instructOverride, quietToLoud, systemPrompt, prefill);
// Allow extensions to stop generation before it happens
const eventAbortController = new AbortController();
const abortHook = () => eventAbortController.abort(new Error('Cancelled by extension'));
eventSource.on(event_types.GENERATION_STOPPED, abortHook);
try {
if (responseLengthCustomized) {
TempResponseLength.save(api, responseLength);
}
/** @type {object|any[]} */
let generateData = {};
// Allow extensions to modify the prompt before generation
// 1. for text completion
if (typeof prompt === 'string') {
const eventData = { prompt: prompt, dryRun: false };
await eventSource.emit(event_types.GENERATE_AFTER_COMBINE_PROMPTS, eventData);
prompt = eventData.prompt;
}
// 2. for chat completion
if (Array.isArray(prompt)) {
const eventData = { chat: prompt, dryRun: false };
await eventSource.emit(event_types.CHAT_COMPLETION_PROMPT_READY, eventData);
prompt = eventData.chat;
}
// Check if the generation was aborted during the event
eventAbortController.signal.throwIfAborted();
switch (api) {
case 'kobold':
case 'koboldhorde':
if (kai_settings.preset_settings === 'gui') {
generateData = { prompt: prompt, gui_settings: true, max_length: amount_gen, max_context_length: max_context, api_server: kai_settings.api_server };
} else {
const isHorde = api === 'koboldhorde';
const koboldSettings = koboldai_settings[koboldai_setting_names[kai_settings.preset_settings]];
generateData = getKoboldGenerationData(prompt.toString(), koboldSettings, amount_gen, max_context, isHorde, 'quiet');
}
TempResponseLength.restore(api);
break;
case 'novel': {
const novelSettings = novelai_settings[novelai_setting_names[nai_settings.preset_settings_novel]];
generateData = getNovelGenerationData(prompt, novelSettings, amount_gen, false, false, null, 'quiet');
TempResponseLength.restore(api);
break;
}
case 'textgenerationwebui':
generateData = await getTextGenGenerationData(prompt, amount_gen, false, false, null, 'quiet');
TempResponseLength.restore(api);
break;
case 'openai': {
generateData = prompt; // generateData is just the chat message object
eventHook = TempResponseLength.setupEventHook(api);
} break;
}
let data = {};
if (api === 'koboldhorde') {
data = await generateHorde(prompt.toString(), generateData, abortController.signal, false);
} else if (api === 'openai') {
data = await sendOpenAIRequest('quiet', generateData, abortController.signal, { jsonSchema });
} else {
const generateUrl = getGenerateUrl(api);
const response = await fetch(generateUrl, {
method: 'POST',
headers: getRequestHeaders(),
cache: 'no-cache',
body: JSON.stringify(generateData),
signal: abortController.signal,
});
if (!response.ok) {
throw await response.json();
}
data = await response.json();
}
// should only happen for text completions
// other frontend paths do not return data if calling the backend fails,
// they throw things instead
if (data.error) {
throw new Error(data.response);
}
if (jsonSchema) {
return extractJsonFromData(data, { mainApi: api });
}
// format result, exclude user prompt bias
const message = cleanUpMessage({
getMessage: extractMessageFromData(data),
isImpersonate: false,
isContinue: false,
displayIncompleteSentences: true,
includeUserPromptBias: false,
trimNames: trimNames,
trimWrongNames: trimNames,
});
if (!message) {
throw new Error('No message generated');
}
return message;
} finally {
eventSource.removeListener(event_types.GENERATION_STOPPED, abortHook);
if (responseLengthCustomized && TempResponseLength.isCustomized()) {
TempResponseLength.restore(api);
TempResponseLength.removeEventHook(api, eventHook);
}
}
}
class TempResponseLength {
static #originalResponseLength = -1;
static #lastApi = null;
static isCustomized() {
return this.#originalResponseLength > -1;
}
/**
* Save the current response length for the specified API.
* @param {string} api API identifier
* @param {number} responseLength New response length
*/
static save(api, responseLength) {
if (api === 'openai') {
this.#originalResponseLength = oai_settings.openai_max_tokens;
oai_settings.openai_max_tokens = responseLength;
} else {
this.#originalResponseLength = amount_gen;
amount_gen = responseLength;
}
this.#lastApi = api;
console.log('[TempResponseLength] Saved original response length:', TempResponseLength.#originalResponseLength);
}
/**
* Restore the original response length for the specified API.
* @param {string|null} api API identifier
* @returns {void}
*/
static restore(api) {
if (this.#originalResponseLength === -1) {
return;
}
if (!api && this.#lastApi) {
api = this.#lastApi;
}
if (api === 'openai') {
oai_settings.openai_max_tokens = this.#originalResponseLength;
} else {
amount_gen = this.#originalResponseLength;
}
console.log('[TempResponseLength] Restored original response length:', this.#originalResponseLength);
this.#originalResponseLength = -1;
this.#lastApi = null;
}
/**
* Sets up an event hook to restore the original response length when the event is emitted.
* @param {string} api API identifier
* @returns {function(): void} Event hook function
*/
static setupEventHook(api) {
const eventHook = () => {
if (this.isCustomized()) {
this.restore(api);
}
};
switch (api) {
case 'openai':
eventSource.once(event_types.CHAT_COMPLETION_SETTINGS_READY, eventHook);
break;
default:
eventSource.once(event_types.GENERATE_AFTER_DATA, eventHook);
break;
}
return eventHook;
}
/**
* Removes the event hook for the specified API.
* @param {string} api API identifier
* @param {function(): void} eventHook Previously set up event hook
*/
static removeEventHook(api, eventHook) {
switch (api) {
case 'openai':
eventSource.removeListener(event_types.CHAT_COMPLETION_SETTINGS_READY, eventHook);
break;
default:
eventSource.removeListener(event_types.GENERATE_AFTER_DATA, eventHook);
break;
}
}
}
/**
* Removes last message from the chat DOM.
* @returns {Promise<void>} Resolves when the message is removed.
*/
function removeLastMessage() {
return new Promise((resolve) => {
const lastMes = chatElement.children('.mes').last();
if (lastMes.length === 0) {
return resolve();
}
lastMes.hide(animation_duration, function () {
$(this).remove();
resolve();
});
});
}
/**
* @typedef {object} JsonSchema
* @property {string} name Name of the schema.
* @property {object} value JSON schema value.
* @property {string} [description] Description of the schema.
* @property {boolean} [strict] If true, the schema will be used in strict mode, meaning that only the fields defined in the schema will be allowed.
*
* @typedef {object} GenerateOptions
* @property {boolean} [automatic_trigger] If the generation was triggered automatically (e.g. group auto mode).
* @property {boolean} [force_name2] If a char name should be forced to add to the prompt's last line (Text Completion, non-Instruct only).
* @property {string} [quiet_prompt] A system instruction to use for the quiet prompt.
* @property {boolean} [quietToLoud] Whether the system instruction should be sent in background (quiet) or a foreground (loud) mode.
* @property {boolean} [skipWIAN] Skip adding World Info and Author's Note to the prompt.
* @property {number} [force_chid] Force character ID to use for the generation. Only works in groups.
* @property {AbortSignal} [signal] Abort signal to cancel the generation. If not provided, will create a new AbortController.
* @property {string} [quietImage] Image URL to use for the quiet prompt (defaults to empty string)
* @property {string} [quietName] Name to use for the quiet prompt (defaults to "System:")
* @property {number} [depth] Recursion depth for the generation. Used to prevent infinite loops in tool calls.
* @property {JsonSchema} [jsonSchema] JSON schema to use for the structured generation. Usually requires a special instruction.
*/
/**
* MARK:Generate()
* Runs a generation using the current chat context.
* @param {string} type Generation type
* @param {GenerateOptions} options Generation options
* @param {boolean} dryRun Whether to actually generate a message or just assemble the prompt
* @returns {Promise<any>} Returns a promise that resolves when the text is done generating.
*/
export async function Generate(type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage, quietName, jsonSchema = null, depth = 0 } = {}, dryRun = false) {
console.log('Generate entered');
setGenerationProgress(0);
generation_started = new Date();
// Prevent generation from shallow characters
await unshallowCharacter(this_chid);
// Occurs every time, even if the generation is aborted due to slash commands execution
await eventSource.emit(event_types.GENERATION_STARTED, type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage }, dryRun);
// Don't recreate abort controller if signal is passed
if (!(abortController && signal)) {
abortController = new AbortController();
}
// OpenAI doesn't need instruct mode. Use OAI main prompt instead.
const isInstruct = power_user.instruct.enabled && main_api !== 'openai';
const isImpersonate = type == 'impersonate';
if (!(dryRun || type == 'regenerate' || type == 'swipe' || type == 'quiet')) {
const interruptedByCommand = await processCommands(String($('#send_textarea').val()));
if (interruptedByCommand) {
//$("#send_textarea").val('')[0].dispatchEvent(new Event('input', { bubbles:true }));
unblockGeneration(type);
return Promise.resolve();
}
}
// Occurs only if the generation is not aborted due to slash commands execution
await eventSource.emit(event_types.GENERATION_AFTER_COMMANDS, type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage }, dryRun);
if (main_api == 'kobold' && kai_settings.streaming_kobold && !kai_flags.can_use_streaming) {
toastr.error(t`Streaming is enabled, but the version of Kobold used does not support token streaming.`, undefined, { timeOut: 10000, preventDuplicates: true });
unblockGeneration(type);
return Promise.resolve();
}
if (isHordeGenerationNotAllowed()) {
unblockGeneration(type);
return Promise.resolve();
}
if (!dryRun) {
// Ping server to make sure it is still alive
const pingResult = await pingServer();
if (!pingResult) {
unblockGeneration(type);
toastr.error(t`Verify that the server is running and accessible.`, t`ST Server cannot be reached`);
throw new Error('Server unreachable');
}
// Hide swipes if not in a dry run.
hideSwipeButtons();
// If generated any message, set the flag to indicate it can't be recreated again.
chat_metadata['tainted'] = true;
}
if (selected_group && !is_group_generating) {
if (!dryRun) {
// Returns the promise that generateGroupWrapper returns; resolves when generation is done
return generateGroupWrapper(false, type, { quiet_prompt, force_chid, signal: abortController.signal, quietImage });
}
const characterIndexMap = new Map(characters.map((char, index) => [char.avatar, index]));
const group = groups.find((x) => x.id === selected_group);
const enabledMembers = group.members.reduce((acc, member) => {
if (!group.disabled_members.includes(member) && !acc.includes(member)) {
acc.push(member);
}
return acc;
}, []);
const memberIds = enabledMembers
.map((member) => characterIndexMap.get(member))
.filter((index) => index !== undefined && index !== null);
if (memberIds.length > 0) {
if (menu_type != 'character_edit') setCharacterId(memberIds[0]);
setCharacterName('');
} else {
console.log('No enabled members found');
unblockGeneration(type);
return Promise.resolve();
}
}
//#########QUIET PROMPT STUFF##############
//this function just gives special care to novel quiet instruction prompts
if (quiet_prompt) {
quiet_prompt = substituteParams(quiet_prompt);
quiet_prompt = main_api == 'novel' && !quietToLoud ? adjustNovelInstructionPrompt(quiet_prompt) : quiet_prompt;
}
const hasBackendConnection = online_status !== 'no_connection';
// We can't do anything because we're not in a chat right now. (Unless it's a dry run, in which case we need to
// assemble the prompt so we can count its tokens regardless of whether a chat is active.)
if (!dryRun && !hasBackendConnection) {
is_send_press = false;
return Promise.resolve();
}
let textareaText;
if (type !== 'regenerate' && type !== 'swipe' && type !== 'quiet' && !isImpersonate && !dryRun) {
is_send_press = true;
textareaText = String($('#send_textarea').val());
$('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles: true }));
} else {
textareaText = '';
if (chat.length && chat[chat.length - 1]['is_user']) {
//do nothing? why does this check exist?
}
else if (type !== 'quiet' && type !== 'swipe' && !isImpersonate && !dryRun && chat.length) {
chat.length = chat.length - 1;
await removeLastMessage();
await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);
}
}
const isContinue = type == 'continue';
// Rewrite the generation timer to account for the time passed for all the continuations.
if (isContinue && chat.length) {
const prevFinished = chat[chat.length - 1]['gen_finished'];
const prevStarted = chat[chat.length - 1]['gen_started'];
if (prevFinished && prevStarted) {
const timePassed = Number(prevFinished) - Number(prevStarted);
generation_started = new Date(Date.now() - timePassed);
chat[chat.length - 1]['gen_started'] = generation_started;
}
}
if (!dryRun) {
deactivateSendButtons();
}
let { messageBias, promptBias, isUserPromptBias } = getBiasStrings(textareaText, type);
//*********************************
//PRE FORMATING STRING
//*********************************
// These generation types should not attach pending files to the chat
const noAttachTypes = [
'regenerate',
'swipe',
'impersonate',
'quiet',
'continue',
];
//for normal messages sent from user..
if ((textareaText != '' || (hasPendingFileAttachment() && !noAttachTypes.includes(type))) && !automatic_trigger && type !== 'quiet' && !dryRun) {
// If user message contains no text other than bias - send as a system message
if (messageBias && !removeMacros(textareaText)) {
sendSystemMessage(system_message_types.GENERIC, ' ', { bias: messageBias });
}
else {
await sendMessageAsUser(textareaText, messageBias);
}
}
else if (textareaText == '' && !automatic_trigger && !dryRun && [undefined, 'normal'].includes(type) && main_api == 'openai' && oai_settings.send_if_empty.trim().length > 0) {
// Use send_if_empty if set and the user message is empty. Only when sending messages normally
await sendMessageAsUser(oai_settings.send_if_empty.trim(), messageBias);
}
let {
description,
personality,
persona,
scenario,
mesExamples,
system,
jailbreak,
charDepthPrompt,
creatorNotes,
} = getCharacterCardFields();
// Depth prompt (character-specific A/N)
removeDepthPrompts();
const groupDepthPrompts = getGroupDepthPrompts(selected_group, Number(this_chid));
if (selected_group && Array.isArray(groupDepthPrompts) && groupDepthPrompts.length > 0) {
groupDepthPrompts.forEach((value, index) => {
const role = getExtensionPromptRoleByName(value.role);
setExtensionPrompt(inject_ids.DEPTH_PROMPT_INDEX(index), value.text, extension_prompt_types.IN_CHAT, value.depth, extension_settings.note.allowWIScan, role);
});
} else {
const depthPromptText = charDepthPrompt || '';
const depthPromptDepth = characters[this_chid]?.data?.extensions?.depth_prompt?.depth ?? depth_prompt_depth_default;
const depthPromptRole = getExtensionPromptRoleByName(characters[this_chid]?.data?.extensions?.depth_prompt?.role ?? depth_prompt_role_default);
setExtensionPrompt(inject_ids.DEPTH_PROMPT, depthPromptText, extension_prompt_types.IN_CHAT, depthPromptDepth, extension_settings.note.allowWIScan, depthPromptRole);
}
// First message in fresh 1-on-1 chat reacts to user/character settings changes
if (chat.length) {
chat[0].mes = substituteParams(chat[0].mes);
}
// Collect messages with usable content
const canUseTools = ToolManager.isToolCallingSupported();
const canPerformToolCalls = !dryRun && ToolManager.canPerformToolCalls(type) && depth < ToolManager.RECURSE_LIMIT;
let coreChat = chat.filter(x => !x.is_system || (canUseTools && Array.isArray(x.extra?.tool_invocations)));
if (type === 'swipe') {
coreChat.pop();
}
coreChat = await Promise.all(coreChat.map(async (/** @type {ChatMessage} */ chatItem, index) => {
let message = chatItem.mes;
let regexType = chatItem.is_user ? regex_placement.USER_INPUT : regex_placement.AI_OUTPUT;
let options = { isPrompt: true, depth: (coreChat.length - index - (isContinue ? 2 : 1)) };
let regexedMessage = getRegexedString(message, regexType, options);
regexedMessage = await appendFileContent(chatItem, regexedMessage);
const titles = [];
if (chatItem?.extra?.append_title && chatItem?.extra?.title) {
titles.push(chatItem.extra.title);
}
if (Array.isArray(chatItem?.extra?.media)) {
for (const mediaItem of chatItem.extra.media) {
if (mediaItem?.title && mediaItem?.append_title) {
titles.push(mediaItem.title);
}
}
}
if (titles.length > 0) {
regexedMessage = `${regexedMessage}\n\n${titles.join('\n\n')}`;
}
return {
...chatItem,
mes: regexedMessage,
index,
};
}));
const promptReasoning = new PromptReasoning();
for (let i = coreChat.length - 1; i >= 0; i--) {
const depth = coreChat.length - i - (isContinue ? 2 : 1);
const isPrefix = isContinue && i === coreChat.length - 1;
coreChat[i] = {
...coreChat[i],
mes: promptReasoning.addToMessage(
coreChat[i].mes,
getRegexedString(
String(coreChat[i].extra?.reasoning ?? ''),
regex_placement.REASONING,
{ isPrompt: true, depth: depth },
),
isPrefix,
coreChat[i].extra?.reasoning_duration,
),
};
if (promptReasoning.isLimitReached()) {
break;
}
}
// Determine token limit
let this_max_context = getMaxContextSize();
if (!dryRun) {
console.debug('Running extension interceptors');
const aborted = await runGenerationInterceptors(coreChat, this_max_context, type);
if (aborted) {
console.debug('Generation aborted by extension interceptors');
unblockGeneration(type);
return Promise.resolve();
}
} else {
console.debug('Skipping extension interceptors for dry run');
}
// Adjust token limit for Horde
let adjustedParams;
if (main_api == 'koboldhorde' && (horde_settings.auto_adjust_context_length || horde_settings.auto_adjust_response_length)) {
try {
adjustedParams = await adjustHordeGenerationParams(max_context, amount_gen);
}
catch {
unblockGeneration(type);
return Promise.resolve();
}
if (horde_settings.auto_adjust_context_length) {
this_max_context = (adjustedParams.maxContextLength - adjustedParams.maxLength);
}
}
// Fetches the combined prompt for both negative and positive prompts
const cfgGuidanceScale = getGuidanceScale();
const useCfgPrompt = cfgGuidanceScale && cfgGuidanceScale.value !== 1;
// Adjust max context based on CFG prompt to prevent overfitting
if (useCfgPrompt) {
const negativePrompt = getCfgPrompt(cfgGuidanceScale, true, true)?.value || '';
const positivePrompt = getCfgPrompt(cfgGuidanceScale, false, true)?.value || '';
if (negativePrompt || positivePrompt) {
const previousMaxContext = this_max_context;
const [negativePromptTokenCount, positivePromptTokenCount] = await Promise.all([getTokenCountAsync(negativePrompt), getTokenCountAsync(positivePrompt)]);
const decrement = Math.max(negativePromptTokenCount, positivePromptTokenCount);
this_max_context -= decrement;
console.log(`Max context reduced by ${decrement} tokens of CFG prompt (${previousMaxContext} -> ${this_max_context})`);
}
}
console.log(`Core/all messages: ${coreChat.length}/${chat.length}`);
if ((promptBias && !isUserPromptBias) || power_user.always_force_name2 || main_api == 'novel') {
force_name2 = true;
}
if (isImpersonate) {
force_name2 = false;
}
let mesExamplesArray = parseMesExamples(mesExamples, isInstruct);
// Set non-WI AN
setFloatingPrompt();
// Add WI to prompt (and also inject WI to AN value via hijack)
// Make quiet prompt available for WIAN
setExtensionPrompt(inject_ids.QUIET_PROMPT, quiet_prompt || '', extension_prompt_types.IN_PROMPT, 0, true);
const chatForWI = coreChat.map(x => world_info_include_names ? `${x.name}: ${x.mes}` : x.mes).reverse();
/** @type {import('./scripts/world-info.js').WIGlobalScanData} */
const globalScanData = {
personaDescription: persona,
characterDescription: description,
characterPersonality: personality,
characterDepthPrompt: charDepthPrompt,
scenario: scenario,
creatorNotes: creatorNotes,
trigger: GENERATION_TYPE_TRIGGERS.includes(type) ? type : 'normal',
};
const { worldInfoString, worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth, outletEntries } = await getWorldInfoPrompt(chatForWI, this_max_context, dryRun, globalScanData);
setExtensionPrompt(inject_ids.QUIET_PROMPT, '', extension_prompt_types.IN_PROMPT, 0, true);
// Add message example WI
for (const example of worldInfoExamples) {
const exampleMessage = example.content;
if (exampleMessage.length === 0) {
continue;
}
const formattedExample = baseChatReplace(exampleMessage);
const cleanedExample = parseMesExamples(formattedExample, isInstruct);
// Insert depending on before or after position
if (example.position === wi_anchor_position.before) {
mesExamplesArray.unshift(...cleanedExample);
} else {
mesExamplesArray.push(...cleanedExample);
}
}
// At this point, the raw message examples can be created
const mesExamplesRawArray = [...mesExamplesArray];
if (mesExamplesArray && isInstruct) {
mesExamplesArray = formatInstructModeExamples(mesExamplesArray, name1, name2);
}
if (skipWIAN !== true) {
console.log('skipWIAN not active, adding WIAN');
// Add all depth WI entries to prompt
flushWIInjections();
if (Array.isArray(worldInfoDepth)) {
worldInfoDepth.forEach((e) => {
const joinedEntries = e.entries.join('\n');
setExtensionPrompt(inject_ids.CUSTOM_WI_DEPTH_ROLE(e.depth, e.role), joinedEntries, extension_prompt_types.IN_CHAT, e.depth, false, e.role);
});
}
if (outletEntries && typeof outletEntries === 'object' && Object.keys(outletEntries).length > 0) {
Object.entries(outletEntries).forEach(([key, value]) => {
setExtensionPrompt(inject_ids.CUSTOM_WI_OUTLET(key), value.join('\n'), extension_prompt_types.NONE, 0);
});
}
} else {
console.log('skipping WIAN');
}
// Add persona description to prompt
addPersonaDescriptionExtensionPrompt();
// Prepare the system prompt for Text Completion APIs
if (main_api !== 'openai') {
if (power_user.sysprompt.enabled) {
system = power_user.prefer_character_prompt && system
? substituteParams(system, { original: power_user.sysprompt.content ?? '' })
: baseChatReplace(power_user.sysprompt.content);
system = isInstruct ? substituteParams(system, { original: power_user.sysprompt.content ?? '' }) : system;
} else {
// Nullify if it's not enabled
system = '';
}
}
// Collect before / after story string injections
const beforeScenarioAnchor = await getExtensionPrompt(extension_prompt_types.BEFORE_PROMPT);
const afterScenarioAnchor = await getExtensionPrompt(extension_prompt_types.IN_PROMPT);
const storyStringParams = {
description: description,
personality: personality,
persona: power_user.persona_description_position == persona_description_positions.IN_PROMPT ? persona : '',
scenario: scenario,
system: system,
char: name2,
user: name1,
wiBefore: worldInfoBefore,
wiAfter: worldInfoAfter,
loreBefore: worldInfoBefore,
loreAfter: worldInfoAfter,
anchorBefore: beforeScenarioAnchor.trim(),
anchorAfter: afterScenarioAnchor.trim(),
mesExamples: mesExamplesArray.join(''),
mesExamplesRaw: mesExamplesRawArray.join(''),
};
// Render the story string and combine with injections
const storyString = renderStoryString(storyStringParams);
let combinedStoryString = isInstruct ? formatInstructModeStoryString(storyString) : storyString;
// Inject the story string as in-chat prompt (if needed)
const applyStoryStringInject = main_api !== 'openai' && power_user.context.story_string_position === extension_prompt_types.IN_CHAT;
if (applyStoryStringInject) {
const depth = power_user.context.story_string_depth ?? 1;
const role = power_user.context.story_string_role ?? extension_prompt_roles.SYSTEM;
setExtensionPrompt(inject_ids.STORY_STRING, combinedStoryString, extension_prompt_types.IN_CHAT, depth, false, role);
// Remove to prevent duplication
combinedStoryString = '';
} else {
setExtensionPrompt(inject_ids.STORY_STRING, '', extension_prompt_types.IN_CHAT, 0);
}
// Story string rendered, safe to remove
if (power_user.strip_examples) {
mesExamplesArray = [];
}
// Inject all Depth prompts. Chat Completion does it separately
let injectedIndices = [];
if (main_api !== 'openai') {
injectedIndices = await doChatInject(coreChat, isContinue);
}
if (main_api !== 'openai' && power_user.sysprompt.enabled) {
jailbreak = power_user.prefer_character_jailbreak && jailbreak
? substituteParams(jailbreak, { original: power_user.sysprompt.post_history ?? '' })
: baseChatReplace(power_user.sysprompt.post_history);
// Only inject the jb if there is one
if (jailbreak) {
// When continuing generation of previous output, last user message precedes the message to continue
if (isContinue) {
coreChat.splice(coreChat.length - 1, 0, { mes: jailbreak, is_user: true });
}
else {
// This operation will result in the injectedIndices indexes being off by one
coreChat.push({ mes: jailbreak, is_user: true });
// Add +1 to the elements to correct for the new PHI/Jailbreak message.
injectedIndices.forEach(shiftUpByOne);
}
}
}
let chat2 = [];
let continue_mag = '';
let userMessageIndices = [];
const lastUserMessageIndex = coreChat.findLastIndex(x => x.is_user);
for (let i = coreChat.length - 1, j = 0; i >= 0; i--, j++) {
if (main_api == 'openai') {
chat2[i] = coreChat[j].mes;
if (i === 0 && isContinue) {
chat2[i] = chat2[i].slice(0, chat2[i].lastIndexOf(coreChat[j].mes) + coreChat[j].mes.length);
continue_mag = coreChat[j].mes;
}
continue;
}
chat2[i] = formatMessageHistoryItem(coreChat[j], isInstruct, false);
if (j === 0 && isInstruct) {
// Reformat with the first output sequence (if any)
chat2[i] = formatMessageHistoryItem(coreChat[j], isInstruct, force_output_sequence.FIRST);
}
if (lastUserMessageIndex >= 0 && j === lastUserMessageIndex && isInstruct) {
// Reformat with the last input sequence (if any)
chat2[i] = formatMessageHistoryItem(coreChat[j], isInstruct, force_output_sequence.LAST);
}
// Do not suffix the message for continuation
if (i === 0 && isContinue) {
// Pick something that's very unlikely to be in a message
const FORMAT_TOKEN = '\u0000\ufffc\u0000\ufffd';
if (isInstruct) {
const originalMessage = String(coreChat[j].mes ?? '');
coreChat[j].mes = originalMessage.replaceAll(FORMAT_TOKEN, '') + FORMAT_TOKEN;
// Reformat with the last output sequence (if any)
chat2[i] = formatMessageHistoryItem(coreChat[j], isInstruct, force_output_sequence.LAST);
coreChat[j].mes = originalMessage;
}
chat2[i] = chat2[i].includes(FORMAT_TOKEN)
? chat2[i].slice(0, chat2[i].lastIndexOf(FORMAT_TOKEN))
: chat2[i].slice(0, chat2[i].lastIndexOf(coreChat[j].mes) + coreChat[j].mes.length);
continue_mag = coreChat[j].mes;
}
if (coreChat[j].is_user) {
userMessageIndices.push(i);
}
}
let addUserAlignment = isInstruct && power_user.instruct.user_alignment_message;
let userAlignmentMessage = '';
if (addUserAlignment) {
const alignmentMessage = {
name: name1,
mes: substituteParams(power_user.instruct.user_alignment_message),
is_user: true,
};
userAlignmentMessage = formatMessageHistoryItem(alignmentMessage, isInstruct, force_output_sequence.FIRST);
}
let oaiMessages = [];
let oaiMessageExamples = [];
if (main_api === 'openai') {
oaiMessages = setOpenAIMessages(coreChat);
oaiMessageExamples = setOpenAIMessageExamples(mesExamplesArray);
}
// hack for regeneration of the first message
if (chat2.length == 0) {
chat2.push('');
}
let examplesString = '';
let chatString = addChatsPreamble(addChatsSeparator(''));
let cyclePrompt = '';
async function getMessagesTokenCount() {
const encodeString = [
combinedStoryString,
examplesString,
userAlignmentMessage,
chatString,
modifyLastPromptLine(''),
cyclePrompt,
].join('').replace(/\r/gm, '');
return getTokenCountAsync(encodeString, power_user.token_padding);
}
// Force pinned examples into the context
let pinExmString;
if (power_user.pin_examples) {
pinExmString = examplesString = mesExamplesArray.join('');
}
// Only add the chat in context if past the greeting message
if (isContinue && (chat2.length > 1 || main_api === 'openai')) {
cyclePrompt = chat2.shift();
// Adjust indices to account for the shift
injectedIndices = injectedIndices.map(shiftDownByOne).filter(x => x >= 0);
userMessageIndices = userMessageIndices.map(shiftDownByOne).filter(x => x >= 0);
}
// Collect enough messages to fill the context
let arrMes = new Array(chat2.length);
let tokenCount = await getMessagesTokenCount();
let lastAddedIndex = 0;
// Pre-allocate all injections first.
// If it doesn't fit - user shot himself in the foot
for (const index of injectedIndices) {
// not needed for OAI prompting
if (main_api == 'openai') {
break;
}
const item = chat2[index];
if (typeof item !== 'string') {
continue;
}
tokenCount += await getTokenCountAsync(item.replace(/\r/gm, ''));
if (tokenCount < this_max_context) {
chatString = chatString + item;
arrMes[index] = item;
lastAddedIndex = Math.max(lastAddedIndex, index);
} else {
break;
}
}
for (let i = 0; i < chat2.length; i++) {
// not needed for OAI prompting
if (main_api == 'openai') {
break;
}
// Skip already injected messages
if (arrMes[i] !== undefined) {
continue;
}
const item = chat2[i];
if (typeof item !== 'string') {
continue;
}
tokenCount += await getTokenCountAsync(item.replace(/\r/gm, ''));
if (tokenCount < this_max_context) {
chatString = chatString + item;
arrMes[i] = item;
lastAddedIndex = Math.max(lastAddedIndex, i);
} else {
break;
}
}
// Add user alignment message if last message is not a user message
const stoppedAtUser = userMessageIndices.includes(lastAddedIndex);
if (addUserAlignment && !stoppedAtUser) {
tokenCount += await getTokenCountAsync(userAlignmentMessage.replace(/\r/gm, ''));
chatString = userAlignmentMessage + chatString;
arrMes.push(userAlignmentMessage);
injectedIndices.push(arrMes.length - 1);
}
// Unsparse the array. Adjust injected indices
const newArrMes = [];
const newInjectedIndices = [];
for (let i = 0; i < arrMes.length; i++) {
if (arrMes[i] !== undefined) {
newArrMes.push(arrMes[i]);
if (injectedIndices.includes(i)) {
newInjectedIndices.push(newArrMes.length - 1);
}
}
}
arrMes = newArrMes;
injectedIndices = newInjectedIndices;
if (main_api !== 'openai') {
setInContextMessages(arrMes.length - injectedIndices.length, type);
}
// Estimate how many unpinned example messages fit in the context
tokenCount = await getMessagesTokenCount();
let count_exm_add = 0;
if (!power_user.pin_examples) {
for (let example of mesExamplesArray) {
tokenCount += await getTokenCountAsync(example.replace(/\r/gm, ''));
examplesString += example;
if (tokenCount < this_max_context) {
count_exm_add++;
} else {
break;
}
}
}
let mesSend = [];
console.debug('calling runGenerate');
if (isContinue) {
// Coping mechanism for OAI spacing
if (main_api === 'openai' && !cyclePrompt.endsWith(' ')) {
cyclePrompt += oai_settings.continue_postfix;
continue_mag += oai_settings.continue_postfix;
}
}
const originalType = type;
if (!dryRun) {
is_send_press = true;
}
let generatedPromptCache = cyclePrompt || '';
if (generatedPromptCache.length == 0 || type === 'continue') {
console.debug('generating prompt');
chatString = '';
arrMes = arrMes.reverse();
arrMes.forEach(function (item, i, arr) {
// OAI doesn't need all of this
if (main_api === 'openai') {
return;
}
// Cohee: This removes a newline from the end of the last message in the context
// Last prompt line will add a newline if it's not a continuation
// In instruct mode it only removes it if wrap is enabled and it's not a quiet generation
if (i === arrMes.length - 1 && type !== 'continue') {
if (!isInstruct || (power_user.instruct.wrap && type !== 'quiet')) {
item = item.replace(/\n?$/, '');
}
}
mesSend[mesSend.length] = { message: item, extensionPrompts: [] };
});
}
let mesExmString = '';
function setPromptString() {
if (main_api == 'openai') {
return;
}
console.debug('--setting Prompt string');
mesExmString = pinExmString ?? mesExamplesArray.slice(0, count_exm_add).join('');
if (mesSend.length) {
mesSend[mesSend.length - 1].message = modifyLastPromptLine(mesSend[mesSend.length - 1].message);
}
}
function modifyLastPromptLine(lastMesString) {
//#########QUIET PROMPT STUFF PT2##############
// Add quiet generation prompt at depth 0
if (quiet_prompt && quiet_prompt.length) {
// here name1 is forced for all quiet prompts..why?
const name = name1;
//checks if we are in instruct, if so, formats the chat as such, otherwise just adds the quiet prompt
const quietAppend = isInstruct ? formatInstructModeChat(name, quiet_prompt, false, true, '', name1, name2, false) : `\n${quiet_prompt}`;
//This begins to fix quietPrompts (particularly /sysgen) for instruct
//previously instruct input sequence was being appended to the last chat message w/o '\n'
//and no output sequence was added after the input's content.
//TODO: respect output_sequence vs last_output_sequence settings
//TODO: decide how to prompt this to clarify who is talking 'Narrator', 'System', etc.
if (isInstruct) {
lastMesString += quietAppend; // + power_user.instruct.output_sequence + '\n';
} else {
lastMesString += quietAppend;
}
// Ross: bailing out early prevents quiet prompts from respecting other instruct prompt toggles
// for sysgen, SD, and summary this is desireable as it prevents the AI from responding as char..
// but for idle prompting, we want the flexibility of the other prompt toggles, and to respect them as per settings in the extension
// need a detection for what the quiet prompt is being asked for...
// Bail out early?
if (!isInstruct && !quietToLoud) {
return lastMesString;
}
}
// Get instruct mode line
if (isInstruct && !isContinue) {
const name = (quiet_prompt && !quietToLoud && !isImpersonate) ? (quietName ?? 'System') : (isImpersonate ? name1 : name2);
const isQuiet = quiet_prompt && type == 'quiet';
lastMesString += formatInstructModePrompt(name, isImpersonate, promptBias, name1, name2, isQuiet, quietToLoud);
}
// Get non-instruct impersonation line
if (!isInstruct && isImpersonate && !isContinue) {
const name = name1;
if (!lastMesString.endsWith('\n')) {
lastMesString += '\n';
}
lastMesString += name + ':';
}
// Add character's name
// Force name append on continue (if not continuing on user message or first message)
const isContinuingOnFirstMessage = chat.length === 1 && isContinue;
if (!isInstruct && force_name2 && !isContinuingOnFirstMessage) {
if (!lastMesString.endsWith('\n')) {
lastMesString += '\n';
}
if (!isContinue || !(chat[chat.length - 1]?.is_user)) {
lastMesString += `${name2}:`;
}
}
return lastMesString;
}
async function checkPromptSize() {
console.debug('---checking Prompt size');
setPromptString();
const jointMessages = mesSend.map((e) => `${e.extensionPrompts.join('')}${e.message}`).join('');
const prompt = [
combinedStoryString,
mesExmString,
addChatsPreamble(addChatsSeparator(jointMessages)),
'\n',
modifyLastPromptLine(''),
generatedPromptCache,
].join('').replace(/\r/gm, '');
let thisPromptContextSize = await getTokenCountAsync(prompt, power_user.token_padding);
if (thisPromptContextSize > this_max_context) { //if the prepared prompt is larger than the max context size...
if (count_exm_add > 0) { // ..and we have example mesages..
count_exm_add--; // remove the example messages...
await checkPromptSize(); // and try agin...
} else if (mesSend.length > 0) { // if the chat history is longer than 0
mesSend.shift(); // remove the first (oldest) chat entry..
await checkPromptSize(); // and check size again..
} else {
//end
console.debug(`---mesSend.length = ${mesSend.length}`);
}
}
}
if (generatedPromptCache.length > 0 && main_api !== 'openai') {
console.debug('---Generated Prompt Cache length: ' + generatedPromptCache.length);
await checkPromptSize();
} else {
console.debug('---calling setPromptString ' + generatedPromptCache.length);
setPromptString();
}
// For prompt bit itemization
let mesSendString = '';
async function getCombinedPrompt(isNegative) {
// Only return if the guidance scale doesn't exist or the value is 1
// Also don't return if constructing the neutral prompt
if (isNegative && !useCfgPrompt) {
return;
}
// OAI has its own prompt manager. No need to do anything here
if (main_api === 'openai') {
return '';
}
// Deep clone
let finalMesSend = structuredClone(mesSend);
if (useCfgPrompt) {
const cfgPrompt = getCfgPrompt(cfgGuidanceScale, isNegative);
if (cfgPrompt.value) {
if (cfgPrompt.depth === 0) {
finalMesSend[finalMesSend.length - 1].message +=
/\s/.test(finalMesSend[finalMesSend.length - 1].message.slice(-1))
? cfgPrompt.value
: ` ${cfgPrompt.value}`;
} else {
// TODO: Make all extension prompts use an array/splice method
const lengthDiff = mesSend.length - cfgPrompt.depth;
const cfgDepth = lengthDiff >= 0 ? lengthDiff : 0;
const cfgMessage = finalMesSend[cfgDepth];
if (cfgMessage) {
if (!Array.isArray(finalMesSend[cfgDepth].extensionPrompts)) {
finalMesSend[cfgDepth].extensionPrompts = [];
}
finalMesSend[cfgDepth].extensionPrompts.push(`${cfgPrompt.value}\n`);
}
}
}
}
// Add prompt bias after everything else
// Always run with continue
if (!isInstruct && !isImpersonate) {
if (promptBias.trim().length !== 0) {
finalMesSend[finalMesSend.length - 1].message +=
/\s/.test(finalMesSend[finalMesSend.length - 1].message.slice(-1))
? promptBias.trimStart()
: ` ${promptBias.trimStart()}`;
}
}
// Flattens the multiple prompt objects to a string.
const combine = () => {
// Right now, everything is suffixed with a newline
mesSendString = finalMesSend.map((e) => `${e.extensionPrompts.join('')}${e.message}`).join('');
// add a custom dingus (if defined)
mesSendString = addChatsSeparator(mesSendString);
// add chat preamble
mesSendString = addChatsPreamble(mesSendString);
let combinedPrompt = [
combinedStoryString,
mesExmString,
mesSendString,
generatedPromptCache,
].join('').replace(/\r/gm, '');
if (power_user.collapse_newlines) {
combinedPrompt = collapseNewlines(combinedPrompt);
}
return combinedPrompt;
};
finalMesSend.forEach((item, i) => {
item.injected = injectedIndices.includes(finalMesSend.length - i - 1);
});
let data = {
api: main_api,
combinedPrompt: null,
description,
personality,
persona,
scenario,
char: name2,
user: name1,
worldInfoBefore,
worldInfoAfter,
beforeScenarioAnchor,
afterScenarioAnchor,
storyString,
mesExmString,
mesSendString,
finalMesSend,
generatedPromptCache,
main: system,
jailbreak,
naiPreamble: nai_settings.preamble,
};
// Before returning the combined prompt, give available context related information to all subscribers.
await eventSource.emit(event_types.GENERATE_BEFORE_COMBINE_PROMPTS, data);
// If one or multiple subscribers return a value, forfeit the responsibillity of flattening the context.
return !data.combinedPrompt ? combine() : data.combinedPrompt;
}
let finalPrompt = await getCombinedPrompt(false);
const eventData = { prompt: finalPrompt, dryRun: dryRun };
await eventSource.emit(event_types.GENERATE_AFTER_COMBINE_PROMPTS, eventData);
finalPrompt = eventData.prompt;
let maxLength = Number(amount_gen); // how many tokens the AI will be requested to generate
let thisPromptBits = [];
let generate_data;
switch (main_api) {
case 'koboldhorde':
case 'kobold':
if (main_api == 'koboldhorde' && horde_settings.auto_adjust_response_length) {
maxLength = Math.min(maxLength, adjustedParams.maxLength);
maxLength = Math.max(maxLength, MIN_LENGTH); // prevent validation errors
}
generate_data = {
prompt: finalPrompt,
gui_settings: true,
max_length: maxLength,
max_context_length: max_context,
api_server: kai_settings.api_server,
};
if (kai_settings.preset_settings != 'gui') {
const isHorde = main_api == 'koboldhorde';
const presetSettings = koboldai_settings[koboldai_setting_names[kai_settings.preset_settings]];
const maxContext = (adjustedParams && horde_settings.auto_adjust_context_length) ? adjustedParams.maxContextLength : max_context;
generate_data = getKoboldGenerationData(finalPrompt, presetSettings, maxLength, maxContext, isHorde, type);
}
break;
case 'textgenerationwebui': {
const cfgValues = useCfgPrompt ? { guidanceScale: cfgGuidanceScale, negativePrompt: await getCombinedPrompt(true) } : null;
generate_data = await getTextGenGenerationData(finalPrompt, maxLength, isImpersonate, isContinue, cfgValues, type);
break;
}
case 'novel': {
const cfgValues = useCfgPrompt ? { guidanceScale: cfgGuidanceScale } : null;
const presetSettings = novelai_settings[novelai_setting_names[nai_settings.preset_settings_novel]];
generate_data = getNovelGenerationData(finalPrompt, presetSettings, maxLength, isImpersonate, isContinue, cfgValues, type);
break;
}
case 'openai': {
let [prompt, counts] = await prepareOpenAIMessages({
name2: name2,
charDescription: description,
charPersonality: personality,
scenario: scenario,
worldInfoBefore: worldInfoBefore,
worldInfoAfter: worldInfoAfter,
extensionPrompts: extension_prompts,
bias: promptBias,
type: type,
quietPrompt: quiet_prompt,
quietImage: quietImage,
cyclePrompt: cyclePrompt,
systemPromptOverride: system,
jailbreakPromptOverride: jailbreak,
messages: oaiMessages,
messageExamples: oaiMessageExamples,
}, dryRun);
generate_data = { prompt: prompt };
// TODO: move these side-effects somewhere else, so this switch-case solely sets generate_data
// counts will return false if the user has not enabled the token breakdown feature
if (counts) {
parseTokenCounts(counts, thisPromptBits);
}
if (!dryRun) {
setInContextMessages(openai_messages_count, type);
}
break;
}
}
await eventSource.emit(event_types.GENERATE_AFTER_DATA, generate_data, dryRun);
if (dryRun) {
return Promise.resolve();
}
/**
* Saves itemized prompt bits and calls streaming or non-streaming generation API.
* @returns {Promise<void|*|Awaited<*>|String|{fromStream}|string|undefined|Object>}
* @throws {Error|object} Error with message text, or Error with response JSON (OAI/Horde), or the actual response JSON (novel|textgenerationwebui|kobold)
*/
async function finishGenerating() {
if (power_user.console_log_prompts) {
console.log(generate_data.prompt);
}
console.debug('rungenerate calling API');
showStopButton();
//set array object for prompt token itemization of this message
let currentArrayEntry = Number(thisPromptBits.length - 1);
let additionalPromptStuff = {
...thisPromptBits[currentArrayEntry],
rawPrompt: generate_data.prompt || generate_data.input,
mesId: getNextMessageId(type),
allAnchors: await getAllExtensionPrompts(),
chatInjects: injectedIndices?.map(index => arrMes[arrMes.length - index - 1])?.join('') || '',
summarizeString: (extension_prompts['1_memory']?.value || ''),
authorsNoteString: (extension_prompts['2_floating_prompt']?.value || ''),
smartContextString: (extension_prompts['chromadb']?.value || ''),
chatVectorsString: (extension_prompts['3_vectors']?.value || ''),
dataBankVectorsString: (extension_prompts['4_vectors_data_bank']?.value || ''),
worldInfoString: worldInfoString,
storyString: storyString,
beforeScenarioAnchor: beforeScenarioAnchor,
afterScenarioAnchor: afterScenarioAnchor,
examplesString: examplesString,
mesSendString: mesSendString,
generatedPromptCache: generatedPromptCache,
promptBias: promptBias,
finalPrompt: finalPrompt,
charDescription: description,
charPersonality: personality,
scenarioText: scenario,
this_max_context: this_max_context,
padding: power_user.token_padding,
main_api: main_api,
instruction: main_api !== 'openai' && power_user.sysprompt.enabled ? substituteParams(power_user.prefer_character_prompt && system ? system : power_user.sysprompt.content) : '',
userPersona: (power_user.persona_description_position == persona_description_positions.IN_PROMPT ? (persona || '') : ''),
tokenizer: getFriendlyTokenizerName(main_api).tokenizerName || '',
presetName: getPresetManager()?.getSelectedPresetName() || '',
messagesCount: main_api !== 'openai' ? mesSend.length : oaiMessages.length,
examplesCount: main_api !== 'openai' ? (pinExmString ? mesExamplesArray.length : count_exm_add) : oaiMessageExamples.length,
};
//console.log(additionalPromptStuff);
const itemizedIndex = itemizedPrompts.findIndex((item) => item.mesId === additionalPromptStuff.mesId);
if (itemizedIndex !== -1) {
itemizedPrompts[itemizedIndex] = additionalPromptStuff;
}
else {
itemizedPrompts.push(additionalPromptStuff);
}
console.debug(`pushed prompt bits to itemizedPrompts array. Length is now: ${itemizedPrompts.length}`);
if (isStreamingEnabled() && type !== 'quiet') {
continue_mag = promptReasoning.removePrefix(continue_mag);
streamingProcessor = new StreamingProcessor(type, force_name2, generation_started, continue_mag, promptReasoning);
if (isContinue) {
// Save reply does add cycle text to the prompt, so it's not needed here
streamingProcessor.firstMessageText = '';
}
streamingProcessor.generator = await sendStreamingRequest(type, generate_data);
hideSwipeButtons();
let getMessage = await streamingProcessor.generate();
let messageChunk = cleanUpMessage({
getMessage: getMessage,
isImpersonate: isImpersonate,
isContinue: isContinue,
displayIncompleteSentences: false,
});
if (isContinue) {
getMessage = continue_mag + getMessage;
}
const isStreamFinished = streamingProcessor && !streamingProcessor.isStopped && streamingProcessor.isFinished;
const isStreamWithToolCalls = streamingProcessor && Array.isArray(streamingProcessor.toolCalls) && streamingProcessor.toolCalls.length;
if (canPerformToolCalls && isStreamFinished && isStreamWithToolCalls) {
const lastMessage = chat[chat.length - 1];
const hasToolCalls = ToolManager.hasToolCalls(streamingProcessor.toolCalls);
const shouldDeleteMessage = type !== 'swipe' && ['', '...'].includes(lastMessage?.mes) && !lastMessage?.extra?.reasoning && ['', '...'].includes(streamingProcessor?.result);
hasToolCalls && shouldDeleteMessage && await deleteLastMessage();
const invocationResult = await ToolManager.invokeFunctionTools(streamingProcessor.toolCalls);
const shouldStopGeneration = (!invocationResult.invocations.length && shouldDeleteMessage) || invocationResult.stealthCalls.length;
if (hasToolCalls) {
if (shouldStopGeneration) {
if (Array.isArray(invocationResult.errors) && invocationResult.errors.length) {
ToolManager.showToolCallError(invocationResult.errors);
}
unblockGeneration(type);
streamingProcessor = null;
return;
}
streamingProcessor = null;
depth = depth + 1;
await ToolManager.saveFunctionToolInvocations(invocationResult.invocations);
return Generate('normal', { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage, quietName, depth }, dryRun);
}
}
if (isStreamFinished) {
await streamingProcessor.onFinishStreaming(streamingProcessor.messageId, getMessage);
streamingProcessor = null;
triggerAutoContinue(messageChunk, isImpersonate);
return Object.defineProperties(new String(getMessage), {
'messageChunk': { value: messageChunk },
'fromStream': { value: true },
});
}
} else {
return await sendGenerationRequest(type, generate_data, { jsonSchema });
}
}
return finishGenerating().then(onSuccess, onError);
/**
* Handles the successful response from the generation API.
* @param data
* @returns {Promise<String|{fromStream}|*|string|string|void|Awaited<*>|undefined>}
* @throws {Error} Throws an error if the response data contains an error message
*/
async function onSuccess(data) {
if (!data) return;
if (data?.fromStream) {
return data;
}
let messageChunk = '';
// if an error was returned in data (textgenwebui), show it and throw it
if (data.error) {
unblockGeneration(type);
if (data?.response) {
toastr.error(data.response, t`API Error`, { preventDuplicates: true });
}
throw new Error(data?.response);
}
if (jsonSchema) {
unblockGeneration(type);
return extractJsonFromData(data);
}
//const getData = await response.json();
let getMessage = extractMessageFromData(data);
let title = extractTitleFromData(data);
let reasoning = extractReasoningFromData(data);
let imageUrls = extractImagesFromData(data);
const reasoningSignature = extractReasoningSignatureFromData(data);
kobold_horde_model = title;
const swipes = extractMultiSwipes(data, type);
messageChunk = cleanUpMessage({
getMessage: getMessage,
isImpersonate: isImpersonate,
isContinue: isContinue,
displayIncompleteSentences: false,
});
reasoning = getRegexedString(reasoning, regex_placement.REASONING);
if (power_user.trim_spaces) {
reasoning = reasoning.trim();
}
if (isContinue) {
continue_mag = promptReasoning.removePrefix(continue_mag);
getMessage = continue_mag + getMessage;
}
//Formating
const displayIncomplete = type === 'quiet' && !quietToLoud;
getMessage = cleanUpMessage({
getMessage: getMessage,
isImpersonate: isImpersonate,
isContinue: isContinue,
displayIncompleteSentences: displayIncomplete,
});
if (isImpersonate) {
$('#send_textarea').val(getMessage)[0].dispatchEvent(new Event('input', { bubbles: true }));
await eventSource.emit(event_types.IMPERSONATE_READY, getMessage);
}
else if (type == 'quiet') {
unblockGeneration(type);
return getMessage;
}
else {
// Without streaming we'll be having a full message on continuation. Treat it as a last chunk.
if (originalType !== 'continue') {
({ type, getMessage } = await saveReply({ type, getMessage, title, swipes, reasoning, imageUrls, reasoningSignature }));
}
else {
({ type, getMessage } = await saveReply({ type: 'appendFinal', getMessage, title, swipes, reasoning, imageUrls, reasoningSignature }));
}
// This relies on `saveReply` having been called to add the message to the chat, so it must be last.
parseAndSaveLogprobs(data, continue_mag);
}
if (canPerformToolCalls) {
const hasToolCalls = ToolManager.hasToolCalls(data);
const shouldDeleteMessage = type !== 'swipe' && ['', '...'].includes(getMessage) && !reasoning;
hasToolCalls && shouldDeleteMessage && await deleteLastMessage();
const invocationResult = await ToolManager.invokeFunctionTools(data);
const shouldStopGeneration = (!invocationResult.invocations.length && shouldDeleteMessage) || invocationResult.stealthCalls.length;
if (hasToolCalls) {
if (shouldStopGeneration) {
if (Array.isArray(invocationResult.errors) && invocationResult.errors.length) {
ToolManager.showToolCallError(invocationResult.errors);
}
unblockGeneration(type);
return;
}
depth = depth + 1;
await ToolManager.saveFunctionToolInvocations(invocationResult.invocations);
return Generate('normal', { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage, quietName, depth }, dryRun);
}
}
if (type !== 'quiet') {
playMessageSound();
}
const isAborted = abortController && abortController.signal.aborted;
if (!isAborted && power_user.auto_swipe && generatedTextFiltered(getMessage)) {
is_send_press = false;
return await swipe(null, SWIPE_DIRECTION.RIGHT, { source: SWIPE_SOURCE.AUTO_SWIPE, repeated: true, forceMesId: chat.length - 1 });
}
console.debug('/api/chats/save called by /Generate');
await saveChatConditional();
unblockGeneration(type);
streamingProcessor = null;
if (type !== 'quiet') {
triggerAutoContinue(messageChunk, isImpersonate);
}
// Don't break the API chain that expects a single string in return
return Object.defineProperty(new String(getMessage), 'messageChunk', { value: messageChunk });
}
/**
* Exception handler for finishGenerating
* @param {Error|object} exception Error or response JSON
* @throws {Error|object} Re-throws the exception
*/
function onError(exception) {
// if the response JSON was thrown (novel|textgenerationwebui|kobold), show the error message
if (typeof exception?.error?.message === 'string') {
toastr.error(exception.error.message, t`Text generation error`, { timeOut: 10000, extendedTimeOut: 20000 });
}
unblockGeneration(type);
console.log(exception);
streamingProcessor = null;
throw exception;
}
}
//MARK: Generate() ends
/**
* Stops the generation and any streaming if it is currently running.
*/
export function stopGeneration() {
let stopped = false;
if (streamingProcessor) {
streamingProcessor.onStopStreaming();
stopped = true;
}
if (abortController) {
abortController.abort('Clicked stop button');
hideStopButton();
stopped = true;
}
eventSource.emit(event_types.GENERATION_STOPPED);
return stopped;
}
/**
* Injects extension prompts into chat messages.
* @param {object[]} messages Array of chat messages
* @param {boolean} isContinue Whether the generation is a continuation. If true, the extension prompts of depth 0 are injected at position 1.
* @returns {Promise<number[]>} Array of indices where the extension prompts were injected
*/
async function doChatInject(messages, isContinue) {
const injectedMessages = [];
let totalInsertedMessages = 0;
messages.reverse();
const maxDepth = getExtensionPromptMaxDepth();
for (let i = 0; i <= maxDepth; i++) {
// Order of priority (most important go lower)
const roles = [extension_prompt_roles.SYSTEM, extension_prompt_roles.USER, extension_prompt_roles.ASSISTANT];
const names = {
[extension_prompt_roles.SYSTEM]: '',
[extension_prompt_roles.USER]: name1,
[extension_prompt_roles.ASSISTANT]: name2,
};
const roleMessages = [];
const separator = '\n';
const wrap = false;
for (const role of roles) {
const extensionPrompt = String(await getExtensionPrompt(extension_prompt_types.IN_CHAT, i, separator, role, wrap)).trimStart();
const isNarrator = role === extension_prompt_roles.SYSTEM;
const isUser = role === extension_prompt_roles.USER;
const name = names[role];
if (extensionPrompt) {
roleMessages.push({
name: name,
is_user: isUser,
mes: extensionPrompt,
extra: {
type: isNarrator ? system_message_types.NARRATOR : null,
},
});
}
}
if (roleMessages.length) {
const depth = isContinue && i === 0 ? 1 : i;
const injectIdx = Math.min(depth + totalInsertedMessages, messages.length);
messages.splice(injectIdx, 0, ...roleMessages);
totalInsertedMessages += roleMessages.length;
injectedMessages.push(...roleMessages);
}
}
const injectedIndices = injectedMessages.map(msg => messages.indexOf(msg));
messages.reverse();
return injectedIndices;
}
function flushWIInjections() {
const depthPrefix = inject_ids.CUSTOM_WI_DEPTH;
const outletPrefix = inject_ids.CUSTOM_WI_OUTLET('');
for (const key of Object.keys(extension_prompts)) {
if (key.startsWith(depthPrefix) || key.startsWith(outletPrefix)) {
delete extension_prompts[key];
}
}
}
/**
* Unblocks the UI after a generation is complete.
* @param {string} [type] Generation type (optional)
*/
function unblockGeneration(type) {
// Don't unblock if a parallel stream is still running
if (type === 'quiet' && streamingProcessor && !streamingProcessor.isFinished) {
return;
}
is_send_press = false;
activateSendButtons();
setGenerationProgress(0);
flushEphemeralStoppingStrings();
flushWIInjections();
}
export function getNextMessageId(type) {
return type == 'swipe' ? chat.length - 1 : chat.length;
}
/**
* Determines if the message should be auto-continued.
* @param {string} messageChunk Current message chunk
* @param {boolean} isImpersonate Is the user impersonation
* @returns {boolean} Whether the message should be auto-continued
*/
export function shouldAutoContinue(messageChunk, isImpersonate) {
if (!power_user.auto_continue.enabled) {
console.debug('Auto-continue is disabled by user.');
return false;
}
if (typeof messageChunk !== 'string') {
console.debug('Not triggering auto-continue because message chunk is not a string');
return false;
}
if (isImpersonate) {
console.log('Continue for impersonation is not implemented yet');
return false;
}
if (is_send_press) {
console.debug('Auto-continue is disabled because a message is currently being sent.');
return false;
}
if (abortController && abortController.signal.aborted) {
console.debug('Auto-continue is not triggered because the generation was stopped.');
return false;
}
if (power_user.auto_continue.target_length <= 0) {
console.log('Auto-continue target length is 0, not triggering auto-continue');
return false;
}
if (main_api === 'openai' && !power_user.auto_continue.allow_chat_completions) {
console.log('Auto-continue for OpenAI is disabled by user.');
return false;
}
const textareaText = String($('#send_textarea').val());
const USABLE_LENGTH = 5;
if (textareaText.length > 0) {
console.log('Not triggering auto-continue because user input is not empty');
return false;
}
if (messageChunk.trim().length > USABLE_LENGTH && chat.length) {
const lastMessage = chat[chat.length - 1];
const messageLength = getTokenCount(lastMessage.mes);
const shouldAutoContinue = messageLength < power_user.auto_continue.target_length;
if (shouldAutoContinue) {
console.log(`Triggering auto-continue. Message tokens: ${messageLength}. Target tokens: ${power_user.auto_continue.target_length}. Message chunk: ${messageChunk}`);
return true;
} else {
console.log(`Not triggering auto-continue. Message tokens: ${messageLength}. Target tokens: ${power_user.auto_continue.target_length}`);
return false;
}
} else {
console.log('Last generated chunk was empty, not triggering auto-continue');
return false;
}
}
/**
* Triggers auto-continue if the message meets the criteria.
* @param {string} messageChunk Current message chunk
* @param {boolean} isImpersonate Is the user impersonation
*/
export function triggerAutoContinue(messageChunk, isImpersonate) {
if (selected_group) {
console.debug('Auto-continue is disabled for group chat');
return;
}
if (shouldAutoContinue(messageChunk, isImpersonate)) {
$('#option_continue').trigger('click');
}
}
export function getBiasStrings(textareaText, type) {
if (type == 'impersonate' || type == 'continue') {
return { messageBias: '', promptBias: '', isUserPromptBias: false };
}
let promptBias = '';
let messageBias = extractMessageBias(textareaText);
// If user input is not provided, retrieve the bias of the most recent relevant message
if (!textareaText) {
for (let i = chat.length - 1; i >= 0; i--) {
const mes = chat[i];
if (type === 'swipe' && chat.length - 1 === i) {
continue;
}
if (mes && (mes.is_user || mes.is_system || mes.extra?.type === system_message_types.NARRATOR)) {
if (mes.extra?.bias?.trim()?.length > 0) {
promptBias = mes.extra.bias;
}
break;
}
}
}
promptBias = messageBias || promptBias || power_user.user_prompt_bias || '';
const isUserPromptBias = promptBias === power_user.user_prompt_bias;
// Substitute params for everything
messageBias = substituteParams(messageBias);
promptBias = substituteParams(promptBias);
return { messageBias, promptBias, isUserPromptBias };
}
/**
* @param {Object} chatItem Message history item.
* @param {boolean} isInstruct Whether instruct mode is enabled.
* @param {boolean|number} forceOutputSequence Whether to force the first/last output sequence for instruct mode.
*/
function formatMessageHistoryItem(chatItem, isInstruct, forceOutputSequence) {
const isNarratorType = chatItem?.extra?.type === system_message_types.NARRATOR;
const characterName = chatItem?.name ? chatItem.name : name2;
const itemName = chatItem.is_user ? chatItem['name'] : characterName;
const shouldPrependName = !isNarratorType;
// If this symbol flag is set, completely ignore the message.
// This can be used to hide messages without affecting the number of messages in the chat.
if (chatItem.extra?.[IGNORE_SYMBOL]) {
return '';
}
// Don't include a name if it's empty
let textResult = chatItem?.name && shouldPrependName ? `${itemName}: ${chatItem.mes}\n` : `${chatItem.mes}\n`;
if (isInstruct) {
textResult = formatInstructModeChat(itemName, chatItem.mes, chatItem.is_user, isNarratorType, chatItem.force_avatar, name1, name2, forceOutputSequence);
}
return textResult;
}
/**
* Removes all {{macros}} from a string.
* @param {string} str String to remove macros from.
* @returns {string} String with macros removed.
*/
export function removeMacros(str) {
return (str ?? '').replace(/\{\{[\s\S]*?\}\}/gm, '').trim();
}
/**
* Inserts a user message into the chat history.
* @param {string} messageText Message text.
* @param {string} messageBias Message bias.
* @param {number} [insertAt] Optional index to insert the message at.
* @param {boolean} [compact] Send as a compact display message.
* @param {string} [name] Name of the user sending the message. Defaults to name1.
* @param {string} [avatar] Avatar of the user sending the message. Defaults to user_avatar.
* @returns {Promise<any>} A promise that resolves to the message when it is inserted.
*/
export async function sendMessageAsUser(messageText, messageBias, insertAt = null, compact = false, name = name1, avatar = user_avatar) {
messageText = getRegexedString(messageText, regex_placement.USER_INPUT);
const message = {
name: name,
is_user: true,
is_system: false,
send_date: getMessageTimeStamp(),
mes: substituteParams(messageText),
extra: {
isSmallSys: compact,
},
};
if (power_user.message_token_count_enabled) {
message.extra.token_count = await getTokenCountAsync(message.mes, 0);
}
// Lock user avatar to a persona.
if (avatar in power_user.personas) {
message.force_avatar = getThumbnailUrl('persona', avatar);
}
if (messageBias) {
message.extra.bias = messageBias;
message.mes = removeMacros(message.mes);
}
await populateFileAttachment(message);
statMesProcess(message, 'user', characters, this_chid, '');
chat_metadata['tainted'] = true;
if (typeof insertAt === 'number' && insertAt >= 0 && insertAt <= chat.length) {
chat.splice(insertAt, 0, message);
await saveChatConditional();
await eventSource.emit(event_types.MESSAGE_SENT, insertAt);
await reloadCurrentChat();
await eventSource.emit(event_types.USER_MESSAGE_RENDERED, insertAt);
} else {
chat.push(message);
const chat_id = (chat.length - 1);
await eventSource.emit(event_types.MESSAGE_SENT, chat_id);
addOneMessage(message);
await eventSource.emit(event_types.USER_MESSAGE_RENDERED, chat_id);
await saveChatConditional();
}
return message;
}
/**
* Gets the maximum usable context size for the current API.
* @param {number|null} overrideResponseLength Optional override for the response length.
* @returns {number} Maximum usable context size.
*/
export function getMaxContextSize(overrideResponseLength = null) {
if (typeof overrideResponseLength !== 'number' || overrideResponseLength <= 0 || isNaN(overrideResponseLength)) {
overrideResponseLength = null;
}
let this_max_context = 1487;
if (main_api == 'kobold' || main_api == 'koboldhorde' || main_api == 'textgenerationwebui') {
this_max_context = (max_context - (overrideResponseLength || amount_gen));
}
if (main_api == 'novel') {
this_max_context = Number(max_context);
if (nai_settings.model_novel.includes('clio')) {
this_max_context = Math.min(max_context, 8192);
}
if (nai_settings.model_novel.includes('kayra')) {
this_max_context = Math.min(max_context, 8192);
const subscriptionLimit = getKayraMaxContextTokens();
if (typeof subscriptionLimit === 'number' && this_max_context > subscriptionLimit) {
this_max_context = subscriptionLimit;
console.log(`NovelAI subscription limit reached. Max context size is now ${this_max_context}`);
}
}
if (nai_settings.model_novel.includes('erato')) {
// subscriber limits coming soon
this_max_context = Math.min(max_context, 8192);
// Added special tokens and whatnot
this_max_context -= 10;
}
this_max_context = this_max_context - (overrideResponseLength || amount_gen);
}
if (main_api == 'openai') {
this_max_context = oai_settings.openai_max_context - (overrideResponseLength || oai_settings.openai_max_tokens);
}
return this_max_context;
}
function parseTokenCounts(counts, thisPromptBits) {
/**
* @param {any[]} numbers
*/
function getSum(...numbers) {
return numbers.map(x => Number(x)).filter(x => !Number.isNaN(x)).reduce((acc, val) => acc + val, 0);
}
const total = getSum(Object.values(counts));
thisPromptBits.push({
oaiStartTokens: (counts?.start + counts?.controlPrompts) || 0,
oaiPromptTokens: getSum(counts?.prompt, counts?.charDescription, counts?.charPersonality, counts?.scenario) || 0,
oaiBiasTokens: counts?.bias || 0,
oaiNudgeTokens: counts?.nudge || 0,
oaiJailbreakTokens: counts?.jailbreak || 0,
oaiImpersonateTokens: counts?.impersonate || 0,
oaiExamplesTokens: (counts?.dialogueExamples + counts?.examples) || 0,
oaiConversationTokens: (counts?.conversation + counts?.chatHistory) || 0,
oaiNsfwTokens: counts?.nsfw || 0,
oaiMainTokens: counts?.main || 0,
oaiTotalTokens: total,
});
}
function addChatsPreamble(mesSendString) {
return main_api === 'novel'
? substituteParams(nai_settings.preamble) + '\n' + mesSendString
: mesSendString;
}
function addChatsSeparator(mesSendString) {
if (power_user.context.chat_start) {
return substituteParams(power_user.context.chat_start + '\n') + mesSendString;
}
else {
return mesSendString;
}
}
export async function duplicateCharacter() {
if (this_chid === undefined || !characters[this_chid]) {
toastr.warning(t`You must first select a character to duplicate!`);
return '';
}
const confirmMessage = $(await renderTemplateAsync('duplicateConfirm'));
const confirm = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM);
if (!confirm) {
console.log('User cancelled duplication');
return '';
}
const body = { avatar_url: characters[this_chid].avatar };
const response = await fetch('/api/characters/duplicate', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(body),
});
if (response.ok) {
toastr.success(t`Character Duplicated`);
const data = await response.json();
await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: body.avatar_url, newAvatar: data.path });
await getCharacters();
}
return '';
}
function setInContextMessages(msgInContextCount, type) {
chatElement.find('.mes').removeClass('lastInContext');
if (type === 'swipe' || type === 'regenerate' || type === 'continue') {
msgInContextCount++;
}
const lastMessageBlock = chatElement.find('.mes:not([is_system="true"]), .mes.toolCall').eq(-msgInContextCount);
lastMessageBlock.addClass('lastInContext');
if (lastMessageBlock.length === 0) {
const firstMessageId = getFirstDisplayedMessageId();
chatElement.find(`.mes[mesid="${firstMessageId}"`).addClass('lastInContext');
}
// Update last id to chat. No metadata save on purpose, gets hopefully saved via another call
const lastMessageId = Math.max(0, chat.length - msgInContextCount);
chat_metadata['lastInContextMessageId'] = lastMessageId;
}
/**
* @typedef {object} AdditionalRequestOptions
* @property {JsonSchema} [jsonSchema]
*/
/**
* Sends a non-streaming request to the API.
* @param {string} type Generation type
* @param {object} data Generation data
* @param {AdditionalRequestOptions} [options] Additional options for the generation request
* @returns {Promise<object>} Response data from the API
* @throws {Error|object}
*/
export async function sendGenerationRequest(type, data, options = {}) {
if (main_api === 'openai') {
return await sendOpenAIRequest(type, data.prompt, abortController.signal, options);
}
if (main_api === 'koboldhorde') {
return await generateHorde(data.prompt, data, abortController.signal, true);
}
const response = await fetch(getGenerateUrl(main_api), {
method: 'POST',
headers: getRequestHeaders(),
cache: 'no-cache',
body: JSON.stringify(data),
signal: abortController.signal,
});
if (!response.ok) {
throw await response.json();
}
return await response.json();
}
/**
* Sends a streaming request to the API.
* @param {string} type Generation type
* @param {object} data Generation data
* @param {AdditionalRequestOptions} [options] Additional options for the generation request
* @returns {Promise<any>} Streaming generator
*/
export async function sendStreamingRequest(type, data, options = {}) {
if (abortController?.signal?.aborted) {
throw new Error('Generation was aborted.');
}
switch (main_api) {
case 'openai':
return await sendOpenAIRequest(type, data.prompt, streamingProcessor.abortController.signal, options);
case 'textgenerationwebui':
return await generateTextGenWithStreaming(data, streamingProcessor.abortController.signal);
case 'novel':
return await generateNovelWithStreaming(data, streamingProcessor.abortController.signal);
case 'kobold':
return await generateKoboldWithStreaming(data, streamingProcessor.abortController.signal);
default:
throw new Error('Streaming is enabled, but the current API does not support streaming.');
}
}
/**
* Gets the generation endpoint URL for the specified API.
* @param {string} api API name
* @returns {string} Generation URL
* @throws {Error} If the API is unknown
*/
export function getGenerateUrl(api) {
switch (api) {
case 'kobold':
return '/api/backends/kobold/generate';
case 'koboldhorde':
return '/api/backends/koboldhorde/generate';
case 'textgenerationwebui':
return '/api/backends/text-completions/generate';
case 'novel':
return '/api/novelai/generate';
default:
throw new Error(`Unknown API: ${api}`);
}
}
function extractTitleFromData(data) {
if (main_api == 'koboldhorde') {
return data.workerName;
}
return undefined;
}
/**
* Extracts the image from the response data.
* @param {object} data Response data
* @param {object} [options] Extraction options
* @param {string} [options.mainApi] Main API to use
* @param {string} [options.chatCompletionSource] Chat completion source
* @returns {string[]} Extracted images or empty array
*/
function extractImagesFromData(data, { mainApi = null, chatCompletionSource = null } = {}) {
switch (mainApi ?? main_api) {
case 'openai': {
switch (chatCompletionSource ?? oai_settings.chat_completion_source) {
case chat_completion_sources.VERTEXAI:
case chat_completion_sources.MAKERSUITE: {
const inlineData = data?.responseContent?.parts?.filter(x => x.inlineData && !x.thought)?.map(x => x.inlineData);
if (Array.isArray(inlineData) && inlineData.length > 0) {
return inlineData.map(x => `data:${x.mimeType};base64,${x.data}`).filter(isDataURL);
}
} break;
case chat_completion_sources.OPENROUTER: {
const imageUrl = data?.choices[0]?.message?.images?.filter(x => x.type === 'image_url')?.map(x => x?.image_url?.url);
if (Array.isArray(imageUrl) && imageUrl.length > 0) {
return imageUrl.filter(isDataURL);
}
// TODO: Handle remote URLs
}
}
} break;
}
return [];
}
/**
* parseAndSaveLogprobs receives the full data response for a non-streaming
* generation, parses logprobs for all tokens in the message, and saves them
* to the currently active message.
* @param {object} data - response data containing all tokens/logprobs
* @param {string} continueFrom - for 'continue' generations, the prompt
* */
function parseAndSaveLogprobs(data, continueFrom) {
/** @type {import('./scripts/logprobs.js').TokenLogprobs[] | null} */
let logprobs = null;
switch (main_api) {
case 'novel':
// parser only handles one token/logprob pair at a time
logprobs = data.logprobs?.map(parseNovelAILogprobs) || null;
break;
case 'openai':
// OAI and other chat completion APIs must handle this earlier in
// `sendOpenAIRequest`. `data` for these APIs is just a string with
// the text of the generated message, logprobs are not included.
return;
case 'textgenerationwebui':
switch (textgen_settings.type) {
case textgen_types.LLAMACPP: {
logprobs = data?.completion_probabilities?.map(x => parseTextgenLogprobs(x.content, [x])) || null;
} break;
case textgen_types.KOBOLDCPP:
case textgen_types.VLLM:
case textgen_types.INFERMATICAI:
case textgen_types.APHRODITE:
case textgen_types.MANCER:
case textgen_types.TABBY: {
logprobs = parseTabbyLogprobs(data) || null;
} break;
} break;
default:
return;
}
saveLogprobsForActiveMessage(logprobs, continueFrom);
}
/**
* Extracts the message from the response data.
* @param {object} data Response data
* @param {string} activeApi If it's set, ignores active API
* @returns {string} Extracted message
*/
export function extractMessageFromData(data, activeApi = null) {
function getResult() {
if (typeof data === 'string') {
return data;
}
switch (activeApi ?? main_api) {
case 'kobold':
return data.results[0].text;
case 'koboldhorde':
return data.text;
case 'textgenerationwebui':
return data.choices?.[0]?.text ?? data.choices?.[0]?.message?.content ?? data.content ?? data.response ?? data[0]?.content ?? '';
case 'novel':
return data.output;
case 'openai':
return data?.content?.find(p => p.type === 'text')?.text ?? data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? data?.text ?? data?.message?.content?.[0]?.text ?? data?.message?.tool_plan ?? '';
default:
return '';
}
}
const result = getResult();
return Array.isArray(result) ? result.map(x => x.text).filter(x => x).join('') : result;
}
/**
* Extracts JSON from the response data.
* @param {object} data Response data
* @returns {string} Extracted JSON string from the response data
*/
export function extractJsonFromData(data, { mainApi = null, chatCompletionSource = null } = {}) {
mainApi = mainApi ?? main_api;
chatCompletionSource = chatCompletionSource ?? oai_settings.chat_completion_source;
const tryParse = (/** @type {string} */ value) => {
try {
return JSON.parse(value);
} catch (e) {
console.debug('Failed to parse content as JSON.', e);
}
};
let result = {};
switch (mainApi) {
case 'openai': {
const text = extractMessageFromData(data, mainApi);
switch (chatCompletionSource) {
case chat_completion_sources.CLAUDE:
result = data?.content?.find(x => x.type === 'tool_use')?.input;
break;
case chat_completion_sources.PERPLEXITY:
result = tryParse(removeReasoningFromString(text));
break;
case chat_completion_sources.VERTEXAI:
case chat_completion_sources.MAKERSUITE:
case chat_completion_sources.DEEPSEEK:
case chat_completion_sources.AI21:
case chat_completion_sources.GROQ:
case chat_completion_sources.POLLINATIONS:
case chat_completion_sources.AIMLAPI:
case chat_completion_sources.OPENAI:
case chat_completion_sources.OPENROUTER:
case chat_completion_sources.MISTRALAI:
case chat_completion_sources.CUSTOM:
case chat_completion_sources.COHERE:
case chat_completion_sources.XAI:
case chat_completion_sources.ELECTRONHUB:
case chat_completion_sources.CHUTES:
case chat_completion_sources.AZURE_OPENAI:
case chat_completion_sources.ZAI:
default:
result = tryParse(text);
break;
}
} break;
}
return JSON.stringify(result ?? {});
}
/**
* Extracts multiswipe swipes from the response data.
* @param {Object} data Response data
* @param {string} type Type of generation
* @returns {string[]} Array of extra swipes
*/
function extractMultiSwipes(data, type) {
const swipes = [];
if (!data) {
return swipes;
}
if (type === 'continue' || type === 'impersonate' || type === 'quiet') {
return swipes;
}
if (main_api === 'textgenerationwebui' && textgen_settings.type === textgen_types.LLAMACPP) {
if (!Array.isArray(data)) {
return swipes;
}
const multiSwipeCount = data.length - 1;
if (multiSwipeCount <= 0) {
return swipes;
}
for (let i = 1; i < data.length; i++) {
const text = data?.[i]?.content ?? '';
swipes.push(text);
}
}
if (main_api === 'openai' || (main_api === 'textgenerationwebui' && [textgen_types.MANCER, textgen_types.VLLM, textgen_types.APHRODITE, textgen_types.TABBY, textgen_types.INFERMATICAI].includes(textgen_settings.type))) {
if (!Array.isArray(data.choices)) {
return swipes;
}
const multiSwipeCount = data.choices.length - 1;
if (multiSwipeCount <= 0) {
return swipes;
}
for (let i = 1; i < data.choices.length; i++) {
const text = data?.choices[i]?.message?.content ?? data?.choices[i]?.text ?? '';
swipes.push(text);
}
}
const cleanedSwipes = swipes.map(text => cleanUpMessage({
getMessage: text,
isImpersonate: false,
isContinue: false,
displayIncompleteSentences: false,
}));
return cleanedSwipes;
}
/**
* Formats a message according to user settings
* @param {object} [options] - Additional options.
* @param {string} [options.getMessage] The message to clean up
* @param {boolean} [options.isImpersonate] Whether this is an impersonated message
* @param {boolean} [options.isContinue] Whether this is a continued message
* @param {boolean} [options.displayIncompleteSentences] Whether to keep incomplete sentences at the end.
* @param {array} [options.stoppingStrings] Array of stopping strings.
* @param {boolean} [options.includeUserPromptBias] Whether to permit prepending the user prompt bias at the beginning.
* @param {boolean} [options.trimNames] Whether to allow trimming "{{char}}:" or "{{user}}:" from the beginning.
* @param {boolean} [options.trimWrongNames] Whether to allow deleting responses prefixed by the incorrect name, depending on isImpersonate
*
* @returns {string} The formatted message
*/
export function cleanUpMessage({ getMessage, isImpersonate, isContinue, displayIncompleteSentences = false, stoppingStrings = null, includeUserPromptBias = true, trimNames = true, trimWrongNames = true } = {}) {
if (arguments.length > 0 && typeof arguments[0] !== 'object') {
console.trace('cleanUpMessage called with positional arguments. Please use an object instead.');
[getMessage, isImpersonate, isContinue, displayIncompleteSentences, stoppingStrings, includeUserPromptBias, trimNames, trimWrongNames] = arguments;
}
if (!getMessage) {
return '';
}
// Add the prompt bias before anything else
if (
includeUserPromptBias &&
power_user.user_prompt_bias &&
!isImpersonate &&
!isContinue &&
power_user.user_prompt_bias.length !== 0
) {
getMessage = substituteParams(power_user.user_prompt_bias) + getMessage;
}
// Allow for caching of stopping strings. getStoppingStrings is an expensive function, especially with macros
// enabled, so for streaming, we call it once and then pass it into each cleanUpMessage call.
if (!stoppingStrings) {
stoppingStrings = getStoppingStrings(isImpersonate, isContinue);
}
for (const stoppingString of stoppingStrings) {
if (stoppingString.length) {
for (let j = stoppingString.length; j > 0; j--) {
if (getMessage.slice(-j) === stoppingString.slice(0, j)) {
getMessage = getMessage.slice(0, -j);
break;
}
}
}
}
// Regex uses vars, so add before formatting
getMessage = getRegexedString(getMessage, isImpersonate ? regex_placement.USER_INPUT : regex_placement.AI_OUTPUT);
if (power_user.collapse_newlines) {
getMessage = collapseNewlines(getMessage);
}
// trailing invisible whitespace before every newlines, on a multiline string
// "trailing whitespace on newlines \nevery line of the string \n?sample text" ->
// "trailing whitespace on newlines\nevery line of the string\nsample text"
getMessage = getMessage.replace(/[^\S\r\n]+$/gm, '');
if (trimWrongNames) {
// If this is an impersonation, delete the entire response if it starts with "{{char}}:"
// If this isn't an impersonation, delete the entire response if it starts with "{{user}}:"
// Also delete any trailing text that starts with the wrong name.
// This only occurs if the corresponding "power_user.allow_nameX_display" is false.
let wrongName = isImpersonate
? (!power_user.allow_name2_display ? name2 : '') // char
: (!power_user.allow_name1_display ? name1 : ''); // user
if (wrongName) {
// If the message starts with the wrong name, delete the entire response
let startIndex = getMessage.indexOf(`${wrongName}:`);
if (startIndex === 0) {
getMessage = '';
console.debug(`Message started with the wrong name: "${wrongName}" - response was deleted.`);
}
// If there is trailing text starting with the wrong name, trim it off.
startIndex = getMessage.indexOf(`\n${wrongName}:`);
if (startIndex >= 0) {
getMessage = getMessage.substring(0, startIndex);
}
}
}
if (getMessage.indexOf('<|endoftext|>') != -1) {
getMessage = getMessage.substring(0, getMessage.indexOf('<|endoftext|>'));
}
const isInstruct = power_user.instruct.enabled && main_api !== 'openai';
const isNotEmpty = (str) => str && str.trim() !== '';
if (isInstruct && power_user.instruct.stop_sequence) {
if (getMessage.indexOf(power_user.instruct.stop_sequence) != -1) {
getMessage = getMessage.substring(0, getMessage.indexOf(power_user.instruct.stop_sequence));
}
}
// Hana: Only use the first sequence (should be <|model|>)
// of the prompt before <|user|> (as KoboldAI Lite does it).
if (isInstruct && isNotEmpty(power_user.instruct.input_sequence)) {
if (getMessage.indexOf(power_user.instruct.input_sequence) != -1) {
getMessage = getMessage.substring(0, getMessage.indexOf(power_user.instruct.input_sequence));
}
}
// Remove instruct sequences leaking to the output
if (isInstruct && power_user.instruct.sequences_as_stop_strings) {
const sequences = [
{ value: power_user.instruct.input_sequence, apply: isImpersonate && isNotEmpty(power_user.instruct.input_sequence) },
{ value: power_user.instruct.output_sequence, apply: !isImpersonate && isNotEmpty(power_user.instruct.output_sequence) },
{ value: power_user.instruct.last_output_sequence, apply: !isImpersonate && isNotEmpty(power_user.instruct.last_output_sequence) },
];
for (const seq of sequences.filter(s => s.apply)) {
seq.value.split('\n').filter(line => line.trim() !== '').forEach(line => { getMessage = getMessage.replaceAll(line, ''); });
}
}
// clean-up group message from excessive generations
if (selected_group) {
getMessage = cleanGroupMessage(getMessage);
}
if (!power_user.allow_name2_display) {
const name2Escaped = escapeRegex(name2);
getMessage = getMessage.replace(new RegExp(`(^|\n)${name2Escaped}:\\s*`, 'g'), '$1');
}
if (isImpersonate) {
getMessage = getMessage.trim();
}
if (power_user.auto_fix_generated_markdown) {
getMessage = fixMarkdown(getMessage, false);
}
if (trimNames) {
// If this is an impersonation, trim "{{user}}:" from the beginning
// If this isn't an impersonation, trim "{{char}}:" from the beginning.
// Only applied when the corresponding "power_user.allow_nameX_display" is false.
const nameToTrim2 = isImpersonate
? (!power_user.allow_name1_display ? name1 : '') // user
: (!power_user.allow_name2_display ? name2 : ''); // char
if (nameToTrim2 && getMessage.startsWith(nameToTrim2 + ':')) {
getMessage = getMessage.replace(nameToTrim2 + ':', '');
getMessage = getMessage.trimStart();
}
}
if (isImpersonate) {
getMessage = getMessage.trim();
}
if (!displayIncompleteSentences && power_user.trim_sentences) {
getMessage = trimToEndSentence(getMessage);
}
if (power_user.trim_spaces && !PromptReasoning.getLatestPrefix()) {
getMessage = getMessage.trim();
}
return getMessage;
}
/**
* Adds an image to the message.
* @param {object} message Message object
* @param {object} sources Image sources
* @param {string[]} [sources.imageUrls] Image URLs
*
* @returns {Promise<void>}
*/
async function processImageAttachment(message, { imageUrls }) {
if (!Array.isArray(imageUrls) || imageUrls.length === 0) {
return;
}
for (const [index, imageUrl] of imageUrls.filter(onlyUnique).entries()) {
if (!imageUrl) {
continue;
}
let url = imageUrl;
if (isDataURL(url)) {
const fileName = `inline_image_${Date.now().toString()}_${index}`;
const [mime, base64] = /^data:(.*?);base64,(.*)$/.exec(imageUrl).slice(1);
url = await saveBase64AsFile(base64, message.name, fileName, mime.split('/')[1]);
}
saveImageToMessage({ image: url, inline: true }, message);
}
}
/**
* Saves a resulting message to the chat.
* @param {SaveReplyParams} params
* @returns {Promise<SaveReplyResult>} Promise when the message is saved
*
* @typedef {object} SaveReplyParams
* @property {string} type Type of generation
* @property {string} getMessage Generated message
* @property {boolean} [fromStreaming] If the message is from streaming
* @property {string} [title] Message tooltip
* @property {string[]} [swipes] Extra swipes
* @property {string} [reasoning] Message reasoning
* @property {string[]} [imageUrls] Links to images
* @property {string?} [reasoningSignature] Encrypted signature of the reasoning text
*
* @typedef {object} SaveReplyResult
* @property {string} type Type of generation
* @property {string} getMessage Generated message
*/
export async function saveReply({ type, getMessage, fromStreaming = false, title = '', swipes = [], reasoning = '', imageUrls = [], reasoningSignature = null }) {
// Backward compatibility
if (arguments.length > 1 && typeof arguments[0] !== 'object') {
console.trace('saveReply called with positional arguments. Please use an object instead.');
[type, getMessage, fromStreaming, title, swipes, reasoning, imageUrls, reasoningSignature] = arguments;
}
if (type != 'append' && type != 'continue' && type != 'appendFinal' && chat.length && (chat[chat.length - 1]['swipe_id'] === undefined ||
chat[chat.length - 1]['is_user'])) {
type = 'normal';
}
if (chat.length && (!chat[chat.length - 1]['extra'] || typeof chat[chat.length - 1]['extra'] !== 'object')) {
chat[chat.length - 1]['extra'] = {};
}
// Coerce null/undefined to empty string
if (chat.length && !chat[chat.length - 1]['extra']['reasoning']) {
chat[chat.length - 1]['extra']['reasoning'] = '';
}
if (!reasoning) {
reasoning = '';
}
let oldMessage = '';
const generationFinished = new Date();
if (type === 'swipe') {
oldMessage = chat[chat.length - 1]['mes'];
chat[chat.length - 1]['swipes'].length++;
if (chat[chat.length - 1]['swipe_id'] === chat[chat.length - 1]['swipes'].length - 1) {
chat[chat.length - 1]['title'] = title;
chat[chat.length - 1]['mes'] = getMessage;
chat[chat.length - 1]['gen_started'] = generation_started;
chat[chat.length - 1]['gen_finished'] = generationFinished;
chat[chat.length - 1]['send_date'] = getMessageTimeStamp();
chat[chat.length - 1]['extra']['api'] = getGeneratingApi();
chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
chat[chat.length - 1]['extra']['reasoning'] = reasoning;
chat[chat.length - 1]['extra']['reasoning_duration'] = null;
chat[chat.length - 1]['extra']['reasoning_signature'] = reasoningSignature;
await processImageAttachment(chat[chat.length - 1], { imageUrls });
if (power_user.message_token_count_enabled) {
const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];
chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
}
const chat_id = (chat.length - 1);
!fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
addOneMessage(chat[chat_id], { type: 'swipe' });
!fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);
} else {
chat[chat.length - 1]['mes'] = getMessage;
}
} else if (type === 'append' || type === 'continue') {
console.debug('Trying to append.');
oldMessage = chat[chat.length - 1]['mes'];
chat[chat.length - 1]['title'] = title;
chat[chat.length - 1]['mes'] += getMessage;
chat[chat.length - 1]['gen_started'] = generation_started;
chat[chat.length - 1]['gen_finished'] = generationFinished;
chat[chat.length - 1]['send_date'] = getMessageTimeStamp();
chat[chat.length - 1]['extra']['api'] = getGeneratingApi();
chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
chat[chat.length - 1]['extra']['reasoning'] = reasoning;
chat[chat.length - 1]['extra']['reasoning_duration'] = null;
chat[chat.length - 1]['extra']['reasoning_signature'] = reasoningSignature;
await processImageAttachment(chat[chat.length - 1], { imageUrls });
if (power_user.message_token_count_enabled) {
const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];
chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
}
const chat_id = (chat.length - 1);
!fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
addOneMessage(chat[chat_id], { type: 'swipe' });
!fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);
} else if (type === 'appendFinal') {
oldMessage = chat[chat.length - 1]['mes'];
console.debug('Trying to appendFinal.');
chat[chat.length - 1]['title'] = title;
chat[chat.length - 1]['mes'] = getMessage;
chat[chat.length - 1]['gen_started'] = generation_started;
chat[chat.length - 1]['gen_finished'] = generationFinished;
chat[chat.length - 1]['send_date'] = getMessageTimeStamp();
chat[chat.length - 1]['extra']['api'] = getGeneratingApi();
chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
chat[chat.length - 1]['extra']['reasoning'] += reasoning;
chat[chat.length - 1]['extra']['reasoning_signature'] = reasoningSignature;
await processImageAttachment(chat[chat.length - 1], { imageUrls });
// We don't know if the reasoning duration extended, so we don't update it here on purpose.
if (power_user.message_token_count_enabled) {
const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];
chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
}
const chat_id = (chat.length - 1);
!fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
addOneMessage(chat[chat_id], { type: 'swipe' });
!fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);
} else {
console.debug('entering chat update routine for non-swipe post');
chat[chat.length] = {};
chat[chat.length - 1]['extra'] = {};
chat[chat.length - 1]['name'] = name2;
chat[chat.length - 1]['is_user'] = false;
chat[chat.length - 1]['send_date'] = getMessageTimeStamp();
chat[chat.length - 1]['extra']['api'] = getGeneratingApi();
chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
chat[chat.length - 1]['extra']['reasoning'] = reasoning;
chat[chat.length - 1]['extra']['reasoning_duration'] = null;
chat[chat.length - 1]['extra']['reasoning_signature'] = reasoningSignature;
if (power_user.trim_spaces) {
getMessage = getMessage.trim();
}
chat[chat.length - 1]['mes'] = getMessage;
chat[chat.length - 1]['title'] = title;
chat[chat.length - 1]['gen_started'] = generation_started;
chat[chat.length - 1]['gen_finished'] = generationFinished;
if (power_user.message_token_count_enabled) {
const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];
chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
}
if (selected_group) {
console.debug('entering chat update for groups');
let avatarImg = 'img/ai4.png';
if (characters[this_chid].avatar != 'none') {
avatarImg = getThumbnailUrl('avatar', characters[this_chid].avatar);
}
chat[chat.length - 1]['force_avatar'] = avatarImg;
chat[chat.length - 1]['original_avatar'] = characters[this_chid].avatar;
chat[chat.length - 1]['extra']['gen_id'] = group_generation_id;
}
await processImageAttachment(chat[chat.length - 1], { imageUrls });
const chat_id = (chat.length - 1);
!fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
addOneMessage(chat[chat_id]);
!fromStreaming && await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, type);
}
const item = chat[chat.length - 1];
if (item['swipe_info'] === undefined) {
item['swipe_info'] = [];
}
if (item['swipe_id'] !== undefined) {
const swipeId = item['swipe_id'];
item['swipes'][swipeId] = item['mes'];
item['swipe_info'][swipeId] = {
send_date: item['send_date'],
gen_started: item['gen_started'],
gen_finished: item['gen_finished'],
extra: structuredClone(item['extra']),
};
} else {
item['swipe_id'] = 0;
item['swipes'] = [];
item['swipes'][0] = chat[chat.length - 1]['mes'];
item['swipe_info'][0] = {
send_date: chat[chat.length - 1]['send_date'],
gen_started: chat[chat.length - 1]['gen_started'],
gen_finished: chat[chat.length - 1]['gen_finished'],
extra: structuredClone(chat[chat.length - 1]['extra']),
};
}
if (Array.isArray(swipes) && swipes.length > 0) {
const swipeInfoExtra = structuredClone(item.extra ?? {});
delete swipeInfoExtra.token_count;
delete swipeInfoExtra.reasoning;
delete swipeInfoExtra.reasoning_duration;
const swipeInfo = {
send_date: item.send_date,
gen_started: item.gen_started,
gen_finished: item.gen_finished,
extra: swipeInfoExtra,
};
const swipeInfoArray = Array(swipes.length).fill().map(() => structuredClone(swipeInfo));
parseReasoningInSwipes(swipes, swipeInfoArray, item.extra?.reasoning_duration);
item.swipes.push(...swipes);
item.swipe_info.push(...swipeInfoArray);
}
statMesProcess(chat[chat.length - 1], type, characters, this_chid, oldMessage);
return { type, getMessage };
}
/**
* Creates a message's `swipes`, `swipe_id` and `swipe_info` if necessary.
* @param {ChatMessage} message
* @returns {boolean} true if the message was updated.
*/
export function ensureSwipes(message) {
let updated = false;
if (!message || typeof message !== 'object') {
console.trace(`[ensureSwipes] failed. '${message}' is not an object.`);
return updated;
}
//Small system messages and user messages should not have swipes.
if (message?.is_user || message?.extra?.isSmallSys) {
return updated;
}
if (!Array.isArray(message.swipes)) {
message.swipes = [message.mes ?? ''];
updated = true;
}
if (typeof message.swipe_id !== 'number') {
message.swipe_id = 0;
updated = true;
}
/** @type {() => SwipeInfo} */
const createSwipeInfo = () => ({
send_date: message.send_date,
gen_started: message.gen_started,
gen_finished: message.gen_finished,
extra: {},
});
if (!Array.isArray(message.swipe_info)) {
message.swipe_info = message.swipes.map(_ => createSwipeInfo());
updated = true;
}
for (let i = 0; i < message.swipes.length; i++) {
if (typeof message.swipes[i] !== 'string') {
updated = true;
console.warn('The message had a swipe that is not a string. It has has been set to \'\'.', message);
message.swipes[i] = '';
}
if (!message.swipe_info[i] || typeof message.swipe_info[i] !== 'object') {
updated = true;
console.warn('The message had missing or invalid swipe_info for a swipe. It has been backfilled.', message);
message.swipe_info[i] = createSwipeInfo();
}
}
return updated;
}
/**
* Syncs the current message and all its data into the swipe data at the given message ID (or the last message if no ID is given).
*
* If the swipe data is invalid in some way, this function will exit out without doing anything.
* @param {number?} [messageId=null] - The ID of the message to sync with the swipe data. If no ID is given, the last message is used.
* @returns {boolean} Whether the message was successfully synced
*/
export function syncMesToSwipe(messageId = null) {
if (!chat.length) {
return false;
}
const targetMessageId = messageId ?? chat.length - 1;
if (targetMessageId >= chat.length || targetMessageId < 0) {
console.warn(`[syncMesToSwipe] Invalid message ID: ${messageId}`);
return false;
}
const targetMessage = chat[targetMessageId];
if (!targetMessage) {
return false;
}
// No swipe data there yet, exit out
if (typeof targetMessage.swipe_id !== 'number') {
return false;
}
// If swipes structure is invalid, exit out (for now?)
if (!Array.isArray(targetMessage.swipe_info) || !Array.isArray(targetMessage.swipes)) {
return false;
}
// If the swipe is not present yet, exit out (will likely be copied later)
// "" is falsy. An empty string is a valid message.
if (typeof targetMessage.swipes[targetMessage.swipe_id] !== 'string' || !targetMessage.swipe_info[targetMessage.swipe_id]) {
return false;
}
const targetSwipeInfo = targetMessage.swipe_info[targetMessage.swipe_id];
if (typeof targetSwipeInfo !== 'object') {
return false;
}
targetMessage.swipes[targetMessage.swipe_id] = targetMessage.mes;
targetSwipeInfo.send_date = targetMessage.send_date;
targetSwipeInfo.gen_started = targetMessage.gen_started;
targetSwipeInfo.gen_finished = targetMessage.gen_finished;
targetSwipeInfo.extra = structuredClone(targetMessage.extra);
return true;
}
/**
* Syncs swipe data back to the message data at the given message ID (or the last message if no ID is given).
* If the swipe ID is not provided, the current swipe ID in the message object is used.
*
* If the swipe data is invalid in some way, this function will exit out without doing anything.
* @param {number?} [messageId=null] - The ID of the message to sync with the swipe data. If no ID is given, the last message is used.
* @param {number?} [swipeId=null] - The ID of the swipe to sync. If no ID is given, the current swipe ID in the message object is used.
* @returns {boolean} Whether the swipe data was successfully synced to the message
*/
export function syncSwipeToMes(messageId = null, swipeId = null) {
if (!chat.length) {
return false;
}
const targetMessageId = messageId ?? chat.length - 1;
if (targetMessageId >= chat.length || targetMessageId < 0) {
console.warn(`[syncSwipeToMes] Invalid message ID: ${messageId}`);
return false;
}
const targetMessage = chat[targetMessageId];
if (!targetMessage) {
return false;
}
if (swipeId !== null) {
if (isNaN(swipeId) || swipeId < 0) {
console.warn(`[syncSwipeToMes] Invalid swipe ID: ${swipeId}`);
return false;
}
targetMessage.swipe_id = swipeId;
}
// No swipe data there yet, exit out
if (typeof targetMessage.swipe_id !== 'number') {
return false;
}
// If swipes structure is invalid, exit out
if (!Array.isArray(targetMessage.swipes)) {
return false;
}
// Backfill swipe_info if missing.
if (!Array.isArray(targetMessage.swipe_info)) {
targetMessage.swipe_info = targetMessage.swipes.map(_ => ({
send_date: targetMessage.send_date,
gen_started: void 0,
gen_finished: void 0,
extra: {},
}));
}
const targetSwipeId = targetMessage.swipe_id;
if (typeof targetMessage.swipes[targetSwipeId] !== 'string') {
console.warn(`[syncSwipeToMes] Invalid swipe ID: ${targetSwipeId}`);
return false;
}
const targetSwipeInfo = targetMessage?.swipe_info?.[targetSwipeId];
if (typeof targetSwipeInfo !== 'object') {
console.warn(`[syncSwipeToMes] Invalid swipe info: ${targetSwipeId}`);
}
targetMessage.mes = targetMessage.swipes[targetSwipeId];
targetMessage.send_date = targetSwipeInfo?.send_date;
targetMessage.gen_started = targetSwipeInfo?.gen_started;
targetMessage.gen_finished = targetSwipeInfo?.gen_finished;
targetMessage.extra = structuredClone(targetSwipeInfo?.extra) ?? {};
return true;
}
/**
* Saves the image to the message object.
* @param {ParsedImage} img Image object
* @param {ChatMessage} mes Chat message object
* @typedef {{ image?: string, title?: string, inline?: boolean }} ParsedImage
*/
function saveImageToMessage(img, mes) {
if (mes && img.image) {
if (!mes.extra || typeof mes.extra !== 'object') {
mes.extra = {};
}
if (!Array.isArray(mes.extra.media)) {
mes.extra.media = [];
}
mes.extra.media.push({ url: img.image, type: MEDIA_TYPE.IMAGE, title: img.title, source: MEDIA_SOURCE.API });
mes.extra.inline_image = img.inline;
}
}
export function getGeneratingApi() {
switch (main_api) {
case 'openai':
return oai_settings.chat_completion_source || 'openai';
case 'textgenerationwebui':
return textgen_settings.type === textgen_types.OOBA ? 'textgenerationwebui' : textgen_settings.type;
default:
return main_api;
}
}
export function getGeneratingModel(mes) {
let model = '';
switch (main_api) {
case 'kobold':
model = online_status;
break;
case 'novel':
model = nai_settings.model_novel;
break;
case 'openai':
model = getChatCompletionModel();
break;
case 'textgenerationwebui':
model = online_status;
break;
case 'koboldhorde':
model = kobold_horde_model;
break;
}
return model;
}
/**
* A function mainly used to switch 'generating' state - setting it to false and activating the buttons again
*/
export function activateSendButtons() {
is_send_press = false;
hideStopButton();
showSwipeButtons();
delete document.body.dataset.generating;
}
/**
* A function mainly used to switch 'generating' state - setting it to true and deactivating the buttons
*/
export function deactivateSendButtons() {
showStopButton();
hideSwipeButtons();
document.body.dataset.generating = 'true';
}
export function resetChatState() {
// replaces deleted charcter name with system user since it will be displayed next.
name2 = (this_chid === undefined && neutralCharacterName) ? neutralCharacterName : systemUserName;
//unsets expected chid before reloading (related to getCharacters/printCharacters from using old arrays)
setCharacterId(undefined);
// sets up system user to tell user about having deleted a character
chat.splice(0, chat.length, ...SAFETY_CHAT);
// resets chat metadata
chat_metadata = {};
// resets the characters array, forcing getcharacters to reset
characters.length = 0;
}
/**
*
* @param {'characters' | 'character_edit' | 'create' | 'group_edit' | 'group_create'} value
*/
export function setMenuType(value) {
menu_type = value;
// Allow custom CSS to see which menu type is active
document.getElementById('right-nav-panel').dataset.menuType = menu_type;
}
export function setExternalAbortController(controller) {
abortController = controller;
}
/**
* Sets a character array index.
* @param {number|string|undefined} value
*/
export function setCharacterId(value) {
switch (typeof value) {
case 'bigint':
case 'number':
this_chid = String(value);
break;
case 'string':
this_chid = !isNaN(parseInt(value)) ? value : undefined;
break;
case 'object':
this_chid = characters.indexOf(value) !== -1 ? String(characters.indexOf(value)) : undefined;
break;
case 'undefined':
this_chid = undefined;
break;
default:
console.error('Invalid character ID type:', value);
break;
}
}
export function setCharacterName(value) {
name2 = value;
}
/**
* Sets the API connection status of the application
* @param {string|'no_connection'} value Connection status value
*/
export function setOnlineStatus(value) {
const previousStatus = online_status;
online_status = value;
displayOnlineStatus();
if (previousStatus !== online_status) {
eventSource.emitAndWait(event_types.ONLINE_STATUS_CHANGED, online_status);
}
}
export function setEditedMessageId(value) {
this_edit_mes_id = value;
}
export function setSendButtonState(value) {
is_send_press = value;
}
/**
* Renames the currently selected character, updating relevant references and optionally renaming past chats.
*
* If no name is provided, a popup prompts for a new name. If the new name matches the current name,
* the renaming process is aborted. The function sends a request to the server to rename the character
* and handles updates to other related fields such as tags, lore, and author notes.
*
* If the renaming is successful, the character list is reloaded and the renamed character is selected.
* Optionally, past chats can be renamed to reflect the new character name.
*
* @param {string?} [name=null] - The new name for the character. If not provided, a popup will prompt for it.
* @param {object} [options] - Additional options.
* @param {boolean} [options.silent=false] - If true, suppresses popups and warnings.
* @param {boolean?} [options.renameChats=null] - If true, renames past chats to reflect the new character name.
* @returns {Promise<boolean>} - Returns true if the character was successfully renamed, false otherwise.
*/
export async function renameCharacter(name = null, { silent = false, renameChats = null } = {}) {
if (!name && silent) {
toastr.warning(t`No character name provided.`, t`Rename Character`);
return false;
}
if (this_chid === undefined) {
toastr.warning(t`No character selected.`, t`Rename Character`);
return false;
}
const oldAvatar = characters[this_chid].avatar;
const newValue = name || await callGenericPopup('<h3>' + t`New name:` + '</h3>', POPUP_TYPE.INPUT, characters[this_chid].name);
if (!newValue) {
toastr.warning(t`No character name provided.`, t`Rename Character`);
return false;
}
if (newValue === characters[this_chid].name) {
toastr.info(t`Same character name provided, so name did not change.`, t`Rename Character`);
return false;
}
const body = JSON.stringify({ avatar_url: oldAvatar, new_name: newValue });
const response = await fetch('/api/characters/rename', {
method: 'POST',
headers: getRequestHeaders(),
body,
});
try {
if (response.ok) {
const data = await response.json();
const newAvatar = data.avatar;
const oldName = getCharaFilename(null, { manualAvatarKey: oldAvatar });
const newName = getCharaFilename(null, { manualAvatarKey: newAvatar });
// Replace other auxiliary fields where was referenced by avatar key
// Tag List
renameTagKey(oldAvatar, newAvatar);
// Additional lore books
const charLore = world_info.charLore?.find(x => x.name == oldName);
if (charLore) {
charLore.name = newName;
saveSettingsDebounced();
}
// Char-bound Author's Notes
const charNote = extension_settings.note.chara?.find(x => x.name == oldName);
if (charNote) {
charNote.name = newName;
saveSettingsDebounced();
}
// Update active character, if the current one was the currently active one
if (active_character === oldAvatar) {
active_character = newAvatar;
saveSettingsDebounced();
}
await eventSource.emit(event_types.CHARACTER_RENAMED, oldAvatar, newAvatar);
// Unload current character
setCharacterId(undefined);
// Reload characters list
await getCharacters();
// Find newly renamed character
const newChId = characters.findIndex(c => c.avatar == data.avatar);
if (newChId !== -1) {
// Select the character after the renaming
await selectCharacterById(newChId);
// Async delay to update UI
await delay(1);
if (this_chid === undefined) {
throw new Error('New character not selected');
}
// Also rename as a group member
await renameGroupMember(oldAvatar, newAvatar, newValue.toString());
const renamePastChatsConfirm = renameChats !== null
? renameChats
: silent
? false
: await Popup.show.confirm(
t`Character renamed!`,
`<p>${t`Past chats will still contain the old character name. Would you like to update the character name in previous chats as well?`}</p>
<i><b>${t`Sprites folder (if any) should be renamed manually.`}</b></i>`,
) == POPUP_RESULT.AFFIRMATIVE;
if (renamePastChatsConfirm) {
await renamePastChats(oldAvatar, newAvatar, newValue);
await reloadCurrentChat();
toastr.success(t`Character renamed and past chats updated!`, t`Rename Character`);
} else {
toastr.success(t`Character renamed!`, t`Rename Character`);
}
}
else {
throw new Error('Newly renamed character was lost?');
}
}
else {
throw new Error('Could not rename the character');
}
}
catch (error) {
// Reloading to prevent data corruption
if (!silent) await Popup.show.text(t`Rename Character`, t`Something went wrong. The page will be reloaded.`);
else toastr.error(t`Something went wrong. The page will be reloaded.`, t`Rename Character`);
console.log('Renaming character error:', error);
location.reload();
return false;
}
return true;
}
async function renamePastChats(oldAvatar, newAvatar, newName) {
const pastChats = await getPastCharacterChats();
for (const { file_name } of pastChats) {
try {
const fileNameWithoutExtension = file_name.replace('.jsonl', '');
const getChatResponse = await fetch('/api/chats/get', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
ch_name: newName,
file_name: fileNameWithoutExtension,
avatar_url: newAvatar,
}),
cache: 'no-cache',
});
if (getChatResponse.ok) {
const currentChat = await getChatResponse.json();
for (const message of currentChat) {
if (message.is_user || message.is_system || message.extra?.type == system_message_types.NARRATOR) {
continue;
}
if (message.name !== undefined) {
message.name = newName;
}
}
await eventSource.emit(event_types.CHARACTER_RENAMED_IN_PAST_CHAT, currentChat, oldAvatar, newAvatar);
const saveChatResponse = await fetch('/api/chats/save', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
ch_name: newName,
file_name: fileNameWithoutExtension,
chat: currentChat,
avatar_url: newAvatar,
}),
cache: 'no-cache',
});
if (!saveChatResponse.ok) {
throw new Error('Could not save chat');
}
}
} catch (error) {
toastr.error(t`Past chat could not be updated: ${file_name}`);
console.error(error);
}
}
}
export function saveChatDebounced() {
const chid = this_chid;
const selectedGroup = selected_group;
cancelDebouncedChatSave();
chatSaveTimeout = setTimeout(async () => {
if (selectedGroup !== selected_group) {
console.warn('Chat save timeout triggered, but group changed. Aborting.');
return;
}
if (chid !== this_chid) {
console.warn('Chat save timeout triggered, but chid changed. Aborting.');
return;
}
console.debug('Chat save timeout triggered');
await saveChatConditional();
console.debug('Chat saved');
}, DEFAULT_SAVE_EDIT_TIMEOUT);
}
/**
* Saves the chat to the server.
* @param {object} [options] - Additional options.
* @param {string} [options.chatName] The name of the chat file to save to
* @param {object} [options.withMetadata] Additional metadata to save with the chat
* @param {number} [options.mesId] The message ID to save the chat up to
* @param {boolean} [options.force] Force the saving despite the integrity check result
*
* @returns {Promise<void>}
*/
export async function saveChat({ chatName, withMetadata, mesId, force = false } = {}) {
if (selected_group) {
toastr.error(t`Operation was aborted to prevent data corruption.`, t`saveChat called for a group chat`);
throw new Error('saveChat called for a group chat');
}
if (arguments.length > 0 && typeof arguments[0] !== 'object') {
console.trace('saveChat called with positional arguments. Please use an object instead.');
[chatName, withMetadata, mesId, force] = arguments;
}
const metadata = { ...chat_metadata, ...(withMetadata || {}) };
const fileName = chatName ?? characters[this_chid]?.chat;
if (!fileName && name2 === neutralCharacterName) {
// TODO: Do something for a temporary chat with no character.
return;
}
if (!fileName) {
console.warn('saveChat called without chat_name and no chat file found');
return;
}
characters[this_chid]['date_last_chat'] = Date.now();
const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)
? chat.slice(0, Number(mesId) + 1)
: chat.slice();
/** @type {ChatHeader} */
const chatHeader = {
chat_metadata: metadata,
user_name: 'unused',
character_name: 'unused',
};
try {
const result = await fetch('/api/chats/save', {
method: 'POST',
cache: 'no-cache',
headers: getRequestHeaders(),
body: JSON.stringify({
ch_name: characters[this_chid].name,
file_name: fileName,
chat: [chatHeader, ...trimmedChat],
avatar_url: characters[this_chid].avatar,
force: force,
}),
});
if (result.ok) {
return;
}
const errorData = await result.json();
const isIntegrityError = errorData?.error === 'integrity' && !force;
if (!isIntegrityError) {
throw new Error(result.statusText);
}
const popupResult = await Popup.show.input(
t`ERROR: Chat integrity check failed while saving the file.`,
t`<p>After you click OK, the page will be reloaded to prevent data corruption.</p>
<p>To confirm an overwrite (and potentially <b>LOSE YOUR DATA</b>), enter <code>OVERWRITE</code> (in all caps) in the box below before clicking OK.</p>`,
'',
{ okButton: 'OK', cancelButton: false },
);
const forceSaveConfirmed = popupResult === 'OVERWRITE';
if (!forceSaveConfirmed) {
console.warn('Chat integrity check failed, and user did not confirm the overwrite. Reloading the page.');
window.location.reload();
return;
}
await saveChat({ chatName, withMetadata, mesId, force: true });
} catch (error) {
console.error(error);
toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Chat could not be saved`);
}
}
/**
* Processes the avatar image from the input element, allowing the user to crop it if necessary.
* @param {HTMLInputElement} input - The input element containing the avatar file.
* @returns {Promise<void>}
*/
async function read_avatar_load(input) {
if (input.files && input.files[0]) {
if (selected_button == 'create') {
create_save.avatar = input.files;
}
crop_data = undefined;
const file = input.files[0];
const fileData = await getBase64Async(file);
if (!power_user.never_resize_avatars) {
const dlg = new Popup('Set the crop position of the avatar image', POPUP_TYPE.CROP, '', { cropImage: fileData });
const croppedImage = await dlg.show();
if (!croppedImage) {
return;
}
crop_data = dlg.cropData;
$('#avatar_load_preview').attr('src', String(croppedImage));
} else {
$('#avatar_load_preview').attr('src', fileData);
}
if (menu_type == 'create') {
return;
}
await createOrEditCharacter();
await delay(DEFAULT_SAVE_EDIT_TIMEOUT);
const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));
await fetch(getThumbnailUrl('avatar', formData.get('avatar_url').toString()), {
method: 'GET',
cache: 'reload',
});
const messages = $('.mes').toArray();
for (const el of messages) {
const $el = $(el);
const nameMatch = $el.attr('ch_name') == formData.get('ch_name');
if ($el.attr('is_system') == 'true' && !nameMatch) continue;
if ($el.attr('is_user') == 'true') continue;
if (nameMatch) {
const previewSrc = $('#avatar_load_preview').attr('src');
const avatar = $el.find('.avatar img');
avatar.attr('src', default_avatar);
await delay(1);
avatar.attr('src', previewSrc);
}
}
console.log('Avatar refreshed');
}
}
/**
* Gets the URL for a thumbnail of a specific type and file.
* @param {import('../src/endpoints/thumbnails.js').ThumbnailType} type The type of the thumbnail to get
* @param {string} file The file name or path for which to get the thumbnail URL
* @param {boolean} [t=false] Whether to add a cache-busting timestamp to the URL
* @returns {string} The URL for the thumbnail
*/
export function getThumbnailUrl(type, file, t = false) {
return `/thumbnail?type=${type}&file=${encodeURIComponent(file)}${t ? `&t=${Date.now()}` : ''}`;
}
export function buildAvatarList(block, entities, { templateId = 'inline_avatar_template', empty = true, interactable = false, highlightFavs = true } = {}) {
if (empty) {
block.empty();
}
for (const entity of entities) {
const id = entity.id;
// Populate the template
const avatarTemplate = $(`#${templateId} .avatar`).clone();
let this_avatar = default_avatar;
if (entity.item.avatar !== undefined && entity.item.avatar != 'none') {
this_avatar = getThumbnailUrl('avatar', entity.item.avatar);
}
avatarTemplate.attr('data-type', entity.type);
avatarTemplate.attr('data-chid', id);
avatarTemplate.find('img').attr('src', this_avatar).attr('alt', entity.item.name);
avatarTemplate.attr('title', `[Character] ${entity.item.name}\nFile: ${entity.item.avatar}`);
if (highlightFavs) {
avatarTemplate.toggleClass('is_fav', entity.item.fav || entity.item.fav == 'true');
avatarTemplate.find('.ch_fav').val(entity.item.fav);
}
// If this is a group, we need to hack slightly. We still want to keep most of the css classes and layout, but use a group avatar instead.
if (entity.type === 'group') {
const grpTemplate = getGroupAvatar(entity.item);
avatarTemplate.addClass(grpTemplate.attr('class'));
avatarTemplate.empty();
avatarTemplate.append(grpTemplate.children());
avatarTemplate.attr({ 'data-grid': id, 'data-chid': null });
avatarTemplate.attr('title', `[Group] ${entity.item.name}`);
}
else if (entity.type === 'persona') {
avatarTemplate.attr({ 'data-pid': id, 'data-chid': null });
avatarTemplate.find('img').attr('src', getThumbnailUrl('persona', entity.item.avatar));
avatarTemplate.attr('title', `[Persona] ${entity.item.name}\nFile: ${entity.item.avatar}`);
}
if (interactable) {
avatarTemplate.addClass(INTERACTABLE_CONTROL_CLASS);
avatarTemplate.toggleClass('character_select', entity.type === 'character');
avatarTemplate.toggleClass('group_select', entity.type === 'group');
}
block.append(avatarTemplate);
}
}
/**
* Loads all the data of a shallow character.
* @param {string|undefined} characterId Array index
* @returns {Promise<void>} Promise that resolves when the character is unshallowed
*/
export async function unshallowCharacter(characterId) {
if (characterId === undefined) {
console.debug('Undefined character cannot be unshallowed');
return;
}
/** @type {Character} */
const character = characters[characterId];
if (!character) {
console.debug('Character not found:', characterId);
return;
}
// Character is not shallow
if (!character.shallow) {
return;
}
const avatar = character.avatar;
if (!avatar) {
console.debug('Character has no avatar field:', characterId);
return;
}
await getOneCharacter(avatar);
}
export async function getChat() {
//console.log('/api/chats/get -- entered for -- ' + characters[this_chid].name);
try {
await unshallowCharacter(this_chid);
const response = await $.ajax({
type: 'POST',
url: '/api/chats/get',
data: JSON.stringify({
ch_name: characters[this_chid].name,
file_name: characters[this_chid].chat,
avatar_url: characters[this_chid].avatar,
}),
dataType: 'json',
contentType: 'application/json',
});
if (response[0] !== undefined) {
chat.splice(0, chat.length, ...response);
chat_metadata = chat[0]['chat_metadata'] ?? {};
chat.shift();
chat.forEach(ensureMessageMediaIsArray);
}
if (!chat_metadata['integrity']) {
chat_metadata['integrity'] = uuidv4();
}
await getChatResult();
eventSource.emit('chatLoaded', { detail: { id: this_chid, character: characters[this_chid] } });
// Focus on the textarea if not already focused on a visible text input
setTimeout(function () {
if ($(document.activeElement).is('input:visible, textarea:visible')) {
return;
}
$('#send_textarea').trigger('click').trigger('focus');
}, 200);
} catch (error) {
await getChatResult();
console.log(error);
}
}
async function getChatResult() {
name2 = characters[this_chid].name;
let freshChat = false;
if (chat.length === 0) {
const message = getFirstMessage();
if (message.mes) {
chat.push(message);
freshChat = true;
}
// Make sure the chat appears on the server
await saveChatConditional();
}
await loadItemizedPrompts(getCurrentChatId());
await printMessages();
select_selected_character(this_chid);
await eventSource.emit(event_types.CHAT_CHANGED, (getCurrentChatId()));
if (freshChat) await eventSource.emit(event_types.CHAT_CREATED);
if (chat.length === 1) {
const chat_id = (chat.length - 1);
await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, 'first_message');
await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, chat_id, 'first_message');
}
}
function getFirstMessage() {
const firstMes = characters[this_chid]?.first_mes || '';
const alternateGreetings = characters[this_chid]?.data?.alternate_greetings;
const message = {
name: name2,
is_user: false,
is_system: false,
send_date: getMessageTimeStamp(),
mes: getRegexedString(firstMes, regex_placement.AI_OUTPUT),
extra: {},
};
if (Array.isArray(alternateGreetings) && alternateGreetings.length > 0) {
const swipes = [message.mes, ...(alternateGreetings.map(greeting => getRegexedString(greeting, regex_placement.AI_OUTPUT)))];
if (!message.mes) {
swipes.shift();
message.mes = swipes[0];
}
message['swipe_id'] = 0;
message['swipes'] = swipes;
message['swipe_info'] = swipes.map(_ => ({
send_date: message.send_date,
gen_started: void 0,
gen_finished: void 0,
extra: {},
}));
}
return message;
}
export async function openCharacterChat(file_name) {
await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10);
await clearChat();
characters[this_chid]['chat'] = file_name;
chat.length = 0;
chat_metadata = {};
await getChat();
$('#selected_chat_pole').val(file_name);
await createOrEditCharacter(new CustomEvent('newChat'));
}
////////// OPTIMZED MAIN API CHANGE FUNCTION ////////////
export function changeMainAPI(api = null) {
const selectedVal = api ?? $('#main_api').val();
//console.log(selectedVal);
const apiElements = {
'koboldhorde': {
apiStreaming: $('#NULL_SELECTOR'),
apiSettings: $('#kobold_api-settings'),
apiConnector: $('#kobold_horde'),
apiPresets: $('#kobold_api-presets'),
apiRanges: $('#range_block'),
maxContextElem: $('#max_context_block'),
amountGenElem: $('#amount_gen_block'),
},
'kobold': {
apiStreaming: $('#streaming_kobold_block'),
apiSettings: $('#kobold_api-settings'),
apiConnector: $('#kobold_api'),
apiPresets: $('#kobold_api-presets'),
apiRanges: $('#range_block'),
maxContextElem: $('#max_context_block'),
amountGenElem: $('#amount_gen_block'),
},
'textgenerationwebui': {
apiStreaming: $('#streaming_textgenerationwebui_block'),
apiSettings: $('#textgenerationwebui_api-settings'),
apiConnector: $('#textgenerationwebui_api'),
apiPresets: $('#textgenerationwebui_api-presets'),
apiRanges: $('#range_block_textgenerationwebui'),
maxContextElem: $('#max_context_block'),
amountGenElem: $('#amount_gen_block'),
},
'novel': {
apiStreaming: $('#streaming_novel_block'),
apiSettings: $('#novel_api-settings'),
apiConnector: $('#novel_api'),
apiPresets: $('#novel_api-presets'),
apiRanges: $('#range_block_novel'),
maxContextElem: $('#max_context_block'),
amountGenElem: $('#amount_gen_block'),
},
'openai': {
apiStreaming: $('#NULL_SELECTOR'),
apiSettings: $('#openai_settings'),
apiConnector: $('#openai_api'),
apiPresets: $('#openai_api-presets'),
apiRanges: $('#range_block_openai'),
maxContextElem: $('#max_context_block'),
amountGenElem: $('#amount_gen_block'),
},
};
//console.log('--- apiElements--- ');
//console.log(apiElements);
//first, disable everything so the old elements stop showing
for (const apiName in apiElements) {
const apiObj = apiElements[apiName];
//do not hide items to then proceed to immediately show them.
if (selectedVal === apiName) {
continue;
}
apiObj.apiSettings.css('display', 'none');
apiObj.apiConnector.css('display', 'none');
apiObj.apiRanges.css('display', 'none');
apiObj.apiPresets.css('display', 'none');
apiObj.apiStreaming.css('display', 'none');
}
//then, find and enable the active item.
//This is split out of the loop so that different apis can share settings divs
let activeItem = apiElements[selectedVal];
activeItem.apiStreaming.css('display', 'block');
activeItem.apiSettings.css('display', 'block');
activeItem.apiConnector.css('display', 'block');
activeItem.apiRanges.css('display', 'block');
activeItem.apiPresets.css('display', 'block');
if (selectedVal === 'openai') {
activeItem.apiPresets.css('display', 'flex');
}
if (selectedVal === 'textgenerationwebui' || selectedVal === 'novel') {
console.debug('enabling amount_gen for ooba/novel');
activeItem.amountGenElem.find('input').prop('disabled', false);
activeItem.amountGenElem.css('opacity', 1.0);
}
//custom because streaming has been moved up under response tokens, which exists inside common settings block
if (selectedVal === 'novel') {
$('#ai_module_block_novel').css('display', 'block');
} else {
$('#ai_module_block_novel').css('display', 'none');
}
$('#prompt_cost_block').toggle(selectedVal === 'textgenerationwebui' && textgen_settings.type === textgen_types.OPENROUTER);
// Hide common settings for OpenAI
console.debug('value?', selectedVal);
if (selectedVal == 'openai') {
console.debug('hiding settings?');
$('#common-gen-settings-block').css('display', 'none');
} else {
$('#common-gen-settings-block').css('display', 'block');
}
main_api = selectedVal;
setOnlineStatus('no_connection');
if (main_api == 'koboldhorde') {
getStatusHorde();
getHordeModels(true);
}
validateDisabledSamplers();
setupChatCompletionPromptManager(oai_settings);
forceCharacterEditorTokenize();
}
export function setUserName(value, { toastPersonaNameChange = true } = {}) {
name1 = value;
if (name1 === undefined || name1 == '')
name1 = default_user_name;
console.log(`User name changed to ${name1}`);
$('#your_name').text(name1);
if (toastPersonaNameChange && power_user.persona_show_notifications && !isPersonaPanelOpen()) {
toastr.success(t`Your messages will now be sent as ${name1}`, t`Persona Changed`);
}
saveSettingsDebounced();
}
async function doOnboarding(avatarId) {
const template = $('#onboarding_template .onboarding');
let userName = await callGenericPopup(template, POPUP_TYPE.INPUT, currentUser?.name || name1, { wider: true, cancelButton: false });
if (userName) {
userName = String(userName).replace('\n', ' ');
setUserName(userName);
console.log(`Binding persona ${avatarId} to name ${userName}`);
power_user.personas[avatarId] = userName;
power_user.persona_descriptions[avatarId] = {
description: '',
position: persona_description_positions.IN_PROMPT,
};
}
}
function reloadLoop() {
const MAX_RELOADS = 5;
let reloads = Number(sessionStorage.getItem('reloads') || 0);
if (reloads < MAX_RELOADS) {
reloads++;
sessionStorage.setItem('reloads', String(reloads));
window.location.reload();
}
}
//MARK: getSettings()
///////////////////////////////////////////
export async function getSettings() {
const response = await fetch('/api/settings/get', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({}),
cache: 'no-cache',
});
if (!response.ok) {
reloadLoop();
toastr.error(t`Settings could not be loaded after multiple attempts. Please try again later.`);
throw new Error('Error getting settings');
}
const data = await response.json();
if (data.result != 'file not find' && data.settings) {
settings = JSON.parse(data.settings);
if (settings.username !== undefined && settings.username !== '') {
name1 = settings.username;
$('#your_name').text(name1);
}
accountStorage.init(settings?.accountStorage);
await setUserControls(data.enable_accounts);
// Allow subscribers to mutate settings
await eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings);
//Load AI model config settings
amount_gen = settings.amount_gen;
if (settings.max_context !== undefined)
max_context = parseInt(settings.max_context);
swipes = settings.swipes !== undefined ? !!settings.swipes : true; // enable swipes by default
$('#swipes-checkbox').prop('checked', swipes); /// swipecode
refreshSwipeButtons();
// Kobold
loadKoboldSettings(data, settings.kai_settings ?? settings, settings);
// Novel
loadNovelSettings(data, settings.nai_settings ?? settings);
// TextGen
await loadTextGenSettings(data, settings);
// OpenAI
loadOpenAISettings(data, settings.oai_settings ?? settings);
// Horde
loadHordeSettings(settings);
// Load power user settings
await loadPowerUserSettings(settings, data);
// Apply theme toggles from power user settings
applyPowerUserSettings();
// Load character tags
loadTagsSettings(settings);
// Load background
loadBackgroundSettings(settings);
// Load proxy presets
loadProxyPresets(settings);
// Allow subscribers to mutate settings
await eventSource.emit(event_types.SETTINGS_LOADED_AFTER, settings);
// Set context size after loading power user (may override the max value)
$('#max_context').val(max_context);
$('#max_context_counter').val(max_context);
$('#amount_gen').val(amount_gen);
$('#amount_gen_counter').val(amount_gen);
//Load which API we are using
if (settings.main_api == undefined) {
settings.main_api = 'kobold';
}
if (settings.main_api == 'poe') {
settings.main_api = 'openai';
}
main_api = settings.main_api;
$('#main_api').val(main_api);
$(`#main_api option[value=${main_api}]`).attr('selected', 'true');
changeMainAPI();
//Load User's Name and Avatar
initUserAvatar(settings.user_avatar);
setPersonaDescription();
//Load the active character and group
active_character = settings.active_character;
active_group = settings.active_group;
setWorldInfoSettings(settings.world_info_settings ?? settings, data);
selected_button = settings.selected_button;
if (data.enable_extensions) {
const enableAutoUpdate = Boolean(data.enable_extensions_auto_update);
const isVersionChanged = settings.currentVersion !== currentVersion;
await loadExtensionSettings(settings, isVersionChanged, enableAutoUpdate);
await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED);
}
firstRun = !!settings.firstRun;
if (firstRun) {
hideLoader();
await doOnboarding(user_avatar);
firstRun = false;
}
}
await validateDisabledSamplers();
settingsReady = true;
await eventSource.emit(event_types.SETTINGS_LOADED);
}
//MARK: saveSettings()
export async function saveSettings(loopCounter = 0) {
if (!settingsReady) {
console.warn('Settings not ready, scheduling another save');
saveSettingsDebounced();
return;
}
const MAX_RETRIES = 3;
if (TempResponseLength.isCustomized()) {
if (loopCounter < MAX_RETRIES) {
console.warn('Response length is currently being overridden, scheduling another save');
saveSettingsDebounced(++loopCounter);
return;
}
console.error('Response length is currently being overridden, but the save loop has reached the maximum number of retries');
TempResponseLength.restore(null);
}
const payload = {
firstRun: firstRun,
accountStorage: accountStorage.getState(),
currentVersion: currentVersion,
username: name1,
active_character: active_character,
active_group: active_group,
user_avatar: user_avatar,
amount_gen: amount_gen,
max_context: max_context,
main_api: main_api,
world_info_settings: getWorldInfoSettings(),
textgenerationwebui_settings: textgen_settings,
swipes: swipes,
horde_settings: horde_settings,
power_user: power_user,
extension_settings: extension_settings,
tags: tags,
tag_map: tag_map,
nai_settings: nai_settings,
kai_settings: kai_settings,
oai_settings: oai_settings,
background: background_settings,
proxies: proxies,
selected_proxy: selected_proxy,
};
try {
const result = await fetch('/api/settings/save', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(payload),
cache: 'no-cache',
});
if (!result.ok) {
throw new Error(`Failed to save settings: ${result.statusText}`);
}
settings = payload;
await eventSource.emit(event_types.SETTINGS_UPDATED);
} catch (error) {
console.error('Error saving settings:', error);
toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Settings could not be saved`);
}
}
/**
* Sets the generation parameters from a preset object.
* @param {{ genamt?: number, max_length?: number }} preset Preset object
*/
export function setGenerationParamsFromPreset(preset) {
const needsUnlock = (preset.max_length ?? max_context) > MAX_CONTEXT_DEFAULT || (preset.genamt ?? amount_gen) > MAX_RESPONSE_DEFAULT;
$('#max_context_unlocked').prop('checked', needsUnlock).trigger('change');
if (preset.genamt !== undefined) {
amount_gen = preset.genamt;
$('#amount_gen').val(amount_gen);
$('#amount_gen_counter').val(amount_gen);
}
if (preset.max_length !== undefined) {
max_context = preset.max_length;
$('#max_context').val(max_context);
$('#max_context_counter').val(max_context);
}
}
// Common code for message editor done and auto-save
function updateMessage(div) {
const mesBlock = div.closest('.mes_block');
let text = mesBlock.find('.edit_textarea').val()
?? mesBlock.find('.mes_text').text();
const mesElement = div.closest('.mes');
const mes = chat[mesElement.attr('mesid')];
// editing old messages
mes['extra'] ??= {};
let regexPlacement;
if (mes?.is_user) {
regexPlacement = regex_placement.USER_INPUT;
} else if (mes.extra?.type === 'narrator') {
regexPlacement = regex_placement.SLASH_COMMAND;
} else {
regexPlacement = regex_placement.AI_OUTPUT;
}
// Ignore character override if sent as system
text = getRegexedString(
text,
regexPlacement,
{
characterOverride: mes.extra?.type === 'narrator' ? undefined : mes.name,
isEdit: true,
},
);
if (power_user.trim_spaces) {
text = text.trim();
}
const bias = substituteParams(extractMessageBias(text));
text = substituteParams(text);
if (bias) {
text = removeMacros(text);
}
mes['mes'] = text;
if (mes['swipe_id'] !== undefined) {
ensureSwipes(mes);
mes['swipes'][mes['swipe_id']] = text;
}
if (mes?.is_system || mes?.is_user || mes.extra?.type === system_message_types.NARRATOR) {
mes.extra.bias = bias ?? null;
} else {
mes.extra.bias = null;
}
chat_metadata['tainted'] = true;
return { mesBlock, text, mes, bias };
}
function openMessageDelete(fromSlashCommand) {
closeMessageEditor();
hideSwipeButtons();
if (fromSlashCommand || (!is_send_press) || (selected_group && !is_group_generating)) {
$('#dialogue_del_mes').css('display', 'block');
$('#send_form').css('display', 'none');
$('.del_checkbox').each(function () {
$(this).css('display', 'grid');
$(this).parent().children('.for_checkbox').css('display', 'none');
});
} else {
console.debug(`
ERR -- could not enter del mode
this_chid: ${this_chid}
is_send_press: ${is_send_press}
selected_group: ${selected_group}
is_group_generating: ${is_group_generating}`);
}
this_del_mes = -1;
is_delete_mode = true;
}
function messageEditAuto(div) {
const { mesBlock, text, mes, bias } = updateMessage(div);
mesBlock.find('.mes_text').val('');
mesBlock.find('.mes_text').val(messageFormatting(
text,
this_edit_mes_chname,
mes.is_system,
mes.is_user,
this_edit_mes_id,
{},
false,
));
mesBlock.find('.mes_bias').empty();
mesBlock.find('.mes_bias').append(messageFormatting(bias, '', false, false, -1, {}, false));
saveChatDebounced();
}
/**
* Create the message edit UI.
* @param {number} editMessageId The ID of the message to edit
*/
export async function messageEdit(editMessageId) {
const editMessage = chat[editMessageId];
if (!editMessage) {
console.warn(`Message with id ${editMessageId} not found in chat array.`);
return;
}
const messageElement = chatElement.find(`.mes[mesid="${editMessageId}"]`);
if (messageElement.length === 0) {
console.warn(`Message element with id ${editMessageId} not found in DOM.`);
return;
}
this_edit_mes_id = editMessageId;
this_edit_mes_chname = editMessage.name || (editMessage.is_user ? name1 : name2);
refreshSwipeButtons();
const chatScrollPosition = chatElement.scrollTop();
const messageBlock = messageElement.find('.mes_block');
const messageText = messageBlock.find('.mes_text');
messageText.empty();
messageBlock.find('.mes_buttons').css('display', 'none');
messageBlock.find('.mes_edit_buttons').css('display', 'inline-flex');
// Also edit reasoning, if it exists
const reasoningEdit = messageBlock.find('.mes_reasoning_edit:visible');
if (reasoningEdit.length > 0) {
reasoningEdit.trigger('click');
}
const editTextArea = document.createElement('textarea');
editTextArea.id = 'curEditTextarea';
editTextArea.className = 'edit_textarea mdHotkeys';
messageText.append(editTextArea);
const text = trimSpaces(editMessage.mes || '');
const $editTextArea = $(editTextArea);
$editTextArea.val(text);
const cssAutofit = CSS.supports('field-sizing', 'content');
if (!cssAutofit) {
$editTextArea.height(0);
$editTextArea.height(editTextArea.scrollHeight);
}
$editTextArea.trigger('focus');
// Sets the cursor at the end of the text
editTextArea.setSelectionRange(text.length, text.length);
if (Number(this_edit_mes_id) === chat.length - 1) {
chatElement.scrollTop(chatScrollPosition);
}
updateEditArrowClasses();
}
/**
* Close the open message editor.
* This deletes the user's unsaved changes.
* @param {number} [messageId=this_edit_mes_id]
*/
async function messageEditCancel(messageId = this_edit_mes_id) {
let text = chat[messageId]['mes'];
let thisMesDiv;
// If this is the button then select it's parent. Otherwise, select by messageId.
if (this?.classList?.contains('mes_edit_cancel')) {
thisMesDiv = $(this).closest('.mes');
} else {
thisMesDiv = chatElement.children('.mes').filter(`[mesid="${messageId}"]`);
}
const thisMesBlock = thisMesDiv.find('.mes_block');
thisMesBlock.find('.mes_text').empty();
thisMesDiv.find('.mes_edit_buttons').css('display', 'none');
thisMesBlock.find('.mes_buttons').css('display', '');
thisMesBlock.find('.mes_text')
.append(messageFormatting(
text,
this_edit_mes_chname,
chat[messageId].is_system,
chat[messageId].is_user,
messageId,
{},
false,
));
appendMediaToMessage(chat[messageId], thisMesDiv);
addCopyToCodeBlocks(thisMesDiv);
const reasoningEditDone = thisMesBlock.find('.mes_reasoning_edit_cancel:visible');
if (reasoningEditDone.length > 0) {
reasoningEditDone.trigger('click');
}
await eventSource.emit(event_types.MESSAGE_UPDATED, messageId);
if (messageId == this_edit_mes_id) {
this_edit_mes_id = undefined;
}
else {
console.warn(`The message editor was closed on message #${messageId} while #${this_edit_mes_id} is being edited.`);
}
showSwipeButtons();
}
/**
* Swaps chat[sourceId] with chat[targetId]. They must be adjacent.
* @param {number} sourceId Index of the message to move
* @param {number} targetId Index of the target message
* @returns {Promise<boolean>} True if the messages were moved, false otherwise
*/
async function messageEditMove(sourceId, targetId) {
if (is_send_press) {
console.warn(`The message #${sourceId} was not moved to #${targetId} because a generation is in progress.`);
return false;
}
if (Math.abs(sourceId - targetId) !== 1) {
console.error(`Message #${sourceId} and #${targetId} are not adjacent.`);
return false;
}
const targetMessageDiv = chatElement.find(`.mes[mesid="${targetId}"]`);
const sourceMessageDiv = chatElement.find(`.mes[mesid="${sourceId}"]`);
if (sourceMessageDiv.length === 0 || targetMessageDiv.length === 0) {
console.error(`Message #${sourceId} or #${targetId} were not found.`);
return false;
}
if (sourceId <= targetId) {
sourceMessageDiv.insertAfter(targetMessageDiv);
}
else {
sourceMessageDiv.insertBefore(targetMessageDiv);
}
//Swap Ids.
targetMessageDiv.attr('mesid', sourceId);
sourceMessageDiv.attr('mesid', targetId);
// Swap chat array entries.
[chat[sourceId], chat[targetId]] = [chat[targetId], chat[sourceId]];
// Update edited message id
if (this_edit_mes_id === sourceId) {
this_edit_mes_id = targetId;
}
updateViewMessageIds();
refreshSwipeButtons();
await saveChatConditional();
return true;
}
async function messageEditDone(div) {
if (!(this_edit_mes_id >= 0)) {
console.trace('this_edit_mes_id cannot be blank when calling messageEditDone.');
return;
}
let { mesBlock, text, mes, bias } = updateMessage(div);
if (this_edit_mes_id == 0) {
text = substituteParams(text);
}
await eventSource.emit(event_types.MESSAGE_EDITED, this_edit_mes_id);
text = chat[this_edit_mes_id]?.mes ?? text;
mesBlock.find('.mes_text').empty();
mesBlock.find('.mes_edit_buttons').css('display', 'none');
mesBlock.find('.mes_buttons').css('display', '');
mesBlock.find('.mes_text').append(
messageFormatting(
text,
this_edit_mes_chname,
mes.is_system,
mes.is_user,
this_edit_mes_id,
{},
false,
),
);
mesBlock.find('.mes_bias').empty();
mesBlock.find('.mes_bias').append(messageFormatting(bias, '', false, false, -1, {}, false));
appendMediaToMessage(mes, div.closest('.mes'));
addCopyToCodeBlocks(div.closest('.mes'));
const reasoningEditDone = mesBlock.find('.mes_reasoning_edit_done:visible');
if (reasoningEditDone.length > 0) {
reasoningEditDone.trigger('click');
}
await eventSource.emit(event_types.MESSAGE_UPDATED, this_edit_mes_id);
this_edit_mes_id = undefined;
await saveChatConditional();
showSwipeButtons();
}
/**
* Fetches the chat content for each chat file from the server and compiles them into a dictionary.
* The function iterates over a provided list of chat metadata and requests the actual chat content
* for each chat, either as an individual chat or a group chat based on the context.
*
* @param {Array} data - An array containing metadata about each chat such as file_name.
* @param {boolean} isGroupChat - A flag indicating if the chat is a group chat.
* @returns {Promise<Object>} chat_dict - A dictionary where each key is a file_name and the value is the
* corresponding chat content fetched from the server.
*/
export async function getChatsFromFiles(data, isGroupChat) {
const context = getContext();
let chat_dict = {};
let chat_list = Object.values(data).sort((a, b) => a['file_name'].localeCompare(b['file_name'])).reverse();
let chat_promise = chat_list.map(({ file_name }) => {
return new Promise(async (res, rej) => {
try {
const endpoint = isGroupChat ? '/api/chats/group/get' : '/api/chats/get';
const requestBody = isGroupChat
? JSON.stringify({ id: file_name })
: JSON.stringify({
ch_name: characters[context.characterId].name,
file_name: file_name.replace('.jsonl', ''),
avatar_url: characters[context.characterId].avatar,
});
const chatResponse = await fetch(endpoint, {
method: 'POST',
headers: getRequestHeaders(),
body: requestBody,
cache: 'no-cache',
});
if (!chatResponse.ok) {
return res();
// continue;
}
const currentChat = await chatResponse.json();
if (!isGroupChat) {
// remove the first message, which is metadata, only for individual chats
currentChat.shift();
}
chat_dict[file_name] = currentChat;
} catch (error) {
console.error(error);
}
return res();
});
});
await Promise.all(chat_promise);
return chat_dict;
}
/**
* Fetches the metadata of all past chats related to a specific character based on its avatar URL.
* The function sends a POST request to the server to retrieve all chats for the character. It then
* processes the received data, sorts it by the file name, and returns the sorted data.
*
* @param {null|number} [characterId=null] - When set, the function will use this character id instead of this_chid.
*
* @returns {Promise<Array>} - An array containing metadata of all past chats of the character, sorted
* in descending order by file name. Returns an empty array if the fetch request is unsuccessful or the
* response is an object with an `error` property set to `true`.
*/
export async function getPastCharacterChats(characterId = null) {
characterId = characterId ?? parseInt(this_chid);
if (!characters[characterId]) return [];
const response = await fetch('/api/characters/chats', {
method: 'POST',
body: JSON.stringify({ avatar_url: characters[characterId].avatar }),
headers: getRequestHeaders(),
});
if (!response.ok) {
return [];
}
const data = await response.json();
if (typeof data === 'object' && data.error === true) {
return [];
}
const chats = Object.values(data);
return chats.sort((a, b) => a['file_name'].localeCompare(b['file_name'])).reverse();
}
/**
* Helper for `displayPastChats`, to make the same info consistently available for other functions
*/
export function getCurrentChatDetails() {
if (!characters[this_chid] && !selected_group) {
return { sessionName: '', group: null, characterName: '', avatarImgURL: '' };
}
const group = selected_group ? groups.find(x => x.id === selected_group) : null;
const currentChat = selected_group ? group?.chat_id : characters[this_chid]['chat'];
const displayName = selected_group ? group?.name : characters[this_chid].name;
const avatarImg = selected_group ? group?.avatar_url : getThumbnailUrl('avatar', characters[this_chid]['avatar']);
return { sessionName: currentChat, group: group, characterName: displayName, avatarImgURL: avatarImg };
}
/**
* Displays the past chats for a character or a group based on the selected context.
* The function first fetches the chats, processes them, and then displays them in
* the HTML. It also has a built-in search functionality that allows filtering the
* displayed chats based on a search query.
* @param {string[]} hightlightNames - An array of chat names to highlight
*/
export async function displayPastChats(hightlightNames = []) {
$('#select_chat_div').empty();
$('#select_chat_search').val('').off('input');
const chatDetails = getCurrentChatDetails();
const currentChat = chatDetails.sessionName;
const displayName = chatDetails.characterName;
const avatarImg = chatDetails.avatarImgURL;
await displayChats('', currentChat, displayName, avatarImg, selected_group, hightlightNames);
const debouncedDisplay = debounce((searchQuery) => {
displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, []);
});
// Define the search input listener
$('#select_chat_search').off('input').on('input', function () {
const searchQuery = $(this).val();
debouncedDisplay(searchQuery);
});
// UX convenience: Focus the search field when the Manage Chat Files view opens.
setTimeout(function () {
const textSearchElement = $('#select_chat_search');
textSearchElement.trigger('click').trigger('focus').trigger('select');
}, 200);
addChatBackupsBrowser();
}
async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) {
try {
const trimExtension = (fileName) => String(fileName).replace('.jsonl', '');
const response = await fetch('/api/chats/search', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
query: searchQuery,
avatar_url: selected_group ? null : characters[this_chid].avatar,
group_id: selected_group || null,
}),
});
if (!response.ok) {
throw new Error('Search failed');
}
const filteredData = await response.json();
$('#select_chat_div').empty();
filteredData.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)));
for (const chat of filteredData) {
const isSelected = trimExtension(currentChat) === trimExtension(chat.file_name);
const template = $('#past_chat_template .select_chat_block_wrapper').clone();
template.find('.select_chat_block').attr('file_name', chat.file_name);
template.find('.avatar img').attr('src', avatarImg);
template.find('.select_chat_block_filename').text(chat.file_name);
template.find('.chat_file_size').text(`(${chat.file_size},`);
template.find('.chat_messages_num').text(`${chat.message_count} 💬)`);
template.find('.select_chat_block_mes').text(chat.preview_message);
template.find('.PastChat_cross').attr('file_name', chat.file_name);
template.find('.chat_messages_date').text(timestampToMoment(chat.last_mes).format('lll'));
if (isSelected) {
template.find('.select_chat_block').attr('highlight', String(true));
}
$('#select_chat_div').append(template);
if (Array.isArray(highlightNames) && highlightNames.includes(chat.file_name)) {
const templateOffset = template.offset().top - template.parent().offset().top;
$('#select_chat_div').scrollTop(templateOffset);
flashHighlight(template, debounce_timeout.extended);
}
}
} catch (error) {
console.error('Error loading chats:', error);
toastr.error('Could not load chat data. Try reloading the page.');
}
}
export function selectRightMenuWithAnimation(selectedMenuId) {
const displayModes = {
'rm_group_chats_block': 'flex',
'rm_api_block': 'grid',
'rm_characters_block': 'flex',
};
$('#result_info').toggle(selectedMenuId === 'rm_ch_create_block');
document.querySelectorAll('#right-nav-panel .right_menu').forEach((menu) => {
$(menu).css('display', 'none');
if (selectedMenuId && selectedMenuId.replace('#', '') === menu.id) {
const mode = displayModes[menu.id] ?? 'block';
$(menu).css('display', mode);
$(menu).css('opacity', 0.0);
$(menu).transition({
opacity: 1.0,
duration: animation_duration,
easing: animation_easing,
complete: function () { },
});
}
});
}
export function select_rm_info(type, charId, previousCharId = null) {
if (!type) {
toastr.error(t`Invalid process (no 'type')`);
return;
}
if (type !== 'group_create') {
var displayName = String(charId).replace('.png', '');
}
if (type === 'char_delete') {
toastr.warning(t`Character Deleted: ${displayName}`);
}
if (type === 'char_create') {
toastr.success(t`Character Created: ${displayName}`);
}
if (type === 'group_create') {
toastr.success(t`Group Created`);
}
if (type === 'group_delete') {
toastr.warning(t`Group Deleted`);
}
if (type === 'char_import') {
toastr.success(t`Character Imported: ${displayName}`);
}
selectRightMenuWithAnimation('rm_characters_block');
// Set a timeout so multiple flashes don't overlap
clearTimeout(importFlashTimeout);
importFlashTimeout = setTimeout(function () {
if (type === 'char_import' || type === 'char_create' || type === 'char_import_no_toast') {
// Find the page at which the character is located
const avatarFileName = charId;
const charData = getEntitiesList({ doFilter: true });
const charIndex = charData.findIndex((x) => x?.item?.avatar?.startsWith(avatarFileName));
if (charIndex === -1) {
console.log(`Could not find character ${charId} in the list`);
return;
}
try {
const perPage = Number(accountStorage.getItem('Characters_PerPage')) || per_page_default;
const page = Math.floor(charIndex / perPage) + 1;
const selector = `#rm_print_characters_block [title*="${avatarFileName}"]`;
$('#rm_print_characters_pagination').pagination('go', page);
waitUntilCondition(() => document.querySelector(selector) !== null).then(() => {
const element = $(selector).parent();
if (element.length === 0) {
console.log(`Could not find element for character ${charId}`);
return;
}
const scrollOffset = element.offset().top - element.parent().offset().top;
element.parent().scrollTop(scrollOffset);
flashHighlight(element, 5000);
});
} catch (e) {
console.error(e);
}
}
if (type === 'group_create') {
// Find the page at which the character is located
const charData = getEntitiesList({ doFilter: true });
const charIndex = charData.findIndex((x) => String(x?.item?.id) === String(charId));
if (charIndex === -1) {
console.log(`Could not find group ${charId} in the list`);
return;
}
const perPage = Number(accountStorage.getItem('Characters_PerPage')) || per_page_default;
const page = Math.floor(charIndex / perPage) + 1;
$('#rm_print_characters_pagination').pagination('go', page);
const selector = `#rm_print_characters_block [grid="${charId}"]`;
try {
waitUntilCondition(() => document.querySelector(selector) !== null).then(() => {
const element = $(selector);
const scrollOffset = element.offset().top - element.parent().offset().top;
element.parent().scrollTop(scrollOffset);
flashHighlight(element, 5000);
});
} catch (e) {
console.error(e);
}
}
}, 250);
if (previousCharId) {
const newId = characters.findIndex((x) => x.avatar == previousCharId);
if (newId >= 0) {
setCharacterId(newId);
}
}
}
/**
* Selects the right menu for displaying the character editor.
* @param {string} chid Character array index
* @param {object} [param1] Options for the switch
* @param {boolean} [param1.switchMenu=true] Whether to switch the menu
*/
export function select_selected_character(chid, { switchMenu = true } = {}) {
//character select
//console.log('select_selected_character() -- starting with input of -- ' + chid + ' (name:' + characters[chid].name + ')');
select_rm_create({ switchMenu });
switchMenu && setMenuType('character_edit');
$('#delete_button').css('display', 'flex');
$('#export_button').css('display', 'flex');
//create text poles
$('#rm_button_back').css('display', 'none');
//$("#character_import_button").css("display", "none");
$('#create_button').attr('value', 'Save'); // what is the use case for this?
$('#dupe_button').show();
$('#create_button_label').css('display', 'none');
$('#char_connections_button').show();
// Hide the chat scenario button if we're peeking the group member defs
$('#set_chat_character_settings').toggle(!selected_group);
// Don't update the navbar name if we're peeking the group member defs
if (!selected_group) {
$('#rm_button_selected_ch').children('h2').text(characters[chid].name);
}
$('#add_avatar_button').val('');
$('#character_popup-button-h3').text(characters[chid].name);
$('#character_name_pole').val(characters[chid].name);
$('#description_textarea').val(characters[chid].description);
$('#character_world').val(characters[chid].data?.extensions?.world || '');
$('#creator_notes_textarea').val(characters[chid].data?.creator_notes || characters[chid].creatorcomment);
$('#creator_notes_spoiler').html(formatCreatorNotes(characters[chid].data?.creator_notes || characters[chid].creatorcomment, characters[chid].avatar));
$('#character_version_textarea').val(characters[chid].data?.character_version || '');
$('#system_prompt_textarea').val(characters[chid].data?.system_prompt || '');
$('#post_history_instructions_textarea').val(characters[chid].data?.post_history_instructions || '');
$('#tags_textarea').val(Array.isArray(characters[chid].data?.tags) ? characters[chid].data.tags.join(', ') : '');
$('#creator_textarea').val(characters[chid].data?.creator);
$('#character_version_textarea').val(characters[chid].data?.character_version || '');
$('#personality_textarea').val(characters[chid].personality);
$('#firstmessage_textarea').val(characters[chid].first_mes);
$('#scenario_pole').val(characters[chid].scenario);
$('#depth_prompt_prompt').val(characters[chid].data?.extensions?.depth_prompt?.prompt ?? '');
$('#depth_prompt_depth').val(characters[chid].data?.extensions?.depth_prompt?.depth ?? depth_prompt_depth_default);
$('#depth_prompt_role').val(characters[chid].data?.extensions?.depth_prompt?.role ?? depth_prompt_role_default);
$('#talkativeness_slider').val(characters[chid].talkativeness || talkativeness_default);
$('#mes_example_textarea').val(characters[chid].mes_example);
$('#selected_chat_pole').val(characters[chid].chat);
$('#create_date_pole').val(timestampToMoment(characters[chid].create_date).toISOString());
$('#avatar_url_pole').val(characters[chid].avatar);
$('#chat_import_avatar_url').val(characters[chid].avatar);
$('#chat_import_character_name').val(characters[chid].name);
$('#character_json_data').val(characters[chid].json_data);
updateFavButtonState(characters[chid].fav || characters[chid].fav == 'true');
const avatarUrl = characters[chid].avatar != 'none' ? getThumbnailUrl('avatar', characters[chid].avatar) : default_avatar;
$('#avatar_load_preview').attr('src', avatarUrl);
$('.open_alternate_greetings').data('chid', chid);
$('#set_character_world').data('chid', chid);
setWorldInfoButtonClass(chid);
checkEmbeddedWorld(chid);
$('#name_div').removeClass('displayBlock');
$('#name_div').addClass('displayNone');
$('#renameCharButton').css('display', '');
$('#form_create').attr('actiontype', 'editcharacter');
$('.form_create_bottom_buttons_block .chat_lorebook_button').show();
const externalMediaState = isExternalMediaAllowed();
$('#character_open_media_overrides').toggle(!selected_group);
$('#character_media_allowed_icon').toggle(externalMediaState);
$('#character_media_forbidden_icon').toggle(!externalMediaState);
// Update some stuff about the char management dropdown
$('#character_source').attr('disabled', !getCharacterSource(chid) ? '' : null);
eventSource.emit(event_types.CHARACTER_EDITOR_OPENED, chid);
saveSettingsDebounced();
}
/**
* Selects the right menu for creating a new character.
* @param {object} [options] Options for the switch
* @param {boolean} [options.switchMenu=true] Whether to switch the menu
*/
function select_rm_create({ switchMenu = true } = {}) {
switchMenu && setMenuType('create');
//console.log('select_rm_Create() -- selected button: '+selected_button);
if (selected_button == 'create' && create_save.avatar) {
const addAvatarInput = /** @type {HTMLInputElement} */ ($('#add_avatar_button').get(0));
addAvatarInput.files = create_save.avatar;
read_avatar_load(addAvatarInput);
}
switchMenu && selectRightMenuWithAnimation('rm_ch_create_block');
$('#set_chat_character_settings').hide();
$('#delete_button_div').css('display', 'none');
$('#delete_button').css('display', 'none');
$('#export_button').css('display', 'none');
$('#create_button_label').css('display', '');
$('#create_button').attr('value', 'Create');
$('#dupe_button').hide();
$('#char_connections_button').hide();
//create text poles
$('#rm_button_back').css('display', '');
$('#character_import_button').css('display', '');
$('#character_popup-button-h3').text('Create character');
$('#character_name_pole').val(create_save.name);
$('#description_textarea').val(create_save.description);
$('#character_world').val(create_save.world);
$('#creator_notes_textarea').val(create_save.creator_notes);
$('#creator_notes_spoiler').html(formatCreatorNotes(create_save.creator_notes, ''));
$('#post_history_instructions_textarea').val(create_save.post_history_instructions);
$('#system_prompt_textarea').val(create_save.system_prompt);
$('#tags_textarea').val(create_save.tags);
$('#creator_textarea').val(create_save.creator);
$('#character_version_textarea').val(create_save.character_version);
$('#personality_textarea').val(create_save.personality);
$('#firstmessage_textarea').val(create_save.first_message);
$('#talkativeness_slider').val(create_save.talkativeness);
$('#scenario_pole').val(create_save.scenario);
$('#depth_prompt_prompt').val(create_save.depth_prompt_prompt);
$('#depth_prompt_depth').val(create_save.depth_prompt_depth);
$('#depth_prompt_role').val(create_save.depth_prompt_role);
$('#mes_example_textarea').val(create_save.mes_example);
$('#character_json_data').val('');
$('#avatar_div').css('display', 'flex');
$('#avatar_load_preview').attr('src', default_avatar);
$('#renameCharButton').css('display', 'none');
$('#name_div').removeClass('displayNone');
$('#name_div').addClass('displayBlock');
$('.open_alternate_greetings').data('chid', -1);
$('#set_character_world').data('chid', -1);
setWorldInfoButtonClass(undefined, !!create_save.world);
updateFavButtonState(false);
checkEmbeddedWorld();
$('#form_create').attr('actiontype', 'createcharacter');
$('.form_create_bottom_buttons_block .chat_lorebook_button').hide();
$('#character_open_media_overrides').hide();
}
function select_rm_characters() {
const doFullRefresh = menu_type === 'characters';
setMenuType('characters');
selectRightMenuWithAnimation('rm_characters_block');
printCharacters(doFullRefresh);
}
/**
* Sets a prompt injection to insert custom text into any outgoing prompt. For use in UI extensions.
* @param {string} key Prompt injection id.
* @param {string} value Prompt injection value.
* @param {number} position Insertion position. 0 is after story string, 1 is in-chat with custom depth.
* @param {number} depth Insertion depth. 0 represets the last message in context. Expected values up to MAX_INJECTION_DEPTH.
* @param {number} role Extension prompt role. Defaults to SYSTEM.
* @param {boolean} scan Should the prompt be included in the world info scan.
* @param {(function(): Promise<boolean>|boolean)} filter Filter function to determine if the prompt should be injected.
*/
export function setExtensionPrompt(key, value, position, depth, scan = false, role = extension_prompt_roles.SYSTEM, filter = null) {
extension_prompts[key] = {
value: String(value),
position: Number(position),
depth: Number(depth),
scan: !!scan,
role: Number(role ?? extension_prompt_roles.SYSTEM),
filter: filter,
};
}
/**
* Gets a enum value of the extension prompt role by its name.
* @param {string} roleName The name of the extension prompt role.
* @returns {number} The role id of the extension prompt.
*/
export function getExtensionPromptRoleByName(roleName) {
// If the role is already a valid number, return it
if (typeof roleName === 'number' && Object.values(extension_prompt_roles).includes(roleName)) {
return roleName;
}
switch (roleName) {
case 'system':
return extension_prompt_roles.SYSTEM;
case 'user':
return extension_prompt_roles.USER;
case 'assistant':
return extension_prompt_roles.ASSISTANT;
}
// Skill issue?
return extension_prompt_roles.SYSTEM;
}
/**
* Removes all char A/N prompt injections from the chat.
* To clean up when switching from groups to solo and vice versa.
*/
export function removeDepthPrompts() {
for (const key of Object.keys(extension_prompts)) {
if (key.startsWith(inject_ids.DEPTH_PROMPT)) {
delete extension_prompts[key];
}
}
}
/**
* Adds or updates the metadata for the currently active chat.
* @param {Object} newValues An object with collection of new values to be added into the metadata.
* @param {boolean} reset Should a metadata be reset by this call.
*/
export function updateChatMetadata(newValues, reset) {
chat_metadata = reset ? { ...newValues } : { ...chat_metadata, ...newValues };
}
/**
* Updates the state of the favorite button based on the provided state.
* @param {boolean} state Whether the favorite button should be on or off.
*/
function updateFavButtonState(state) {
// Update global state of the flag
// TODO: This is bad and needs to be refactored.
fav_ch_checked = state;
$('#fav_checkbox').prop('checked', state);
$('#favorite_button').toggleClass('fav_on', state);
$('#favorite_button').toggleClass('fav_off', !state);
}
export async function setCharacterSettingsOverrides() {
if (!selected_group && (this_chid === undefined || !characters[this_chid])) {
console.warn('setCharacterSettingsOverrides() -- no selected group or character');
return;
}
const scenarioOverrideValue = chat_metadata['scenario'] || '';
const exampleMessagesValue = chat_metadata['mes_example'] || '';
const systemPromptValue = chat_metadata['system_prompt'] || '';
const isGroup = !!selected_group;
const $template = $(await renderTemplateAsync('scenarioOverride'));
$template.find('[data-group="true"]').toggle(isGroup);
$template.find('[data-character="true"]').toggle(!isGroup);
const pendingChanges = {
scenario: scenarioOverrideValue,
examples: exampleMessagesValue,
system_prompt: systemPromptValue,
};
// Keep edits local until the popup is closed/confirmed
const $scenario = $template.find('.chat_scenario');
$scenario.val(scenarioOverrideValue).on('input', function () {
pendingChanges.scenario = String($(this).val());
});
const $examples = $template.find('.chat_examples');
$examples.val(exampleMessagesValue).on('input', function () {
pendingChanges.examples = String($(this).val());
});
const $systemPrompt = $template.find('.chat_system_prompt');
$systemPrompt.val(systemPromptValue).on('input', function () {
pendingChanges.system_prompt = String($(this).val());
});
$template.find('.remove_scenario_override').on('click', async function () {
const confirm = await Popup.show.confirm(t`Are you sure you want to remove all overrides?`, t`This action cannot be undone.`);
if (!confirm) {
return;
}
$scenario.val('');
pendingChanges.scenario = '';
$examples.val('');
pendingChanges.examples = '';
$systemPrompt.val('');
pendingChanges.system_prompt = '';
});
// Wait for popup close/confirm.
await callGenericPopup($template, POPUP_TYPE.TEXT, '', {
wide: true,
large: true,
allowVerticalScrolling: true,
});
chat_metadata['scenario'] = pendingChanges.scenario;
chat_metadata['mes_example'] = pendingChanges.examples;
chat_metadata['system_prompt'] = pendingChanges.system_prompt;
await saveMetadata();
}
/**
* Displays a blocking popup with a given text and type.
* @param {JQuery<HTMLElement>|string|Element} text - Text to display in the popup.
* @param {string} type
* @param {string} inputValue - Value to set the input to.
* @param {PopupOptions} options - Options for the popup.
* @typedef {{okButton?: string, rows?: number, wide?: boolean, wider?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean, cropAspect?: number }} PopupOptions - Options for the popup.
* @returns {Promise<any>} A promise that resolves when the popup is closed.
* @deprecated Use `callGenericPopup` instead.
*/
export function callPopup(text, type, inputValue = '', { okButton, rows, wide, wider, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) {
function getOkButtonText() {
if (['text', 'char_not_selected'].includes(popup_type)) {
$dialoguePopupCancel.css('display', 'none');
return okButton ?? t`Ok`;
} else if (['delete_extension'].includes(popup_type)) {
return okButton ?? t`Ok`;
} else if (['new_chat', 'confirm'].includes(popup_type)) {
return okButton ?? t`Yes`;
} else if (['input'].includes(popup_type)) {
return okButton ?? t`Save`;
}
return okButton ?? t`Delete`;
}
dialogueCloseStop = true;
if (type) {
popup_type = type;
}
const $dialoguePopup = $('#dialogue_popup');
const $dialoguePopupCancel = $('#dialogue_popup_cancel');
const $dialoguePopupOk = $('#dialogue_popup_ok');
const $dialoguePopupInput = $('#dialogue_popup_input');
const $dialoguePopupText = $('#dialogue_popup_text');
const $shadowPopup = $('#shadow_popup');
$dialoguePopup.toggleClass('wide_dialogue_popup', !!wide)
.toggleClass('wider_dialogue_popup', !!wider)
.toggleClass('large_dialogue_popup', !!large)
.toggleClass('horizontal_scrolling_dialogue_popup', !!allowHorizontalScrolling)
.toggleClass('vertical_scrolling_dialogue_popup', !!allowVerticalScrolling);
$dialoguePopupCancel.css('display', 'inline-block');
$dialoguePopupOk.text(getOkButtonText());
$dialoguePopupInput.toggle(popup_type === 'input').val(inputValue).attr('rows', rows ?? 1);
$dialoguePopupText.empty().append(text);
$shadowPopup.css('display', 'block');
if (popup_type == 'input') {
$dialoguePopupInput.trigger('focus');
}
$shadowPopup.transition({
opacity: 1,
duration: animation_duration,
easing: animation_easing,
});
return new Promise((resolve) => {
dialogueResolve = resolve;
});
}
/**
* Update the swipe counter for mesId.
* By default, the swipe counter's opacity will appear greyed out. The opacity is changed with CSS.
* @param {Number} mesId
* @param {object} [options] Options
* @param {ChatMessage} [options.message=undefined] Swipe numbers from this message will be used instead of mesId.
* @param {JQuery<HTMLElement>} [options.messageElement=undefined] Target Element. Passing in the message's element will save a DOM query.
*/
export async function updateSwipeCounter(mesId, { message = undefined, messageElement = undefined } = {}) {
message ??= chat[mesId];
messageElement ??= chatElement.children('.mes').filter(`[mesid="${mesId}"]`);
//If the message does not have swipes, create them.
if (ensureSwipes(message)) {
syncMesToSwipe(mesId);
}
const swipeCounterText = formatSwipeCounter((message?.swipe_id + 1), message?.swipes?.length);
const swipeCounter = messageElement.find('.swipes-counter');
swipeCounter.text(swipeCounterText).prop('hidden', false);
}
/**
* Returns true if messages are generally swipeable.
* @returns {boolean}
*/
export function isSwipingAllowed() {
return (
//Swipe cannot be called on an empty chat.
chat.length !== 0 &&
//The swipes setting must be enabled, and swipes can't be hidden.
swipes && !swipesHidden &&
//Cannot swipe while generating.
!isGenerating() &&
//If mid-swipe, the message cannot be swiped.
swipeState === SWIPE_STATE.NONE
);
}
/**
* Returns true if the message is swipeable.
* This does not check if messages are generally swipeable. See isSwipingAllowed().
* This does not check if the swipes exist or are valid.
* @param {number} messageId The message Id to check.
* @param {ChatMessage} [message=undefined] If undefined, then the message checks will be skipped.
* @returns {boolean}
*/
export function isMessageSwipeable(messageId, message = undefined) {
message ??= chat[messageId];
//If the message does not have swipes, create them.
if (ensureSwipes(message)) {
syncMesToSwipe(messageId);
}
if (
//Only messages below the currently edited message can be swiped, if it's not mid-swipe edit.
((messageId > (this_edit_mes_id ?? -1)) && (swipeState != SWIPE_STATE.EDITING)) &&
//If the message is the last message, and it exists.
(messageId == chat.length - 1) &&
(message &&
//Small system messages cannot be swiped.
!(message?.extra?.isSmallSys) &&
//Some messages, like the welcome screen, are not swipeable.
!(message?.extra?.swipeable === false) &&
//User messages are not swipeable.
!message.is_user
)
)
//The message is swipeable.
{ return true; }
//The message is not swipeable.
else { return false; }
}
/**
* Returns the message's behavior when swiped past it's last branch.
* This does not check if the message can currently be swiped. See isMessageSwipeable().
* This does not check if messages are generally swipeable. See isSwipingAllowed().
* This does not check if the swipes exist or are valid.
* @param {number} messageId The message Id to check.
* @param {ChatMessage} [message=undefined] If defined, this will be used instead of chat[messageId].
* @returns {OVERSWIPE_BEHAVIOR}
*/
export function getOverswipeBehavior(messageId, message = undefined) {
message ??= chat[messageId];
const isPristine = !chat_metadata?.tainted;
const isGreeting = messageId === 0;
//Do not override explicitly set overswipe_behavior.
if (typeof message?.extra?.overswipe_behavior == 'string') return message.extra.overswipe_behavior;
//Some messages, like the welcome screen, are not swipeable.
else if (message?.extra?.swipeable === false) return OVERSWIPE_BEHAVIOR.NONE;
//Small System messages can't be swiped.
else if (message?.extra?.isSmallSys) return OVERSWIPE_BEHAVIOR.NONE;
//The first message in a priistine chat will loop. It's chevrons will always be visible https://github.com/SillyTavern/SillyTavern/pull/4712#issuecomment-3557893373
else if (isGreeting && isPristine) return OVERSWIPE_BEHAVIOR.PRISTINE_GREETING;
//Non-user and non-prompt hidden messages will regenerate.
else if (!message?.is_user && !message?.is_system) return OVERSWIPE_BEHAVIOR.REGENERATE;
//By default, all other messages will loop. Their swipe chevrons will only be shown if there is more than one swipe.
else { return OVERSWIPE_BEHAVIOR.LOOP; }
}
/**
* Refreshes all swipe buttons and updates their swipe counters.
* This has been optimized for bulk updates by minimizing DOM queries.
* @param {boolean} updateCounters When true, the swipe counters will also be updated. Typically redundant because addOneMessage updates the counters.
* @param {boolean} fade By default, the chevrons fade in and out.
* @returns
*/
export function refreshSwipeButtons(updateCounters = false, fade = true) {
//Never show swipe buttons on an empty chat.
if (chat?.length === 0) return false;
//If swipes are disabled or hidden, hide all swipe buttons.
if (!isSwipingAllowed()) {
$('body').addClass('hideAllSwipeButtons');
return;
//Don't hide all swipe buttons.
} else {
//CSS will hide all messages.
$('body').removeClass('hideAllSwipeButtons');
}
//Non-messages can appear in chat. '.mes' is required.
const messageElements = chatElement.children('.mes[mesid]');
const firstDisplayedMesId = Number(messageElements.first().attr('mesid'));
//Group each message.
messageElements.each((index, div) => {
//This assumes the messages are in order and their Id's are accurate.
const messageId = firstDisplayedMesId + index;
//Number($(div).attr('mesid')); Would not misscount due to a missing div, but is much slower.
const message = chat[messageId];
//Chevrons should not fade-in during printMessages. //https://github.com/SillyTavern/SillyTavern/pull/4712#issuecomment-3539315919
div.classList.toggle('fade', fade);
if (isMessageSwipeable(messageId, message)) {
//If a right swipe would trigger a generation or loop to the first swipe.
const isLastSwipe = (message?.swipes?.length ?? 1) - 1 <= (message?.swipe_id ?? 0);
const hasSwipes = (message?.swipes?.length > 1);
const overswipe = getOverswipeBehavior(messageId, message);
// Chevrons should always be shown on pristine greetings: https://github.com/SillyTavern/SillyTavern/pull/4712#issuecomment-3557893373
const pristineGreeting = overswipe == OVERSWIPE_BEHAVIOR.PRISTINE_GREETING;
//The swipe button will be shown if an overswipe would trigger REGENERATE or EDIT_GENERATE.
const isOverswipeable = isLastSwipe &&
overswipe == OVERSWIPE_BEHAVIOR.REGENERATE ||
overswipe == OVERSWIPE_BEHAVIOR.EDIT_GENERATE;
div.classList.toggle('last_swipe', isOverswipeable);
//If there's only one swipe, the left arrow should not be shown.
div.classList.toggle('swipes_visible', hasSwipes || pristineGreeting);
//updateSwipeCounter does not need to be awaited, It can run a bit later.
if (updateCounters) updateSwipeCounter(messageId, { message, messageElement: $(div) });
} else {
//Hide all messages that are not swipeable.
div.classList.remove('swipes_visible', 'last_swipe');
}
});
}
/**
* This function is misleadingly named. It allows generation then refreshes the swipe buttons and counters.
*/
export function showSwipeButtons() {
swipesHidden = false;
refreshSwipeButtons();
}
/**
* This function is misleadingly named. It blocks generation then refreshes the swipe buttons and counters.
* @param {object} [options] Options
* @param {boolean} [options.hideCounters=false] Also hide the swipes counter.
*/
export function hideSwipeButtons({ hideCounters = false } = {}) {
swipesHidden = true;
refreshSwipeButtons();
if (hideCounters === true) {
chatElement.find('.last_mes .swipes-counter').prop('hidden', true);
}
}
/**
* Deletes a swipe from the chat.
*
* @param {number?} [swipeId = null] - The ID of the swipe to delete. If not provided, the current swipe will be deleted.
* @param {number?} [messageId = chat.length - 1] - The ID of the message to delete from. If not provided, the last message will be targeted.
* @returns {Promise<number>|undefined} - The ID of the new swipe after deletion.
*/
export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
if (swipeId && (isNaN(swipeId) || swipeId < 0)) {
toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);
return;
}
const message = chat[messageId];
if (!message || !Array.isArray(message.swipes) || !message.swipes.length) {
toastr.warning(t`No messages to delete swipes from.`);
return;
}
if (message.swipes.length <= 1) {
toastr.warning(t`Can't delete the last swipe.`);
return;
}
swipeId = swipeId ?? message.swipe_id;
if (swipeId < 0 || swipeId >= message.swipes.length) {
toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);
return;
}
message.swipes.splice(swipeId, 1);
if (Array.isArray(message.swipe_info) && message.swipe_info.length) {
message.swipe_info.splice(swipeId, 1);
}
// Select the next swipe, or the one before if it was the last one
const newSwipeId = Math.min(swipeId, message.swipes.length - 1);
chat_metadata['tainted'] = true;
messageId = Number(messageId);
swipeId = Number(swipeId);
await eventSource.emit(event_types.MESSAGE_SWIPE_DELETED, { messageId, swipeId, newSwipeId });
let direction = (swipeId <= newSwipeId) ? SWIPE_DIRECTION.RIGHT : SWIPE_DIRECTION.LEFT;
//Animate swipe and swap dispayed message.
await swipe(null, direction, { source: SWIPE_SOURCE.DELETE, repeated: false, forceMesId: messageId, forceSwipeId: newSwipeId });
await saveChatConditional();
return newSwipeId;
}
export async function saveMetadata() {
return await saveChatConditional();
}
export async function saveChatConditional() {
try {
await waitUntilCondition(() => !isChatSaving, DEFAULT_SAVE_EDIT_TIMEOUT, 100);
} catch {
console.warn('Timeout waiting for chat to save');
return;
}
try {
cancelDebouncedChatSave();
isChatSaving = true;
if (selected_group) {
await saveGroupChat(selected_group, true);
}
else {
await saveChat();
}
// Save token and prompts cache to IndexedDB storage
saveTokenCache();
saveItemizedPrompts(getCurrentChatId());
} catch (error) {
console.error('Error saving chat', error);
} finally {
isChatSaving = false;
}
}
/**
* Saves the chat to the server.
* @param {FormData} formData Form data to send to the server.
* @param {object} [options={}] Options for the import
* @param {boolean} [options.refresh] Whether to refresh the group chat list after import
* @returns {Promise<string[]>} List of imported file names.
*/
export async function importCharacterChat(formData, { refresh = true } = {}) {
const fetchResult = await fetch('/api/chats/import', {
method: 'POST',
body: formData,
headers: getRequestHeaders({ omitContentType: true }),
cache: 'no-cache',
});
if (fetchResult.ok) {
const data = await fetchResult.json();
if (data.res && refresh) {
await displayPastChats();
}
return data?.fileNames || [];
}
return [];
}
export function updateViewMessageIds(startIndex = null) {
const minId = startIndex ?? getFirstDisplayedMessageId();
chatElement.find('.mes').each(function (index, element) {
$(element).attr('mesid', minId + index);
$(element).find('.mesIDDisplay').text(`#${minId + index}`);
});
chatElement.find('.mes').removeClass('last_mes');
chatElement.find('.mes').last().addClass('last_mes');
updateEditArrowClasses();
}
export function getFirstDisplayedMessageId() {
const allIds = Array.from(document.querySelectorAll('#chat .mes')).map(el => Number(el.getAttribute('mesid'))).filter(x => !isNaN(x));
const minId = Math.min(...allIds);
return minId;
}
export function updateEditArrowClasses() {
if (!(this_edit_mes_id >= 0)) {
return;
}
const message = chatElement.children('.mes').filter(`.mes[mesid="${this_edit_mes_id}"]`);
const downButton = message.find('.mes_edit_down');
const upButton = message.find('.mes_edit_up');
const copyButton = message.find('.mes_edit_copy');
const deleteButton = message.find('.mes_edit_delete');
const lastId = Number(chatElement.find('.mes').last().attr('mesid'));
const firstId = Number(chatElement.find('.mes').first().attr('mesid'));
copyButton.removeClass('disabled');
deleteButton.removeClass('disabled');
// The last message cannot be moved down.
downButton.toggleClass('disabled', lastId === Number(this_edit_mes_id));
// The first message cannot be moved up.
upButton.toggleClass('disabled', firstId === Number(this_edit_mes_id));
}
/**
* Closes the message editor.
* @param {'message'|'reasoning'|'all'} what What to close. Default is 'all'.
*/
export function closeMessageEditor(what = 'all') {
if (what === 'message' || what === 'all') {
if (this_edit_mes_id >= 0) {
chatElement.find(`.mes[mesid="${this_edit_mes_id}"] .mes_edit_cancel`).trigger('click');
}
}
if (what === 'reasoning' || what === 'all') {
document.querySelectorAll('.reasoning_edit_textarea').forEach((el) => {
const cancelButton = el.closest('.mes')?.querySelector('.mes_reasoning_edit_cancel');
if (cancelButton instanceof HTMLElement) {
cancelButton.click();
}
});
}
}
export function setGenerationProgress(progress) {
if (!progress) {
$('#send_textarea').css({ 'background': '', 'transition': '' });
}
else {
$('#send_textarea').css({
'background': `linear-gradient(90deg, #008000d6 ${progress}%, transparent ${progress}%)`,
'transition': '0.25s ease-in-out',
});
}
}
export function cancelTtsPlay() {
if ('speechSynthesis' in window) {
speechSynthesis.cancel();
}
}
function updateAlternateGreetingsHintVisibility(root) {
const numberOfGreetings = root.find('.alternate_greetings_list .alternate_greeting').length;
$(root).find('.alternate_grettings_hint').toggle(numberOfGreetings == 0);
}
async function openCharacterWorldPopup() {
const chid = $('#set_character_world').data('chid');
if (menu_type != 'create' && chid === undefined) {
toastr.error('Does not have an Id for this character in world select menu.');
return;
}
// TODO: Maybe make this utility function not use the window context?
const fileName = getCharaFilename(chid);
const charName = (menu_type == 'create' ? create_save.name : characters[chid]?.data?.name) || 'Nameless';
const worldId = (menu_type == 'create' ? create_save.world : characters[chid]?.data?.extensions?.world) || '';
const template = $('#character_world_template .character_world').clone();
template.find('.character_name').text(charName);
// --- Event Handlers ---
async function handlePrimaryWorldSelect() {
const selectedValue = $(this).val();
const worldIndex = selectedValue !== '' ? Number(selectedValue) : NaN;
const name = !isNaN(worldIndex) ? world_names[worldIndex] : '';
await charUpdatePrimaryWorld(name);
}
function handleExtrasWorldSelect(evt) {
const el = evt?.currentTarget ?? this;
const selectedValues = $(el).val();
const selected = Array.isArray(selectedValues) ? selectedValues : [];
const fileName = getCharaFilename(null, {});
const nextList = selected.map(i => world_names[i]).filter(Boolean);
charSetAuxWorlds(fileName, nextList);
}
// --- Populate Dropdowns ---
// Append to primary dropdown.
const primarySelect = template.find('.character_world_info_selector');
world_names.forEach((item, i) => {
primarySelect.append(new Option(item, String(i), item === worldId, item === worldId));
});
// Append to extras dropdown.
const extrasSelect = template.find('.character_extra_world_info_selector');
const existingCharLore = world_info.charLore?.find((e) => e.name === fileName);
world_names.forEach((item, i) => {
const array = (menu_type == 'create' ? create_save.extra_books : existingCharLore?.extraBooks);
const isSelected = !!array?.includes(item);
extrasSelect.append(new Option(item, String(i), isSelected, isSelected));
});
const popup = new Popup(template, POPUP_TYPE.TEXT, '', {
onOpen: function (popup) {
const popupDialog = $(popup.dlg);
primarySelect.on('change', handlePrimaryWorldSelect);
extrasSelect.on('change', handleExtrasWorldSelect);
// Not needed on mobile.
if (!isMobile()) {
extrasSelect.select2({
width: '100%',
placeholder: t`No auxiliary Lorebooks set. Click here to select.`,
allowClear: true,
closeOnSelect: false,
dropdownParent: popupDialog,
});
}
},
});
await popup.show();
}
function openAlternateGreetings() {
const chid = $('.open_alternate_greetings').data('chid');
if (menu_type != 'create' && chid === undefined) {
toastr.error('Does not have an Id for this character in editor menu.');
return;
} else {
// If the character does not have alternate greetings, create an empty array
if (characters[chid] && !Array.isArray(characters[chid].data.alternate_greetings)) {
characters[chid].data.alternate_greetings = [];
}
}
const template = $('#alternate_greetings_template .alternate_grettings').clone();
const getArray = () => menu_type == 'create' ? create_save.alternate_greetings : characters[chid].data.alternate_greetings;
const popup = new Popup(template, POPUP_TYPE.TEXT, '', {
wide: true,
large: true,
allowVerticalScrolling: true,
onClose: async () => {
if (menu_type !== 'create') {
await createOrEditCharacter();
}
},
});
for (let index = 0; index < getArray().length; index++) {
addAlternateGreeting(template, getArray()[index], index, getArray, popup);
}
template.find('.add_alternate_greeting').on('click', function () {
const array = getArray();
const index = array.length;
array.push('');
addAlternateGreeting(template, '', index, getArray, popup);
updateAlternateGreetingsHintVisibility(template);
const list = template.find('.alternate_greetings_list');
list.scrollTop(list.prop('scrollHeight'));
});
popup.show();
updateAlternateGreetingsHintVisibility(template);
}
/**
* Adds an alternate greeting to the template.
* @param {JQuery<HTMLElement>} template
* @param {string} greeting
* @param {number} index
* @param {() => any[]} getArray
* @param {Popup} popup
*/
function addAlternateGreeting(template, greeting, index, getArray, popup) {
const greetingBlock = $('#alternate_greeting_form_template .alternate_greeting').clone();
greetingBlock.attr('data-index', index);
greetingBlock.find('.alternate_greeting_text')
.attr('id', `alternate_greeting_${index}`)
.on('input', async function () {
const value = $(this).val();
const array = getArray();
array[index] = value;
}).val(greeting);
greetingBlock.find('.editor_maximize').attr('data-for', `alternate_greeting_${index}`);
greetingBlock.find('.greeting_index').text(index + 1);
greetingBlock.find('.delete_alternate_greeting').on('click', async function (event) {
event.preventDefault();
event.stopPropagation();
const confirm = await callGenericPopup(t`Are you sure you want to delete this alternate greeting?`, POPUP_TYPE.CONFIRM);
if (!confirm) {
return;
}
const array = getArray();
array.splice(index, 1);
// We need to reopen the popup to update the index numbers
await popup.complete(POPUP_RESULT.AFFIRMATIVE);
openAlternateGreetings();
});
greetingBlock.find('.move_up_alternate_greeting').on('click', function (event) {
handleMoveAlternateGreeting(event, -1);
});
greetingBlock.find('.move_down_alternate_greeting').on('click', function (event) {
handleMoveAlternateGreeting(event, 1);
});
/**
* Handles moving an alternate greeting up or down in the list.
* @param {JQuery.ClickEvent} event - The click event
* @param {number} direction - Direction to move: -1 for up, 1 for down
*/
function handleMoveAlternateGreeting(event, direction) {
event.preventDefault();
event.stopPropagation();
const array = getArray();
const index = Number(greetingBlock.attr('data-index'));
const newIndex = index + direction;
// Check bounds
if (direction === -1 && index <= 0) {
return;
}
if (direction === 1 && index >= array.length - 1) {
return;
}
// Swap the greetings
[array[index], array[newIndex]] = [array[newIndex], array[index]];
// Update current greeting
greetingBlock.find('.alternate_greeting_text').val(array[index]);
// Update adjacent greeting
const adjacentGreetingBlock = template.find(`.alternate_greeting[data-index="${newIndex}"]`);
adjacentGreetingBlock.find('.alternate_greeting_text').val(array[newIndex]);
}
template.find('.alternate_greetings_list').append(greetingBlock);
}
/**
* Creates or edits a character based on the form data.
* @param {Event} [e] Event that triggered the function call.
*/
export async function createOrEditCharacter(e) {
$('#rm_info_avatar').html('');
const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));
formData.set('fav', String(fav_ch_checked));
const isNewChat = e instanceof CustomEvent && e.type === 'newChat';
const rawFile = formData.get('avatar');
if (rawFile instanceof File) {
const convertedFile = await ensureImageFormatSupported(rawFile);
formData.set('avatar', convertedFile);
}
const headers = getRequestHeaders({ omitContentType: true });
if ($('#form_create').attr('actiontype') == 'createcharacter') {
if (String($('#character_name_pole').val()).length === 0) {
toastr.error(t`Name is required`);
return;
}
if (is_group_generating || is_send_press) {
toastr.error(t`Cannot create characters while generating. Stop the request and try again.`, t`Creation aborted`);
return;
}
try {
//if the character name text area isn't empty (only posible when creating a new character)
let url = '/api/characters/create';
if (crop_data != undefined) {
url += `?crop=${encodeURIComponent(JSON.stringify(crop_data))}`;
}
formData.delete('alternate_greetings');
for (const value of create_save.alternate_greetings) {
formData.append('alternate_greetings', value);
}
formData.append('extensions', JSON.stringify(create_save.extensions));
const fetchResult = await fetch(url, {
method: 'POST',
headers: headers,
body: formData,
cache: 'no-cache',
});
if (!fetchResult.ok) {
throw new Error('Fetch result is not ok');
}
const avatarId = await fetchResult.text();
$('#character_cross').trigger('click'); //closes the advanced character editing popup
const fields = [
{ id: '#character_name_pole', callback: value => create_save.name = value },
{ id: '#description_textarea', callback: value => create_save.description = value },
{ id: '#creator_notes_textarea', callback: value => create_save.creator_notes = value },
{ id: '#character_version_textarea', callback: value => create_save.character_version = value },
{ id: '#post_history_instructions_textarea', callback: value => create_save.post_history_instructions = value },
{ id: '#system_prompt_textarea', callback: value => create_save.system_prompt = value },
{ id: '#tags_textarea', callback: value => create_save.tags = value },
{ id: '#creator_textarea', callback: value => create_save.creator = value },
{ id: '#personality_textarea', callback: value => create_save.personality = value },
{ id: '#firstmessage_textarea', callback: value => create_save.first_message = value },
{ id: '#talkativeness_slider', callback: value => create_save.talkativeness = value, defaultValue: talkativeness_default },
{ id: '#scenario_pole', callback: value => create_save.scenario = value },
{ id: '#depth_prompt_prompt', callback: value => create_save.depth_prompt_prompt = value },
{ id: '#depth_prompt_depth', callback: value => create_save.depth_prompt_depth = value, defaultValue: depth_prompt_depth_default },
{ id: '#depth_prompt_role', callback: value => create_save.depth_prompt_role = value, defaultValue: depth_prompt_role_default },
{ id: '#mes_example_textarea', callback: value => create_save.mes_example = value },
{ id: '#character_json_data', callback: () => { } },
{ id: '#alternate_greetings_template', callback: value => create_save.alternate_greetings = value, defaultValue: [] },
{ id: '#character_world', callback: value => create_save.world = value },
{ id: '#_character_extensions_fake', callback: value => create_save.extensions = {} },
];
fields.forEach(field => {
const fieldValue = field.defaultValue !== undefined ? field.defaultValue : '';
$(field.id).val(fieldValue);
field.callback && field.callback(fieldValue);
});
if (Array.isArray(create_save.extra_books) && create_save.extra_books.length > 0) {
const fileName = getCharaFilename(null, { manualAvatarKey: avatarId });
const charLore = world_info.charLore ?? [];
charLore.push({ name: fileName, extraBooks: create_save.extra_books });
Object.assign(world_info, { charLore: charLore });
saveSettingsDebounced();
}
create_save.extra_books = [];
$('#character_popup-button-h3').text('Create character');
create_save.avatar = null;
$('#add_avatar_button').replaceWith(
$('#add_avatar_button').val('').clone(true),
);
let oldSelectedChar = null;
if (this_chid !== undefined) {
oldSelectedChar = characters[this_chid].avatar;
}
console.log(`new avatar id: ${avatarId}`);
createTagMapFromList('#tagList', avatarId);
await getCharacters();
select_rm_info('char_create', avatarId, oldSelectedChar);
crop_data = undefined;
} catch (error) {
console.error('Error creating character', error);
toastr.error(t`Failed to create character`);
}
} else {
try {
let url = '/api/characters/edit';
if (crop_data != undefined) {
url += `?crop=${encodeURIComponent(JSON.stringify(crop_data))}`;
}
formData.delete('alternate_greetings');
const chid = $('.open_alternate_greetings').data('chid');
if (characters[chid] && Array.isArray(characters[chid]?.data?.alternate_greetings)) {
for (const value of characters[chid].data.alternate_greetings) {
formData.append('alternate_greetings', value);
}
}
const fetchResult = await fetch(url, {
method: 'POST',
headers: headers,
body: formData,
cache: 'no-cache',
});
if (!fetchResult.ok) {
throw new Error('Fetch result is not ok');
}
await getOneCharacter(formData.get('avatar_url'));
favsToHotswap(); // Update fav state
$('#add_avatar_button').replaceWith(
$('#add_avatar_button').val('').clone(true),
);
$('#create_button').attr('value', 'Save');
crop_data = undefined;
await eventSource.emit(event_types.CHARACTER_EDITED, { detail: { id: this_chid, character: characters[this_chid] } });
// Recreate the chat if it hasn't been used at least once (i.e. with continue).
const message = getFirstMessage();
const shouldRegenerateMessage =
!isNewChat &&
message.mes &&
!selected_group &&
!chat_metadata['tainted'] &&
(chat.length === 0 || (chat.length === 1 && !chat[0].is_user && !chat[0].is_system));
if (shouldRegenerateMessage) {
chat.splice(0, chat.length, message);
const messageId = (chat.length - 1);
await eventSource.emit(event_types.MESSAGE_RECEIVED, messageId, 'first_message');
await clearChat();
await printMessages();
await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, messageId, 'first_message');
await saveChatConditional();
}
} catch (error) {
console.log(error);
toastr.error(t`Something went wrong while saving the character, or the image file provided was in an invalid format. Double check that the image is not a webp.`);
}
}
}
/**
* Visually updates all chat messages including andd after index by removing them, then adding them.
* @param {ChatMessage[]} chat All messages in chat before index will remain unchanged.
* @param {Number} index The last unchanged messageId.
*/
export async function redisplayChat(chat, index) {
//Remove messages after index.
chatElement.children(`.mes[mesid="${index}"]`).nextAll('.mes').addBack().remove();
//Skip to index, then add extra messages.
for (let i = index; i <= chat.length - 1; i++) {
//addOneMessage will update last_mes.
addOneMessage(chat[i], { scroll: false, showSwipes: false, forceId: i });
}
refreshSwipeButtons();
}
/**
* Formats a counter for a swipe view.
* @param {number} current The current number of items.
* @param {number} total The total number of items.
* @returns {string} The formatted counter.
*/
function formatSwipeCounter(current, total) {
if (isNaN(current) && isNaN(total)) {
return '';
}
return `${!isNaN(current) ? current : '?'}\u200b/\u200b${!isNaN(total) ? total : '?'}`;
}
/**
* Handles the swipe event.
* @param {SwipeEvent} event Event.
* @param {SWIPE_DIRECTION} direction The direction to swipe.
* @param {object} params Additional parameters.
* @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. null, 'keyboard', 'auto_swipe', 'back' or 'delete'.
* @param {boolean} [params.repeated] Is the swipe event repeated.
* @param {ChatMessage} [params.message=chat[chat.length - 1]] The chat message to swipe.
* @param {number} [params.forceMesId] The message id to swipe.
* @param {number} [params.forceSwipeId] The target swipe_id. When out of range, it will be looped or clamped.
* @param {number} [params.forceDuration] Overwrites the default swipe duration.
*/
export async function swipe(event, direction, { source, repeated, message = chat[chat.length - 1], forceMesId, forceSwipeId, forceDuration } = {}) {
if (chat.length === 0) {
console.warn('Swipe was called on an empty chat.');
return;
}
let messageIndex;
//Only set messageIndex if message exists because -1 is truthy.
if (message) {
messageIndex = chat.indexOf(message);
if (messageIndex === -1 && typeof (forceMesId) != 'number') {
console.error(`The message must exist in chat. ${message};`);
return;
}
}
const mesId = Number(forceMesId ?? event?.currentTarget?.closest('.mes')?.getAttribute('mesid') ?? messageIndex ?? chat.length - 1);
if (source === SWIPE_SOURCE.DELETE || source === SWIPE_SOURCE.BACK || source === SWIPE_SOURCE.AUTO_SWIPE) {
console.info(`The ${direction} swipe source on message #${mesId} is ${source}, Most checks have been bypassed. `);
} else {
//Only show an error if swipes are not hidden and a message is generating.
if (isGenerating() && (swipes && !swipesHidden && (swipeState === SWIPE_STATE.NONE))) {
toastr.warning(t`Cannot swipe while generating. Stop the request and try again.`, t`Swipe aborted`);
return;
}
//Only allow one concurrent swipe.
if (!isSwipingAllowed()) {
console.info('The swipe has been ignored messages cannot currently be swiped.');
return;
}
if (!isMessageSwipeable(mesId, message)) {
console.info(`Message #${mesId} cannot be swiped. ${message}`);
return;
}
}
// Cancel pending save to prevent accidental swipe_id overwrites.
cancelDebouncedChatSave();
swipeState = SWIPE_STATE.SWIPING;
let generation;
const thisMesDiv = chatElement.children('.mes').filter(`[mesid="${mesId}"]`);
const thisMesText = thisMesDiv.find('.mes_block .mes_text');
const thisMesDivHeight = thisMesDiv[0]?.scrollHeight;
const thisMesTextHeight = thisMesText[0]?.scrollHeight;
if (![thisMesDiv.length, thisMesText.length].every(num => num > 0)) {
console.error(`Message #${mesId}'s DOM element is not valid.`);
return;
}
const originalSwipeId = Number(chat[mesId]?.['swipe_id'] ?? 0);
let newSwipeId = Number(forceSwipeId ?? originalSwipeId);
/**
* Calculates the next swipe duration with how many swipes have been repeated.
* @param {number} animation_duration
* @returns {number} The adjusted swipe duration.
*/
function getSwipeDuration(animation_duration) {
const now = performance.now();
const resetTime = animation_duration * 2 + 300;
//Reset the counter if the last swipe was more than half a second ago.
if (now - lastSwipeInfo.now >= resetTime || direction !== lastSwipeInfo.direction) recentSwipes = 0;
recentSwipes++;
lastSwipeInfo = { now, direction };
//At 4 swipes, animation_duration will be halved.
const sigmoid = 1 / (1 + Math.exp(recentSwipes - 4));
return animation_duration * sigmoid;
}
const swipeDuration = forceDuration ?? getSwipeDuration(animation_duration);
//The offscreen messages may be visible if the user resizes the viewport during a swipe.
const thisMesDivWidth = thisMesDiv.width() + 30;
let swipeRange = (direction === SWIPE_DIRECTION.RIGHT) ? -thisMesDivWidth : thisMesDivWidth;
/**
* Waits for the generation to end, reverts the swipe if swipe_id has not changed.
* @param {boolean} revert Attept to revert the swipe without saving.
*/
async function endSwipe(revert = false) {
//Wait for the generation to end.
try {
//`mes_buttons` need to be hidden until the animation completes.
if (generation) {
document.body.dataset.swiping = 'true';
await generation;
}
}
catch (error) {
console.warn(`Swipe failed, Swiping back. ${error}`);
}
//Clamp Id between swipes.
let clampedId = clamp(chat[mesId]['swipe_id'], 0, Math.max(0, chat[mesId]['swipes'].length - 1));
await updateSwipeCounter(mesId);
//Fallback.
if (mesId != chat.length - 1) {
await updateSwipeCounter(chat.length - 1);
}
// If swipe_id has not changed, give the user feedback.
if (clampedId == originalSwipeId && source != SWIPE_SOURCE.DELETE) {
try {
//Shake 700/140=5px
shakeElement(thisMesDiv, -swipeRange / 140, animation_duration, 'ease-in');
//Flash red.
const flashTime = Math.max(animation_duration * 2, 100);
await Promise.race([thisMesDiv.find('.swipes-counter').animate({ color: 'red' }, flashTime).animate({ color: '' }).promise(), createTimeout(flashTime * 4, `The shake animation did not end within ${flashTime * 4}ms`)].filter(Boolean));
} catch (error) {
console.warn(error);
}
}
//If the id is not within bounds, Swipe back.
if (chat[mesId]?.swipe_id !== clampedId || revert) {
// Prevent recursion.
if (source != SWIPE_SOURCE.BACK) {
source = SWIPE_SOURCE.BACK;
chat[mesId].swipe_id = clampedId;
//Update the chat.
await loadFromSwipeId(mesId, chat[mesId].swipe_id);
await redisplayChat(chat, mesId);
}
else {
await Popup.show.confirm(
t`ERROR: <code>syncSwipeToMes</code> has failed to revert the failed ${direction} swipe on message #${mesId}.`,
t`<p>After you click OK, the chat will be reloaded to prevent data corruption.</p>`,
{ okButton: 'OK', cancelButton: false },
);
console.trace(`Error! Recursion detected when reverting failed ${direction} swipe on message #${mesId}. Something has broken.`);
await reloadCurrentChat();
}
//Out of bounds swipes should not be saved.
} else if (source != SWIPE_SOURCE.BACK) {
//Save the chat if swipe_id has changed.
saveChatDebounced();
}
//Allow for another swipe.
swipeState = SWIPE_STATE.NONE;
delete document.body.dataset.swiping;
showSwipeButtons();
}
async function standardSwipe(newSwipeId) {
//If swipe_id has changed, or the source is being deleted.
if (newSwipeId !== originalSwipeId || source == SWIPE_SOURCE.DELETE || source == SWIPE_SOURCE.BACK) {
//Update the chat.
await loadFromSwipeId(mesId, newSwipeId);
//Transition to the new chat.
await animateSwipe();
}
await endSwipe();
}
/**
* Removes a message's extra and gen times.
* @param {ChatMessage} message
*/
function clearMessageData(message) {
if (message.extra && typeof message.extra === 'object') {
delete message.extra.memory;
delete message.extra.display_text;
delete message.extra.media;
delete message.extra.inline_image;
delete message.extra.files;
delete message.extra.fileLength;
delete message.extra.generationType;
delete message.extra.negative;
delete message.extra.title;
delete message.extra.append_title;
}
delete message.gen_started;
delete message.gen_finished;
}
/**
* Sets the message to the newSwipeId and loads it.
* @param {number} mesId
* @param {number} newSwipeId
*/
async function loadFromSwipeId(mesId, newSwipeId) {
//Update the swipe_id.
chat[mesId]['swipe_id'] = newSwipeId;
clearMessageData(chat[mesId]);
//Load from swipes.
if (syncSwipeToMes(mesId, newSwipeId) == false) {
let errorMessage = t`When swiping ${direction} on message ${mesId}, syncSwipeToMes has returned false. Attempting to swipe back!`;
toastr.error(errorMessage);
chat[mesId].swipe_id = originalSwipeId;
await endSwipe(true);
}
return true;
}
/**
* Animates a swipe for all messages >= mesId.
* @param {number} mesId
* @param {object} params
* @param {string} [params.xStart='opx']
* @param {string} [params.xEnd='0px']
* @param {number} [params.duration=animation_duration]
* @param {string} [params.classes=''] Additional CSS classes to target during the swipe.
* @param {boolean} [params.freeze=true] When true, do not remove the class from the animation, leaving it stuck at xEnd.
* @returns {Promise<boolean|Function>} endSlide unfreezes the messages from xEnd.
*/
async function animateSwipeTransition(mesId, { xStart = '0px', xEnd = '0px', duration = animation_duration, classes = '', freeze = false } = {}) {
// If the animation_duration is zero, the 'animationend' promise will never resolve.
//Skip the animation if it's faster than 50ms.
if (duration <= 50) return;
//Select MAXIMUM_ANIMATED messages after mesId. Ideally, only visible messages would be animated.
const MAXIMUM_ANIMATED = 100;
const messages = chatElement.children('.mes');
const firstDisplayedMesId = Number(messages.first().attr('mesid'));
const swipedMessagesDiv = messages.filter((index, div) => {
// const messageId = Number($(div).attr('mesid')); //Slower.
//This assumes the messages are in order and their Id's are accurate.
const divMessageId = firstDisplayedMesId + index;
return (divMessageId < mesId + MAXIMUM_ANIMATED && divMessageId >= mesId);
});
if (swipedMessagesDiv.length > 0) {
let swipeClasses = '.mes_block, .mesAvatarWrapper';
swipeClasses += classes;
//Select only the target classes.
const swipedElementsDiv = swipedMessagesDiv.children(swipeClasses);
if (swipedElementsDiv.length > 0) {
//This is a global variable, only one swipe transition can occur concurrently.
document.documentElement.style.setProperty('--slide-mes-x-start', xStart);
document.documentElement.style.setProperty('--slide-mes-x-end', xEnd);
document.documentElement.style.setProperty('--slide-mes-x-duration', `${duration}ms`);
//The class must be removed to unfreze previous slides.
swipedElementsDiv.removeClass('slide');
//CSS starts the animation.
void swipedElementsDiv[0].offsetWidth;
swipedElementsDiv.addClass('slide');
const endSlide = () => {
//Remove the style when done.
swipedElementsDiv.removeClass('slide');
document.documentElement.style.setProperty('--slide-mes-x-start', '');
document.documentElement.style.setProperty('--slide-mes-x-end', '');
document.documentElement.style.setProperty('--slide-mes-duration', '');
return true;
};
//Wait for the animation's end. https://developer.mozilla.org/en-US/docs/Web/API/Animation/finished
const animation = swipedElementsDiv[0]?.getAnimations().filter((a) => a['animationName'] == 'slide')[0];
try {
await Promise.race([animation?.finished, createTimeout(duration * 2, `The ${duration}ms swipe animation has not ended after ${duration * 2}ms. It has been skipped.`)].filter(Boolean));
} catch (error) {
console.warn(error);
}
//If not frozen, end the slide now.
return freeze ? endSlide : endSlide();
}
}
console.warn(`No animatable messages were found after message #${mesId}.`);
return false;
}
function getMessageBottomHeight(thisMesDiv) {
const thisMesRect = thisMesDiv[0].getBoundingClientRect();
//Scroll position + Chat height = Bottom of chat height.
const chatBottom = chatElement.scrollTop() - chatElement.height();
//Message offset from viewport top + height = Bottom of message offset.
const messageBottom = thisMesRect.top + thisMesDiv.height();
// Bottom of chat + Bottom of message offset = target scroll position.
const scrollHeight = (chatBottom + messageBottom);
return scrollHeight;
}
function expandNewMessage(thisMesDiv) {
//Only scroll if the view is not near the bottom.
const is_animation_scroll = (chatElement.scrollTop() >= (chatElement.prop('scrollHeight') - chatElement.outerHeight()) - 10);
let new_height = thisMesDivHeight - (thisMesTextHeight - thisMesText[0].scrollHeight);
if (new_height < 103) new_height = 103;
//Keep the swipe buttons at the same height when scrolling is finished.
//Expand new message.
thisMesDiv.animate({ height: new_height + 'px' }, {
duration: 0, //used to be 100 //Disabled on Cohee's request. https://github.com/SillyTavern/SillyTavern/pull/4610/files#r2408731744
queue: false,
progress: function (animation, progress, remainingMs) {
if (is_animation_scroll) chatElement.scrollTop(getMessageBottomHeight(thisMesDiv));
},
complete: function () {
thisMesDiv.css('height', 'auto');
//Correct height auto offset.
if (is_animation_scroll) chatElement.scrollTop(getMessageBottomHeight(thisMesDiv));
},
});
}
/**
* Anime a swipe, optionally running a generation.
* @param {boolean} run_generate
* @param {boolean} [skipSwipeOut=false]
*/
async function animateSwipe(run_generate = false, skipSwipeOut = false) {
if (!skipSwipeOut) {
//Swipe out.
await animateSwipeTransition(mesId, { xEnd: `${swipeRange}px`, duration: swipeDuration });
}
if (run_generate) {
await updateSwipeCounter(mesId);
//shows "..." while generating
thisMesDiv.find('.mes_text').html('...');
// resets the timer
thisMesDiv.find('.mes_timer').html('');
thisMesDiv.find('.tokenCounterDisplay').text('');
updateReasoningUI(thisMesDiv, { reset: true });
} else {
//console.log('showing previously generated swipe candidate, or "..."');
//console.log('onclick right swipe calling addOneMessage');
//Only scroll when swiping the last message.
const scroll = (mesId == chat.length - 1);
//The swipe buttons will be refreshed in endSwipe(), refreshing them now will cause flickering.
addOneMessage(chat[mesId], { type: 'swipe', forceId: mesId, scroll: scroll, showSwipes: false });
if (power_user.message_token_count_enabled) {
if (!chat[mesId].extra) {
chat[mesId].extra = {};
}
const tokenCountText = (chat[mesId]?.extra?.reasoning || '') + chat[mesId].mes;
const tokenCount = await getTokenCountAsync(tokenCountText, 0);
chat[mesId]['extra']['token_count'] = tokenCount;
thisMesDiv.find('.tokenCounterDisplay').text(`${tokenCount}t`);
}
}
//Animate expanding to the new message height.
thisMesDiv.css('height', thisMesDivHeight);
expandNewMessage(thisMesDiv);
appendMediaToMessage(chat[mesId], thisMesDiv);
await eventSource.emit(event_types.MESSAGE_SWIPED, (mesId));
if (run_generate && !is_send_press) {
is_send_press = true;
generation = Generate('swipe');
}
//Swipe in from the opposite side.
await animateSwipeTransition(mesId, { xStart: `${-swipeRange}px`, xEnd: `${0}px`, duration: swipeDuration });
}
if (mesId === Number(this_edit_mes_id)) {
closeMessageEditor();
}
if (isStreamingEnabled() && streamingProcessor) {
streamingProcessor.onStopStreaming();
}
if (isHordeGenerationNotAllowed()) {
return unblockGeneration();
}
//If the swipe is not being deleted.
if (source != SWIPE_SOURCE.DELETE && source != SWIPE_SOURCE.BACK) {
// Make sure ad-hoc changes to extras are saved before swiping away
syncMesToSwipe(mesId);
if (chat[mesId]['swipe_id'] === undefined) { // if there is no swipe-message in the last spot of the chat array
chat[mesId]['swipe_id'] = 0; // set it to id 0
chat[mesId]['swipes'] = []; // empty the array
chat[mesId]['swipe_info'] = [];
chat[mesId]['swipes'][0] = chat[mesId]['mes']; //assign swipe array with last chat[mesId] from chat
chat[mesId]['swipe_info'][0] = {
'send_date': chat[mesId]['send_date'],
'gen_started': chat[mesId]['gen_started'],
'gen_finished': chat[mesId]['gen_finished'],
'extra': structuredClone(chat[mesId]['extra']),
};
}
// If the user is holding down the key and we're at the last or first swipe, don't do anything.
let isLastSwipe = (direction === SWIPE_DIRECTION.RIGHT) ? (chat[mesId].swipe_id === Math.max(0, chat[mesId]['swipes'].length - 1)) : chat[mesId].swipe_id === 0;
if (source === SWIPE_SOURCE.KEYBOARD && repeated && isLastSwipe) {
await endSwipe();
return;
}
} else if (source == SWIPE_SOURCE.DELETE || source == SWIPE_SOURCE.BACK) {
//If the swipe is being deleted or reverted.
await standardSwipe(newSwipeId);
return;
}
//If swiping left.
if (direction === SWIPE_DIRECTION.LEFT) {
if (forceSwipeId == null) newSwipeId--;
//Loop to last swipe if negative.
if (newSwipeId < 0) {
newSwipeId = Math.max(0, chat[mesId]['swipes'].length - 1);
}
//Limit swipe_id to swipes.
if (newSwipeId > chat[mesId]['swipes'].length - 1) {
toastr.warning(`The swipe_id for message #${mesId} was ${newSwipeId}. It has been reset to ${chat[mesId]['swipes'].length - 1}.`);
chat[mesId]['swipe_id'] = chat[mesId]['swipes'].length - 1;
await endSwipe();
return;
}
await standardSwipe(newSwipeId);
return;
}
//If swiping right.
else if (direction === SWIPE_DIRECTION.RIGHT) {
// make new slot in array
if (forceSwipeId == null) newSwipeId++;
//Minimum of zero.
if (newSwipeId < 0) {
toastr.warning(`The swipe_id for message #${mesId} was ${newSwipeId}. It has been reset to zero.`);
chat[mesId]['swipe_id'] = 0;
await endSwipe();
return;
}
//If overswiping.
if (newSwipeId >= chat[mesId]['swipes'].length) {
newSwipeId = chat[mesId]['swipes'].length;
//Update the swipe_id.
chat[mesId]['swipe_id'] = newSwipeId;
const overswipe = getOverswipeBehavior(mesId);
//Cancel the generation.
if (overswipe == OVERSWIPE_BEHAVIOR.NONE) {
//Cancel swipe.
chat[mesId]['swipe_id'] = originalSwipeId;
await endSwipe();
return;
}
//Regenerate the message
else if (overswipe == OVERSWIPE_BEHAVIOR.REGENERATE) {
clearMessageData(chat[mesId]);
let run_generate = true;
//Generate.
await animateSwipe(run_generate);
await endSwipe();
return;
}
// Loop to the first swipe.
else if (overswipe == OVERSWIPE_BEHAVIOR.LOOP || overswipe == OVERSWIPE_BEHAVIOR.PRISTINE_GREETING) {
newSwipeId = 0;
}
}
await standardSwipe(newSwipeId);
return;
}
}
/**
* @deprecated Use `swipe` instead.
* Handles the swipe to the left event.
* @param {SwipeEvent} [event] Event.
* @param {object} params Additional parameters.
* @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. null, 'keyboard', 'auto_swipe', 'back' or 'delete'.
* @param {boolean} [params.repeated] Is the swipe event repeated.
* @param {object} [params.message] The chat message to swipe.
*/
export async function swipe_left(event, { source, repeated, message } = {}) {
await swipe.call(this, event, SWIPE_DIRECTION.LEFT, { source: source, repeated: repeated, message: message });
}
/**
* @deprecated Use `swipe` instead.
* Handles the swipe to the right event.
* @param {SwipeEvent} [event] Event.
* @param {object} params Additional parameters.
* @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. null, 'keyboard', 'auto_swipe', 'back' or 'delete'.
* @param {boolean} [params.repeated] Is the swipe event repeated.
* @param {object} [params.message] The chat message to swipe.
*/
//MARK: swipe_right
export async function swipe_right(event = null, { source, repeated, message } = {}) {
await swipe.call(this, event, SWIPE_DIRECTION.RIGHT, { source: source, repeated: repeated, message: message });
}
/**
* Imports supported files dropped into the app window.
* @param {File[]} files Array of files to process
* @param {Map<File, string>} [data] Extra data to pass to the import function
* @returns {Promise<void>}
*/
export async function processDroppedFiles(files, data = new Map()) {
const allowedMimeTypes = [
'application/json',
'image/png',
'application/yaml',
'application/x-yaml',
'text/yaml',
'text/x-yaml',
];
const allowedExtensions = [
'charx',
'byaf',
];
const avatarFileNames = [];
for (const file of files) {
const extension = file.name.split('.').pop().toLowerCase();
if (allowedMimeTypes.some(x => file.type.startsWith(x)) || allowedExtensions.includes(extension)) {
const preservedName = data instanceof Map && data.get(file);
const avatarFileName = await importCharacter(file, { preserveFileName: preservedName });
if (avatarFileName !== undefined) {
avatarFileNames.push(avatarFileName);
}
} else {
toastr.warning(t`Unsupported file type: ` + file.name);
}
}
if (avatarFileNames.length > 0) {
await importCharactersTags(avatarFileNames);
selectImportedChar(avatarFileNames[avatarFileNames.length - 1]);
}
}
/**
* Imports tags for the given characters
* @param {string[]} avatarFileNames character avatar filenames whose tags are to import
*/
async function importCharactersTags(avatarFileNames) {
await getCharacters();
for (let i = 0; i < avatarFileNames.length; i++) {
if (power_user.tag_import_setting !== tag_import_setting.NONE) {
const importedCharacter = characters.find(character => character.avatar === avatarFileNames[i]);
await importTags(importedCharacter);
}
}
}
/**
* Selects the given imported char
* @param {string} charId char to select
*/
function selectImportedChar(charId) {
let oldSelectedChar = null;
if (this_chid !== undefined) {
oldSelectedChar = characters[this_chid].avatar;
}
select_rm_info('char_import_no_toast', charId, oldSelectedChar);
}
/**
* Imports a character from a file.
* @param {File} file File to import
* @param {object} [options] - Options
* @param {string} [options.preserveFileName] Whether to preserve original file name
* @param {Boolean} [options.importTags=false] Whether to import tags
* @returns {Promise<string>}
*/
async function importCharacter(file, { preserveFileName = '', importTags = false } = {}) {
if (is_group_generating || is_send_press) {
toastr.error(t`Cannot import characters while generating. Stop the request and try again.`, t`Import aborted`);
throw new Error('Cannot import character while generating');
}
const ext = file.name.match(/\.(\w+)$/);
if (!ext || !(['json', 'png', 'yaml', 'yml', 'charx', 'byaf'].includes(ext[1].toLowerCase()))) {
return;
}
const exists = preserveFileName ? characters.find(character => character.avatar === preserveFileName) : undefined;
const format = ext[1].toLowerCase();
$('#character_import_file_type').val(format);
const formData = new FormData();
formData.append('avatar', file);
formData.append('file_type', format);
formData.append('user_name', name1);
if (preserveFileName) formData.append('preserved_name', preserveFileName);
try {
const result = await fetch('/api/characters/import', {
method: 'POST',
body: formData,
headers: getRequestHeaders({ omitContentType: true }),
cache: 'no-cache',
});
if (!result.ok) {
throw new Error(`Failed to import character: ${result.statusText}`);
}
const data = await result.json();
if (data.error) {
throw new Error(`Server returned an error: ${data.error}`);
}
if (data.file_name !== undefined) {
let avatarFileName = `${data.file_name}.png`;
// Refresh existing thumbnail
if (exists && this_chid !== undefined) {
await fetch(getThumbnailUrl('avatar', avatarFileName), { cache: 'reload' });
}
$('#character_search_bar').val('').trigger('input');
if (exists) {
toastr.success(t`Character Replaced: ${String(data.file_name).replace('.png', '')}`);
} else {
toastr.success(t`Character Created: ${String(data.file_name).replace('.png', '')}`);
}
if (importTags) {
await importCharactersTags([avatarFileName]);
selectImportedChar(data.file_name);
}
return avatarFileName;
}
} catch (error) {
console.error('Error importing character', error);
toastr.error(t`The file is likely invalid or corrupted.`, t`Could not import character`);
}
}
async function importFromURL(items, files) {
for (const item of items) {
if (item.type === 'text/uri-list') {
const uriList = await new Promise((resolve) => {
item.getAsString((uriList) => { resolve(uriList); });
});
const uris = uriList.split('\n').filter(uri => uri.trim() !== '');
try {
for (const uri of uris) {
const request = await fetch(uri);
const data = await request.blob();
const fileName = request.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || uri.split('/').pop() || 'file.png';
const file = new File([data], fileName, { type: data.type });
files.push(file);
}
} catch (error) {
console.error('Failed to import from URL', error);
}
}
}
}
export async function doNewChat({ deleteCurrentChat = false } = {}) {
//Make a new chat for selected character
if ((!selected_group && this_chid == undefined) || menu_type == 'create') {
return;
}
//Fix it; New chat doesn't create while open create character menu
await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10);
await clearChat();
chat.length = 0;
chat_file_for_del = getCurrentChatDetails()?.sessionName;
// Make it easier to find in backups
if (deleteCurrentChat) {
await saveChatConditional();
}
if (selected_group) {
await createNewGroupChat(selected_group);
if (deleteCurrentChat) await deleteGroupChat(selected_group, chat_file_for_del, { jumpToNewChat: false }); // don't jump, new chat was already created and jumped to above
}
else {
//RossAscends: added character name to new chat filenames and replaced Date.now() with humanizedDateTime;
chat_metadata = {};
characters[this_chid].chat = `${name2} - ${humanizedDateTime()}`;
$('#selected_chat_pole').val(characters[this_chid].chat);
await getChat();
await createOrEditCharacter(new CustomEvent('newChat'));
if (deleteCurrentChat) await delChat(chat_file_for_del + '.jsonl');
}
}
/**
* Renames a group or character chat.
* @param {object} param Parameters for renaming chat
* @param {string} [param.characterId] Character ID to rename chat for
* @param {string} [param.groupId] Group ID to rename chat for
* @param {string} param.oldFileName Old name of the chat (no JSONL extension)
* @param {string} param.newFileName New name for the chat (no JSONL extension)
* @param {boolean} [param.loader=true] Whether to show loader during the operation
*/
export async function renameGroupOrCharacterChat({ characterId, groupId, oldFileName, newFileName, loader }) {
const currentChatId = getCurrentChatId();
const body = {
is_group: !!groupId,
avatar_url: characters[characterId]?.avatar,
original_file: `${oldFileName}.jsonl`,
renamed_file: `${newFileName.trim()}.jsonl`,
};
if (body.original_file === body.renamed_file) {
console.debug('Chat rename cancelled, old and new names are the same');
return;
}
if (equalsIgnoreCaseAndAccents(body.original_file, body.renamed_file)) {
toastr.warning(t`Name not accepted, as it is the same as before (ignoring case and accents).`, t`Rename Chat`);
return;
}
try {
loader && showLoader();
const response = await fetch('/api/chats/rename', {
method: 'POST',
body: JSON.stringify(body),
headers: getRequestHeaders(),
});
if (!response.ok) {
throw new Error('Unsuccessful request.');
}
const data = await response.json();
if (data.error) {
throw new Error('Server returned an error.');
}
if (data.sanitizedFileName) {
newFileName = data.sanitizedFileName;
}
if (groupId) {
await renameGroupChat(groupId, oldFileName, newFileName);
}
else if (characterId !== undefined && String(characterId) === String(this_chid) && characters[characterId]?.chat === oldFileName) {
characters[characterId].chat = newFileName;
$('#selected_chat_pole').val(characters[characterId].chat);
await createOrEditCharacter();
}
if (currentChatId) {
await reloadCurrentChat();
}
} catch {
loader && hideLoader();
await delay(500);
await callGenericPopup('An error has occurred. Chat was not renamed.', POPUP_TYPE.TEXT);
} finally {
loader && hideLoader();
}
}
/**
* Renames the currently selected chat.
* @param {string} oldFileName Old name of the chat (no JSONL extension)
* @param {string} newName New name for the chat (no JSONL extension)
*/
export async function renameChat(oldFileName, newName) {
return await renameGroupOrCharacterChat({
characterId: this_chid,
groupId: selected_group,
oldFileName: oldFileName,
newFileName: newName,
loader: true,
});
}
/**
* Closes the current chat, clearing all associated data and resetting the UI.
* If a message generation is in progress, it prompts the user to stop it first.
* @returns {Promise<boolean>} True if the chat was successfully closed, false otherwise.
*/
export async function closeCurrentChat() {
if (is_send_press == false) {
await waitUntilCondition(() => !isChatSaving, debounce_timeout.extended, 10);
await clearChat();
chat.length = 0;
resetSelectedGroup();
setCharacterId(undefined);
setCharacterName('');
setActiveCharacter(null);
setActiveGroup(null);
this_edit_mes_id = undefined;
chat_metadata = {};
selected_button = 'characters';
$('#rm_button_selected_ch').children('h2').text('');
select_rm_characters();
await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId());
return true;
} else {
toastr.info(t`Please stop the message generation first.`);
return false;
}
}
/**
* Forces the update of the chat name for a remote character.
* @param {string|number} characterId Character ID to update chat name for
* @param {string} newName New name for the chat
* @returns {Promise<void>}
*/
export async function updateRemoteChatName(characterId, newName) {
const character = characters[characterId];
if (!character) {
console.warn(`Character not found for ID: ${characterId}`);
return;
}
character.chat = newName;
const mergeRequest = {
avatar: character.avatar,
chat: newName,
};
const mergeResponse = await fetch('/api/characters/merge-attributes', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(mergeRequest),
});
if (!mergeResponse.ok) {
console.error('Failed to save extension field', mergeResponse.statusText);
}
}
function doCharListDisplaySwitch() {
power_user.charListGrid = !power_user.charListGrid;
document.body.classList.toggle('charListGrid', power_user.charListGrid);
saveSettingsDebounced();
}
/**
* Function to handle the deletion of a character, given a specific popup type and character ID.
* If popup type equals "del_ch", it will proceed with deletion otherwise it will exit the function.
* It fetches the delete character route, sending necessary parameters, and in case of success,
* it proceeds to delete character from UI and saves settings.
* In case of error during the fetch request, it logs the error details.
*
* @param {string} this_chid - The character ID to be deleted.
* @param {boolean} delete_chats - Whether to delete chats or not.
*/
export async function handleDeleteCharacter(this_chid, delete_chats) {
if (!characters[this_chid]) {
return;
}
await deleteCharacter(characters[this_chid].avatar, { deleteChats: delete_chats });
}
/**
* Deletes a character completely, including associated chats if specified
*
* @param {string|string[]} characterKey - The key (avatar) of the character to be deleted
* @param {Object} [options] - Optional parameters for the deletion
* @param {boolean} [options.deleteChats=true] - Whether to delete associated chats or not
* @return {Promise<void>} - A promise that resolves when the character is successfully deleted
*/
export async function deleteCharacter(characterKey, { deleteChats = true } = {}) {
if (!Array.isArray(characterKey)) {
characterKey = [characterKey];
}
const inTempChat = this_chid === undefined && name2 === neutralCharacterName;
if (inTempChat) {
const confirmClose = await Popup.show.confirm(
t`You are currently in a temporary chat.`,
t`Deleting this character will close the chat and you will lose any unsaved messages. Do you want to proceed?`,
);
if (!confirmClose) {
return;
}
}
const closeChatResult = await closeCurrentChat();
if (!closeChatResult) {
return;
}
for (const key of characterKey) {
const character = characters.find(x => x.avatar == key);
if (!character) {
toastr.warning(t`Character ${key} not found. Skipping deletion.`);
continue;
}
const chid = characters.indexOf(character);
const pastChats = await getPastCharacterChats(chid);
const msg = { avatar_url: character.avatar, delete_chats: deleteChats };
const response = await fetch('/api/characters/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(msg),
cache: 'no-cache',
});
if (!response.ok) {
toastr.error(`${response.status} ${response.statusText}`, t`Failed to delete character`);
continue;
}
accountStorage.removeItem(`AlertWI_${character.avatar}`);
accountStorage.removeItem(`AlertRegex_${character.avatar}`);
accountStorage.removeItem(`mediaWarningShown:${character.avatar}`);
delete tag_map[character.avatar];
select_rm_info('char_delete', character.name);
if (deleteChats) {
for (const chat of pastChats) {
const name = chat.file_name.replace('.jsonl', '');
await eventSource.emit(event_types.CHAT_DELETED, name);
}
}
await eventSource.emit(event_types.CHARACTER_DELETED, { id: chid, character: character });
}
await removeCharacterFromUI();
}
/**
* Function to delete a character from UI after character deletion API success.
* It manages necessary UI changes such as closing advanced editing popup, unsetting
* character ID, resetting characters array and chat metadata, deselecting character's tab
* panel, removing character name from navigation tabs, clearing chat, fetching updated list of characters.
* It also ensures to save the settings after all the operations.
*/
async function removeCharacterFromUI() {
preserveNeutralChat();
await clearChat();
$('#character_cross').trigger('click');
resetChatState();
$(document.getElementById('rm_button_selected_ch')).children('h2').text('');
restoreNeutralChat();
await getCharacters();
await printMessages();
saveSettingsDebounced();
await eventSource.emit(event_types.CHAT_CHANGED, getCurrentChatId());
}
/**
* Creates a new assistant chat.
* @param {object} params - Parameters for the new assistant chat
* @param {boolean} [params.temporary=false] I need a temporary secretary
* @returns {Promise<void>} - A promise that resolves when the new assistant chat is created
*/
export async function newAssistantChat({ temporary = false } = {}) {
await clearChat();
if (!temporary) {
return openPermanentAssistantChat();
}
chat.splice(0, chat.length);
chat_metadata = {};
setCharacterName(neutralCharacterName);
sendSystemMessage(system_message_types.ASSISTANT_NOTE);
}
/**
* Event handler to open a navbar drawer when a drawer open button is clicked.
* Handles click events on .drawer-opener elements.
* Opens the drawer associated with the clicked button according to the data-target attribute.
* @returns {void}
*/
function doDrawerOpenClick() {
const targetDrawerID = $(this).attr('data-target');
const drawer = $(`#${targetDrawerID}`);
const drawerToggle = drawer.find('.drawer-toggle');
const drawerWasOpenAlready = drawerToggle.parent().find('.drawer-content').hasClass('openDrawer');
if (drawerWasOpenAlready || drawer.hasClass('resizing')) { return; }
doNavbarIconClick.call(drawerToggle);
}
/**
* Event handler to open or close a navbar drawer when a navbar icon is clicked.
* Handles click events on .drawer-toggle elements.
* @returns {Promise<void>}
*/
export async function doNavbarIconClick() {
const icon = $(this).find('.drawer-icon');
const drawer = $(this).parent().find('.drawer-content');
const drawerWasOpenAlready = $(this).parent().find('.drawer-content').hasClass('openDrawer');
const targetDrawerID = $(this).parent().find('.drawer-content').attr('id');
if (!drawerWasOpenAlready) {
const $openDrawers = $('.openDrawer:not(.pinnedOpen)');
const $openIcons = $('.openIcon:not(.drawerPinnedOpen)');
for (const iconEl of $openIcons) {
$(iconEl).toggleClass('closedIcon openIcon');
}
for (const el of $openDrawers) {
$(el).toggleClass('closedDrawer openDrawer');
}
if ($openDrawers.length && animation_duration) {
await delay(animation_duration);
}
icon.toggleClass('openIcon closedIcon');
drawer.toggleClass('openDrawer closedDrawer');
if (targetDrawerID === 'right-nav-panel') {
favsToHotswap();
$('#rm_print_characters_block').trigger('scroll');
}
// Set the height of "autoSetHeight" textareas within the drawer to their scroll height
if (!CSS.supports('field-sizing', 'content')) {
const textareas = $(this).closest('.drawer').find('.drawer-content textarea.autoSetHeight');
for (const textarea of textareas) {
await resetScrollHeight($(textarea));
}
}
} else if (drawerWasOpenAlready) {
icon.toggleClass('closedIcon openIcon');
drawer.toggleClass('closedDrawer openDrawer');
}
}
function addDebugFunctions() {
const doBackfill = async () => {
for (const message of chat) {
// System messages are not counted
if (message.is_system) {
continue;
}
if (!message.extra) {
message.extra = {};
}
const tokenCountText = (message?.extra?.reasoning || '') + message.mes;
message.extra.token_count = await getTokenCountAsync(tokenCountText, 0);
}
await saveChatConditional();
await reloadCurrentChat();
};
registerDebugFunction('forceOnboarding', 'Force onboarding', 'Forces the onboarding process to restart.', async () => {
firstRun = true;
await saveSettings();
location.reload();
});
registerDebugFunction('backfillTokenCounts', 'Backfill token counters',
`Recalculates token counts of all messages in the current chat to refresh the counters.
Useful when you switch between models that have different tokenizers.
This is a visual change only. Your chat will be reloaded.`, doBackfill);
registerDebugFunction('generationTest', 'Send a generation request', 'Generates text using the currently selected API.', async () => {
const text = prompt('Input text:', 'Hello');
toastr.info('Working on it...');
const message = await generateRaw({ prompt: text });
alert(message);
});
registerDebugFunction('toggleEventTracing', 'Toggle event tracing', 'Useful to see what triggered a certain event.', () => {
localStorage.setItem('eventTracing', localStorage.getItem('eventTracing') === 'true' ? 'false' : 'true');
toastr.info('Event tracing is now ' + (localStorage.getItem('eventTracing') === 'true' ? 'enabled' : 'disabled'));
});
registerDebugFunction('toggleRegenerateWarning', 'Toggle Ctrl+Enter regeneration confirmation', 'Toggle the warning when regenerating a message with a Ctrl+Enter hotkey.', () => {
accountStorage.setItem('RegenerateWithCtrlEnter', accountStorage.getItem('RegenerateWithCtrlEnter') === 'true' ? 'false' : 'true');
toastr.info('Regenerate warning is now ' + (accountStorage.getItem('RegenerateWithCtrlEnter') === 'true' ? 'disabled' : 'enabled'));
});
registerDebugFunction('copySetup', 'Copy ST setup to clipboard [WIP]', 'Useful data when reporting bugs', async () => {
const getContextContents = getContext();
const getSettingsContents = settings;
//console.log(getSettingsContents);
const logMessage = `
\`\`\`
API: ${getSettingsContents.main_api}
API Type: ${getSettingsContents[getSettingsContents.main_api + '_settings'].type}
API server: ${getSettingsContents.api_server}
Model: ${getContextContents.onlineStatus}
Context Template: ${power_user.context.preset}
Instruct Template: ${power_user.instruct.preset}
API Settings: ${JSON.stringify(getSettingsContents[getSettingsContents.main_api + '_settings'], null, 2)}
\`\`\`
`;
//console.log(getSettingsContents)
//console.log(logMessage);
try {
await copyText(logMessage);
toastr.info('Your ST API setup data has been copied to the clipboard.');
} catch (error) {
toastr.error('Failed to copy ST Setup to clipboard:', error);
}
});
}
function initCharacterSearch() {
const debouncedCharacterSearch = debounce((searchQuery) => {
entitiesFilter.setFilterData(FILTER_TYPES.SEARCH, searchQuery);
});
const searchForm = $('#form_character_search_form');
const searchInput = $('#character_search_bar');
const searchButton = $('#rm_button_search');
const storageKey = 'characterSearchFormVisible';
searchInput.on('input', function () {
const searchQuery = String($(this).val());
debouncedCharacterSearch(searchQuery);
});
searchButton.on('click', function () {
const newVisibility = !searchForm.is(':visible');
searchForm.toggle(newVisibility);
searchButton.toggleClass('active', newVisibility);
accountStorage.setItem(storageKey, String(newVisibility));
if (newVisibility) {
searchInput.trigger('focus');
}
});
eventSource.on(event_types.APP_READY, () => {
const isVisible = accountStorage.getItem(storageKey) === 'true';
searchForm.toggle(isVisible);
searchButton.toggleClass('active', isVisible);
});
}
// MARK: DOM Handlers Start
jQuery(async function () {
setTimeout(function () {
$('#groupControlsToggle').trigger('click');
$('#groupCurrentMemberListToggle .inline-drawer-icon').trigger('click');
}, 200);
$(document).on('click', '.api_loading', () => cancelStatusCheck('Canceled because connecting was manually canceled'));
//////////INPUT BAR FOCUS-KEEPING LOGIC/////////////
let S_TAPreviouslyFocused = false;
$('#send_textarea').on('focusin focus click', () => {
S_TAPreviouslyFocused = true;
});
$('#send_but, #option_regenerate, #option_continue, #mes_continue, #mes_impersonate').on('click', () => {
if (S_TAPreviouslyFocused) {
$('#send_textarea').trigger('focus');
}
});
$(document).on('click', event => {
if ($(':focus').attr('id') !== 'send_textarea') {
var validIDs = ['options_button', 'send_but', 'mes_impersonate', 'mes_continue', 'send_textarea', 'option_regenerate', 'option_continue'];
if (!validIDs.includes($(event.target).attr('id'))) {
S_TAPreviouslyFocused = false;
}
} else {
S_TAPreviouslyFocused = true;
}
});
/////////////////
$('#swipes-checkbox').on('change', function () {
swipes = !!$('#swipes-checkbox').prop('checked');
if (swipes) {
//console.log('toggle change calling showswipebtns');
showSwipeButtons();
} else {
hideSwipeButtons();
}
saveSettingsDebounced();
});
///// SWIPE BUTTON CLICKS ///////
//limit swiping to only last message clicks
$(document).on('click', '.last_mes .swipe_right', async (e, data) => await swipe(e, SWIPE_DIRECTION.RIGHT, data));
$(document).on('click', '.last_mes .swipe_left', async (e, data) => await swipe(e, SWIPE_DIRECTION.LEFT, data));
initCharacterSearch();
$('#mes_impersonate').on('click', function () {
$('#option_impersonate').trigger('click');
});
$('#mes_continue').on('click', function () {
$('#option_continue').trigger('click');
});
const userInputGenerateMutex = new SimpleMutex(sendTextareaMessage);
$('#send_but').on('click', async function () {
await userInputGenerateMutex.update();
});
//menu buttons setup
$('#rm_button_settings').on('click', function () {
selected_button = 'settings';
selectRightMenuWithAnimation('rm_api_block');
});
$('#rm_button_characters').on('click', function () {
selected_button = 'characters';
select_rm_characters();
});
$('#rm_button_back').on('click', function () {
selected_button = 'characters';
select_rm_characters();
});
$('#rm_button_create').on('click', function () {
selected_button = 'create';
select_rm_create();
});
$('#rm_button_selected_ch').on('click', function () {
if (selected_group) {
select_group_chats(selected_group, false);
} else {
selected_button = 'character_edit';
select_selected_character(this_chid);
}
$('#character_search_bar').val('').trigger('input');
});
$(document).on('click', '.character_select', async function () {
const id = Number($(this).attr('data-chid'));
await selectCharacterById(id);
});
$(document).on('click', '.bogus_folder_select', function () {
const tagId = $(this).attr('tagid');
console.debug('Bogus folder clicked', tagId);
chooseBogusFolder($(this), tagId);
});
const cssAutofit = CSS.supports('field-sizing', 'content');
if (!cssAutofit) {
/**
* Sets the scroll height of the edit textarea to fit the content.
* @param {HTMLTextAreaElement} e Textarea element to auto-fit
*/
function autoFitEditTextArea(e) {
const scrollTop = chatElement.scrollTop();
e.style.height = '0px';
const newHeight = e.scrollHeight + 4;
e.style.height = `${newHeight}px`;
chatElement.scrollTop(scrollTop);
}
const autoFitEditTextAreaDebounced = debounce(autoFitEditTextArea, debounce_timeout.short);
document.addEventListener('input', e => {
if (e.target instanceof HTMLTextAreaElement && e.target.classList.contains('edit_textarea')) {
const scrollbarShown = e.target.clientWidth < e.target.offsetWidth && e.target.offsetHeight >= window.innerHeight * 0.75;
const immediately = (e.target.scrollHeight > e.target.offsetHeight && !scrollbarShown) || e.target.value === '';
immediately ? autoFitEditTextArea(e.target) : autoFitEditTextAreaDebounced(e.target);
}
});
}
const chatElementScroll = document.getElementById('chat');
const chatScrollHandler = function () {
if (power_user.waifuMode) {
scrollLock = true;
return;
}
const scrollIsAtBottom = Math.abs(chatElementScroll.scrollHeight - chatElementScroll.clientHeight - chatElementScroll.scrollTop) < 5;
// Resume autoscroll if the user scrolls to the bottom
if (scrollLock && scrollIsAtBottom) {
scrollLock = false;
}
// Cancel autoscroll if the user scrolls up
if (!scrollLock && !scrollIsAtBottom) {
scrollLock = true;
}
};
chatElementScroll.addEventListener('scroll', chatScrollHandler, { passive: true });
$(document).on('click', '.mes', function () {
//when a 'delete message' parent div is clicked
// and we are in delete mode and del_checkbox is visible
if (!is_delete_mode || !$(this).children('.del_checkbox').is(':visible')) {
return;
}
$('.mes').children('.del_checkbox').each(function () {
$(this).prop('checked', false);
$(this).parent().removeClass('selected');
});
$(this).addClass('selected'); //sets the bg of the mes selected for deletion
var i = Number($(this).attr('mesid')); //checks the message ID in the chat
this_del_mes = i;
//as long as the current message ID is less than the total chat length
while (i < chat.length) {
//sets the bg of the all msgs BELOW the selected .mes
$(`.mes[mesid="${i}"]`).addClass('selected');
$(`.mes[mesid="${i}"]`).children('.del_checkbox').prop('checked', true);
i++;
}
});
/**
* Handles the deletion of a chat file, including group chats.
*
* @param {string} chatFile - The name of the chat file to delete.
* @param {object} group - The group object if the chat is part of a group.
* @param {boolean} [fromSlashCommand=false] - Whether the deletion was triggered from a slash command.
* @returns {Promise<void>}
*/
async function handleDeleteChat(chatFile, group, fromSlashCommand = false) {
// Close past chat popup.
$('#select_chat_cross').trigger('click');
showLoader();
if (group) {
await deleteGroupChat(group, chatFile);
} else {
await delChat(chatFile);
}
if (fromSlashCommand) { // When called from `/delchat` command, don't re-open the history view.
$('#options').hide(); // Hide option popup menu.
hideLoader();
} else { // Open the history view again after 2 seconds (delay to avoid edge cases for deleting last chat).
setTimeout(function () {
$('#option_select_chat').trigger('click');
$('#options').hide(); // Hide option popup menu.
hideLoader();
}, 2000);
}
}
$(document).on('click', '.PastChat_cross', async function (e, { fromSlashCommand = false } = {}) {
e.stopPropagation();
chat_file_for_del = $(this).attr('file_name');
console.debug('detected cross click for' + chat_file_for_del);
// Skip confirmation if called from a slash command.
if (fromSlashCommand) {
await handleDeleteChat(chat_file_for_del, selected_group, true);
return;
}
const result = await callGenericPopup('<h3>' + t`Delete the Chat File?` + '</h3>', POPUP_TYPE.CONFIRM);
if (result === POPUP_RESULT.AFFIRMATIVE) {
await handleDeleteChat(chat_file_for_del, selected_group, false);
}
});
$('#advanced_div').on('click', function () {
if (!is_advanced_char_open) {
is_advanced_char_open = true;
$('#character_popup').css({ 'display': 'flex', 'opacity': 0.0 }).addClass('open');
$('#character_popup').transition({
opacity: 1.0,
duration: animation_duration,
easing: animation_easing,
});
} else {
is_advanced_char_open = false;
$('#character_popup').css('display', 'none').removeClass('open');
}
});
$('#character_cross').on('click', function () {
is_advanced_char_open = false;
$('#character_popup').transition({
opacity: 0,
duration: animation_duration,
easing: animation_easing,
});
setTimeout(function () { $('#character_popup').css('display', 'none'); }, animation_duration);
});
$('#character_popup_ok').on('click', function () {
is_advanced_char_open = false;
$('#character_popup').css('display', 'none');
});
$('#dialogue_popup_ok').on('click', async function (_e, customData) {
const fromSlashCommand = customData?.fromSlashCommand || false;
dialogueCloseStop = false;
$('#shadow_popup').transition({
opacity: 0,
duration: animation_duration,
easing: animation_easing,
});
setTimeout(function () {
if (dialogueCloseStop) return;
$('#shadow_popup').css('display', 'none');
$('#dialogue_popup').removeClass('large_dialogue_popup');
$('#dialogue_popup').removeClass('wide_dialogue_popup');
}, animation_duration);
if (popup_type == 'del_chat') {
await handleDeleteChat(chat_file_for_del, selected_group, fromSlashCommand);
}
if (dialogueResolve) {
if (popup_type == 'input') {
dialogueResolve($('#dialogue_popup_input').val());
$('#dialogue_popup_input').val('');
}
else {
dialogueResolve(true);
}
dialogueResolve = null;
}
});
$('#dialogue_popup_cancel').on('click', function (e) {
dialogueCloseStop = false;
$('#shadow_popup').transition({
opacity: 0,
duration: animation_duration,
easing: animation_easing,
});
setTimeout(function () {
if (dialogueCloseStop) return;
$('#shadow_popup').css('display', 'none');
$('#dialogue_popup').removeClass('large_dialogue_popup');
}, animation_duration);
popup_type = '';
if (dialogueResolve) {
dialogueResolve(false);
dialogueResolve = null;
}
});
$('#add_avatar_button').on('change', function () {
const inputElement = /** @type {HTMLInputElement} */ (this);
read_avatar_load(inputElement);
});
$('#form_create').on('submit', (e) => createOrEditCharacter(e.originalEvent));
$('#delete_button').on('click', async function () {
if (this_chid === undefined || !characters[this_chid]) {
toastr.warning('No character selected.');
return;
}
let deleteChats = false;
const confirm = await Popup.show.confirm(t`Delete the character?`, await renderTemplateAsync('deleteConfirm'), {
onClose: () => { deleteChats = !!$('#del_char_checkbox').prop('checked'); },
});
if (!confirm) {
return;
}
await deleteCharacter(characters[this_chid].avatar, { deleteChats: deleteChats });
});
//////// OPTIMIZED ALL CHAR CREATION/EDITING TEXTAREA LISTENERS ///////////////
$('#character_name_pole').on('input', function () {
if (menu_type == 'create') {
create_save.name = String($('#character_name_pole').val());
}
});
const elementsToUpdate = {
'#description_textarea': function () { create_save.description = String($('#description_textarea').val()); },
'#creator_notes_textarea': function () { create_save.creator_notes = String($('#creator_notes_textarea').val()); },
'#character_version_textarea': function () { create_save.character_version = String($('#character_version_textarea').val()); },
'#system_prompt_textarea': function () { create_save.system_prompt = String($('#system_prompt_textarea').val()); },
'#post_history_instructions_textarea': function () { create_save.post_history_instructions = String($('#post_history_instructions_textarea').val()); },
'#creator_textarea': function () { create_save.creator = String($('#creator_textarea').val()); },
'#tags_textarea': function () { create_save.tags = String($('#tags_textarea').val()); },
'#personality_textarea': function () { create_save.personality = String($('#personality_textarea').val()); },
'#scenario_pole': function () { create_save.scenario = String($('#scenario_pole').val()); },
'#mes_example_textarea': function () { create_save.mes_example = String($('#mes_example_textarea').val()); },
'#firstmessage_textarea': function () { create_save.first_message = String($('#firstmessage_textarea').val()); },
'#talkativeness_slider': function () { create_save.talkativeness = Number($('#talkativeness_slider').val()); },
'#depth_prompt_prompt': function () { create_save.depth_prompt_prompt = String($('#depth_prompt_prompt').val()); },
'#depth_prompt_depth': function () { create_save.depth_prompt_depth = Number($('#depth_prompt_depth').val()); },
'#depth_prompt_role': function () { create_save.depth_prompt_role = String($('#depth_prompt_role').val()); },
};
Object.keys(elementsToUpdate).forEach(function (id) {
$(id).on('input', function () {
if (menu_type == 'create') {
elementsToUpdate[id]();
} else {
saveCharacterDebounced();
}
});
});
$('#creator_notes_textarea').on('input', function () {
const notes = String($('#creator_notes_textarea').val());
const avatar = menu_type === 'create' ? '' : characters[this_chid]?.avatar;
$('#creator_notes_spoiler').html(formatCreatorNotes(notes, avatar));
});
$('#favorite_button').on('click', function () {
updateFavButtonState(!fav_ch_checked);
if (menu_type != 'create') {
saveCharacterDebounced();
}
});
/* $("#renameCharButton").on('click', renameCharacter); */
$(document).on('click', '.renameChatButton', async function (e) {
e.stopPropagation();
const oldFileNameFull = $(this).closest('.select_chat_block_wrapper').find('.select_chat_block_filename').text();
const oldFileName = oldFileNameFull.replace('.jsonl', '');
const popupText = await renderTemplateAsync('chatRename');
const newName = await callGenericPopup(popupText, POPUP_TYPE.INPUT, oldFileName);
if (!newName || typeof newName !== 'string' || newName == oldFileName) {
console.log('no new name found, aborting');
return;
}
await renameChat(oldFileName, newName);
await delay(250);
$('#option_select_chat').trigger('click');
$('#options').hide();
});
$(document).on('click', '.exportChatButton, .exportRawChatButton', async function (e) {
e.stopPropagation();
const format = $(this).data('format') || 'txt';
await saveChatConditional();
const filenamefull = $(this).closest('.select_chat_block_wrapper').find('.select_chat_block_filename').text();
console.log(`exporting ${filenamefull} in ${format} format`);
const filename = filenamefull.replace('.jsonl', '');
const body = {
is_group: !!selected_group,
avatar_url: characters[this_chid]?.avatar,
file: `${filename}.jsonl`,
exportfilename: `${filename}.${format}`,
format: format,
};
console.log(body);
try {
const response = await fetch('/api/chats/export', {
method: 'POST',
body: JSON.stringify(body),
headers: getRequestHeaders(),
});
const data = await response.json();
if (!response.ok) {
// display error message
console.log(data.message);
await delay(250);
toastr.error(`Error: ${data.message}`);
return;
} else {
const mimeType = format == 'txt' ? 'text/plain' : 'application/octet-stream';
// success, handle response data
console.log(data);
await delay(250);
toastr.success(data.message);
download(data.result, body.exportfilename, mimeType);
}
} catch (error) {
// display error message
console.log(`An error has occurred: ${error.message}`);
await delay(250);
toastr.error(`Error: ${error.message}`);
}
});
const button = $('#options_button');
const menu = $('#options');
let isOptionsMenuVisible = false;
function showMenu() {
showBookmarksButtons();
menu.fadeIn(animation_duration);
optionsPopper.update();
isOptionsMenuVisible = true;
}
function hideMenu() {
menu.fadeOut(animation_duration);
optionsPopper.update();
isOptionsMenuVisible = false;
}
function isMouseOverButtonOrMenu() {
return menu.is(':hover, :focus-within') || button.is(':hover, :focus');
}
button.on('click', function () {
if (isOptionsMenuVisible) {
hideMenu();
} else {
showMenu();
}
});
$(document).on('click', function () {
if (!isOptionsMenuVisible) return;
if (!isMouseOverButtonOrMenu()) { hideMenu(); }
});
/* $('#set_chat_character_settings').on('click', setScenarioOverride); */
///////////// OPTIMIZED LISTENERS FOR LEFT SIDE OPTIONS POPUP MENU //////////////////////
$('#options [id]').on('click', async function (event, customData) {
const fromSlashCommand = customData?.fromSlashCommand || false;
var id = $(this).attr('id');
// Check whether a custom prompt was provided via custom data (for example through a slash command)
const additionalPrompt = customData?.additionalPrompt?.trim() || undefined;
const buildOrFillAdditionalArgs = (args = {}) => ({
...args,
...(additionalPrompt !== undefined && { quiet_prompt: additionalPrompt, quietToLoud: true }),
});
if (id == 'option_select_chat') {
if (this_chid === undefined && !is_send_press && !selected_group) {
await openPermanentAssistantCard();
}
if ((selected_group && !is_group_generating) || (this_chid !== undefined && !is_send_press) || fromSlashCommand) {
await displayPastChats();
//this is just to avoid the shadow for past chat view when using /delchat
//however, the dialog popup still gets one..
if (!fromSlashCommand) {
console.log('displaying shadow');
$('#shadow_select_chat_popup').css('display', 'block');
$('#shadow_select_chat_popup').css('opacity', 0.0);
$('#shadow_select_chat_popup').transition({
opacity: 1.0,
duration: animation_duration,
easing: animation_easing,
});
}
}
}
else if (id == 'option_start_new_chat') {
if ((selected_group || this_chid !== undefined) && !is_send_press) {
let deleteCurrentChat = false;
const result = await Popup.show.confirm(t`Start new chat?`, await renderTemplateAsync('newChatConfirm'), {
onClose: () => { deleteCurrentChat = !!$('#del_chat_checkbox').prop('checked'); },
});
if (!result) {
return;
}
await doNewChat({ deleteCurrentChat: deleteCurrentChat });
}
if (!selected_group && this_chid === undefined && !is_send_press) {
const alreadyInTempChat = this_chid === undefined && name2 === neutralCharacterName;
await newAssistantChat({ temporary: alreadyInTempChat });
}
}
else if (id == 'option_regenerate') {
//Attempting to regenerate a user message will instead generate a new message.
if (chat.length && chat.length - 1 === this_edit_mes_id && chat[this_edit_mes_id]?.is_user == false) {
toastr.warning(t`Finish the edit before starting a generation.`, t`You cannot regenerate the message you are editing.`);
return;
}
if (is_send_press == false) {
if (selected_group) {
regenerateGroup();
}
else {
is_send_press = true;
Generate('regenerate', buildOrFillAdditionalArgs());
}
}
}
else if (id == 'option_impersonate') {
if (is_send_press == false || fromSlashCommand) {
is_send_press = true;
Generate('impersonate', buildOrFillAdditionalArgs());
}
}
else if (id == 'option_continue') {
if (swipeState == SWIPE_STATE.EDITING) {
toastr.warning(t`Confirm the edit to start a generation.`, t`You cannot send a message during a swipe-edit.`);
return;
}
if (chat.length && chat.length - 1 === this_edit_mes_id) {
toastr.warning(t`Finish the edit before starting a generation.`, t`You cannot continue the message you are editing.`);
return;
}
if (is_send_press == false || fromSlashCommand) {
is_send_press = true;
Generate('continue', buildOrFillAdditionalArgs());
}
}
else if (id == 'option_delete_mes') {
setTimeout(() => openMessageDelete(fromSlashCommand), animation_duration);
}
else if (id == 'option_close_chat') {
await closeCurrentChat();
}
else if (id === 'option_settings') {
//var checkBox = document.getElementById("waifuMode");
var topBar = document.getElementById('top-bar');
var topSettingsHolder = document.getElementById('top-settings-holder');
var divchat = document.getElementById('chat');
//if (checkBox.checked) {
if (topBar.style.display === 'none') {
topBar.style.display = ''; // or "inline-block" if that's the original display value
topSettingsHolder.style.display = ''; // or "inline-block" if that's the original display value
divchat.style.borderRadius = '';
divchat.style.backgroundColor = '';
} else {
divchat.style.borderRadius = '10px'; // Adjust the value to control the roundness of the corners
divchat.style.backgroundColor = ''; // Set the background color to your preference
topBar.style.display = 'none';
topSettingsHolder.style.display = 'none';
}
//}
}
hideMenu();
});
$('#newChatFromManageScreenButton').on('click', async function () {
await doNewChat({ deleteCurrentChat: false });
$('#select_chat_cross').trigger('click');
});
//////////////////////////////////////////////////////////////////////////////////////////////
//functionality for the cancel delete messages button, reverts to normal display of input form
$('#dialogue_del_mes_cancel').on('click', function () {
$('#dialogue_del_mes').css('display', 'none');
$('#send_form').css('display', css_send_form_display);
$('.del_checkbox').each(function () {
$(this).css('display', 'none');
$(this).parent().children('.for_checkbox').css('display', 'block');
$(this).parent().removeClass('selected');
$(this).prop('checked', false);
});
showSwipeButtons();
this_del_mes = -1;
is_delete_mode = false;
});
//confirms message deletion with the "ok" button
$('#dialogue_del_mes_ok').on('click', async function () {
$('#dialogue_del_mes').css('display', 'none');
$('#send_form').css('display', css_send_form_display);
$('.del_checkbox').each(function () {
$(this).css('display', 'none');
$(this).parent().children('.for_checkbox').css('display', 'block');
$(this).parent().removeClass('selected');
$(this).prop('checked', false);
});
if (this_del_mes >= 0) {
chatElement.find(`.mes[mesid="${this_del_mes}"]`).nextAll('div').remove();
chatElement.find(`.mes[mesid="${this_del_mes}"]`).remove();
chat.length = this_del_mes;
chat_metadata['tainted'] = true;
await saveChatConditional();
chatElement.scrollTop(chatElement[0].scrollHeight);
await eventSource.emit(event_types.MESSAGE_DELETED, chat.length);
chatElement.find('.mes').removeClass('last_mes');
chatElement.find('.mes').last().addClass('last_mes');
} else {
console.log('this_del_mes is not >= 0, not deleting');
}
showSwipeButtons();
this_del_mes = -1;
is_delete_mode = false;
});
$('#main_api').on('change', async function () {
cancelStatusCheck('Canceled because main api changed');
changeMainAPI();
saveSettingsDebounced();
await eventSource.emit(event_types.MAIN_API_CHANGED, { apiId: main_api });
});
////////////////// OPTIMIZED RANGE SLIDER LISTENERS////////////////
var sliderLocked = true;
var sliderTimer;
$('input[type=\'range\']').on('touchstart', function () {
// Unlock the slider after 300ms
setTimeout(function () {
sliderLocked = false;
$(this).css('background-color', 'var(--SmartThemeQuoteColor)');
}.bind(this), 300);
});
$('input[type=\'range\']').on('touchend', function () {
clearTimeout(sliderTimer);
$(this).css('background-color', '');
sliderLocked = true;
});
$('input[type=\'range\']').on('touchmove', function (event) {
if (sliderLocked) {
event.preventDefault();
}
});
const sliders = [
{
sliderId: '#amount_gen',
counterId: '#amount_gen_counter',
format: (val) => `${val}`,
setValue: (val) => { amount_gen = Number(val); },
},
{
sliderId: '#max_context',
counterId: '#max_context_counter',
format: (val) => `${val}`,
setValue: (val) => { max_context = Number(val); },
},
];
sliders.forEach(slider => {
$(document).on('input', slider.sliderId, function () {
const value = $(this).val();
const formattedValue = slider.format(value);
slider.setValue(value);
$(slider.counterId).val(formattedValue);
saveSettingsDebounced();
});
});
//////////////////////////////////////////////////////////////
$('#select_chat_cross').on('click', function () {
$('#shadow_select_chat_popup').transition({
opacity: 0,
duration: animation_duration,
easing: animation_easing,
});
setTimeout(function () { $('#shadow_select_chat_popup').css('display', 'none'); }, animation_duration);
});
$(document).on('pointerup', '.mes_copy', async function () {
if (this_chid !== undefined || selected_group || name2 === neutralCharacterName) {
try {
const messageId = $(this).closest('.mes').attr('mesid');
const text = chat[messageId]['mes'];
await copyText(text);
toastr.info('Copied!', '', { timeOut: 2000 });
} catch (err) {
console.error('Failed to copy: ', err);
}
}
});
//********************
//***Message Editor***
$(document).on('click', '.mes_edit', async function () {
if (is_delete_mode) {
return;
}
if (this_chid !== undefined || selected_group || name2 === neutralCharacterName) {
// Previously system messages we're allowed to be edited
/*const message = $(this).closest(".mes");
if (message.data("isSystem")) {
return;
}*/
if (this_edit_mes_id >= 0) {
let mes_edited = chatElement.find(`[mesid="${this_edit_mes_id}"]`).find('.mes_edit_done');
if (Number(edit_mes_id) == chat.length - 1) { //if the generating swipe (...)
let run_edit = true;
if (chat[edit_mes_id]['swipe_id'] !== undefined) {
if (chat[edit_mes_id]['swipes'].length === chat[edit_mes_id]['swipe_id']) {
run_edit = false;
}
}
if (run_edit) {
hideSwipeButtons();
}
}
await messageEditDone(mes_edited);
}
var edit_mes_id = Number($(this).closest('.mes').attr('mesid'));
await messageEdit(edit_mes_id);
}
});
$(document).on('input', '#curEditTextarea', function () {
if (power_user.auto_save_msg_edits === true) {
messageEditAuto($(this));
}
});
$(document).on('click', '.extraMesButtonsHint', function (e) {
const $hint = $(e.target);
const $buttons = $hint.siblings('.extraMesButtons');
$hint.transition({
opacity: 0,
duration: animation_duration,
easing: animation_easing,
complete: function () {
$hint.hide();
$buttons
.addClass('visible')
.css({
opacity: 0,
display: 'flex',
})
.transition({
opacity: 1,
duration: animation_duration,
easing: animation_easing,
});
},
});
});
$(document).on('click', function (e) {
// Expanded options don't need to be closed
if (power_user.expand_message_actions) {
return;
}
// Check if the click was outside the relevant elements
if (!$(e.target).closest('.extraMesButtons, .extraMesButtonsHint').length) {
const $visibleButtons = $('.extraMesButtons.visible');
if (!$visibleButtons.length) {
return;
}
const $hiddenHints = $('.extraMesButtonsHint:hidden');
// Transition out the .extraMesButtons first
$visibleButtons.transition({
opacity: 0,
duration: animation_duration,
easing: animation_easing,
complete: function () {
// Hide the .extraMesButtons after the transition
$(this)
.hide()
.removeClass('visible');
// Transition the .extraMesButtonsHint back in
$hiddenHints
.show()
.transition({
opacity: 0.3,
duration: animation_duration,
easing: animation_easing,
complete: function () {
$(this).css('opacity', '');
},
});
},
});
}
});
$(document).on('click', '.mes_edit_cancel', async function () {
await messageEditCancel.call(this, this_edit_mes_id);
});
$(document).on('click', '.mes_edit_up', async function () {
if (this_edit_mes_id <= 0) {
return;
}
const targetId = Number(this_edit_mes_id) - 1;
await messageEditMove(this_edit_mes_id, targetId);
});
$(document).on('click', '.mes_edit_down', async function () {
if (this_edit_mes_id >= chat.length - 1) {
return;
}
const targetId = Number(this_edit_mes_id) + 1;
await messageEditMove(this_edit_mes_id, targetId);
});
$(document).on('click', '.mes_edit_copy', async function () {
const confirmation = await callGenericPopup(t`Create a copy of this message?`, POPUP_TYPE.CONFIRM);
if (!confirmation) {
return;
}
hideSwipeButtons();
const oldScroll = chatElement[0].scrollTop;
const clone = structuredClone(chat[this_edit_mes_id]);
clone.send_date = Date.now();
clone.mes = $(this).closest('.mes').find('.edit_textarea').val().toString();
if (power_user.trim_spaces) {
clone.mes = clone.mes.trim();
}
chat.splice(Number(this_edit_mes_id) + 1, 0, clone);
addOneMessage(clone, { insertAfter: this_edit_mes_id });
updateViewMessageIds();
await saveChatConditional();
chatElement[0].scrollTop = oldScroll;
showSwipeButtons();
});
$(document).on('click', '.mes_edit_delete', async function (event, customData) {
const fromSlashCommand = customData?.fromSlashCommand || false;
const message = chat[this_edit_mes_id];
const selectedSwipe = message['swipe_id'] ?? undefined;
const swipesArray = Array.isArray(message['swipes']) ? message['swipes'] : [];
const canDeleteSwipe = power_user.confirm_message_delete && !fromSlashCommand && !message.is_user && swipesArray.length > 1 && this_edit_mes_id === chat.length - 1 && selectedSwipe !== undefined;
await deleteMessage(Number(this_edit_mes_id), canDeleteSwipe ? selectedSwipe : undefined, power_user.confirm_message_delete && fromSlashCommand !== true);
});
$(document).on('click', '.mes_edit_done', async function () {
await messageEditDone($(this));
});
//Select chat
//**************************CHARACTER IMPORT EXPORT*************************//
$('#character_import_button').on('click', function () {
$('#character_import_file').trigger('click');
});
$('#character_import_file').on('change', async function (e) {
$('#rm_info_avatar').html('');
if (!(e.target instanceof HTMLInputElement)) {
return;
}
if (!e.target.files.length) {
return;
}
const avatarFileNames = [];
for (const file of e.target.files) {
const avatarFileName = await importCharacter(file);
if (avatarFileName !== undefined) {
avatarFileNames.push(avatarFileName);
}
}
if (avatarFileNames.length > 0) {
await importCharactersTags(avatarFileNames);
selectImportedChar(avatarFileNames[avatarFileNames.length - 1]);
}
// Clear the file input value to allow re-uploading the same file
e.target.value = '';
});
$('#export_button').on('click', function () {
isExportPopupOpen = !isExportPopupOpen;
$('#export_format_popup').toggle(isExportPopupOpen);
exportPopper.update();
});
$(document).on('click', '.export_format', async function () {
const format = $(this).data('format');
if (!format) {
return;
}
$('#export_format_popup').hide();
isExportPopupOpen = false;
exportPopper.update();
// Save before exporting
await createOrEditCharacter();
const body = { format, avatar_url: characters[this_chid].avatar };
const response = await fetch('/api/characters/export', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(body),
});
if (response.ok) {
const filename = characters[this_chid].avatar.replace('.png', `.${format}`);
const blob = await response.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.setAttribute('download', filename);
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
}
});
//**************************CHAT IMPORT EXPORT*************************//
$('#chat_import_button').on('click', function () {
$('#chat_import_file').trigger('click');
});
$('#chat_import_file').on('change', async function (e) {
const targetElement = e.target;
const formElement = document.getElementById('form_import_chat');
if (!(targetElement instanceof HTMLInputElement) || !(formElement instanceof HTMLFormElement)) {
return;
}
const importedFileNames = [];
for (const file of targetElement.files) {
const ext = file.name.match(/\.(\w+)$/);
const format = ext?.[1]?.toLowerCase();
if (!['json', 'jsonl'].includes(format)) {
toastr.warning(t`Only JSON and JSONL files are supported for chat imports.`);
continue;
}
if (selected_group && format === 'json') {
toastr.warning(t`Only SillyTavern's own format is supported for group chat imports. Sorry!`);
continue;
}
const formData = new FormData(formElement);
formData.set('file_type', format);
formData.set('avatar', file);
formData.set('user_name', name1);
const importFn = selected_group ? importGroupChat : importCharacterChat;
const result = await importFn(formData, { refresh: false });
importedFileNames.push(...result);
}
if (importedFileNames.length > 0) {
toastr.success(t`Successfully imported ${importedFileNames.length} chat(s).`);
}
await displayPastChats(importedFileNames);
targetElement.value = '';
});
$('#rm_button_group_chats').on('click', function () {
selected_button = 'group_chats';
select_group_chats(null, false);
});
$('#rm_button_back_from_group').on('click', function () {
selected_button = 'characters';
select_rm_characters();
});
$('#dupe_button').on('click', async function () {
await duplicateCharacter();
});
$(document).on('click', '.mes_stop', function () {
stopGeneration();
});
$(document).on('click', '#form_sheld .stscript_continue', function () {
pauseScriptExecution();
});
$(document).on('click', '#form_sheld .stscript_pause', function () {
pauseScriptExecution();
});
$(document).on('click', '#form_sheld .stscript_stop', function () {
stopScriptExecution();
});
$(document).on('click', '.drawer-opener', doDrawerOpenClick);
$('.drawer-toggle').on('click', doNavbarIconClick);
$('html').on('touchstart mousedown', async function (e) {
const clickTarget = $(e.target);
if (isExportPopupOpen
&& clickTarget.closest('#export_button').length == 0
&& clickTarget.closest('#export_format_popup').length == 0) {
$('#export_format_popup').hide();
isExportPopupOpen = false;
exportPopper.update();
}
const forbiddenTargets = [
'#character_cross',
'#avatar-and-name-block',
'#shadow_popup',
'.popup',
'#world_popup',
'.ui-widget',
'.text_pole',
'#toast-container',
'.select2-results',
];
for (const id of forbiddenTargets) {
if (clickTarget.closest(id).length > 0) {
return;
}
}
// This autocloses open drawers that are not pinned if a click happens inside the app which does not target them.
const targetParentHasOpenDrawer = clickTarget.parents('.openDrawer').length;
if (!clickTarget.hasClass('drawer-icon') && !clickTarget.hasClass('openDrawer')) {
const $openDrawers = $('.openDrawer').not('.pinnedOpen');
if ($openDrawers.length && targetParentHasOpenDrawer === 0) {
// Toggle icon and drawer classes
$('.openIcon').not('.drawerPinnedOpen').toggleClass('closedIcon openIcon');
$openDrawers.toggleClass('closedDrawer openDrawer');
}
}
});
$(document).on('click', '.inline-drawer-toggle', async function (e) {
if ($(e.target).hasClass('text_pole')) {
return;
}
const drawer = $(this).closest('.inline-drawer');
const icon = drawer.find('>.inline-drawer-header .inline-drawer-icon');
const drawerContent = drawer.find('>.inline-drawer-content');
icon.toggleClass('down up');
icon.toggleClass('fa-circle-chevron-down fa-circle-chevron-up');
drawer.trigger('inline-drawer-toggle');
drawerContent.stop().slideToggle({
complete: () => {
$(this).css('height', '');
},
});
// Set the height of "autoSetHeight" textareas within the inline-drawer to their scroll height
if (!CSS.supports('field-sizing', 'content')) {
const textareas = drawerContent.find('textarea.autoSetHeight');
for (const textarea of textareas) {
await resetScrollHeight($(textarea));
}
}
});
$(document).on('click', '.inline-drawer-maximize', function () {
const icon = $(this).find('.inline-drawer-icon, .floating_panel_maximize');
icon.toggleClass('fa-window-maximize fa-window-restore');
const drawerContent = $(this).closest('.drawer-content');
drawerContent.toggleClass('maximized');
const drawerId = drawerContent.attr('id');
resetMovableStyles(drawerId);
});
$(document).on('click', '.mes .avatar', function () {
const messageElement = $(this).closest('.mes');
const thumbURL = $(this).children('img').attr('src');
const charsPath = '/characters/';
const targetAvatarImg = thumbURL.substring(thumbURL.lastIndexOf('=') + 1);
const charname = targetAvatarImg.replace('.png', '');
const isValidCharacter = characters.some(x => x.avatar === decodeURIComponent(targetAvatarImg));
// Remove existing zoomed avatars for characters that are not the clicked character when moving UI is not enabled
if (!power_user.movingUI) {
$('.zoomed_avatar').each(function () {
const currentForChar = $(this).attr('forChar');
if (currentForChar !== charname && typeof currentForChar !== 'undefined') {
console.debug(`Removing zoomed avatar for character: ${currentForChar}`);
$(this).remove();
}
});
}
const avatarSrc = (isDataURL(thumbURL) || /^\/?img\/(?:.+)/.test(thumbURL)) ? thumbURL : charsPath + targetAvatarImg;
if ($(`.zoomed_avatar[forChar="${charname}"]`).length) {
console.debug('removing container as it already existed');
$(`.zoomed_avatar[forChar="${charname}"]`).fadeOut(animation_duration, () => {
$(`.zoomed_avatar[forChar="${charname}"]`).remove();
});
} else {
console.debug('making new container from template');
const template = $('#zoomed_avatar_template').html();
const newElement = $(template);
newElement.attr('forChar', charname);
newElement.attr('id', `zoomFor_${charname}`);
newElement.addClass('draggable');
newElement.find('.drag-grabber').attr('id', `zoomFor_${charname}header`);
$('body').append(newElement);
newElement.fadeIn(animation_duration);
const zoomedAvatarImgElement = $(`.zoomed_avatar[forChar="${charname}"] img`);
if (messageElement.attr('is_user') == 'true' || (messageElement.attr('is_system') == 'true' && !isValidCharacter)) {
//handle user and system avatars
const isValidPersona = decodeURIComponent(targetAvatarImg) in power_user.personas;
if (isValidPersona) {
const personaSrc = getUserAvatar(targetAvatarImg);
zoomedAvatarImgElement.attr('src', personaSrc);
zoomedAvatarImgElement.attr('data-izoomify-url', personaSrc);
} else {
zoomedAvatarImgElement.attr('src', thumbURL);
zoomedAvatarImgElement.attr('data-izoomify-url', thumbURL);
}
} else if (messageElement.attr('is_user') == 'false') { //handle char avatars
zoomedAvatarImgElement.attr('src', avatarSrc);
zoomedAvatarImgElement.attr('data-izoomify-url', avatarSrc);
}
loadMovingUIState();
$(`.zoomed_avatar[forChar="${charname}"]`).css('display', 'flex');
dragElement(newElement);
if (power_user.zoomed_avatar_magnification) {
$('.zoomed_avatar_container').izoomify();
}
$('.zoomed_avatar, .zoomed_avatar .dragClose').on('click touchend', (e) => {
if (e.target.closest('.dragClose')) {
$(`.zoomed_avatar[forChar="${charname}"]`).fadeOut(animation_duration, () => {
$(`.zoomed_avatar[forChar="${charname}"]`).remove();
});
}
});
zoomedAvatarImgElement.on('dragstart', (e) => {
console.log('saw drag on avatar!');
e.preventDefault();
return false;
});
}
});
document.addEventListener('click', function (e) {
if (!(e.target instanceof HTMLElement)) return;
if (e.target.matches('#OpenAllWIEntries')) {
document.querySelectorAll('#world_popup_entries_list .inline-drawer').forEach((/** @type {HTMLElement} */ drawer) => {
delay(0).then(() => toggleDrawer(drawer, true));
});
} else if (e.target.matches('#CloseAllWIEntries')) {
document.querySelectorAll('#world_popup_entries_list .inline-drawer').forEach((/** @type {HTMLElement} */ drawer) => {
toggleDrawer(drawer, false);
});
}
});
$(document).on('click', '.open_alternate_greetings', openAlternateGreetings);
/* $('#set_character_world').on('click', openCharacterWorldPopup); */
$(document).on('focus', 'input.auto-select, textarea.auto-select', function () {
if (!power_user.enable_auto_select_input) return;
const control = $(this)[0];
if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) {
control.select();
console.debug('Auto-selecting content of input control', control);
}
});
$(document).on('keydown', function (e) {
if (e.key === 'Escape' && !e.originalEvent.isComposing) {
const isEditVisible = $('#curEditTextarea').is(':visible') || $('.reasoning_edit_textarea').length > 0;
if (isEditVisible && power_user.auto_save_msg_edits === false) {
closeMessageEditor('all');
$('#send_textarea').trigger('focus');
return;
}
if (isEditVisible && power_user.auto_save_msg_edits === true) {
chatElement.find(`.mes[mesid="${this_edit_mes_id}"] .mes_edit_done`).trigger('click');
closeMessageEditor('reasoning');
$('#send_textarea').trigger('focus');
return;
}
if (this_edit_mes_id === undefined && $('#mes_stop').is(':visible')) {
$('#mes_stop').trigger('click');
if (chat.length && Array.isArray(chat[chat.length - 1].swipes) && chat[chat.length - 1].swipe_id == chat[chat.length - 1].swipes.length) {
$('.last_mes .swipe_left').trigger('click');
}
}
}
});
$('#char-management-dropdown').on('change', async (e) => {
const targetElement = /** @type {HTMLSelectElement} */ (e.target);
const target = $(targetElement.selectedOptions).attr('id');
switch (target) {
case 'set_character_world':
await openCharacterWorldPopup();
break;
case 'set_chat_character_settings':
await setCharacterSettingsOverrides();
break;
case 'renameCharButton':
await renameCharacter();
break;
case 'import_character_info':
await importEmbeddedWorldInfo();
saveCharacterDebounced();
break;
case 'character_source': {
const source = getCharacterSource(this_chid);
if (source && isValidUrl(source)) {
const url = new URL(source);
const confirm = await Popup.show.confirm('Open Source', `<span>Do you want to open the link to ${url.hostname} in a new tab?</span><var>${url}</var>`);
if (confirm) {
window.open(source, '_blank');
}
} else {
toastr.info('This character doesn\'t seem to have a source.');
}
} break;
case 'replace_update': {
let onlineUrl = getCharacterSource(this_chid);
const POPUP_RESULT_URL = POPUP_RESULT.CUSTOM1, POPUP_RESULT_FILE = POPUP_RESULT.CUSTOM2;
const result = await Popup.show.confirm(t`Replace Character`,
`<p>${t`Choose a new character card to replace this character with.`}</p>` +
`<p>${t`You can also replace this character with the one from the online source.`}${onlineUrl ? `<br />This character was downloaded from: <var>${onlineUrl}</var>` : ''}</p>` +
`<p>${t`All chats, assets and group memberships will be preserved, but local changes to the character data will be lost.`}<br />${t`Proceed?`}</p>`,
{
okButton: false,
customButtons: [{
text: t`Replace with URL`,
result: POPUP_RESULT_URL,
classes: ['popup-button-ok'],
}, {
text: t`Replace with File`,
result: POPUP_RESULT_FILE,
classes: ['popup-button-ok'],
}],
defaultResult: onlineUrl ? POPUP_RESULT_URL : POPUP_RESULT_FILE,
});
// Remember the chat currently selected, so we can reload it after the replacement
const currentChatFile = characters[this_chid]['chat'];
async function postReplace() {
await openCharacterChat(currentChatFile);
}
switch (result) {
case POPUP_RESULT_FILE: {
async function uploadReplacementCard(e) {
const file = e.target.files[0];
if (!file) {
return;
}
try {
const data = new Map();
data.set(file, characters[this_chid].avatar);
await processDroppedFiles([file], data);
await postReplace();
} catch {
toastr.error('Failed to replace the character card.', 'Something went wrong');
}
}
$('#character_replace_file').off('change').on('change', uploadReplacementCard).trigger('click');
break;
}
case POPUP_RESULT_URL: {
const inputUrl = await Popup.show.input(t`Replace Character from URL`,
`<p>${t`Enter the URL of the character card to replace this character with.`}</p>` +
(onlineUrl ? `<p>${t`This character was downloaded from: <var>${onlineUrl}</var>`}</p>` : ''),
onlineUrl);
if (!inputUrl) {
break;
}
onlineUrl = inputUrl;
await importFromExternalUrl(onlineUrl, { preserveFileName: characters[this_chid].avatar });
await postReplace();
break;
}
}
} break;
case 'import_tags': {
await importTags(characters[this_chid], { importSetting: tag_import_setting.ASK });
} break;
/*case 'delete_button':
popup_type = "del_ch";
callPopup(`
<h3>Delete the character?</h3>
<b>THIS IS PERMANENT!<br><br>
THIS WILL ALSO DELETE ALL<br>
OF THE CHARACTER'S CHAT FILES.<br><br></b>`
);
break;*/
default:
await eventSource.emit(event_types.CHARACTER_MANAGEMENT_DROPDOWN, target);
}
$('#char-management-dropdown').prop('selectedIndex', 0);
});
$(window).on('beforeunload', () => {
cancelTtsPlay();
if (streamingProcessor) {
console.log('Page reloaded. Aborting streaming...');
streamingProcessor.onStopStreaming();
}
});
var isManualInput = false;
var valueBeforeManualInput;
$(document).on('input', '.range-block-counter input, .neo-range-input', function () {
valueBeforeManualInput = $(this).val();
console.log(valueBeforeManualInput);
});
$(document).on('change', '.range-block-counter input, .neo-range-input', function (e) {
if (!(e.target instanceof HTMLElement)) {
return;
}
e.target.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
});
$(document).on('keydown', '.range-block-counter input, .neo-range-input', function (e) {
const masterSelector = '#' + $(this).data('for');
const masterElement = $(masterSelector);
if (e.key === 'Enter') {
let manualInput = Number($(this).val());
if (isManualInput) {
//disallow manual inputs outside acceptable range
if (manualInput >= Number($(this).attr('min')) && manualInput <= Number($(this).attr('max'))) {
//if value is ok, assign to slider and update handle text and position
//newSlider.val(manualInput)
//handleSlideEvent.call(newSlider, null, { value: parseFloat(manualInput) }, 'manual');
valueBeforeManualInput = manualInput;
$(masterElement).val($(this).val()).trigger('input', { forced: true });
} else {
//if value not ok, warn and reset to last known valid value
toastr.warning(`Invalid value. Must be between ${$(this).attr('min')} and ${$(this).attr('max')}`);
//newSlider.val(valueBeforeManualInput)
$(this).val(valueBeforeManualInput);
}
}
}
});
$(document).on('keyup', '.range-block-counter input, .neo-range-input', function () {
valueBeforeManualInput = $(this).val();
isManualInput = true;
});
//trigger slider changes when user clicks away
$(document).on('mouseup blur', '.range-block-counter input, .neo-range-input', function () {
const masterSelector = '#' + $(this).data('for');
const masterElement = $(masterSelector);
let manualInput = Number($(this).val());
if (isManualInput) {
//if value is between correct range for the slider
if (manualInput >= Number($(this).attr('min')) && manualInput <= Number($(this).attr('max'))) {
valueBeforeManualInput = manualInput;
//set the slider value to input value
$(masterElement).val($(this).val()).trigger('input', { forced: true });
} else {
//if value not ok, warn and reset to last known valid value
toastr.warning(`Invalid value. Must be between ${$(this).attr('min')} and ${$(this).attr('max')}`);
$(this).val(valueBeforeManualInput);
}
}
isManualInput = false;
});
$('.user_stats_button').on('click', function () {
userStatsHandler();
});
$(document).on('click', '.external_import_button, #external_import_button', async () => {
const html = await renderTemplateAsync('importCharacters');
const input = await callGenericPopup(html, POPUP_TYPE.INPUT, '', { allowVerticalScrolling: true, wider: true, okButton: $('#popup_template').attr('popup-button-import'), rows: 4 });
if (!input) {
console.debug('Custom content import cancelled');
return;
}
// break input into one input per line
const inputs = String(input).split('\n').map(x => x.trim()).filter(x => x.length > 0);
for (const url of inputs) {
await importFromExternalUrl(url);
}
});
charDragDropHandler = new DragAndDropHandler('body', async (files, event) => {
if (!files.length) {
await importFromURL(event.originalEvent.dataTransfer.items, files);
}
await processDroppedFiles(files);
}, { noAnimation: true });
chatDragDropHandler = new DragAndDropHandler('#select_chat_popup', async (_, event) => {
const importFile = document.getElementById('chat_import_file');
if (importFile instanceof HTMLInputElement) {
importFile.files = event.originalEvent.dataTransfer.files;
$(importFile).trigger('change');
}
});
$('#charListGridToggle').on('click', async () => {
doCharListDisplaySwitch();
});
$('#hideCharPanelAvatarButton').on('click', () => {
$('#avatar-and-name-block').slideToggle();
});
$(document).on('mouseup touchend', '#show_more_messages', async function () {
await showMoreMessages();
});
$(document).on('click', '.open_characters_library', async function () {
await getCharacters();
await eventSource.emit(event_types.OPEN_CHARACTER_LIBRARY);
});
// Added here to prevent execution before script.js is loaded and get rid of quirky timeouts
await firstLoadInit();
window.addEventListener('beforeunload', (e) => {
if (isChatSaving || this_edit_mes_id >= 0) {
e.preventDefault();
e.returnValue = true;
}
});
});